From a2235270617e3bd5bbe6ca508e4bf43c02f574ac Mon Sep 17 00:00:00 2001 From: wicm84266964 Date: Mon, 14 Sep 2026 09:55:27 +0800 Subject: [PATCH 1/2] release: v2.0.12 Dashboard interaction and send stability Keep late turn responses from rewriting the current session or draft. Queue vs interrupt follows composer content. Session retention no longer blocks first paint or a new send. Long session resume reads the archive tail; turn start waits 60s. TUI approval Tab stays in-panel. Gateway 429 honors Retry-After. --- CHANGELOG.md | 11 ++ README.md | 2 +- ...dashboard-interaction-send-stability_zh.md | 42 +++++ npm-shrinkwrap.json | 4 +- package-lock.json | 4 +- package.json | 2 +- src/cli/tui/activity.ts | 4 +- src/cli/tui/app-actions.ts | 4 +- src/cli/tui/app-keys.ts | 34 ++-- src/cli/tui/components.ts | 13 +- src/cli/tui/format.ts | 53 +++--- src/cli/tui/input-handlers.ts | 8 +- src/cli/tui/inspector.ts | 3 +- src/commands/registry.ts | 16 +- src/core/session-resume.ts | 61 +++++-- src/dashboard/events.ts | 43 ++--- src/dashboard/public/app-core.ts | 1 + src/dashboard/public/app-ui1.ts | 27 ++- src/dashboard/public/app-ui2.ts | 47 +++++- src/dashboard/public/app-ui4.ts | 15 +- src/dashboard/public/app-ui8.ts | 18 +- src/dashboard/public/app.js | 154 +++++++++++++++--- src/dashboard/public/index.html | 6 +- src/dashboard/public/styles.css | 25 ++- src/dashboard/runtime/api-sessions.ts | 3 +- src/dashboard/runtime/api-settings.ts | 3 +- src/dashboard/sessions.ts | 12 +- src/model-gateway/client.ts | 53 ++++-- src/model-gateway/health.ts | 47 +++++- src/storage/session-store.ts | 30 +++- src/tools/labels.ts | 55 +++++++ src/version.ts | 17 +- tests/browser/dashboard-browser.test.ts | 6 +- tests/unit/commands.test.ts | 7 +- tests/unit/dashboard-runtime/turn.ts | 2 +- tests/unit/dashboard-server.test.ts | 9 + tests/unit/dashboard-status-retention.test.ts | 114 +++++++++++++ tests/unit/dashboard-ui.test.ts | 20 ++- tests/unit/gateway-health.test.ts | 34 ++-- tests/unit/session-resume.test.ts | 53 ++++++ tests/unit/storage.test.ts | 51 ++++++ tests/unit/tui-approval-keys.test.ts | 19 +++ tests/unit/tui-format.test.ts | 8 +- 43 files changed, 929 insertions(+), 211 deletions(-) create mode 100644 docs/releases/2.0.12-dashboard-interaction-send-stability_zh.md create mode 100644 src/tools/labels.ts create mode 100644 tests/unit/dashboard-status-retention.test.ts create mode 100644 tests/unit/session-resume.test.ts create mode 100644 tests/unit/tui-approval-keys.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 843c4d6..7936428 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## Unreleased +## 2.0.12 - 2026-09-14 + +Candidate small release for Dashboard send stability and interaction correctness. GitHub CI, tag and Release status will be recorded after publication. + +- A successful `/api/turns` response no longer rewrites the session or draft after the user has already switched away. +- While a turn is running, the main button queues when the composer has text or attachments, and interrupts only when it is empty. +- Session retention cleanup no longer blocks first paint or a new Dashboard send. Long session resume reads from the tail of the archive; turn start waits up to 60 seconds instead of 15. +- Approval Tab stays inside the panel in the TUI. Gateway 429 responses honor `Retry-After` up to 30 seconds. + +Validation, upgrade steps and limitations: [Chinese release note](docs/releases/2.0.12-dashboard-interaction-send-stability_zh.md). + ## 2.0.11 - 2026-09-10 Published 2026-09-10 09:14:36 UTC (2026-09-10 17:14:36 UTC+08:00): [v2.0.11](https://github.com/wicm84266964/Ant-Code/releases/tag/v2.0.11). diff --git a/README.md b/README.md index e5ee59a..f877b61 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ ant-code doctor ant-code ``` -`ant-code --version` should print `2.0.11`. If you previously linked a +`ant-code --version` should print `2.0.12`. If you previously linked a JavaScript install, run `npm link` again so the global command points at `src/cli/index.ts`. diff --git a/docs/releases/2.0.12-dashboard-interaction-send-stability_zh.md b/docs/releases/2.0.12-dashboard-interaction-send-stability_zh.md new file mode 100644 index 0000000..2bcd491 --- /dev/null +++ b/docs/releases/2.0.12-dashboard-interaction-send-stability_zh.md @@ -0,0 +1,42 @@ +# Ant Code v2.0.12:Dashboard 交互收口与发送稳定 + +发布日期:2026-09-14 + +这是 2.0 TypeScript 运行时上的小版本。Dashboard 日常发送不再被会话清理或超长历史恢复卡住,迟到的提交也不会改写已经切走的会话和草稿。同时收口运行中主按钮、审批按键和网关 429 等待。权限模式 id 不变。本说明处于公开发布候选阶段,不表示 CI、tag 或 GitHub Release 已经完成。 + +## 对用户工作流的直接变化 + +- Dashboard 提交 `/api/turns` 成功后,如果已经切到另一个会话,界面不再被拉回去,也不会清掉等待期间改过的输入。 +- 任务运行中:输入框或附件有内容时,主按钮是「加入队列」;空输入时才是「中断」。 +- 刚打开 Dashboard 或会话很多时,首屏和会话列表不再等待过期会话清理。新建任务也不再和清理抢同一把锁,发送可以马上打到网关。 +- 续跑很长的历史会话时,恢复上下文改为从尾部按预算读取;发送 `/api/turns` 等到 60 秒。不再因为本机恢复超过 15 秒就显示「请求超时」、请求其实还没发出。 +- 权限确认标题使用中文工具名。PDF 页范围在回形针上可见。窄屏空状态改为「文件在「文件」页」。 +- TUI 审批或需求核对打开时,Tab / Shift+Tab 只在该面板内移动,不再先切侧栏或权限档。`/keybindings` 与真实按键一致。 +- 网关返回 429 时优先尊重 `Retry-After`,上限仍 30 秒。live health 允许两次探测并限制错误体大小。 + +## 配置、数据与安全边界 + +- 不要求手动迁移配置或历史会话。已有网关、权限和 `.lab-agent` 会话可继续使用。 +- 过期会话清理策略不变:默认仍按保留天数在后台清理;保存「保留天数」仍会立即清理。清理不会删除当前内存里正在用的会话。 +- 本次不更新其他产品的内嵌运行时,不同步真实会话、凭据或研究数据。自动化测试不等于所有外部模型供应商或每份私人研究数据都已验证。 + +## 升级与产物位置 + +需要 Node.js 22.18+。从源码更新: + +```sh +git pull +npm ci +npm run verify:install +npm link +ant-code --version +``` + +`ant-code --version` 应显示 `2.0.12`。若以前 link 过旧安装,需要重新 `npm link`。正在跑的 Dashboard 需要重启并硬刷新浏览器。现有网关配置和会话不必重建。 + +## 验证、限制与待完成门禁 + +- 开发阶段已覆盖迟到提交不改写当前会话、运行中入队/中断、会话清理不挡首屏和新建任务、超长会话恢复裁剪、发送超时、审批 Tab、网关 429 与 health 探测;用户已完成本地 Dashboard 验收,包括长任务交互。 +- 开发仓完整 `npm run check` 通过:单元/集成 1,342 项、浏览器 38 项,均无失败或跳过;语法、类型检查 0 诊断、依赖、来源和资源一致性检查通过。跨仓一致性检查和正式仓复验在脱敏同步后进行。 +- 候选提交后仍须 GitHub PR 和 main CI 通过,随后创建标签与 Release;实际完成状态记录在公开 CHANGELOG。 +- 未对每个外部模型供应商做真实账户验收。超长会话恢复仍受本机磁盘和上下文预算限制;网关 503 等上游不可用不是本版本修复范围。 diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 6863d8b..86b0722 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1,12 +1,12 @@ { "name": "@ant-code/cli", - "version": "2.0.11", + "version": "2.0.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ant-code/cli", - "version": "2.0.11", + "version": "2.0.12", "license": "AGPL-3.0-only", "dependencies": { "@napi-rs/canvas": "1.0.9", diff --git a/package-lock.json b/package-lock.json index 6863d8b..86b0722 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ant-code/cli", - "version": "2.0.11", + "version": "2.0.12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ant-code/cli", - "version": "2.0.11", + "version": "2.0.12", "license": "AGPL-3.0-only", "dependencies": { "@napi-rs/canvas": "1.0.9", diff --git a/package.json b/package.json index 4a824fc..34b4dfe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ant-code/cli", - "version": "2.0.11", + "version": "2.0.12", "description": "AGPL-licensed local coding agent with a terminal UI, local dashboard, tool permissions, skills, MCP integration, and model gateway adapters.", "type": "module", "private": true, diff --git a/src/cli/tui/activity.ts b/src/cli/tui/activity.ts index 731cdbf..b93a657 100644 --- a/src/cli/tui/activity.ts +++ b/src/cli/tui/activity.ts @@ -97,14 +97,14 @@ export function updateActivity(current: TuiActivity, event: TuiRuntimeEvent): Tu return { ...current, status: event.interrupted ? "工具已中断" : event.ok ? "工具完成" : event.blocked ? "工具被阻止" : "工具失败", - lastTool: `${event.name} ${event.interrupted ? "interrupted" : event.ok ? "done" : event.blocked ? "blocked" : "failed"}`, + lastTool: `${event.name} ${event.interrupted ? "已中断" : event.ok ? "完成" : event.blocked ? "被阻止" : "失败"}`, toolCount: current.toolCount + 1, blockedTools: current.blockedTools + (event.blocked ? 1 : 0), failedTools: current.failedTools + (!event.ok && !event.blocked && !event.interrupted ? 1 : 0) }; } if (event.type === "review_gate") { - return { ...current, status: "等待复核", lastTool: "review gate" }; + return { ...current, status: "等待复核", lastTool: "复核门" }; } if (event.type === "assistant_final") { return { ...current, status: "就绪", assistantBytes: event.outputBytes ?? current.assistantBytes }; diff --git a/src/cli/tui/app-actions.ts b/src/cli/tui/app-actions.ts index 4ed53b2..15a8675 100644 --- a/src/cli/tui/app-actions.ts +++ b/src/cli/tui/app-actions.ts @@ -811,9 +811,9 @@ export function useTuiAppActions(s: ReturnType) { `详情:/agents task ${taskId}` ].join("\n"); if (stateRef.current.entries?.some((entry) => entry.id === entryId)) { - updateEntryById(entryId, { kind: "agent", title: "子任务已启动", body, taskId, profile, taskStatus: "running" }); + updateEntryById(entryId, { kind: "agent", title: "子任务后台运行中", body, taskId, profile, taskStatus: "running" }); } else { - addEntry("agent", "子任务已启动", body, { id: entryId, taskId, profile, taskStatus: "running" }); + addEntry("agent", "子任务后台运行中", body, { id: entryId, taskId, profile, taskStatus: "running" }); } setSideView("tasks"); setSideOffset(0); diff --git a/src/cli/tui/app-keys.ts b/src/cli/tui/app-keys.ts index c534cdf..127b611 100644 --- a/src/cli/tui/app-keys.ts +++ b/src/cli/tui/app-keys.ts @@ -755,9 +755,15 @@ export function useTuiAppKeys(s: ReturnType) { return; } if (isCtrlKey(inputValue, key, "l")) { - setEntries([]); + setEntries([withEntryIdentity({ + kind: "session", + title: "已清屏", + body: "只隐藏当前可见记录,会话文件未删除。", + at: new Date().toLocaleTimeString() + })]); setTranscriptOffset(0); setStreamOffset(0); + setActivity((value) => ({ ...value, status: "已清屏" })); return; } const canScrollConversation = current.mode === "input" @@ -833,19 +839,6 @@ export function useTuiAppKeys(s: ReturnType) { setHistoryIndex(null); return; } - if (key.shift && key.tab) { - cyclePermissionMode(current, "ink-shift-tab"); - return; - } - if (key.tab) { - setSideView((value) => { - const next = nextSideView(value); - setActivity((currentActivity) => ({ ...currentActivity, status: `panel ${next}` })); - return next; - }); - setSidePanelOffset(0); - return; - } if (current.mode === "approval") { handleApprovalInput(inputValue, key, current, sessionApprovals.current, addEntry, setActivity, setMode, setPendingApproval, setApprovalChoiceIndex); return; @@ -861,6 +854,19 @@ export function useTuiAppKeys(s: ReturnType) { }); return; } + if (key.shift && key.tab) { + cyclePermissionMode(current, "ink-shift-tab"); + return; + } + if (key.tab) { + setSideView((value) => { + const next = nextSideView(value); + setActivity((currentActivity) => ({ ...currentActivity, status: `panel ${next}` })); + return next; + }); + setSidePanelOffset(0); + return; + } if (current.modelPickerOpen) { if (key.escape) { setModelPickerOpen(false); diff --git a/src/cli/tui/components.ts b/src/cli/tui/components.ts index 8cff004..c2f2051 100644 --- a/src/cli/tui/components.ts +++ b/src/cli/tui/components.ts @@ -5,6 +5,7 @@ import type { LabModel } from "../../model-gateway/models.ts"; import { listKeybindings } from "../../commands/registry.ts"; import { buildTaskTree } from "../../agents/orchestrator.ts"; import { summarizeContextWindow } from "../../core/context-window.ts"; +import { getAntCodeVersionSync } from "../../version.ts"; import { formatTuiGoalFooter } from "./goal.ts"; import { DEFAULT_TUI_THEME, themeColor } from "./theme.ts"; import { @@ -365,8 +366,8 @@ const EMPTY_ACTIVITY: TuiActivity = { export function StatusBar({ session, cwd = "", activity, pulse = 0, detailMode = "compact", width = 100, theme = DEFAULT_TUI_THEME }: TuiViewProps & { session: AgentSession }) { const gateway = session.config.lab.gatewayUrl - ? `${session.config.lab.gatewayProtocol ?? "openai-chat"} configured` - : "gateway missing"; + ? `${session.config.lab.gatewayProtocol ?? "openai-chat"} 已配置` + : "网关未配置"; const context = summarizeContextWindow(session); const inputBrief = Number.isFinite(context.promptTokens) ? ` input ${formatTokenCount(context.promptTokens)}` @@ -381,7 +382,7 @@ export function StatusBar({ session, cwd = "", activity, pulse = 0, detailMode = : `${spinnerFrame(pulse)} `; const cwdWidth = Math.min(44, Math.max(18, Math.floor(width * 0.35))); const statusWidth = Math.max(24, (width ?? 100) - cwdWidth - 12); - const statusText = truncateMiddle(` v1.1 - ${activityPrefix}${activityStatus} - ${session.model} - ${contextBrief} - ${gateway} - ${permissionModeLabel(session)} - ${session.networkMode} - ${detailModeLabel(detailMode)} - events=${activity?.eventCount ?? 0}`, statusWidth); + const statusText = truncateMiddle(` v${getAntCodeVersionSync()} - ${activityPrefix}${activityStatus} - ${session.model} - ${contextBrief} - ${gateway} - ${permissionModeLabel(session)} - ${session.networkMode} - ${detailModeLabel(detailMode)} - 事件=${activity?.eventCount ?? 0}`, statusWidth); return h(Box, { paddingX: 1, justifyContent: "space-between" }, h(Text, null, h(Text, { color: themeColor(theme, "identity", "cyan"), bold: true }, "Ant Code"), @@ -427,8 +428,8 @@ export function LogPane({ entries, width, height, stream, pulse = 0, detailMode ...Array.from({ length: scrollbackMode ? 0 : Math.max(0, viewportRows - viewport.lines.length) }, () => line("")) ]; const counter = viewport.totalRows === 0 - ? "0 rows" - : `${viewport.firstRow}-${viewport.lastRow}/${viewport.totalRows}${viewport.offset > 0 ? ` +${viewport.offset}` : scrollbackMode ? " scrollback" : " bottom"}`; + ? "0 行" + : `${viewport.firstRow}-${viewport.lastRow}/${viewport.totalRows}${viewport.offset > 0 ? ` +${viewport.offset}` : scrollbackMode ? " 回看" : " 底部"}`; const readingHistory = viewport.offset > 0; const selectionRange = transcriptSelection ? { @@ -1238,7 +1239,7 @@ function statusMark(status: string) { export function startupEntry(session: AgentSession) { return { kind: "startup", - title: "Ant Code TUI v1.1", + title: `Ant Code TUI v${getAntCodeVersionSync()}`, body: startupBannerLines(session).join("\n"), at: new Date().toLocaleTimeString() }; diff --git a/src/cli/tui/format.ts b/src/cli/tui/format.ts index 4623d47..ac62c7a 100644 --- a/src/cli/tui/format.ts +++ b/src/cli/tui/format.ts @@ -1,4 +1,5 @@ import type { AgentSession } from "../../core/session.ts"; +import { toolLabel } from "../../tools/labels.ts"; import { composerSegments, displayWidth } from "./input-editor.ts"; import { parseMarkdownBlocks, renderTable } from "./markdown-table.ts"; export { approvalKeyFor } from "../../permissions/approval-keys.ts"; @@ -629,7 +630,7 @@ export function promptLines( if (mode === "approval") { return [ { text: "权限弹窗已打开" }, - { text: " 用方向键/Tab 后按 Enter,或 Y 允许一次、A 本会话允许、N 拒绝、Esc 取消。", dim: true } + { text: " 用方向键/Tab 后按 Enter,或 Y 允许一次、A 本会话允许、N 拒绝。Esc 取消并拒绝本次请求。", dim: true } ]; } if (mode === "question") { @@ -802,7 +803,7 @@ export function permissionModalLines( const sensitive = request.decision?.sensitive === true; return [ line("需要权限", false, "yellow"), - line(`${riskBadge(risk)} ${toolName}`, false, riskColor(risk)), + line(`${riskBadge(risk)} ${toolLabel(toolName)}`, false, riskColor(risk)), line(reason, true), ...(sensitive ? [ @@ -1046,11 +1047,11 @@ function compactUserPromptLines(body: string | undefined, detailMode: string): T * @param {{ title?: string; body?: string }} entry */ export function toolCardLines(entry: { title?: string; body?: string } = {}, detailMode: string = "compact"): TuiLine[] { - const title = entry.title ?? "tool"; + const title = entry.title ?? "工具"; const state = inferToolState(title, entry.body); - const name = title.replace(/\s+(running|done|blocked|failed)$/i, ""); + const name = title.replace(/\s+(running|done|blocked|failed|interrupted|运行中|完成|被阻止|失败|已中断)$/i, ""); return [ - line(`${toolStatusMarker(state)} ${toolClassLabel(name)} - ${title}`, false, toolStateColor(state)), + line(`${toolStatusMarker(state)} ${toolClassLabel(name)} - ${toolLabel(name)} ${toolRuntimeStateLabel(state)}`, false, toolStateColor(state)), ...foldBodyLines(splitFoldableLines(entry.body ?? ""), detailMode, { compact: 2, detailed: 8 }).map((item) => line(` ${item.text}`, true)) ]; } @@ -1116,54 +1117,62 @@ function foldBodyLines(lines: string[], detailMode: string, limits: FoldLimits): function inferToolState(title: string, body: string | undefined): string { const value = `${title} ${body ?? ""}`.toLowerCase(); - if (value.includes("blocked")) { + if (value.includes("blocked") || value.includes("阻止")) { return "blocked"; } - if (value.includes("failed") || value.includes("error=")) { + if (value.includes("failed") || value.includes("失败") || value.includes("error=")) { return "failed"; } - if (value.includes("cancelled") || value.includes("interrupted")) { + if (value.includes("cancelled") || value.includes("interrupted") || value.includes("中断")) { return "interrupted"; } - if (value.includes("done") || value.includes("approved")) { + if (value.includes("done") || value.includes("approved") || value.includes("完成")) { return "done"; } return "running"; } function toolClassLabel(name: string) { - if (["read_file", "list_files", "glob", "grep", "git_status", "git_diff"].includes(name)) { - return "read"; + if (["read_file", "list_files", "glob", "grep", "rg_search", "rg_files", "rg_files_with_matches", "rg_count", "git_status", "git_diff", "git_log", "git_show"].includes(name)) { + return "读取"; } if (["write_file", "edit_file", "todo_write", "plan_update"].includes(name)) { - return "edit"; + return "写入"; } if (["powershell", "bash"].includes(name)) { - return "shell"; + return "命令"; } - if (name === "mcp_call") { - return "mcp"; + if (name === "mcp_call" || name === "mcp_list") { + return "MCP"; } if (name === "ask_user") { - return "ask"; + return "询问"; } - return "tool"; + return "工具"; +} + +function toolRuntimeStateLabel(state: string): string { + if (state === "done") return "完成"; + if (state === "blocked") return "被阻止"; + if (state === "failed") return "失败"; + if (state === "interrupted") return "已中断"; + return "运行中"; } function toolStatusMarker(state: string): string { if (state === "done") { - return "[ok]"; + return "[完成]"; } if (state === "blocked") { - return "[blocked]"; + return "[阻止]"; } if (state === "failed") { - return "[failed]"; + return "[失败]"; } if (state === "interrupted") { - return "[stopped]"; + return "[中断]"; } - return "[run]"; + return "[运行]"; } function toolStateColor(state: string): string { diff --git a/src/cli/tui/input-handlers.ts b/src/cli/tui/input-handlers.ts index 7fbef04..6e9805a 100644 --- a/src/cli/tui/input-handlers.ts +++ b/src/cli/tui/input-handlers.ts @@ -36,7 +36,7 @@ export function handleApprovalInput( if (!current.pendingApproval) { return; } - if (key.leftArrow || key.upArrow) { + if (key.leftArrow || key.upArrow || (key.tab && key.shift)) { setApprovalChoiceIndex((value) => (value + APPROVAL_CHOICES.length - 1) % APPROVAL_CHOICES.length); return; } @@ -128,6 +128,12 @@ export function handleQuestionInput(inputValue: unknown, key: InkKey, current: T setPendingQuestion((pending: TuiUiState["pendingQuestion"]) => pending ? { ...pending, focusedIndex: nextIndex } : pending); return; } + if (hasChoices && key.tab) { + const delta = key.shift ? -1 : 1; + const nextIndex = (prompt.focusedIndex + delta + prompt.choices.length) % prompt.choices.length; + setPendingQuestion((pending: TuiUiState["pendingQuestion"]) => pending ? { ...pending, focusedIndex: nextIndex } : pending); + return; + } if (hasChoices && inputValue === " " && (!prompt.allowCustom || current.questionBuffer.length === 0)) { if (prompt.multiple) { const selected = new Set(prompt.selectedIndices); diff --git a/src/cli/tui/inspector.ts b/src/cli/tui/inspector.ts index f5a42f3..8e635d5 100644 --- a/src/cli/tui/inspector.ts +++ b/src/cli/tui/inspector.ts @@ -1,4 +1,5 @@ import type { AgentSession } from "../../core/session.ts"; +import { getAntCodeVersionSync } from "../../version.ts"; import { line, permissionModeLabel, splitLines, truncate } from "./format.ts"; export const MAX_INSPECTOR_ITEMS = 30; @@ -85,7 +86,7 @@ const EMPTY_INSPECTOR: InspectorItem = {}; const EMPTY_TEXT_OPTIONS: InspectorTextOptions = {}; export function initialInspector(session: AgentSession): InspectorItem { - return makeInspector("欢迎", "Ant Code TUI v1.0", [ + return makeInspector("欢迎", `Ant Code TUI v${getAntCodeVersionSync()}`, [ `模型:${session.model}`, `模式:${permissionModeLabel(session)}`, `网络:${session.networkMode}`, diff --git a/src/commands/registry.ts b/src/commands/registry.ts index 77733cf..1a5d0f9 100644 --- a/src/commands/registry.ts +++ b/src/commands/registry.ts @@ -55,13 +55,13 @@ export const KEYBINDINGS = Object.freeze([ keybinding("slash", "发现", "/", "打开斜杠命令面板。"), keybinding("file-mention", "发现", "@", "打开工作区文件提及面板。"), keybinding("shell", "发现", "!", "通过 /run 进入本地 shell 模式。"), - keybinding("side-tab", "面板", "Tab", "在输入区和会话区之间切换焦点。", "斜杠/文件/模型面板打开时不切换。"), - keybinding("side-arrows", "面板", "Shift+←/→", "切换右侧栏:状态、任务、子智能体。单击侧栏标题也可切换。"), - keybinding("scrollback-nav", "会话区", "↑/↓", "选中上一条/下一条消息或工具卡。", "先按 Tab 进入会话区。"), - keybinding("scrollback-copy", "会话区", "y", "复制当前选中块的正文到系统剪贴板。"), - keybinding("scrollback-open", "会话区", "Enter", "打开当前块的摘录面板。"), - keybinding("scrollback-fold", "会话区", "←/→ 或 e", "折叠或展开当前块。"), - keybinding("message-actions", "面板", "鼠标点击消息块", "选中消息块;再按 y 复制,双击或 Enter 进入摘录。", "回退只影响对话上下文,不撤销文件改动。"), + keybinding("side-tab", "面板", "Tab", "切换右侧栏:状态、任务、子智能体。", "斜杠/文件/模型面板或审批框打开时不切换。"), + keybinding("side-arrows", "面板", "←/→", "输入为空时切换右侧栏分类;任务/子智能体栏内切换筛选。"), + keybinding("scrollback-nav", "会话区", "↑/↓", "输入为空时滚动聊天区。", "输入框有内容时在草稿内上下移动光标。"), + keybinding("scrollback-copy", "会话区", "拖选后复制", "拖选可见聊天文字复制到系统剪贴板。"), + keybinding("scrollback-open", "会话区", "双击消息", "打开当前块的摘录面板。"), + keybinding("message-actions", "面板", "鼠标单击消息块", "短时高亮该块;再双击进入摘录。", "回退只影响对话上下文,不撤销文件改动。"), + keybinding("clear-screen", "会话区", "Ctrl+L", "清屏:只隐藏当前可见记录,不删除会话文件。"), keybinding("transcript-detail", "面板", "Ctrl+O", "在紧凑/详细/完整 transcript 间切换。"), keybinding("thinking-toggle", "面板", "/thinking", "切换 thinking 预览显示;默认隐藏,超长内容仅保留最新片段。"), keybinding("wheel", "面板", "鼠标滚轮", "按鼠标位置滚动聊天区、右侧栏或当前弹层。"), @@ -72,7 +72,7 @@ export const KEYBINDINGS = Object.freeze([ keybinding("close", "生命周期", "Esc", "先关闭顶部弹层;忙碌时第一次确认、第二次中断当前轮次。"), keybinding("interrupt", "生命周期", "Ctrl+G", "先关闭顶部弹层;忙碌时直接中断当前轮次。"), keybinding("exit", "生命周期", "Ctrl+C", "第一次进入退出确认;第二次退出。"), - keybinding("approval", "权限", "Y/N/A", "权限弹窗中分别表示允许一次、拒绝、允许本会话同类请求。", "仅在权限确认框显示时有效。") + keybinding("approval", "权限", "Y/N/A、Tab", "权限弹窗中 Y 允许一次、A 本会话允许、N 拒绝;Tab / 方向键移动选项。Esc 取消并拒绝本次请求。", "仅在权限确认框显示时有效。Shift+Tab 在框内反向移动,不切换会话权限。") ]); const CATEGORY_ORDER = Object.freeze([ diff --git a/src/core/session-resume.ts b/src/core/session-resume.ts index 260a766..a6055b9 100644 --- a/src/core/session-resume.ts +++ b/src/core/session-resume.ts @@ -269,13 +269,13 @@ export async function restoreArchivedContextMessages(store: ReturnType : {}; const maxMessages = positiveInteger(contextRecord.resumeMaxMessages, DEFAULT_RESUME_CONTEXT_MESSAGES) ?? DEFAULT_RESUME_CONTEXT_MESSAGES; - const chunkCount = Math.max(1, Math.ceil(maxMessages / normalized.chunkSize)); - const chunks = normalized.chunks.slice(-chunkCount); + const maxBytes = positiveInteger(contextRecord.resumeMaxBytes, DEFAULT_RESUME_CONTEXT_BYTES) ?? DEFAULT_RESUME_CONTEXT_BYTES; + const chunks = selectResumeArchiveChunks(normalized.chunks, { maxMessages, maxBytes }); + const results = await Promise.all(chunks.map((chunk) => store.readTranscriptChunk(normalized, chunk.index))); const messages = []; - for (const chunk of chunks) { - const result = await store.readTranscriptChunk(normalized, chunk.index); + for (const result of results) { if (!result.ok) { - const error = new Error(result.error?.message ?? `Unable to read transcript chunk '${chunk.index}'`); + const error = new Error(result.error?.message ?? "Unable to read transcript chunk"); Object.assign(error, { code: result.error?.code ?? "TRANSCRIPT_CHUNK_READ_ERROR" }); throw error; } @@ -285,6 +285,30 @@ export async function restoreArchivedContextMessages(store: ReturnType( + chunks: T[], + limits: { maxMessages: number; maxBytes: number } +): T[] { + if (!Array.isArray(chunks) || chunks.length === 0) { + return []; + } + const selected = []; + let messages = 0; + let bytes = 0; + for (let index = chunks.length - 1; index >= 0; index -= 1) { + const chunk = chunks[index]; + selected.push(chunk); + messages += Math.max(0, Number(chunk?.messages) || 0); + bytes += Math.max(0, Number(chunk?.bytes) || 0); + if (messages >= limits.maxMessages || bytes >= limits.maxBytes) { + break; + } + } + selected.reverse(); + return selected; +} + + export function limitResumeContextMessages(messages: unknown, context: unknown = {}): SessionMessage[] { if (!Array.isArray(messages)) { return []; @@ -293,15 +317,28 @@ export function limitResumeContextMessages(messages: unknown, context: unknown = const maxMessages = positiveInteger(record.resumeMaxMessages, DEFAULT_RESUME_CONTEXT_MESSAGES) ?? DEFAULT_RESUME_CONTEXT_MESSAGES; const maxTokens = positiveInteger(record.resumeMaxTokens, DEFAULT_RESUME_CONTEXT_TOKENS) ?? DEFAULT_RESUME_CONTEXT_TOKENS; const maxBytes = positiveInteger(record.resumeMaxBytes, DEFAULT_RESUME_CONTEXT_BYTES) ?? DEFAULT_RESUME_CONTEXT_BYTES; - let kept = messages.filter(Boolean).slice(-maxMessages); - while (kept.length > 1) { - const bytes = estimatePersistedMessagesBytes(kept); - if (bytes <= maxBytes && estimateTokensFromBytesLocal(bytes) <= maxTokens) { - break; + const kept = messages.filter(Boolean).slice(-maxMessages); + if (kept.length <= 1) { + return alignContextStartToUser(kept); + } + const fits = (slice: unknown[]) => { + const bytes = estimatePersistedMessagesBytes(slice); + return bytes <= maxBytes && estimateTokensFromBytesLocal(bytes) <= maxTokens; + }; + if (fits(kept)) { + return alignContextStartToUser(kept); + } + let lo = 1; + let hi = kept.length - 1; + while (lo < hi) { + const mid = Math.floor((lo + hi) / 2); + if (fits(kept.slice(mid))) { + hi = mid; + } else { + lo = mid + 1; } - kept = kept.slice(1); } - return alignContextStartToUser(kept); + return alignContextStartToUser(kept.slice(lo)); } diff --git a/src/dashboard/events.ts b/src/dashboard/events.ts index 748232f..2ad18b5 100644 --- a/src/dashboard/events.ts +++ b/src/dashboard/events.ts @@ -1,24 +1,4 @@ -const TOOL_LABELS: Readonly> = Object.freeze({ - read_file: "读取文件", - list_files: "列出文件", - glob: "查找文件", - grep: "搜索文本", - git_status: "检查 Git 状态", - git_diff: "查看 Git 差异", - write_file: "写入文件", - edit_file: "编辑文件", - powershell: "运行 PowerShell", - bash: "运行 Shell", - background_shell: "启动后台终端", - web_fetch: "访问网页", - web_search: "搜索网页", - document_intake: "读取文档", - mcp_call: "调用 MCP 工具", - mcp_list: "列出 MCP 能力", - agent_run: "启动子智能体", - todo_write: "更新任务清单", - plan_update: "更新计划" -}); +import { TOOL_LABELS, toolLabel } from "../tools/labels.ts"; const SEVERITY_BY_STATUS: Readonly> = Object.freeze({ running: "info", @@ -326,7 +306,7 @@ function backgroundSubagentActivity(event: Record, options: Bac function roundDetail(event: Record) { const parts = [ - Number.isFinite(event.round) ? `round ${event.round}` : null, + Number.isFinite(event.round) ? `第 ${event.round} 轮` : null, Number.isFinite(event.messageCount) ? `${event.messageCount} 条消息` : null, Number.isFinite(event.toolSchemaCount) ? `${event.toolSchemaCount} 个工具定义` : null ].filter(Boolean); @@ -335,7 +315,7 @@ function roundDetail(event: Record) { function gatewayRetryDetail(event: Record) { const parts = [ - Number.isFinite(event.round) ? `round ${event.round}` : null, + Number.isFinite(event.round) ? `第 ${event.round} 轮` : null, Number.isFinite(event.attempt) && Number.isFinite(event.maxAttempts) ? `第 ${event.attempt}/${event.maxAttempts} 次` : null, asRecord(event.error).code ? String(asRecord(event.error).code) : null, event.stage ? `阶段 ${event.stage}` : null, @@ -344,11 +324,18 @@ function gatewayRetryDetail(event: Record) { return parts.join(" · ") || "网关响应异常,正在自动重试"; } +function waitForLabel(value: unknown) { + if (value === "all") return "等待全部完成"; + if (value === "any") return "等待任一完成"; + if (value === "none") return "完成后不接续"; + return value ? String(value) : ""; +} + function backgroundSubagentStartedDetail(event: Record) { const parts = [ - event.profile ? `profile=${event.profile}` : null, - event.groupId ? `group=${event.groupId}` : null, - event.waitFor ? `waitFor=${event.waitFor}` : null, + event.profile ? String(event.profile) : null, + event.groupId ? `组 ${event.groupId}` : null, + waitForLabel(event.waitFor) || null, event.wakeParent === false ? "完成后仅记录结果" : "完成后自动唤醒主控" ].filter(Boolean); return parts.join(" · "); @@ -406,9 +393,7 @@ function toolCallNames(toolCalls: unknown) { return toolCalls.map((call) => toolLabel(asRecord(call).name)).join("、"); } -export function toolLabel(name: unknown) { - return (typeof name === "string" ? TOOL_LABELS[name] : undefined) ?? String(name ?? "工具"); -} +export { TOOL_LABELS, toolLabel }; function toolDetail(event: Record) { const keys = Array.isArray(event.inputKeys) && event.inputKeys.length > 0 diff --git a/src/dashboard/public/app-core.ts b/src/dashboard/public/app-core.ts index 3c8eaf2..61671e7 100644 --- a/src/dashboard/public/app-core.ts +++ b/src/dashboard/public/app-core.ts @@ -964,6 +964,7 @@ export const EVENT_STALE_AFTER_MS = 35_000; export const EVENT_CONNECT_TIMEOUT_MS = 10_000; export const EVENT_RECONNECT_MAX_ATTEMPTS = 6; export const DASHBOARD_REQUEST_TIMEOUT_MS = 15_000; +export const DASHBOARD_TURN_START_TIMEOUT_MS = 60_000; export const DASHBOARD_API_VERSION = "dashboard.v2"; export const DASHBOARD_LIFECYCLE_TIMEOUT_MS = 5_000; export const DASHBOARD_SHUTDOWN_TIMEOUT_MS = 15_000; diff --git a/src/dashboard/public/app-ui1.ts b/src/dashboard/public/app-ui1.ts index 843705b..662e158 100644 --- a/src/dashboard/public/app-ui1.ts +++ b/src/dashboard/public/app-ui1.ts @@ -1,8 +1,8 @@ import { renderMarkdown } from "./markdown.ts"; -import { classifyComposerFile } from "./app-ui2.ts"; +import { classifyComposerFile, composerHasOutboundContent } from "./app-ui2.ts"; import { hydrateRichContent } from "./rich-renderers.ts"; import { visibleTranscriptRole } from "./transcript.ts"; -import { MANUAL_AGENT_MODEL_VALUE, state, els, MODE_DESCRIPTIONS, LOCAL_FILE_EXTENSIONS, FILE_REFERENCE_PATTERN, TRANSCRIPT_DOM_LIMIT, EVENT_STALE_AFTER_MS, EVENT_CONNECT_TIMEOUT_MS, EVENT_RECONNECT_MAX_ATTEMPTS, DASHBOARD_REQUEST_TIMEOUT_MS, DASHBOARD_API_VERSION, DASHBOARD_LIFECYCLE_TIMEOUT_MS, DASHBOARD_SHUTDOWN_TIMEOUT_MS, DASHBOARD_INTERRUPT_TIMEOUT_MS, MAX_IMAGE_ATTACHMENTS, MAX_IMAGE_ATTACHMENT_BYTES, CURRENT_SESSION_STORAGE_KEY, DASHBOARD_CLIENT_STORAGE_KEY, PREVIEW_WIDTH_STORAGE_KEY, PREVIEW_WIDTH_DEFAULT, PREVIEW_WIDTH_MIN, PREVIEW_WIDTH_MAX, PREVIEW_WORKSPACE_MIN , emptySessionStatus, emptyBackgroundSubagent } from "./app-core.ts"; +import { MANUAL_AGENT_MODEL_VALUE, state, els, MODE_DESCRIPTIONS, LOCAL_FILE_EXTENSIONS, FILE_REFERENCE_PATTERN, TRANSCRIPT_DOM_LIMIT, EVENT_STALE_AFTER_MS, EVENT_CONNECT_TIMEOUT_MS, EVENT_RECONNECT_MAX_ATTEMPTS, DASHBOARD_REQUEST_TIMEOUT_MS, DASHBOARD_TURN_START_TIMEOUT_MS, DASHBOARD_API_VERSION, DASHBOARD_LIFECYCLE_TIMEOUT_MS, DASHBOARD_SHUTDOWN_TIMEOUT_MS, DASHBOARD_INTERRUPT_TIMEOUT_MS, MAX_IMAGE_ATTACHMENTS, MAX_IMAGE_ATTACHMENT_BYTES, CURRENT_SESSION_STORAGE_KEY, DASHBOARD_CLIENT_STORAGE_KEY, PREVIEW_WIDTH_STORAGE_KEY, PREVIEW_WIDTH_DEFAULT, PREVIEW_WIDTH_MIN, PREVIEW_WIDTH_MAX, PREVIEW_WORKSPACE_MIN , emptySessionStatus, emptyBackgroundSubagent } from "./app-core.ts"; import type { DashboardConfigSource, DashboardReasoningEffort, DashboardTurnChangeStats, DashboardScopedDefaultSelection, DashboardVisionAgent, DashboardReasoningDiscovery, DashboardReasoningCapabilityCandidate, DashboardGatewayProbeModel, DashboardGatewayProbeResult, DashboardLifecycleActivity, DashboardSettings, DashboardFile, DashboardTableSheet, DashboardTablePreview, DashboardLightboxItem, DashboardQuestionChoice, DashboardPendingQuestion, DashboardApproval, DashboardGatewayTransport, DashboardScopedRequest, DashboardFetchOptions, DashboardModelSource, DashboardSessionStatus, DashboardApiResult, DashboardActivity, DashboardModelOption, DashboardGatewayProfile, DashboardGatewayConfig, DashboardModelSelection, DashboardPendingGuide, DashboardStreamEvent, DashboardUiState , DashboardSessionSummary } from "./app-core.ts"; import { eventTargetOf, eventElement, isPlainObject, modelSourceOf, errorMessageOf, activateModal, collectModalBackground, focusModalInitialTarget, deactivateModal, restoreModalAttribute, handleGlobalKeydown, closeActiveModal, loadTrust, loadSessions, restoreInitialSession, latestBackgroundSessionId, initialSessionId, rememberCurrentSession, renderSessions, sessionMeta, sessionStatusView, toggleSidebar, setSidebarCollapsed, sessionsNeedRefresh, scheduleSessionsRefresh, handleSessionAction, openSession, restoreBackgroundSnapshot, deleteSession, setSessionsRefreshState, copySessionId, newTask, rememberNewTaskModelState, restoreNewTaskModelState, refreshNewTaskModelState, addAttachmentFiles, readImageAttachment, renderAttachmentStrip, attachmentPayload, clearAttachments, sendPrompt, stableTurnRequest, dashboardRequestId, dashboardClientId, statusUrl, interruptTurn, guideTurn, cancelQueuedTurn, cancelBackgroundSubagent, backgroundCancelKey, connectEvents, ensureEventsConnected, disconnectEvents, closeEventSource, markEventConnectionAlive, armEventConnectTimer, armEventStaleTimer, scheduleEventReconnect, reconnectEventsManually, clearEventReconnectTimer, clearEventConnectTimer, clearEventStaleTimer, setConnectionState, resetEventReplayState, rememberEventCursor, handleDashboardEvent, shouldSkipDashboardEvent, beginEventTurn, renderTranscriptMessages, renderSessionFailure, setTranscriptPaging, renderTranscriptHistoryStatus, removeTranscriptHistoryStatus, transcriptFirstContentNode, handleTranscriptScroll, loadOlderTranscript, renderWorkflowPanel, renderWorkflowStrip, currentWorkflowItem, workflowSection, workflowItem, normalizeWorkflowStatus, summarizeWorkflow, appendMessage, createMessageNode, appendTranscriptNode, trimTranscriptWindow, isProtectedTranscriptNode, renderTranscriptWindowMarker, captureTranscriptViewportAnchor, restoreTranscriptViewportAnchor, transcriptNodeTop, restoreTranscriptNodeAnchor, resetTranscriptWindow, appendAssistantDraft, scheduleDraftRender, renderAssistantDraft, appendActivity, appendContextBoundary, contextBoundaryText, handleActivity, isBackgroundSubagentActivity, handleBackgroundSubagentActivity, clearBackgroundSubagentStatus, reconcileBackgroundSubagentSnapshot, backgroundSubagentDisplayStatus, backgroundSubagentVisible, updateLiveActivity, removeLiveActivity, setLiveTitle, toggleLiveStatusDetails, updateLiveStatus, liveStatusTitle, primaryLiveActivity, gatewayRetryChipText, renderBackgroundSubagentStatus, backgroundSubagentCompactLabel, backgroundSubagentTitle, backgroundSubagentMeta, backgroundSubagentCancellable, resetLiveStatus, backgroundSubagentCounts, idleRunStatus, applyIdleRunStatus, updateRunStatusForBackground, updateSessionStatus, updateTurnChangeStats, resetTurnChangeStats, normalizeChangeStats, renderComposerStatus, modelStatusHtml, unresolvedModelStatusHtml, handleModelStatusActivate, handleModelStatusKeydown, toggleModelPanel, hideModelPanel, showSettingsWorkspace, refreshSettingsConfiguration, hideSettingsWorkspace, showModelConfigPanel, hideModelConfigPanel, renderModelPanel, modelCapabilityLabels, handleModelPanelClick, handleModelPanelChange, renderSettingsView, syncSettingsRail, modelSettingsHtml, transcriptSettingsHtml, transcriptRetentionOptionsHtml, networkSettingsHtml, networkModeOptionHtml, agentSettingsHtml, reliabilitySettingsHtml, settingsSectionHeading, settingsToggleHtml, managedFieldHtml, settingsDisabled, settingsFormActions, settingsFeedbackHtml, settingsGatewayProfileHtml, gatewayProfileReadonlyLabel, providerModelKey, scopedDefaultModelLabel, settingsModelHtml, handleSettingsRailClick, initializeSettingsFormTracking, handleSettingsFormChange, settingsControlValue, changedSettingsFields, canonicalSettingsField, setSettingsFormSaving, renderSettingsFeedbackInPlace, saveSettingsConfig, handleSettingsClick, protocolDisplayName, agentModelPickerHtml, renderModelConfigPanel, handleModelConfigPanelClick, handleModelConfigInput, handleModelConfigChange, markModelConfigCredentialChanged, markModelConfigEndpointChanged, handleModelConfigModelIdChanged, markReasoningCapabilityManual, clearReasoningCapabilityControls, syncGatewayUrlHint, gatewayUrlPlaceholder, gatewayUrlHint, syncReasoningDefaultOptions, probeGateway, isCurrentModelConfigRequest, currentGatewayProbeResult, currentGatewayCatalogModels, modelConfigGatewayProfile, modelConfigEndpointChanged, modelConfigAgentModelsSnapshot, initializeAgentModelPickerSnapshot, syncAgentModelPickersForEndpoint, uniqueAgentModelCandidates, appendAgentModelOptions, renderAgentModelPickers, updateAgentModelPickerManualStatus, handleAgentModelSelection, renderGatewayProbeResult, applyProbedModel, applyGatewayDiscoveredModel, applySuggestedGatewayUrl, gatewayCredentialAction, normalizeGatewayProbeModels, normalizeReasoningDiscovery, reasoningCapabilityCandidate, applyReasoningCapabilityCandidate, ensureReasoningEffortOptions, applyPendingReasoningCapabilities, reasoningCapabilityIsActionable, renderReasoningCapabilityStatus, reasoningCapabilityStatusText, reasoningDiscoveryStatusText, probeModelCapabilities, setFormControlsSaving, setModelConfigFormSaving, renderModelConfigFailure, clearModelConfigFailure, manualAgentModelIds, saveModelConfig, saveDefaultModelSelection, switchModel, handleReasoningEffortChange, switchReasoningEffort, deleteGatewayProfile, deleteModel, updateConfigRevisions, normalizeScopedDefaultSelection, configScope, configMutationMetadata, isConfigRevisionConflict, configRevisionConflictMessage, refreshConfigRevisionsAfterConflict, normalizeGatewayConfig, normalizeDashboardSettings, mergeGatewayConfig, normalizeGatewayProfiles, normalizeConfigSource, normalizeModels, normalizeModelSource, normalizeReasoningEfforts, normalizedReasoningEffort, isDisabledReasoningEffort, configuredReasoningEffort, reasoningEffortFallbackLabel, localizedReasoningEffortLabel, reasoningEffortCatalog, reasoningEffortLabel, resolveAtomicModelSelection, currentModelSelection, currentSessionNeedsModelSelection, currentGatewayProfile, gatewayProfileById, settingsInspectedGatewayProfile, modelSourceLabel, markCurrentModel, currentModelInfo, modelDisplayName, normalizeAgentModelTiers, normalizeVisionAgent, firstVisionModelId, hasAgentModelTiers, agentModelTiersSummary, gatewaySummary, modelSaveTargetLabel, gatewaySourceNote, environmentGatewayDefaultNote, sourceBadge, sourceLabel, formatContextUsage, firstFiniteNumber, formatTokenCount, trimNumber, nonNegativeInteger, collapseCompletedActivities, clearAssistantDrafts, collapseAssistantDrafts, isMeaningfulCompletedActivity, isDuplicateDraftText, normalizeComparableText, showApproval, resolveApproval, hideApproval, showQuestion, revealInteractionPanel, renderQuestionPanel, reviewQuestionConversation, returnToQuestion, activateQuestionReviewBackground, deactivateQuestionReviewBackground, questionChoiceButton, toggleQuestionChoice, submitQuestion, cancelQuestion, finishQuestionSubmission, hideQuestion, showTrustPanel, renderTrustPanel, confirmTrust, renderQueuePanel, renderQueueItem, setPendingGuide, clearPendingGuide, syncPendingGuideFromQueue, renderGuideFeedback, guideCopy, guideSource, guideTurnFromQueue, guideButtonText, guideButtonDisabled, guideButtonVisible, syncGuideButton, shouldKeepGuideFeedback, isInterruptError, updateSendButton, showContextConfirm, hideContextConfirm, runContextAction, contextActionRequestOptions, contextSummaryLine, compactResultLine, rememberQuestionDraft, questionResolutionText, showShutdownPanel, hideShutdownPanel, shutdownDashboard, lockClosedDashboard, normalizeLifecycleActivity, shutdownRequestBody, shutdownResultIsClosed, lifecycleActivitySummary, renderShutdownActivity, renderFiles, currentImageFiles, openFile, handleLocalFileOpenClick, renderOfficePreview, officePreviewMeta, officePreviewBodyHtml, renderTablePreview, normalizeTablePreview, tablePreviewMeta, renderCompactTableHtml, renderExpandedTableHtml, renderTableHtml, tableTruncationNote, maxVisibleColumns, columnLabel, renderSheetPreviewHtml, renderSheetCellHtml, resetPreview, fencedDataForFile, dataLanguageForExtension, showImageLightbox, showTableLightbox, renderLightboxImage, bindTableLightboxControls, moveLightbox, hideLightbox, setPermissionMode, clearTranscript, cancelTranscriptAnimationFrames, clearAssistantDraftTimers, hideEmptyState, showError, showNotice, renderBootstrapLoading, renderBootstrapFailure, dashboardPayloadError, bootstrapFailurePresentation, clearBootstrapStatus, scrollTranscript, isTranscriptNearBottom, syncTranscriptFollowState, followTranscript, updateTranscriptJump, beginScopedRequest, isCurrentScopedRequest, finishScopedRequest, cancelScopedRequest, isAbortError, getJson, postJson, deleteJson, dashboardFetch, responseJson, dashboardJsonHeaders, dashboardCsrfToken, messageText, messageDisplayText, userMessageDisplayText, normalizeAttachmentMetadata, imageAttachmentLine, renderMessageText, renderLinkedText, bindRichContent, linkifyFileTextNodes, replaceFileReferences, isLikelyLocalFileReference, resolveDisplayFilePath, normalizeFileReferencePath, parentDirectory, filePreviewUrl, rawFileUrl, apiFileUrl, imagePreviewUrl, isSafeInlineBitmapUrl, normalizeRelativePath, isWorkspaceRelativeToBase, copyCodeBlock, previewText, formatNumber, formatBytes, escapeHtml, escapeAttribute, formatTime, formatRelativeTime } from "./app-barrel.ts"; export async function init() { @@ -72,6 +72,8 @@ export function updateRunStatusTone() { let tone = "idle"; if (/失败|拒绝/.test(status)) { tone = "error"; + } else if (/失联|无进展/.test(status)) { + tone = "error"; } else if (/等待|待/.test(status)) { tone = "waiting"; } else if (/运行|启动|引导|中断|停止|收尾|排队|压缩/.test(status)) { @@ -100,6 +102,10 @@ export function bindEvents() { return; } if (state.running) { + if (composerHasOutboundContent()) { + sendPrompt(); + return; + } interruptTurn(); return; } @@ -114,6 +120,7 @@ export function bindEvents() { els.promptInput.addEventListener("input", () => { syncGuideButton(); resizePromptInput(); + updateSendButton(); }); els.attachButton.addEventListener("click", () => els.attachmentInput.click()); els.attachmentInput.addEventListener("change", async () => { @@ -466,10 +473,24 @@ export function setResponsiveView(view: unknown) { syncResponsiveNavigation(); } +export function emptyStateCopy(width: number = Number(window.innerWidth) || 1200) { + return width >= 1200 + ? "过程折叠。结果展开。文件留在右侧。" + : "过程折叠。结果展开。文件在「文件」页。"; +} + +export function syncEmptyStateCopy(width: number = Number(window.innerWidth) || 1200) { + const copy = document.querySelector("#empty-copy"); + if (copy) { + copy.textContent = emptyStateCopy(width); + } +} + export function syncResponsiveNavigation() { const width = Number(window.innerWidth) || Number(document.documentElement?.clientWidth) || 1200; const view = normalizedResponsiveView(width, state.responsiveView); state.responsiveView = view; + syncEmptyStateCopy(width); if (width < 1200) { state.sidebarCollapsed = false; document.body.classList.remove("sidebar-collapsed", "preview-collapsed"); @@ -789,7 +810,7 @@ export async function enableGoalWithObjective(text: string) { goalText: text, clientPreviousPermissionMode: previous, clientId: dashboardClientId() - }); + }, { timeoutMs: DASHBOARD_TURN_START_TIMEOUT_MS }); } catch (error) { result = { ok: false, error: errorMessageOf(error) }; } finally { diff --git a/src/dashboard/public/app-ui2.ts b/src/dashboard/public/app-ui2.ts index 40ba90f..c82b277 100644 --- a/src/dashboard/public/app-ui2.ts +++ b/src/dashboard/public/app-ui2.ts @@ -1,7 +1,7 @@ import { renderMarkdown } from "./markdown.ts"; import { hydrateRichContent } from "./rich-renderers.ts"; import { visibleTranscriptRole } from "./transcript.ts"; -import { MANUAL_AGENT_MODEL_VALUE, state, els, MODE_DESCRIPTIONS, LOCAL_FILE_EXTENSIONS, FILE_REFERENCE_PATTERN, TRANSCRIPT_DOM_LIMIT, EVENT_STALE_AFTER_MS, EVENT_CONNECT_TIMEOUT_MS, EVENT_RECONNECT_MAX_ATTEMPTS, DASHBOARD_REQUEST_TIMEOUT_MS, DASHBOARD_API_VERSION, DASHBOARD_LIFECYCLE_TIMEOUT_MS, DASHBOARD_SHUTDOWN_TIMEOUT_MS, DASHBOARD_INTERRUPT_TIMEOUT_MS, MAX_IMAGE_ATTACHMENTS, MAX_IMAGE_ATTACHMENT_BYTES, MAX_DOCUMENT_ATTACHMENTS, MAX_DOCUMENT_ATTACHMENT_BYTES, DOCUMENT_EXTENSIONS, IMAGE_EXTENSIONS, CURRENT_SESSION_STORAGE_KEY, DASHBOARD_CLIENT_STORAGE_KEY, PREVIEW_WIDTH_STORAGE_KEY, PREVIEW_WIDTH_DEFAULT, PREVIEW_WIDTH_MIN, PREVIEW_WIDTH_MAX, PREVIEW_WORKSPACE_MIN , emptySessionStatus, emptyBackgroundSubagent } from "./app-core.ts"; +import { MANUAL_AGENT_MODEL_VALUE, state, els, MODE_DESCRIPTIONS, LOCAL_FILE_EXTENSIONS, FILE_REFERENCE_PATTERN, TRANSCRIPT_DOM_LIMIT, EVENT_STALE_AFTER_MS, EVENT_CONNECT_TIMEOUT_MS, EVENT_RECONNECT_MAX_ATTEMPTS, DASHBOARD_REQUEST_TIMEOUT_MS, DASHBOARD_TURN_START_TIMEOUT_MS, DASHBOARD_API_VERSION, DASHBOARD_LIFECYCLE_TIMEOUT_MS, DASHBOARD_SHUTDOWN_TIMEOUT_MS, DASHBOARD_INTERRUPT_TIMEOUT_MS, MAX_IMAGE_ATTACHMENTS, MAX_IMAGE_ATTACHMENT_BYTES, MAX_DOCUMENT_ATTACHMENTS, MAX_DOCUMENT_ATTACHMENT_BYTES, DOCUMENT_EXTENSIONS, IMAGE_EXTENSIONS, CURRENT_SESSION_STORAGE_KEY, DASHBOARD_CLIENT_STORAGE_KEY, PREVIEW_WIDTH_STORAGE_KEY, PREVIEW_WIDTH_DEFAULT, PREVIEW_WIDTH_MIN, PREVIEW_WIDTH_MAX, PREVIEW_WORKSPACE_MIN , emptySessionStatus, emptyBackgroundSubagent } from "./app-core.ts"; import type { DashboardConfigSource, DashboardReasoningEffort, DashboardTurnChangeStats, DashboardScopedDefaultSelection, DashboardVisionAgent, DashboardReasoningDiscovery, DashboardReasoningCapabilityCandidate, DashboardGatewayProbeModel, DashboardGatewayProbeResult, DashboardLifecycleActivity, DashboardSettings, DashboardFile, DashboardTableSheet, DashboardTablePreview, DashboardLightboxItem, DashboardQuestionChoice, DashboardPendingQuestion, DashboardApproval, DashboardGatewayTransport, DashboardScopedRequest, DashboardFetchOptions, DashboardModelSource, DashboardSessionStatus, DashboardApiResult, DashboardActivity, DashboardModelOption, DashboardGatewayProfile, DashboardGatewayConfig, DashboardModelSelection, DashboardPendingGuide, DashboardStreamEvent, DashboardUiState , DashboardSessionSummary } from "./app-core.ts"; import { eventTargetOf, eventElement, isPlainObject, modelSourceOf, errorMessageOf, init, bootstrapDashboard, observeRunStatus, updateRunStatusTone, bindEvents, normalizedResponsiveView, composerHeightFor, previewWidthBounds, clampedPreviewWidth, permissionIndexForKey, focusTrapTarget, shouldFollowTranscript, scheduleAnimationFrameOnce, cancelScheduledAnimationFrame, appendPlainDraftDelta, renderFinalAssistantBody, selectTranscriptNodesToRemove, responsiveLayoutMode, restorePreviewWidth, setPreviewWidth, syncPreviewResizeHandle, beginPreviewResize, updatePreviewResize, finishPreviewResize, handlePreviewResizeKeydown, setResponsiveView, syncResponsiveNavigation, setResponsiveSurfaceInert, handleResponsiveFileNavigation, syncVisualViewport, resizePromptInput, handlePermissionModeKeydown, requestPermissionMode, defaultGoalMaxAutoContinues, emptyGoalSnapshot, applyGoalSnapshot, renderGoalControls, renderGoalStatusBar, requestGoalMode, showGoalConfirm, hideGoalConfirm, showGoalTextPanel, hideGoalTextPanel, enableGoalWithObjective, submitGoalAction, adoptGoalRunResult, showPermissionConfirm, hidePermissionConfirm, updateContextActions, announceStatus, modalFocusableElements, cancelBackgroundSubagent, backgroundCancelKey, connectEvents, ensureEventsConnected, disconnectEvents, closeEventSource, markEventConnectionAlive, armEventConnectTimer, armEventStaleTimer, scheduleEventReconnect, reconnectEventsManually, clearEventReconnectTimer, clearEventConnectTimer, clearEventStaleTimer, setConnectionState, resetEventReplayState, rememberEventCursor, handleDashboardEvent, shouldSkipDashboardEvent, beginEventTurn, renderTranscriptMessages, renderSessionFailure, setTranscriptPaging, renderTranscriptHistoryStatus, removeTranscriptHistoryStatus, transcriptFirstContentNode, handleTranscriptScroll, loadOlderTranscript, renderWorkflowPanel, renderWorkflowStrip, currentWorkflowItem, workflowSection, workflowItem, normalizeWorkflowStatus, summarizeWorkflow, appendMessage, createMessageNode, appendTranscriptNode, trimTranscriptWindow, isProtectedTranscriptNode, renderTranscriptWindowMarker, captureTranscriptViewportAnchor, restoreTranscriptViewportAnchor, transcriptNodeTop, restoreTranscriptNodeAnchor, resetTranscriptWindow, appendAssistantDraft, scheduleDraftRender, renderAssistantDraft, appendActivity, appendContextBoundary, contextBoundaryText, handleActivity, isBackgroundSubagentActivity, handleBackgroundSubagentActivity, clearBackgroundSubagentStatus, reconcileBackgroundSubagentSnapshot, backgroundSubagentDisplayStatus, backgroundSubagentVisible, updateLiveActivity, removeLiveActivity, setLiveTitle, toggleLiveStatusDetails, updateLiveStatus, liveStatusTitle, primaryLiveActivity, gatewayRetryChipText, renderBackgroundSubagentStatus, backgroundSubagentCompactLabel, backgroundSubagentTitle, backgroundSubagentMeta, backgroundSubagentCancellable, resetLiveStatus, backgroundSubagentCounts, idleRunStatus, applyIdleRunStatus, updateRunStatusForBackground, updateSessionStatus, updateTurnChangeStats, resetTurnChangeStats, normalizeChangeStats, renderComposerStatus, modelStatusHtml, unresolvedModelStatusHtml, handleModelStatusActivate, handleModelStatusKeydown, toggleModelPanel, hideModelPanel, showSettingsWorkspace, refreshSettingsConfiguration, hideSettingsWorkspace, showModelConfigPanel, hideModelConfigPanel, renderModelPanel, modelCapabilityLabels, handleModelPanelClick, handleModelPanelChange, renderSettingsView, syncSettingsRail, modelSettingsHtml, transcriptSettingsHtml, transcriptRetentionOptionsHtml, networkSettingsHtml, networkModeOptionHtml, agentSettingsHtml, reliabilitySettingsHtml, settingsSectionHeading, settingsToggleHtml, managedFieldHtml, settingsDisabled, settingsFormActions, settingsFeedbackHtml, settingsGatewayProfileHtml, gatewayProfileReadonlyLabel, providerModelKey, scopedDefaultModelLabel, settingsModelHtml, handleSettingsRailClick, initializeSettingsFormTracking, handleSettingsFormChange, settingsControlValue, changedSettingsFields, canonicalSettingsField, setSettingsFormSaving, renderSettingsFeedbackInPlace, saveSettingsConfig, handleSettingsClick, protocolDisplayName, agentModelPickerHtml, renderModelConfigPanel, handleModelConfigPanelClick, handleModelConfigInput, handleModelConfigChange, markModelConfigCredentialChanged, markModelConfigEndpointChanged, handleModelConfigModelIdChanged, markReasoningCapabilityManual, clearReasoningCapabilityControls, syncGatewayUrlHint, gatewayUrlPlaceholder, gatewayUrlHint, syncReasoningDefaultOptions, probeGateway, isCurrentModelConfigRequest, currentGatewayProbeResult, currentGatewayCatalogModels, modelConfigGatewayProfile, modelConfigEndpointChanged, modelConfigAgentModelsSnapshot, initializeAgentModelPickerSnapshot, syncAgentModelPickersForEndpoint, uniqueAgentModelCandidates, appendAgentModelOptions, renderAgentModelPickers, updateAgentModelPickerManualStatus, handleAgentModelSelection, renderGatewayProbeResult, applyProbedModel, applyGatewayDiscoveredModel, applySuggestedGatewayUrl, gatewayCredentialAction, normalizeGatewayProbeModels, normalizeReasoningDiscovery, reasoningCapabilityCandidate, applyReasoningCapabilityCandidate, ensureReasoningEffortOptions, applyPendingReasoningCapabilities, reasoningCapabilityIsActionable, renderReasoningCapabilityStatus, reasoningCapabilityStatusText, reasoningDiscoveryStatusText, probeModelCapabilities, setFormControlsSaving, setModelConfigFormSaving, renderModelConfigFailure, clearModelConfigFailure, manualAgentModelIds, saveModelConfig, saveDefaultModelSelection, switchModel, handleReasoningEffortChange, switchReasoningEffort, deleteGatewayProfile, deleteModel, updateConfigRevisions, normalizeScopedDefaultSelection, configScope, configMutationMetadata, isConfigRevisionConflict, configRevisionConflictMessage, refreshConfigRevisionsAfterConflict, normalizeGatewayConfig, normalizeDashboardSettings, mergeGatewayConfig, normalizeGatewayProfiles, normalizeConfigSource, normalizeModels, normalizeModelSource, normalizeReasoningEfforts, normalizedReasoningEffort, isDisabledReasoningEffort, configuredReasoningEffort, reasoningEffortFallbackLabel, localizedReasoningEffortLabel, reasoningEffortCatalog, reasoningEffortLabel, resolveAtomicModelSelection, currentModelSelection, currentSessionNeedsModelSelection, currentGatewayProfile, gatewayProfileById, settingsInspectedGatewayProfile, modelSourceLabel, markCurrentModel, currentModelInfo, modelDisplayName, normalizeAgentModelTiers, normalizeVisionAgent, firstVisionModelId, hasAgentModelTiers, agentModelTiersSummary, gatewaySummary, modelSaveTargetLabel, gatewaySourceNote, environmentGatewayDefaultNote, sourceBadge, sourceLabel, formatContextUsage, firstFiniteNumber, formatTokenCount, trimNumber, nonNegativeInteger, collapseCompletedActivities, clearAssistantDrafts, collapseAssistantDrafts, isMeaningfulCompletedActivity, isDuplicateDraftText, normalizeComparableText, showApproval, resolveApproval, hideApproval, showQuestion, revealInteractionPanel, renderQuestionPanel, reviewQuestionConversation, returnToQuestion, activateQuestionReviewBackground, deactivateQuestionReviewBackground, questionChoiceButton, toggleQuestionChoice, submitQuestion, cancelQuestion, finishQuestionSubmission, hideQuestion, showTrustPanel, renderTrustPanel, confirmTrust, renderQueuePanel, renderQueueItem, setPendingGuide, clearPendingGuide, syncPendingGuideFromQueue, renderGuideFeedback, guideCopy, guideSource, guideTurnFromQueue, guideButtonText, guideButtonDisabled, guideButtonVisible, syncGuideButton, shouldKeepGuideFeedback, isInterruptError, updateSendButton, showContextConfirm, hideContextConfirm, runContextAction, contextActionRequestOptions, contextSummaryLine, compactResultLine, rememberQuestionDraft, questionResolutionText, showShutdownPanel, hideShutdownPanel, shutdownDashboard, lockClosedDashboard, normalizeLifecycleActivity, shutdownRequestBody, shutdownResultIsClosed, lifecycleActivitySummary, renderShutdownActivity, renderFiles, currentImageFiles, openFile, renderOfficePreview, officePreviewMeta, officePreviewBodyHtml, renderTablePreview, normalizeTablePreview, tablePreviewMeta, renderCompactTableHtml, renderExpandedTableHtml, renderTableHtml, tableTruncationNote, maxVisibleColumns, columnLabel, renderSheetPreviewHtml, renderSheetCellHtml, resetPreview, fencedDataForFile, dataLanguageForExtension, showImageLightbox, showTableLightbox, renderLightboxImage, bindTableLightboxControls, moveLightbox, hideLightbox, setPermissionMode, clearTranscript, cancelTranscriptAnimationFrames, clearAssistantDraftTimers, hideEmptyState, showError, showNotice, renderBootstrapLoading, renderBootstrapFailure, dashboardPayloadError, bootstrapFailurePresentation, clearBootstrapStatus, scrollTranscript, isTranscriptNearBottom, syncTranscriptFollowState, followTranscript, updateTranscriptJump, beginScopedRequest, isCurrentScopedRequest, finishScopedRequest, cancelScopedRequest, isAbortError, getJson, postJson, deleteJson, dashboardFetch, responseJson, dashboardJsonHeaders, dashboardCsrfToken, messageText, messageDisplayText, userMessageDisplayText, normalizeAttachmentMetadata, imageAttachmentLine, renderMessageText, renderLinkedText, bindRichContent, linkifyFileTextNodes, replaceFileReferences, isLikelyLocalFileReference, resolveDisplayFilePath, normalizeFileReferencePath, parentDirectory, filePreviewUrl, rawFileUrl, apiFileUrl, imagePreviewUrl, isSafeInlineBitmapUrl, normalizeRelativePath, isWorkspaceRelativeToBase, copyCodeBlock, previewText, formatNumber, formatBytes, escapeHtml, escapeAttribute, formatTime, formatRelativeTime } from "./app-barrel.ts"; export function activateModal(modal: HTMLElement | null | undefined, options: { initialFocus?: string; returnFocus?: Element | null } = {}) { @@ -774,7 +774,15 @@ export function renderAttachmentStrip() { if (isDocument && /\.pdf$/i.test(label)) { const range = document.createElement("div"); range.className = "attachment-page-range"; - for (const [key, labelText, placeholder] of [["pageStart", "起始页", "默认"], ["pageEnd", "结束页", "默认"]] as const) { + const hint = document.createElement("p"); + hint.className = "attachment-page-range-hint"; + hint.textContent = "不填则扫描件只识别前几页;全量视觉请填写结束页"; + range.append(hint); + for (const [key, labelText, placeholder] of [["pageStart", "起始页", "1"], ["pageEnd", "结束页", "末页"]] as const) { + const field = document.createElement("label"); + field.className = "attachment-page-field"; + const caption = document.createElement("span"); + caption.textContent = labelText; const input = document.createElement("input"); input.type = "number"; input.min = "1"; @@ -786,7 +794,9 @@ export function renderAttachmentStrip() { input.setAttribute("aria-label", `${label} ${labelText}`); input.value = attachment[key] === undefined ? "" : String(attachment[key]); input.addEventListener("input", () => { attachment[key] = input.value ? Number(input.value) : undefined; }); - range.append(input as unknown as Node); + field.append(caption); + field.append(input as unknown as Node); + range.append(field as unknown as Node); } item.append(range); } @@ -824,6 +834,14 @@ export function clearAttachments() { renderAttachmentStrip(); } +export function attachmentSignature(attachments: Array<{ id?: string; name?: string; mimeType?: string; size?: number; pageStart?: number; pageEnd?: number }> = []) { + return JSON.stringify(attachments.map((item) => [item.id, item.name, item.mimeType, item.size, item.pageStart, item.pageEnd])); +} + +export function composerHasOutboundContent() { + return Boolean(els.promptInput?.value?.trim()) || state.attachments.length > 0; +} + export async function sendPrompt() { if (state.turnSubmitting) { return; @@ -841,6 +859,9 @@ export async function sendPrompt() { showTrustPanel(); return; } + const submittedSessionId = state.currentSessionId ?? null; + const submittedPrompt = prompt; + const submittedAttachments = attachmentSignature(attachments); state.turnSubmitting = true; updateSendButton(); els.runStatus.textContent = state.running ? "已排队" : "启动中"; @@ -851,19 +872,25 @@ export async function sendPrompt() { requestId: turnRequest.id, prompt, attachments: attachments.map(attachmentPayload), - sessionId: state.currentSessionId, - clientId: state.currentSessionId ? undefined : dashboardClientId(), + sessionId: submittedSessionId, + clientId: submittedSessionId ? undefined : dashboardClientId(), permissionMode: state.permissionMode, goalMode: state.goal.enabled === true, goalText: state.goal.enabled ? state.goal.text : undefined, clientPreviousPermissionMode: state.goal.enabled ? state.goal.previousPermissionMode : undefined - }); + }, { timeoutMs: DASHBOARD_TURN_START_TIMEOUT_MS }); } catch (error) { result = { ok: false, error: errorMessageOf(error) }; } finally { state.turnSubmitting = false; updateSendButton(); } + const sessionChanged = (state.currentSessionId ?? null) !== submittedSessionId; + const draftUnchanged = els.promptInput.value.trim() === submittedPrompt + && attachmentSignature(state.attachments) === submittedAttachments; + if (sessionChanged) { + return; + } if (!result.ok) { if (Number.isFinite(Number(result.status))) { state.turnRequest = null; @@ -878,9 +905,11 @@ export async function sendPrompt() { return; } state.turnRequest = null; - els.promptInput.value = ""; - resizePromptInput(); - clearAttachments(); + if (draftUnchanged) { + els.promptInput.value = ""; + resizePromptInput(); + clearAttachments(); + } state.queue = result.queue ?? state.queue; state.running = result.running === true || state.running; applyGoalSnapshot(result.goal ?? state.goal, { permissionMode: result.permission?.mode ?? state.permissionMode }); diff --git a/src/dashboard/public/app-ui4.ts b/src/dashboard/public/app-ui4.ts index 7f1cc56..8cf7248 100644 --- a/src/dashboard/public/app-ui4.ts +++ b/src/dashboard/public/app-ui4.ts @@ -539,10 +539,17 @@ export function backgroundSubagentTitle(item: DashboardActivity) { return `${profile}子智能体运行中`; } +function waitForDisplay(value: unknown) { + if (value === "all") return "等待全部完成"; + if (value === "any") return "等待任一完成"; + if (value === "none") return "完成后不接续"; + return value ? String(value) : ""; +} + export function backgroundSubagentMeta(item: DashboardActivity) { if (item.kind === "terminal") { return [ - item.taskId ? `task=${item.taskId}` : null, + item.taskId ? `任务 ${item.taskId}` : null, item.status === "starting" ? "启动中" : null, item.status === "cancelling" ? "退出确认中" : null, item.runningCount === 1 ? "运行中" : null, @@ -550,9 +557,9 @@ export function backgroundSubagentMeta(item: DashboardActivity) { ].filter(Boolean).join(" · "); } return [ - item.groupId ? `group=${item.groupId}` : null, - item.taskId ? `task=${item.taskId}` : null, - item.waitFor ? `waitFor=${item.waitFor}` : null, + item.groupId ? `组 ${item.groupId}` : null, + item.taskId ? `任务 ${item.taskId}` : null, + waitForDisplay(item.waitFor) || null, Number.isFinite(item.runningCount) && Number.isFinite(item.taskCount) ? `${item.runningCount}/${item.taskCount} 运行中` : null, item.lastProgressAt ? `进展 ${formatRelativeTime(item.lastProgressAt)}` : null, item.heartbeatAt ? `心跳 ${formatRelativeTime(item.heartbeatAt)}` : null, diff --git a/src/dashboard/public/app-ui8.ts b/src/dashboard/public/app-ui8.ts index c32865c..9406d1f 100644 --- a/src/dashboard/public/app-ui8.ts +++ b/src/dashboard/public/app-ui8.ts @@ -1,9 +1,10 @@ +import { toolLabel } from "../../tools/labels.ts"; import { renderMarkdown } from "./markdown.ts"; import { hydrateRichContent } from "./rich-renderers.ts"; import { visibleTranscriptRole } from "./transcript.ts"; import { MANUAL_AGENT_MODEL_VALUE, state, els, MODE_DESCRIPTIONS, LOCAL_FILE_EXTENSIONS, FILE_REFERENCE_PATTERN, TRANSCRIPT_DOM_LIMIT, EVENT_STALE_AFTER_MS, EVENT_CONNECT_TIMEOUT_MS, EVENT_RECONNECT_MAX_ATTEMPTS, DASHBOARD_REQUEST_TIMEOUT_MS, DASHBOARD_API_VERSION, DASHBOARD_LIFECYCLE_TIMEOUT_MS, DASHBOARD_SHUTDOWN_TIMEOUT_MS, DASHBOARD_INTERRUPT_TIMEOUT_MS, MAX_IMAGE_ATTACHMENTS, MAX_IMAGE_ATTACHMENT_BYTES, CURRENT_SESSION_STORAGE_KEY, DASHBOARD_CLIENT_STORAGE_KEY, PREVIEW_WIDTH_STORAGE_KEY, PREVIEW_WIDTH_DEFAULT, PREVIEW_WIDTH_MIN, PREVIEW_WIDTH_MAX, PREVIEW_WORKSPACE_MIN , emptySessionStatus, emptyBackgroundSubagent } from "./app-core.ts"; import type { DashboardConfigSource, DashboardReasoningEffort, DashboardTurnChangeStats, DashboardScopedDefaultSelection, DashboardVisionAgent, DashboardReasoningDiscovery, DashboardReasoningCapabilityCandidate, DashboardGatewayProbeModel, DashboardGatewayProbeResult, DashboardLifecycleActivity, DashboardSettings, DashboardFile, DashboardTableSheet, DashboardTablePreview, DashboardLightboxItem, DashboardQuestionChoice, DashboardPendingQuestion, DashboardApproval, DashboardGatewayTransport, DashboardScopedRequest, DashboardFetchOptions, DashboardModelSource, DashboardSessionStatus, DashboardApiResult, DashboardActivity, DashboardModelOption, DashboardGatewayProfile, DashboardGatewayConfig, DashboardModelSelection, DashboardPendingGuide, DashboardStreamEvent, DashboardUiState , DashboardSessionSummary } from "./app-core.ts"; -import { eventTargetOf, eventElement, isPlainObject, modelSourceOf, errorMessageOf, init, bootstrapDashboard, observeRunStatus, updateRunStatusTone, bindEvents, normalizedResponsiveView, composerHeightFor, previewWidthBounds, clampedPreviewWidth, permissionIndexForKey, focusTrapTarget, shouldFollowTranscript, scheduleAnimationFrameOnce, cancelScheduledAnimationFrame, appendPlainDraftDelta, renderFinalAssistantBody, selectTranscriptNodesToRemove, responsiveLayoutMode, restorePreviewWidth, setPreviewWidth, syncPreviewResizeHandle, beginPreviewResize, updatePreviewResize, finishPreviewResize, handlePreviewResizeKeydown, setResponsiveView, syncResponsiveNavigation, setResponsiveSurfaceInert, handleResponsiveFileNavigation, syncVisualViewport, resizePromptInput, handlePermissionModeKeydown, requestPermissionMode, defaultGoalMaxAutoContinues, emptyGoalSnapshot, applyGoalSnapshot, renderGoalControls, renderGoalStatusBar, requestGoalMode, showGoalConfirm, hideGoalConfirm, showGoalTextPanel, hideGoalTextPanel, enableGoalWithObjective, submitGoalAction, adoptGoalRunResult, showPermissionConfirm, hidePermissionConfirm, updateContextActions, announceStatus, modalFocusableElements, activateModal, collectModalBackground, focusModalInitialTarget, deactivateModal, restoreModalAttribute, handleGlobalKeydown, closeActiveModal, loadTrust, loadSessions, restoreInitialSession, latestBackgroundSessionId, initialSessionId, rememberCurrentSession, renderSessions, sessionMeta, sessionStatusView, toggleSidebar, setSidebarCollapsed, sessionsNeedRefresh, scheduleSessionsRefresh, handleSessionAction, openSession, restoreBackgroundSnapshot, deleteSession, setSessionsRefreshState, copySessionId, newTask, rememberNewTaskModelState, restoreNewTaskModelState, refreshNewTaskModelState, addAttachmentFiles, readImageAttachment, renderAttachmentStrip, attachmentPayload, clearAttachments, sendPrompt, stableTurnRequest, dashboardRequestId, dashboardClientId, statusUrl, interruptTurn, guideTurn, cancelQueuedTurn, cancelBackgroundSubagent, backgroundCancelKey, connectEvents, ensureEventsConnected, disconnectEvents, closeEventSource, markEventConnectionAlive, armEventConnectTimer, armEventStaleTimer, scheduleEventReconnect, reconnectEventsManually, clearEventReconnectTimer, clearEventConnectTimer, clearEventStaleTimer, setConnectionState, resetEventReplayState, rememberEventCursor, handleDashboardEvent, shouldSkipDashboardEvent, beginEventTurn, renderTranscriptMessages, renderSessionFailure, setTranscriptPaging, renderTranscriptHistoryStatus, removeTranscriptHistoryStatus, transcriptFirstContentNode, handleTranscriptScroll, loadOlderTranscript, renderWorkflowPanel, renderWorkflowStrip, currentWorkflowItem, workflowSection, workflowItem, normalizeWorkflowStatus, summarizeWorkflow, appendMessage, createMessageNode, appendTranscriptNode, trimTranscriptWindow, isProtectedTranscriptNode, renderTranscriptWindowMarker, captureTranscriptViewportAnchor, restoreTranscriptViewportAnchor, transcriptNodeTop, restoreTranscriptNodeAnchor, resetTranscriptWindow, appendAssistantDraft, scheduleDraftRender, renderAssistantDraft, appendActivity, appendContextBoundary, contextBoundaryText, handleActivity, isBackgroundSubagentActivity, handleBackgroundSubagentActivity, clearBackgroundSubagentStatus, reconcileBackgroundSubagentSnapshot, backgroundSubagentDisplayStatus, backgroundSubagentVisible, updateLiveActivity, removeLiveActivity, setLiveTitle, toggleLiveStatusDetails, updateLiveStatus, liveStatusTitle, primaryLiveActivity, gatewayRetryChipText, renderBackgroundSubagentStatus, backgroundSubagentCompactLabel, backgroundSubagentTitle, backgroundSubagentMeta, backgroundSubagentCancellable, resetLiveStatus, backgroundSubagentCounts, idleRunStatus, applyIdleRunStatus, updateRunStatusForBackground, updateSessionStatus, updateTurnChangeStats, resetTurnChangeStats, normalizeChangeStats, renderComposerStatus, modelStatusHtml, unresolvedModelStatusHtml, handleModelStatusActivate, handleModelStatusKeydown, toggleModelPanel, hideModelPanel, showSettingsWorkspace, refreshSettingsConfiguration, hideSettingsWorkspace, showModelConfigPanel, hideModelConfigPanel, renderModelPanel, modelCapabilityLabels, handleModelPanelClick, handleModelPanelChange, renderSettingsView, syncSettingsRail, modelSettingsHtml, transcriptSettingsHtml, transcriptRetentionOptionsHtml, networkSettingsHtml, networkModeOptionHtml, agentSettingsHtml, reliabilitySettingsHtml, settingsSectionHeading, settingsToggleHtml, managedFieldHtml, settingsDisabled, settingsFormActions, settingsFeedbackHtml, settingsGatewayProfileHtml, gatewayProfileReadonlyLabel, providerModelKey, scopedDefaultModelLabel, settingsModelHtml, handleSettingsRailClick, initializeSettingsFormTracking, handleSettingsFormChange, settingsControlValue, changedSettingsFields, canonicalSettingsField, setSettingsFormSaving, renderSettingsFeedbackInPlace, saveSettingsConfig, handleSettingsClick, protocolDisplayName, agentModelPickerHtml, renderModelConfigPanel, handleModelConfigPanelClick, handleModelConfigInput, handleModelConfigChange, markModelConfigCredentialChanged, markModelConfigEndpointChanged, handleModelConfigModelIdChanged, markReasoningCapabilityManual, clearReasoningCapabilityControls, syncGatewayUrlHint, gatewayUrlPlaceholder, gatewayUrlHint, syncReasoningDefaultOptions, probeGateway, isCurrentModelConfigRequest, currentGatewayProbeResult, currentGatewayCatalogModels, modelConfigGatewayProfile, modelConfigEndpointChanged, modelConfigAgentModelsSnapshot, initializeAgentModelPickerSnapshot, syncAgentModelPickersForEndpoint, uniqueAgentModelCandidates, appendAgentModelOptions, renderAgentModelPickers, updateAgentModelPickerManualStatus, handleAgentModelSelection, renderGatewayProbeResult, applyProbedModel, applyGatewayDiscoveredModel, applySuggestedGatewayUrl, gatewayCredentialAction, normalizeGatewayProbeModels, normalizeReasoningDiscovery, reasoningCapabilityCandidate, applyReasoningCapabilityCandidate, ensureReasoningEffortOptions, applyPendingReasoningCapabilities, reasoningCapabilityIsActionable, renderReasoningCapabilityStatus, reasoningCapabilityStatusText, reasoningDiscoveryStatusText, probeModelCapabilities, setFormControlsSaving, setModelConfigFormSaving, renderModelConfigFailure, clearModelConfigFailure, manualAgentModelIds, saveModelConfig, saveDefaultModelSelection, switchModel, handleReasoningEffortChange, switchReasoningEffort, deleteGatewayProfile, deleteModel, updateConfigRevisions, normalizeScopedDefaultSelection, configScope, configMutationMetadata, isConfigRevisionConflict, configRevisionConflictMessage, refreshConfigRevisionsAfterConflict, normalizeGatewayConfig, normalizeDashboardSettings, mergeGatewayConfig, normalizeGatewayProfiles, normalizeConfigSource, normalizeModels, normalizeModelSource, normalizeReasoningEfforts, normalizedReasoningEffort, isDisabledReasoningEffort, configuredReasoningEffort, reasoningEffortFallbackLabel, localizedReasoningEffortLabel, reasoningEffortCatalog, reasoningEffortLabel, resolveAtomicModelSelection, currentModelSelection, currentSessionNeedsModelSelection, currentGatewayProfile, gatewayProfileById, settingsInspectedGatewayProfile, modelSourceLabel, markCurrentModel, currentModelInfo, modelDisplayName, normalizeAgentModelTiers, normalizeVisionAgent, firstVisionModelId, hasAgentModelTiers, agentModelTiersSummary, gatewaySummary, modelSaveTargetLabel, gatewaySourceNote, environmentGatewayDefaultNote, sourceBadge, lockClosedDashboard, normalizeLifecycleActivity, shutdownRequestBody, shutdownResultIsClosed, lifecycleActivitySummary, renderShutdownActivity, renderFiles, currentImageFiles, openFile, renderOfficePreview, officePreviewMeta, officePreviewBodyHtml, renderTablePreview, normalizeTablePreview, tablePreviewMeta, renderCompactTableHtml, renderExpandedTableHtml, renderTableHtml, tableTruncationNote, maxVisibleColumns, columnLabel, renderSheetPreviewHtml, renderSheetCellHtml, resetPreview, fencedDataForFile, dataLanguageForExtension, showImageLightbox, showTableLightbox, renderLightboxImage, bindTableLightboxControls, moveLightbox, hideLightbox, setPermissionMode, clearTranscript, cancelTranscriptAnimationFrames, clearAssistantDraftTimers, hideEmptyState, showError, showNotice, renderBootstrapLoading, renderBootstrapFailure, dashboardPayloadError, bootstrapFailurePresentation, clearBootstrapStatus, scrollTranscript, isTranscriptNearBottom, syncTranscriptFollowState, followTranscript, updateTranscriptJump, beginScopedRequest, isCurrentScopedRequest, finishScopedRequest, cancelScopedRequest, isAbortError, getJson, postJson, deleteJson, dashboardFetch, responseJson, dashboardJsonHeaders, dashboardCsrfToken, messageText, messageDisplayText, userMessageDisplayText, normalizeAttachmentMetadata, imageAttachmentLine, renderMessageText, renderLinkedText, bindRichContent, linkifyFileTextNodes, replaceFileReferences, isLikelyLocalFileReference, resolveDisplayFilePath, normalizeFileReferencePath, parentDirectory, filePreviewUrl, rawFileUrl, apiFileUrl, imagePreviewUrl, isSafeInlineBitmapUrl, normalizeRelativePath, isWorkspaceRelativeToBase, copyCodeBlock, previewText, formatNumber, formatBytes, escapeHtml, escapeAttribute, formatTime, formatRelativeTime } from "./app-barrel.ts"; +import { eventTargetOf, eventElement, isPlainObject, modelSourceOf, errorMessageOf, init, bootstrapDashboard, observeRunStatus, updateRunStatusTone, bindEvents, normalizedResponsiveView, composerHeightFor, previewWidthBounds, clampedPreviewWidth, permissionIndexForKey, focusTrapTarget, shouldFollowTranscript, scheduleAnimationFrameOnce, cancelScheduledAnimationFrame, appendPlainDraftDelta, renderFinalAssistantBody, selectTranscriptNodesToRemove, responsiveLayoutMode, restorePreviewWidth, setPreviewWidth, syncPreviewResizeHandle, beginPreviewResize, updatePreviewResize, finishPreviewResize, handlePreviewResizeKeydown, setResponsiveView, syncResponsiveNavigation, setResponsiveSurfaceInert, handleResponsiveFileNavigation, syncVisualViewport, resizePromptInput, handlePermissionModeKeydown, requestPermissionMode, defaultGoalMaxAutoContinues, emptyGoalSnapshot, applyGoalSnapshot, renderGoalControls, renderGoalStatusBar, requestGoalMode, showGoalConfirm, hideGoalConfirm, showGoalTextPanel, hideGoalTextPanel, enableGoalWithObjective, submitGoalAction, adoptGoalRunResult, showPermissionConfirm, hidePermissionConfirm, updateContextActions, announceStatus, modalFocusableElements, activateModal, collectModalBackground, focusModalInitialTarget, deactivateModal, restoreModalAttribute, handleGlobalKeydown, closeActiveModal, loadTrust, loadSessions, restoreInitialSession, latestBackgroundSessionId, initialSessionId, rememberCurrentSession, renderSessions, sessionMeta, sessionStatusView, toggleSidebar, setSidebarCollapsed, sessionsNeedRefresh, scheduleSessionsRefresh, handleSessionAction, openSession, restoreBackgroundSnapshot, deleteSession, setSessionsRefreshState, copySessionId, newTask, rememberNewTaskModelState, restoreNewTaskModelState, refreshNewTaskModelState, addAttachmentFiles, readImageAttachment, renderAttachmentStrip, attachmentPayload, clearAttachments, sendPrompt, composerHasOutboundContent, stableTurnRequest, dashboardRequestId, dashboardClientId, statusUrl, interruptTurn, guideTurn, cancelQueuedTurn, cancelBackgroundSubagent, backgroundCancelKey, connectEvents, ensureEventsConnected, disconnectEvents, closeEventSource, markEventConnectionAlive, armEventConnectTimer, armEventStaleTimer, scheduleEventReconnect, reconnectEventsManually, clearEventReconnectTimer, clearEventConnectTimer, clearEventStaleTimer, setConnectionState, resetEventReplayState, rememberEventCursor, handleDashboardEvent, shouldSkipDashboardEvent, beginEventTurn, renderTranscriptMessages, renderSessionFailure, setTranscriptPaging, renderTranscriptHistoryStatus, removeTranscriptHistoryStatus, transcriptFirstContentNode, handleTranscriptScroll, loadOlderTranscript, renderWorkflowPanel, renderWorkflowStrip, currentWorkflowItem, workflowSection, workflowItem, normalizeWorkflowStatus, summarizeWorkflow, appendMessage, createMessageNode, appendTranscriptNode, trimTranscriptWindow, isProtectedTranscriptNode, renderTranscriptWindowMarker, captureTranscriptViewportAnchor, restoreTranscriptViewportAnchor, transcriptNodeTop, restoreTranscriptNodeAnchor, resetTranscriptWindow, appendAssistantDraft, scheduleDraftRender, renderAssistantDraft, appendActivity, appendContextBoundary, contextBoundaryText, handleActivity, isBackgroundSubagentActivity, handleBackgroundSubagentActivity, clearBackgroundSubagentStatus, reconcileBackgroundSubagentSnapshot, backgroundSubagentDisplayStatus, backgroundSubagentVisible, updateLiveActivity, removeLiveActivity, setLiveTitle, toggleLiveStatusDetails, updateLiveStatus, liveStatusTitle, primaryLiveActivity, gatewayRetryChipText, renderBackgroundSubagentStatus, backgroundSubagentCompactLabel, backgroundSubagentTitle, backgroundSubagentMeta, backgroundSubagentCancellable, resetLiveStatus, backgroundSubagentCounts, idleRunStatus, applyIdleRunStatus, updateRunStatusForBackground, updateSessionStatus, updateTurnChangeStats, resetTurnChangeStats, normalizeChangeStats, renderComposerStatus, modelStatusHtml, unresolvedModelStatusHtml, handleModelStatusActivate, handleModelStatusKeydown, toggleModelPanel, hideModelPanel, showSettingsWorkspace, refreshSettingsConfiguration, hideSettingsWorkspace, showModelConfigPanel, hideModelConfigPanel, renderModelPanel, modelCapabilityLabels, handleModelPanelClick, handleModelPanelChange, renderSettingsView, syncSettingsRail, modelSettingsHtml, transcriptSettingsHtml, transcriptRetentionOptionsHtml, networkSettingsHtml, networkModeOptionHtml, agentSettingsHtml, reliabilitySettingsHtml, settingsSectionHeading, settingsToggleHtml, managedFieldHtml, settingsDisabled, settingsFormActions, settingsFeedbackHtml, settingsGatewayProfileHtml, gatewayProfileReadonlyLabel, providerModelKey, scopedDefaultModelLabel, settingsModelHtml, handleSettingsRailClick, initializeSettingsFormTracking, handleSettingsFormChange, settingsControlValue, changedSettingsFields, canonicalSettingsField, setSettingsFormSaving, renderSettingsFeedbackInPlace, saveSettingsConfig, handleSettingsClick, protocolDisplayName, agentModelPickerHtml, renderModelConfigPanel, handleModelConfigPanelClick, handleModelConfigInput, handleModelConfigChange, markModelConfigCredentialChanged, markModelConfigEndpointChanged, handleModelConfigModelIdChanged, markReasoningCapabilityManual, clearReasoningCapabilityControls, syncGatewayUrlHint, gatewayUrlPlaceholder, gatewayUrlHint, syncReasoningDefaultOptions, probeGateway, isCurrentModelConfigRequest, currentGatewayProbeResult, currentGatewayCatalogModels, modelConfigGatewayProfile, modelConfigEndpointChanged, modelConfigAgentModelsSnapshot, initializeAgentModelPickerSnapshot, syncAgentModelPickersForEndpoint, uniqueAgentModelCandidates, appendAgentModelOptions, renderAgentModelPickers, updateAgentModelPickerManualStatus, handleAgentModelSelection, renderGatewayProbeResult, applyProbedModel, applyGatewayDiscoveredModel, applySuggestedGatewayUrl, gatewayCredentialAction, normalizeGatewayProbeModels, normalizeReasoningDiscovery, reasoningCapabilityCandidate, applyReasoningCapabilityCandidate, ensureReasoningEffortOptions, applyPendingReasoningCapabilities, reasoningCapabilityIsActionable, renderReasoningCapabilityStatus, reasoningCapabilityStatusText, reasoningDiscoveryStatusText, probeModelCapabilities, setFormControlsSaving, setModelConfigFormSaving, renderModelConfigFailure, clearModelConfigFailure, manualAgentModelIds, saveModelConfig, saveDefaultModelSelection, switchModel, handleReasoningEffortChange, switchReasoningEffort, deleteGatewayProfile, deleteModel, updateConfigRevisions, normalizeScopedDefaultSelection, configScope, configMutationMetadata, isConfigRevisionConflict, configRevisionConflictMessage, refreshConfigRevisionsAfterConflict, normalizeGatewayConfig, normalizeDashboardSettings, mergeGatewayConfig, normalizeGatewayProfiles, normalizeConfigSource, normalizeModels, normalizeModelSource, normalizeReasoningEfforts, normalizedReasoningEffort, isDisabledReasoningEffort, configuredReasoningEffort, reasoningEffortFallbackLabel, localizedReasoningEffortLabel, reasoningEffortCatalog, reasoningEffortLabel, resolveAtomicModelSelection, currentModelSelection, currentSessionNeedsModelSelection, currentGatewayProfile, gatewayProfileById, settingsInspectedGatewayProfile, modelSourceLabel, markCurrentModel, currentModelInfo, modelDisplayName, normalizeAgentModelTiers, normalizeVisionAgent, firstVisionModelId, hasAgentModelTiers, agentModelTiersSummary, gatewaySummary, modelSaveTargetLabel, gatewaySourceNote, environmentGatewayDefaultNote, sourceBadge, lockClosedDashboard, normalizeLifecycleActivity, shutdownRequestBody, shutdownResultIsClosed, lifecycleActivitySummary, renderShutdownActivity, renderFiles, currentImageFiles, openFile, renderOfficePreview, officePreviewMeta, officePreviewBodyHtml, renderTablePreview, normalizeTablePreview, tablePreviewMeta, renderCompactTableHtml, renderExpandedTableHtml, renderTableHtml, tableTruncationNote, maxVisibleColumns, columnLabel, renderSheetPreviewHtml, renderSheetCellHtml, resetPreview, fencedDataForFile, dataLanguageForExtension, showImageLightbox, showTableLightbox, renderLightboxImage, bindTableLightboxControls, moveLightbox, hideLightbox, setPermissionMode, clearTranscript, cancelTranscriptAnimationFrames, clearAssistantDraftTimers, hideEmptyState, showError, showNotice, renderBootstrapLoading, renderBootstrapFailure, dashboardPayloadError, bootstrapFailurePresentation, clearBootstrapStatus, scrollTranscript, isTranscriptNearBottom, syncTranscriptFollowState, followTranscript, updateTranscriptJump, beginScopedRequest, isCurrentScopedRequest, finishScopedRequest, cancelScopedRequest, isAbortError, getJson, postJson, deleteJson, dashboardFetch, responseJson, dashboardJsonHeaders, dashboardCsrfToken, messageText, messageDisplayText, userMessageDisplayText, normalizeAttachmentMetadata, imageAttachmentLine, renderMessageText, renderLinkedText, bindRichContent, linkifyFileTextNodes, replaceFileReferences, isLikelyLocalFileReference, resolveDisplayFilePath, normalizeFileReferencePath, parentDirectory, filePreviewUrl, rawFileUrl, apiFileUrl, imagePreviewUrl, isSafeInlineBitmapUrl, normalizeRelativePath, isWorkspaceRelativeToBase, copyCodeBlock, previewText, formatNumber, formatBytes, escapeHtml, escapeAttribute, formatTime, formatRelativeTime } from "./app-barrel.ts"; export function sourceLabel(source: DashboardConfigSource | string | null | undefined) { const type = typeof source === "string" ? source : String(source?.type ?? ""); if (type === "project") return "当前项目"; @@ -207,7 +208,7 @@ export function renderApprovalPanel(approval: DashboardApproval) { els.approvalPanel.setAttribute("aria-labelledby", "approval-title"); const queued = state.approvalQueue.length; els.approvalPanel.innerHTML = ` -
需要权限确认 · ${escapeHtml(approval.toolName)}${queued > 0 ? ` · 还有 ${queued} 个排队` : ""}
+
需要权限确认 · ${escapeHtml(toolLabel(approval.toolName))}${queued > 0 ? ` · 还有 ${queued} 个排队` : ""}
${escapeHtml([ approval.reason, queued > 0 ? `还有 ${queued} 个权限请求排队。` : "", @@ -228,7 +229,7 @@ export function renderApprovalPanel(approval: DashboardApproval) { activateModal(els.approvalPanel, { initialFocus: "button[data-action='allow-once']" }); revealInteractionPanel(els.approvalPanel, "button[data-action]"); updateLiveStatus(); - announceStatus(`需要确认 ${approval.toolName ?? "工具"} 权限`); + announceStatus(`需要确认 ${toolLabel(approval.toolName)} 权限`); } export async function resolveApproval(action: unknown) { @@ -280,7 +281,7 @@ function refreshApprovalQueueLabel() { return; } const queued = state.approvalQueue.length; - title.textContent = `需要权限确认 · ${state.pendingApproval.toolName ?? "工具"}${queued > 0 ? ` · 还有 ${queued} 个排队` : ""}`; + title.textContent = `需要权限确认 · ${toolLabel(state.pendingApproval.toolName)}${queued > 0 ? ` · 还有 ${queued} 个排队` : ""}`; } function mountApprovalPanelOverlay() { @@ -793,8 +794,13 @@ export function updateSendButton() { return; } if (state.running) { - els.sendButton.textContent = "中断"; - els.sendButton.title = "点击中断当前任务"; + if (composerHasOutboundContent()) { + els.sendButton.textContent = "加入队列"; + els.sendButton.title = "将当前输入加入队列,不中断正在运行的任务"; + } else { + els.sendButton.textContent = "中断"; + els.sendButton.title = "点击中断当前任务"; + } } else { els.sendButton.textContent = "发送"; els.sendButton.title = "发送"; diff --git a/src/dashboard/public/app.js b/src/dashboard/public/app.js index 08e3066..366b650 100644 --- a/src/dashboard/public/app.js +++ b/src/dashboard/public/app.js @@ -366,6 +366,7 @@ var EVENT_STALE_AFTER_MS = 35e3; var EVENT_CONNECT_TIMEOUT_MS = 1e4; var EVENT_RECONNECT_MAX_ATTEMPTS = 6; var DASHBOARD_REQUEST_TIMEOUT_MS = 15e3; +var DASHBOARD_TURN_START_TIMEOUT_MS = 6e4; var DASHBOARD_API_VERSION = "dashboard.v2"; var DASHBOARD_LIFECYCLE_TIMEOUT_MS = 5e3; var DASHBOARD_SHUTDOWN_TIMEOUT_MS = 15e3; @@ -1090,7 +1091,15 @@ function renderAttachmentStrip() { if (isDocument && /\.pdf$/i.test(label)) { const range = document.createElement("div"); range.className = "attachment-page-range"; - for (const [key, labelText, placeholder] of [["pageStart", "起始页", "默认"], ["pageEnd", "结束页", "默认"]]) { + const hint = document.createElement("p"); + hint.className = "attachment-page-range-hint"; + hint.textContent = "不填则扫描件只识别前几页;全量视觉请填写结束页"; + range.append(hint); + for (const [key, labelText, placeholder] of [["pageStart", "起始页", "1"], ["pageEnd", "结束页", "末页"]]) { + const field = document.createElement("label"); + field.className = "attachment-page-field"; + const caption = document.createElement("span"); + caption.textContent = labelText; const input = document.createElement("input"); input.type = "number"; input.min = "1"; @@ -1102,7 +1111,9 @@ function renderAttachmentStrip() { input.addEventListener("input", () => { attachment[key] = input.value ? Number(input.value) : void 0; }); - range.append(input); + field.append(caption); + field.append(input); + range.append(field); } item.append(range); } @@ -1135,6 +1146,12 @@ function clearAttachments() { } renderAttachmentStrip(); } +function attachmentSignature(attachments = []) { + return JSON.stringify(attachments.map((item) => [item.id, item.name, item.mimeType, item.size, item.pageStart, item.pageEnd])); +} +function composerHasOutboundContent() { + return Boolean(els.promptInput?.value?.trim()) || state.attachments.length > 0; +} async function sendPrompt() { if (state.turnSubmitting) { return; @@ -1152,6 +1169,9 @@ async function sendPrompt() { showTrustPanel(); return; } + const submittedSessionId = state.currentSessionId ?? null; + const submittedPrompt = prompt; + const submittedAttachments = attachmentSignature(attachments); state.turnSubmitting = true; updateSendButton(); els.runStatus.textContent = state.running ? "已排队" : "启动中"; @@ -1162,19 +1182,24 @@ async function sendPrompt() { requestId: turnRequest.id, prompt, attachments: attachments.map(attachmentPayload), - sessionId: state.currentSessionId, - clientId: state.currentSessionId ? void 0 : dashboardClientId(), + sessionId: submittedSessionId, + clientId: submittedSessionId ? void 0 : dashboardClientId(), permissionMode: state.permissionMode, goalMode: state.goal.enabled === true, goalText: state.goal.enabled ? state.goal.text : void 0, clientPreviousPermissionMode: state.goal.enabled ? state.goal.previousPermissionMode : void 0 - }); + }, { timeoutMs: DASHBOARD_TURN_START_TIMEOUT_MS }); } catch (error) { result = { ok: false, error: errorMessageOf(error) }; } finally { state.turnSubmitting = false; updateSendButton(); } + const sessionChanged = (state.currentSessionId ?? null) !== submittedSessionId; + const draftUnchanged = els.promptInput.value.trim() === submittedPrompt && attachmentSignature(state.attachments) === submittedAttachments; + if (sessionChanged) { + return; + } if (!result.ok) { if (Number.isFinite(Number(result.status))) { state.turnRequest = null; @@ -1189,9 +1214,11 @@ async function sendPrompt() { return; } state.turnRequest = null; - els.promptInput.value = ""; - resizePromptInput(); - clearAttachments(); + if (draftUnchanged) { + els.promptInput.value = ""; + resizePromptInput(); + clearAttachments(); + } state.queue = result.queue ?? state.queue; state.running = result.running === true || state.running; applyGoalSnapshot(result.goal ?? state.goal, { permissionMode: result.permission?.mode ?? state.permissionMode }); @@ -1415,6 +1442,8 @@ function updateRunStatusTone2() { let tone = "idle"; if (/失败|拒绝/.test(status)) { tone = "error"; + } else if (/失联|无进展/.test(status)) { + tone = "error"; } else if (/等待|待/.test(status)) { tone = "waiting"; } else if (/运行|启动|引导|中断|停止|收尾|排队|压缩/.test(status)) { @@ -1442,6 +1471,10 @@ function bindEvents2() { return; } if (state.running) { + if (composerHasOutboundContent()) { + sendPrompt(); + return; + } interruptTurn(); return; } @@ -1456,6 +1489,7 @@ function bindEvents2() { els.promptInput.addEventListener("input", () => { syncGuideButton(); resizePromptInput(); + updateSendButton(); }); els.attachButton.addEventListener("click", () => els.attachmentInput.click()); els.attachmentInput.addEventListener("change", async () => { @@ -1763,10 +1797,20 @@ function setResponsiveView(view) { state.responsiveView = normalizedResponsiveView2(Number(window.innerWidth) || 1200, view); syncResponsiveNavigation(); } +function emptyStateCopy(width = Number(window.innerWidth) || 1200) { + return width >= 1200 ? "过程折叠。结果展开。文件留在右侧。" : "过程折叠。结果展开。文件在「文件」页。"; +} +function syncEmptyStateCopy(width = Number(window.innerWidth) || 1200) { + const copy = document.querySelector("#empty-copy"); + if (copy) { + copy.textContent = emptyStateCopy(width); + } +} function syncResponsiveNavigation() { const width = Number(window.innerWidth) || Number(document.documentElement?.clientWidth) || 1200; const view = normalizedResponsiveView2(width, state.responsiveView); state.responsiveView = view; + syncEmptyStateCopy(width); if (width < 1200) { state.sidebarCollapsed = false; document.body.classList.remove("sidebar-collapsed", "preview-collapsed"); @@ -2058,7 +2102,7 @@ async function enableGoalWithObjective2(text) { goalText: text, clientPreviousPermissionMode: previous, clientId: dashboardClientId() - }); + }, { timeoutMs: DASHBOARD_TURN_START_TIMEOUT_MS }); } catch (error) { result = { ok: false, error: errorMessageOf(error) }; } finally { @@ -3638,10 +3682,16 @@ function backgroundSubagentTitle4(item) { if (item.status === "stale") return `${profile}子智能体长时间无进展`; return `${profile}子智能体运行中`; } +function waitForDisplay(value) { + if (value === "all") return "等待全部完成"; + if (value === "any") return "等待任一完成"; + if (value === "none") return "完成后不接续"; + return value ? String(value) : ""; +} function backgroundSubagentMeta4(item) { if (item.kind === "terminal") { return [ - item.taskId ? `task=${item.taskId}` : null, + item.taskId ? `任务 ${item.taskId}` : null, item.status === "starting" ? "启动中" : null, item.status === "cancelling" ? "退出确认中" : null, item.runningCount === 1 ? "运行中" : null, @@ -3649,9 +3699,9 @@ function backgroundSubagentMeta4(item) { ].filter(Boolean).join(" · "); } return [ - item.groupId ? `group=${item.groupId}` : null, - item.taskId ? `task=${item.taskId}` : null, - item.waitFor ? `waitFor=${item.waitFor}` : null, + item.groupId ? `组 ${item.groupId}` : null, + item.taskId ? `任务 ${item.taskId}` : null, + waitForDisplay(item.waitFor) || null, Number.isFinite(item.runningCount) && Number.isFinite(item.taskCount) ? `${item.runningCount}/${item.taskCount} 运行中` : null, item.lastProgressAt ? `进展 ${formatRelativeTime4(item.lastProgressAt)}` : null, item.heartbeatAt ? `心跳 ${formatRelativeTime4(item.heartbeatAt)}` : null, @@ -4716,7 +4766,7 @@ function renderModelConfigPanel4() {