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
@@ -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>
|
||||
`,
|
||||
|
||||
Reference in new issue
Block a user