From 46ed726820e623835bfaff34f3002828ebe2e946 Mon Sep 17 00:00:00 2001 From: wicm84266964 Date: Sun, 6 Sep 2026 01:14:02 +0800 Subject: [PATCH 1/2] release: prepare v2.0.8 dashboard office documents --- .gitignore | 3 + CHANGELOG.md | 35 ++ README.md | 2 +- THIRD_PARTY_NOTICES.md | 1 + config/skills/document-intake/SKILL.md | 6 +- .../2.0.8-dashboard-office-documents_zh.md | 41 ++ npm-shrinkwrap.json | 22 +- package-lock.json | 22 +- package.json | 3 +- scripts/verify-release-seal.ts | 6 + src/context/builder.ts | 2 +- src/core/session-messages.ts | 61 ++- src/core/session-persist.ts | 10 +- src/core/session-turn.ts | 16 +- src/core/session-types.ts | 9 + src/dashboard/files.ts | 49 ++- src/dashboard/public/app-core.ts | 4 + src/dashboard/public/app-ui1.ts | 3 +- src/dashboard/public/app-ui2.ts | 124 ++++-- src/dashboard/public/app-ui3.ts | 65 +++- src/dashboard/public/app-ui9.ts | 155 +++++++- src/dashboard/public/app.js | 357 +++++++++++++++--- src/dashboard/public/index.html | 8 +- src/dashboard/public/styles.css | 56 ++- src/dashboard/runtime/turn-queue.ts | 139 +++++-- src/dashboard/runtime/types.ts | 3 + src/dashboard/server.ts | 91 ++++- src/tools/composer-documents.ts | 311 +++++++++++++++ src/tools/definitions.ts | 6 +- src/tools/document-tools.ts | 88 ++++- src/tools/png-encode.ts | 48 +++ src/version.ts | 2 +- tests/unit/dashboard-files.test.ts | 24 +- tests/unit/dashboard-runtime/turn.ts | 48 +++ tests/unit/dashboard-server.test.ts | 75 ++++ tests/unit/dashboard-ui.test.ts | 10 + tests/unit/tools.test.ts | 125 ++++++ 37 files changed, 1865 insertions(+), 165 deletions(-) create mode 100644 docs/releases/2.0.8-dashboard-office-documents_zh.md create mode 100644 src/tools/composer-documents.ts create mode 100644 src/tools/png-encode.ts diff --git a/.gitignore b/.gitignore index c890ff5..5a8a688 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,9 @@ recordings/ .lab-agent/ .lab-agent/sessions/ +# Composer paperclip uploads in a workspace checkout +ant-code-uploads/ + # Local scratch files created during manual verification test.txt test_file.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index a1b22be..47dd42d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,41 @@ ## Unreleased +## 2.0.8 - 2026-09-06 + +This is a small Dashboard workflow release on the 2.0 TypeScript runtime. +The paperclip can attach PDF, docx, xlsx, pptx, and common text files. +Documents are saved under `ant-code-uploads/` with a bounded preview for +the model. The right pane can preview those files, and Open launches the +workspace copy with the system app. Permission mode ids are unchanged. + +### Added + +- Dashboard paperclip accepts PDF / Office Open XML / text documents in + addition to images. Documents are stored in `ant-code-uploads/` and + ingested through `document_intake`. +- PDF text-layer extraction via `unpdf`. Scanned PDFs are not OCR'd; + composer-attached PDFs with no text layer send a few page images to + the configured vision model. +- Right-pane preview for PDF, extracted Office text/tables, and + clickable attachment chips after refresh. +- Open uses the OS file association on the workspace copy. +- `ant-code-uploads/` is added to the project `.gitignore` on send. + +### Upgrade + +```sh +git pull +npm ci +npm run verify:install +npm link +ant-code --version +``` + +`ant-code --version` should print `2.0.8`. Restart a running Dashboard +and hard-refresh the browser. Gateway config and `.lab-agent` sessions +do not need to be recreated. + ## 2.0.7 - 2026-09-05 This is a dependency security patch on the 2.0 TypeScript runtime. diff --git a/README.md b/README.md index c417fa9..98bc5d5 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ ant-code doctor ant-code ``` -`ant-code --version` should print `2.0.7`. If you previously linked a +`ant-code --version` should print `2.0.8`. If you previously linked a JavaScript install, run `npm link` again so the global command points at `src/cli/index.ts`. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index c0b7fe3..5620d9b 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -12,6 +12,7 @@ runtime assets from the following third-party packages: | `react` | UI runtime dependency | MIT | | `katex` | math rendering runtime dependency; Dashboard CSS and font assets | MIT | | `mermaid` | Dashboard diagram rendering bundle input | MIT | +| `unpdf` | PDF text-layer extraction for document_intake and Dashboard paperclip | MIT | | `yaml` | Dashboard YAML parsing bundle input | ISC | | `esbuild` | development/build tool for Dashboard assets | MIT | | `postject` | development/build tool for optional executable packaging | MIT | diff --git a/config/skills/document-intake/SKILL.md b/config/skills/document-intake/SKILL.md index ad3b1c1..5a9c271 100644 --- a/config/skills/document-intake/SKILL.md +++ b/config/skills/document-intake/SKILL.md @@ -15,8 +15,10 @@ argument_hint: 提供文档路径、需要提取的内容类型、摘要粒度 2. 优先调用 `document_intake`: - 支持 txt/md/json/csv/xml/html。 - 支持轻量解析 docx/pptx/xlsx。 - - PDF 在核心实现中只给出边界提示。 -3. 如果用户允许,并且本机安装了 MarkItDown,可通过受权限控制的 shell 命令做额外转换。 + - PDF 抽取内嵌文本层,默认每次最多 20 页;需要后续页时传 `pageStart`。 + - Dashboard 回形针也可直接附上这些文件;发送时会写入 `ant-code-uploads/` 并抽文本。 + - 扫描件没有 OCR。回形针附上的无文本层 PDF 会把前几页内嵌图送给视觉模型。 +3. 只有文本层为空、且用户允许时,才考虑本机 MarkItDown 或其他 OCR 转换器。 4. 对大文件只提取目录、标题、表格预览和关键段落,不输出全文。 ## 输出要求 diff --git a/docs/releases/2.0.8-dashboard-office-documents_zh.md b/docs/releases/2.0.8-dashboard-office-documents_zh.md new file mode 100644 index 0000000..cb8eda6 --- /dev/null +++ b/docs/releases/2.0.8-dashboard-office-documents_zh.md @@ -0,0 +1,41 @@ +# Ant Code v2.0.8:Dashboard 回形针办公文档与本地预览 + +发布日期:2026-09-06 + +这是 2.0 TypeScript 运行时上的小版本。Dashboard 回形针可以附上 PDF、docx、xlsx、pptx 和常见文本,发送后写入当前项目的 `ant-code-uploads/`,并抽出有界预览给模型。右侧栏可预览这些文件,Office/PDF 的「打开」会调用本机系统应用。权限模式 id 不变。本说明处于公开发布候选阶段,不表示 CI、tag 或 GitHub Release 已经完成。 + +## 对用户工作流的直接变化 + +- Dashboard 回形针同时接受图片和 PDF / docx / xlsx / pptx / txt / Markdown / CSV / JSON / HTML。图片仍走视觉附件;文档写入工作区 `ant-code-uploads/`,提示里只放短预览和保存路径,避免把全文塞进网关请求。 +- 无文本层的扫描 PDF 不做 OCR。回形针附上的这类 PDF 最多把 2 页缩小后的页面图送给已配置的视觉模型。 +- 点对话里的附件芯片会在右侧栏预览。PDF 用浏览器内嵌查看器;docx / xlsx / pptx 显示抽出的文本或表格。预览栏的「打开」用系统关联应用打开工作区里那份副本,而不是再下载一遍。 +- 刷新或重开任务后,新发送的附件芯片仍可点。上传目录会写入项目 `.gitignore` 的 `ant-code-uploads/`,避免大文件被提交。 +- 老格式 `.doc` / `.xls` / `.ppt` 仍不支持。TUI 没有回形针。 + +## 配置、数据与安全边界 + +- 配置格式、凭据存储和网关协议没有迁移。现有配置和 `.lab-agent` 会话可继续使用。 +- 回形针文档落在当前工作区的 `ant-code-uploads/`。这是项目内副本,不是你在文件选择器里选中的原路径。「打开」编辑并保存时,改的是这份副本。 +- 这次没有改权限模式 id。Dashboard 仍只绑定本机回环地址。 +- 自动化测试不能代表所有私有研究数据、外部模型供应商或现场网络环境都已得到验证。 + +## 升级与产物位置 + +需要 Node.js 22.18+。从源码更新: + +```sh +git pull +npm ci +npm run verify:install +npm link +ant-code --version +``` + +`ant-code --version` 应显示 `2.0.8`。若以前 link 过旧安装,需要重新 `npm link`。正在跑的 Dashboard 需要重启并硬刷新浏览器。现有网关配置和会话不必重建。本次新增运行时依赖 `unpdf`(MIT),用于 PDF 文本层。 + +## 验证、限制与待完成门禁 + +- 开发仓 `npm test`(`node:test` 口径):tests 1283,pass 1282,fail 1。失败项是 `full access lets fork skills use the base profile toolset instead of the skill allowlist`(TLS `ECONNRESET`),与本次回形针/预览改动无关。Dashboard 文件、服务、运行时与 UI 相关单测已通过。完整 `npm run verify:release` 交给 GitHub CI。 +- 扫描件 PDF 没有 OCR;Office 预览是抽出文本,不是排版还原。Windows 用系统关联应用打开本地文件。 +- 未把 TaxaMask 内嵌主题、`dashboard-embed` 或源码写入守卫带入独立版。独立 Ant Code 发布不会自动更新 TaxaMask 内嵌拷贝。 +- 候选阶段最终结论以正式仓同一提交的 CI 和 Release 门禁为准。 diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 9cd4a75..6175063 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1,12 +1,12 @@ { "name": "@ant-code/cli", - "version": "2.0.7", + "version": "2.0.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ant-code/cli", - "version": "2.0.7", + "version": "2.0.8", "license": "AGPL-3.0-only", "dependencies": { "@vscode/ripgrep": "1.18.0", @@ -15,6 +15,7 @@ "mermaid": "^11.17.2", "react": "^19.2.5", "typescript": "6.0.3", + "unpdf": "^1.8.1", "yaml": "^2.9.0" }, "bin": { @@ -2363,6 +2364,23 @@ "dev": true, "license": "MIT" }, + "node_modules/unpdf": { + "version": "1.8.1", + "resolved": "https://registry.npmmirror.com/unpdf/-/unpdf-1.8.1.tgz", + "integrity": "sha512-xkURhy2SoGpOIH0a1gLHNkASPIQYonadDJs2AQwPEfUakafeD9EA1WTWWsaR++gfTCXJpV27W7tU1nXuk82UKQ==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "@napi-rs/canvas": "^0.1.69 || ^1.0.0" + }, + "peerDependenciesMeta": { + "@napi-rs/canvas": { + "optional": true + } + } + }, "node_modules/uuid": { "version": "14.0.0", "resolved": "https://registry.npmmirror.com/uuid/-/uuid-14.0.0.tgz", diff --git a/package-lock.json b/package-lock.json index 9cd4a75..6175063 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ant-code/cli", - "version": "2.0.7", + "version": "2.0.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ant-code/cli", - "version": "2.0.7", + "version": "2.0.8", "license": "AGPL-3.0-only", "dependencies": { "@vscode/ripgrep": "1.18.0", @@ -15,6 +15,7 @@ "mermaid": "^11.17.2", "react": "^19.2.5", "typescript": "6.0.3", + "unpdf": "^1.8.1", "yaml": "^2.9.0" }, "bin": { @@ -2363,6 +2364,23 @@ "dev": true, "license": "MIT" }, + "node_modules/unpdf": { + "version": "1.8.1", + "resolved": "https://registry.npmmirror.com/unpdf/-/unpdf-1.8.1.tgz", + "integrity": "sha512-xkURhy2SoGpOIH0a1gLHNkASPIQYonadDJs2AQwPEfUakafeD9EA1WTWWsaR++gfTCXJpV27W7tU1nXuk82UKQ==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "@napi-rs/canvas": "^0.1.69 || ^1.0.0" + }, + "peerDependenciesMeta": { + "@napi-rs/canvas": { + "optional": true + } + } + }, "node_modules/uuid": { "version": "14.0.0", "resolved": "https://registry.npmmirror.com/uuid/-/uuid-14.0.0.tgz", diff --git a/package.json b/package.json index 6a87288..6d7567e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ant-code/cli", - "version": "2.0.7", + "version": "2.0.8", "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, @@ -96,6 +96,7 @@ "mermaid": "^11.17.2", "react": "^19.2.5", "typescript": "6.0.3", + "unpdf": "^1.8.1", "yaml": "^2.9.0" }, "devDependencies": { diff --git a/scripts/verify-release-seal.ts b/scripts/verify-release-seal.ts index 4fd0680..6c0331a 100644 --- a/scripts/verify-release-seal.ts +++ b/scripts/verify-release-seal.ts @@ -41,6 +41,12 @@ const REVIEWED_RUNTIME_DEPENDENCIES = new Map([ license: "Apache-2.0", provenanceMarker: "open_source: TypeScript compiler and language service, Apache-2.0 licensed, public npm package" }], + ["unpdf", { + versionSpec: "^1.8.1", + installedVersion: "1.8.1", + license: "MIT", + provenanceMarker: "open_source: unpdf PDF text extraction library, MIT licensed, public npm package" + }], ["yaml", { versionSpec: "^2.9.0", installedVersion: "2.9.0", diff --git a/src/context/builder.ts b/src/context/builder.ts index 9ef1fba..819d6cf 100644 --- a/src/context/builder.ts +++ b/src/context/builder.ts @@ -111,7 +111,7 @@ export async function buildInitialContext(options: { cwd: string; config: import "- /compact is a context-window operation that prefers a separate model summarization request through the configured lab gateway; if the gateway is unavailable or summarization fails, it falls back to local deterministic compaction.", "- Compaction keeps recent messages exactly and stores a bounded redacted summary of older messages for future turns.", "- web_fetch is the stable public tool name, but URL fetching is configured MCP-first by default: prefer the fetch MCP when available, then fall back to the built-in bounded fetcher. web_search uses built-in DuckDuckGo HTML only; do not expect Bing/Brave scrapers or search MCP. A self-hosted SearXNG is the only stable no-key search backend. Use network tools only when the task needs current or external information, and cite URLs in user-facing conclusions.", - "- document_intake extracts bounded local text from common text/HTML/Office files inside the workspace. It does not fully parse PDFs unless a skill workflow provides an external converter.", + "- document_intake extracts bounded local text from common text/HTML/Office files and the text layer of PDFs inside the workspace. PDFs default to a 20-page window; pass pageStart/maxPages to continue. Dashboard paperclip can attach those files directly: images stay vision attachments, documents are saved under ant-code-uploads/ and ingested. Scanned PDFs are not OCR'd; composer-attached PDFs with no text layer send a few embedded page images to the configured vision model.", "- MCP is optional. Use mcp_list to inspect configured servers/tools; mcp_call works only when a local or lab-approved MCP server is configured, and missing MCP servers do not disable built-in local tools.", "- Recommended no-key MCP servers are enabled by default when self-contained; service-bound entries such as SearXNG/SQLite may remain disabled until configured. Use /mcp doctor before relying on MCP, and /mcp doctor --live when you need tool discovery.", "- Skills are local instruction resources discovered from project/configured skill directories. Use skill_list, skill_read, or skill_run before applying a specialized workflow.", diff --git a/src/core/session-messages.ts b/src/core/session-messages.ts index e5b2e4e..e4d8cf8 100644 --- a/src/core/session-messages.ts +++ b/src/core/session-messages.ts @@ -285,16 +285,57 @@ export function normalizeUserTurnMessage(message: SessionMessage | string | Reco export function persistableUserTurnMessage(prompt: string, attachments: unknown = []): SessionMessage { const normalized = normalizeInputAttachments(attachments); - if (normalized.length === 0) { - return { role: "user", content: prompt }; + const documents = Array.isArray(attachments) + ? attachments.filter((item): item is Record => Boolean(item) && typeof item === "object" && item.type === "document") + : []; + const documentBlocks = documents.map((item) => ({ + type: "text", + text: `[文档附件:${String(item.name ?? "document")}]` + })); + const chips = persistableAttachmentChips(attachments); + const message: SessionMessage = normalized.length === 0 && documentBlocks.length === 0 + ? { role: "user", content: prompt } + : { + role: "user", + content: [ + ...(String(prompt ?? "").trim() ? [{ type: "text", text: String(prompt ?? "") }] : []), + ...documentBlocks, + ...normalized.map(imageAttachmentSummaryBlock) + ] + }; + if (chips.length > 0) { + message.attachments = chips; } - return { - role: "user", - content: [ - ...(String(prompt ?? "").trim() ? [{ type: "text", text: String(prompt ?? "") }] : []), - ...normalized.map(imageAttachmentSummaryBlock) - ] - }; + return message; +} + +export function persistableAttachmentChips(attachments: unknown = []): NonNullable { + if (!Array.isArray(attachments)) { + return []; + } + const chips: NonNullable = []; + for (const item of attachments) { + if (!item || typeof item !== "object") { + continue; + } + const record = item as Record; + const type = record.type === "document" ? "document" : record.type === "image" ? "image" : null; + if (!type) { + continue; + } + const storedPath = String(record.path ?? "").trim().replace(/\\/g, "/"); + chips.push({ + type, + name: String(record.name ?? type).trim().slice(0, 160) || type, + mimeType: String(record.mimeType ?? record.mime_type ?? "").trim(), + size: nonNegativeInteger(record.size ?? record.bytes ?? record.sizeBytes, 0) ?? 0, + ...(storedPath ? { path: storedPath } : {}) + }); + if (chips.length >= 10) { + break; + } + } + return chips; } @@ -366,7 +407,7 @@ export function messagesForModelContext(messages: unknown = []): SessionMessage[ if (!message || typeof message !== "object") { return []; } - const { interruptedDraft: _interruptedDraft, ...rest } = message; + const { interruptedDraft: _interruptedDraft, attachments: _attachments, ...rest } = message; return [rest]; }); } diff --git a/src/core/session-persist.ts b/src/core/session-persist.ts index e702ff7..7275874 100644 --- a/src/core/session-persist.ts +++ b/src/core/session-persist.ts @@ -52,7 +52,8 @@ import type { SessionTurnMetadata } from "./session-types.ts"; import { - imageAttachmentSummaryBlock + imageAttachmentSummaryBlock, + persistableAttachmentChips } from "./session-messages.ts"; import { isPlainObject @@ -326,6 +327,7 @@ export function persistableTranscriptMessages(messages: unknown, session: AgentS return persistableMessagesWithOptions(messages, { includeThinking: true, includeToolCalls: false, + includeAttachments: true, stripGoalStatus: session?.goal?.enabled === true }); } @@ -700,6 +702,12 @@ export function persistableMessage(message: unknown, options: Record 0) { + persisted.attachments = chips; + } + } return persisted; } diff --git a/src/core/session-turn.ts b/src/core/session-turn.ts index 1d7e598..035b80a 100644 --- a/src/core/session-turn.ts +++ b/src/core/session-turn.ts @@ -59,6 +59,7 @@ import { normalizeInputAttachments, attachmentMetadataList } from "./session-messages.ts"; +import { ingestComposerDocuments } from "../tools/composer-documents.ts"; import { formatAssistantOutput, analyzeAssistantOutputHealth, @@ -110,7 +111,16 @@ import { export async function runSessionTurn(session: AgentSession, options: RunSessionTurnOptions) { const displayPrompt = typeof options.displayPrompt === "string" ? options.displayPrompt : options.prompt; - const attachments = normalizeInputAttachments(options.attachments); + const ingested = await ingestComposerDocuments({ + cwd: session.cwd, + attachments: options.attachments, + existingImageCount: normalizeInputAttachments(options.attachments).length + }); + const modelPrompt = [options.prompt, ingested.promptAppendix].filter((part) => String(part ?? "").trim()).join("\n\n"); + const attachments = [ + ...normalizeInputAttachments(options.attachments), + ...ingested.visionImages + ].slice(0, 6); const thinkingCapture = createThinkingCapture(); const interruptedDraft = createInterruptedDraftCapture(); const eventOptions = withAntEventOptions(session, { @@ -281,7 +291,7 @@ export async function runSessionTurn(session: AgentSession, options: RunSessionT }); } - const userMessage = buildUserTurnMessage(options.prompt, session.workflow, visionPreparation.attachments, visionPreparation.analysisText); + const userMessage = buildUserTurnMessage(modelPrompt, session.workflow, visionPreparation.attachments, visionPreparation.analysisText); let messages: SessionMessage[] = buildTurnMessages(session, userMessage); let toolResults: SessionToolResult[] = []; const turnMessages: SessionMessage[] = [persistableUserTurnMessage(options.prompt, attachments)]; @@ -309,7 +319,7 @@ export async function runSessionTurn(session: AgentSession, options: RunSessionT } const budgetPreparation = await preparePromptBudgetForGateway({ session, - prompt: options.prompt, + prompt: modelPrompt, messages, toolResults, round, diff --git a/src/core/session-types.ts b/src/core/session-types.ts index d217136..2da3932 100644 --- a/src/core/session-types.ts +++ b/src/core/session-types.ts @@ -56,6 +56,14 @@ export type CreateSessionOptions = { hooksTrusted?: boolean; }; +export type SessionAttachmentChip = { + type: "image" | "document"; + name: string; + mimeType?: string; + size?: number; + path?: string; +}; + export type SessionMessage = { role: string; content?: unknown; @@ -66,6 +74,7 @@ export type SessionMessage = { toolCalls?: Array<{ id?: string; name?: string; input?: unknown }>; toolCallId?: string; interruptedDraft?: boolean; + attachments?: SessionAttachmentChip[]; }; export type AgentSession = { diff --git a/src/dashboard/files.ts b/src/dashboard/files.ts index 31a90a2..daeb166 100644 --- a/src/dashboard/files.ts +++ b/src/dashboard/files.ts @@ -1,7 +1,8 @@ import fs from "node:fs/promises"; -import { realpathSync, statSync } from "node:fs"; +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"; 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"]); @@ -9,6 +10,11 @@ const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]); const PREVIEWABLE_IMAGE_EXTENSIONS = IMAGE_EXTENSIONS; const OFFICE_EXTENSIONS = new Set([".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx"]); const PREVIEWABLE_OFFICE_EXTENSIONS = new Set([".docx", ".xlsx", ".pptx"]); +const SYSTEM_OPEN_EXTENSIONS = new Set([ + ".pdf", ".docx", ".xlsx", ".pptx", ".doc", ".xls", ".ppt", + ".txt", ".md", ".markdown", ".csv", ".tsv", ".json", ".html", + ".png", ".jpg", ".jpeg", ".gif", ".webp" +]); const MAX_TEXT_BYTES = 512 * 1024; const MAX_RAW_BYTES = 20 * 1024 * 1024; const MAX_OFFICE_BYTES = 10 * 1024 * 1024; @@ -17,7 +23,7 @@ const MAX_TABLE_ROWS = 500; const MAX_TABLE_COLUMNS = 80; const MAX_TABLE_TEXT_BYTES = 1024 * 1024; -type WorkspaceFailure = { ok: false; status: number; error: string }; +type WorkspaceFailure = { ok: false; status: number; error: string; code?: string }; type WorkspaceResolved = { ok: true; path: string; root: string }; type WorkspaceOpened = { ok: true; @@ -326,9 +332,48 @@ export function collectSessionFiles(session: { cwd?: unknown; workflow?: unknown for (const candidate of extractPaths(finalOutput)) { addFile(items, cwd, candidate, { source: "mentioned" }); } + addComposerUploads(items, cwd); return dedupeFiles(items); } +function addComposerUploads(items: SessionFileItem[], cwd: string) { + const dir = path.join(path.resolve(cwd), COMPOSER_UPLOAD_DIR); + let names: string[] = []; + try { + names = readdirSync(dir); + } catch { + return; + } + for (const name of names) { + addFile(items, cwd, path.join(COMPOSER_UPLOAD_DIR, name), { source: "attached" }); + } +} + +/** + * @param {string} cwd + * @param {string} requestedPath + */ +export async function resolveSystemOpenFile(cwd: string, requestedPath: string): Promise { + const resolved = resolveWorkspaceFile(cwd, requestedPath); + if (!resolved.ok) { + return resolved; + } + let stat; + try { + stat = await fs.stat(resolved.path); + } catch { + return { ok: false, status: 404, error: "文件不存在或无法读取" }; + } + if (!stat.isFile()) { + return { ok: false, status: 404, error: "文件不存在或不是普通文件" }; + } + const ext = path.extname(resolved.path).toLowerCase(); + if (!SYSTEM_OPEN_EXTENSIONS.has(ext)) { + return { ok: false, status: 403, code: "FILE_OPEN_NOT_ALLOWED", error: "此类型不能用系统应用打开" }; + } + return resolved; +} + /** * @param {string} cwd * @param {string} requestedPath diff --git a/src/dashboard/public/app-core.ts b/src/dashboard/public/app-core.ts index 263bdf9..646b320 100644 --- a/src/dashboard/public/app-core.ts +++ b/src/dashboard/public/app-core.ts @@ -966,6 +966,10 @@ export const DASHBOARD_SHUTDOWN_TIMEOUT_MS = 15_000; export const DASHBOARD_INTERRUPT_TIMEOUT_MS = 5_000; export const MAX_IMAGE_ATTACHMENTS = 6; export const MAX_IMAGE_ATTACHMENT_BYTES = 8 * 1024 * 1024; +export const MAX_DOCUMENT_ATTACHMENTS = 4; +export const MAX_DOCUMENT_ATTACHMENT_BYTES = 40 * 1024 * 1024; +export const DOCUMENT_EXTENSIONS = new Set([".pdf", ".docx", ".xlsx", ".pptx", ".txt", ".md", ".markdown", ".csv", ".json", ".html", ".htm"]); +export const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]); export const CURRENT_SESSION_STORAGE_KEY = "ant-code-dashboard-current-session"; export const DASHBOARD_CLIENT_STORAGE_KEY = "ant-code-dashboard-client-id"; export const PREVIEW_WIDTH_STORAGE_KEY = "ant-code-dashboard-preview-width"; diff --git a/src/dashboard/public/app-ui1.ts b/src/dashboard/public/app-ui1.ts index 577fc7b..8aeff72 100644 --- a/src/dashboard/public/app-ui1.ts +++ b/src/dashboard/public/app-ui1.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, activateModal, collectModalBackground, focusModalInitialTarget, deactivateModal, restoreModalAttribute, handleGlobalKeydown, closeActiveModal, loadTrust, loadSessions, restoreInitialSession, latestBackgroundSessionId, initialSessionId, rememberCurrentSession, renderSessions, sessionMeta, sessionStatusView, toggleSidebar, setSidebarCollapsed, sessionsNeedRefresh, scheduleSessionsRefresh, handleSessionAction, openSession, restoreBackgroundSnapshot, deleteSession, setSessionsRefreshState, copySessionId, newTask, rememberNewTaskModelState, restoreNewTaskModelState, refreshNewTaskModelState, addAttachmentFiles, readImageAttachment, renderAttachmentStrip, attachmentPayload, clearAttachments, sendPrompt, stableTurnRequest, dashboardRequestId, dashboardClientId, statusUrl, interruptTurn, guideTurn, cancelQueuedTurn, cancelBackgroundSubagent, backgroundCancelKey, connectEvents, ensureEventsConnected, disconnectEvents, closeEventSource, markEventConnectionAlive, armEventConnectTimer, armEventStaleTimer, scheduleEventReconnect, reconnectEventsManually, clearEventReconnectTimer, clearEventConnectTimer, clearEventStaleTimer, setConnectionState, resetEventReplayState, rememberEventCursor, handleDashboardEvent, shouldSkipDashboardEvent, beginEventTurn, renderTranscriptMessages, renderSessionFailure, setTranscriptPaging, renderTranscriptHistoryStatus, removeTranscriptHistoryStatus, transcriptFirstContentNode, handleTranscriptScroll, loadOlderTranscript, renderWorkflowPanel, renderWorkflowStrip, currentWorkflowItem, workflowSection, workflowItem, normalizeWorkflowStatus, summarizeWorkflow, appendMessage, createMessageNode, appendTranscriptNode, trimTranscriptWindow, isProtectedTranscriptNode, renderTranscriptWindowMarker, captureTranscriptViewportAnchor, restoreTranscriptViewportAnchor, transcriptNodeTop, restoreTranscriptNodeAnchor, resetTranscriptWindow, appendAssistantDraft, scheduleDraftRender, renderAssistantDraft, appendActivity, appendContextBoundary, contextBoundaryText, handleActivity, isBackgroundSubagentActivity, handleBackgroundSubagentActivity, clearBackgroundSubagentStatus, reconcileBackgroundSubagentSnapshot, backgroundSubagentDisplayStatus, backgroundSubagentVisible, updateLiveActivity, removeLiveActivity, setLiveTitle, toggleLiveStatusDetails, updateLiveStatus, liveStatusTitle, primaryLiveActivity, gatewayRetryChipText, renderBackgroundSubagentStatus, backgroundSubagentCompactLabel, backgroundSubagentTitle, backgroundSubagentMeta, backgroundSubagentCancellable, resetLiveStatus, backgroundSubagentCounts, idleRunStatus, applyIdleRunStatus, updateRunStatusForBackground, updateSessionStatus, updateTurnChangeStats, resetTurnChangeStats, normalizeChangeStats, renderComposerStatus, modelStatusHtml, unresolvedModelStatusHtml, handleModelStatusActivate, handleModelStatusKeydown, toggleModelPanel, hideModelPanel, showSettingsWorkspace, refreshSettingsConfiguration, hideSettingsWorkspace, showModelConfigPanel, hideModelConfigPanel, renderModelPanel, modelCapabilityLabels, handleModelPanelClick, handleModelPanelChange, renderSettingsView, syncSettingsRail, modelSettingsHtml, transcriptSettingsHtml, transcriptRetentionOptionsHtml, networkSettingsHtml, networkModeOptionHtml, agentSettingsHtml, reliabilitySettingsHtml, settingsSectionHeading, settingsToggleHtml, managedFieldHtml, settingsDisabled, settingsFormActions, settingsFeedbackHtml, settingsGatewayProfileHtml, gatewayProfileReadonlyLabel, providerModelKey, scopedDefaultModelLabel, settingsModelHtml, handleSettingsRailClick, initializeSettingsFormTracking, handleSettingsFormChange, settingsControlValue, changedSettingsFields, canonicalSettingsField, setSettingsFormSaving, renderSettingsFeedbackInPlace, saveSettingsConfig, handleSettingsClick, protocolDisplayName, agentModelPickerHtml, renderModelConfigPanel, handleModelConfigPanelClick, handleModelConfigInput, handleModelConfigChange, markModelConfigCredentialChanged, markModelConfigEndpointChanged, handleModelConfigModelIdChanged, markReasoningCapabilityManual, clearReasoningCapabilityControls, syncGatewayUrlHint, gatewayUrlPlaceholder, gatewayUrlHint, syncReasoningDefaultOptions, probeGateway, isCurrentModelConfigRequest, currentGatewayProbeResult, currentGatewayCatalogModels, modelConfigGatewayProfile, modelConfigEndpointChanged, modelConfigAgentModelsSnapshot, initializeAgentModelPickerSnapshot, syncAgentModelPickersForEndpoint, uniqueAgentModelCandidates, appendAgentModelOptions, renderAgentModelPickers, updateAgentModelPickerManualStatus, handleAgentModelSelection, renderGatewayProbeResult, applyProbedModel, applyGatewayDiscoveredModel, applySuggestedGatewayUrl, gatewayCredentialAction, normalizeGatewayProbeModels, normalizeReasoningDiscovery, reasoningCapabilityCandidate, applyReasoningCapabilityCandidate, ensureReasoningEffortOptions, applyPendingReasoningCapabilities, reasoningCapabilityIsActionable, renderReasoningCapabilityStatus, reasoningCapabilityStatusText, reasoningDiscoveryStatusText, probeModelCapabilities, setFormControlsSaving, setModelConfigFormSaving, renderModelConfigFailure, clearModelConfigFailure, manualAgentModelIds, saveModelConfig, saveDefaultModelSelection, switchModel, handleReasoningEffortChange, switchReasoningEffort, deleteGatewayProfile, deleteModel, updateConfigRevisions, normalizeScopedDefaultSelection, configScope, configMutationMetadata, isConfigRevisionConflict, configRevisionConflictMessage, refreshConfigRevisionsAfterConflict, normalizeGatewayConfig, normalizeDashboardSettings, mergeGatewayConfig, normalizeGatewayProfiles, normalizeConfigSource, normalizeModels, normalizeModelSource, normalizeReasoningEfforts, normalizedReasoningEffort, isDisabledReasoningEffort, configuredReasoningEffort, reasoningEffortFallbackLabel, localizedReasoningEffortLabel, reasoningEffortCatalog, reasoningEffortLabel, resolveAtomicModelSelection, currentModelSelection, currentSessionNeedsModelSelection, currentGatewayProfile, gatewayProfileById, settingsInspectedGatewayProfile, modelSourceLabel, markCurrentModel, currentModelInfo, modelDisplayName, normalizeAgentModelTiers, normalizeVisionAgent, firstVisionModelId, hasAgentModelTiers, agentModelTiersSummary, gatewaySummary, modelSaveTargetLabel, gatewaySourceNote, environmentGatewayDefaultNote, sourceBadge, sourceLabel, formatContextUsage, firstFiniteNumber, formatTokenCount, trimNumber, nonNegativeInteger, collapseCompletedActivities, clearAssistantDrafts, collapseAssistantDrafts, isMeaningfulCompletedActivity, isDuplicateDraftText, normalizeComparableText, showApproval, resolveApproval, hideApproval, showQuestion, revealInteractionPanel, renderQuestionPanel, reviewQuestionConversation, returnToQuestion, activateQuestionReviewBackground, deactivateQuestionReviewBackground, questionChoiceButton, toggleQuestionChoice, submitQuestion, cancelQuestion, finishQuestionSubmission, hideQuestion, showTrustPanel, renderTrustPanel, confirmTrust, renderQueuePanel, renderQueueItem, setPendingGuide, clearPendingGuide, syncPendingGuideFromQueue, renderGuideFeedback, guideCopy, guideSource, guideTurnFromQueue, guideButtonText, guideButtonDisabled, guideButtonVisible, syncGuideButton, shouldKeepGuideFeedback, isInterruptError, updateSendButton, showContextConfirm, hideContextConfirm, runContextAction, contextActionRequestOptions, contextSummaryLine, compactResultLine, rememberQuestionDraft, questionResolutionText, showShutdownPanel, hideShutdownPanel, shutdownDashboard, lockClosedDashboard, normalizeLifecycleActivity, shutdownRequestBody, shutdownResultIsClosed, lifecycleActivitySummary, renderShutdownActivity, renderFiles, currentImageFiles, openFile, 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, activateModal, collectModalBackground, focusModalInitialTarget, deactivateModal, restoreModalAttribute, handleGlobalKeydown, closeActiveModal, loadTrust, loadSessions, restoreInitialSession, latestBackgroundSessionId, initialSessionId, rememberCurrentSession, renderSessions, sessionMeta, sessionStatusView, toggleSidebar, setSidebarCollapsed, sessionsNeedRefresh, scheduleSessionsRefresh, handleSessionAction, openSession, restoreBackgroundSnapshot, deleteSession, setSessionsRefreshState, copySessionId, newTask, rememberNewTaskModelState, restoreNewTaskModelState, refreshNewTaskModelState, addAttachmentFiles, readImageAttachment, renderAttachmentStrip, attachmentPayload, clearAttachments, sendPrompt, stableTurnRequest, dashboardRequestId, dashboardClientId, statusUrl, interruptTurn, guideTurn, cancelQueuedTurn, cancelBackgroundSubagent, backgroundCancelKey, connectEvents, ensureEventsConnected, disconnectEvents, closeEventSource, markEventConnectionAlive, armEventConnectTimer, armEventStaleTimer, scheduleEventReconnect, reconnectEventsManually, clearEventReconnectTimer, clearEventConnectTimer, clearEventStaleTimer, setConnectionState, resetEventReplayState, rememberEventCursor, handleDashboardEvent, shouldSkipDashboardEvent, beginEventTurn, renderTranscriptMessages, renderSessionFailure, setTranscriptPaging, renderTranscriptHistoryStatus, removeTranscriptHistoryStatus, transcriptFirstContentNode, handleTranscriptScroll, loadOlderTranscript, renderWorkflowPanel, renderWorkflowStrip, currentWorkflowItem, workflowSection, workflowItem, normalizeWorkflowStatus, summarizeWorkflow, appendMessage, createMessageNode, appendTranscriptNode, trimTranscriptWindow, isProtectedTranscriptNode, renderTranscriptWindowMarker, captureTranscriptViewportAnchor, restoreTranscriptViewportAnchor, transcriptNodeTop, restoreTranscriptNodeAnchor, resetTranscriptWindow, appendAssistantDraft, scheduleDraftRender, renderAssistantDraft, appendActivity, appendContextBoundary, contextBoundaryText, handleActivity, isBackgroundSubagentActivity, handleBackgroundSubagentActivity, clearBackgroundSubagentStatus, reconcileBackgroundSubagentSnapshot, backgroundSubagentDisplayStatus, backgroundSubagentVisible, updateLiveActivity, removeLiveActivity, setLiveTitle, toggleLiveStatusDetails, updateLiveStatus, liveStatusTitle, primaryLiveActivity, gatewayRetryChipText, renderBackgroundSubagentStatus, backgroundSubagentCompactLabel, backgroundSubagentTitle, backgroundSubagentMeta, backgroundSubagentCancellable, resetLiveStatus, backgroundSubagentCounts, idleRunStatus, applyIdleRunStatus, updateRunStatusForBackground, updateSessionStatus, updateTurnChangeStats, resetTurnChangeStats, normalizeChangeStats, renderComposerStatus, modelStatusHtml, unresolvedModelStatusHtml, handleModelStatusActivate, handleModelStatusKeydown, toggleModelPanel, hideModelPanel, showSettingsWorkspace, refreshSettingsConfiguration, hideSettingsWorkspace, showModelConfigPanel, hideModelConfigPanel, renderModelPanel, modelCapabilityLabels, handleModelPanelClick, handleModelPanelChange, renderSettingsView, syncSettingsRail, modelSettingsHtml, transcriptSettingsHtml, transcriptRetentionOptionsHtml, networkSettingsHtml, networkModeOptionHtml, agentSettingsHtml, reliabilitySettingsHtml, settingsSectionHeading, settingsToggleHtml, managedFieldHtml, settingsDisabled, settingsFormActions, settingsFeedbackHtml, settingsGatewayProfileHtml, gatewayProfileReadonlyLabel, providerModelKey, scopedDefaultModelLabel, settingsModelHtml, handleSettingsRailClick, initializeSettingsFormTracking, handleSettingsFormChange, settingsControlValue, changedSettingsFields, canonicalSettingsField, setSettingsFormSaving, renderSettingsFeedbackInPlace, saveSettingsConfig, handleSettingsClick, protocolDisplayName, agentModelPickerHtml, renderModelConfigPanel, handleModelConfigPanelClick, handleModelConfigInput, handleModelConfigChange, markModelConfigCredentialChanged, markModelConfigEndpointChanged, handleModelConfigModelIdChanged, markReasoningCapabilityManual, clearReasoningCapabilityControls, syncGatewayUrlHint, gatewayUrlPlaceholder, gatewayUrlHint, syncReasoningDefaultOptions, probeGateway, isCurrentModelConfigRequest, currentGatewayProbeResult, currentGatewayCatalogModels, modelConfigGatewayProfile, modelConfigEndpointChanged, modelConfigAgentModelsSnapshot, initializeAgentModelPickerSnapshot, syncAgentModelPickersForEndpoint, uniqueAgentModelCandidates, appendAgentModelOptions, renderAgentModelPickers, updateAgentModelPickerManualStatus, handleAgentModelSelection, renderGatewayProbeResult, applyProbedModel, applyGatewayDiscoveredModel, applySuggestedGatewayUrl, gatewayCredentialAction, normalizeGatewayProbeModels, normalizeReasoningDiscovery, reasoningCapabilityCandidate, applyReasoningCapabilityCandidate, ensureReasoningEffortOptions, applyPendingReasoningCapabilities, reasoningCapabilityIsActionable, renderReasoningCapabilityStatus, reasoningCapabilityStatusText, reasoningDiscoveryStatusText, probeModelCapabilities, setFormControlsSaving, setModelConfigFormSaving, renderModelConfigFailure, clearModelConfigFailure, manualAgentModelIds, saveModelConfig, saveDefaultModelSelection, switchModel, handleReasoningEffortChange, switchReasoningEffort, deleteGatewayProfile, deleteModel, updateConfigRevisions, normalizeScopedDefaultSelection, configScope, configMutationMetadata, isConfigRevisionConflict, configRevisionConflictMessage, refreshConfigRevisionsAfterConflict, normalizeGatewayConfig, normalizeDashboardSettings, mergeGatewayConfig, normalizeGatewayProfiles, normalizeConfigSource, normalizeModels, normalizeModelSource, normalizeReasoningEfforts, normalizedReasoningEffort, isDisabledReasoningEffort, configuredReasoningEffort, reasoningEffortFallbackLabel, localizedReasoningEffortLabel, reasoningEffortCatalog, reasoningEffortLabel, resolveAtomicModelSelection, currentModelSelection, currentSessionNeedsModelSelection, currentGatewayProfile, gatewayProfileById, settingsInspectedGatewayProfile, modelSourceLabel, markCurrentModel, currentModelInfo, modelDisplayName, normalizeAgentModelTiers, normalizeVisionAgent, firstVisionModelId, hasAgentModelTiers, agentModelTiersSummary, gatewaySummary, modelSaveTargetLabel, gatewaySourceNote, environmentGatewayDefaultNote, sourceBadge, sourceLabel, formatContextUsage, firstFiniteNumber, formatTokenCount, trimNumber, nonNegativeInteger, collapseCompletedActivities, clearAssistantDrafts, collapseAssistantDrafts, isMeaningfulCompletedActivity, isDuplicateDraftText, normalizeComparableText, showApproval, resolveApproval, hideApproval, showQuestion, revealInteractionPanel, renderQuestionPanel, reviewQuestionConversation, returnToQuestion, activateQuestionReviewBackground, deactivateQuestionReviewBackground, questionChoiceButton, toggleQuestionChoice, submitQuestion, cancelQuestion, finishQuestionSubmission, hideQuestion, showTrustPanel, renderTrustPanel, confirmTrust, renderQueuePanel, renderQueueItem, setPendingGuide, clearPendingGuide, syncPendingGuideFromQueue, renderGuideFeedback, guideCopy, guideSource, guideTurnFromQueue, guideButtonText, guideButtonDisabled, guideButtonVisible, syncGuideButton, shouldKeepGuideFeedback, isInterruptError, updateSendButton, showContextConfirm, hideContextConfirm, runContextAction, contextActionRequestOptions, contextSummaryLine, compactResultLine, rememberQuestionDraft, questionResolutionText, showShutdownPanel, hideShutdownPanel, shutdownDashboard, lockClosedDashboard, normalizeLifecycleActivity, shutdownRequestBody, shutdownResultIsClosed, lifecycleActivitySummary, renderShutdownActivity, renderFiles, currentImageFiles, openFile, handleLocalFileOpenClick, renderOfficePreview, officePreviewMeta, officePreviewBodyHtml, renderTablePreview, normalizeTablePreview, tablePreviewMeta, renderCompactTableHtml, renderExpandedTableHtml, renderTableHtml, tableTruncationNote, maxVisibleColumns, columnLabel, renderSheetPreviewHtml, renderSheetCellHtml, resetPreview, fencedDataForFile, dataLanguageForExtension, showImageLightbox, showTableLightbox, renderLightboxImage, bindTableLightboxControls, moveLightbox, hideLightbox, setPermissionMode, clearTranscript, cancelTranscriptAnimationFrames, clearAssistantDraftTimers, hideEmptyState, showError, showNotice, renderBootstrapLoading, renderBootstrapFailure, dashboardPayloadError, bootstrapFailurePresentation, clearBootstrapStatus, scrollTranscript, isTranscriptNearBottom, syncTranscriptFollowState, followTranscript, updateTranscriptJump, beginScopedRequest, isCurrentScopedRequest, finishScopedRequest, cancelScopedRequest, isAbortError, getJson, postJson, deleteJson, dashboardFetch, responseJson, dashboardJsonHeaders, dashboardCsrfToken, messageText, messageDisplayText, userMessageDisplayText, normalizeAttachmentMetadata, imageAttachmentLine, renderMessageText, renderLinkedText, bindRichContent, linkifyFileTextNodes, replaceFileReferences, isLikelyLocalFileReference, resolveDisplayFilePath, normalizeFileReferencePath, parentDirectory, filePreviewUrl, rawFileUrl, apiFileUrl, imagePreviewUrl, isSafeInlineBitmapUrl, normalizeRelativePath, isWorkspaceRelativeToBase, copyCodeBlock, previewText, formatNumber, formatBytes, escapeHtml, escapeAttribute, formatTime, formatRelativeTime } from "./app-barrel.ts"; export async function init() { restorePreviewWidth(); bindEvents(); @@ -135,6 +135,7 @@ export function bindEvents() { }); els.permissionMode.addEventListener("keydown", handlePermissionModeKeydown); els.goalMode?.addEventListener("click", () => requestGoalMode()); + els.preview.addEventListener("click", handleLocalFileOpenClick); els.collapsePreview.addEventListener("click", () => { if (responsiveLayoutMode() === "desktop") { document.body.classList.toggle("preview-collapsed"); diff --git a/src/dashboard/public/app-ui2.ts b/src/dashboard/public/app-ui2.ts index 00b3c34..8c707d9 100644 --- a/src/dashboard/public/app-ui2.ts +++ b/src/dashboard/public/app-ui2.ts @@ -1,7 +1,7 @@ import { renderMarkdown } from "./markdown.ts"; import { hydrateRichContent } from "./rich-renderers.ts"; import { visibleTranscriptRole } from "./transcript.ts"; -import { MANUAL_AGENT_MODEL_VALUE, state, els, MODE_DESCRIPTIONS, LOCAL_FILE_EXTENSIONS, FILE_REFERENCE_PATTERN, TRANSCRIPT_DOM_LIMIT, EVENT_STALE_AFTER_MS, EVENT_CONNECT_TIMEOUT_MS, EVENT_RECONNECT_MAX_ATTEMPTS, DASHBOARD_REQUEST_TIMEOUT_MS, DASHBOARD_API_VERSION, DASHBOARD_LIFECYCLE_TIMEOUT_MS, DASHBOARD_SHUTDOWN_TIMEOUT_MS, DASHBOARD_INTERRUPT_TIMEOUT_MS, MAX_IMAGE_ATTACHMENTS, MAX_IMAGE_ATTACHMENT_BYTES, CURRENT_SESSION_STORAGE_KEY, DASHBOARD_CLIENT_STORAGE_KEY, PREVIEW_WIDTH_STORAGE_KEY, PREVIEW_WIDTH_DEFAULT, PREVIEW_WIDTH_MIN, PREVIEW_WIDTH_MAX, PREVIEW_WORKSPACE_MIN , emptySessionStatus, emptyBackgroundSubagent } from "./app-core.ts"; +import { MANUAL_AGENT_MODEL_VALUE, state, els, MODE_DESCRIPTIONS, LOCAL_FILE_EXTENSIONS, FILE_REFERENCE_PATTERN, TRANSCRIPT_DOM_LIMIT, EVENT_STALE_AFTER_MS, EVENT_CONNECT_TIMEOUT_MS, EVENT_RECONNECT_MAX_ATTEMPTS, DASHBOARD_REQUEST_TIMEOUT_MS, DASHBOARD_API_VERSION, DASHBOARD_LIFECYCLE_TIMEOUT_MS, DASHBOARD_SHUTDOWN_TIMEOUT_MS, DASHBOARD_INTERRUPT_TIMEOUT_MS, MAX_IMAGE_ATTACHMENTS, MAX_IMAGE_ATTACHMENT_BYTES, MAX_DOCUMENT_ATTACHMENTS, MAX_DOCUMENT_ATTACHMENT_BYTES, DOCUMENT_EXTENSIONS, IMAGE_EXTENSIONS, CURRENT_SESSION_STORAGE_KEY, DASHBOARD_CLIENT_STORAGE_KEY, PREVIEW_WIDTH_STORAGE_KEY, PREVIEW_WIDTH_DEFAULT, PREVIEW_WIDTH_MIN, PREVIEW_WIDTH_MAX, PREVIEW_WORKSPACE_MIN , emptySessionStatus, emptyBackgroundSubagent } from "./app-core.ts"; import type { DashboardConfigSource, DashboardReasoningEffort, DashboardTurnChangeStats, DashboardScopedDefaultSelection, DashboardVisionAgent, DashboardReasoningDiscovery, DashboardReasoningCapabilityCandidate, DashboardGatewayProbeModel, DashboardGatewayProbeResult, DashboardLifecycleActivity, DashboardSettings, DashboardFile, DashboardTableSheet, DashboardTablePreview, DashboardLightboxItem, DashboardQuestionChoice, DashboardPendingQuestion, DashboardApproval, DashboardGatewayTransport, DashboardScopedRequest, DashboardFetchOptions, DashboardModelSource, DashboardSessionStatus, DashboardApiResult, DashboardActivity, DashboardModelOption, DashboardGatewayProfile, DashboardGatewayConfig, DashboardModelSelection, DashboardPendingGuide, DashboardStreamEvent, DashboardUiState , DashboardSessionSummary } from "./app-core.ts"; import { eventTargetOf, eventElement, isPlainObject, modelSourceOf, errorMessageOf, init, bootstrapDashboard, observeRunStatus, updateRunStatusTone, bindEvents, normalizedResponsiveView, composerHeightFor, previewWidthBounds, clampedPreviewWidth, permissionIndexForKey, focusTrapTarget, shouldFollowTranscript, scheduleAnimationFrameOnce, cancelScheduledAnimationFrame, appendPlainDraftDelta, renderFinalAssistantBody, selectTranscriptNodesToRemove, responsiveLayoutMode, restorePreviewWidth, setPreviewWidth, syncPreviewResizeHandle, beginPreviewResize, updatePreviewResize, finishPreviewResize, handlePreviewResizeKeydown, setResponsiveView, syncResponsiveNavigation, setResponsiveSurfaceInert, handleResponsiveFileNavigation, syncVisualViewport, resizePromptInput, handlePermissionModeKeydown, requestPermissionMode, defaultGoalMaxAutoContinues, emptyGoalSnapshot, applyGoalSnapshot, renderGoalControls, renderGoalStatusBar, requestGoalMode, showGoalConfirm, hideGoalConfirm, showGoalTextPanel, hideGoalTextPanel, enableGoalWithObjective, submitGoalAction, adoptGoalRunResult, showPermissionConfirm, hidePermissionConfirm, updateContextActions, announceStatus, modalFocusableElements, cancelBackgroundSubagent, backgroundCancelKey, connectEvents, ensureEventsConnected, disconnectEvents, closeEventSource, markEventConnectionAlive, armEventConnectTimer, armEventStaleTimer, scheduleEventReconnect, reconnectEventsManually, clearEventReconnectTimer, clearEventConnectTimer, clearEventStaleTimer, setConnectionState, resetEventReplayState, rememberEventCursor, handleDashboardEvent, shouldSkipDashboardEvent, beginEventTurn, renderTranscriptMessages, renderSessionFailure, setTranscriptPaging, renderTranscriptHistoryStatus, removeTranscriptHistoryStatus, transcriptFirstContentNode, handleTranscriptScroll, loadOlderTranscript, renderWorkflowPanel, renderWorkflowStrip, currentWorkflowItem, workflowSection, workflowItem, normalizeWorkflowStatus, summarizeWorkflow, appendMessage, createMessageNode, appendTranscriptNode, trimTranscriptWindow, isProtectedTranscriptNode, renderTranscriptWindowMarker, captureTranscriptViewportAnchor, restoreTranscriptViewportAnchor, transcriptNodeTop, restoreTranscriptNodeAnchor, resetTranscriptWindow, appendAssistantDraft, scheduleDraftRender, renderAssistantDraft, appendActivity, appendContextBoundary, contextBoundaryText, handleActivity, isBackgroundSubagentActivity, handleBackgroundSubagentActivity, clearBackgroundSubagentStatus, reconcileBackgroundSubagentSnapshot, backgroundSubagentDisplayStatus, backgroundSubagentVisible, updateLiveActivity, removeLiveActivity, setLiveTitle, toggleLiveStatusDetails, updateLiveStatus, liveStatusTitle, primaryLiveActivity, gatewayRetryChipText, renderBackgroundSubagentStatus, backgroundSubagentCompactLabel, backgroundSubagentTitle, backgroundSubagentMeta, backgroundSubagentCancellable, resetLiveStatus, backgroundSubagentCounts, idleRunStatus, applyIdleRunStatus, updateRunStatusForBackground, updateSessionStatus, updateTurnChangeStats, resetTurnChangeStats, normalizeChangeStats, renderComposerStatus, modelStatusHtml, unresolvedModelStatusHtml, handleModelStatusActivate, handleModelStatusKeydown, toggleModelPanel, hideModelPanel, showSettingsWorkspace, refreshSettingsConfiguration, hideSettingsWorkspace, showModelConfigPanel, hideModelConfigPanel, renderModelPanel, modelCapabilityLabels, handleModelPanelClick, handleModelPanelChange, renderSettingsView, syncSettingsRail, modelSettingsHtml, transcriptSettingsHtml, transcriptRetentionOptionsHtml, networkSettingsHtml, networkModeOptionHtml, agentSettingsHtml, reliabilitySettingsHtml, settingsSectionHeading, settingsToggleHtml, managedFieldHtml, settingsDisabled, settingsFormActions, settingsFeedbackHtml, settingsGatewayProfileHtml, gatewayProfileReadonlyLabel, providerModelKey, scopedDefaultModelLabel, settingsModelHtml, handleSettingsRailClick, initializeSettingsFormTracking, handleSettingsFormChange, settingsControlValue, changedSettingsFields, canonicalSettingsField, setSettingsFormSaving, renderSettingsFeedbackInPlace, saveSettingsConfig, handleSettingsClick, protocolDisplayName, agentModelPickerHtml, renderModelConfigPanel, handleModelConfigPanelClick, handleModelConfigInput, handleModelConfigChange, markModelConfigCredentialChanged, markModelConfigEndpointChanged, handleModelConfigModelIdChanged, markReasoningCapabilityManual, clearReasoningCapabilityControls, syncGatewayUrlHint, gatewayUrlPlaceholder, gatewayUrlHint, syncReasoningDefaultOptions, probeGateway, isCurrentModelConfigRequest, currentGatewayProbeResult, currentGatewayCatalogModels, modelConfigGatewayProfile, modelConfigEndpointChanged, modelConfigAgentModelsSnapshot, initializeAgentModelPickerSnapshot, syncAgentModelPickersForEndpoint, uniqueAgentModelCandidates, appendAgentModelOptions, renderAgentModelPickers, updateAgentModelPickerManualStatus, handleAgentModelSelection, renderGatewayProbeResult, applyProbedModel, applyGatewayDiscoveredModel, applySuggestedGatewayUrl, gatewayCredentialAction, normalizeGatewayProbeModels, normalizeReasoningDiscovery, reasoningCapabilityCandidate, applyReasoningCapabilityCandidate, ensureReasoningEffortOptions, applyPendingReasoningCapabilities, reasoningCapabilityIsActionable, renderReasoningCapabilityStatus, reasoningCapabilityStatusText, reasoningDiscoveryStatusText, probeModelCapabilities, setFormControlsSaving, setModelConfigFormSaving, renderModelConfigFailure, clearModelConfigFailure, manualAgentModelIds, saveModelConfig, saveDefaultModelSelection, switchModel, handleReasoningEffortChange, switchReasoningEffort, deleteGatewayProfile, deleteModel, updateConfigRevisions, normalizeScopedDefaultSelection, configScope, configMutationMetadata, isConfigRevisionConflict, configRevisionConflictMessage, refreshConfigRevisionsAfterConflict, normalizeGatewayConfig, normalizeDashboardSettings, mergeGatewayConfig, normalizeGatewayProfiles, normalizeConfigSource, normalizeModels, normalizeModelSource, normalizeReasoningEfforts, normalizedReasoningEffort, isDisabledReasoningEffort, configuredReasoningEffort, reasoningEffortFallbackLabel, localizedReasoningEffortLabel, reasoningEffortCatalog, reasoningEffortLabel, resolveAtomicModelSelection, currentModelSelection, currentSessionNeedsModelSelection, currentGatewayProfile, gatewayProfileById, settingsInspectedGatewayProfile, modelSourceLabel, markCurrentModel, currentModelInfo, modelDisplayName, normalizeAgentModelTiers, normalizeVisionAgent, firstVisionModelId, hasAgentModelTiers, agentModelTiersSummary, gatewaySummary, modelSaveTargetLabel, gatewaySourceNote, environmentGatewayDefaultNote, sourceBadge, sourceLabel, formatContextUsage, firstFiniteNumber, formatTokenCount, trimNumber, nonNegativeInteger, collapseCompletedActivities, clearAssistantDrafts, collapseAssistantDrafts, isMeaningfulCompletedActivity, isDuplicateDraftText, normalizeComparableText, showApproval, resolveApproval, hideApproval, showQuestion, revealInteractionPanel, renderQuestionPanel, reviewQuestionConversation, returnToQuestion, activateQuestionReviewBackground, deactivateQuestionReviewBackground, questionChoiceButton, toggleQuestionChoice, submitQuestion, cancelQuestion, finishQuestionSubmission, hideQuestion, showTrustPanel, renderTrustPanel, confirmTrust, renderQueuePanel, renderQueueItem, setPendingGuide, clearPendingGuide, syncPendingGuideFromQueue, renderGuideFeedback, guideCopy, guideSource, guideTurnFromQueue, guideButtonText, guideButtonDisabled, guideButtonVisible, syncGuideButton, shouldKeepGuideFeedback, isInterruptError, updateSendButton, showContextConfirm, hideContextConfirm, runContextAction, contextActionRequestOptions, contextSummaryLine, compactResultLine, rememberQuestionDraft, questionResolutionText, showShutdownPanel, hideShutdownPanel, shutdownDashboard, lockClosedDashboard, normalizeLifecycleActivity, shutdownRequestBody, shutdownResultIsClosed, lifecycleActivitySummary, renderShutdownActivity, renderFiles, currentImageFiles, openFile, renderOfficePreview, officePreviewMeta, officePreviewBodyHtml, renderTablePreview, normalizeTablePreview, tablePreviewMeta, renderCompactTableHtml, renderExpandedTableHtml, renderTableHtml, tableTruncationNote, maxVisibleColumns, columnLabel, renderSheetPreviewHtml, renderSheetCellHtml, resetPreview, fencedDataForFile, dataLanguageForExtension, showImageLightbox, showTableLightbox, renderLightboxImage, bindTableLightboxControls, moveLightbox, hideLightbox, setPermissionMode, clearTranscript, cancelTranscriptAnimationFrames, clearAssistantDraftTimers, hideEmptyState, showError, showNotice, renderBootstrapLoading, renderBootstrapFailure, dashboardPayloadError, bootstrapFailurePresentation, clearBootstrapStatus, scrollTranscript, isTranscriptNearBottom, syncTranscriptFollowState, followTranscript, updateTranscriptJump, beginScopedRequest, isCurrentScopedRequest, finishScopedRequest, cancelScopedRequest, isAbortError, getJson, postJson, deleteJson, dashboardFetch, responseJson, dashboardJsonHeaders, dashboardCsrfToken, messageText, messageDisplayText, userMessageDisplayText, normalizeAttachmentMetadata, imageAttachmentLine, renderMessageText, renderLinkedText, bindRichContent, linkifyFileTextNodes, replaceFileReferences, isLikelyLocalFileReference, resolveDisplayFilePath, normalizeFileReferencePath, parentDirectory, filePreviewUrl, rawFileUrl, apiFileUrl, imagePreviewUrl, isSafeInlineBitmapUrl, normalizeRelativePath, isWorkspaceRelativeToBase, copyCodeBlock, previewText, formatNumber, formatBytes, escapeHtml, escapeAttribute, formatTime, formatRelativeTime } from "./app-barrel.ts"; export function activateModal(modal: HTMLElement | null | undefined, options: { initialFocus?: string; returnFocus?: Element | null } = {}) { @@ -618,34 +618,69 @@ export async function refreshNewTaskModelState() { } export async function addAttachmentFiles(files: ArrayLike | unknown[] | FileList | null | undefined) { - const list = Array.from(files ?? []); - const images = list.filter((file): file is File => file instanceof File && String(file.type ?? "").startsWith("image/")); - if (images.length === 0) { + const list = Array.from(files ?? []).filter((file): file is File => file instanceof File); + if (list.length === 0) { return; } - const slots = Math.max(0, MAX_IMAGE_ATTACHMENTS - state.attachments.length); - if (slots <= 0) { - showError(`最多可附加 ${MAX_IMAGE_ATTACHMENTS} 张图片`); - return; - } - for (const file of images.slice(0, slots)) { - if (file.size > MAX_IMAGE_ATTACHMENT_BYTES) { - showError(`${file.name || "图片"} 超过 8MB,暂不发送`); + let ignoredUnknown = 0; + for (const file of list) { + const kind = classifyComposerFile(file); + if (kind === "image") { + const imageCount = state.attachments.filter((item: { type?: string }) => item.type !== "document").length; + if (imageCount >= MAX_IMAGE_ATTACHMENTS) { + showError(`最多可附加 ${MAX_IMAGE_ATTACHMENTS} 张图片`); + continue; + } + if (file.size > MAX_IMAGE_ATTACHMENT_BYTES) { + showError(`${file.name || "图片"} 超过 8MB,暂不发送`); + continue; + } + try { + state.attachments.push(await readImageAttachment(file)); + } catch (error) { + showError(errorMessageOf(error) || "读取图片失败"); + } continue; } - try { - state.attachments.push(await readImageAttachment(file)); - } catch (error) { - showError(errorMessageOf(error) || "读取图片失败"); + if (kind === "document") { + const documentCount = state.attachments.filter((item: { type?: string }) => item.type === "document").length; + if (documentCount >= MAX_DOCUMENT_ATTACHMENTS) { + showError(`最多可附加 ${MAX_DOCUMENT_ATTACHMENTS} 个文档`); + continue; + } + if (file.size > MAX_DOCUMENT_ATTACHMENT_BYTES) { + showError(`${file.name || "文档"} 超过 40MB,暂不发送`); + continue; + } + try { + state.attachments.push(await readDocumentAttachment(file)); + } catch (error) { + showError(errorMessageOf(error) || "读取文档失败"); + } + continue; } + ignoredUnknown += 1; } - if (images.length > slots) { - showError(`最多可附加 ${MAX_IMAGE_ATTACHMENTS} 张图片,已忽略多余图片`); + if (ignoredUnknown > 0) { + showError("回形针只接收图片和 PDF / Word / Excel / PPT / 文本,不支持旧版 .doc .xls .ppt"); } renderAttachmentStrip(); updateSendButton(); } +export function classifyComposerFile(file: File) { + const name = String(file.name ?? ""); + const ext = name.includes(".") ? `.${name.split(".").pop()?.toLowerCase() ?? ""}` : ""; + const mime = String(file.type ?? "").toLowerCase(); + if (IMAGE_EXTENSIONS.has(ext) || mime === "image/png" || mime === "image/jpeg" || mime === "image/gif" || mime === "image/webp") { + return "image"; + } + if (DOCUMENT_EXTENSIONS.has(ext) || mime === "application/pdf") { + return "document"; + } + return "unsupported"; +} + export function readImageAttachment(file: File) { return new Promise<{ id: string; @@ -679,6 +714,39 @@ export function readImageAttachment(file: File) { }); } +export function readDocumentAttachment(file: File) { + return new Promise<{ + id: string; + type: "document"; + name: string; + mimeType: string; + size: number; + data: string; + previewUrl: string; + }>((resolve, reject) => { + const reader = new FileReader(); + reader.addEventListener("error", () => reject(new Error("读取文档失败"))); + reader.addEventListener("load", () => { + const dataUrl = String(reader.result ?? ""); + const match = dataUrl.match(/^data:([^;,]+);base64,([\s\S]+)$/); + if (!match) { + reject(new Error("文档格式无法作为附件发送")); + return; + } + resolve({ + id: `attachment-${Date.now()}-${Math.random().toString(16).slice(2)}`, + type: "document", + name: file.name || "document", + mimeType: match[1] || file.type || "application/octet-stream", + size: file.size, + data: match[2], + previewUrl: "" + }); + }); + reader.readAsDataURL(file); + }); +} + export function renderAttachmentStrip() { if (!els.attachmentStrip) { return; @@ -688,10 +756,15 @@ export function renderAttachmentStrip() { for (const attachment of state.attachments) { const item = document.createElement("div"); item.className = "attachment-chip"; + const isDocument = attachment.type === "document"; + const label = attachment.name || (isDocument ? "文档" : "图片"); + const preview = isDocument || !attachment.previewUrl + ? `${escapeHtml(documentChipLabel(attachment.name))}` + : ``; item.innerHTML = ` - - ${escapeHtml(attachment.name || "图片")} - + ${preview} + ${escapeHtml(label)} + `; item.querySelector("button").addEventListener("click", () => { state.attachments = state.attachments.filter((candidate: { id?: string }) => candidate.id !== attachment.id); @@ -702,9 +775,14 @@ export function renderAttachmentStrip() { } } -export function attachmentPayload(attachment: { name?: string; mimeType?: string; size?: number; data?: string }) { +export function documentChipLabel(name: unknown) { + const ext = String(name ?? "").split(".").pop()?.toUpperCase() ?? "FILE"; + return ext.slice(0, 4); +} + +export function attachmentPayload(attachment: { type?: string; name?: string; mimeType?: string; size?: number; data?: string }) { return { - type: "image", + type: attachment.type === "document" ? "document" : "image", name: attachment.name, mimeType: attachment.mimeType, size: attachment.size, diff --git a/src/dashboard/public/app-ui3.ts b/src/dashboard/public/app-ui3.ts index 0e92c47..52d09e4 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, 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, 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, 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"; export async function cancelBackgroundSubagent(groupId: unknown, taskId: unknown) { const key = backgroundCancelKey(groupId, taskId); if (!state.currentSessionId || !key || state.backgroundCancelling.has(key)) { @@ -288,7 +288,12 @@ export function handleDashboardEvent(event: DashboardStreamEvent) { beginEventTurn(event); updateTurnChangeStats(null, { reset: true }); state.lastAssistantFinalSignature = ""; - appendMessage("user", event.queuedKind === "guide" ? "引导" : event.queuedKind === "wakeup" ? "子智能体" : event.queuedKind === "goal-continue" ? "Goal" : "你", userMessageDisplayText(event.text, event.attachments)); + appendMessage( + "user", + event.queuedKind === "guide" ? "引导" : event.queuedKind === "wakeup" ? "子智能体" : event.queuedKind === "goal-continue" ? "Goal" : "你", + event.text, + event.attachments + ); state.running = true; scheduleSessionsRefresh(); if (event.queuedKind === "guide") { @@ -613,7 +618,11 @@ export function renderTranscriptMessages(messages: unknown, options: Record }; } -export function appendMessage(kind: string, label: string, text: string | null | undefined) { +export function appendMessage(kind: string, label: string, text: string | null | undefined, attachments: unknown = []) { const wasAtBottom = isTranscriptNearBottom(); - const node = createMessageNode(kind, label, text); + const node = createMessageNode(kind, label, text, attachments); appendTranscriptNode(node); scrollTranscript({ onlyIfNearBottom: true, wasAtBottom }); if (kind === "assistant") announceStatus("收到新的助手回复"); } -export function createMessageNode(kind: string, label: string, text: string | null | undefined) { +export function createMessageNode(kind: string, label: string, text: string | null | undefined, attachments: unknown = []) { hideEmptyState(); const node = document.createElement("article"); node.className = `message ${kind}`; @@ -906,10 +915,52 @@ export function createMessageNode(kind: string, label: string, text: string | nu `; const body = node.querySelector(".message-body"); if (kind === "assistant") renderFinalAssistantBody(body, text); - else renderMessageText(body, text ?? "", { markdown: false }); + else { + renderMessageText(body, text ?? "", { markdown: false }); + appendMessageAttachmentChips(body, attachments); + } return node; } +export function appendMessageAttachmentChips(body: Element | null, attachments: unknown) { + if (!body) { + return; + } + const items = normalizeAttachmentMetadata(attachments); + if (items.length === 0) { + return; + } + const row = document.createElement("div"); + row.className = "message-attachments"; + for (const item of items) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "file-link attachment-chip"; + button.dataset.file = resolveAttachedFilePath(item); + const name = String(item.name ?? "file"); + button.textContent = name; + button.title = name; + row.append(button); + } + body.append(row); + bindRichContent(row); +} + +export function resolveAttachedFilePath(item: { path?: unknown; name?: unknown }) { + const stored = String(item.path ?? "").trim().replace(/\\/g, "/"); + if (stored) { + return stored; + } + const base = String(item.name ?? "").split(/[/\\]/).pop() ?? ""; + const files = Array.isArray(state.files) ? state.files : []; + const match = [...files].reverse().find((file) => { + const relative = String(file.relativePath ?? "").replace(/\\/g, "/"); + const name = String(file.name ?? ""); + return name === base || relative.endsWith(`/${base}`) || relative.endsWith(base); + }); + return match?.relativePath ?? base; +} + export function appendTranscriptNode(node: Node, options: { deferTrim?: boolean } = {}) { hideEmptyState(); els.transcript.append(node); diff --git a/src/dashboard/public/app-ui9.ts b/src/dashboard/public/app-ui9.ts index c192720..1748fb6 100644 --- a/src/dashboard/public/app-ui9.ts +++ b/src/dashboard/public/app-ui9.ts @@ -133,14 +133,28 @@ export async function openFile(filePath: string | null | undefined) { showImageLightbox(file, images.length ? images : [file], index); }); } else if (file.kind === "pdf") { - els.previewBody.innerHTML = ``; + els.previewBody.classList.add("document-preview-body"); + els.previewBody.innerHTML = ` +
+
+
+ ${escapeHtml(file.name)} + PDF 预览 +
+ ${localOpenButtonHtml(file.relativePath)} +
+ +
+ `; } else if (file.kind === "office-preview") { els.previewBody.classList.add("document-preview-body"); els.previewBody.replaceChildren(renderOfficePreview(file)); } else if (file.kind === "table-preview") { els.previewBody.classList.add("document-preview-body"); els.previewBody.replaceChildren(renderTablePreview(file)); - } else if (file.kind === "office" || file.kind === "binary" || file.kind === "download") { + } else if (file.kind === "office") { + els.previewBody.innerHTML = `
${escapeHtml(file.name)}

${escapeHtml(file.message ?? "此文件第一版不直接预览。")}

${escapeHtml(file.relativePath)}

${localOpenButtonHtml(file.relativePath)}
`; + } else if (file.kind === "binary" || file.kind === "download") { const download = file.downloadOnly ? ` download="${escapeHtml(file.name)}"` : ""; const target = file.downloadOnly ? "" : ` target="_blank"`; els.previewBody.innerHTML = `
${escapeHtml(file.name)}

${escapeHtml(file.message ?? "此文件第一版不直接预览。")}

${escapeHtml(file.relativePath)}

${file.downloadOnly ? "下载文件" : "打开文件"}
`; @@ -166,6 +180,50 @@ export async function openFile(filePath: string | null | undefined) { } } +export function localOpenButtonHtml(relativePath: string | null | undefined) { + return ``; +} + +export function handleLocalFileOpenClick(event: Event) { + const button = eventTargetOf(event).closest("[data-open-local]"); + if (!(button instanceof HTMLElement)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + void openLocalFile(button.getAttribute("data-open-local") ?? "", button); +} + +export async function openLocalFile(filePath: string, button: HTMLElement | null = null) { + const relativePath = String(filePath ?? "").trim(); + if (!relativePath) { + showError("没有可打开的本地文件路径"); + return; + } + const label = button?.textContent ?? "打开"; + if (button) { + button.setAttribute("disabled", "true"); + button.textContent = "正在打开…"; + } + try { + const result = await postJson("/api/files/open", { + path: relativePath, + sessionId: state.currentSessionId ?? "" + }).catch((error: unknown): DashboardApiResult => ({ + ok: false, + error: error instanceof Error ? error.message : String(error) + })); + if (!result.ok) { + showError(result.error ?? "无法打开本地文件"); + } + } finally { + if (button) { + button.removeAttribute("disabled"); + button.textContent = label; + } + } +} + export function renderOfficePreview(file: DashboardFile) { if (file.table) { return renderTablePreview(file); @@ -175,14 +233,13 @@ export function renderOfficePreview(file: DashboardFile) { article.tabIndex = 0; article.setAttribute("aria-label", `${file.name} 轻量预览`); const meta = officePreviewMeta(file); - const openHref = file.rawUrl ?? rawFileUrl(file.relativePath); article.innerHTML = `
${escapeHtml(file.name)} ${escapeHtml(meta)}
- 打开 + ${localOpenButtonHtml(file.relativePath)}
${officePreviewBodyHtml(file)} ${file.truncated ? `
仅显示前 ${formatNumber(file.content?.length ?? 0)} 字符,完整内容请打开文件。
` : ""} @@ -211,7 +268,6 @@ export function renderTablePreview(file: DashboardFile) { article.tabIndex = 0; article.setAttribute("aria-label", `${file.name} 表格预览`); const table = normalizeTablePreview(file.table); - const openHref = file.rawUrl ?? rawFileUrl(file.relativePath); const meta = tablePreviewMeta(file, table); article.innerHTML = `
@@ -221,7 +277,7 @@ export function renderTablePreview(file: DashboardFile) {
- 打开 + ${localOpenButtonHtml(file.relativePath)}
@@ -888,8 +944,10 @@ export function messageDisplayText(content: unknown) { export function userMessageDisplayText(text: string | null | undefined, attachments: unknown = []) { const lines = [String(text ?? "").trim()].filter(Boolean); - const imageLines = normalizeAttachmentMetadata(attachments).map(imageAttachmentLine); - return [...lines, ...imageLines].join("\n"); + const meta = normalizeAttachmentMetadata(attachments); + const imageLines = meta.filter((item) => item.type === "image").map(imageAttachmentLine); + const documentLines = meta.filter((item) => item.type === "document").map(documentAttachmentLine); + return [...lines, ...documentLines, ...imageLines].join("\n"); } export function normalizeAttachmentMetadata(attachments: unknown) { @@ -897,15 +955,86 @@ export function normalizeAttachmentMetadata(attachments: unknown) { return []; } return attachments - .filter((item) => item && typeof item === "object" && item.type === "image") + .filter((item) => item && typeof item === "object" && (item.type === "image" || item.type === "document")) .map((item) => ({ - type: "image", - name: String(item.name ?? "image"), - mimeType: String(item.mimeType ?? item.mime_type ?? "image"), - size: Number.isFinite(Number(item.size)) ? Number(item.size) : Number(item.bytes ?? item.sizeBytes ?? 0) + type: item.type === "document" ? "document" : "image", + name: String(item.name ?? (item.type === "document" ? "document" : "image")), + mimeType: String(item.mimeType ?? item.mime_type ?? (item.type === "document" ? "document" : "image")), + size: Number.isFinite(Number(item.size)) ? Number(item.size) : Number(item.bytes ?? item.sizeBytes ?? 0), + path: String(item.path ?? "").trim() })); } +export function transcriptMessageAttachments(message: unknown) { + const record = isPlainObject(message) ? message : {}; + const stored = normalizeAttachmentMetadata(record.attachments); + if (stored.length > 0) { + return stored; + } + return attachmentsFromPlaceholderText(messageDisplayText(record.content)); +} + +export function userTranscriptDisplayText(content: unknown, attachments: unknown = []) { + const chips = normalizeAttachmentMetadata(attachments); + if (chips.length === 0) { + return messageDisplayText(content); + } + if (typeof content === "string") { + return stripAttachmentPlaceholders(content); + } + if (!Array.isArray(content)) { + return ""; + } + return content.map((item) => { + if (typeof item === "string") { + return stripAttachmentPlaceholders(item); + } + if (item && typeof item === "object" && "text" in item) { + return stripAttachmentPlaceholders(String(item.text ?? "")); + } + return ""; + }).filter(Boolean).join("\n"); +} + +function attachmentsFromPlaceholderText(text: string) { + const items: Array<{ type: string; name: string; mimeType: string; size: number; path: string }> = []; + const pattern = /\[(文档附件|图片附件):([^\]]+)\]/g; + let match = pattern.exec(text); + while (match) { + const name = String(match[2] ?? "").split(" · ")[0].trim(); + if (name) { + items.push({ + type: match[1] === "图片附件" ? "image" : "document", + name, + mimeType: "", + size: 0, + path: "" + }); + } + match = pattern.exec(text); + } + return items; +} + +function stripAttachmentPlaceholders(text: string) { + return String(text ?? "") + .split(/\n/) + .filter((line) => { + const value = line.trim(); + return !value.startsWith("[文档附件:") && !value.startsWith("[图片附件:"); + }) + .join("\n") + .trim(); +} + +export function documentAttachmentLine(item: Record) { + const parts = [ + item.name ? String(item.name) : "document", + Number.isFinite(Number(item.size)) && Number(item.size) > 0 ? formatBytes(item.size) : "" + ].filter(Boolean); + return `[文档附件:${parts.join(" · ")}]`; +} + export function imageAttachmentLine(item: Record) { const parts = [ item.name ? String(item.name) : "image", diff --git a/src/dashboard/public/app.js b/src/dashboard/public/app.js index a282931..ee5f3b0 100644 --- a/src/dashboard/public/app.js +++ b/src/dashboard/public/app.js @@ -371,6 +371,10 @@ var DASHBOARD_SHUTDOWN_TIMEOUT_MS = 15e3; var DASHBOARD_INTERRUPT_TIMEOUT_MS = 5e3; var MAX_IMAGE_ATTACHMENTS = 6; var MAX_IMAGE_ATTACHMENT_BYTES = 8 * 1024 * 1024; +var MAX_DOCUMENT_ATTACHMENTS = 4; +var MAX_DOCUMENT_ATTACHMENT_BYTES = 40 * 1024 * 1024; +var DOCUMENT_EXTENSIONS = /* @__PURE__ */ new Set([".pdf", ".docx", ".xlsx", ".pptx", ".txt", ".md", ".markdown", ".csv", ".json", ".html", ".htm"]); +var IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]); var CURRENT_SESSION_STORAGE_KEY = "ant-code-dashboard-current-session"; var DASHBOARD_CLIENT_STORAGE_KEY = "ant-code-dashboard-client-id"; var PREVIEW_WIDTH_STORAGE_KEY = "ant-code-dashboard-preview-width"; @@ -507,6 +511,7 @@ function bindEvents() { }); els.permissionMode.addEventListener("keydown", handlePermissionModeKeydown); els.goalMode?.addEventListener("click", () => requestGoalMode()); + els.preview.addEventListener("click", handleLocalFileOpenClick); els.collapsePreview.addEventListener("click", () => { if (responsiveLayoutMode() === "desktop") { document.body.classList.toggle("preview-collapsed"); @@ -1797,33 +1802,67 @@ async function refreshNewTaskModelState2() { rememberNewTaskModelState(); } async function addAttachmentFiles(files) { - const list = Array.from(files ?? []); - const images = list.filter((file) => file instanceof File && String(file.type ?? "").startsWith("image/")); - if (images.length === 0) { - return; - } - const slots = Math.max(0, MAX_IMAGE_ATTACHMENTS - state.attachments.length); - if (slots <= 0) { - showError(`最多可附加 ${MAX_IMAGE_ATTACHMENTS} 张图片`); - return; - } - for (const file of images.slice(0, slots)) { - if (file.size > MAX_IMAGE_ATTACHMENT_BYTES) { - showError(`${file.name || "图片"} 超过 8MB,暂不发送`); + const list = Array.from(files ?? []).filter((file) => file instanceof File); + if (list.length === 0) { + return; + } + let ignoredUnknown = 0; + for (const file of list) { + const kind = classifyComposerFile(file); + if (kind === "image") { + const imageCount = state.attachments.filter((item) => item.type !== "document").length; + if (imageCount >= MAX_IMAGE_ATTACHMENTS) { + showError(`最多可附加 ${MAX_IMAGE_ATTACHMENTS} 张图片`); + continue; + } + if (file.size > MAX_IMAGE_ATTACHMENT_BYTES) { + showError(`${file.name || "图片"} 超过 8MB,暂不发送`); + continue; + } + try { + state.attachments.push(await readImageAttachment2(file)); + } catch (error) { + showError(errorMessageOf(error) || "读取图片失败"); + } continue; } - try { - state.attachments.push(await readImageAttachment2(file)); - } catch (error) { - showError(errorMessageOf(error) || "读取图片失败"); + if (kind === "document") { + const documentCount = state.attachments.filter((item) => item.type === "document").length; + if (documentCount >= MAX_DOCUMENT_ATTACHMENTS) { + showError(`最多可附加 ${MAX_DOCUMENT_ATTACHMENTS} 个文档`); + continue; + } + if (file.size > MAX_DOCUMENT_ATTACHMENT_BYTES) { + showError(`${file.name || "文档"} 超过 40MB,暂不发送`); + continue; + } + try { + state.attachments.push(await readDocumentAttachment(file)); + } catch (error) { + showError(errorMessageOf(error) || "读取文档失败"); + } + continue; } + ignoredUnknown += 1; } - if (images.length > slots) { - showError(`最多可附加 ${MAX_IMAGE_ATTACHMENTS} 张图片,已忽略多余图片`); + if (ignoredUnknown > 0) { + showError("回形针只接收图片和 PDF / Word / Excel / PPT / 文本,不支持旧版 .doc .xls .ppt"); } renderAttachmentStrip2(); updateSendButton(); } +function classifyComposerFile(file) { + const name = String(file.name ?? ""); + const ext = name.includes(".") ? `.${name.split(".").pop()?.toLowerCase() ?? ""}` : ""; + const mime = String(file.type ?? "").toLowerCase(); + if (IMAGE_EXTENSIONS.has(ext) || mime === "image/png" || mime === "image/jpeg" || mime === "image/gif" || mime === "image/webp") { + return "image"; + } + if (DOCUMENT_EXTENSIONS.has(ext) || mime === "application/pdf") { + return "document"; + } + return "unsupported"; +} function readImageAttachment2(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -1848,6 +1887,30 @@ function readImageAttachment2(file) { reader.readAsDataURL(file); }); } +function readDocumentAttachment(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.addEventListener("error", () => reject(new Error("读取文档失败"))); + reader.addEventListener("load", () => { + const dataUrl = String(reader.result ?? ""); + const match = dataUrl.match(/^data:([^;,]+);base64,([\s\S]+)$/); + if (!match) { + reject(new Error("文档格式无法作为附件发送")); + return; + } + resolve({ + id: `attachment-${Date.now()}-${Math.random().toString(16).slice(2)}`, + type: "document", + name: file.name || "document", + mimeType: match[1] || file.type || "application/octet-stream", + size: file.size, + data: match[2], + previewUrl: "" + }); + }); + reader.readAsDataURL(file); + }); +} function renderAttachmentStrip2() { if (!els.attachmentStrip) { return; @@ -1857,10 +1920,13 @@ function renderAttachmentStrip2() { for (const attachment of state.attachments) { const item = document.createElement("div"); item.className = "attachment-chip"; + const isDocument = attachment.type === "document"; + const label = attachment.name || (isDocument ? "文档" : "图片"); + const preview = isDocument || !attachment.previewUrl ? `${escapeHtml(documentChipLabel(attachment.name))}` : ``; item.innerHTML = ` - - ${escapeHtml(attachment.name || "图片")} - + ${preview} + ${escapeHtml(label)} + `; item.querySelector("button").addEventListener("click", () => { state.attachments = state.attachments.filter((candidate) => candidate.id !== attachment.id); @@ -1870,9 +1936,13 @@ function renderAttachmentStrip2() { els.attachmentStrip.append(item); } } +function documentChipLabel(name) { + const ext = String(name ?? "").split(".").pop()?.toUpperCase() ?? "FILE"; + return ext.slice(0, 4); +} function attachmentPayload2(attachment) { return { - type: "image", + type: attachment.type === "document" ? "document" : "image", name: attachment.name, mimeType: attachment.mimeType, size: attachment.size, @@ -2373,7 +2443,12 @@ function handleDashboardEvent3(event) { beginEventTurn3(event); updateTurnChangeStats2(null, { reset: true }); state.lastAssistantFinalSignature = ""; - appendMessage3("user", event.queuedKind === "guide" ? "引导" : event.queuedKind === "wakeup" ? "子智能体" : event.queuedKind === "goal-continue" ? "Goal" : "你", userMessageDisplayText3(event.text, event.attachments)); + appendMessage3( + "user", + event.queuedKind === "guide" ? "引导" : event.queuedKind === "wakeup" ? "子智能体" : event.queuedKind === "goal-continue" ? "Goal" : "你", + event.text, + event.attachments + ); state.running = true; scheduleSessionsRefresh2(); if (event.queuedKind === "guide") { @@ -2695,7 +2770,9 @@ function renderTranscriptMessages2(messages, options = {}) { if (!role) { continue; } - const node = createMessageNode3(role, role === "assistant" ? "Ant Code" : "你", messageDisplayText3(message.content)); + const attachments = role === "user" ? transcriptMessageAttachments(message) : []; + const text = role === "assistant" ? messageDisplayText3(message.content) : userTranscriptDisplayText(message.content, attachments); + const node = createMessageNode3(role, role === "assistant" ? "Ant Code" : "你", text, attachments); node.setAttribute("aria-live", "off"); nodes.push(node); } @@ -2939,14 +3016,14 @@ function summarizeWorkflow3(workflow) { cancelled: items.filter((item) => item.status === "cancelled").length }; } -function appendMessage3(kind, label, text) { +function appendMessage3(kind, label, text, attachments = []) { const wasAtBottom = isTranscriptNearBottom3(); - const node = createMessageNode3(kind, label, text); + const node = createMessageNode3(kind, label, text, attachments); appendTranscriptNode3(node); scrollTranscript2({ onlyIfNearBottom: true, wasAtBottom }); if (kind === "assistant") announceStatus("收到新的助手回复"); } -function createMessageNode3(kind, label, text) { +function createMessageNode3(kind, label, text, attachments = []) { hideEmptyState3(); const node = document.createElement("article"); node.className = `message ${kind}`; @@ -2957,9 +3034,49 @@ function createMessageNode3(kind, label, text) { `; const body = node.querySelector(".message-body"); if (kind === "assistant") renderFinalAssistantBody(body, text); - else renderMessageText(body, text ?? "", { markdown: false }); + else { + renderMessageText(body, text ?? "", { markdown: false }); + appendMessageAttachmentChips(body, attachments); + } return node; } +function appendMessageAttachmentChips(body, attachments) { + if (!body) { + return; + } + const items = normalizeAttachmentMetadata3(attachments); + if (items.length === 0) { + return; + } + const row = document.createElement("div"); + row.className = "message-attachments"; + for (const item of items) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "file-link attachment-chip"; + button.dataset.file = resolveAttachedFilePath(item); + const name = String(item.name ?? "file"); + button.textContent = name; + button.title = name; + row.append(button); + } + body.append(row); + bindRichContent3(row); +} +function resolveAttachedFilePath(item) { + const stored = String(item.path ?? "").trim().replace(/\\/g, "/"); + if (stored) { + return stored; + } + const base = String(item.name ?? "").split(/[/\\]/).pop() ?? ""; + const files = Array.isArray(state.files) ? state.files : []; + const match = [...files].reverse().find((file) => { + const relative = String(file.relativePath ?? "").replace(/\\/g, "/"); + const name = String(file.name ?? ""); + return name === base || relative.endsWith(`/${base}`) || relative.endsWith(base); + }); + return match?.relativePath ?? base; +} function appendTranscriptNode3(node, options = {}) { hideEmptyState3(); els.transcript.append(node); @@ -4507,11 +4624,11 @@ function renderModelConfigPanel4() {