diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b1c4e6..81118d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,38 @@ ## Unreleased +## 2.0.13 - 2026-09-14 + +This is a small Dashboard artifact, credential, and session-guard release on the 2.0 TypeScript runtime. +Generated plots and reports can appear in the right pane without a manual open. Same-URL credentials are chosen automatically so chat is not blocked before send. Empty-body diagnostics stay in the UI, not the next model prompt. A one-line tool promise with no tool call is retried once with a new gateway session binding. Permission mode ids are unchanged. + +### Added + +- Dashboard lists script-generated previewable files and opens the latest exhibit automatically. +- Optional `gatewaySessionAffinity` is persisted so a session can rebind the gateway conversation without changing the local session id. + +### Fixed + +- Multiple keys on one gateway URL no longer block chat until a settings click. HTTP 401/403 then asks the user to inspect or switch the active credential. +- Empty-visible-text diagnostics are no longer written into model context. +- A short “I will call this tool now” stop with no tool call is retried once after rotating gateway session affinity. +- Unsolicited Responses `web_search_call` events fail as a protocol mismatch instead of a silent completion. +- Dashboard event-stream disconnects show unknown task status instead of implying the turn finished. + +### Upgrade + +```sh +git pull +npm ci +npm run verify:install +npm link +ant-code --version +``` + +`ant-code --version` should print `2.0.13`. Restart a running Dashboard +and hard-refresh the browser. Gateway config and `.lab-agent` sessions +do not need to be recreated. + ## 2.0.12 - 2026-09-14 Published 2026-09-14 02:04:48 UTC (2026-09-14 10:04:48 UTC+08:00): [v2.0.12](https://github.com/wicm84266964/Ant-Code/releases/tag/v2.0.12). diff --git a/README.md b/README.md index f877b61..33a00b3 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ ant-code doctor ant-code ``` -`ant-code --version` should print `2.0.12`. If you previously linked a +`ant-code --version` should print `2.0.13`. 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.13-artifacts-credentials-session-guard_zh.md b/docs/releases/2.0.13-artifacts-credentials-session-guard_zh.md new file mode 100644 index 0000000..8b38448 --- /dev/null +++ b/docs/releases/2.0.13-artifacts-credentials-session-guard_zh.md @@ -0,0 +1,43 @@ +# Ant Code v2.0.13:产物自动展出、凭据选择与会话短停防护 + +发布日期:2026-09-14 + +这是 2.0 TypeScript 运行时上的小版本。Dashboard 会把脚本生成的图、报表和导出文件自动列入右侧产物栏并打开预览。同一网关来源有多份凭据时先自动选定一份再发请求,只有凭据被拒绝才提示去设置检查或切换。同时避免把空正文诊断写进模型上下文,并对「只口头承诺调用工具就结束」做一次换绑定重试。权限模式 id 不变。本说明处于公开发布候选阶段,不表示 CI、tag 或 GitHub Release 已经完成。 + +## 对用户工作流的直接变化 + +- Dashboard 右侧「产物与文件」会收集 `write_file` / `edit_file` 以及脚本刚写出的可预览文件(图、PDF、表格、Office、Markdown 等),生成后自动打开最新一份。源码编辑仍出现在列表,但不抢预览。中文路径和 Markdown 链接也能入栏。 +- 同一网关 URL(含端口和路径)下有多份 Key 时,不再因为「未选手动生效凭据」而拦下聊天。程序按已保存选择、同一把 Key、当前活动配置或该来源第一份配置自动选定。 +- 自动选定的凭据若被上游拒绝(HTTP 401/403),聊天里提示去设置页切换或检查生效凭据。网关超时、5xx 不套这句。 +- 模型只回 thinking、没有可见正文时,界面仍可显示本地诊断,但这段话不再写进下一轮模型上下文。 +- 若一轮只回一句「马上调用某工具」且没有实际 tool call,Ant Code 会换网关会话绑定后再要一轮,且不把那句承诺写回模型。正常长回复和真正的工具调用不受影响。 +- Responses 协议若返回未请求的原生 `web_search_call`,不再当成正常结束,而是报告协议错配。 +- Dashboard 事件连接中断时标明任务状态未知,避免当成已经结束;保留自动重连。 + +## 配置、数据与安全边界 + +- 不要求手动迁移配置或历史会话。已有网关、权限和 `.lab-agent` 会话可继续使用。 +- 生效凭据选择仍保存在现有通用配置里;Key 不离开原有凭据存储。会话可另存可选的网关绑定字段,默认仍用原会话 ID。 +- 产物栏只收集工作区内可预览文件,忽略 `node_modules`、`.git` 等目录;工作区外路径不会入栏。 +- 空正文诊断仍只给界面看。已经写进旧会话模型上下文的诊断不会自动清除。 +- 本次不更新其他产品的内嵌运行时,不同步真实会话、凭据或研究数据。自动化测试不等于所有外部模型供应商或每份私人研究数据都已验证。 + +## 升级与产物位置 + +需要 Node.js 22.18+。从源码更新: + +```sh +git pull +npm ci +npm run verify:install +npm link +ant-code --version +``` + +`ant-code --version` 应显示 `2.0.13`。若以前 link 过旧安装,需要重新 `npm link`。正在跑的 Dashboard 需要重启并硬刷新浏览器。现有网关配置和会话不必重建。若同一来源有多份不同 Key,可在设置页确认或改选生效凭据。 + +## 验证、限制与待完成门禁 + +- 开发阶段已覆盖产物收集与自动预览、凭据自动选定与 401 提示、空正文诊断不进模型上下文、口头承诺调工具的换绑定重试、原生搜索协议错配,以及会话绑定落盘恢复。 +- 开发仓完整 `npm run check` 与跨仓一致性检查在脱敏同步时记录实际数字。候选提交后仍须 GitHub PR 和 main CI 通过,随后创建标签与 Release;实际完成状态记录在公开 CHANGELOG。 +- 未对每个外部模型供应商做真实账户验收。口头承诺重试只覆盖「短正文 + 点名本地工具 + 无 tool call」;普通短答不会触发。网关绑定重试不能证明所有第三方代理的内部路由实现。原生 `web_search_call` 会报错,不会自动改成本地搜索工具。 diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 86b0722..de33fcc 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1,12 +1,12 @@ { "name": "@ant-code/cli", - "version": "2.0.12", + "version": "2.0.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ant-code/cli", - "version": "2.0.12", + "version": "2.0.13", "license": "AGPL-3.0-only", "dependencies": { "@napi-rs/canvas": "1.0.9", diff --git a/package-lock.json b/package-lock.json index 86b0722..de33fcc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ant-code/cli", - "version": "2.0.12", + "version": "2.0.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ant-code/cli", - "version": "2.0.12", + "version": "2.0.13", "license": "AGPL-3.0-only", "dependencies": { "@napi-rs/canvas": "1.0.9", diff --git a/package.json b/package.json index 34b4dfe..f25d345 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ant-code/cli", - "version": "2.0.12", + "version": "2.0.13", "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/config/source-credentials.ts b/src/config/source-credentials.ts index 3fbe165..c156dcb 100644 --- a/src/config/source-credentials.ts +++ b/src/config/source-credentials.ts @@ -14,18 +14,57 @@ export function sourceCredentialState(config: RecordValue) { for (const [url, members] of groups) { const explicit = lab.sourceCredentialSelections?.[url]; const retained = lab.resolvedSourceCredentials?.[url]; - const requested = explicit ?? retained; - if (requested !== undefined) { - result[url] = members.find((p) => p.id === requested)?.id ?? null; - continue; - } - const keys = new Set(members.map((p) => JSON.stringify([p.gatewayApiKeyDisabled === true, p.gatewayApiKey || ""]))); - const active = members.find((p) => p.id === lab.activeGatewayProfile); - result[url] = keys.size === 1 ? members[0].id : active?.id ?? null; + result[url] = pickSourceCredential(members, lab, explicit ?? retained); } return result; } +function pickSourceCredential(members: RecordValue[], lab: RecordValue, requested: unknown) { + const requestedId = typeof requested === "string" ? requested.trim() : ""; + if (requestedId) { + const saved = members.find((profile) => profile.id === requestedId); + if (saved) { + return saved.id; + } + } + const keys = new Set(members.map((profile) => JSON.stringify([profile.gatewayApiKeyDisabled === true, profile.gatewayApiKey || ""]))); + if (keys.size === 1) { + return members[0].id; + } + const active = members.find((profile) => profile.id === lab.activeGatewayProfile); + if (active) { + return active.id; + } + return members[0]?.id ?? null; +} + +export function sourceHasAlternateCredentials(config: RecordValue) { + return distinctSourceCredentialCount(config) > 1; +} + +export function sourceCredentialFailureMessage(canSwitch: boolean, status: number | null = null) { + const action = canSwitch + ? "请打开设置,为这个来源切换生效凭据后再试。" + : "请到设置页检查这份凭据。"; + const http = Number.isInteger(status) && Number(status) > 0 ? `(HTTP ${status})` : ""; + return `当前生效凭据不能用。${action}${http}`; +} + +function distinctSourceCredentialCount(config: RecordValue) { + const lab = config.lab ?? {}; + const url = String(lab.gatewayUrl || ""); + if (!url) { + return 0; + } + const profiles: RecordValue[] = Array.isArray(lab.gatewayProfiles) ? lab.gatewayProfiles : []; + const keys = new Set( + profiles + .filter((profile) => String(profile.gatewayUrl || "") === url) + .map((profile) => JSON.stringify([profile.gatewayApiKeyDisabled === true, profile.gatewayApiKey || ""])) + ); + return keys.size; +} + export function applySourceCredentials(config: T): T { const lab = config.lab ?? {}; const resolved = sourceCredentialState(config); diff --git a/src/core/session-health.ts b/src/core/session-health.ts index 33b8f7e..e66b827 100644 --- a/src/core/session-health.ts +++ b/src/core/session-health.ts @@ -127,10 +127,50 @@ export function formatAssistantOutput(data: import("../model-gateway/protocol.ts return "模型本轮没有返回可展示正文。"; } +const PROMISED_TOOL_STOP_REASONS = new Set(["", "stop", "end_turn", "completed"]); +const PROMISED_TOOL_VISIBLE_LIMIT = 160; + +export function looksLikePromisedToolWithoutCall( + data: import("../model-gateway/protocol.ts").NormalizedGatewayResponse, + visibleText: string, + thinkingText: string, + toolNames: string[] | undefined +) { + if (data?.toolCalls?.length) { + return false; + } + const stopReason = String(data?.stopReason ?? "").toLowerCase(); + if (!PROMISED_TOOL_STOP_REASONS.has(stopReason)) { + return false; + } + const text = String(visibleText ?? "").trim(); + if (text.length > PROMISED_TOOL_VISIBLE_LIMIT) { + return false; + } + const names = (toolNames ?? []).map((name) => String(name ?? "").trim()).filter((name) => name.length >= 3); + if (names.length === 0) { + return false; + } + const haystack = `${thinkingText}\n${text}`; + const mentioned = names.filter((name) => haystack.includes(name)); + if (mentioned.length === 0) { + return false; + } + if (/(马上|立刻|现在|补测|随后|然后|调用|let me|i will|i'll|going to)/i.test(haystack)) { + return true; + } + return Boolean(thinkingText) && text.length <= 80; +} -export function analyzeAssistantOutputHealth(data: import("../model-gateway/protocol.ts").NormalizedGatewayResponse, finalOutput: unknown, thinking: { text?: string; bytes?: number } | null | undefined) { + +export function analyzeAssistantOutputHealth( + data: import("../model-gateway/protocol.ts").NormalizedGatewayResponse, + _finalOutput: unknown, + thinking: { text?: string; bytes?: number } | null | undefined, + options: { toolNames?: string[] } = {} +) { const reasons = []; - const text = String(finalOutput ?? "").trim(); + const text = assistantResponseText(data).trim(); const stopReason = String(data?.stopReason ?? "").toLowerCase(); const thinkingText = String(thinking?.text ?? ""); const thinkingBytes = thinking && Number.isFinite(thinking.bytes) ? Number(thinking.bytes) : gatewayThinkingBytes(data); @@ -141,7 +181,7 @@ export function analyzeAssistantOutputHealth(data: import("../model-gateway/prot if (["length", "max_tokens", "token_limit", "context_length_exceeded"].includes(stopReason)) { reasons.push(`stop_reason:${stopReason}`); } - if (text.length > 0 && data?.toolCalls?.length === 0 && thinkingBytes >= 1024 && dataTextBytes(data) === 0 && ["length", "max_tokens", "token_limit"].includes(stopReason)) { + if (data?.toolCalls?.length === 0 && thinkingBytes >= 1024 && dataTextBytes(data) === 0 && ["length", "max_tokens", "token_limit"].includes(stopReason)) { reasons.push("reasoning_only_length"); } if (data?.toolCalls?.length === 0 && dataTextBytes(data) === 0 && thinkingBytes >= 4096 && looksLikeRepetitiveThinkingLoop(thinkingText)) { @@ -159,6 +199,9 @@ export function analyzeAssistantOutputHealth(data: import("../model-gateway/prot if (looksLikeTruncatedSentence(text)) { reasons.push("truncated_visible_text"); } + if (looksLikePromisedToolWithoutCall(data, text, thinkingText, options.toolNames)) { + reasons.push("promised_tool_without_call"); + } return { ok: reasons.length === 0, @@ -262,6 +305,9 @@ export function buildOutputHealthRepairPrompt(health: { reasons?: string[] } | n reasoningOnlyLength ? "The previous model call exhausted its completion budget in reasoning/thinking without producing visible user-facing text." : "", + reasons.includes("promised_tool_without_call") + ? "The previous model call only promised a tool call in a short sentence and did not emit a tool call. Call the tool now; do not answer with another one-line promise." + : "", repetitiveThinking ? "The previous model call repeated internal planning text in a thinking loop. Break the loop and answer directly." : "", diff --git a/src/core/session-persist.ts b/src/core/session-persist.ts index 5913640..91d3176 100644 --- a/src/core/session-persist.ts +++ b/src/core/session-persist.ts @@ -17,6 +17,7 @@ import { createSessionStore } from "../storage/session-store.ts"; import { serializeToolResult } from "../tools/result.ts"; import { countLineChanges } from "../tools/diff.ts"; import { createToolRuntime } from "../tools/runtime.ts"; +import { serializeExhibitArtifacts } from "../tools/artifacts.ts"; import { createWorkflowState, formatWorkflowContext, summarizeWorkflow, syncWorkflowCompletionOnFinal, type WorkflowState } from "../tools/workflow-tools.ts"; import { getAgentProfile } from "../agents/profiles.ts"; import { resolveMaxParallelReadonlyAgentRuns } from "../agents/orchestration-config.ts"; @@ -182,23 +183,32 @@ export async function appendSessionMessages(session: AgentSession, data: import( hooksTrusted?: boolean; eventOptions?: Record; } = {}) { - const assistantContent = data.content.length > 0 - ? data.content - : [{ type: "text", text: fallbackText }]; - const assistantMessage: SessionMessage = { + const thinking = normalizeAssistantThinking(options.thinking); + const modelContent = Array.isArray(data.content) ? data.content : []; + const contextAssistant: SessionMessage = { role: "assistant", - content: assistantContent + content: modelContent }; - const thinking = normalizeAssistantThinking(options.thinking); if (thinking) { - assistantMessage.thinking = thinking; + contextAssistant.thinking = thinking; + } + if (Array.isArray(data.toolCalls) && data.toolCalls.length > 0) { + contextAssistant.toolCalls = data.toolCalls; + } + + const transcriptAssistant: SessionMessage = { + role: "assistant", + content: modelContent.length > 0 ? modelContent : [{ type: "text", text: fallbackText }] + }; + if (thinking) { + transcriptAssistant.thinking = thinking; } const turnMessages = Array.isArray(options.turnMessages) ? options.turnMessages : []; const transcriptMessages = Array.isArray(options.transcriptMessages) ? options.transcriptMessages : turnMessages; - session.messages.push(...turnMessages, assistantMessage); - appendTranscriptMessages(session, [...transcriptMessages, assistantMessage]); - appendModelContextArchiveMessages(session, [...turnMessages, assistantMessage]); + session.messages.push(...turnMessages, contextAssistant); + appendTranscriptMessages(session, [...transcriptMessages, transcriptAssistant]); + appendModelContextArchiveMessages(session, [...turnMessages, contextAssistant]); return compactSessionContextWithModel(session, { reason: "automatic", @@ -243,6 +253,7 @@ export async function persistSessionMetadata(store: ReturnType} */ export function sessionModelMetadata(session: AgentSession): Record { const selection = refreshSessionModelSelection(session); - if (selection) return patchSessionModelSelectionMetadata({}, selection as Record); + const routing = typeof session.gatewaySessionAffinity === "string" && session.gatewaySessionAffinity.trim() + ? { gatewaySessionAffinity: session.gatewaySessionAffinity.trim() } + : {}; + if (selection) return patchSessionModelSelectionMetadata(routing, selection as Record); if (session.config?.configV2?.enabled === true) { throw new SessionModelSelectionUnresolvedError({ reason: "invalid-runtime-selection", @@ -535,6 +550,7 @@ export function sessionModelMetadata(session: AgentSession): Record) => emitGatewayStreamEvent(eventOptions, event, round + 1) @@ -516,7 +517,9 @@ export async function runSessionTurn(session: AgentSession, options: RunSessionT metadata.rounds = round + 1; finalOutput = formatAssistantOutput(response.data); const thinking = thinkingForRound(thinkingCapture, round + 1, response.data); - const outputHealth = analyzeAssistantOutputHealth(response.data, finalOutput, thinking); + const outputHealth = analyzeAssistantOutputHealth(response.data, finalOutput, thinking, { + toolNames: (session.context.tools ?? []).map((tool) => String(tool?.name ?? "")).filter(Boolean) + }); if (OUTPUT_HEALTH_CHECK_ENABLED || outputHealth.mustRetry) { recordOutputHealth(metadata, { round: round + 1, @@ -525,6 +528,20 @@ export async function runSessionTurn(session: AgentSession, options: RunSessionT }); if (!outputHealth.ok && shouldRetryOutputHealth(outputHealth, outputHealthRetries)) { outputHealthRetries += 1; + if (outputHealth.reasons.includes("promised_tool_without_call")) { + rotateGatewaySessionAffinity(session); + await emitEvent(eventOptions, { + type: "output_health_retry", + round: round + 1, + reasons: outputHealth.reasons, + stopReason: response.data.stopReason ?? null, + textBytes: Buffer.byteLength(finalOutput, "utf8"), + thinkingBytes: thinking?.bytes ?? gatewayThinkingBytes(response.data), + retry: outputHealthRetries, + gatewaySessionAffinity: session.gatewaySessionAffinity + }); + continue; + } const retryAssistantMessage: SessionMessage = { role: "assistant", content: response.data.content.length > 0 @@ -709,6 +726,10 @@ export async function runSessionTurn(session: AgentSession, options: RunSessionT } } +function rotateGatewaySessionAffinity(session: AgentSession) { + session.gatewaySessionAffinity = `retry-${Date.now().toString(36)}-${crypto.randomBytes(4).toString("hex")}`; +} + /** * @param {AgentSession} session * @param {string} prompt diff --git a/src/core/session-types.ts b/src/core/session-types.ts index 2da3932..731b25e 100644 --- a/src/core/session-types.ts +++ b/src/core/session-types.ts @@ -35,7 +35,8 @@ export const OUTPUT_HEALTH_MAX_RETRIES = 1; export const OUTPUT_HEALTH_RETRY_REQUIRED_REASONS = new Set([ "missing_terminal_signal", "repetitive_thinking_loop", - "reasoning_only_length" + "reasoning_only_length", + "promised_tool_without_call" ]); export const TRANSCRIPT_MEMORY_MESSAGES = 50; export const DEFAULT_RESUME_CONTEXT_MESSAGES = 200; @@ -79,6 +80,8 @@ export type SessionMessage = { export type AgentSession = { id: string; + /** Optional routing reset; does not change the local session identity. */ + gatewaySessionAffinity?: string; cwd: string; startedAt: string; mode: "interactive" | "print"; diff --git a/src/core/session.ts b/src/core/session.ts index 7b6af77..0ac8607 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -129,6 +129,9 @@ export async function createSession(options: CreateSessionOptions): Promise) { })]; } if (type === "gateway_error" || type === "gateway_not_configured") { - return [activity("gateway-error", "模型请求失败", asRecord(event.error).message ?? "网关未配置或请求失败", "failed", "gateway", event, { coalesceKey: "gateway" })]; + return [activity("gateway-error", "模型请求失败", gatewayErrorDetail(event.error), "failed", "gateway", event, { coalesceKey: "gateway" })]; } if (type === "turn_interrupted") { return [activity("turn-interrupted", "任务已中断", event.reason ?? "用户中断", "interrupted", "session", event)]; @@ -251,6 +251,18 @@ function backgroundProgressTitle(status: string) { return "子任务组执行失败"; } +function gatewayErrorDetail(error: unknown) { + const record = asRecord(error); + const message = String(record.message ?? "").trim() || "网关未配置或请求失败"; + const hint = Array.isArray(record.diagnostics) + ? record.diagnostics.map((item) => String(item ?? "").trim()).find((item) => item.includes("生效凭据")) + : ""; + if (!hint || message.includes("生效凭据")) { + return message; + } + return `${message} ${hint}`; +} + /** * @param {Record} request */ diff --git a/src/dashboard/files.ts b/src/dashboard/files.ts index daeb166..89d3744 100644 --- a/src/dashboard/files.ts +++ b/src/dashboard/files.ts @@ -3,6 +3,7 @@ import { readdirSync, realpathSync, statSync } from "node:fs"; import path from "node:path"; import { parseDocumentBufferAsync } from "../tools/document-tools.ts"; import { COMPOSER_UPLOAD_DIR } from "../tools/composer-documents.ts"; +import { extractCandidatePaths } from "../tools/artifacts.ts"; const DATA_EXTENSIONS = new Set([".json", ".csv", ".tsv", ".yaml", ".yml"]); const TEXT_EXTENSIONS = new Set([".txt", ".log", ".json", ".csv", ".tsv", ".md", ".markdown", ".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".css", ".html", ".xml", ".yaml", ".yml", ".py", ".ps1", ".cmd", ".sh", ".java", ".c", ".cpp", ".h", ".hpp", ".cs", ".go", ".rs", ".php", ".rb", ".sql", ".toml", ".ini"]); @@ -317,7 +318,7 @@ export async function readRawFile(cwd: string, requestedPath: string) { * @param {Record} session * @param {string} finalOutput */ -export function collectSessionFiles(session: { cwd?: unknown; workflow?: unknown } = {}, finalOutput: string = "") { +export function collectSessionFiles(session: { cwd?: unknown; workflow?: unknown; artifacts?: unknown } = {}, finalOutput: string = "") { const cwd = typeof session?.cwd === "string" ? session.cwd : process.cwd(); const items: SessionFileItem[] = []; const workflow = session?.workflow && typeof session.workflow === "object" ? session.workflow as { changes?: unknown } : {}; @@ -329,6 +330,13 @@ export function collectSessionFiles(session: { cwd?: unknown; workflow?: unknown toolName: record.toolName ?? null }); } + const persisted = Array.isArray(session?.artifacts) ? session.artifacts : []; + for (const artifact of persisted) { + const record = isRecord(artifact) ? artifact : {}; + addFile(items, cwd, record.relativePath ?? record.path, { + source: typeof record.source === "string" ? record.source : "created" + }); + } for (const candidate of extractPaths(finalOutput)) { addFile(items, cwd, candidate, { source: "mentioned" }); } @@ -526,8 +534,7 @@ function fileKindForTextExtension(ext: string) { } function extractPaths(text: string) { - const matches = String(text ?? "").match(/(?:[A-Za-z]:[\\/][^\s"'<>|]+|(?:\.{1,2}[\\/])?[A-Za-z0-9_.-]+[\\/][A-Za-z0-9_.-]+\.[A-Za-z0-9]{1,8}|[A-Za-z0-9_.-]+\.[A-Za-z0-9]{1,8})/g); - return matches ?? []; + return extractCandidatePaths(text); } function addFile(items: SessionFileItem[], cwd: string, target: unknown, meta: Partial = {}) { diff --git a/src/dashboard/public/app-core.ts b/src/dashboard/public/app-core.ts index 61671e7..ac53108 100644 --- a/src/dashboard/public/app-core.ts +++ b/src/dashboard/public/app-core.ts @@ -598,6 +598,7 @@ export type DashboardUiState = { deletingSessions: Set; deleteConfirmSessionId: string; files: DashboardFile[]; + previewPath: string; liveTitle: string; liveActivities: Map; backgroundSubagents: Map; @@ -769,6 +770,7 @@ export const state: DashboardUiState = { deletingSessions: new Set(), deleteConfirmSessionId: "", files: [], + previewPath: "", liveTitle: "", liveActivities: new Map(), backgroundSubagents: new Map(), diff --git a/src/dashboard/public/app-ui2.ts b/src/dashboard/public/app-ui2.ts index c82b277..f49960b 100644 --- a/src/dashboard/public/app-ui2.ts +++ b/src/dashboard/public/app-ui2.ts @@ -3,7 +3,7 @@ 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_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"; +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, applySessionFiles, 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 } = {}) { if (!modal) return; if (state.modalContext?.modal === modal) { @@ -436,9 +436,8 @@ export async function openSession(id: string) { }); updateSendButton(); resetTurnChangeStats(); - state.files = Array.isArray(loadedSession.files) ? loadedSession.files : []; - renderFiles(); - resetPreview(); + state.previewPath = ""; + applySessionFiles(Array.isArray(loadedSession.files) ? loadedSession.files : [], { force: true }); els.runStatus.textContent = loadedSession.status || "历史"; setTranscriptPaging(loadedSession.transcriptPage); renderTranscriptMessages(loadedSession.transcript ?? []); @@ -548,6 +547,7 @@ export function newTask() { hideContextConfirm(); clearTranscript(); state.files = []; + state.previewPath = ""; state.queue = []; state.queueCancelling.clear(); state.backgroundCancelling.clear(); diff --git a/src/dashboard/public/app-ui3.ts b/src/dashboard/public/app-ui3.ts index 82584e0..4c52ff7 100644 --- a/src/dashboard/public/app-ui3.ts +++ b/src/dashboard/public/app-ui3.ts @@ -3,7 +3,7 @@ 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, 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, clearPermissionWaitActivity, 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, userTranscriptDisplayText, transcriptMessageAttachments, 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, stableTurnRequest, dashboardRequestId, dashboardClientId, statusUrl, interruptTurn, guideTurn, cancelQueuedTurn, 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, clearPermissionWaitActivity, 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, applySessionFiles, 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, userTranscriptDisplayText, transcriptMessageAttachments, 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 cancelBackgroundSubagent(groupId: unknown, taskId: unknown) { const key = backgroundCancelKey(groupId, taskId); if (!state.currentSessionId || !key || state.backgroundCancelling.has(key)) { @@ -249,7 +249,10 @@ export function setConnectionState(next: string) { const text = els.connectionStatus.querySelector(".connection-label"); if (text) text.textContent = label; if (next !== previous && ["offline", "unavailable", "error", "reconnecting", "stale"].includes(next)) { - announceStatus(label); + const uncertain = "Dashboard 连接中断,任务状态暂时未知;请勿重复发送。点击连接状态重连;若服务已退出,请重新启动 Dashboard 后刷新页面。"; + els.connectionStatus.title = uncertain; + if (state.running) els.runStatus.textContent = "连接中断 · 任务状态未知"; + announceStatus(uncertain); } else if (next === "connected" && ["offline", "unavailable", "error", "reconnecting", "stale"].includes(previous)) { announceStatus("本地网关已重新连接"); } @@ -562,9 +565,12 @@ export function handleDashboardEvent(event: DashboardStreamEvent) { scheduleSessionsRefresh(); return; } + if (event.type === "artifacts_updated") { + applySessionFiles(event.files ?? [], { exhibit: true }); + return; + } if (event.type === "files_updated") { - state.files = event.files ?? []; - renderFiles(); + applySessionFiles(event.files ?? [], { exhibit: true }); if (shouldKeepGuideFeedback()) { els.runStatus.textContent = "引导中"; updateLiveStatus(); diff --git a/src/dashboard/public/app-ui9.ts b/src/dashboard/public/app-ui9.ts index 1748fb6..219bf3f 100644 --- a/src/dashboard/public/app-ui9.ts +++ b/src/dashboard/public/app-ui9.ts @@ -68,6 +68,68 @@ export function renderShutdownActivity() { els.shutdownConfirm.textContent = activity.total > 0 ? "取消任务并关闭" : "确认关闭"; } +const EXHIBIT_FILE_KINDS = new Set([ + "image", + "pdf", + "office-preview", + "office", + "markdown", + "table-preview", + "data" +]); +const EXHIBIT_TEXT_EXTENSIONS = new Set(["txt", "log", "html", "htm"]); + +export function isExhibitFile(file: DashboardFile | null | undefined) { + if (!file?.relativePath) { + return false; + } + const kind = String(file.kind ?? ""); + if (EXHIBIT_FILE_KINDS.has(kind)) { + return true; + } + if (kind !== "text") { + return false; + } + const name = String(file.relativePath ?? file.name ?? ""); + const ext = name.includes(".") ? name.slice(name.lastIndexOf(".") + 1).toLowerCase() : ""; + return EXHIBIT_TEXT_EXTENSIONS.has(ext); +} + +export function pickExhibitFile( + files: DashboardFile[] | null | undefined, + options: { previousPaths?: Array; force?: boolean } = {} +) { + const previous = new Set( + (options.previousPaths ?? []).map((value) => String(value ?? "").replace(/\\/g, "/").toLowerCase()).filter(Boolean) + ); + const items = (Array.isArray(files) ? files : []).filter(isExhibitFile); + const fresh = items.filter((file) => !previous.has(String(file.relativePath ?? "").replace(/\\/g, "/").toLowerCase())); + const pool = fresh.length > 0 ? fresh : (options.force === true ? items : []); + return pool.at(-1) ?? null; +} + +export function applySessionFiles(files: DashboardFile[] | null | undefined, options: { exhibit?: boolean; force?: boolean } = {}) { + const previousPaths = state.files.map((file) => file.relativePath); + state.files = Array.isArray(files) ? files : []; + renderFiles(); + if (options.exhibit === false) { + return; + } + const next = pickExhibitFile(state.files, { + previousPaths, + force: options.force === true || !state.previewPath + }); + if (next?.relativePath && next.relativePath !== state.previewPath) { + void openFile(next.relativePath); + return; + } + if (!state.previewPath) { + resetPreview(); + return; + } + syncFileListSelection(); +} + export function renderFiles() { els.fileList.innerHTML = ""; if (state.files.length === 0) { @@ -81,15 +143,26 @@ export function renderFiles() { const item = document.createElement("button"); item.type = "button"; item.className = "file-item"; + item.dataset.file = file.relativePath ?? ""; item.innerHTML = `
${escapeHtml(file.name)}
${escapeHtml(file.kind)} · ${escapeHtml(file.source ?? "file")}
`; + item.classList.toggle("active", Boolean(state.previewPath) && item.dataset.file === state.previewPath); item.addEventListener("click", () => openFile(file.relativePath)); els.fileList.append(item); } } +export function syncFileListSelection() { + els.fileList.querySelectorAll(".file-item").forEach((item) => { + if (!(item instanceof HTMLElement)) { + return; + } + item.classList.toggle("active", Boolean(state.previewPath) && item.dataset.file === state.previewPath); + }); +} + export function currentImageFiles() { return state.files .filter((file) => file.kind === "image") @@ -102,6 +175,8 @@ export function currentImageFiles() { export async function openFile(filePath: string | null | undefined) { if (!filePath) return; + state.previewPath = filePath; + syncFileListSelection(); const sessionId = state.currentSessionId; const request = beginScopedRequest("file", `${sessionId ?? "new"}:${filePath}`); els.previewBody.className = "preview-body"; @@ -442,8 +517,10 @@ export function renderSheetCellHtml(line: unknown) { } export function resetPreview(message: string = "任务产物会显示在这里") { + state.previewPath = ""; els.previewBody.className = "preview-body"; els.previewBody.innerHTML = `
${escapeHtml(message)}
`; + syncFileListSelection(); } export function fencedDataForFile(file: DashboardFile) { diff --git a/src/dashboard/public/app.js b/src/dashboard/public/app.js index c560e4e..4944e4c 100644 --- a/src/dashboard/public/app.js +++ b/src/dashboard/public/app.js @@ -87,6 +87,7 @@ var state = { deletingSessions: /* @__PURE__ */ new Set(), deleteConfirmSessionId: "", files: [], + previewPath: "", liveTitle: "", liveActivities: /* @__PURE__ */ new Map(), backgroundSubagents: /* @__PURE__ */ new Map(), @@ -785,9 +786,8 @@ async function openSession(id) { }); updateSendButton(); resetTurnChangeStats(); - state.files = Array.isArray(loadedSession.files) ? loadedSession.files : []; - renderFiles(); - resetPreview(); + state.previewPath = ""; + applySessionFiles(Array.isArray(loadedSession.files) ? loadedSession.files : [], { force: true }); els.runStatus.textContent = loadedSession.status || "历史"; setTranscriptPaging(loadedSession.transcriptPage); renderTranscriptMessages(loadedSession.transcript ?? []); @@ -891,6 +891,7 @@ function newTask() { hideContextConfirm(); clearTranscript(); state.files = []; + state.previewPath = ""; state.queue = []; state.queueCancelling.clear(); state.backgroundCancelling.clear(); @@ -2476,7 +2477,10 @@ function setConnectionState2(next) { const text = els.connectionStatus.querySelector(".connection-label"); if (text) text.textContent = label; if (next !== previous && ["offline", "unavailable", "error", "reconnecting", "stale"].includes(next)) { - announceStatus2(label); + const uncertain = "Dashboard 连接中断,任务状态暂时未知;请勿重复发送。点击连接状态重连;若服务已退出,请重新启动 Dashboard 后刷新页面。"; + els.connectionStatus.title = uncertain; + if (state.running) els.runStatus.textContent = "连接中断 · 任务状态未知"; + announceStatus2(uncertain); } else if (next === "connected" && ["offline", "unavailable", "error", "reconnecting", "stale"].includes(previous)) { announceStatus2("本地网关已重新连接"); } @@ -2786,9 +2790,12 @@ function handleDashboardEvent3(event) { scheduleSessionsRefresh(); return; } + if (event.type === "artifacts_updated") { + applySessionFiles(event.files ?? [], { exhibit: true }); + return; + } if (event.type === "files_updated") { - state.files = event.files ?? []; - renderFiles(); + applySessionFiles(event.files ?? [], { exhibit: true }); if (shouldKeepGuideFeedback3()) { els.runStatus.textContent = "引导中"; updateLiveStatus3(); @@ -8296,6 +8303,61 @@ function renderShutdownActivity8() { els.shutdownConfirm.disabled = false; els.shutdownConfirm.textContent = activity.total > 0 ? "取消任务并关闭" : "确认关闭"; } +var EXHIBIT_FILE_KINDS = /* @__PURE__ */ new Set([ + "image", + "pdf", + "office-preview", + "office", + "markdown", + "table-preview", + "data" +]); +var EXHIBIT_TEXT_EXTENSIONS = /* @__PURE__ */ new Set(["txt", "log", "html", "htm"]); +function isExhibitFile(file) { + if (!file?.relativePath) { + return false; + } + const kind = String(file.kind ?? ""); + if (EXHIBIT_FILE_KINDS.has(kind)) { + return true; + } + if (kind !== "text") { + return false; + } + const name = String(file.relativePath ?? file.name ?? ""); + const ext = name.includes(".") ? name.slice(name.lastIndexOf(".") + 1).toLowerCase() : ""; + return EXHIBIT_TEXT_EXTENSIONS.has(ext); +} +function pickExhibitFile(files, options = {}) { + const previous = new Set( + (options.previousPaths ?? []).map((value) => String(value ?? "").replace(/\\/g, "/").toLowerCase()).filter(Boolean) + ); + const items = (Array.isArray(files) ? files : []).filter(isExhibitFile); + const fresh = items.filter((file) => !previous.has(String(file.relativePath ?? "").replace(/\\/g, "/").toLowerCase())); + const pool = fresh.length > 0 ? fresh : options.force === true ? items : []; + return pool.at(-1) ?? null; +} +function applySessionFiles(files, options = {}) { + const previousPaths = state.files.map((file) => file.relativePath); + state.files = Array.isArray(files) ? files : []; + renderFiles(); + if (options.exhibit === false) { + return; + } + const next = pickExhibitFile(state.files, { + previousPaths, + force: options.force === true || !state.previewPath + }); + if (next?.relativePath && next.relativePath !== state.previewPath) { + void openFile9(next.relativePath); + return; + } + if (!state.previewPath) { + resetPreview(); + return; + } + syncFileListSelection(); +} function renderFiles() { els.fileList.innerHTML = ""; if (state.files.length === 0) { @@ -8309,14 +8371,24 @@ function renderFiles() { const item = document.createElement("button"); item.type = "button"; item.className = "file-item"; + item.dataset.file = file.relativePath ?? ""; item.innerHTML = `
${escapeHtml(file.name)}
${escapeHtml(file.kind)} · ${escapeHtml(file.source ?? "file")}
`; + item.classList.toggle("active", Boolean(state.previewPath) && item.dataset.file === state.previewPath); item.addEventListener("click", () => openFile9(file.relativePath)); els.fileList.append(item); } } +function syncFileListSelection() { + els.fileList.querySelectorAll(".file-item").forEach((item) => { + if (!(item instanceof HTMLElement)) { + return; + } + item.classList.toggle("active", Boolean(state.previewPath) && item.dataset.file === state.previewPath); + }); +} function currentImageFiles9() { return state.files.filter((file) => file.kind === "image").map((file) => ({ name: file.name, @@ -8326,6 +8398,8 @@ function currentImageFiles9() { } async function openFile9(filePath) { if (!filePath) return; + state.previewPath = filePath; + syncFileListSelection(); const sessionId = state.currentSessionId; const request = beginScopedRequest("file", `${sessionId ?? "new"}:${filePath}`); els.previewBody.className = "preview-body"; @@ -8644,8 +8718,10 @@ function renderSheetCellHtml9(line) { return `
${escapeHtml(match[1])}
${escapeHtml(match[2])}
`; } function resetPreview(message = "任务产物会显示在这里") { + state.previewPath = ""; els.previewBody.className = "preview-body"; els.previewBody.innerHTML = `
${escapeHtml(message)}
`; + syncFileListSelection(); } function fencedDataForFile9(file) { const language = dataLanguageForExtension9(file.extension); @@ -9928,6 +10004,7 @@ export { applyPendingReasoningCapabilities5 as applyPendingReasoningCapabilities, applyProbedModel5 as applyProbedModel, applyReasoningCapabilityCandidate6 as applyReasoningCapabilityCandidate, + applySessionFiles, applySuggestedGatewayUrl5 as applySuggestedGatewayUrl, armEventConnectTimer3 as armEventConnectTimer, armEventStaleTimer3 as armEventStaleTimer, @@ -10117,6 +10194,7 @@ export { isCurrentScopedRequest, isDisabledReasoningEffort6 as isDisabledReasoningEffort, isDuplicateDraftText8 as isDuplicateDraftText, + isExhibitFile, isInterruptError3 as isInterruptError, isLikelyLocalFileReference10 as isLikelyLocalFileReference, isMeaningfulCompletedActivity4 as isMeaningfulCompletedActivity, @@ -10192,6 +10270,7 @@ export { openSession, parentDirectory9 as parentDirectory, permissionIndexForKey2 as permissionIndexForKey, + pickExhibitFile, postJson, previewText8 as previewText, previewWidthBounds2 as previewWidthBounds, @@ -10360,6 +10439,7 @@ export { switchReasoningEffort7 as switchReasoningEffort, syncAgentModelPickersForEndpoint6 as syncAgentModelPickersForEndpoint, syncEmptyStateCopy, + syncFileListSelection, syncGatewayUrlHint5 as syncGatewayUrlHint, syncGuideButton, syncPendingGuideFromQueue3 as syncPendingGuideFromQueue, diff --git a/src/dashboard/public/index.html b/src/dashboard/public/index.html index 3cbe152..e1a1c70 100644 --- a/src/dashboard/public/index.html +++ b/src/dashboard/public/index.html @@ -186,7 +186,7 @@

设置

-
图片、文本、Markdown、PDF
+
生成的图、报表和导出文件会自动预览
diff --git a/src/dashboard/runtime/api-sessions.ts b/src/dashboard/runtime/api-sessions.ts index 65096bc..1a39cc6 100644 --- a/src/dashboard/runtime/api-sessions.ts +++ b/src/dashboard/runtime/api-sessions.ts @@ -278,7 +278,8 @@ export async function runtimeReadSession(ctx: DashboardFactoryState, selector: u failure: persistedSessionFailure(metadata), files: collectSessionFiles({ cwd: session?.cwd ?? metadata.cwd ?? ctx.cwd, - workflow: session?.workflow ?? metadata.workflow ?? null + workflow: session?.workflow ?? metadata.workflow ?? null, + artifacts: metadata.artifacts ?? session?.artifacts ?? null }, finalText), workflow: session?.workflow ?? metadata.workflow ?? null, backgroundSnapshot: backgroundSnapshot ? publicBackgroundSnapshot(backgroundSnapshot) : null, diff --git a/src/dashboard/runtime/turn-queue.ts b/src/dashboard/runtime/turn-queue.ts index 04dd6a5..b60bda2 100644 --- a/src/dashboard/runtime/turn-queue.ts +++ b/src/dashboard/runtime/turn-queue.ts @@ -800,6 +800,7 @@ export function createTurnState( pendingApprovals: new Map(), pendingQuestions: new Map(), finalOutput: "", + lastArtifactFingerprint: "", backgroundSnapshotTimer: null, backgroundSnapshotDirty: false, backgroundSnapshotPromise: null, @@ -807,6 +808,34 @@ export function createTurnState( }; } +function artifactFingerprint(files: Array<{ relativePath?: string }>): string { + return files.map((file) => String(file.relativePath ?? "").toLowerCase()).join("\n"); +} + +function rememberSessionArtifactFingerprint(state: DashboardActiveSessionState, files: T[]): T[] { + state.lastArtifactFingerprint = artifactFingerprint(files); + return files; +} + +function publishSessionArtifacts(state: DashboardActiveSessionState) { + if (!state.currentTurnId || state.disposed) { + return; + } + const files = collectSessionFiles(state.session, state.finalOutput); + const fingerprint = artifactFingerprint(files); + if (fingerprint === state.lastArtifactFingerprint) { + return; + } + state.lastArtifactFingerprint = fingerprint; + appendDashboardEvent(state, { + type: "artifacts_updated", + id: eventId("artifacts"), + turnId: state.currentTurnId, + files, + at: new Date().toISOString() + }); +} + export async function prepareDashboardSessionForQueuedTurn(state: DashboardActiveSessionState, env: NodeJS.ProcessEnv | undefined) { let config; @@ -954,10 +983,13 @@ export function runTurnInBackground(state: DashboardActiveSessionState, item: Da if (currentTurn && event.type === "tool_finish" && (event.name === "todo_write" || event.name === "plan_update")) { appendWorkflowSnapshot(state, event.name); } - if (currentTurn && event.type === "tool_finish" && state.session.goal?.enabled) { + if (currentTurn && event.type === "tool_finish") { const name = String(event.name ?? ""); if (["write_file", "edit_file", "powershell", "bash"].includes(name)) { - state.session.goal.hasWrites = true; + publishSessionArtifacts(state); + if (state.session.goal?.enabled) { + state.session.goal.hasWrites = true; + } } } if (currentTurn && event.type === "workflow_updated") { @@ -987,7 +1019,7 @@ export function runTurnInBackground(state: DashboardActiveSessionState, item: Da type: "files_updated", id: eventId("files"), turnId: state.currentTurnId, - files: collectSessionFiles(state.session, state.finalOutput), + files: rememberSessionArtifactFingerprint(state, collectSessionFiles(state.session, state.finalOutput)), sessionStatus: sessionStatusSummary(state.session), changeStats: { ...state.turnChangeStats }, at: new Date().toISOString() diff --git a/src/dashboard/runtime/types.ts b/src/dashboard/runtime/types.ts index b6c68e6..be11fe7 100644 --- a/src/dashboard/runtime/types.ts +++ b/src/dashboard/runtime/types.ts @@ -218,6 +218,7 @@ export type DashboardActiveSessionState = { pendingApprovals: Map; pendingQuestions: Map; finalOutput: string; + lastArtifactFingerprint: string; backgroundSnapshotTimer: ReturnType | NodeJS.Timeout | null; backgroundSnapshotDirty: boolean; backgroundSnapshotPromise: Promise | null; diff --git a/src/model-gateway/client.ts b/src/model-gateway/client.ts index 2c51ab4..515abcc 100644 --- a/src/model-gateway/client.ts +++ b/src/model-gateway/client.ts @@ -1,4 +1,5 @@ import { decideNetworkAccess } from "../permissions/network-policy.ts"; +import { sourceCredentialFailureMessage, sourceHasAlternateCredentials } from "../config/source-credentials.ts"; import { isGatewayStreamInterruptedError, normalizeGatewayError, redactGatewayText } from "./errors.ts"; import { createOpenAIChatCompletionRequest, @@ -88,7 +89,7 @@ export function createLabModelGateway(config: import("../config/load-config.ts") /** * @param {{ messages: Array>; tools?: Array>; toolResults?: Array>; sessionId?: string; stream?: boolean; signal?: AbortSignal; onEvent?: (event: Record) => void | Promise }} request */ - async sendChat(request: { messages: Array>; tools?: Array>; toolResults?: Array>; sessionId?: string; stream?: boolean; signal?: AbortSignal; onEvent?: (event: Record) => void | Promise }): Promise { + async sendChat(request: { messages: Array>; tools?: Array>; toolResults?: Array>; sessionId?: string; sessionAffinity?: string; stream?: boolean; signal?: AbortSignal; onEvent?: (event: Record) => void | Promise }): Promise { if (config.lab.sourceCredentialSelectionRequired) { throw new Error("请先在设置页为当前 URL 来源选择生效凭据;未发送模型请求。"); } @@ -152,7 +153,7 @@ export function createLabModelGateway(config: import("../config/load-config.ts") try { response = await fetch(config.lab.gatewayUrl, { method: "POST", - headers: createHeaders(config, request.sessionId, protocol), + headers: createHeaders(config, request.sessionAffinity?.trim() || request.sessionId, protocol), body: JSON.stringify(gatewayRequest), signal: attemptAbort.signal }); @@ -257,11 +258,16 @@ export function createLabModelGateway(config: import("../config/load-config.ts") } finally { attemptAbort.cleanup(); } + const canSwitchSourceCredential = sourceHasAlternateCredentials(config); + const credentialFailure = response.status === 401 || response.status === 403; const error = normalizeGatewayError(null, { code: "GATEWAY_HTTP_ERROR", - message: `Gateway returned HTTP ${response.status}`, + message: credentialFailure + ? sourceCredentialFailureMessage(canSwitchSourceCredential, response.status) + : `Gateway returned HTTP ${response.status}`, status: response.status, protocol, + canSwitchSourceCredential, details: { body: errorBody.body, bodyTruncated: errorBody.truncated, @@ -393,6 +399,17 @@ export function createLabModelGateway(config: import("../config/load-config.ts") continue; } + if (protocol === "openai-responses" && isRecord(data.raw) && data.raw.nativeWebSearch === true + && Array.isArray(gatewayRequest.tools) + && !gatewayRequest.tools.some((tool) => isRecord(tool) && ["web_search", "web_search_preview"].includes(String(tool.type))) + && gatewayRequest.tools.some((tool) => isRecord(tool) && tool.type === "function" && tool.name === "web_search")) { + return { ok: false, error: normalizeGatewayError(null, { + code: "GATEWAY_TOOL_PROTOCOL_MISMATCH", + message: "网关返回了未请求的原生搜索事件,而不是本地 web_search 工具调用;本轮未正常完成。请检查网关协议映射或重置此会话的网关绑定。", + details: { receivedToolType: "web_search_call", expectedToolType: "function_call" }, + protocol + }) }; + } return { ok: true, data }; } diff --git a/src/model-gateway/errors.ts b/src/model-gateway/errors.ts index 9ff6f84..f6397d8 100644 --- a/src/model-gateway/errors.ts +++ b/src/model-gateway/errors.ts @@ -9,7 +9,7 @@ const LABELED_GATEWAY_CREDENTIAL_PATTERN = new RegExp( * @param {unknown} error * @param {{ code?: string; message?: string; status?: number; details?: Record; protocol?: string }} context */ -export function normalizeGatewayError(error: unknown, context: { code?: string; message?: string; status?: number; details?: Record; protocol?: string } = {}) { +export function normalizeGatewayError(error: unknown, context: { code?: string; message?: string; status?: number; details?: Record; protocol?: string; canSwitchSourceCredential?: boolean } = {}) { const code = context.code ?? inferCode(error); const details = redactDetails(context.details ?? {}); const providerMessage = extractProviderErrorMessage(details); @@ -19,7 +19,10 @@ export function normalizeGatewayError(error: unknown, context: { code?: string; status: context.status ?? null, details, providerMessage, - diagnostics: gatewayTroubleshootingHints(code, context.status ?? null, context.protocol, { providerMessage }), + diagnostics: gatewayTroubleshootingHints(code, context.status ?? null, context.protocol, { + providerMessage, + canSwitchSourceCredential: context.canSwitchSourceCredential === true + }), redacted: true }; } @@ -77,7 +80,7 @@ export function formatGatewayError(error: unknown) { * @param {string | undefined} protocol * @param {{ providerMessage?: string }} options */ -export function gatewayTroubleshootingHints(code: string, status: number | null = null, protocol: string | undefined = undefined, options: { providerMessage?: string } = {}) { +export function gatewayTroubleshootingHints(code: string, status: number | null = null, protocol: string | undefined = undefined, options: { providerMessage?: string; canSwitchSourceCredential?: boolean } = {}) { if (code === "GATEWAY_NOT_CONFIGURED") { return [ "Set LAB_MODEL_GATEWAY_URL to the lab gateway chat endpoint.", @@ -98,7 +101,9 @@ export function gatewayTroubleshootingHints(code: string, status: number | null ]; } if (status === 401 || status === 403) { - return ["Check lab gateway authentication and user authorization at the gateway service."]; + return [options.canSwitchSourceCredential === true + ? "当前生效凭据不能用。请打开设置,为这个来源切换生效凭据后再试。" + : "当前生效凭据不能用。请到设置页检查这份凭据。"]; } if (status === 404) { return [ diff --git a/src/model-gateway/openai-responses.ts b/src/model-gateway/openai-responses.ts index cc39e2e..c66264a 100644 --- a/src/model-gateway/openai-responses.ts +++ b/src/model-gateway/openai-responses.ts @@ -176,6 +176,7 @@ export async function parseOpenAIResponsesStream(body: ReadableStream(), itemIndexes: new Map(), responseItems: new Map(), @@ -314,6 +316,8 @@ async function applyResponsesRecord(aggregate: ResponsesAggregate, record: Recor if (isPlainObject(response.usage)) aggregate.usage = response.usage; } const type = String(record.type ?? ""); + aggregate.nativeWebSearch ||= type.startsWith("response.web_search_call.") + || (isPlainObject(record.item) && record.item.type === "web_search_call"); if (!aggregate.sawStart && (type === "response.created" || type === "response.in_progress" || aggregate.id)) { aggregate.sawStart = true; await emit(onEvent, { type: "message_start", id: aggregate.id, model: aggregate.model }); @@ -662,6 +666,7 @@ function summarizeResponse(raw: unknown, text: string, thinking: unknown, toolCa textBytes: Buffer.byteLength(text, "utf8"), thinkingBytes: Buffer.byteLength(String(thinking ?? ""), "utf8"), toolCallCount, + nativeWebSearch: Array.isArray(record.output) && record.output.some((item) => isPlainObject(item) && item.type === "web_search_call"), usage: isPlainObject(record.usage) ? record.usage : null }; } diff --git a/src/tools/artifacts.ts b/src/tools/artifacts.ts new file mode 100644 index 0000000..80c3abe --- /dev/null +++ b/src/tools/artifacts.ts @@ -0,0 +1,277 @@ +import { readdirSync, statSync } from "node:fs"; +import path from "node:path"; + +export const EXHIBIT_WALK_EXTENSIONS = new Set([ + ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", + ".pdf", + ".md", ".markdown", + ".csv", ".tsv", + ".docx", ".xlsx", ".pptx" +]); + +export const EXHIBIT_MENTION_EXTENSIONS = new Set([ + ...EXHIBIT_WALK_EXTENSIONS, + ".txt", ".json", ".yaml", ".yml", ".html", ".htm" +]); + +const IGNORE_DIR_NAMES = new Set([ + "node_modules", + ".git", + ".lab-agent", + "dist", + "coverage", + "__pycache__", + ".venv", + "venv", + "vendor", + ".tmp_validation", + ".tox", + ".mypy_cache", + ".pytest_cache", + ".next" +]); + +const MAX_WALK_ENTRIES = 4000; +const MAX_WALK_DEPTH = 6; +const MAX_SHELL_ARTIFACTS = 24; +const MAX_PERSISTED_ARTIFACTS = 50; +const ARTIFACT_MTIME_SLACK_MS = 2000; + +export type PersistedExhibitArtifact = { + relativePath: string; + name: string; + source: string; +}; + +export type ShellArtifact = { + path: string; + created: boolean; +}; + +/** + * Pull file-like paths out of command text, tool output, or assistant replies. + * Existence and workspace checks happen at the collection site. + */ +export function extractCandidatePaths(text: string): string[] { + const source = String(text ?? ""); + const out: string[] = []; + const seen = new Set(); + const add = (raw: string) => { + let value = String(raw ?? "").trim(); + if (!value) { + return; + } + value = value.replace(/^<|>$/g, ""); + const titled = value.match(/^(\S+)(?:\s+"[^"]*")?$/); + if (titled) { + value = titled[1]; + } + value = value.replace(/[.,;:!?]+$/g, ""); + if (!value || seen.has(value)) { + return; + } + if (/^(https?:|data:|mailto:)/i.test(value)) { + return; + } + const cleaned = value.split(/[?#]/)[0]; + const ext = path.extname(cleaned).toLowerCase(); + if (!ext || ext === ".") { + return; + } + seen.add(value); + out.push(cleaned); + }; + + for (const match of source.matchAll(/\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)) { + add(match[1]); + } + for (const match of source.matchAll(/`([^`]+)`/g)) { + add(match[1]); + } + for (const match of source.matchAll(/["']([^"']+)["']/g)) { + add(match[1]); + } + for (const match of source.matchAll(/[A-Za-z]:[\\/][^\s"'<>|*?]+/g)) { + add(match[0]); + } + for (const match of source.matchAll(/(?|*?:\\/]+[\\/])*[^\s"'<>|*?:\\/]+\.[A-Za-z0-9]{1,8}\b/g)) { + add(match[0]); + } + return out; +} + +export function isExhibitMentionPath(target: string): boolean { + return EXHIBIT_MENTION_EXTENSIONS.has(path.extname(String(target ?? "")).toLowerCase()); +} + +export function isExhibitWalkPath(target: string): boolean { + return EXHIBIT_WALK_EXTENSIONS.has(path.extname(String(target ?? "")).toLowerCase()); +} + +/** + * Collect previewable files a shell command just produced. + * Mentions in stdout/stderr are trusted; command-line paths and a bounded + * workspace walk only accept recently modified exhibit files. + */ +export function serializeExhibitArtifacts(cwd: string, changes: unknown): PersistedExhibitArtifact[] { + const root = path.resolve(String(cwd ?? "")); + const items: PersistedExhibitArtifact[] = []; + const seen = new Set(); + const records = Array.isArray(changes) ? changes : []; + for (const change of records) { + const record = change && typeof change === "object" && !Array.isArray(change) + ? change as { path?: unknown; created?: unknown; edited?: unknown } + : {}; + const value = String(record.path ?? "").trim(); + if (!value || !isExhibitWalkPath(value)) { + continue; + } + const resolved = path.resolve(root, value); + const relative = path.relative(root, resolved); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + continue; + } + if (relative.split(/[\\/]/).some((part) => IGNORE_DIR_NAMES.has(part.toLowerCase()))) { + continue; + } + try { + if (!statSync(resolved).isFile()) { + continue; + } + } catch { + continue; + } + const key = resolved.toLowerCase(); + if (seen.has(key)) { + continue; + } + seen.add(key); + items.push({ + relativePath: relative, + name: path.basename(resolved), + source: record.created ? "created" : record.edited ? "edited" : "changed" + }); + } + return items.slice(-MAX_PERSISTED_ARTIFACTS); +} + +export function collectShellArtifacts( + cwd: string, + input: { + command?: unknown; + stdout?: unknown; + stderr?: unknown; + durationMs?: unknown; + } = {} +): ShellArtifact[] { + const root = path.resolve(String(cwd ?? "")); + const now = Date.now(); + const durationMs = Number(input.durationMs); + const startedAt = Number.isFinite(durationMs) && durationMs >= 0 + ? now - durationMs - ARTIFACT_MTIME_SLACK_MS + : now - ARTIFACT_MTIME_SLACK_MS; + const items: ShellArtifact[] = []; + const seen = new Set(); + + const add = (target: unknown, requireRecent: boolean) => { + if (items.length >= MAX_SHELL_ARTIFACTS) { + return; + } + const value = String(target ?? "").trim(); + if (!value || !isExhibitMentionPath(value)) { + return; + } + const resolved = path.resolve(root, value); + const relative = path.relative(root, resolved); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + return; + } + if (relative.split(/[\\/]/).some((part) => IGNORE_DIR_NAMES.has(part.toLowerCase()))) { + return; + } + const key = resolved.toLowerCase(); + if (seen.has(key)) { + return; + } + let stat; + try { + stat = statSync(resolved); + } catch { + return; + } + if (!stat.isFile() || stat.size <= 0) { + return; + } + if (requireRecent && stat.mtimeMs < startedAt) { + return; + } + seen.add(key); + items.push({ + path: relative, + created: true + }); + }; + + for (const candidate of extractCandidatePaths(`${String(input.stdout ?? "")}\n${String(input.stderr ?? "")}`)) { + add(candidate, false); + } + for (const candidate of extractCandidatePaths(String(input.command ?? ""))) { + add(candidate, true); + } + for (const found of walkRecentArtifactFiles(root, startedAt)) { + add(found, true); + } + return items; +} + +function walkRecentArtifactFiles(root: string, sinceMs: number): string[] { + const found: string[] = []; + const stack: Array<{ dir: string; depth: number }> = [{ dir: root, depth: 0 }]; + let visited = 0; + while (stack.length > 0 && visited < MAX_WALK_ENTRIES && found.length < MAX_SHELL_ARTIFACTS) { + const current = stack.pop(); + if (!current) { + break; + } + let entries; + try { + entries = readdirSync(current.dir, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + visited += 1; + if (visited > MAX_WALK_ENTRIES || found.length >= MAX_SHELL_ARTIFACTS) { + break; + } + const name = entry.name; + if (name.startsWith(".")) { + continue; + } + const full = path.join(current.dir, name); + if (entry.isDirectory()) { + if (current.depth >= MAX_WALK_DEPTH) { + continue; + } + if (IGNORE_DIR_NAMES.has(name.toLowerCase())) { + continue; + } + stack.push({ dir: full, depth: current.depth + 1 }); + continue; + } + if (!entry.isFile() || !isExhibitWalkPath(name)) { + continue; + } + try { + const stat = statSync(full); + if (!stat.isFile() || stat.size <= 0 || stat.mtimeMs < sinceMs) { + continue; + } + } catch { + continue; + } + found.push(full); + } + } + return found; +} diff --git a/src/tools/runtime.ts b/src/tools/runtime.ts index 13e411c..88d4fe6 100644 --- a/src/tools/runtime.ts +++ b/src/tools/runtime.ts @@ -47,6 +47,7 @@ import { webFetchTool, webSearchTool } from "./web-tools.ts"; +import { collectShellArtifacts } from "./artifacts.ts"; import { createWorkflowState, planUpdateTool, recordFileChange, recordValidation, todoReadTool, todoWriteTool } from "./workflow-tools.ts"; const HANDLERS = Object.freeze({ @@ -329,7 +330,7 @@ export function createToolRuntime(options: ToolRuntimeOptions) { const execution = await executeWebFetchTool(options, input, definition); const fetchResult = asResultRecord(execution); if (fetchResult.ok === true) { - recordToolEffect(workflowState, name, input, isPlainObject(fetchResult.result) ? fetchResult.result : EMPTY_RECORD); + recordToolEffect(workflowState, name, input, isPlainObject(fetchResult.result) ? fetchResult.result : EMPTY_RECORD, options.cwd); } return finishTool(options, name, input, definition, options.signal?.aborted && fetchResult.interrupted !== true ? interruptedToolExecution(name, input, definition, fetchResult) @@ -461,13 +462,13 @@ export function createToolRuntime(options: ToolRuntimeOptions) { }); } if (options.signal?.aborted || resultFields.interrupted === true) { - recordToolEffect(workflowState, name, input, resultFields); + recordToolEffect(workflowState, name, input, resultFields, options.cwd); if (name === "write_file" || (name === "edit_file" && resultFields.edited !== false)) { await emitFileChangedHook(options, name, input, resultFields); } return finishTool(options, name, input, definition, interruptedToolExecution(name, input, definition, resultFields)); } - recordToolEffect(workflowState, name, input, resultFields); + recordToolEffect(workflowState, name, input, resultFields, options.cwd); if (name === "write_file" || (name === "edit_file" && resultFields.edited !== false)) { await emitFileChangedHook(options, name, input, resultFields); } @@ -1812,7 +1813,7 @@ function lookupHandler(name: string): BuiltinHandler | undefined { * @param {Record} input * @param {Record} result */ -function recordToolEffect(workflowState: ReturnType, name: string, input: Record, result: Record) { +function recordToolEffect(workflowState: ReturnType, name: string, input: Record, result: Record, cwd: string = "") { if (name === "write_file") { recordFileChange(workflowState, { toolName: name, @@ -1843,6 +1844,18 @@ function recordToolEffect(workflowState: ReturnType, stderrTruncated: result.stderrTruncated, error: result.error }); + for (const artifact of collectShellArtifacts(cwd, { + command: input.command, + stdout: result.stdout, + stderr: result.stderr, + durationMs: result.durationMs + })) { + recordFileChange(workflowState, { + toolName: name, + path: artifact.path, + created: artifact.created + }); + } } } diff --git a/src/version.ts b/src/version.ts index 9f79d29..5b22ca6 100644 --- a/src/version.ts +++ b/src/version.ts @@ -3,7 +3,7 @@ import fsPromises from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; -const FALLBACK_VERSION = "2.0.12"; +const FALLBACK_VERSION = "2.0.13"; /** * @param {NodeJS.ProcessEnv} [env] diff --git a/tests/unit/artifacts.test.ts b/tests/unit/artifacts.test.ts new file mode 100644 index 0000000..6cec40c --- /dev/null +++ b/tests/unit/artifacts.test.ts @@ -0,0 +1,73 @@ +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 { collectShellArtifacts, extractCandidatePaths, serializeExhibitArtifacts } from "../../src/tools/artifacts.ts"; + +test("extractCandidatePaths keeps unicode, markdown, and quoted artifact paths", () => { + const paths = extractCandidatePaths([ + "见 `图1.png` 和 [报表](reports/成绩.xlsx)", + "saved \"outputs/final.md\"", + "https://example.com/remote.png should be ignored" + ].join("\n")); + + assert.equal(paths.includes("图1.png"), true); + assert.equal(paths.includes("reports/成绩.xlsx"), true); + assert.equal(paths.includes("outputs/final.md"), true); + assert.equal(paths.some((item) => /^https?:/i.test(item)), false); +}); + +test("collectShellArtifacts picks up silent image writes and announced reports", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "lab-agent-artifacts-")); + await fs.mkdir(path.join(cwd, "outputs")); + await fs.mkdir(path.join(cwd, "node_modules", "pkg"), { recursive: true }); + await fs.writeFile(path.join(cwd, "outputs", "chart.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47])); + await fs.writeFile(path.join(cwd, "report.md"), "# Report\n", "utf8"); + await fs.writeFile(path.join(cwd, "stale.txt"), "old", "utf8"); + await fs.writeFile(path.join(cwd, "node_modules", "pkg", "noise.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47])); + const stale = path.join(cwd, "stale.txt"); + const past = new Date(Date.now() - 60_000); + await fs.utimes(stale, past, past); + + const artifacts = collectShellArtifacts(cwd, { + command: "python plot.py", + stdout: "wrote report.md\n", + stderr: "", + durationMs: 250 + }); + const relativePaths = artifacts.map((item) => item.path.replace(/\\/g, "/")); + + assert.equal(relativePaths.includes("outputs/chart.png"), true); + assert.equal(relativePaths.includes("report.md"), true); + assert.equal(relativePaths.includes("stale.txt"), false); + assert.equal(relativePaths.some((item) => item.includes("node_modules")), false); +}); + +test("serializeExhibitArtifacts keeps previewable outputs and skips ordinary text paths", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "lab-agent-artifacts-persist-")); + await fs.writeFile(path.join(cwd, "chart.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47])); + await fs.writeFile(path.join(cwd, "notes.txt"), "hello", "utf8"); + + const artifacts = serializeExhibitArtifacts(cwd, [ + { path: "chart.png", created: true }, + { path: "notes.txt", created: true }, + { path: "missing.png", created: true } + ]); + + assert.deepEqual(artifacts.map((item) => item.relativePath.replace(/\\/g, "/")), ["chart.png"]); + assert.equal(artifacts[0]?.source, "created"); +}); + +test("collectShellArtifacts does not treat package.json as a silent walk artifact", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "lab-agent-artifacts-json-")); + await fs.writeFile(path.join(cwd, "package.json"), "{\"ok\":true}\n", "utf8"); + + const artifacts = collectShellArtifacts(cwd, { + command: "echo ready", + stdout: "ready\n", + durationMs: 20 + }); + + assert.equal(artifacts.some((item) => item.path.endsWith("package.json")), false); +}); diff --git a/tests/unit/dashboard-events.test.ts b/tests/unit/dashboard-events.test.ts index 9c69c16..b47b24e 100644 --- a/tests/unit/dashboard-events.test.ts +++ b/tests/unit/dashboard-events.test.ts @@ -104,6 +104,20 @@ test("dashboard maps final gateway failure to the retry live activity key", () = assert.equal(events[0].title, "模型请求失败"); assert.equal(events[0].status, "failed"); assert.equal(events[0].coalesceKey, "gateway"); + assert.equal(events[0].detail, "Gateway returned HTTP 502"); +}); + +test("dashboard gateway auth failure surfaces the credential switch hint", () => { + const events = mapSessionEventToDashboard({ + type: "gateway_error", + error: { + code: "GATEWAY_HTTP_ERROR", + message: "Gateway returned HTTP 401", + diagnostics: ["当前生效凭据不能用。请打开设置,为这个来源切换生效凭据后再试。"] + } + }); + + assert.match(events[0].detail, /切换生效凭据/); }); test("dashboard maps context compaction to live status and transcript boundary", () => { diff --git a/tests/unit/dashboard-files.test.ts b/tests/unit/dashboard-files.test.ts index f7c278f..243d01c 100644 --- a/tests/unit/dashboard-files.test.ts +++ b/tests/unit/dashboard-files.test.ts @@ -265,6 +265,36 @@ test("dashboard skips mentioned image files that do not exist", async () => { assert.equal(files.some((file) => file.relativePath === "missing.png"), false); }); +test("dashboard collects unicode mentioned reports from assistant text", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "dashboard-files-unicode-")); + await fs.writeFile(path.join(cwd, "图1.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47])); + await fs.mkdir(path.join(cwd, "reports")); + await fs.writeFile(path.join(cwd, "reports", "成绩.xlsx"), Buffer.from("PK"), "utf8"); + const session = { cwd, workflow: { changes: [] } }; + + const files = collectSessionFiles(session, "见 `图1.png` 和 [报表](reports/成绩.xlsx)"); + const relativePaths = files.map((file) => String(file.relativePath).replace(/\\/g, "/")); + + assert.equal(relativePaths.includes("图1.png"), true); + assert.equal(relativePaths.includes("reports/成绩.xlsx"), true); +}); + +test("dashboard restores persisted exhibit artifacts when workflow changes are summarized", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "dashboard-files-artifacts-")); + await fs.writeFile(path.join(cwd, "chart.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47])); + const session = { + cwd, + workflow: { + changes: { total: 1, created: 1, edited: 0, diffTruncated: 0 } + }, + artifacts: [{ relativePath: "chart.png", name: "chart.png", source: "created" }] + }; + + const files = collectSessionFiles(session, ""); + + assert.equal(files.some((file) => file.relativePath === "chart.png" && file.source === "created"), true); +}); + test("dashboard collects mentioned files when persisted workflow changes are summarized", async () => { const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "dashboard-files-")); await fs.writeFile(path.join(cwd, "chart.png"), Buffer.from([0x89, 0x50, 0x4e, 0x47])); diff --git a/tests/unit/dashboard-ui.test.ts b/tests/unit/dashboard-ui.test.ts index a43d3c2..39a2a88 100644 --- a/tests/unit/dashboard-ui.test.ts +++ b/tests/unit/dashboard-ui.test.ts @@ -43,8 +43,14 @@ test("configured idle gateway shows connected without masking event failures", a assert.equal(node.dataset.state, "connected"); app.setConnectionState("error"); assert.equal(label.textContent, "本地网关异常"); + app.state.running = true; + app.els.runStatus = { textContent: "正在回答" }; app.setConnectionState("offline"); assert.equal(label.textContent, "本地网关离线"); + assert.match(app.els.runStatus.textContent, /任务状态未知/); + assert.match(node.title, /请勿重复发送/); + assert.equal(app.state.running, true); + app.state.running = false; app.state.gatewayConfig = { apiKeyConfigured: false }; app.state.gatewayProfiles = []; app.setConnectionState("idle"); @@ -672,6 +678,31 @@ test("dashboard composer controls keep confirmations reviewable and critical sta assert.match(css, /\.gateway-probe-result\.success strong\s*\{/); }); +test("dashboard auto-exhibits newly generated preview files", async () => { + const app = await publicTsSource(); + const html = await fs.readFile(path.resolve("src/dashboard/public/index.html"), "utf8"); + const module = await loadAppExports(["pickExhibitFile", "isExhibitFile"]); + + assert.match(app, /event\.type === "artifacts_updated"/); + assert.match(app, /function applySessionFiles\(/); + assert.match(app, /function pickExhibitFile\(/); + assert.match(html, /生成的图、报表和导出文件会自动预览/); + assert.equal(module.isExhibitFile({ kind: "image", relativePath: "chart.png" }), true); + assert.equal(module.isExhibitFile({ kind: "binary", relativePath: "blob.bin" }), false); + assert.equal(module.isExhibitFile({ kind: "text", relativePath: "notes.txt" }), true); + assert.equal(module.isExhibitFile({ kind: "text", relativePath: "src/app.ts" }), false); + assert.equal(module.pickExhibitFile([ + { kind: "markdown", relativePath: "notes.md", source: "created" }, + { kind: "image", relativePath: "chart.png", source: "created" } + ], { previousPaths: ["notes.md"] })?.relativePath, "chart.png"); + assert.equal(module.pickExhibitFile([ + { kind: "image", relativePath: "chart.png", source: "created" } + ], { previousPaths: ["chart.png"] }), null); + assert.equal(module.pickExhibitFile([ + { kind: "image", relativePath: "chart.png", source: "created" } + ], { previousPaths: ["chart.png"], force: true })?.relativePath, "chart.png"); +}); + test("dashboard renders lightweight office previews in the side panel", async () => { const app = await publicTsSource(); const css = await fs.readFile(path.resolve("src/dashboard/public/styles.css"), "utf8"); diff --git a/tests/unit/gateway-health.test.ts b/tests/unit/gateway-health.test.ts index b60770a..f369302 100644 --- a/tests/unit/gateway-health.test.ts +++ b/tests/unit/gateway-health.test.ts @@ -767,6 +767,25 @@ test("gateway client retries transient fetch failures before response", async () } }); +test("Responses rejects unsolicited hosted search instead of silently completing", async () => { + const originalFetch = globalThis.fetch; + try { + for (const streaming of [false, true]) { + const raw = { status: "completed", output: [{ type: "web_search_call", id: "search-1", status: "completed" }] }; + globalThis.fetch = async () => new Response(streaming + ? `data: ${JSON.stringify({ type: "response.output_item.added", item: raw.output[0] })}\n\ndata: ${JSON.stringify({ type: "response.completed", response: { status: "completed", output: [] } })}\n\n` + : JSON.stringify(raw), { headers: { "content-type": streaming ? "text/event-stream" : "application/json" } }); + const gateway = createLabModelGateway({ modelAlias: "test", networkMode: "offline", allowedHosts: [], lab: { + gatewayUrl: "http://127.0.0.1/v1/responses", gatewayProtocol: "openai-responses", gatewayMaxRetries: 0 + } }); + const result = await gateway.sendChat({ messages: [{ role: "user", content: "search" }], + tools: [{ name: "web_search", description: "Search", inputSchema: { type: "object", properties: {} } }], stream: streaming }); + assert.equal(result.ok, false); + assert.equal(result.error?.code, "GATEWAY_TOOL_PROTOCOL_MISMATCH"); + } + } finally { globalThis.fetch = originalFetch; } +}); + test("gateway client sends session affinity header", async () => { const originalFetch = globalThis.fetch; try { @@ -802,6 +821,13 @@ test("gateway client sends session affinity header", async () => { assert.equal(result.ok, true); assert.equal(headers["x-session-affinity"], "session-affinity-1"); + const rotated = await gateway.sendChat({ + sessionId: "session-affinity-1", + sessionAffinity: "repaired-affinity", + messages: [{ role: "user", content: "hello again" }] + }); + assert.equal(rotated.ok, true); + assert.equal(headers["x-session-affinity"], "repaired-affinity"); } finally { globalThis.fetch = originalFetch; } diff --git a/tests/unit/session-helpers.ts b/tests/unit/session-helpers.ts index 1eda5e3..fbec6e3 100644 --- a/tests/unit/session-helpers.ts +++ b/tests/unit/session-helpers.ts @@ -1099,6 +1099,62 @@ export function createReasoningOnlyOpenAIStreamingGateway() { }); } +export function createPromisedToolThenHealthyGateway(requests: Array>, affinities: string[] = []) { + return http.createServer(async (request, response) => { + if (request.method !== "POST" || request.url !== "/v1/chat") { + response.writeHead(404, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: { code: "NOT_FOUND" } })); + return; + } + + const body = await readRequestJson(request); + requests.push(body); + affinities.push(String(request.headers["x-session-affinity"] ?? "")); + response.writeHead(200, { "content-type": "application/json" }); + if (requests.length === 1) { + response.end(JSON.stringify({ + id: "mock-promised-tool", + model: body.model, + content: [{ type: "text", text: "马上调用 web_search 做最小实测,再写完整工具清单。" }], + thinking: "The user wants me to call web_search now, not just promise. Let me call web_search immediately.", + toolCalls: [], + stopReason: "stop" + })); + return; + } + response.end(JSON.stringify({ + id: "mock-promised-tool-retry", + model: body.model, + content: [{ type: "text", text: "已完成最小搜索。\n\n第一段:web_search 可用。\n\n第二段:其余工具清单。\n\n第三段:缺口与残留。" }], + toolCalls: [], + stopReason: "stop" + })); + }); +} + +export function createReasoningOnlyThenHealthyOpenAIGateway(requests: Array>) { + return http.createServer(async (request, response) => { + if (request.method !== "POST" || request.url !== "/v1/chat/completions") { + response.writeHead(404, { "content-type": "application/json" }); + response.end(JSON.stringify({ error: { code: "NOT_FOUND" } })); + return; + } + + const body = await readRequestJson(request); + requests.push(body); + response.writeHead(200, { "content-type": "text/event-stream" }); + if (requests.length === 1) { + response.write('data: {"id":"chatcmpl-reasoning","model":"mock-openai","choices":[{"delta":{"reasoning_content":"private final text"}}]}\n\n'); + response.write('data: {"choices":[{"delta":{"content":null},"finish_reason":"stop"}]}\n\n'); + response.end("data: [DONE]\n\n"); + return; + } + response.write('data: {"id":"chatcmpl-ok","model":"mock-openai","choices":[{"delta":{"content":"visible reply"}}]}\n\n'); + response.write('data: {"choices":[{"finish_reason":"stop"}]}\n\n'); + response.end("data: [DONE]\n\n"); + }); +} + /** * @param {Array>} requests */ diff --git a/tests/unit/session-persist-cases.ts b/tests/unit/session-persist-cases.ts index 24fe097..71079c9 100644 --- a/tests/unit/session-persist-cases.ts +++ b/tests/unit/session-persist-cases.ts @@ -51,6 +51,7 @@ test("createSession can resume legacy bounded metadata without transcript text", const store = createSessionStore({ cwd }); await store.writeMetadata({ id: "session-to-resume", + gatewaySessionAffinity: "repaired-route", startedAt: "2026-04-28T00:00:00.000Z", turnIndex: 4, prompt: "continue the TUI work", @@ -66,6 +67,7 @@ test("createSession can resume legacy bounded metadata without transcript text", }); assert.equal(session.id, "session-to-resume"); + assert.equal(session.gatewaySessionAffinity, "repaired-route"); assert.equal(session.startedAt, "2026-04-28T00:00:00.000Z"); assert.equal(session.turnCount, 4); assert.deepEqual(session.messages, []); @@ -73,6 +75,10 @@ test("createSession can resume legacy bounded metadata without transcript text", assert.equal(session.resumedFrom.prompt, "continue the TUI work"); assert.equal(session.resumedFrom.status, "completed"); assert.equal(session.resumedFrom.model, "mock-sonnet"); + await persistSessionSnapshot(session, { env: {} }); + const reloaded = await createSession({ cwd, mode: "interactive", env: {}, resume: session.id }); + assert.equal(reloaded.id, session.id); + assert.equal(reloaded.gatewaySessionAffinity, "repaired-route"); }); test("createSession restores bounded persisted conversation messages", async () => { diff --git a/tests/unit/session-turn-cases.ts b/tests/unit/session-turn-cases.ts index 744197a..2f0df3d 100644 --- a/tests/unit/session-turn-cases.ts +++ b/tests/unit/session-turn-cases.ts @@ -35,6 +35,8 @@ import { createResponsesReasoningToolGateway, createLongReasoningToolGateway, createReasoningOnlyOpenAIStreamingGateway, + createReasoningOnlyThenHealthyOpenAIGateway, + createPromisedToolThenHealthyGateway, createSlowOpenAIStreamingGateway, createValidationGateway, readRequestJson, @@ -795,6 +797,40 @@ test("session does not retry malformed final output while output health check is } }); +test("session retries a one-line tool promise without sending it back to the model", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "lab-agent-test-")); + const requests = []; + const affinities = []; + const events = []; + const server = await listen(createPromisedToolThenHealthyGateway(requests, affinities), "127.0.0.1"); + + try { + const env = mockGatewayEnv(serverUrl(server)); + delete env.LAB_AGENT_TRANSCRIPT_ENABLED; + const session = await createSession({ + cwd, + mode: "interactive", + env + }); + + const result = await runSessionTurn(session, { + prompt: "继续完成测试", + env, + onEvent: (event) => events.push(event) + }); + + assert.match(result.output, /已完成最小搜索/); + assert.equal(requests.length, 2); + assert.equal(JSON.stringify(requests[1]).includes("马上调用 web_search"), false); + assert.equal(events.some((event) => event.type === "output_health_retry" && event.reasons.includes("promised_tool_without_call")), true); + assert.equal(affinities.length, 2); + assert.notEqual(affinities[1], affinities[0]); + assert.match(String(session.gatewaySessionAffinity ?? ""), /^retry-/); + } finally { + await close(server); + } +}); + test("session retries reasoning-only length final output even when generic health check is disabled", async () => { const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "lab-agent-test-")); const requests = []; @@ -1819,6 +1855,47 @@ test("interactive session does not persist raw OpenAI reasoning-only streams as assert.doesNotMatch(result.output, /private final text/); assert.ok(events.some((event) => event.type === "assistant_thinking_delta")); assert.ok(events.some((event) => event.type === "assistant_final")); + assert.equal(JSON.stringify(session.messages).includes("没有返回可展示正文"), false); + assert.match(JSON.stringify(session.transcriptMessages), /没有返回可展示正文/); + } finally { + await close(server); + } +}); + +test("reasoning-only diagnostic is not sent back to the model on the next turn", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "lab-agent-test-")); + const requests = []; + const server = await listen(createReasoningOnlyThenHealthyOpenAIGateway(requests), "127.0.0.1"); + + try { + const env = { + LAB_MODEL_GATEWAY_URL: `${serverUrl(server)}/v1/chat/completions`, + LAB_AGENT_MODEL: "mock-openai", + LAB_AGENT_NETWORK_MODE: "offline", + LAB_AGENT_TRANSCRIPT_ENABLED: "true", + LAB_MODEL_GATEWAY_PROTOCOL: "openai-chat" + }; + const session = await createSession({ + cwd, + mode: "interactive", + env + }); + + const first = await runSessionTurn(session, { + prompt: "reasoning only", + env + }); + const second = await runSessionTurn(session, { + prompt: "continue after empty visible text", + env + }); + + assert.match(first.output, /没有返回可展示正文/); + assert.equal(second.output, "visible reply"); + assert.equal(requests.length, 2); + assert.equal(JSON.stringify(requests[1]).includes("没有返回可展示正文"), false); + assert.equal(JSON.stringify(session.messages).includes("没有返回可展示正文"), false); + assert.match(JSON.stringify(session.transcriptMessages), /没有返回可展示正文/); } finally { await close(server); } diff --git a/tests/unit/source-credentials.test.ts b/tests/unit/source-credentials.test.ts index 92e8519..d1bc387 100644 --- a/tests/unit/source-credentials.test.ts +++ b/tests/unit/source-credentials.test.ts @@ -1,27 +1,92 @@ import assert from "node:assert/strict"; import test from "node:test"; import fs from "node:fs/promises"; +import http from "node:http"; import os from "node:os"; import path from "node:path"; -import { sourceCredentialState, applySourceCredentials } from "../../src/config/source-credentials.ts"; +import { sourceCredentialState, applySourceCredentials, sourceCredentialFailureMessage, sourceHasAlternateCredentials } from "../../src/config/source-credentials.ts"; +import { createLabModelGateway } from "../../src/model-gateway/client.ts"; +import { normalizeGatewayError } from "../../src/model-gateway/errors.ts"; +import { mapSessionEventToDashboard } from "../../src/dashboard/events.ts"; import { loadConfig } from "../../src/config/load-config.ts"; import { applyRuntimeModelSelection } from "../../src/config-v2/runtime-selection.ts"; import { buildDashboardSettingsConfig, normalizeDashboardSettingsInput } from "../../src/dashboard/runtime/settings.ts"; -import { createLabModelGateway } from "../../src/model-gateway/client.ts"; import { createCredentialStore } from "../../src/credentials/store.ts"; import { credentialsPath } from "../../src/config-v2/paths.ts"; const url = "http://127.0.0.1:9999/v1/chat"; const profiles = ["a", "b"].map((id) => ({ id, gatewayUrl: url, gatewayProtocol: "lab-agent-gateway", gatewayApiKey: `test-${id}`, models: [{ id }] })); -test("ambiguous sources require a choice, explicit invalid references fail closed", async () => { +test("ambiguous sources pick a default credential instead of blocking chat", async () => { const config = { lab: { gatewayUrl: url, gatewayProfiles: profiles } }; - assert.equal(sourceCredentialState(config)[url], null); - const blocked = applySourceCredentials(config); - await assert.rejects(createLabModelGateway(blocked as any).sendChat({ messages: [] }), /选择生效凭据/); + assert.equal(sourceCredentialState(config)[url], "a"); + const resolved = applySourceCredentials(config); + assert.equal(resolved.lab.gatewayApiKey, "test-a"); + assert.equal(resolved.lab.sourceCredentialSelectionRequired, false); const invalid = applySourceCredentials({ lab: { ...config.lab, activeGatewayProfile: "a", sourceCredentialSelections: { [url]: "deleted" } } }); - assert.equal(invalid.lab.gatewayApiKey, null); - assert.equal(invalid.lab.sourceCredentialSelectionRequired, true); + assert.equal(invalid.lab.gatewayApiKey, "test-a"); + assert.equal(invalid.lab.sourceCredentialSelectionRequired, false); + const activeB = applySourceCredentials({ lab: { gatewayUrl: url, activeGatewayProfile: "b", gatewayProfiles: profiles } }); + assert.equal(activeB.lab.gatewayApiKey, "test-b"); +}); + +test("unusable selected credentials tell the user to switch when another key exists", async () => { + const config = { lab: { gatewayUrl: url, gatewayProfiles: profiles } }; + assert.equal(sourceHasAlternateCredentials(config), true); + assert.match(sourceCredentialFailureMessage(true, 401), /切换生效凭据/); + const hinted = normalizeGatewayError(null, { + code: "GATEWAY_HTTP_ERROR", + message: "Gateway returned HTTP 401", + status: 401, + canSwitchSourceCredential: true + }); + assert.match(hinted.diagnostics.join("\n"), /切换生效凭据/); + const events = mapSessionEventToDashboard({ type: "gateway_error", error: hinted }); + assert.match(String(events[0]?.detail ?? ""), /切换生效凭据/); + + const server = http.createServer((_req, res) => { + res.writeHead(401, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: { message: "unauthorized" } })); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + const origin = typeof address === "object" && address ? `http://127.0.0.1:${address.port}` : ""; + try { + const failed = await createLabModelGateway({ + modelAlias: "test-model", + networkMode: "offline", + allowedHosts: [], + lab: { + gatewayUrl: origin, + gatewayProtocol: "openai-chat", + gatewayApiKey: "test-a", + gatewayProfiles: [ + { id: "a", gatewayUrl: origin, gatewayApiKey: "test-a" }, + { id: "b", gatewayUrl: origin, gatewayApiKey: "test-b" } + ] + } + } as any).sendChat({ messages: [{ role: "user", content: "hello" }] }); + assert.equal(failed.ok, false); + if (failed.ok) { + return; + } + assert.equal(failed.error.status, 401); + assert.match(failed.error.message, /切换生效凭据/); + assert.doesNotMatch(failed.error.message, /未发送模型请求/); + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + } +}); + +test("a single unusable credential asks the user to inspect settings without offering a switch", () => { + const config = { lab: { gatewayUrl: url, gatewayProfiles: [profiles[0]] } }; + assert.equal(sourceHasAlternateCredentials(config), false); + const hinted = normalizeGatewayError(null, { + code: "GATEWAY_HTTP_ERROR", + status: 401 + }); + assert.match(hinted.diagnostics.join("\n"), /检查这份凭据/); + assert.doesNotMatch(hinted.diagnostics.join("\n"), /切换生效凭据/); }); test("selection is stable across model switches and does not mutate running snapshots", () => { diff --git a/tests/unit/tools.test.ts b/tests/unit/tools.test.ts index aa91018..bb0d501 100644 --- a/tests/unit/tools.test.ts +++ b/tests/unit/tools.test.ts @@ -2391,6 +2391,27 @@ test("powershell mutating commands are blocked without approval", { skip: proces await assert.rejects(fs.readFile(path.join(cwd, "notes.txt"), "utf8"), /ENOENT/); }); +test("powershell artifact writes are recorded as workflow file changes", { skip: process.platform !== "win32" ? "PowerShell executable is only assumed in Windows CI for now" : false }, async () => { + const cwd = await makeTempWorkspace(); + const workflow = createWorkflowState(); + const runtime = createToolRuntime({ + cwd, + workflowState: workflow, + policy: { + networkMode: "offline", + approvals: { workspaceCommands: true } + } + }); + const result = await runtime.execute("powershell", { + command: "Set-Content -LiteralPath chart.png -Value dummy", + timeoutMs: 10_000 + }); + + assert.equal(result.ok, true); + assert.equal(result.result.exitCode, 0); + assert.equal(workflow.changes.some((change) => String(change.path).replace(/\\/g, "/").endsWith("chart.png")), true); +}); + test("powershell mutating commands execute with approval", { skip: process.platform !== "win32" ? "PowerShell executable is only assumed in Windows CI for now" : false }, async () => { const cwd = await makeTempWorkspace(); const runtime = createToolRuntime({