feat: add guided exploration and share user-started MCP backend

This commit is contained in:
chenmingxuan committed 2026-10-16 18:32:46 +08:00
1 parent f86db1ced6
commit ee256ad7ce
144 files changed
+8625 -2075

No files matched your search

@@ -506,6 +506,7 @@ limitations under the License.
<!-- Event Logs Section -->
<main class="stream-logs-content">
<ng-content select="[task-summary]"></ng-content>
@if (agentService.isSessionContentLoading()) {
<div class="session-loading-state" aria-live="polite">
<span class="material-symbols-outlined spin-animation">progress_activity</span>
@@ -513,7 +514,7 @@ limitations under the License.
</div>
}
@if (!agentService.isSessionContentLoading() && consolidatedBlocks().length === 0 && !showPlanningLoader() && startupWorkItems().length === 0) {
@if (!agentService.isSessionContentLoading() && phases().length === 0 && !showPlanningLoader() && startupWorkItems().length === 0) {
<div class="empty-state">
<div class="empty-icon"><span class="material-symbols-outlined">sensors</span></div>
<h3>尚未选择执行记录</h3>
@@ -552,6 +553,19 @@ limitations under the License.
</div>
}
@for (phase of phases(); track trackPhase($index, phase); let first = $first) {
@if(phase.demonstration; as demo) {
<article class="exploration-message demonstration"><strong>人工示范 · {{demo.source === 'android' ? '安卓' : demo.source === 'computer' ? '电脑' : '安卓 + 电脑'}} · {{demoLabel(demo.status)}}</strong>
@if(demo.analysis){<div class="markdown-content" [innerHTML]="renderMarkdown(demo.analysis.summary, phase.id)"></div>}
@if(demo.error){<p class="error">{{demo.error}}</p>}
@for(track of demo.media; track $index){@for(segment of track.segments; track segment.file){<details><summary>{{track.kind === 'android' ? '安卓' : '电脑'}}示范录像 · 片段 {{$index + 1}}</summary><video controls preload="none" [src]="demoMediaUrl(demo.run_id,segment.file)"></video><a [href]="demoMediaUrl(demo.run_id,segment.file)" download>下载录像</a></details>}}
</article>
} @else if(phase.conversation; as message) {
<article class="exploration-message" [class.user]="message.role === 'user'" [class.assistant]="message.role === 'assistant'" [class.question]="pendingQuestion() === message.id" [class.system]="message.role === 'system'">
<small><span class="material-symbols-outlined">{{message.role === 'user' ? 'person' : message.role === 'assistant' ? 'auto_awesome' : 'info'}}</span>{{message.role === 'user' ? '你的指导' : message.role === 'assistant' ? '探索助手' : '探索记录'}} · {{message.created_at * 1000 | date:'HH:mm:ss'}} @if(pendingQuestion() === message.id){<b>等待你回答</b>}</small>
<div class="markdown-content" [innerHTML]="renderMarkdown(message.text, phase.id)"></div>
@if(message.recommendation){<div class="recommendation">建议:{{message.recommendation}} @if(pendingQuestion() === message.id){<button (click)="useRecommendation.emit(message.recommendation)">采用建议</button>}</div>}
</article>
} @else {
<div class="phase-container">
@if (hasVisibleBlocks(phase) && phase.blocks[0]?.data?.phase !== 'outcome') {
<div
@@ -1323,8 +1337,9 @@ limitations under the License.
</div>
}
</div>
}
}
@if (awaitingGuidance().length) {
@if (!conversationMessages().length && awaitingGuidance().length) {
<div class="guidance-queue" aria-label="待处理用户指导">
@for (item of awaitingGuidance(); track item.id) {
<div class="guidance-queue-item">
@@ -1427,6 +1442,7 @@ limitations under the License.
}
</div>
}
<ng-content select="[task-result]"></ng-content>
</main>
<!-- Fullscreen Image Zoom Lightbox Modal -->
@@ -4066,3 +4066,13 @@ $stream-row-height: 25px;
box-shadow: none;
}
}
.stream-logs-content {padding-bottom:24px}
.exploration-message {padding:12px 16px;border:1px solid #e2e8f0;border-radius:12px;margin:14px 0;background:#fff;color:#334155;font-size:13px;line-height:1.7}
.exploration-message.user {background:#eff6ff;border-color:#dbeafe;margin-left:28px}.exploration-message.system {color:#64748b;background:#f8fafc;font-size:12px}
.exploration-message small {color:#64748b;font-size:11px}.exploration-message p {white-space:pre-wrap;overflow-wrap:anywhere;margin:6px 0}.exploration-message button {font:inherit;padding:4px 10px;border:1px solid #cbd5e1;border-radius:6px;background:#fff;color:#334155;cursor:pointer}
.exploration-message video {width:100%;max-height:300px;background:#0f172a}.exploration-message summary {cursor:pointer}.exploration-message .error {color:#b91c1c}
.exploration-message.assistant {border-left:3px solid #818cf8;background:#fafaff}
.exploration-message.question {border:1px solid #f4c97a;border-left:3px solid #f59e0b;background:#fffbeb}
.exploration-message small {display:flex;align-items:center;gap:6px;font-size:11px;margin-bottom:8px}.exploration-message small .material-symbols-outlined {font-size:16px;color:#6366f1}.exploration-message small b {margin-left:auto;color:#92400e;background:#fef3c7;border-radius:12px;padding:2px 8px}
@@ -1,3 +1,5 @@
import { ExplorationMessage, Demonstration } from '../../services/exploration.service';
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 { AutomationPanelComponent } from '../automation-panel/automation-panel.component';
@@ -18,7 +20,7 @@ import { UiLabelPipe } from '../../utils/ui-label.pipe';
* limitations under the License.
*/
import { Component, ChangeDetectionStrategy, NgZone, signal, computed, effect, inject, untracked, DestroyRef, AfterViewInit } from '@angular/core';
import { Component, ChangeDetectionStrategy, NgZone, signal, computed, effect, inject, untracked, DestroyRef, AfterViewInit, input, output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
@@ -248,6 +250,13 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote, StreamRe
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AgentStreamComponent implements AfterViewInit {
conversationMessages = input<ExplorationMessage[]>([]);
demonstrations = input<Demonstration[]>([]);
pendingQuestion = input('');
demoMediaUrl(runId: string, path: string) { return '/videos/' + [runId, ...path.split('/')].map(encodeURIComponent).join('/'); }
demoLabel(status: string) { return ({requested:'准备录制',recording:'录制中',analyzing:'分析中',completed:'已分析',failed:'分析失败',cancelled:'已取消',timed_out:'到时已保存'} as Record<string,string>)[status] || status; }
useRecommendation = output<string>();
public agentService = inject(AgentService);
private http = inject(HttpClient);
private destroyRef = inject(DestroyRef);
@@ -393,7 +402,7 @@ export class AgentStreamComponent implements AfterViewInit {
public activeQueue = computed(() => {
const list = this.agentService.sessions().filter((s) => {
const status = this.getTaskStatus(s);
return ['running', 'paused', 'task_paused', 'resuming', 'pending', 'queued'].includes(status) && this.agentService.matchesBatchFilter(s);
return ['running', 'paused', 'task_paused', 'resuming', 'pending', 'queued'].includes(status) && this.agentService.matchesBatchFilter(s) && this.agentService.isQueueRepresentative(s);
});
return list.sort((a, b) => {
const statusA = this.getTaskStatus(a);
@@ -501,9 +510,9 @@ export class AgentStreamComponent implements AfterViewInit {
return terminal ? '模型已读取,未留下后续动作记录' : '模型已读取,等待动作记录';
}
public phases = computed<PhaseBlock[]>(() => {
public phases = computed(() => {
const currentSession = this.agentService.sessions().find(s => s.session_id === this.agentService.currentSessionId());
return groupBlocksToPhases(this.consolidatedBlocks(), currentSession?.start_time || 0);
return mergeConversation(groupBlocksToPhases(this.consolidatedBlocks(), currentSession?.start_time || 0), this.conversationMessages(), this.demonstrations());
});
public startupWorkItems = computed(() => {
@@ -568,6 +577,12 @@ export class AgentStreamComponent implements AfterViewInit {
}
constructor() {
const conversationRevision = computed(() => JSON.stringify([
this.conversationMessages().map(message => message.id),
this.demonstrations().map(demo => [demo.id,demo.status])
]));
effect(() => { conversationRevision(); untracked(() => this.scheduleAutoScroll(false)); });
effect((onCleanup) => {
const id = this.agentService.currentSessionId();
const status = this.selectedTerminalStatus();
@@ -924,6 +939,9 @@ export class AgentStreamComponent implements AfterViewInit {
}
public getTaskStatus(session: Session): string {
const exploration = this.agentService.explorationQueueStatus(session);
if (exploration) return exploration as 'running' | 'paused' | 'completed' | 'pending' | 'failed' | 'cancelled';
if (session.status) {
const s = session.status.toLowerCase();
if (['completed', 'success', 'failed', 'cancelled', 'interrupted', 'skipped'].includes(s)) {
@@ -2233,6 +2251,7 @@ export class AgentStreamComponent implements AfterViewInit {
public getModelIcon(name: string | undefined | null): string {
if (!name) return 'bolt';
const lower = name.toLowerCase();
if (lower === '探索') return 'explore';
if (lower.includes('pro')) return 'diamond';
if (lower.includes('flash')) return 'bolt';
return 'smart_toy';
@@ -2248,6 +2267,7 @@ export class AgentStreamComponent implements AfterViewInit {
public getArchitectureTooltip(model?: ModelInfo | null): string {
if (!model) return "执行模式:ARTEMIS Flash(快速响应)";
if (model.architecture === 'explore') return `探索模式(可交流与人工示范) · LLM: ${model.id}`;
const name = this.getModelDisplayName(model.name);
const isPro = name.toLowerCase().includes('pro');
const archDesc = isPro
@@ -82,9 +82,11 @@ limitations under the License.
@if (getTaskStatus(session) === 'running') {
<span class="status-dot"></span>
}
{{ getTaskStatus(session) | uiLabel }}
{{ agentService.explorationStatusLabel(session) || (getTaskStatus(session) | uiLabel) }}
</span>
<app-test-result-badge [result]="session.test_result" [executionStatus]="getTaskStatus(session)" [compact]="true" />
@if (session.exploration_kind !== 'exploration') { <app-test-result-badge [result]="session.test_result" [executionStatus]="getTaskStatus(session)" [compact]="true" /> }
<app-task-mode-badge [session]="session" />
@if (session.exploration_kind === 'validation') { <span class="task-origin-badge">用例复跑 · v{{session.case_version}}</span> }
</div>
<div class="task-card-header-actions">
@if (getDeviceSerial(session)) {
@@ -335,7 +335,8 @@ $text-muted: #a1a1aa;
gap: 8px;
min-width: 0;
.task-status-group { display: inline-flex; align-items: center; gap: 6px; flex-shrink: 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-badge {
flex-shrink: 0;
@@ -24,6 +24,7 @@ import { FormsModule } from '@angular/forms';
import { AgentService } from '../../services/agent.service';
import { Session } from '../../core/models/session.model';
import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
import { TaskModeBadgeComponent } from '../task-mode-badge/task-mode-badge.component';
import { TaskBatchFilterComponent } from '../task-batch-filter/task-batch-filter.component';
import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model';
import { parseNote, parseNoteLines } from '../../utils/markdown-parser.util';
@@ -33,7 +34,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote };
@Component({
selector: 'app-chat-interface',
standalone: true,
imports: [TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, AutomationPanelComponent, TaskHistoryComponent, TestResultBadgeComponent, TaskBatchFilterComponent],
imports: [TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, AutomationPanelComponent, TaskHistoryComponent, TestResultBadgeComponent, TaskModeBadgeComponent, TaskBatchFilterComponent],
templateUrl: './chat-interface.component.html',
styleUrl: './chat-interface.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
@@ -56,7 +57,7 @@ export class ChatInterfaceComponent {
public activeQueue = computed(() => {
const list = this.agentService.sessions().filter((s) => {
const status = this.getTaskStatus(s);
return ['running', 'paused', 'task_paused', 'resuming', 'pending', 'queued'].includes(status) && this.agentService.matchesBatchFilter(s);
return ['running', 'paused', 'task_paused', 'resuming', 'pending', 'queued'].includes(status) && this.agentService.matchesBatchFilter(s) && this.agentService.isQueueRepresentative(s);
});
return list.sort((a, b) => {
const statusA = this.getTaskStatus(a);
@@ -182,6 +183,9 @@ export class ChatInterfaceComponent {
* Determine the current task execution status
*/
public getTaskStatus(session: Session): 'running' | 'paused' | 'completed' | 'pending' | 'failed' | 'cancelled' {
const exploration = this.agentService.explorationQueueStatus(session);
if (exploration) return exploration as 'running' | 'paused' | 'completed' | 'pending' | 'failed' | 'cancelled';
if (session.status) {
const s = session.status.toLowerCase();
if (s === 'completed' || s === 'success' || s === 'failed' || s === 'cancelled') {
@@ -0,0 +1,57 @@
@if(session(); as s) {
<app-agent-stream [conversationMessages]="messages()" [demonstrations]="s.demonstrations" [pendingQuestion]="s.pending_question || ''" (useRecommendation)="text = $event; send()">
<section task-summary class="session-summary">
<div class="row"><strong><span class="material-symbols-outlined">explore</span> {{activeValidation() ? '用例复跑中' : label(s.status)}}</strong><small>{{s.config.model}} / {{s.config.reasoning_effort}} · 续接 {{s.continuation_count}} 次</small></div>
<p>{{s.goal}}</p>
@if(s.pause_reason === 'execution_budget'){<p class="feedback">本段执行到时,进度已保存。可继续探索,不计为测试失败。</p>}
@if(s.pause_reason === 'device_observation'){<p class="feedback" role="status">设备画面暂时不可用,探索已暂停。指导已保留,请确认设备恢复响应后点击“继续探索”。</p>}
<details class="execution-history"><summary>执行记录({{s.runs.length}}) · 当前展示所选轮次的操作步骤</summary>@for(run of s.runs; track run.id){<div class="run"><button class="run-link" (click)="agent.selectSession(run.id,true)">{{run.kind === 'validation' ? '用例 v' + run.case_version + ' 复跑' : '探索执行'}} · {{label(run.status)}}{{agent.currentSessionId() === run.id ? ' · 当前查看' : ''}}</button><app-test-result-badge [result]="run.test_result" [executionStatus]="run.status" [compact]="true" /></div>}</details>
</section>
<div task-result>
@if(showCase()) {
<section class="case-editor" aria-label="可复用用例"><div class="row"><strong class="case-title"><span class="material-symbols-outlined">task_alt</span>{{s.case ? '探索成果 · 可复用用例' : '本轮探索已结束'}}</strong><button (click)="generate()" [disabled]="busy() || !!s.active_run_id || generating() || dirty">{{generating() ? '正在提炼…' : s.case ? '重新提炼' : '提炼用例'}}</button></div>
@if(!s.case){<p class="muted">探索验收通过后自动生成;也可以先编辑并保存草稿。</p><button (click)="editorOpen.set(true)">编辑草稿</button>}
@if(s.generations[0]?.status === 'failed'){<p class="error">上次提炼未完成,执行结果保留。可重试提炼。</p>}
@if(s.case){<div class="case-compact"><h3>{{s.case.name}} <small>v{{s.case.version}}</small></h3><p class="muted">已整理为一个可复用用例。你可以查看修改,或交给 Flash / Pro 独立复跑。</p><div class="actions"><button class="primary" (click)="copyCase()"><span class="material-symbols-outlined">content_copy</span>复制任务提示词</button><button (click)="editorOpen.set(!editorOpen())">{{editorOpen() ? '收起编辑' : '查看 / 编辑'}}</button><button (click)="rerunOpen.set(!rerunOpen())">复跑 / 批次</button></div></div>}
@if(editorOpen()) {
@if(s.case){<label>查看版本<select [ngModel]="editVersion" (ngModelChange)="selectVersion($event)" [disabled]="dirty">@for(version of versions(); track version){<option [value]="version">v{{version}}</option>}</select></label>}
@if(dirty && editVersion !== s.case_version){<p class="error">已有新版本;当前编辑仍保留,请先复制修改内容再加载最新版本。</p>}
<label>用例名称<input [(ngModel)]="editName" (ngModelChange)="dirty = true" maxlength="200" /></label><label>前置条件<textarea [(ngModel)]="editPreconditions" (ngModelChange)="dirty = true" rows="2" maxlength="10000"></textarea></label><label>任务提示词<textarea [(ngModel)]="editGoal" (ngModelChange)="dirty = true" rows="7" maxlength="10000"></textarea></label>
<div class="actions"><button (click)="saveCase()" [disabled]="busy() || !dirty || !editName.trim() || !editGoal.trim()">保存新版本</button><button (click)="copyCase()" [disabled]="!editGoal.trim()">复制提示词</button></div>
}
@if(rerunOpen()) {<div class="rerun-options"><datalist id="exploration-models">@for(model of models;track model){<option [value]="model"></option>}</datalist><strong>复跑与批次</strong><p class="muted">固定使用所选已保存版本。复跑通过与探索通过分别记录。</p><label>执行模式<select [(ngModel)]="rerunProfile"><option value="flash">Flash</option><option value="pro">Pro</option></select></label><label>复跑模型<input [(ngModel)]="rerunModel" list="exploration-models" placeholder="留空使用 Flash / Pro 默认模型" /></label><label>思考强度<select [(ngModel)]="rerunReasoning">@for(effort of efforts; track effort){<option [value]="effort">{{effort}}</option>}</select></label><div class="actions"><button (click)="rerun()" [disabled]="busy() || dirty || !editVersion || !!s.active_run_id">复跑此版本</button><button (click)="rerun('batch')" [disabled]="busy() || dirty || !editVersion">加入新批次草稿</button></div></div>}
</section>
}
</div>
</app-agent-stream>
<section class="composer" [class.needs-answer]="!!s.pending_question" aria-label="指导当前探索">
@if(error()){<p role="alert" class="feedback error">{{error()}} <button (click)="refresh()">重试读取</button></p>}
@if(notice()){<p role="status" class="feedback">{{notice()}}</p>}
@if(s.pending_question) {
<div class="question-banner" role="status" aria-live="polite"><span class="material-symbols-outlined">chat_bubble</span><div><strong>探索已暂停,等待你回答</strong><p>{{pendingQuestion()?.text || '探索助手有一个问题需要你确认,请查看上方对话。'}}</p>@if(pendingQuestion()?.recommendation; as suggestion){<button (click)="text = suggestion; send()" [disabled]="busy()">采用建议并继续</button>}</div></div>
} @else if(finalizing()) {
<div class="feedback" role="status">设备操作已结束,正在根据已验证的步骤提炼用例。完成后会在上方展示。</div>
}
@if(activeDemo(); as demo) {
<section class="demo" [class.warning]="demo.status === 'recording' && secondsLeft(demo) <= demo.warning_seconds" aria-live="polite"><strong><span class="record-dot" [class.recording]="demo.status === 'recording'"></span>{{label(demo.status)}}</strong>
@if(demo.status === 'recording'){<p>现在可以操作设备。剩余 {{secondsLeft(demo)}} 秒;AI 已暂停操作。</p><div class="actions"><button class="primary" (click)="demoAction('finish')" [disabled]="busy()">操作完成,开始分析</button><button (click)="demoAction('extend')" [disabled]="busy()">延长示范</button></div>}
@else {<p>{{demo.status === 'requested' ? '正在等待当前动作安全结束,请等“录制中”出现后再操作。' : '正在保存并分析本次示范录像。'}}</p>}
@if(['requested','recording'].includes(demo.status)){<button (click)="demoAction('cancel')" [disabled]="busy()">取消示范</button>}
</section>
} @else if(demoOptionsOpen() && s.active_run_id && !activeValidation() && ['running','waiting_user'].includes(s.status)) {
<div class="manual-control"><label>示范来源<select [(ngModel)]="source"><option value="android">安卓</option><option value="computer">电脑</option><option value="both">安卓 + 电脑</option></select></label><button (click)="action('demonstrations', {source})" [disabled]="busy()"><span class="material-symbols-outlined">touch_app</span>开始示范</button></div>
}
@if(activeValidation()){<p class="feedback">用例正在独立复跑,请进入复跑任务查看或指导。<button (click)="agent.selectSession(s.active_run_id!,true)">查看当前复跑</button></p>}
<div class="composer-heading"><label for="exploration-message">{{s.pending_question ? '回答探索助手' : '与探索助手交流'}}</label><span class="composer-status" [class.waiting]="!!s.pending_question">{{label(s.status)}}</span></div>
<textarea id="exploration-message" [(ngModel)]="text" (keydown)="onKeyDown($event)" rows="2" maxlength="10000" [placeholder]="s.pending_question ? '在这里回答,发送后继续探索…' : '告诉我下一步怎么做,或补充你的想法…'" [disabled]="busy() || activeValidation() || finalizing()"></textarea>
<div class="actions composer-actions">
@if(!activeDemo() && s.active_run_id && !activeValidation() && ['running','waiting_user'].includes(s.status)){<button class="tool-button" (click)="demoOptionsOpen.set(!demoOptionsOpen())" [attr.aria-expanded]="demoOptionsOpen()"><span class="material-symbols-outlined">touch_app</span>我来操作</button>}
@if(!activeValidation() && !s.pending_question && (!s.active_run_id || s.status === 'paused')){<button (click)="action(s.status === 'draft' ? 'start' : 'continue')" [disabled]="busy()">{{s.status === 'draft' ? '开始探索' : '继续探索'}}</button>}
@if(s.active_run_id && !activeValidation() && !finalizing()){<button class="tool-button" (click)="action('pause')" [disabled]="busy() || ['paused','waiting_user','demonstrating'].includes(s.status)">暂停</button><button class="tool-button" (click)="action('stop')" [disabled]="busy()">停止</button>}
<small class="send-hint">Enter 发送 · Shift+Enter 换行</small>
<button class="primary send-button" (click)="send()" [disabled]="busy() || activeValidation() || finalizing() || !text.trim()" [attr.aria-label]="s.pending_question ? '回答并继续' : '发送'"><span>{{s.pending_question ? '回答并继续' : '发送'}}</span><span class="material-symbols-outlined">arrow_upward</span></button>
</div>
@if(!s.active_run_id){<p class="composer-footnote">可在此讨论和修订用例;继续操作设备请点击“继续探索”。</p>}
</section>
} @else {<div class="empty" role="status">{{loading() ? '正在读取探索记录…' : '探索记录暂时无法读取'}} @if(error()){<p class="error">{{error()}}</p><button (click)="refresh()">重试</button>}</div>}
@@ -0,0 +1,77 @@
:host { display:flex; flex-direction:column; min-height:0; height:100%; overflow:hidden; color:#334155; font-size:13px; }
.exploration-panel { padding:18px; display:flex; flex-direction:column; gap:16px; }
header,.row,.actions,.manual-control { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
header,.row { justify-content:space-between; }
header strong { font-size:15px; color:#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; }
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:disabled { opacity:.5; cursor:default; }
button.primary { background:#0f172a; color:white; border-color:#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; }
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; }
.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; }
.recommendation button { margin:6px 0 0 8px; }
.composer { border-top:1px solid #e2e8f0; padding-top:4px; }
.composer > button { margin-top:8px; }
.manual-control { padding:10px 12px; border:1px solid #dbe3ec; border-radius:10px; background:#fafcfd; }
.manual-control label { flex:1; margin:0; }
.manual-control button { align-self:flex-end; }
.demo { border:1px solid #bbf7d0; border-radius:12px; padding:12px; background:#f0fdf4; }
.demo.warning { background:#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; }
.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; }
.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 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}
.manual-control {padding:0;margin-bottom:8px;border:0;background:transparent;justify-content:flex-end}.manual-control label {flex:none;flex-direction:row;align-items:center}.manual-control select {width: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-status {font-size:11px;border-radius:20px;padding:3px 8px;background:#eef2ff;color:#4f46e5}
.composer-status.waiting {color:#92400e;background:#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}
.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}.session-summary p {color:#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}}
@@ -0,0 +1,97 @@
import { Component, input, output, signal } from '@angular/core';
import { TestBed, fakeAsync, flushMicrotasks } from '@angular/core/testing';
import { ExplorationPanelComponent } from './exploration-panel.component';
import { ExplorationService, ExplorationSession } from '../../services/exploration.service';
import { AgentService } from '../../services/agent.service';
import { AgentStreamComponent } from '../agent-stream/agent-stream.component';
@Component({selector:'app-agent-stream',standalone:true,template:'<ng-content></ng-content>'}) class StreamStub {conversationMessages=input<any[]>([]);demonstrations=input<any[]>([]);pendingQuestion=input('');useRecommendation=output<string>();}
describe('Exploration conversation', () => {
const config = {model: 'gpt-6.1-sol', reasoning_effort: 'high', timeout_seconds: 1800, demonstration_timeout_seconds: 900, demonstration_warning_seconds: 60};
let current: ExplorationSession;
let api: any;
beforeEach(() => {
current = {id:'s', goal:'检查设置', status:'running', config, active_run_id:'run', active_demonstration_id:null, case_version:0, message_count:0, elapsed_seconds:10, continuation_count:0, messages:[], runs:[], case:null, demonstrations:[], generations:[]};
api = {drafts: new Map(),caseDrafts:new Map(),requests:new Map(),settings: jasmine.createSpy().and.resolveTo(config), list:jasmine.createSpy().and.resolveTo([]), get:jasmine.createSpy().and.callFake(async () => structuredClone(current)), act:jasmine.createSpy().and.resolveTo({}), version:jasmine.createSpy(), saveSettings:jasmine.createSpy().and.resolveTo(config)};
TestBed.configureTestingModule({imports:[ExplorationPanelComponent], providers:[
{provide:ExplorationService,useValue:api},
{provide:AgentService,useValue:{selectSession:jasmine.createSpy(),fetchSessions:jasmine.createSpy(),fetchStatus:jasmine.createSpy(),currentSessionId:signal('run')}}
]}).overrideComponent(ExplorationPanelComponent,{remove:{imports:[AgentStreamComponent]},add:{imports:[StreamStub]}});
});
function render() {
const fixture = TestBed.createComponent(ExplorationPanelComponent);
fixture.componentRef.setInput('sessionId','s');
fixture.detectChanges(); flushMicrotasks(); fixture.detectChanges();
return fixture;
}
it('highlights unanswered questions beside the composer and hides unfinished cases', fakeAsync(() => {
current.status='waiting_user'; current.pending_question='q';
current.messages=[{id:'q',sequence:1,role:'assistant',kind:'question',text:'下一步是否回到主页?',created_at:1}];
current.case={version:1,name:'旧用例',goal:'旧目标',preconditions:'',evidence:[]};current.case_version=1;
const fixture=render();
expect(fixture.nativeElement.querySelector('.question-banner').textContent).toContain('下一步是否回到主页');
expect(fixture.nativeElement.querySelector('[aria-label="可复用用例"]')).toBeNull();
expect(fixture.nativeElement.textContent).not.toContain('继续探索');
current.active_run_id=null;current.status='completed';current.pending_question=undefined;
void fixture.componentInstance.refresh();flushMicrotasks();fixture.detectChanges();
expect(fixture.nativeElement.querySelector('[aria-label="可复用用例"]').textContent).toContain('旧用例');
expect(fixture.nativeElement.querySelector('.question-banner')).toBeNull();fixture.destroy();
}));
it('keeps a draft and disables guidance while the verified run is being synthesized', fakeAsync(() => {
current.status='finalizing';
const fixture=render();fixture.componentInstance.text='返回主页';
void fixture.componentInstance.send();flushMicrotasks();
expect(api.act).not.toHaveBeenCalled();expect(fixture.componentInstance.text).toBe('返回主页');
expect(fixture.nativeElement.querySelector('textarea').disabled).toBeTrue();fixture.destroy();
}));
it('only invites manual input when the recorder is ready, and warns before its deadline', fakeAsync(() => {
current.active_demonstration_id='demo';
current.demonstrations=[{id:'demo',run_id:'run',source:'android',status:'requested',warning_seconds:60,media:[]}];
const fixture=render();
expect(fixture.nativeElement.textContent).toContain('请等“录制中”出现后再操作');
expect(fixture.nativeElement.textContent).not.toContain('现在可以操作设备');
current.demonstrations[0]={...current.demonstrations[0],status:'recording',deadline:Date.now()/1000+30};
void fixture.componentInstance.refresh(); flushMicrotasks(); fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('现在可以操作设备');
expect(fixture.nativeElement.querySelector('.demo.warning')).not.toBeNull();
fixture.destroy();
}));
it('shows budget exhaustion as a resumable pause, never a failed test', fakeAsync(() => {
current.status='paused'; current.pause_reason='execution_budget'; current.active_run_id=null;
current.case={version:1,name:'旧用例',goal:'旧目标',preconditions:'',evidence:[]};
const fixture=render();
expect(fixture.nativeElement.textContent).toContain('不计为测试失败');
expect(fixture.nativeElement.querySelector('[aria-label="可复用用例"]')).toBeNull();
const button=[...fixture.nativeElement.querySelectorAll('button')].find((b:any)=>b.textContent.includes('继续探索')) as HTMLButtonElement;
button.click(); flushMicrotasks();
expect(api.act.calls.mostRecent().args[1]).toBe('continue');
fixture.destroy();
}));
it('retains the request ID and unsent text when a send response is lost', fakeAsync(() => {
const fixture=render();
api.act.and.rejectWith(new Error('network'));
fixture.componentInstance.text='改用右上角入口';
void fixture.componentInstance.send(); flushMicrotasks();
const first=api.act.calls.mostRecent().args[3];
expect(fixture.componentInstance.text).toBe('改用右上角入口');
api.act.and.resolveTo({});
void fixture.componentInstance.send(); flushMicrotasks();
expect(api.act.calls.mostRecent().args[3]).toBe(first);
expect(fixture.componentInstance.text).toBe('');
fixture.destroy();
}));
it('preserves unsaved edits across polling, but clears them when choosing another conversation', fakeAsync(() => {
current.case_version=1; current.case={version:1,name:'检查',goal:'旧目标',preconditions:'',evidence:[]};
const fixture=render();
fixture.componentInstance.editGoal='用户草稿'; fixture.componentInstance.dirty=true;
current.case={...current.case,version:2,goal:'其他窗口保存'}; current.case_version=2;
void fixture.componentInstance.refresh(); flushMicrotasks();
expect(fixture.componentInstance.editGoal).toBe('用户草稿');
current={...current,id:'other',case:null,case_version:0}; fixture.componentRef.setInput('sessionId','other');
fixture.detectChanges(); flushMicrotasks(); fixture.detectChanges();
expect(fixture.componentInstance.editGoal).toBe('');
expect(api.caseDrafts.get('s')?.goal).toBe('用户草稿');
fixture.destroy();
}));
});
@@ -0,0 +1,165 @@
import { Component, ChangeDetectionStrategy, DestroyRef, effect, inject, signal, untracked, input } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { AgentStreamComponent } from '../agent-stream/agent-stream.component';
import { ExplorationService, ExplorationSession, ExplorationMessage, Demonstration } from '../../services/exploration.service';
import { AgentService } from '../../services/agent.service';
import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
@Component({
selector: 'app-exploration-panel', standalone: true,
imports: [FormsModule, AgentStreamComponent, TestResultBadgeComponent],
templateUrl: './exploration-panel.component.html', styleUrl: './exploration-panel.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ExplorationPanelComponent {
api = inject(ExplorationService);
agent = inject(AgentService);
private destroyRef = inject(DestroyRef);
session = signal<ExplorationSession | null>(null);
messages = signal<ExplorationMessage[]>([]);
loading = signal(false);
busy = signal(false);
error = signal('');
notice = signal('');
now = signal(Date.now() / 1000);
sessionId = input.required<string>();
editorOpen = signal(false);
rerunOpen = signal(false);
demoOptionsOpen = signal(false);
get text() { return this.api.drafts.get(this.sessionId()) || ''; }
set text(value: string) { this.api.drafts.set(this.sessionId(), value); }
source = 'android';
editName = '';
editGoal = '';
editPreconditions = '';
editVersion = 0;
dirty = false;
rerunProfile = 'flash';
rerunModel = '';
rerunReasoning = 'high';
private polling = false;
private refreshSerial = 0;
private followsLatest = true;
private requests = this.api.requests;
private displayedId: string | null = null;
readonly models = ['gpt-6.1-sol', 'gpt-6-astra', 'gpt-6-luna', 'mimo-v2.6-pro', 'mimo-v2.6-flash'];
readonly efforts = ['default', 'none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
constructor() {
effect(() => {
const id = this.sessionId();
untracked(() => {
this.rememberDraft(); this.displayedId = id;
this.session.set(null); this.messages.set([]); this.dirty = false; this.editVersion = 0; this.followsLatest = true;
this.editName = ''; this.editGoal = ''; this.editPreconditions = '';
this.editorOpen.set(false); this.rerunOpen.set(false); this.error.set(''); this.notice.set('');
const draft = this.api.caseDrafts.get(id);
if (draft) { this.loadCase(draft); this.dirty = true; this.editorOpen.set(true); }
if (id) void this.refresh(id);
});
});
const interval = setInterval(() => {
this.now.set(Date.now() / 1000);
const id = this.sessionId();
if (id && !this.polling) void this.refresh(id, false);
}, 2000);
this.destroyRef.onDestroy(() => { this.rememberDraft(); this.refreshSerial++; clearInterval(interval); });
}
private rememberDraft() {
if (!this.displayedId) return;
if (this.dirty) this.api.caseDrafts.set(this.displayedId, {name:this.editName,goal:this.editGoal,preconditions:this.editPreconditions,version:this.editVersion});
else this.api.caseDrafts.delete(this.displayedId);
}
async refresh(id = this.sessionId(), initial = true) {
if (!id || this.destroyRef.destroyed) return;
const serial = ++this.refreshSerial;
if (initial) this.loading.set(true);
this.polling = true;
try {
const current = this.session();
const after = current?.id === id ? (this.messages().at(-1)?.sequence || 0) : 0;
const value = await this.api.get(id, after);
if (id !== this.sessionId() || serial !== this.refreshSerial) return;
const combined = new Map((current?.id === id ? this.messages() : []).map(m => [m.id, m]));
value.messages.forEach(m => combined.set(m.id, m));
this.messages.set([...combined.values()].sort((a, b) => a.sequence - b.sequence));
this.session.set(value);
if (!current) this.source = value.task_config?.['execution_target'] === 'computer' ? 'computer' : 'android';
if (value.case && this.followsLatest && !this.dirty && this.editVersion !== value.case.version) this.loadCase(value.case);
} catch (e) { if (id === this.sessionId()) this.showError(e); }
finally { if (serial === this.refreshSerial) { this.polling = false; this.loading.set(false); } }
}
private loadCase(value: {name: string; goal: string; preconditions: string; version: number}) {
this.editName = value.name; this.editGoal = value.goal; this.editPreconditions = value.preconditions;
this.editVersion = value.version; this.dirty = false;
}
async selectVersion(version: string) {
const id = this.sessionId(); if (!id || this.dirty) return;
this.followsLatest = Number(version) === this.session()?.case_version;
try { const value = await this.api.version(id, Number(version)); if (id === this.sessionId()) this.loadCase(value); }
catch(e) { this.showError(e); }
}
versions() { return Array.from({length: this.session()?.case_version || 0}, (_, i) => i + 1); }
label(status: string) {
return ({draft:'未开始', queued:'等待执行', reserved:'等待执行', running:'探索中', waiting_user:'等待你回答', demonstrating:'人工示范', analyzing_demo:'分析示范', finalizing:'正在提炼用例', paused:'已暂停,可继续', completed:'探索已结束', cancelled:'已取消', failed:'执行失败', interrupted:'已中断,可继续', requested:'等待安全接管', recording:'录制中', analyzing:'分析中', timed_out:'示范到时,已保存'} as Record<string,string>)[status] || status;
}
pendingQuestion() { return this.messages().find(m => m.id === this.session()?.pending_question); }
finalizing() { return this.session()?.status === 'finalizing'; }
showCase() { const s = this.session(); return !!s && !s.active_run_id && ['completed', 'cancelled'].includes(s.status); }
activeDemo() { const s = this.session(); return s?.demonstrations.find(d => d.id === s.active_demonstration_id); }
activeValidation() { const s = this.session(); return s?.runs.some(r => r.id === s.active_run_id && r.kind === 'validation') || false; }
secondsLeft(demo: Demonstration) { return Math.max(0, Math.ceil((demo.deadline || this.now()) - this.now())); }
async action(action: string, payload: Record<string, unknown> = {}): Promise<Record<string, unknown> | null> {
const id = this.sessionId(); if (!id || this.busy()) return null;
const selectedRun = this.agent.currentSessionId();
const key = JSON.stringify([id, action, payload]);
const requestId = this.requests.get(key) || crypto.randomUUID(); this.requests.set(key, requestId);
this.busy.set(true); this.error.set(''); this.notice.set('');
try {
const value = await this.api.act(id, action, payload, requestId);
this.requests.delete(key);
await this.refresh(id, false);
const run = value['run'] as {id?: string} | undefined;
if (id === this.sessionId() && selectedRun === this.agent.currentSessionId() && run?.id) { this.agent.selectSession(run.id, true); }
this.agent.fetchSessions(); this.agent.fetchStatus();
return id === this.sessionId() ? value : null;
} catch (e) { if (id === this.sessionId()) this.showError(e); return null; }
finally { this.busy.set(false); }
}
async send() {
const text = this.text.trim(), id = this.sessionId(); if (!text || this.activeValidation() || this.finalizing()) return;
if (await this.action('messages', {text}) && this.api.drafts.get(id)?.trim() === text) this.api.drafts.delete(id);
}
demoAction(action: string) {
const demo = this.activeDemo();
if (demo) void this.action(`demonstrations/${demo.id}/${action}`, action === 'extend' ? {seconds: this.session()?.config.demonstration_timeout_seconds || 900} : {});
}
onKeyDown(event: KeyboardEvent) {
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) { event.preventDefault(); void this.send(); }
}
async saveCase() {
if (!this.editName.trim() || !this.editGoal.trim()) return;
const result = await this.action('case', {expected_version: this.editVersion, case: {name: this.editName, goal: this.editGoal, preconditions: this.editPreconditions, evidence: this.session()?.case?.evidence || []}});
if (result) { this.dirty = false; this.followsLatest = true; await this.refresh(); this.notice.set('用例已保存为新版本'); }
}
generate() {
const source = [...(this.session()?.runs || [])].reverse().find(r => r.kind === 'exploration' && r.test_result?.status === 'passed' && r.status === 'completed');
if (source) void this.action('generate', {source_run_id: source.id, expected_version: this.session()?.case_version || 0});
else this.error.set('尚无验证通过的探索执行,请继续探索;也可以手动保存草稿。');
}
generating() { return this.session()?.generations.some(g => g.status === 'running') || false; }
rerun(action = 'validate') {
const task_config: Record<string, unknown> = {};
if (this.rerunModel.trim()) { task_config['model'] = this.rerunModel.trim(); task_config['reasoning_effort'] = this.rerunReasoning; }
const payload: Record<string, unknown> = {version: this.editVersion, profile: this.rerunProfile, task_config};
if (action === 'batch') payload['name'] = this.editName;
void this.action(action, payload).then(value => { if (value && action === 'batch') this.notice.set('已创建批次草稿,可在“报告与批量”中查看并启动。'); });
}
async copyCase() {
try { await navigator.clipboard.writeText(`${this.editPreconditions ? '前置条件:' + this.editPreconditions + '\n\n' : ''}${this.editGoal}`); this.notice.set('用例提示词已复制'); }
catch { this.error.set('剪贴板不可用,请选中下方提示词复制'); }
}
mediaUrl(runId: string, path: string) { return '/videos/' + [runId, ...path.split('/')].map(encodeURIComponent).join('/'); }
private showError(error: unknown) { const e = error as {error?: {detail?: unknown}; message?: string}; this.error.set(typeof e.error?.detail === 'string' ? e.error.detail : e.message || '操作失败,请刷新状态后重试'); }
}
@@ -84,6 +84,12 @@ interface Capabilities {
.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}
@media(max-width: 480px) { .number-fields { grid-template-columns: 1fr; } }`]
})
export class CapabilitySettingsComponent implements OnInit {
@@ -71,9 +71,9 @@ describe('持久化模型与功能设置', () => {
expect(input.autocomplete).toBe('off');
input.value = 'custom/model-v2';
input.dispatchEvent(new Event('input'));
const reasoning = root.querySelector<HTMLSelectElement>('#model-reasoning')!;
reasoning.value = 'max';
reasoning.dispatchEvent(new Event('change'));
const reasoning = root.querySelector<HTMLInputElement>('input[aria-label="思考强度"]')!;
reasoning.value = '7';
reasoning.dispatchEvent(new Event('input'));
fixture.detectChanges();
await fixture.whenStable();
expect(presets[0].getAttribute('aria-pressed')).toBe('false');
@@ -90,7 +90,7 @@ describe('持久化模型与功能设置', () => {
reopened.detectChanges();
await reopened.whenStable();
expect(reopened.nativeElement.querySelector('#model-name').value).toBe('custom/model-v2');
expect(reopened.nativeElement.querySelector('#model-reasoning').value).toBe('max');
expect(reopened.nativeElement.querySelector('input[aria-label="思考强度"]').getAttribute('aria-valuetext')).toBe('最高 · max');
});
it('高级稳定性设置可编辑并保存', async () => {
@@ -2,6 +2,7 @@ import { Component, OnInit, inject, signal, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { finalize } from 'rxjs';
import { SettingSliderComponent, REASONING_VALUES, REASONING_LABELS } from '../setting-slider/setting-slider.component';
interface ModelSettings {
base_url: string;
@@ -24,7 +25,7 @@ interface ModelSettings {
@Component({
selector: 'app-model-settings',
standalone: true,
imports: [FormsModule],
imports: [FormsModule, SettingSliderComponent],
template: `
<form class="model-settings" (ngSubmit)="save()">
<p>统一使用 OpenAI Responses。保存后新任务立即生效,重启后自动加载。</p>
@@ -43,14 +44,7 @@ interface ModelSettings {
[(ngModel)]="model" required placeholder="输入接口支持的模型名" aria-describedby="model-name-help"
[disabled]="busy() || loading()" />
<small id="model-name-help">点击预设填入,或直接输入自定义模型名称;保存后可继续使用。</small>
<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><option value="max">最高(max)</option>
</select>
<small>接口需支持所选强度;不支持时会明确报错,可手动选择“由模型默认”。</small>
<app-setting-slider label="思考强度" [(value)]="reasoning" [values]="reasoningValues" [labels]="reasoningLabels" [disabled]="!!busy() || loading()" hint="更高的强度适合复杂推理;需接口支持,也可选择模型默认。" />
<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()" />
@@ -69,13 +63,7 @@ interface ModelSettings {
<label>兜底模型名称<input name="toolFallbackModel" type="text" required autocomplete="off"
autocapitalize="off" spellcheck="false" [(ngModel)]="toolFallbackModel"
[disabled]="busy() || loading()" /></label>
<label>兜底思考强度<select name="toolFallbackReasoning" [(ngModel)]="toolFallbackReasoning"
[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><option value="max">最高(max)</option>
</select></label>
<app-setting-slider label="兜底思考强度" [(value)]="toolFallbackReasoning" [values]="reasoningValues" [labels]="reasoningLabels" [disabled]="!!busy() || loading()" />
<div class="number-fields">
<label>兜底请求重试次数<input name="toolFallbackRetries" type="number" min="0" max="10" required
[(ngModel)]="toolFallbackRetries" [disabled]="busy() || loading()" /></label>
@@ -119,9 +107,16 @@ interface ModelSettings {
.model-presets button[aria-pressed="true"] { background: #e0f2fe; border-color: #0284c7; color: #075985; }
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}
.model-presets button {border-radius:20px;font-size:12px}.model-presets button[aria-pressed=true] {background:#eef2ff;border-color:#a5b4fc;color:#4338ca}
.recovery-settings {border-color:#e2e8f0;border-radius:14px;padding:14px 16px}.recovery-settings summary {font-size:13px}
`]
})
export class ModelSettingsComponent implements OnInit {
readonly reasoningValues = REASONING_VALUES;
readonly reasoningLabels = REASONING_LABELS;
private http = inject(HttpClient);
saved = output<void>();
modelPresets: string[] = [];
@@ -0,0 +1,17 @@
import { TestBed } from '@angular/core/testing';
import { SettingSliderComponent, REASONING_VALUES, REASONING_LABELS } from './setting-slider.component';
describe('Setting slider', () => {
it('maps the selected position to the exact request value and exposes its readable label', () => {
const f=TestBed.createComponent(SettingSliderComponent);
f.componentRef.setInput('label','思考强度');f.componentRef.setInput('values',REASONING_VALUES);f.componentRef.setInput('labels',REASONING_LABELS);f.componentRef.setInput('value','high');f.detectChanges();
const slider=f.nativeElement.querySelector('input');expect(slider.getAttribute('aria-valuetext')).toBe('高 · high');
const emit=spyOn(f.componentInstance.valueChange,'emit');slider.value='7';slider.dispatchEvent(new Event('input'));
expect(emit).toHaveBeenCalledWith('max');f.destroy();
});
it('preserves a saved custom time until the slider is actually changed', () => {
const f=TestBed.createComponent(SettingSliderComponent);const emit=spyOn(f.componentInstance.valueChange,'emit');
f.componentRef.setInput('label','执行时限');f.componentRef.setInput('values',[60,300,1800]);f.componentRef.setInput('value',125);f.detectChanges();
expect(f.nativeElement.querySelector('output').textContent).toBe('125 秒');expect(emit).not.toHaveBeenCalled();f.destroy();
});
});
@@ -0,0 +1,46 @@
import { Component, computed, input, output } from '@angular/core';
@Component({
selector: 'app-setting-slider', standalone: true,
template: `
<div class="slider-heading"><span>{{label()}}</span><output>{{display()}}</output></div>
<input type="range" min="0" [max]="values().length - 1" step="1" [value]="index()"
[disabled]="disabled()" [attr.aria-label]="label()" [attr.aria-valuetext]="display()"
[style.--fill]="(index() / (values().length - 1) * 100) + '%'"
(input)="change($any($event.target).value)" />
<div class="slider-ends"><span>{{labels()[0] || values()[0]}}</span><span>{{labels().at(-1) || values().at(-1)}}</span></div>
@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}
.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}
`]
})
export class SettingSliderComponent {
label = input.required<string>();
value = input.required<string | number>();
values = input.required<(string | number)[]>();
labels = input<string[]>([]);
hint = input('');
disabled = input(false);
valueChange = output<any>();
index = computed(() => {
const exact = this.values().indexOf(this.value());
if(exact >= 0) return exact;
if(typeof this.value() === 'number') return this.values().reduce<number>((best, v, i) => Math.abs(Number(v)-Number(this.value())) < Math.abs(Number(this.values()[best])-Number(this.value())) ? i : best, 0);
return 0;
});
display = computed(() => {
const exact = this.values().indexOf(this.value());
return exact >= 0 ? this.labels()[exact] || String(this.value()) : String(this.value()) + (typeof this.value() === 'number' ? ' 秒' : '');
});
change(index: string) { this.valueChange.emit(this.values()[Number(index)]); }
}
export const REASONING_VALUES = ['default','none','minimal','low','medium','high','xhigh','max'];
export const REASONING_LABELS = ['模型默认','关闭 · none','最低 · minimal','低 · low','中 · medium','高 · high','极高 · xhigh','最高 · max'];
@@ -14,8 +14,8 @@ describe('队列批次筛选', () => {
http.expectOne('/api/automation/batches').flush([{id:'batch-1',name:'第一轮冒烟',created_at:100},{id:'batch-2',name:'第二轮冒烟',created_at:200}]);
fixture.detectChanges(); tick(); fixture.detectChanges();
const select: HTMLSelectElement = fixture.nativeElement.querySelector('select');
expect(select.value).toBe('batch-1'); expect(select.options.length).toBe(5);
for(const value of ['__batch__','__single__','batch-2']){
expect(select.value).toBe('batch-1'); expect(select.options.length).toBe(6);
for(const value of ['__batch__','__single__','__exploration__','batch-2']){
select.value=value; select.dispatchEvent(new Event('change')); tick(); fixture.detectChanges();
expect(agent.batchFilter()).toBe(value);
}
@@ -7,9 +7,9 @@ import { AgentService } from '../../services/agent.service';
@Component({
selector: 'app-task-batch-filter', standalone: true, imports: [FormsModule, DatePipe],
template: `<div class="batch-filter"><label>任务范围<select aria-label="按批次筛选任务" [ngModel]="agent.batchFilter()" (ngModelChange)="agent.batchFilter.set($event)">
<option value="">全部任务</option><option value="__batch__">全部批量任务</option><option value="__single__">单独任务</option>
<option value="">全部任务</option><option value="__batch__">全部批量任务</option><option value="__single__">单独任务</option><option value="__exploration__">探索任务</option>
@for(batch of batches(); track batch.id){<option [value]="batch.id">{{batch.name}} · {{batch.created_at * 1000 | date:'MM-dd HH:mm'}}</option>}
@if(agent.batchFilter() && !['__batch__','__single__'].includes(agent.batchFilter()) && !hasSelectedBatch()){<option [value]="agent.batchFilter()">当前选定批次</option>}
@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}`]
@@ -15,6 +15,23 @@ describe('历史任务检索', () => {
});
afterEach(() => http.verify());
it('完成任务分别显示探索、Flash、Pro,模式不受通过或失败颜色影响', () => {
const fixture = TestBed.createComponent(TaskHistoryComponent);
http.expectOne(r => r.url === '/api/automation/history').flush({total:3,items:[
{session_id:'e',status:'completed',start_time:100,profile:'pro',exploration_kind:'exploration'},
{session_id:'f',status:'failed',start_time:100,profile:'flash',exploration_kind:'validation',case_version:2},
{session_id:'p',status:'completed',start_time:100,profile:'pro'}
]});
fixture.detectChanges();
const badges = fixture.nativeElement.querySelectorAll('.mode-badge');
expect(badges.length).toBe(3);
expect(badges[0].textContent).toContain('探索');
expect(badges[1].textContent).toContain('Flash');
expect(badges[2].textContent).toContain('Pro');
expect(fixture.nativeElement.textContent).toContain('用例复跑 · v2');
fixture.destroy();
});
it('更换批次时保留搜索条件并回到第一页,复验后立即刷新', () => {
const fixture = TestBed.createComponent(TaskHistoryComponent);
http.expectOne(r => r.url === '/api/automation/history').flush({total:0,items:[]});
@@ -1,4 +1,5 @@
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
import { TaskModeBadgeComponent } from '../task-mode-badge/task-mode-badge.component';
import { TestResultBadgeComponent } from '../test-result-badge/test-result-badge.component';
import { Component, DestroyRef, effect, inject, signal, untracked } from '@angular/core';
import { CommonModule } from '@angular/common';
@@ -7,14 +8,14 @@ import { HttpClient, HttpParams } from '@angular/common/http';
import { AgentService } from '../../services/agent.service';
@Component({
selector:'app-task-history', standalone:true, imports:[CommonModule,FormsModule,TaskIdCopyComponent,TestResultBadgeComponent],
selector:'app-task-history', standalone:true, imports:[CommonModule,FormsModule,TaskIdCopyComponent,TestResultBadgeComponent,TaskModeBadgeComponent],
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" [class]="'history-item status-'+row.status"><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)" [title]="row.initial_goal || ''"><strong>{{row.name || row.initial_goal}}</strong><span class="history-meta"><b class="history-status">{{label(row.status)}}</b><app-test-result-badge [result]="row.test_result" [executionStatus]="row.status" [compact]="true" /> <span>{{row.end_time ? '完成于 '+(row.end_time*1000 | date:'MM-dd HH:mm:ss') : '开始于 '+(row.start_time*1000 | date:'MM-dd HH:mm:ss')}}</span><span>总耗时 {{duration(row.start_time,row.end_time)}}</span></span>@if(row.error){<span class="history-error" [title]="row.error">{{row.error}}</span>}</button><app-task-id-copy [taskId]="row.session_id" /><button class="delete-history" (click)="remove(row.session_id)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button></div>}
@for(row of group.rows; track row.session_id){<div class="history-item" [class]="'history-item status-'+row.status"><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)" [title]="row.initial_goal || ''"><strong>{{row.name || row.initial_goal}}</strong><span class="history-meta"><app-task-mode-badge [session]="row" />@if(row.exploration_kind === "validation"){<span>用例复跑 · v{{row.case_version}}</span>}<b class="history-status">{{label(row.status)}}</b><app-test-result-badge [result]="row.test_result" [executionStatus]="row.status" [compact]="true" /> <span>{{row.end_time ? '完成于 '+(row.end_time*1000 | date:'MM-dd HH:mm:ss') : '开始于 '+(row.start_time*1000 | date:'MM-dd HH:mm:ss')}}</span><span>总耗时 {{duration(row.start_time,row.end_time)}}</span></span>@if(row.error){<span class="history-error" [title]="row.error">{{row.error}}</span>}</button><app-task-id-copy [taskId]="row.session_id" />@if(row.exploration_kind !== "exploration"){<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>
@@ -0,0 +1,21 @@
import { TestBed } from '@angular/core/testing';
import { TaskModeBadgeComponent } from './task-mode-badge.component';
describe('执行模式徽标', () => {
it('探索优先于底层 Pro,复跑显示实际使用的 Flash / Pro', () => {
const fixture = TestBed.createComponent(TaskModeBadgeComponent);
const check = (session: object, mode: string, text: string) => {
fixture.componentRef.setInput('session', session);
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.mode-badge');
expect(badge.classList.contains(mode)).toBeTrue();
expect(badge.textContent).toContain(text);
expect(badge.title).toBe('执行模式:' + text);
};
check({exploration_kind:'exploration',profile:'pro'}, 'explore', '探索');
check({exploration_kind:'validation',profile:'flash'}, 'flash', 'Flash');
check({profile:'pro'}, 'pro', 'Pro');
check({model_info:{architecture:'ARTEMIS Flash'}}, 'flash', 'Flash');
check({model_info:{id:'mimo-v2.6-pro'}}, 'unknown', '模式未记录');
});
});
@@ -0,0 +1,27 @@
import { Component, computed, input } from '@angular/core';
import { Session } from '../../core/models/session.model';
@Component({
selector: 'app-task-mode-badge',
standalone: true,
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}
.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}
`]
})
export class TaskModeBadgeComponent {
session = input.required<Partial<Session>>();
mode = computed<'explore' | 'flash' | 'pro' | 'unknown'>(() => {
const s = this.session();
if (s.exploration_kind === 'exploration') return 'explore';
const profile = (s.profile || s.model_info?.architecture || '').toLowerCase().replace(/^artemis\s+/, '');
return profile === 'explore' || profile === 'flash' || profile === 'pro' ? profile : 'unknown';
});
label = computed(() => ({explore:'探索',flash:'Flash',pro:'Pro',unknown:'模式未记录'})[this.mode()]);
icon = computed(() => ({explore:'explore',flash:'bolt',pro:'psychology',unknown:'help_outline'})[this.mode()]);
}
@@ -50,6 +50,13 @@ export interface TaskQueueItem {
export interface Session {
session_id: string;
profile?: string | null;
exploration_id?: string;
exploration_latest_run_id?: string;
exploration_status?: string;
exploration_run_count?: number;
exploration_kind?: 'exploration' | 'validation';
case_version?: number | null;
batch_id?: string | null;
initial_goal: string;
start_time: number;
@@ -1423,8 +1423,10 @@
<span class="mode-name">Flash</span>
<span class="bullet">·</span>
<span>节省 Token,快速响应 <strong>每步 3–5 秒</strong> 直接根据实时屏幕执行操作,适合路径明确的日常任务。</span>
<span class="limitation-tag">(不规划、不检查结果、不执行 ADB 命令)</span>
<span class="limitation-tag">(轻量执行,结束时判断测试结果)</span>
</p>
} @else if(selectedProfile() === 'exploration') {
<p class="desc-line"><span class="mode-name">探索</span><span class="bullet">·</span><span>边执行边交流,支持人工示范,成功后生成可复用用例。</span></p>
} @else {
<p class="desc-line">
<span class="mode-name">Pro</span>
@@ -1468,6 +1470,9 @@
<span class="material-symbols-outlined btn-icon">psychology</span>
<span class="btn-text">Pro</span>
</button>
<button type="button" class="model-toggle-btn btn-pro" [class.active]="selectedProfile() === 'exploration'" (click)="setProfile('exploration')" title="探索:边执行边交流,可人工示范,完成后生成可复用用例">
<span class="material-symbols-outlined btn-icon">explore</span><span class="btn-text">探索</span>
</button>
</div>
</div>
@@ -1488,13 +1493,36 @@
@if (isSubmitting()) {
<span class="spinner"></span><span>正在启动…</span>
} @else {
<span class="material-symbols-outlined run-icon">play_arrow</span><span>执行任务</span>
<span class="material-symbols-outlined run-icon">play_arrow</span><span>{{selectedProfile() === 'exploration' ? '开始探索' : '执行任务'}}</span>
}
</button>
</div>
</div>
</div>
@if(selectedProfile() === 'exploration') {
<section class="exploration-settings">
<div class="exploration-intro"><span class="material-symbols-outlined">explore</span><div><strong>一起探索复杂用例</strong><p>描述大概流程,边执行边交流。可以纠正方向或亲自示范,结束后生成可复用的用例。</p></div></div>
@if(explorationConfig(); as config) {
<button type="button" class="exploration-settings-toggle" (click)="explorationSettingsOpen.set(!explorationSettingsOpen())" [attr.aria-expanded]="explorationSettingsOpen()"><span class="material-symbols-outlined">tune</span><span>{{config.model}} · {{config.reasoning_effort}}</span><span class="settings-toggle-label">调整探索设置</span><span class="material-symbols-outlined">{{explorationSettingsOpen() ? 'expand_less' : 'expand_more'}}</span></button>
@if(explorationSettingsOpen()) {
<div class="exploration-settings-body">
<label class="model-label">探索模型<input list="explore-models" [(ngModel)]="config.model" [disabled]="explorationSettingsBusy()" /></label>
<datalist id="explore-models"><option value="gpt-6.1-sol"></option><option value="gpt-6-astra"></option><option value="gpt-6-luna"></option><option value="mimo-v2.6-pro"></option></datalist>
<div class="exploration-fields">
<app-setting-slider label="思考强度" [(value)]="config.reasoning_effort" [values]="reasoningValues" [labels]="reasoningLabels" hint="越高越适合复杂推理,也可能等待更久。" [disabled]="explorationSettingsBusy()" />
<app-setting-slider label="每段执行时限" [(value)]="config.timeout_seconds" [values]="executionTimes" [labels]="executionTimeLabels" hint="到时暂停并保存进度,点击继续即可接着探索。" [disabled]="explorationSettingsBusy()" />
<app-setting-slider label="人工示范时限" [(value)]="config.demonstration_timeout_seconds" [values]="demoTimes" [labels]="demoTimeLabels" hint="点击“我来操作”后,允许录制的示范时长。" [disabled]="explorationSettingsBusy()" />
<app-setting-slider label="示范提前提醒" [(value)]="config.demonstration_warning_seconds" [values]="warningTimes" [labels]="warningLabels" hint="示范即将到时时提醒,可手动延长。" [disabled]="explorationSettingsBusy()" />
</div>
<p class="settings-note">等待回答和人工示范不占执行时间。这组设置只用于探索。</p>
@if(!explorationConfigValid()){<p role="alert" class="settings-error">请填写模型,并将提前提醒设为小于示范时限。</p>}
<div class="settings-footer"><button type="button" class="save-settings" (click)="saveExplorationSettings()" [disabled]="explorationSettingsBusy() || !explorationConfigValid()">{{explorationSettingsBusy() ? '正在保存…' : '记住此配置'}}</button><span role="status">{{explorationNotice()}}</span></div>
</div>
}
} @else {<button (click)="loadExplorationSettings()" [disabled]="explorationSettingsBusy()">{{explorationSettingsBusy() ? '正在读取配置…' : '重新读取探索配置'}}</button>}
</section>
}
<!-- Pro Mode Standalone Expected Output Card (Clean & Minimalist) -->
@if (selectedProfile() === 'pro') {
<div class="pro-outputter-standalone-card">
@@ -4065,12 +4065,13 @@ $text-light: #94a3b8;
border-radius: 18px;
gap: 3px;
border: 1px solid rgba(226, 232, 240, 0.88);
width: 172px;
width: max-content;
flex-shrink: 0;
box-sizing: border-box;
.model-toggle-btn {
flex: 1 1 0;
width: 50%;
min-width: 78px;
height: 32px;
padding: 0 10px;
border-radius: 15px;
@@ -5168,3 +5169,19 @@ $text-light: #94a3b8;
}
.composer-card > app-task-plugins { margin-bottom: 8px; }
.exploration-settings {
margin:18px 0 24px; padding:18px 20px; background:#fff; border:1px solid #e2e8f0; border-radius:20px; font-size:13px; color:#475569;
p {line-height:1.7;margin:4px 0 0}
.exploration-intro {display:flex;gap:12px;align-items:flex-start; strong {color:#1e293b;font-size:14px} > .material-symbols-outlined {color:#6366f1;background:#eef2ff;border-radius:12px;padding:8px;font-size:22px}}
button,input {font:inherit;box-sizing:border-box} button {cursor:pointer;transition:background .15s} button:disabled {opacity:.5;cursor:default}
button:focus-visible,input:focus-visible {outline:2px solid #818cf8;outline-offset:3px}
.exploration-settings-toggle {display:flex;align-items:center;gap:8px;width:100%;border:0;background:#f8fafc;color:#475569;border-radius:12px;padding:10px 12px;margin-top:14px;text-align:left; .material-symbols-outlined {font-size:18px} .settings-toggle-label {margin-left:auto;font-size:12px;color:#64748b} &:hover {background:#eef2ff}}
.exploration-settings-body {padding-top:18px}
.model-label {display:flex;align-items:center;gap:16px;font-weight:600;input {flex:1;min-width:0;border:1px solid #dbe3ec;border-radius:10px;padding:10px 12px;color:#1e293b;background:#fff}}
.exploration-fields {display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}
.settings-note {font-size:12px;color:#64748b}.settings-error {color:#b91c1c}
.settings-footer {display:flex;align-items:center;gap:12px;margin-top:14px;span {font-size:12px;color:#047857}}
.save-settings {border:0;background:#4f46e5;color:#fff;border-radius:10px;padding:9px 16px;font-weight:600;&:hover:enabled {background:#4338ca}}
@media(max-width:600px) {.exploration-fields {grid-template-columns:1fr}.settings-toggle-label {display:none}.exploration-settings-toggle {flex-wrap:wrap}}
}
@@ -0,0 +1,29 @@
import { TestBed, fakeAsync, flushMicrotasks } from '@angular/core/testing';
import { signal } from '@angular/core';
import { Router } from '@angular/router';
import { HomeComponent } from './home.component';
import { AgentService } from '../../services/agent.service';
import { ExplorationService } from '../../services/exploration.service';
import { SystemService } from '../../services/system.service';
import { TaskRecommendationService } from '../../core/services/task-recommendation.service';
describe('Homepage exploration entry',()=>{
let api:any,agent:any,router:any;
const config={model:'gpt-6.1-sol',reasoning_effort:'high',timeout_seconds:1800,demonstration_timeout_seconds:900,demonstration_warning_seconds:60};
beforeEach(()=>{
api={settings:jasmine.createSpy().and.resolveTo(config),create:jasmine.createSpy().and.resolveTo({id:'conversation'}),act:jasmine.createSpy().and.resolveTo({run:{id:'run'}})};
agent={runTask:jasmine.createSpy(),selectSession:jasmine.createSpy(),fetchSessions:jasmine.createSpy(),fetchStatus:jasmine.createSpy()};router={navigate:jasmine.createSpy().and.resolveTo(true)};
TestBed.configureTestingModule({providers:[{provide:AgentService,useValue:agent},{provide:ExplorationService,useValue:api},{provide:SystemService,useValue:{isReady:()=>true}},{provide:TaskRecommendationService,useValue:{allTasks:signal([])}},{provide:Router,useValue:router}]});
});
it('creates and starts exploration using independent settings and opens its run',fakeAsync(()=>{
const home=TestBed.runInInjectionContext(()=>new HomeComponent());home.setProfile('exploration');flushMicrotasks();home.taskGoal.set('探索流程');home.runTask();flushMicrotasks();
expect(api.create).toHaveBeenCalledWith('探索流程',config,{},jasmine.any(String));expect(api.act).toHaveBeenCalledWith('conversation','start',{},jasmine.any(String));
expect(agent.runTask).not.toHaveBeenCalled();expect(agent.selectSession).toHaveBeenCalledWith('run',true);expect(router.navigate).toHaveBeenCalledWith(['/workspace']);expect(home.isSubmitting()).toBeFalse();
localStorage.removeItem('artemis.taskProfile');
}));
it('reuses create and start IDs after a lost admission response',fakeAsync(()=>{
const home=TestBed.runInInjectionContext(()=>new HomeComponent());home.selectedProfile.set('exploration');home.explorationConfig.set(config);home.taskGoal.set('探索流程');api.act.and.rejectWith(new Error('offline'));home.runTask();flushMicrotasks();
const createId=api.create.calls.mostRecent().args[3],startId=api.act.calls.mostRecent().args[3];expect(home.errorMessage()).toContain('失败');
api.act.and.resolveTo({run:{id:'run'}});home.runTask();flushMicrotasks();expect(api.create.calls.mostRecent().args[3]).toBe(createId);expect(api.act.calls.mostRecent().args[3]).toBe(startId);
}));
});
@@ -1,4 +1,5 @@
import { CapabilitySettingsComponent } from '../../components/model-settings/capability-settings.component';
import { SettingSliderComponent, REASONING_VALUES, REASONING_LABELS } from '../../components/setting-slider/setting-slider.component';
import { ModelSettingsComponent } from '../../components/model-settings/model-settings.component';
/**
* Copyright 2026 Google LLC
@@ -20,6 +21,7 @@ import { Component, signal, computed, inject, OnInit, OnDestroy, ChangeDetection
import { FormsModule } from '@angular/forms';
import { TaskPluginsComponent, TaskPluginOptions } from '../../components/task-plugins/task-plugins.component';
import { Router } from '@angular/router';
import { ExplorationService, ExplorationConfig } from '../../services/exploration.service';
import { AgentService } from '../../services/agent.service';
import { SystemService, formatAdbRestartFeedback } from '../../services/system.service';
import {
@@ -106,7 +108,7 @@ type AdbGuideTab = 'emulator' | 'usb' | 'wifi' | 'remote';
@Component({
selector: 'app-home',
standalone: true,
imports: [FormsModule, ModelSettingsComponent, CapabilitySettingsComponent, TaskPluginsComponent],
imports: [FormsModule, ModelSettingsComponent, CapabilitySettingsComponent, TaskPluginsComponent, SettingSliderComponent],
templateUrl: './home.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
styleUrl: './home.component.scss'
@@ -168,7 +170,7 @@ export class HomeComponent implements OnInit, OnDestroy {
});
// Task execution parameters
public selectedProfile = signal<'flash' | 'pro'>('flash');
public selectedProfile = signal<'flash' | 'pro' | 'exploration'>('flash');
public taskGoal = signal<string>('');
public isSubmitting = signal<boolean>(false);
public errorMessage = signal<string | null>(null);
@@ -477,6 +479,8 @@ export class HomeComponent implements OnInit, OnDestroy {
};
ngOnInit(): void {
const savedProfile = localStorage.getItem('artemis.taskProfile');
if (savedProfile === 'exploration' || savedProfile === 'flash' || savedProfile === 'pro') this.setProfile(savedProfile);
this.loadProTuningDefaults();
// Initial fetch of system readiness & model configuration
@@ -751,8 +755,10 @@ export class HomeComponent implements OnInit, OnDestroy {
}
}
public setProfile(profile: 'flash' | 'pro'): void {
public setProfile(profile: 'flash' | 'pro' | 'exploration'): void {
this.selectedProfile.set(profile);
localStorage.setItem('artemis.taskProfile', profile);
if (profile === 'exploration' && !this.explorationConfig()) void this.loadExplorationSettings();
}
public setCategory(cat: SuggestionCategory): void {
@@ -786,7 +792,58 @@ export class HomeComponent implements OnInit, OnDestroy {
this.activeTab.set('launcher');
}
exploration = inject(ExplorationService);
readonly reasoningValues = REASONING_VALUES;
readonly reasoningLabels = REASONING_LABELS;
readonly executionTimes = [30,60,300,600,900,1800,3600,7200,14400,28800,86400];
readonly executionTimeLabels = ['30 秒','1 分钟','5 分钟','10 分钟','15 分钟','30 分钟','1 小时','2 小时','4 小时','8 小时','24 小时'];
readonly demoTimes = this.executionTimes.slice(1);
readonly demoTimeLabels = this.executionTimeLabels.slice(1);
readonly warningTimes = [10,30,60,120,300,600];
readonly warningLabels = ['10 秒','30 秒','1 分钟','2 分钟','5 分钟','10 分钟'];
explorationSettingsOpen = signal(false);
explorationConfigValid() {
const c = this.explorationConfig();
return !!c?.model.trim() && c.demonstration_warning_seconds < c.demonstration_timeout_seconds;
}
explorationConfig = signal<ExplorationConfig | null>(null);
explorationSettingsBusy = signal(false);
explorationNotice = signal('');
private explorationRequests = new Map<string, {create: string; start: string}>();
async loadExplorationSettings() {
this.explorationSettingsBusy.set(true);
try { this.explorationConfig.set(await this.exploration.settings()); }
catch { this.errorMessage.set('探索配置读取失败,请重试。'); }
finally { this.explorationSettingsBusy.set(false); }
}
async saveExplorationSettings() {
const config = this.explorationConfig(); if (!config || this.explorationSettingsBusy() || !this.explorationConfigValid()) return;
this.explorationSettingsBusy.set(true); this.explorationNotice.set('');
try { this.explorationConfig.set(await this.exploration.saveSettings(config)); this.explorationNotice.set('已记住探索配置'); }
catch(e) { this.errorMessage.set((e as any)?.error?.detail || '配置保存失败'); }
finally { this.explorationSettingsBusy.set(false); }
}
private async startExploration(goal: string) {
const config = this.explorationConfig();
if (!config) { this.errorMessage.set('请先读取探索配置。'); this.isSubmitting.set(false); return; }
if (!this.explorationConfigValid()) { this.errorMessage.set('示范提前提醒须小于人工示范时限。'); this.isSubmitting.set(false); return; }
const taskConfig = {...this.taskPlugins()};
const key = JSON.stringify([goal, config, taskConfig]);
const ids = this.explorationRequests.get(key) || {create: crypto.randomUUID(), start: crypto.randomUUID()};
this.explorationRequests.set(key, ids);
try {
const conversation = await this.exploration.create(goal, config, taskConfig, ids.create);
const result = await this.exploration.act<{run: {id: string}}>(conversation.id, 'start', {}, ids.start);
this.explorationRequests.delete(key);
this.agentService.selectSession(result.run.id, true);
this.agentService.fetchSessions(); this.agentService.fetchStatus();
await this.router.navigate(['/workspace']);
} catch(e) { this.errorMessage.set((e as any)?.error?.detail || '探索启动失败,可重试。'); }
finally { this.isSubmitting.set(false); }
}
public runTask(): void {
if (this.isSubmitting()) return;
const goal = this.taskGoal().trim();
if (!goal) {
this.errorMessage.set("请先输入任务目标。");
@@ -802,10 +859,12 @@ export class HomeComponent implements OnInit, OnDestroy {
this.isSubmitting.set(true);
this.errorMessage.set(null);
const profile = this.selectedProfile();
if (profile === 'exploration') { void this.startExploration(goal); return; }
this.agentService
.runTask(
goal,
this.selectedProfile(),
profile,
this.selectedProfile() === 'pro' && this.expectedOutput().trim()
? this.expectedOutput().trim()
: undefined,
@@ -1,238 +1,32 @@
<!--
Copyright 2026 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<div class="workspace-container" [class.is-resizing]="isDragging()">
<!-- Left Main Panel: Raw Agent Log Stream -->
<main class="left-panel glass-panel">
<app-agent-stream>
@if (guidanceStatus(); as status) {
<div task-guidance class="task-guidance-control">
<button type="button" class="guidance-trigger" (click)="toggleGuidance()"
[attr.aria-expanded]="guidanceOpenId() === agentService.currentSessionId()"
aria-controls="workspace-guidance-panel" aria-label="指导当前任务" title="暂停或指导当前任务">
<span class="material-symbols-outlined">edit_note</span>
指导任务
</button>
@if (guidanceOpenId() === agentService.currentSessionId()) {
<section id="workspace-guidance-panel" class="task-guidance-panel" aria-label="当前任务指导">
<div class="guidance-heading">
<strong>指导当前任务 · {{ status === 'running' ? '执行中' : status === 'resuming' ? '等待设备恢复' : status === 'paused' ? '模型调用暂停' : '等待指导' }}</strong>
<span [title]="agentService.currentSession()?.initial_goal || ''">{{ agentService.currentSession()?.initial_goal || agentService.currentSessionId() }}</span>
</div>
<label for="workspace-task-guidance">补充信息或调整方向</label>
<textarea id="workspace-task-guidance" [(ngModel)]="taskGuidance" rows="2" maxlength="10000"
[disabled]="guidanceBusy()" placeholder="输入指导;将中断当前模型思考并立即重新规划"></textarea>
<div class="guidance-actions">
<span>{{ status === 'paused' ? '提交指导后请在执行日志中恢复模型调用' : '下方输入框用于开始新任务' }}</span>
@if (status === 'running') {
<button type="button" (click)="pauseCurrentTask()"
[disabled]="guidanceBusy() || pauseRequestId() === agentService.currentSessionId()">
{{ pauseRequestId() === agentService.currentSessionId() ? '等待暂停…' : '暂停当前任务' }}
</button>
}
@if (status === 'task_paused') {
<button type="button" (click)="continueCurrentTask()" [disabled]="guidanceBusy()">直接继续</button>
}
<button type="button" class="guidance-submit" (click)="submitGuidance()"
[disabled]="guidanceBusy() || !taskGuidance.trim()">{{ status === 'task_paused' ? '提交并恢复' : '提交指导' }}</button>
</div>
</section>
}
</div>
}
</app-agent-stream>
<!-- Bottom Floating Command Bar Dock -->
<div class="workspace-floating-bar-wrapper" [class.is-expanded]="isBarExpanded()" [class.is-dormant]="!isBarExpanded()"
[class.is-focused]="isInputFocused()" [class.has-text]="!!taskInput.trim()" [class.is-running]="isTaskRunning()">
<!-- Floating Error / Notification Toast -->
@if (errorMessage()) {
<div class="floating-dock-toast error">
<span class="material-symbols-outlined toast-icon">error</span>
<span class="toast-text">{{ errorMessage() }}</span>
<button type="button" class="btn-toast-close" (click)="errorMessage.set(null)" aria-label="关闭错误消息">
<span class="material-symbols-outlined">close</span>
</button>
</div>
}
<!-- Dock Row (Contains the Main Card and the Separate Stop Button when running) -->
<div class="floating-dock-row">
<!-- Floating Dock Main Card -->
<div class="floating-dock-card" (click)="onCardClick($event)"
(mouseenter)="isHoveringCard.set(true)" (mouseleave)="isHoveringCard.set(false)">
<!-- Ambient Liquid Wave / Aurora Background Layer (Active in both Dormant & Expanded) -->
<div class="dock-wave-container" [class.is-pro]="selectedProfile() === 'pro'">
<div class="wave-glow-ambient"></div>
<div class="wave-tracks-wrapper">
<!-- Back Fluid Wave -->
<svg class="liquid-wave wave-back" viewBox="0 0 2400 80" preserveAspectRatio="none">
<defs>
<linearGradient id="waveGradBack" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" class="wave-stop-1" />
<stop offset="25%" class="wave-stop-2" />
<stop offset="50%" class="wave-stop-3" />
<stop offset="75%" class="wave-stop-4" />
<stop offset="100%" class="wave-stop-1" />
</linearGradient>
</defs>
<path d="M 0,25 Q 300,55 600,20 T 1200,25 Q 1500,55 1800,20 T 2400,25 L 2400,80 L 0,80 Z"
fill="url(#waveGradBack)"></path>
</svg>
<!-- Mid Fluid Wave -->
<svg class="liquid-wave wave-mid" viewBox="0 0 2400 80" preserveAspectRatio="none">
<defs>
<linearGradient id="waveGradMid" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" class="wave-stop-3" />
<stop offset="35%" class="wave-stop-1" />
<stop offset="70%" class="wave-stop-4" />
<stop offset="100%" class="wave-stop-3" />
</linearGradient>
</defs>
<path d="M 0,35 Q 300,5 600,45 T 1200,35 Q 1500,5 1800,45 T 2400,35 L 2400,80 L 0,80 Z"
fill="url(#waveGradMid)"></path>
</svg>
<!-- Front Fluid Wave -->
<svg class="liquid-wave wave-front" viewBox="0 0 2400 80" preserveAspectRatio="none">
<defs>
<linearGradient id="waveGradFront" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" class="wave-stop-2" />
<stop offset="30%" class="wave-stop-4" />
<stop offset="65%" class="wave-stop-1" />
<stop offset="100%" class="wave-stop-2" />
</linearGradient>
</defs>
<path d="M 0,18 Q 300,50 600,15 T 1200,18 Q 1500,50 1800,15 T 2400,18 L 2400,80 L 0,80 Z"
fill="url(#waveGradFront)"></path>
</svg>
</div>
@if (explorationId(); as id) {
<app-exploration-panel [sessionId]="id" />
} @else {
<app-agent-stream>
@if(agentService.currentSession()?.exploration_kind === 'validation') {
<div task-summary class="origin-note">用例 v{{agentService.currentSession()?.case_version}} 的独立复跑
<button type="button" (click)="openOrigin()">查看来源探索</button>
</div>
<!-- Dormant / Capsule Peek View (Visible when mouse is away and input idle) -->
<div class="capsule-peek-view">
<span class="capsule-placeholder">开始新任务…</span>
</div>
<!-- Expanded Full View Content (Visible on hover, focus, or text) -->
<div class="expanded-card-content">
<div class="card-main-input-row">
<!-- Textarea Container with Dynamic English Placeholder -->
<div class="input-content-area">
<textarea #dockInput [(ngModel)]="taskInput"
(input)="onTextareaInput($event)"
(keydown)="onKeyDown($event)"
(focus)="onInputFocus()"
(blur)="onInputBlur()"
rows="1"
[disabled]="isSubmitting()"
[placeholder]="selectedProfile() === 'pro'
? 'Pro: plans first, checks results and recovers from blocked steps. For long or complex tasks'
: 'Flash: fast single-look loop for everyday app tasks'"
class="dock-textarea"
spellcheck="false"
></textarea>
</div>
<!-- Clear Button (visible when text is entered) -->
@if (taskInput.trim() && !isSubmitting()) {
<button type="button" class="dock-circle-btn btn-clear"
(click)="clearInput($event)" title="清空输入" aria-label="清空输入">
<span class="material-symbols-outlined">close</span>
</button>
}
<!-- Stop Button (Visible when currently viewed task is active) -->
@if (isTaskRunning()) {
<button type="button" class="dock-circle-btn btn-stop" [disabled]="isSubmitting()"
(click)="stopTask($event)"
[title]="stopButtonTitle()"
[attr.aria-label]="stopButtonTitle()"
>
<span class="material-symbols-outlined">stop</span>
</button>
}
<!-- Send Button -->
<button type="button" class="dock-circle-btn btn-send"
[class.has-content]="!!taskInput.trim()"
[disabled]="!taskInput.trim() || isSubmitting()"
(click)="submitTask()"
[title]="isSubmitting() ? 'Starting task...' : 'Start task (Enter)'"
aria-label="开始任务"
>
@if (isSubmitting()) {
<span class="material-symbols-outlined spinning-icon">sync</span>
}
@if (!isSubmitting()) {
<span class="material-symbols-outlined">arrow_upward</span>
}
</button>
</div>
<!-- Bottom Segmented Controls Row -->
<app-task-plugins class="dock-plugin-options" (changed)="taskPlugins = $event" />
<div class="card-footer-row">
<div class="footer-left-group">
<!-- Segmented Architecture Toggle (Flash / Pro) -->
<div class="model-profile-toggle" role="group" aria-label="选择执行模式">
<button type="button" class="profile-toggle-pill" [class.active]="selectedProfile() === 'flash'"
(mousedown)="$event.preventDefault()" (click)="setProfile('flash', $event)"
title="Flash:快速执行日常应用任务(每步 3–5 秒)">
<span class="material-symbols-outlined pill-icon icon-flash">bolt</span>
<span class="pill-label">Flash</span>
</button>
<button type="button" class="profile-toggle-pill" [class.active]="selectedProfile() === 'pro'"
(mousedown)="$event.preventDefault()" (click)="setProfile('pro', $event)"
title="Pro:先规划、检查结果并恢复受阻步骤,适合长流程或复杂任务(每步 15–40 秒)">
<span class="material-symbols-outlined pill-icon icon-pro">psychology</span>
<span class="pill-label">Pro</span>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Separate Standalone Stop Button Beside the Capsule (Visible when currently viewed task is active) -->
@if (isTaskRunning()) {
<button type="button" class="standalone-stop-btn"
[disabled]="isSubmitting()"
(click)="stopTask($event)"
[title]="stopButtonTitle()"
[attr.aria-label]="stopButtonTitle()"
>
<span class="material-symbols-outlined">stop</span>
</button>
}
</div>
</div>
</app-agent-stream>
<section class="task-composer" aria-label="当前任务指导">
@if(errorMessage()){<p role="alert" class="feedback error">{{errorMessage()}}</p>}
@if(notice()){<p role="status" class="feedback">{{notice()}}</p>}
<div class="composer-heading"><strong>指导当前任务</strong><span>{{agentService.currentSession()?.display_name || agentService.currentSession()?.initial_goal || '请从右侧选择任务'}}</span></div>
<textarea #dockInput aria-label="指导当前任务" [(ngModel)]="taskGuidance" (keydown)="onKeyDown($event)" rows="2" maxlength="10000"
[disabled]="!guidanceStatus() || guidanceBusy()" [placeholder]="guidanceStatus() ? '补充信息或纠正操作方向,Enter 发送,Shift+Enter 换行' : '任务已结束或尚未执行,当前不可发送指导' "></textarea>
<div class="composer-actions">
<small>{{guidanceStatus() === 'paused' ? '模型调用暂停:提交指导后,请在执行记录中恢复模型调用。' : guidanceStatus() ? '指导只发送给当前任务,不会创建新任务。' : '新任务请从“新任务 / 首页”创建。'}}</small>
@if(guidanceStatus() === 'running'){<button (click)="pauseCurrentTask()" [disabled]="guidanceBusy()">暂停</button>}
@if(guidanceStatus() === 'task_paused'){<button (click)="continueCurrentTask()" [disabled]="guidanceBusy()">直接继续</button>}
@if(guidanceStatus()){<button (click)="stopTask()" [disabled]="guidanceBusy()">停止</button>}
<button class="primary" (click)="submitGuidance()" [disabled]="!guidanceStatus() || guidanceBusy() || !taskGuidance.trim()">{{guidanceStatus() === 'task_paused' ? '提交并恢复' : '发送指导'}}</button>
</div>
</section>
}
</main>
<!-- Split Pane Drag Resizer -->
<div class="resizer" (mousedown)="onDragStart($event)">
<div class="resizer-handle"></div>
</div>
<!-- Right Sidebar Panel: Chat Interface Mock -->
<aside class="right-panel" [style.width.px]="rightPanelWidth()">
<app-chat-interface></app-chat-interface>
</aside>
<!-- Floating Video Replay Player Modal / Window -->
<app-floating-video-player></app-floating-video-player>
<div class="resizer" (mousedown)="onDragStart($event)"><div class="resizer-handle"></div></div>
<aside class="right-panel" [style.width.px]="rightPanelWidth()"><app-chat-interface /></aside>
<app-floating-video-player />
</div>
File diff suppressed because it is too large. Load diff
@@ -1,118 +1,56 @@
import { Component, computed, signal } from '@angular/core';
import { Component, computed, signal, input } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { of, Subject } from 'rxjs';
import { AgentService } from '../../services/agent.service';
import { ExplorationService } from '../../services/exploration.service';
import { AgentStreamComponent } from '../../components/agent-stream/agent-stream.component';
import { ChatInterfaceComponent } from '../../components/chat-interface/chat-interface.component';
import { FloatingVideoPlayerComponent } from '../../components/floating-video-player/floating-video-player.component';
import { ExplorationPanelComponent } from '../../components/exploration-panel/exploration-panel.component';
import { WorkspaceComponent } from './workspace.component';
@Component({ selector: 'app-agent-stream', standalone: true,
template: '<div class="stream-floating-toolbar"><ng-content select="[task-guidance]"></ng-content></div>' })
class StreamStub {}
@Component({ selector: 'app-chat-interface', standalone: true, template: '' })
class ChatStub {}
@Component({ selector: 'app-floating-video-player', standalone: true, template: '' })
class VideoStub {}
@Component({selector:'app-agent-stream',standalone:true,template:'<ng-content></ng-content>'}) class StreamStub {}
@Component({selector:'app-chat-interface',standalone:true,template:'任务队列'}) class ChatStub {}
@Component({selector:'app-floating-video-player',standalone:true,template:''}) class VideoStub {}
@Component({selector:'app-exploration-panel',standalone:true,template:'探索交流 {{sessionId()}}'}) class ExploreStub {sessionId=input('');}
describe('Workspace task guidance', () => {
const currentId = signal<string | null>('running-task');
const session = signal({ session_id: 'running-task', status: 'running', initial_goal: '观察设置' });
const agent = {
currentSessionId: currentId,
currentSession: computed(() => currentId() === session().session_id ? session() : null),
runningSessionId: signal<string | null>('running-task'),
agentStatus: signal('running'),
isCurrentSessionRunning: computed(() => ['running', 'paused'].includes(session().status)),
fetchStatus: jasmine.createSpy('fetchStatus'),
fetchSessions: jasmine.createSpy('fetchSessions'),
fetchGuidance: jasmine.createSpy('fetchGuidance')
};
const http = jasmine.createSpyObj<HttpClient>('HttpClient', ['post']);
beforeEach(async () => {
currentId.set('running-task');
session.set({ session_id: 'running-task', status: 'running', initial_goal: '观察设置' });
agent.runningSessionId.set('running-task');
http.post.and.returnValue(of({}));
http.post.calls.reset();
await TestBed.configureTestingModule({
imports: [WorkspaceComponent],
providers: [
{ provide: AgentService, useValue: agent },
{ provide: HttpClient, useValue: http }
]
}).overrideComponent(WorkspaceComponent, {
remove: { imports: [AgentStreamComponent, ChatInterfaceComponent, FloatingVideoPlayerComponent] },
add: { imports: [StreamStub, ChatStub, VideoStub] }
}).compileComponents();
describe('Workspace current-task composer', () => {
const selected=signal<any>({});
const agent={currentSession: selected,currentSessionId:computed(()=>selected().session_id),runningSessionId:signal('run'),agentStatus:signal('running'),fetchStatus:jasmine.createSpy(),fetchSessions:jasmine.createSpy(),fetchGuidance:jasmine.createSpy()};
const http=jasmine.createSpyObj('HttpClient',['post']);
beforeEach(async()=>{
selected.set({session_id:'run',status:'running',initial_goal:'观察设置'});
http.post.calls.reset();http.post.and.returnValue(of({}));
await TestBed.configureTestingModule({imports:[WorkspaceComponent],providers:[{provide:AgentService,useValue:agent},{provide:HttpClient,useValue:http},{provide:ExplorationService,useValue:{}}]})
.overrideComponent(WorkspaceComponent,{remove:{imports:[AgentStreamComponent,ChatInterfaceComponent,FloatingVideoPlayerComponent,ExplorationPanelComponent]},add:{imports:[StreamStub,ChatStub,VideoStub,ExploreStub]}}).compileComponents();
});
it('shows pause and guidance for the selected running task, then hides them on cancellation', async () => {
const fixture = TestBed.createComponent(WorkspaceComponent);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.task-guidance-panel')).toBeNull();
expect(fixture.nativeElement.querySelector('.stream-floating-toolbar .guidance-trigger')).not.toBeNull();
expect(fixture.nativeElement.querySelector('.workspace-floating-bar-wrapper .guidance-trigger')).toBeNull();
(fixture.nativeElement.querySelector('.guidance-trigger') as HTMLButtonElement).click();
fixture.detectChanges();
const panel = fixture.nativeElement.querySelector('.task-guidance-panel') as HTMLElement;
expect(panel.textContent).toContain('指导当前任务');
expect(panel.textContent).toContain('下方输入框用于开始新任务');
const pause = Array.from(panel.querySelectorAll('button')).find(b => b.textContent?.includes('暂停当前任务'))!;
pause.click();
await fixture.whenStable();
expect(http.post).toHaveBeenCalledWith('/api/tasks/running-task/pause', {});
fixture.detectChanges();
(fixture.nativeElement.querySelector('.guidance-trigger') as HTMLButtonElement).click();
fixture.detectChanges();
const reopened = fixture.nativeElement.querySelector('.task-guidance-panel') as HTMLElement;
const input = reopened.querySelector('textarea')!;
input.value = '先确认设置首页';
input.dispatchEvent(new Event('input'));
fixture.detectChanges();
const submit = Array.from(reopened.querySelectorAll('button')).find(b => b.textContent?.includes('提交指导'))!;
submit.click();
await fixture.whenStable();
expect(http.post).toHaveBeenCalledWith('/api/tasks/running-task/guide', { instruction: '先确认设置首页' });
expect(agent.fetchGuidance).toHaveBeenCalledWith('running-task');
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.task-guidance-panel')).toBeNull();
expect(fixture.nativeElement.textContent).not.toContain('指导已提交');
session.set({ session_id: 'running-task', status: 'cancelled', initial_goal: '观察设置' });
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('.task-guidance-panel')).toBeNull();
expect(fixture.nativeElement.querySelector('.guidance-trigger')).toBeNull();
it('guides the selected ordinary task without submitting a new task',async()=>{
const f=TestBed.createComponent(WorkspaceComponent);f.detectChanges();
const input=f.nativeElement.querySelector('textarea');input.value='先观察';input.dispatchEvent(new Event('input'));f.detectChanges();
await f.componentInstance.submitGuidance();
expect(http.post).toHaveBeenCalledWith('/api/tasks/run/guide',jasmine.objectContaining({instruction:'先观察',request_id:jasmine.any(String)}));
expect(f.componentInstance.taskGuidance).toBe('');
selected.set({...selected(),status:'completed'});f.detectChanges();await f.whenStable();f.detectChanges();expect(input.disabled).toBeTrue();
expect(f.nativeElement.textContent).not.toContain('开始新任务');f.destroy();
});
it('continues a paused task without creating a new task', async () => {
session.set({ session_id: 'running-task', status: 'task_paused', initial_goal: '观察设置' });
const fixture = TestBed.createComponent(WorkspaceComponent);
fixture.detectChanges();
(fixture.nativeElement.querySelector('.guidance-trigger') as HTMLButtonElement).click();
fixture.detectChanges();
const button = Array.from<HTMLElement>(fixture.nativeElement.querySelectorAll('.task-guidance-panel button'))
.find(b => b.textContent?.includes('直接继续'))!;
button.click();
await fixture.whenStable();
expect(http.post).toHaveBeenCalledWith('/api/tasks/running-task/continue', {});
it('keeps a Flash validation ordinary and opens exploration only for exploration runs',()=>{
const f=TestBed.createComponent(WorkspaceComponent);
selected.set({...selected(),exploration_id:'conversation',exploration_kind:'validation',case_version:2});f.detectChanges();
expect(f.nativeElement.querySelector('app-exploration-panel')).toBeNull();expect(f.nativeElement.textContent).toContain('查看来源探索');
selected.set({...selected(),exploration_kind:'exploration'});f.detectChanges();
expect(f.nativeElement.querySelector('app-exploration-panel').textContent).toContain('conversation');
expect(f.nativeElement.querySelector('.task-composer')).toBeNull();f.destroy();
});
it('does not offer task resume for a paused model request', () => {
session.set({ session_id: 'running-task', status: 'paused', initial_goal: '观察设置' });
const fixture = TestBed.createComponent(WorkspaceComponent);
fixture.detectChanges();
(fixture.nativeElement.querySelector('.guidance-trigger') as HTMLButtonElement).click();
fixture.detectChanges();
const panel = fixture.nativeElement.querySelector('.task-guidance-panel') as HTMLElement;
expect(panel.textContent).toContain('模型调用暂停');
expect(panel.textContent).toContain('请在执行日志中恢复模型调用');
expect(panel.textContent).not.toContain('直接继续');
expect(panel.textContent).not.toContain('提交并恢复');
it('does not erase another task draft or navigate after a delayed send',async()=>{
const reply=new Subject<any>();http.post.and.returnValue(reply);
const f=TestBed.createComponent(WorkspaceComponent);f.detectChanges();const c=f.componentInstance;c.taskGuidance='A';const pending=c.submitGuidance();
selected.set({session_id:'other',status:'running'});f.detectChanges();c.taskGuidance='B';reply.next({});reply.complete();await pending;
expect(c.taskGuidance).toBe('B');expect(c.notice()).toBe('');f.destroy();
});
it('continues a task pause while leaving model request recovery separate',async()=>{
selected.set({...selected(),status:'task_paused'});const f=TestBed.createComponent(WorkspaceComponent);f.detectChanges();await f.componentInstance.continueCurrentTask();
expect(http.post).toHaveBeenCalledWith('/api/tasks/run/continue',{});
selected.set({...selected(),status:'paused'});f.detectChanges();expect(f.nativeElement.textContent).toContain('模型调用暂停');expect(f.nativeElement.textContent).not.toContain('直接继续');f.destroy();
});
});
@@ -1,372 +1,96 @@
/**
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component, ChangeDetectionStrategy, NgZone, DestroyRef, inject, computed, signal, ViewChild, ElementRef, OnInit } from '@angular/core';
import { Component, ChangeDetectionStrategy, NgZone, DestroyRef, inject, computed, signal, ViewChild, ElementRef, OnInit, effect } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { AgentStreamComponent } from '../../components/agent-stream/agent-stream.component';
import { ChatInterfaceComponent } from '../../components/chat-interface/chat-interface.component';
import { FloatingVideoPlayerComponent } from '../../components/floating-video-player/floating-video-player.component';
import { ExplorationPanelComponent } from '../../components/exploration-panel/exploration-panel.component';
import { AgentService } from '../../services/agent.service';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { TaskPluginsComponent, TaskPluginOptions } from '../../components/task-plugins/task-plugins.component';
import { ExplorationService } from '../../services/exploration.service';
@Component({
selector: 'app-workspace',
standalone: true,
imports: [
FormsModule,
TaskPluginsComponent,
AgentStreamComponent,
ChatInterfaceComponent,
FloatingVideoPlayerComponent
],
templateUrl: './workspace.component.html',
styleUrl: './workspace.component.scss',
selector: 'app-workspace', standalone: true,
imports: [FormsModule, AgentStreamComponent, ChatInterfaceComponent, FloatingVideoPlayerComponent, ExplorationPanelComponent],
templateUrl: './workspace.component.html', styleUrl: './workspace.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class WorkspaceComponent implements OnInit {
taskPlugins: TaskPluginOptions = {};
public agentService = inject(AgentService);
agentService = inject(AgentService);
exploration = inject(ExplorationService);
private http = inject(HttpClient);
private zone = inject(NgZone);
private destroyRef = inject(DestroyRef);
// Default right panel width to 1/3 of the screen (or 450px as fallback)
public rightPanelWidth = signal<number>(
typeof window !== 'undefined' ? Math.round(window.innerWidth / 3) : 450
);
public isDragging = signal<boolean>(false);
rightPanelWidth = signal(Math.round(window.innerWidth / 3));
isDragging = signal(false);
private dragWidthRafId: number | null = null;
private pendingDragWidth = 0;
// Floating Command Bar State. taskInput is backed by a signal so computed
// expressions (isBarExpanded) genuinely track it under OnPush.
private taskInputSignal = signal<string>('');
public get taskInput(): string { return this.taskInputSignal(); }
public set taskInput(value: string) { this.taskInputSignal.set(value); }
public isSubmitting = signal<boolean>(false);
public errorMessage = signal<string | null>(null);
public selectedProfile = signal<'flash' | 'pro'>('flash');
private guidanceDrafts = new Map<string, string>();
public guidanceBusy = signal(false);
public guidanceOpenId = signal<string | null>(null);
public pauseRequestId = signal<string | null>(null);
public get taskGuidance(): string {
const id = this.agentService.currentSessionId();
return id ? this.guidanceDrafts.get(id) || '' : '';
guidanceBusy = signal(false);
errorMessage = signal('');
notice = signal('');
private requests = new Map<string, string>();
constructor() {
effect(() => { this.agentService.currentSessionId(); this.notice.set(''); this.errorMessage.set(''); });
}
public set taskGuidance(value: string) {
@ViewChild('dockInput') dockInputRef?: ElementRef<HTMLTextAreaElement>;
isExploration = computed(() => this.agentService.currentSession()?.exploration_kind === 'exploration');
explorationId = computed(() => this.isExploration() ? this.agentService.currentSession()?.exploration_id || null : null);
get taskGuidance(): string { return this.guidanceDrafts.get(this.agentService.currentSessionId() || '') || ''; }
set taskGuidance(value: string) { const id = this.agentService.currentSessionId(); if (id) this.guidanceDrafts.set(id, value); }
guidanceStatus = computed(() => {
const id = this.agentService.currentSessionId();
if (id) this.guidanceDrafts.set(id, value);
}
public guidanceStatus = computed(() => {
const id = this.agentService.currentSessionId();
if (!id) return null;
const status = this.agentService.currentSession()?.status
|| (this.agentService.runningSessionId() === id ? this.agentService.agentStatus() : '');
if (!id || this.isExploration()) return null;
const status = this.agentService.currentSession()?.status || (this.agentService.runningSessionId() === id ? this.agentService.agentStatus() : '');
return ['running', 'paused', 'task_paused', 'resuming'].includes(status || '') ? status : null;
});
public toggleGuidance(): void {
const id = this.agentService.currentSessionId();
this.guidanceOpenId.set(id && this.guidanceOpenId() !== id ? id : null);
ngOnInit() {
this.zone.runOutsideAngular(() => window.addEventListener('keydown', this.onGlobalKeyDown));
this.destroyRef.onDestroy(() => { window.removeEventListener('keydown', this.onGlobalKeyDown); this.detachDragListeners(); });
}
// Expand States (Signals for 0-latency reactivity)
public isHoveringCard = signal<boolean>(false);
public isInputFocused = signal<boolean>(false);
@ViewChild('dockInput') public dockInputRef?: ElementRef<HTMLTextAreaElement>;
ngOnInit(): void {
if (typeof localStorage !== 'undefined') {
const saved = localStorage.getItem('artemis_selected_profile');
if (saved === 'flash' || saved === 'pro') {
this.selectedProfile.set(saved);
}
}
// The global ⌘K/Ctrl+K shortcut is registered outside the Angular zone so
// ordinary typing never schedules an extra change-detection pass.
this.zone.runOutsideAngular(() => {
window.addEventListener('keydown', this.onGlobalKeyDown);
});
this.destroyRef.onDestroy(() => {
window.removeEventListener('keydown', this.onGlobalKeyDown);
this.detachDragListeners();
});
}
/**
* Set agent architecture profile ('flash' vs 'pro')
*/
public setProfile(profile: 'flash' | 'pro', event?: MouseEvent): void {
if (event) {
event.stopPropagation();
}
this.selectedProfile.set(profile);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('artemis_selected_profile', profile);
}
}
/**
* Computed boolean whether the currently viewed task is actively running or paused.
* Only displays the stop/cancel button when inspecting an active task.
*/
public isTaskRunning = computed(() => {
return this.agentService.isCurrentSessionRunning();
});
public async pauseCurrentTask(): Promise<void> {
const id = this.agentService.currentSessionId();
if (!id || this.guidanceStatus() !== 'running' || this.guidanceBusy()) return;
this.guidanceBusy.set(true);
this.errorMessage.set(null);
try {
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/pause`, {}));
this.pauseRequestId.set(id);
this.guidanceOpenId.set(null);
this.agentService.fetchStatus();
this.agentService.fetchSessions();
} catch (error) {
this.showGuidanceError(error);
} finally {
this.guidanceBusy.set(false);
}
}
public async submitGuidance(): Promise<void> {
const id = this.agentService.currentSessionId();
const instruction = this.taskGuidance.trim();
const status = this.guidanceStatus();
if (!id || !status || !instruction || this.guidanceBusy()) return;
this.guidanceBusy.set(true);
this.errorMessage.set(null);
try {
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/guide`, { instruction }));
this.guidanceDrafts.delete(id);
this.pauseRequestId.set(null);
this.guidanceOpenId.set(null);
this.agentService.fetchGuidance(id);
this.agentService.fetchStatus();
this.agentService.fetchSessions();
} catch (error) {
this.showGuidanceError(error);
} finally {
this.guidanceBusy.set(false);
}
}
public async continueCurrentTask(): Promise<void> {
const id = this.agentService.currentSessionId();
if (!id || this.guidanceStatus() !== 'task_paused' || this.guidanceBusy()) return;
this.guidanceBusy.set(true);
this.errorMessage.set(null);
try {
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/continue`, {}));
this.pauseRequestId.set(null);
this.guidanceOpenId.set(null);
this.agentService.fetchStatus();
this.agentService.fetchSessions();
} catch (error) {
this.showGuidanceError(error);
} finally {
this.guidanceBusy.set(false);
}
}
private showGuidanceError(error: unknown): void {
const response = error as { error?: { detail?: string }; message?: string };
this.errorMessage.set(response.error?.detail || response.message || '操作失败,请刷新任务状态后重试');
}
/**
* Dynamic tooltip and label indicating which task will be stopped
*/
public stopButtonTitle = computed(() => {
const session = this.agentService.currentSession();
if (session?.initial_goal) {
const truncated = session.initial_goal.length > 45
? session.initial_goal.substring(0, 42) + '...'
: session.initial_goal;
return `停止当前任务:"${truncated}"`;
}
const curId = this.agentService.currentSessionId();
if (curId) {
return `停止当前任务(${curId})`;
}
return "停止当前任务";
});
/**
* Computed boolean whether the command bar should be in its expanded state:
* - Mouse is hovering directly on the command card
* - Input textarea is focused
* - User has entered task text (drafting)
*/
public isBarExpanded = computed(() => {
return (
this.isHoveringCard() ||
this.isInputFocused() ||
this.taskInput.trim().length > 0
);
});
/**
* Focus input handler
*/
public onInputFocus(): void {
this.isInputFocused.set(true);
}
/**
* Blur input handler
*/
public onInputBlur(): void {
this.isInputFocused.set(false);
}
/**
* Click on the resting capsule or dock card to focus textarea
*/
public onCardClick(event: MouseEvent): void {
const target = event.target as HTMLElement;
// Don't steal focus if clicking action buttons or textarea directly
if (target.closest('button') || target.tagName.toLowerCase() === 'textarea') {
return;
}
this.focusInput();
}
/**
* Focus textarea programmatically
*/
public focusInput(): void {
setTimeout(() => {
this.dockInputRef?.nativeElement?.focus();
}, 30);
}
/**
* Clear typed task input
*/
public clearInput(event?: MouseEvent): void {
if (event) {
event.stopPropagation();
}
this.taskInput = '';
if (this.dockInputRef?.nativeElement) {
this.dockInputRef.nativeElement.style.height = 'auto';
this.dockInputRef.nativeElement.focus();
}
}
/**
* Auto-resize textarea as user types multi-line tasks
*/
public onTextareaInput(event: Event): void {
const textarea = event.target as HTMLTextAreaElement;
if (!textarea) return;
textarea.style.height = 'auto';
const newHeight = Math.min(Math.max(textarea.scrollHeight, 24), 120);
textarea.style.height = `${newHeight}px`;
}
/**
* Global keyboard shortcut (⌘K or Ctrl+K) to focus input bar from anywhere
*/
private onGlobalKeyDown = (event: KeyboardEvent): void => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
event.preventDefault();
this.focusInput();
private onGlobalKeyDown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k' && !this.isExploration()) {
event.preventDefault(); this.dockInputRef?.nativeElement.focus();
}
};
/**
* Handle enter key in floating dock textarea
*/
public onKeyDown(event: KeyboardEvent): void {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
this.submitTask();
}
onKeyDown(event: KeyboardEvent) {
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) { event.preventDefault(); void this.submitGuidance(); }
}
/**
* Submit a new task instruction
*/
public submitTask(): void {
const goal = this.taskInput.trim();
if (!goal || this.isSubmitting()) {
return;
}
this.isSubmitting.set(true);
this.errorMessage.set(null);
if (this.dockInputRef?.nativeElement) {
this.dockInputRef.nativeElement.blur();
}
this.isInputFocused.set(false);
this.agentService.runTask(goal, this.selectedProfile(), undefined, undefined, undefined, { ...this.taskPlugins }).subscribe({
next: (res) => {
this.taskInput = '';
if (this.dockInputRef?.nativeElement) {
this.dockInputRef.nativeElement.style.height = 'auto';
}
this.isSubmitting.set(false);
this.agentService.fetchStatus();
},
error: (err) => {
console.error('Failed to submit task:', err);
this.isSubmitting.set(false);
this.errorMessage.set(err.error?.detail || "执行器忙碌,请等待当前任务结束。");
setTimeout(() => {
this.errorMessage.set(null);
}, 5000);
private async guideAction(action: string, body: object = {}) {
const id = this.agentService.currentSessionId();
if (!id || !this.guidanceStatus() || this.guidanceBusy()) return;
const sentText = this.taskGuidance;
this.guidanceBusy.set(true); this.errorMessage.set(''); this.notice.set('');
try {
const key = JSON.stringify([id,action,body]);
const requestId = this.requests.get(key) || crypto.randomUUID(); this.requests.set(key,requestId);
await firstValueFrom(this.http.post(`/api/tasks/${encodeURIComponent(id)}/${action}`, action === 'guide' ? {...body,request_id:requestId} : body));
this.requests.delete(key);
if (action === 'guide') {
if (this.guidanceDrafts.get(id) === sentText) this.guidanceDrafts.delete(id);
this.agentService.fetchGuidance(id);
}
});
if (this.agentService.currentSessionId() === id) {
this.notice.set(action === 'guide' ? '指导已发送,是否落实请查看后续操作记录。' : action === 'pause' ? '正在等待当前动作安全结束。' : '已请求继续执行。');
}
this.agentService.fetchSessions(); this.agentService.fetchStatus();
} catch (e) {
if (this.agentService.currentSessionId() === id) { const error = e as {error?:{detail?:string};message?:string}; this.errorMessage.set(error.error?.detail || error.message || '操作失败,请重试'); }
} finally { this.guidanceBusy.set(false); }
}
/**
* Stop currently viewed task
*/
public stopTask(event?: MouseEvent): void {
if (event) {
event.stopPropagation();
}
if (!this.isTaskRunning()) {
return;
}
const targetSessionId = this.agentService.currentSessionId();
this.isSubmitting.set(true);
this.errorMessage.set(null);
this.agentService.stopTask(targetSessionId, false);
setTimeout(() => {
this.isSubmitting.set(false);
}, 400);
async submitGuidance() { if (this.taskGuidance.trim()) await this.guideAction('guide', {instruction:this.taskGuidance.trim()}); }
async pauseCurrentTask() { if (this.guidanceStatus() === 'running') await this.guideAction('pause'); }
async continueCurrentTask() { if (this.guidanceStatus() === 'task_paused') await this.guideAction('continue'); }
stopTask() { if (this.guidanceStatus()) this.agentService.stopTask(this.agentService.currentSessionId(), false); }
async openOrigin() {
const selectedId = this.agentService.currentSessionId();
const origin = this.agentService.currentSession()?.exploration_id; if (!origin) return;
try {
const session = await this.exploration.get(origin);
const run = [...session.runs].reverse().find(r => r.kind === 'exploration');
if (run && this.agentService.currentSessionId() === selectedId) this.agentService.selectSession(run.id, true);
} catch { this.errorMessage.set('来源探索暂时无法读取,请重试'); }
}
/**
* Handle mouse down on resizer bar to start dragging. The move/up listeners
* are attached only for the duration of the drag and run outside the Angular
@@ -510,3 +510,24 @@ describe('AgentService task cancellation and active session tracking', () => {
expect(service.activeTasks().map((at: any) => at.session_id)).toEqual(['sess-1']);
});
});
describe('Exploration queue grouping',()=>{
const service=Object.create(AgentService.prototype) as AgentService;
it('keeps validations independent and groups only exploration executions',()=>{
service.batchFilter=signal('__exploration__');
const old:any={session_id:'old',exploration_id:'e',exploration_kind:'exploration',exploration_latest_run_id:'new'};
const next:any={...old,session_id:'new',exploration_status:'paused'};
const validation:any={...old,session_id:'validation',exploration_kind:'validation'};
expect(service.isQueueRepresentative(old)).toBeFalse();expect(service.isQueueRepresentative(next)).toBeTrue();expect(service.isQueueRepresentative(validation)).toBeTrue();
expect(service.matchesBatchFilter(next)).toBeTrue();expect(service.matchesBatchFilter(validation)).toBeFalse();expect(service.explorationQueueStatus(next)).toBe('paused');
service.batchFilter.set('__single__');expect(service.matchesBatchFilter(validation)).toBeTrue();expect(service.matchesBatchFilter(next)).toBeFalse();
});
});
describe('Queued exploration identity',()=>{
it('preserves exploration mode before the session row exists',()=>{
const service=Object.create(AgentService.prototype) as AgentService;
const fields=service.explorationSessionFields({exploration_id:'conversation',exploration_kind:'exploration',exploration_model:'gpt-6.1-sol',exploration_latest_run_id:'new',exploration_status:'queued'});
expect(fields.exploration_id).toBe('conversation');expect(fields.model_info?.architecture).toBe('explore');expect(fields.exploration_latest_run_id).toBe('new');
});
});
@@ -107,10 +107,38 @@ export class AgentService {
const filter = this.batchFilter();
if (!filter) return true;
if (filter === '__batch__') return !!session.batch_id;
if (filter === '__single__') return !session.batch_id;
if (filter === '__single__') return !session.batch_id && session.exploration_kind !== 'exploration';
if (filter === '__exploration__') return session.exploration_kind === 'exploration';
return session.batch_id === filter;
}
public explorationSessionFields(item: any): Partial<Session> {
if (!item?.exploration_id) return {};
return {
exploration_id:item.exploration_id, exploration_kind:item.exploration_kind,
exploration_latest_run_id:item.exploration_latest_run_id, exploration_status:item.exploration_status,
exploration_run_count:item.exploration_run_count, case_version:item.case_version,
...(item.display_name ? {display_name:item.display_name} : {}),
...(item.exploration_kind === 'exploration' ? {model_info:{name:'探索',id:item.exploration_model,provider:'openai',architecture:'explore'}} : {})
};
}
public isQueueRepresentative(session: Session): boolean {
return session.exploration_kind !== 'exploration' || !session.exploration_latest_run_id || session.exploration_latest_run_id === session.session_id;
}
public explorationQueueStatus(session: Session): string | null {
if (session.exploration_kind !== 'exploration') return null;
const status = session.exploration_status;
if (status === 'finalizing') return 'running';
if (['paused','waiting_user','demonstrating','analyzing_demo','interrupted'].includes(status || '')) return 'paused';
if (['draft','queued','reserved'].includes(status || '')) return 'pending';
return status || null;
}
public explorationStatusLabel(session: Session): string | null {
if(session.exploration_kind !== 'exploration') return null;
return ({waiting_user:'等待你回答',demonstrating:'人工示范中',analyzing_demo:'分析示范中',paused:'已暂停 · 可继续',finalizing:'正在提炼用例',running:'探索中',completed:'探索结束'} as Record<string,string>)[session.exploration_status || ''] || null;
}
public applyCaseReview(review: CaseReviewResponse): void {
this.rawSessions.update(items => items.map(session => session.session_id === review.id
? {...session, test_result: review.test_result} : session));
@@ -164,7 +192,7 @@ export class AgentService {
batch_id: s.batch_id || activeMatch?.batch_id || pendingMatch?.batch_id || null,
status: sStatus,
device_serial: serial,
model_info: isCurrentActive && this.activeModel() ? this.activeModel()! : s.model_info
model_info: !s.exploration_id && isCurrentActive && this.activeModel() ? this.activeModel()! : s.model_info
};
sessionMap.set(s.session_id, finalSession);
if (isTerminal) {
@@ -198,13 +226,14 @@ export class AgentService {
const existing = sessionMap.get(sid);
if (!existing) {
const newSession: Session = {
...this.explorationSessionFields(at),
session_id: sid,
batch_id: at.batch_id || null,
display_name: at.display_name,
initial_goal: at.goal || goal || '',
start_time: at.acquired_at ? (new Date(at.acquired_at).getTime() / 1000) : (Date.now() / 1000),
status: at.status || 'running',
model_info: this.activeModel() || undefined,
model_info: this.explorationSessionFields(at).model_info || this.activeModel() || undefined,
device_serial: at.device_id || null
};
sessionMap.set(sid, newSession);
@@ -1630,6 +1659,7 @@ export class AgentService {
const pending: Session[] = (data.queue || []).map((item: any, index: number) => {
if (typeof item === 'object' && item !== null) {
return {
...this.explorationSessionFields(item),
session_id: item.session_id || `pending-task-${index}`,
batch_id: item.batch_id || null,
display_name: item.display_name,
@@ -0,0 +1,41 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { CaseTestResult } from '../core/models/session.model';
export interface ExplorationConfig {
model: string; reasoning_effort: string; timeout_seconds: number;
demonstration_timeout_seconds: number; demonstration_warning_seconds: number;
}
export interface ExplorationMessage { id: string; sequence: number; role: string; text: string; recommendation?: string; kind?: string; created_at: number; run_id?: string; }
export interface ExplorationCase { version: number; name: string; goal: string; preconditions: string; evidence: string[]; source_run_id?: string; }
export interface ExplorationRun { id: string; kind: 'exploration' | 'validation'; status: string; user_guidance_count?: number; case_version?: number; test_result?: CaseTestResult | null; created_at: number; task_config: Record<string, unknown>; }
export interface Demonstration { id: string; run_id: string; source: string; status: string; created_at?: number; started_at?: number; finished_at?: number; deadline?: number; warning_seconds: number; error?: string; analysis?: {summary: string}; media: {kind: string; segments: {file: string}[]}[]; }
export interface ExplorationSession {
id: string; goal: string; status: string; config: ExplorationConfig; active_run_id: string | null;
active_demonstration_id: string | null; case_version: number; message_count: number;
task_config?: Record<string, unknown>;
elapsed_seconds: number; continuation_count: number; pending_question?: string; pause_reason?: string;
messages: ExplorationMessage[]; runs: ExplorationRun[]; case: ExplorationCase | null; demonstrations: Demonstration[];
generations: {id: string; status: string; error?: string}[];
}
@Injectable({providedIn: 'root'})
export class ExplorationService {
private http = inject(HttpClient);
drafts = new Map<string, string>();
requests = new Map<string, string>();
caseDrafts = new Map<string, {name: string; goal: string; preconditions: string; version: number}>();
private base = '/api/explorations';
list() { return firstValueFrom(this.http.get<ExplorationSession[]>(this.base)); }
settings() { return firstValueFrom(this.http.get<ExplorationConfig>(this.base + '/settings')); }
saveSettings(value: ExplorationConfig) { return firstValueFrom(this.http.put<ExplorationConfig>(this.base + '/settings', value)); }
get(id: string, after = 0) { return firstValueFrom(this.http.get<ExplorationSession>(`${this.base}/${encodeURIComponent(id)}?after=${after}`)); }
create(goal: string, config: ExplorationConfig | undefined, task_config: Record<string, unknown>, request_id: string) {
return firstValueFrom(this.http.post<ExplorationSession>(this.base, {goal, config, task_config, request_id}));
}
act<T = Record<string, unknown>>(id: string, action: string, payload: Record<string, unknown> = {}, requestId: string = crypto.randomUUID()) {
return firstValueFrom(this.http.post<T>(`${this.base}/${encodeURIComponent(id)}/${action}`, {...payload, request_id: requestId}));
}
version(id: string, version: number) { return firstValueFrom(this.http.get<ExplorationCase>(`${this.base}/${encodeURIComponent(id)}/case?version=${version}`)); }
}
@@ -0,0 +1,9 @@
import { mergeConversation } from './exploration-timeline.util';
describe('Exploration timeline',()=>{
it('interleaves guidance without charging chat time or tokens to operations',()=>{
const operation={id:'step',durationSeconds:8,tokens:100,blocks:[{id:'b',type:'step' as const,timestamp:new Date(20000).toISOString(),data:{operator_raw_thinking:'准备动作。 完成,已确认页面。'}}]};
const messages=[{id:'a',sequence:1,role:'user',text:'先观察',created_at:10},{id:'b',sequence:2,role:'assistant',text:'完成,已确认页面。',created_at:21},{id:'c',sequence:3,role:'assistant',text:'下一步?',recommendation:'继续',created_at:22}];
const result=mergeConversation([operation],messages);
expect(result.map(p=>p.id)).toEqual(['message-a','step','message-c']);expect(result[1]).toBe(operation);expect(result[1].durationSeconds).toBe(8);expect(result[1].tokens).toBe(100);
});
});
@@ -0,0 +1,19 @@
import { PhaseBlock } from '../core/models/stream.model';
import { ExplorationMessage, Demonstration } from '../services/exploration.service';
export type ConversationPhase = PhaseBlock & {conversation?: ExplorationMessage; demonstration?: Demonstration};
/** Keep step accounting unchanged when inserting messages between real operations. */
export function mergeConversation(phases: PhaseBlock[], messages: ExplorationMessage[], demonstrations: Demonstration[] = []): ConversationPhase[] {
const normalize = (text: string) => text.replace(/\s+/g, ' ').trim();
const texts = phases.flatMap(p => p.blocks.flatMap(b => [b.data?.operator_raw_thinking, b.data?.text])).filter(Boolean).map(t => normalize(String(t)));
const entries: ConversationPhase[] = [...phases];
for (const message of messages) {
if (message.role === 'assistant' && !message.recommendation && texts.some(t => t.includes(normalize(message.text)))) continue;
entries.push({id: 'message-' + message.id, durationSeconds: 0, blocks: [], conversation: message});
}
for (const demo of demonstrations) entries.push({id: 'demo-' + demo.id, durationSeconds: 0, blocks: [], demonstration: demo});
return entries.sort((a,b) => {
const time = (p: ConversationPhase) => p.demonstration ? (p.demonstration.started_at || p.demonstration.created_at || 0) * 1000 : p.conversation ? p.conversation.created_at * 1000 : Date.parse(p.blocks[0]?.timestamp || '') || 0;
return time(a) - time(b) || (a.conversation?.sequence || 0) - (b.conversation?.sequence || 0);
});
}