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..72c0427 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); @@ -7963,14 +8080,28 @@ async function openFile9(filePath) { showImageLightbox9(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(renderOfficePreview9(file)); } else if (file.kind === "table-preview") { els.previewBody.classList.add("document-preview-body"); els.previewBody.replaceChildren(renderTablePreview9(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 ? "下载文件" : "打开文件"}
`; @@ -7995,6 +8126,47 @@ async function openFile9(filePath) { els.previewBody.innerHTML = `
${escapeHtml(file.content ?? "")}
`; } } +function localOpenButtonHtml(relativePath) { + return ``; +} +function handleLocalFileOpenClick(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); +} +async function openLocalFile(filePath, button = 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) => ({ + 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; + } + } +} function renderOfficePreview9(file) { if (file.table) { return renderTablePreview9(file); @@ -8004,14 +8176,13 @@ function renderOfficePreview9(file) { article.tabIndex = 0; article.setAttribute("aria-label", `${file.name} 轻量预览`); const meta = officePreviewMeta9(file); - const openHref = file.rawUrl ?? rawFileUrl9(file.relativePath); article.innerHTML = `
${escapeHtml(file.name)} ${escapeHtml(meta)}
- 打开 + ${localOpenButtonHtml(file.relativePath)}
${officePreviewBodyHtml9(file)} ${file.truncated ? `
仅显示前 ${formatNumber9(file.content?.length ?? 0)} 字符,完整内容请打开文件。
` : ""} @@ -8037,7 +8208,6 @@ function renderTablePreview9(file) { article.tabIndex = 0; article.setAttribute("aria-label", `${file.name} 表格预览`); const table = normalizeTablePreview9(file.table); - const openHref = file.rawUrl ?? rawFileUrl9(file.relativePath); const meta = tablePreviewMeta9(file, table); article.innerHTML = `
@@ -8047,7 +8217,7 @@ function renderTablePreview9(file) {
- 打开 + ${localOpenButtonHtml(file.relativePath)}
@@ -8643,22 +8813,86 @@ function messageDisplayText3(content) { } return lines.filter(Boolean).join("\n"); } -function userMessageDisplayText3(text, attachments = []) { +function userMessageDisplayText9(text, attachments = []) { const lines = [String(text ?? "").trim()].filter(Boolean); - const imageLines = normalizeAttachmentMetadata9(attachments).map(imageAttachmentLine9); - return [...lines, ...imageLines].join("\n"); + const meta = normalizeAttachmentMetadata3(attachments); + const imageLines = meta.filter((item) => item.type === "image").map(imageAttachmentLine9); + const documentLines = meta.filter((item) => item.type === "document").map(documentAttachmentLine); + return [...lines, ...documentLines, ...imageLines].join("\n"); } -function normalizeAttachmentMetadata9(attachments) { +function normalizeAttachmentMetadata3(attachments) { if (!Array.isArray(attachments)) { return []; } - return attachments.filter((item) => item && typeof item === "object" && item.type === "image").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) + return attachments.filter((item) => item && typeof item === "object" && (item.type === "image" || item.type === "document")).map((item) => ({ + 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() })); } +function transcriptMessageAttachments(message) { + const record = isPlainObject(message) ? message : {}; + const stored = normalizeAttachmentMetadata3(record.attachments); + if (stored.length > 0) { + return stored; + } + return attachmentsFromPlaceholderText(messageDisplayText3(record.content)); +} +function userTranscriptDisplayText(content, attachments = []) { + const chips = normalizeAttachmentMetadata3(attachments); + if (chips.length === 0) { + return messageDisplayText3(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) { + const items = []; + 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) { + return String(text ?? "").split(/\n/).filter((line) => { + const value = line.trim(); + return !value.startsWith("[文档附件:") && !value.startsWith("[图片附件:"); + }).join("\n").trim(); +} +function documentAttachmentLine(item) { + const parts = [ + item.name ? String(item.name) : "document", + Number.isFinite(Number(item.size)) && Number(item.size) > 0 ? formatBytes9(item.size) : "" + ].filter(Boolean); + return `[文档附件:${parts.join(" · ")}]`; +} function imageAttachmentLine9(item) { const parts = [ item.name ? String(item.name) : "image", @@ -8675,12 +8909,12 @@ function renderMessageText(node, text, options = {}) { if (!options.lightweight) { linkifyFileTextNodes9(node, options.basePath); } - bindRichContent9(node, { lightweight: options.lightweight === true }); + bindRichContent3(node, { lightweight: options.lightweight === true }); } function renderLinkedText9(node, text) { node.textContent = text ?? ""; linkifyFileTextNodes9(node); - bindRichContent9(node); + bindRichContent3(node); } // src/dashboard/public/structured-data.ts @@ -9013,7 +9247,7 @@ async function hydrateData(root) { continue; } const result = renderStructuredData(kind, raw, vendor); - output.innerHTML = result.ok ? `
${escapeHtml5(result.summary)}
${result.html}${result.tsv ? `` : ""}` : result.html; + output.innerHTML = result.ok ? `
${escapeHtml5(result.summary)}
${result.html}${result.tsv ? `` : ""}` : result.html; bindDeferredTree(output, result.ok ? result.expandTreeNode : void 0); frame.dataset.rendered = "true"; output.querySelectorAll(".data-copy").forEach((button) => { @@ -9113,12 +9347,12 @@ function loadVendor() { function escapeHtml5(value) { return String(value ?? "").replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); } -function escapeAttribute8(value) { +function escapeAttribute7(value) { return escapeHtml5(value).replace(/`/g, "`"); } // src/dashboard/public/app-ui10.ts -function bindRichContent9(node, options = {}) { +function bindRichContent3(node, options = {}) { node.querySelectorAll("[data-file]").forEach((link) => { if (!(link instanceof HTMLElement)) { return; @@ -9374,13 +9608,17 @@ export { DASHBOARD_LIFECYCLE_TIMEOUT_MS, DASHBOARD_REQUEST_TIMEOUT_MS, DASHBOARD_SHUTDOWN_TIMEOUT_MS, + DOCUMENT_EXTENSIONS, EVENT_CONNECT_TIMEOUT_MS, EVENT_RECONNECT_MAX_ATTEMPTS, EVENT_STALE_AFTER_MS, FILE_REFERENCE_PATTERN, FOCUSABLE_SELECTOR, + IMAGE_EXTENSIONS, LOCAL_FILE_EXTENSIONS, MANUAL_AGENT_MODEL_VALUE, + MAX_DOCUMENT_ATTACHMENTS, + MAX_DOCUMENT_ATTACHMENT_BYTES, MAX_IMAGE_ATTACHMENTS, MAX_IMAGE_ATTACHMENT_BYTES, MODE_DESCRIPTIONS, @@ -9404,6 +9642,7 @@ export { appendAssistantDraft3 as appendAssistantDraft, appendContextBoundary3 as appendContextBoundary, appendMessage3 as appendMessage, + appendMessageAttachmentChips, appendPlainDraftDelta, appendTranscriptNode3 as appendTranscriptNode, applyGatewayDiscoveredModel6 as applyGatewayDiscoveredModel, @@ -9428,7 +9667,7 @@ export { beginPreviewResize, beginScopedRequest, bindEvents, - bindRichContent9 as bindRichContent, + bindRichContent3 as bindRichContent, bindTableLightboxControls9 as bindTableLightboxControls, bootstrapDashboard, bootstrapFailurePresentation9 as bootstrapFailurePresentation, @@ -9442,6 +9681,7 @@ export { captureTranscriptViewportAnchor3 as captureTranscriptViewportAnchor, changedSettingsFields5 as changedSettingsFields, clampedPreviewWidth, + classifyComposerFile, clearAssistantDraftTimers9 as clearAssistantDraftTimers, clearAssistantDrafts3 as clearAssistantDrafts, clearAttachments2 as clearAttachments, @@ -9497,6 +9737,8 @@ export { deleteModel5 as deleteModel, deleteSession2 as deleteSession, disconnectEvents2 as disconnectEvents, + documentAttachmentLine, + documentChipLabel, els, emptyBackgroundSubagent, emptyGoalSnapshot, @@ -9547,6 +9789,7 @@ export { handleBackgroundSubagentActivity3 as handleBackgroundSubagentActivity, handleDashboardEvent3 as handleDashboardEvent, handleGlobalKeydown, + handleLocalFileOpenClick, handleModelConfigChange, handleModelConfigInput, handleModelConfigModelIdChanged5 as handleModelConfigModelIdChanged, @@ -9607,6 +9850,7 @@ export { loadOlderTranscript3 as loadOlderTranscript, loadSessions, loadTrust, + localOpenButtonHtml, localizedReasoningEffortLabel7 as localizedReasoningEffortLabel, lockClosedDashboard8 as lockClosedDashboard, managedFieldHtml5 as managedFieldHtml, @@ -9637,7 +9881,7 @@ export { newTask, nonNegativeInteger4 as nonNegativeInteger, normalizeAgentModelTiers, - normalizeAttachmentMetadata9 as normalizeAttachmentMetadata, + normalizeAttachmentMetadata3 as normalizeAttachmentMetadata, normalizeChangeStats4 as normalizeChangeStats, normalizeComparableText3 as normalizeComparableText, normalizeConfigSource7 as normalizeConfigSource, @@ -9662,6 +9906,7 @@ export { officePreviewBodyHtml9 as officePreviewBodyHtml, officePreviewMeta9 as officePreviewMeta, openFile9 as openFile, + openLocalFile, openSession2 as openSession, parentDirectory9 as parentDirectory, permissionIndexForKey, @@ -9676,6 +9921,7 @@ export { questionChoiceButton8 as questionChoiceButton, questionResolutionText3 as questionResolutionText, rawFileUrl9 as rawFileUrl, + readDocumentAttachment, readImageAttachment2 as readImageAttachment, reasoningCapabilityCandidate6 as reasoningCapabilityCandidate, reasoningCapabilityIsActionable6 as reasoningCapabilityIsActionable, @@ -9748,6 +9994,7 @@ export { resizePromptInput, resolveApproval2 as resolveApproval, resolveAtomicModelSelection7 as resolveAtomicModelSelection, + resolveAttachedFilePath, resolveDisplayFilePath10 as resolveDisplayFilePath, responseJson9 as responseJson, responsiveLayoutMode, @@ -9845,6 +10092,7 @@ export { toggleQuestionChoice8 as toggleQuestionChoice, toggleSidebar, transcriptFirstContentNode3 as transcriptFirstContentNode, + transcriptMessageAttachments, transcriptNodeTop3 as transcriptNodeTop, transcriptRetentionOptionsHtml5 as transcriptRetentionOptionsHtml, transcriptSettingsHtml5 as transcriptSettingsHtml, @@ -9864,7 +10112,8 @@ export { updateSessionStatus, updateTranscriptJump9 as updateTranscriptJump, updateTurnChangeStats2 as updateTurnChangeStats, - userMessageDisplayText3 as userMessageDisplayText, + userMessageDisplayText9 as userMessageDisplayText, + userTranscriptDisplayText, workflowItem3 as workflowItem, workflowSection3 as workflowSection }; diff --git a/src/dashboard/public/index.html b/src/dashboard/public/index.html index ea03a7b..1b9a7a7 100644 --- a/src/dashboard/public/index.html +++ b/src/dashboard/public/index.html @@ -5,7 +5,7 @@ Ant Code Dashboard - + @@ -163,8 +163,8 @@

设置

按 Enter 发送,按 Shift 加 Enter 换行 - -
- + diff --git a/src/dashboard/public/styles.css b/src/dashboard/public/styles.css index 4249643..017287a 100644 --- a/src/dashboard/public/styles.css +++ b/src/dashboard/public/styles.css @@ -3065,7 +3065,8 @@ button { padding: 4px; } -.attachment-chip img { +.attachment-chip img, +.attachment-chip-file { aspect-ratio: 1; border-radius: 6px; height: 28px; @@ -3073,6 +3074,18 @@ button { width: 28px; } +.attachment-chip-file { + align-items: center; + background: rgba(255, 255, 255, 0.06); + color: var(--text-muted); + display: flex; + font-size: 10px; + font-weight: 700; + justify-content: center; + letter-spacing: 0.02em; + object-fit: unset; +} + .attachment-chip span { font-size: var(--fs-ui); overflow: hidden; @@ -3118,6 +3131,29 @@ button { color: var(--text); } +.message-attachments { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; +} + +.message-attachments .attachment-chip { + align-items: center; + cursor: pointer; + display: inline-flex; + grid-template-columns: none; + max-width: min(100%, 280px); + min-height: 32px; + overflow: hidden; + overflow-wrap: normal; + padding: 6px 10px; + text-decoration: none; + text-overflow: ellipsis; + white-space: nowrap; + word-break: normal; +} + .composer-footer { align-items: center; color: var(--text-muted); @@ -4366,6 +4402,19 @@ textarea:focus { height: 70vh; } +.pdf-preview { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + height: 100%; + min-height: 0; + width: 100%; +} + +.pdf-preview .preview-frame { + height: 100%; + min-height: 70vh; +} + .preview-code { color: var(--text-soft); flex: 1; @@ -4674,9 +4723,14 @@ textarea:focus { } .open-file { + background: transparent; + border: 0; color: var(--accent-2); + cursor: pointer; display: inline-flex; + font: inherit; margin-top: 8px; + padding: 0; text-decoration: none; } diff --git a/src/dashboard/runtime/turn-queue.ts b/src/dashboard/runtime/turn-queue.ts index 59410a1..60df363 100644 --- a/src/dashboard/runtime/turn-queue.ts +++ b/src/dashboard/runtime/turn-queue.ts @@ -71,10 +71,13 @@ import { ActiveSessionCapacityError, ActiveSessionMap, DASHBOARD_ACTIVE_SESSION_DEFAULTS, + MAX_DOCUMENT_BYTES, MAX_IMAGE_BYTES, MAX_PROMPT_BYTES, MAX_QUEUE, MAX_TOTAL_IMAGE_BYTES, + MAX_TURN_ATTACHMENTS, + MAX_TURN_DOCUMENTS, MAX_TURN_IMAGES, MAX_TURN_REQUESTS, TURN_REQUEST_TTL_MS @@ -105,6 +108,12 @@ import { queueBackgroundWakePrompt, scheduleBackgroundSubagentSnapshot } from "./background.ts"; +import { + isAllowedDocumentExtension, + extensionOfName, + mimeTypeForDocumentExt, + storeComposerAttachments +} from "../../tools/composer-documents.ts"; import { dropGoalContinueItems, emitGoalState, @@ -216,29 +225,65 @@ export function validateTurnSubmission(input: DashboardRequestInput = {}): TurnS if (!Array.isArray(source)) { return { ok: false, status: 400, code: "INVALID_ATTACHMENTS", error: "attachments 必须是数组" }; } - if (source.length > MAX_TURN_IMAGES) { - return { ok: false, status: 400, code: "TOO_MANY_IMAGES", error: "每次任务最多上传 6 张图片" }; + if (source.length > MAX_TURN_ATTACHMENTS) { + return { ok: false, status: 400, code: "TOO_MANY_ATTACHMENTS", error: "每次任务最多上传 10 个附件" }; } const attachments = []; - let totalBytes = 0; + let imageBytes = 0; + let imageCount = 0; + let documentCount = 0; for (const item of source) { - if (!isPlainObject(item) || item.type !== "image") { - return { ok: false, status: 400, code: "INVALID_IMAGE", error: "附件只允许图片" }; - } - const mimeType = String(item.mimeType ?? item.mime_type ?? "").trim().toLowerCase(); - if (!ALLOWED_IMAGE_MIME_TYPES.has(mimeType)) { - return { ok: false, status: 400, code: "UNSUPPORTED_IMAGE_TYPE", error: "图片只支持 PNG、JPEG、GIF 或 WebP" }; + if (!isPlainObject(item)) { + return { ok: false, status: 400, code: "INVALID_IMAGE", error: "附件只允许图片或办公文档" }; } + const type = String(item.type ?? "image").trim().toLowerCase(); const data = String(item.data ?? ""); if (!isCanonicalBase64(data)) { - return { ok: false, status: 400, code: "INVALID_IMAGE_BASE64", error: "图片内容不是有效的 base64" }; + return { ok: false, status: 400, code: type === "document" ? "INVALID_DOCUMENT_BASE64" : "INVALID_IMAGE_BASE64", error: type === "document" ? "文档内容不是有效的 base64" : "图片内容不是有效的 base64" }; } const decoded = Buffer.from(data, "base64"); + const name = String(item.name ?? (type === "document" ? "document" : "image")).trim().slice(0, 160) + || (type === "document" ? "document" : "image"); + if (type === "document") { + documentCount += 1; + if (documentCount > MAX_TURN_DOCUMENTS) { + return { ok: false, status: 400, code: "TOO_MANY_DOCUMENTS", error: "每次任务最多上传 4 个文档" }; + } + const ext = extensionOfName(name); + if (!isAllowedDocumentExtension(ext)) { + return { ok: false, status: 400, code: "UNSUPPORTED_DOCUMENT_TYPE", error: "文档只支持 PDF、docx、xlsx、pptx 和常见文本" }; + } + if (decoded.length > MAX_DOCUMENT_BYTES) { + return { ok: false, status: 413, code: "DOCUMENT_TOO_LARGE", error: "单个文档不能超过 40 MiB" }; + } + if (!matchesDocumentSignature(decoded, ext)) { + return { ok: false, status: 400, code: "DOCUMENT_SIGNATURE_MISMATCH", error: "文档内容与文件名类型不匹配" }; + } + attachments.push({ + type: "document", + data, + mimeType: mimeTypeForDocumentExt(ext), + name, + size: decoded.length + }); + continue; + } + if (type !== "image") { + return { ok: false, status: 400, code: "INVALID_IMAGE", error: "附件只允许图片或办公文档" }; + } + imageCount += 1; + if (imageCount > MAX_TURN_IMAGES) { + return { ok: false, status: 400, code: "TOO_MANY_IMAGES", error: "每次任务最多上传 6 张图片" }; + } + const mimeType = String(item.mimeType ?? item.mime_type ?? "").trim().toLowerCase(); + if (!ALLOWED_IMAGE_MIME_TYPES.has(mimeType)) { + return { ok: false, status: 400, code: "UNSUPPORTED_IMAGE_TYPE", error: "图片只支持 PNG、JPEG、GIF 或 WebP" }; + } if (decoded.length > MAX_IMAGE_BYTES) { return { ok: false, status: 413, code: "IMAGE_TOO_LARGE", error: "单张图片不能超过 8 MiB" }; } - totalBytes += decoded.length; - if (totalBytes > MAX_TOTAL_IMAGE_BYTES) { + imageBytes += decoded.length; + if (imageBytes > MAX_TOTAL_IMAGE_BYTES) { return { ok: false, status: 413, code: "IMAGES_TOO_LARGE", error: "图片总量不能超过 24 MiB" }; } if (!matchesImageSignature(decoded, mimeType)) { @@ -248,13 +293,32 @@ export function validateTurnSubmission(input: DashboardRequestInput = {}): TurnS type: "image", data, mimeType, - name: String(item.name ?? "image").trim().slice(0, 160) || "image", + name, size: decoded.length }); } return { ok: true, prompt, attachments }; } +export function matchesDocumentSignature(buffer: Buffer, ext: string) { + if (ext === ".pdf") { + return buffer.length >= 5 && buffer.subarray(0, 5).toString("ascii") === "%PDF-"; + } + if (ext === ".docx" || ext === ".xlsx" || ext === ".pptx") { + return buffer.length >= 2 && buffer[0] === 0x50 && buffer[1] === 0x4b; + } + if (buffer.includes(0)) { + return false; + } + return buffer.length > 0; +} + +function imageAttachmentBytes(attachments: Array<{ type?: string; size?: number }> = []) { + return attachments.reduce((total, attachment) => ( + String(attachment.type ?? "image") === "image" ? total + nonNegativeInteger(attachment.size) : total + ), 0); +} + export function isCanonicalBase64(value: unknown): value is string { if (typeof value !== "string" || !value || value.length % 4 !== 0 || /\s/.test(value)) { @@ -378,7 +442,8 @@ export async function startDashboardTurn(context: DashboardRuntimeContext, input if (!validated.ok) { return validated; } - const { prompt, attachments } = validated; + const prompt = validated.prompt; + const attachments = await storeComposerAttachments(context.cwd, validated.attachments); if (!prompt && attachments.length === 0) { return { ok: false, status: 400, error: "请输入任务需求" }; } @@ -485,9 +550,9 @@ export async function startDashboardTurn(context: DashboardRuntimeContext, input if (state.running) { const queuedAttachmentBytes = state.queuedPrompts.reduce((total, queued) => ( - total + (queued.attachments ?? []).reduce((sum, attachment) => sum + nonNegativeInteger(attachment.size), 0) + total + imageAttachmentBytes(queued.attachments ?? []) ), 0); - const newAttachmentBytes = attachments.reduce((total, attachment) => total + nonNegativeInteger(attachment.size), 0); + const newAttachmentBytes = imageAttachmentBytes(attachments); if (state.currentAttachmentBytes + queuedAttachmentBytes + newAttachmentBytes > MAX_TOTAL_IMAGE_BYTES) { return { ok: false, @@ -787,7 +852,7 @@ export function beginPrompt(state: DashboardActiveSessionState, item: DashboardQ state.status = "running"; state.currentPrompt = String(item.prompt ?? ""); state.currentTurnId = eventId("turn"); - state.currentAttachmentBytes = (item.attachments ?? []).reduce((total, attachment) => total + nonNegativeInteger(attachment.size), 0); + state.currentAttachmentBytes = imageAttachmentBytes(item.attachments ?? []); state.currentTranscriptStart = activeTranscriptMessages(state).length; state.currentPermissionMode = item.permissionMode ?? "plan"; state.turnChangeStats = emptyChangeStats(); @@ -1118,7 +1183,7 @@ export function publicQueueItem(item: DashboardQueueItem) { kind: item.kind, preview: previewText([ item.title || (item.kind === "guide" ? item.guidance : item.kind === GOAL_CONTINUE_KIND ? "Goal 续跑" : item.prompt), - attachments.length > 0 ? `${attachments.length} 张图片` : "" + attachments.length > 0 ? `${attachments.length} 个附件` : "" ].filter(Boolean).join(" · ")), attachments, permissionMode: item.permissionMode, @@ -1156,35 +1221,61 @@ export function normalizeTurnAttachments(value: unknown): DashboardTurnAttachmen return value .map(normalizeTurnAttachment) .filter((item): item is DashboardTurnAttachment => item != null) - .slice(0, 6); + .slice(0, MAX_TURN_ATTACHMENTS); } export function normalizeTurnAttachment(item: unknown): DashboardTurnAttachment | null { - if (!isPlainObject(item) || item.type !== "image") { + if (!isPlainObject(item)) { return null; } + const type = String(item.type ?? "image").trim().toLowerCase(); const data = String(item.data ?? "").replace(/\s+/g, ""); + if (!data) { + return null; + } + if (type === "document") { + const name = String(item.name ?? "document").trim().slice(0, 160) || "document"; + const ext = extensionOfName(name); + if (!isAllowedDocumentExtension(ext)) { + return null; + } + const storedPath = String(item.path ?? "").trim().replace(/\\/g, "/"); + return { + type: "document", + data, + mimeType: mimeTypeForDocumentExt(ext), + name, + size: nonNegativeInteger(item.size ?? item.bytes ?? item.sizeBytes), + ...(storedPath ? { path: storedPath } : {}) + }; + } + if (type !== "image") { + return null; + } const mimeType = String(item.mimeType ?? item.mime_type ?? "").trim().toLowerCase(); - if (!data || !/^image\/[a-z0-9.+-]+$/i.test(mimeType)) { + if (!/^image\/[a-z0-9.+-]+$/i.test(mimeType)) { return null; } + const storedPath = String(item.path ?? "").trim().replace(/\\/g, "/"); return { type: "image", data, mimeType, name: String(item.name ?? "image").trim().slice(0, 160), - size: nonNegativeInteger(item.size ?? item.bytes ?? item.sizeBytes) + size: nonNegativeInteger(item.size ?? item.bytes ?? item.sizeBytes), + ...(storedPath ? { path: storedPath } : {}) }; } export function publicAttachments(attachments: unknown) { return normalizeTurnAttachments(attachments).map((item) => ({ - type: "image", + type: item.type === "document" ? "document" : "image", name: item.name, mimeType: item.mimeType, - size: item.size + size: item.size, + ...(item.path ? { path: item.path } : {}) })); } diff --git a/src/dashboard/runtime/types.ts b/src/dashboard/runtime/types.ts index 0ce94d8..b6c68e6 100644 --- a/src/dashboard/runtime/types.ts +++ b/src/dashboard/runtime/types.ts @@ -83,8 +83,11 @@ export const TURN_REQUEST_TTL_MS = 5 * 60 * 1000; export const MAX_TURN_REQUESTS = 1_000; export const MAX_PROMPT_BYTES = 256 * 1024; export const MAX_TURN_IMAGES = 6; +export const MAX_TURN_DOCUMENTS = 4; +export const MAX_TURN_ATTACHMENTS = 10; export const MAX_IMAGE_BYTES = 8 * 1024 * 1024; export const MAX_TOTAL_IMAGE_BYTES = 24 * 1024 * 1024; +export const MAX_DOCUMENT_BYTES = 40 * 1024 * 1024; export const RETENTION_MAINTENANCE_INTERVAL_MS = 24 * 60 * 60 * 1000; export const SENSITIVE_GATEWAY_QUERY_KEYS = new Set(["access_token", "api_key", "key", "token", "authorization"]); export const INVALID_REASONING_EFFORT_PROBE = "antcode_invalid_effort_probe"; diff --git a/src/dashboard/server.ts b/src/dashboard/server.ts index a0ff50e..c18a2c1 100644 --- a/src/dashboard/server.ts +++ b/src/dashboard/server.ts @@ -6,7 +6,7 @@ import type { Socket } from "node:net"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { createDashboardRuntime } from "./sessions.ts"; -import { previewFile, readRawFile } from "./files.ts"; +import { previewFile, readRawFile, resolveSystemOpenFile } from "./files.ts"; type DashboardRuntime = ReturnType; type JsonObject = Record; @@ -18,6 +18,7 @@ type DashboardServerOptions = { publicDir?: string; onShutdown?: () => void; requestShutdown?: () => void; + openLocalPath?: (filePath: string) => void | Promise; }; const DEFAULT_HOST = "127.0.0.1"; @@ -53,6 +54,18 @@ const CONTENT_SECURITY_POLICY = [ "connect-src 'self'" ].join("; "); +// PDF iframe preview needs the framed resource to allow same-origin embedding. +// Chrome's built-in viewer also treats the plugin as an object, so object-src +// must not stay at 'none' on the PDF response itself. +const EMBEDDABLE_PDF_CONTENT_SECURITY_POLICY = [ + "default-src 'none'", + "base-uri 'none'", + "object-src 'self'", + "frame-ancestors 'self'", + "form-action 'none'", + "script-src 'none'" +].join("; "); + /** * @param {{ cwd: string; env?: NodeJS.ProcessEnv; packageRoot?: string; host?: string; port?: number; open?: boolean; project?: string | null }} options */ @@ -345,6 +358,17 @@ function applySecurityHeaders(res: http.ServerResponse) { res.setHeader("referrer-policy", "no-referrer"); } +function applyEmbeddableRawFileHeaders( + res: http.ServerResponse, + result: { contentType?: unknown; downloadOnly?: unknown } +) { + if (result.downloadOnly || result.contentType !== "application/pdf") { + return; + } + res.setHeader("content-security-policy", EMBEDDABLE_PDF_CONTENT_SECURITY_POLICY); + res.setHeader("x-frame-options", "SAMEORIGIN"); +} + class RequestError extends Error { status: number; code: string; @@ -698,6 +722,24 @@ async function routeRequest(req: http.IncomingMessage, res: http.ServerResponse, const result = options.runtime.resolveQuestion(questionId, body); return sendJson(res, responseStatus(result, 200, 400), result); } + if (req.method === "POST" && url.pathname === "/api/files/open") { + const body = isJsonObject(requestBody) ? requestBody : EMPTY_JSON; + const sessionId = String(body.sessionId ?? ""); + const cwd = await resolveFileRequestCwd(options, sessionId); + if (!cwd.ok || !("cwd" in cwd) || typeof cwd.cwd !== "string") { + return sendJson(res, "status" in cwd && typeof cwd.status === "number" ? cwd.status : 400, cwd); + } + const result = await resolveSystemOpenFile(cwd.cwd, String(body.path ?? "")); + if (!result.ok || !("path" in result) || typeof result.path !== "string") { + return sendJson(res, "status" in result && typeof result.status === "number" ? result.status : 400, result); + } + try { + await launchLocalPath(options, result.path); + } catch { + return sendJson(res, 500, { ok: false, error: "无法用系统应用打开文件" }); + } + return sendJson(res, 200, { ok: true }); + } if (req.method === "GET" && url.pathname === "/api/files") { const sessionId = url.searchParams.get("sessionId") ?? ""; const cwd = await resolveFileRequestCwd(options, sessionId); @@ -716,6 +758,7 @@ async function routeRequest(req: http.IncomingMessage, res: http.ServerResponse, if (!result.ok || !("contentType" in result) || !("bytes" in result)) { return sendJson(res, "status" in result && typeof result.status === "number" ? result.status : 400, result); } + applyEmbeddableRawFileHeaders(res, result); res.writeHead(200, { "content-type": result.contentType, "content-disposition": contentDispositionHeader( @@ -1117,6 +1160,52 @@ function isJsonObject(value: unknown): value is JsonObject { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } +async function launchLocalPath(options: DashboardServerOptions, filePath: string) { + if (typeof options.openLocalPath === "function") { + await options.openLocalPath(filePath); + return; + } + await openPathWithSystem(filePath); +} + +function openPathWithSystem(filePath: string) { + return new Promise((resolve, reject) => { + const platform = process.platform; + const child = platform === "win32" + ? spawn("powershell.exe", [ + "-NoProfile", + "-NonInteractive", + "-WindowStyle", "Hidden", + "-Command", + "Start-Process -FilePath $env:ANT_CODE_OPEN_PATH" + ], { + env: { ...process.env, ANT_CODE_OPEN_PATH: filePath }, + stdio: "ignore", + windowsHide: true + }) + : spawn(platform === "darwin" ? "open" : "xdg-open", [filePath], { + detached: true, + stdio: "ignore" + }); + const timer = setTimeout(() => { + child.unref(); + resolve(); + }, 8000); + child.once("error", (error) => { + clearTimeout(timer); + reject(error); + }); + child.once("close", (code) => { + clearTimeout(timer); + if (code) { + reject(new Error("系统应用打开失败")); + return; + } + resolve(); + }); + }); +} + function openBrowser(url: string) { const platform = process.platform; let child; diff --git a/src/tools/composer-documents.ts b/src/tools/composer-documents.ts new file mode 100644 index 0000000..909a69f --- /dev/null +++ b/src/tools/composer-documents.ts @@ -0,0 +1,311 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { parseDocumentBufferAsync } from "./document-tools.ts"; +import { encodePng } from "./png-encode.ts"; +import { extractImages, getDocumentProxy } from "unpdf"; + +export const COMPOSER_UPLOAD_DIR = "ant-code-uploads"; +export const MAX_DOCUMENT_ATTACHMENTS = 4; +export const MAX_DOCUMENT_BYTES = 40 * 1024 * 1024; +export const MAX_VISION_PDF_PAGES = 2; +export const PDF_SPARSE_TEXT_CHARS = 40; +export const COMPOSER_DOCUMENT_PREVIEW_CHARS = 4000; +export const MAX_VISION_PAGE_EDGE = 1280; +export const MAX_VISION_PAGE_PNG_BYTES = 1_500_000; + +export const ALLOWED_DOCUMENT_EXTENSIONS = new Set([ + ".pdf", ".docx", ".xlsx", ".pptx", ".txt", ".md", ".markdown", ".csv", ".json", ".html", ".htm" +]); + +const DOCUMENT_MIME_BY_EXT: Record = { + ".pdf": "application/pdf", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ".txt": "text/plain", + ".md": "text/markdown", + ".markdown": "text/markdown", + ".csv": "text/csv", + ".json": "application/json", + ".html": "text/html", + ".htm": "text/html" +}; + +type ComposerImage = { + type: "image"; + name: string; + mimeType: string; + size: number; + data: string; +}; + +type ComposerDocument = { + type: "document"; + name: string; + mimeType: string; + size: number; + data: string; + ext: string; + path?: string; +}; + +export function extensionOfName(name: unknown) { + const base = path.extname(String(name ?? "")).toLowerCase(); + return base; +} + +export function isAllowedDocumentExtension(ext: string) { + return ALLOWED_DOCUMENT_EXTENSIONS.has(ext); +} + +export function mimeTypeForDocumentExt(ext: string) { + return DOCUMENT_MIME_BY_EXT[ext] ?? "application/octet-stream"; +} + +export function normalizeComposerDocuments(value: unknown): ComposerDocument[] { + if (!Array.isArray(value)) { + return []; + } + const documents: ComposerDocument[] = []; + for (const item of value) { + if (!item || typeof item !== "object" || !("type" in item) || item.type !== "document") { + continue; + } + const record = item as Record; + const name = String(record.name ?? "document").trim().slice(0, 160) || "document"; + const ext = extensionOfName(name); + if (!isAllowedDocumentExtension(ext)) { + continue; + } + const data = String(record.data ?? "").replace(/\s+/g, ""); + if (!data) { + continue; + } + documents.push({ + type: "document", + name, + mimeType: mimeTypeForDocumentExt(ext), + size: Number(record.size ?? 0) || 0, + data, + ext, + path: String(record.path ?? "").trim() || undefined + }); + if (documents.length >= MAX_DOCUMENT_ATTACHMENTS) { + break; + } + } + return documents; +} + +export async function storeComposerAttachments( + cwd: string, + attachments: T[] = [] +): Promise> { + if (!Array.isArray(attachments) || attachments.length === 0) { + return []; + } + const uploadRoot = path.join(path.resolve(cwd), COMPOSER_UPLOAD_DIR); + await fs.mkdir(uploadRoot, { recursive: true }); + await ensureComposerUploadIgnore(cwd).catch(() => undefined); + const stored: Array = []; + for (const item of attachments) { + if (item?.path) { + stored.push(item); + continue; + } + const data = String(item?.data ?? "").replace(/\s+/g, ""); + if (!data) { + stored.push(item); + continue; + } + const savedName = uniqueUploadName(String(item.name ?? (item.type === "document" ? "document" : "image"))); + const relativePath = `${COMPOSER_UPLOAD_DIR}/${savedName}`.replace(/\\/g, "/"); + await fs.writeFile(path.join(uploadRoot, savedName), Buffer.from(data, "base64")); + stored.push({ ...item, path: relativePath }); + } + return stored; +} + +export async function ingestComposerDocuments(input: { + cwd: string; + attachments?: unknown; + existingImageCount?: number; +}): Promise<{ promptAppendix: string; visionImages: ComposerImage[] }> { + const documents = normalizeComposerDocuments(input.attachments); + if (documents.length === 0) { + return { promptAppendix: "", visionImages: [] }; + } + const uploadRoot = path.join(path.resolve(input.cwd), COMPOSER_UPLOAD_DIR); + await fs.mkdir(uploadRoot, { recursive: true }); + await ensureComposerUploadIgnore(input.cwd).catch(() => undefined); + const sections: string[] = [ + "Composer attached the following files. They are already saved. Do not search the whole workspace to find them. Use document_intake or read_file on the saved paths if you need more than the preview." + ]; + const visionImages: ComposerImage[] = []; + const existingImages = Math.max(0, Number(input.existingImageCount) || 0); + const remainingVisionSlots = Math.max(0, 6 - existingImages); + + for (const document of documents) { + const relativePath = typeof document.path === "string" && document.path.trim() + ? document.path.replace(/\\/g, "/") + : `${COMPOSER_UPLOAD_DIR}/${uniqueUploadName(document.name)}`; + const savedAbs = path.resolve(input.cwd, relativePath); + const buffer = document.path + ? await fs.readFile(savedAbs) + : Buffer.from(document.data, "base64"); + if (!document.path) { + await fs.writeFile(savedAbs, buffer); + } + const parsed = await parseDocumentBufferAsync(buffer, document.ext); + const sparsePdf = document.ext === ".pdf" && parsed.content.replace(/\s+/g, "").length < PDF_SPARSE_TEXT_CHARS; + if (sparsePdf && remainingVisionSlots - visionImages.length > 0) { + const pageBudget = Math.min(MAX_VISION_PDF_PAGES, remainingVisionSlots - visionImages.length, existingImages > 0 ? 1 : MAX_VISION_PDF_PAGES); + const pages = await pdfEmbeddedImagesAsPng(buffer, pageBudget); + visionImages.push(...pages.images.slice(0, pageBudget)); + parsed.notes.push(...pages.notes); + } + sections.push(formatDocumentSection(relativePath, document.name, parsed.content, parsed.notes, parsed.supported)); + } + + return { + promptAppendix: sections.join("\n\n"), + visionImages: visionImages.slice(0, remainingVisionSlots) + }; +} + +export async function pdfEmbeddedImagesAsPng(buffer: Buffer, maxPages: number) { + const notes: string[] = []; + const images: ComposerImage[] = []; + try { + const pdf = await getDocumentProxy(new Uint8Array(buffer)); + const pageCount = Number(pdf.numPages) || 0; + const limit = Math.min(Math.max(1, maxPages), pageCount, MAX_VISION_PDF_PAGES); + for (let page = 1; page <= limit; page += 1) { + const extracted = await extractImages(pdf, page); + const best = pickLargestImage(extracted); + if (!best || !best.data || !best.width || !best.height) { + continue; + } + const channels = best.channels === 1 || best.channels === 3 || best.channels === 4 ? best.channels : 4; + const scaled = downscaleRawImage(best.width, best.height, channels, best.data, MAX_VISION_PAGE_EDGE); + const png = encodePng(scaled.width, scaled.height, channels, scaled.data); + if (png.length > MAX_VISION_PAGE_PNG_BYTES) { + notes.push(`Page ${page} image was still larger than ${MAX_VISION_PAGE_PNG_BYTES} bytes after downscale and was skipped.`); + continue; + } + images.push({ + type: "image", + name: `pdf-page-${page}.png`, + mimeType: "image/png", + size: png.length, + data: png.toString("base64") + }); + } + if (images.length > 0) { + notes.push(`No usable PDF text layer; sent ${images.length} embedded page image(s) to the vision model (pages 1-${images.length}).`); + } else { + notes.push("No usable PDF text layer and no embedded page images. Vision was not given raster pages; OCR is not bundled."); + } + } catch (error) { + notes.push(`PDF page-image extraction failed: ${error instanceof Error ? error.message : String(error)}`); + } + return { images, notes }; +} + +function pickLargestImage(images: Array<{ width?: number; height?: number; data?: Uint8ClampedArray; channels?: number }>) { + return [...images].sort((left, right) => { + const leftArea = Number(left.width ?? 0) * Number(left.height ?? 0); + const rightArea = Number(right.width ?? 0) * Number(right.height ?? 0); + return rightArea - leftArea; + })[0]; +} + +export async function ensureComposerUploadIgnore(cwd: string) { + const root = path.resolve(cwd); + const uploadDir = path.join(root, COMPOSER_UPLOAD_DIR); + await fs.mkdir(uploadDir, { recursive: true }); + await writeFileIfMissing(path.join(uploadDir, ".gitignore"), "*\n!.gitignore\n"); + await ensureGitignoreEntry(path.join(root, ".gitignore"), "ant-code-uploads/"); +} + +async function writeFileIfMissing(filePath: string, content: string) { + try { + await fs.writeFile(filePath, content, { encoding: "utf8", flag: "wx" }); + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "EEXIST") { + return; + } + throw error; + } +} + +async function ensureGitignoreEntry(filePath: string, entry: string) { + let current = ""; + try { + current = await fs.readFile(filePath, "utf8"); + } catch (error) { + if (!(error && typeof error === "object" && "code" in error && error.code === "ENOENT")) { + throw error; + } + } + if (gitignoreHasEntry(current, entry)) { + return; + } + const prefix = current.length === 0 || current.endsWith("\n") ? "" : "\n"; + const block = `${prefix}${current.length === 0 ? "" : "\n"}# Ant Code composer uploads\n${entry}\n`; + await fs.appendFile(filePath, block, "utf8"); +} + +function gitignoreHasEntry(text: string, entry: string) { + const normalized = entry.replace(/\/$/, ""); + const variants = new Set([normalized, `${normalized}/`, `/${normalized}`, `/${normalized}/`]); + return String(text ?? "").split(/\r?\n/).some((line) => variants.has(line.trim())); +} + +function uniqueUploadName(name: string) { + const ext = extensionOfName(name); + const stem = path.basename(name, ext).replace(/[^\w.\u4e00-\u9fff-]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 80) || "document"; + return `${Date.now()}-${stem}${ext}`; +} + +function downscaleRawImage( + width: number, + height: number, + channels: 1 | 3 | 4, + data: Uint8Array | Uint8ClampedArray | Buffer, + maxEdge: number +) { + const edge = Math.max(width, height); + if (edge <= maxEdge) { + return { width, height, data }; + } + const scale = maxEdge / edge; + const nextWidth = Math.max(1, Math.round(width * scale)); + const nextHeight = Math.max(1, Math.round(height * scale)); + const next = new Uint8Array(nextWidth * nextHeight * channels); + for (let y = 0; y < nextHeight; y += 1) { + const sourceY = Math.min(height - 1, Math.floor(y / scale)); + for (let x = 0; x < nextWidth; x += 1) { + const sourceX = Math.min(width - 1, Math.floor(x / scale)); + const sourceIndex = (sourceY * width + sourceX) * channels; + const destIndex = (y * nextWidth + x) * channels; + next.set(data.subarray(sourceIndex, sourceIndex + channels), destIndex); + } + } + return { width: nextWidth, height: nextHeight, data: next }; +} + +function formatDocumentSection(relativePath: string, originalName: string, content: string, notes: string[], supported: boolean) { + const body = String(content ?? "").trim(); + const truncated = body.length > COMPOSER_DOCUMENT_PREVIEW_CHARS; + const preview = truncated ? `${body.slice(0, COMPOSER_DOCUMENT_PREVIEW_CHARS).trimEnd()}\n\n[preview truncated; read ${relativePath} for the rest]` : body; + const header = [ + `### Attached document: ${originalName}`, + `Saved to ${relativePath}. Do not glob the workspace to rediscover this file.`, + `Use document_intake or read_file on ${relativePath} if you need more than this preview.`, + supported ? null : "Extraction reported this type as unsupported.", + truncated ? `Preview is first ${COMPOSER_DOCUMENT_PREVIEW_CHARS} characters of extracted text.` : null, + ...notes.map((note) => `Note: ${note}`) + ].filter(Boolean).join("\n"); + return preview ? `${header}\n\n${preview}` : header; +} diff --git a/src/tools/definitions.ts b/src/tools/definitions.ts index d479ecb..7f31260 100644 --- a/src/tools/definitions.ts +++ b/src/tools/definitions.ts @@ -538,7 +538,7 @@ export const BUILT_IN_TOOLS = Object.freeze([ }, { name: "document_intake", - description: "Extract local text or Markdown from txt/md/html/xml/json/csv and lightweight Office documents inside the workspace, or an approved/full-access local path. Returns full extracted content unless maxBytes is explicitly supplied.", + description: "Extract local text from txt/md/html/xml/json/csv, lightweight Office (docx/pptx/xlsx), and the text layer of PDF files inside the workspace, or an approved/full-access local path. PDFs default to a 20-page window; pass pageStart/maxPages to continue. Does not OCR scanned pages. Returns extracted content unless maxBytes is explicitly supplied.", risk: "document", supportsAbort: false, inputSchema: { @@ -546,7 +546,9 @@ export const BUILT_IN_TOOLS = Object.freeze([ required: ["path"], properties: { path: { type: "string" }, - maxBytes: { type: "number" } + maxBytes: { type: "number" }, + pageStart: { type: "number" }, + maxPages: { type: "number" } } } }, diff --git a/src/tools/document-tools.ts b/src/tools/document-tools.ts index 2510248..6c9888f 100644 --- a/src/tools/document-tools.ts +++ b/src/tools/document-tools.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import zlib from "node:zlib"; import { Worker } from "node:worker_threads"; +import { extractText, getDocumentProxy } from "unpdf"; import { isInside } from "../permissions/policy-engine.ts"; import { normalizeToolPath } from "../permissions/path-utils.ts"; import { htmlToMarkdown } from "./web-tools.ts"; @@ -15,6 +16,12 @@ const OFFICE_ZIP_LIMITS = Object.freeze({ timeoutMs: 3000 }); +const PDF_LIMITS = Object.freeze({ + maxBytes: 40 * 1024 * 1024, + defaultMaxPages: 20, + maxPagesCap: 50 +}); + type OfficeZipLimits = { maxArchiveBytes: number; maxEntries: number; @@ -36,6 +43,10 @@ type DocumentParseResult = { truncatedRows: boolean; truncatedColumns: boolean; }>; + pageStart?: number; + pageEnd?: number; + totalPages?: number; + truncated?: boolean; }; type ZipLocalEntry = { @@ -57,6 +68,8 @@ export async function documentIntakeTool(input: { cwd: string; path?: unknown; maxBytes?: unknown; + pageStart?: unknown; + maxPages?: unknown; policy?: Record; }) { const workspace = path.resolve(input.cwd); @@ -76,7 +89,9 @@ export async function documentIntakeTool(input: { const configuredMaxBytes = positiveIntegerOrNull(input.maxBytes); const maxBytes = configuredMaxBytes ?? ([".docx", ".pptx", ".xlsx"].includes(ext) ? OFFICE_ZIP_LIMITS.maxArchiveBytes - : null); + : ext === ".pdf" + ? PDF_LIMITS.maxBytes + : null); if (maxBytes && stat.size > maxBytes) { return { path: toDisplayPath(workspace, target), @@ -88,15 +103,22 @@ export async function documentIntakeTool(input: { } const buffer = await fs.readFile(target); - const parsed = await parseDocumentBufferAsync(buffer, ext); + const parsed = await parseDocumentBufferAsync(buffer, ext, { + pageStart: input.pageStart, + maxPages: input.maxPages + }); return { path: toDisplayPath(workspace, target), kind: parsed.kind, supported: parsed.supported, bytes: stat.size, content: parsed.content, - contentTruncated: false, - notes: parsed.notes + contentTruncated: parsed.truncated === true, + notes: parsed.notes, + pageStart: parsed.pageStart, + pageEnd: parsed.pageEnd, + totalPages: parsed.totalPages, + truncated: parsed.truncated }; } @@ -141,10 +163,7 @@ export function parseDocumentBuffer(buffer: Buffer, ext: string, options: Record kind: "pdf", supported: false, content: "", - notes: [ - "PDF binary parsing is not bundled in the Ant Code core.", - "Use the document-intake skill with MarkItDown installed, or convert the PDF to text/Markdown first." - ] + notes: ["PDF text-layer extraction is async; use parseDocumentBufferAsync or document_intake."] }; } @@ -157,6 +176,9 @@ export function parseDocumentBuffer(buffer: Buffer, ext: string, options: Record } export async function parseDocumentBufferAsync(buffer: Buffer, ext: string, options: Record = EMPTY_RECORD) { + if (ext === ".pdf") { + return parsePdfTextLayer(buffer, options); + } if (![".docx", ".pptx", ".xlsx"].includes(ext)) { return parseDocumentBuffer(buffer, ext, options); } @@ -164,6 +186,56 @@ export async function parseDocumentBufferAsync(buffer: Buffer, ext: string, opti return parseOfficeArchiveInWorker(buffer, ext, limits); } +async function parsePdfTextLayer(buffer: Buffer, options: Record = EMPTY_RECORD): Promise { + try { + const pdf = await getDocumentProxy(new Uint8Array(buffer)); + const extracted = await extractText(pdf, { mergePages: false }); + const pages = Array.isArray(extracted.text) ? extracted.text.map((page) => String(page ?? "")) : [String(extracted.text ?? "")]; + const totalPages = extracted.totalPages || pages.length; + const pageStart = Math.min(totalPages || 1, positiveIntegerOrNull(options.pageStart) ?? 1); + const requestedPages = positiveIntegerOrNull(options.maxPages) ?? PDF_LIMITS.defaultMaxPages; + const maxPages = Math.min(PDF_LIMITS.maxPagesCap, requestedPages); + const slice = pages.slice(pageStart - 1, pageStart - 1 + maxPages); + const pageEnd = pageStart - 1 + slice.length; + const truncated = pageStart > 1 || pageEnd < totalPages; + const joined = slice + .map((page, index) => `--- page ${pageStart + index} ---\n${page.replace(/\s+$/g, "")}`) + .join("\n\n") + .trim(); + const visibleChars = joined.replace(/\s+/g, "").length; + const notes = [ + `Extracted the PDF text layer from pages ${pageStart}-${Math.max(pageStart, pageEnd)} of ${totalPages}.` + ]; + if (truncated) { + notes.push(`This call was windowed to at most ${maxPages} pages. Pass pageStart=${pageEnd + 1} to continue.`); + } + if (visibleChars < 40) { + notes.push("Very little extractable text was found. This PDF may be scanned images; OCR is not bundled."); + } + return { + kind: "pdf", + supported: true, + content: joined, + notes, + pageStart, + pageEnd, + totalPages, + truncated + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + kind: "pdf", + supported: false, + content: "", + notes: [ + `PDF text-layer extraction failed: ${message}`, + "Password-protected or damaged PDFs are not opened. Scanned PDFs need an external OCR converter." + ] + }; + } +} + function officeZipLimits(options: Record = EMPTY_RECORD): OfficeZipLimits { const limited: OfficeZipLimits = { ...OFFICE_ZIP_LIMITS }; for (const key of Object.keys(OFFICE_ZIP_LIMITS) as Array) { diff --git a/src/tools/png-encode.ts b/src/tools/png-encode.ts new file mode 100644 index 0000000..4b28356 --- /dev/null +++ b/src/tools/png-encode.ts @@ -0,0 +1,48 @@ +import zlib from "node:zlib"; + +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +/** + * Encode raw 8-bit grayscale/RGB/RGBA pixels as a PNG without extra native libraries. + */ +export function encodePng( + width: number, + height: number, + channels: 1 | 3 | 4, + pixels: Uint8Array | Uint8ClampedArray | Buffer +): Buffer { + if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) { + throw Object.assign(new Error("invalid PNG dimensions"), { code: "PNG_DIMENSIONS_INVALID" }); + } + const stride = width * channels; + if (pixels.length < stride * height) { + throw Object.assign(new Error("PNG pixel buffer is shorter than width*height*channels"), { code: "PNG_PIXELS_SHORT" }); + } + const colorType = channels === 1 ? 0 : channels === 3 ? 2 : 6; + const raw = Buffer.alloc((stride + 1) * height); + for (let y = 0; y < height; y += 1) { + const rowStart = y * (stride + 1); + raw[rowStart] = 0; + raw.set(pixels.subarray(y * stride, y * stride + stride), rowStart + 1); + } + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; + ihdr[9] = colorType; + return Buffer.concat([ + PNG_SIGNATURE, + pngChunk("IHDR", ihdr), + pngChunk("IDAT", zlib.deflateSync(raw)), + pngChunk("IEND", Buffer.alloc(0)) + ]); +} + +function pngChunk(type: string, data: Buffer) { + const typeBytes = Buffer.from(type, "ascii"); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(zlib.crc32(Buffer.concat([typeBytes, data])) >>> 0, 0); + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length, 0); + return Buffer.concat([length, typeBytes, data, crc]); +} diff --git a/src/version.ts b/src/version.ts index 036ed00..2a1adf7 100644 --- a/src/version.ts +++ b/src/version.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; -const FALLBACK_VERSION = "2.0.7"; +const FALLBACK_VERSION = "2.0.8"; /** * @param {NodeJS.ProcessEnv} [env] diff --git a/tests/unit/dashboard-files.test.ts b/tests/unit/dashboard-files.test.ts index 2307cf0..f7c278f 100644 --- a/tests/unit/dashboard-files.test.ts +++ b/tests/unit/dashboard-files.test.ts @@ -3,7 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; -import { collectSessionFiles, previewFile, readRawFile, resolveWorkspaceFile } from "../../src/dashboard/files.ts"; +import { collectSessionFiles, previewFile, readRawFile, resolveSystemOpenFile, resolveWorkspaceFile } from "../../src/dashboard/files.ts"; import { parseDocumentBufferAsync } from "../../src/tools/document-tools.ts"; import { createDocxBuffer, createOfficeZip, createXlsxBuffer } from "../fixtures/office-fixtures.ts"; @@ -234,6 +234,28 @@ test("dashboard collects workflow and mentioned files inside workspace", async ( assert.equal(files.some((file) => file.path.includes("secret.txt")), false); }); +test("dashboard only opens document-like workspace files with the system app", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "dashboard-files-open-")); + await fs.writeFile(path.join(cwd, "report.docx"), createDocxBuffer("正文")); + await fs.writeFile(path.join(cwd, "evil.exe"), Buffer.from("MZ")); + await fs.mkdir(path.join(cwd, "folder")); + + const allowed = await resolveSystemOpenFile(cwd, "report.docx"); + const blocked = await resolveSystemOpenFile(cwd, "evil.exe"); + const missing = await resolveSystemOpenFile(cwd, "gone.docx"); + const directory = await resolveSystemOpenFile(cwd, "folder"); + + assert.equal(allowed.ok, true); + assert.equal(path.basename(allowed.path), "report.docx"); + assert.equal(blocked.ok, false); + assert.equal(blocked.status, 403); + assert.equal(blocked.code, "FILE_OPEN_NOT_ALLOWED"); + assert.equal(missing.ok, false); + assert.equal(missing.status, 404); + assert.equal(directory.ok, false); + assert.equal(directory.status, 404); +}); + test("dashboard skips mentioned image files that do not exist", async () => { const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "dashboard-files-")); const session = { cwd, workflow: { changes: [] } }; diff --git a/tests/unit/dashboard-runtime/turn.ts b/tests/unit/dashboard-runtime/turn.ts index 85bf548..08280c1 100644 --- a/tests/unit/dashboard-runtime/turn.ts +++ b/tests/unit/dashboard-runtime/turn.ts @@ -243,6 +243,54 @@ test("dashboard runtime rejects malformed and oversized turn attachments before assert.equal(promptTooLarge.status, 413); assert.equal(promptTooLarge.code, "PROMPT_TOO_LARGE"); assert.equal(runtime.active.size, 0); + + const oldWord = await runtime.startTurn({ + prompt: "old word", + attachments: [{ + type: "document", + name: "legacy.doc", + mimeType: "application/msword", + size: 4, + data: Buffer.from("ABCD").toString("base64") + }] + }); + assert.equal(oldWord.ok, false); + assert.equal(oldWord.code, "UNSUPPORTED_DOCUMENT_TYPE"); + assert.equal(runtime.active.size, 0); +}); + +test("dashboard runtime accepts composer PDF attachments and rejects them from the image budget", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "dashboard-runtime-docs-")); + const seen = deferred(); + const runtime = createDashboardRuntime({ + cwd, + env: { USERPROFILE: cwd }, + runTurn: async (_session, options) => { + seen.resolve(options.attachments ?? []); + await options.onEvent({ type: "turn_complete", status: "completed" }); + return { output: "done" }; + } + }); + await runtime.trustWorkspace(); + const pdf = Buffer.from("%PDF-1.4\ntrailer<>\n%%EOF\n"); + const started = await runtime.startTurn({ + prompt: "read attached pdf", + attachments: [{ + type: "document", + name: "note.pdf", + mimeType: "application/pdf", + size: pdf.length, + data: pdf.toString("base64") + }], + permissionMode: "plan" + }); + assert.equal(started.ok, true); + assert.equal(runtime.active.get(started.sessionId).currentAttachmentBytes, 0); + const received = await seen.promise; + assert.equal(received.length, 1); + assert.equal(received[0].type, "document"); + assert.equal(received[0].name, "note.pdf"); + assert.match(String(received[0].path ?? ""), /ant-code-uploads[/\\].*note\.pdf$/); }); test("dashboard runtime includes the active turn in the queued attachment budget", async () => { diff --git a/tests/unit/dashboard-server.test.ts b/tests/unit/dashboard-server.test.ts index b197edd..d92e29e 100644 --- a/tests/unit/dashboard-server.test.ts +++ b/tests/unit/dashboard-server.test.ts @@ -1117,6 +1117,8 @@ test("dashboard file routes resolve paths through the selected session cwd", asy assert.equal(raw.contentType, "image/png"); assert.match(raw.headers["content-disposition"], /^inline; filename="chart\.png";/); assert.equal(raw.headers["x-content-type-options"], "nosniff"); + assert.equal(raw.headers["x-frame-options"], "DENY"); + assert.match(raw.headers["content-security-policy"], /frame-ancestors 'none'/); assert.deepEqual(Array.from(raw.body.subarray(0, 4)), [0x89, 0x50, 0x4e, 0x47]); assert.equal(svg.status, 200); assert.equal(svg.contentType, "application/octet-stream"); @@ -1129,6 +1131,79 @@ test("dashboard file routes resolve paths through the selected session cwd", asy } }); +test("dashboard raw PDF responses allow same-origin iframe preview", async () => { + const dashboardCwd = await fs.mkdtemp(path.join(os.tmpdir(), "dashboard-pdf-")); + await fs.writeFile(path.join(dashboardCwd, "note.pdf"), Buffer.from("%PDF-1.4\n%%EOF\n")); + const server = createDashboardServer({ + cwd: dashboardCwd, + runtime: createRuntimeStub() + }); + await listen(server, "127.0.0.1", 0); + + try { + const preview = await fetchJson(server, "/api/files?path=note.pdf"); + const raw = await fetchBuffer(server, "/api/files/raw?path=note.pdf"); + const page = await fetchBuffer(server, "/"); + + assert.equal(preview.status, 200); + assert.equal(preview.body.file.kind, "pdf"); + assert.equal(preview.body.file.embeddable, true); + assert.equal(raw.status, 200); + assert.equal(raw.contentType, "application/pdf"); + assert.match(raw.headers["content-disposition"], /^inline;/); + assert.equal(raw.headers["x-frame-options"], "SAMEORIGIN"); + assert.match(raw.headers["content-security-policy"], /frame-ancestors 'self'/); + assert.match(raw.headers["content-security-policy"], /object-src 'self'/); + assert.doesNotMatch(raw.headers["content-security-policy"], /frame-ancestors 'none'/); + assert.equal(page.headers["x-frame-options"], "DENY"); + assert.match(page.headers["content-security-policy"], /frame-ancestors 'none'/); + assert.match(page.headers["content-security-policy"], /object-src 'none'/); + } finally { + await close(server); + } +}); + +test("dashboard open route launches workspace documents with the system app", async () => { + const dashboardCwd = await fs.mkdtemp(path.join(os.tmpdir(), "dashboard-open-")); + await fs.writeFile(path.join(dashboardCwd, "note.docx"), Buffer.from("PK")); + await fs.writeFile(path.join(dashboardCwd, "evil.exe"), Buffer.from("MZ")); + const opened = []; + const server = createDashboardServer({ + cwd: dashboardCwd, + runtime: createRuntimeStub(), + openLocalPath: (filePath) => { + opened.push(filePath); + } + }); + await listen(server, "127.0.0.1", 0); + + try { + const ok = await fetchJson(server, "/api/files/open", { + method: "POST", + body: { path: "note.docx" } + }); + const blocked = await fetchJson(server, "/api/files/open", { + method: "POST", + body: { path: "evil.exe" } + }); + const missing = await fetchJson(server, "/api/files/open", { + method: "POST", + body: { path: "gone.docx" } + }); + + assert.equal(ok.status, 200); + assert.equal(ok.body.ok, true); + assert.equal(opened.length, 1); + assert.equal(path.basename(opened[0]), "note.docx"); + assert.equal(blocked.status, 403); + assert.equal(blocked.body.code, "FILE_OPEN_NOT_ALLOWED"); + assert.equal(missing.status, 404); + assert.equal(opened.length, 1); + } finally { + await close(server); + } +}); + test("dashboard file routes reject a session cwd outside the startup workspace", async () => { const dashboardCwd = await fs.mkdtemp(path.join(os.tmpdir(), "dashboard-root-")); const outsideCwd = await fs.mkdtemp(path.join(os.tmpdir(), "dashboard-outside-")); diff --git a/tests/unit/dashboard-ui.test.ts b/tests/unit/dashboard-ui.test.ts index 6327f36..37da46a 100644 --- a/tests/unit/dashboard-ui.test.ts +++ b/tests/unit/dashboard-ui.test.ts @@ -654,6 +654,16 @@ test("dashboard renders lightweight office previews in the side panel", async () assert.match(css, /\.expanded-table-scroll\s*\{[^}]*overflow: auto;/s); assert.doesNotMatch(css, /\.expanded-sheet-tabs\s*\{/); assert.doesNotMatch(css, /\.table-sheet-tabs\s*\{/); + assert.match(app, /postJson\("\/api\/files\/open"/); + assert.match(app, /data-open-local/); + assert.match(app, /function handleLocalFileOpenClick\(/); + assert.match(app, /els\.preview\.addEventListener\("click", handleLocalFileOpenClick\)/); + assert.match(app, /class="pdf-preview"/); + assert.match(app, /function transcriptMessageAttachments\(/); + assert.match(app, /function userTranscriptDisplayText\(/); + assert.match(css, /\.pdf-preview\s*\{[^}]*grid-template-rows: auto minmax\(0, 1fr\);/s); + assert.match(css, /\.message-attachments \.attachment-chip\s*\{[^}]*display: inline-flex;/s); + assert.match(css, /\.message-attachments \.attachment-chip\s*\{[^}]*white-space: nowrap;/s); }); test("dashboard responsive, composer, focus, and scroll helpers enforce UI behavior", async () => { diff --git a/tests/unit/tools.test.ts b/tests/unit/tools.test.ts index 1512b2b..f271f3d 100644 --- a/tests/unit/tools.test.ts +++ b/tests/unit/tools.test.ts @@ -13,6 +13,10 @@ import { tsDiagnosticsTool, tsFindDefinitionTool, tsFindReferencesTool, tsSymbol import { scrubEnvironment } from "../../src/tools/env-scrubber.ts"; import { createMcpRuntime } from "../../src/mcp/runtime.ts"; import { documentIntakeTool } from "../../src/tools/document-tools.ts"; +import { ingestComposerDocuments, storeComposerAttachments } from "../../src/tools/composer-documents.ts"; +import { persistableUserTurnMessage } from "../../src/core/session-messages.ts"; +import { persistableContextMessages, persistableTranscriptMessages } from "../../src/core/session-persist.ts"; +import { encodePng } from "../../src/tools/png-encode.ts"; import { serializeToolResult } from "../../src/tools/result.ts"; import { createToolRuntime } from "../../src/tools/runtime.ts"; import { @@ -1960,6 +1964,107 @@ test("document_intake extracts markdown from local HTML documents", async () => assert.match(result.content, /Alpha beta/); }); +test("document_intake extracts PDF text-layer windows", async () => { + const cwd = await makeTempWorkspace(); + await fs.writeFile(path.join(cwd, "paper.pdf"), twoPageHelloPdf()); + + const first = await documentIntakeTool({ cwd, path: "paper.pdf", maxPages: 1 }); + const second = await documentIntakeTool({ cwd, path: "paper.pdf", pageStart: 2, maxPages: 1 }); + + assert.equal(first.supported, true); + assert.equal(first.kind, "pdf"); + assert.equal(first.totalPages, 2); + assert.equal(first.truncated, true); + assert.match(first.content, /Page One/); + assert.doesNotMatch(first.content, /Page Two/); + assert.match(second.content, /Page Two/); + assert.doesNotMatch(second.content, /Page One/); +}); + +test("encodePng writes a PNG signature for raw pixels", () => { + const png = encodePng(1, 1, 3, Buffer.from([255, 0, 0])); + assert.deepEqual(png.subarray(0, 8), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])); +}); + +test("composer document ingest saves an attached PDF and extracts its text layer", async () => { + const cwd = await makeTempWorkspace(); + const pdf = twoPageHelloPdf(); + const ingested = await ingestComposerDocuments({ + cwd, + attachments: [{ + type: "document", + name: "paper.pdf", + mimeType: "application/pdf", + size: pdf.length, + data: pdf.toString("base64") + }] + }); + assert.match(ingested.promptAppendix, /Attached document: paper\.pdf/); + assert.match(ingested.promptAppendix, /Page One/); + assert.match(ingested.promptAppendix, /Do not glob the workspace/); + assert.equal(ingested.visionImages.length, 0); + const saved = await fs.readdir(path.join(cwd, "ant-code-uploads")); + assert.equal(saved.some((name) => name.endsWith("paper.pdf")), true); +}); + +test("composer uploads add gitignore entries so ant-code-uploads stays untracked", async () => { + const cwd = await makeTempWorkspace(); + const pdf = twoPageHelloPdf(); + await storeComposerAttachments(cwd, [{ + type: "document", + name: "paper.pdf", + data: pdf.toString("base64") + }]); + const rootIgnore = await fs.readFile(path.join(cwd, ".gitignore"), "utf8"); + const innerIgnore = await fs.readFile(path.join(cwd, "ant-code-uploads", ".gitignore"), "utf8"); + assert.match(rootIgnore, /ant-code-uploads\//); + assert.match(innerIgnore, /^\*\n!\.gitignore\n$/); + await storeComposerAttachments(cwd, [{ + type: "document", + name: "second.pdf", + data: pdf.toString("base64") + }]); + const again = await fs.readFile(path.join(cwd, ".gitignore"), "utf8"); + assert.equal(again.split("ant-code-uploads/").length - 1, 1); +}); + +test("transcript persistence keeps composer attachment chips without bytes", () => { + const message = persistableUserTurnMessage("总结一下", [{ + type: "document", + name: "note.docx", + mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + size: 12, + path: "ant-code-uploads/1-note.docx", + data: "AAAA" + }]); + const transcript = persistableTranscriptMessages([message], { goal: { enabled: false } }); + const model = persistableContextMessages([message]); + + assert.equal(message.attachments[0].path, "ant-code-uploads/1-note.docx"); + assert.equal(message.attachments[0].data, undefined); + assert.equal(transcript[0].attachments[0].path, "ant-code-uploads/1-note.docx"); + assert.equal(transcript[0].attachments[0].data, undefined); + assert.equal(model[0].attachments, undefined); +}); + +test("composer document ingest previews large text instead of dumping it into the prompt", async () => { + const cwd = await makeTempWorkspace(); + const csv = `id,name\n${Array.from({ length: 4000 }, (_, index) => `${index},row-${index}`).join("\n")}\n`; + const ingested = await ingestComposerDocuments({ + cwd, + attachments: [{ + type: "document", + name: "table.csv", + mimeType: "text/csv", + size: Buffer.byteLength(csv), + data: Buffer.from(csv).toString("base64") + }] + }); + assert.match(ingested.promptAppendix, /preview truncated/); + assert.equal(ingested.promptAppendix.includes("row-3999"), false); + assert.ok(ingested.promptAppendix.length < csv.length); +}); + test("document_intake rejects oversized Office archives before parsing", async () => { const cwd = await makeTempWorkspace(); const target = path.join(cwd, "oversized.docx"); @@ -2614,6 +2719,26 @@ test("background terminal liveness sampling never runs tasklist synchronously", assert.match(source, /scheduleProcessLivenessSnapshot\(\)/); }); +function twoPageHelloPdf() { + return Buffer.from(`%PDF-1.4 +1 0 obj<>endobj +2 0 obj<>endobj +3 0 obj<>>>>>endobj +4 0 obj<>stream +BT /F1 12 Tf 20 100 Td (Page One) Tj ET +endstream +endobj +5 0 obj<>endobj +6 0 obj<>>>>>endobj +7 0 obj<>stream +BT /F1 12 Tf 20 100 Td (Page Two) Tj ET +endstream +endobj +trailer<> +%%EOF +`); +} + async function makeTempWorkspace() { return fs.mkdtemp(path.join(os.tmpdir(), "lab-agent-test-")); }