diff --git a/AGENTS.md b/AGENTS.md index 04f37c5..30ac4c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Command Code OpenAI Bridge -Command Code OpenAI Bridge 是一个仅在本机运行的 OpenAI 兼容服务。它把 Chat Completions 文本对话与 function calling 决策转交给 Command Code CLI 执行,并提供 Responses API 文本子集。 +Command Code OpenAI Bridge 是一个仅在本机运行的 OpenAI 兼容服务。它把 Chat Completions/Responses 文本、受限图片与纯文本文件输入及 function calling 决策转交给 Command Code CLI 执行。 默认 API 地址:`http://127.0.0.1:18000/v1` @@ -9,7 +9,7 @@ Command Code OpenAI Bridge 是一个仅在本机运行的 OpenAI 兼容服务。 每个请求都会启动一个独立的 Command Code headless agent: 1. 接收并校验 OpenAI Chat Completions 请求。 -2. 保留消息的角色、顺序和完整文本,将全部会话历史序列化后通过 stdin 传给 Command Code。 +2. 保留消息的角色、顺序和完整文本,将会话历史通过 stdin 传给 Command Code;附件经校验后写入请求专用临时文件并以 `@path` 交给真实 `read_file`。 3. 在启动服务的终端中显示模型状态、turn、工具调用、工具结果、stderr、最终回答和耗时。 4. 从 Command Code 的 NDJSON 结构化结果中提取 `finalText`。 5. 把最终回复、外部 function tool calls 和可用的 token usage 返回给 API 客户端。 @@ -20,6 +20,7 @@ Command Code OpenAI Bridge 是一个仅在本机运行的 OpenAI 兼容服务。 - 提供 `GET /health`、`GET /v1/models` 和 `POST /v1/chat/completions`。 - 支持字符串形式的 `content` 和 OpenAI 文本 part 数组。 +- Chat 支持图片 data URL;Responses 还支持 UTF-8 `input_file.file_data`。远程 URL、本地路径、PDF 和其他二进制拒绝。 - Chat Completions 支持标准 function tools、工具选择、assistant tool calls 和 tool 结果消息;工具由客户端执行。 - 接受 `stream: true`;普通 Chat 实时转发文本 delta,function calling 在完整决策校验后发送工具调用 SSE。 - 支持中文、Unicode、Markdown、代码块和较长文本。 @@ -30,7 +31,7 @@ Command Code OpenAI Bridge 是一个仅在本机运行的 OpenAI 兼容服务。 - 单次 Command Code 失败后,HTTP 服务可继续处理后续请求。 - Authorization 请求头会被忽略,服务强制绑定 `127.0.0.1`。 -当前只接受文本消息和文本输入。普通 Chat SSE 实时转发 Command Code 文本 delta;function calling 会等待完整决策通过 JSON 与参数 Schema 校验后发送 `tool_calls`。Responses 只实现文本子集。图片、音频、文件输入、Computer Use、托管工具和服务端 Chat 会话均未实现。 +普通 Chat SSE 实时转发 Command Code 文本 delta;function calling 会等待完整决策通过 JSON 与参数 Schema 校验后发送 `tool_calls`。图片只接受 PNG/JPEG/GIF/WebP base64 data URL,并要求模型映射显式声明 `supports_image_input: true`。文件只接受 Responses UTF-8 `file_data`。音频、视频、PDF/Office、任意二进制、Computer Use、托管工具和服务端 Chat 会话均未实现。 ## 技术实现 @@ -50,6 +51,7 @@ src/ ├── index.ts # 程序入口、服务启动和信号处理 ├── server.ts # HTTP 路由、共享并发协调和错误处理 ├── openai.ts # 请求校验、消息转换和响应生成 +├── attachments.ts # data URL/base64 校验、临时文件和清理 ├── command-code.ts # CLI 检查、子进程管理和最终结果提取 ├── tool-calling.ts # 外部 function calling 决策解析与参数校验 ├── renderer.ts # Command Code 事件的终端显示 @@ -61,7 +63,8 @@ scripts/ examples/ ├── python_client.py # OpenAI Python SDK 示例 -└── node_client.mjs # OpenAI Node.js SDK 示例 +├── node_client.mjs # OpenAI Node.js SDK 示例 +└── opencode.json # OpenCode provider 与模型模态示例 ``` ## 快速开始 @@ -92,56 +95,25 @@ curl http://127.0.0.1:18000/v1/chat/completions \ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **command-code-openai-bridge** (330 symbols, 941 relationships, 28 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **command-code-openai-bridge** (461 symbols, 1291 relationships, 29 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. -> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. +> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939). ## Always Do -- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. -- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. +- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. +- **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. For regression review, compare against the default branch: `detect_changes({scope: "compare", base_ref: "main"})`. - **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. -- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. -- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. - -## When Debugging - -1. `gitnexus_query({query: ""})` — find execution flows related to the issue -2. `gitnexus_context({name: ""})` — see all callers, callees, and process participation -3. `READ gitnexus://repo/command-code-openai-bridge/process/{processName}` — trace the full execution flow step by step -4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed - -## When Refactoring - -- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`. -- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code. -- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed. +- When exploring unfamiliar code, use `query({search_query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. +- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`. +- For security review, `explain({target: "fileOrSymbol"})` lists taint findings (source→sink flows; needs `analyze --pdg`). ## Never Do -- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. +- NEVER edit a function, class, or method without first running `impact` on it. - NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. -- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. -- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. - -## Tools Quick Reference - -| Tool | When to use | Command | -|------|-------------|---------| -| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` | -| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` | -| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` | -| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` | -| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` | -| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` | - -## Impact Risk Levels - -| Depth | Meaning | Action | -|-------|---------|--------| -| d=1 | WILL BREAK — direct callers/importers | MUST update these | -| d=2 | LIKELY AFFECTED — indirect deps | Should test | -| d=3 | MAY NEED TESTING — transitive | Test if critical path | +- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph. +- NEVER commit changes without running `detect_changes()` to check affected scope. ## Resources @@ -152,32 +124,6 @@ This project is indexed by GitNexus as **command-code-openai-bridge** (330 symbo | `gitnexus://repo/command-code-openai-bridge/processes` | All execution flows | | `gitnexus://repo/command-code-openai-bridge/process/{name}` | Step-by-step execution trace | -## Self-Check Before Finishing - -Before completing any code modification task, verify: -1. `gitnexus_impact` was run for all modified symbols -2. No HIGH/CRITICAL risk warnings were ignored -3. `gitnexus_detect_changes()` confirms changes match expected scope -4. All d=1 (WILL BREAK) dependents were updated - -## Keeping the Index Fresh - -After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it: - -```bash -npx gitnexus analyze -``` - -If the index previously included embeddings, preserve them by adding `--embeddings`: - -```bash -npx gitnexus analyze --embeddings -``` - -To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.** - -> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`. - ## CLI | Task | Read this skill file | diff --git a/README.md b/README.md index 2f80a5a..3d8800d 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,42 @@ # Command Code OpenAI Bridge -这是一个只绑定本机地址的 OpenAI 兼容转发服务。它实现 Chat Completions 与 Responses API 的文本对话和外部 function calling。每个生成请求启动一次独立的 Command Code headless agent,将完整消息历史通过 stdin 发送给 CLI,在当前终端显示运行过程,再把结构化事件和最终 `finalText` 转换成 OpenAI 风格的 JSON 或 SSE。 +这是一个只绑定本机地址的 OpenAI 兼容转发服务。它实现 Chat Completions 与 Responses API 的文本对话、受限图片/纯文本文件输入和外部 function calling。每个生成请求启动一次独立的 Command Code headless agent,将完整消息历史通过 stdin 发送给 CLI,把附件安全落为请求专用临时文件并通过真实 `read_file` 路径交给 Command Code,再把结构化事件和最终 `finalText` 转换成 OpenAI 风格的 JSON 或 SSE。 默认地址:`http://127.0.0.1:18000/v1` ## 当前机器上的调查结论 -- 系统:macOS 26.4.1 arm64。 - Node.js:v22.22.2,符合 Command Code 的 Node.js 22+ 要求。 -- 安装方式:`npm install --global command-code@latest`。 -- 已安装 Command Code:v1.10.0,npm `latest` 也是 v1.10.0。 -- 可执行文件:`command-code`;macOS/Linux 短名为 `cmd`,Windows 短名为 `cmdc`。 -- 登录:`command-code login`;检查:`command-code status --json`;本机已登录。 -- npm 包没有 `exports` 字段,`main` 指向会直接启动 CLI 的 `dist/cli.mjs`,包尾直接解析命令行,没有公开、稳定的可嵌入 API。 -- 官方 headless 调用:`command-code -p --output-format json`。不传查询参数时会从 stdin 读取。 +- 已安装 Command Code:v1.18.0;可执行文件是全局 npm 包的 `dist/index.mjs`。 +- npm 包没有公开、稳定的可嵌入输入 API。官方 headless 调用仍是 `command-code -p --output-format json`。 +- `--help` 没有 `--image`、`--attachment`、文件 URL、二进制 stdin 或 JSON stdin envelope。`-p` 的参数或 piped stdin 都只是查询文本。 +- 官方文档同时明确支持在提示中用 `@path/to/file` 引用工作区文件;`read_file` 会把图片真正交给原生视觉模型或 VISION 工具。普通二进制只返回 MIME 提示,PDF 当前只提示使用 `pdftotext`,并不是 PDF 内容输入。 +- 真实 headless 检查使用一张四象限 PNG。`moonshotai/kimi-k2.7-code` 与 `gpt-5.6-luna` 都在 NDJSON 中实际调用 `read_file`,并正确返回 `Red, Green, Blue, Yellow`。这证明本地图片路径在 `-p --output-format json` 下会被底层模型理解,不只是把文件名写进 prompt。 +- 因此 Bridge 只把经过 MIME、魔数、大小和 UTF-8 校验的数据落为工作区内临时文件,再传入 `@` 路径。它不把 base64 塞进 prompt,也不做 OCR/PDF 摘要降级。 +- 远程 HTTP(S)、`file://`、任意本地路径和 `file_id` 均不接受。Bridge 完全不为附件发起网络请求,从策略上消除 SSRF、重定向和 URL token 泄漏。 - JSON 输出是 NDJSON:运行中输出 `AgentEvent`,正常情况下最后输出唯一的 `result` 行;最终回答位于 `finalText`,usage 和耗时也在该行。 -- `--no-session` 让每次请求只使用内存会话,不写入或恢复跨请求 session。 +- `--no-session` 让每次请求只使用内存会话,不写入或恢复跨请求 Command Code session。 - 官方 headless 模式明确不提供键盘、问题回答或权限批准等交互。原始 TTY/TUI 与可靠的独立 `finalText` 目前不能同时获得。 -- 官方退出码:0 成功;1 常规错误;3 未登录;4 权限拒绝;5 限流;6 网络错误;7 服务端错误;8 达到 turn 上限;9 无回复;10 余额不足;130 被信号中断。 官方资料: - [Quickstart](https://commandcode.ai/docs/quickstart) - [CLI Reference](https://commandcode.ai/docs/reference/cli) - [Headless Mode](https://commandcode.ai/docs/headless) +- [Common Workflows:文件和图片路径](https://commandcode.ai/docs/workflows) +- [Vision](https://commandcode.ai/docs/vision) +- [The Read Tool](https://commandcode.ai/docs/harness-engineering/read-tool) - [Permissions](https://commandcode.ai/docs/core-concepts/permissions) ## 为什么使用普通子进程与结构化事件 本项目使用官方 headless 子进程和 NDJSON,不使用 PTY,也不解析 TUI 文本。 -原因是 v1.10.0 没有公开嵌入 API,交互式 TUI 没有独立的结构化最终结果通道。headless 的最后一行提供稳定 `finalText`,能保证工具参数、工具结果、状态、stderr、思考事件和 ANSI 内容不会混入 API 回复。 +原因是 v1.18.0 没有公开嵌入 API,交互式 TUI 没有独立的结构化最终结果通道。headless 的最后一行提供稳定 `finalText`,能保证工具参数、工具结果、状态、stderr、思考事件和 ANSI 内容不会混入 API 回复。 终端会显示:请求开始、模型、turn 状态、工具事件、工具参数、工具结果、stderr、最终回答和耗时。每个 turn 结束时还会显示本轮和服务启动以来的累计 input/output/total token;服务退出时再输出一次累计,重启后从零开始。终端不会显示原始 Ink TUI、动画、键盘快捷键、人工权限批准、人工问题回答和详细内部思考文本。 -本机 v1.10.0 实测表明,headless 即使设为 `auto-accept`,shell 仍会被拒绝。默认 `dangerously_skip_permissions: true`,因此实际传入 `--yolo`,让文件写入和命令工具能继续执行。它会绕过所有权限确认,只应对可信工作目录使用。若要只读,把 `dangerously_skip_permissions` 改为 `false`,同时把 `permission_mode` 改成 `plan`。 +默认 `dangerously_skip_permissions: true`,因此实际传入 `--yolo`,让文件写入和命令工具能继续执行。它会绕过所有权限确认,只应对可信工作目录使用。若要只读,把 `dangerously_skip_permissions` 改为 `false`,同时把 `permission_mode` 改成 `plan`;`read_file` 在只读模式仍可读取 Bridge 创建的附件。 ## 项目结构 @@ -104,9 +106,16 @@ models: command-default: cli_model: deepseek/deepseek-v4-flash effort: max + supports_image_input: false + command-vision: + cli_model: gpt-5.6-luna + effort: max + supports_image_input: true ``` -`command_code_working_directory` 决定 Command Code 能看到和操作的项目目录。`response_store_directory` 保存 `store: true` 的 Response;两个相对路径都以配置文件所在目录为基准。默认存储目录是隐藏目录 `.command-code-openai-bridge/responses`。`max_concurrent_requests` 是同时运行的 Command Code 请求上限,范围 `1..64`,默认 `1`。`max_queue_size` 是等待执行的生成请求上限,不包含正在运行的请求;设为 `0` 可恢复并发槽满时立即返回 429 的行为。`stream_thinking` 控制 Chat Completions 流是否把 Command Code 思考事件包装成 `` 块;默认关闭。`models..effort` 会传给 Command Code 的 `--effort`,Responses 请求中的 `reasoning.effort` 优先。可用 effort 由对应底层模型决定。服务强制只监听 `127.0.0.1`。客户端只能选择配置中的模型名,不能注入额外 CLI 参数。 +`command_code_working_directory` 决定 Command Code 能看到和操作的项目目录。`response_store_directory` 保存 `store: true` 的 Response;两个相对路径都以配置文件所在目录为基准。默认存储目录是隐藏目录 `.command-code-openai-bridge/responses`。`max_concurrent_requests` 是同时运行的 Command Code 请求上限,范围 `1..64`,默认 `1`。`max_queue_size` 是等待执行的生成请求上限,不包含正在运行的请求;设为 `0` 可恢复并发槽满时立即返回 429 的行为。`stream_thinking` 控制 Chat Completions 流是否把 Command Code 思考事件包装成 `` 块;默认关闭。`models..effort` 会传给 Command Code 的 `--effort`,Responses 请求中的 `reasoning.effort` 优先。可用 effort 由对应底层模型决定。 + +`models..supports_image_input` 默认 `false`。只有对该实际 CLI 模型完成图片路径检查后才能设为 `true`;图片请求选中未声明的模型时返回 400,不会假装模型看到了图片。`GET /v1/models` 同时返回每个映射的 `capabilities.input`。服务强制只监听 `127.0.0.1`,客户端只能选择配置中的模型名,不能注入额外 CLI 参数。 ### Obsidian Copilot 流式输出 @@ -139,7 +148,9 @@ OpenCode 1.18.15 实际发送的 Chat 请求包含 `stream: true`、`stream_opti OpenCode 还会在会话标题生成、子 agent 和后台任务等场景发起独立请求。这些请求与主 agent 请求共用同一 provider;将 `max_concurrent_requests` 设为大于 `1` 可让它们与多个会话并行执行。 -已知限制:Bridge 不实现 OpenCode hosted tools、图片/音频输入、Computer Use 或 Responses `/v1/responses` 路径;OpenCode 自定义 provider 走 `/v1/chat/completions`。`temperature`、`max_tokens` 等采样参数不会传给 Command Code CLI。 +示例把 `command-default` 明确声明为仅文本,把已经通过真实 CLI 检查且在 `config.yaml` 中启用的 `command-luna` 声明为 `modalities.input: ["text", "image"]`。OpenCode 只有看到该字段才会把图片转换成 Chat `image_url`;不要给 `supports_image_input: false` 的映射添加 `image`。OpenCode 会把用户图片规范化为 data URL,Bridge 不接受远程图片 URL。 + +已知限制:Bridge 不实现 OpenCode hosted tools、音频、视频、PDF attachment、Computer Use 或托管 File Search/Code Interpreter/Hosted Shell。OpenCode 自定义 provider 走 `/v1/chat/completions`;Responses 路径由 OpenAI SDK 等客户端使用。`temperature`、`max_tokens` 等采样参数不会传给 Command Code CLI。 ## 启动、停止与重启 @@ -172,11 +183,28 @@ npm run build `GET /health` 额外返回 `active_requests`、`max_concurrent_requests`、`queue_length` 和 `queue_capacity`;`busy` 作为兼容字段保留,等价于 `active_requests > 0`。 -Bearer Token 会被忽略。所有生成接口只接受文本内容。图片、音频、文件输入和其他未实现字段会返回 OpenAI 格式的 400,`code` 为 `unsupported_parameter`;服务不会静默忽略会改变行为的参数。 +Bearer Token 会被忽略。服务不会静默忽略会改变行为的参数。 + +### 输入能力表 + +| 输入 | Chat Completions | Responses | 实际传给 Command Code | +| --- | --- | --- | --- | +| 文本 | `content` 字符串或 `text` part | 字符串、`input_text`、`output_text` | UTF-8 stdin prompt | +| 图片 | `image_url.url` 的 base64 data URL | `input_image.image_url` 的 base64 data URL | 校验后写入临时 PNG/JPEG/GIF/WebP,传 `@path`,由 `read_file`/视觉模型读取 | +| 纯文本文件 | 不支持 API 附件;OpenCode `read` 工具结果仍是普通文本消息 | `input_file.file_data`,必须带 filename | 校验 UTF-8 后写入临时文本文件,传 `@path`,由 `read_file` 读取 | +| HTTP(S)、`file://`、本地路径 | 拒绝 | 拒绝 | 不联网、不读取客户端指定路径 | +| PDF、Office、任意二进制 | 拒绝 | 拒绝 | 不做伪 OCR、伪摘要或二进制转文本 | +| 音频、视频、Computer Use、托管工具 | 拒绝 | 拒绝 | 未实现 | + +附件最多 16 个;单张图片解码后最多 10 MiB,单个 UTF-8 文本文件最多 5 MiB,合计最多 15 MiB,并继续受 `max_request_bytes` 限制。图片 MIME 白名单为 `image/png`、`image/jpeg`、`image/gif`、`image/webp`,声明 MIME 必须与魔数一致。临时文件权限为 `0600`,位于 `.command-code-openai-bridge/inputs` 的请求专用目录,并在成功、错误、取消或超时后从 `finally` 清理。 + +附件原始 data URL、base64 和 `read_file` 返回的原始文件内容不会进入 Command Code prompt、桥接终端日志或 Responses 本地存储。终端 renderer 会把附件 `read_file` 的完整 tool result 替换为脱敏标记,并递归遮蔽其他事件中的 data URL、base64、凭据和 URL 查询参数;NDJSON 解析错误也不回显原始行。模型按用户要求生成的最终回答仍会照常显示。`store: true` 只保存文本/工具历史;附件 part 被省略,因此后续 `previous_response_id` 不会伪装成仍能读取旧附件。需要再次使用时必须重新提交。 ### Chat Completions -支持 `system`、`developer`、`user`、`assistant` 和 `tool` 消息。文本 `content` 可以是字符串,也可以是由 `{ "type": "text", "text": "..." }` 组成的数组;assistant 工具轮次还支持 `content: null`、`tool_calls`,tool 消息支持 `tool_call_id` 和可选 `name`。 +支持 `system`、`developer`、`user`、`assistant` 和 `tool` 消息。文本 `content` 可以是字符串,也可以是由 `{ "type": "text", "text": "..." }` 组成的数组;user 消息还可包含标准 `{ "type": "image_url", "image_url": { "url": "data:image/png;base64,..." } }`。assistant 工具轮次支持 `content: null`、`tool_calls`,tool 消息支持 `tool_call_id` 和可选 `name`。 + +`image_url` 只接受 base64 data URL;远程 URL、`file://` 和本地路径返回 `400 unsupported_parameter`,`param` 精确指向 `messages..content..image_url.url`。`detail` 只能省略或为 `auto`,因为 CLI 没有 low/high 映射。所选模型必须配置 `supports_image_input: true`。 Chat 接口接受标准 function tools、`tool_choice` 和 `parallel_tool_calls`。Bridge 把工具定义、完整消息历史和工具结果交给 Command Code 决定下一步,校验返回的工具名与 arguments JSON Schema;第一次不合格时在原请求总截止时间内执行一次修复。工具由 API 客户端执行,Bridge 不执行客户端工具,也不直接访问 Obsidian vault。该流程适用于 Obsidian Copilot 的 `localSearch`、`readNote`、`getFileTree`、`writeFile` 和 `editFile`,也适用于其他标准 function tools。协议流程参考 [OpenAI Function calling](https://developers.openai.com/api/docs/guides/function-calling)。 @@ -203,7 +231,9 @@ Chat Completions 和 Responses 共用 Command Code NDJSON 执行层。服务终 - `tool_choice` - `parallel_tool_calls` -`input` 可以是字符串,也可以是 item 数组。message 支持 `system`、`developer`、`user`、`assistant` 角色、字符串 content,以及 `input_text`、`output_text` part。工具轮次还支持 `function_call` 和 `function_call_output` item,分别携带 `call_id`、工具名、`arguments` 和工具执行结果 `output`。 +`input` 可以是字符串,也可以是 item 数组。message 支持 `system`、`developer`、`user`、`assistant` 角色、字符串 content,以及 `input_text`、`output_text`、`input_image`、`input_file` part。工具轮次还支持 `function_call` 和 `function_call_output` item,分别携带 `call_id`、工具名、`arguments` 和工具执行结果 `output`。 + +`input_image.image_url` 与 Chat 一样只接受图片 data URL;`file_id`、远程 URL、`file://`、本地路径和 low/high detail 不支持。`input_file` 只接受 `file_data` 加 `filename`,其中 `file_data` 可以是严格 base64 或带受支持文本 MIME 的 base64 data URL;解码结果必须是无 NUL 的 UTF-8 文本。`file_url`、`file_id`、PDF、Office 和其他二进制会返回带准确字段路径的 400。 Responses 接受标准 function tools、`tool_choice` 和 `parallel_tool_calls`。Bridge 把工具定义、完整历史(含先前 response 的完整 output items)和工具结果交给 Command Code 决定下一步,校验返回的工具名与 arguments JSON Schema;第一次不合格时在原请求总截止时间内执行一次修复。工具由 API 客户端执行,Bridge 不执行客户端工具,也不会让 Command Code 用自身文件、终端、网络等工具替代外部 tools。该流程适用于 OpenCode 等使用 `@ai-sdk/openai` 或 OpenAI Node.js SDK 的 Responses 客户端。 @@ -348,27 +378,27 @@ command-code \ --yolo ``` -随后通过子进程 stdin 写入 UTF-8 的完整请求历史。消息序列被放进一个 JSON 对象,角色、顺序、空消息、Unicode、Markdown、代码块和自定义分隔符均不会被简单文本分隔符破坏。Bridge 不总结、不删除、不截断消息。Chat Completions 不保存会话;Responses 只在 `store: true` 时保存协议对象,并在新请求中把响应链重新序列化给一个新的 `--no-session` 进程。 +随后通过子进程 stdin 写入 UTF-8 的完整文本历史和不含原始数据的附件路径区块。消息序列被放进一个 JSON 对象,角色、顺序、空消息、Unicode、Markdown、代码块和自定义分隔符均不会被简单文本分隔符破坏。附件 data URL/base64 不进入该对象。Chat Completions 不保存会话;Responses 只在 `store: true` 时保存去除附件数据后的协议对象,并在新请求中把响应链重新序列化给一个新的 `--no-session` 进程。 ## 有限队列和错误 同一时间最多运行 `max_concurrent_requests` 个 Command Code 实例,默认值 `1` 保持原来的单并发行为。其余 Chat Completions 和 Responses 生成请求共享同一条 FIFO 等待队列;任一执行槽释放后都会按队首顺序补齐空闲槽。默认最多等待 8 个请求,队列已满时返回 HTTP 429 和 `code: queue_full`。流式请求进入队列后会立即建立 SSE 连接;非流式请求保持等待。客户端在排队期间断开会立即移出队列,活动请求取消并结束后会推进队首请求。`timeout_seconds` 从请求取得执行位置后开始计算。 -请求体超过 `max_request_bytes` 返回 413。未知模型和非文本内容返回 4xx。CLI 的详细错误留在服务终端,客户端只收到简洁的 OpenAI 格式错误。 +请求体超过 `max_request_bytes` 返回 413。未知模型、未声明图片能力、无效 base64、MIME/魔数不一致、超限附件和不支持的输入来源返回 4xx,并带准确 `param`。CLI 的详细错误留在服务终端,客户端只收到简洁的 OpenAI 格式错误。 一次 CLI 异常不会结束 HTTP 服务,后续请求仍可继续。 ## 本机实际检查结果 -检查日期:Chat Completions 原有检查为 2026-08-04;Responses 文本子集检查为 2026-08-05;Chat function calling 协议检查为 2026-08-06;Responses function calling、OpenCode 1.18.15 Chat Completions E2E 与可配置并发检查为 2026-08-12。没有编写测试用例。原有条目来自真实 CLI 和真实 HTTP/SDK 客户端;可配置并发改动完成了类型检查、生产构建和真实 Command Code 多进程 HTTP 冒烟检查。Chat function calling 完成了内部协议冒烟检查、Obsidian Copilot 当前依赖 `@langchain/openai 1.2.2` 的双轮 wire compatibility 检查,以及真实 Command Code 与 OpenAI Node.js SDK 的双轮 HTTP/SSE 调用;尚未在 Obsidian UI 中运行完整检查。Responses function calling 完成了类型检查、生产构建、真实 HTTP 双轮检查、流式 function call SSE 检查和 OpenAI Node.js SDK 双轮检查。OpenCode 1.18.15 完成了真实 `opencode run` 文本回复、`glob` 工具循环、并行 `glob`、流式 `tool_calls`/`stop` SSE、`store: false` 与 `max_tokens` 接受、工具 Schema `$schema` 兼容,以及客户端断开后的取消恢复检查。 +检查日期:Chat Completions 原有检查为 2026-08-04;Responses 文本子集检查为 2026-08-05;Chat function calling 协议检查为 2026-08-06;Responses function calling、OpenCode 1.18.15 Chat Completions E2E、可配置并发和 Command Code 1.18.0 非文本输入检查为 2026-08-12。没有编写测试用例。非文本输入先由真实 CLI 图片路径检查确认,再完成类型检查、生产构建和 Bridge HTTP 冒烟检查。 - TypeScript 严格类型检查和生产构建通过。 - npm 生产依赖审计:0 个已知漏洞。 -- `/health` 返回 Command Code v1.10.0、已登录、空闲。 +- `/health` 返回 Command Code v1.18.0、已登录、空闲。 - `max_concurrent_requests: 2` 时实际观察到 2 个 Command Code CLI 子进程并行运行,后续请求保持 FIFO;取消排队请求会移出队列,取消活动请求后队首请求会被推进,最终健康状态恢复为 `active_requests: 0`、`queue_length: 0`。 - `/v1/models` 返回两个本地映射。 - curl 中文请求返回 HTTP 200,客户端只收到 `中文接口成功` 和标准 completion 字段。 -- 字符串 content 与 text part 数组均通过;非文本 part 返回 HTTP 400。 +- 字符串 content 与 text part 数组均通过;不支持的非文本 part 返回 HTTP 400。 - system、user、assistant、user 完整历史检查返回了历史 assistant 中的 `蓝鲸-42`。 - 约 96 KiB 的中文消息通过 stdin 完整传入并返回 `长文本回退成功`,没有经过命令行参数。 - read_file 工具调用、参数和文件结果显示在服务终端,API 只返回 package name。 @@ -391,7 +421,7 @@ command-code \ - OpenAI Node.js SDK 成功消费 Responses SSE;事件顺序、递增 sequence number、多个 text delta 和最终文本均正确。 - 强制 `read_file` 的两 turn 请求只向 SSE 输出第二个无工具 turn 的 6 个文本 delta,中间工具轮次没有污染 `output_text`。 - `store: false` 的流式 Response 随后查询返回 404;删除已保存 Response 返回 deleted,随后查询返回 404。 -- `input_image` 返回 400 `unsupported_parameter`,并包含准确的参数路径。 +- 真实 `-p --output-format json` 图片路径检查中,Kimi K2.7 Code 和 GPT-5.6 Luna 都调用 `read_file` 并正确识别四象限颜色。 - 真实 `max_turns: 1` 工具请求返回 200 `incomplete/max_turns`;1 秒总超时返回 200 `incomplete/timeout`;主动取消返回 499 `cancelled`。 - 取消后紧接着的旧 Chat Completions 请求返回 `Chat恢复成功`,证明共享执行状态和错误恢复正常。 - Responses 第一轮 `tool_choice: required` 返回 `function_call`,`output_text` 为空;第二轮提交 `function_call_output` 和 `previous_response_id` 后返回最终 message 文本。 @@ -406,10 +436,10 @@ command-code \ - headless 无法在服务终端进行批准、拒绝、选项选择或文字回答;`ask_user_question` 不能由等待中的 HTTP 客户端处理。 - 终端事件渲染由本项目完成,格式接近日志,无法等同原始 TUI。 - 普通文本 Chat Completions 会实时转发 Command Code 文本 delta;外部 function calling 以及 Chat/Responses 结构化输出需要等待 Bridge 校验完成。 -- Chat Completions 和 Responses 都只实现外部 function tools,不实现图片、音频、文件输入、Computer Use、web search 等 hosted tools 或 MCP hosted tool。非 function 工具类型返回 400 `unsupported_parameter`。Responses 不实现原生 reasoning item、加密 reasoning 或隐藏思维过程。 +- Chat Completions 支持受限图片 data URL;Responses 还支持受限 UTF-8 `input_file.file_data`。不实现远程/本地路径附件、PDF/Office/任意二进制、音频、视频、Computer Use、web search 等 hosted tools 或 MCP hosted tool。非 function 工具类型返回 400 `unsupported_parameter`。Responses 不实现原生 reasoning item、加密 reasoning 或隐藏思维过程。 - Chat 与 Responses 的外部 function calling 都是 Bridge 通过提示协议、JSON 解析、工具参数 Schema 校验和一次修复实现的兼容层;Command Code CLI 没有公开原生 function calling 输出接口,因此连续两次输出不合格时返回 HTTP 502 `invalid_tool_decision`。 - Responses 的本地文件存储只供本 Bridge 使用,没有跨进程锁或多实例一致性保证;并发请求应避免同时更新同一条 Response 链。 - Chat 和 Responses Structured Outputs 都是 Bridge 层约束,底层 Command Code 模型仍可能连续两次输出不合格 JSON;Chat 此时返回 `invalid_structured_output`,Responses 状态为 incomplete。 - `usage` 使用 Command Code 最终结果提供的真实 input/output token;Responses 缺失时返回 `null`,Chat Completions 为兼容旧行为返回 0。 - 长文本受 HTTP 请求体上限和 Command Code 模型上下文上限共同限制,不会由桥接服务自行截断。 -- v1.10.0 的大输入会让 `run_end.nextState` 重复完整提示词。实测约 96 KiB 中文输入时,CLI 退出前可能截断该大事件并丢掉紧随其后的 compact `result` 行。桥接服务会忽略冗余 `run_end`,优先使用 `result.finalText`;若退出码为 0 且 result 缺失,只使用最后一个已完整结束、无工具调用的结构化 turn 文本和真实 turn usage,不从 TUI 文本解析。 +- 大输入会让 `run_end.nextState` 重复完整提示词。实测约 96 KiB 中文输入时,CLI 退出前可能截断该大事件并丢掉紧随其后的 compact `result` 行。桥接服务会忽略冗余 `run_end`,优先使用 `result.finalText`;若退出码为 0 且 result 缺失,只使用最后一个已完整结束、无工具调用的结构化 turn 文本和真实 turn usage,不从 TUI 文本解析。 diff --git a/config.example.yaml b/config.example.yaml index 8a1a63f..41a6093 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -17,6 +17,13 @@ models: command-default: cli_model: deepseek/deepseek-v4-flash effort: max + # 只有已通过真实 CLI 图像路径检查的映射才能设为 true。 + supports_image_input: false command-fast: cli_model: deepseek/deepseek-v4-flash effort: low + supports_image_input: false + command-vision: + cli_model: gpt-5.6-luna + effort: max + supports_image_input: true diff --git a/config.yaml b/config.yaml index 4f298d9..c36712b 100644 --- a/config.yaml +++ b/config.yaml @@ -17,12 +17,16 @@ models: command-default: cli_model: deepseek/deepseek-v4-flash effort: max + supports_image_input: false command-flash: cli_model: deepseek/deepseek-v4-flash effort: max + supports_image_input: false command-pro: cli_model: deepseek/deepseek-v4-pro effort: max + supports_image_input: false command-luna: cli_model: gpt-5.6-luna - effort: max \ No newline at end of file + effort: max + supports_image_input: true \ No newline at end of file diff --git a/examples/opencode.json b/examples/opencode.json new file mode 100644 index 0000000..d8b4cea --- /dev/null +++ b/examples/opencode.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "command-bridge": { + "npm": "@ai-sdk/openai-compatible", + "name": "Command Code Bridge", + "options": { + "baseURL": "http://127.0.0.1:18000/v1", + "apiKey": "local-bridge" + }, + "models": { + "command-default": { + "name": "Command Code Default", + "modalities": { + "input": ["text"], + "output": ["text"] + } + }, + "command-luna": { + "name": "Command Code Luna (image data URLs)", + "modalities": { + "input": ["text", "image"], + "output": ["text"] + } + } + } + } + } +} diff --git a/src/attachments.ts b/src/attachments.ts new file mode 100644 index 0000000..1774417 --- /dev/null +++ b/src/attachments.ts @@ -0,0 +1,363 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; + +export const MAX_ATTACHMENTS = 16; +export const MAX_IMAGE_BYTES = 10 * 1024 * 1024; +export const MAX_TEXT_FILE_BYTES = 5 * 1024 * 1024; +export const MAX_TOTAL_ATTACHMENT_BYTES = 15 * 1024 * 1024; + +const imageExtensions = new Map([ + ["image/png", "png"], + ["image/jpeg", "jpg"], + ["image/gif", "gif"], + ["image/webp", "webp"], +]); + +const textMimeExtensions = new Map([ + ["text/plain", "txt"], + ["text/markdown", "md"], + ["text/csv", "csv"], + ["text/tab-separated-values", "tsv"], + ["text/html", "html"], + ["text/xml", "xml"], + ["text/yaml", "yaml"], + ["text/javascript", "js"], + ["application/json", "json"], + ["application/ld+json", "json"], + ["application/xml", "xml"], + ["application/yaml", "yaml"], + ["application/x-yaml", "yaml"], + ["application/javascript", "js"], + ["application/sql", "sql"], +]); + +const textExtensionMimes = new Map([ + ["txt", "text/plain"], + ["md", "text/markdown"], + ["markdown", "text/markdown"], + ["csv", "text/csv"], + ["tsv", "text/tab-separated-values"], + ["html", "text/html"], + ["htm", "text/html"], + ["xml", "application/xml"], + ["json", "application/json"], + ["jsonl", "application/json"], + ["yaml", "application/yaml"], + ["yml", "application/yaml"], + ["toml", "text/plain"], + ["ini", "text/plain"], + ["cfg", "text/plain"], + ["conf", "text/plain"], + ["log", "text/plain"], + ["env", "text/plain"], + ["js", "application/javascript"], + ["mjs", "application/javascript"], + ["cjs", "application/javascript"], + ["jsx", "text/plain"], + ["ts", "text/plain"], + ["tsx", "text/plain"], + ["py", "text/plain"], + ["rb", "text/plain"], + ["go", "text/plain"], + ["rs", "text/plain"], + ["java", "text/plain"], + ["c", "text/plain"], + ["h", "text/plain"], + ["cpp", "text/plain"], + ["hpp", "text/plain"], + ["cs", "text/plain"], + ["php", "text/plain"], + ["sh", "text/plain"], + ["bash", "text/plain"], + ["zsh", "text/plain"], + ["sql", "application/sql"], +]); + +export type AttachmentErrorCode = + | "invalid_attachment" + | "attachment_too_large" + | "unsupported_media_type" + | "unsupported_parameter"; + +export class AttachmentInputError extends Error { + constructor( + message: string, + readonly code: AttachmentErrorCode, + readonly param: string, + ) { + super(message); + this.name = "AttachmentInputError"; + } +} + +export interface PendingAttachment { + param: string; + kind: "image" | "text_file"; + bytes: Buffer; + mimeType: string; + extension: string; + filename?: string; +} + +export interface AttachmentReference { + param: string; + kind: "image" | "text_file"; + mimeType: string; + pathToken: string; + filename?: string; +} + +export interface MaterializedAttachments { + references: ReadonlyMap; + cleanup: () => Promise; +} + +interface ParsedDataUrl { + mimeType: string; + bytes: Buffer; +} + +export function decodeImageDataUrl(value: string, param: string): PendingAttachment { + if (!value.startsWith("data:")) { + throw new AttachmentInputError( + "Remote and local-path image URLs are unsupported; use a base64 image data URL", + "unsupported_parameter", + param, + ); + } + + const parsed = parseBase64DataUrl(value, param); + if (!imageExtensions.has(parsed.mimeType)) { + throw new AttachmentInputError( + "Supported image MIME types are image/png, image/jpeg, image/gif, and image/webp", + "unsupported_media_type", + param, + ); + } + ensureSize(parsed.bytes, MAX_IMAGE_BYTES, param, "Image"); + + const detectedMime = detectImageMime(parsed.bytes); + if (!detectedMime || detectedMime !== parsed.mimeType) { + throw new AttachmentInputError( + "Image data does not match its declared MIME type", + "unsupported_media_type", + param, + ); + } + + return { + param, + kind: "image", + bytes: parsed.bytes, + mimeType: detectedMime, + extension: imageExtensions.get(detectedMime)!, + }; +} + +export function decodeTextFileData( + value: string, + filename: string, + param: string, +): PendingAttachment { + const safeFilename = path.basename(filename); + const fileExtension = path.extname(safeFilename).slice(1).toLowerCase(); + let mimeType: string; + let bytes: Buffer; + + if (value.startsWith("data:")) { + const parsed = parseBase64DataUrl(value, param); + mimeType = parsed.mimeType; + bytes = parsed.bytes; + if (!isTextMime(mimeType)) { + throw new AttachmentInputError( + "input_file only supports UTF-8 plain-text, source-code, JSON, XML, YAML, CSV, Markdown, and HTML data", + "unsupported_media_type", + param, + ); + } + } else { + mimeType = textExtensionMimes.get(fileExtension) ?? ""; + if (mimeType === "") { + throw new AttachmentInputError( + "Raw base64 input_file data requires a recognized plain-text filename extension", + "unsupported_media_type", + param, + ); + } + bytes = decodeBase64(value, param); + } + + ensureSize(bytes, MAX_TEXT_FILE_BYTES, param, "Text file"); + try { + const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + if (text.includes("\0")) throw new Error("NUL byte"); + } catch { + throw new AttachmentInputError( + "input_file data must be valid UTF-8 text without NUL bytes", + "unsupported_media_type", + param, + ); + } + + const extension = textExtensionMimes.has(fileExtension) + ? fileExtension + : textMimeExtensions.get(mimeType) ?? "txt"; + return { + param, + kind: "text_file", + bytes, + mimeType, + extension, + filename: safeFilename, + }; +} + +export function validateAttachmentBatch( + attachments: PendingAttachment[], + param = "input", +): void { + if (attachments.length > MAX_ATTACHMENTS) { + throw new AttachmentInputError( + `At most ${MAX_ATTACHMENTS} attachments are supported per request`, + "invalid_attachment", + param, + ); + } + const totalBytes = attachments.reduce((total, attachment) => total + attachment.bytes.length, 0); + if (totalBytes > MAX_TOTAL_ATTACHMENT_BYTES) { + throw new AttachmentInputError( + `Total decoded attachment data exceeds ${MAX_TOTAL_ATTACHMENT_BYTES} bytes`, + "attachment_too_large", + param, + ); + } +} + +export async function materializeAttachments( + workingDirectory: string, + attachments: PendingAttachment[], +): Promise { + if (attachments.length === 0) { + return { references: new Map(), cleanup: async () => {} }; + } + + const root = path.join(workingDirectory, ".command-code-openai-bridge", "inputs"); + await mkdir(root, { recursive: true, mode: 0o700 }); + const directory = await mkdtemp(path.join(root, "request-")); + const references = new Map(); + let cleaned = false; + + try { + for (const [index, attachment] of attachments.entries()) { + const filePath = path.join(directory, `${index}.${attachment.extension}`); + await writeFile(filePath, attachment.bytes, { flag: "wx", mode: 0o600 }); + const relative = path.relative(workingDirectory, filePath); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error("Attachment path escaped the Command Code workspace"); + } + references.set(attachment.param, { + param: attachment.param, + kind: attachment.kind, + mimeType: attachment.mimeType, + pathToken: `@${relative.split(path.sep).join("/")}`, + ...(attachment.filename ? { filename: attachment.filename } : {}), + }); + } + } catch (error) { + await rm(directory, { recursive: true, force: true }); + throw error; + } + + return { + references, + cleanup: async () => { + if (cleaned) return; + cleaned = true; + await rm(directory, { recursive: true, force: true }); + }, + }; +} + +export function attachmentPromptBlock( + references: ReadonlyMap, +): string | undefined { + if (references.size === 0) return undefined; + return [ + "API 附件已安全解码为当前请求专用的本地临时文件;原始 base64 或 URL 不在提示中。", + "这些路径是用户输入的一部分。需要理解附件时,必须用 read_file 读取对应路径;不得猜测附件内容,也不得读取未列出的文件。", + ...[...references.values()].map((reference) => { + const label = reference.filename ? `,原文件名 ${JSON.stringify(reference.filename)}` : ""; + return `- ${reference.param}(${reference.kind},${reference.mimeType}${label}):${reference.pathToken}`; + }), + ].join("\n"); +} + +function parseBase64DataUrl(value: string, param: string): ParsedDataUrl { + const comma = value.indexOf(","); + if (comma < 0) { + throw new AttachmentInputError("Malformed data URL", "invalid_attachment", param); + } + const metadata = value.slice(5, comma); + const segments = metadata.split(";"); + const mimeType = (segments.shift() ?? "").toLowerCase(); + const parameters = segments.map((segment) => segment.toLowerCase()); + if (mimeType === "" || !parameters.includes("base64")) { + throw new AttachmentInputError( + "Attachment data URLs must include an explicit MIME type and ;base64", + "invalid_attachment", + param, + ); + } + const unsupportedParameter = parameters.find((segment) => ( + segment !== "base64" && segment !== "charset=utf-8" && segment !== "charset=utf8" + )); + if (unsupportedParameter) { + throw new AttachmentInputError("Unsupported data URL parameter", "invalid_attachment", param); + } + return { + mimeType, + bytes: decodeBase64(value.slice(comma + 1), param), + }; +} + +function decodeBase64(value: string, param: string): Buffer { + if ( + value === "" + || value.length % 4 !== 0 + || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value) + ) { + throw new AttachmentInputError("Attachment data is not valid base64", "invalid_attachment", param); + } + return Buffer.from(value, "base64"); +} + +function ensureSize(bytes: Buffer, maximum: number, param: string, label: string): void { + if (bytes.length > maximum) { + throw new AttachmentInputError( + `${label} exceeds the ${maximum}-byte decoded size limit`, + "attachment_too_large", + param, + ); + } +} + +function detectImageMime(bytes: Buffer): string | undefined { + if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) { + return "image/png"; + } + if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { + return "image/jpeg"; + } + const header = bytes.subarray(0, 6).toString("ascii"); + if (header === "GIF87a" || header === "GIF89a") return "image/gif"; + if ( + bytes.length >= 12 + && bytes.subarray(0, 4).toString("ascii") === "RIFF" + && bytes.subarray(8, 12).toString("ascii") === "WEBP" + ) return "image/webp"; + return undefined; +} + +function isTextMime(mimeType: string): boolean { + return textMimeExtensions.has(mimeType); +} diff --git a/src/command-code.ts b/src/command-code.ts index 1172dbc..dad95c7 100644 --- a/src/command-code.ts +++ b/src/command-code.ts @@ -224,7 +224,9 @@ export async function runCommandCode( } } catch (error) { parseError = error instanceof Error ? error : new Error(String(error)); - process.stderr.write(`\n无法解析 Command Code NDJSON:${line.slice(0, 500)}\n`); + process.stderr.write( + `\n无法解析 Command Code NDJSON(${line.length} 字符):${parseError.name}\n`, + ); } }); diff --git a/src/config.ts b/src/config.ts index 43164a8..0bd7d56 100644 --- a/src/config.ts +++ b/src/config.ts @@ -6,6 +6,7 @@ import { z } from "zod"; const modelSchema = z.object({ cli_model: z.string().min(1), effort: z.string().min(1), + supports_image_input: z.boolean().default(false), }); const configSchema = z.object({ diff --git a/src/openai.ts b/src/openai.ts index ccbc3ee..0822688 100644 --- a/src/openai.ts +++ b/src/openai.ts @@ -1,12 +1,32 @@ import { randomUUID } from "node:crypto"; import { z } from "zod"; +import { + attachmentPromptBlock, + AttachmentInputError, + decodeImageDataUrl, + validateAttachmentBatch, + type AttachmentReference, + type PendingAttachment, +} from "./attachments.js"; const textPartSchema = z.object({ type: z.literal("text"), text: z.string(), }).strict(); +const imageUrlPartSchema = z.object({ + type: z.literal("image_url"), + image_url: z.object({ + url: z.string().min(1), + detail: z.enum(["auto", "low", "high"]).optional(), + }).strict(), +}).strict(); + const messageContentSchema = z.union([z.string(), z.array(textPartSchema)]); +const userMessageContentSchema = z.union([ + z.string(), + z.array(z.discriminatedUnion("type", [textPartSchema, imageUrlPartSchema])).min(1), +]); const messageNameSchema = z.string().min(1).max(64); const functionNameSchema = z.string().min(1).max(64).regex(/^[A-Za-z0-9_-]+$/); @@ -33,7 +53,7 @@ const developerMessageSchema = z.object({ const userMessageSchema = z.object({ role: z.literal("user"), - content: messageContentSchema, + content: userMessageContentSchema, name: messageNameSchema.optional(), }).strict(); @@ -194,13 +214,45 @@ export function ignoredChatCompatibilityParameters(request: ChatCompletionReques return ignoredCompatibilityParameters.filter((key) => request[key] !== undefined); } -export function normalizeMessages(request: ChatCompletionRequest) { - return request.messages.map((message) => { +export function chatInputAttachments(request: ChatCompletionRequest): PendingAttachment[] { + const attachments: PendingAttachment[] = []; + for (const [messageIndex, message] of request.messages.entries()) { + if (message.role !== "user" || typeof message.content === "string") continue; + for (const [partIndex, part] of message.content.entries()) { + if (part.type !== "image_url") continue; + if (part.image_url.detail !== undefined && part.image_url.detail !== "auto") { + throw new AttachmentInputError( + "Command Code does not expose image detail controls; omit detail or use 'auto'", + "unsupported_parameter", + `messages.${messageIndex}.content.${partIndex}.image_url.detail`, + ); + } + attachments.push(decodeImageDataUrl( + part.image_url.url, + `messages.${messageIndex}.content.${partIndex}.image_url.url`, + )); + } + } + validateAttachmentBatch(attachments, "messages"); + return attachments; +} + +export function normalizeMessages( + request: ChatCompletionRequest, + attachmentReferences: ReadonlyMap = new Map(), +) { + return request.messages.map((message, messageIndex) => { const content = message.content === null ? null : typeof message.content === "string" ? message.content - : message.content.map((part) => part.text).join(""); + : message.content.map((part, partIndex) => { + if (part.type === "text") return part.text; + const param = `messages.${messageIndex}.content.${partIndex}.image_url.url`; + const reference = attachmentReferences.get(param); + if (!reference) throw new Error(`Missing materialized attachment for ${param}`); + return `\n[图片输入:${reference.pathToken}]\n`; + }).join(""); if (message.role === "assistant") { return { @@ -247,15 +299,19 @@ export function usesChatStructuredOutput(request: ChatCompletionRequest): boolea return request.response_format.type !== "text"; } -export function buildCommandPrompt(request: ChatCompletionRequest): string { - if (usesToolCalling(request)) return buildToolCommandPrompt(request); +export function buildCommandPrompt( + request: ChatCompletionRequest, + attachmentReferences: ReadonlyMap = new Map(), +): string { + if (usesToolCalling(request)) return buildToolCommandPrompt(request, attachmentReferences); const format = chatResponseTextFormat(request); const envelope = { protocol: "openai-chat-completions-history-v1", - messages: normalizeMessages(request), + messages: normalizeMessages(request, attachmentReferences), response_format: request.response_format, }; + const attachments = attachmentPromptBlock(attachmentReferences); return [ "下面 JSON 对象的 messages 数组是外部客户端提交的完整任务。", @@ -265,27 +321,35 @@ export function buildCommandPrompt(request: ChatCompletionRequest): string { "如需调用工具,调用工具的 turn 只发起工具调用,不输出面向用户的文本;所有工具结束后,仅在不再调用工具的最终 turn 输出回答。", "不要复述 JSON,不要输出角色标签,不要添加指令未要求的解释或格式。", chatFormatDirective(format, false), + ...(attachments ? [attachments] : []), "JSON 数据开始:", JSON.stringify(envelope), ].join("\n\n"); } -function buildToolCommandPrompt(request: ChatCompletionRequest): string { +function buildToolCommandPrompt( + request: ChatCompletionRequest, + attachmentReferences: ReadonlyMap, +): string { const effectiveToolChoice = request.tool_choice ?? (request.tools ? "auto" : "none"); const format = chatResponseTextFormat(request); const envelope = { protocol: "openai-chat-completions-tools-v1", - messages: normalizeMessages(request), + messages: normalizeMessages(request, attachmentReferences), tools: request.tools ?? [], tool_choice: effectiveToolChoice, parallel_tool_calls: request.parallel_tool_calls ?? true, response_format: request.response_format, }; + const attachments = attachmentPromptBlock(attachmentReferences); + const toolBoundary = attachments + ? "除读取下方 API 附件临时路径所必需的 read_file 外,不要使用 Command Code 自身工具替代外部 tools;附件读取只是在消费用户输入。" + : "不要使用 Command Code 自身的文件、终端、搜索、网络或其他工具替代外部 tools;只依据 messages、tools 和工具结果作出决定。"; return [ "下面 JSON 对象是外部客户端提交的一次 OpenAI Chat Completions 工具调用任务。", "你只负责决定当前这一轮应该调用外部工具还是返回最终回答。外部工具由客户端执行,你不能模拟工具结果。", - "不要使用 Command Code 自身的文件、终端、搜索、网络或其他工具替代外部 tools;只依据 messages、tools 和工具结果作出决定。", + toolBoundary, "严格执行 messages 中的 system、developer 和 user 指令,并结合 assistant.tool_calls 与 tool 消息理解完整历史。", "只返回以下两个 JSON 对象之一,禁止 Markdown 代码围栏、前后说明和额外字段:", "最终回答:{\"type\":\"final\",\"content\":\"面向用户的完整回答\"}", @@ -295,6 +359,7 @@ function buildToolCommandPrompt(request: ChatCompletionRequest): string { "需要工具提供信息或执行动作时返回 tool_calls 并立即结束;已有足够信息时返回 final。不要在工具调用轮次生成面向用户的正文。", "response_format 只约束 final.content;返回 tool_calls 时不应用正文格式校验。", chatFormatDirective(format, true), + ...(attachments ? [attachments] : []), "JSON 数据开始:", JSON.stringify(envelope), ].join("\n\n"); diff --git a/src/renderer.ts b/src/renderer.ts index 4e7a3df..ac04d52 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -43,18 +43,56 @@ export function printCumulativeUsage(): void { } function details(event: Record): string { - const copy = { ...event }; + const copy = redactForTerminal(event) as Record; delete copy.type; return inspect(copy, { colors: tty, depth: 8, compact: false, breakLength: 120 }); } +function redactForTerminal(value: unknown, key = ""): unknown { + if (typeof value === "string") return redactString(value, key); + if (Array.isArray(value)) return value.map((item) => redactForTerminal(item)); + if (typeof value !== "object" || value === null) return value; + + const redacted: Record = {}; + for (const [childKey, childValue] of Object.entries(value)) { + if (/authorization|api[-_]?key|access[-_]?token|secret|password|cookie/i.test(childKey)) { + redacted[childKey] = "[REDACTED]"; + continue; + } + redacted[childKey] = redactForTerminal(childValue, childKey); + } + return redacted; +} + +function redactString(value: string, key: string): string { + if (value.startsWith("data:")) return `[REDACTED data URL: ${value.length} characters]`; + if ( + value.length >= 128 + && value.length % 4 === 0 + && /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value) + ) return `[REDACTED base64: ${value.length} characters]`; + if (/url|uri/i.test(key)) { + try { + const url = new URL(value); + if (url.search || url.hash || url.username || url.password) { + return `${url.protocol}//${url.host}${url.pathname}[REDACTED query/credentials]`; + } + } catch { + // Not an absolute URL. + } + } + return value; +} + export class TerminalRenderer { private answerStarted = false; private thinkingShown = false; + private readonly attachmentToolCalls = new Set(); begin(model: string, effort: string, proxy?: string): void { this.answerStarted = false; this.thinkingShown = false; + this.attachmentToolCalls.clear(); process.stdout.write(`\n${cyan("━━━━━━━━ Command Code 请求 ━━━━━━━━")}\n`); process.stdout.write(`${dim("模型")} ${model}\n`); process.stdout.write(`${dim("思考深度")} ${effort}\n`); @@ -63,6 +101,10 @@ export class TerminalRenderer { event(event: Record): void { const type = typeof event.type === "string" ? event.type : "unknown"; + const toolCallId = typeof event.toolCallId === "string" ? event.toolCallId : undefined; + if (toolCallId && eventReferencesApiAttachment(event)) { + this.attachmentToolCalls.add(toolCallId); + } switch (type) { case "run_start": @@ -110,7 +152,13 @@ export class TerminalRenderer { } default: if (type.includes("tool") || type.includes("permission") || type.includes("question")) { - process.stdout.write(`${yellow(`\n[${type}]`)}\n${details(event)}\n`); + const displayedEvent = toolCallId && this.attachmentToolCalls.has(toolCallId) && type === "tool_completed" + ? { ...event, result: "[REDACTED API attachment content]" } + : event; + process.stdout.write(`${yellow(`\n[${type}]`)}\n${details(displayedEvent)}\n`); + if (toolCallId && (type === "tool_completed" || type === "tool_failed")) { + this.attachmentToolCalls.delete(toolCallId); + } } } } @@ -129,3 +177,14 @@ export class TerminalRenderer { process.stderr.write(`${color(31, `\nCommand Code 失败:${message}`)}\n`); } } + +function eventReferencesApiAttachment(event: Record): boolean { + if (typeof event.input !== "object" || event.input === null || Array.isArray(event.input)) return false; + const input = event.input as Record; + const filePath = typeof input.file_path === "string" + ? input.file_path + : typeof input.absolute_path === "string" + ? input.absolute_path + : undefined; + return filePath?.replaceAll("\\", "/").includes("/.command-code-openai-bridge/inputs/request-") ?? false; +} diff --git a/src/responses.ts b/src/responses.ts index 8c1903a..cc34fe3 100644 --- a/src/responses.ts +++ b/src/responses.ts @@ -5,6 +5,17 @@ import type { ServerResponse } from "node:http"; import { Ajv, type ErrorObject, type ValidateFunction } from "ajv"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z, ZodError } from "zod"; +import { + attachmentPromptBlock, + AttachmentInputError, + decodeImageDataUrl, + decodeTextFileData, + materializeAttachments, + validateAttachmentBatch, + type AttachmentReference, + type MaterializedAttachments, + type PendingAttachment, +} from "./attachments.js"; import { CommandCodeError, runCommandCode, @@ -43,12 +54,55 @@ const outputTextPartSchema = z.object({ text: z.string(), }).strict(); +const inputImagePartSchema = z.object({ + type: z.literal("input_image"), + image_url: z.string().min(1).optional(), + file_id: z.string().min(1).optional(), + detail: z.enum(["auto", "low", "high"]).optional(), +}).strict().superRefine((part, context) => { + if ((part.image_url === undefined) === (part.file_id === undefined)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "input_image requires exactly one of image_url or file_id", + }); + } +}); + +const inputFilePartSchema = z.object({ + type: z.literal("input_file"), + file_data: z.string().min(1).optional(), + file_url: z.string().min(1).optional(), + file_id: z.string().min(1).optional(), + filename: z.string().min(1).max(255).optional(), +}).strict().superRefine((part, context) => { + const sourceCount = [part.file_data, part.file_url, part.file_id] + .filter((source) => source !== undefined).length; + if (sourceCount !== 1) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "input_file requires exactly one of file_data, file_url, or file_id", + }); + } + if (part.file_data !== undefined && part.filename === undefined) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["filename"], + message: "filename is required with file_data", + }); + } +}); + const inputMessageSchema = z.object({ type: z.literal("message").optional(), role: roleSchema, content: z.union([ z.string(), - z.array(z.union([inputTextPartSchema, outputTextPartSchema])).min(1), + z.array(z.union([ + inputTextPartSchema, + outputTextPartSchema, + inputImagePartSchema, + inputFilePartSchema, + ])).min(1), ]), }).strict(); @@ -771,10 +825,12 @@ export async function registerResponseRoutes( let parsed: ResponseRequest; let validation: StructuredValidation | undefined; + let pendingAttachments: PendingAttachment[]; try { parsed = parseResponseRequest(request.body); validation = createStructuredValidation(parsed.text.format); validateResponseToolRequest(parsed); + pendingAttachments = responseInputAttachments(parsed); } catch (error) { return sendRouteError(reply, error); } @@ -788,6 +844,14 @@ export async function registerResponseRoutes( "model", )); } + const firstImage = pendingAttachments.find((attachment) => attachment.kind === "image"); + if (firstImage && !model.supports_image_input) { + return sendRouteError(reply, new AttachmentInputError( + `Model '${parsed.model}' is not configured for image input`, + "unsupported_parameter", + firstImage.param, + )); + } const lease = coordinator.begin(); if (!lease) return sendBusy(reply); @@ -795,6 +859,7 @@ export async function registerResponseRoutes( let responseCompleted = false; let acquired = false; + let materialized: MaterializedAttachments | undefined; const cancelOnDisconnect = () => { if (!responseCompleted) coordinator.cancel(abortController, "client disconnected"); }; @@ -827,12 +892,16 @@ export async function registerResponseRoutes( responseCompleted = true; return reply; } + materialized = await materializeAttachments( + config.resolvedWorkingDirectory, + pendingAttachments, + ); const chain = parsed.previous_response_id ? await store.loadChain(parsed.previous_response_id) : []; validateResponseFunctionHistory(parsed, chain, inputItems); - const prompt = buildResponsesPrompt(parsed, chain, inputItems); + const prompt = buildResponsesPrompt(parsed, chain, inputItems, materialized.references); const deadline = Date.now() + config.timeout_seconds * 1000; const toolRequest = responseFunctionToolDecisionRequest(parsed); @@ -918,6 +987,11 @@ export async function registerResponseRoutes( } finally { request.raw.off("aborted", cancelOnDisconnect); reply.raw.off("close", cancelOnDisconnect); + try { + await materialized?.cleanup(); + } catch { + process.stderr.write("警告:无法清理本次请求的临时附件目录。\n"); + } if (!acquired) coordinator.cancel(abortController, "request ended before execution"); coordinator.finish(abortController); } @@ -1006,7 +1080,12 @@ function findUnsupportedParameter(body: unknown): string | undefined { if (!Array.isArray(item.content)) continue; for (const [partIndex, part] of item.content.entries()) { if (!isRecord(part)) continue; - if (part.type !== "input_text" && part.type !== "output_text") { + if ( + part.type !== "input_text" + && part.type !== "output_text" + && part.type !== "input_image" + && part.type !== "input_file" + ) { return `input.${itemIndex}.content.${partIndex}.type`; } } @@ -1014,6 +1093,61 @@ function findUnsupportedParameter(body: unknown): string | undefined { return undefined; } +function responseInputAttachments(request: ResponseRequest): PendingAttachment[] { + if (typeof request.input === "string") return []; + const attachments: PendingAttachment[] = []; + for (const [itemIndex, item] of request.input.entries()) { + if (item.type === "function_call" || item.type === "function_call_output") continue; + if (typeof item.content === "string") continue; + for (const [partIndex, part] of item.content.entries()) { + const baseParam = `input.${itemIndex}.content.${partIndex}`; + if (part.type === "input_image") { + if (part.file_id !== undefined) { + throw new AttachmentInputError( + "file_id image inputs require an OpenAI Files service, which this bridge does not implement", + "unsupported_parameter", + `${baseParam}.file_id`, + ); + } + if (part.detail !== undefined && part.detail !== "auto") { + throw new AttachmentInputError( + "Command Code does not expose image detail controls; omit detail or use 'auto'", + "unsupported_parameter", + `${baseParam}.detail`, + ); + } + attachments.push(decodeImageDataUrl( + part.image_url!, + `${baseParam}.image_url`, + )); + continue; + } + if (part.type !== "input_file") continue; + if (part.file_url !== undefined) { + throw new AttachmentInputError( + "Remote file URLs are not fetched; provide UTF-8 text through file_data", + "unsupported_parameter", + `${baseParam}.file_url`, + ); + } + if (part.file_id !== undefined) { + throw new AttachmentInputError( + "file_id inputs require an OpenAI Files service, which this bridge does not implement", + "unsupported_parameter", + `${baseParam}.file_id`, + ); + } + attachments.push(decodeTextFileData( + part.file_data!, + part.filename!, + `${baseParam}.file_data`, + )); + } + } + validateAttachmentBatch(attachments); + return attachments; +} + function normalizeInput(input: ResponseRequest["input"]): ResponseInputItem[] { if (typeof input === "string") { return [{ @@ -1050,7 +1184,11 @@ function normalizeInput(input: ResponseRequest["input"]): ResponseInputItem[] { role: item.role, content: typeof item.content === "string" ? [{ type: item.role === "assistant" ? "output_text" : "input_text", text: item.content }] - : item.content.map((part) => ({ type: part.type, text: part.text })), + : item.content.flatMap((part) => ( + part.type === "input_text" || part.type === "output_text" + ? [{ type: part.type, text: part.text }] + : [] + )), }; }); } @@ -1059,6 +1197,7 @@ function buildResponsesPrompt( request: ResponseRequest, chain: StoredResponse[], currentInput: ResponseInputItem[], + attachmentReferences: ReadonlyMap, ): string { const history: Array = []; for (const stored of chain) { @@ -1076,12 +1215,16 @@ function buildResponsesPrompt( tool_choice: effectiveResponseToolChoice(request), parallel_tool_calls: request.parallel_tool_calls ?? true, }; + const attachments = attachmentPromptBlock(attachmentReferences); if (usesResponseToolCalling(request, history)) { + const toolBoundary = attachments + ? "除读取下方 API 附件临时路径所必需的 read_file 外,不要使用 Command Code 自身工具替代外部 tools;附件读取只是在消费用户输入。" + : "不要使用 Command Code 自身的文件、终端、搜索、网络或其他工具替代外部 tools;只依据 input、tools 和 function_call_output 作出决定。"; return [ "下面 JSON 对象是外部客户端提交的一次 OpenAI Responses 工具调用任务,input 包含完整历史。", "你只负责决定当前这一轮应该调用外部工具还是返回最终回答。外部工具由客户端执行,你不能模拟工具结果。", - "不要使用 Command Code 自身的文件、终端、搜索、网络或其他工具替代外部 tools;只依据 input、tools 和 function_call_output 作出决定。", + toolBoundary, "严格执行 input 中的 system、developer、user 指令和当前 instructions,并结合 message、function_call、function_call_output 及其他已有 output item 理解完整历史。", "历史 reasoning item 只包含此前公开的 summary 文本记录,不是可恢复的内部推理状态;仅把 summary 当作普通历史上下文。", "只返回以下两个 JSON 对象之一,禁止 Markdown 代码围栏、前后说明和额外字段:", @@ -1091,6 +1234,7 @@ function buildResponsesPrompt( "tool_choice 为 none 时必须返回 final;为 required 或指定函数时必须返回 tool_calls。parallel_tool_calls 为 false 时 calls 只能有一项。", "需要工具提供信息或执行动作时返回 tool_calls 并立即结束;已有 function_call_output 且信息足够时返回 final。工具调用轮次禁止生成面向用户的正文。", formatDirective(request.text.format), + ...(attachments ? [attachments] : []), "JSON 数据开始:", JSON.stringify(envelope), ].join("\n\n"); @@ -1102,6 +1246,7 @@ function buildResponsesPrompt( "历史 reasoning item 只包含此前公开的 summary 文本记录,不是可恢复的内部推理状态;仅把 summary 当作普通历史上下文。", "严格执行最后一个用户任务。不要复述 JSON,不要输出角色标签,不要暴露内部思考。", formatDirective(request.text.format), + ...(attachments ? [attachments] : []), "JSON 数据开始:", JSON.stringify(envelope), ].join("\n\n"); @@ -1553,6 +1698,14 @@ function startSse(reply: FastifyReply, messageId: string): ResponsesSseWriter { } function sendRouteError(reply: FastifyReply, error: unknown) { + if (error instanceof AttachmentInputError) { + return reply.status(400).send(openAIError( + error.message, + error.code, + "invalid_request_error", + error.param, + )); + } if (error instanceof ResponseApiError) { return reply.status(error.statusCode).send(openAIError(error.message, error.code, error.type, error.param)); } diff --git a/src/server.ts b/src/server.ts index edf870d..ecbd516 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,5 +1,12 @@ import Fastify, { type FastifyInstance } from "fastify"; import { ZodError } from "zod"; +import { + AttachmentInputError, + materializeAttachments, + type AttachmentReference, + type MaterializedAttachments, + type PendingAttachment, +} from "./attachments.js"; import { startChatCompletionSse, type ChatCompletionSseWriter } from "./chat-stream.js"; import type { BridgeConfig } from "./config.js"; import { @@ -15,6 +22,7 @@ import { buildToolStructuredOutputRepairPrompt, chatResponseTextFormat, chatCompletionRequestSchema, + chatInputAttachments, completionResponse, ignoredChatCompatibilityParameters, openAIError, @@ -116,6 +124,10 @@ export async function createServer(config: BridgeConfig): Promise object: "model", created: 0, owned_by: "command-code-local", + capabilities: { + input: config.models[id]!.supports_image_input ? ["text", "image"] : ["text"], + output: ["text"], + }, })), })); @@ -181,6 +193,28 @@ export async function createServer(config: BridgeConfig): Promise )); } + let pendingAttachments: PendingAttachment[]; + try { + pendingAttachments = chatInputAttachments(parsed); + if (pendingAttachments.length > 0 && !model.supports_image_input) { + throw new AttachmentInputError( + `Model '${parsed.model}' is not configured for image input`, + "unsupported_parameter", + pendingAttachments[0]!.param, + ); + } + } catch (error) { + if (error instanceof AttachmentInputError) { + return reply.status(400).send(openAIError( + error.message, + error.code, + "invalid_request_error", + error.param, + )); + } + throw error; + } + const toolSchemaErrors = validateToolSchemas(parsed); if (toolSchemaErrors.length > 0) { return reply.status(400).send(openAIError( @@ -211,6 +245,7 @@ export async function createServer(config: BridgeConfig): Promise const { controller: abortController } = lease; let responseCompleted = false; let acquired = false; + let materialized: MaterializedAttachments | undefined; const cancelOnDisconnect = () => { if (!responseCompleted) coordinator.cancel(abortController, "client disconnected"); @@ -233,6 +268,10 @@ export async function createServer(config: BridgeConfig): Promise responseCompleted = true; return reply; } + materialized = await materializeAttachments( + config.resolvedWorkingDirectory, + pendingAttachments, + ); const toolMode = usesToolCalling(parsed); const execution = await executeChatTurn({ config, @@ -243,6 +282,7 @@ export async function createServer(config: BridgeConfig): Promise deadline: Date.now() + config.timeout_seconds * 1000, toolMode, format: responseFormat, + attachmentReferences: materialized.references, ...(structuredValidation ? { validation: structuredValidation } : {}), ...(writer ? { writer } : {}), }); @@ -351,6 +391,11 @@ export async function createServer(config: BridgeConfig): Promise } finally { request.raw.off("aborted", cancelOnDisconnect); reply.raw.off("close", cancelOnDisconnect); + try { + await materialized?.cleanup(); + } catch { + process.stderr.write("警告:无法清理本次请求的临时附件目录。\n"); + } if (!acquired) coordinator.cancel(abortController, "request ended before execution"); coordinator.finish(abortController); } @@ -379,6 +424,7 @@ interface ChatTurnOptions { deadline: number; toolMode: boolean; format: ResponseTextFormat; + attachmentReferences: ReadonlyMap; validation?: StructuredValidation; writer?: ChatCompletionSseWriter; } @@ -391,7 +437,7 @@ class ChatStructuredOutputError extends Error { } async function executeChatTurn(options: ChatTurnOptions): Promise { - const originalPrompt = buildCommandPrompt(options.request); + const originalPrompt = buildCommandPrompt(options.request, options.attachmentReferences); const constrained = options.toolMode || options.validation !== undefined; const first = await runCommandCode( options.config,