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..c560e4e 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, @@ -6573,6 +6623,62 @@ function sourceBadge7(source) { return "默认"; } +// src/tools/labels.ts +var TOOL_LABELS = Object.freeze({ + tool_result_read: "读取工具证据", + read_file: "读取文件", + list_files: "列出文件", + glob: "查找文件", + grep: "搜索文本", + rg_search: "搜索代码", + rg_files: "按名查找文件", + rg_files_with_matches: "查找含匹配的文件", + rg_count: "统计匹配", + ts_symbols: "查看 TypeScript 符号", + ts_diagnostics: "查看 TypeScript 诊断", + ts_find_definition: "查找 TypeScript 定义", + ts_find_references: "查找 TypeScript 引用", + git_status: "检查 Git 状态", + git_diff: "查看 Git 差异", + git_log: "查看 Git 记录", + git_show: "查看 Git 对象", + git_branch_list: "列出 Git 分支", + git_stash_list: "列出 Git 暂存", + git_tag_list: "列出 Git 标签", + git_add: "暂存 Git 改动", + git_commit: "提交 Git 改动", + git_branch: "管理 Git 分支", + git_stash: "管理 Git 暂存", + git_tag: "管理 Git 标签", + write_file: "写入文件", + edit_file: "编辑文件", + powershell: "运行 PowerShell", + bash: "运行 Shell", + background_shell: "启动后台终端", + background_terminal_list: "列出后台终端", + background_terminal_cancel: "停止后台终端", + mcp_list: "列出 MCP 能力", + mcp_call: "调用 MCP 工具", + web_fetch: "访问网页", + web_search: "搜索网页", + document_intake: "读取文档", + skill_list: "列出技能", + skill_read: "读取技能", + skill_run: "运行技能", + agent_run: "启动子智能体", + todo_read: "读取任务清单", + todo_write: "更新任务清单", + plan_update: "更新计划", + ask_user: "询问用户" +}); +function toolLabel(name) { + const key = String(name ?? "").trim(); + if (!key) { + return "工具"; + } + return TOOL_LABELS[key] ?? key; +} + // src/dashboard/public/app-ui8.ts function sourceLabel5(source) { const type = typeof source === "string" ? source : String(source?.type ?? ""); @@ -6758,7 +6864,7 @@ function renderApprovalPanel(approval) { 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} 个权限请求排队。` : "", @@ -6779,7 +6885,7 @@ function renderApprovalPanel(approval) { activateModal(els.approvalPanel, { initialFocus: "button[data-action='allow-once']" }); revealInteractionPanel8(els.approvalPanel, "button[data-action]"); updateLiveStatus3(); - announceStatus2(`需要确认 ${approval.toolName ?? "工具"} 权限`); + announceStatus2(`需要确认 ${toolLabel(approval.toolName)} 权限`); } async function resolveApproval(action) { const approval = state.pendingApproval; @@ -6830,7 +6936,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() { if (!els.approvalPanel || !document.body) { @@ -7308,8 +7414,13 @@ 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 = "发送"; @@ -9773,6 +9884,7 @@ export { DASHBOARD_LIFECYCLE_TIMEOUT_MS, DASHBOARD_REQUEST_TIMEOUT_MS, DASHBOARD_SHUTDOWN_TIMEOUT_MS, + DASHBOARD_TURN_START_TIMEOUT_MS, DOCUMENT_EXTENSIONS, EVENT_CONNECT_TIMEOUT_MS, EVENT_RECONNECT_MAX_ATTEMPTS, @@ -9820,6 +9932,7 @@ export { armEventConnectTimer3 as armEventConnectTimer, armEventStaleTimer3 as armEventStaleTimer, attachmentPayload, + attachmentSignature, backgroundCancelKey3 as backgroundCancelKey, backgroundSubagentCancellable4 as backgroundSubagentCancellable, backgroundSubagentCompactLabel4 as backgroundSubagentCompactLabel, @@ -9867,6 +9980,7 @@ export { collectModalBackground, columnLabel9 as columnLabel, compactResultLine8 as compactResultLine, + composerHasOutboundContent, composerHeightFor2 as composerHeightFor, configMutationMetadata6 as configMutationMetadata, configRevisionConflictMessage7 as configRevisionConflictMessage, @@ -9909,6 +10023,7 @@ export { emptyBackgroundSubagent, emptyGoalSnapshot2 as emptyGoalSnapshot, emptySessionStatus, + emptyStateCopy, enableGoalWithObjective2 as enableGoalWithObjective, ensureEventsConnected, ensureReasoningEffortOptions6 as ensureReasoningEffortOptions, @@ -10244,6 +10359,7 @@ export { switchModel5 as switchModel, switchReasoningEffort7 as switchReasoningEffort, syncAgentModelPickersForEndpoint6 as syncAgentModelPickersForEndpoint, + syncEmptyStateCopy, syncGatewayUrlHint5 as syncGatewayUrlHint, syncGuideButton, syncPendingGuideFromQueue3 as syncPendingGuideFromQueue, diff --git a/src/dashboard/public/index.html b/src/dashboard/public/index.html index 7d022a8..3cbe152 100644 --- a/src/dashboard/public/index.html +++ b/src/dashboard/public/index.html @@ -5,7 +5,7 @@ Ant Code Dashboard - + @@ -109,7 +109,7 @@

设置

Ant Code · Local
我将全力配合你的工作
-
过程折叠。结果展开。文件留在右侧。
+
过程折叠。结果展开。文件留在右侧。
@@ -225,6 +225,6 @@

设置

- + diff --git a/src/dashboard/public/styles.css b/src/dashboard/public/styles.css index 0135551..24f2c5e 100644 --- a/src/dashboard/public/styles.css +++ b/src/dashboard/public/styles.css @@ -3096,13 +3096,34 @@ body > .approval-panel:not(.hidden), gap: 6px; } +.attachment-page-range-hint { + grid-column: 1 / -1; + color: var(--text-soft); + font-size: var(--fs-caption, 12px); + line-height: 1.4; + margin: 0; +} + +.attachment-page-field { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} + +.attachment-page-field span { + color: var(--text); + font-size: 11px; + font-weight: 600; +} + .attachment-page-range input { width: 100%; min-width: 0; height: 28px; - color: var(--text-soft); + color: var(--text); background: transparent; - border: 1px solid var(--border); + border: 1px solid var(--line); border-radius: 4px; padding: 2px 6px; } diff --git a/src/dashboard/runtime/api-sessions.ts b/src/dashboard/runtime/api-sessions.ts index fa9bfd1..65096bc 100644 --- a/src/dashboard/runtime/api-sessions.ts +++ b/src/dashboard/runtime/api-sessions.ts @@ -197,7 +197,8 @@ export async function runtimeTrustWorkspace(ctx: DashboardFactoryState) { export async function runtimeListSessionRecords(ctx: DashboardFactoryState) { const configEnv = await ctx.resolveConfigEnv(); const config = await loadConfig({ cwd: ctx.cwd, env: configEnv }); - await ctx.maintainSessionRetention(config); + // Same cleanup as /api/status: run in the background so session list can render. + void ctx.maintainSessionRetention(config); const store = createSessionStore({ cwd: ctx.cwd, transcript: config.transcript, env: ctx.runtimeEnv }); const records = await store.listSessionRecords(); const persisted = records.map((record) => ({ diff --git a/src/dashboard/runtime/api-settings.ts b/src/dashboard/runtime/api-settings.ts index cc16e91..a5147bc 100644 --- a/src/dashboard/runtime/api-settings.ts +++ b/src/dashboard/runtime/api-settings.ts @@ -175,7 +175,8 @@ import { export async function runtimeStatus(ctx: DashboardFactoryState, input: DashboardRequestInput = {}) { const configEnv = await ctx.resolveConfigEnv(); const config = await loadConfig({ cwd: ctx.cwd, env: configEnv }); - await ctx.maintainSessionRetention(config); + // Retention can scan every session file. Never block first paint or /api/status on it. + void ctx.maintainSessionRetention(config); const runtimeSelection = dashboardRuntimeSelection( ctx.clientModelSelections, input.clientId, diff --git a/src/dashboard/sessions.ts b/src/dashboard/sessions.ts index c17adba..11dc37a 100644 --- a/src/dashboard/sessions.ts +++ b/src/dashboard/sessions.ts @@ -304,13 +304,13 @@ export function createDashboardRuntime(options: CreateDashboardRuntimeOptions) { : Number.isFinite(config.transcript?.retentionDays) ? config.transcript.retentionDays : 30; const run = retentionMaintenanceTail.then(async () => { try { - return await withKeyedMutation(activeCapacityLocks, "active-capacity", async () => { - const store = createSessionStore({ cwd: options.cwd, transcript: config.transcript, env: runtimeEnv }); - const result = await store.cleanupExpiredSessions(retentionDays, { - excludeSessionIds: [...active.keys()] - }); - return { ok: true, deleted: result.deleted }; + // Do not take active-capacity: new turns need that lock, and cleanup can + // exceed the 15s Dashboard request timeout. + const store = createSessionStore({ cwd: options.cwd, transcript: config.transcript, env: runtimeEnv }); + const result = await store.cleanupExpiredSessions(retentionDays, { + excludeSessionIds: () => [...active.keys()] }); + return { ok: true, deleted: result.deleted }; } catch (error) { return { ok: false, diff --git a/src/model-gateway/client.ts b/src/model-gateway/client.ts index ee80d2e..2c51ab4 100644 --- a/src/model-gateway/client.ts +++ b/src/model-gateway/client.ts @@ -33,7 +33,7 @@ const DEFAULT_GATEWAY_TIMEOUT_MS = 900000; const DEFAULT_GATEWAY_IDLE_TIMEOUT_MS = 300000; const BASE_RETRY_DELAY_MS = 200; const MAX_RETRY_DELAY_MS = 30000; -const GATEWAY_TRANSIENT_ERROR_PATTERN = /KVTransferError|WaitingForInput|Decode transfer failed|premature close|stream.*interrupted/i; +const MIMO_RETRY_ERROR_PATTERN = /KVTransferError|WaitingForInput|Decode transfer failed|premature close|stream.*interrupted/i; const RETRYABLE_STREAM_PROTOCOL_CODES = new Set(["UPSTREAM_STREAM_ABORTED", "INCOMPLETE_TOOL_CALL"]); const GATEWAY_RESPONSE_PROTOCOL_CODES = new Set([ ...RETRYABLE_STREAM_PROTOCOL_CODES, @@ -199,6 +199,7 @@ export function createLabModelGateway(config: import("../config/load-config.ts") const responseHeaderMs = Date.now() - startedAt; if (!response.ok) { + const retryAfter = response.headers.get("retry-after"); let errorBody; try { errorBody = await boundedResponseText(response, { @@ -242,7 +243,7 @@ export function createLabModelGateway(config: import("../config/load-config.ts") attempt, maxAttempts, retryHistory, - delayMs: retryDelayMs(attempt), + delayMs: retryDelayMs(attempt, retryAfter), error, stage: "http_body" }); @@ -288,7 +289,7 @@ export function createLabModelGateway(config: import("../config/load-config.ts") attempt, maxAttempts, retryHistory, - delayMs: retryDelayMs(attempt), + delayMs: retryDelayMs(attempt, retryAfter), error, stage: "http" }); @@ -626,7 +627,7 @@ function shouldRetryGatewayHttpError(error: Record, options: { if ([408, 409, 429].includes(Number(error.status)) || Number(error.status) >= 500) { return true; } - return isConfiguredGatewayRetryable(error, options.config); + return isMimoGatewayRetryable(error, options.config); } /** @@ -642,7 +643,7 @@ function shouldRetryGatewayResponseError(error: Record, options || error.code === "GATEWAY_STREAM_INTERRUPTED" || RETRYABLE_STREAM_PROTOCOL_CODES.has(String(error.code ?? "")) || isRetryableGatewayParseError(error) - || isConfiguredGatewayRetryable(error, options.config); + || isMimoGatewayRetryable(error, options.config); } function gatewayStreamProtocolCode(error: unknown) { @@ -671,8 +672,8 @@ function isRetryableGatewayParseError(error: unknown) { * @param {Record} error * @param {import("../config/load-config.ts").LabAgentConfig} config */ -function isConfiguredGatewayRetryable(error: Record, config: import("../config/load-config.ts").LabAgentConfig) { - if (!usesGatewayRetryProfile(config)) { +function isMimoGatewayRetryable(error: Record, config: import("../config/load-config.ts").LabAgentConfig) { + if (!isMimoModel(config)) { return false; } const details = isRecord(error.details) ? error.details : {}; @@ -681,21 +682,43 @@ function isConfiguredGatewayRetryable(error: Record, config: im details.body, details.responseReadStage ].filter(Boolean).join("\n"); - return GATEWAY_TRANSIENT_ERROR_PATTERN.test(text); + return MIMO_RETRY_ERROR_PATTERN.test(text); } /** * @param {import("../config/load-config.ts").LabAgentConfig} config */ -function usesGatewayRetryProfile(config: import("../config/load-config.ts").LabAgentConfig) { - const lab = config?.lab as { gatewayRetryProfile?: unknown } | undefined; - return /retry/i.test(String(config?.modelAlias ?? lab?.gatewayRetryProfile ?? "")); +function isMimoModel(config: import("../config/load-config.ts").LabAgentConfig) { + return /mimo/i.test(String(config?.modelAlias ?? "")); } -/** - * @param {number} attempt - */ -function retryDelayMs(attempt: number) { +export function parseRetryAfterMs(value: unknown, maxMs: number = MAX_RETRY_DELAY_MS) { + if (value == null) { + return null; + } + const text = String(value).trim(); + if (!text) { + return null; + } + if (/^\d+$/.test(text)) { + const ms = Number(text) * 1000; + if (!Number.isFinite(ms) || ms < 0) { + return null; + } + return Math.min(maxMs, ms); + } + const at = Date.parse(text); + if (!Number.isFinite(at)) { + return null; + } + return Math.min(maxMs, Math.max(0, at - Date.now())); +} + +export function retryDelayMs(attempt: number, retryAfterHeader?: string | null) { + const retryAfterMs = parseRetryAfterMs(retryAfterHeader); + if (retryAfterMs != null) { + return retryAfterMs; + } const rawDelay = BASE_RETRY_DELAY_MS * (2 ** Math.max(0, attempt - 1)); const jitter = 0.9 + (Math.random() * 0.2); return Math.min(MAX_RETRY_DELAY_MS, Math.max(0, Math.round(rawDelay * jitter))); diff --git a/src/model-gateway/health.ts b/src/model-gateway/health.ts index 62d353c..15a4295 100644 --- a/src/model-gateway/health.ts +++ b/src/model-gateway/health.ts @@ -1,6 +1,7 @@ import { loadConfig } from "../config/load-config.ts"; import { decideNetworkAccess } from "../permissions/network-policy.ts"; import { gatewayTroubleshootingHints, normalizeGatewayError } from "./errors.ts"; +import { GATEWAY_MAX_ERROR_BODY_BYTES } from "./limits.ts"; /** * @param {{ cwd?: string; env?: NodeJS.ProcessEnv; live?: boolean; timeoutMs?: number }} options @@ -69,7 +70,7 @@ export async function runGatewayHealth(options: { cwd?: string; env?: NodeJS.Pro return result(config, checks, live); } - checks.push(await fetchHealth(healthUrl, options.timeoutMs ?? 5000)); + checks.push(await fetchHealth(healthUrl, options.timeoutMs ?? 5000, 2)); return result(config, checks, live); } @@ -125,27 +126,63 @@ function result(config: import("../config/load-config.ts").LabAgentConfig, check * @param {URL} healthUrl * @param {number} timeoutMs */ -async function fetchHealth(healthUrl: URL, timeoutMs: number) { +async function fetchHealth(healthUrl: URL, timeoutMs: number, attempts: number = 1) { + let last = await fetchHealthOnce(healthUrl, timeoutMs); + for (let attempt = 2; attempt <= attempts && last.status !== "ok"; attempt += 1) { + last = await fetchHealthOnce(healthUrl, timeoutMs); + } + return last; +} + +async function discardBoundedBody(response: Response, maxBytes: number) { + if (!response.body) { + return; + } + const reader = response.body.getReader(); + let received = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + received += value?.byteLength ?? 0; + if (received >= maxBytes) { + await reader.cancel(); + break; + } + } +} + +async function fetchHealthOnce(healthUrl: URL, timeoutMs: number) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); + const startedAt = Date.now(); try { const response = await fetch(healthUrl, { method: "GET", headers: { accept: "application/json" }, signal: controller.signal }); + const latencyMs = Date.now() - startedAt; + if (!response.ok) { + await discardBoundedBody(response, GATEWAY_MAX_ERROR_BODY_BYTES); + } else { + await discardBoundedBody(response, 4096); + } return { name: "gateway live health", status: response.ok ? "ok" : "error", - message: `HTTP ${response.status}` + message: `HTTP ${response.status} · ${latencyMs}ms`, + latencyMs }; } catch (error) { const normalized = normalizeGatewayError(error); return { name: "gateway live health", status: "error", - message: `${normalized.code}: ${normalized.message}`, - code: normalized.code + message: `${normalized.code}: ${normalized.message} · ${Date.now() - startedAt}ms`, + code: normalized.code, + latencyMs: Date.now() - startedAt }; } finally { clearTimeout(timeout); diff --git a/src/storage/session-store.ts b/src/storage/session-store.ts index 240e6a0..505b21f 100644 --- a/src/storage/session-store.ts +++ b/src/storage/session-store.ts @@ -57,6 +57,23 @@ function errorCode(error: unknown): unknown { return error && typeof error === "object" && "code" in error ? error.code : undefined; } +function createSessionExclusionCheck( + excludeSessionIds?: Iterable | (() => Iterable) +): (sessionId: string) => boolean { + if (typeof excludeSessionIds === "function") { + return (sessionId) => { + for (const id of excludeSessionIds()) { + if (String(id) === sessionId) { + return true; + } + } + return false; + }; + } + const excluded = new Set(Array.from(excludeSessionIds ?? [], (sessionId) => String(sessionId))); + return (sessionId) => excluded.has(sessionId); +} + /** * @param {{ cwd: string; transcript?: Record; env?: NodeJS.ProcessEnv }} options */ @@ -118,9 +135,9 @@ export function createSessionStore(options: { cwd: string; transcript?: Record }} cleanupOptions + * @param {{ now?: Date; excludeSessionIds?: Iterable | (() => Iterable) }} cleanupOptions */ - async cleanupExpiredSessions(retentionDays: number | null, cleanupOptions: { now?: Date; excludeSessionIds?: Iterable } = {}) { + async cleanupExpiredSessions(retentionDays: number | null, cleanupOptions: { now?: Date; excludeSessionIds?: Iterable | (() => Iterable) } = {}) { if (retentionDays === null) { return { deleted: [], skipped: "forever" }; } @@ -128,14 +145,12 @@ export function createSessionStore(options: { cwd: string; transcript?: Record String(sessionId)) - ); + const isExcluded = createSessionExclusionCheck(cleanupOptions.excludeSessionIds); const deleted: string[] = []; const grouped = groupSessionEntries(entries); for (const [sessionId, sessionEntries] of grouped) { - if (excludedSessionIds.has(sessionId)) { + if (isExcluded(sessionId)) { continue; } const records = await Promise.all(sessionEntries.map(async (entry) => { @@ -149,6 +164,9 @@ export function createSessionStore(options: { cwd: string; transcript?: Record { + if (isExcluded(sessionId)) { + return; + } const currentEntries = (await fs.readdir(root, { withFileTypes: true })) .filter((entry) => entry.isFile() && isSessionFile(entry.name)) .filter((entry) => sessionIdFromFileName(entry.name) === sessionId); diff --git a/src/tools/labels.ts b/src/tools/labels.ts new file mode 100644 index 0000000..903e31a --- /dev/null +++ b/src/tools/labels.ts @@ -0,0 +1,55 @@ +export const TOOL_LABELS: Readonly> = Object.freeze({ + tool_result_read: "读取工具证据", + read_file: "读取文件", + list_files: "列出文件", + glob: "查找文件", + grep: "搜索文本", + rg_search: "搜索代码", + rg_files: "按名查找文件", + rg_files_with_matches: "查找含匹配的文件", + rg_count: "统计匹配", + ts_symbols: "查看 TypeScript 符号", + ts_diagnostics: "查看 TypeScript 诊断", + ts_find_definition: "查找 TypeScript 定义", + ts_find_references: "查找 TypeScript 引用", + git_status: "检查 Git 状态", + git_diff: "查看 Git 差异", + git_log: "查看 Git 记录", + git_show: "查看 Git 对象", + git_branch_list: "列出 Git 分支", + git_stash_list: "列出 Git 暂存", + git_tag_list: "列出 Git 标签", + git_add: "暂存 Git 改动", + git_commit: "提交 Git 改动", + git_branch: "管理 Git 分支", + git_stash: "管理 Git 暂存", + git_tag: "管理 Git 标签", + write_file: "写入文件", + edit_file: "编辑文件", + powershell: "运行 PowerShell", + bash: "运行 Shell", + background_shell: "启动后台终端", + background_terminal_list: "列出后台终端", + background_terminal_cancel: "停止后台终端", + mcp_list: "列出 MCP 能力", + mcp_call: "调用 MCP 工具", + web_fetch: "访问网页", + web_search: "搜索网页", + document_intake: "读取文档", + skill_list: "列出技能", + skill_read: "读取技能", + skill_run: "运行技能", + agent_run: "启动子智能体", + todo_read: "读取任务清单", + todo_write: "更新任务清单", + plan_update: "更新计划", + ask_user: "询问用户" +}); + +export function toolLabel(name: unknown) { + const key = String(name ?? "").trim(); + if (!key) { + return "工具"; + } + return TOOL_LABELS[key] ?? key; +} diff --git a/src/version.ts b/src/version.ts index fd93780..9f79d29 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,8 +1,9 @@ -import fs from "node:fs/promises"; +import fs from "node:fs"; +import fsPromises from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; -const FALLBACK_VERSION = "2.0.11"; +const FALLBACK_VERSION = "2.0.12"; /** * @param {NodeJS.ProcessEnv} [env] @@ -21,10 +22,20 @@ export function resolvePackageRoot(env: NodeJS.ProcessEnv = process.env) { * @param {string} [packageRoot] */ export async function readPackageJson(packageRoot: string = resolvePackageRoot()) { - const text = await fs.readFile(path.join(packageRoot, "package.json"), "utf8"); + const text = await fsPromises.readFile(path.join(packageRoot, "package.json"), "utf8"); return JSON.parse(text); } +export function getAntCodeVersionSync(packageRoot: string = resolvePackageRoot()) { + try { + const text = fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"); + const pkg = JSON.parse(text); + return typeof pkg.version === "string" && pkg.version.length > 0 ? pkg.version : FALLBACK_VERSION; + } catch { + return FALLBACK_VERSION; + } +} + /** * @param {string} [packageRoot] */ diff --git a/tests/browser/dashboard-browser.test.ts b/tests/browser/dashboard-browser.test.ts index 15877a9..7fbb20f 100644 --- a/tests/browser/dashboard-browser.test.ts +++ b/tests/browser/dashboard-browser.test.ts @@ -2184,7 +2184,7 @@ test("permission approval dialog stays on screen and does not leave a stuck wait assert.equal(first.parent, "BODY"); assert.equal(first.hidden, false); assert.equal(first.modal, true); - assert.match(first.title, /web_search/); + assert.match(first.title, /搜索网页/); assert.match(first.title, /还有 1 个排队/); assert.ok(first.left >= 0 && first.top >= 0, `approval dialog clipped at origin: ${JSON.stringify(first)}`); assert.ok(first.right <= 390 && first.bottom <= 844, `approval dialog outside mobile viewport: ${JSON.stringify(first)}`); @@ -2199,8 +2199,8 @@ test("permission approval dialog stays on screen and does not leave a stuck wait allowed: true }); }); - await page.waitForFunction(() => document.querySelector("#approval-title")?.textContent?.includes("web_fetch")); - assert.match(await panel.locator("#approval-title").textContent(), /web_fetch/); + await page.waitForFunction(() => document.querySelector("#approval-title")?.textContent?.includes("访问网页")); + assert.match(await panel.locator("#approval-title").textContent(), /访问网页/); assert.equal(await page.locator("#live-title").textContent(), "等待权限确认"); await page.evaluate(() => { diff --git a/tests/unit/commands.test.ts b/tests/unit/commands.test.ts index 379f494..f305e93 100644 --- a/tests/unit/commands.test.ts +++ b/tests/unit/commands.test.ts @@ -1118,9 +1118,10 @@ test("keybindings expose TUI discovery states", async () => { assert.match(keybindings, /Ctrl\+↑\/Ctrl\+↓:召回上一条\/下一条已提交提示/); assert.match(keybindings, /@:打开工作区文件提及面板/); assert.match(keybindings, /鼠标滚轮:按鼠标位置滚动聊天区、右侧栏或当前弹层/); - assert.match(keybindings, /Tab:在输入区和会话区之间切换焦点/); - assert.match(keybindings, /y:复制当前选中块的正文到系统剪贴板/); - assert.match(keybindings, /Shift\+←\/→:切换右侧栏:状态、任务、子智能体/); + assert.match(keybindings, /Tab:切换右侧栏:状态、任务、子智能体/); + assert.match(keybindings, /拖选后复制:拖选可见聊天文字复制到系统剪贴板/); + assert.match(keybindings, /←\/→:输入为空时切换右侧栏分类/); + assert.match(keybindings, /Ctrl\+L:清屏:只隐藏当前可见记录,不删除会话文件/); assert.doesNotMatch(keybindings, /切换右侧栏:状态、待办、任务/); assert.match(keybindings, /\/logs:打开最近运行日志/); assert.doesNotMatch(keybindings, /Ctrl\+T:切换检查过滤器/); diff --git a/tests/unit/dashboard-runtime/turn.ts b/tests/unit/dashboard-runtime/turn.ts index 08280c1..1556357 100644 --- a/tests/unit/dashboard-runtime/turn.ts +++ b/tests/unit/dashboard-runtime/turn.ts @@ -626,7 +626,7 @@ test("dashboard runtime throttles retention maintenance and immediately applies const status = await runtime.status(); assert.equal(status.ok, true); - assert.equal((await store.readMetadataExact("old-session")).ok, false); + await waitForCondition(async () => (await store.readMetadataExact("old-session")).ok === false); await assert.rejects(fs.access(path.join(store.root, "old-session.transcript")), { code: "ENOENT" }); assert.equal((await store.readMetadataExact("fresh-session")).ok, true); diff --git a/tests/unit/dashboard-server.test.ts b/tests/unit/dashboard-server.test.ts index d92e29e..63ad1f3 100644 --- a/tests/unit/dashboard-server.test.ts +++ b/tests/unit/dashboard-server.test.ts @@ -14,6 +14,15 @@ import { const dashboardAuthCache = new WeakMap(); +test("dashboard listen does not probe runtime status before the first browser request", async () => { + const source = await fs.readFile(new URL("../../src/dashboard/server.ts", import.meta.url), "utf8"); + const start = source.slice( + source.indexOf("export async function startDashboard"), + source.indexOf("export function normalizeDashboardHost") + ); + assert.doesNotMatch(start, /runtime\.status\(/); +}); + test("dashboard host is restricted to loopback", () => { assert.equal(normalizeDashboardHost("127.0.0.1"), "127.0.0.1"); assert.equal(normalizeDashboardHost("localhost"), "localhost"); diff --git a/tests/unit/dashboard-status-retention.test.ts b/tests/unit/dashboard-status-retention.test.ts new file mode 100644 index 0000000..9de517a --- /dev/null +++ b/tests/unit/dashboard-status-retention.test.ts @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { createDashboardRuntime } from "../../src/dashboard/sessions.ts"; +import { runtimeListSessionRecords } from "../../src/dashboard/runtime/api-sessions.ts"; +import { runtimeStatus } from "../../src/dashboard/runtime/api-settings.ts"; +import { createSessionStore } from "../../src/storage/session-store.ts"; +import { + close, + createGateway, + listen, + mockGatewayEnv +} from "./dashboard-runtime/helpers.ts"; + +test("dashboard status and session list do not wait for session retention cleanup", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "dashboard-status-retention-")); + let retentionStarted = false; + let retentionFinished = false; + const hang = () => new Promise((resolve) => { + retentionStarted = true; + setTimeout(() => { + retentionFinished = true; + resolve({ ok: true, deleted: [] }); + }, 5000); + }); + const ctx = { + cwd, + runtimeEnv: process.env, + clientModelSelections: new Map(), + selectedProviderId: "", + selectedModelId: "", + selectedReasoningEffort: "", + active: new Map(), + resolveConfigEnv: async () => process.env, + maintainSessionRetention: hang + }; + + const started = Date.now(); + const status = await runtimeStatus(ctx, {}); + const listed = await runtimeListSessionRecords(ctx); + const elapsed = Date.now() - started; + + assert.equal(status.ok, true); + assert.ok(Array.isArray(listed)); + assert.equal(retentionStarted, true); + assert.equal(retentionFinished, false); + assert.ok(elapsed < 1000, `interactive status waited ${elapsed}ms for retention`); +}); + +test("new dashboard turn does not wait for background session retention", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "dashboard-turn-retention-")); + const store = createSessionStore({ cwd }); + const expiredPath = await store.writeMetadata({ id: "expired-session" }); + const oldTime = new Date("2020-01-01T00:00:00.000Z"); + await fs.utimes(expiredPath, oldTime, oldTime); + + const originalStat = fs.stat; + let releaseHold = () => {}; + const held = new Promise((resolve) => { + releaseHold = resolve; + }); + let cleanupEnteredResolve = () => {}; + const cleanupEntered = new Promise((resolve) => { + cleanupEnteredResolve = resolve; + }); + let intercepted = false; + fs.stat = async (...args) => { + const result = await originalStat(...args); + if (!intercepted && path.resolve(String(args[0])) === path.resolve(expiredPath)) { + intercepted = true; + cleanupEnteredResolve(); + await held; + } + return result; + }; + + const server = await listen(createGateway("dashboard answer"), "127.0.0.1", 0); + const runtime = createDashboardRuntime({ cwd, env: mockGatewayEnv(server) }); + try { + await runtime.trustWorkspace(); + await runtime.status(); + await cleanupEntered; + + const started = runtime.startTurn({ + prompt: "hello dashboard", + permissionMode: "plan" + }); + /** @type {{ result?: { ok?: boolean; sessionId?: string }; timeout?: boolean }} */ + const raced = await Promise.race([ + started.then((result) => ({ result })), + new Promise((resolve) => { + setTimeout(() => resolve({ timeout: true }), 3000); + }) + ]); + + assert.notEqual(raced.timeout, true, "new turn waited for session retention"); + assert.equal(raced.result?.ok, true); + assert.ok(raced.result?.sessionId); + } finally { + releaseHold(); + fs.stat = originalStat; + await runtime.shutdown({ force: true, cancel: true }).catch(() => {}); + await close(server); + } +}); + +test("session retention maintenance does not take the active-capacity lock", async () => { + const source = await fs.readFile(new URL("../../src/dashboard/sessions.ts", import.meta.url), "utf8"); + const maintain = source.slice(source.indexOf("const maintainSessionRetention"), source.indexOf("const activeSweepTimer")); + assert.match(maintain, /excludeSessionIds:\s*\(\)\s*=>\s*\[\.\.\.active\.keys\(\)\]/); + assert.doesNotMatch(maintain, /withKeyedMutation\(\s*activeCapacityLocks/); +}); diff --git a/tests/unit/dashboard-ui.test.ts b/tests/unit/dashboard-ui.test.ts index 8739031..a43d3c2 100644 --- a/tests/unit/dashboard-ui.test.ts +++ b/tests/unit/dashboard-ui.test.ts @@ -25,6 +25,12 @@ if (typeof globalThis.document === "undefined") { }; } +test("empty conversation copy changes on narrow viewports", async () => { + const module = await loadAppExports(["emptyStateCopy"]); + assert.equal(module.emptyStateCopy(1280), "过程折叠。结果展开。文件留在右侧。"); + assert.equal(module.emptyStateCopy(390), "过程折叠。结果展开。文件在「文件」页。"); +}); + test("configured idle gateway shows connected without masking event failures", async () => { const app = await loadAppExports(["state", "els", "setConnectionState"]); const label = { textContent: "" }; @@ -48,7 +54,10 @@ test("configured idle gateway shows connected without masking event failures", a async function publicTsSource() { const dir = path.resolve("src/dashboard/public"); const names = (await fs.readdir(dir)).filter((name) => name === "app-core.ts" || /^app-ui\d+\.ts$/.test(name)); - const texts = await Promise.all(names.map((name) => fs.readFile(path.join(dir, name), "utf8"))); + const texts = await Promise.all([ + fs.readFile(path.resolve("src/tools/labels.ts"), "utf8"), + ...names.map((name) => fs.readFile(path.join(dir, name), "utf8")) + ]); return texts.join("\n"); } @@ -146,6 +155,13 @@ test("dashboard running send button exposes interrupt action", async () => { assert.match(source, /els\.sendButton\.textContent = "中断"/); assert.match(source, /els\.sendButton\.title = "点击中断当前任务"/); + assert.match(source, /els\.sendButton\.textContent = "加入队列"/); + assert.match(source, /composerHasOutboundContent\(\)/); + assert.match(source, /submittedSessionId/); + assert.match(source, /draftUnchanged/); + assert.match(source, /attachment-page-range-hint/); + assert.match(source, /起始页/); + assert.match(source, /扫描件只识别前几页/); }); test("dashboard distinguishes configuration failures from service and network failures", async () => { @@ -630,7 +646,7 @@ test("dashboard composer controls keep confirmations reviewable and critical sta assert.match(app, /postJson\("\/api\/default-model"/); assert.match(app, /expectedRevision: state\.configRevisions\[scope\] \|\| undefined/); assert.match(app, /payload\?\.configV2\?\.revisions/); - assert.match(app, /clientId: state\.currentSessionId \? undefined : dashboardClientId\(\)/); + assert.match(app, /clientId: submittedSessionId \? undefined : dashboardClientId\(\)/); assert.match(app, /const DASHBOARD_CLIENT_STORAGE_KEY = "ant-code-dashboard-client-id"/); assert.match(app, /value="global"/); assert.match(app, /当前项目默认/); diff --git a/tests/unit/gateway-health.test.ts b/tests/unit/gateway-health.test.ts index 8505d0d..b60770a 100644 --- a/tests/unit/gateway-health.test.ts +++ b/tests/unit/gateway-health.test.ts @@ -5,11 +5,19 @@ import os from "node:os"; import path from "node:path"; import test from "node:test"; import { createMockGatewayServer } from "../../scripts/mock-gateway.ts"; -import { createLabModelGateway } from "../../src/model-gateway/client.ts"; +import { createLabModelGateway, parseRetryAfterMs, retryDelayMs } from "../../src/model-gateway/client.ts"; import { formatGatewayError, normalizeGatewayError, redactGatewayText } from "../../src/model-gateway/errors.ts"; import { formatGatewayHealthReport, runGatewayHealth } from "../../src/model-gateway/health.ts"; import { GATEWAY_MAX_STREAM_RECORD_BYTES } from "../../src/model-gateway/limits.ts"; +test("retry-after header prefers server wait time and caps at 30s", () => { + assert.equal(parseRetryAfterMs("0"), 0); + assert.equal(parseRetryAfterMs("2"), 2000); + assert.equal(parseRetryAfterMs("120"), 30000); + assert.equal(parseRetryAfterMs("not-a-date"), null); + assert.equal(retryDelayMs(1, "0"), 0); +}); + test("normalizes gateway timeout errors", () => { const error = new Error("operation aborted"); error.name = "AbortError"; @@ -158,10 +166,9 @@ test("gateway health live check accepts the mock gateway health endpoint", async }); assert.equal(report.ok, true); - assert.deepEqual( - report.checks.find((check) => check.name === "gateway live health"), - { name: "gateway live health", status: "ok", message: "HTTP 200" } - ); + const live = report.checks.find((check) => check.name === "gateway live health"); + assert.equal(live?.status, "ok"); + assert.match(String(live?.message), /^HTTP 200 · \d+ms$/); } finally { await close(server); } @@ -327,7 +334,10 @@ test("gateway client retries HTTP 429 responses", async () => { let calls = 0; const server = await listen(http.createServer((_request, response) => { calls += 1; - response.writeHead(calls === 1 ? 429 : 200, { "content-type": "application/json" }); + response.writeHead(calls === 1 ? 429 : 200, { + "content-type": "application/json", + ...(calls === 1 ? { "retry-after": "0" } : {}) + }); response.end(calls === 1 ? JSON.stringify({ error: { message: "rate limited" } }) : JSON.stringify({ @@ -797,7 +807,7 @@ test("gateway client sends session affinity header", async () => { } }); -test("gateway client retries configured transient HTTP 500 responses", async () => { +test("gateway client retries Mimo KVTransfer HTTP 500 responses", async () => { const originalFetch = globalThis.fetch; const events = []; let calls = 0; @@ -816,8 +826,8 @@ test("gateway client retries configured transient HTTP 500 responses", async () }); } return new Response(JSON.stringify({ - id: "retry-ok", - model: "example-retry-model", + id: "mimo-retry-ok", + model: "mimo-v2.5", content: [{ type: "text", text: "recovered" }], toolCalls: [], stopReason: "stop" @@ -828,7 +838,7 @@ test("gateway client retries configured transient HTTP 500 responses", async () }; const gateway = createLabModelGateway({ - modelAlias: "example-retry-model", + modelAlias: "mimo-v2.5", networkMode: "offline", allowedHosts: [], lab: { @@ -877,7 +887,7 @@ test("gateway client retries interrupted streams", async () => { }); } return new Response([ - 'data: {"id":"retry-ok","model":"example-retry-model","choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}]}', + 'data: {"id":"retry-ok","model":"mimo-v2.5","choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}]}', "", "data: [DONE]", "" @@ -888,7 +898,7 @@ test("gateway client retries interrupted streams", async () => { }; const gateway = createLabModelGateway({ - modelAlias: "example-retry-model", + modelAlias: "mimo-v2.5", networkMode: "offline", allowedHosts: [], lab: { diff --git a/tests/unit/session-resume.test.ts b/tests/unit/session-resume.test.ts new file mode 100644 index 0000000..39f2d67 --- /dev/null +++ b/tests/unit/session-resume.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + limitResumeContextMessages, + selectResumeArchiveChunks +} from "../../src/core/session-resume.ts"; + +test("resume context trim keeps a user-aligned tail under the byte budget", () => { + const messages = [ + { role: "assistant", content: "old" }, + { role: "user", content: "x".repeat(400) }, + { role: "assistant", content: "y".repeat(400) }, + { role: "user", content: "keep-a" }, + { role: "assistant", content: "keep-b" } + ]; + const limited = limitResumeContextMessages(messages, { + resumeMaxMessages: 50, + resumeMaxTokens: 10_000, + resumeMaxBytes: 200 + }); + assert.equal(limited[0]?.role, "user"); + assert.ok(limited.length >= 1); + assert.ok(limited.length < messages.length); + assert.equal(limited.at(-1)?.role, "assistant"); +}); + +test("resume context trim does not walk one message at a time on a large over-budget history", () => { + const messages = Array.from({ length: 2500 }, (_, index) => ({ + role: index % 2 === 0 ? "user" : "assistant", + content: `payload-${index}-${"z".repeat(80)}` + })); + const started = Date.now(); + const limited = limitResumeContextMessages(messages, { + resumeMaxMessages: 100000, + resumeMaxTokens: 2_000, + resumeMaxBytes: 8_000 + }); + const elapsed = Date.now() - started; + assert.ok(limited.length >= 1); + assert.ok(limited.length < 2500); + assert.equal(limited[0]?.role, "user"); + assert.ok(elapsed < 250, `resume trim took ${elapsed}ms`); +}); + +test("resume archive chunk selection reads only the tail needed for the budget", () => { + const chunks = Array.from({ length: 20 }, (_, index) => ({ + index: index + 1, + messages: 50, + bytes: 20_000 + })); + const selected = selectResumeArchiveChunks(chunks, { maxMessages: 100000, maxBytes: 45_000 }); + assert.deepEqual(selected.map((chunk) => chunk.index), [18, 19, 20]); +}); diff --git a/tests/unit/storage.test.ts b/tests/unit/storage.test.ts index e44db42..88b9e4f 100644 --- a/tests/unit/storage.test.ts +++ b/tests/unit/storage.test.ts @@ -52,6 +52,57 @@ test("session store permanent retention skips cleanup without deleting old metad assert.equal((await store.readMetadataExact("permanent-session")).ok, true); }); +test("session store cleanup live-excludes sessions that become active during the scan", async () => { + const cwd = await makeTempWorkspace(); + const store = createSessionStore({ cwd }); + const livePath = await store.writeMetadata({ id: "live-session" }); + const expiredPath = await store.writeMetadata({ id: "expired-session" }); + const oldTime = new Date("2026-01-01T00:00:00.000Z"); + await Promise.all([ + fs.utimes(livePath, oldTime, oldTime), + fs.utimes(expiredPath, oldTime, oldTime) + ]); + + const activeIds = new Set(); + const originalStat = fs.stat; + let releaseFirstStat; + let firstStatObservedResolve; + const firstStatObserved = new Promise((resolve) => { + firstStatObservedResolve = resolve; + }); + const releaseFirst = new Promise((resolve) => { + releaseFirstStat = resolve; + }); + let intercepted = false; + fs.stat = async (...args) => { + const result = await originalStat(...args); + if (!intercepted && path.resolve(String(args[0])) === path.resolve(livePath)) { + intercepted = true; + firstStatObservedResolve(); + await releaseFirst; + } + return result; + }; + + try { + const cleanup = store.cleanupExpiredSessions(0, { + now: new Date("2026-04-28T00:00:00.000Z"), + excludeSessionIds: () => activeIds + }); + await firstStatObserved; + activeIds.add("live-session"); + releaseFirstStat(); + const result = await cleanup; + + assert.deepEqual(result.deleted, [expiredPath]); + assert.equal((await store.readMetadataExact("live-session")).ok, true); + assert.equal((await store.readMetadataExact("expired-session")).ok, false); + } finally { + fs.stat = originalStat; + releaseFirstStat?.(); + } +}); + test("session store cleanup preserves explicitly excluded sessions", async () => { const cwd = await makeTempWorkspace(); const store = createSessionStore({ cwd }); diff --git a/tests/unit/tui-approval-keys.test.ts b/tests/unit/tui-approval-keys.test.ts new file mode 100644 index 0000000..7252b9c --- /dev/null +++ b/tests/unit/tui-approval-keys.test.ts @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { handleApprovalInput } from "../../src/cli/tui/input-handlers.ts"; + +function runApprovalKey(key: Record, startIndex = 0) { + let nextIndex = startIndex; + handleApprovalInput("", key, { + pendingApproval: { toolName: "web_search" }, + approvalChoiceIndex: startIndex + }, { add() {} }, () => {}, () => {}, () => {}, () => {}, (value) => { + nextIndex = typeof value === "function" ? value(startIndex) : value; + }); + return nextIndex; +} + +test("approval Tab and Shift+Tab move choices without leaving the modal", () => { + assert.equal(runApprovalKey({ tab: true }, 0), 1); + assert.equal(runApprovalKey({ tab: true, shift: true }, 0), 3); +}); diff --git a/tests/unit/tui-format.test.ts b/tests/unit/tui-format.test.ts index 41cc0ba..d3d0b6c 100644 --- a/tests/unit/tui-format.test.ts +++ b/tests/unit/tui-format.test.ts @@ -394,7 +394,7 @@ test("exit confirmation and permission modal lines are bounded and decision-focu assert.ok(exitLines.some((line) => line.includes("再次按 Ctrl+C"))); assert.match(permission, /需要权限/); - assert.match(permission, /\[write\] edit_file/); + assert.match(permission, /\[write\] 编辑文件/); assert.match(permission, /> 本会话允许/); assertTerminalBounds(permission, { columns: 100, rows: 12 }); }); @@ -410,7 +410,7 @@ test("transcript and tool card lines expose block semantics", () => { }); assert.equal(assistant[0].text, "Ant Code"); - assert.match(tool[0].text, /\[blocked\] shell/); + assert.match(tool[0].text, /\[阻止\] 命令/); assert.match(agent[0].text, /\[✓\] 子智能体 - 子任务完成/); assert.ok(agent.some((item) => item.text.includes("/agents task task-1"))); }); @@ -559,7 +559,7 @@ test("transcript detail modes collapse routine gateway and tool telemetry", () = assert.match(detailedText, /Ctrl\+O 再按一次/); assert.doesNotMatch(detailedText, /id=read-1/); assert.match(fullText, /gateway round 1/); - assert.match(fullText, /read_file done/); + assert.match(fullText, /读取文件 完成/); }); test("failed and blocked tool telemetry remains visible in compact transcript mode", () => { @@ -574,7 +574,7 @@ test("failed and blocked tool telemetry remains visible in compact transcript mo const text = compact.lines.map((item) => item.text).join("\n"); assert.doesNotMatch(text, /running id=write-1/); - assert.match(text, /\[blocked\] edit - write_file blocked/); + assert.match(text, /\[阻止\] 写入 - 写入文件 被阻止/); assert.match(text, /需要权限/); });