feat: enable full AI automation and safe startup updates

This commit is contained in:
chenmingxuan committed 2026-09-22 23:50:03 +08:00
1 parent 2ddec53954
commit 7b1a0755e2
62 files changed
+3123 -240

No files matched your search

@@ -42,7 +42,9 @@ limitations under the License.
<span class="material-symbols-outlined tab-icon">description</span>
<span class="tab-label">笔记与计划</span>
</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 /></div></ng-template>
<!-- Dropdown Popover Panel for Task Queue -->
<ng-template
@@ -61,7 +63,7 @@ limitations under the License.
</div>
<div class="dropdown-section-label">
<span class="material-symbols-outlined icon-bolt">bolt</span>
<span>Active Queue ({{ activeQueue().length }})</span>
<span>当前队列 ({{ activeQueue().length }})</span>
</div>
@if (activeQueue().length === 0) {
<div class="dropdown-empty-state">
@@ -127,65 +129,7 @@ limitations under the License.
</div>
</div>
}
<div class="dropdown-section-label history-label">
<span class="material-symbols-outlined icon-history">history</span>
<span>Past Runs & History ({{ historyTasks().length }})</span>
</div>
@if (historyTasks().length === 0) {
<div class="dropdown-empty-state">
暂无历史任务
</div>
}
<div class="dropdown-history-list">
@for (session of historyTasks(); track trackSession($index, session)) {
<div
class="dropdown-item-card"
[class.selected]="agentService.currentSessionId() === session.session_id"
[class.running]="getTaskStatus(session) === 'running'"
(click)="selectTask(session.session_id, $event)"
>
<div class="item-header">
<span class="task-badge" [ngClass]="getTaskStatus(session)">
@if (getTaskStatus(session) === 'running') {
<span class="status-dot"></span>
}
{{ getTaskStatus(session) | uiLabel }}
</span>
<div class="item-header-actions">
@if (getDeviceSerial(session)) {
<span class="item-device" [title]="'Device: ' + getDeviceSerial(session)">
<span class="material-symbols-outlined device-icon">phone_android</span>
<span class="device-name">{{ getDeviceSerial(session) }}</span>
</span>
}
@if (getDeviceSerial(session)) {
<span class="item-time-divider">·</span>
}
<span class="item-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
@if (getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused') {
<button
type="button"
class="btn-delete-card"
(click)="deleteTask(session.session_id, $event)"
title="删除此任务"
aria-label="删除此任务"
>
<span class="material-symbols-outlined">delete</span>
</button>
}
</div>
</div>
<div class="item-goal" title="{{ session.initial_goal }}">
{{ session.initial_goal || '未命名任务' }}
</div>
<div class="item-footer">
<span class="item-id" [title]="session.session_id">ID: {{ session.session_id }}</span>
<span class="item-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }}</span>
</div>
</div>
}
</div>
<app-task-history />
</div>
</ng-template>
@@ -483,7 +427,7 @@ limitations under the License.
<button
class="btn-screen-recording"
[class.active]="agentService.isVideoWindowOpen()"
[class.recording]="agentService.isCurrentSessionRunning()"
[class.recording]="agentService.isCurrentSessionRunning() && agentService.currentSession()?.recording_status === 'recording'"
[class.processing]="!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() === 'processing'"
[class.has-video]="!agentService.isCurrentSessionRunning() && !!agentService.currentSessionVideoUrl()"
[class.has-steps]="!agentService.isCurrentSessionRunning() && !agentService.currentSessionVideoUrl() && agentService.hasCurrentSessionStepFrames()"
@@ -493,7 +437,7 @@ limitations under the License.
>
@if (agentService.isCurrentSessionRunning()) {
<span class="material-symbols-outlined video-icon">
videocam
{{ agentService.currentSession()?.recording_status === 'recording' ? 'videocam' : 'progress_activity' }}
</span>
}
@if (!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() === 'processing') {
@@ -522,7 +466,7 @@ limitations under the License.
</span>
}
@if (agentService.isCurrentSessionRunning()) {
<span class="btn-text">正在录制…</span>
<span class="btn-text">{{ agentService.currentSession()?.recording_status === 'recording' ? '正在录制…' : '任务执行中…' }}</span>
}
@if (!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() === 'processing') {
<span class="btn-text">正在准备…</span>
@@ -1,3 +1,5 @@
import { TaskHistoryComponent } from '../task-history/task-history.component';
import { AutomationPanelComponent } from '../automation-panel/automation-panel.component';
import { UiLabelPipe } from '../../utils/ui-label.pipe';
/**
* Copyright 2026 Google LLC
@@ -234,7 +236,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote, StreamRe
@Component({
selector: 'app-agent-stream',
standalone: true,
imports: [UiLabelPipe, CommonModule, FormsModule, OverlayModule],
imports: [UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent],
templateUrl: './agent-stream.component.html',
styleUrl: './agent-stream.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
@@ -267,6 +269,7 @@ export class AgentStreamComponent implements AfterViewInit {
// Top Nav Dropdown States
public isTaskDropdownOpen = signal<boolean>(false);
public isNotesDropdownOpen = signal<boolean>(false);
public isAutomationOpen = signal(false);
// Run info popover (opened from the Flash / Pro chip): elapsed time, token
// usage, executor context share and the Pro tuning the run started with.
@@ -1555,7 +1558,7 @@ export class AgentStreamComponent implements AfterViewInit {
public getScreenRecordingButtonTitle(): string {
if (this.agentService.isCurrentSessionRunning()) {
return "任务正在运行(录屏中)";
return this.agentService.currentSession()?.recording_status === 'recording' ? "任务正在运行(录屏中)" : "任务正在运行";
}
if (this.agentService.currentSessionRecordingStatus() === 'processing') {
return "正在准备屏幕录像…";
@@ -0,0 +1,53 @@
<nav class="tabs" aria-label="自动化工具">
<button [class.selected]="tab() === 'reports'" (click)="tab.set('reports')">结论报告</button>
<button [class.selected]="tab() === 'batches'" (click)="tab.set('batches')">批量任务</button>
</nav>
@if (error()) { <p class="error" role="alert">{{error()}}</p> }
@if (notice()) { <p class="notice" role="status">{{notice()}}</p> }
@if (tab() === 'reports') {
<section><h3>默认报告格式</h3><p>任务结束后自动生成。批次报告包同时包含汇总和每个任务的报告。</p>
<div class="format-options"><label><input type="checkbox" [(ngModel)]="word"> Word 文档</label>
<label><input type="checkbox" [(ngModel)]="excel"> Excel 表格</label></div>
<button [disabled]="busy()" (click)="savePreferences()">保存默认设置</button>
</section>
<section><h3>当前任务报告</h3>
@if (report(); as r) {
<strong>{{r.name}}</strong><p>{{label(r.status)}} · {{r.verdict}}</p>
<div class="actions"><a [href]="'/api/automation/reports/'+r.id+'/docx'">下载 Word</a><a [href]="'/api/automation/reports/'+r.id+'/xlsx'">下载 Excel</a></div>
<p class="muted">报告包含目标、预期、实际结论、步骤与截图证据。执行中的下载为当前快照。</p>
<pre class="preview">{{r.content}}</pre>
} @else { <p>从任务队列或历史记录中选择任务,即可下载报告。</p> }
</section>
}
@if (tab() !== 'reports') {
<section class="device"><label>执行设备<select [(ngModel)]="device"><option value="">请选择设备</option>@for(d of system.connectedDevices(); track d.serial){<option [value]="d.serial">{{d.serial}}</option>}</select></label></section>
}
@if (tab() === 'batches') {
<section><h3>导入任务</h3><p>每行一个用例,填写名称与目标。预期结果、应用、前置条件、参数和标签可选。</p>
<div class="actions"><a href="/api/automation/template">下载 Excel 模板</a><label class="upload">导入 Excel<input type="file" accept=".xlsx" [disabled]="busy()" (change)="upload($event)"></label></div>
@if(preview(); as p) {
<p>有效任务 {{p.tasks.length}} 条 · 错误 {{p.errors.length}} 条</p>
@for(e of p.errors; track e.row){<p class="error">第 {{e.row}} 行:{{e.message}}</p>}
<div class="table-wrap"><table><thead><tr><th>行</th><th>名称</th><th>目标</th></tr></thead><tbody>@for(t of p.tasks; track t.row){<tr><td>{{t.row}}</td><td>{{t.name}}</td><td>{{t.goal}}</td></tr>}</tbody></table></div>
<label>批次名称<input [(ngModel)]="name" maxlength="200"></label>
<div class="fields"><label>执行模式<select [(ngModel)]="profile"><option value="flash">Flash</option><option value="pro">Pro</option></select></label><label>单任务时限(秒)<input type="number" min="30" max="86400" [(ngModel)]="timeout"></label></div>
<label><input type="checkbox" [(ngModel)]="stopOnFailure"> 失败即停止后续任务(默认继续)</label>
<label><input type="checkbox" [(ngModel)]="word"> Word</label><label><input type="checkbox" [(ngModel)]="excel"> Excel</label>
<button class="primary" [disabled]="busy() || !p.valid || !device || !name.trim() || !formats().length" (click)="startBatch()">确认导入并开始执行</button>
}
</section>
<section><h3>批次记录</h3>
@for(b of batches(); track b.id){<button class="record" [class.selected]="batch()?.id === b.id" (click)="selectBatch(b.id)"><strong>{{b.name}}</strong><span>{{label(b.status)}} · {{b.created_at*1000 | date:'MM-dd HH:mm'}}</span></button>}
@if(!batches().length){<p>暂无批次。</p>}
</section>
@if(batch(); as b){<section><h3>{{b.name}}</h3><p>{{label(b.status)}} · {{b.reason}}</p>
<div class="actions">
@if(b.status === 'running'){<button [disabled]="busy()" (click)="batchAction('pause')">暂停后续任务</button>}
@if(b.status === 'draft' || b.status === 'paused'){<button [disabled]="busy()" (click)="batchAction('resume')">{{b.status === 'draft' ? '开始执行' : '继续未开始任务'}}</button>}
@if(b.status !== 'running'){<button [disabled]="busy()" (click)="batchAction('retry_failed')">复制失败项为新批次</button>}
<a [href]="'/api/automation/batches/'+b.id+'/archive'">下载全部报告 ZIP</a>
</div><p class="muted">服务重启后等待手动继续。失败项不会自动重试;暂停不打断当前任务。</p>
@if(b.report_error){<p class="error">报告生成失败:{{b.report_error}}</p>}
@for(j of b.jobs; track j.session_id){<div class="job"><button (click)="agent.selectSession(j.session_id,true)">{{j.position}}. {{j.case.name}}</button><span>{{label(j.status)}}</span>@if(j.error){<small>{{j.error}}</small>}</div>}
</section>}
}
@@ -0,0 +1,22 @@
:host { display:block;min-width:0;color:#1e293b;font-size:13px;line-height:1.65; }
.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;} }
}
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;}
.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;}
.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;}
.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;}
@@ -0,0 +1,66 @@
import { Component, DestroyRef, effect, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { AgentService } from '../../services/agent.service';
import { SystemService } from '../../services/system.service';
@Component({
selector: 'app-automation-panel', standalone: true, imports: [CommonModule, FormsModule],
templateUrl: './automation-panel.component.html', styleUrl: './automation-panel.component.scss'
})
export class AutomationPanelComponent {
agent = inject(AgentService); system = inject(SystemService); http = inject(HttpClient);
tab = signal('reports'); error = signal(''); notice = signal(''); busy = signal(false);
batches = signal<any[]>([]); preview = signal<any>(null);
batch = signal<any>(null); report = signal<any>(null);
word = true; excel = true; name = ''; device = ''; profile = 'flash'; stopOnFailure = false; timeout = 1800;
private destroyed = false;
constructor() {
const destroy = inject(DestroyRef);
this.http.get<any>('/api/automation/preferences').subscribe(p => { this.word = p.formats.includes('docx'); this.excel = p.formats.includes('xlsx'); this.timeout = p.batch_timeout_seconds || 1800; });
void this.refresh();
const timer = setInterval(() => void this.refresh(), 5000);
destroy.onDestroy(() => { this.destroyed = true; clearInterval(timer); });
effect(() => {
const sid = this.agent.currentSessionId(); this.report.set(null);
if (sid) this.http.get<any>(`/api/automation/reports/${encodeURIComponent(sid)}`).subscribe({ next: r => { if (this.agent.currentSessionId() === sid) this.report.set(r); }, error: () => {} });
});
}
formats() { return [ ...(this.word ? ['docx'] : []), ...(this.excel ? ['xlsx'] : []) ]; }
label(s: string) { return ({draft:'待开始',running:'执行中',paused:'已暂停',completed:'执行完成',failed:'失败',cancelled:'已取消',pending:'待执行',queued:'排队中',interrupted:'中断',skipped:'跳过',finalizing:'正在生成报告'} as Record<string,string>)[s] || s; }
async action(fn: () => Promise<void>) {
if (this.busy()) return;
this.busy.set(true); this.error.set(''); this.notice.set('');
try { await fn(); } catch (e: any) { const d = e.error?.detail; this.error.set(typeof d === 'string' ? d : d ? JSON.stringify(d) : e.message || '操作失败'); }
finally { this.busy.set(false); }
}
async refresh() {
try {
const b = await firstValueFrom(this.http.get<any[]>('/api/automation/batches'));
if (this.destroyed) return;
this.batches.set(b);
const sid = this.agent.currentSessionId();
if (sid && this.tab() === 'reports') { const r = await firstValueFrom(this.http.get<any>(`/api/automation/reports/${encodeURIComponent(sid)}`)); if (this.agent.currentSessionId() === sid) this.report.set(r); }
const id = this.batch()?.id;
if (id) { const item = await firstValueFrom(this.http.get<any>(`/api/automation/batches/${id}`)); if (this.batch()?.id === id) this.batch.set(item); }
} catch { /* User initiated actions expose errors; polling retries on the next interval. */ }
}
savePreferences() { void this.action(async () => { if (!this.formats().length) throw Error('请至少选择一种报告格式'); await firstValueFrom(this.http.put('/api/automation/preferences',{formats:this.formats()})); this.notice.set('已保存,后续任务和批次使用此默认格式'); }); }
upload(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]; if (!file) return;
void this.action(async () => { this.preview.set(null); const data = new FormData(); data.append('file',file); this.preview.set(await firstValueFrom(this.http.post('/api/automation/import/preview',data))); this.name = file.name.replace(/\.xlsx$/i,''); });
}
startBatch() { void this.action(async () => {
if (!this.preview()?.valid) throw Error('请先导入并修正所有错误');
if (!this.device) throw Error('请选择执行设备');
const cases = this.preview().tasks.map(({row, ...c}: any) => c);
const created: any = await firstValueFrom(this.http.post('/api/automation/batches',{name:this.name.trim(),cases,device_serial:this.device,profile:this.profile,stop_on_failure:this.stopOnFailure,timeout_seconds:this.timeout,formats:this.formats()}));
this.batch.set(created);
await firstValueFrom(this.http.post(`/api/automation/batches/${created.id}/resume`,{}));
this.preview.set(null); this.notice.set('批次已开始,可暂停或查看每项结果'); await this.refresh();
}); }
selectBatch(id: string) { void this.action(async () => { this.batch.set(await firstValueFrom(this.http.get(`/api/automation/batches/${id}`))); }); }
batchAction(action: string) { void this.action(async () => { this.batch.set(await firstValueFrom(this.http.post(`/api/automation/batches/${this.batch().id}/${action}`,{}))); await this.refresh(); }); }
}
@@ -35,6 +35,7 @@ limitations under the License.
<span class="material-symbols-outlined tab-icon">description</span>
<span class="tab-text">笔记与计划</span>
</button>
<button class="tab-selector-btn" [class.active]="agentService.activeTab() === 'automation'" (click)="agentService.activeTab.set('automation')"><span class="material-symbols-outlined tab-icon">automation</span><span class="tab-text">报告与批量</span></button>
</div>
<div class="header-right">
@@ -54,11 +55,12 @@ limitations under the License.
<!-- Scrollable Messages/Content Area -->
<main class="chat-messages">
@if (agentService.activeTab() === 'automation') { <app-automation-panel /> }
<!-- Tasks Tab Content -->
@if (agentService.activeTab() === 'tasks') {
<!-- Active Queue Section -->
<div class="queue-section-header">
<span class="material-symbols-outlined section-icon">bolt</span> Active Queue ({{ activeQueue().length }})
<span class="material-symbols-outlined section-icon">bolt</span> 当前队列 ({{ activeQueue().length }})
</div>
@if (activeQueue().length === 0) {
<div class="empty-section-placeholder">
@@ -125,68 +127,7 @@ limitations under the License.
</div>
</div>
}
<!-- Collapsible History Section -->
<details class="history-section" open>
<summary class="history-summary">
<span class="material-symbols-outlined section-icon">history</span> Past Runs & History ({{ historyTasks().length }})
</summary>
<div class="history-list-wrapper">
@if (historyTasks().length === 0) {
<div class="empty-section-placeholder">
暂无历史任务。
</div>
}
@for (session of historyTasks(); track trackSession($index, session)) {
<div
class="task-card"
[class.selected]="agentService.currentSessionId() === session.session_id"
[class.running]="getTaskStatus(session) === 'running'"
(click)="selectTask(session.session_id)"
>
<div class="task-card-header">
<span class="task-badge" [ngClass]="getTaskStatus(session)">
@if (getTaskStatus(session) === 'running') {
<span class="status-dot"></span>
}
{{ getTaskStatus(session) | uiLabel }}
</span>
<div class="task-card-header-actions">
@if (getDeviceSerial(session)) {
<span class="task-device" [title]="'Device: ' + getDeviceSerial(session)">
<span class="material-symbols-outlined device-icon">phone_android</span>
<span class="device-name">{{ getDeviceSerial(session) }}</span>
</span>
}
@if (getDeviceSerial(session)) {
<span class="task-time-divider">·</span>
}
<span class="task-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
@if (getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused') {
<button
type="button"
class="btn-delete-card"
(click)="deleteTask(session.session_id, $event)"
title="删除此任务"
aria-label="删除此任务"
[disabled]="isSubmitting()"
>
<span class="material-symbols-outlined">delete</span>
</button>
}
</div>
</div>
<div class="task-goal" title="{{ session.initial_goal }}">
{{ session.initial_goal || '未命名任务' }}
</div>
<div class="task-card-footer">
<span class="task-id" [title]="session.session_id">ID: {{ session.session_id }}</span>
<span class="task-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }}</span>
</div>
</div>
}
</div>
</details>
<app-task-history />
}
<!-- Notes Tab Content -->
@@ -1,3 +1,5 @@
import { AutomationPanelComponent } from '../automation-panel/automation-panel.component';
import { TaskHistoryComponent } from '../task-history/task-history.component';
import { UiLabelPipe } from '../../utils/ui-label.pipe';
/**
* Copyright 2026 Google LLC
@@ -28,7 +30,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote };
@Component({
selector: 'app-chat-interface',
standalone: true,
imports: [UiLabelPipe, CommonModule, FormsModule],
imports: [UiLabelPipe, CommonModule, FormsModule, AutomationPanelComponent, TaskHistoryComponent],
templateUrl: './chat-interface.component.html',
styleUrl: './chat-interface.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
@@ -36,7 +36,7 @@ interface Capabilities {
export class CapabilitySettingsComponent implements OnInit {
changed = output<Capabilities>();
private http = inject(HttpClient);
config: Capabilities = { video_enabled: true, outputter_enabled: true, step_summarizer_enabled: true };
config: Capabilities = { video_enabled: false, outputter_enabled: true, step_summarizer_enabled: true };
busy = signal(false); loading = signal(true); loaded = signal(false); message = signal(''); error = signal('');
ngOnInit(): void {
this.http.get<Capabilities>('/api/system/capability-settings').pipe(finalize(() => this.loading.set(false))).subscribe({
@@ -7,7 +7,7 @@ import { CapabilitySettingsComponent } from './capability-settings.component';
describe('持久化模型与功能设置', () => {
let http: HttpTestingController;
const config = { base_url: 'https://example.test/v1', model: 'mimo-v2.6-flash',
api_key_configured: true, timeout_seconds: 120, max_retries: 0, env_path: '/app/.env' };
api_key_configured: true, timeout_seconds: 300, max_retries: 5, retry_interval_seconds: 10, reasoning_effort: 'high', env_path: '/app/.env' };
beforeEach(() => {
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
http = TestBed.inject(HttpTestingController);
@@ -26,6 +26,10 @@ describe('持久化模型与功能设置', () => {
const request = http.expectOne('/api/system/model-settings');
expect(request.request.method).toBe('POST');
expect(request.request.body.model).toBe('another-model');
expect(request.request.body.reasoning_effort).toBe('high');
expect(request.request.body.timeout_seconds).toBe(300);
expect(request.request.body.max_retries).toBe(5);
expect(request.request.body.retry_interval_seconds).toBe(10);
expect(request.request.body.api_key).toBeUndefined();
request.flush({ ...config, model: 'another-model' });
expect(component.busy()).toBeNull();
@@ -9,6 +9,8 @@ interface ModelSettings {
api_key_configured: boolean;
timeout_seconds: number;
max_retries: number;
retry_interval_seconds: number;
reasoning_effort: string;
env_path: string;
message?: string;
}
@@ -25,16 +27,25 @@ interface ModelSettings {
placeholder="https://api.openai.com/v1" [disabled]="busy() || loading()" />
<small>填写接口根地址,通常包含 /v1,无需添加 /responses。</small>
<label for="model-name">模型名称</label>
<input id="model-name" name="model" [(ngModel)]="model" required placeholder="输入接口支持的模型名"
<input id="model-name" name="model" list="model-presets" [(ngModel)]="model" required placeholder="输入接口支持的模型名"
[disabled]="busy() || loading()" />
<datalist id="model-presets"><option value="gpt-5.6-luna"></option><option value="mimo-v2.6-flash"></option></datalist>
<label for="model-reasoning">思考强度</label>
<select id="model-reasoning" name="reasoning" [(ngModel)]="reasoning" [disabled]="busy() || loading()">
<option value="default">由模型默认</option><option value="none">关闭(none)</option>
<option value="minimal">最低(minimal)</option><option value="low">低(low)</option>
<option value="medium">中(medium)</option><option value="high">高(high)</option><option value="xhigh">最高(xhigh)</option>
</select>
<small>接口需支持所选强度;不支持时会明确报错,可手动选择“由模型默认”。</small>
<label for="model-key">API Key <span>{{ keyConfigured() ? '(已配置,留空保留)' : '(尚未配置)' }}</span></label>
<input id="model-key" name="apiKey" type="password" [(ngModel)]="apiKey" autocomplete="new-password"
placeholder="输入新密钥" [disabled]="busy() || loading()" />
<div class="number-fields">
<label>请求超时(秒)<input name="timeout" type="number" min="1" max="3600" required
[(ngModel)]="timeout" [disabled]="busy() || loading()" /></label>
<label>接口重试次数<input name="retries" type="number" min="0" max="10" required
<label>失败后重试次数<input name="retries" type="number" min="0" max="10" required
[(ngModel)]="retries" [disabled]="busy() || loading()" /></label>
<label>重试间隔(秒)<input name="retryInterval" type="number" min="0" max="300" required [(ngModel)]="retryInterval" [disabled]="busy() || loading()" /></label>
</div>
<div class="actions">
<button type="submit" [disabled]="busy() || loading() || !valid()">{{ busy() === 'save' ? '正在保存…' : '保存配置' }}</button>
@@ -52,9 +63,9 @@ interface ModelSettings {
.model-settings { display: grid; gap: 10px; color: #1e293b; }
p, small { margin: 0; line-height: 1.6; color: #64748b; }
label { font-weight: 600; } label span { font-weight: 400; font-size: 12px; }
input { box-sizing: border-box; width: 100%; padding: 12px; border-radius: 10px; border: 1px solid #64748b66;
input, select { box-sizing: border-box; width: 100%; padding: 12px; border-radius: 10px; border: 1px solid #64748b66;
background: #f8fafc; color: inherit; font: inherit; }
input:focus { outline: 2px solid #38bdf8; }
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; }
.actions { display: flex; gap: 12px; margin-top: 8px; }
@@ -66,7 +77,7 @@ interface ModelSettings {
export class ModelSettingsComponent implements OnInit {
private http = inject(HttpClient);
saved = output<void>();
baseUrl = ''; model = ''; apiKey = ''; timeout = 120; retries = 0;
baseUrl = ''; model = ''; apiKey = ''; timeout = 300; retries = 5; retryInterval = 10; reasoning = 'high';
busy = signal<'save' | 'test' | null>(null);
loading = signal(true); keyConfigured = signal(false); envPath = signal('');
message = signal(''); error = signal('');
@@ -79,15 +90,15 @@ export class ModelSettingsComponent implements OnInit {
}
valid(): boolean {
return !!this.baseUrl.trim() && !!this.model.trim() && (!!this.apiKey.trim() || this.keyConfigured())
&& this.timeout > 0 && this.timeout <= 3600 && Number.isInteger(this.retries) && this.retries >= 0 && this.retries <= 10;
&& this.timeout > 0 && this.timeout <= 3600 && Number.isInteger(this.retries) && this.retries >= 0 && this.retries <= 10 && this.retryInterval >= 0 && this.retryInterval <= 300;
}
private payload() {
return { base_url: this.baseUrl.trim(), model: this.model.trim(), api_key: this.apiKey.trim() || undefined,
timeout_seconds: this.timeout, max_retries: this.retries };
timeout_seconds: this.timeout, max_retries: this.retries, retry_interval_seconds: this.retryInterval, reasoning_effort: this.reasoning };
}
private apply(config: ModelSettings): void {
this.baseUrl = config.base_url; this.model = config.model; this.timeout = config.timeout_seconds;
this.retries = config.max_retries; this.keyConfigured.set(config.api_key_configured); this.envPath.set(config.env_path);
this.retries = config.max_retries; this.retryInterval = config.retry_interval_seconds ?? 10; this.reasoning = config.reasoning_effort ?? 'high'; this.keyConfigured.set(config.api_key_configured); this.envPath.set(config.env_path);
}
save(): void {
if (this.busy() || !this.valid()) return;
@@ -0,0 +1,46 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { AgentService } from '../../services/agent.service';
import { TaskHistoryComponent } from './task-history.component';
describe('历史任务检索', () => {
let http: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({providers: [provideHttpClient(), provideHttpClientTesting(),
{provide: AgentService, useValue: {currentSessionId: signal(null), selectSession: jasmine.createSpy()}}]});
http = TestBed.inject(HttpTestingController);
});
afterEach(() => http.verify());
it('迟到的旧查询不会覆盖最新搜索结果', () => {
const fixture = TestBed.createComponent(TaskHistoryComponent);
const first = http.expectOne(r => r.url === '/api/automation/history');
const component = fixture.componentInstance;
component.query = '新用例';
component.load();
const latest = http.expectOne(r => r.params.get('q') === '新用例');
latest.flush({total: 1, items: [{session_id:'new', name:'新用例', status:'completed', start_time:Date.now()/1000}]});
first.flush({total: 99, items: []});
expect(component.total()).toBe(1);
expect(component.groups()[0].rows[0].session_id).toBe('new');
expect(component.groups()[0].title).toBe('今天');
fixture.destroy();
});
it('日期筛选用本地零点到次日零点,后端分页参数独立传递', () => {
const fixture = TestBed.createComponent(TaskHistoryComponent);
http.expectOne(r => r.url === '/api/automation/history').flush({total:0,items:[]});
const component = fixture.componentInstance;
component.start = '2026-09-21'; component.end = '2026-09-22'; component.page = 3;
component.load();
const req = http.expectOne(r => r.url === '/api/automation/history');
expect(req.request.params.get('start')).toBe(String(new Date('2026-09-21T00:00:00').getTime()/1000));
expect(req.request.params.get('end')).toBe(String(new Date('2026-09-23T00:00:00').getTime()/1000));
expect(req.request.params.get('page')).toBe('3');
req.flush({total: 80, items: []});
expect(component.pages()).toBe(3);
fixture.destroy();
});
});
@@ -0,0 +1,44 @@
import { Component, DestroyRef, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient, HttpParams } from '@angular/common/http';
import { AgentService } from '../../services/agent.service';
@Component({
selector:'app-task-history', standalone:true, imports:[CommonModule,FormsModule],
template:`<section aria-label="历史任务"><h3>历史任务 <small>{{total()}} 条</small></h3>
<input aria-label="搜索历史任务" placeholder="搜索名称、目标、任务编号或标签" [(ngModel)]="query" (ngModelChange)="search()">
<div class="filters"><label>起始日期<input type="date" [(ngModel)]="start" (ngModelChange)="search()"></label><label>结束日期<input type="date" [(ngModel)]="end" (ngModelChange)="search()"></label><label>状态<select [(ngModel)]="status" (ngModelChange)="search()"><option value="">全部状态</option><option value="completed">执行完成</option><option value="failed">失败</option><option value="cancelled">已取消</option><option value="interrupted">中断</option><option value="skipped">跳过</option></select></label></div>
@if(error()){<p role="alert">{{error()}}</p>}
@if(loading()){<small>正在查询…</small>}
@for(group of groups(); track group.key){<details [open]="!collapsed.has(group.key)" (toggle)="toggle(group.key,$event)"><summary>{{group.title}} <small>本页 {{group.rows.length}} 条</small></summary>
@for(row of group.rows; track row.session_id){<div class="history-item"><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)"><strong>{{row.name || row.initial_goal}}</strong><span>{{label(row.status)}} · {{row.start_time*1000 | date:'HH:mm:ss'}} · {{row.tags}}</span><small>{{row.session_id}}</small></button><button class="delete-history" (click)="remove(row.session_id)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button></div>}
</details>}
@if(!loading() && !total()){<p>没有符合条件的历史任务。</p>}
<div class="pager"><button [disabled]="page === 1 || loading()" (click)="page=page-1;load()">上一页</button><span>{{page}} / {{pages()}}</span><button [disabled]="page >= pages() || loading()" (click)="page=page+1;load()">下一页</button></div>
</section>`,
styles:[`:host{display:block;font-size:12px;color:inherit}h3{font-size:14px;margin:14px 0 8px}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{width:100%}select option{background:white;color:#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%}summary{padding:10px 0;cursor:pointer}.row{display:flex;flex-direction:column;gap:4px;width:100%;text-align:left;cursor:pointer;margin:5px 0;overflow-wrap:anywhere}.row strong{font-weight:500}.row span{opacity:.75}.row.selected{border-color:#60a5fa;background:#3b82f615}.history-item{position:relative}.history-item .row{padding-right:35px}.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:#64748b;cursor:pointer;font-size:12px}.delete-history .material-symbols-outlined{font-size:16px}.delete-history:hover{color:#b91c1c}.pager{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0}button:disabled{opacity:.4}p{opacity:.7}`]
})
export class TaskHistoryComponent {
agent=inject(AgentService); private http=inject(HttpClient); query='';start='';end='';status='';page=1;
total=signal(0);groups=signal<any[]>([]);loading=signal(false);error=signal('');collapsed=new Set<string>();
private revision=0;private debounce: ReturnType<typeof setTimeout>|undefined;
constructor(){this.load();const timer=setInterval(()=>this.load(),15000);inject(DestroyRef).onDestroy(()=>{this.revision++;clearInterval(timer);clearTimeout(this.debounce);});}
remove(id:string){if(!confirm("确定删除此历史任务?此操作无法撤销。"))return;this.agent.deleteSession(id).subscribe({next:()=>this.load(),error:()=>this.error.set("删除失败,请稍后重试")});}
pages(){return Math.max(1,Math.ceil(this.total()/30));}
label(s:string){return ({completed:'执行完成',success:'执行完成',failed:'失败',cancelled:'已取消',interrupted:'中断',skipped:'跳过'} as Record<string,string>)[s] || s;}
toggle(key:string,event:Event){if((event.target as HTMLDetailsElement).open)this.collapsed.delete(key);else this.collapsed.add(key);}
search(){this.page=1;this.revision++;clearTimeout(this.debounce);this.debounce=setTimeout(()=>this.load(),250);}
load(){
const revision=++this.revision;this.loading.set(true);this.error.set('');
let params=new HttpParams().set('q',this.query).set('page',this.page).set('status',this.status);
if(this.start)params=params.set('start',new Date(this.start+'T00:00:00').getTime()/1000);
if(this.end){const d=new Date(this.end+'T00:00:00');d.setDate(d.getDate()+1);params=params.set('end',d.getTime()/1000);}
this.http.get<any>('/api/automation/history',{params}).subscribe({next:r=>{
if(revision!==this.revision)return;
this.total.set(r.total);const map=new Map<string,any>();const today=new Date();const yesterday=new Date();yesterday.setDate(today.getDate()-1);
for(const row of r.items){const d=new Date(row.start_time*1000);const key=d.toDateString();if(!map.has(key))map.set(key,{key,title:key===today.toDateString()?'今天':key===yesterday.toDateString()?'昨天':d.toLocaleDateString('zh-CN'),rows:[]});map.get(key).rows.push(row);}
this.groups.set([...map.values()]);this.loading.set(false);
},error:e=>{if(revision===this.revision){this.loading.set(false);this.error.set(e.error?.detail || '历史查询失败,请稍后重试');}}});
}
}