feat: add Chinese UI, Responses models, and mirrored Windows setup
Localize the web console and persist custom model and capability settings. Use OpenAI Responses exclusively and move runtime limits into environment configuration. Bootstrap uv and the virtual environment on Windows, support optional China mirrors, and package source distributions without environments, build output, or credentials. Update regression tests and repository quality checks for the new behavior.
This commit is contained in:
1 parent
371aa6df56
commit
2ddec53954
183 files changed
+3427
-11857
No files matched your search
@@ -1,3 +1,6 @@
|
||||
import { registerLocaleData } from '@angular/common';
|
||||
import zh from '@angular/common/locales/zh';
|
||||
registerLocaleData(zh);
|
||||
/**
|
||||
* Copyright 2026 Google LLC
|
||||
*
|
||||
@@ -14,7 +17,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
|
||||
import { ApplicationConfig, LOCALE_ID, provideZoneChangeDetection } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient, withXhr } from '@angular/common/http';
|
||||
|
||||
@@ -22,6 +25,7 @@ import { routes } from './app.routes';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
{ provide: LOCALE_ID, useValue: 'zh-CN' },
|
||||
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||
provideRouter(routes),
|
||||
provideHttpClient(withXhr())
|
||||
|
||||
@@ -4,17 +4,17 @@ import { buildStartupWorkItems } from './agent-stream.component';
|
||||
describe('startup Work block', () => {
|
||||
it('shows only the three device preparation operations', () => {
|
||||
const events: StartupProgressEvent[] = [
|
||||
{ stage: 'submitting', message: 'Submitting the task', timestamp: 100 },
|
||||
{ stage: 'submitting', message: '正在提交任务', timestamp: 100 },
|
||||
{ stage: 'queued', message: 'Task received and queued', timestamp: 100.9 },
|
||||
{ stage: 'launching', message: 'Starting the execution process', timestamp: 101 },
|
||||
{ stage: 'configuration', message: 'Loading the run configuration', timestamp: 103.1 },
|
||||
{ stage: 'device_check', message: 'Checking the Android device', timestamp: 103.2 },
|
||||
{ stage: 'device_ready', message: 'Android device connected', timestamp: 123 },
|
||||
{ stage: 'device_ready', message: 'Android 设备已连接', timestamp: 123 },
|
||||
{ stage: 'model_warmup', message: 'Warming the model connection', timestamp: 123 },
|
||||
{ stage: 'uiautomator', message: 'Connecting to UI Automator', timestamp: 124 },
|
||||
{ stage: 'uiautomator_ready', message: 'UI Automator is ready', timestamp: 128 },
|
||||
{ stage: 'environment', message: 'Preparing the device environment', timestamp: 128 },
|
||||
{ stage: 'environment_ready', message: 'Device environment is ready', timestamp: 131 }
|
||||
{ stage: 'environment_ready', message: '设备环境已就绪', timestamp: 131 }
|
||||
];
|
||||
|
||||
const items = buildStartupWorkItems(events, 131, false, true);
|
||||
@@ -90,7 +90,7 @@ describe('startup Work block', () => {
|
||||
|
||||
const [item] = buildStartupWorkItems(events, 20, false, true);
|
||||
|
||||
expect(item.message).toBe('UI hierarchy service is ready');
|
||||
expect(item.message).toBe('界面层级服务已就绪');
|
||||
});
|
||||
|
||||
it('uses old first_response events only as a hidden completion boundary', () => {
|
||||
@@ -101,7 +101,7 @@ describe('startup Work block', () => {
|
||||
|
||||
const [item] = buildStartupWorkItems(events, 24, false, true);
|
||||
|
||||
expect(item.message).toBe('Device environment is ready');
|
||||
expect(item.message).toBe('设备环境已就绪');
|
||||
expect(item.elapsed).toBe('3.0s');
|
||||
expect(item.isActive).toBeFalse();
|
||||
});
|
||||
|
||||
@@ -24,11 +24,11 @@ limitations under the License.
|
||||
class="vscode-tab-btn"
|
||||
[class.active]="isTaskDropdownOpen()"
|
||||
(click)="toggleTaskDropdown($event)"
|
||||
title="Task Queue"
|
||||
aria-label="Task Queue"
|
||||
title="任务队列"
|
||||
aria-label="任务队列"
|
||||
>
|
||||
<span class="material-symbols-outlined tab-icon">list_alt</span>
|
||||
<span class="tab-label">Task Queue</span>
|
||||
<span class="tab-label">任务队列</span>
|
||||
</button>
|
||||
<button
|
||||
#notesOrigin="cdkOverlayOrigin"
|
||||
@@ -36,11 +36,11 @@ limitations under the License.
|
||||
class="vscode-tab-btn"
|
||||
[class.active]="isNotesDropdownOpen()"
|
||||
(click)="toggleNotesDropdown($event)"
|
||||
title="Notes & Plans"
|
||||
aria-label="Notes & Plans"
|
||||
title="笔记与计划"
|
||||
aria-label="笔记与计划"
|
||||
>
|
||||
<span class="material-symbols-outlined tab-icon">description</span>
|
||||
<span class="tab-label">Notes & Plans</span>
|
||||
<span class="tab-label">笔记与计划</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -57,7 +57,7 @@ limitations under the License.
|
||||
<div class="vscode-dropdown-menu task-dropdown">
|
||||
<div class="popover-title-header">
|
||||
<span class="material-symbols-outlined">list_alt</span>
|
||||
<span>Task Queue</span>
|
||||
<span>任务队列</span>
|
||||
</div>
|
||||
<div class="dropdown-section-label">
|
||||
<span class="material-symbols-outlined icon-bolt">bolt</span>
|
||||
@@ -65,7 +65,8 @@ limitations under the License.
|
||||
</div>
|
||||
@if (activeQueue().length === 0) {
|
||||
<div class="dropdown-empty-state">
|
||||
No active or queued tasks
|
||||
|
||||
没有正在执行或排队的任务
|
||||
</div>
|
||||
}
|
||||
@for (session of activeQueue(); track trackSession($index, session)) {
|
||||
@@ -80,7 +81,7 @@ limitations under the License.
|
||||
@if (getTaskStatus(session) === 'running') {
|
||||
<span class="status-dot"></span>
|
||||
}
|
||||
{{ getTaskStatus(session) | uppercase }}
|
||||
{{ getTaskStatus(session) | uiLabel }}
|
||||
</span>
|
||||
<div class="item-header-actions">
|
||||
@if (getDeviceSerial(session)) {
|
||||
@@ -98,8 +99,8 @@ limitations under the License.
|
||||
type="button"
|
||||
class="btn-stop-card"
|
||||
(click)="stopTask(session.session_id, $event)"
|
||||
title="Stop this task"
|
||||
aria-label="Stop this task"
|
||||
title="停止此任务"
|
||||
aria-label="停止此任务"
|
||||
>
|
||||
<span class="material-symbols-outlined">stop_circle</span>
|
||||
</button>
|
||||
@@ -109,8 +110,8 @@ limitations under the License.
|
||||
type="button"
|
||||
class="btn-delete-card"
|
||||
(click)="deleteTask(session.session_id, $event)"
|
||||
title="Delete this task"
|
||||
aria-label="Delete this task"
|
||||
title="删除此任务"
|
||||
aria-label="删除此任务"
|
||||
>
|
||||
<span class="material-symbols-outlined">delete</span>
|
||||
</button>
|
||||
@@ -118,7 +119,7 @@ limitations under the License.
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-goal" title="{{ session.initial_goal }}">
|
||||
{{ session.initial_goal || 'Unnamed Task' }}
|
||||
{{ session.initial_goal || '未命名任务' }}
|
||||
</div>
|
||||
<div class="item-footer">
|
||||
<span class="item-id" [title]="session.session_id">ID: {{ session.session_id }}</span>
|
||||
@@ -132,7 +133,8 @@ limitations under the License.
|
||||
</div>
|
||||
@if (historyTasks().length === 0) {
|
||||
<div class="dropdown-empty-state">
|
||||
No past task history
|
||||
|
||||
暂无历史任务
|
||||
</div>
|
||||
}
|
||||
<div class="dropdown-history-list">
|
||||
@@ -148,7 +150,7 @@ limitations under the License.
|
||||
@if (getTaskStatus(session) === 'running') {
|
||||
<span class="status-dot"></span>
|
||||
}
|
||||
{{ getTaskStatus(session) | uppercase }}
|
||||
{{ getTaskStatus(session) | uiLabel }}
|
||||
</span>
|
||||
<div class="item-header-actions">
|
||||
@if (getDeviceSerial(session)) {
|
||||
@@ -166,8 +168,8 @@ limitations under the License.
|
||||
type="button"
|
||||
class="btn-delete-card"
|
||||
(click)="deleteTask(session.session_id, $event)"
|
||||
title="Delete this task"
|
||||
aria-label="Delete this task"
|
||||
title="删除此任务"
|
||||
aria-label="删除此任务"
|
||||
>
|
||||
<span class="material-symbols-outlined">delete</span>
|
||||
</button>
|
||||
@@ -175,7 +177,7 @@ limitations under the License.
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-goal" title="{{ session.initial_goal }}">
|
||||
{{ session.initial_goal || 'Unnamed Task' }}
|
||||
{{ session.initial_goal || '未命名任务' }}
|
||||
</div>
|
||||
<div class="item-footer">
|
||||
<span class="item-id" [title]="session.session_id">ID: {{ session.session_id }}</span>
|
||||
@@ -200,7 +202,7 @@ limitations under the License.
|
||||
<div class="vscode-dropdown-menu notes-dropdown">
|
||||
<div class="popover-title-header">
|
||||
<span class="material-symbols-outlined">description</span>
|
||||
<span>Notes & Plans</span>
|
||||
<span>笔记与计划</span>
|
||||
</div>
|
||||
<!-- Notes Selector / Pills (Shown if there are note files) -->
|
||||
@if (noteKeys().length > 1) {
|
||||
@@ -222,7 +224,7 @@ limitations under the License.
|
||||
@if (noteKeys().length === 0) {
|
||||
<div class="dropdown-empty-state">
|
||||
<span class="material-symbols-outlined empty-icon">description</span>
|
||||
<span>No notes available for this session.</span>
|
||||
<span>此会话暂无笔记。</span>
|
||||
</div>
|
||||
}
|
||||
<!-- Rich Notes Content Viewer -->
|
||||
@@ -402,7 +404,7 @@ limitations under the License.
|
||||
(click)="toggleRunInfo($event)"
|
||||
>
|
||||
<span class="material-symbols-outlined model-icon">{{ getModelIcon(model.name) }}</span>
|
||||
<span class="model-label">Architecture:</span>
|
||||
<span class="model-label">执行模式:</span>
|
||||
<span class="model-name">{{ getModelDisplayName(model.name) }}</span>
|
||||
</button>
|
||||
|
||||
@@ -415,7 +417,7 @@ limitations under the License.
|
||||
(backdropClick)="isRunInfoOpen.set(false)"
|
||||
[cdkConnectedOverlayPositions]="dropdownPositions"
|
||||
>
|
||||
<div class="vscode-dropdown-menu run-info-dropdown" role="dialog" aria-label="Run info">
|
||||
<div class="vscode-dropdown-menu run-info-dropdown" role="dialog" aria-label="运行信息">
|
||||
<div class="run-info-header">
|
||||
<span class="material-symbols-outlined" [class.is-pro]="runInfoIsPro()">{{ getModelIcon(model.name) }}</span>
|
||||
<span class="run-info-title">{{ getModelDisplayName(model.name) }}</span>
|
||||
@@ -425,16 +427,16 @@ limitations under the License.
|
||||
</div>
|
||||
|
||||
<div class="run-info-row">
|
||||
<span class="run-info-label">Elapsed</span>
|
||||
<span class="run-info-label">已用时间</span>
|
||||
<span class="run-info-value">{{ runInfoElapsed() }}</span>
|
||||
</div>
|
||||
|
||||
<div class="run-info-row">
|
||||
<span class="run-info-label">Tokens</span>
|
||||
<span class="run-info-label">Token 用量</span>
|
||||
@if (runUsage(); as usage) {
|
||||
<span class="run-info-value">
|
||||
{{ formatRunTokens(usage.total_tokens) }}
|
||||
<span class="run-info-sub">{{ formatRunTokens(usage.prompt_tokens) }} in · {{ formatRunTokens(usage.completion_tokens) }} out</span>
|
||||
<span class="run-info-sub">{{ formatRunTokens(usage.prompt_tokens) }} 输入 · {{ formatRunTokens(usage.completion_tokens) }} 输出</span>
|
||||
</span>
|
||||
} @else {
|
||||
<span class="run-info-value is-muted">—</span>
|
||||
@@ -443,11 +445,11 @@ limitations under the License.
|
||||
|
||||
<div class="run-info-row is-stacked">
|
||||
<div class="run-info-line">
|
||||
<span class="run-info-label">Context</span>
|
||||
<span class="run-info-label">上下文</span>
|
||||
@if (runInfoContextPercent() !== null) {
|
||||
<span class="run-info-value">
|
||||
{{ runInfoContextPercent() }}%
|
||||
<span class="run-info-sub-inline">of {{ formatRunTokens(runUsage()?.operator_context_window_tokens) }}</span>
|
||||
<span class="run-info-sub-inline">/ {{ formatRunTokens(runUsage()?.operator_context_window_tokens) }}</span>
|
||||
</span>
|
||||
} @else {
|
||||
<span class="run-info-value is-muted">—</span>
|
||||
@@ -464,15 +466,15 @@ limitations under the License.
|
||||
|
||||
@if (runInfoIsPro()) {
|
||||
<div class="run-info-row">
|
||||
<span class="run-info-label">Result check</span>
|
||||
<span class="run-info-label">结果检查</span>
|
||||
<span class="run-info-value">{{ runTuningLabel('verify', runInfoTuning()?.verification_level) }}</span>
|
||||
</div>
|
||||
<div class="run-info-row">
|
||||
<span class="run-info-label">Screen reading</span>
|
||||
<span class="run-info-label">屏幕识别</span>
|
||||
<span class="run-info-value">{{ runTuningLabel('explore', runInfoTuning()?.explorer_mode) }}</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="run-info-note">No extra settings for Flash.</div>
|
||||
<div class="run-info-note">Flash 无需额外设置。</div>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
@@ -520,22 +522,22 @@ limitations under the License.
|
||||
</span>
|
||||
}
|
||||
@if (agentService.isCurrentSessionRunning()) {
|
||||
<span class="btn-text">Recording...</span>
|
||||
<span class="btn-text">正在录制…</span>
|
||||
}
|
||||
@if (!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() === 'processing') {
|
||||
<span class="btn-text">Preparing...</span>
|
||||
<span class="btn-text">正在准备…</span>
|
||||
}
|
||||
@if (!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() !== 'processing' && agentService.currentSessionVideoUrl()) {
|
||||
<span class="btn-text">Play Recording</span>
|
||||
<span class="btn-text">播放录像</span>
|
||||
}
|
||||
@if (!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() !== 'processing' && !agentService.currentSessionVideoUrl() && agentService.hasCurrentSessionStepFrames()) {
|
||||
<span class="btn-text">Step Replay</span>
|
||||
<span class="btn-text">步骤回放</span>
|
||||
}
|
||||
@if (!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() !== 'processing' && !agentService.currentSessionVideoUrl() && !agentService.hasCurrentSessionStepFrames() && agentService.currentSessionRecordingStatus() === 'failed') {
|
||||
<span class="btn-text">Recording Failed</span>
|
||||
<span class="btn-text">录屏失败</span>
|
||||
}
|
||||
@if (!agentService.isCurrentSessionRunning() && agentService.currentSessionRecordingStatus() !== 'processing' && !agentService.currentSessionVideoUrl() && !agentService.hasCurrentSessionStepFrames() && agentService.currentSessionRecordingStatus() !== 'failed') {
|
||||
<span class="btn-text">Screen Recording</span>
|
||||
<span class="btn-text">屏幕录像</span>
|
||||
}
|
||||
@if (agentService.isCurrentSessionRunning()) {
|
||||
<span class="video-pulse-dot red"></span>
|
||||
@@ -554,15 +556,15 @@ limitations under the License.
|
||||
@if (agentService.isSessionContentLoading()) {
|
||||
<div class="session-loading-state" aria-live="polite">
|
||||
<span class="material-symbols-outlined spin-animation">progress_activity</span>
|
||||
<span>Loading run history...</span>
|
||||
<span>正在加载执行历史…</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!agentService.isSessionContentLoading() && consolidatedBlocks().length === 0 && !showPlanningLoader() && startupWorkItems().length === 0) {
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon"><span class="material-symbols-outlined">sensors</span></div>
|
||||
<h3>No Stream Activity Selected</h3>
|
||||
<p>Please select a session from the Task List on the right to view its real-time log payloads.</p>
|
||||
<h3>尚未选择执行记录</h3>
|
||||
<p>请从右侧任务列表选择会话,查看实时执行记录。</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -572,9 +574,9 @@ limitations under the License.
|
||||
@if (startupWorkItems().length > 0) {
|
||||
<div class="phase-container startup-phase" aria-live="polite">
|
||||
<div class="phase-header first-phase">
|
||||
<span class="phase-worked-time">Worked for {{ startupPreparationDuration() }}s</span>
|
||||
<span class="phase-worked-time">准备耗时 {{ startupPreparationDuration() }} 秒</span>
|
||||
</div>
|
||||
<div class="phase-body" aria-label="Task startup progress">
|
||||
<div class="phase-body" aria-label="任务启动进度">
|
||||
<div class="block-item type-stream">
|
||||
<div class="step-body">
|
||||
<div class="step-actions-container">
|
||||
@@ -661,7 +663,7 @@ limitations under the License.
|
||||
[class.collapsed]="isCheckerSegmentCollapsed(block, itemIdx)">
|
||||
<div class="details-summary" (click)="toggleCheckerSegment(block, itemIdx)">
|
||||
<span class="stream-status-dot" [class.streaming]="isCheckerSegmentRunning(block, itemIdx) && !isCheckerSegmentResetNotice(item)" [class.resetting]="isCheckerSegmentResetNotice(item)"></span>
|
||||
<span class="header-label">{{ isCheckerSegmentResetNotice(item) ? getCheckerSegmentResetMessage(item) : 'Thought' }}</span>
|
||||
<span class="header-label">{{ isCheckerSegmentResetNotice(item) ? getCheckerSegmentResetMessage(item) : '思考过程' }}</span>
|
||||
</div>
|
||||
@if (item.data.text && item.data.text.trim()) {
|
||||
<div class="details-content-wrapper">
|
||||
@@ -689,7 +691,7 @@ limitations under the License.
|
||||
[class.is-running]="isCheckerSegmentRunning(block, itemIdx)">
|
||||
<div class="report-header">
|
||||
<span class="stream-status-dot" [class.streaming]="isCheckerSegmentRunning(block, itemIdx)"></span>
|
||||
<span class="header-label">Work</span>
|
||||
<span class="header-label">执行过程</span>
|
||||
</div>
|
||||
<div class="report-content stream-box">
|
||||
<div class="stream-text" [innerHTML]="renderMarkdown(item.data.text, block.id + '-seg-' + itemIdx)"></div>
|
||||
@@ -718,7 +720,7 @@ limitations under the License.
|
||||
[class.collapsed]="collapsedStreams().has(block.data.execution_id || block.data.step_id || block.id)">
|
||||
<div class="details-summary" (click)="toggleCollapse(block.data.execution_id || block.data.step_id || block.id)">
|
||||
<span class="stream-status-dot" [class.streaming]="isBlockRunning(block)"></span>
|
||||
<span class="header-label">Thought</span>
|
||||
<span class="header-label">思考过程</span>
|
||||
</div>
|
||||
@if (getStreamDisplayText(block.id + '-native', item.data.text)) {
|
||||
<div class="details-content-wrapper">
|
||||
@@ -750,7 +752,7 @@ limitations under the License.
|
||||
[class.is-running]="isBlockRunning(block)">
|
||||
<div class="report-header">
|
||||
<span class="stream-status-dot" [class.streaming]="isBlockRunning(block)"></span>
|
||||
<span class="header-label">Work</span>
|
||||
<span class="header-label">执行过程</span>
|
||||
</div>
|
||||
@if (getStreamDisplayText(block.id, item.data.text)) {
|
||||
<div class="report-content stream-box">
|
||||
@@ -772,13 +774,13 @@ limitations under the License.
|
||||
{{ getReportStatusValue(item.data) === 'failed' ? 'cancel' : 'task_alt' }}
|
||||
</span>
|
||||
<span class="action-title">
|
||||
{{ getReportStatusValue(item.data) === 'failed' ? 'Task Execution Failed' : 'Task Completed' }}
|
||||
{{ getReportStatusValue(item.data) === 'failed' ? '任务执行失败' : '任务已完成' }}
|
||||
</span>
|
||||
<span class="action-status-badge" [class.is-failed]="getReportStatusValue(item.data) === 'failed'" [class.is-completed]="getReportStatusValue(item.data) !== 'failed'">
|
||||
<span class="material-symbols-outlined status-icon">
|
||||
{{ getReportStatusValue(item.data) === 'failed' ? 'error' : 'check_circle' }}
|
||||
</span>
|
||||
{{ getReportStatusValue(item.data) === 'failed' ? 'Failed' : 'Completed' }}
|
||||
{{ getReportStatusValue(item.data) === 'failed' ? '失败' : '已完成' }}
|
||||
</span>
|
||||
<span class="action-expand-icon material-symbols-outlined">
|
||||
{{ isActionExpanded('action-' + block.id + '-' + itemIdx) ? 'expand_less' : 'expand_more' }}
|
||||
@@ -788,7 +790,7 @@ limitations under the License.
|
||||
@if (!isActionExpanded('action-' + block.id + '-' + itemIdx) && getReportStatusExplanation(item.data)) {
|
||||
<div class="action-card-body">
|
||||
<div class="action-detail-row">
|
||||
<span class="detail-label">Summary:</span>
|
||||
<span class="detail-label">摘要:</span>
|
||||
<span class="detail-value">"{{ getReportStatusExplanation(item.data) }}"</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -799,7 +801,7 @@ limitations under the License.
|
||||
<div class="expanded-grid">
|
||||
@if (getReportStatusExplanation(item.data); as expText) {
|
||||
<div class="grid-item full-width">
|
||||
<span class="grid-label">Report & Findings</span>
|
||||
<span class="grid-label">报告与发现</span>
|
||||
<span class="grid-value report-markdown-body" [innerHTML]="renderMarkdown(expText, 'report-' + block.id + '-' + itemIdx)"></span>
|
||||
</div>
|
||||
}
|
||||
@@ -809,20 +811,20 @@ limitations under the License.
|
||||
<div class="expanded-screenshots-container">
|
||||
@if (getStepPreImageUrl(block.data, item.data)) {
|
||||
<div class="screenshot-column">
|
||||
<span class="screenshot-label">Final Verified State</span>
|
||||
<span class="screenshot-label">最终验证状态</span>
|
||||
<div class="image-wrapper" (click)="openImageModal(getStepPreImageUrl(block.data, item.data)!, item.data, $event)">
|
||||
<div class="img-relative-box">
|
||||
<img [src]="getStepPreImageUrl(block.data, item.data)" alt="Final Screen Screenshot" />
|
||||
<img [src]="getStepPreImageUrl(block.data, item.data)" alt="最终屏幕截图" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (getStepPostImageUrl(block.data, item.data)) {
|
||||
<div class="screenshot-column">
|
||||
<span class="screenshot-label">Post Verification</span>
|
||||
<span class="screenshot-label">操作后验证</span>
|
||||
<div class="image-wrapper" (click)="openImageModal(getStepPostImageUrl(block.data, item.data)!, null, $event)">
|
||||
<div class="img-relative-box">
|
||||
<img [src]="getStepPostImageUrl(block.data, item.data)" alt="Post Screenshot" />
|
||||
<img [src]="getStepPostImageUrl(block.data, item.data)" alt="操作后截图" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -846,7 +848,8 @@ limitations under the License.
|
||||
@if (isActionFailed(item.data, block.data)) {
|
||||
<span class="action-status-badge is-failed">
|
||||
<span class="material-symbols-outlined status-icon">error</span>
|
||||
Failed
|
||||
|
||||
失败
|
||||
</span>
|
||||
}
|
||||
<span class="action-expand-icon material-symbols-outlined">
|
||||
@@ -858,7 +861,7 @@ limitations under the License.
|
||||
<div class="action-card-body">
|
||||
@if (getActionTargetText(item.data)) {
|
||||
<div class="action-detail-row">
|
||||
<span class="detail-label">Target:</span>
|
||||
<span class="detail-label">目标:</span>
|
||||
<span class="detail-value">"{{ getActionTargetText(item.data) }}"</span>
|
||||
</div>
|
||||
}
|
||||
@@ -870,7 +873,7 @@ limitations under the License.
|
||||
}
|
||||
@if (getActionCoords(item.data)) {
|
||||
<div class="action-detail-row">
|
||||
<span class="detail-label">Coordinates:</span>
|
||||
<span class="detail-label">坐标:</span>
|
||||
<span class="detail-value coordinate-badge">{{ getActionCoords(item.data) }}</span>
|
||||
</div>
|
||||
}
|
||||
@@ -882,7 +885,7 @@ limitations under the License.
|
||||
<div class="expanded-grid">
|
||||
@if (getActionTargetText(item.data)) {
|
||||
<div class="grid-item">
|
||||
<span class="grid-label">Target</span>
|
||||
<span class="grid-label">目标</span>
|
||||
<span class="grid-value">"{{ getActionTargetText(item.data) }}"</span>
|
||||
</div>
|
||||
}
|
||||
@@ -894,19 +897,19 @@ limitations under the License.
|
||||
}
|
||||
@if (getActionCoords(item.data)) {
|
||||
<div class="grid-item">
|
||||
<span class="grid-label">Coordinates</span>
|
||||
<span class="grid-label">坐标</span>
|
||||
<span class="grid-value code-pill">{{ getActionCoords(item.data) }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (getActionBounds(item.data)) {
|
||||
<div class="grid-item">
|
||||
<span class="grid-label">Target Bounds</span>
|
||||
<span class="grid-label">目标边界</span>
|
||||
<span class="grid-value code-pill">[{{ getActionBounds(item.data) }}]</span>
|
||||
</div>
|
||||
}
|
||||
@if (getActionClass(item.data)) {
|
||||
<div class="grid-item">
|
||||
<span class="grid-label">Target Class</span>
|
||||
<span class="grid-label">目标类型</span>
|
||||
<span class="grid-value code-pill">{{ getActionClass(item.data) }}</span>
|
||||
</div>
|
||||
}
|
||||
@@ -919,7 +922,7 @@ limitations under the License.
|
||||
<!-- Fallback item if all detail fields are empty -->
|
||||
@if (!getActionTargetText(item.data) && !getActionInputText(item.data) && !getActionCoords(item.data) && !getActionBounds(item.data) && !getActionClass(item.data) && getActionExtraParams(item.data).length === 0) {
|
||||
<div class="grid-item">
|
||||
<span class="grid-label">Action</span>
|
||||
<span class="grid-label">操作</span>
|
||||
<span class="grid-value">{{ getActionTitle(item.data) }}</span>
|
||||
</div>
|
||||
}
|
||||
@@ -929,10 +932,10 @@ limitations under the License.
|
||||
<div class="expanded-screenshots-container">
|
||||
@if (getStepPreImageUrl(block.data, item.data)) {
|
||||
<div class="screenshot-column">
|
||||
<span class="screenshot-label">Pre-Action (Before)</span>
|
||||
<span class="screenshot-label">操作前</span>
|
||||
<div class="image-wrapper" (click)="openImageModal(getStepPreImageUrl(block.data, item.data)!, item.data, $event)">
|
||||
<div class="img-relative-box">
|
||||
<img #preImg [src]="getStepPreImageUrl(block.data, item.data)" (load)="onImageLoad(preImg, preOverlay, item.data)" alt="Pre-Action Screenshot" />
|
||||
<img #preImg [src]="getStepPreImageUrl(block.data, item.data)" (load)="onImageLoad(preImg, preOverlay, item.data)" alt="操作前截图" />
|
||||
<div #preOverlay class="overlay-layer"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -940,10 +943,10 @@ limitations under the License.
|
||||
}
|
||||
@if (getStepPostImageUrl(block.data, item.data)) {
|
||||
<div class="screenshot-column">
|
||||
<span class="screenshot-label">Post-Action (After)</span>
|
||||
<span class="screenshot-label">操作后</span>
|
||||
<div class="image-wrapper" (click)="openImageModal(getStepPostImageUrl(block.data, item.data)!, null, $event)">
|
||||
<div class="img-relative-box">
|
||||
<img [src]="getStepPostImageUrl(block.data, item.data)" alt="Post-Action Screenshot" />
|
||||
<img [src]="getStepPostImageUrl(block.data, item.data)" alt="操作后截图" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -983,13 +986,15 @@ limitations under the License.
|
||||
<div class="llm-retry-note-text">
|
||||
@if (isLLMRetryEntryWaiting(retry)) {
|
||||
<span class="llm-retry-status-line">
|
||||
LLM temporarily unavailable
|
||||
|
||||
模型暂时不可用
|
||||
@if (getLLMRetryEntryDelay(retry); as retryDelay) {
|
||||
· Retrying in {{ retryDelay }}…
|
||||
}
|
||||
</span>
|
||||
<span class="llm-retry-guidance">
|
||||
Frequent retries may indicate a low API rate limit. Consider increasing the limit or reducing request concurrency.
|
||||
|
||||
频繁重试可能是接口限流导致,可提高调用额度或降低并发数。
|
||||
</span>
|
||||
}
|
||||
@if (!isLLMRetryEntryWaiting(retry)) {
|
||||
@@ -1011,16 +1016,18 @@ limitations under the License.
|
||||
(click)="hasLLMFailureRetryEntries(item.data) && toggleActionExpanded('llm-failure-' + block.id + '-' + itemIdx, $event)">
|
||||
<div class="action-card-header llm-failure-header">
|
||||
<span class="material-symbols-outlined action-icon is-failed">error</span>
|
||||
<span class="action-title">LLM Request Failed</span>
|
||||
<span class="action-title">模型请求失败</span>
|
||||
@if (isTerminalLLMFailure(item.data)) {
|
||||
<span class="action-status-badge is-paused">
|
||||
<span class="material-symbols-outlined status-icon">pause_circle</span>
|
||||
Paused
|
||||
|
||||
已暂停
|
||||
</span>
|
||||
} @else {
|
||||
<span class="action-status-badge is-failed">
|
||||
<span class="material-symbols-outlined status-icon">warning</span>
|
||||
Failed
|
||||
|
||||
失败
|
||||
</span>
|
||||
}
|
||||
@if (hasLLMFailureRetryEntries(item.data)) {
|
||||
@@ -1031,22 +1038,22 @@ limitations under the License.
|
||||
</div>
|
||||
<div class="action-card-body">
|
||||
<div class="action-detail-row">
|
||||
<span class="detail-label">What happened:</span>
|
||||
<span class="detail-value">The AI request could not be completed after automatic retries.</span>
|
||||
<span class="detail-label">发生了什么:</span>
|
||||
<span class="detail-value">自动重试后仍无法完成模型请求。</span>
|
||||
</div>
|
||||
<div class="action-detail-row">
|
||||
<span class="detail-label">Failure reason:</span>
|
||||
<span class="detail-label">失败原因:</span>
|
||||
<span class="detail-value">{{ getLLMErrorText(item.data) }}</span>
|
||||
</div>
|
||||
@if (getLLMFailureWaited(item.data); as waited) {
|
||||
<div class="action-detail-row">
|
||||
<span class="detail-label">Waited:</span>
|
||||
<span class="detail-label">已等待:</span>
|
||||
<span class="detail-value">{{ waited }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (isActionExpanded('llm-failure-' + block.id + '-' + itemIdx)) {
|
||||
<div class="failure-retry-details">
|
||||
<div class="failure-retry-title">Retry details</div>
|
||||
<div class="failure-retry-title">重试详情</div>
|
||||
@for (retry of getLLMFailureRetryEntries(item.data); track retry; let retryIndex = $index) {
|
||||
<div class="failure-retry-row">
|
||||
<span>Retry {{ retryIndex + 1 }}</span>
|
||||
@@ -1066,14 +1073,14 @@ limitations under the License.
|
||||
type="button"
|
||||
class="btn-continue-task"
|
||||
(click)="resumePausedTask($event)"
|
||||
title="Continue the paused task"
|
||||
aria-label="Continue the paused task"
|
||||
title="继续已暂停的任务"
|
||||
aria-label="继续已暂停的任务"
|
||||
>
|
||||
<span class="material-symbols-outlined continue-icon">play_arrow</span>
|
||||
<span>Continue task</span>
|
||||
<span>继续任务</span>
|
||||
<span class="material-symbols-outlined continue-arrow">arrow_forward</span>
|
||||
</button>
|
||||
<span class="llm-resume-hint">Task paused · no steps are running</span>
|
||||
<span class="llm-resume-hint">任务已暂停 · 当前没有执行中的步骤</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -1096,7 +1103,7 @@ limitations under the License.
|
||||
<span
|
||||
class="tool-key-pill"
|
||||
(click)="onToolKeyClick(key)"
|
||||
title="Click to view note content"
|
||||
title="点击查看笔记内容"
|
||||
>
|
||||
<span class="material-symbols-outlined pill-icon">draft</span>
|
||||
{{ key }}
|
||||
@@ -1134,7 +1141,8 @@ limitations under the License.
|
||||
@if (isToolFailed(item.data)) {
|
||||
<span class="action-status-badge is-failed">
|
||||
<span class="material-symbols-outlined status-icon">error</span>
|
||||
Failed
|
||||
|
||||
失败
|
||||
</span>
|
||||
}
|
||||
<span class="action-expand-icon material-symbols-outlined">
|
||||
@@ -1148,19 +1156,19 @@ limitations under the License.
|
||||
@if (isAdbCommandTool(item.data)) {
|
||||
@if (getAdbCommandLine(item.data)) {
|
||||
<div class="action-detail-row">
|
||||
<span class="detail-label">Command:</span>
|
||||
<span class="detail-label">命令:</span>
|
||||
<span class="detail-value command-value"><code>{{ getAdbCommandLine(item.data) }}</code></span>
|
||||
</div>
|
||||
}
|
||||
@if (getAdbCwd(item.data)) {
|
||||
<div class="action-detail-row">
|
||||
<span class="detail-label">Cwd:</span>
|
||||
<span class="detail-label">工作目录:</span>
|
||||
<span class="detail-value">"{{ getAdbCwd(item.data) }}"</span>
|
||||
</div>
|
||||
}
|
||||
@if (getAdbTerminalId(item.data)) {
|
||||
<div class="action-detail-row">
|
||||
<span class="detail-label">Terminal ID:</span>
|
||||
<span class="detail-label">终端 ID:</span>
|
||||
<span class="detail-value">"{{ getAdbTerminalId(item.data) }}"</span>
|
||||
</div>
|
||||
}
|
||||
@@ -1169,7 +1177,7 @@ limitations under the License.
|
||||
@if (!isAdbCommandTool(item.data)) {
|
||||
@if (getToolTargetText(item.data)) {
|
||||
<div class="action-detail-row">
|
||||
<span class="detail-label">Target:</span>
|
||||
<span class="detail-label">目标:</span>
|
||||
<span class="detail-value">"{{ getToolTargetText(item.data) }}"</span>
|
||||
</div>
|
||||
}
|
||||
@@ -1181,19 +1189,19 @@ limitations under the License.
|
||||
}
|
||||
@if (getToolCoords(item.data)) {
|
||||
<div class="action-detail-row">
|
||||
<span class="detail-label">Coordinates:</span>
|
||||
<span class="detail-label">坐标:</span>
|
||||
<span class="detail-value coordinate-badge">{{ getToolCoords(item.data) }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (getToolAnalysisText(item.data)) {
|
||||
<div class="action-detail-row">
|
||||
<span class="detail-label">Analysis:</span>
|
||||
<span class="detail-label">分析:</span>
|
||||
<span class="detail-value">"{{ getToolAnalysisText(item.data) }}"</span>
|
||||
</div>
|
||||
}
|
||||
@if (getToolGenericDetails(item.data)) {
|
||||
<div class="action-detail-row">
|
||||
<span class="detail-label">Details:</span>
|
||||
<span class="detail-label">详情:</span>
|
||||
<span class="detail-value">"{{ getToolGenericDetails(item.data) }}"</span>
|
||||
</div>
|
||||
}
|
||||
@@ -1207,19 +1215,19 @@ limitations under the License.
|
||||
@if (isAdbCommandTool(item.data)) {
|
||||
@if (getAdbCommandLine(item.data)) {
|
||||
<div class="grid-item full-width">
|
||||
<span class="grid-label">Command</span>
|
||||
<span class="grid-label">命令</span>
|
||||
<span class="grid-value command-value"><code>{{ getAdbCommandLine(item.data) }}</code></span>
|
||||
</div>
|
||||
}
|
||||
@if (getAdbCwd(item.data)) {
|
||||
<div class="grid-item">
|
||||
<span class="grid-label">Working Directory</span>
|
||||
<span class="grid-label">工作目录</span>
|
||||
<span class="grid-value">"{{ getAdbCwd(item.data) }}"</span>
|
||||
</div>
|
||||
}
|
||||
@if (getAdbTerminalId(item.data)) {
|
||||
<div class="grid-item">
|
||||
<span class="grid-label">Terminal ID</span>
|
||||
<span class="grid-label">终端 ID</span>
|
||||
<span class="grid-value code-pill">{{ getAdbTerminalId(item.data) }}</span>
|
||||
</div>
|
||||
}
|
||||
@@ -1227,7 +1235,7 @@ limitations under the License.
|
||||
@if (!isAdbCommandTool(item.data)) {
|
||||
@if (getToolTargetText(item.data)) {
|
||||
<div class="grid-item">
|
||||
<span class="grid-label">Target</span>
|
||||
<span class="grid-label">目标</span>
|
||||
<span class="grid-value">"{{ getToolTargetText(item.data) }}"</span>
|
||||
</div>
|
||||
}
|
||||
@@ -1239,19 +1247,19 @@ limitations under the License.
|
||||
}
|
||||
@if (getToolCoords(item.data)) {
|
||||
<div class="grid-item">
|
||||
<span class="grid-label">Coordinates</span>
|
||||
<span class="grid-label">坐标</span>
|
||||
<span class="grid-value code-pill">{{ getToolCoords(item.data) }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (getToolAnalysisText(item.data)) {
|
||||
<div class="grid-item">
|
||||
<span class="grid-label">Analysis</span>
|
||||
<span class="grid-label">分析</span>
|
||||
<span class="grid-value">"{{ getToolAnalysisText(item.data) }}"</span>
|
||||
</div>
|
||||
}
|
||||
@if (getToolGenericDetails(item.data)) {
|
||||
<div class="grid-item">
|
||||
<span class="grid-label">Details</span>
|
||||
<span class="grid-label">详情</span>
|
||||
<span class="grid-value">"{{ getToolGenericDetails(item.data) }}"</span>
|
||||
</div>
|
||||
}
|
||||
@@ -1268,10 +1276,10 @@ limitations under the License.
|
||||
<div class="expanded-screenshots-container">
|
||||
@if (getStepPreImageUrl(block.data, item.data)) {
|
||||
<div class="screenshot-column">
|
||||
<span class="screenshot-label">Pre-Action (Before)</span>
|
||||
<span class="screenshot-label">操作前</span>
|
||||
<div class="image-wrapper" (click)="openImageModal(getStepPreImageUrl(block.data, item.data)!, item.data, $event)">
|
||||
<div class="img-relative-box">
|
||||
<img #preImgTool [src]="getStepPreImageUrl(block.data, item.data)" (load)="onImageLoad(preImgTool, preOverlayTool, item.data)" alt="Pre-Action Screenshot" />
|
||||
<img #preImgTool [src]="getStepPreImageUrl(block.data, item.data)" (load)="onImageLoad(preImgTool, preOverlayTool, item.data)" alt="操作前截图" />
|
||||
<div #preOverlayTool class="overlay-layer"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1279,10 +1287,10 @@ limitations under the License.
|
||||
}
|
||||
@if (getStepPostImageUrl(block.data, item.data)) {
|
||||
<div class="screenshot-column">
|
||||
<span class="screenshot-label">Post-Action (After)</span>
|
||||
<span class="screenshot-label">操作后</span>
|
||||
<div class="image-wrapper" (click)="openImageModal(getStepPostImageUrl(block.data, item.data)!, null, $event)">
|
||||
<div class="img-relative-box">
|
||||
<img [src]="getStepPostImageUrl(block.data, item.data)" alt="Post-Action Screenshot" />
|
||||
<img [src]="getStepPostImageUrl(block.data, item.data)" alt="操作后截图" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1337,12 +1345,12 @@ limitations under the License.
|
||||
<div class="synthesis-card-header">
|
||||
<div class="header-left">
|
||||
<span class="material-symbols-outlined icon-sparkle">assignment_turned_in</span>
|
||||
<span class="main-title">Task Report</span>
|
||||
<span class="main-title">任务报告</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button type="button" class="btn-open-note" (click)="openOutputterNote($event)">
|
||||
<span class="material-symbols-outlined">visibility</span>
|
||||
<span>View in Notes</span>
|
||||
<span>在笔记中查看</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1423,11 +1431,11 @@ limitations under the License.
|
||||
@if (selectedZoomImage()) {
|
||||
<div class="image-modal-backdrop" (click)="closeImageModal($event)">
|
||||
<div class="image-modal-content" (click)="$event.stopPropagation()">
|
||||
<button class="image-modal-close" (click)="closeImageModal($event)" aria-label="Close image preview">
|
||||
<button class="image-modal-close" (click)="closeImageModal($event)" aria-label="关闭图片预览">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
<div class="img-relative-box">
|
||||
<img #modalImg [src]="selectedZoomImage()" (load)="onImageLoad(modalImg, modalOverlay, selectedZoomAction())" alt="Zoomed Screenshot" />
|
||||
<img #modalImg [src]="selectedZoomImage()" (load)="onImageLoad(modalImg, modalOverlay, selectedZoomAction())" alt="放大截图" />
|
||||
<div #modalOverlay class="overlay-layer"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { UiLabelPipe } from '../../utils/ui-label.pipe';
|
||||
/**
|
||||
* Copyright 2026 Google LLC
|
||||
*
|
||||
@@ -34,22 +35,22 @@ import {
|
||||
} from '../../utils/run-info.util';
|
||||
|
||||
export const PLANNING_LOADER_PHRASES: string[] = [
|
||||
'Planning next step...',
|
||||
'Analyzing screen coordinates...',
|
||||
'Consulting neural network...',
|
||||
'Formulating tactical action plan...',
|
||||
'Deciphering UI state & elements...',
|
||||
'Synthesizing decision pathways...',
|
||||
'Calibrating next move...',
|
||||
'Aligning logical vectors...',
|
||||
'Evaluating optimal sub-goals...',
|
||||
'Gathering sensory inputs...',
|
||||
'Computing next interaction...',
|
||||
'Optimizing execution strategy...',
|
||||
'Simulating probable outcomes...',
|
||||
'Summoning AI intuition...',
|
||||
'Brewing the next command...',
|
||||
'Strategizing tactical moves...'
|
||||
"正在规划下一步…",
|
||||
"正在分析屏幕坐标…",
|
||||
"正在请求模型…",
|
||||
"正在制定操作计划…",
|
||||
"正在识别界面状态与元素…",
|
||||
"正在评估执行路径…",
|
||||
"正在确认下一步操作…",
|
||||
"正在整理推理结果…",
|
||||
"正在评估子目标…",
|
||||
"正在收集屏幕信息…",
|
||||
"正在分析下一步交互…",
|
||||
"正在调整执行策略…",
|
||||
"正在预测操作结果…",
|
||||
"正在分析任务…",
|
||||
"正在准备下一条指令…",
|
||||
"正在规划操作步骤…"
|
||||
];
|
||||
|
||||
export interface StartupWorkItem extends StartupProgressEvent {
|
||||
@@ -71,12 +72,12 @@ const STARTUP_WORK_STAGES: StartupWorkStage[] = [
|
||||
{
|
||||
started: 'device_check',
|
||||
completed: 'device_ready',
|
||||
completedMessage: 'Android device connected'
|
||||
completedMessage: "Android 设备已连接"
|
||||
},
|
||||
{
|
||||
started: 'uiautomator',
|
||||
completed: 'uiautomator_ready',
|
||||
completedMessage: 'UI hierarchy service is ready',
|
||||
completedMessage: "界面层级服务已就绪",
|
||||
// First task on a device installs / upgrades the accessibility helper
|
||||
// (a few seconds): say so instead of a generic "connecting".
|
||||
liveDetailStages: ['helper_install', 'helper_upgrade'],
|
||||
@@ -86,7 +87,7 @@ const STARTUP_WORK_STAGES: StartupWorkStage[] = [
|
||||
{
|
||||
started: 'environment',
|
||||
completed: 'environment_ready',
|
||||
completedMessage: 'Device environment is ready'
|
||||
completedMessage: "设备环境已就绪"
|
||||
}
|
||||
];
|
||||
|
||||
@@ -233,7 +234,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote, StreamRe
|
||||
@Component({
|
||||
selector: 'app-agent-stream',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, OverlayModule],
|
||||
imports: [UiLabelPipe, CommonModule, FormsModule, OverlayModule],
|
||||
templateUrl: './agent-stream.component.html',
|
||||
styleUrl: './agent-stream.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
@@ -558,7 +559,7 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
onCleanup(() => clearInterval(clockInterval));
|
||||
});
|
||||
|
||||
// The retry clock only needs to tick while something on screen is timing
|
||||
// The retry clock only needs to tick while something 在屏幕上 is timing
|
||||
// (startup progress, LLM retry countdowns). An idle page stays quiet.
|
||||
this.zone.runOutsideAngular(() => {
|
||||
const retryClockInterval = setInterval(() => {
|
||||
@@ -712,7 +713,7 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
this.scheduledCollapses.clear();
|
||||
this.collapsedStreams.set(new Set<string>());
|
||||
this.isUserAtBottom = true;
|
||||
|
||||
|
||||
setTimeout(() => {
|
||||
const container = document.querySelector('.stream-logs-content');
|
||||
if (container) {
|
||||
@@ -929,12 +930,12 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
|
||||
public deleteTask(sessionId: string, event?: Event): void {
|
||||
if (event) event.stopPropagation();
|
||||
if (!confirm(`Are you sure you want to delete this task? This cannot be undone.`)) {
|
||||
if (!confirm("确定删除此任务?此操作无法撤销。")) {
|
||||
return;
|
||||
}
|
||||
this.agentService.deleteSession(sessionId).subscribe({
|
||||
error: (err) => {
|
||||
console.error(`Failed to delete session ${sessionId}:`, err);
|
||||
console.error(`删除会话失败:${sessionId}:`, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1516,12 +1517,12 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
}
|
||||
|
||||
public getVideoAnalysisLabel(tool: any): string {
|
||||
return getVideoAnalysisView(tool)?.title || 'Analyzing screen recording';
|
||||
return getVideoAnalysisView(tool)?.title || "正在分析屏幕录像";
|
||||
}
|
||||
|
||||
public getVideoAnalysisRangeLabel(tool: any): string {
|
||||
const range = getVideoAnalysisView(tool)?.requestedRange;
|
||||
if (!range) return 'Screen recording';
|
||||
if (!range) return "屏幕录像";
|
||||
return `${formatVideoTime(range.start)}–${formatVideoTime(range.end)}`;
|
||||
}
|
||||
|
||||
@@ -1530,11 +1531,11 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
if (!view || view.totalCount <= 1) return '';
|
||||
if (view.outcome === 'running' || view.outcome === 'recovering') {
|
||||
return view.completedCount > 0
|
||||
? `${view.completedCount}/${view.totalCount} segments saved`
|
||||
? `${view.completedCount}/${view.totalCount} 段已保存`
|
||||
: '';
|
||||
}
|
||||
if (view.outcome === 'partial') {
|
||||
return `${view.completedCount}/${view.totalCount} segments saved`;
|
||||
return `${view.completedCount}/${view.totalCount} 段已保存`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
@@ -1554,23 +1555,23 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
|
||||
public getScreenRecordingButtonTitle(): string {
|
||||
if (this.agentService.isCurrentSessionRunning()) {
|
||||
return 'Task is currently running (Recording screen)';
|
||||
return "任务正在运行(录屏中)";
|
||||
}
|
||||
if (this.agentService.currentSessionRecordingStatus() === 'processing') {
|
||||
return 'Preparing screen recording...';
|
||||
return "正在准备屏幕录像…";
|
||||
}
|
||||
if (this.agentService.currentSessionVideoUrl()) {
|
||||
return 'Play Screen Recording Video';
|
||||
return "播放屏幕录像";
|
||||
}
|
||||
if (this.agentService.hasCurrentSessionStepFrames()) {
|
||||
return this.agentService.currentSessionRecordingStatus() === 'failed'
|
||||
? 'Video recording failed — Click to replay step-by-step screenshots'
|
||||
: 'Play step-by-step screenshots replay';
|
||||
? "录屏失败,点击逐步回放截图"
|
||||
: "逐步回放截图";
|
||||
}
|
||||
if (this.agentService.currentSessionRecordingStatus() === 'failed') {
|
||||
return 'Screen recording generation failed';
|
||||
return "屏幕录像生成失败";
|
||||
}
|
||||
return 'Screen Recording';
|
||||
return "屏幕录像";
|
||||
}
|
||||
|
||||
public isDeviceActionTool(tool: any): boolean {
|
||||
@@ -1639,11 +1640,11 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
public getCheckerPhaseLabel(block: any): string {
|
||||
switch (block?.data?.phase) {
|
||||
case 'final':
|
||||
return 'Final check';
|
||||
return "最终检查";
|
||||
case 'outcome':
|
||||
return 'Result';
|
||||
return "结果";
|
||||
default:
|
||||
return 'Check';
|
||||
return "勾选";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1660,8 +1661,8 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
|
||||
/** How the item is judged, in plain words (verify = must pass, assert = recorded test result). */
|
||||
public getCheckMethodLabel(item: any, block: any): string {
|
||||
const parts: string[] = [item?.kind === 'assert' ? 'Test assertion' : 'Must pass'];
|
||||
if (item?.when === 'at_end' && block?.data?.phase !== 'final') parts.push('at the end');
|
||||
const parts: string[] = [item?.kind === 'assert' ? "测试断言" : "必须通过"];
|
||||
if (item?.when === 'at_end' && block?.data?.phase !== 'final') parts.push("结束时");
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
@@ -1680,15 +1681,15 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
public getVerdictStatusText(status: string): string {
|
||||
switch (status) {
|
||||
case 'passed':
|
||||
return 'Passed';
|
||||
return "已通过";
|
||||
case 'failed':
|
||||
return 'Failed';
|
||||
return "失败";
|
||||
case 'inconclusive':
|
||||
return 'Could not be confirmed';
|
||||
return "无法确认";
|
||||
case 'superseded':
|
||||
return 'Superseded by a newer check';
|
||||
return "已被较新的检查替代";
|
||||
case 'unchecked':
|
||||
return 'Not checked';
|
||||
return "未检查";
|
||||
default:
|
||||
return status || '';
|
||||
}
|
||||
@@ -1706,9 +1707,9 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
if (d.phase === 'outcome') return Array.isArray(d.last_findings) ? d.last_findings : [];
|
||||
const notes: string[] = [];
|
||||
if (Array.isArray(d.unmet_subgoals)) {
|
||||
for (const text of d.unmet_subgoals) notes.push(`Not finished yet: ${text}`);
|
||||
for (const text of d.unmet_subgoals) notes.push(`尚未完成:${text}`);
|
||||
}
|
||||
if (d.reverted) notes.push('The step was sent back to be redone.');
|
||||
if (d.reverted) notes.push("此步骤已退回重新执行。");
|
||||
if (d.error) notes.push(String(d.error));
|
||||
return notes;
|
||||
}
|
||||
@@ -1760,32 +1761,32 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
const d = block?.data || {};
|
||||
if (d.phase === 'outcome') {
|
||||
const t = d.tests || {};
|
||||
const label = d.task_status === 'completed' ? 'Goal completed'
|
||||
: (d.task_status === 'blocked' ? 'Blocked' : 'Partially completed');
|
||||
const label = d.task_status === 'completed' ? "目标已完成"
|
||||
: (d.task_status === 'blocked' ? "受阻" : "部分完成");
|
||||
const counts = [
|
||||
[t.passed, 'passed'],
|
||||
[t.failed, 'failed'],
|
||||
[t.inconclusive, 'inconclusive'],
|
||||
[t.unchecked, 'not checked']
|
||||
[t.unchecked, "未检查"]
|
||||
]
|
||||
.filter(([n]) => Number(n) > 0)
|
||||
.map(([n, word]) => `${n} ${word}`);
|
||||
return counts.length > 0 ? `${label} · ${counts.join(' · ')}` : label;
|
||||
}
|
||||
if (d.isCompleted === false) return 'Checking…';
|
||||
if (d.isCompleted === false) return "正在检查…";
|
||||
switch (d.status) {
|
||||
case 'superseded':
|
||||
return 'Superseded';
|
||||
return "已替代";
|
||||
case 'unchecked':
|
||||
return 'Not checked';
|
||||
return "未检查";
|
||||
case 'error':
|
||||
return 'No verdict';
|
||||
return "暂无结论";
|
||||
}
|
||||
const verdicts = this.getCheckerVerdicts(block);
|
||||
const failed = verdicts.filter((v) => v.status === 'failed').length;
|
||||
if (failed > 0) return `${failed} failed`;
|
||||
if (verdicts.some((v) => v.status === 'inconclusive')) return 'Inconclusive';
|
||||
return verdicts.length > 0 ? 'Passed' : 'Done';
|
||||
if (verdicts.some((v) => v.status === 'inconclusive')) return "无法确定";
|
||||
return verdicts.length > 0 ? "已通过" : "完成";
|
||||
}
|
||||
|
||||
public getVerdictIcon(status: string): string {
|
||||
@@ -1854,7 +1855,7 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
}
|
||||
|
||||
public getBackendSwitchMessage(tool: any): string {
|
||||
return String(tool?.payload?.message || 'UI hierarchy source changed');
|
||||
return String(tool?.payload?.message || "界面层级来源已变更");
|
||||
}
|
||||
|
||||
public resumePausedTask(event: Event): void {
|
||||
@@ -1863,12 +1864,12 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
}
|
||||
|
||||
public getLLMErrorText(tool: any): string {
|
||||
const noDetails = 'The AI provider did not return error details after the request failed.';
|
||||
const noDetails = "模型请求失败,接口未返回错误详情。";
|
||||
if (!tool) return noDetails;
|
||||
const rawError = tool.payload?.error || tool.error;
|
||||
if (!rawError) return noDetails;
|
||||
const cleaned = this.cleanErrorMessage(rawError);
|
||||
return cleaned === 'Unknown error' ? noDetails : cleaned;
|
||||
return cleaned === "未知错误" ? noDetails : cleaned;
|
||||
}
|
||||
|
||||
public isLLMRetry(tool: any): boolean {
|
||||
@@ -1945,7 +1946,7 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
|
||||
public getToolCallName(action: any): string {
|
||||
const act = this.getActionObject(action);
|
||||
if (!act) return 'Tool';
|
||||
if (!act) return "工具";
|
||||
const label = this.getToolDisplayLabel(act);
|
||||
const key = this.getToolKey(act);
|
||||
return key ? `${label}: ${key}` : label;
|
||||
@@ -2051,13 +2052,13 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
public getStatusLabel(status: string): string {
|
||||
switch (status) {
|
||||
case 'idle':
|
||||
return 'Idle';
|
||||
return "空闲";
|
||||
case 'running':
|
||||
return 'Running';
|
||||
return "执行中";
|
||||
case 'completed':
|
||||
return 'Completed';
|
||||
return "已完成";
|
||||
case 'offline':
|
||||
return 'Offline';
|
||||
return "离线";
|
||||
default:
|
||||
return status.toUpperCase();
|
||||
}
|
||||
@@ -2085,7 +2086,7 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
public onToolKeyClick(key: string): void {
|
||||
this.agentService.selectedNoteKey.set(key);
|
||||
this.agentService.activeTab.set('notes');
|
||||
|
||||
|
||||
const sessionId = this.agentService.currentSessionId();
|
||||
if (sessionId) {
|
||||
this.agentService.fetchNotes(sessionId);
|
||||
@@ -2162,16 +2163,16 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
}
|
||||
|
||||
public getArchitectureTooltip(model?: ModelInfo | null): string {
|
||||
if (!model) return 'Agent Architecture: ARTEMIS Flash (Reactive Fast Loop)';
|
||||
if (!model) return "执行模式:ARTEMIS Flash(快速响应)";
|
||||
const name = this.getModelDisplayName(model.name);
|
||||
const isPro = name.toLowerCase().includes('pro');
|
||||
const archDesc = isPro
|
||||
? 'ARTEMIS Pro (Multi-Agent Cognitive State Graph)'
|
||||
: 'ARTEMIS Flash (Reactive Fast Loop)';
|
||||
? "ARTEMIS Pro(多智能体规划)"
|
||||
: "ARTEMIS Flash(快速响应)";
|
||||
if (model.id) {
|
||||
return `Agent Architecture: ${archDesc} · LLM: ${model.id} (${model.provider || 'google'})`;
|
||||
return `智能体架构:${archDesc} · LLM: ${model.id} (${model.provider || 'google'})`;
|
||||
}
|
||||
return `Agent Architecture: ${archDesc}`;
|
||||
return `智能体架构:${archDesc}`;
|
||||
}
|
||||
|
||||
public formatTokenCount(tokens?: number): string {
|
||||
@@ -2181,9 +2182,9 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
public getTokenTooltip(phase: PhaseBlock): string {
|
||||
if (!phase.tokens) return '';
|
||||
if (phase.promptTokens && phase.completionTokens) {
|
||||
return `Consumed: ${phase.tokens.toLocaleString()} tokens (${phase.promptTokens.toLocaleString()} in / ${phase.completionTokens.toLocaleString()} out)`;
|
||||
return `已使用:${phase.tokens.toLocaleString()} tokens (${phase.promptTokens.toLocaleString()} 输入 / ${phase.completionTokens.toLocaleString()} 输出)`;
|
||||
}
|
||||
return `Consumed: ${phase.tokens.toLocaleString()} tokens`;
|
||||
return `已使用:${phase.tokens.toLocaleString()} tokens`;
|
||||
}
|
||||
|
||||
public isCurrentSessionActive(): boolean {
|
||||
|
||||
@@ -25,7 +25,7 @@ limitations under the License.
|
||||
(click)="agentService.activeTab.set('tasks')"
|
||||
>
|
||||
<span class="material-symbols-outlined tab-icon">list_alt</span>
|
||||
<span class="tab-text">Task Queue</span>
|
||||
<span class="tab-text">任务队列</span>
|
||||
</button>
|
||||
<button
|
||||
class="tab-selector-btn"
|
||||
@@ -33,7 +33,7 @@ limitations under the License.
|
||||
(click)="agentService.activeTab.set('notes')"
|
||||
>
|
||||
<span class="material-symbols-outlined tab-icon">description</span>
|
||||
<span class="tab-text">Notes & Plans</span>
|
||||
<span class="tab-text">笔记与计划</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -42,8 +42,8 @@ limitations under the License.
|
||||
<button
|
||||
class="btn-clear-history"
|
||||
(click)="clearHistory()"
|
||||
title="Clear all tasks and history"
|
||||
aria-label="Clear all tasks and history"
|
||||
title="清空所有任务与历史"
|
||||
aria-label="清空所有任务与历史"
|
||||
[disabled]="isSubmitting()"
|
||||
>
|
||||
<span class="material-symbols-outlined">delete</span>
|
||||
@@ -62,7 +62,8 @@ limitations under the License.
|
||||
</div>
|
||||
@if (activeQueue().length === 0) {
|
||||
<div class="empty-section-placeholder">
|
||||
No active or queued tasks.
|
||||
|
||||
没有正在执行或排队的任务。
|
||||
</div>
|
||||
}
|
||||
@for (session of activeQueue(); track trackSession($index, session)) {
|
||||
@@ -77,7 +78,7 @@ limitations under the License.
|
||||
@if (getTaskStatus(session) === 'running') {
|
||||
<span class="status-dot"></span>
|
||||
}
|
||||
{{ getTaskStatus(session) | uppercase }}
|
||||
{{ getTaskStatus(session) | uiLabel }}
|
||||
</span>
|
||||
<div class="task-card-header-actions">
|
||||
@if (getDeviceSerial(session)) {
|
||||
@@ -95,8 +96,8 @@ limitations under the License.
|
||||
type="button"
|
||||
class="btn-stop-card"
|
||||
(click)="stopTask(session.session_id, $event)"
|
||||
title="Stop this task"
|
||||
aria-label="Stop this task"
|
||||
title="停止此任务"
|
||||
aria-label="停止此任务"
|
||||
>
|
||||
<span class="material-symbols-outlined">stop_circle</span>
|
||||
</button>
|
||||
@@ -106,8 +107,8 @@ limitations under the License.
|
||||
type="button"
|
||||
class="btn-delete-card"
|
||||
(click)="deleteTask(session.session_id, $event)"
|
||||
title="Delete this task"
|
||||
aria-label="Delete this task"
|
||||
title="删除此任务"
|
||||
aria-label="删除此任务"
|
||||
[disabled]="isSubmitting()"
|
||||
>
|
||||
<span class="material-symbols-outlined">delete</span>
|
||||
@@ -116,7 +117,7 @@ limitations under the License.
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-goal" title="{{ session.initial_goal }}">
|
||||
{{ session.initial_goal || 'Unnamed Task' }}
|
||||
{{ session.initial_goal || '未命名任务' }}
|
||||
</div>
|
||||
<div class="task-card-footer">
|
||||
<span class="task-id" [title]="session.session_id">ID: {{ session.session_id }}</span>
|
||||
@@ -132,7 +133,8 @@ limitations under the License.
|
||||
<div class="history-list-wrapper">
|
||||
@if (historyTasks().length === 0) {
|
||||
<div class="empty-section-placeholder">
|
||||
No past tasks.
|
||||
|
||||
暂无历史任务。
|
||||
</div>
|
||||
}
|
||||
@for (session of historyTasks(); track trackSession($index, session)) {
|
||||
@@ -147,7 +149,7 @@ limitations under the License.
|
||||
@if (getTaskStatus(session) === 'running') {
|
||||
<span class="status-dot"></span>
|
||||
}
|
||||
{{ getTaskStatus(session) | uppercase }}
|
||||
{{ getTaskStatus(session) | uiLabel }}
|
||||
</span>
|
||||
<div class="task-card-header-actions">
|
||||
@if (getDeviceSerial(session)) {
|
||||
@@ -165,8 +167,8 @@ limitations under the License.
|
||||
type="button"
|
||||
class="btn-delete-card"
|
||||
(click)="deleteTask(session.session_id, $event)"
|
||||
title="Delete this task"
|
||||
aria-label="Delete this task"
|
||||
title="删除此任务"
|
||||
aria-label="删除此任务"
|
||||
[disabled]="isSubmitting()"
|
||||
>
|
||||
<span class="material-symbols-outlined">delete</span>
|
||||
@@ -175,7 +177,7 @@ limitations under the License.
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-goal" title="{{ session.initial_goal }}">
|
||||
{{ session.initial_goal || 'Unnamed Task' }}
|
||||
{{ session.initial_goal || '未命名任务' }}
|
||||
</div>
|
||||
<div class="task-card-footer">
|
||||
<span class="task-id" [title]="session.session_id">ID: {{ session.session_id }}</span>
|
||||
@@ -212,8 +214,8 @@ limitations under the License.
|
||||
<div class="empty-notes-icon">
|
||||
<span class="material-symbols-outlined">description</span>
|
||||
</div>
|
||||
<h4 class="empty-notes-title">No Notes & Plans</h4>
|
||||
<p class="empty-notes-subtitle">No notes or structured plans were recorded for this session.</p>
|
||||
<h4 class="empty-notes-title">暂无笔记与计划</h4>
|
||||
<p class="empty-notes-subtitle">此会话尚未记录笔记或结构化计划。</p>
|
||||
</div>
|
||||
}
|
||||
<!-- Notes Content Viewer (Clean card blocks matching the second screenshot style) -->
|
||||
@@ -377,4 +379,3 @@ limitations under the License.
|
||||
}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { UiLabelPipe } from '../../utils/ui-label.pipe';
|
||||
/**
|
||||
* Copyright 2026 Google LLC
|
||||
*
|
||||
@@ -27,7 +28,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote };
|
||||
@Component({
|
||||
selector: 'app-chat-interface',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
imports: [UiLabelPipe, CommonModule, FormsModule],
|
||||
templateUrl: './chat-interface.component.html',
|
||||
styleUrl: './chat-interface.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
@@ -95,7 +96,7 @@ export class ChatInterfaceComponent {
|
||||
error: (err) => {
|
||||
console.error('Failed to submit task:', err);
|
||||
this.isSubmitting.set(false);
|
||||
this.errorMessage.set(err.error?.detail || 'The runner is busy. Please wait for the current task to finish.');
|
||||
this.errorMessage.set(err.error?.detail || "执行器忙碌,请等待当前任务结束。");
|
||||
// Auto-dismiss error banner after 5 seconds
|
||||
setTimeout(() => {
|
||||
this.errorMessage.set(null);
|
||||
@@ -133,7 +134,7 @@ export class ChatInterfaceComponent {
|
||||
* Clear all database data/history to start fresh
|
||||
*/
|
||||
public clearHistory(): void {
|
||||
if (!confirm('Are you sure you want to clear all tasks and history? This cannot be undone.')) {
|
||||
if (!confirm("确定清空所有任务和历史记录?此操作无法撤销。")) {
|
||||
return;
|
||||
}
|
||||
this.isSubmitting.set(true);
|
||||
@@ -145,7 +146,7 @@ export class ChatInterfaceComponent {
|
||||
error: (err: any) => {
|
||||
console.error('Failed to clear history:', err);
|
||||
this.isSubmitting.set(false);
|
||||
this.errorMessage.set(err.error?.detail || 'Failed to clear history.');
|
||||
this.errorMessage.set(err.error?.detail || "清空历史记录失败。");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -155,7 +156,7 @@ export class ChatInterfaceComponent {
|
||||
*/
|
||||
public deleteTask(sessionId: string, event: MouseEvent): void {
|
||||
event.stopPropagation();
|
||||
if (!confirm(`Are you sure you want to delete this task? This cannot be undone.`)) {
|
||||
if (!confirm("确定删除此任务?此操作无法撤销。")) {
|
||||
return;
|
||||
}
|
||||
this.isSubmitting.set(true);
|
||||
@@ -165,9 +166,9 @@ export class ChatInterfaceComponent {
|
||||
this.isSubmitting.set(false);
|
||||
},
|
||||
error: (err: any) => {
|
||||
console.error(`Failed to delete task ${sessionId}:`, err);
|
||||
console.error(`删除任务失败:${sessionId}:`, err);
|
||||
this.isSubmitting.set(false);
|
||||
this.errorMessage.set(err.error?.detail || 'Failed to delete task.');
|
||||
this.errorMessage.set(err.error?.detail || "删除任务失败。");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+39
-37
@@ -37,7 +37,7 @@ limitations under the License.
|
||||
{{ agentService.recordingPlaybackStatus() === 'live' ? 'sensors' : (agentService.playerMode() === 'steps' ? 'slideshow' : (agentService.recordingPlaybackStatus() === 'processing' ? 'progress_activity' : 'smart_display')) }}
|
||||
</span>
|
||||
<span class="header-title" [title]="agentService.activeVideoTitle()">
|
||||
{{ agentService.recordingPlaybackStatus() === 'live' ? 'Live Device Screen' : (agentService.playerMode() === 'steps' ? ('Step Replay (' + (totalStepFrames() > 0 ? (activeStepIndex() + 1) + '/' + totalStepFrames() : '0') + ')') : (agentService.recordingPlaybackStatus() === 'processing' ? 'Preparing Recording' : (agentService.activeVideoTitle() || 'Screen Recording'))) }}
|
||||
{{ agentService.recordingPlaybackStatus() === 'live' ? '设备实时画面' : (agentService.playerMode() === 'steps' ? ('步骤回放(' + (totalStepFrames() > 0 ? (activeStepIndex() + 1) + '/' + totalStepFrames() : '0') + ')') : (agentService.recordingPlaybackStatus() === 'processing' ? '正在准备录像' : (agentService.activeVideoTitle() || '屏幕录像'))) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="header-actions" (mousedown)="$event.stopPropagation()">
|
||||
@@ -48,19 +48,19 @@ limitations under the License.
|
||||
class="mode-btn"
|
||||
[class.active]="agentService.playerMode() === 'video'"
|
||||
(click)="agentService.setPlayerMode('video')"
|
||||
title="Video Recording"
|
||||
title="视频录制"
|
||||
>
|
||||
<span class="material-symbols-outlined mode-icon">videocam</span>
|
||||
<span>Video</span>
|
||||
<span>视频</span>
|
||||
</button>
|
||||
<button
|
||||
class="mode-btn"
|
||||
[class.active]="agentService.playerMode() === 'steps'"
|
||||
(click)="agentService.setPlayerMode('steps')"
|
||||
title="Step Screenshot Replay"
|
||||
title="步骤截图回放"
|
||||
>
|
||||
<span class="material-symbols-outlined mode-icon">slideshow</span>
|
||||
<span>Steps</span>
|
||||
<span>步骤</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@@ -68,8 +68,8 @@ limitations under the License.
|
||||
<button
|
||||
class="header-btn"
|
||||
(click)="toggleMinimize()"
|
||||
[title]="agentService.isVideoMinimized() ? 'Restore' : 'Minimize'"
|
||||
aria-label="Minimize"
|
||||
[title]="agentService.isVideoMinimized() ? 'Restore' : '最小化'"
|
||||
aria-label="最小化"
|
||||
>
|
||||
<span class="material-symbols-outlined">{{ agentService.isVideoMinimized() ? 'expand_less' : 'remove' }}</span>
|
||||
</button>
|
||||
@@ -79,7 +79,7 @@ limitations under the License.
|
||||
class="header-btn"
|
||||
(click)="toggleTheater()"
|
||||
[title]="isTheaterMode() ? 'Exit Theater' : 'Theater Mode'"
|
||||
aria-label="Theater"
|
||||
aria-label="影院模式"
|
||||
>
|
||||
<span class="material-symbols-outlined">{{ isTheaterMode() ? 'close_fullscreen' : 'open_in_full' }}</span>
|
||||
</button>
|
||||
@@ -88,8 +88,8 @@ limitations under the License.
|
||||
<button
|
||||
class="header-btn close-btn"
|
||||
(click)="agentService.closeVideoPlayer()"
|
||||
title="Close"
|
||||
aria-label="Close"
|
||||
title="关闭"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
@@ -106,14 +106,14 @@ limitations under the License.
|
||||
class="live-screen-img"
|
||||
[src]="liveStreamUrl()"
|
||||
(error)="onLiveStreamError()"
|
||||
alt="Live Device Screen"
|
||||
alt="设备实时画面"
|
||||
/>
|
||||
<div class="live-status-overlay">
|
||||
<div class="live-badge">
|
||||
<span class="pulse-dot"></span>
|
||||
<span>LIVE</span>
|
||||
<span>实时</span>
|
||||
</div>
|
||||
<button class="live-refresh-btn" (click)="retryLiveStream()" title="Refresh Live Stream">
|
||||
<button class="live-refresh-btn" (click)="retryLiveStream()" title="刷新实时画面">
|
||||
<span class="material-symbols-outlined">refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -124,10 +124,10 @@ limitations under the License.
|
||||
<div class="fallback-icon-wrap">
|
||||
<span class="material-symbols-outlined fallback-icon">phonelink_off</span>
|
||||
</div>
|
||||
<h4 class="fallback-title">Device Stream Connecting</h4>
|
||||
<p class="fallback-desc">Waiting for ADB device screen stream...</p>
|
||||
<h4 class="fallback-title">正在连接设备画面</h4>
|
||||
<p class="fallback-desc">正在等待 ADB 设备画面…</p>
|
||||
<button class="retry-btn" (click)="retryLiveStream()">
|
||||
<span class="material-symbols-outlined">refresh</span> Reconnect
|
||||
<span class="material-symbols-outlined">refresh</span> 重新连接
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@@ -138,7 +138,7 @@ limitations under the License.
|
||||
@if (agentService.playerMode() === 'video' && (agentService.recordingPlaybackStatus() === 'processing' || agentService.isVideoLoading())) {
|
||||
<div class="video-loading-state">
|
||||
<span class="material-symbols-outlined spinning-icon">progress_activity</span>
|
||||
<span>{{ agentService.recordingPlaybackMessage() || 'Preparing screen recording...' }}</span>
|
||||
<span>{{ agentService.recordingPlaybackMessage() || '正在准备屏幕录像…' }}</span>
|
||||
</div>
|
||||
}
|
||||
<!-- Option A: STEP REPLAY VIEW (ACTIVE IN STEP MODE) -->
|
||||
@@ -148,7 +148,7 @@ limitations under the License.
|
||||
@if (agentService.recordingPlaybackStatus() === 'failed' || videoLoadError()) {
|
||||
<div class="step-replay-notice">
|
||||
<span class="material-symbols-outlined notice-icon">info</span>
|
||||
<span>Screen recording unavailable. Replaying step-by-step screenshots.</span>
|
||||
<span>录像不可用,正在逐步回放截图。</span>
|
||||
</div>
|
||||
}
|
||||
<!-- Step Image Viewport with Action Coordinates Overlay -->
|
||||
@@ -161,14 +161,14 @@ limitations under the License.
|
||||
@if (isStepImageLoading()) {
|
||||
<div class="step-image-loading-state">
|
||||
<span class="material-symbols-outlined spinning-icon">progress_activity</span>
|
||||
<span>Loading screenshot...</span>
|
||||
<span>正在加载截图…</span>
|
||||
</div>
|
||||
}
|
||||
<!-- Error / Missing Image Fallback -->
|
||||
@if (stepImageError() && !isStepImageLoading()) {
|
||||
<div class="step-image-error-state">
|
||||
<span class="material-symbols-outlined broken-icon">broken_image</span>
|
||||
<span>Screenshot preview unavailable for this step</span>
|
||||
<span>此步骤暂无可用截图</span>
|
||||
</div>
|
||||
}
|
||||
<!-- Device Screen Frame & Action Coordinates Overlay -->
|
||||
@@ -182,7 +182,7 @@ limitations under the License.
|
||||
(load)="onStepImageLoad()"
|
||||
(error)="onStepImageError()"
|
||||
(click)="openImageInNewTab()"
|
||||
title="Click to view image in new tab"
|
||||
title="点击在新标签页查看图片"
|
||||
/>
|
||||
<!-- Action Visual Coordinates Overlay (Touch point, swipe arrow, sequence path) -->
|
||||
<div
|
||||
@@ -200,7 +200,7 @@ limitations under the License.
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<div class="badge-content-left">
|
||||
<span class="badge-step-no">Step {{ frame.stepNumber }}</span>
|
||||
<span class="badge-step-no">步骤 {{ frame.stepNumber }}</span>
|
||||
<span class="badge-action-desc" [title]="frame.actionText || frame.title">
|
||||
@if (frame.action) {
|
||||
<span class="material-symbols-outlined action-type-icon">
|
||||
@@ -218,17 +218,19 @@ limitations under the License.
|
||||
class="toggle-btn"
|
||||
[class.active]="viewMode() === 'pre'"
|
||||
(click)="setStepViewMode('pre', $event)"
|
||||
title="View screen before action (with touch coordinates)"
|
||||
title="查看操作前画面(含点击坐标)"
|
||||
>
|
||||
Pre
|
||||
|
||||
操作前
|
||||
</button>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
[class.active]="viewMode() === 'post'"
|
||||
(click)="setStepViewMode('post', $event)"
|
||||
title="View resulting screen after action executed"
|
||||
title="查看操作后的屏幕"
|
||||
>
|
||||
Post
|
||||
|
||||
操作后
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@@ -269,7 +271,7 @@ limitations under the License.
|
||||
[class.passed]="i < activeStepIndex()"
|
||||
[style.left.%]="(i / (totalStepFrames() - 1)) * 100"
|
||||
(click)="seekStepFrame(i); $event.stopPropagation()"
|
||||
[title]="'Step ' + (i + 1) + ': ' + (f.actionText || f.title)"
|
||||
[title]="'步骤 ' + (i + 1) + ': ' + (f.actionText || f.title)"
|
||||
></span>
|
||||
}
|
||||
</div>
|
||||
@@ -279,24 +281,24 @@ limitations under the License.
|
||||
<div class="controls-row">
|
||||
<div class="controls-left">
|
||||
<!-- Play / Pause Button -->
|
||||
<button class="ctrl-btn play-btn" (click)="toggleStepPlay()" [title]="isStepPlaying() ? 'Pause' : 'Play Slideshow'">
|
||||
<button class="ctrl-btn play-btn" (click)="toggleStepPlay()" [title]="isStepPlaying() ? '暂停' : '播放截图'">
|
||||
<span class="material-symbols-outlined">{{ isStepPlaying() ? 'pause' : 'play_arrow' }}</span>
|
||||
</button>
|
||||
<!-- Previous Frame Button -->
|
||||
<button class="ctrl-btn" (click)="prevStepFrame()" [disabled]="activeStepIndex() <= 0" title="Previous Step">
|
||||
<button class="ctrl-btn" (click)="prevStepFrame()" [disabled]="activeStepIndex() <= 0" title="上一步">
|
||||
<span class="material-symbols-outlined">skip_previous</span>
|
||||
</button>
|
||||
<!-- Next Frame Button -->
|
||||
<button class="ctrl-btn" (click)="nextStepFrame()" [disabled]="activeStepIndex() >= totalStepFrames() - 1" title="Next Step">
|
||||
<button class="ctrl-btn" (click)="nextStepFrame()" [disabled]="activeStepIndex() >= totalStepFrames() - 1" title="下一步">
|
||||
<span class="material-symbols-outlined">skip_next</span>
|
||||
</button>
|
||||
<!-- Step Replay / Reset -->
|
||||
<button class="ctrl-btn" (click)="restartStep()" title="Replay from start">
|
||||
<button class="ctrl-btn" (click)="restartStep()" title="从头回放">
|
||||
<span class="material-symbols-outlined">replay</span>
|
||||
</button>
|
||||
<!-- Step Counter Display -->
|
||||
<span class="time-display">
|
||||
Step {{ activeStepIndex() + 1 }} / {{ totalStepFrames() }}
|
||||
步骤 {{ activeStepIndex() + 1 }} / {{ totalStepFrames() }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="controls-right">
|
||||
@@ -318,7 +320,7 @@ limitations under the License.
|
||||
<span class="material-symbols-outlined">repeat</span>
|
||||
</button>
|
||||
<!-- Open in new tab -->
|
||||
<button class="ctrl-btn" (click)="openImageInNewTab()" title="Open image in new tab">
|
||||
<button class="ctrl-btn" (click)="openImageInNewTab()" title="在新标签页打开图片">
|
||||
<span class="material-symbols-outlined">open_in_new</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -364,11 +366,11 @@ limitations under the License.
|
||||
<div class="controls-row">
|
||||
<div class="controls-left">
|
||||
<!-- Play / Pause Button -->
|
||||
<button class="ctrl-btn play-btn" (click)="togglePlay()" [title]="isPlaying() ? 'Pause' : 'Play'">
|
||||
<button class="ctrl-btn play-btn" (click)="togglePlay()" [title]="isPlaying() ? '暂停' : '播放'">
|
||||
<span class="material-symbols-outlined">{{ isPlaying() ? 'pause' : 'play_arrow' }}</span>
|
||||
</button>
|
||||
<!-- Replay / Reset -->
|
||||
<button class="ctrl-btn" (click)="restart()" title="Replay from start">
|
||||
<button class="ctrl-btn" (click)="restart()" title="从头回放">
|
||||
<span class="material-symbols-outlined">replay</span>
|
||||
</button>
|
||||
<!-- Time Display -->
|
||||
@@ -399,7 +401,7 @@ limitations under the License.
|
||||
<span class="material-symbols-outlined">{{ isMuted() ? 'volume_off' : 'volume_up' }}</span>
|
||||
</button>
|
||||
<!-- Open in new tab -->
|
||||
<button class="ctrl-btn" (click)="openInNewTab()" title="Open video in new tab">
|
||||
<button class="ctrl-btn" (click)="openInNewTab()" title="在新标签页打开视频">
|
||||
<span class="material-symbols-outlined">open_in_new</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -421,7 +423,7 @@ limitations under the License.
|
||||
</p>
|
||||
@if (agentService.recordingPlaybackStatus() === 'failed' || videoLoadError()) {
|
||||
<button class="retry-btn" (click)="videoLoadError.set(false); agentService.retryVideoRecording()">
|
||||
<span class="material-symbols-outlined">refresh</span> Retry
|
||||
<span class="material-symbols-outlined">refresh</span> 重试
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
+13
-13
@@ -18,19 +18,19 @@ limitations under the License.
|
||||
<!-- Header with Session Selector -->
|
||||
<header class="stream-header">
|
||||
<div class="header-title">
|
||||
<h2>👣 Real-time Agent Stream (Legacy Card View)</h2>
|
||||
<p class="subtitle">Select a session to monitor raw agent activity</p>
|
||||
<h2>👣 智能体实时记录(经典卡片视图)</h2>
|
||||
<p class="subtitle">选择会话,查看智能体执行记录</p>
|
||||
</div>
|
||||
|
||||
<div class="session-selector-wrapper">
|
||||
<label for="session-select">Active Session:</label>
|
||||
<label for="session-select">当前会话:</label>
|
||||
<select
|
||||
id="session-select"
|
||||
[value]="agentService.currentSessionId() || ''"
|
||||
(change)="onSessionChange($event)"
|
||||
class="session-dropdown"
|
||||
>
|
||||
<option value="" disabled selected>-- Select a Session --</option>
|
||||
<option value="" disabled selected>-- 选择会话 --</option>
|
||||
@for (session of agentService.sessions(); track session) {
|
||||
<option
|
||||
[value]="session.session_id"
|
||||
@@ -39,7 +39,7 @@ limitations under the License.
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
<button class="btn-refresh" (click)="agentService.fetchSessions()">🔄 Refresh</button>
|
||||
<button class="btn-refresh" (click)="agentService.fetchSessions()">🔄 刷新</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -48,8 +48,8 @@ limitations under the License.
|
||||
@if (agentService.sessionLogs().length === 0) {
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">📡</div>
|
||||
<h3>No Stream Activity Selected</h3>
|
||||
<p>Please select a running session from the dropdown above to view real-time log payloads.</p>
|
||||
<h3>尚未选择执行记录</h3>
|
||||
<p>请在上方下拉列表选择正在运行的会话,查看实时日志。</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -73,28 +73,28 @@ limitations under the License.
|
||||
<!-- Step Summary (Message to User) -->
|
||||
@if (log.data.summary) {
|
||||
<div class="step-summary-section">
|
||||
<div class="section-title">💬 Message to User / Summary:</div>
|
||||
<div class="section-title">💬 用户消息 / 摘要:</div>
|
||||
<div class="summary-bubble">{{ log.data.summary }}</div>
|
||||
</div>
|
||||
}
|
||||
<!-- Model thinking process (collapsible) -->
|
||||
@if (log.data.operator_native_thinking) {
|
||||
<details class="thinking-details">
|
||||
<summary class="details-summary">🧠 Model Thinking Process (Click to expand)</summary>
|
||||
<summary class="details-summary">🧠 模型思考过程(点击展开)</summary>
|
||||
<div class="details-content thinking-text">{{ log.data.operator_native_thinking }}</div>
|
||||
</details>
|
||||
}
|
||||
<!-- Raw thinking process (collapsible) -->
|
||||
@if (log.data.operator_raw_thinking) {
|
||||
<details class="planning-details">
|
||||
<summary class="details-summary">📝 Raw Plan / Thoughts (Click to expand)</summary>
|
||||
<summary class="details-summary">📝 原始计划 / 思考(点击展开)</summary>
|
||||
<div class="details-content planning-text">{{ log.data.operator_raw_thinking }}</div>
|
||||
</details>
|
||||
}
|
||||
<!-- Tool Call (collapsible) -->
|
||||
@if (log.data.action_taken) {
|
||||
<details class="tool-details" [open]="isShortContent(log.data.action_taken)">
|
||||
<summary class="details-summary">🛠️ Tool Call Details (Click to expand)</summary>
|
||||
<summary class="details-summary">🛠️ 工具调用详情(点击展开)</summary>
|
||||
<div class="details-content">
|
||||
<pre class="json-code"><code>{{ formatJson(log.data.action_taken) }}</code></pre>
|
||||
</div>
|
||||
@@ -103,7 +103,7 @@ limitations under the License.
|
||||
<!-- Tool Call Result (collapsible) -->
|
||||
@if (log.data.last_execution_result) {
|
||||
<details class="result-details">
|
||||
<summary class="details-summary">✓ Execution Result (Click to expand)</summary>
|
||||
<summary class="details-summary">✓ 执行结果(点击展开)</summary>
|
||||
<div class="details-content">
|
||||
<pre class="json-code"><code>{{ formatJson(log.data.last_execution_result) }}</code></pre>
|
||||
</div>
|
||||
@@ -115,7 +115,7 @@ limitations under the License.
|
||||
@if (!isStepEvent(log.type)) {
|
||||
<div class="log-card-body raw-body">
|
||||
<details class="raw-details" [open]="isShortContent(log.data)">
|
||||
<summary class="details-summary">📄 Raw Payload Data (Click to expand)</summary>
|
||||
<summary class="details-summary">📄 原始数据(点击展开)</summary>
|
||||
<div class="details-content">
|
||||
<pre class="json-code"><code>{{ formatJson(log.data) }}</code></pre>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Component, inject, OnInit, signal, output } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { finalize } from 'rxjs';
|
||||
|
||||
interface Capabilities {
|
||||
video_enabled: boolean;
|
||||
outputter_enabled: boolean;
|
||||
step_summarizer_enabled: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-capability-settings', standalone: true, imports: [FormsModule],
|
||||
template: `
|
||||
<section aria-label="功能开关">
|
||||
<h3>功能开关</h3>
|
||||
<p>按需启用,保存后对新任务生效。</p>
|
||||
<label><input type="checkbox" role="switch" [(ngModel)]="config.video_enabled" [disabled]="busy() || loading() || !loaded()" />
|
||||
<span>视频分析与录屏<small>录制设备画面,并允许智能体分析录像;关闭可减少资源占用。</small></span></label>
|
||||
<label><input type="checkbox" role="switch" [(ngModel)]="config.outputter_enabled" [disabled]="busy() || loading() || !loaded()" />
|
||||
<span>最终报告生成<small>根据执行记录生成任务结果报告。</small></span></label>
|
||||
<label><input type="checkbox" role="switch" [(ngModel)]="config.step_summarizer_enabled" [disabled]="busy() || loading() || !loaded()" />
|
||||
<span>步骤摘要压缩<small>Flash 模式下在后台生成摘要,减少长任务的上下文占用。</small></span></label>
|
||||
<button type="button" (click)="save()" [disabled]="busy() || loading() || !loaded()">{{ busy() ? '正在保存…' : '保存功能设置' }}</button>
|
||||
@if (message()) { <p role="status">{{ message() }}</p> }
|
||||
@if (error()) { <p role="alert" class="error">{{ error() }}</p> }
|
||||
</section>
|
||||
`,
|
||||
styles: [`section { margin-top: 24px; padding-top: 20px; border-top: 1px solid #64748b55; }
|
||||
h3 { margin: 0 0 8px; } p, small { color: #64748b; line-height: 1.6; }
|
||||
label { display: flex; gap: 12px; align-items: flex-start; padding: 14px 0; cursor: pointer; }
|
||||
input { width: 20px; height: 20px; accent-color: #0284c7; flex-shrink: 0; }
|
||||
small { display: block; margin-top: 4px; } button { padding: 10px 18px; border-radius: 10px; border: 1px solid #38bdf877; background: #0284c7; color: white; font: inherit; cursor: pointer; }
|
||||
button:disabled { opacity: .5; cursor: default; } .error { color: #be123c; }`]
|
||||
})
|
||||
export class CapabilitySettingsComponent implements OnInit {
|
||||
changed = output<Capabilities>();
|
||||
private http = inject(HttpClient);
|
||||
config: Capabilities = { video_enabled: true, outputter_enabled: true, step_summarizer_enabled: true };
|
||||
busy = signal(false); loading = signal(true); loaded = signal(false); message = signal(''); error = signal('');
|
||||
ngOnInit(): void {
|
||||
this.http.get<Capabilities>('/api/system/capability-settings').pipe(finalize(() => this.loading.set(false))).subscribe({
|
||||
next: config => { this.config = config; this.loaded.set(true); this.changed.emit(config); }, error: () => this.error.set('功能配置加载失败,请刷新页面重试。')
|
||||
});
|
||||
}
|
||||
save(): void {
|
||||
if (this.busy() || this.loading() || !this.loaded()) return;
|
||||
this.busy.set(true); this.message.set(''); this.error.set('');
|
||||
this.http.post<Capabilities>('/api/system/capability-settings', this.config).pipe(finalize(() => this.busy.set(false))).subscribe({
|
||||
next: config => { this.config = config; this.changed.emit(config); this.message.set('功能设置已保存,新任务将使用此配置。'); },
|
||||
error: () => this.error.set('保存失败,请检查服务器连接和 .env 文件权限。')
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { ModelSettingsComponent } from './model-settings.component';
|
||||
import { CapabilitySettingsComponent } from './capability-settings.component';
|
||||
|
||||
describe('持久化模型与功能设置', () => {
|
||||
let http: HttpTestingController;
|
||||
const config = { base_url: 'https://example.test/v1', model: 'mimo-v2.6-flash',
|
||||
api_key_configured: true, timeout_seconds: 120, max_retries: 0, env_path: '/app/.env' };
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
|
||||
http = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
afterEach(() => http.verify());
|
||||
|
||||
it('加载配置后保留密钥,保存新模型且不发送空密钥', () => {
|
||||
const fixture = TestBed.createComponent(ModelSettingsComponent);
|
||||
fixture.detectChanges();
|
||||
http.expectOne('/api/system/model-settings').flush(config);
|
||||
const component = fixture.componentInstance;
|
||||
expect(component.apiKey).toBe('');
|
||||
expect(component.keyConfigured()).toBeTrue();
|
||||
component.model = 'another-model';
|
||||
component.save();
|
||||
const request = http.expectOne('/api/system/model-settings');
|
||||
expect(request.request.method).toBe('POST');
|
||||
expect(request.request.body.model).toBe('another-model');
|
||||
expect(request.request.body.api_key).toBeUndefined();
|
||||
request.flush({ ...config, model: 'another-model' });
|
||||
expect(component.busy()).toBeNull();
|
||||
expect(component.message()).toContain('保存');
|
||||
});
|
||||
|
||||
it('连接测试使用当前输入而不写入保存接口', () => {
|
||||
const fixture = TestBed.createComponent(ModelSettingsComponent);
|
||||
fixture.detectChanges();
|
||||
http.expectOne('/api/system/model-settings').flush(config);
|
||||
fixture.componentInstance.test();
|
||||
const request = http.expectOne('/api/system/model-settings/test');
|
||||
expect(request.request.body.model).toBe('mimo-v2.6-flash');
|
||||
request.flush({ message: 'Responses 连接测试成功' });
|
||||
http.expectNone('/api/system/model-settings');
|
||||
expect(fixture.componentInstance.message()).toContain('成功');
|
||||
});
|
||||
|
||||
it('加载失败时保持功能开关不可保存,避免覆盖现有设置', () => {
|
||||
const fixture = TestBed.createComponent(CapabilitySettingsComponent);
|
||||
fixture.detectChanges();
|
||||
http.expectOne('/api/system/capability-settings').flush({}, { status: 500, statusText: 'Error' });
|
||||
expect(fixture.componentInstance.error()).toContain('加载失败');
|
||||
fixture.componentInstance.save();
|
||||
http.expectNone('/api/system/capability-settings');
|
||||
});
|
||||
|
||||
it('保存关闭视频分析的配置,重新打开时恢复关闭状态', () => {
|
||||
const capabilities = { video_enabled: true, outputter_enabled: true, step_summarizer_enabled: false };
|
||||
const fixture = TestBed.createComponent(CapabilitySettingsComponent);
|
||||
fixture.detectChanges();
|
||||
http.expectOne('/api/system/capability-settings').flush(capabilities);
|
||||
fixture.componentInstance.config.video_enabled = false;
|
||||
fixture.componentInstance.save();
|
||||
const request = http.expectOne('/api/system/capability-settings');
|
||||
expect(request.request.body.video_enabled).toBeFalse();
|
||||
const saved = { ...capabilities, video_enabled: false };
|
||||
request.flush(saved);
|
||||
fixture.destroy();
|
||||
const reopened = TestBed.createComponent(CapabilitySettingsComponent);
|
||||
reopened.detectChanges();
|
||||
http.expectOne('/api/system/capability-settings').flush(saved);
|
||||
expect(reopened.componentInstance.config.video_enabled).toBeFalse();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Component, OnInit, inject, signal, output } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { finalize } from 'rxjs';
|
||||
|
||||
interface ModelSettings {
|
||||
base_url: string;
|
||||
model: string;
|
||||
api_key_configured: boolean;
|
||||
timeout_seconds: number;
|
||||
max_retries: number;
|
||||
env_path: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-model-settings',
|
||||
standalone: true,
|
||||
imports: [FormsModule],
|
||||
template: `
|
||||
<form class="model-settings" (ngSubmit)="save()">
|
||||
<p>统一使用 OpenAI Responses。保存后新任务立即生效,重启后自动加载。</p>
|
||||
<label for="model-base-url">接口地址</label>
|
||||
<input id="model-base-url" name="baseUrl" type="url" [(ngModel)]="baseUrl" required
|
||||
placeholder="https://api.openai.com/v1" [disabled]="busy() || loading()" />
|
||||
<small>填写接口根地址,通常包含 /v1,无需添加 /responses。</small>
|
||||
<label for="model-name">模型名称</label>
|
||||
<input id="model-name" name="model" [(ngModel)]="model" required placeholder="输入接口支持的模型名"
|
||||
[disabled]="busy() || loading()" />
|
||||
<label for="model-key">API Key <span>{{ keyConfigured() ? '(已配置,留空保留)' : '(尚未配置)' }}</span></label>
|
||||
<input id="model-key" name="apiKey" type="password" [(ngModel)]="apiKey" autocomplete="new-password"
|
||||
placeholder="输入新密钥" [disabled]="busy() || loading()" />
|
||||
<div class="number-fields">
|
||||
<label>请求超时(秒)<input name="timeout" type="number" min="1" max="3600" required
|
||||
[(ngModel)]="timeout" [disabled]="busy() || loading()" /></label>
|
||||
<label>接口重试次数<input name="retries" type="number" min="0" max="10" required
|
||||
[(ngModel)]="retries" [disabled]="busy() || loading()" /></label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit" [disabled]="busy() || loading() || !valid()">{{ busy() === 'save' ? '正在保存…' : '保存配置' }}</button>
|
||||
<button type="button" (click)="test()" [disabled]="busy() || loading() || !valid()">
|
||||
{{ busy() === 'test' ? '正在测试…' : '测试连接' }}
|
||||
</button>
|
||||
</div>
|
||||
@if (loading()) { <p role="status">正在加载配置…</p> }
|
||||
@if (message()) { <p role="status" class="success">{{ message() }}</p> }
|
||||
@if (error()) { <p role="alert" class="error">{{ error() }}</p> }
|
||||
@if (envPath()) { <small>保存位置:{{ envPath() }}</small> }
|
||||
</form>
|
||||
`,
|
||||
styles: [`
|
||||
.model-settings { display: grid; gap: 10px; color: #1e293b; }
|
||||
p, small { margin: 0; line-height: 1.6; color: #64748b; }
|
||||
label { font-weight: 600; } label span { font-weight: 400; font-size: 12px; }
|
||||
input { box-sizing: border-box; width: 100%; padding: 12px; border-radius: 10px; border: 1px solid #64748b66;
|
||||
background: #f8fafc; color: inherit; font: inherit; }
|
||||
input:focus { outline: 2px solid #38bdf8; }
|
||||
.number-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 8px; }
|
||||
.number-fields input { margin-top: 8px; }
|
||||
.actions { display: flex; gap: 12px; margin-top: 8px; }
|
||||
button { padding: 11px 20px; border-radius: 10px; border: 1px solid #38bdf877; background: #0284c7; color: white; cursor: pointer; font: inherit; }
|
||||
button:disabled { opacity: .5; cursor: default; } .success { color: #047857; } .error { color: #be123c; }
|
||||
small { overflow-wrap: anywhere; } @media(max-width: 480px) { .number-fields { grid-template-columns: 1fr; } }
|
||||
`]
|
||||
})
|
||||
export class ModelSettingsComponent implements OnInit {
|
||||
private http = inject(HttpClient);
|
||||
saved = output<void>();
|
||||
baseUrl = ''; model = ''; apiKey = ''; timeout = 120; retries = 0;
|
||||
busy = signal<'save' | 'test' | null>(null);
|
||||
loading = signal(true); keyConfigured = signal(false); envPath = signal('');
|
||||
message = signal(''); error = signal('');
|
||||
|
||||
ngOnInit(): void {
|
||||
this.http.get<ModelSettings>('/api/system/model-settings').pipe(finalize(() => this.loading.set(false))).subscribe({
|
||||
next: config => this.apply(config),
|
||||
error: () => this.error.set('配置加载失败,请刷新页面重试。')
|
||||
});
|
||||
}
|
||||
valid(): boolean {
|
||||
return !!this.baseUrl.trim() && !!this.model.trim() && (!!this.apiKey.trim() || this.keyConfigured())
|
||||
&& this.timeout > 0 && this.timeout <= 3600 && Number.isInteger(this.retries) && this.retries >= 0 && this.retries <= 10;
|
||||
}
|
||||
private payload() {
|
||||
return { base_url: this.baseUrl.trim(), model: this.model.trim(), api_key: this.apiKey.trim() || undefined,
|
||||
timeout_seconds: this.timeout, max_retries: this.retries };
|
||||
}
|
||||
private apply(config: ModelSettings): void {
|
||||
this.baseUrl = config.base_url; this.model = config.model; this.timeout = config.timeout_seconds;
|
||||
this.retries = config.max_retries; this.keyConfigured.set(config.api_key_configured); this.envPath.set(config.env_path);
|
||||
}
|
||||
save(): void {
|
||||
if (this.busy() || !this.valid()) return;
|
||||
this.busy.set('save'); this.message.set(''); this.error.set('');
|
||||
this.http.post<ModelSettings>('/api/system/model-settings', this.payload()).pipe(finalize(() => this.busy.set(null))).subscribe({
|
||||
next: config => { this.apply(config); this.apiKey = ''; this.message.set(config.message || '配置已保存。'); this.saved.emit(); },
|
||||
error: err => this.error.set(typeof err.error?.detail === 'string' ? err.error.detail : '保存失败,请检查配置内容。')
|
||||
});
|
||||
}
|
||||
test(): void {
|
||||
if (this.busy() || !this.valid()) return;
|
||||
this.busy.set('test'); this.message.set(''); this.error.set('');
|
||||
this.http.post<{ message: string }>('/api/system/model-settings/test', this.payload()).pipe(finalize(() => this.busy.set(null))).subscribe({
|
||||
next: result => this.message.set(result.message),
|
||||
error: err => this.error.set(typeof err.error?.detail === 'string' ? err.error.detail : '连接测试失败,请检查配置。')
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -23,25 +23,25 @@ import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||
standalone: true,
|
||||
imports: [RouterLink, RouterLinkActive],
|
||||
template: `
|
||||
<nav class="floating-nav-switcher" aria-label="Main Navigation">
|
||||
<a
|
||||
routerLink="/"
|
||||
routerLinkActive="active"
|
||||
<nav class="floating-nav-switcher" aria-label="主导航">
|
||||
<a
|
||||
routerLink="/"
|
||||
routerLinkActive="active"
|
||||
[routerLinkActiveOptions]="{exact: true}"
|
||||
class="nav-tab-btn"
|
||||
title="Return to Home Launcher to start a new task"
|
||||
title="返回首页开始新任务"
|
||||
>
|
||||
<span class="material-symbols-outlined tab-icon">add_task</span>
|
||||
<span class="tab-label">New / Home</span>
|
||||
<span class="tab-label">新任务 / 首页</span>
|
||||
</a>
|
||||
<a
|
||||
routerLink="/workspace"
|
||||
routerLinkActive="active"
|
||||
<a
|
||||
routerLink="/workspace"
|
||||
routerLinkActive="active"
|
||||
class="nav-tab-btn"
|
||||
title="Open Workspace"
|
||||
title="打开工作区"
|
||||
>
|
||||
<span class="material-symbols-outlined tab-icon">space_dashboard</span>
|
||||
<span class="tab-label">Workspace</span>
|
||||
<span class="tab-label">工作区</span>
|
||||
</a>
|
||||
</nav>
|
||||
`,
|
||||
|
||||
@@ -47,29 +47,29 @@ export interface SmartSuggestion {
|
||||
*/
|
||||
export const APP_REGISTRY: Record<string, AppReference> = {
|
||||
// Google Suite & System
|
||||
'com.google.android.apps.maps': { name: 'Maps', icon: 'explore', pkg: 'com.google.android.apps.maps', category: 'navigation' },
|
||||
'com.google.android.apps.maps': { name: "地图", icon: 'explore', pkg: 'com.google.android.apps.maps', category: 'navigation' },
|
||||
'com.google.android.gm': { name: 'Gmail', icon: 'mail', pkg: 'com.google.android.gm', category: 'productivity' },
|
||||
'com.android.chrome': { name: 'Chrome', icon: 'public', pkg: 'com.android.chrome', category: 'browser' },
|
||||
'com.google.android.youtube': { name: 'YouTube', icon: 'smart_display', pkg: 'com.google.android.youtube', category: 'entertainment' },
|
||||
'com.android.settings': { name: 'Settings', icon: 'settings', pkg: 'com.android.settings', category: 'system' },
|
||||
'com.google.android.deskclock': { name: 'Clock', icon: 'timer', pkg: 'com.google.android.deskclock', category: 'utility' },
|
||||
'com.android.deskclock': { name: 'Clock', icon: 'timer', pkg: 'com.android.deskclock', category: 'utility' },
|
||||
'com.google.android.calculator': { name: 'Calculator', icon: 'calculate', pkg: 'com.google.android.calculator', category: 'utility' },
|
||||
'com.android.calculator2': { name: 'Calculator', icon: 'calculate', pkg: 'com.android.calculator2', category: 'utility' },
|
||||
'com.google.android.apps.photos': { name: 'Photos', icon: 'photo_library', pkg: 'com.google.android.apps.photos', category: 'media' },
|
||||
'com.google.android.calendar': { name: 'Calendar', icon: 'calendar_month', pkg: 'com.google.android.calendar', category: 'productivity' },
|
||||
'com.google.android.keep': { name: 'Keep Notes', icon: 'note_alt', pkg: 'com.google.android.keep', category: 'productivity' },
|
||||
'com.android.vending': { name: 'Play Store', icon: 'storefront', pkg: 'com.android.vending', category: 'tools' },
|
||||
'com.google.android.apps.messaging': { name: 'Messages', icon: 'chat', pkg: 'com.google.android.apps.messaging', category: 'communication' },
|
||||
'com.android.settings': { name: "设置", icon: 'settings', pkg: 'com.android.settings', category: 'system' },
|
||||
'com.google.android.deskclock': { name: "时钟", icon: 'timer', pkg: 'com.google.android.deskclock', category: 'utility' },
|
||||
'com.android.deskclock': { name: "时钟", icon: 'timer', pkg: 'com.android.deskclock', category: 'utility' },
|
||||
'com.google.android.calculator': { name: "计算器", icon: 'calculate', pkg: 'com.google.android.calculator', category: 'utility' },
|
||||
'com.android.calculator2': { name: "计算器", icon: 'calculate', pkg: 'com.android.calculator2', category: 'utility' },
|
||||
'com.google.android.apps.photos': { name: "相册", icon: 'photo_library', pkg: 'com.google.android.apps.photos', category: 'media' },
|
||||
'com.google.android.calendar': { name: "日历", icon: 'calendar_month', pkg: 'com.google.android.calendar', category: 'productivity' },
|
||||
'com.google.android.keep': { name: "Keep 笔记", icon: 'note_alt', pkg: 'com.google.android.keep', category: 'productivity' },
|
||||
'com.android.vending': { name: "应用商店", icon: 'storefront', pkg: 'com.android.vending', category: 'tools' },
|
||||
'com.google.android.apps.messaging': { name: "短信", icon: 'chat', pkg: 'com.google.android.apps.messaging', category: 'communication' },
|
||||
|
||||
// Popular Ecosystem Apps
|
||||
'com.tencent.mm': { name: 'WeChat', icon: 'forum', pkg: 'com.tencent.mm', category: 'social' },
|
||||
'com.xingin.xhs': { name: 'Xiaohongshu', icon: 'auto_stories', pkg: 'com.xingin.xhs', category: 'social' },
|
||||
'com.sankuai.meituan': { name: 'Meituan', icon: 'restaurant', pkg: 'com.sankuai.meituan', category: 'lifestyle' },
|
||||
'com.dianping.v1': { name: 'Dianping', icon: 'star', pkg: 'com.dianping.v1', category: 'lifestyle' },
|
||||
'tv.danmaku.bili': { name: 'Bilibili', icon: 'video_library', pkg: 'tv.danmaku.bili', category: 'entertainment' },
|
||||
'com.eg.android.AlipayGphone': { name: 'Alipay', icon: 'account_balance_wallet', pkg: 'com.eg.android.AlipayGphone', category: 'finance' },
|
||||
'com.netease.cloudmusic': { name: 'NetEase Music', icon: 'headphones', pkg: 'com.netease.cloudmusic', category: 'entertainment' },
|
||||
'com.tencent.mm': { name: "微信", icon: 'forum', pkg: 'com.tencent.mm', category: 'social' },
|
||||
'com.xingin.xhs': { name: "小红书", icon: 'auto_stories', pkg: 'com.xingin.xhs', category: 'social' },
|
||||
'com.sankuai.meituan': { name: "美团", icon: 'restaurant', pkg: 'com.sankuai.meituan', category: 'lifestyle' },
|
||||
'com.dianping.v1': { name: "大众点评", icon: 'star', pkg: 'com.dianping.v1', category: 'lifestyle' },
|
||||
'tv.danmaku.bili': { name: "哔哩哔哩", icon: 'video_library', pkg: 'tv.danmaku.bili', category: 'entertainment' },
|
||||
'com.eg.android.AlipayGphone': { name: "支付宝", icon: 'account_balance_wallet', pkg: 'com.eg.android.AlipayGphone', category: 'finance' },
|
||||
'com.netease.cloudmusic': { name: "网易云音乐", icon: 'headphones', pkg: 'com.netease.cloudmusic', category: 'entertainment' },
|
||||
'com.spotify.music': { name: 'Spotify', icon: 'music_note', pkg: 'com.spotify.music', category: 'entertainment' }
|
||||
};
|
||||
|
||||
@@ -80,27 +80,27 @@ export const SMART_TASK_LIBRARY: SmartSuggestion[] = [
|
||||
// 1. Google Maps
|
||||
{
|
||||
id: 'maps_coffee',
|
||||
title: 'Find Specialty Coffee',
|
||||
description: 'Search nearby top-rated cafes in Google Maps',
|
||||
goal: 'Open Google Maps, search for top-rated specialty coffee shops nearby, and view the top result details.',
|
||||
title: "寻找精品咖啡",
|
||||
description: "在 Google 地图查找附近高评分咖啡馆",
|
||||
goal: "打开 Google 地图,搜索附近评分最高的精品咖啡店,查看首个结果详情。",
|
||||
profile: 'flash',
|
||||
category: 'flash',
|
||||
tag: 'Maps',
|
||||
apps: [{ name: 'Maps', icon: 'explore', pkg: 'com.google.android.apps.maps' }],
|
||||
tag: "地图",
|
||||
apps: [{ name: "地图", icon: 'explore', pkg: 'com.google.android.apps.maps' }],
|
||||
requiredPackages: ['com.google.android.apps.maps'],
|
||||
priority: 95
|
||||
},
|
||||
{
|
||||
id: 'pro_commute_share',
|
||||
title: 'Commute ETA & Message Draft',
|
||||
description: 'Check transit time on Maps and draft arrival ETA in Messages',
|
||||
goal: 'Open Google Maps to check commute time to the International Airport, calculate arrival time, then open Messages and draft an ETA text message.',
|
||||
title: "通勤时间与短信草稿",
|
||||
description: "查询地图通勤时间,并起草预计到达时间短信",
|
||||
goal: "打开 Google 地图查询前往国际机场的通勤时间,计算到达时间,再打开短信应用起草预计到达时间短信。",
|
||||
profile: 'pro',
|
||||
category: 'cross_app',
|
||||
tag: 'Maps + Messages',
|
||||
tag: "地图 + 短信",
|
||||
apps: [
|
||||
{ name: 'Maps', icon: 'explore', pkg: 'com.google.android.apps.maps' },
|
||||
{ name: 'Messages', icon: 'chat', pkg: 'com.google.android.apps.messaging' }
|
||||
{ name: "地图", icon: 'explore', pkg: 'com.google.android.apps.maps' },
|
||||
{ name: "短信", icon: 'chat', pkg: 'com.google.android.apps.messaging' }
|
||||
],
|
||||
requiredPackages: ['com.google.android.apps.maps', 'com.google.android.apps.messaging'],
|
||||
matchMode: 'all',
|
||||
@@ -110,9 +110,9 @@ export const SMART_TASK_LIBRARY: SmartSuggestion[] = [
|
||||
// 2. Gmail
|
||||
{
|
||||
id: 'gmail_receipts',
|
||||
title: 'Search Order Receipts',
|
||||
description: 'Find recent flight or delivery confirmation emails in Gmail',
|
||||
goal: 'Open Gmail and search for recent flight or package delivery confirmation emails.',
|
||||
title: "查找订单回执",
|
||||
description: "在 Gmail 查找近期航班或快递确认邮件",
|
||||
goal: "打开 Gmail,搜索近期航班或包裹投递确认邮件。",
|
||||
profile: 'flash',
|
||||
category: 'flash',
|
||||
tag: 'Gmail',
|
||||
@@ -122,15 +122,15 @@ export const SMART_TASK_LIBRARY: SmartSuggestion[] = [
|
||||
},
|
||||
{
|
||||
id: 'pro_email_to_calendar',
|
||||
title: 'Email Itinerary to Calendar',
|
||||
description: 'Extract flight or event dates from Gmail and schedule in Calendar',
|
||||
goal: 'Open Gmail to find the latest event invitation or itinerary, extract dates and location, then open Google Calendar and create a corresponding calendar event.',
|
||||
title: "将邮件行程加入日历",
|
||||
description: "提取 Gmail 中的行程日期并创建日历事件",
|
||||
goal: "打开 Gmail 查找最新活动邀请或行程,提取日期和地点,再打开 Google 日历创建对应事件。",
|
||||
profile: 'pro',
|
||||
category: 'cross_app',
|
||||
tag: 'Gmail + Calendar',
|
||||
tag: "Gmail + 日历",
|
||||
apps: [
|
||||
{ name: 'Gmail', icon: 'mail', pkg: 'com.google.android.gm' },
|
||||
{ name: 'Calendar', icon: 'calendar_month', pkg: 'com.google.android.calendar' }
|
||||
{ name: "日历", icon: 'calendar_month', pkg: 'com.google.android.calendar' }
|
||||
],
|
||||
requiredPackages: ['com.google.android.gm'],
|
||||
priority: 94
|
||||
@@ -139,9 +139,9 @@ export const SMART_TASK_LIBRARY: SmartSuggestion[] = [
|
||||
// 3. Chrome
|
||||
{
|
||||
id: 'chrome_research',
|
||||
title: 'Search AI News Breakthroughs',
|
||||
description: 'Search latest multimodal AI developments in Chrome browser',
|
||||
goal: 'Open Chrome browser and search for latest breakthroughs in multimodal mobile AI agents.',
|
||||
title: "搜索 AI 最新进展",
|
||||
description: "在 Chrome 搜索多模态 AI 最新动态",
|
||||
goal: "打开 Chrome,搜索多模态移动 AI 智能体的最新进展。",
|
||||
profile: 'flash',
|
||||
category: 'flash',
|
||||
tag: 'Chrome',
|
||||
@@ -151,15 +151,15 @@ export const SMART_TASK_LIBRARY: SmartSuggestion[] = [
|
||||
},
|
||||
{
|
||||
id: 'pro_research_keep',
|
||||
title: 'Product Research & Notes Note',
|
||||
description: 'Compare top 3 headphones on Chrome and record comparison in Keep',
|
||||
goal: 'Open Chrome, research top 3 noise-cancelling headphones comparing price and battery life, then write a structured comparison summary note in Keep Notes.',
|
||||
title: "产品对比与笔记",
|
||||
description: "在 Chrome 对比三款热门耳机,并记入 Keep",
|
||||
goal: "打开 Chrome,调研三款热门降噪耳机,对比价格和续航,再在 Keep 中写一份结构化对比笔记。",
|
||||
profile: 'pro',
|
||||
category: 'pro',
|
||||
tag: 'Chrome + Keep',
|
||||
tag: "Chrome + Keep 笔记",
|
||||
apps: [
|
||||
{ name: 'Chrome', icon: 'public', pkg: 'com.android.chrome' },
|
||||
{ name: 'Keep Notes', icon: 'note_alt', pkg: 'com.google.android.keep' }
|
||||
{ name: "Keep 笔记", icon: 'note_alt', pkg: 'com.google.android.keep' }
|
||||
],
|
||||
requiredPackages: ['com.android.chrome'],
|
||||
priority: 91
|
||||
@@ -168,9 +168,9 @@ export const SMART_TASK_LIBRARY: SmartSuggestion[] = [
|
||||
// 4. YouTube
|
||||
{
|
||||
id: 'youtube_lofi',
|
||||
title: 'Play Lo-Fi Music Radio',
|
||||
description: 'Search and play a Lo-Fi hip hop live stream on YouTube',
|
||||
goal: 'Open YouTube, search for "Lofi hip hop beats relaxing radio" and tap on the live stream.',
|
||||
title: "播放 Lo-Fi 音乐电台",
|
||||
description: "在 YouTube 搜索并播放 Lo-Fi 嘻哈直播",
|
||||
goal: "打开 YouTube,搜索“Lofi hip hop beats relaxing radio”,播放对应直播。",
|
||||
profile: 'flash',
|
||||
category: 'flash',
|
||||
tag: 'YouTube',
|
||||
@@ -182,25 +182,25 @@ export const SMART_TASK_LIBRARY: SmartSuggestion[] = [
|
||||
// 5. Settings
|
||||
{
|
||||
id: 'settings_display_wifi',
|
||||
title: 'Dark Mode & Wi-Fi Check',
|
||||
description: 'Toggle dark theme and verify network connection in Settings',
|
||||
goal: 'Open Settings app, navigate to Display settings, ensure Dark theme is enabled, and check Wi-Fi connection status.',
|
||||
title: "深色模式与 Wi-Fi 检查",
|
||||
description: "在设置中启用深色模式并检查网络连接",
|
||||
goal: "打开设置,进入显示设置,确认深色模式已开启,再检查 Wi-Fi 连接状态。",
|
||||
profile: 'flash',
|
||||
category: 'flash',
|
||||
tag: 'Settings',
|
||||
apps: [{ name: 'Settings', icon: 'settings', pkg: 'com.android.settings' }],
|
||||
tag: "设置",
|
||||
apps: [{ name: "设置", icon: 'settings', pkg: 'com.android.settings' }],
|
||||
requiredPackages: ['com.android.settings'],
|
||||
priority: 87
|
||||
},
|
||||
{
|
||||
id: 'pro_settings_qa',
|
||||
title: 'Subsystem Health & Crash Probe',
|
||||
description: 'Traverse Settings submenus to verify screens and check for crash dialogs',
|
||||
goal: 'Explore Settings submenus (Network, Connected devices, Apps, Battery, Storage), verify each screen loads properly without ANR or crash dialogs, and summarize results.',
|
||||
title: "系统功能与崩溃检查",
|
||||
description: "遍历设置子菜单,检查页面与崩溃弹窗",
|
||||
goal: "遍历设置中的网络、已连接设备、应用、电池和存储菜单,确认各页面正常加载且没有无响应或崩溃弹窗,汇总检查结果。",
|
||||
profile: 'pro',
|
||||
category: 'monitor',
|
||||
tag: 'Settings QA',
|
||||
apps: [{ name: 'Settings', icon: 'settings', pkg: 'com.android.settings' }],
|
||||
tag: "设置功能测试",
|
||||
apps: [{ name: "设置", icon: 'settings', pkg: 'com.android.settings' }],
|
||||
requiredPackages: ['com.android.settings'],
|
||||
priority: 93
|
||||
},
|
||||
@@ -208,13 +208,13 @@ export const SMART_TASK_LIBRARY: SmartSuggestion[] = [
|
||||
// 6. Clock
|
||||
{
|
||||
id: 'clock_timer',
|
||||
title: '25-Min Pomodoro Timer',
|
||||
description: 'Start a 25-minute focus countdown timer in Clock app',
|
||||
goal: 'Open Clock app, switch to Timer tab, set 25 minutes and start the countdown timer.',
|
||||
title: "25 分钟番茄钟",
|
||||
description: "在时钟应用启动 25 分钟专注计时",
|
||||
goal: "打开时钟,切换至计时器,设置 25 分钟并开始倒计时。",
|
||||
profile: 'flash',
|
||||
category: 'flash',
|
||||
tag: 'Clock',
|
||||
apps: [{ name: 'Clock', icon: 'timer', pkg: 'com.google.android.deskclock' }],
|
||||
tag: "时钟",
|
||||
apps: [{ name: "时钟", icon: 'timer', pkg: 'com.google.android.deskclock' }],
|
||||
requiredPackages: ['com.google.android.deskclock', 'com.android.deskclock'],
|
||||
priority: 86
|
||||
},
|
||||
@@ -222,13 +222,13 @@ export const SMART_TASK_LIBRARY: SmartSuggestion[] = [
|
||||
// 7. Calculator
|
||||
{
|
||||
id: 'calc_gratuity',
|
||||
title: 'Split Bill & Calculate Tip',
|
||||
description: 'Calculate 18% gratuity on $186.40 for 3 people in Calculator',
|
||||
goal: 'Open Calculator and calculate 18% tip on a bill of $186.40, then divide by 3 people.',
|
||||
title: "分摊账单与小费",
|
||||
description: "计算 186.40 美元账单加 18% 小费后的三人均摊金额",
|
||||
goal: "打开计算器,为 186.40 美元账单计算 18% 小费,再计算三人均摊金额。",
|
||||
profile: 'flash',
|
||||
category: 'flash',
|
||||
tag: 'Calculator',
|
||||
apps: [{ name: 'Calculator', icon: 'calculate', pkg: 'com.google.android.calculator' }],
|
||||
tag: "计算器",
|
||||
apps: [{ name: "计算器", icon: 'calculate', pkg: 'com.google.android.calculator' }],
|
||||
requiredPackages: ['com.google.android.calculator', 'com.android.calculator2'],
|
||||
priority: 84
|
||||
},
|
||||
@@ -236,13 +236,13 @@ export const SMART_TASK_LIBRARY: SmartSuggestion[] = [
|
||||
// 8. Photos
|
||||
{
|
||||
id: 'photos_inspect',
|
||||
title: 'Inspect Recent Screenshot',
|
||||
description: 'Open Google Photos and review the latest screenshot taken',
|
||||
goal: 'Open Google Photos and view the most recent screenshot in the screenshots album.',
|
||||
title: "查看最新截图",
|
||||
description: "打开 Google 相册查看最新截图",
|
||||
goal: "打开 Google 相册,查看截图相册中的最新截图。",
|
||||
profile: 'flash',
|
||||
category: 'flash',
|
||||
tag: 'Photos',
|
||||
apps: [{ name: 'Photos', icon: 'photo_library', pkg: 'com.google.android.apps.photos' }],
|
||||
tag: "相册",
|
||||
apps: [{ name: "相册", icon: 'photo_library', pkg: 'com.google.android.apps.photos' }],
|
||||
requiredPackages: ['com.google.android.apps.photos'],
|
||||
priority: 82
|
||||
},
|
||||
@@ -250,27 +250,27 @@ export const SMART_TASK_LIBRARY: SmartSuggestion[] = [
|
||||
// 9. WeChat
|
||||
{
|
||||
id: 'wechat_browse',
|
||||
title: 'Check WeChat Messages',
|
||||
description: 'Open WeChat and view top recent chat conversations',
|
||||
goal: 'Open WeChat and view the top recent chat messages.',
|
||||
title: "查看微信消息",
|
||||
description: "打开微信查看最近聊天",
|
||||
goal: "打开微信,查看最近的聊天消息。",
|
||||
profile: 'flash',
|
||||
category: 'flash',
|
||||
tag: 'WeChat',
|
||||
apps: [{ name: 'WeChat', icon: 'forum', pkg: 'com.tencent.mm' }],
|
||||
tag: "微信",
|
||||
apps: [{ name: "微信", icon: 'forum', pkg: 'com.tencent.mm' }],
|
||||
requiredPackages: ['com.tencent.mm'],
|
||||
priority: 89
|
||||
},
|
||||
{
|
||||
id: 'pro_wechat_to_calendar',
|
||||
title: 'WeChat Notice to Calendar',
|
||||
description: 'Extract meeting notice from WeChat chat and add to Calendar',
|
||||
goal: 'Open WeChat, locate the latest meeting announcement or event message in the top chat, extract the time and topic, then open Calendar and schedule an event.',
|
||||
title: "将微信通知加入日历",
|
||||
description: "提取微信会议通知并加入日历",
|
||||
goal: "打开微信,在最近聊天中查找最新会议或活动通知,提取时间和主题,再打开日历创建事件。",
|
||||
profile: 'pro',
|
||||
category: 'cross_app',
|
||||
tag: 'WeChat + Calendar',
|
||||
tag: "微信 + 日历",
|
||||
apps: [
|
||||
{ name: 'WeChat', icon: 'forum', pkg: 'com.tencent.mm' },
|
||||
{ name: 'Calendar', icon: 'calendar_month', pkg: 'com.google.android.calendar' }
|
||||
{ name: "微信", icon: 'forum', pkg: 'com.tencent.mm' },
|
||||
{ name: "日历", icon: 'calendar_month', pkg: 'com.google.android.calendar' }
|
||||
],
|
||||
requiredPackages: ['com.tencent.mm'],
|
||||
priority: 93
|
||||
@@ -279,13 +279,13 @@ export const SMART_TASK_LIBRARY: SmartSuggestion[] = [
|
||||
// 10. Xiaohongshu
|
||||
{
|
||||
id: 'xhs_coffee_guide',
|
||||
title: 'RED Cafe Guide Search',
|
||||
description: 'Search trending specialty cafe reviews on Xiaohongshu',
|
||||
goal: 'Open Xiaohongshu, search for top-rated specialty coffee shops, and view the top post.',
|
||||
title: "小红书咖啡店攻略",
|
||||
description: "在小红书搜索热门精品咖啡店评测",
|
||||
goal: "打开小红书,搜索高评分精品咖啡店,查看首篇笔记。",
|
||||
profile: 'flash',
|
||||
category: 'flash',
|
||||
tag: 'Xiaohongshu',
|
||||
apps: [{ name: 'Xiaohongshu', icon: 'auto_stories', pkg: 'com.xingin.xhs' }],
|
||||
tag: "小红书",
|
||||
apps: [{ name: "小红书", icon: 'auto_stories', pkg: 'com.xingin.xhs' }],
|
||||
requiredPackages: ['com.xingin.xhs'],
|
||||
priority: 87
|
||||
},
|
||||
@@ -293,13 +293,13 @@ export const SMART_TASK_LIBRARY: SmartSuggestion[] = [
|
||||
// 11. Meituan / Dianping
|
||||
{
|
||||
id: 'meituan_ramen_search',
|
||||
title: 'Meituan Food Search',
|
||||
description: 'Search top-rated Ramen nearby on Meituan or Dianping',
|
||||
goal: 'Open Meituan or Dianping, search for top-rated Ramen nearby, and view top restaurant rating.',
|
||||
title: "美团美食搜索",
|
||||
description: "在美团或大众点评搜索附近高评分拉面店",
|
||||
goal: "打开美团或大众点评,搜索附近高评分拉面店,查看首家餐厅评分。",
|
||||
profile: 'flash',
|
||||
category: 'flash',
|
||||
tag: 'Meituan',
|
||||
apps: [{ name: 'Meituan', icon: 'restaurant', pkg: 'com.sankuai.meituan' }],
|
||||
tag: "美团",
|
||||
apps: [{ name: "美团", icon: 'restaurant', pkg: 'com.sankuai.meituan' }],
|
||||
requiredPackages: ['com.sankuai.meituan', 'com.dianping.v1'],
|
||||
priority: 86
|
||||
},
|
||||
@@ -307,13 +307,13 @@ export const SMART_TASK_LIBRARY: SmartSuggestion[] = [
|
||||
// 12. Bilibili
|
||||
{
|
||||
id: 'bilibili_stream',
|
||||
title: 'Bilibili Tech Video',
|
||||
description: 'Search and play an AI Agent tutorial video on Bilibili',
|
||||
goal: 'Open Bilibili, search for "AI Agent Architecture", and play the top matching video.',
|
||||
title: "哔哩哔哩科技视频",
|
||||
description: "在哔哩哔哩搜索并播放 AI 智能体教程",
|
||||
goal: "打开哔哩哔哩,搜索“AI Agent Architecture”,播放最匹配的视频。",
|
||||
profile: 'flash',
|
||||
category: 'flash',
|
||||
tag: 'Bilibili',
|
||||
apps: [{ name: 'Bilibili', icon: 'video_library', pkg: 'tv.danmaku.bili' }],
|
||||
tag: "哔哩哔哩",
|
||||
apps: [{ name: "哔哩哔哩", icon: 'video_library', pkg: 'tv.danmaku.bili' }],
|
||||
requiredPackages: ['tv.danmaku.bili'],
|
||||
priority: 85
|
||||
},
|
||||
@@ -321,13 +321,13 @@ export const SMART_TASK_LIBRARY: SmartSuggestion[] = [
|
||||
// 13. Play Store
|
||||
{
|
||||
id: 'pro_playstore_review',
|
||||
title: 'Play Store App Review Study',
|
||||
description: 'Compare top task management apps and ratings on Google Play',
|
||||
goal: 'Open Google Play Store, search for top rated task management apps, compare ratings and latest user reviews of the top 2 candidates, and record recommendations.',
|
||||
title: "应用商店评价调研",
|
||||
description: "对比 Google Play 上的任务管理应用与评价",
|
||||
goal: "打开 Google Play,搜索高评分任务管理应用,对比前两款应用的评分和最新评论,记录推荐结果。",
|
||||
profile: 'pro',
|
||||
category: 'pro',
|
||||
tag: 'Play Store',
|
||||
apps: [{ name: 'Play Store', icon: 'storefront', pkg: 'com.android.vending' }],
|
||||
tag: "应用商店",
|
||||
apps: [{ name: "应用商店", icon: 'storefront', pkg: 'com.android.vending' }],
|
||||
requiredPackages: ['com.android.vending'],
|
||||
priority: 88
|
||||
}
|
||||
|
||||
@@ -44,89 +44,89 @@ export interface TuningLevel<TId extends string = string> {
|
||||
export const VERIFICATION_LEVELS: readonly TuningLevel<VerificationLevelId>[] = [
|
||||
{
|
||||
id: 'off',
|
||||
label: 'Off',
|
||||
tagline: 'No checking. The run ends as soon as the task looks done.',
|
||||
latency: 'no extra time',
|
||||
label: "关闭",
|
||||
tagline: "不检查结果,任务看似完成时即结束。",
|
||||
latency: "不增加耗时",
|
||||
runs: [
|
||||
'Each step is treated as finished the moment it is carried out.',
|
||||
'You get the full action trace, but no pass / fail verdict.'
|
||||
"每个步骤执行后即视为完成。",
|
||||
"保留完整操作记录,不给出通过或失败结论。"
|
||||
],
|
||||
skips: ['Nothing is double-checked and nothing is retried.'],
|
||||
bestFor: 'Quick tries and demos, when you only want to watch what happens.'
|
||||
skips: ["不复查,也不重试。"],
|
||||
bestFor: "适合快速试用和演示,仅观察执行过程。"
|
||||
},
|
||||
{
|
||||
id: 'final',
|
||||
label: 'At the end',
|
||||
tagline: 'One check of the finished result against your goal. This is the default.',
|
||||
latency: 'adds about 20–60 s at the end',
|
||||
label: "结束时检查",
|
||||
tagline: "任务结束后按目标检查一次结果,默认使用此模式。",
|
||||
latency: "结束时额外耗时约 20–60 秒",
|
||||
runs: [
|
||||
'When the task finishes, the final screen, the step history and the device state are compared with what you asked for.',
|
||||
'If the result does not match, the task goes back and tries to fix it, up to 3 times.'
|
||||
"任务完成后,将最终屏幕、操作历史和设备状态与目标进行对照。",
|
||||
"若结果不符合目标,将返回修正,最多尝试 3 次。"
|
||||
],
|
||||
skips: ['Nothing is checked while the task is still running.'],
|
||||
bestFor: 'Everyday tasks: an honest pass / fail without slowing the run down.'
|
||||
skips: ["任务执行期间不进行检查。"],
|
||||
bestFor: "适合日常任务,在结束时给出通过或失败结论。"
|
||||
},
|
||||
{
|
||||
id: 'checkpoints',
|
||||
label: 'Every step',
|
||||
tagline: 'Each step is checked as soon as it is done, plus the final check.',
|
||||
latency: 'a short check after each step, done in the background',
|
||||
label: "逐步检查",
|
||||
tagline: "每步完成后立即检查,结束时再做最终检查。",
|
||||
latency: "每步结束后在后台进行简短检查",
|
||||
runs: [
|
||||
'Every step is checked right after it completes, using the screenshots from that moment.',
|
||||
'If a step went wrong, it gets fixed before moving on (up to 2 tries per step).',
|
||||
'A failed test condition is written down and the task keeps going.',
|
||||
'The final check still runs at the end.'
|
||||
"每步完成后,立即使用当时的截图进行检查。",
|
||||
"若某步出错,会先修正再继续,每步最多重试 2 次。",
|
||||
"记录失败的测试条件,然后继续执行任务。",
|
||||
"结束时仍会进行最终检查。"
|
||||
],
|
||||
bestFor: 'Long tasks where one early mistake would spoil everything after it.'
|
||||
bestFor: "适合前期错误会影响后续结果的长流程任务。"
|
||||
},
|
||||
{
|
||||
id: 'strict',
|
||||
label: 'Strict',
|
||||
tagline: 'Every step is checked, with more retries. The first failed test stops the run.',
|
||||
latency: 'slowest: more checks and more retries',
|
||||
label: "严格检查",
|
||||
tagline: "逐步检查并增加重试次数,首次测试失败即停止。",
|
||||
latency: "耗时最长:更多检查与重试",
|
||||
runs: [
|
||||
'Each check takes longer and gets more attempts: 4 fixes per step and 5 at the end.',
|
||||
'The first failed test condition stops the run immediately, with the evidence attached.'
|
||||
"增加检查时间与修正次数:每步最多 4 次,结束时最多 5 次。",
|
||||
"首次测试条件失败时立即停止,并附上证据。"
|
||||
],
|
||||
bestFor: 'Release checks and regression runs, where a wrong pass is never acceptable.'
|
||||
bestFor: "适合发布验收和回归测试,严格避免误判通过。"
|
||||
}
|
||||
];
|
||||
|
||||
export const EXPLORER_MODES: readonly TuningLevel<ExplorerModeId>[] = [
|
||||
{
|
||||
id: 'flash',
|
||||
label: 'Quick glance',
|
||||
tagline: 'Finds buttons and text on the screen in a single look.',
|
||||
latency: '1 look per search',
|
||||
label: "快速识别",
|
||||
tagline: "通过一次识别查找屏幕按钮和文本。",
|
||||
latency: "每次搜索识别 1 次",
|
||||
runs: [
|
||||
'Something on screen is asked for by name, icon or colour and its position comes back straight away.',
|
||||
'Several things can be looked up at once.'
|
||||
"根据名称、图标或颜色查找屏幕元素,直接返回位置。",
|
||||
"支持一次查找多个元素。"
|
||||
],
|
||||
skips: ['No zooming in and no second try.'],
|
||||
bestFor: 'Ordinary apps with clearly labelled buttons, icons and text.'
|
||||
skips: ["不放大,也不再次识别。"],
|
||||
bestFor: "适合按钮、图标和文字清晰的普通应用。"
|
||||
},
|
||||
{
|
||||
id: 'pro',
|
||||
label: 'Second look',
|
||||
tagline: 'Takes up to 3 looks, thinking in between, before answering.',
|
||||
latency: 'up to 3 looks per search',
|
||||
label: "多次识别",
|
||||
tagline: "最多识别 3 次,结合推理后返回结果。",
|
||||
latency: "每次搜索最多识别 3 次",
|
||||
runs: [
|
||||
'The screen layout is read first, then the picture is searched.',
|
||||
'If the first try misses, a different approach is tried within the 3 looks.'
|
||||
"先读取界面布局,再搜索画面。",
|
||||
"首次未找到时,会在 3 次预算内尝试其他方式。"
|
||||
],
|
||||
skips: ['Still no zooming into small areas, to keep searches short.'],
|
||||
bestFor: 'Things described by where they are ("the switch next to Wi-Fi") or with unclear labels.'
|
||||
skips: ["不放大小区域,以缩短搜索时间。"],
|
||||
bestFor: "适合按位置描述的元素(如“Wi-Fi 旁的开关”)或标签不清晰的目标。"
|
||||
},
|
||||
{
|
||||
id: 'ultra',
|
||||
label: 'Close-up',
|
||||
tagline: 'Zooms into parts of the screen and takes up to 8 looks.',
|
||||
latency: 'up to 8 looks per search (slowest)',
|
||||
label: "精细识别",
|
||||
tagline: "放大屏幕局部,最多识别 8 次。",
|
||||
latency: "每次搜索最多识别 8 次(耗时最长)",
|
||||
runs: [
|
||||
'Parts of the screen can be cropped and magnified to read tiny text and crowded layouts piece by piece.',
|
||||
'Later looks reuse the earlier ones, so they cost less time than they sound.'
|
||||
"可裁剪和放大屏幕局部,逐一识别小字与密集布局。",
|
||||
"后续识别会复用之前的信息,减少重复处理。"
|
||||
],
|
||||
bestFor: 'Crowded screens, tiny targets, charts and drawings, and checks where exact placement matters.'
|
||||
bestFor: "适合密集界面、微小目标、图表和需要精确定位的检查。"
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ export interface LLMStreamEventData {
|
||||
}
|
||||
|
||||
export const DEFAULT_STREAM_RESET_MESSAGE =
|
||||
'A request error occurred during output generation, typically caused by lower API priority. Retrying automatically...';
|
||||
"生成过程中请求出错,正在自动重试…";
|
||||
|
||||
export interface StreamResetNotice {
|
||||
id: string;
|
||||
@@ -243,4 +243,3 @@ export interface StepReplayFrame {
|
||||
phaseId?: string;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,3 +1,5 @@
|
||||
import { CapabilitySettingsComponent } from '../../components/model-settings/capability-settings.component';
|
||||
import { ModelSettingsComponent } from '../../components/model-settings/model-settings.component';
|
||||
/**
|
||||
* Copyright 2026 Google LLC
|
||||
*
|
||||
@@ -14,9 +16,9 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Component, signal, computed, effect, inject, OnInit, OnDestroy, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { Component, signal, computed, inject, OnInit, OnDestroy, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { Router } from '@angular/router';
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
import { SystemService } from '../../services/system.service';
|
||||
import {
|
||||
@@ -103,7 +105,7 @@ type AdbGuideTab = 'emulator' | 'usb' | 'wifi' | 'remote';
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
standalone: true,
|
||||
imports: [FormsModule],
|
||||
imports: [FormsModule, ModelSettingsComponent, CapabilitySettingsComponent],
|
||||
templateUrl: './home.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
styleUrl: './home.component.scss'
|
||||
@@ -121,32 +123,6 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
public activeAdbGuideTab = signal<AdbGuideTab>('emulator');
|
||||
public emulatorSetupMode = signal<'studio' | 'cli'>('studio');
|
||||
|
||||
// Interactive guide tab for LLM / OCR credentials: 'gemini' | 'ocr'
|
||||
public modelSetupMode = signal<'gemini' | 'custom'>('gemini');
|
||||
public showOcrConfig = signal<boolean>(false);
|
||||
public showFullConfigFile = signal<boolean>(false);
|
||||
|
||||
// Model & Environment configuration from backend
|
||||
public modelConfigEnv = computed(() => this.systemService.modelConfigEnv());
|
||||
|
||||
// Google Gemini API Key State
|
||||
public geminiKeyInput = signal<string>('');
|
||||
public showGeminiKey = signal<boolean>(false);
|
||||
public isSavingGeminiKey = signal<boolean>(false);
|
||||
public isTestingGeminiKey = signal<boolean>(false);
|
||||
public geminiSaveMessage = signal<string | null>(null);
|
||||
public geminiSaveError = signal<string | null>(null);
|
||||
public isGeminiKeyEdited = signal<boolean>(false);
|
||||
|
||||
// Vision OCR API Key State
|
||||
public ocrKeyInput = signal<string>('');
|
||||
public showOcrKey = signal<boolean>(false);
|
||||
public isSavingOcrKey = signal<boolean>(false);
|
||||
public isTestingOcrKey = signal<boolean>(false);
|
||||
public ocrSaveMessage = signal<string | null>(null);
|
||||
public ocrSaveError = signal<string | null>(null);
|
||||
public isOcrKeyEdited = signal<boolean>(false);
|
||||
|
||||
// Clipboard copy state tracker for interactive feedback
|
||||
public copiedId = signal<string | null>(null);
|
||||
|
||||
@@ -198,7 +174,6 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
|
||||
// Pro Mode Outputter & Structured Output Configuration
|
||||
public expectedOutput = signal<string>('');
|
||||
public enableOutputter = signal<boolean>(true);
|
||||
public showOutputterDrawer = signal<boolean>(false);
|
||||
|
||||
// Slider indexes map to the API ids in VERIFICATION_LEVELS / EXPLORER_MODES.
|
||||
@@ -238,8 +213,8 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
return [
|
||||
{
|
||||
kind: 'verify',
|
||||
name: 'Result check',
|
||||
ends: ['Off', 'Strict'],
|
||||
name: "结果检查",
|
||||
ends: ["关闭", "严格检查"],
|
||||
ladder: VERIFICATION_LEVELS,
|
||||
index: vi,
|
||||
level: VERIFICATION_LEVELS[vi],
|
||||
@@ -247,8 +222,8 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
},
|
||||
{
|
||||
kind: 'explore',
|
||||
name: 'Screen reading',
|
||||
ends: ['Faster', 'Sharper'],
|
||||
name: "屏幕识别",
|
||||
ends: ["更快", "更精细"],
|
||||
ladder: EXPLORER_MODES,
|
||||
index: ei,
|
||||
level: EXPLORER_MODES[ei],
|
||||
@@ -352,13 +327,13 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
public emulatorLaunchState = computed(() => this.systemService.emulatorLaunchState());
|
||||
public isEmulatorLaunching = computed(() => this.systemService.isEmulatorLaunching());
|
||||
public showLaunchLogs = signal<boolean>(false);
|
||||
|
||||
|
||||
// Probes
|
||||
public pythonProbe = computed(() => this.systemService.pythonProbe());
|
||||
public configProbe = computed(() => this.systemService.configProbe());
|
||||
public adbProbe = computed(() => this.systemService.adbProbe());
|
||||
public llmProbe = computed(() => this.systemService.llmProbe());
|
||||
public geminiProbe = computed(() => this.systemService.geminiProbe());
|
||||
public openaiProbe = computed(() => this.systemService.openaiProbe());
|
||||
public ocrProbe = computed(() => this.systemService.ocrProbe());
|
||||
public toolchainProbe = computed(() => this.systemService.toolchainProbe());
|
||||
|
||||
@@ -426,16 +401,16 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
|
||||
public emuHypervisorTitle = computed(() => {
|
||||
const os = this.effectiveOs();
|
||||
if (os === 'windows') return 'Enable Windows Hypervisor Platform (WHPX)';
|
||||
if (os === 'darwin') return 'Verify macOS Hypervisor / Install Tools';
|
||||
return 'Enable KVM Hardware Acceleration (Linux)';
|
||||
if (os === 'windows') return "启用 Windows 虚拟机监控程序平台(WHPX)";
|
||||
if (os === 'darwin') return "检查 macOS 虚拟化并安装工具";
|
||||
return "启用 KVM 硬件加速(Linux)";
|
||||
});
|
||||
|
||||
public emuHypervisorDesc = computed(() => {
|
||||
const os = this.effectiveOs();
|
||||
if (os === 'windows') return 'Enable Windows Hypervisor Platform in PowerShell (Run as Administrator):';
|
||||
if (os === 'darwin') return 'macOS uses native Hypervisor.framework. Install SDK tools via Homebrew (or Studio):';
|
||||
return 'Ensure virtualization permissions are granted to your user account:';
|
||||
if (os === 'windows') return "以管理员身份打开 PowerShell,启用 Windows 虚拟机监控程序平台:";
|
||||
if (os === 'darwin') return "macOS 使用系统 Hypervisor.framework。通过 Homebrew 或 Android Studio 安装 SDK 工具:";
|
||||
return "确保当前用户拥有虚拟化权限:";
|
||||
});
|
||||
|
||||
public emuHypervisorCmd = computed(() => {
|
||||
@@ -467,27 +442,6 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
return meta?.['configured'] === true;
|
||||
});
|
||||
|
||||
public currentApiKey = computed<string>(() => this.systemService.currentApiKey());
|
||||
public apiKeysMap = computed<Record<string, string>>(() => this.systemService.apiKeysMap());
|
||||
|
||||
public savedGeminiKey = computed<string>(() => {
|
||||
const keys = this.apiKeysMap();
|
||||
return keys['google'] || this.currentApiKey() || '';
|
||||
});
|
||||
|
||||
public isGeminiModified = computed<boolean>(() => {
|
||||
return this.geminiKeyInput().trim() !== this.savedGeminiKey().trim();
|
||||
});
|
||||
|
||||
public savedOcrKey = computed<string>(() => {
|
||||
const keys = this.apiKeysMap();
|
||||
return keys['ocr'] || '';
|
||||
});
|
||||
|
||||
public isOcrModified = computed<boolean>(() => {
|
||||
return this.ocrKeyInput().trim() !== this.savedOcrKey().trim();
|
||||
});
|
||||
|
||||
// Rich Smart Suggestions Library (Device-Aware, Flash vs Pro Tailored)
|
||||
public readonly allSuggestions = this.taskRecService.allTasks;
|
||||
|
||||
@@ -520,21 +474,6 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
this.systemService.fetchReadiness().subscribe();
|
||||
};
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const keys = this.systemService.apiKeysMap();
|
||||
const current = this.systemService.currentApiKey();
|
||||
const googleKey = keys['google'] || current || '';
|
||||
const ocrKey = keys['ocr'] || '';
|
||||
if (!this.isGeminiKeyEdited()) {
|
||||
this.geminiKeyInput.set(googleKey);
|
||||
}
|
||||
if (!this.isOcrKeyEdited()) {
|
||||
this.ocrKeyInput.set(ocrKey);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
|
||||
this.loadProTuningDefaults();
|
||||
@@ -581,234 +520,15 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
this.emulatorSetupMode.set(mode);
|
||||
}
|
||||
|
||||
public setModelSetupMode(mode: 'gemini' | 'custom'): void {
|
||||
this.modelSetupMode.set(mode);
|
||||
if (mode === 'custom') {
|
||||
this.systemService.setSkipCredentialsCheck(true);
|
||||
this.systemService.fetchModelConfigEnv().subscribe();
|
||||
} else {
|
||||
this.systemService.setSkipCredentialsCheck(false);
|
||||
}
|
||||
}
|
||||
|
||||
public toggleFullConfigFile(): void {
|
||||
this.showFullConfigFile.update(v => !v);
|
||||
}
|
||||
|
||||
public toggleGeminiKeyVisibility(): void {
|
||||
this.showGeminiKey.update(v => !v);
|
||||
}
|
||||
|
||||
public toggleOcrKeyVisibility(): void {
|
||||
this.showOcrKey.update(v => !v);
|
||||
}
|
||||
|
||||
public toggleOcrConfig(): void {
|
||||
this.showOcrConfig.update(v => !v);
|
||||
}
|
||||
|
||||
public onGeminiKeyChange(val: string): void {
|
||||
this.geminiKeyInput.set(val);
|
||||
this.isGeminiKeyEdited.set(true);
|
||||
this.geminiSaveError.set(null);
|
||||
this.geminiSaveMessage.set(null);
|
||||
}
|
||||
|
||||
public onOcrKeyChange(val: string): void {
|
||||
this.ocrKeyInput.set(val);
|
||||
this.isOcrKeyEdited.set(true);
|
||||
this.ocrSaveError.set(null);
|
||||
this.ocrSaveMessage.set(null);
|
||||
}
|
||||
|
||||
public saveGeminiKey(): void {
|
||||
const key = this.geminiKeyInput().trim();
|
||||
if (!key) return;
|
||||
this.isSavingGeminiKey.set(true);
|
||||
this.geminiSaveError.set(null);
|
||||
this.geminiSaveMessage.set(null);
|
||||
|
||||
this.systemService.updateApiKey('google', key, true).subscribe({
|
||||
next: (res) => {
|
||||
this.isSavingGeminiKey.set(false);
|
||||
this.isGeminiKeyEdited.set(false);
|
||||
this.geminiSaveMessage.set(res?.message || '✓ Gemini API key verified & saved successfully.');
|
||||
setTimeout(() => this.geminiSaveMessage.set(null), 5000);
|
||||
},
|
||||
error: (err) => {
|
||||
this.isSavingGeminiKey.set(false);
|
||||
this.geminiSaveError.set(err?.error?.detail || err?.message || 'Failed to update Gemini API key.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public clearGeminiKey(): void {
|
||||
this.geminiKeyInput.set('');
|
||||
this.isGeminiKeyEdited.set(false);
|
||||
this.geminiSaveError.set(null);
|
||||
this.geminiSaveMessage.set(null);
|
||||
|
||||
if (this.savedGeminiKey().trim()) {
|
||||
this.isSavingGeminiKey.set(true);
|
||||
this.systemService.updateApiKey('google', '', true).subscribe({
|
||||
next: (res) => {
|
||||
this.isSavingGeminiKey.set(false);
|
||||
this.geminiSaveMessage.set(res?.message || '✓ Gemini API key cleared.');
|
||||
setTimeout(() => this.geminiSaveMessage.set(null), 5000);
|
||||
},
|
||||
error: (err) => {
|
||||
this.isSavingGeminiKey.set(false);
|
||||
this.geminiSaveError.set(err?.error?.detail || err?.message || 'Failed to clear Gemini API key.');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.geminiSaveMessage.set('✓ Gemini API key cleared.');
|
||||
setTimeout(() => this.geminiSaveMessage.set(null), 3000);
|
||||
}
|
||||
}
|
||||
|
||||
public saveOcrKey(): void {
|
||||
const key = this.ocrKeyInput().trim();
|
||||
if (!key) return;
|
||||
this.isSavingOcrKey.set(true);
|
||||
this.ocrSaveError.set(null);
|
||||
this.ocrSaveMessage.set(null);
|
||||
|
||||
this.systemService.updateApiKey('ocr', key, true).subscribe({
|
||||
next: (res) => {
|
||||
this.isSavingOcrKey.set(false);
|
||||
this.isOcrKeyEdited.set(false);
|
||||
this.ocrSaveMessage.set(res?.message || '✓ Vision OCR API key verified & saved.');
|
||||
setTimeout(() => this.ocrSaveMessage.set(null), 5000);
|
||||
},
|
||||
error: (err) => {
|
||||
this.isSavingOcrKey.set(false);
|
||||
this.ocrSaveError.set(err?.error?.detail || err?.message || 'Failed to update Vision OCR key.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public clearOcrKey(): void {
|
||||
this.ocrKeyInput.set('');
|
||||
this.isOcrKeyEdited.set(false);
|
||||
this.ocrSaveError.set(null);
|
||||
this.ocrSaveMessage.set(null);
|
||||
|
||||
if (this.savedOcrKey().trim()) {
|
||||
this.isSavingOcrKey.set(true);
|
||||
this.systemService.updateApiKey('ocr', '', true).subscribe({
|
||||
next: (res) => {
|
||||
this.isSavingOcrKey.set(false);
|
||||
this.ocrSaveMessage.set(res?.message || '✓ Vision OCR API key cleared.');
|
||||
setTimeout(() => this.ocrSaveMessage.set(null), 5000);
|
||||
},
|
||||
error: (err) => {
|
||||
this.isSavingOcrKey.set(false);
|
||||
this.ocrSaveError.set(err?.error?.detail || err?.message || 'Failed to clear Vision OCR key.');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.ocrSaveMessage.set('✓ Vision OCR API key cleared.');
|
||||
setTimeout(() => this.ocrSaveMessage.set(null), 3000);
|
||||
}
|
||||
}
|
||||
|
||||
public testGeminiKey(): void {
|
||||
const key = this.geminiKeyInput().trim();
|
||||
if (!key) return;
|
||||
this.isTestingGeminiKey.set(true);
|
||||
this.geminiSaveError.set(null);
|
||||
this.geminiSaveMessage.set(null);
|
||||
|
||||
this.systemService.testApiKey('google', key).subscribe({
|
||||
next: (res) => {
|
||||
this.isTestingGeminiKey.set(false);
|
||||
if (res?.valid) {
|
||||
this.geminiSaveMessage.set(res?.message || '✓ Gemini API key is valid!');
|
||||
} else {
|
||||
this.geminiSaveError.set(res?.message || 'Gemini API key verification failed.');
|
||||
}
|
||||
setTimeout(() => this.geminiSaveMessage.set(null), 5000);
|
||||
},
|
||||
error: (err) => {
|
||||
this.isTestingGeminiKey.set(false);
|
||||
this.geminiSaveError.set(err?.error?.detail || err?.message || 'Gemini API key test failed.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public testOcrKey(): void {
|
||||
const key = this.ocrKeyInput().trim();
|
||||
if (!key) return;
|
||||
this.isTestingOcrKey.set(true);
|
||||
this.ocrSaveError.set(null);
|
||||
this.ocrSaveMessage.set(null);
|
||||
|
||||
this.systemService.testApiKey('ocr', key).subscribe({
|
||||
next: (res) => {
|
||||
this.isTestingOcrKey.set(false);
|
||||
if (res?.valid) {
|
||||
this.ocrSaveMessage.set(res?.message || '✓ Vision OCR API key is valid!');
|
||||
} else {
|
||||
this.ocrSaveError.set(res?.message || 'Vision OCR API key verification failed.');
|
||||
}
|
||||
setTimeout(() => this.ocrSaveMessage.set(null), 5000);
|
||||
},
|
||||
error: (err) => {
|
||||
this.isTestingOcrKey.set(false);
|
||||
this.ocrSaveError.set(err?.error?.detail || err?.message || 'Vision OCR API key test failed.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public getProviderDisplayName(tab: string): string {
|
||||
switch (tab) {
|
||||
case 'gemini': return 'Gemini';
|
||||
case 'ocr': return 'Vision OCR';
|
||||
default: return tab;
|
||||
}
|
||||
}
|
||||
|
||||
public getProviderEnvVar(tab: string): string {
|
||||
switch (tab) {
|
||||
case 'gemini': return 'GEMINI_API_KEY';
|
||||
case 'ocr': return 'GOOGLE_VISION_API_KEY';
|
||||
default: return 'API_KEY';
|
||||
}
|
||||
}
|
||||
|
||||
public getProviderHint(tab: string): string {
|
||||
switch (tab) {
|
||||
case 'gemini':
|
||||
return 'For a quick start, Google Gemini provides a free API key. Artemis also supports other models (OpenAI, Claude, OpenRouter, etc.)—you can configure your own API keys directly in .env or your environment.';
|
||||
case 'ocr':
|
||||
return 'Google Cloud Vision API key for on-screen OCR text detection and UI grounding.';
|
||||
default:
|
||||
return 'Configure your API key or use environment definitions.';
|
||||
}
|
||||
}
|
||||
|
||||
public skipCredentialsCheck(): void {
|
||||
this.systemService.skipCredentialsCheck();
|
||||
}
|
||||
|
||||
public getApiKeyPlaceholder(tab: string): string {
|
||||
switch (tab) {
|
||||
case 'gemini': return 'Enter Gemini API Key (e.g. AIzaSy...)';
|
||||
case 'ocr': return 'Enter Vision OCR API Key (e.g. AIzaSy...)';
|
||||
default: return 'Enter API Key...';
|
||||
}
|
||||
}
|
||||
|
||||
public isTabProviderActive(tab: string): boolean {
|
||||
if (tab === 'ocr') {
|
||||
return this.isOcrConfigured();
|
||||
}
|
||||
const targetProvider = tab === 'gemini' ? 'google' : tab;
|
||||
const providers = this.configuredLlmProviders();
|
||||
return providers.some(p => p.provider === targetProvider);
|
||||
}
|
||||
|
||||
public refreshReadiness(): void {
|
||||
this.isRefreshingDiagnostics.set(true);
|
||||
this.systemService.fetchReadiness(false, true).subscribe({
|
||||
@@ -829,12 +549,12 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
this.systemService.restartAdb().subscribe({
|
||||
next: (res) => {
|
||||
this.adbRestartFeedback.set(
|
||||
res?.restart_result?.skipped ? 'Devices Refreshed ✓' : 'ADB Refreshed ✓'
|
||||
res?.restart_result?.skipped ? "设备已刷新 ✓" : "ADB 已刷新 ✓"
|
||||
);
|
||||
setTimeout(() => this.adbRestartFeedback.set(null), 2500);
|
||||
},
|
||||
error: () => {
|
||||
this.adbRestartFeedback.set('Restart Failed');
|
||||
this.adbRestartFeedback.set("重启失败");
|
||||
setTimeout(() => this.adbRestartFeedback.set(null), 3000);
|
||||
}
|
||||
});
|
||||
@@ -842,7 +562,7 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
|
||||
public connectWifiDevice(): void {
|
||||
if (this.isRemoteAdbServer()) {
|
||||
this.wifiConnectError.set('Switch to local ADB before connecting a Wireless ADB device.');
|
||||
this.wifiConnectError.set("连接无线设备前,请先切换至本地 ADB。");
|
||||
return;
|
||||
}
|
||||
const host = this.wifiHost().trim();
|
||||
@@ -850,7 +570,7 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
const port = parseInt(portStr, 10) || 5555;
|
||||
|
||||
if (!host) {
|
||||
this.wifiConnectError.set('Please enter a valid IP address.');
|
||||
this.wifiConnectError.set("请输入有效的 IP 地址。");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -863,15 +583,15 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
this.isConnectingWifi.set(false);
|
||||
const cr = res?.connect_result;
|
||||
if (cr?.success) {
|
||||
this.wifiConnectMessage.set(`Connected to ${host}:${port}!`);
|
||||
this.wifiConnectMessage.set(`已连接至 ${host}:${port}!`);
|
||||
setTimeout(() => this.wifiConnectMessage.set(null), 4000);
|
||||
} else {
|
||||
this.wifiConnectError.set(cr?.message || 'Connection failed. Please check phone IP & Wi-Fi.');
|
||||
this.wifiConnectError.set(cr?.message || "连接失败,请检查手机 IP 地址和 Wi-Fi。");
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
this.isConnectingWifi.set(false);
|
||||
this.wifiConnectError.set(err?.error?.detail || 'Failed to connect. Please check adb connection.');
|
||||
this.wifiConnectError.set(err?.error?.detail || "连接失败,请检查 ADB 连接。");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -881,11 +601,11 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
const port = Number(this.remoteAdbPort().trim());
|
||||
|
||||
if (!host) {
|
||||
this.remoteAdbError.set('Enter the host name or IP address of the ADB server.');
|
||||
this.remoteAdbError.set("请输入 ADB 服务器的主机名或 IP 地址。");
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
this.remoteAdbError.set('Enter a port between 1 and 65535.');
|
||||
this.remoteAdbError.set("请输入 1–65535 之间的端口。");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -910,7 +630,7 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
error: error => {
|
||||
this.isConnectingRemoteAdb.set(false);
|
||||
this.remoteAdbError.set(
|
||||
error?.error?.detail || 'Unable to test the ADB server endpoint.'
|
||||
error?.error?.detail || "无法测试 ADB 服务器连接。"
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -919,7 +639,7 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
public activateRemoteAdbServer(): void {
|
||||
const tested = this.remoteAdbProbeResult();
|
||||
if (!tested?.success) {
|
||||
this.remoteAdbError.set('Test the endpoint before using it.');
|
||||
this.remoteAdbError.set("请先测试服务器连接。");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -944,7 +664,7 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
error: error => {
|
||||
this.isActivatingRemoteAdb.set(false);
|
||||
this.remoteAdbError.set(
|
||||
error?.error?.detail || 'Unable to use the ADB server endpoint.'
|
||||
error?.error?.detail || "无法使用此 ADB 服务器。"
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -979,7 +699,7 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
error: error => {
|
||||
this.isSwitchingToLocalAdb.set(false);
|
||||
this.remoteAdbError.set(
|
||||
error?.error?.detail || 'Unable to switch back to the local ADB server.'
|
||||
error?.error?.detail || "无法切换回本地 ADB 服务器。"
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1069,12 +789,12 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
public runTask(): void {
|
||||
const goal = this.taskGoal().trim();
|
||||
if (!goal) {
|
||||
this.errorMessage.set('Please enter a task goal before running.');
|
||||
this.errorMessage.set("请先输入任务目标。");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isReady()) {
|
||||
this.errorMessage.set('System prerequisites are not satisfied. Please review System Setup first.');
|
||||
this.errorMessage.set("运行条件尚未满足,请先检查系统设置。");
|
||||
this.activeTab.set('diagnostics');
|
||||
return;
|
||||
}
|
||||
@@ -1089,7 +809,7 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
this.selectedProfile() === 'pro' && this.expectedOutput().trim()
|
||||
? this.expectedOutput().trim()
|
||||
: undefined,
|
||||
this.selectedProfile() === 'pro' ? this.enableOutputter() : undefined,
|
||||
undefined,
|
||||
this.selectedProfile() === 'pro'
|
||||
? { verificationLevel: this.verificationLevel().id, explorerMode: this.explorerMode().id }
|
||||
: undefined
|
||||
@@ -1103,7 +823,7 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
console.error('Failed to submit task from home page:', err);
|
||||
this.isSubmitting.set(false);
|
||||
this.errorMessage.set(
|
||||
err?.error?.detail || 'Failed to submit task. Please check server connection.'
|
||||
err?.error?.detail || "提交任务失败,请检查服务器连接。"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ limitations under the License.
|
||||
<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="Close error message">
|
||||
<button type="button" class="btn-toast-close" (click)="errorMessage.set(null)" aria-label="关闭错误消息">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -89,7 +89,7 @@ limitations under the License.
|
||||
|
||||
<!-- Dormant / Capsule Peek View (Visible when mouse is away and input idle) -->
|
||||
<div class="capsule-peek-view">
|
||||
<span class="capsule-placeholder">Start a new task...</span>
|
||||
<span class="capsule-placeholder">开始新任务…</span>
|
||||
</div>
|
||||
|
||||
<!-- Expanded Full View Content (Visible on hover, focus, or text) -->
|
||||
@@ -104,7 +104,7 @@ limitations under the License.
|
||||
(blur)="onInputBlur()"
|
||||
rows="1"
|
||||
[disabled]="isSubmitting()"
|
||||
[placeholder]="selectedProfile() === 'pro'
|
||||
[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"
|
||||
@@ -115,7 +115,7 @@ limitations under the License.
|
||||
<!-- Clear Button (visible when text is entered) -->
|
||||
@if (taskInput.trim() && !isSubmitting()) {
|
||||
<button type="button" class="dock-circle-btn btn-clear"
|
||||
(click)="clearInput($event)" title="Clear input text" aria-label="Clear input text">
|
||||
(click)="clearInput($event)" title="清空输入" aria-label="清空输入">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
}
|
||||
@@ -137,7 +137,7 @@ limitations under the License.
|
||||
[disabled]="!taskInput.trim() || isSubmitting()"
|
||||
(click)="submitTask()"
|
||||
[title]="isSubmitting() ? 'Starting task...' : 'Start task (Enter)'"
|
||||
aria-label="Start task"
|
||||
aria-label="开始任务"
|
||||
>
|
||||
@if (isSubmitting()) {
|
||||
<span class="material-symbols-outlined spinning-icon">sync</span>
|
||||
@@ -152,16 +152,16 @@ limitations under the License.
|
||||
<div class="card-footer-row">
|
||||
<div class="footer-left-group">
|
||||
<!-- Segmented Architecture Toggle (Flash / Pro) -->
|
||||
<div class="model-profile-toggle" role="group" aria-label="Select Agent Architecture">
|
||||
<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: fast single-look loop for everyday app tasks (3–5s/step)">
|
||||
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: plans first, checks results and recovers from blocked steps. For long or complex tasks (15–40s/step)">
|
||||
title="Pro:先规划、检查结果并恢复受阻步骤,适合长流程或复杂任务(每步 15–40 秒)">
|
||||
<span class="material-symbols-outlined pill-icon icon-pro">psychology</span>
|
||||
<span class="pill-label">Pro</span>
|
||||
</button>
|
||||
|
||||
@@ -112,13 +112,13 @@ export class WorkspaceComponent implements OnInit {
|
||||
const truncated = session.initial_goal.length > 45
|
||||
? session.initial_goal.substring(0, 42) + '...'
|
||||
: session.initial_goal;
|
||||
return `Stop current task: "${truncated}"`;
|
||||
return `停止当前任务:"${truncated}"`;
|
||||
}
|
||||
const curId = this.agentService.currentSessionId();
|
||||
if (curId) {
|
||||
return `Stop current task (${curId})`;
|
||||
return `停止当前任务(${curId})`;
|
||||
}
|
||||
return 'Stop current running task';
|
||||
return "停止当前任务";
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -244,7 +244,7 @@ export class WorkspaceComponent implements OnInit {
|
||||
error: (err) => {
|
||||
console.error('Failed to submit task:', err);
|
||||
this.isSubmitting.set(false);
|
||||
this.errorMessage.set(err.error?.detail || 'The runner is busy. Please wait for current task to finish.');
|
||||
this.errorMessage.set(err.error?.detail || "执行器忙碌,请等待当前任务结束。");
|
||||
setTimeout(() => {
|
||||
this.errorMessage.set(null);
|
||||
}, 5000);
|
||||
|
||||
@@ -356,8 +356,8 @@ describe('AgentService task cancellation and active session tracking', () => {
|
||||
|
||||
// 2. Completed session selected -> false
|
||||
service.rawSessions.set([
|
||||
{ session_id: 'sess-completed', status: 'completed', initial_goal: 'Done', start_time: 100 },
|
||||
{ session_id: 'sess-running', status: 'running', initial_goal: 'Running', start_time: 101 }
|
||||
{ session_id: 'sess-completed', status: 'completed', initial_goal: '完成', start_time: 100 },
|
||||
{ session_id: 'sess-running', status: 'running', initial_goal: '执行中', start_time: 101 }
|
||||
]);
|
||||
service.currentSessionId.set('sess-completed');
|
||||
expect(service.isCurrentSessionRunning()).toBeFalse();
|
||||
@@ -368,7 +368,7 @@ describe('AgentService task cancellation and active session tracking', () => {
|
||||
|
||||
// 4. Paused session selected -> true
|
||||
service.rawSessions.set([
|
||||
{ session_id: 'sess-paused', status: 'paused', initial_goal: 'Paused', start_time: 102 }
|
||||
{ session_id: 'sess-paused', status: 'paused', initial_goal: '已暂停', start_time: 102 }
|
||||
]);
|
||||
service.currentSessionId.set('sess-paused');
|
||||
expect(service.isCurrentSessionRunning()).toBeTrue();
|
||||
|
||||
@@ -425,7 +425,7 @@ export class AgentService {
|
||||
return new Observable((obs) => {
|
||||
const submittedEvent: StartupProgressEvent = {
|
||||
stage: 'submitting',
|
||||
message: 'Submitting the task',
|
||||
message: "正在提交任务",
|
||||
timestamp: Date.now() / 1000
|
||||
};
|
||||
this.pendingStartupProgress.set([submittedEvent]);
|
||||
@@ -655,7 +655,7 @@ export class AgentService {
|
||||
obs.complete();
|
||||
},
|
||||
error: (err) => {
|
||||
console.error(`Failed to delete session ${sessionId}:`, err);
|
||||
console.error(`删除会话失败:${sessionId}:`, err);
|
||||
this.fetchSessions();
|
||||
this.fetchStatus();
|
||||
obs.error(err);
|
||||
@@ -828,7 +828,7 @@ export class AgentService {
|
||||
this.activeVideoSegments.set([]);
|
||||
this.recordingPlaybackStatus.set('failed');
|
||||
this.recordingPlaybackMessage.set(
|
||||
parsedData?.error || 'Recording finalization failed.'
|
||||
parsedData?.error || "录屏收尾失败。"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -929,9 +929,9 @@ export class AgentService {
|
||||
const attempt = Number(parsedData.attempt || 0);
|
||||
const max = Number(parsedData.max_retries || 0);
|
||||
const delay = Number(parsedData.delay || 0);
|
||||
const attemptText = attempt && max ? ` (Attempt ${attempt}/${max})` : '';
|
||||
const delayText = delay > 0 ? `; retrying in ${delay.toFixed(2).replace(/\.00$/, '')}s` : '';
|
||||
this.retryMessage.set(`AI service is temporarily busy${attemptText}${delayText}...`);
|
||||
const attemptText = attempt && max ? `(第 ${attempt}/${max})` : '';
|
||||
const delayText = delay > 0 ? `;重试等待 ${delay.toFixed(2).replace(/\.00$/, '')}s` : '';
|
||||
this.retryMessage.set(`AI 服务暂时繁忙${attemptText}${delayText}...`);
|
||||
if (curId) {
|
||||
this.appendLiveLLMRetryTrace(parsedData, curId);
|
||||
}
|
||||
@@ -945,7 +945,7 @@ export class AgentService {
|
||||
if (curId) {
|
||||
this.runningSessionId.set(curId);
|
||||
this.setSessionStatus(curId, 'paused');
|
||||
const pauseError = parsedData.error || 'AI call failed';
|
||||
const pauseError = parsedData.error || "模型调用失败";
|
||||
this.pausedError.set(pauseError);
|
||||
this.appendPausedErrorCard(
|
||||
pauseError,
|
||||
@@ -1522,7 +1522,7 @@ export class AgentService {
|
||||
this.isRetrying.set(false);
|
||||
const pauseError = data.paused_error
|
||||
|| this.pausedError()
|
||||
|| 'AI model request failed. The task is paused.';
|
||||
|| "模型请求失败,任务已暂停。";
|
||||
this.pausedError.set(pauseError);
|
||||
if (data.session_id && this.currentSessionId() === data.session_id) {
|
||||
this.appendPausedErrorCard(pauseError, data.session_id);
|
||||
@@ -1563,7 +1563,7 @@ export class AgentService {
|
||||
this.lastActiveTasksSignature = activeTasksSignature;
|
||||
this.activeTasks.set(data.active_tasks || []);
|
||||
}
|
||||
|
||||
|
||||
if (oldStatus !== data.status || oldRunningSessionId !== data.session_id) {
|
||||
this.fetchSessions();
|
||||
}
|
||||
@@ -1677,8 +1677,8 @@ export class AgentService {
|
||||
attempt_id: attemptId,
|
||||
checkpoint_id: checkpointId,
|
||||
subgoal_text: String(rec.subgoal_text || (checkpointId === 'final'
|
||||
? "Final review against the user's original goal"
|
||||
: `Subgoal ${checkpointId.slice(0, 8)}`)),
|
||||
? "根据用户原始目标进行最终检查"
|
||||
: `子目标 ${checkpointId.slice(0, 8)}`)),
|
||||
anchor_step_id: rec.anchor_step_id ?? null,
|
||||
trace_id: rec.trace_id ?? null,
|
||||
status: 'done',
|
||||
@@ -1749,7 +1749,7 @@ export class AgentService {
|
||||
next: (res) => {
|
||||
if (res && res.notes) {
|
||||
this.currentNotes.set(res.notes);
|
||||
|
||||
|
||||
// Default to task_plan.md if available, otherwise first note
|
||||
const keys = Object.keys(res.notes);
|
||||
if (keys.length > 0) {
|
||||
@@ -1783,7 +1783,7 @@ export class AgentService {
|
||||
const targetSessionId = sessionId || this.currentSessionId();
|
||||
const session = this.sessions().find(s => s.session_id === targetSessionId);
|
||||
const targetUrl = videoUrl || session?.video_url || null;
|
||||
const goalTitle = title || session?.initial_goal || (targetSessionId ? `Task: ${targetSessionId.slice(0, 8)}...` : 'Screen Recording');
|
||||
const goalTitle = title || session?.initial_goal || (targetSessionId ? `任务:${targetSessionId.slice(0, 8)}...` : "屏幕录像");
|
||||
|
||||
this.activeVideoTitle.set(goalTitle);
|
||||
this.isVideoWindowOpen.set(true);
|
||||
@@ -1818,7 +1818,7 @@ export class AgentService {
|
||||
this.videoWaitStartedAt = Date.now();
|
||||
this.isVideoLoading.set(true);
|
||||
this.recordingPlaybackStatus.set('processing');
|
||||
this.recordingPlaybackMessage.set('Loading screen recording...');
|
||||
this.recordingPlaybackMessage.set("正在加载屏幕录像…");
|
||||
if (targetUrl) {
|
||||
this.playerMode?.set('video');
|
||||
} else if (this.hasCurrentSessionStepFrames?.()) {
|
||||
@@ -1870,7 +1870,7 @@ export class AgentService {
|
||||
this.shouldAutoplayVideo.set(true);
|
||||
this.isVideoLoading.set(true);
|
||||
this.recordingPlaybackStatus.set('processing');
|
||||
this.recordingPlaybackMessage.set('Finalizing screen recording...');
|
||||
this.recordingPlaybackMessage.set("正在完成录屏处理…");
|
||||
this.requestSessionVideo(sessionId, this.videoRequestGeneration);
|
||||
}
|
||||
|
||||
@@ -1901,7 +1901,7 @@ export class AgentService {
|
||||
this.activeVideoSegments.set([]);
|
||||
this.isVideoLoading.set(true);
|
||||
this.recordingPlaybackStatus.set('processing');
|
||||
this.recordingPlaybackMessage.set('Finalizing screen recording...');
|
||||
this.recordingPlaybackMessage.set("正在完成录屏处理…");
|
||||
this.scheduleVideoRetry(sessionId, generation, res.retry_after_ms);
|
||||
return;
|
||||
}
|
||||
@@ -1913,8 +1913,8 @@ export class AgentService {
|
||||
this.recordingPlaybackStatus.set(status === 'failed' ? 'failed' : 'unavailable');
|
||||
this.recordingPlaybackMessage.set(
|
||||
res.message || (status === 'failed'
|
||||
? 'Recording finalization failed.'
|
||||
: 'No screen recording is available for this task.')
|
||||
? "录屏收尾失败。"
|
||||
: "此任务暂无可用录像。")
|
||||
);
|
||||
if (this.hasCurrentSessionStepFrames?.()) {
|
||||
this.playerMode?.set('steps');
|
||||
@@ -1930,7 +1930,7 @@ export class AgentService {
|
||||
}
|
||||
this.isVideoLoading.set(false);
|
||||
this.recordingPlaybackStatus.set('failed');
|
||||
this.recordingPlaybackMessage.set('Unable to load the screen recording.');
|
||||
this.recordingPlaybackMessage.set("无法加载屏幕录像。");
|
||||
if (this.hasCurrentSessionStepFrames?.()) {
|
||||
this.playerMode?.set('steps');
|
||||
}
|
||||
@@ -1943,7 +1943,7 @@ export class AgentService {
|
||||
if (Date.now() - this.videoWaitStartedAt > 120_000) {
|
||||
this.isVideoLoading.set(false);
|
||||
this.recordingPlaybackStatus.set('failed');
|
||||
this.recordingPlaybackMessage.set('Recording finalization timed out. You can retry.');
|
||||
this.recordingPlaybackMessage.set("录屏处理超时,可以重试。");
|
||||
return;
|
||||
}
|
||||
const delay = Math.max(500, Math.min(3000, retryAfterMs));
|
||||
|
||||
@@ -56,8 +56,8 @@ export class SystemService {
|
||||
public configProbe = computed(() => this.probes().find(p => p.id === 'system_config') || null);
|
||||
public toolchainProbe = computed(() => this.probes().find(p => p.id === 'toolchain') || null);
|
||||
public adbProbe = computed(() => this.probes().find(p => p.id === 'android_adb') || null);
|
||||
public llmProbe = computed(() => this.probes().find(p => p.id === 'gemini_api_key' || p.id === 'llm_api_key') || null);
|
||||
public geminiProbe = computed(() => this.llmProbe());
|
||||
public llmProbe = computed(() => this.probes().find(p => p.id === 'openai_api_key' || p.id === 'llm_api_key') || null);
|
||||
public openaiProbe = computed(() => this.llmProbe());
|
||||
public ocrProbe = computed(() => this.probes().find(p => p.id === 'vision_ocr_key' || p.id === 'ocr_api_key') || null);
|
||||
|
||||
// Grouped readiness helpers for the 3-step onboarding flow
|
||||
@@ -324,7 +324,7 @@ export class SystemService {
|
||||
status: 'starting',
|
||||
pid: null,
|
||||
serial: null,
|
||||
stage_message: 'Spawning emulator process...',
|
||||
stage_message: "正在启动模拟器进程…",
|
||||
progress_percent: 15,
|
||||
started_at: Date.now() / 1000,
|
||||
elapsed_seconds: 0,
|
||||
@@ -346,13 +346,13 @@ export class SystemService {
|
||||
error: (err) => {
|
||||
console.error('Failed to launch emulator:', err);
|
||||
this.launchingAvd.set(null);
|
||||
const errorMsg = err?.error?.detail || err?.message || 'Failed to start emulator process.';
|
||||
const errorMsg = err?.error?.detail || err?.message || "启动模拟器进程失败。";
|
||||
this.emulatorLaunchState.set({
|
||||
avd_name: avdName,
|
||||
status: 'failed',
|
||||
pid: null,
|
||||
serial: null,
|
||||
stage_message: 'Failed to initiate launch.',
|
||||
stage_message: "启动失败。",
|
||||
progress_percent: 0,
|
||||
started_at: null,
|
||||
elapsed_seconds: 0,
|
||||
@@ -607,4 +607,3 @@ export interface ModelConfigEnvResponse {
|
||||
description: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -250,7 +250,7 @@ describe('extractStepReplayFrames', () => {
|
||||
expect(frames.length).toBe(2);
|
||||
expect(frames[0].stepNumber).toBe(1);
|
||||
expect(frames[0].action?.action).toBe('click');
|
||||
expect(frames[0].actionText).toContain('Tapping Element');
|
||||
expect(frames[0].actionText).toContain('点击元素');
|
||||
expect(frames[1].stepNumber).toBe(2);
|
||||
expect(frames[1].title).toContain('Report Task Status');
|
||||
});
|
||||
@@ -288,7 +288,7 @@ describe('extractStepReplayFrames', () => {
|
||||
step_number: 1,
|
||||
pre_image_name: 'img1',
|
||||
post_image_name: 'img2',
|
||||
action_taken: { action: 'manage_app', app_name: 'Maps' },
|
||||
action_taken: { action: 'manage_app', app_name: '地图' },
|
||||
timestamp: 100
|
||||
}
|
||||
},
|
||||
@@ -344,20 +344,20 @@ describe('extractStepReplayFrames', () => {
|
||||
// Frame 1 MUST start at Step 1, NOT Step 4!
|
||||
expect(frames[0].stepNumber).toBe(1);
|
||||
expect(frames[0].index).toBe(0);
|
||||
expect(frames[0].title).toContain('Step 1');
|
||||
expect(frames[0].title).toContain('步骤 1');
|
||||
expect(frames[0].summary).toBe('In Step 1, I launched Google Maps');
|
||||
expect(frames[0].preImageUrl).toBe('/images/img1');
|
||||
|
||||
// Frame 2 MUST be Step 2
|
||||
expect(frames[1].stepNumber).toBe(2);
|
||||
expect(frames[1].index).toBe(1);
|
||||
expect(frames[1].title).toContain('Step 2');
|
||||
expect(frames[1].title).toContain('步骤 2');
|
||||
expect(frames[1].summary).toBe('In Step 2, I typed Coffee');
|
||||
|
||||
// Frame 3 MUST be Step 3
|
||||
expect(frames[2].stepNumber).toBe(3);
|
||||
expect(frames[2].index).toBe(2);
|
||||
expect(frames[2].title).toContain('Step 3');
|
||||
expect(frames[2].title).toContain('步骤 3');
|
||||
expect(frames[2].summary).toBe('In Step 3, I pressed ENTER');
|
||||
});
|
||||
|
||||
@@ -419,9 +419,9 @@ describe('getActionTargetText self-described targets', () => {
|
||||
it('prefers observed target_text over target_description when both are present', () => {
|
||||
expect(getActionTargetText({
|
||||
action: 'click',
|
||||
target_text: 'Play',
|
||||
target_text: '播放',
|
||||
target_description: 'play button'
|
||||
})).toBe('Play');
|
||||
})).toBe('播放');
|
||||
});
|
||||
|
||||
it('chains per-point target_descriptions for click_sequence with the same arrow as coordinates', () => {
|
||||
@@ -464,7 +464,7 @@ describe('isActionFailed / getActionErrorMessage with Validator attempts', () =>
|
||||
const action = { action: 'click', coordinates: [500, 900] };
|
||||
|
||||
it('treats a retry that ended in Dispatched as not failed', () => {
|
||||
const stepData = stepWithAttempts(['Execution error: x', 'Dispatched']);
|
||||
const stepData = stepWithAttempts(['Execution error: x', '已发送']);
|
||||
expect(isActionFailed(action, stepData)).toBeFalse();
|
||||
});
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ export function isAndroidAction(action: any): boolean {
|
||||
name = (act.name || act.action || '').toLowerCase();
|
||||
}
|
||||
return [
|
||||
'tap', 'click', 'click_sequence', 'input', 'input_text', 'swipe', 'scroll', 'press_key',
|
||||
'tap', 'click', 'click_sequence', 'input', 'input_text', 'swipe', 'scroll', 'press_key',
|
||||
'press_home', 'press_back', 'launch_app', 'manage_app', 'open_app',
|
||||
'focus_and_input_text', 'focus_and_clear_text', 'long_press', 'long_press_on',
|
||||
'wait_for_delay', 'wait', 'drag', 'drag_and_drop'
|
||||
@@ -165,60 +165,60 @@ export function getActionIcon(action: any): string {
|
||||
*/
|
||||
export function getActionTitle(action: any): string {
|
||||
const act = getActionObject(action);
|
||||
if (!act) return 'Action';
|
||||
if (!act) return "操作";
|
||||
const name = (act.name || act.action || '').toLowerCase();
|
||||
switch (name) {
|
||||
case 'tap':
|
||||
case 'click':
|
||||
case 'tap_element':
|
||||
case 'click_element':
|
||||
return 'Tapping Element';
|
||||
return "点击元素";
|
||||
case 'input':
|
||||
case 'input_text':
|
||||
case 'focus_and_input_text':
|
||||
return 'Entering Text';
|
||||
return "输入文本";
|
||||
case 'focus_and_clear_text':
|
||||
case 'clear_text':
|
||||
return 'Clearing Text';
|
||||
return "清空文本";
|
||||
case 'swipe':
|
||||
case 'scroll': {
|
||||
const actObj = getActionObject(action);
|
||||
const args = actObj?.args && typeof actObj.args === 'object' ? actObj.args : {};
|
||||
const dir = actObj?.direction || actObj?.gesture || args.direction || args.gesture || (typeof args.action === 'string' ? args.action : '') || (typeof actObj?.action === 'string' && actObj.action !== name ? actObj.action : '');
|
||||
if (dir && isPureDirectionString(dir)) {
|
||||
return `Swiping Screen (${String(dir).toUpperCase()})`;
|
||||
return `滑动屏幕(${String(dir).toUpperCase()})`;
|
||||
}
|
||||
return 'Swiping Screen';
|
||||
return "滑动屏幕";
|
||||
}
|
||||
case 'drag':
|
||||
case 'drag_and_drop':
|
||||
return 'Dragging Screen';
|
||||
return "拖动屏幕";
|
||||
case 'press_key':
|
||||
case 'press_home':
|
||||
case 'press_back':
|
||||
return 'Pressing Hardware Key';
|
||||
return "按下实体按键";
|
||||
case 'launch_app':
|
||||
case 'open_app':
|
||||
return 'Launching Application';
|
||||
return "启动应用";
|
||||
case 'stop_app':
|
||||
case 'close_app':
|
||||
return 'Stopping Application';
|
||||
return "停止应用";
|
||||
case 'manage_app': {
|
||||
const actObj = getActionObject(action);
|
||||
const actStr = (actObj?.action || actObj?.args?.action || '').toLowerCase();
|
||||
if (actStr === 'launch') return 'Launching Application';
|
||||
if (actStr === 'stop' || actStr === 'close') return 'Stopping Application';
|
||||
return 'Managing Application';
|
||||
if (actStr === 'launch') return "启动应用";
|
||||
if (actStr === 'stop' || actStr === 'close') return "停止应用";
|
||||
return "管理应用";
|
||||
}
|
||||
case 'wait_for_delay':
|
||||
case 'delay':
|
||||
case 'wait':
|
||||
return 'Waiting for Delay';
|
||||
return "等待";
|
||||
case 'long_press':
|
||||
case 'long_press_on':
|
||||
return 'Long Pressing Element';
|
||||
return "长按元素";
|
||||
case 'click_sequence':
|
||||
return 'Clicking Sequence';
|
||||
return "连续点击";
|
||||
default:
|
||||
return name.replace(/_/g, ' ').replace(/\b\w/g, (c: string) => c.toUpperCase());
|
||||
}
|
||||
@@ -264,27 +264,27 @@ export function getActionTargetText(action: any): string {
|
||||
*/
|
||||
export function getActionInputLabel(action: any): string {
|
||||
const act = getActionObject(action);
|
||||
if (!act) return 'Input';
|
||||
if (!act) return "输入";
|
||||
const name = (act.name || act.action || '').toLowerCase();
|
||||
if (name.includes('delay') || name.includes('wait')) {
|
||||
return 'Duration';
|
||||
return "耗时";
|
||||
}
|
||||
if (name === 'swipe' || name === 'scroll' || name === 'drag' || name === 'drag_and_drop') {
|
||||
const actObj = getActionObject(action);
|
||||
const args = actObj?.args && typeof actObj.args === 'object' ? actObj.args : {};
|
||||
const dir = actObj?.direction || actObj?.gesture || args.direction || args.gesture || (typeof args.action === 'string' ? args.action : '') || (typeof actObj?.action === 'string' && actObj.action !== name ? actObj.action : '');
|
||||
if (dir && isPureDirectionString(dir)) {
|
||||
return 'Direction';
|
||||
return "方向";
|
||||
}
|
||||
return 'Input';
|
||||
return "输入";
|
||||
}
|
||||
if (name === 'press_key' || name.includes('key')) {
|
||||
return 'Key';
|
||||
return "按键";
|
||||
}
|
||||
if (name === 'input_text' || name.includes('input')) {
|
||||
return 'Input Text';
|
||||
return "输入文本";
|
||||
}
|
||||
return 'Input';
|
||||
return "输入";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -376,12 +376,12 @@ export function getActionCoords(action: any): string {
|
||||
*/
|
||||
function attemptsFailed(attempts: any): boolean {
|
||||
if (!Array.isArray(attempts) || attempts.length === 0) return false;
|
||||
return String(attempts[attempts.length - 1]) !== 'Dispatched';
|
||||
return String(attempts[attempts.length - 1]) !== "已发送";
|
||||
}
|
||||
|
||||
function lastFailedAttempt(attempts: any): string | null {
|
||||
if (!Array.isArray(attempts)) return null;
|
||||
const failing = attempts.filter((a: any) => String(a) !== 'Dispatched');
|
||||
const failing = attempts.filter((a: any) => String(a) !== "已发送");
|
||||
return failing.length > 0 ? String(failing[failing.length - 1]) : null;
|
||||
}
|
||||
|
||||
@@ -450,7 +450,7 @@ export function getActionErrorMessage(action: any, stepData?: any): string {
|
||||
}
|
||||
}
|
||||
}
|
||||
return 'Action Failed';
|
||||
return "操作失败";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -531,7 +531,7 @@ export function extractActionExtraParams(action: any, cache?: WeakMap<any, Actio
|
||||
// Explicitly check duration if available and not already formatted
|
||||
const dur = mergedObj.duration || mergedObj.duration_ms;
|
||||
if (dur !== undefined && dur !== null && dur !== '') {
|
||||
result.push({ key: 'Duration', value: typeof dur === 'number' ? `${dur}ms` : String(dur) });
|
||||
result.push({ key: "耗时", value: typeof dur === 'number' ? `${dur}ms` : String(dur) });
|
||||
}
|
||||
|
||||
for (const [k, v] of Object.entries(mergedObj)) {
|
||||
@@ -547,7 +547,7 @@ export function extractActionExtraParams(action: any, cache?: WeakMap<any, Actio
|
||||
}
|
||||
|
||||
let prettyKey = k.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
if (lowerK === 'time_in_ms' || lowerK === 'delay_ms') prettyKey = 'Delay';
|
||||
if (lowerK === 'time_in_ms' || lowerK === 'delay_ms') prettyKey = "延迟";
|
||||
|
||||
result.push({ key: prettyKey, value: valStr });
|
||||
}
|
||||
@@ -981,7 +981,7 @@ export function extractStepReplayFrames(logsOrSteps: any[]): StepReplayFrame[] {
|
||||
for (let i = 0; i < visualCandidates.length; i++) {
|
||||
const { stepData, act, preUrl, postUrl, primaryImg } = visualCandidates[i];
|
||||
const stepNum = i + 1; // 1-indexed sequential frame number (1, 2, 3...)
|
||||
const title = act ? (getActionTitle(act) || 'Action') : `Step ${stepNum}`;
|
||||
const title = act ? (getActionTitle(act) || "操作") : `步骤 ${stepNum}`;
|
||||
const coords = act ? getActionCoords(act) : '';
|
||||
const targetText = act ? getActionTargetText(act) : '';
|
||||
const actionDesc = coords ? `${title} (${coords})` : (targetText ? `${title} (${targetText})` : title);
|
||||
@@ -995,7 +995,7 @@ export function extractStepReplayFrames(logsOrSteps: any[]): StepReplayFrame[] {
|
||||
stepNumber: stepNum,
|
||||
rawStepNumber: rawStepNum,
|
||||
stepId: String(stepData.step_id || `step-${stepNum}`),
|
||||
title: `Step ${stepNum}: ${title}`,
|
||||
title: `步骤 ${stepNum}: ${title}`,
|
||||
actionText: actionDesc,
|
||||
action: act,
|
||||
actionType: act?.action || act?.name || 'action',
|
||||
|
||||
@@ -228,9 +228,9 @@ export function drawActionCoordinatesOnOverlay(
|
||||
// 2. Check separate start and end coordinate objects
|
||||
const normStart = extractNumbersFromCoordinateValue(act.normalized_start_coordinates || args.normalized_start_coordinates);
|
||||
const normEnd = extractNumbersFromCoordinateValue(act.normalized_end_coordinates || args.normalized_end_coordinates);
|
||||
const startCoords = normStart || extractNumbersFromCoordinateValue(act.start_coordinates || act.start_point || act.start || act.from ||
|
||||
const startCoords = normStart || extractNumbersFromCoordinateValue(act.start_coordinates || act.start_point || act.start || act.from ||
|
||||
args.start_coordinates || args.start_point || args.start || args.from);
|
||||
const endCoords = normEnd || extractNumbersFromCoordinateValue(act.end_coordinates || act.end_point || act.end || act.to ||
|
||||
const endCoords = normEnd || extractNumbersFromCoordinateValue(act.end_coordinates || act.end_point || act.end || act.to ||
|
||||
args.end_coordinates || args.end_point || args.end || args.to);
|
||||
|
||||
if (startCoords && endCoords && startCoords.length >= 2 && endCoords.length >= 2) {
|
||||
@@ -320,12 +320,12 @@ export function drawActionCoordinatesOnOverlay(
|
||||
* Helper to create and position an animated red dot point marker with edge-aware label
|
||||
*/
|
||||
export function createPointMarker(
|
||||
x: number,
|
||||
y: number,
|
||||
actionType: string,
|
||||
naturalWidth: number,
|
||||
naturalHeight: number,
|
||||
overlay: HTMLElement,
|
||||
x: number,
|
||||
y: number,
|
||||
actionType: string,
|
||||
naturalWidth: number,
|
||||
naturalHeight: number,
|
||||
overlay: HTMLElement,
|
||||
isNormalized: boolean = false,
|
||||
sequenceIndex?: number,
|
||||
totalSequence?: number
|
||||
@@ -387,7 +387,7 @@ export function createPointMarker(
|
||||
|
||||
const label = document.createElement('div');
|
||||
if (sequenceIndex !== undefined) {
|
||||
label.innerText = totalSequence && totalSequence > 1 ? `#${sequenceIndex} TAP` : actionType.toUpperCase();
|
||||
label.innerText = totalSequence && totalSequence > 1 ? `#${sequenceIndex} 点击` : actionType.toUpperCase();
|
||||
} else {
|
||||
label.innerText = actionType.toUpperCase();
|
||||
}
|
||||
@@ -527,13 +527,13 @@ export function createSequenceConnector(
|
||||
* Helper to create and position an SVG arrow, high-contrast dashed line, and badge for swipes/drags
|
||||
*/
|
||||
export function createLineMarker(
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
naturalWidth: number,
|
||||
naturalHeight: number,
|
||||
overlay: HTMLElement,
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
naturalWidth: number,
|
||||
naturalHeight: number,
|
||||
overlay: HTMLElement,
|
||||
isNormalized: boolean = false,
|
||||
actionType: string = 'swipe'
|
||||
): void {
|
||||
|
||||
@@ -199,7 +199,7 @@ export function extractCheckerResult(text: string): CheckerResult | null {
|
||||
if (parsed && typeof parsed === 'object' && 'success' in parsed) {
|
||||
return {
|
||||
success: Boolean(parsed.success),
|
||||
reason: parsed.reason || 'No reason provided.'
|
||||
reason: parsed.reason || "未提供原因。"
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -62,10 +62,10 @@ describe('run-info.util', () => {
|
||||
|
||||
describe('tuningLabel', () => {
|
||||
it('maps ids to the launcher labels and defaults unknown ids', () => {
|
||||
expect(tuningLabel('verify', 'checkpoints')).toBe('Every step');
|
||||
expect(tuningLabel('verify', 'nonsense')).toBe('At the end');
|
||||
expect(tuningLabel('explore', 'ultra')).toBe('Close-up');
|
||||
expect(tuningLabel('explore', null)).toBe('Quick glance');
|
||||
expect(tuningLabel('verify', 'checkpoints')).toBe('逐步检查');
|
||||
expect(tuningLabel('verify', 'nonsense')).toBe('结束时检查');
|
||||
expect(tuningLabel('explore', 'ultra')).toBe('精细识别');
|
||||
expect(tuningLabel('explore', null)).toBe('快速识别');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -507,10 +507,10 @@ describe('stream aggregator single source of truth (Option A)', () => {
|
||||
it('does not duplicate action_taken when generic_tools already contains the action trace', () => {
|
||||
const events = getSortedStepEvents({
|
||||
timestamp: 100,
|
||||
action_taken: [{ action: 'launch_app', app_name: 'Maps', timestamp: 102 }],
|
||||
action_taken: [{ action: 'launch_app', app_name: '地图', timestamp: 102 }],
|
||||
generic_tools: [
|
||||
{ trace_id: 'tool-note', type: 'tool', name: 'update_note', timestamp: 101 },
|
||||
{ trace_id: 'act-launch', type: 'action', name: 'launch_app', timestamp: 102, payload: { args: { action: { action: 'launch_app', app_name: 'Maps' } } } }
|
||||
{ trace_id: 'act-launch', type: 'action', name: 'launch_app', timestamp: 102, payload: { args: { action: { action: 'launch_app', app_name: '地图' } } } }
|
||||
]
|
||||
});
|
||||
|
||||
@@ -565,7 +565,7 @@ describe('stream aggregator single source of truth (Option A)', () => {
|
||||
{ trace_id: 't-adb-1-sub', parent_trace_id: 't-adb-1', name: 'run_adb_command', type: 'tool', timestamp: 1788457631.5, status: 'success' },
|
||||
{ trace_id: 't-adb-2', parent_trace_id: 't-op', name: 'run_adb_command', type: 'tool', timestamp: 1788457632, status: 'success', payload: { args: { CommandLine: 'am start -a android.intent.action.SENDTO ...' } } },
|
||||
{ trace_id: 't-adb-2-sub', parent_trace_id: 't-adb-2', name: 'run_adb_command', type: 'tool', timestamp: 1788457632.5, status: 'success' },
|
||||
{ trace_id: 't-exp', parent_trace_id: 't-op', name: 'ask_explorer', type: 'tool', timestamp: 1788457633, status: 'success', payload: { args: { query: 'any text on screen' } } },
|
||||
{ trace_id: 't-exp', parent_trace_id: 't-op', name: 'ask_explorer', type: 'tool', timestamp: 1788457633, status: 'success', payload: { args: { query: 'any text 在屏幕上' } } },
|
||||
{ trace_id: 't-exp-sub', parent_trace_id: 't-exp', name: 'ask_explorer', type: 'tool', timestamp: 1788457633.5, status: 'success' },
|
||||
{ trace_id: 't-wait', parent_trace_id: 't-val', name: 'wait_for_delay', type: 'action', timestamp: 1788457635, status: 'success', payload: { args: { action: { action: 'wait_for_delay', time_in_ms: 1000 } } } },
|
||||
{ trace_id: 't-safety', parent_trace_id: 't-wait', name: 'safety_net_pixel_validation', type: 'tool', timestamp: 1788457636, status: 'success' }
|
||||
@@ -597,7 +597,7 @@ describe('stream aggregator single source of truth (Option A)', () => {
|
||||
text: 'Here is the aborted plan...',
|
||||
isCompleted: false,
|
||||
isReset: true,
|
||||
resetMessage: 'A request error occurred during output generation, typically caused by lower API priority. Retrying automatically...'
|
||||
resetMessage: '生成过程中请求出错,正在自动重试…'
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -605,7 +605,7 @@ describe('stream aggregator single source of truth (Option A)', () => {
|
||||
const initialBlocks = consolidateLogsToBlocks(resetLogs);
|
||||
expect(initialBlocks.length).toBe(1);
|
||||
expect(initialBlocks[0].data.isReset).toBeTrue();
|
||||
expect(initialBlocks[0].data.resetMessage).toBe('A request error occurred during output generation, typically caused by lower API priority. Retrying automatically...');
|
||||
expect(initialBlocks[0].data.resetMessage).toBe('生成过程中请求出错,正在自动重试…');
|
||||
|
||||
// getSortedStepEvents retains the reset state and message
|
||||
const resetEvents = getSortedStepEvents(initialBlocks[0].data);
|
||||
@@ -637,4 +637,3 @@ describe('stream aggregator single source of truth (Option A)', () => {
|
||||
expect(updatedBlocks[0].data.isCompleted).toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -750,13 +750,13 @@ export function formatTokenCount(tokens?: number): string {
|
||||
export function groupBlocksToPhases(blocks: StepBlock[], sessionStartTime: number): PhaseBlock[] {
|
||||
if (!blocks || blocks.length === 0) return [];
|
||||
const phases: PhaseBlock[] = [];
|
||||
|
||||
|
||||
// Base start timestamp in ms
|
||||
let previousEndTime = sessionStartTime ? sessionStartTime * 1000 : 0;
|
||||
|
||||
blocks.forEach((block, index) => {
|
||||
const blockTime = block.timestamp ? new Date(block.timestamp).getTime() : Date.now();
|
||||
|
||||
|
||||
if (previousEndTime === 0 && blockTime > 0) {
|
||||
previousEndTime = blockTime;
|
||||
}
|
||||
@@ -791,7 +791,7 @@ export function groupBlocksToPhases(blocks: StepBlock[], sessionStartTime: numbe
|
||||
* Returns all actions and tools inside a step card, sorted in true chronological sequence by timestamp
|
||||
*/
|
||||
export function getSortedStepEvents(
|
||||
stepData: any,
|
||||
stepData: any,
|
||||
cache?: WeakMap<any, { signature: string; events: StepEvent[] }>
|
||||
): StepEvent[] {
|
||||
if (!stepData || typeof stepData !== 'object') return [];
|
||||
@@ -940,20 +940,20 @@ export function getSortedStepEvents(
|
||||
* Compile human-readable session summary from logs
|
||||
*/
|
||||
export function compileSessionSummary(logs: any[]): string {
|
||||
if (!logs || logs.length === 0) return 'No logs available.';
|
||||
if (!logs || logs.length === 0) return "暂无日志。";
|
||||
|
||||
// Check for goal completed / checker response / cancellation / report_task_status
|
||||
for (let i = logs.length - 1; i >= 0; i--) {
|
||||
const log = logs[i];
|
||||
if (log.type === 'session_ended') {
|
||||
if (log.data?.status === 'cancelled' || log.data?.was_stopped_manually) {
|
||||
return 'Task stopped manually.';
|
||||
return "任务已手动停止。";
|
||||
}
|
||||
}
|
||||
if (log.type === 'llm_stream' && log.data?.text) {
|
||||
const text = log.data.text;
|
||||
if (text.includes('```json') && text.includes('"success"')) {
|
||||
return 'Execution completed and verified by checker.';
|
||||
return "任务已执行完成,并通过检查。";
|
||||
}
|
||||
}
|
||||
if (log.type === 'step_recorded' || log.type === 'step_updated') {
|
||||
@@ -963,12 +963,12 @@ export function compileSessionSummary(logs: any[]): string {
|
||||
if (exp) return exp;
|
||||
}
|
||||
if (log.data?.status === 'completed') {
|
||||
return log.data.message || 'Task completed successfully.';
|
||||
return log.data.message || "任务已成功完成。";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 'Execution session in progress or ended.';
|
||||
return "会话正在执行或已结束。";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -986,8 +986,8 @@ export function computeSessionDuration(sessionStartTime: number, logs: any[]): n
|
||||
* Check if the planning loader should be visible (while running, waiting for next step/action and not actively typing a stream)
|
||||
*/
|
||||
export function checkPlanningLoader(
|
||||
logs: any[],
|
||||
isRunning: boolean,
|
||||
logs: any[],
|
||||
isRunning: boolean,
|
||||
isCurrentRunningSession: boolean = true
|
||||
): boolean {
|
||||
if (!isRunning || !isCurrentRunningSession) return false;
|
||||
|
||||
@@ -84,10 +84,10 @@ describe('compress_history timeline line', () => {
|
||||
describe('phase (args.phase from the backend)', () => {
|
||||
it('reads the declared phase and maps it to one short plain-language label', () => {
|
||||
const cases: Array<[string, string, string]> = [
|
||||
['running', 'summarizing', 'Summarizing this stretch'],
|
||||
['running', 'ready', 'Summary ready; kept in reserve until the context fills up'],
|
||||
['success', 'applied', 'Replaced this stretch with its summary'],
|
||||
['failed', 'failed', 'Summary failed; full record kept']
|
||||
['running', 'summarizing', '正在总结此段记录'],
|
||||
['running', 'ready', '摘要已就绪,上下文接近容量上限时替换原文'],
|
||||
['success', 'applied', '已使用摘要替换此段记录'],
|
||||
['failed', 'failed', '摘要失败,保留完整记录']
|
||||
];
|
||||
for (const [status, phase, label] of cases) {
|
||||
const tool = trace(status, { start_step: 1, end_step: 4, phase });
|
||||
@@ -129,7 +129,7 @@ describe('cleanErrorMessage', () => {
|
||||
});
|
||||
|
||||
it('does not present an empty LLM wrapper label as an error reason', () => {
|
||||
expect(cleanErrorMessage('LLM Error:')).toBe('Unknown error');
|
||||
expect(cleanErrorMessage('LLM Error:')).toBe('未知错误');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -222,7 +222,7 @@ describe('video analysis timeline formatting', () => {
|
||||
|
||||
expect(view?.outcome).toBe('complete');
|
||||
expect(view?.reuse).toBe('full');
|
||||
expect(view?.title).toBe('Reused video analysis');
|
||||
expect(view?.title).toBe('已复用视频分析');
|
||||
expect(view?.requestedRange).toEqual({ start: 0, end: 42 });
|
||||
});
|
||||
|
||||
@@ -259,7 +259,7 @@ describe('video analysis timeline formatting', () => {
|
||||
expect(result[0].trace_id).toBe('video-analysis-video-agent-1');
|
||||
expect(result[0].payload.result.outcome).toBe('partial');
|
||||
expect(result[0].payload.result.requested_range).toEqual({ start: 0, end: 60 });
|
||||
expect(getVideoAnalysisView(result[0])?.title).toBe('Video analysis partially completed');
|
||||
expect(getVideoAnalysisView(result[0])?.title).toBe('视频分析部分完成');
|
||||
});
|
||||
|
||||
it('keeps video analyses from independent parent executions separate', () => {
|
||||
@@ -333,8 +333,8 @@ describe('getToolTargetText self-described targets', () => {
|
||||
it('prefers observed target_text over target_description', () => {
|
||||
expect(getToolTargetText({
|
||||
name: 'click',
|
||||
payload: { args: { target_text: 'Play', target_description: 'play button' } }
|
||||
})).toBe('Play');
|
||||
payload: { args: { target_text: '播放', target_description: 'play button' } }
|
||||
})).toBe('播放');
|
||||
});
|
||||
|
||||
it('chains click_sequence target_descriptions with the coordinate arrow', () => {
|
||||
|
||||
@@ -138,7 +138,7 @@ function parseVideoResultText(text: string): Partial<VideoAnalysisView> {
|
||||
return { outcome: 'failed', summary: value };
|
||||
}
|
||||
if (value.includes('Analysis is already in progress in another video agent')) {
|
||||
return { outcome: 'waiting', summary: 'Another video agent is already analyzing this evidence.' };
|
||||
return { outcome: 'waiting', summary: "另一个视频智能体正在分析此段内容。" };
|
||||
}
|
||||
return { outcome: 'complete', summary: value };
|
||||
}
|
||||
@@ -175,14 +175,14 @@ export function getVideoAnalysisView(tool: any): VideoAnalysisView | null {
|
||||
const completedCount = Number(structured.completed_count ?? completedRanges.length ?? 0);
|
||||
const totalCount = Number(structured.total_count ?? (completedRanges.length + failedRanges.length));
|
||||
const titleByOutcome: Record<VideoAnalysisOutcome, string> = {
|
||||
running: 'Analyzing screen recording',
|
||||
recovering: 'Analyzing unfinished recording segment',
|
||||
waiting: 'Waiting for existing video analysis',
|
||||
running: "正在分析屏幕录像",
|
||||
recovering: "正在分析尚未结束的录像片段",
|
||||
waiting: "正在等待已有的视频分析",
|
||||
complete: structured.reuse === 'full' || parsed.reuse === 'full'
|
||||
? 'Reused video analysis'
|
||||
: 'Analyzed screen recording',
|
||||
partial: 'Video analysis partially completed',
|
||||
failed: 'Video analysis returned no result'
|
||||
? "已复用视频分析"
|
||||
: "已分析屏幕录像",
|
||||
partial: "视频分析部分完成",
|
||||
failed: "视频分析未返回结果"
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -236,7 +236,7 @@ export function getVideoToolTarget(tool: any): string | null {
|
||||
* Canonical list of mobile device automation and ADB command tool names.
|
||||
*/
|
||||
export const DEVICE_ACTION_TOOL_NAMES = new Set([
|
||||
'click', 'click_sequence', 'long_press', 'long_press_on', 'input', 'input_text',
|
||||
'click', 'click_sequence', 'long_press', 'long_press_on', 'input', 'input_text',
|
||||
'swipe', 'scroll', 'press_key', 'press_home', 'press_back', 'launch_app', 'manage_app', 'open_app',
|
||||
'focus_and_input_text', 'focus_and_clear_text', 'tap', 'wait_for_delay', 'wait',
|
||||
'run_adb_command', 'run_short_adb_command'
|
||||
@@ -558,9 +558,9 @@ export function getToolDisplayLabel(tool: any, isFirstSaveNote: boolean = false)
|
||||
case 'manage_app':
|
||||
case 'launch_app': {
|
||||
const rawApp = args.app_name || args.package_name || args.app || '';
|
||||
const app = rawApp ? (rawApp.charAt(0).toUpperCase() + rawApp.slice(1)) : 'Application';
|
||||
const app = rawApp ? (rawApp.charAt(0).toUpperCase() + rawApp.slice(1)) : "应用";
|
||||
const rawAction = args.action ? String(args.action).toLowerCase() : '';
|
||||
const verb = rawAction === 'launch' ? 'Launching' : (rawAction === 'stop' || rawAction === 'close' ? 'Stopping' : 'Managing');
|
||||
const verb = rawAction === 'launch' ? "启动中" : (rawAction === 'stop' || rawAction === 'close' ? "停止中" : "管理中");
|
||||
return `${verb} "${app}"`;
|
||||
}
|
||||
|
||||
@@ -568,100 +568,100 @@ export function getToolDisplayLabel(tool: any, isFirstSaveNote: boolean = false)
|
||||
case 'wait_delay':
|
||||
case 'wait': {
|
||||
const delay = args.delay_seconds || args.seconds || args.delay || args.duration;
|
||||
return delay ? `Waiting for ${delay} second${Number(delay) > 1 ? 's' : ''}...` : 'Waiting for delay...';
|
||||
return delay ? `等待 ${delay} second${Number(delay) > 1 ? 's' : ''}...` : "正在等待…";
|
||||
}
|
||||
case 'wait_for_text': {
|
||||
const text = args.text || args.target_text || '';
|
||||
return text ? `Waiting for text "${text}" to appear on screen` : 'Waiting for text on screen';
|
||||
return text ? `等待文本 "${text}" 出现在屏幕上` : "正在等待屏幕出现文本";
|
||||
}
|
||||
|
||||
case 'input_text':
|
||||
case 'input': {
|
||||
const text = args.text || args.input_text || '';
|
||||
return text ? `Entering text "${text}" into field` : 'Entering text into input field';
|
||||
return text ? `输入文本 "${text}" 至输入框` : "正在向输入框输入文本";
|
||||
}
|
||||
case 'focus_and_input_text': {
|
||||
const text = args.text || args.input_text || '';
|
||||
return text ? `Focusing field and typing "${text}"` : 'Focusing field and entering text';
|
||||
return text ? `聚焦输入框并输入 "${text}"` : "正在聚焦输入框并输入文本";
|
||||
}
|
||||
case 'focus_and_clear_text':
|
||||
return 'Focusing and clearing field text';
|
||||
return "正在聚焦输入框并清空文本";
|
||||
|
||||
case 'click':
|
||||
case 'tap': {
|
||||
const target = args.target_text || args.text || args.query || '';
|
||||
return target ? `Tapping on "${target}"` : 'Tapping on screen element';
|
||||
return target ? `点击 "${target}"` : "正在点击屏幕元素";
|
||||
}
|
||||
case 'click_sequence':
|
||||
return 'Executing click sequence';
|
||||
return "正在执行连续点击";
|
||||
case 'long_press': {
|
||||
const target = args.target_text || args.text || '';
|
||||
return target ? `Long pressing on "${target}"` : 'Long pressing screen element';
|
||||
return target ? `长按 "${target}"` : "正在长按屏幕元素";
|
||||
}
|
||||
case 'swipe': {
|
||||
const dir = args.action || args.direction || '';
|
||||
return dir ? `Swiping ${String(dir).toUpperCase()} on screen` : 'Swiping screen';
|
||||
return dir ? `滑动 ${String(dir).toUpperCase()} 在屏幕上` : "正在滑动屏幕";
|
||||
}
|
||||
case 'press_key': {
|
||||
const key = args.key || args.keycode || '';
|
||||
return key ? `Pressing key ${String(key).toUpperCase()}` : 'Pressing hardware key';
|
||||
return key ? `按键 ${String(key).toUpperCase()}` : "正在按下实体按键";
|
||||
}
|
||||
|
||||
case 'save_note':
|
||||
return isFirstSaveNote ? 'Creating note' : 'Saving note';
|
||||
return isFirstSaveNote ? "正在创建笔记" : "正在保存笔记";
|
||||
case 'read_note':
|
||||
return 'Reading note';
|
||||
return "正在读取笔记";
|
||||
case 'list_notes':
|
||||
return 'Browsing all saved notes';
|
||||
return "正在浏览已保存笔记";
|
||||
case 'update_note':
|
||||
return 'Updating note';
|
||||
return "正在更新笔记";
|
||||
case 'append_note':
|
||||
return 'Updating note';
|
||||
return "正在更新笔记";
|
||||
|
||||
case 'object_detection': {
|
||||
const q = Array.isArray(args.queries) ? args.queries.join(', ') : (args.queries || '');
|
||||
return q ? `Locating on screen: "${q}"` : 'Locating elements on screen';
|
||||
return q ? `Locating 在屏幕上: "${q}"` : "正在定位屏幕元素";
|
||||
}
|
||||
case 'ask_explorer': {
|
||||
const query = args.query || args.prompt || '';
|
||||
return query ? `Searching on screen: "${query}"` : 'Searching on screen';
|
||||
return query ? `Searching 在屏幕上: "${query}"` : "正在搜索屏幕";
|
||||
}
|
||||
case 'report_failure_analysis': {
|
||||
const reason = args.reason || args.analysis || '';
|
||||
return reason ? `Investigating issue: ${reason}` : 'Investigating execution issue';
|
||||
return reason ? `排查问题:${reason}` : "正在排查执行问题";
|
||||
}
|
||||
case 'run_adb_command':
|
||||
case 'run_short_adb_command': {
|
||||
const cmd = args.command || args.cmd || '';
|
||||
return cmd ? `Running command: ${cmd}` : 'Running system command';
|
||||
return cmd ? `运行命令:${cmd}` : "正在运行系统命令";
|
||||
}
|
||||
case 'search_logs':
|
||||
case 'read_logs': {
|
||||
const q = args.query || args.filter || '';
|
||||
return q ? `Searching logs for "${q}"` : 'Analyzing system logs';
|
||||
return q ? `搜索日志:"${q}"` : "正在分析系统日志";
|
||||
}
|
||||
case 'log_analyzer':
|
||||
case 'output_analyzer':
|
||||
return 'Analyzing logs';
|
||||
return "正在分析日志";
|
||||
case 'diagnoser':
|
||||
case 'diagnose':
|
||||
return 'Diagnosing issue';
|
||||
return "正在诊断问题";
|
||||
case 'video_analyzer':
|
||||
case 'video_analyzer_pure':
|
||||
return 'Analyzing screen recording';
|
||||
return "正在分析屏幕录像";
|
||||
case 'extract_segment_metadata': {
|
||||
const start = args.start_time !== undefined ? `${args.start_time}s` : '';
|
||||
const end = args.end_time !== undefined ? `${args.end_time}s` : '';
|
||||
const range = (start && end) ? ` (${start} - ${end})` : (start ? ` (from ${start})` : '');
|
||||
return `Cropping screen recording segment${range}`;
|
||||
return `截取录屏片段${range}`;
|
||||
}
|
||||
case 'spawn_sub_agent': {
|
||||
const q = args.specific_query || args.query || args.prompt || '';
|
||||
return q ? `Analyzing recording with sub-agent: "${q}"` : 'Analyzing recording with sub-agent';
|
||||
return q ? `分析录屏:"${q}"` : "正在调用子智能体分析录像";
|
||||
}
|
||||
case 'analyze_audio_only': {
|
||||
const q = args.specific_query || args.query || '';
|
||||
return q ? `Analyzing audio track: "${q}"` : 'Analyzing recording audio track';
|
||||
return q ? `Analyzing audio track: "${q}"` : "正在分析录像音轨";
|
||||
}
|
||||
case 'search_history': {
|
||||
const q = args.query || '';
|
||||
@@ -676,30 +676,30 @@ export function getToolDisplayLabel(tool: any, isFirstSaveNote: boolean = false)
|
||||
if (n !== undefined && n !== '' && end !== undefined && end !== null && end !== '' && String(end) !== String(n)) {
|
||||
return `Reviewing steps ${n}–${end}`;
|
||||
}
|
||||
return n !== undefined && n !== '' ? `Reviewing step ${n}` : 'Reviewing step details';
|
||||
return n !== undefined && n !== '' ? `Reviewing step ${n}` : "正在查看步骤详情";
|
||||
}
|
||||
case 'get_step_screenshot': {
|
||||
const n = args.step_number;
|
||||
const variant = String(args.which || '').toLowerCase();
|
||||
if (variant === 'overlay') {
|
||||
return n !== undefined && n !== '' ? `Looking at where step ${n}'s action landed` : 'Looking at where an action landed';
|
||||
return n !== undefined && n !== '' ? `Looking at where step ${n}'s action landed` : "正在查看操作位置";
|
||||
}
|
||||
const which = variant === 'post' ? 'after' : 'before';
|
||||
return n !== undefined && n !== '' ? `Looking at the screen ${which} step ${n}` : 'Looking at a step screenshot';
|
||||
return n !== undefined && n !== '' ? `Looking at the screen ${which} step ${n}` : "正在查看步骤截图";
|
||||
}
|
||||
case 'probe_device': {
|
||||
const kind = args.kind ? String(args.kind).replace(/_/g, ' ') : '';
|
||||
return kind ? `Reading ${kind} from the device` : 'Reading device state';
|
||||
return kind ? `Reading ${kind} from the device` : "正在读取设备状态";
|
||||
}
|
||||
case 'outputter':
|
||||
case 'output_synthesis':
|
||||
return 'Synthesizing output report';
|
||||
return "正在生成输出报告";
|
||||
case 'web_search': {
|
||||
const q = args.query || '';
|
||||
return q ? `Searching web for "${q}"` : 'Searching the web';
|
||||
return q ? `Searching web for "${q}"` : "正在搜索网页";
|
||||
}
|
||||
case 'read_url':
|
||||
return 'Fetching web page';
|
||||
return "正在读取网页";
|
||||
case 'compress_history':
|
||||
return getCompressionLabel(tool);
|
||||
|
||||
@@ -723,7 +723,7 @@ export function getCompressionLabel(tool: any): string {
|
||||
const hasRange = Number.isFinite(start) && Number.isFinite(end) && start > 0 && end > 0;
|
||||
const range = hasRange
|
||||
? (start === end ? `step ${start}` : `steps ${start}–${end}`)
|
||||
: 'earlier steps';
|
||||
: "之前的步骤";
|
||||
const rangeCapitalized = range.charAt(0).toUpperCase() + range.slice(1);
|
||||
const status = String(tool?.status || '').toLowerCase();
|
||||
const phase = getCompressionPhase(tool);
|
||||
@@ -802,13 +802,13 @@ export function getCompressionPhase(tool: any): CompressionPhase {
|
||||
export function getCompressionPhaseLabel(tool: any): string {
|
||||
switch (getCompressionPhase(tool)) {
|
||||
case 'ready':
|
||||
return 'Summary ready; kept in reserve until the context fills up';
|
||||
return "摘要已就绪,上下文接近容量上限时替换原文";
|
||||
case 'applied':
|
||||
return 'Replaced this stretch with its summary';
|
||||
return "已使用摘要替换此段记录";
|
||||
case 'failed':
|
||||
return 'Summary failed; full record kept';
|
||||
return "摘要失败,保留完整记录";
|
||||
default:
|
||||
return 'Summarizing this stretch';
|
||||
return "正在总结此段记录";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -898,76 +898,76 @@ export function getToolIcon(tool: any): string {
|
||||
* Get formatted title for a tool call card
|
||||
*/
|
||||
export function getToolTitle(tool: any): string {
|
||||
if (!tool || !tool.name) return 'Tool Call';
|
||||
if (!tool || !tool.name) return "工具调用";
|
||||
const cleanName = tool.name.replace(/^(_)?exec_/, '');
|
||||
const name = cleanName.toLowerCase();
|
||||
switch (name) {
|
||||
case 'click':
|
||||
case 'tap':
|
||||
return 'Tapping Element';
|
||||
return "点击元素";
|
||||
case 'click_sequence':
|
||||
return 'Executing Click Sequence';
|
||||
return "执行连续点击";
|
||||
case 'long_press':
|
||||
return 'Long Pressing Element';
|
||||
return "长按元素";
|
||||
case 'input_text':
|
||||
case 'input':
|
||||
return 'Entering Text';
|
||||
return "输入文本";
|
||||
case 'swipe':
|
||||
case 'scroll': {
|
||||
const args = getToolArgs(tool);
|
||||
const dir = args.direction || args.gesture || (typeof args.action === 'string' ? args.action : '');
|
||||
if (dir && isPureDirectionString(dir)) return `Swiping Screen (${String(dir).toUpperCase()})`;
|
||||
return 'Swiping Screen';
|
||||
if (dir && isPureDirectionString(dir)) return `滑动屏幕(${String(dir).toUpperCase()})`;
|
||||
return "滑动屏幕";
|
||||
}
|
||||
case 'drag':
|
||||
case 'drag_and_drop':
|
||||
return 'Dragging Screen';
|
||||
return "拖动屏幕";
|
||||
case 'press_key':
|
||||
return 'Pressing Hardware Key';
|
||||
return "按下实体按键";
|
||||
case 'manage_app':
|
||||
case 'launch_app': {
|
||||
const args = getToolArgs(tool);
|
||||
const rawAction = args.action ? String(args.action).toLowerCase() : '';
|
||||
if (rawAction === 'launch') return 'Launching Application';
|
||||
if (rawAction === 'stop' || rawAction === 'close') return 'Stopping Application';
|
||||
return 'Managing Application';
|
||||
if (rawAction === 'launch') return "启动应用";
|
||||
if (rawAction === 'stop' || rawAction === 'close') return "停止应用";
|
||||
return "管理应用";
|
||||
}
|
||||
case 'wait_for_delay':
|
||||
case 'wait_delay':
|
||||
return 'Waiting for Delay';
|
||||
return "等待";
|
||||
case 'wait_for_text':
|
||||
return 'Waiting for Text';
|
||||
return "等待文本";
|
||||
case 'object_detection':
|
||||
return 'Locating Elements';
|
||||
return "定位元素";
|
||||
case 'ask_explorer':
|
||||
return 'Searching on Screen';
|
||||
return "搜索屏幕";
|
||||
case 'report_failure_analysis':
|
||||
return 'Investigating Issue';
|
||||
return "排查问题";
|
||||
case 'run_adb_command':
|
||||
case 'run_short_adb_command':
|
||||
return 'Running System Command';
|
||||
return "运行系统命令";
|
||||
case 'web_search':
|
||||
return 'Web Search';
|
||||
return "网页搜索";
|
||||
case 'read_url':
|
||||
return 'Fetching Web Page';
|
||||
return "读取网页";
|
||||
case 'search_logs':
|
||||
case 'read_logs':
|
||||
return 'Searching Logs';
|
||||
return "搜索日志";
|
||||
case 'log_analyzer':
|
||||
case 'output_analyzer':
|
||||
return 'Analyzing Logs';
|
||||
return "分析日志";
|
||||
case 'diagnoser':
|
||||
case 'diagnose':
|
||||
return 'Diagnosing Issue';
|
||||
return "诊断问题";
|
||||
case 'video_analyzer':
|
||||
case 'video_analyzer_pure':
|
||||
return 'Analyzing Screen Recording';
|
||||
return "分析屏幕录像";
|
||||
case 'extract_segment_metadata':
|
||||
return 'Cropping Screen Recording';
|
||||
return "裁剪屏幕录像";
|
||||
case 'spawn_sub_agent':
|
||||
return 'Delegating Video Analysis';
|
||||
return "委派视频分析";
|
||||
case 'analyze_audio_only':
|
||||
return 'Analyzing Audio Track';
|
||||
return "分析音轨";
|
||||
default:
|
||||
return cleanName.replace(/_/g, ' ').replace(/\b\w/g, (c: string) => c.toUpperCase());
|
||||
}
|
||||
@@ -1046,26 +1046,26 @@ export function getToolTargetText(tool: any): string {
|
||||
* Get input label for tools
|
||||
*/
|
||||
export function getToolInputLabel(tool: any): string {
|
||||
if (!tool || !tool.name) return 'Input';
|
||||
if (!tool || !tool.name) return "输入";
|
||||
const name = tool.name.toLowerCase().replace(/^(_)?exec_/, '');
|
||||
if (name === 'wait_for_delay' || name === 'wait_delay' || name === 'delay' || name === 'wait') {
|
||||
return 'Duration';
|
||||
return "耗时";
|
||||
}
|
||||
if (name === 'swipe' || name === 'scroll' || name === 'drag' || name === 'drag_and_drop') {
|
||||
const args = getToolArgs(tool);
|
||||
const dir = args.direction || args.gesture || (typeof args.action === 'string' ? args.action : '');
|
||||
if (dir && isPureDirectionString(dir)) {
|
||||
return 'Direction';
|
||||
return "方向";
|
||||
}
|
||||
return 'Input';
|
||||
return "输入";
|
||||
}
|
||||
if (name === 'press_key' || name === 'press_home' || name === 'press_back') {
|
||||
return 'Key';
|
||||
return "按键";
|
||||
}
|
||||
if (name === 'input_text' || name === 'input') {
|
||||
return 'Input Text';
|
||||
return "输入文本";
|
||||
}
|
||||
return 'Input';
|
||||
return "输入";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1220,14 +1220,14 @@ export function isToolFailed(tool: any): boolean {
|
||||
* Get error message for a failed tool
|
||||
*/
|
||||
export function getToolErrorMessage(tool: any): string {
|
||||
if (!tool) return 'Tool Failed';
|
||||
if (!tool) return "工具失败";
|
||||
const args = getToolArgs(tool);
|
||||
if (args.status === 'cannot_fix') return 'Status: cannot_fix';
|
||||
if (args.error || args.message || args.failure_reason) {
|
||||
return args.error || args.message || args.failure_reason;
|
||||
}
|
||||
if (tool.error || tool.message) return tool.error || tool.message;
|
||||
return 'Action Failed';
|
||||
return "操作失败";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1286,7 +1286,7 @@ export function extractToolExtraParams(toolData: any, cache?: WeakMap<any, Actio
|
||||
}
|
||||
|
||||
let prettyKey = k.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
if (lowerK === 'time_in_ms' || lowerK === 'delay_ms') prettyKey = 'Delay';
|
||||
if (lowerK === 'time_in_ms' || lowerK === 'delay_ms') prettyKey = "延迟";
|
||||
|
||||
result.push({ key: prettyKey, value: valStr });
|
||||
}
|
||||
@@ -1363,7 +1363,7 @@ export function cleanErrorMessage(rawError: any): string {
|
||||
}
|
||||
|
||||
const errorStr = String(rawError).trim();
|
||||
if (!errorStr) return 'Unknown error';
|
||||
if (!errorStr) return "未知错误";
|
||||
|
||||
// 1. Try regex extraction for "message": "..."
|
||||
const doubleQuoteMsgMatch = errorStr.match(/"message"\s*:\s*"((?:[^"\\]|\\.)*)"/i);
|
||||
@@ -1415,5 +1415,5 @@ export function cleanErrorMessage(rawError: any): string {
|
||||
.replace(/^(?:LLM\s+(?:Request\s+)?Error\s*:\s*)+/i, '')
|
||||
.trim();
|
||||
|
||||
return fallback || 'Unknown error';
|
||||
return fallback || "未知错误";
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
running: '执行中', queued: '排队中', pending: '等待中', completed: '已完成',
|
||||
success: '成功', succeeded: '成功', failed: '失败', error: '错误', paused: '已暂停',
|
||||
cancelled: '已取消', canceled: '已取消', stopped: '已停止', unknown: '未知',
|
||||
idle: '空闲', starting: '启动中', launching: '启动中', ready: '就绪',
|
||||
pass: '通过', fail: '失败', warn: '警告', warning: '警告', blocked: '受阻',
|
||||
done: '完成', active: '当前', waiting: '等待中', processing: '处理中',
|
||||
};
|
||||
|
||||
@Pipe({ name: 'uiLabel', standalone: true })
|
||||
export class UiLabelPipe implements PipeTransform {
|
||||
transform(value: string | null | undefined): string {
|
||||
return LABELS[(value || '').toLowerCase()] || value || '';
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user