feat: enable full AI automation and safe startup updates
This commit is contained in:
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 || '历史查询失败,请稍后重试');}}});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user