feat: prepare 2.2.0 with UI and execution fixes

Add dark theme, richer Markdown and readable plugin results.
Improve Android input evidence, response integrity and diagnostics.
Unify lifecycle, guidance receipts and evidence sources across clients.
Keep direct action dispatch without an added AI quota approval layer.
Align version metadata at 2.2.0 and bundle Helper 1.2.2.

Validation: 3063 backend tests before the version bump; 44 after.
Frontend: 317 tests and production build passed.
This commit is contained in:
chenmingxuan committed 2026-10-26 00:44:13 +08:00
1 parent 83bcfd54d5
commit fac4ccb760
146 files changed
+5689 -2033

No files matched your search

+15 -2
View File
@@ -1,12 +1,12 @@
{
"name": "artemis-showcase-ui",
"version": "2.1.0",
"version": "2.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "artemis-showcase-ui",
"version": "2.1.0",
"version": "2.2.0",
"dependencies": {
"@angular/cdk": "^22.1.5",
"@angular/common": "^22.1.4",
@@ -16,6 +16,7 @@
"@angular/platform-browser": "^22.1.4",
"@angular/platform-browser-dynamic": "^22.1.4",
"@angular/router": "^22.1.4",
"marked": "18.1.1",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
@@ -7211,6 +7212,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/marked": {
"version": "18.1.1",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.1.1.tgz",
"integrity": "sha512-zEGgrtUG8crk+TrMacJSVABU4DYDDkS8uqgU3ohrn/2fW9C2AEfUrO3m6IhpIpQpl7YcVpiXXC9cSMWDANT3dg==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "artemis-showcase-ui",
"version": "2.1.0",
"version": "2.2.0",
"scripts": {
"ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json",
@@ -18,6 +18,7 @@
"@angular/platform-browser": "^22.1.4",
"@angular/platform-browser-dynamic": "^22.1.4",
"@angular/router": "^22.1.4",
"marked": "18.1.1",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
+41
View File
@@ -0,0 +1,41 @@
// Shared markup appears in component innerHTML and in body-mounted overlays.
:is(.report-prose, .stream-text, .markdown-content) {
min-width: 0;
overflow-wrap: anywhere;
a { color: var(--app-primary-text, #2563eb); text-decoration: underline; }
img { max-width: 100%; height: auto; border-radius: 8px; }
.md-table-scroll { max-width: 100%; overflow-x: auto; margin: 10px 0; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th, td {
border: 1px solid var(--app-border-control, #cbd5e1);
padding: 7px 9px;
text-align: left;
vertical-align: top;
}
th { background: var(--app-primary-soft, #eff6ff); font-weight: 600; }
.md-table-scroll .md-align-left { text-align: left; }
.md-table-scroll .md-align-center { text-align: center; }
.md-table-scroll .md-align-right { text-align: right; }
pre { max-width: 100%; overflow-x: auto; white-space: pre; }
ul, ol { padding-left: 24px; }
li > ul, li > ol { margin: 4px 0; }
.md-task-item { list-style: none; }
.md-task-marker { display: inline-block; margin-left: -18px; margin-right: 4px; }
.md-task-marker.checked { color: var(--app-success-text, #047857); }
.md-task-marker.progress { color: var(--app-primary-text, #2563eb); }
.verify-badge, .finding-badge {
display: inline-block;
margin-right: 6px;
padding: 1px 5px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
background: var(--app-primary-soft, #eff6ff);
color: var(--app-primary-text, #1d4ed8);
}
.verify-badge.assert, .finding-badge {
background: var(--app-warning-soft, #fffbeb);
color: var(--app-warning-text, #92400e);
}
}
+59
View File
@@ -0,0 +1,59 @@
@use 'sass:color';
// Light values stay beside each existing declaration as fallbacks. These shared
// semantic overrides also reach CDK overlays mounted outside routed components.
:root[data-theme='dark'] {
color-scheme: dark;
@each $role, $value in (
canvas: #0b1220,
surface: #111c2e,
surface-soft: #172337,
surface-raised: #1e2c42,
text-strong: #f1f5f9,
text-control: #dbe4f0,
text-secondary: #bdcadd,
text-muted: #98aac1,
text-faint: #8da1bb,
border: #2b3b52,
border-control: #3d506a,
highlight: #24344d,
primary-text: #8ebcff,
primary-soft: #152e50,
primary-border: #375b83,
exploration-text: #b7b3ff,
exploration-soft: #29264b,
exploration-border: #514a83,
success-text: #6ee7b7,
success-soft: #103b32,
success-border: #24634d,
warning-text: #fcd089,
warning-soft: #3b2b16,
warning-border: #775728,
error-text: #ff9ca7,
error-soft: #421f2b,
error-border: #7d3d4e,
info-text: #83d8f5,
info-soft: #153747,
info-border: #316479
) {
--app-#{$role}: #{$value};
--app-#{$role}-rgb: #{color.channel($value, 'red', $space: rgb)} #{color.channel($value, 'green', $space: rgb)} #{color.channel($value, 'blue', $space: rgb)};
}
}
:root {
color-scheme: light;
background: var(--app-canvas, #f8f9fa);
}
body {
background: var(--app-canvas, #f8f9fa);
}
input, textarea, select {
color: inherit;
}
::selection {
background: var(--app-primary-border, #bfdbfe);
}
@@ -44,7 +44,7 @@ limitations under the License.
</button>
<button #automationOrigin="cdkOverlayOrigin" cdkOverlayOrigin class="vscode-tab-btn" [class.active]="isAutomationOpen()" (click)="isAutomationOpen.set(!isAutomationOpen())" title="报告与批量" aria-label="报告与批量"><span class="material-symbols-outlined tab-icon">assignment</span><span>报告与批量</span></button>
</div>
<ng-template cdkConnectedOverlay [cdkConnectedOverlayOrigin]="automationOrigin" [cdkConnectedOverlayOpen]="isAutomationOpen()" [cdkConnectedOverlayHasBackdrop]="true" cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop" (backdropClick)="isAutomationOpen.set(false)" [cdkConnectedOverlayPositions]="dropdownPositions"><div class="vscode-dropdown-menu" style="width:min(560px,calc(100vw - 24px));max-height:85vh;overflow:auto;padding:20px"><div class="popover-title-header" style="justify-content:space-between;margin-bottom:6px"><span>报告与自动化</span><button type="button" (click)="isAutomationOpen.set(false)" aria-label="关闭报告与自动化" style="border:0;background:transparent;color:#64748b;cursor:pointer;padding:4px"><span class="material-symbols-outlined">close</span></button></div><app-automation-panel (queueRequested)="openBatchQueue()" /></div></ng-template>
<ng-template cdkConnectedOverlay [cdkConnectedOverlayOrigin]="automationOrigin" [cdkConnectedOverlayOpen]="isAutomationOpen()" [cdkConnectedOverlayHasBackdrop]="true" cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop" (backdropClick)="isAutomationOpen.set(false)" [cdkConnectedOverlayPositions]="dropdownPositions"><div class="vscode-dropdown-menu" style="width:min(560px,calc(100vw - 24px));max-height:85vh;overflow:auto;padding:20px"><div class="popover-title-header" style="justify-content:space-between;margin-bottom:6px"><span>报告与自动化</span><button type="button" (click)="isAutomationOpen.set(false)" aria-label="关闭报告与自动化" style="border:0;background:transparent;color:var(--app-text-muted, #64748b);cursor:pointer;padding:4px"><span class="material-symbols-outlined">close</span></button></div><app-automation-panel (queueRequested)="openBatchQueue()" /></div></ng-template>
<!-- Dropdown Popover Panel for Task Queue -->
<ng-template
@@ -87,7 +87,7 @@ limitations under the License.
@if (getTaskStatus(session) === 'running') {
<span class="status-dot"></span>
}
{{ getTaskStatus(session) | uiLabel }}
{{ taskPhaseLabel(session) || (getTaskStatus(session) | uiLabel) }}
</span>
<app-test-result-badge [result]="session.test_result" [executionStatus]="getTaskStatus(session)" [compact]="true" />
</div>
@@ -183,160 +183,7 @@ limitations under the License.
<!-- Rich Notes Content Viewer -->
@if (noteKeys().length > 0) {
<div class="dropdown-notes-content">
@if (agentService.selectedNoteKey() !== 'task_plan.md') {
<app-report-markdown [content]="currentNoteContent()" />
} @else {
@if (parsedNote().title) {
<h3 class="note-title">{{ parsedNote().title }}</h3>
}
<!-- Milestones list -->
@if (parsedNote().milestones.length > 0) {
<div class="milestones-container">
@for (milestone of parsedNote().milestones; track trackMilestone($index, milestone)) {
<div
class="milestone-block"
[ngClass]="milestone.type"
>
<div class="milestone-title-row">
@switch (milestone.type) {
@case ('checked') {
<span class="material-symbols-outlined milestone-status-icon checked">check_box</span>
}
@case ('progress') {
<span class="material-symbols-outlined milestone-status-icon progress">indeterminate_check_box</span>
}
@case ('unchecked') {
<span class="material-symbols-outlined milestone-status-icon unchecked">check_box_outline_blank</span>
}
}
<div class="milestone-text">
@for (seg of milestone.segments; track seg) {
<span
[class.bold-segment]="seg.bold"
[class.code-segment]="seg.code"
>{{ seg.text }}</span>
}
</div>
</div>
<!-- Sub-steps list -->
@if (milestone.subSteps.length > 0) {
<div class="milestone-substeps">
@for (step of milestone.subSteps; track trackMarkdownLine($index, step)) {
<div
class="substep-row"
[ngClass]="step.type"
[style.padding-left.px]="(step.indent > 2 ? (step.indent - 2) * 12 : 0)"
>
@switch (step.type) {
@case ('checked') {
<span class="material-symbols-outlined check-icon checked">check_box</span>
}
@case ('progress') {
<span class="material-symbols-outlined check-icon progress">indeterminate_check_box</span>
}
@case ('unchecked') {
<span class="material-symbols-outlined check-icon unchecked">check_box_outline_blank</span>
}
@case ('list-item') {
<span class="bullet">•</span>
}
}
@if (step.type !== 'empty') {
<span class="line-text">
@for (seg of step.segments; track seg) {
<span
[class.bold-segment]="seg.bold"
[class.code-segment]="seg.code"
>{{ seg.text }}</span>
}
</span>
}
</div>
}
</div>
}
<!-- Verification result -->
@if (milestone.checks.length > 0) {
<div class="milestone-checks-container">
@for (check of milestone.checks; track trackMarkdownLine($index, check)) {
<div
class="verification-card"
[ngClass]="check.type"
[style.margin-left.px]="(check.indent > 2 ? (check.indent - 2) * 12 : 0)"
>
<div class="card-header">
<span class="card-tag" [class.assert]="check.checkKind === 'assert'">{{ check.checkKind || 'verify' }}{{ check.atEnd ? ' @end' : '' }}</span>
</div>
<div class="card-body">
@for (seg of check.segments; track seg) {
<span
[class.bold-segment]="seg.bold"
[class.code-segment]="seg.code"
>{{ seg.text }}</span>
}
</div>
</div>
}
</div>
}
</div>
}
</div>
}
<!-- Other Markdown Lines Container -->
@if (parsedNote().otherLines.length > 0) {
<div class="other-lines-container">
@for (line of parsedNote().otherLines; track trackMarkdownLine($index, line)) {
@if (line.type === 'verify' || line.type === 'finding') {
<div class="verification-card standalone" [ngClass]="line.type" [style.margin-left.px]="line.indent * 12">
<div class="card-header">
<span class="card-tag" [class.assert]="line.checkKind === 'assert'">{{ line.checkKind || 'verify' }}{{ line.atEnd ? ' @end' : '' }}</span>
</div>
<div class="card-body">
@for (seg of line.segments; track seg) {
<span
[class.bold-segment]="seg.bold"
[class.code-segment]="seg.code"
>{{ seg.text }}</span>
}
</div>
</div>
} @else {
<div
class="md-line"
[ngClass]="line.type"
[style.margin-left.px]="line.indent * 12"
>
@switch (line.type) {
@case ('checked') {
<span class="material-symbols-outlined check-icon checked">check_box</span>
}
@case ('progress') {
<span class="material-symbols-outlined check-icon progress">indeterminate_check_box</span>
}
@case ('unchecked') {
<span class="material-symbols-outlined check-icon unchecked">check_box_outline_blank</span>
}
@case ('list-item') {
<span class="bullet">•</span>
}
}
@if (line.type !== 'empty') {
<span class="line-text">
@for (seg of line.segments; track seg) {
<span
[class.bold-segment]="seg.bold"
[class.code-segment]="seg.code"
>{{ seg.text }}</span>
}
</span>
}
</div>
}
}
</div>
}
}
<app-report-markdown [content]="currentNoteContent()" />
</div>
}
</div>
@@ -514,6 +361,7 @@ limitations under the License.
<!-- Event Logs Section -->
<main class="stream-logs-content">
<ng-content select="[task-summary]"></ng-content>
<app-task-lifecycle [value]="agentService.currentLifecycle()" />
<app-llm-request-status [sessionId]="agentService.currentSessionId()" [running]="agentService.isCurrentSessionRunning()" (activeChange)="hasModelRequest.set($event)" />
@if (agentService.isSessionContentLoading()) {
<div class="session-loading-state" aria-live="polite">
@@ -522,16 +370,16 @@ limitations under the License.
</div>
}
@if (!agentService.isSessionContentLoading() && phases().length === 0 && !showPlanningLoader() && !hasModelRequest() && startupWorkItems().length === 0) {
@if (!agentService.isSessionContentLoading() && phases().length === 0 && !showPlanningLoader() && !hasModelRequest() && startupWorkItems().length === 0 && awaitingGuidance().length === 0) {
<div class="empty-state">
<div class="empty-icon"><span class="material-symbols-outlined">sensors</span></div>
<h3>尚未选择执行记录</h3>
<p>请从右侧任务列表选择会话,查看实时执行记录。</p>
<h3>{{ agentService.currentSessionId() ? '暂无步骤记录' : '尚未选择执行记录' }}</h3>
<p>{{ agentService.currentSessionId() ? '此任务尚未留下执行步骤。' : '请从右侧任务列表选择会话,查看实时执行记录。' }}</p>
</div>
}
<!-- The phase-based session logs list -->
@if (!agentService.isSessionContentLoading() && (phases().length > 0 || showPlanningLoader() || startupWorkItems().length > 0)) {
@if (!agentService.isSessionContentLoading() && (phases().length > 0 || showPlanningLoader() || startupWorkItems().length > 0 || awaitingGuidance().length > 0)) {
<div class="logs-list-container">
@if (startupWorkItems().length > 0) {
<div class="phase-container startup-phase" aria-live="polite">
@@ -1230,10 +1078,10 @@ limitations under the License.
<span class="grid-value code-pill">{{ param.value }}</span>
</div>
}
@if (plugin.result) {
@if (plugin.result || plugin.rawResult || plugin.facts.length) {
<div class="grid-item full-width">
<span class="grid-label">实际结果</span>
<span class="grid-value plugin-result">{{ plugin.result }}</span>
<app-plugin-tool-result [view]="plugin" />
</div>
}
}
@@ -1346,10 +1194,11 @@ limitations under the License.
}
}
@if (!conversationMessages().length && awaitingGuidance().length) {
<div class="guidance-queue" aria-label="待处理用户指导">
<div class="guidance-queue" aria-label="指导接收与消费记录">
@for (item of awaitingGuidance(); track item.id) {
<div class="guidance-queue-item">
<strong>用户指导 · {{ guidanceStatusLabel(item) }}</strong>
<small>接收 {{ (item.accepted_at || item.timestamp) * 1000 | date:'HH:mm:ss' }}@if (item.consumed_at) { · 执行器读取 {{ item.consumed_at * 1000 | date:'HH:mm:ss' }} }</small>
<p>{{ item.instruction }}</p>
</div>
}
File diff suppressed because it is too large. Load diff
@@ -3,7 +3,9 @@ import { mergeConversation } from '../../utils/exploration-timeline.util';
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
import { TaskHistoryComponent } from '../task-history/task-history.component';
import { LlmRequestStatusComponent } from '../llm-request-status/llm-request-status.component';
import { TaskLifecycleComponent } from '../task-lifecycle/task-lifecycle.component';
import { ReportMarkdownComponent } from '../report-markdown/report-markdown.component';
import { PluginToolResultComponent } from '../plugin-tool-result/plugin-tool-result.component';
import { AutomationPanelComponent } from '../automation-panel/automation-panel.component';
import { UiLabelPipe } from '../../utils/ui-label.pipe';
/**
@@ -248,7 +250,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote, StreamRe
@Component({
selector: 'app-agent-stream',
standalone: true,
imports: [ReportMarkdownComponent, LlmRequestStatusComponent, TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent, TestResultBadgeComponent, TestResultCardComponent, TaskBatchFilterComponent],
imports: [PluginToolResultComponent, ReportMarkdownComponent, LlmRequestStatusComponent, TaskLifecycleComponent, TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent, TestResultBadgeComponent, TestResultCardComponent, TaskBatchFilterComponent],
templateUrl: './agent-stream.component.html',
styleUrl: './agent-stream.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
@@ -504,12 +506,13 @@ export class AgentStreamComponent implements AfterViewInit {
});
public guidanceStatusLabel(event: GuidanceEvent): string {
const terminal = ['completed', 'failed', 'cancelled', 'interrupted']
const terminal = ['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped']
.includes(this.agentService.currentSession()?.status || '');
if (event.status === 'pending') {
return terminal ? '任务结束前未进入模型' : '已接收,正在打断当前模型步骤';
}
return terminal ? '模型已读取,未留下后续动作记录' : '模型已读取,等待动作记录';
if (event.status === 'unapplied' || (event.status === 'pending' && terminal)) return '任务结束,未记录消费';
if (event.status === 'unconfirmed' || (event.status === 'reading' && terminal)) return '任务结束,读取未确认';
if (event.status === 'reading') return '执行器正在读取';
if (event.status === 'pending') return '已接收,等待安全时点读取';
return terminal ? '执行器已读取,未留下后续动作记录' : '执行器已读取,等待动作记录';
}
public phases = computed(() => {
@@ -959,6 +962,11 @@ export class AgentStreamComponent implements AfterViewInit {
return 'completed';
}
public taskPhaseLabel(session: Session): string | null {
if (['completed', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(this.getTaskStatus(session))) return null;
return ({ waiting_resources: '等待资源', paused: '已暂停', demonstrating: '人工示范中', analyzing_demo: '分析示范中' } as Record<string, string>)[session.phase || ''] || null;
}
/**
* Determine the device serial number for the session
*/
@@ -20,6 +20,7 @@
@if (r.error) { <div class="report-error" role="status"><strong>异常原因</strong><p>{{r.error}}</p></div> }
</div>
<div class="report-meta"><div><strong>任务目标与预期</strong><p>{{r.goal || '未记录'}}</p></div>@if(r.expected){<div><strong>预期结果</strong><p>{{r.expected}}</p></div>}</div>
<app-evidence-sources [value]="r.evidence_sources || null" />
<div class="actions"><a [href]="'/api/automation/reports/'+r.id+'/docx'">下载 Word</a><a [href]="'/api/automation/reports/'+r.id+'/xlsx'">下载 Excel</a>
<a [href]="'/api/automation/reports/'+r.id+'/screenshots'">步骤截图 ZIP</a>
<a [href]="'/api/automation/reports/'+r.id+'/video'">分段录屏 ZIP</a>
@@ -1,60 +1,60 @@
:host { display:block;min-width:0;color:#1e293b;font-size:13px;line-height:1.65; }
:host { display:block;min-width:0;color:var(--app-text-control, #1e293b);font-size:13px;line-height:1.65; }
.back-to-batch{margin-bottom:12px;max-width:100%;text-align:left;overflow-wrap:anywhere}
.job.selected{background:#eff6ff;border-radius:8px;padding-left:8px;padding-right:8px}.job.selected button{color:#1d4ed8;border-color:#93c5fd;background:#fff}
.tabs {display:flex;gap:4px;padding:4px;margin-bottom:18px;background:#f1f5f9;border:1px solid #e2e8f0;border-radius:20px;
button {flex:1;padding:7px 8px;border:0;border-radius:16px;color:#475569;background:transparent;white-space:nowrap;
&.selected {color:#2563eb;background:#fff;box-shadow:0 1px 4px #0f172a12;} }
.job.selected{background:var(--app-primary-soft, #eff6ff);border-radius:8px;padding-left:8px;padding-right:8px}.job.selected button{color:var(--app-primary-text, #1d4ed8);border-color:var(--app-primary-border, #93c5fd);background:var(--app-surface, #fff)}
.tabs {display:flex;gap:4px;padding:4px;margin-bottom:18px;background:var(--app-surface-soft, #f1f5f9);border:1px solid var(--app-border, #e2e8f0);border-radius:20px;
button {flex:1;padding:7px 8px;border:0;border-radius:16px;color:var(--app-text-secondary, #475569);background:transparent;white-space:nowrap;
&.selected {color:var(--app-primary-text, #2563eb);background:var(--app-surface, #fff);box-shadow:0 1px 4px #0f172a12;} }
}
section {padding:16px;margin-bottom:14px;border:1px solid #e2e8f0;border-radius:12px;background:#fff;&:last-child{margin-bottom:0;}}
h3 {color:#0f172a;font-size:14px;font-weight:600;margin:0 0 8px;}
p {margin:8px 0 12px;color:#475569;overflow-wrap:anywhere;}strong{font-weight:600;}.muted,small{color:#64748b;font-size:12px;}
.error,.notice{padding:10px 12px;border-radius:8px;margin:0 0 14px;}.error{color:#b91c1c;background:#fff1f2;border:1px solid #fecdd3;}.notice{color:#047857;background:#ecfdf5;border:1px solid #a7f3d0;}
button,a,.upload {display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:34px;border:1px solid #cbd5e1;border-radius:8px;padding:7px 12px;background:#fff;color:#334155;text-decoration:none;cursor:pointer;font:inherit;font-weight:500;line-height:1.5;transition:background .15s,border-color .15s;
&:hover{background:#f8fafc;border-color:#94a3b8;}&:focus-visible{outline:2px solid #3b82f6;outline-offset:2px;}&:disabled{color:#94a3b8;background:#f8fafc;border-color:#e2e8f0;cursor:not-allowed;}}
a{color:#2563eb;border-color:#bfdbfe;background:#eff6ff;}button.primary{background:#2563eb;color:white;border-color:#2563eb;width:100%;margin-top:10px;}button.primary:hover{background:#1d4ed8;}button.primary:disabled{background:#e2e8f0;color:#64748b;border-color:#e2e8f0;}
.actions,.fields,.format-options{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}.actions{margin:12px 0 4px;}.fields>*{flex:1;min-width:130px;}.format-options{margin:12px 0;}.format-options label{flex:1;min-width:120px;margin:0;padding:10px 12px;border:1px solid #e2e8f0;border-radius:8px;background:#f8fafc;}
label{display:block;margin:12px 0;color:#334155;font-weight:500;}
input:not([type=checkbox]):not([type=file]),select,textarea{box-sizing:border-box;display:block;width:100%;margin-top:6px;border:1px solid #cbd5e1;border-radius:8px;padding:9px 10px;background:#fff;color:#1e293b;font:inherit;font-weight:400;&:focus{outline:2px solid #bfdbfe;border-color:#3b82f6;}}
input[type=checkbox]{width:15px;height:15px;accent-color:#2563eb;vertical-align:-3px;margin-right:6px;}
section {padding:16px;margin-bottom:14px;border:1px solid var(--app-border, #e2e8f0);border-radius:12px;background:var(--app-surface, #fff);&:last-child{margin-bottom:0;}}
h3 {color:var(--app-text-strong, #0f172a);font-size:14px;font-weight:600;margin:0 0 8px;}
p {margin:8px 0 12px;color:var(--app-text-secondary, #475569);overflow-wrap:anywhere;}strong{font-weight:600;}.muted,small{color:var(--app-text-muted, #64748b);font-size:12px;}
.error,.notice{padding:10px 12px;border-radius:8px;margin:0 0 14px;}.error{color:var(--app-error-text, #b91c1c);background:var(--app-error-soft, #fff1f2);border:1px solid var(--app-error-border, #fecdd3);}.notice{color:var(--app-success-text, #047857);background:var(--app-success-soft, #ecfdf5);border:1px solid var(--app-success-border, #a7f3d0);}
button,a,.upload {display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:34px;border:1px solid var(--app-border-control, #cbd5e1);border-radius:8px;padding:7px 12px;background:var(--app-surface, #fff);color:var(--app-text-control, #334155);text-decoration:none;cursor:pointer;font:inherit;font-weight:500;line-height:1.5;transition:background .15s,border-color .15s;
&:hover{background:var(--app-surface-soft, #f8fafc);border-color:var(--app-border-control, #94a3b8);}&:focus-visible{outline:2px solid #3b82f6;outline-offset:2px;}&:disabled{color:var(--app-text-faint, #94a3b8);background:var(--app-surface-soft, #f8fafc);border-color:var(--app-border, #e2e8f0);cursor:not-allowed;}}
a{color:var(--app-primary-text, #2563eb);border-color:var(--app-primary-border, #bfdbfe);background:var(--app-primary-soft, #eff6ff);}button.primary{background:#2563eb;color:white;border-color:#2563eb;width:100%;margin-top:10px;}button.primary:hover{background:#1d4ed8;}button.primary:disabled{background:var(--app-surface-raised, #e2e8f0);color:var(--app-text-muted, #64748b);border-color:var(--app-border, #e2e8f0);}
.actions,.fields,.format-options{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}.actions{margin:12px 0 4px;}.fields>*{flex:1;min-width:130px;}.format-options{margin:12px 0;}.format-options label{flex:1;min-width:120px;margin:0;padding:10px 12px;border:1px solid var(--app-border, #e2e8f0);border-radius:8px;background:var(--app-surface-soft, #f8fafc);}
label{display:block;margin:12px 0;color:var(--app-text-control, #334155);font-weight:500;}
input:not([type=checkbox]):not([type=file]),select,textarea{box-sizing:border-box;display:block;width:100%;margin-top:6px;border:1px solid var(--app-border-control, #cbd5e1);border-radius:8px;padding:9px 10px;background:var(--app-surface, #fff);color:var(--app-text-control, #1e293b);font:inherit;font-weight:400;&:focus{outline:2px solid var(--app-primary-border, #bfdbfe);border-color:#3b82f6;}}
input[type=checkbox]{width:15px;height:15px;accent-color:var(--app-primary-text, #2563eb);vertical-align:-3px;margin-right:6px;}
.upload{position:relative;margin:0;color:#fff;background:#2563eb;border-color:#2563eb;}.upload:hover{background:#1d4ed8;border-color:#1d4ed8;}.upload input{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer;}
.device{background:#f8fafc;padding:12px 16px;}.device label{margin:0;}
.selection-toolbar{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0;color:#475569;}
.device{background:var(--app-surface-soft, #f8fafc);padding:12px 16px;}.device label{margin:0;}
.selection-toolbar{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0;color:var(--app-text-secondary, #475569);}
.selection-column{flex:0 0 18px;text-align:center;}.selection-column input{margin:0;}
.case-preview-list{max-height:320px;overflow:auto;border:1px solid #e2e8f0;border-radius:8px;margin-bottom:14px;}
.case-preview{display:flex;align-items:flex-start;gap:10px;padding:12px;border-bottom:1px solid #e2e8f0;&:last-child{border-bottom:0;}&.unselected{background:#f8fafc;color:#64748b;}}
.case-preview-content{min-width:0;flex:1;strong{display:block;overflow-wrap:anywhere;}small{display:block;}details{margin:5px 0 0;}summary{padding:0;font-size:12px;color:#2563eb;}pre{max-height:160px;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;background:#f8fafc;border-radius:6px;padding:8px;font-size:11px;}}
.case-preview-list{max-height:320px;overflow:auto;border:1px solid var(--app-border, #e2e8f0);border-radius:8px;margin-bottom:14px;}
.case-preview{display:flex;align-items:flex-start;gap:10px;padding:12px;border-bottom:1px solid var(--app-border, #e2e8f0);&:last-child{border-bottom:0;}&.unselected{background:var(--app-surface-soft, #f8fafc);color:var(--app-text-muted, #64748b);}}
.case-preview-content{min-width:0;flex:1;strong{display:block;overflow-wrap:anywhere;}small{display:block;}details{margin:5px 0 0;}summary{padding:0;font-size:12px;color:var(--app-primary-text, #2563eb);}pre{max-height:160px;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;background:var(--app-surface-soft, #f8fafc);border-radius:6px;padding:8px;font-size:11px;}}
.case-goal{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;white-space:pre-wrap;margin:5px 0;font-size:12px;}
.execution-summary{padding:9px 12px;border-radius:8px;background:#eff6ff;color:#1d4ed8;font-weight:500;}
.record{display:flex;width:100%;text-align:left;align-items:flex-start;flex-direction:column;gap:4px;margin:8px 0 0;padding:12px;overflow-wrap:anywhere;}.record span{color:#64748b;font-size:12px;font-weight:400;}.record.selected{border-color:#93c5fd;background:#eff6ff;}.record.selected strong{color:#1d4ed8;}
.batch-record{position:relative;display:flex;gap:8px;align-items:stretch;margin-top:8px;}.batch-record>app-favorite-button{position:absolute;left:9px;top:9px;z-index:1}.batch-record .record{padding-left:44px;flex:1;min-width:0;width:auto;margin:0;}.batch-record .delete-batch{flex:0 0 40px;min-width:40px;padding:0;color:#b91c1c;}.batch-record .delete-batch:hover:not(:disabled){background:#fff1f2;border-color:#fda4af;}.batch-record .delete-batch .material-symbols-outlined{font-size:19px;}
.archive-progress{margin:12px 0;padding:11px 13px;border:1px solid #bfdbfe;border-radius:10px;background:#f8fbff;color:#1e40af;}
.execution-summary{padding:9px 12px;border-radius:8px;background:var(--app-primary-soft, #eff6ff);color:var(--app-primary-text, #1d4ed8);font-weight:500;}
.record{display:flex;width:100%;text-align:left;align-items:flex-start;flex-direction:column;gap:4px;margin:8px 0 0;padding:12px;overflow-wrap:anywhere;}.record span{color:var(--app-text-muted, #64748b);font-size:12px;font-weight:400;}.record.selected{border-color:var(--app-primary-border, #93c5fd);background:var(--app-primary-soft, #eff6ff);}.record.selected strong{color:var(--app-primary-text, #1d4ed8);}
.batch-record{position:relative;display:flex;gap:8px;align-items:stretch;margin-top:8px;}.batch-record>app-favorite-button{position:absolute;left:9px;top:9px;z-index:1}.batch-record .record{padding-left:44px;flex:1;min-width:0;width:auto;margin:0;}.batch-record .delete-batch{flex:0 0 40px;min-width:40px;padding:0;color:var(--app-error-text, #b91c1c);}.batch-record .delete-batch:hover:not(:disabled){background:var(--app-error-soft, #fff1f2);border-color:var(--app-error-border, #fda4af);}.batch-record .delete-batch .material-symbols-outlined{font-size:19px;}
.archive-progress{margin:12px 0;padding:11px 13px;border:1px solid var(--app-primary-border, #bfdbfe);border-radius:10px;background:var(--app-primary-soft, #f8fbff);color:var(--app-primary-text, #1e40af);}
.archive-progress-heading{display:flex;align-items:center;gap:8px;}
.archive-progress p{margin:7px 0 0;font-size:12px;color:#475569;}
.archive-progress.archive-failed{border-color:#fecdd3;background:#fff1f2;color:#be123c;}
.archive-track{height:7px;margin-top:10px;border-radius:999px;background:#dbeafe;overflow:hidden;}
.archive-progress p{margin:7px 0 0;font-size:12px;color:var(--app-text-secondary, #475569);}
.archive-progress.archive-failed{border-color:var(--app-error-border, #fecdd3);background:var(--app-error-soft, #fff1f2);color:var(--app-error-text, #be123c);}
.archive-track{height:7px;margin-top:10px;border-radius:999px;background:var(--app-primary-soft, #dbeafe);overflow:hidden;}
.archive-track span{display:block;height:100%;border-radius:inherit;background:#3b82f6;transition:width .3s ease;}
.archive-spinner{width:13px;height:13px;border:2px solid #bfdbfe;border-top-color:#2563eb;border-radius:50%;animation:archive-spin .8s linear infinite;}
.archive-spinner{width:13px;height:13px;border:2px solid var(--app-primary-border, #bfdbfe);border-top-color:#2563eb;border-radius:50%;animation:archive-spin .8s linear infinite;}
@keyframes archive-spin{to{transform:rotate(360deg);}}
.code{font-family:Consolas,monospace;font-size:12px;line-height:1.6;resize:vertical;background:#f8fafc;}.table-wrap{max-height:260px;overflow:auto;border:1px solid #e2e8f0;border-radius:8px;margin-bottom:14px;}table{width:100%;border-collapse:collapse;}th,td{padding:9px 10px;border-bottom:1px solid #e2e8f0;text-align:left;overflow-wrap:anywhere;}th{background:#f8fafc;color:#475569;font-weight:500;}
.preview{white-space:pre-wrap;max-height:400px;overflow:auto;font:inherit;color:#334155;background:#f8fafc;border-radius:8px;padding:12px;border:1px solid #e2e8f0;}
.job{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:10px 0;border-bottom:1px solid #f1f5f9;}.job:last-child{border-bottom:0;}.job button{flex:1;justify-content:flex-start;text-align:left;min-width:150px;}.job span{font-size:12px;background:#f1f5f9;color:#475569;border-radius:5px;padding:3px 7px;}.job small{width:100%;}details{margin:14px 0;}summary{color:#475569;cursor:pointer;padding:8px 0;}
.task-guidance{margin-top:16px;padding:14px;border:1px solid #bfdbfe;border-radius:10px;background:#f8fbff;}
.code{font-family:Consolas,monospace;font-size:12px;line-height:1.6;resize:vertical;background:var(--app-surface-soft, #f8fafc);}.table-wrap{max-height:260px;overflow:auto;border:1px solid var(--app-border, #e2e8f0);border-radius:8px;margin-bottom:14px;}table{width:100%;border-collapse:collapse;}th,td{padding:9px 10px;border-bottom:1px solid var(--app-border, #e2e8f0);text-align:left;overflow-wrap:anywhere;}th{background:var(--app-surface-soft, #f8fafc);color:var(--app-text-secondary, #475569);font-weight:500;}
.preview{white-space:pre-wrap;max-height:400px;overflow:auto;font:inherit;color:var(--app-text-control, #334155);background:var(--app-surface-soft, #f8fafc);border-radius:8px;padding:12px;border:1px solid var(--app-border, #e2e8f0);}
.job{display:flex;flex-wrap:wrap;gap:8px;align-items:center;padding:10px 0;border-bottom:1px solid var(--app-border, #f1f5f9);}.job:last-child{border-bottom:0;}.job button{flex:1;justify-content:flex-start;text-align:left;min-width:150px;}.job span{font-size:12px;background:var(--app-surface-soft, #f1f5f9);color:var(--app-text-secondary, #475569);border-radius:5px;padding:3px 7px;}.job small{width:100%;}details{margin:14px 0;}summary{color:var(--app-text-secondary, #475569);cursor:pointer;padding:8px 0;}
.task-guidance{margin-top:16px;padding:14px;border:1px solid var(--app-primary-border, #bfdbfe);border-radius:10px;background:var(--app-primary-soft, #f8fbff);}
.task-guidance label{margin:8px 0 4px;}
.report-overview{padding:14px;border:1px solid #dbeafe;border-radius:10px;background:#f8fbff;}
.report-overview{padding:14px;border:1px solid var(--app-primary-border, #dbeafe);border-radius:10px;background:var(--app-primary-soft, #f8fbff);}
.report-heading{display:flex;align-items:flex-start;gap:8px;}.report-heading .report-title{flex:1;min-width:0;}
.report-title{display:block;font-size:14px;line-height:1.5;color:#0f172a;overflow-wrap:anywhere;}
.report-badges{display:flex;gap:7px;flex-wrap:wrap;margin-top:9px;span{padding:3px 8px;border:1px solid #bfdbfe;border-radius:20px;background:#eff6ff;color:#1e40af;font-size:12px;&.failed{background:#fff1f2;border-color:#fecdd3;color:#be123c;}}}
.report-error{margin-top:11px;padding:10px 12px;background:#fff1f2;border-left:3px solid #e11d48;border-radius:6px;color:#9f1239;strong{font-size:12px;}p{margin:4px 0 0;color:#9f1239;white-space:pre-wrap;}}
.report-meta{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:10px;margin:12px 0;div{min-width:0;padding:10px 12px;border:1px solid #e2e8f0;border-radius:8px;background:#fff;}strong{font-size:12px;color:#475569;}p{margin:5px 0 0;white-space:pre-wrap;color:#1e293b;}}
.report-content{margin-top:14px;padding:14px;border:1px solid #e2e8f0;border-radius:10px;background:#fff;h3{margin-bottom:12px;}}
.report-body{overflow-x:auto;overflow-wrap:anywhere;color:#1e293b;line-height:1.75;}
.report-title{display:block;font-size:14px;line-height:1.5;color:var(--app-text-strong, #0f172a);overflow-wrap:anywhere;}
.report-badges{display:flex;gap:7px;flex-wrap:wrap;margin-top:9px;span{padding:3px 8px;border:1px solid var(--app-primary-border, #bfdbfe);border-radius:20px;background:var(--app-primary-soft, #eff6ff);color:var(--app-primary-text, #1e40af);font-size:12px;&.failed{background:var(--app-error-soft, #fff1f2);border-color:var(--app-error-border, #fecdd3);color:var(--app-error-text, #be123c);}}}
.report-error{margin-top:11px;padding:10px 12px;background:var(--app-error-soft, #fff1f2);border-left:3px solid #e11d48;border-radius:6px;color:var(--app-error-text, #9f1239);strong{font-size:12px;}p{margin:4px 0 0;color:var(--app-error-text, #9f1239);white-space:pre-wrap;}}
.report-meta{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:10px;margin:12px 0;div{min-width:0;padding:10px 12px;border:1px solid var(--app-border, #e2e8f0);border-radius:8px;background:var(--app-surface, #fff);}strong{font-size:12px;color:var(--app-text-secondary, #475569);}p{margin:5px 0 0;white-space:pre-wrap;color:var(--app-text-control, #1e293b);}}
.report-content{margin-top:14px;padding:14px;border:1px solid var(--app-border, #e2e8f0);border-radius:10px;background:var(--app-surface, #fff);h3{margin-bottom:12px;}}
.report-body{overflow-x:auto;overflow-wrap:anywhere;color:var(--app-text-control, #1e293b);line-height:1.75;}
:host ::ng-deep .report-body, :host ::ng-deep .report-markdown-body {
h1,h2,h3,h4{margin:18px 0 8px;color:#0f172a;line-height:1.4;}h1{font-size:18px;}h2{font-size:16px;}h3,h4{font-size:14px;}
h1,h2,h3,h4{margin:18px 0 8px;color:var(--app-text-strong, #0f172a);line-height:1.4;}h1{font-size:18px;}h2{font-size:16px;}h3,h4{font-size:14px;}
> div{margin:3px 0;}
ul,ol{margin:8px 0;padding-left:24px;}li{margin:4px 0;}
blockquote{margin:10px 0;padding:8px 12px;border-left:3px solid #93c5fd;background:#f8fafc;}
pre{max-width:100%;overflow:auto;padding:10px 12px;background:#f1f5f9;border-radius:6px;white-space:pre-wrap;}
code{font-family:Consolas,monospace;background:#f1f5f9;padding:1px 3px;border-radius:3px;}
table{width:100%;border-collapse:collapse;font-size:12px;}th,td{padding:7px 9px;border:1px solid #e2e8f0;vertical-align:top;}th{background:#eff6ff;color:#1e3a8a;}
blockquote{margin:10px 0;padding:8px 12px;border-left:3px solid var(--app-primary-border, #93c5fd);background:var(--app-surface-soft, #f8fafc);}
pre{max-width:100%;overflow:auto;padding:10px 12px;background:var(--app-surface-soft, #f1f5f9);border-radius:6px;white-space:pre-wrap;}
code{font-family:Consolas,monospace;background:var(--app-surface-soft, #f1f5f9);padding:1px 3px;border-radius:3px;}
table{width:100%;border-collapse:collapse;font-size:12px;}th,td{padding:7px 9px;border:1px solid var(--app-border, #e2e8f0);vertical-align:top;}th{background:var(--app-primary-soft, #eff6ff);color:var(--app-primary-text, #1e3a8a);}
.md-para-gap{height:8px;}
}
@@ -11,6 +11,7 @@ import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge
import { TestResultCardComponent } from '../test-result-badge/test-result-card.component';
import { CaseReviewResponse } from '../../core/models/session.model';
import { TaskPluginsComponent, TaskPluginOptions } from '../task-plugins/task-plugins.component';
import { EvidenceSourcesComponent } from '../evidence-sources/evidence-sources.component';
interface ArchiveProgress {
status: 'idle' | 'preparing' | 'ready' | 'failed';
@@ -23,7 +24,7 @@ interface ArchiveProgress {
}
@Component({
selector: 'app-automation-panel', standalone: true, imports: [FavoriteButtonComponent, ReportMarkdownComponent, CommonModule, FormsModule, TestResultBadgeComponent, TestResultCardComponent, TaskPluginsComponent],
selector: 'app-automation-panel', standalone: true, imports: [FavoriteButtonComponent, ReportMarkdownComponent, CommonModule, FormsModule, TestResultBadgeComponent, TestResultCardComponent, TaskPluginsComponent, EvidenceSourcesComponent],
templateUrl: './automation-panel.component.html', styleUrl: './automation-panel.component.scss'
})
export class AutomationPanelComponent {
@@ -15,9 +15,9 @@
*/
$border-color: rgba(0, 0, 0, 0.08);
$text-primary: #1f1f1f;
$text-secondary: #71717a;
$text-muted: #a1a1aa;
$text-primary: var(--app-text-strong, #1f1f1f);
$text-secondary: var(--app-text-muted, #71717a);
$text-muted: var(--app-text-faint, #a1a1aa);
:host {
display: flex;
@@ -32,7 +32,7 @@ $text-muted: #a1a1aa;
display: flex;
flex-direction: column;
height: 100%;
background: #ffffff;
background: var(--app-surface, #ffffff);
box-sizing: border-box;
.chat-header {
@@ -43,23 +43,23 @@ $text-muted: #a1a1aa;
height: 60px;
padding: 0 20px;
border-bottom: 1px solid $border-color;
background: #ffffff;
background: var(--app-surface, #ffffff);
box-sizing: border-box;
.tab-selector-bar {
display: flex;
height: 36px;
box-sizing: border-box;
background: rgba(241, 245, 249, 0.72);
background: rgb(var(--app-surface-soft-rgb, 241 245 249) / 0.72);
backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(16px) saturate(180%);
border: 1px solid rgba(226, 232, 240, 0.85);
border: 1px solid rgb(var(--app-border-rgb, 226 232 240) / 0.85);
border-radius: 18px;
padding: 3px;
gap: 3px;
align-items: center;
flex-shrink: 0;
box-shadow: 0 2px 8px -1px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgba(255, 255, 255, 0.8);
box-shadow: 0 2px 8px -1px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgb(var(--app-highlight-rgb, 255 255 255) / 0.8);
.tab-selector-btn {
display: inline-flex;
@@ -74,7 +74,7 @@ $text-muted: #a1a1aa;
border-radius: 15px;
font-size: 13px;
font-weight: 600;
color: #64748b;
color: var(--app-text-muted, #64748b);
cursor: pointer;
line-height: 1;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
@@ -90,13 +90,13 @@ $text-muted: #a1a1aa;
}
&:hover:not(.active) {
color: #0f172a;
background: rgba(255, 255, 255, 0.7);
color: var(--app-text-strong, #0f172a);
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.7);
}
&.active {
background: #ffffff;
color: #2563eb;
background: var(--app-surface, #ffffff);
color: var(--app-primary-text, #2563eb);
font-weight: 700;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.02);
}
@@ -116,7 +116,7 @@ $text-muted: #a1a1aa;
background: linear-gradient(135deg, rgba(37, 99, 235, 0.92), rgba(26, 115, 232, 0.85));
backdrop-filter: blur(14px) saturate(180%);
-webkit-backdrop-filter: blur(14px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.35);
border: 1px solid rgb(var(--app-border-rgb, 255 255 255) / 0.35);
border-radius: 16px;
font-size: 13.5px;
font-weight: 600;
@@ -127,7 +127,7 @@ $text-muted: #a1a1aa;
gap: 5px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
margin-right: 8px;
box-shadow: 0 4px 14px rgba(37, 99, 235, 0.3), inset 0 1px 1px rgba(255, 255, 255, 0.45);
box-shadow: 0 4px 14px rgba(37, 99, 235, 0.3), inset 0 1px 1px rgb(var(--app-highlight-rgb, 255 255 255) / 0.45);
.material-symbols-outlined {
font-size: 16px;
@@ -136,8 +136,8 @@ $text-muted: #a1a1aa;
&:hover {
background: linear-gradient(135deg, rgba(37, 99, 235, 1), rgba(26, 115, 232, 0.95));
border-color: rgba(255, 255, 255, 0.6);
box-shadow: 0 6px 18px rgba(37, 99, 235, 0.42), inset 0 1px 1px rgba(255, 255, 255, 0.6);
border-color: rgb(var(--app-border-rgb, 255 255 255) / 0.6);
box-shadow: 0 6px 18px rgba(37, 99, 235, 0.42), inset 0 1px 1px rgb(var(--app-highlight-rgb, 255 255 255) / 0.6);
transform: translateY(-0.5px);
}
@@ -151,17 +151,17 @@ $text-muted: #a1a1aa;
height: 32px;
padding: 0;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.72);
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.72);
backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(16px) saturate(180%);
border: 1px solid rgba(203, 213, 225, 0.85);
border: 1px solid rgb(var(--app-border-control-rgb, 203 213 225) / 0.85);
border-radius: 50%;
color: #64748b;
color: var(--app-text-muted, #64748b);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 6px -1px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgba(255, 255, 255, 0.9);
box-shadow: 0 2px 6px -1px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgb(var(--app-highlight-rgb, 255 255 255) / 0.9);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
.material-symbols-outlined {
@@ -170,9 +170,9 @@ $text-muted: #a1a1aa;
}
&:hover:not(:disabled) {
background: #fef2f2;
color: #ef4444;
border-color: #fca5a5;
background: var(--app-error-soft, #fef2f2);
color: var(--app-error-text, #ef4444);
border-color: var(--app-error-border, #fca5a5);
box-shadow: 0 3px 10px rgba(239, 68, 68, 0.15);
transform: scale(1.05);
}
@@ -192,7 +192,7 @@ $text-muted: #a1a1aa;
display: flex;
flex-direction: column;
gap: 8px;
background: #ffffff;
background: var(--app-surface, #ffffff);
&::-webkit-scrollbar {
width: 5px;
@@ -205,7 +205,7 @@ $text-muted: #a1a1aa;
.queue-section-header {
font-size: 11.5px;
font-weight: 700;
color: #475569;
color: var(--app-text-secondary, #475569);
margin: 6px 0 2px;
display: flex;
align-items: center;
@@ -221,18 +221,18 @@ $text-muted: #a1a1aa;
display: flex;
align-items: center;
justify-content: center;
background: #f1f5f9;
border: 1px dashed #cbd5e1;
background: var(--app-surface-soft, #f1f5f9);
border: 1px dashed var(--app-border-control, #cbd5e1);
border-radius: 8px;
padding: 12px;
color: #64748b;
color: var(--app-text-muted, #64748b);
font-size: 12px;
text-align: center;
}
.history-section {
margin-top: 12px;
border-top: 1px solid rgba(226, 232, 240, 0.8);
border-top: 1px solid rgb(var(--app-border-rgb, 226 232 240) / 0.8);
padding-top: 12px;
&[open] .history-summary {
@@ -242,7 +242,7 @@ $text-muted: #a1a1aa;
.history-summary {
font-size: 11.5px;
font-weight: 700;
color: #475569;
color: var(--app-text-secondary, #475569);
cursor: pointer;
outline: none;
user-select: none;
@@ -265,8 +265,8 @@ $text-muted: #a1a1aa;
}
&:hover {
background: #f1f5f9;
color: #1e293b;
background: var(--app-surface-soft, #f1f5f9);
color: var(--app-text-control, #1e293b);
}
}
@@ -278,8 +278,8 @@ $text-muted: #a1a1aa;
}
.task-card {
background: #ffffff;
border: 1px solid rgba(226, 232, 240, 0.8);
background: var(--app-surface, #ffffff);
border: 1px solid rgb(var(--app-border-rgb, 226 232 240) / 0.8);
border-radius: 10px;
padding: 10px 12px;
display: flex;
@@ -292,7 +292,7 @@ $text-muted: #a1a1aa;
&:hover {
transform: translateY(-1px);
border-color: rgba(203, 213, 225, 1);
border-color: rgb(var(--app-border-control-rgb, 203 213 225) / 1);
box-shadow: 0 3px 8px rgba(148, 163, 184, 0.08);
.task-card-header .task-card-header-actions {
@@ -314,7 +314,7 @@ $text-muted: #a1a1aa;
&.selected {
border-color: #3b82f6;
box-shadow: 0 0 0 1px #3b82f6, 0 4px 12px rgba(59, 130, 246, 0.08);
background: #ffffff;
background: var(--app-surface, #ffffff);
}
&.running {
@@ -336,7 +336,7 @@ $text-muted: #a1a1aa;
min-width: 0;
.task-status-group { display: inline-flex; align-items: center; gap: 6px; flex-wrap: wrap; min-width: 0; }
.task-origin-badge { display: inline-flex; align-items: center; box-sizing: border-box; height: 20px; padding: 0 6px; border: 1px solid #e2e8f0; border-radius: 5px; background: #f8fafc; color: #64748b; font-size: 11px; line-height: 14px; white-space: nowrap; }
.task-origin-badge { display: inline-flex; align-items: center; box-sizing: border-box; height: 20px; padding: 0 6px; border: 1px solid var(--app-border, #e2e8f0); border-radius: 5px; background: var(--app-surface-soft, #f8fafc); color: var(--app-text-muted, #64748b); font-size: 11px; line-height: 14px; white-space: nowrap; }
.task-badge {
flex-shrink: 0;
@@ -350,9 +350,9 @@ $text-muted: #a1a1aa;
padding: 0 5px;
border-radius: 4px;
letter-spacing: 0.05em;
border: 1px solid #e2e8f0;
background: #fafafa;
color: #64748b;
border: 1px solid var(--app-border, #e2e8f0);
background: var(--app-surface-soft, #fafafa);
color: var(--app-text-muted, #64748b);
&::before {
content: '';
@@ -364,48 +364,48 @@ $text-muted: #a1a1aa;
}
&.running {
background: #fafafa;
color: #1f1f1f;
border-color: #1f1f1f;
background: var(--app-surface-soft, #fafafa);
color: var(--app-text-strong, #1f1f1f);
border-color: var(--app-border-control, #1f1f1f);
&::before {
background-color: #1f1f1f;
animation: pulse-black-dot-small 1.5s infinite ease-in-out;
}
}
&.paused {
background: #fff7ed;
color: #c2410c;
border-color: #fdba74;
background: var(--app-warning-soft, #fff7ed);
color: var(--app-warning-text, #c2410c);
border-color: var(--app-warning-border, #fdba74);
&::before { background-color: #f97316; }
}
&.completed {
background: #fafafa;
color: #1f1f1f;
border-color: #e2e8f0;
background: var(--app-surface-soft, #fafafa);
color: var(--app-text-strong, #1f1f1f);
border-color: var(--app-border, #e2e8f0);
&::before {
background-color: #1f1f1f;
}
}
&.pending {
background: #fafafa;
color: #64748b;
border-color: #e2e8f0;
background: var(--app-surface-soft, #fafafa);
color: var(--app-text-muted, #64748b);
border-color: var(--app-border, #e2e8f0);
&::before {
background-color: #94a3b8;
}
}
&.failed {
background: #fafafa;
color: #ef4444;
border-color: #fee2e2;
background: var(--app-surface-soft, #fafafa);
color: var(--app-error-text, #ef4444);
border-color: var(--app-error-border, #fee2e2);
&::before {
background-color: #ef4444;
}
}
&.cancelled {
background: #fafafa;
color: #d97706;
border-color: #fef3c7;
background: var(--app-surface-soft, #fafafa);
color: var(--app-warning-text, #d97706);
border-color: var(--app-warning-border, #fef3c7);
&::before {
background-color: #f59e0b;
}
@@ -433,7 +433,7 @@ $text-muted: #a1a1aa;
min-width: 0;
flex-shrink: 1;
font-size: 11px;
color: #94a3b8;
color: var(--app-text-faint, #94a3b8);
line-height: 1;
cursor: default;
transition: color 0.15s ease, opacity 0.15s ease;
@@ -443,7 +443,7 @@ $text-muted: #a1a1aa;
width: 12.5px;
height: 12.5px;
line-height: 12.5px;
color: #94a3b8;
color: var(--app-text-faint, #94a3b8);
opacity: 0.75;
flex-shrink: 0;
}
@@ -458,9 +458,9 @@ $text-muted: #a1a1aa;
}
&:hover {
color: #64748b;
color: var(--app-text-muted, #64748b);
.device-icon {
color: #64748b;
color: var(--app-text-muted, #64748b);
opacity: 1;
}
}
@@ -477,7 +477,7 @@ $text-muted: #a1a1aa;
.task-time {
font-size: 11.5px;
color: #94a3b8;
color: var(--app-text-faint, #94a3b8);
white-space: nowrap;
flex-shrink: 0;
line-height: 1;
@@ -495,7 +495,7 @@ $text-muted: #a1a1aa;
border: none;
background: transparent;
border-radius: 4px;
color: #94a3b8;
color: var(--app-text-faint, #94a3b8);
cursor: pointer;
display: inline-flex;
align-items: center;
@@ -510,8 +510,8 @@ $text-muted: #a1a1aa;
}
&:hover:not(:disabled) {
color: #ef4444;
background: #fee2e2;
color: var(--app-error-text, #ef4444);
background: var(--app-error-soft, #fee2e2);
}
&:disabled {
@@ -531,7 +531,7 @@ $text-muted: #a1a1aa;
border: none;
background: transparent;
border-radius: 4px;
color: #ef4444;
color: var(--app-error-text, #ef4444);
cursor: pointer;
display: inline-flex;
align-items: center;
@@ -546,8 +546,8 @@ $text-muted: #a1a1aa;
}
&:hover:not(:disabled) {
color: #dc2626;
background: #fee2e2;
color: var(--app-error-text, #dc2626);
background: var(--app-error-soft, #fee2e2);
transform: translateY(-50%) scale(1.1);
}
@@ -572,7 +572,7 @@ $text-muted: #a1a1aa;
.task-goal {
font-size: 12.5px;
font-weight: 500;
color: #1e293b;
color: var(--app-text-control, #1e293b);
line-height: 1.4;
word-break: break-word;
display: -webkit-box;
@@ -587,15 +587,15 @@ $text-muted: #a1a1aa;
justify-content: space-between;
align-items: center;
gap: 12px;
border-top: 1px solid rgba(241, 245, 249, 0.8);
border-top: 1px solid rgb(var(--app-border-rgb, 241 245 249) / 0.8);
padding-top: 6px;
font-size: 11.5px;
color: #94a3b8;
color: var(--app-text-faint, #94a3b8);
.task-id {
font-family: 'Fira Code', 'Roboto Mono', Consolas, monospace;
font-size: 11px;
color: #94a3b8;
color: var(--app-text-faint, #94a3b8);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -604,14 +604,14 @@ $text-muted: #a1a1aa;
transition: color 0.15s ease;
&:hover {
color: #475569;
color: var(--app-text-secondary, #475569);
}
}
.task-date {
flex-shrink: 0;
font-size: 11px;
color: #94a3b8;
color: var(--app-text-faint, #94a3b8);
}
}
}
@@ -635,7 +635,7 @@ $text-muted: #a1a1aa;
.selector-title {
font-size: 11px;
font-weight: 700;
color: #94a3b8;
color: var(--app-text-faint, #94a3b8);
letter-spacing: 0.05em;
}
@@ -649,12 +649,12 @@ $text-muted: #a1a1aa;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 1px solid #e2e8f0;
background: #ffffff;
border: 1px solid var(--app-border, #e2e8f0);
background: var(--app-surface, #ffffff);
border-radius: 20px;
font-size: 13px;
font-weight: 500;
color: #475569;
color: var(--app-text-secondary, #475569);
cursor: pointer;
transition: all 0.15s ease;
@@ -663,15 +663,15 @@ $text-muted: #a1a1aa;
}
&:hover {
background: #f8fafc;
border-color: #cbd5e1;
color: #1e293b;
background: var(--app-surface-soft, #f8fafc);
border-color: var(--app-border-control, #cbd5e1);
color: var(--app-text-control, #1e293b);
}
&.active {
background: #eff6ff;
border-color: #bfdbfe;
color: #1d4ed8;
background: var(--app-primary-soft, #eff6ff);
border-color: var(--app-primary-border, #bfdbfe);
color: var(--app-primary-text, #1d4ed8);
font-weight: 600;
}
}
@@ -694,8 +694,8 @@ $text-muted: #a1a1aa;
width: 52px;
height: 52px;
border-radius: 50%;
background: #f1f5f9;
border: 1px solid #e2e8f0;
background: var(--app-surface-soft, #f1f5f9);
border: 1px solid var(--app-border, #e2e8f0);
display: flex;
align-items: center;
justify-content: center;
@@ -704,7 +704,7 @@ $text-muted: #a1a1aa;
.material-symbols-outlined {
font-size: 26px;
color: #94a3b8;
color: var(--app-text-faint, #94a3b8);
}
}
@@ -712,14 +712,14 @@ $text-muted: #a1a1aa;
font-family: 'Google Sans', 'Google Sans Text', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
font-weight: 600;
color: #1e293b;
color: var(--app-text-control, #1e293b);
margin: 0 0 6px 0;
}
.empty-notes-subtitle {
font-family: 'Google Sans', 'Google Sans Text', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 12.5px;
color: #64748b;
color: var(--app-text-muted, #64748b);
line-height: 1.5;
max-width: 240px;
margin: 0;
@@ -736,7 +736,7 @@ $text-muted: #a1a1aa;
.note-title {
font-size: 14px;
font-weight: 700;
color: #1f1f1f;
color: var(--app-text-strong, #1f1f1f);
margin-top: 0;
margin-bottom: 20px;
font-family: 'Google Sans', 'Google Sans Text', 'Inter', sans-serif;
@@ -744,8 +744,8 @@ $text-muted: #a1a1aa;
.code-segment {
font-family: 'Fira Code', 'Roboto Mono', Consolas, monospace;
background-color: #f1f3f4;
color: #1967d2;
background-color: var(--app-info-soft, #f1f3f4);
color: var(--app-primary-text, #1967d2);
padding: 2px 6px;
border-radius: 4px;
font-size: 14px;
@@ -756,8 +756,8 @@ $text-muted: #a1a1aa;
.checked {
.code-segment {
color: #a1a1aa;
background-color: #f8f9fa;
color: var(--app-text-faint, #a1a1aa);
background-color: var(--app-surface-soft, #f8f9fa);
}
}
@@ -778,7 +778,7 @@ $text-muted: #a1a1aa;
opacity: 0.7;
}
.milestone-text {
color: #a1a1aa;
color: var(--app-text-faint, #a1a1aa);
}
}
}
@@ -795,11 +795,11 @@ $text-muted: #a1a1aa;
font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24;
&.checked {
color: #1a73e8;
color: var(--app-primary-text, #1a73e8);
}
&.progress {
color: #1a73e8;
color: var(--app-primary-text, #1a73e8);
}
&.unchecked {
@@ -809,7 +809,7 @@ $text-muted: #a1a1aa;
.milestone-text {
font-family: 'Google Sans', 'Google Sans Text', 'Inter', sans-serif;
color: #1f1f1f;
color: var(--app-text-strong, #1f1f1f);
font-size: 14px;
line-height: 1.8;
font-weight: 400;
@@ -839,7 +839,7 @@ $text-muted: #a1a1aa;
align-items: flex-start;
gap: 10px;
font-family: 'Google Sans', 'Google Sans Text', 'Inter', sans-serif;
color: #1f1f1f;
color: var(--app-text-strong, #1f1f1f);
font-size: 14px;
line-height: 1.8;
@@ -848,7 +848,7 @@ $text-muted: #a1a1aa;
}
&.checked {
color: #a1a1aa;
color: var(--app-text-faint, #a1a1aa);
.check-icon.checked {
opacity: 0.7;
@@ -862,11 +862,11 @@ $text-muted: #a1a1aa;
font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24;
&.checked {
color: #1a73e8;
color: var(--app-primary-text, #1a73e8);
}
&.progress {
color: #1a73e8;
color: var(--app-primary-text, #1a73e8);
}
&.unchecked {
@@ -875,7 +875,7 @@ $text-muted: #a1a1aa;
}
.bullet {
color: #9ca3af;
color: var(--app-text-faint, #9ca3af);
font-weight: bold;
user-select: none;
padding-left: 2px;
@@ -907,8 +907,8 @@ $text-muted: #a1a1aa;
}
.verification-card {
background: #f8fafc;
border: 1px solid #e2e8f0;
background: var(--app-surface-soft, #f8fafc);
border: 1px solid var(--app-border, #e2e8f0);
border-radius: 6px;
padding: 8px 12px;
display: flex;
@@ -931,10 +931,10 @@ $text-muted: #a1a1aa;
line-height: 1;
letter-spacing: 0.6px;
text-transform: uppercase;
color: #64748b;
color: var(--app-text-muted, #64748b);
&.assert {
color: #0284c7;
color: var(--app-info-text, #0284c7);
}
}
}
@@ -943,35 +943,35 @@ $text-muted: #a1a1aa;
font-family: inherit;
font-size: 13.5px;
line-height: 1.6;
color: #334155;
color: var(--app-text-control, #334155);
word-break: break-word;
.bold-segment {
font-weight: 600;
color: #1e293b;
color: var(--app-text-control, #1e293b);
}
.code-segment {
font-family: 'Fira Code', 'Roboto Mono', Consolas, monospace;
font-size: 12px;
background: #f1f5f9;
color: #0f172a;
background: var(--app-surface-soft, #f1f5f9);
color: var(--app-text-strong, #0f172a);
padding: 1.5px 6px;
border-radius: 4px;
border: 1px solid #e2e8f0;
border: 1px solid var(--app-border, #e2e8f0);
}
}
&.finding {
background: #fffbeb;
border-color: #fef3c7;
background: var(--app-warning-soft, #fffbeb);
border-color: var(--app-warning-border, #fef3c7);
.card-header .card-tag {
color: #b45309;
color: var(--app-warning-text, #b45309);
}
.card-body {
color: #92400e;
color: var(--app-warning-text, #92400e);
}
}
}
@@ -988,7 +988,7 @@ $text-muted: #a1a1aa;
margin-bottom: 14px;
min-height: 22px;
font-family: 'Google Sans', 'Google Sans Text', 'Inter', sans-serif;
color: #1f1f1f;
color: var(--app-text-strong, #1f1f1f);
font-size: 14px;
line-height: 1.6;
@@ -1001,7 +1001,7 @@ $text-muted: #a1a1aa;
font-weight: 700;
margin-top: 24px;
margin-bottom: 12px;
border-bottom: 1px solid #e5e7eb;
border-bottom: 1px solid var(--app-border, #e5e7eb);
padding-bottom: 6px;
}
@@ -1020,17 +1020,17 @@ $text-muted: #a1a1aa;
}
&.checked {
color: #a1a1aa;
color: var(--app-text-faint, #a1a1aa);
.check-icon {
color: #1a73e8;
color: var(--app-primary-text, #1a73e8);
opacity: 0.7;
}
}
&.progress {
.check-icon {
color: #1a73e8;
color: var(--app-primary-text, #1a73e8);
}
}
@@ -1042,7 +1042,7 @@ $text-muted: #a1a1aa;
&.list-item {
.bullet {
color: #9ca3af;
color: var(--app-text-faint, #9ca3af);
font-weight: bold;
user-select: none;
}
@@ -0,0 +1,27 @@
import { TestBed } from '@angular/core/testing';
import { EvidenceSourcesComponent } from './evidence-sources.component';
describe('EvidenceSourcesComponent', () => {
it('keeps supporting API data separate from the platform and retains actual record identifiers', () => {
const fixture = TestBed.createComponent(EvidenceSourcesComponent);
fixture.componentRef.setInput('value', {total_sources:1, sources_omitted:0, unattributed_step_count:2,
note:'界面结果不能单独证明后端持久状态。', sources:[{
id:'src', category:'auxiliary', kind:'api', label:'安卓业务后台', target_id:'service', plugin_id:'data',
reference_count:1, references:[{step_number:7, step_id:'actual-step', call_id:'actual-call'}]
}]});
fixture.detectChanges();
const text = fixture.nativeElement.textContent;
expect(text).toContain('辅助来源 · 接口数据');
expect(text).toContain('actual-step');
expect(text).toContain('actual-call');
expect(text).toContain('来源未完整记录的步骤:2');
expect(text).not.toContain('界面来源 · 安卓界面');
expect(fixture.nativeElement.querySelector('app-test-result-badge')).toBeNull();
});
it('does not fabricate source metadata for an older backend', () => {
const fixture = TestBed.createComponent(EvidenceSourcesComponent);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('details')).toBeNull();
});
});
@@ -0,0 +1,52 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
interface SourceReference { step_id?: string; step_number?: number; call_id?: string; source_id?: string; }
interface RecordedSource {
id: string; kind: string; label: string; target_id: string; plugin_id: string;
category: 'interface' | 'auxiliary'; references: SourceReference[]; reference_count: number;
}
interface SourceSummary {
sources: RecordedSource[]; total_sources: number; sources_omitted: number;
unattributed_step_count: number; note: string;
}
@Component({
selector: 'app-evidence-sources', standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `@if (value(); as summary) {
<details class="sources" aria-label="证据来源与验收边界">
<summary>证据来源 · {{ summary.total_sources ? summary.total_sources + ' 个' : '未记录' }}</summary>
<p>{{ summary.note }}</p>
@for (source of summary.sources; track source.id) {
<article>
<strong>{{ source.category === 'interface' ? '界面来源' : '辅助来源' }} · {{ kind(source.kind) }}</strong>
<div>{{ source.label || source.target_id }}</div>
<small>目标 {{ source.target_id }}@if(source.plugin_id) { · 插件 {{ source.plugin_id }} }</small>
<ul>
@for (ref of source.references; track $index) {
<li>步骤 {{ ref.step_number ?? '未记录' }}@if(ref.step_id) { · <code>{{ ref.step_id }}</code> }@if(ref.call_id) { · 调用 <code>{{ ref.call_id }}</code> }</li>
}
</ul>
@if(source.reference_count > source.references.length) { <small>另 {{ source.reference_count - source.references.length }} 条引用见步骤记录。</small> }
</article>
}
@if(summary.sources_omitted) { <p>另 {{ summary.sources_omitted }} 个来源见原始步骤记录。</p> }
@if(summary.unattributed_step_count) { <p>来源未完整记录的步骤:{{ summary.unattributed_step_count }};不据此推断执行平台。</p> }
</details>
}`,
styles: [`
:host { display:block; margin:10px 0; }
.sources { border:1px solid var(--app-border-control,#cbd5e1); border-radius:8px; padding:10px 12px; background:var(--app-surface,#fff); color:var(--app-text-control,#1e293b); font-size:12px; line-height:1.7; overflow-wrap:anywhere; }
summary { cursor:pointer; font-weight:600; }
article { border-top:1px solid var(--app-border-control,#cbd5e1); margin-top:8px; padding-top:8px; }
p,small { color:var(--app-text-secondary,#475569); }
ul { padding-left:20px; margin:4px 0; }
code { font-size:11px; }
`]
})
export class EvidenceSourcesComponent {
value = input<SourceSummary | null>(null);
kind(kind: string): string {
return ({android:'安卓界面', computer:'电脑界面', api:'接口数据', terminal:'终端', file:'文件', other:'其他'} as Record<string,string>)[kind] || '来源类型未记录';
}
}
@@ -1,78 +1,78 @@
:host { display:flex; flex-direction:column; min-height:0; height:100%; overflow:hidden; color:#334155; font-size:13px; }
:host { display:flex; flex-direction:column; min-height:0; height:100%; overflow:hidden; color:var(--app-text-control, #334155); font-size:13px; }
.exploration-panel { padding:18px; display:flex; flex-direction:column; gap:16px; }
header,.row,.actions { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
header,.row { justify-content:space-between; }
header strong { font-size:15px; color:#0f172a; }
header strong { font-size:15px; color:var(--app-text-strong, #0f172a); }
p { margin:8px 0; line-height:1.65; white-space:pre-wrap; overflow-wrap:anywhere; }
header p,.muted,small { color:#64748b; font-size:12px; }
header p,.muted,small { color:var(--app-text-muted, #64748b); font-size:12px; }
button,input,select,textarea { font:inherit; box-sizing:border-box; }
button { display:inline-flex; align-items:center; justify-content:center; gap:4px; border:1px solid #dbe3ec; border-radius:8px; background:white; color:#334155; padding:6px 10px; min-height:32px; cursor:pointer; }
button:hover:enabled { background:#f1f5f9; border-color:#94a3b8; }
button { display:inline-flex; align-items:center; justify-content:center; gap:4px; border:1px solid var(--app-border, #dbe3ec); border-radius:8px; background:var(--app-surface, white); color:var(--app-text-control, #334155); padding:6px 10px; min-height:32px; cursor:pointer; }
button:hover:enabled { background:var(--app-surface-soft, #f1f5f9); border-color:var(--app-border-control, #94a3b8); }
button:disabled { opacity:.5; cursor:default; }
button.primary { background:#0f172a; color:white; border-color:#0f172a; }
button.primary { background:#0f172a; color:white; border-color:var(--app-border-control, #0f172a); }
.material-symbols-outlined { font-size:18px; }
label { display:flex; flex-direction:column; gap:6px; color:#475569; margin:8px 0; }
input,select,textarea { width:100%; min-width:0; border:1px solid #cbd5e1; border-radius:8px; padding:8px 10px; background:white; color:#0f172a; }
label { display:flex; flex-direction:column; gap:6px; color:var(--app-text-secondary, #475569); margin:8px 0; }
input,select,textarea { width:100%; min-width:0; border:1px solid var(--app-border-control, #cbd5e1); border-radius:8px; padding:8px 10px; background:var(--app-surface, white); color:var(--app-text-strong, #0f172a); }
textarea { resize:vertical; line-height:1.6; }
input:focus,select:focus,textarea:focus { outline:2px solid #94a3b8; outline-offset:1px; }
details { border:1px solid #e2e8f0; border-radius:10px; padding:10px 12px; }
summary { cursor:pointer; font-size:12px; color:#475569; }
.session-summary,.case-editor { padding:12px; border:1px solid #e2e8f0; border-radius:12px; background:#f8fafc; }
input:focus,select:focus,textarea:focus { outline:2px solid var(--app-border-control, #94a3b8); outline-offset:1px; }
details { border:1px solid var(--app-border, #e2e8f0); border-radius:10px; padding:10px 12px; }
summary { cursor:pointer; font-size:12px; color:var(--app-text-secondary, #475569); }
.session-summary,.case-editor { padding:12px; border:1px solid var(--app-border, #e2e8f0); border-radius:12px; background:var(--app-surface-soft, #f8fafc); }
.session-summary .actions { margin-top:10px; }
.conversation { display:flex; flex-direction:column; gap:12px; }
.message { padding:10px 12px; border-radius:12px; background:#f1f5f9; border:1px solid transparent; }
.message.user { margin-left:20px; background:#ecfdf5; border-color:#d1fae5; }
.message.system { padding:4px 8px; background:transparent; color:#64748b; font-size:12px; }
.recommendation { border-left:2px solid #94a3b8; padding-left:10px; line-height:1.6; }
.message { padding:10px 12px; border-radius:12px; background:var(--app-surface-soft, #f1f5f9); border:1px solid transparent; }
.message.user { margin-left:20px; background:var(--app-success-soft, #ecfdf5); border-color:var(--app-success-border, #d1fae5); }
.message.system { padding:4px 8px; background:transparent; color:var(--app-text-muted, #64748b); font-size:12px; }
.recommendation { border-left:2px solid var(--app-border-control, #94a3b8); padding-left:10px; line-height:1.6; }
.recommendation button { margin:6px 0 0 8px; }
.composer { border-top:1px solid #e2e8f0; padding-top:4px; }
.composer { border-top:1px solid var(--app-border, #e2e8f0); padding-top:4px; }
.composer > button { margin-top:8px; }
.demo { border:1px solid #bbf7d0; border-radius:12px; padding:12px; background:#f0fdf4; }
.demo.warning { background:#fffbeb; border-color:#fcd34d; }
.demo { border:1px solid var(--app-success-border, #bbf7d0); border-radius:12px; padding:12px; background:var(--app-success-soft, #f0fdf4); }
.demo.warning { background:var(--app-warning-soft, #fffbeb); border-color:#fcd34d; }
.record-dot { display:inline-block; width:7px; height:7px; border-radius:50%; background:#94a3b8; margin-right:6px; }
.record-dot.recording { background:#dc2626; animation:pulse 1.8s infinite; }
@keyframes pulse { 50% { opacity:.4; } }
@media(prefers-reduced-motion:reduce) { .record-dot.recording { animation:none; } }
.feedback { padding:9px 12px; background:#f0fdf4; border-radius:8px; font-size:12px; }
.error { color:#b91c1c; }
.feedback.error { background:#fef2f2; }
.run,.demo-history { padding:10px 0; border-bottom:1px solid #e2e8f0; }
.feedback { padding:9px 12px; background:var(--app-success-soft, #f0fdf4); border-radius:8px; font-size:12px; }
.error { color:var(--app-error-text, #b91c1c); }
.feedback.error { background:var(--app-error-soft, #fef2f2); }
.run,.demo-history { padding:10px 0; border-bottom:1px solid var(--app-border, #e2e8f0); }
.run-link { border:none; padding-left:0; background:transparent; text-align:left; }
video { width:100%; max-height:260px; margin-top:8px; background:#0f172a; }
a { color:#0f766e; font-size:12px; }
.empty { color:#64748b; text-align:center; padding:25px 10px; }
a { color:var(--app-info-text, #0f766e); font-size:12px; }
.empty { color:var(--app-text-muted, #64748b); text-align:center; padding:25px 10px; }
.empty .material-symbols-outlined { font-size:32px; }
.settings-fields,.case-editor { display:flex; flex-direction:column; gap:3px; }
app-agent-stream {flex:1;min-height:0;display:block}
.composer {flex:none;margin:0 20px 16px;padding:12px 16px;background:#fff;border:1px solid #dbe3ec;border-radius:16px;box-shadow:0 4px 18px #0f172a06;max-height:45%;overflow:auto}
.composer {flex:none;margin:0 20px 16px;padding:12px 16px;background:var(--app-surface, #fff);border:1px solid var(--app-border, #dbe3ec);border-radius:16px;box-shadow:0 4px 18px #0f172a06;max-height:45%;overflow:auto}
.composer label {margin:0 0 6px}.composer textarea {resize:none}.composer-actions {margin-top:8px}.composer-actions small {flex:1}
.session-summary {margin-bottom:18px;background:#fff}.session-summary p {margin:6px 0}.session-summary .row strong {display:flex;align-items:center;gap:6px}
.execution-history {margin-top:8px;border:0;padding:4px 0}.case-editor {margin:16px 0;background:#fff}.case-compact {display:flex;flex-wrap:wrap;align-items:center;gap:8px}.case-compact .actions {margin-left:auto}
.session-summary {margin-bottom:18px;background:var(--app-surface, #fff)}.session-summary p {margin:6px 0}.session-summary .row strong {display:flex;align-items:center;gap:6px}
.execution-history {margin-top:8px;border:0;padding:4px 0}.case-editor {margin:16px 0;background:var(--app-surface, #fff)}.case-compact {display:flex;flex-wrap:wrap;align-items:center;gap:8px}.case-compact .actions {margin-left:auto}
.demo {padding:8px 10px;margin-bottom:8px}.demo p {margin:4px 0}
.composer {margin:0 24px 18px;padding:14px 18px;border-radius:22px;box-shadow:0 6px 24px #0f172a08;max-height:50%;box-sizing:border-box;min-width:0}
.composer-heading {display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:4px}
.composer-heading label {margin:0;font-size:12px;color:#64748b}
.composer-heading label {margin:0;font-size:12px;color:var(--app-text-muted, #64748b)}
.composer .manual-control {display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap;margin:0 0 10px}
.composer .manual-control label {display:flex;flex-direction:row;align-items:center;gap:8px;margin:0;min-width:0}
.composer .manual-control select {width:auto;min-width:110px;height:36px;padding:6px 10px}
.composer .manual-control button {align-self:center;height:36px;white-space:nowrap}
.composer-status {font-size:11px;border-radius:20px;padding:3px 8px;background:#eef2ff;color:#4f46e5}
.composer-status.waiting {color:#92400e;background:#fef3c7}
.composer-status {font-size:11px;border-radius:20px;padding:3px 8px;background:var(--app-primary-soft, #eef2ff);color:var(--app-exploration-text, #4f46e5)}
.composer-status.waiting {color:var(--app-warning-text, #92400e);background:var(--app-warning-soft, #fef3c7)}
.composer textarea {border:0;border-radius:0;padding:10px 0;background:transparent;min-height:66px;max-height:140px;font-size:14px;box-shadow:none}
.composer textarea:focus {outline:none}
.composer:focus-within {border-color:#a5b4fc;box-shadow:0 0 0 3px #eef2ff}
.composer-actions {gap:6px;flex-wrap:wrap;border-top:1px solid #f1f5f9;padding-top:10px}
.composer-actions .tool-button {border:0;background:#f8fafc;color:#64748b;border-radius:10px;padding:6px 9px}
.composer-actions .send-hint {flex:1;text-align:right;font-size:10px;color:#94a3b8;min-width:100px}
.composer:focus-within {border-color:var(--app-primary-border, #a5b4fc);box-shadow:0 0 0 3px #eef2ff}
.composer-actions {gap:6px;flex-wrap:wrap;border-top:1px solid var(--app-border, #f1f5f9);padding-top:10px}
.composer-actions .tool-button {border:0;background:var(--app-surface-soft, #f8fafc);color:var(--app-text-muted, #64748b);border-radius:10px;padding:6px 9px}
.composer-actions .send-hint {flex:1;text-align:right;font-size:10px;color:var(--app-text-faint, #94a3b8);min-width:100px}
.send-button {min-height:36px;border-radius:12px;padding:7px 12px;background:#4f46e5!important;border-color:#4f46e5!important;gap:6px}
.composer-footnote {font-size:11px;color:#94a3b8;margin:8px 0 0}
.composer.needs-answer {border-color:#f4c97a}
.question-banner {display:flex;gap:10px;background:#fffbeb;border:1px solid #fde68a;border-radius:12px;padding:12px;margin-bottom:12px;color:#92400e}
.question-banner > .material-symbols-outlined {font-size:22px;margin-top:2px}.question-banner p {margin:4px 0;font-size:13px}.question-banner strong {font-size:13px}.question-banner button {margin-top:5px;background:#fff7d6;border-color:#f4c97a;color:#92400e}
.case-editor {border-color:#c7d2fe;background:linear-gradient(135deg,#f5f7ff,#fff);padding:20px;border-radius:18px}
.case-title {display:flex;align-items:center;gap:8px;color:#4f46e5;font-size:14px}.case-title .material-symbols-outlined {font-size:22px}
.case-compact {display:block}.case-compact h3 {font-size:18px;line-height:1.5;color:#1e293b;margin:14px 0 6px}.case-compact small {font-size:11px;font-weight:400;background:#e0e7ff;color:#4338ca;padding:3px 6px;border-radius:5px;white-space:nowrap}.case-compact .actions {margin:16px 0 0}.case-compact .primary {background:#4f46e5;border-color:#4f46e5}
.session-summary {padding:12px 16px;border-radius:14px}.exploration-goal{display:flex;align-items:flex-start;gap:6px}.session-summary .exploration-goal p{flex:1;min-width:0;margin:4px 0}.case-compact h3{display:flex;align-items:flex-start;gap:6px}.case-compact h3>span{min-width:0;overflow-wrap:anywhere}.session-summary p {color:#64748b;font-size:12px}
.composer-footnote {font-size:11px;color:var(--app-text-faint, #94a3b8);margin:8px 0 0}
.composer.needs-answer {border-color:var(--app-warning-border, #f4c97a)}
.question-banner {display:flex;gap:10px;background:var(--app-warning-soft, #fffbeb);border:1px solid var(--app-warning-border, #fde68a);border-radius:12px;padding:12px;margin-bottom:12px;color:var(--app-warning-text, #92400e)}
.question-banner > .material-symbols-outlined {font-size:22px;margin-top:2px}.question-banner p {margin:4px 0;font-size:13px}.question-banner strong {font-size:13px}.question-banner button {margin-top:5px;background:var(--app-warning-soft, #fff7d6);border-color:var(--app-warning-border, #f4c97a);color:var(--app-warning-text, #92400e)}
.case-editor {border-color:var(--app-primary-border, #c7d2fe);background:linear-gradient(135deg,var(--app-primary-soft, #f5f7ff),var(--app-surface, #fff));padding:20px;border-radius:18px}
.case-title {display:flex;align-items:center;gap:8px;color:var(--app-exploration-text, #4f46e5);font-size:14px}.case-title .material-symbols-outlined {font-size:22px}
.case-compact {display:block}.case-compact h3 {font-size:18px;line-height:1.5;color:var(--app-text-control, #1e293b);margin:14px 0 6px}.case-compact small {font-size:11px;font-weight:400;background:var(--app-primary-soft, #e0e7ff);color:var(--app-exploration-text, #4338ca);padding:3px 6px;border-radius:5px;white-space:nowrap}.case-compact .actions {margin:16px 0 0}.case-compact .primary {background:#4f46e5;border-color:#4f46e5}
.session-summary {padding:12px 16px;border-radius:14px}.exploration-goal{display:flex;align-items:flex-start;gap:6px}.session-summary .exploration-goal p{flex:1;min-width:0;margin:4px 0}.case-compact h3{display:flex;align-items:flex-start;gap:6px}.case-compact h3>span{min-width:0;overflow-wrap:anywhere}.session-summary p {color:var(--app-text-muted, #64748b);font-size:12px}
@media(max-width:600px) {.composer {margin:0 12px 12px;padding:12px}.send-hint {display:none}.send-button {margin-left:auto}.question-banner {max-height:160px;overflow:auto}.case-editor {padding:14px}}
@@ -8,7 +8,7 @@ import { FavoriteKind, FavoritesService } from '../../services/favorites.service
(click)="toggle($event)"><span class="material-symbols-outlined" aria-hidden="true">{{favorite() ? 'star' : 'star_outline'}}</span></button>
@if(error()){<span class="feedback" role="alert">{{error()}}</span>}
@if(api.error() && !api.loaded()){<button type="button" class="retry" (click)="$event.stopPropagation(); api.load(true)" title="重试加载收藏">重试</button>}`,
styles: [`:host{display:inline-flex;align-items:center;position:relative;flex-shrink:0}button{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:0;border-radius:7px;background:transparent;color:#64748b;cursor:pointer}button:hover{background:#fef3c7;color:#b45309}button.saved{color:#c47b0b}button.saved span{font-variation-settings:'FILL' 1}button:focus-visible{outline:2px solid #818cf8;outline-offset:2px}button:disabled{opacity:.45;cursor:default}.material-symbols-outlined{font-size:19px}.feedback{position:absolute;left:0;top:30px;min-width:170px;max-width:260px;padding:8px 10px;z-index:20;border:1px solid #fecaca;border-radius:8px;background:#fff7f7;color:#b91c1c;font-size:12px;line-height:1.5;box-shadow:0 4px 16px #0f172a12}.retry{font-size:10px;width:auto}`]
styles: [`:host{display:inline-flex;align-items:center;position:relative;flex-shrink:0}button{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border:0;border-radius:7px;background:transparent;color:var(--app-text-muted, #64748b);cursor:pointer}button:hover{background:var(--app-warning-soft, #fef3c7);color:var(--app-warning-text, #b45309)}button.saved{color:var(--app-warning-text, #c47b0b)}button.saved span{font-variation-settings:'FILL' 1}button:focus-visible{outline:2px solid var(--app-exploration-border, #818cf8);outline-offset:2px}button:disabled{opacity:.45;cursor:default}.material-symbols-outlined{font-size:19px}.feedback{position:absolute;left:0;top:30px;min-width:170px;max-width:260px;padding:8px 10px;z-index:20;border:1px solid var(--app-error-border, #fecaca);border-radius:8px;background:var(--app-error-soft, #fff7f7);color:var(--app-error-text, #b91c1c);font-size:12px;line-height:1.5;box-shadow:0 4px 16px #0f172a12}.retry{font-size:10px;width:auto}`]
})
export class FavoriteButtonComponent {
api = inject(FavoritesService);
@@ -1,58 +1,58 @@
:host { display: block; width: 100%; color: #334155; }
:host { display: block; width: 100%; color: var(--app-text-control, #334155); }
.featured { margin-top: 26px; }
.heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
h2 { display: flex; align-items: center; gap: 7px; margin: 0; font-size: 16px; font-weight: 650; color: #1e293b; }
h2 span { font-size: 20px; color: #d29a32; }
.heading p { font-size: 12px; color: #64748b; margin: 6px 0 0; }
h2 { display: flex; align-items: center; gap: 7px; margin: 0; font-size: 16px; font-weight: 650; color: var(--app-text-control, #1e293b); }
h2 span { font-size: 20px; color: var(--app-warning-text, #d29a32); }
.heading p { font-size: 12px; color: var(--app-text-muted, #64748b); margin: 6px 0 0; }
.heading > input { max-width: 210px; width: 40%; border-radius: 20px; }
.filters { display: flex; gap: 5px; flex-wrap: wrap; margin: 17px 0; }
.filters button { border: 0; padding: 6px 13px; border-radius: 18px; font-size: 12px; background: transparent; color: #64748b; }
.filters button.selected { background: #e0e7ff; color: #4338ca; font-weight: 600; }
.filters button { border: 0; padding: 6px 13px; border-radius: 18px; font-size: 12px; background: transparent; color: var(--app-text-muted, #64748b); }
.filters button.selected { background: var(--app-primary-soft, #e0e7ff); color: var(--app-exploration-text, #4338ca); font-weight: 600; }
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr)); gap: 14px; align-items: start; }
.card { padding: 16px; border: 1px solid #e2e8f0; border-radius: 16px; background: #fff; min-width: 0; transition: border-color .15s, box-shadow .15s; }
.card:hover { border-color: #c7d2fe; box-shadow: 0 4px 16px #33415508; }
.card.expanded { border-color: #a5b4fc; }
.card { padding: 16px; border: 1px solid var(--app-border, #e2e8f0); border-radius: 16px; background: var(--app-surface, #fff); min-width: 0; transition: border-color .15s, box-shadow .15s; }
.card:hover { border-color: var(--app-primary-border, #c7d2fe); box-shadow: 0 4px 16px #33415508; }
.card.expanded { border-color: var(--app-primary-border, #a5b4fc); }
.card-overview { display: flex; flex-direction: column; min-height: 178px; }
.card-heading { display: flex; align-items: center; justify-content: space-between; gap: 6px; min-height: 22px; }
.mode { padding: 3px 8px; background: #f1f5f9; color: #475569; border-radius: 6px; font-size: 11px; line-height: 16px; }
.mode.pro { background: #ede9fe; color: #6d28d9; }
.mode.explore { background: #e0f2fe; color: #0369a1; }
.case-version { font-size: 11px; color: #94a3b8; white-space: nowrap; }
.mode { padding: 3px 8px; background: var(--app-surface-soft, #f1f5f9); color: var(--app-text-secondary, #475569); border-radius: 6px; font-size: 11px; line-height: 16px; }
.mode.pro { background: var(--app-exploration-soft, #ede9fe); color: var(--app-exploration-text, #6d28d9); }
.mode.explore { background: var(--app-info-soft, #e0f2fe); color: var(--app-info-text, #0369a1); }
.case-version { font-size: 11px; color: var(--app-text-faint, #94a3b8); white-space: nowrap; }
.card-title { display: flex; align-items: flex-start; gap: 6px; margin: 13px 0 7px; min-height: 42px; }
.card-title h3 { flex: 1; min-width: 0; margin: 0; font-size: 14px; line-height: 21px; overflow-wrap: anywhere; color: #1e293b;
.card-title h3 { flex: 1; min-width: 0; margin: 0; font-size: 14px; line-height: 21px; overflow-wrap: anywhere; color: var(--app-text-control, #1e293b);
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; }
.star { display: flex; flex-shrink: 0; width: 24px; height: 24px; align-items: center; justify-content: center; padding: 0; border: 0; background: transparent; color: #c58b23; }
.star { display: flex; flex-shrink: 0; width: 24px; height: 24px; align-items: center; justify-content: center; padding: 0; border: 0; background: transparent; color: var(--app-warning-text, #c58b23); }
.star span { font-size: 19px; font-variation-settings: 'FILL' 1; }
.model-summary { font-size: 12px; line-height: 18px; color: #64748b; margin: 0 0 12px; min-height: 36px; overflow-wrap: anywhere;
.model-summary { font-size: 12px; line-height: 18px; color: var(--app-text-muted, #64748b); margin: 0 0 12px; min-height: 36px; overflow-wrap: anywhere;
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; }
.card-actions { display: flex; align-items: center; justify-content: space-between; margin-top: auto; gap: 10px; }
.card-actions button { display: flex; align-items: center; gap: 4px; min-height: 34px; padding: 7px 10px; border-radius: 8px; font-size: 12px; white-space: nowrap; }
.card-actions span { font-size: 16px; }
.configure { color: #64748b; border: 0; background: transparent; }
.configure { color: var(--app-text-muted, #64748b); border: 0; background: transparent; }
.primary { border: 1px solid #6366f1; background: #6366f1; color: white; }
.primary:hover { background: #4f46e5; }
.configuration { border-top: 1px solid #e2e8f0; margin-top: 14px; padding-top: 14px; display: grid; gap: 12px; }
.configuration label { display: grid; gap: 6px; font-size: 12px; color: #475569; }
.configuration { border-top: 1px solid var(--app-border, #e2e8f0); margin-top: 14px; padding-top: 14px; display: grid; gap: 12px; }
.configuration label { display: grid; gap: 6px; font-size: 12px; color: var(--app-text-secondary, #475569); }
.configuration .checkbox { display: flex; align-items: center; }
.checkbox input { width: auto; min-height: auto; }
input, select { box-sizing: border-box; min-width: 0; width: 100%; min-height: 40px; padding: 10px 12px; border: 1px solid #dbe3ec; border-radius: 10px; color: #334155; background: white; font: inherit; font-size: 13px; }
option { color: #334155; background: #fff; }
.hint { font-size: 11px; color: #64748b; line-height: 1.5; margin: 7px 0 0; }
.save { justify-self: start; padding: 7px 10px; border: 1px solid #cbd5e1; background: #fff; color: #475569; border-radius: 8px; font-size: 12px; }
details { border: 1px solid #e2e8f0; padding: 10px; border-radius: 9px; }
input, select { box-sizing: border-box; min-width: 0; width: 100%; min-height: 40px; padding: 10px 12px; border: 1px solid var(--app-border, #dbe3ec); border-radius: 10px; color: var(--app-text-control, #334155); background: var(--app-surface, white); font: inherit; font-size: 13px; }
option { color: var(--app-text-control, #334155); background: var(--app-surface, #fff); }
.hint { font-size: 11px; color: var(--app-text-muted, #64748b); line-height: 1.5; margin: 7px 0 0; }
.save { justify-self: start; padding: 7px 10px; border: 1px solid var(--app-border-control, #cbd5e1); background: var(--app-surface, #fff); color: var(--app-text-secondary, #475569); border-radius: 8px; font-size: 12px; }
details { border: 1px solid var(--app-border, #e2e8f0); padding: 10px; border-radius: 9px; }
details summary { font-size: 12px; cursor: pointer; overflow-wrap: anywhere; }
details[open] > label, details[open] > app-setting-slider { margin-top: 12px; display: block; }
details[open] > app-model-selector { margin-top: 12px; }
button { font-family: inherit; cursor: pointer; }
button:disabled { opacity: .5; cursor: default; }
button:focus-visible, input:focus-visible, select:focus-visible, summary:focus-visible { outline: 2px solid #818cf8; outline-offset: 2px; }
.empty { display: flex; flex-direction: column; align-items: center; text-align: center; padding: 28px 18px; border: 1px dashed #cbd5e1; border-radius: 16px; color: #64748b; font-size: 12px; line-height: 1.7; }
.empty > span { font-size: 28px; color: #c5a365; margin-bottom: 9px; }
.empty strong { font-size: 14px; color: #475569; }
button:focus-visible, input:focus-visible, select:focus-visible, summary:focus-visible { outline: 2px solid var(--app-exploration-border, #818cf8); outline-offset: 2px; }
.empty { display: flex; flex-direction: column; align-items: center; text-align: center; padding: 28px 18px; border: 1px dashed var(--app-border-control, #cbd5e1); border-radius: 16px; color: var(--app-text-muted, #64748b); font-size: 12px; line-height: 1.7; }
.empty > span { font-size: 28px; color: var(--app-warning-text, #c5a365); margin-bottom: 9px; }
.empty strong { font-size: 14px; color: var(--app-text-secondary, #475569); }
.empty p { max-width: 440px; margin: 8px 0 0; }
.error, .notice { padding: 10px 12px; border-radius: 8px; font-size: 12px; line-height: 1.6; }
.error { background: #fff1f2; color: #b91c1c; }
.notice { background: #f0fdf4; color: #166534; }
.error { background: var(--app-error-soft, #fff1f2); color: var(--app-error-text, #b91c1c); }
.notice { background: var(--app-success-soft, #f0fdf4); color: var(--app-success-text, #166534); }
@media (max-width: 560px) {
.heading { align-items: flex-start; flex-direction: column; }
.heading > input { width: 100%; max-width: none; }
@@ -32,12 +32,12 @@
width: 400px;
max-width: calc(100vw - 32px);
max-height: calc(100dvh - var(--player-top, 60px) - 12px);
background: rgba(255, 255, 255, 0.84);
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.84);
backdrop-filter: blur(24px) saturate(180%);
-webkit-backdrop-filter: blur(24px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.85);
border: 1px solid rgb(var(--app-border-rgb, 255 255 255) / 0.85);
border-radius: 16px;
box-shadow: 0 20px 50px -10px rgba(15, 23, 42, 0.22), 0 0 0 1px rgba(226, 232, 240, 0.6), inset 0 1px 1px rgba(255, 255, 255, 0.9);
box-shadow: 0 20px 50px -10px rgba(15, 23, 42, 0.22), 0 0 0 1px rgba(226, 232, 240, 0.6), inset 0 1px 1px rgb(var(--app-highlight-rgb, 255 255 255) / 0.9);
z-index: 1050;
overflow: hidden;
display: flex;
@@ -80,9 +80,9 @@
/* --- Window Header (Draggable Handle) --- */
.window-header {
height: 44px;
background: rgba(255, 255, 255, 0.75);
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.75);
backdrop-filter: blur(16px);
border-bottom: 1px solid rgba(226, 232, 240, 0.75);
border-bottom: 1px solid rgb(var(--app-border-rgb, 226 232 240) / 0.75);
display: flex;
align-items: center;
justify-content: space-between;
@@ -104,14 +104,14 @@
.header-icon {
font-size: 18px;
color: #2563eb;
color: var(--app-primary-text, #2563eb);
flex-shrink: 0;
}
.header-title {
font-size: 13px;
font-weight: 600;
color: #0f172a;
color: var(--app-text-strong, #0f172a);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -121,9 +121,9 @@
.mode-switcher-group {
display: flex;
align-items: center;
background: rgba(241, 245, 249, 0.65);
background: rgb(var(--app-surface-soft-rgb, 241 245 249) / 0.65);
backdrop-filter: blur(12px);
border: 1px solid rgba(226, 232, 240, 0.8);
border: 1px solid rgb(var(--app-border-rgb, 226 232 240) / 0.8);
border-radius: 8px;
padding: 2px;
margin-left: 4px;
@@ -132,7 +132,7 @@
.mode-btn {
border: none;
background: transparent;
color: #475569;
color: var(--app-text-secondary, #475569);
font-size: 10px;
font-weight: 700;
padding: 2px 7px;
@@ -152,8 +152,8 @@
}
&:hover:not(:disabled) {
color: #0f172a;
background: rgba(255, 255, 255, 0.6);
color: var(--app-text-strong, #0f172a);
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.6);
}
&.active {
@@ -180,9 +180,9 @@
.mode-toggle-group {
display: flex;
align-items: center;
background: rgba(241, 245, 249, 0.7);
background: rgb(var(--app-surface-soft-rgb, 241 245 249) / 0.7);
backdrop-filter: blur(12px);
border: 1px solid rgba(226, 232, 240, 0.85);
border: 1px solid rgb(var(--app-border-rgb, 226 232 240) / 0.85);
border-radius: 8px;
padding: 2px;
margin-right: 4px;
@@ -194,7 +194,7 @@
gap: 3px;
border: none;
background: transparent;
color: #475569;
color: var(--app-text-secondary, #475569);
font-size: 11px;
font-weight: 600;
padding: 2px 7px;
@@ -207,8 +207,8 @@
}
&:hover:not(.active) {
color: #0f172a;
background: rgba(255, 255, 255, 0.8);
color: var(--app-text-strong, #0f172a);
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.8);
}
&.active {
@@ -222,9 +222,9 @@
.speed-pill-group {
display: flex;
align-items: center;
background: rgba(241, 245, 249, 0.65);
background: rgb(var(--app-surface-soft-rgb, 241 245 249) / 0.65);
backdrop-filter: blur(12px);
border: 1px solid rgba(226, 232, 240, 0.8);
border: 1px solid rgb(var(--app-border-rgb, 226 232 240) / 0.8);
border-radius: 8px;
padding: 2px;
margin-right: 4px;
@@ -232,7 +232,7 @@
.speed-btn {
border: none;
background: transparent;
color: #334155;
color: var(--app-text-control, #334155);
font-size: 11px;
font-weight: 700;
padding: 2px 6px;
@@ -241,8 +241,8 @@
transition: all 0.15s ease;
&:hover {
color: #0f172a;
background: #cbd5e1;
color: var(--app-text-strong, #0f172a);
background: var(--app-surface-raised, #cbd5e1);
}
&.active {
@@ -255,11 +255,11 @@
}
.header-btn {
background: rgba(241, 245, 249, 0.75);
background: rgb(var(--app-surface-soft-rgb, 241 245 249) / 0.75);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(226, 232, 240, 0.85);
color: #1e293b;
border: 1px solid rgb(var(--app-border-rgb, 226 232 240) / 0.85);
color: var(--app-text-control, #1e293b);
cursor: pointer;
display: flex;
align-items: center;
@@ -267,21 +267,21 @@
width: 28px;
height: 28px;
border-radius: 7px;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgba(255, 255, 255, 0.8);
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgb(var(--app-highlight-rgb, 255 255 255) / 0.8);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
background: rgba(255, 255, 255, 0.95);
color: #0f172a;
border-color: rgba(203, 213, 225, 0.95);
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.95);
color: var(--app-text-strong, #0f172a);
border-color: rgb(var(--app-border-control-rgb, 203 213 225) / 0.95);
transform: translateY(-0.5px);
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.08);
}
&.close-btn:hover {
background: rgba(254, 226, 226, 0.95);
color: #dc2626;
border-color: rgba(252, 165, 165, 0.9);
background: rgb(var(--app-error-soft-rgb, 254 226 226) / 0.95);
color: var(--app-error-text, #dc2626);
border-color: rgb(var(--app-error-border-rgb, 252 165 165) / 0.9);
box-shadow: 0 2px 6px rgba(220, 38, 38, 0.15);
}
@@ -301,7 +301,7 @@
.video-body {
min-height: 0;
position: relative;
background: #ffffff;
background: var(--app-surface, #ffffff);
display: flex;
flex-direction: column;
align-items: stretch;
@@ -314,16 +314,16 @@
flex-direction: column;
align-items: center;
gap: 10px;
background: #ffffff;
background: var(--app-surface, #ffffff);
width: 100%;
box-sizing: border-box;
color: #475569;
color: var(--app-text-secondary, #475569);
font-size: 13px;
font-weight: 500;
.spinning-icon {
font-size: 28px;
color: #2563eb;
color: var(--app-primary-text, #2563eb);
animation: spin 1s linear infinite;
}
}
@@ -336,7 +336,7 @@
flex-direction: column;
align-items: center;
justify-content: center;
background: #ffffff;
background: var(--app-surface, #ffffff);
min-height: 260px;
overflow: hidden;
@@ -346,7 +346,7 @@
display: block;
object-fit: contain;
user-select: none;
background: #ffffff;
background: var(--app-surface, #ffffff);
}
.live-status-overlay {
@@ -363,9 +363,9 @@
display: flex;
align-items: center;
gap: 6px;
background: #fef2f2;
border: 1px solid #fca5a5;
color: #dc2626;
background: var(--app-error-soft, #fef2f2);
border: 1px solid var(--app-error-border, #fca5a5);
color: var(--app-error-text, #dc2626);
font-size: 10.5px;
font-weight: 700;
padding: 3px 9px;
@@ -384,10 +384,10 @@
.live-refresh-btn {
pointer-events: auto;
background: rgba(255, 255, 255, 0.95);
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.95);
backdrop-filter: blur(6px);
border: 1px solid #cbd5e1;
color: #1e293b;
border: 1px solid var(--app-border-control, #cbd5e1);
color: var(--app-text-control, #1e293b);
cursor: pointer;
display: flex;
align-items: center;
@@ -399,9 +399,9 @@
transition: all 0.15s ease;
&:hover {
background: #f1f5f9;
color: #0f172a;
border-color: #94a3b8;
background: var(--app-surface-soft, #f1f5f9);
color: var(--app-text-strong, #0f172a);
border-color: var(--app-border-control, #94a3b8);
transform: rotate(90deg);
}
@@ -415,7 +415,7 @@
.step-replay-container {
display: flex;
flex-direction: column;
background: #ffffff;
background: var(--app-surface, #ffffff);
position: relative;
width: 100%;
overflow: hidden;
@@ -424,16 +424,16 @@
display: flex;
align-items: center;
gap: 6px;
background: #fffbeb;
color: #b45309;
background: var(--app-warning-soft, #fffbeb);
color: var(--app-warning-text, #b45309);
padding: 6px 12px;
font-size: 11.5px;
font-weight: 500;
border-bottom: 1px solid #fde68a;
border-bottom: 1px solid var(--app-warning-border, #fde68a);
.notice-icon {
font-size: 14px;
color: #d97706;
color: var(--app-warning-text, #d97706);
flex-shrink: 0;
}
}
@@ -443,7 +443,7 @@
align-items: center;
justify-content: center;
position: relative;
background: #f8fafc;
background: var(--app-surface-soft, #f8fafc);
min-height: 280px;
max-height: 520px;
overflow: hidden;
@@ -458,21 +458,21 @@
align-items: center;
justify-content: center;
gap: 8px;
background: #f8fafc;
color: #64748b;
background: var(--app-surface-soft, #f8fafc);
color: var(--app-text-muted, #64748b);
font-size: 12px;
font-weight: 500;
z-index: 5;
.spinning-icon {
font-size: 24px;
color: #0284c7;
color: var(--app-info-text, #0284c7);
animation: spin 1s linear infinite;
}
.broken-icon {
font-size: 28px;
color: #94a3b8;
color: var(--app-text-faint, #94a3b8);
}
}
@@ -495,7 +495,7 @@
cursor: pointer;
border-radius: 8px;
box-shadow: 0 4px 20px -2px rgba(15, 23, 42, 0.14), 0 0 0 1px rgba(226, 232, 240, 0.9);
background: #ffffff;
background: var(--app-surface, #ffffff);
transition: opacity 0.15s ease;
&:hover {
@@ -532,7 +532,7 @@
background: rgba(15, 23, 42, 0.88);
backdrop-filter: blur(14px) saturate(180%);
-webkit-backdrop-filter: blur(14px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.2);
border: 1px solid rgb(var(--app-border-rgb, 255 255 255) / 0.2);
border-radius: 10px;
padding: 6px 10px;
color: #ffffff;
@@ -580,7 +580,7 @@
.action-type-icon {
font-size: 15px;
color: #38bdf8;
color: var(--app-info-text, #38bdf8);
flex-shrink: 0;
}
}
@@ -595,8 +595,8 @@
.pre-post-toggle {
display: inline-flex;
align-items: center;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.12);
border: 1px solid rgb(var(--app-border-rgb, 255 255 255) / 0.15);
border-radius: 6px;
padding: 2px;
gap: 2px;
@@ -614,7 +614,7 @@
&:hover {
color: #ffffff;
background: rgba(255, 255, 255, 0.2);
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.2);
}
&.active {
@@ -639,11 +639,11 @@
&:hover {
color: #ffffff;
background: rgba(255, 255, 255, 0.15);
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.15);
}
&.pinned {
color: #38bdf8;
color: var(--app-info-text, #38bdf8);
background: rgba(56, 189, 248, 0.2);
}
@@ -664,7 +664,7 @@
flex-direction: column;
align-items: center;
justify-content: center;
background: #ffffff;
background: var(--app-surface, #ffffff);
video {
min-height: 0;
@@ -674,21 +674,21 @@
display: block;
object-fit: contain;
cursor: pointer;
background: #ffffff;
background: var(--app-surface, #ffffff);
}
}
.video-controls-footer {
background: rgba(255, 255, 255, 0.84);
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.84);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border-top: 1px solid rgba(226, 232, 240, 0.75);
border-top: 1px solid rgb(var(--app-border-rgb, 226 232 240) / 0.75);
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 10px;
flex-shrink: 0;
box-shadow: 0 -4px 16px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgba(255, 255, 255, 0.9);
box-shadow: 0 -4px 16px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgb(var(--app-highlight-rgb, 255 255 255) / 0.9);
.scrubber-bar-container {
position: relative;
@@ -714,7 +714,7 @@
left: 0;
right: 0;
height: 5px;
background: rgba(226, 232, 240, 0.85);
background: rgb(var(--app-surface-raised-rgb, 226 232 240) / 0.85);
border-radius: 3px;
}
@@ -740,7 +740,7 @@
width: 12px;
height: 12px;
padding: 0;
border: 2px solid #fff;
border: 2px solid var(--app-border, #fff);
border-radius: 50%;
background: #64748b;
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.25);
@@ -775,8 +775,8 @@
width: 7px;
height: 7px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
border: 1.5px solid #94a3b8;
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.9);
border: 1.5px solid var(--app-border-control, #94a3b8);
transform: translate(-50%, 0);
pointer-events: auto;
cursor: pointer;
@@ -785,17 +785,17 @@
&:hover {
transform: translate(-50%, 0) scale(1.4);
border-color: #0284c7;
background: #ffffff;
background: var(--app-surface, #ffffff);
}
&.passed {
background: #0284c7;
border-color: #ffffff;
border-color: var(--app-border, #ffffff);
}
&.active {
background: #38bdf8;
border-color: #ffffff;
border-color: var(--app-border, #ffffff);
transform: translate(-50%, 0) scale(1.3);
box-shadow: 0 0 6px rgba(56, 189, 248, 0.8);
}
@@ -815,12 +815,12 @@
}
.ctrl-btn {
background: rgba(241, 245, 249, 0.75);
background: rgb(var(--app-surface-soft-rgb, 241 245 249) / 0.75);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(226, 232, 240, 0.85);
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgba(255, 255, 255, 0.85);
color: #0f172a;
border: 1px solid rgb(var(--app-border-rgb, 226 232 240) / 0.85);
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgb(var(--app-highlight-rgb, 255 255 255) / 0.85);
color: var(--app-text-strong, #0f172a);
width: 32px;
height: 32px;
border-radius: 8px;
@@ -831,9 +831,9 @@
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
background: rgba(255, 255, 255, 0.95);
border-color: rgba(191, 219, 254, 0.9);
color: #0284c7;
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.95);
border-color: rgb(var(--app-primary-border-rgb, 191 219 254) / 0.9);
color: var(--app-info-text, #0284c7);
transform: translateY(-0.5px);
box-shadow: 0 3px 8px rgba(15, 23, 42, 0.08);
}
@@ -843,8 +843,8 @@
}
&.active {
color: #0284c7;
background: rgba(224, 242, 254, 0.85);
color: var(--app-info-text, #0284c7);
background: rgb(var(--app-info-soft-rgb, 224 242 254) / 0.85);
border-color: rgba(56, 189, 248, 0.8);
box-shadow: 0 2px 6px rgba(2, 132, 199, 0.15);
}
@@ -853,16 +853,16 @@
background: linear-gradient(135deg, rgba(2, 132, 199, 0.92), rgba(37, 99, 235, 0.88));
backdrop-filter: blur(14px) saturate(180%);
-webkit-backdrop-filter: blur(14px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.35);
border: 1px solid rgb(var(--app-border-rgb, 255 255 255) / 0.35);
color: #ffffff;
width: 36px;
height: 36px;
border-radius: 50%;
box-shadow: 0 4px 14px rgba(2, 132, 199, 0.35), inset 0 1px 1px rgba(255, 255, 255, 0.5);
box-shadow: 0 4px 14px rgba(2, 132, 199, 0.35), inset 0 1px 1px rgb(var(--app-highlight-rgb, 255 255 255) / 0.5);
&:hover {
background: linear-gradient(135deg, rgba(2, 132, 199, 1), rgba(37, 99, 235, 0.95));
box-shadow: 0 6px 18px rgba(2, 132, 199, 0.45), inset 0 1px 1px rgba(255, 255, 255, 0.6);
box-shadow: 0 6px 18px rgba(2, 132, 199, 0.45), inset 0 1px 1px rgb(var(--app-highlight-rgb, 255 255 255) / 0.6);
transform: translateY(-1px) scale(1.04);
}
@@ -883,18 +883,18 @@
font-size: 11px;
font-weight: 700;
font-family: 'JetBrains Mono', 'Roboto Mono', monospace;
color: #334155;
color: var(--app-text-control, #334155);
letter-spacing: -0.2px;
&:hover {
color: #0284c7;
border-color: rgba(191, 219, 254, 0.9);
color: var(--app-info-text, #0284c7);
border-color: rgb(var(--app-primary-border-rgb, 191 219 254) / 0.9);
}
}
.material-symbols-outlined {
font-size: 18px;
color: #1e293b;
color: var(--app-text-control, #1e293b);
}
}
@@ -902,7 +902,7 @@
font-size: 12px;
font-weight: 700;
font-family: 'JetBrains Mono', 'Roboto Mono', monospace;
color: #1e293b;
color: var(--app-text-control, #1e293b);
margin-left: 4px;
letter-spacing: 0.5px;
}
@@ -911,7 +911,7 @@
/* --- Clean, Minimal High-Contrast Empty State --- */
.empty-video-fallback {
background: #ffffff;
background: var(--app-surface, #ffffff);
padding: 36px 20px;
display: flex;
flex-direction: column;
@@ -924,8 +924,8 @@
width: 48px;
height: 48px;
border-radius: 50%;
background: #fef2f2;
border: 1px solid #fee2e2;
background: var(--app-error-soft, #fef2f2);
border: 1px solid var(--app-error-border, #fee2e2);
display: flex;
align-items: center;
justify-content: center;
@@ -933,7 +933,7 @@
.fallback-icon {
font-size: 24px;
color: #ef4444;
color: var(--app-error-text, #ef4444);
}
}
@@ -941,13 +941,13 @@
margin: 0 0 6px 0;
font-size: 13.5px;
font-weight: 600;
color: #0f172a;
color: var(--app-text-strong, #0f172a);
}
.fallback-desc {
margin: 0 0 12px 0;
font-size: 12px;
color: #64748b;
color: var(--app-text-muted, #64748b);
line-height: 1.4;
max-width: 240px;
}
@@ -1026,25 +1026,25 @@
gap: 8px;
align-items: center;
padding: 8px 12px;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
background: var(--app-surface-soft, #f8fafc);
border-bottom: 1px solid var(--app-border, #e2e8f0);
font-size: 12px;
color: #475569;
color: var(--app-text-secondary, #475569);
select {
min-width: 0;
flex: 1;
border: 1px solid #cbd5e1;
border: 1px solid var(--app-border-control, #cbd5e1);
border-radius: 6px;
padding: 4px;
background: white;
background: var(--app-surface, white);
}
}
.recording-notice {
padding: 8px 12px;
background: #fffbeb;
color: #92400e;
background: var(--app-warning-soft, #fffbeb);
color: var(--app-warning-text, #92400e);
font-size: 12px;
}
@@ -1055,8 +1055,8 @@
align-items: center;
justify-content: center;
padding: 24px;
color: #475569;
background: #f1f5f9;
color: var(--app-text-secondary, #475569);
background: var(--app-surface-soft, #f1f5f9);
text-align: center;
z-index: 2;
}
@@ -1066,13 +1066,13 @@
justify-content: space-between;
flex-wrap: wrap;
gap: 4px 12px;
color: #64748b;
color: var(--app-text-muted, #64748b);
font-size: 11px;
line-height: 16px;
}
.recording-skip-notice {
color: #64748b;
color: var(--app-text-muted, #64748b);
font-size: 11px;
line-height: 16px;
}
@@ -15,10 +15,10 @@
*/
$border-color: rgba(0, 0, 0, 0.08);
$text-primary: #1f2937;
$text-secondary: #4b5563;
$text-muted: #9ca3af;
$bg-main: #f9fafb;
$text-primary: var(--app-text-strong, #1f2937);
$text-secondary: var(--app-text-secondary, #4b5563);
$text-muted: var(--app-text-faint, #9ca3af);
$bg-main: var(--app-surface-soft, #f9fafb);
:host {
display: flex;
@@ -32,7 +32,7 @@ $bg-main: #f9fafb;
display: flex;
flex-direction: column;
height: 100%;
background: rgba(255, 255, 255, 0.4);
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.4);
backdrop-filter: blur(16px);
box-sizing: border-box;
@@ -42,7 +42,7 @@ $bg-main: #f9fafb;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid $border-color;
background: rgba(255, 255, 255, 0.6);
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.6);
flex-wrap: wrap;
gap: 16px;
@@ -74,10 +74,10 @@ $bg-main: #f9fafb;
.session-dropdown {
padding: 8px 12px;
border: 1px solid rgba(0, 0, 0, 0.12);
border: 1px solid rgb(var(--app-border-control-rgb, 0 0 0) / 0.12);
border-radius: 8px;
font-size: 13.5px;
background-color: #ffffff;
background-color: var(--app-surface, #ffffff);
outline: none;
color: $text-primary;
min-width: 250px;
@@ -92,8 +92,8 @@ $bg-main: #f9fafb;
.btn-refresh {
padding: 8px 14px;
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.12);
background: var(--app-surface, #ffffff);
border: 1px solid rgb(var(--app-border-control-rgb, 0 0 0) / 0.12);
border-radius: 8px;
font-size: 13px;
font-weight: 600;
@@ -105,7 +105,7 @@ $bg-main: #f9fafb;
transition: background 0.2s, transform 0.1s;
&:hover {
background: #f3f4f6;
background: var(--app-surface-raised, #f3f4f6);
}
&:active {
@@ -119,7 +119,7 @@ $bg-main: #f9fafb;
flex: 1;
overflow-y: auto;
padding: 24px;
background: rgba(249, 250, 251, 0.3);
background: rgb(var(--app-surface-soft-rgb, 249 250 251) / 0.3);
&::-webkit-scrollbar {
width: 6px;
@@ -172,8 +172,8 @@ $bg-main: #f9fafb;
gap: 16px;
.log-card {
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.06);
background: var(--app-surface, #ffffff);
border: 1px solid rgb(var(--app-border-control-rgb, 0 0 0) / 0.06);
border-radius: 12px;
padding: 16px 20px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.01), 0 2px 4px -1px rgba(0, 0, 0, 0.006);
@@ -214,24 +214,24 @@ $bg-main: #f9fafb;
font-family: monospace;
&.llm_stream {
background: #e0f2fe;
color: #0369a1;
background: var(--app-info-soft, #e0f2fe);
color: var(--app-info-text, #0369a1);
}
&.trace_recorded {
background: #f3e8ff;
color: #6b21a8;
background: var(--app-exploration-soft, #f3e8ff);
color: var(--app-exploration-text, #6b21a8);
}
&.step_recorded {
background: #ecfdf5;
color: #047857;
background: var(--app-success-soft, #ecfdf5);
color: var(--app-success-text, #047857);
}
&.step_updated {
background: #fef3c7;
color: #b45309;
background: var(--app-warning-soft, #fef3c7);
color: var(--app-warning-text, #b45309);
}
&.background_tasks_updated {
background: #fff1f2;
color: #be123c;
background: var(--app-error-soft, #fff1f2);
color: var(--app-error-text, #be123c);
}
}
}
@@ -260,28 +260,28 @@ $bg-main: #f9fafb;
}
.summary-bubble {
background: #f1f5f9;
border: 1px solid rgba(0, 0, 0, 0.05);
border-left: 4px solid #475569;
background: var(--app-surface-soft, #f1f5f9);
border: 1px solid rgb(var(--app-border-control-rgb, 0 0 0) / 0.05);
border-left: 4px solid var(--app-border-control, #475569);
border-radius: 8px;
padding: 12px 16px;
font-size: 13.5px;
line-height: 1.5;
color: #1e293b;
color: var(--app-text-control, #1e293b);
}
}
details {
border: 1px solid rgba(0, 0, 0, 0.06);
border: 1px solid rgb(var(--app-border-control-rgb, 0 0 0) / 0.06);
border-radius: 8px;
background: #fafafa;
background: var(--app-surface-soft, #fafafa);
overflow: hidden;
transition: border-color 0.2s;
&[open] {
border-color: rgba(0, 0, 0, 0.12);
border-color: rgb(var(--app-border-control-rgb, 0 0 0) / 0.12);
.details-summary {
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
border-bottom: 1px solid rgb(var(--app-border-control-rgb, 0 0 0) / 0.06);
background: rgba(0, 0, 0, 0.02);
}
}
@@ -294,7 +294,7 @@ $bg-main: #f9fafb;
cursor: pointer;
user-select: none;
outline: none;
background: #ffffff;
background: var(--app-surface, #ffffff);
transition: background 0.2s;
&:hover {
@@ -306,18 +306,18 @@ $bg-main: #f9fafb;
padding: 14px;
font-size: 13px;
line-height: 1.6;
color: #334155;
color: var(--app-text-control, #334155);
&.thinking-text {
background: #f0fdf4;
color: #14532d;
background: var(--app-success-soft, #f0fdf4);
color: var(--app-success-text, #14532d);
white-space: pre-wrap;
font-family: inherit;
}
&.planning-text {
background: #f8fafc;
color: #334155;
background: var(--app-surface-soft, #f8fafc);
color: var(--app-text-control, #334155);
white-space: pre-wrap;
font-family: inherit;
}
@@ -326,28 +326,28 @@ $bg-main: #f9fafb;
&.thinking-details {
border-left: 4px solid #16a34a;
.details-summary {
color: #16a34a;
color: var(--app-success-text, #16a34a);
}
}
&.planning-details {
border-left: 4px solid #4f46e5;
.details-summary {
color: #4f46e5;
color: var(--app-exploration-text, #4f46e5);
}
}
&.tool-details {
border-left: 4px solid #2563eb;
.details-summary {
color: #2563eb;
color: var(--app-primary-text, #2563eb);
}
}
&.result-details {
border-left: 4px solid #0891b2;
.details-summary {
color: #0891b2;
color: var(--app-info-text, #0891b2);
}
}
}
@@ -355,7 +355,7 @@ $bg-main: #f9fafb;
&.raw-body {
details {
border: 1px solid rgba(0, 0, 0, 0.06);
border: 1px solid rgb(var(--app-border-control-rgb, 0 0 0) / 0.06);
border-radius: 8px;
overflow: hidden;
@@ -366,7 +366,7 @@ $bg-main: #f9fafb;
color: $text-secondary;
cursor: pointer;
outline: none;
background: #ffffff;
background: var(--app-surface, #ffffff);
}
.details-content {
@@ -392,11 +392,11 @@ $bg-main: #f9fafb;
height: 4px;
}
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.2);
border-radius: 4px;
}
&::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.3);
}
}
}
@@ -47,7 +47,7 @@
@if (review.last_failure_category) { <dt>最近错误类别</dt><dd>{{ review.last_failure_category }}</dd> }
@if (review.status === 'budget_exhausted') { <dt>结束原因</dt><dd class="error">取证、模型请求与重试已耗尽验收总预算;未确认通过。</dd> }
@if (review.status === 'request_retries_exhausted') { <dt>结束原因</dt><dd class="error">验收模型请求重试已用尽;未确认通过,没有重跑设备操作。</dd> }
@if (review.status === 'cancelled') { <dt>结束原因</dt><dd>验收被停止或指导中断,不作为请求超时重试。</dd> }
@if (review.status === 'cancelled') { <dt>结束原因</dt><dd>{{ cancellationReason(review.cancel_reason) }}</dd> }
</dl>
</details>
}
@@ -57,7 +57,12 @@
<dl>
<dt>本地请求编号</dt><dd>{{ request.request_id }}</dd>
<dt>本次尝试编号</dt><dd>{{ request.attempt_id }}</dd>
@if (request.previous_attempt_id) { <dt>上次尝试编号</dt><dd>{{ request.previous_attempt_id }}</dd> }
<dt>出站请求编号</dt><dd>{{ request.client_request_id || '未记录' }}</dd>
<dt>服务端请求编号</dt><dd>{{ request.server_request_id || '服务未提供' }}</dd>
<dt>响应编号</dt><dd>{{ request.response_id || '服务未提供' }}</dd>
@if (request.server_trace_id) { <dt>服务端链路编号</dt><dd>{{ request.server_trace_id }}</dd> }
@if (request.traceparent) { <dt>链路上下文</dt><dd>{{ request.traceparent }}</dd> }
<dt>模型配置</dt><dd>{{ request.model || '未记录' }} / {{ request.reasoning_effort || '默认' }} · 第 {{ request.attempt }} 次请求,最多重试 {{ request.max_retries }} 次</dd>
@if (request.final_review; as review) { <dt>所属最终验收</dt><dd>{{ review.review_id }} · {{ reviewStage(review) }} · 请求记录时剩余 {{ review.remaining_seconds }} 秒</dd> }
<dt>输出 Token 上限</dt><dd>{{ request.max_output_tokens || '未设置(由模型服务决定)' }}</dd>
@@ -68,6 +73,8 @@
<dt>首个有效输出</dt><dd>{{ request.first_output_at ? (request.first_output_at * 1000 | date:'HH:mm:ss.SSS') : '未收到/未记录' }}</dd>
<dt>最后有效输出</dt><dd>{{ request.last_output_at ? (request.last_output_at * 1000 | date:'HH:mm:ss.SSS') : '未收到/未记录' }}</dd>
<dt>结束时间</dt><dd>{{ request.ended_at ? (request.ended_at * 1000 | date:'HH:mm:ss.SSS') : '尚未记录' }}</dd>
@if (termination(request); as reason) { <dt>结束原因</dt><dd>{{ reason }}</dd> }
@if (request.cancel_phase) { <dt>中断阶段</dt><dd>{{ phase(request.cancel_phase) }}</dd> }
<dt>输入图片</dt><dd>{{ request.images.length }} 张 @for (image of request.images; track $index) { <span class="image-size">{{ image.width && image.height ? image.width + ' × ' + image.height : '尺寸未知' }}</span> }</dd>
<dt>Token 用量</dt><dd>{{ request.usage ? '输入 ' + request.usage.prompt_tokens + ' / 输出 ' + request.usage.completion_tokens + ' / 合计 ' + request.usage.total_tokens : '未知(服务未报告)' }}</dd>
@if (request.error) { <dt>失败原因</dt><dd class="error">{{ request.error_type }} · {{ request.error }} · 阶段 {{ phase(request.failure_phase || '') }} @if (request.category) { · {{ request.category }} }</dd> }
@@ -1,26 +1,26 @@
:host { display: block; }
.model-requests { margin: 10px 0 16px; color: #475569; font-size: 12px; }
.request-live { padding: 12px 14px; border: 1px solid #dbeafe; border-radius: 10px; background: #f8fbff; margin-bottom: 8px; }
.model-requests { margin: 10px 0 16px; color: var(--app-text-secondary, #475569); font-size: 12px; }
.request-live { padding: 12px 14px; border: 1px solid var(--app-primary-border, #dbeafe); border-radius: 10px; background: var(--app-primary-soft, #f8fbff); margin-bottom: 8px; }
.request-heading { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
strong { color: #334155; font-size: 13px; }
.model { color: #64748b; overflow-wrap: anywhere; }
.elapsed { margin-left: auto; color: #1d4ed8; font-variant-numeric: tabular-nums; }
strong { color: var(--app-text-control, #334155); font-size: 13px; }
.model { color: var(--app-text-muted, #64748b); overflow-wrap: anywhere; }
.elapsed { margin-left: auto; color: var(--app-primary-text, #1d4ed8); font-variant-numeric: tabular-nums; }
.status-dot { height: 7px; width: 7px; border-radius: 50%; background: #64748b; flex: 0 0 auto; }
.waiting { background: #3b82f6; animation: pulse 1.5s ease-in-out infinite; }
.retrying { border-color: #fde68a; background: #fffbeb; .elapsed { color: #92400e; } .waiting { background: #d97706; } }
.stale { border-color: #cbd5e1; background: #f8fafc; }
.retrying { border-color: var(--app-warning-border, #fde68a); background: var(--app-warning-soft, #fffbeb); .elapsed { color: var(--app-warning-text, #92400e); } .waiting { background: #d97706; } }
.stale { border-color: var(--app-border-control, #cbd5e1); background: var(--app-surface-soft, #f8fafc); }
p { margin: 6px 0 0; line-height: 1.7; }
.error { color: #b45309; overflow-wrap: anywhere; }
summary { cursor: pointer; padding: 5px 0; line-height: 1.7; } summary:hover { color: #2563eb; }
.error { color: var(--app-warning-text, #b45309); overflow-wrap: anywhere; }
summary { cursor: pointer; padding: 5px 0; line-height: 1.7; } summary:hover { color: var(--app-primary-text, #2563eb); }
summary:focus-visible, a:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; }
.diagnostics-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; margin: 8px 0; color: #64748b; }
a { color: #2563eb; text-decoration: none; border: 1px solid #dbeafe; border-radius: 6px; padding: 4px 9px; }
.diagnostics-header { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; margin: 8px 0; color: var(--app-text-muted, #64748b); }
a { color: var(--app-primary-text, #2563eb); text-decoration: none; border: 1px solid var(--app-primary-border, #dbeafe); border-radius: 6px; padding: 4px 9px; }
.request-history { max-height: 360px; overflow-y: auto; }
.request-detail { border-top: 1px solid #e2e8f0; padding: 4px 0; }
.request-detail { border-top: 1px solid var(--app-border, #e2e8f0); padding: 4px 0; }
dl { display: grid; grid-template-columns: 110px minmax(0, 1fr); gap: 7px 10px; margin: 8px 0 12px; line-height: 1.6; }
dt { color: #64748b; } dd { margin: 0; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
.image-size, .fallback { display: inline-block; padding: 0 5px; margin: 2px 3px; background: #f1f5f9; border-radius: 4px; }
.notice { color: #64748b; }
dt { color: var(--app-text-muted, #64748b); } dd { margin: 0; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
.image-size, .fallback { display: inline-block; padding: 0 5px; margin: 2px 3px; background: var(--app-surface-soft, #f1f5f9); border-radius: 4px; }
.notice { color: var(--app-text-muted, #64748b); }
@keyframes pulse { 50% { opacity: .35; } }
@media (prefers-reduced-motion: reduce) { .waiting { animation: none; } }
@media (max-width: 480px) { dl { grid-template-columns: 90px minmax(0, 1fr); } .elapsed { margin-left: 15px; } }
@@ -1,7 +1,7 @@
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { FinalReview, LlmRequestStatusComponent, ModelRequest, requestHint, reviewStage } from './llm-request-status.component';
import { cancellationReason, FinalReview, LlmRequestStatusComponent, ModelRequest, requestHint, requestTermination, reviewStage } from './llm-request-status.component';
const waiting: ModelRequest = {
request_id: 'call-1', attempt_id: 'attempt-1', model: 'gpt-6-luna', role: 'FlashRunner',
@@ -118,4 +118,42 @@ describe('模型请求诊断', () => {
expect(reviewStage({ ...review, status: 'cancelled' })).toBe('验收已中断');
expect(reviewStage({ ...review, status: 'completed' })).toBe('验收完成');
});
it('取消原因有来源才显示,旧记录与未知字段保留未知', () => {
expect(cancellationReason('user_stop')).toContain('用户停止');
expect(cancellationReason('guidance_replaced')).toContain('新指导替换');
expect(cancellationReason('task_paused')).toContain('任务暂停');
expect(cancellationReason('task_replaced')).toContain('新任务替换');
expect(cancellationReason('superseded')).toContain('新的检查或计划');
expect(cancellationReason()).toContain('取消来源未记录');
expect(cancellationReason('untrusted new instruction')).toContain('取消来源未记录');
expect(requestTermination({ ...waiting, phase: 'cancelled' })).toContain('取消来源未记录');
});
it('区分三层超时、未完成输出与未确认来源的499', () => {
expect(requestTermination({ ...waiting, phase: 'failed', timeout_scope: 'request' })).toContain('单次模型请求');
expect(requestTermination({ ...waiting, phase: 'failed', timeout_scope: 'transport' })).toContain('连接或读写超时');
expect(requestTermination({ ...waiting, phase: 'failed', timeout_scope: 'final_review' })).toContain('最终验收总预算');
expect(requestTermination({ ...waiting, phase: 'cancelled', cancel_reason: 'outer_timeout', timeout_scope: 'outer' })).toContain('外层等待时限');
expect(requestTermination({ ...waiting, phase: 'failed', http_status: 499 })).toContain('来源尚未确认');
expect(requestTermination({ ...waiting, phase: 'failed', category: 'connection' })).toContain('来源需结合服务端记录');
expect(requestTermination({ ...waiting, phase: 'failed', response_guard: { reason: 'missing_completion' } })).toContain('本次输出未被采用');
expect(requestTermination({ ...waiting, phase: 'completed' })).toBe('');
});
it('将本次尝试、上次重试、链路编号与结束原因一起展示', () => {
const fixture = TestBed.createComponent(LlmRequestStatusComponent);
fixture.componentRef.setInput('sessionId', 'cancelled'); fixture.detectChanges();
TestBed.inject(HttpTestingController).expectOne('/api/sessions/cancelled/request-diagnostics').flush({ requests: [{
...waiting, active: false, phase: 'cancelled', cancel_reason: 'guidance_replaced',
cancel_phase: 'retry_wait', previous_attempt_id: 'attempt-before', client_request_id: 'client-wire-id',
server_request_id: 'server-wire-id', response_id: 'resp-from-server', server_trace_id: 'gateway-trace',
traceparent: '00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01', ended_at: Date.now() / 1000
}] });
fixture.detectChanges();
const text = fixture.nativeElement.textContent;
for (const value of ['attempt-before', 'client-wire-id', 'server-wire-id', 'resp-from-server', 'gateway-trace', '新指导替换了旧请求', '中断阶段', '等待重试']) expect(text).toContain(value);
expect(fixture.nativeElement.querySelector('.request-live')).toBeNull();
fixture.destroy();
});
});
@@ -9,6 +9,10 @@ export interface ModelRequest {
attempt: number; max_retries: number; timeout_seconds: number; elapsed_seconds: number;
started_at: number; headers_at?: number; first_output_at?: number; last_output_at?: number;
ended_at?: number; retry_at?: number; http_status?: number; server_request_id?: string;
client_request_id?: string | null; previous_attempt_id?: string | null; response_id?: string;
server_trace_id?: string | null; traceparent?: string;
cancel_reason?: string; cancel_phase?: string; timeout_scope?: string;
response_guard?: { reason: string; status?: string };
error?: string; error_type?: string; failure_phase?: string;
output_chunks?: number; tool_argument_chars?: number; max_output_tokens?: number | null;
tools?: { name: string | null; argument_chars: number }[];
@@ -24,6 +28,29 @@ export interface FinalReview {
total_seconds: number; elapsed_seconds: number; remaining_seconds: number;
request_count: number; retry_count: number; request_attempt?: number; max_retries: number;
request_phase?: string; last_failure_category?: string;
cancel_reason?: string;
}
export function cancellationReason(reason?: string): string {
return ({
user_stop: '用户停止了任务。',
guidance_replaced: '新指导替换了旧请求,任务可按新指导继续。',
task_paused: '任务暂停,已中断当前请求。',
task_replaced: '新任务替换了当前任务。',
superseded: '新的检查或计划替换了当前请求。',
outer_timeout: '外层等待时限已到,取消了当前请求。'
} as Record<string, string>)[reason || ''] || '取消来源未记录,无法确定是否由用户停止、指导或外层时限触发。';
}
export function requestTermination(request: ModelRequest): string {
if (request.phase === 'cancelled') return cancellationReason(request.cancel_reason);
if (request.timeout_scope === 'final_review') return '最终验收总预算已用尽,本次请求已中断。';
if (request.timeout_scope === 'request') return '单次模型请求时限已到。';
if (request.timeout_scope === 'transport') return '模型连接或读写超时。';
if (request.http_status === 499) return '服务返回 HTTP 499;来源尚未确认,需对照本地记录和服务端日志。';
if (request.response_guard) return '模型响应未完整结束或工具参数无效,本次输出未被采用。';
if (request.category === 'connection') return '模型连接中断或不可用,来源需结合服务端记录核对。';
return '';
}
export function reviewStage(review: FinalReview): string {
@@ -73,6 +100,8 @@ export class LlmRequestStatusComponent {
phase = requestPhase;
hint = requestHint;
reviewStage = reviewStage;
termination = requestTermination;
cancellationReason = cancellationReason;
constructor() {
effect(() => this.activeChange.emit(this.active().length > 0 || this.activeReviews().length > 0));
@@ -24,19 +24,19 @@ let nextId = 0;
}
`,
styles: [`
:host { display: grid; gap: 7px; min-width: 0; color: #334155; font-size: 13px; }
:host { display: grid; gap: 7px; min-width: 0; color: var(--app-text-control, #334155); font-size: 13px; }
label { font-weight: 600; line-height: 1.5; }
.selector { position: relative; min-width: 0; }
select, input { box-sizing: border-box; width: 100%; min-width: 0; min-height: 40px;
padding: 10px 12px; border: 1px solid #dbe3ec; border-radius: 10px; background: #fff;
padding: 10px 12px; border: 1px solid var(--app-border, #dbe3ec); border-radius: 10px; background: var(--app-surface, #fff);
color: inherit; font: inherit; }
select { appearance: none; padding-right: 36px; cursor: pointer; text-overflow: ellipsis; }
.selector > span { position: absolute; right: 11px; top: 50%; transform: translateY(-50%);
pointer-events: none; font-size: 19px; color: #64748b; }
select:hover:not(:disabled), input:hover:not(:disabled) { border-color: #a5b4fc; }
select:focus-visible, input:focus-visible { outline: 2px solid #a5b4fc; outline-offset: 2px; }
pointer-events: none; font-size: 19px; color: var(--app-text-muted, #64748b); }
select:hover:not(:disabled), input:hover:not(:disabled) { border-color: var(--app-primary-border, #a5b4fc); }
select:focus-visible, input:focus-visible { outline: 2px solid var(--app-primary-border, #a5b4fc); outline-offset: 2px; }
select:disabled, input:disabled { opacity: .5; cursor: default; }
option { background: #fff; color: #334155; }
option { background: var(--app-surface, #fff); color: var(--app-text-control, #334155); }
`]
})
export class ModelSelectorComponent {
@@ -29,7 +29,7 @@ interface Capabilities {
template: `
<section aria-label="功能开关">
<h3>功能开关</h3>
<p>按需启用,保存后对尚未开始的任务生效,不影响运行中的任务。</p>
<p>Flash 最终 AI 判定切换后自动保存;其余设置点击底部按钮保存。保存后对尚未开始的任务生效,不影响运行中的任务。</p>
<label><input type="checkbox" role="switch" [(ngModel)]="config.adb_auto_connect" [disabled]="busy() || loading() || !loaded()" />
<span>自动连接设备<small>刷新设备时发现本机常见模拟器,并重连手动连接过的 TCP 设备;重启 ADB 后也会重连。不扫描局域网,不自动选择任务设备。保存后立即生效。</small></span></label>
<label><input type="checkbox" role="switch" [(ngModel)]="config.video_enabled" [disabled]="busy() || loading() || !loaded()" />
@@ -52,7 +52,7 @@ interface Capabilities {
}
<label><input type="checkbox" role="switch" [(ngModel)]="config.outputter_enabled" [disabled]="busy() || loading() || !loaded()" />
<span>最终报告生成<small>根据执行记录生成任务结果报告。</small></span></label>
<label><input type="checkbox" role="switch" aria-label="Flash 最终 AI 判定与分析" [(ngModel)]="config.flash_final_check_enabled" [disabled]="busy() || loading() || !loaded()" />
<label><input type="checkbox" role="switch" aria-label="Flash 最终 AI 判定与分析" [ngModel]="config.flash_final_check_enabled" (ngModelChange)="saveFlashFinalCheck($event)" [disabled]="busy() || loading() || !loaded()" />
<span>Flash 最终 AI 判定与分析<small>默认关闭。开启后,在 Flash 执行结束时根据用例需求和执行步骤复核结果,会增加耗时。关闭时显示“执行完成 · 未做 AI 判定”,仍保留步骤和报告。不影响 Pro。</small></span></label>
<label><input type="checkbox" role="switch" [(ngModel)]="config.step_summarizer_enabled" [disabled]="busy() || loading() || !loaded()" />
<span>步骤摘要压缩<small>Flash 模式下在后台生成摘要,减少长任务的上下文占用。</small></span></label>
@@ -77,33 +77,55 @@ interface Capabilities {
@if (error()) { <p role="alert" class="error">{{ error() }}</p> }
</section>
`,
styles: [`section { margin-top: 24px; padding-top: 20px; border-top: 1px solid #64748b55; }
h3 { margin: 0 0 8px; } p, small { color: #64748b; line-height: 1.6; }
styles: [`section { margin-top: 24px; padding-top: 20px; border-top: 1px solid rgb(var(--app-border-control-rgb, 100 116 139) / 0.3333); }
h3 { margin: 0 0 8px; } p, small { color: var(--app-text-muted, #64748b); line-height: 1.6; }
label { display: flex; gap: 12px; align-items: flex-start; padding: 14px 0; cursor: pointer; }
input { width: 20px; height: 20px; accent-color: #0284c7; flex-shrink: 0; }
input { width: 20px; height: 20px; accent-color: var(--app-info-text, #0284c7); flex-shrink: 0; }
small { display: block; margin-top: 4px; } button { padding: 10px 18px; border-radius: 10px; border: 1px solid #38bdf877; background: #0284c7; color: white; font: inherit; cursor: pointer; }
button:disabled { opacity: .5; cursor: default; } .error { color: #be123c; }
.execution-settings { margin: 12px 0 20px; padding: 12px 14px; border: 1px solid #cbd5e1; border-radius: 10px; background: #f8fafc; }
summary { cursor: pointer; font-weight: 600; color: #334155; }
button:disabled { opacity: .5; cursor: default; } .error { color: var(--app-error-text, #be123c); }
.execution-settings { margin: 12px 0 20px; padding: 12px 14px; border: 1px solid var(--app-border-control, #cbd5e1); border-radius: 10px; background: var(--app-surface-soft, #f8fafc); }
summary { cursor: pointer; font-weight: 600; color: var(--app-text-control, #334155); }
.number-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px; }
.number-fields label { display: block; padding: 0; color: #334155; }
.number-fields input, .number-fields select { box-sizing: border-box; display: block; width: 100%; height: auto; margin-top: 8px; padding: 9px; border: 1px solid #cbd5e1; border-radius: 8px; font: inherit; }
section {font-size:13px;border-color:#e2e8f0}h3 {font-size:15px;color:#334155}
section > label {align-items:center;flex-direction:row-reverse;justify-content:space-between;border-bottom:1px solid #f1f5f9;gap:20px}section > label > span {flex:1}small {font-size:12px}
input[role=switch] {appearance:none;position:relative;width:36px;height:22px;border:1px solid #cbd5e1;border-radius:20px;background:#e2e8f0;cursor:pointer;transition:background .15s}
input[role=switch]::after {content:'';position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:#fff;box-shadow:0 1px 3px #0f172a25;transition:transform .15s}
input[role=switch]:checked {background:#6366f1;border-color:#6366f1}input[role=switch]:checked::after {transform:translateX(14px)}input[role=switch]:focus-visible {outline:2px solid #a5b4fc;outline-offset:3px}input:disabled {opacity:.5;cursor:default}
button {background:#4f46e5;border-color:#4f46e5;border-radius:12px;font-weight:600}.execution-settings {border-color:#e2e8f0;border-radius:14px;padding:14px 16px}.number-fields input,.number-fields select {background:#fff;border-color:#dbe3ec}
.number-fields label { display: block; padding: 0; color: var(--app-text-control, #334155); }
.number-fields input, .number-fields select { box-sizing: border-box; display: block; width: 100%; height: auto; margin-top: 8px; padding: 9px; border: 1px solid var(--app-border-control, #cbd5e1); border-radius: 8px; font: inherit; }
section {font-size:13px;border-color:var(--app-border, #e2e8f0)}h3 {font-size:15px;color:var(--app-text-control, #334155)}
section > label {align-items:center;flex-direction:row-reverse;justify-content:space-between;border-bottom:1px solid var(--app-border, #f1f5f9);gap:20px}section > label > span {flex:1}small {font-size:12px}
input[role=switch] {appearance:none;position:relative;width:36px;height:22px;border:1px solid var(--app-border-control, #cbd5e1);border-radius:20px;background:var(--app-surface-raised, #e2e8f0);cursor:pointer;transition:background .15s}
input[role=switch]::after {content:'';position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:var(--app-surface, #fff);box-shadow:0 1px 3px #0f172a25;transition:transform .15s}
input[role=switch]:checked {background:#6366f1;border-color:#6366f1}input[role=switch]:checked::after {transform:translateX(14px)}input[role=switch]:focus-visible {outline:2px solid var(--app-primary-border, #a5b4fc);outline-offset:3px}input:disabled {opacity:.5;cursor:default}
button {background:#4f46e5;border-color:#4f46e5;border-radius:12px;font-weight:600}.execution-settings {border-color:var(--app-border, #e2e8f0);border-radius:14px;padding:14px 16px}.number-fields input,.number-fields select {background:var(--app-surface, #fff);border-color:var(--app-border, #dbe3ec)}
@media(max-width: 480px) { .number-fields { grid-template-columns: 1fr; } }`]
})
export class CapabilitySettingsComponent implements OnInit {
changed = output<Capabilities>();
private http = inject(HttpClient);
private savedConfig: Capabilities | null = null;
config: Capabilities = { adb_auto_connect: true, adb_discovery_interval_seconds: 10, adb_discovery_timeout_seconds: 5, video_enabled: false, recording_enabled: false, outputter_enabled: true, flash_final_check_enabled: false, step_summarizer_enabled: true, screenshot_limit_enabled: true, screenshot_max_long_edge: 2048, task_timeout_seconds: 1800, observation_timeout_seconds: 60, observation_max_attempts: 3, device_command_timeout_seconds: 10, action_failure_attempts: 3, worker_cleanup_timeout_seconds: 30 };
busy = signal(false); loading = signal(true); loaded = signal(false); message = signal(''); error = signal('');
ngOnInit(): void {
this.http.get<Capabilities>('/api/system/capability-settings').pipe(timeout(UI_READ_TIMEOUT_MS), finalize(() => this.loading.set(false))).subscribe({
next: config => { this.config = config; this.loaded.set(true); this.changed.emit(config); }, error: () => this.error.set('功能配置加载失败,请刷新页面重试。')
next: config => { this.savedConfig = structuredClone(config); this.config = config; this.loaded.set(true); this.changed.emit(config); }, error: () => this.error.set('功能配置加载失败,请刷新页面重试。')
});
}
saveFlashFinalCheck(enabled: boolean): void {
if (this.busy() || !this.loaded() || !this.savedConfig) return;
const previous = this.savedConfig.flash_final_check_enabled;
this.config.flash_final_check_enabled = enabled;
this.busy.set(true); this.message.set('正在保存 Flash 判定设置…'); this.error.set('');
// Preserve other settings that are still being edited, including invalid drafts.
const update = { ...this.savedConfig, flash_final_check_enabled: enabled };
this.http.post<Capabilities>('/api/system/capability-settings', update).pipe(finalize(() => this.busy.set(false))).subscribe({
next: config => {
this.savedConfig = structuredClone(config);
this.config.flash_final_check_enabled = config.flash_final_check_enabled;
this.changed.emit(config);
this.message.set(`Flash 最终 AI 判定已${config.flash_final_check_enabled ? '开启' : '关闭'},尚未开始的任务将使用此设置,无需重启服务。`);
},
error: () => {
this.config.flash_final_check_enabled = previous;
this.message.set('');
this.error.set('Flash 判定设置保存失败,开关已恢复。请检查连接后重新切换。');
}
});
}
save(): void {
@@ -113,7 +135,7 @@ export class CapabilitySettingsComponent implements OnInit {
}
this.busy.set(true); this.message.set(''); this.error.set('');
this.http.post<Capabilities>('/api/system/capability-settings', this.config).pipe(finalize(() => this.busy.set(false))).subscribe({
next: config => { this.config = config; this.changed.emit(config); this.message.set('功能设置已保存,新任务将使用此配置。'); },
next: config => { this.savedConfig = structuredClone(config); this.config = config; this.changed.emit(config); this.message.set('功能设置已保存,新任务将使用此配置。'); },
error: () => this.error.set('保存失败,请检查服务器连接和 .env 文件权限。')
});
}
@@ -221,7 +221,7 @@ describe('持久化模型与功能设置', () => {
http.expectNone('/api/system/capability-settings');
});
it('Flash 最终 AI 判定默认关闭,保存失败保留选择,成功后重新打开仍保留', async () => {
it('Flash 判定切换即保存,失败恢复原值,重新切换后持久生效', async () => {
const fixture = TestBed.createComponent(CapabilitySettingsComponent);
const capabilities = { ...fixture.componentInstance.config };
fixture.detectChanges();
@@ -234,17 +234,20 @@ describe('持久化模型与功能设置', () => {
expect(toggle.checked).toBeFalse();
expect(root.textContent).toContain('不影响 Pro');
toggle.click(); fixture.detectChanges(); await fixture.whenStable();
root.querySelector<HTMLButtonElement>('button')!.click();
let request = http.expectOne('/api/system/capability-settings');
expect(request.request.body.flash_final_check_enabled).toBeTrue();
expect(fixture.componentInstance.busy()).toBeTrue();
request.flush({}, {status:500,statusText:'Error'});
fixture.detectChanges();
expect(fixture.componentInstance.config.flash_final_check_enabled).toBeTrue();
await fixture.whenStable();
expect(fixture.componentInstance.config.flash_final_check_enabled).toBeFalse();
expect(toggle.checked).toBeFalse();
expect(fixture.componentInstance.error()).toContain('保存失败');
root.querySelector<HTMLButtonElement>('button')!.click();
toggle.click(); fixture.detectChanges(); await fixture.whenStable();
request = http.expectOne('/api/system/capability-settings');
const saved = request.request.body;
request.flush(saved);
expect(fixture.componentInstance.message()).toContain('无需重启');
fixture.destroy();
const reopened = TestBed.createComponent(CapabilitySettingsComponent);
reopened.detectChanges();
@@ -253,4 +256,24 @@ describe('持久化模型与功能设置', () => {
expect(reopened.nativeElement.querySelector('[aria-label="Flash 最终 AI 判定与分析"]').checked).toBeTrue();
reopened.destroy();
});
it('自动保存 Flash 判定不提交其他未保存或无效的草稿', async () => {
const fixture = TestBed.createComponent(CapabilitySettingsComponent);
const capabilities = { ...fixture.componentInstance.config };
fixture.detectChanges();
http.expectOne('/api/system/capability-settings').flush(capabilities);
fixture.detectChanges(); await fixture.whenStable();
fixture.componentInstance.config.task_timeout_seconds = 0;
fixture.componentInstance.config.recording_enabled = true;
const toggle: HTMLInputElement = fixture.nativeElement.querySelector('[aria-label="Flash 最终 AI 判定与分析"]');
toggle.click(); fixture.detectChanges(); await fixture.whenStable();
const request = http.expectOne('/api/system/capability-settings');
expect(request.request.body.flash_final_check_enabled).toBeTrue();
expect(request.request.body.task_timeout_seconds).toBe(1800);
expect(request.request.body.recording_enabled).toBeFalse();
request.flush(request.request.body);
expect(fixture.componentInstance.config.task_timeout_seconds).toBe(0);
expect(fixture.componentInstance.config.recording_enabled).toBeTrue();
fixture.destroy();
});
});
@@ -78,27 +78,27 @@ interface ModelSettings {
</form>
`,
styles: [`
.model-settings { display: grid; gap: 10px; color: #1e293b; }
p, small { margin: 0; line-height: 1.6; color: #64748b; }
.model-settings { display: grid; gap: 10px; color: var(--app-text-control, #1e293b); }
p, small { margin: 0; line-height: 1.6; color: var(--app-text-muted, #64748b); }
label { font-weight: 600; } label span { font-weight: 400; font-size: 12px; }
input, select { box-sizing: border-box; width: 100%; padding: 12px; border-radius: 10px; border: 1px solid #64748b66;
background: #f8fafc; color: inherit; font: inherit; }
input, select { box-sizing: border-box; width: 100%; padding: 12px; border-radius: 10px; border: 1px solid rgb(var(--app-border-control-rgb, 100 116 139) / 0.4000);
background: var(--app-surface-soft, #f8fafc); color: inherit; font: inherit; }
input:focus, select:focus { outline: 2px solid #38bdf8; }
.number-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 8px; }
.number-fields input { margin-top: 8px; }
.recovery-settings { border: 1px solid #cbd5e1; border-radius: 10px; background: #f8fafc; padding: 10px 14px; }
.recovery-settings summary { cursor: pointer; font-weight: 600; color: #334155; }
.recovery-settings { border: 1px solid var(--app-border-control, #cbd5e1); border-radius: 10px; background: var(--app-surface-soft, #f8fafc); padding: 10px 14px; }
.recovery-settings summary { cursor: pointer; font-weight: 600; color: var(--app-text-control, #334155); }
.recovery-content { display: grid; gap: 10px; padding-top: 12px; }
.recovery-content label input, .recovery-content label select { margin-top: 8px; }
.actions { display: flex; gap: 12px; margin-top: 8px; }
button { padding: 11px 20px; border-radius: 10px; border: 1px solid #38bdf877; background: #0284c7; color: white; cursor: pointer; font: inherit; }
button:disabled { opacity: .5; cursor: default; } .success { color: #047857; } .error { color: #be123c; }
button:disabled { opacity: .5; cursor: default; } .success { color: var(--app-success-text, #047857); } .error { color: var(--app-error-text, #be123c); }
button:focus-visible { outline: 2px solid #0284c7; outline-offset: 2px; }
small { overflow-wrap: anywhere; } @media(max-width: 480px) { .number-fields { grid-template-columns: 1fr; } }
.model-settings {gap:12px;font-size:13px} label {margin-top:6px}input,select {background:#fff;border-color:#dbe3ec;padding:11px 12px}
input:focus,select:focus {outline:2px solid #a5b4fc;outline-offset:2px}
.actions {border-top:1px solid #e2e8f0;padding-top:16px;align-items:center}.actions button {min-height:40px;background:#4f46e5;border-color:#4f46e5;font-weight:600;border-radius:12px}.actions button[type=button] {background:#fff;border-color:#dbe3ec;color:#475569}
.recovery-settings {border-color:#e2e8f0;border-radius:14px;padding:14px 16px}.recovery-settings summary {font-size:13px}
.model-settings {gap:12px;font-size:13px} label {margin-top:6px}input,select {background:var(--app-surface, #fff);border-color:var(--app-border, #dbe3ec);padding:11px 12px}
input:focus,select:focus {outline:2px solid var(--app-primary-border, #a5b4fc);outline-offset:2px}
.actions {border-top:1px solid var(--app-border, #e2e8f0);padding-top:16px;align-items:center}.actions button {min-height:40px;background:#4f46e5;border-color:#4f46e5;font-weight:600;border-radius:12px}.actions button[type=button] {background:var(--app-surface, #fff);border-color:var(--app-border, #dbe3ec);color:var(--app-text-secondary, #475569)}
.recovery-settings {border-color:var(--app-border, #e2e8f0);border-radius:14px;padding:14px 16px}.recovery-settings summary {font-size:13px}
`]
})
export class ModelSettingsComponent implements OnInit {
@@ -23,14 +23,14 @@
box-sizing: border-box;
display: inline-flex;
align-items: center;
background: rgba(255, 255, 255, 0.72);
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.72);
backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(16px) saturate(180%);
border: 1px solid rgba(226, 232, 240, 0.85);
border: 1px solid rgb(var(--app-border-rgb, 226 232 240) / 0.85);
border-radius: 18px;
padding: 3px;
gap: 3px;
box-shadow: 0 2px 8px -1px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(0, 0, 0, 0.02), inset 0 1px 1px rgba(255, 255, 255, 0.9);
box-shadow: 0 2px 8px -1px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(0, 0, 0, 0.02), inset 0 1px 1px rgb(var(--app-highlight-rgb, 255 255 255) / 0.9);
user-select: none;
font-family: 'Google Sans', 'Google Sans Text', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
transition: background 0.25s cubic-bezier(0.4, 0, 0.2, 1),
@@ -38,15 +38,15 @@
border-color 0.25s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
background: rgba(255, 255, 255, 0.85);
box-shadow: 0 4px 14px -2px rgba(15, 23, 42, 0.1), 0 2px 4px rgba(0, 0, 0, 0.03), inset 0 1px 1px rgba(255, 255, 255, 1);
border-color: rgba(203, 213, 225, 0.9);
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.85);
box-shadow: 0 4px 14px -2px rgba(15, 23, 42, 0.1), 0 2px 4px rgba(0, 0, 0, 0.03), inset 0 1px 1px rgb(var(--app-highlight-rgb, 255 255 255) / 1);
border-color: rgb(var(--app-border-control-rgb, 203 213 225) / 0.9);
}
.app-version {
padding: 0 10px 0 8px;
border-left: 1px solid #e2e8f0;
color: #64748b;
border-left: 1px solid var(--app-border, #e2e8f0);
color: var(--app-text-muted, #64748b);
font-size: 11px;
font-weight: 500;
line-height: 18px;
@@ -65,7 +65,7 @@
border-radius: 15px;
font-size: 13px;
font-weight: 600;
color: #475569;
color: var(--app-text-secondary, #475569);
text-decoration: none;
cursor: pointer;
line-height: 1;
@@ -83,24 +83,24 @@
display: inline-flex;
align-items: center;
justify-content: center;
color: #64748b;
color: var(--app-text-muted, #64748b);
transition: color 0.15s ease, transform 0.15s ease;
}
.tab-label {
color: #475569;
color: var(--app-text-secondary, #475569);
transition: color 0.15s ease;
}
&:hover:not(.active) {
background: rgba(255, 255, 255, 0.7);
color: #0f172a;
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.7);
color: var(--app-text-strong, #0f172a);
.tab-icon {
color: #1a73e8;
color: var(--app-primary-text, #1a73e8);
}
.tab-label {
color: #0f172a;
color: var(--app-text-strong, #0f172a);
}
}
@@ -110,7 +110,7 @@
-webkit-backdrop-filter: blur(12px);
color: #ffffff !important;
font-weight: 700;
box-shadow: 0 2px 8px rgba(26, 115, 232, 0.35), inset 0 1px 1px rgba(255, 255, 255, 0.35);
box-shadow: 0 2px 8px rgba(26, 115, 232, 0.35), inset 0 1px 1px rgb(var(--app-highlight-rgb, 255 255 255) / 0.35);
.tab-icon,
.tab-label,
@@ -124,3 +124,40 @@
}
}
}
.theme-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 30px;
width: 30px;
height: 30px;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--app-text-secondary, #475569);
cursor: pointer;
.material-symbols-outlined { font-size: 16px; }
&:hover {
background: var(--app-surface-raised, #eef2f7);
color: var(--app-primary-text, #1a73e8);
}
}
.floating-nav-switcher .nav-tab-btn:focus-visible,
.theme-toggle:focus-visible {
outline: 2px solid var(--app-primary-text, #1a73e8);
outline-offset: 2px;
}
@media (max-width: 600px) {
.floating-nav-switcher {
left: 12px;
max-width: calc(100vw - 24px);
.nav-tab-btn { padding: 0 10px; }
.nav-tab-btn .tab-label { display: none; }
.app-version { padding: 0 6px; }
}
}
@@ -14,10 +14,11 @@
* limitations under the License.
*/
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { version } from '../../../../package.json';
import { ThemeService } from '../../services/theme.service';
@Component({
selector: 'app-nav-switcher',
@@ -30,6 +31,7 @@ import { version } from '../../../../package.json';
routerLinkActive="active"
[routerLinkActiveOptions]="{exact: true}"
class="nav-tab-btn"
aria-label="新任务 / 首页"
title="返回首页开始新任务"
>
<span class="material-symbols-outlined tab-icon">add_task</span>
@@ -39,15 +41,22 @@ import { version } from '../../../../package.json';
routerLink="/workspace"
routerLinkActive="active"
class="nav-tab-btn"
aria-label="工作区"
title="打开工作区"
>
<span class="material-symbols-outlined tab-icon">space_dashboard</span>
<span class="tab-label">工作区</span>
</a>
<a routerLink="/plugins" routerLinkActive="active" class="nav-tab-btn" title="管理扩展插件">
<a routerLink="/plugins" routerLinkActive="active" class="nav-tab-btn" aria-label="插件" title="管理扩展插件">
<span class="material-symbols-outlined tab-icon">extension</span><span class="tab-label">插件</span>
</a>
<span class="app-version" [title]="'ARTEMIS 版本 ' + version" [attr.aria-label]="'ARTEMIS 版本 ' + version">v{{version}}</span>
<button type="button" class="theme-toggle"
[attr.aria-label]="theme.isDark() ? '切换为浅色模式' : '切换为暗色模式'"
[title]="theme.isDark() ? '切换为浅色模式' : '切换为暗色模式'"
(click)="theme.toggle()">
<span class="material-symbols-outlined" aria-hidden="true">{{ theme.isDark() ? 'light_mode' : 'dark_mode' }}</span>
</button>
</nav>
`,
changeDetection: ChangeDetectionStrategy.Eager,
@@ -55,4 +64,5 @@ import { version } from '../../../../package.json';
})
export class NavSwitcherComponent {
readonly version = version;
readonly theme = inject(ThemeService);
}
@@ -0,0 +1,68 @@
import { TestBed } from '@angular/core/testing';
import { PluginToolResultComponent } from './plugin-tool-result.component';
import { PluginToolView } from '../../utils/plugin-tool-view.util';
describe('readable plugin receipts', () => {
const makeView = (result: string): PluginToolView => ({
title: '观察电脑窗口', kind: '辅助观察', auxiliary: true, status: '已执行', args: [],
result, rawResult: JSON.stringify({ result, call_id: 'original-call' }, null, 2),
facts: [{ key: '窗口', value: 'Demo' }, { key: '截图', value: '1280 × 720' }]
});
const mount = async (view: PluginToolView) => {
await TestBed.configureTestingModule({ imports: [PluginToolResultComponent] }).compileComponents();
const fixture = TestBed.createComponent(PluginToolResultComponent);
fixture.componentRef.setInput('view', view);
fixture.detectChanges();
return fixture;
};
it('keeps a long control tree and the original payload folded and fully accessible', async () => {
const fullText = Array.from({ length: 200 }, (_, i) => `- [${i}] Button "Item ${i}"`).join('\n');
const view = makeView(fullText);
const fixture = await mount(view);
const root: HTMLElement = fixture.nativeElement;
expect(root.querySelector('dl')!.textContent).toContain('1280 × 720');
expect(root.querySelector('.receipt-preview')!.textContent!.length).toBeLessThan(1100);
const textDetails = root.querySelector<HTMLDetailsElement>('.receipt-text')!;
const rawDetails = root.querySelector<HTMLDetailsElement>('.receipt-raw')!;
expect(textDetails.open).toBeFalse();
expect(rawDetails.open).toBeFalse();
textDetails.querySelector('summary')!.click();
expect(textDetails.open).toBeTrue();
expect(textDetails.querySelector('pre')!.textContent).toBe(fullText);
expect(rawDetails.querySelector('pre')!.textContent).toBe(view.rawResult);
});
it('renders untrusted tool text as text and keeps short results readable', async () => {
const text = '<img src=x onerror="alert(1)">\n已观察,未验证业务结果';
const fixture = await mount(makeView(text));
const root: HTMLElement = fixture.nativeElement;
expect(root.querySelector('.receipt-preview')!.textContent).toBe(text);
expect(root.querySelector('img')).toBeNull();
expect(root.querySelector('.receipt-text')).toBeNull();
});
it('reflects a new receipt without resetting an open raw payload', async () => {
const fixture = await mount(makeView('执行中'));
const raw = fixture.nativeElement.querySelector('.receipt-raw') as HTMLDetailsElement;
raw.querySelector('summary')!.click();
fixture.componentRef.setInput('view', makeView('已返回新的窗口观察'));
fixture.detectChanges();
expect(raw.open).toBeTrue();
expect(fixture.nativeElement.querySelector('.receipt-preview').textContent).toBe('已返回新的窗口观察');
expect(raw.querySelector('pre')!.textContent).toContain('已返回新的窗口观察');
});
it('keeps long window labels and unbroken receipt text inside a narrow container', async () => {
const view = makeView('long-unbroken-result'.repeat(200));
view.facts[0].value = 'VeryLongWindowTitle'.repeat(30);
const fixture = await mount(view);
const root = fixture.nativeElement as HTMLElement;
root.style.width = '240px';
fixture.detectChanges();
expect(root.clientWidth).toBe(240);
expect(root.scrollWidth).toBeLessThanOrEqual(root.clientWidth);
const label = root.querySelector('dd')!;
expect(label.scrollWidth).toBeLessThanOrEqual(label.clientWidth);
});
});
@@ -0,0 +1,56 @@
import { Component, computed, input } from '@angular/core';
import { PluginToolView } from '../../utils/plugin-tool-view.util';
/** Readable presentation only: the full receipt and model context stay intact. */
@Component({
selector: 'app-plugin-tool-result',
standalone: true,
template: `
@if (view().facts.length) {
<dl class="receipt-facts">
@for (fact of view().facts; track fact.key) {
<div><dt>{{ fact.key }}</dt><dd>{{ fact.value }}</dd></div>
}
</dl>
}
@if (view().result) {
<pre class="receipt-preview">{{ preview() }}</pre>
@if (isLong()) {
<details class="receipt-detail receipt-text">
<summary>完整文本与控件树 <span>{{ view().result.length }} 字符</span></summary>
<pre>{{ view().result }}</pre>
</details>
}
}
@if (view().rawResult) {
<details class="receipt-detail receipt-raw">
<summary>原始返回 payload <span>完整保留</span></summary>
<pre>{{ view().rawResult }}</pre>
</details>
}
`,
styles: [`
:host { display: block; min-width: 0; width: 100%; user-select: text; cursor: auto; }
.receipt-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(140px, 100%), 1fr)); gap: 8px 16px; margin: 0 0 12px; }
.receipt-facts div { min-width: 0; }
dt { font-size: 11px; color: var(--app-text-muted, #64748b); }
dd { margin: 3px 0 0; font-size: 12px; color: var(--app-text-control, #334155); overflow-wrap: anywhere; }
pre { margin: 0; padding: 10px 12px; border-radius: 6px; font: 12px/1.6 Consolas, monospace; color: var(--app-text-control, #334155); background: var(--app-surface-soft, #f8fafc); white-space: pre-wrap; overflow-wrap: anywhere; }
.receipt-preview { max-height: 180px; overflow: auto; }
.receipt-detail { margin-top: 10px; border: 1px solid var(--app-border, #e2e8f0); border-radius: 6px; min-width: 0; }
summary { padding: 9px 10px; font-size: 12px; color: var(--app-text-secondary, #475569); cursor: pointer; overflow-wrap: anywhere; }
summary span { margin-left: 8px; font-size: 11px; color: var(--app-text-muted, #64748b); }
summary:focus-visible { outline: 2px solid var(--app-primary-text, #1a73e8); outline-offset: 2px; }
.receipt-detail pre { max-height: 400px; overflow: auto; border-top: 1px solid var(--app-border, #e2e8f0); border-radius: 0 0 6px 6px; }
`]
})
export class PluginToolResultComponent {
view = input.required<PluginToolView>();
private readonly previewLimit = 1000;
preview = computed(() => {
const text = this.view().result;
const excerpt = text.split('\n').slice(0, 7).join('\n').slice(0, this.previewLimit);
return excerpt.length < text.length ? `${excerpt}\n…` : text;
});
isLong = computed(() => this.preview() !== this.view().result);
}
@@ -4,7 +4,7 @@ import { ReportMarkdownComponent } from './report-markdown.component';
describe('报告 Markdown 展示', () => {
it('renders report headings, emphasis, lists, tables and code in both preview and full view', () => {
const fixture = TestBed.createComponent(ReportMarkdownComponent);
fixture.componentRef.setInput('content', '### 双端布局报告\n- **手机**:`/zh`\n\n| 区域 | 结论 |\n| --- | --- |\n| 页底 | 已确认 |\n\n```text\n<原始证据>\n```');
fixture.componentRef.setInput('content', '### 双端布局报告\n- **手机**:`/zh`\n- [x] 已检查\n- [/] 进行中\n\n| 区域 | 结论 |\n| --- | ---: |\n| 页底 | 已确认 |\n\n```text\n<原始证据>\n```');
fixture.componentRef.setInput('compact', true);
fixture.detectChanges();
const view: HTMLElement = fixture.nativeElement;
@@ -12,6 +12,9 @@ describe('报告 Markdown 展示', () => {
expect(view.querySelector('li strong')?.textContent).toBe('手机');
expect(view.querySelector('li code')?.textContent).toBe('/zh');
expect(view.querySelector('td')?.textContent).toBe('页底');
expect(view.querySelector('td.md-align-right')?.textContent).toBe('已确认');
expect(view.querySelector('[aria-label="已完成"]')).not.toBeNull();
expect(view.querySelector('[aria-label="进行中"]')).not.toBeNull();
expect(view.querySelector('pre code')?.textContent).toBe('<原始证据>');
expect(view.querySelector('.compact')).not.toBeNull();
fixture.componentRef.setInput('compact', false);
@@ -1,7 +1,7 @@
import { Component, computed, input } from '@angular/core';
import { renderMarkdownToHtml } from '../../utils/markdown-parser.util';
/** Shared, sanitized presentation for report prose (not the editable task plan). */
/** Shared presentation for notes, plans and reports; Angular sanitizes the HTML. */
@Component({
selector: 'app-report-markdown',
standalone: true,
@@ -14,21 +14,21 @@ import { renderMarkdownToHtml } from '../../utils/markdown-parser.util';
> div, p { margin: 4px 0; }
> :first-child { margin-top: 0; }
> :last-child { margin-bottom: 0; }
h1,h2,h3,h4,h5,h6 { margin: 16px 0 6px; font-weight: 600; line-height: 1.5; color: #0f172a; }
h1,h2,h3,h4,h5,h6 { margin: 16px 0 6px; font-weight: 600; line-height: 1.5; color: var(--app-text-strong, #0f172a); }
h1 { font-size: 18px; } h2 { font-size: 16px; } h3,h4,h5,h6 { font-size: 14px; }
strong { font-weight: 600; }
ul,ol { margin: 6px 0; padding-left: 24px; }
li { margin: 4px 0; }
blockquote { margin: 10px 0; padding: 6px 12px; border-left: 3px solid #93c5fd; background: #f8fafc; }
code { font-family: Consolas, monospace; font-size: .92em; border-radius: 4px; padding: 1px 4px; background: #f1f5f9; }
pre { max-width: 100%; overflow: auto; padding: 10px 12px; border-radius: 6px; background: #f1f5f9; white-space: pre; }
blockquote { margin: 10px 0; padding: 6px 12px; border-left: 3px solid var(--app-primary-border, #93c5fd); background: var(--app-surface-soft, #f8fafc); }
code { font-family: Consolas, monospace; font-size: .92em; border-radius: 4px; padding: 1px 4px; background: var(--app-surface-soft, #f1f5f9); }
pre { max-width: 100%; overflow: auto; padding: 10px 12px; border-radius: 6px; background: var(--app-surface-soft, #f1f5f9); white-space: pre; }
pre code { padding: 0; background: none; }
.md-table-scroll { max-width: 100%; overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th,td { border: 1px solid #cbd5e1; padding: 7px 9px; text-align: left; vertical-align: top; }
th { background: #eff6ff; font-weight: 600; }
th,td { border: 1px solid var(--app-border-control, #cbd5e1); padding: 7px 9px; text-align: left; vertical-align: top; }
th { background: var(--app-primary-soft, #eff6ff); font-weight: 600; }
.md-para-gap { height: 4px; margin: 0; }
hr { border: 0; border-top: 1px solid #e2e8f0; margin: 12px 0; }
hr { border: 0; border-top: 1px solid var(--app-border, #e2e8f0); margin: 12px 0; }
}
`]
})
@@ -12,13 +12,13 @@ import { Component, computed, input, output } from '@angular/core';
@if(hint()){<p>{{hint()}}</p>}
`,
styles: [`
:host{display:block;min-width:0;padding:14px 16px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:14px;font-size:13px;color:#334155}
:host{display:block;min-width:0;padding:14px 16px;background:var(--app-surface-soft, #f8fafc);border:1px solid var(--app-border, #e2e8f0);border-radius:14px;font-size:13px;color:var(--app-text-control, #334155)}
.slider-heading,.slider-ends{display:flex;align-items:center;justify-content:space-between;gap:10px}
.slider-heading{font-weight:600}output{color:#4f46e5;font-size:12px;white-space:nowrap}.slider-ends{font-size:11px;color:#64748b}
input{appearance:none;display:block;box-sizing:border-box;width:100%;height:6px;border:0;border-radius:10px;margin:20px 0 12px;background:linear-gradient(to right,#6366f1 var(--fill),#dbe3ee var(--fill));cursor:pointer}
input::-webkit-slider-thumb{appearance:none;width:18px;height:18px;border-radius:50%;background:#fff;border:2px solid #6366f1;box-shadow:0 2px 6px #33415526}
input::-moz-range-thumb{width:14px;height:14px;border-radius:50%;background:#fff;border:2px solid #6366f1}
input:focus-visible{outline:2px solid #a5b4fc;outline-offset:5px}input:disabled{opacity:.5;cursor:default}p{margin:10px 0 0;font-size:12px;line-height:1.6;color:#64748b}
.slider-heading{font-weight:600}output{color:var(--app-exploration-text, #4f46e5);font-size:12px;white-space:nowrap}.slider-ends{font-size:11px;color:var(--app-text-muted, #64748b)}
input{appearance:none;display:block;box-sizing:border-box;width:100%;height:6px;border:0;border-radius:10px;margin:20px 0 12px;background:linear-gradient(to right,#6366f1 var(--fill),var(--app-primary-soft, #dbe3ee) var(--fill));cursor:pointer}
input::-webkit-slider-thumb{appearance:none;width:18px;height:18px;border-radius:50%;background:var(--app-surface, #fff);border:2px solid #6366f1;box-shadow:0 2px 6px #33415526}
input::-moz-range-thumb{width:14px;height:14px;border-radius:50%;background:var(--app-surface, #fff);border:2px solid #6366f1}
input:focus-visible{outline:2px solid var(--app-primary-border, #a5b4fc);outline-offset:5px}input:disabled{opacity:.5;cursor:default}p{margin:10px 0 0;font-size:12px;line-height:1.6;color:var(--app-text-muted, #64748b)}
`]
})
export class SettingSliderComponent {
@@ -12,7 +12,7 @@ import { AgentService } from '../../services/agent.service';
@if(agent.batchFilter() && !['__batch__','__single__','__exploration__'].includes(agent.batchFilter()) && !hasSelectedBatch()){<option [value]="agent.batchFilter()">当前选定批次</option>}
</select></label>@if(agent.batchFilter()){<button type="button" (click)="agent.batchFilter.set('')">清除筛选</button>}</div>
@if(error()){<div class="filter-error" role="alert">{{error()}} <button type="button" (click)="load()">重试</button></div>}`,
styles: [`:host{display:block;margin:0 0 12px}.batch-filter{display:flex;align-items:flex-end;gap:8px;color:#475569;font-size:12px}label{flex:1;min-width:0}select{display:block;box-sizing:border-box;width:100%;height:34px;margin-top:5px;border:1px solid #cbd5e1;border-radius:7px;padding:0 9px;background:#fff;color:#1e293b;font:inherit}option{background:#fff;color:#1e293b}button{height:34px;white-space:nowrap;border:1px solid #cbd5e1;border-radius:7px;padding:0 9px;background:#fff;color:#475569;font:inherit;cursor:pointer}select:focus-visible,button:focus-visible{outline:2px solid #3b82f6;outline-offset:2px}.filter-error{font-size:12px;color:#b91c1c;margin-top:5px}.filter-error button{height:auto;padding:2px 6px}`]
styles: [`:host{display:block;margin:0 0 12px}.batch-filter{display:flex;align-items:flex-end;gap:8px;color:var(--app-text-secondary, #475569);font-size:12px}label{flex:1;min-width:0}select{display:block;box-sizing:border-box;width:100%;height:34px;margin-top:5px;border:1px solid var(--app-border-control, #cbd5e1);border-radius:7px;padding:0 9px;background:var(--app-surface, #fff);color:var(--app-text-control, #1e293b);font:inherit}option{background:var(--app-surface, #fff);color:var(--app-text-control, #1e293b)}button{height:34px;white-space:nowrap;border:1px solid var(--app-border-control, #cbd5e1);border-radius:7px;padding:0 9px;background:var(--app-surface, #fff);color:var(--app-text-secondary, #475569);font:inherit;cursor:pointer}select:focus-visible,button:focus-visible{outline:2px solid #3b82f6;outline-offset:2px}.filter-error{font-size:12px;color:var(--app-error-text, #b91c1c);margin-top:5px}.filter-error button{height:auto;padding:2px 6px}`]
})
export class TaskBatchFilterComponent {
agent = inject(AgentService); private http = inject(HttpClient);
@@ -25,9 +25,9 @@ import { AgentService } from '../../services/agent.service';
h3{display:flex;align-items:center;gap:6px;font-size:14px;margin:14px 0 8px}
.query-status{margin-left:auto;font-size:11px;font-weight:400;white-space:nowrap}
small{opacity:.6}
input,select,button{font:inherit;color:inherit;border:1px solid #64748b55;border-radius:6px;padding:8px;background:transparent;box-sizing:border-box}
input,select,button{font:inherit;color:inherit;border:1px solid rgb(var(--app-border-control-rgb, 100 116 139) / 0.3333);border-radius:6px;padding:8px;background:transparent;box-sizing:border-box}
input{width:100%}
select option{background:white;color:#1e293b}
select option{background:var(--app-surface, white);color:var(--app-text-control, #1e293b)}
.filters{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}
.filters label{flex:1;min-width:110px}
.filters input,.filters select{display:block;width:100%}
@@ -35,26 +35,26 @@ import { AgentService } from '../../services/agent.service';
.row{display:flex;flex-direction:column;gap:6px;width:100%;text-align:left;cursor:pointer;margin:5px 0;overflow-wrap:anywhere}
.row strong{font-weight:600;display:flex;align-items:center;min-height:28px;padding-left:33px}
.row.selected{border-color:#60a5fa;background:#3b82f615}
.history-item{position:relative;border:1px solid #cbd5e1;border-left:3px solid #94a3b8;border-radius:8px;margin:6px 0;padding:8px;background:#fff}
.history-item{position:relative;border:1px solid var(--app-border-control, #cbd5e1);border-left:3px solid var(--app-border-control, #94a3b8);border-radius:8px;margin:6px 0;padding:8px;background:var(--app-surface, #fff)}
.history-item.status-completed,.history-item.status-success{border-left-color:#16a34a}
.history-item.status-failed{border-left-color:#dc2626;background:#fffafa}
.history-item.status-cancelled,.history-item.status-interrupted{border-left-color:#d97706;background:#fffdf8}
.history-item.status-skipped{border-left-color:#64748b}
.history-item:has(.row.selected){border-color:#60a5fa;background:#eff6ff}
.history-item.status-failed{border-left-color:#dc2626;background:var(--app-error-soft, #fffafa)}
.history-item.status-cancelled,.history-item.status-interrupted{border-left-color:#d97706;background:var(--app-warning-soft, #fffdf8)}
.history-item.status-skipped{border-left-color:var(--app-border-control, #64748b)}
.history-item:has(.row.selected){border-color:#60a5fa;background:var(--app-primary-soft, #eff6ff)}
.history-item .row{border:0;padding:0 35px 0 0;margin:0 0 4px}
.history-item .row.selected{background:transparent}
.history-heading{position:relative;min-width:0}
.history-heading>app-favorite-button{position:absolute;left:0;top:0;z-index:1}
.history-heading>.row{min-width:0;width:100%}
.history-meta{display:flex;gap:5px;align-items:center;flex-wrap:wrap;color:#475569;font-size:11px}
.history-status{display:inline-flex;align-items:center;box-sizing:border-box;height:20px;line-height:14px;border-radius:5px;padding:0 6px;background:#f1f5f9;color:#334155}
.status-completed .history-status,.status-success .history-status{background:#dcfce7;color:#166534}
.status-failed .history-status{background:#fee2e2;color:#991b1b}
.status-cancelled .history-status,.status-interrupted .history-status{background:#fef3c7;color:#92400e}
.history-error{font-size:11px;color:#b91c1c;line-height:1.4}
.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:#64748b;cursor:pointer;font-size:12px}
.history-meta{display:flex;gap:5px;align-items:center;flex-wrap:wrap;color:var(--app-text-secondary, #475569);font-size:11px}
.history-status{display:inline-flex;align-items:center;box-sizing:border-box;height:20px;line-height:14px;border-radius:5px;padding:0 6px;background:var(--app-surface-soft, #f1f5f9);color:var(--app-text-control, #334155)}
.status-completed .history-status,.status-success .history-status{background:var(--app-success-soft, #dcfce7);color:var(--app-success-text, #166534)}
.status-failed .history-status{background:var(--app-error-soft, #fee2e2);color:var(--app-error-text, #991b1b)}
.status-cancelled .history-status,.status-interrupted .history-status{background:var(--app-warning-soft, #fef3c7);color:var(--app-warning-text, #92400e)}
.history-error{font-size:11px;color:var(--app-error-text, #b91c1c);line-height:1.4}
.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:var(--app-text-muted, #64748b);cursor:pointer;font-size:12px}
.delete-history .material-symbols-outlined{font-size:16px}
.delete-history:hover{color:#b91c1c}
.delete-history:hover{color:var(--app-error-text, #b91c1c)}
.pager{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0}
button:disabled{opacity:.4}
p{opacity:.7}
@@ -19,7 +19,7 @@ import { Component, DestroyRef, Input, inject, signal } from '@angular/core';
padding: 3px 0; cursor: pointer; user-select: text; border-radius: 3px; }
.value { min-width: 0; overflow-wrap: anywhere; user-select: text; }
.material-symbols-outlined { flex-shrink: 0; font-size: 14px; user-select: none; }
.copy-target:hover { color: #2563eb; }
.copy-target:hover { color: var(--app-primary-text, #2563eb); }
.copy-target:focus-visible { outline: 2px solid #60a5fa; outline-offset: 2px; }
.feedback { display: block; line-height: 1.6; user-select: text; }
.feedback:empty { display: none; }
@@ -0,0 +1,33 @@
import { TestBed } from '@angular/core/testing';
import { TaskLifecycleComponent, durationText } from './task-lifecycle.component';
describe('TaskLifecycleComponent', () => {
it('distinguishes missing time, zero and longer durations', () => {
expect(durationText(null)).toBe('未记录');
expect(durationText(0)).toBe('0 秒');
expect(durationText(125.7)).toBe('2 分 5 秒');
});
it('shows final review separately and never turns execution completion into a passed verdict', () => {
const fixture = TestBed.createComponent(TaskLifecycleComponent);
fixture.componentRef.setInput('value', {
schema_version:1, status:'completed', phase:'finished', observed_at:100,
durations:{queued:60, resource_wait:5, execution:100, final_review:20, paused:null}
});
fixture.detectChanges();
const text = fixture.nativeElement.textContent;
expect(text).toContain('执行已结束');
expect(text).toContain('1 分 0 秒');
expect(text).toContain('最终验收');
expect(text).toContain('20 秒');
expect(text).toContain('未记录');
expect(text).toContain('执行完成不等于验证通过');
expect(fixture.nativeElement.querySelector('app-test-result-badge')).toBeNull();
});
it('does not invent lifecycle data for an older backend', () => {
const fixture = TestBed.createComponent(TaskLifecycleComponent);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('details')).toBeNull();
});
});
@@ -0,0 +1,50 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { DatePipe } from '@angular/common';
import { TaskLifecycle } from '../../core/models/session.model';
export function durationText(value: number | null | undefined): string {
if (value == null || !Number.isFinite(value)) return '未记录';
const seconds = Math.max(0, Math.floor(value));
return seconds < 60 ? `${seconds} 秒` : `${Math.floor(seconds / 60)} 分 ${seconds % 60} 秒`;
}
@Component({
selector: 'app-task-lifecycle', standalone: true, imports: [DatePipe],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `@if (value(); as progress) {
<details class="lifecycle">
<summary>任务进度 · {{ phase(progress.phase) }}</summary>
<dl>
@for (item of items; track item.key) {
<div><dt>{{ item.label }}</dt><dd>{{ duration(progress.durations[item.key]) }}</dd></div>
}
</dl>
<p>{{ progress.note || '分项使用实际记录;缺失为未知。执行完成不等于验证通过。' }}</p>
@if (progress.observed_at != null) { <small>记录更新于 {{ progress.observed_at * 1000 | date:'HH:mm:ss' }}</small> }
</details>
}`,
styles: [`
:host { display: block; }
.lifecycle { margin: 8px 0; padding: 10px 12px; border: 1px solid var(--app-border-control, #cbd5e1); border-radius: 8px; color: var(--app-text-control, #1e293b); background: var(--app-surface, #fff); font-size: 12px; }
summary { cursor: pointer; font-weight: 600; }
dl { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 10px; margin: 12px 0; }
dt, p, small { color: var(--app-text-muted, #64748b); }
dd { margin: 4px 0 0; font-variant-numeric: tabular-nums; }
p { margin: 8px 0; line-height: 1.6; overflow-wrap: anywhere; }
`]
})
export class TaskLifecycleComponent {
value = input<TaskLifecycle | null>(null);
duration = durationText;
items = [
{key:'total', label:'总历时'}, {key:'queued', label:'后台排队'},
{key:'resource_wait', label:'等待设备/资源'}, {key:'paused', label:'暂停与示范'},
{key:'execution', label:'执行与整理'}, {key:'final_review', label:'最终验收'},
{key:'report_export', label:'报告导出'}
];
phase(value: string): string {
return ({queued:'后台排队', waiting_resources:'等待设备/资源', running:'执行中', paused:'已暂停',
demonstrating:'人工示范中', analyzing_demo:'正在分析示范', final_review:'正在最终验收',
report_export:'正在导出报告', finished:'执行已结束', unknown:'状态未记录'} as Record<string,string>)[value] || '状态待确认';
}
}
@@ -7,11 +7,11 @@ import { Session } from '../../core/models/session.model';
template: `<span class="mode-badge" [class]="'mode-badge ' + mode()" [title]="'执行模式:' + label()"><span class="material-symbols-outlined" aria-hidden="true">{{icon()}}</span>{{label()}}</span>`,
styles: [`
:host{display:inline-flex;vertical-align:middle}
.mode-badge{display:inline-flex;align-items:center;box-sizing:border-box;height:20px;gap:3px;padding:0 6px;border:1px solid #e2e8f0;border-radius:5px;background:#f8fafc;color:#475569;font-size:11px;font-weight:600;line-height:14px;white-space:nowrap}
.mode-badge{display:inline-flex;align-items:center;box-sizing:border-box;height:20px;gap:3px;padding:0 6px;border:1px solid var(--app-border, #e2e8f0);border-radius:5px;background:var(--app-surface-soft, #f8fafc);color:var(--app-text-secondary, #475569);font-size:11px;font-weight:600;line-height:14px;white-space:nowrap}
.material-symbols-outlined{font-size:13px;line-height:1}
.explore{background:#eef2ff;border-color:#c7d2fe;color:#4338ca}
.flash{background:#fff7ed;border-color:#fed7aa;color:#9a3412}
.pro{background:#eff6ff;border-color:#bfdbfe;color:#1d4ed8}
.explore{background:var(--app-primary-soft, #eef2ff);border-color:var(--app-primary-border, #c7d2fe);color:var(--app-exploration-text, #4338ca)}
.flash{background:var(--app-warning-soft, #fff7ed);border-color:var(--app-warning-border, #fed7aa);color:var(--app-warning-text, #9a3412)}
.pro{background:var(--app-primary-soft, #eff6ff);border-color:var(--app-primary-border, #bfdbfe);color:var(--app-primary-text, #1d4ed8)}
`]
})
export class TaskModeBadgeComponent {
@@ -1,20 +1,20 @@
:host { display: block; width: 100%; color: #475569; font-size: 12px; }
.plugin-toggle { display: inline-flex; align-items: center; gap: 6px; border: 1px solid #e2e8f0; border-radius: 20px; padding: 5px 10px; background: #f8fafc; color: #64748b; font: inherit; cursor: pointer; transition: background .15s, border-color .15s; }
.plugin-toggle:hover, .plugin-toggle[aria-expanded=true] { background: #eff6ff; border-color: #bfdbfe; color: #2563eb; }
:host { display: block; width: 100%; color: var(--app-text-secondary, #475569); font-size: 12px; }
.plugin-toggle { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--app-border, #e2e8f0); border-radius: 20px; padding: 5px 10px; background: var(--app-surface-soft, #f8fafc); color: var(--app-text-muted, #64748b); font: inherit; cursor: pointer; transition: background .15s, border-color .15s; }
.plugin-toggle:hover, .plugin-toggle[aria-expanded=true] { background: var(--app-primary-soft, #eff6ff); border-color: var(--app-primary-border, #bfdbfe); color: var(--app-primary-text, #2563eb); }
.material-symbols-outlined { font-size: 16px; }
.mode { color: #2563eb; padding-left: 6px; border-left: 1px solid #dce5ef; }
.mode { color: var(--app-primary-text, #2563eb); padding-left: 6px; border-left: 1px solid var(--app-primary-border, #dce5ef); }
.chevron { font-size: 15px; }
.plugin-options { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 14px; padding: 14px 16px; margin-top: 8px; display: grid; gap: 12px; }
.plugin-options { background: var(--app-surface-soft, #f8fafc); border: 1px solid var(--app-border, #e2e8f0); border-radius: 14px; padding: 14px 16px; margin-top: 8px; display: grid; gap: 12px; }
p { margin: 0; line-height: 1.65; }
label { display: flex; align-items: center; gap: 8px; }
.auto-option { font-weight: 500; color: #334155; }
input[type=checkbox] { accent-color: #2563eb; margin: 0; width: 14px; height: 14px; flex-shrink: 0; }
.auto-option { font-weight: 500; color: var(--app-text-control, #334155); }
input[type=checkbox] { accent-color: var(--app-primary-text, #2563eb); margin: 0; width: 14px; height: 14px; flex-shrink: 0; }
.choice { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
select { font: inherit; color: #334155; background: #fff; border: 1px solid #dce3eb; border-radius: 8px; padding: 6px 10px; max-width: 100%; }
select { font: inherit; color: var(--app-text-control, #334155); background: var(--app-surface, #fff); border: 1px solid var(--app-primary-border, #dce3eb); border-radius: 8px; padding: 6px 10px; max-width: 100%; }
select:disabled { opacity: .5; }
.hint { color: #64748b; font-size: 11px; }
.error { color: #b91c1c; }
.text-button, .manage-link { color: #2563eb; font: inherit; text-decoration: none; }
.hint { color: var(--app-text-muted, #64748b); font-size: 11px; }
.error { color: var(--app-error-text, #b91c1c); }
.text-button, .manage-link { color: var(--app-primary-text, #2563eb); font: inherit; text-decoration: none; }
.text-button { background: none; border: 0; padding: 0 6px; cursor: pointer; }
.manage-link { display: inline-flex; align-items: center; gap: 4px; justify-self: start; }
:focus-visible { outline: 2px solid #3b82f6; outline-offset: 3px; }
@@ -15,12 +15,12 @@ export function testResultSourceLabel(result: CaseTestResult | null | undefined)
template: `<span [class]="'test-result-badge ' + status()" [class.compact]="compact()" [class.prominent]="prominent()" [title]="sourceLabel() + ':' + (result()?.reason || '执行状态与测试结论分别记录')"><span class="material-symbols-outlined" aria-hidden="true">{{icon()}}</span>{{label()}}</span>`,
styles: [`
:host{display:inline-flex;vertical-align:middle;max-width:100%}
.test-result-badge{display:inline-flex;align-items:center;gap:4px;white-space:nowrap;border-radius:6px;border:1px solid #cbd5e1;padding:3px 8px;font-size:12px;font-weight:700;line-height:1.5;background:#f1f5f9;color:#475569}
.test-result-badge{display:inline-flex;align-items:center;gap:4px;white-space:nowrap;border-radius:6px;border:1px solid var(--app-border-control, #cbd5e1);padding:3px 8px;font-size:12px;font-weight:700;line-height:1.5;background:var(--app-surface-soft, #f1f5f9);color:var(--app-text-secondary, #475569)}
.material-symbols-outlined{font-size:16px;line-height:1}
.passed{background:#15803d;color:#fff;border-color:#15803d}
.failed{background:#b91c1c;color:#fff;border-color:#b91c1c}
.inconclusive{background:#fff7ed;color:#9a3412;border-color:#fdba74}
.blocked{background:#fef3c7;color:#92400e;border-color:#fbbf24}
.inconclusive{background:var(--app-warning-soft, #fff7ed);color:var(--app-warning-text, #9a3412);border-color:var(--app-warning-border, #fdba74)}
.blocked{background:var(--app-warning-soft, #fef3c7);color:var(--app-warning-text, #92400e);border-color:#fbbf24}
.compact{box-sizing:border-box;height:20px;padding:0 6px;font-size:11px;line-height:14px;gap:3px;border-radius:5px}
.compact .material-symbols-outlined{font-size:13px}
.prominent{padding:4px 9px;font-size:14px;gap:5px}
@@ -29,24 +29,24 @@ import { TestResultBadgeComponent, testResultSourceLabel } from './test-result-b
</section>`,
styles: [`
:host{display:block;margin:8px 0}
.test-result-card{border:1px solid #cbd5e1;border-left:3px solid #64748b;border-radius:10px;padding:12px 16px;background:#f8fafc;color:#334155}
.passed{border-color:#bbf7d0;border-left-color:#15803d;background:#f0fdf4}
.failed{border-color:#fecaca;border-left-color:#b91c1c;background:#fef2f2}
.inconclusive,.blocked{border-color:#fde68a;border-left-color:#b45309;background:#fffbeb}
.test-result-card{border:1px solid var(--app-border-control, #cbd5e1);border-left:3px solid var(--app-border-control, #64748b);border-radius:10px;padding:12px 16px;background:var(--app-surface-soft, #f8fafc);color:var(--app-text-control, #334155)}
.passed{border-color:var(--app-success-border, #bbf7d0);border-left-color:#15803d;background:var(--app-success-soft, #f0fdf4)}
.failed{border-color:var(--app-error-border, #fecaca);border-left-color:#b91c1c;background:var(--app-error-soft, #fef2f2)}
.inconclusive,.blocked{border-color:var(--app-warning-border, #fde68a);border-left-color:#b45309;background:var(--app-warning-soft, #fffbeb)}
header{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap}
.caption{font-size:12px;font-weight:600;color:#475569}
.caption small{font-size:11px;font-weight:400;margin-left:7px;color:#64748b}
.reason{font-size:13px;line-height:1.7;margin:8px 0 0;white-space:pre-wrap;overflow-wrap:anywhere;color:#1e293b}
details{margin-top:6px;font-size:12px;line-height:1.7;color:#475569}
.caption{font-size:12px;font-weight:600;color:var(--app-text-secondary, #475569)}
.caption small{font-size:11px;font-weight:400;margin-left:7px;color:var(--app-text-muted, #64748b)}
.reason{font-size:13px;line-height:1.7;margin:8px 0 0;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--app-text-control, #1e293b)}
details{margin-top:6px;font-size:12px;line-height:1.7;color:var(--app-text-secondary, #475569)}
summary{cursor:pointer;width:fit-content;padding:2px 0;font-weight:600}
ol{padding-left:22px;margin:8px 0 0}li{padding:3px 0;white-space:pre-wrap;overflow-wrap:anywhere}
.review-time{margin:5px 0 0;color:#64748b;font-size:11px}
.review-time{margin:5px 0 0;color:var(--app-text-muted, #64748b);font-size:11px}
.review-toolbar,.review-actions{display:flex;align-items:center;flex-wrap:wrap;gap:8px;margin-top:8px;font-size:12px}
button{border:1px solid #cbd5e1;border-radius:6px;padding:4px 9px;background:#fff;color:#475569;font:inherit;cursor:pointer;line-height:1.5}
button:hover{border-color:#94a3b8;background:#f8fafc}button:disabled{opacity:.55;cursor:wait}button:focus-visible{outline:2px solid #3b82f6;outline-offset:2px}
.review-toolbar span{color:#15803d}.review-form{border-top:1px solid #cbd5e1;margin-top:10px;padding-top:10px;font-size:12px}
label{display:block;color:#475569}textarea{display:block;box-sizing:border-box;width:100%;resize:vertical;border:1px solid #cbd5e1;border-radius:6px;margin-top:5px;padding:7px 9px;color:#1e293b;background:#fff;font:inherit;line-height:1.6}
.confirm-pass{color:#166534;border-color:#86efac;background:#f0fdf4}.confirm-fail{color:#991b1b;border-color:#fca5a5;background:#fef2f2}.review-error{color:#b91c1c;margin:8px 0 0}
button{border:1px solid var(--app-border-control, #cbd5e1);border-radius:6px;padding:4px 9px;background:var(--app-surface, #fff);color:var(--app-text-secondary, #475569);font:inherit;cursor:pointer;line-height:1.5}
button:hover{border-color:var(--app-border-control, #94a3b8);background:var(--app-surface-soft, #f8fafc)}button:disabled{opacity:.55;cursor:wait}button:focus-visible{outline:2px solid #3b82f6;outline-offset:2px}
.review-toolbar span{color:var(--app-success-text, #15803d)}.review-form{border-top:1px solid var(--app-border-control, #cbd5e1);margin-top:10px;padding-top:10px;font-size:12px}
label{display:block;color:var(--app-text-secondary, #475569)}textarea{display:block;box-sizing:border-box;width:100%;resize:vertical;border:1px solid var(--app-border-control, #cbd5e1);border-radius:6px;margin-top:5px;padding:7px 9px;color:var(--app-text-control, #1e293b);background:var(--app-surface, #fff);font:inherit;line-height:1.6}
.confirm-pass{color:var(--app-success-text, #166534);border-color:var(--app-success-border, #86efac);background:var(--app-success-soft, #f0fdf4)}.confirm-fail{color:var(--app-error-text, #991b1b);border-color:var(--app-error-border, #fca5a5);background:var(--app-error-soft, #fef2f2)}.review-error{color:var(--app-error-text, #b91c1c);margin:8px 0 0}
@media(max-width:480px){.test-result-card{padding:10px 12px}}
`]
})
@@ -21,6 +21,15 @@ export interface ModelInfo {
architecture?: string;
}
export interface TaskLifecycle {
schema_version: number;
status: string;
phase: string;
observed_at: number | null;
durations: Record<string, number | null>;
note?: string;
}
export interface CaseTestResult {
status: 'passed' | 'failed' | 'inconclusive' | 'blocked';
reason: string;
@@ -39,6 +48,7 @@ export interface CaseReviewResponse {
export interface TaskQueueItem {
session_id: string;
phase?: string | null;
batch_id?: string | null;
goal: string;
profile?: string;
@@ -51,6 +61,7 @@ export interface TaskQueueItem {
export interface Session {
session_id: string;
phase?: string | null;
profile?: string | null;
exploration_id?: string;
exploration_latest_run_id?: string;
File diff suppressed because it is too large. Load diff
@@ -15,8 +15,8 @@
*/
$bg-base: #e2e5ea;
$glass-bg: rgba(255, 255, 255, 0.45);
$glass-border: rgba(255, 255, 255, 0.6);
$glass-bg: rgb(var(--app-surface-rgb, 255 255 255) / 0.45);
$glass-border: rgb(var(--app-border-rgb, 255 255 255) / 0.6);
.workspace-container {
position: relative;
@@ -40,7 +40,7 @@ $glass-border: rgba(255, 255, 255, 0.6);
height: 100%;
z-index: 0;
overflow: hidden;
background: linear-gradient(135deg, #e2e5ea 0%, #cfd6df 100%);
background: linear-gradient(135deg, var(--app-primary-soft, #e2e5ea) 0%, var(--app-primary-soft, #cfd6df) 100%);
.glow {
position: absolute;
@@ -94,8 +94,8 @@ $glass-border: rgba(255, 255, 255, 0.6);
display: flex;
flex-direction: column;
overflow: hidden;
background: rgba(255, 255, 255, 0.35);
border-right: 1px solid rgba(255, 255, 255, 0.8);
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.35);
border-right: 1px solid rgb(var(--app-border-rgb, 255 255 255) / 0.8);
border-left: none;
border-top: none;
border-bottom: none;
@@ -106,7 +106,7 @@ $glass-border: rgba(255, 255, 255, 0.6);
flex-shrink: 0;
padding: 0;
border: none;
background-color: #ffffff;
background-color: var(--app-surface, #ffffff);
box-shadow: -6px 0 24px rgba(0, 0, 0, 0.03);
height: 100%;
display: flex;
@@ -126,7 +126,7 @@ $glass-border: rgba(255, 255, 255, 0.6);
margin: 0 -6px;
&:hover, .workspace-container.is-resizing & {
background: rgba(255, 255, 255, 0.15);
background: rgb(var(--app-surface-rgb, 255 255 255) / 0.15);
.resizer-handle {
background: rgba(94, 114, 228, 0.8);
@@ -1,30 +1,30 @@
:host { display: block; color: #1e293b; font-size: 14px; }
:host { display: block; color: var(--app-text-control, #1e293b); font-size: 14px; }
.plugins-page { max-width: 1120px; padding: 88px 32px 56px; margin: auto; }
header, .card-top, footer, .inline, .install-progress > div { display: flex; align-items: center; gap: 12px; }
header { justify-content: space-between; margin-bottom: 28px; }
h1 { margin: 8px 0; font-size: 30px; letter-spacing: -.6px; } h2 { margin: 0 0 6px; font-size: 17px; }
p { color: #64748b; line-height: 1.7; white-space: pre-wrap; margin: 8px 0 16px; }
.eyebrow { color: #3b6e9c; font-size: 11px; letter-spacing: 1.4px; font-weight: 600; }
button { border: 1px solid #dce3eb; border-radius: 20px; background: #fff; color: #334155; padding: 8px 16px; font-size: 13px; cursor: pointer; white-space: nowrap; }
button:hover:not(:disabled) { border-color: #9fb7d3; background: #f0f6ff; } button:disabled { opacity: .5; cursor: not-allowed; }
button.primary { color: #fff; background: #2563eb; border-color: #2563eb; } button.danger { color: #b91c1c; border-color: #fecaca; }
.text-button { border: 0; margin-left: auto; color: #64748b; } :focus-visible { outline: 2px solid #3b82f6; outline-offset: 3px; }
.tabs { display: flex; gap: 4px; width: fit-content; margin-bottom: 24px; background: #f1f5f9; padding: 4px; border-radius: 24px; }
.tabs button { border: 0; background: transparent; }.tabs button.active { color: #1d4ed8; background: #fff; box-shadow: 0 1px 4px #0f172a0d; font-weight: 600; }.tabs span { margin-left: 8px; }
p { color: var(--app-text-muted, #64748b); line-height: 1.7; white-space: pre-wrap; margin: 8px 0 16px; }
.eyebrow { color: var(--app-primary-text, #3b6e9c); font-size: 11px; letter-spacing: 1.4px; font-weight: 600; }
button { border: 1px solid var(--app-primary-border, #dce3eb); border-radius: 20px; background: var(--app-surface, #fff); color: var(--app-text-control, #334155); padding: 8px 16px; font-size: 13px; cursor: pointer; white-space: nowrap; }
button:hover:not(:disabled) { border-color: var(--app-primary-border, #9fb7d3); background: var(--app-primary-soft, #f0f6ff); } button:disabled { opacity: .5; cursor: not-allowed; }
button.primary { color: #fff; background: #2563eb; border-color: #2563eb; } button.danger { color: var(--app-error-text, #b91c1c); border-color: var(--app-error-border, #fecaca); }
.text-button { border: 0; margin-left: auto; color: var(--app-text-muted, #64748b); } :focus-visible { outline: 2px solid #3b82f6; outline-offset: 3px; }
.tabs { display: flex; gap: 4px; width: fit-content; margin-bottom: 24px; background: var(--app-surface-soft, #f1f5f9); padding: 4px; border-radius: 24px; }
.tabs button { border: 0; background: transparent; }.tabs button.active { color: var(--app-primary-text, #1d4ed8); background: var(--app-surface, #fff); box-shadow: 0 1px 4px #0f172a0d; font-weight: 600; }.tabs span { margin-left: 8px; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr)); gap: 18px; }
.card { padding: 22px; background: #fff; border: 1px solid #e1e7ef; border-radius: 14px; }.card > p { min-height: 48px; margin-top: 18px; }.card footer { margin-top: 20px; flex-wrap: wrap; }
.plugin-icon { display: grid; place-items: center; width: 44px; height: 44px; flex-shrink: 0; color: #4477ba; background: #eef4ff; border-radius: 12px; }
.meta, small { color: #64748b; font-size: 12px; line-height: 1.6; }.badge { margin-left: auto; white-space: nowrap; padding: 4px 9px; border-radius: 20px; background: #f1f5f9; font-size: 11px; color: #64748b; }.badge.enabled { background: #ecfdf5; color: #047857; }
.empty { padding: 55px 24px; text-align: center; border: 1px dashed #cbd5e1; border-radius: 14px; background: #ffffff80; margin-bottom: 24px; }.empty > span { font-size: 36px; color: #94a3b8; margin-bottom: 16px; }
.message { padding: 12px 16px; border-radius: 8px; margin-bottom: 18px; line-height: 1.6; }.error { color: #b91c1c; background: #fff1f2; }.success { color: #047857; background: #ecfdf5; }
.install-progress { background: #fff; border: 1px solid #dbeafe; padding: 18px; border-radius: 12px; margin-bottom: 20px; }.install-progress > div { justify-content: space-between; }progress { width: 100%; height: 8px; margin-top: 12px; accent-color: #2563eb; }
details { margin: 16px 0; }summary { cursor: pointer; color: #475569; padding: 8px 0; }.advanced { padding: 18px 22px; border: 1px solid #e2e8f0; border-radius: 12px; margin-top: 24px; }
label { display: flex; flex-direction: column; gap: 7px; margin: 16px 0; font-size: 13px; }input, select, textarea { border: 1px solid #d5dde7; background: #fff; border-radius: 8px; padding: 10px 12px; color: #1e293b; font-size: 13px; min-width: 0; width: 100%; }textarea { resize: vertical; font-family: ui-monospace, 'Microsoft YaHei', monospace; line-height: 1.6; }.inline > input, .inline > select { flex: 1; }
.modal-backdrop { position: fixed; inset: 0; background: #0f172a50; backdrop-filter: blur(3px); display: grid; place-items: center; z-index: 1300; padding: 24px; }.dialog { max-width: 620px; width: 100%; max-height: 90vh; overflow-y: auto; background: #fff; padding: 28px; border-radius: 16px; box-shadow: 0 24px 80px #0f172a30; }.dialog.small { max-width: 430px; }.dialog header { margin-bottom: 12px; }.dialog footer { justify-content: flex-end; margin-top: 24px; }.two-columns { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }.checkbox { flex-direction: row; align-items: center; gap: 10px; }.checkbox input { width: auto; }
.card { padding: 22px; background: var(--app-surface, #fff); border: 1px solid var(--app-primary-border, #e1e7ef); border-radius: 14px; }.card > p { min-height: 48px; margin-top: 18px; }.card footer { margin-top: 20px; flex-wrap: wrap; }
.plugin-icon { display: grid; place-items: center; width: 44px; height: 44px; flex-shrink: 0; color: var(--app-primary-text, #4477ba); background: var(--app-primary-soft, #eef4ff); border-radius: 12px; }
.meta, small { color: var(--app-text-muted, #64748b); font-size: 12px; line-height: 1.6; }.badge { margin-left: auto; white-space: nowrap; padding: 4px 9px; border-radius: 20px; background: var(--app-surface-soft, #f1f5f9); font-size: 11px; color: var(--app-text-muted, #64748b); }.badge.enabled { background: var(--app-success-soft, #ecfdf5); color: var(--app-success-text, #047857); }
.empty { padding: 55px 24px; text-align: center; border: 1px dashed var(--app-border-control, #cbd5e1); border-radius: 14px; background: rgb(var(--app-surface-rgb, 255 255 255) / 0.5020); margin-bottom: 24px; }.empty > span { font-size: 36px; color: var(--app-text-faint, #94a3b8); margin-bottom: 16px; }
.message { padding: 12px 16px; border-radius: 8px; margin-bottom: 18px; line-height: 1.6; }.error { color: var(--app-error-text, #b91c1c); background: var(--app-error-soft, #fff1f2); }.success { color: var(--app-success-text, #047857); background: var(--app-success-soft, #ecfdf5); }
.install-progress { background: var(--app-surface, #fff); border: 1px solid var(--app-primary-border, #dbeafe); padding: 18px; border-radius: 12px; margin-bottom: 20px; }.install-progress > div { justify-content: space-between; }progress { width: 100%; height: 8px; margin-top: 12px; accent-color: var(--app-primary-text, #2563eb); }
details { margin: 16px 0; }summary { cursor: pointer; color: var(--app-text-secondary, #475569); padding: 8px 0; }.advanced { padding: 18px 22px; border: 1px solid var(--app-border, #e2e8f0); border-radius: 12px; margin-top: 24px; }
label { display: flex; flex-direction: column; gap: 7px; margin: 16px 0; font-size: 13px; }input, select, textarea { border: 1px solid var(--app-primary-border, #d5dde7); background: var(--app-surface, #fff); border-radius: 8px; padding: 10px 12px; color: var(--app-text-control, #1e293b); font-size: 13px; min-width: 0; width: 100%; }textarea { resize: vertical; font-family: ui-monospace, 'Microsoft YaHei', monospace; line-height: 1.6; }.inline > input, .inline > select { flex: 1; }
.modal-backdrop { position: fixed; inset: 0; background: #0f172a50; backdrop-filter: blur(3px); display: grid; place-items: center; z-index: 1300; padding: 24px; }.dialog { max-width: 620px; width: 100%; max-height: 90vh; overflow-y: auto; background: var(--app-surface, #fff); padding: 28px; border-radius: 16px; box-shadow: 0 24px 80px #0f172a30; }.dialog.small { max-width: 430px; }.dialog header { margin-bottom: 12px; }.dialog footer { justify-content: flex-end; margin-top: 24px; }.two-columns { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }.checkbox { flex-direction: row; align-items: center; gap: 10px; }.checkbox input { width: auto; }
@media (max-width: 640px) { .plugins-page { padding: 80px 16px 32px; }.card { padding: 18px; }.dialog { padding: 20px; }.two-columns { grid-template-columns: 1fr; gap: 0; }.card-top { gap: 8px; }h2 { font-size: 15px; } }
.loading-icon { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .loading-icon { animation: none; } }
.badge.preview { background: #fffbeb; color: #92400e; }
.badge.preview { background: var(--app-warning-soft, #fffbeb); color: var(--app-warning-text, #92400e); }
@@ -1,16 +1,16 @@
:host {display:block;height:100vh;width:100%;overflow:hidden;color:#334155}
.workspace-container {display:flex;height:100%;background:#f8fafc;overflow:hidden}
.left-panel {flex:1;min-width:420px;display:flex;flex-direction:column;overflow:hidden;background:#f8fafc}
:host {display:block;height:100vh;width:100%;overflow:hidden;color:var(--app-text-control, #334155)}
.workspace-container {display:flex;height:100%;background:var(--app-surface-soft, #f8fafc);overflow:hidden}
.left-panel {flex:1;min-width:420px;display:flex;flex-direction:column;overflow:hidden;background:var(--app-surface-soft, #f8fafc)}
app-agent-stream,app-exploration-panel {flex:1;min-height:0}
.right-panel {min-width:280px;max-width:50%;display:flex;flex-direction:column;background:white;border-left:1px solid #e2e8f0}
.right-panel {min-width:280px;max-width:50%;display:flex;flex-direction:column;background:var(--app-surface, white);border-left:1px solid var(--app-border, #e2e8f0)}
.resizer {width:8px;margin:0 -4px;cursor:col-resize;z-index:5;display:flex;align-items:center;justify-content:center}
.resizer-handle {width:3px;height:32px;border-radius:4px;background:#cbd5e1}
.resizer-handle {width:3px;height:32px;border-radius:4px;background:var(--app-surface-raised, #cbd5e1)}
.is-resizing {cursor:col-resize;user-select:none}
.task-composer {flex:0 0 auto;margin:0 20px 16px;padding:12px 16px;background:#ffffffec;border:1px solid #dce5ee;border-radius:16px;box-shadow:0 6px 24px #0f172a08;font-size:13px}
.composer-heading {display:flex;gap:12px;align-items:center;margin-bottom:8px}.composer-heading strong{white-space:nowrap}.composer-heading span{overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:#64748b;font-size:12px}
textarea {box-sizing:border-box;width:100%;border:0;background:transparent;resize:vertical;min-height:44px;max-height:140px;line-height:1.6;color:#0f172a;font:inherit;outline:none}
textarea:focus-visible{box-shadow:0 2px #93c5fd}textarea:disabled{color:#94a3b8}
.composer-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:8px}.composer-actions small{flex:1;color:#64748b;min-width:180px}
button{font:inherit;border:1px solid #dbe3ec;border-radius:8px;padding:6px 12px;background:white;color:#475569;cursor:pointer}button.primary{background:#0f172a;color:white;border-color:#0f172a}button:disabled{opacity:.45;cursor:default}
.feedback{font-size:12px;margin:0 0 8px;color:#047857}.error{color:#b91c1c}.origin-note{margin:12px 0;padding:10px 12px;border:1px solid #dbe3ec;border-radius:10px;font-size:12px}.origin-note button{margin-left:10px}
.task-composer {flex:0 0 auto;margin:0 20px 16px;padding:12px 16px;background:rgb(var(--app-surface-rgb, 255 255 255) / 0.9255);border:1px solid var(--app-border, #dce5ee);border-radius:16px;box-shadow:0 6px 24px #0f172a08;font-size:13px}
.composer-heading {display:flex;gap:12px;align-items:center;margin-bottom:8px}.composer-heading strong{white-space:nowrap}.composer-heading span{overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:var(--app-text-muted, #64748b);font-size:12px}
textarea {box-sizing:border-box;width:100%;border:0;background:transparent;resize:vertical;min-height:44px;max-height:140px;line-height:1.6;color:var(--app-text-strong, #0f172a);font:inherit;outline:none}
textarea:focus-visible{box-shadow:0 2px #93c5fd}textarea:disabled{color:var(--app-text-faint, #94a3b8)}
.composer-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:8px}.composer-actions small{flex:1;color:var(--app-text-muted, #64748b);min-width:180px}
button{font:inherit;border:1px solid var(--app-border, #dbe3ec);border-radius:8px;padding:6px 12px;background:var(--app-surface, white);color:var(--app-text-secondary, #475569);cursor:pointer}button.primary{background:#0f172a;color:white;border-color:var(--app-border-control, #0f172a)}button:disabled{opacity:.45;cursor:default}
.feedback{font-size:12px;margin:0 0 8px;color:var(--app-success-text, #047857)}.error{color:var(--app-error-text, #b91c1c)}.origin-note{margin:12px 0;padding:10px 12px;border:1px solid var(--app-border, #dbe3ec);border-radius:10px;font-size:12px}.origin-note button{margin-left:10px}
@media(max-width:800px){.left-panel{min-width:320px}.right-panel{min-width:240px}.task-composer{margin:0 10px 10px;padding:10px}}
@@ -104,7 +104,7 @@ describe('AgentService browser connection lifecycle', () => {
}
const status = http.expectOne('/api/status');
const sessions = http.expectOne('/api/sessions');
const guidance = http.expectOne('/api/tasks/selected-task/guidance');
const guidance = http.expectOne('/api/tasks/selected-task/guidance?details=true');
tick(UI_READ_TIMEOUT_MS);
expect(status.cancelled).toBeTrue();
expect(sessions.cancelled).toBeTrue();
@@ -113,8 +113,41 @@ describe('AgentService browser connection lifecycle', () => {
service.fetchStatus();
service.fetchSessions();
service.fetchGuidance('selected-task');
http.expectOne('/api/tasks/selected-task/guidance').flush([]);
http.expectOne('/api/tasks/selected-task/guidance?details=true').flush([]);
flushSnapshots();
service.destroy();
}));
it('refreshes lifecycle with unchanged receipts and accepts the older guidance array', () => {
service = TestBed.inject(AgentService);
flushSnapshots();
service.currentSessionId.set('progress-task');
const instructions = [{id:'g1', instruction:'检查现有证据', status:'unapplied', timestamp:10, accepted_at:10}];
const lifecycle = {schema_version:1, status:'completed', phase:'finished', observed_at:30, durations:{execution:20}};
service.fetchGuidance('progress-task');
http.expectOne('/api/tasks/progress-task/guidance?details=true').flush({instructions, lifecycle});
expect(service.guidanceEvents()[0].status).toBe('unapplied');
expect(service.currentLifecycle()?.durations['execution']).toBe(20);
service.fetchGuidance('progress-task');
http.expectOne('/api/tasks/progress-task/guidance?details=true').flush({instructions, lifecycle:{...lifecycle, durations:{execution:25}}});
expect(service.currentLifecycle()?.durations['execution']).toBe(25);
service.fetchGuidance('progress-task');
http.expectOne('/api/tasks/progress-task/guidance?details=true').flush(instructions);
expect(service.currentLifecycle()).toBeNull();
expect(service.guidanceEvents().length).toBe(1);
});
it('ignores lifecycle and guidance returned after selecting another task', () => {
service = TestBed.inject(AgentService);
flushSnapshots();
service.currentSessionId.set('old-task');
service.fetchGuidance('old-task');
service.currentSessionId.set('new-task');
http.expectOne('/api/tasks/old-task/guidance?details=true').flush({
instructions:[{id:'old', status:'consumed'}],
lifecycle:{schema_version:1, status:'running', phase:'running', observed_at:30, durations:{execution:20}}
});
expect(service.currentLifecycle()).toBeNull();
expect(service.guidanceEvents()).toEqual([]);
});
});
@@ -20,7 +20,7 @@ import { Observable, finalize, tap, timeout } from 'rxjs';
import { UI_READ_TIMEOUT_MS } from '../core/http-timeouts';
import { SharedLiveStream } from './shared-live-stream';
import { Session, ModelInfo, TaskQueueItem, AgentStatusResponse, SessionUsage, CaseReviewResponse } from '../core/models/session.model';
import { Session, ModelInfo, TaskQueueItem, AgentStatusResponse, SessionUsage, CaseReviewResponse, TaskLifecycle } from '../core/models/session.model';
import { ProTuningDefaults, ProTuningOptions } from '../core/models/pro-tuning.model';
import { StepItemData, StepReplayFrame, LLMStreamResetEventData, StreamResetNotice, DEFAULT_STREAM_RESET_MESSAGE, PersistedCheckerStream, StreamSegment } from '../core/models/stream.model';
import { extractStepReplayFrames } from '../utils/action-formatter.util';
@@ -73,7 +73,8 @@ export interface GuidanceEvent {
instruction: string;
source: string;
timestamp: number;
status: 'pending' | 'consumed';
status: 'pending' | 'reading' | 'consumed' | 'unapplied' | 'unconfirmed';
accepted_at?: number;
consumed_at?: number;
}
@@ -191,6 +192,7 @@ export class AgentService {
}
const finalSession: Session = {
...s,
phase: isTerminal ? null : (activeMatch?.phase || pendingMatch?.phase || null),
batch_id: s.batch_id || activeMatch?.batch_id || pendingMatch?.batch_id || null,
status: sStatus,
device_serial: serial,
@@ -230,6 +232,7 @@ export class AgentService {
const newSession: Session = {
...this.explorationSessionFields(at),
session_id: sid,
phase: at.phase || null,
batch_id: at.batch_id || null,
display_name: at.display_name,
initial_goal: at.goal || goal || '',
@@ -278,6 +281,7 @@ export class AgentService {
public currentSessionId = signal<string | null>(null);
public sessionLogs = signal<any[]>([]); // Dynamic array of all raw events received
public guidanceEvents = signal<GuidanceEvent[]>([]);
public currentLifecycle = signal<TaskLifecycle | null>(null);
private guidanceSignature = '';
public isSessionContentLoading = signal<boolean>(false);
public agentStatus = signal<string>('idle'); // Status of the agent runner process
@@ -838,6 +842,7 @@ export class AgentService {
this.currentSessionId.set(null);
this.sessionLogs.set([]);
this.guidanceEvents.set([]);
this.currentLifecycle.set(null);
this.guidanceSignature = '';
this.currentNotes.set({});
return;
@@ -855,6 +860,7 @@ export class AgentService {
this.currentSessionId.set(sessionId);
this.sessionLogs.set([]); // Reset logs for new session selection
this.guidanceEvents.set([]);
this.currentLifecycle.set(null);
this.guidanceSignature = '';
this.fetchGuidance(sessionId);
this.activePauseCardKey = null;
@@ -1408,12 +1414,14 @@ export class AgentService {
public fetchGuidance(sessionId: string): void {
if (this.destroyed || this.currentSessionId() !== sessionId || this.guidanceRequestsPending.has(sessionId)) return;
this.guidanceRequestsPending.add(sessionId);
this.http.get<GuidanceEvent[]>(`/api/tasks/${encodeURIComponent(sessionId)}/guidance`).pipe(
this.http.get<GuidanceEvent[] | {instructions: GuidanceEvent[]; lifecycle: TaskLifecycle}>(`/api/tasks/${encodeURIComponent(sessionId)}/guidance?details=true`).pipe(
timeout(UI_READ_TIMEOUT_MS),
finalize(() => this.guidanceRequestsPending.delete(sessionId))
).subscribe({
next: (items) => {
next: (data) => {
if (this.currentSessionId() !== sessionId) return;
const items = Array.isArray(data) ? data : data.instructions;
this.currentLifecycle.set(Array.isArray(data) ? null : data.lifecycle);
const signature = JSON.stringify(items);
if (signature !== this.guidanceSignature) {
this.guidanceSignature = signature;
@@ -0,0 +1,65 @@
import { TestBed } from '@angular/core/testing';
import { THEME_STORAGE_KEY, ThemeService } from './theme.service';
describe('外观主题', () => {
let stored: string | null;
let systemDark: boolean;
let media: EventTarget;
let originalTheme: string | undefined;
let originalScheme: string;
beforeEach(() => {
originalTheme = document.documentElement.dataset['theme'];
originalScheme = document.documentElement.style.colorScheme;
stored = null;
systemDark = false;
media = new EventTarget();
Object.defineProperty(media, 'matches', { get: () => systemDark });
spyOn(window, 'matchMedia').and.returnValue(media as MediaQueryList);
spyOn(Storage.prototype, 'getItem').and.callFake(key => key === THEME_STORAGE_KEY ? stored : null);
spyOn(Storage.prototype, 'setItem').and.callFake((key, value) => { if (key === THEME_STORAGE_KEY) stored = value; });
});
afterEach(() => {
TestBed.resetTestingModule();
if (originalTheme) document.documentElement.dataset['theme'] = originalTheme;
else delete document.documentElement.dataset['theme'];
document.documentElement.style.colorScheme = originalScheme;
});
it('follows system changes until the user chooses an explicit theme', () => {
const theme = TestBed.inject(ThemeService);
expect(document.documentElement.dataset['theme']).toBe('light');
systemDark = true;
media.dispatchEvent(new Event('change'));
expect(document.documentElement.dataset['theme']).toBe('dark');
theme.toggle();
expect(stored).toBe('light');
media.dispatchEvent(new Event('change'));
expect(document.documentElement.dataset['theme']).toBe('light');
theme.toggle();
expect(document.documentElement.dataset['theme']).toBe('dark');
theme.setPreference('system');
expect(document.documentElement.dataset['theme']).toBe('dark');
});
it('remembers dark mode after reloading and accepts changes from another tab', () => {
TestBed.inject(ThemeService).setPreference('dark');
TestBed.resetTestingModule();
const reloaded = TestBed.inject(ThemeService);
expect(reloaded.preference()).toBe('dark');
expect(document.documentElement.style.colorScheme).toBe('dark');
window.dispatchEvent(new StorageEvent('storage', { key: THEME_STORAGE_KEY, newValue: 'light' }));
expect(reloaded.preference()).toBe('light');
expect(document.documentElement.dataset['theme']).toBe('light');
});
it('still switches when browser storage is blocked', () => {
(Storage.prototype.getItem as jasmine.Spy).and.throwError('blocked');
(Storage.prototype.setItem as jasmine.Spy).and.throwError('blocked');
const theme = TestBed.inject(ThemeService);
theme.toggle();
expect(document.documentElement.dataset['theme']).toBe('dark');
expect(theme.preference()).toBe('dark');
});
});
@@ -0,0 +1,53 @@
import { DOCUMENT } from '@angular/common';
import { DestroyRef, Injectable, inject, signal } from '@angular/core';
export type ThemePreference = 'system' | 'light' | 'dark';
export const THEME_STORAGE_KEY = 'artemis.theme';
@Injectable({ providedIn: 'root' })
export class ThemeService {
private readonly document = inject(DOCUMENT);
private readonly window = this.document.defaultView;
private readonly media = this.window?.matchMedia('(prefers-color-scheme: dark)');
readonly preference = signal<ThemePreference>('system');
readonly isDark = signal(false);
constructor() {
try {
const saved = this.window?.localStorage.getItem(THEME_STORAGE_KEY);
if (saved === 'light' || saved === 'dark') this.preference.set(saved);
} catch { /* A blocked store still allows switching in this tab. */ }
const onSystemChange = () => this.apply();
const onStorage = (event: StorageEvent) => {
if (event.key !== THEME_STORAGE_KEY && event.key !== null) return;
const value = event.newValue;
this.preference.set(value === 'light' || value === 'dark' ? value : 'system');
this.apply();
};
this.media?.addEventListener('change', onSystemChange);
this.window?.addEventListener('storage', onStorage);
inject(DestroyRef).onDestroy(() => {
this.media?.removeEventListener('change', onSystemChange);
this.window?.removeEventListener('storage', onStorage);
});
this.apply();
}
setPreference(value: ThemePreference): void {
if (!['system', 'light', 'dark'].includes(value)) return;
this.preference.set(value);
try { this.window?.localStorage.setItem(THEME_STORAGE_KEY, value); } catch { /* Tab-only preference. */ }
this.apply();
}
toggle(): void {
this.setPreference(this.isDark() ? 'light' : 'dark');
}
private apply(): void {
const dark = this.preference() === 'dark' || (this.preference() === 'system' && this.media?.matches === true);
this.isDark.set(dark);
this.document.documentElement.dataset['theme'] = dark ? 'dark' : 'light';
this.document.documentElement.style.colorScheme = dark ? 'dark' : 'light';
}
}
@@ -71,8 +71,55 @@ describe('markdown-parser.util verification & check lines', () => {
it('renders Chinese report tables and preserves separate lines safely', () => {
const html = renderMarkdownToHtml('## 结论\n第一行\n第二行\n\n| 分类 | 结果 |\n| --- | --- |\n| 网络 | **通过** |');
expect(html).toContain('<h2>结论</h2>');
expect(html).toContain('<div>第一行</div>\n<div>第二行</div>');
expect(html).toContain('<p>第一行<br>第二行</p>');
expect(html).toContain('<th>分类</th>');
expect(html).toContain('<td><strong>通过</strong></td>');
});
it('keeps table alignment, escaped pipes and inline code intact', () => {
const view = document.createElement('div');
view.innerHTML = renderMarkdownToHtml('| 项目 | 值 |\n| :--- | ---: |\n| A\\|B | `**原样**` |');
expect(view.querySelectorAll('th').length).toBe(2);
expect(view.querySelector('td')?.textContent).toBe('A|B');
expect(view.querySelector('td.md-align-right code')?.textContent).toBe('**原样**');
expect(view.querySelector('code strong')).toBeNull();
});
it('renders nested lists, task states, quotes and reference links', () => {
const view = document.createElement('div');
view.innerHTML = renderMarkdownToHtml('#### 计划\n- [x] 完成\n - 子项\n- [/] 执行中\n- [ ] 待办\n- assert@end: **最终断言**\n\n> 引用\n>\n> 3. [文档][docs]\n\n[docs]: https://example.com/a?q=1&x=2');
expect(view.querySelector('h4')?.textContent).toBe('计划');
expect(view.querySelector('li ul li')?.textContent).toBe('子项');
expect(view.querySelectorAll('.md-task-marker').length).toBe(3);
expect(view.querySelector('.md-task-item.progress')?.textContent).toContain('执行中');
expect(view.querySelector('.verify-badge.assert')?.textContent).toBe('assert@end');
expect(view.querySelector('blockquote ol')?.getAttribute('start')).toBe('3');
expect(view.querySelector('a')?.getAttribute('href')).toBe('https://example.com/a?q=1&x=2');
});
it('preserves code whitespace, markup and unfinished streaming fences', () => {
const view = document.createElement('div');
view.innerHTML = renderMarkdownToHtml('~~~xml\r\n<plan>**原样**</plan>\r\n\r\n\r\n code\r\n');
expect(view.querySelector('pre code')?.textContent).toBe('<plan>**原样**</plan>\n\n\n code');
expect(view.querySelector('plan,strong')).toBeNull();
});
it('escapes raw and inline HTML and rejects active link and image protocols', () => {
const view = document.createElement('div');
view.innerHTML = renderMarkdownToHtml('<script>alert(1)</script>\n\n`<img src=x onerror=alert(1)>`\n\n[危险](javascript:alert%281%29) ![危险](data:text/html,attack)\n\n[相对链接](/reports/test)');
expect(view.querySelector('script,img')).toBeNull();
expect(view.querySelector('code')?.textContent).toBe('<img src=x onerror=alert(1)>');
expect(view.querySelectorAll('a').length).toBe(1);
expect(view.querySelector('a')?.getAttribute('href')).toBe('/reports/test');
expect(view.querySelector('a')?.getAttribute('rel')).toBe('noopener noreferrer');
});
it('renders Markdown inside internal wrappers while retaining those tags inside code', () => {
const view = document.createElement('div');
view.innerHTML = renderMarkdownToHtml('<thought>\n## 阶段\n\n- **核对**\n\n```xml\n<plan>字面内容</plan>\n```\n</thought>');
expect(view.querySelector('h2')?.textContent).toBe('阶段');
expect(view.querySelector('li strong')?.textContent).toBe('核对');
expect(view.querySelector('pre code')?.textContent).toBe('<plan>字面内容</plan>');
expect(view.querySelector('thought,plan')).toBeNull();
});
});
@@ -16,6 +16,7 @@
import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../core/models/markdown.model';
import { CheckerResult } from '../core/models/stream.model';
import { Marked, Renderer } from 'marked';
/**
* Split text into bold, code, and plain segments
@@ -208,196 +209,89 @@ export function extractCheckerResult(text: string): CheckerResult | null {
return null;
}
/**
* Helper to format inline markdown segments (bold, italic, code, strikethrough)
*/
function formatInlineMarkdown(text: string): string {
if (!text) return '';
return text
// Inline code first (so asterisks inside code aren't formatted)
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
// Bold + Italic
.replace(/\*\*\*([^*]+)\*\*\*/g, '<strong><em>$1</em></strong>')
.replace(/___([^_]+)___/g, '<strong><em>$1</em></strong>')
// Bold
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/__([^_]+)__/g, '<strong>$1</strong>')
// Italic
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
.replace(/\b_([^_]+)_\b/g, '<em>$1</em>')
// Strikethrough
.replace(/~~([^~]+)~~/g, '<del>$1</del>');
function escapeHtml(text: string): string {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
/**
* Convert basic markdown text to safe HTML for agent logs/thinking
*/
function safeMarkdownUrl(href: string, image = false): boolean {
try {
const url = new URL(href, 'https://artemis.invalid/');
return ['http:', 'https:', ...(image ? [] : ['mailto:'])].includes(url.protocol);
} catch {
return false;
}
}
const baseRenderer = new Renderer();
const agentTag = /^<\/?(?:thought|short_term_memory|strong_term_memory|reasoning|plan|task_plan|call_tool)\b[^\n>]*>/i;
function agentTagToken(src: string) {
const match = src.match(agentTag);
return match ? { type: 'artemisTag', raw: match[0] } : undefined;
}
// Use one scoped parser for notes, plans, reports and streaming text. HTML from
// models stays literal; Angular also sanitizes every innerHTML binding.
const markdown = new Marked({
gfm: true,
breaks: true,
async: false,
extensions: [
{ name: 'artemisTag', level: 'block', tokenizer: agentTagToken, renderer: () => '' },
{
name: 'artemisTag', level: 'inline', tokenizer: agentTagToken,
start: src => src.search(/<\/?(?:thought|short_term_memory|strong_term_memory|reasoning|plan|task_plan|call_tool)\b/i)
}
],
renderer: {
html({ text }) { return escapeHtml(text); },
code({ text, lang }) {
const language = (lang || '').split(/\s/)[0].replace(/[^a-zA-Z0-9_-]/g, '');
return `<pre class="code-block"><code class="language-${language}">${escapeHtml(text)}</code></pre>\n`;
},
codespan({ text }) { return `<code class="inline-code">${escapeHtml(text)}</code>`; },
link(token) {
if (!safeMarkdownUrl(token.href)) return this.parser.parseInline(token.tokens);
return `<a href="${escapeHtml(token.href)}"${token.title ? ` title="${escapeHtml(token.title)}"` : ''} target="_blank" rel="noopener noreferrer">${this.parser.parseInline(token.tokens)}</a>`;
},
image(token) {
if (!safeMarkdownUrl(token.href, true)) return escapeHtml(token.text);
return `<img src="${escapeHtml(token.href)}" alt="${escapeHtml(token.text)}"${token.title ? ` title="${escapeHtml(token.title)}"` : ''} loading="lazy" />`;
},
table(token) {
return `<div class="md-table-scroll">${baseRenderer.table.call(this, token)}</div>`;
},
tablecell(token) {
const tag = token.header ? 'th' : 'td';
const align = token.align ? ` class="md-align-${token.align}"` : '';
return `<${tag}${align}>${this.parser.parseInline(token.tokens)}</${tag}>`;
},
checkbox({ checked }) {
return `<span class="md-task-marker ${checked ? 'checked' : 'unchecked'}" role="img" aria-label="${checked ? '已完成' : '未完成'}">${checked ? '☑' : '☐'}</span> `;
},
listitem(token) {
let html = baseRenderer.listitem.call(this, token);
if (token.task) return html.replace('<li>', `<li class="md-task-item ${token.checked ? 'checked' : 'unchecked'}">`);
if (/^\[\/\]\s/.test(token.text)) {
return html.replace(/^<li>(<p>)?\[\/\]\s*/, '<li class="md-task-item progress">$1<span class="md-task-marker progress" role="img" aria-label="进行中">◧</span> ');
}
const check = token.text.match(/^(verify|assert)(@end)?\s*:\s*/i);
if (check) {
const kind = check[1].toLowerCase();
html = html.replace(/^<li>/, '<li class="md-verify-item">');
return html.replace(/^(<li[^>]*>(?:<p>)?)(verify|assert)(@end)?\s*:\s*/i,
`$1<span class="verify-badge${kind === 'assert' ? ' assert' : ''}">${kind}${check[2] || ''}</span>`);
}
if (/^finding\s*:/i.test(token.text)) {
return html.replace(/^<li>(<p>)?finding\s*:\s*/i,
'<li class="md-finding-item">$1<span class="finding-badge">finding</span>');
}
return html;
}
}
});
/** Render CommonMark/GFM while preserving ARTEMIS plan status markers. */
export function renderMarkdownToHtml(text: string): string {
if (!text) return '';
// Clean up internal agent XML-like tags (e.g. <thought>, <short_term_memory>, etc.)
const cleanedText = text
.replace(/<\/?(thought|short_term_memory|strong_term_memory|reasoning|plan|task_plan|call_tool)[^\n>]*>?/gi, '')
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
// Escape HTML first to prevent XSS
const escaped = cleanedText
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
const rawLines = escaped.split('\n');
const result: string[] = [];
let currentListType: 'ul' | 'ol' | null = null;
let inCodeBlock = false;
let codeBlockLang = '';
let codeBlockLines: string[] = [];
const closeList = () => {
if (currentListType) {
result.push(`</${currentListType}>`);
currentListType = null;
}
};
for (let i = 0; i < rawLines.length; i++) {
const rawLine = rawLines[i];
const trimmed = rawLine.trim();
// 1. Code blocks: ```lang ... ```
if (trimmed.startsWith('```')) {
if (inCodeBlock) {
// End code block
const codeContent = codeBlockLines.join('\n');
result.push(`<pre class="code-block"><code class="lang-${codeBlockLang.replace(/[^a-zA-Z0-9_-]/g, '')}">${codeContent}</code></pre>`);
inCodeBlock = false;
codeBlockLines = [];
codeBlockLang = '';
} else {
closeList();
inCodeBlock = true;
codeBlockLang = trimmed.substring(3).trim();
codeBlockLines = [];
}
continue;
}
if (inCodeBlock) {
codeBlockLines.push(rawLine);
continue;
}
// Markdown result tables need real cells to remain readable in reports.
if (trimmed.includes('|') && i + 1 < rawLines.length
&& /^\s*\|?\s*:?-{3,}:?(?:\s*\|\s*:?-{3,}:?)*\s*\|?\s*$/.test(rawLines[i + 1])) {
closeList();
const cells = (line: string) => line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
const headings = cells(rawLine);
result.push('<div class="md-table-scroll"><table><thead><tr>'
+ headings.map(cell => `<th>${formatInlineMarkdown(cell.trim())}</th>`).join('')
+ '</tr></thead><tbody>');
i += 2;
while (i < rawLines.length && rawLines[i].trim() && rawLines[i].includes('|')) {
const row = cells(rawLines[i]);
result.push('<tr>' + headings.map((_, index) => `<td>${formatInlineMarkdown((row[index] || '').trim())}</td>`).join('') + '</tr>');
i++;
}
result.push('</tbody></table></div>');
i--;
continue;
}
// 2. Headings: # to ######
const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/);
if (headingMatch) {
closeList();
const level = headingMatch[1].length;
const headingContent = formatInlineMarkdown(headingMatch[2]);
result.push(`<h${level}>${headingContent}</h${level}>`);
continue;
}
// 3. Horizontal Rule: ---, ***, ___
if (/^(\-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
closeList();
result.push('<hr class="md-hr" />');
continue;
}
// 4. Blockquotes: > quote
if (trimmed.startsWith('&gt; ') || trimmed.startsWith('&gt;')) {
closeList();
const quoteContent = formatInlineMarkdown(trimmed.replace(/^&gt;\s?/, ''));
result.push(`<blockquote>${quoteContent}</blockquote>`);
continue;
}
// 5. Unordered List: - item or * item
const ulMatch = rawLine.match(/^(\s*)([\*\-])\s+(.*)$/);
if (ulMatch) {
if (currentListType !== 'ul') {
closeList();
currentListType = 'ul';
result.push('<ul>');
}
const spaces = ulMatch[1].length;
const subLevel = Math.max(0, Math.floor((spaces - 2) / 2));
const indent = subLevel * 12;
const style = indent ? ` style="margin-left: ${indent}px"` : '';
const itemText = ulMatch[3];
const checkMatch = itemText.match(/^(verify|assert)(@end)?\s*:\s*(.*)$/i);
const findingMatch = itemText.match(/^finding\s*:\s*(.*)$/i);
if (checkMatch) {
const kind = checkMatch[1].toLowerCase();
const atEnd = checkMatch[2] ? '@end' : '';
const assertClass = kind === 'assert' ? ' assert' : '';
const itemContent = formatInlineMarkdown(checkMatch[3].trim());
result.push(`<li${style} class="md-verify-item"><span class="verify-badge${assertClass}">${kind}${atEnd}</span>${itemContent}</li>`);
} else if (findingMatch) {
const itemContent = formatInlineMarkdown(findingMatch[1].trim());
result.push(`<li${style} class="md-finding-item"><span class="finding-badge">finding</span>${itemContent}</li>`);
} else {
const itemContent = formatInlineMarkdown(itemText);
result.push(`<li${style}>${itemContent}</li>`);
}
continue;
}
// 6. Ordered List: 1. item
const olMatch = rawLine.match(/^(\s*)(\d+)\.\s+(.*)$/);
if (olMatch) {
if (currentListType !== 'ol') {
closeList();
currentListType = 'ol';
result.push('<ol>');
}
const spaces = olMatch[1].length;
const subLevel = Math.max(0, Math.floor((spaces - 2) / 2));
const indent = subLevel * 12;
const style = indent ? ` style="margin-left: ${indent}px"` : '';
const itemContent = formatInlineMarkdown(olMatch[3]);
result.push(`<li${style}>${itemContent}</li>`);
continue;
}
// 7. Regular paragraph / empty line
closeList();
if (trimmed === '') {
result.push('<div class="md-para-gap"></div>');
} else {
const lineContent = formatInlineMarkdown(rawLine);
result.push(`<div>${lineContent}</div>`);
}
}
if (inCodeBlock) {
const codeContent = codeBlockLines.join('\n');
result.push(`<pre class="code-block"><code class="lang-${codeBlockLang.replace(/[^a-zA-Z0-9_-]/g, '')}">${codeContent}</code></pre>`);
}
closeList();
return result.join('\n');
return markdown.parse(text.replace(/\r\n?/g, '\n'), { async: false });
}
@@ -68,4 +68,37 @@ describe('plugin evidence timeline cards', () => {
expect(getPluginToolView(tool)?.status).toBe('已执行');
expect(getPluginToolView(tool)?.result).toBe('已点击');
});
it('presents CUA window facts without modifying either raw result channel', () => {
const tree = '- Window "Demo"\n - [0] Button "Save"';
const raw = { execution: 'completed', content: [{ type: 'text', text: tree }],
structured_content: { window_title: 'Demo', pid: 12, window_id: 34,
returned_element_count: 0, total_element_count: 20, screenshot_width: 1280,
screenshot_height: 720, snapshot_id: 's00000001', truncated: true, elements_complete: false,
tree_markdown: tree, capture_focus: { target_foreground_after: false } } };
const tool = { name: 'plug_abcdef1234_get_window_state', payload: {
plugin_id: 'cua-driver', effect: 'read', observation: true, result: JSON.stringify(raw) } };
const original = JSON.stringify(tool);
const view = getPluginToolView(tool)!;
expect(view.title).toBe('观察电脑窗口');
expect(view.facts).toContain({ key: '窗口', value: 'Demo' });
expect(view.facts).toContain({ key: '控件', value: '0 / 20' });
expect(view.facts).toContain({ key: '截图', value: '1280 × 720' });
expect(view.facts).toContain({ key: '控件树完整性', value: '未声明完整' });
expect(view.facts).toContain({ key: '采集后焦点', value: '目标窗口不在前台' });
expect(JSON.parse(view.rawResult)).toEqual(raw);
expect(view.result).toBe(tree);
expect(JSON.stringify(tool)).toBe(original);
});
it('shows the actual post-action observation and preserves plain legacy receipts', () => {
const view = getPluginToolView({ name: 'plug_abcdef1234_press_key', payload: {
plugin_id: 'cua-driver', result: { structured_content: {
observation: { window_title: 'After key', window_id: 56 } } } } })!;
expect(view.facts).toContain({ key: '窗口', value: 'After key' });
const legacy = getPluginToolView({ action: 'plug_abcdef1234_get_window_state',
result: '来源:cua-driver\n窗口已采集' })!;
expect(legacy.result).toBe('来源:cua-driver\n窗口已采集');
expect(legacy.rawResult).toBe(legacy.result);
});
});
@@ -5,6 +5,8 @@ export interface PluginToolView {
result: string;
status: string;
args: Array<{ key: string; value: string }>;
facts: Array<{ key: string; value: string }>;
rawResult: string;
}
function objectValue(value: any): Record<string, any> {
@@ -18,7 +20,8 @@ function objectValue(value: any): Record<string, any> {
export function getPluginToolView(tool: any): PluginToolView | null {
if (!tool || typeof tool !== 'object' || tool.type === 'llm_call') return null;
const payload = objectValue(tool.payload);
const result = objectValue(payload['result'] ?? tool.result);
const rawResult = payload['result'] ?? tool.result;
const result = objectValue(rawResult);
const pluginId = payload['plugin_id'] || result['plugin_id'] || tool.plugin_id;
if (!pluginId && !/^plug_[a-f0-9]{10}_/.test(tool.name || tool.action || '')) {
return null;
@@ -29,7 +32,8 @@ export function getPluginToolView(tool: any): PluginToolView | null {
['possible', 'confirmed'].includes(result['side_effect']) ? 'write' : '');
const auxiliary = effect !== 'write';
const titles: Record<string, string> = {
get_app_state: '观察电脑窗口', list_apps: '列出电脑应用',
get_app_state: '观察电脑窗口', get_window_state: '观察电脑窗口',
list_apps: '列出电脑应用', list_windows: '列出电脑窗口',
click: '点击', double_click: '双击', drag: '拖动', scroll: '滚动',
type_text: '输入文本', press_key: '按键', screenshot: '截取屏幕',
save_screenshot: '保存截图', launch_app: '打开应用'
@@ -37,6 +41,30 @@ export function getPluginToolView(tool: any): PluginToolView | null {
const content = Array.isArray(result['content']) ? result['content'] : [];
const text = content.filter((item: any) => item?.type === 'text' && typeof item.text === 'string')
.map((item: any) => item.text).join('\n');
const structured = objectValue(result['structured_content'] ?? result['structuredContent']);
const observation = Object.keys(objectValue(structured['observation'])).length
? objectValue(structured['observation']) : structured;
const facts: PluginToolView['facts'] = [];
const addFact = (key: string, value: any) => {
if (value !== undefined && value !== null && value !== '') facts.push({ key, value: String(value) });
};
addFact('窗口', observation['window_title']);
addFact('进程 PID', observation['pid']);
addFact('窗口 ID', observation['window_id']);
const count = observation['returned_element_count'] ?? observation['element_count'];
addFact('控件', count === undefined ? undefined : observation['total_element_count'] === undefined
? count : `${count} / ${observation['total_element_count']}`);
if (observation['screenshot_width'] && observation['screenshot_height']) {
addFact('截图', `${observation['screenshot_width']} × ${observation['screenshot_height']}`);
}
addFact('快照', observation['snapshot_id']);
if (observation['truncated'] === true) addFact('控件范围', '返回已截断');
if (observation['elements_complete'] === false) addFact('控件树完整性', '未声明完整');
const focus = objectValue(observation['capture_focus']);
if (typeof focus['target_foreground_after'] === 'boolean') {
addFact('采集后焦点', focus['target_foreground_after'] ? '目标窗口在前台' : '目标窗口不在前台');
}
const plainResult = typeof rawResult === 'string' && Object.keys(result).length === 0 ? rawResult : '';
const state = result['execution'];
const status = tool.status === 'running' ? '执行中' :
result['status'] === 'uncertain' || state === 'unknown' ? '结果不确定' :
@@ -48,11 +76,14 @@ export function getPluginToolView(tool: any): PluginToolView | null {
kind: payload['observation'] === true ? '辅助观察' : effect === 'read' ? '辅助读取' :
effect === 'write' ? '插件操作' : '插件调用',
auxiliary,
result: [result['message'], text].filter(Boolean).join('\n') || String(tool.error || ''),
result: [result['message'], text].filter(Boolean).join('\n') || plainResult || String(tool.error || ''),
status,
args: Object.entries(objectValue(payload['args'] ?? tool.args)).map(([key, value]) => ({
key, value: typeof value === 'string' ? value : JSON.stringify(value)
}))
})),
facts,
rawResult: rawResult === undefined || rawResult === null ? '' :
Object.keys(result).length ? JSON.stringify(result, null, 2) : String(rawResult)
};
return view;
}
+11
View File
@@ -21,6 +21,17 @@
<title>artemis</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<script>
// Apply before the first paint, including when local storage is unavailable.
(() => {
let preference = 'system';
try { preference = localStorage.getItem('artemis.theme') || 'system'; } catch {}
const dark = preference === 'dark' || (preference !== 'light' && matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
document.documentElement.style.colorScheme = dark ? 'dark' : 'light';
})();
</script>
<link rel="icon" type="image/png" sizes="192x192" href="/logo.png?v=2">
<link rel="icon" type="image/png" sizes="32x32" href="/logo.png?v=2">
<link rel="icon" type="image/svg+xml" href="/logo.svg?v=2">
+4 -2
View File
@@ -14,6 +14,8 @@
* limitations under the License.
*/
@use './theme';
@use './markdown';
@import '@angular/cdk/overlay-prebuilt.css';
// Keep fonts local so production builds and icons do not depend on Google Fonts.
@@ -85,8 +87,8 @@ body, input, button, select, textarea {
body {
margin: 0;
padding: 0;
color: #1e293b;
background-color: #f8f9fa;
color: var(--app-text-control, #1e293b);
background-color: var(--app-canvas, #f8f9fa);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}