From bd18678963bb871b16a3bc312669094dc5b73803 Mon Sep 17 00:00:00 2001 From: Sirius Date: Wed, 5 Aug 2026 17:37:41 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81CROS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 ++++---- src/chat-stream.ts | 12 +++++++++++- src/openai.ts | 1 + src/server.ts | 8 ++++++++ 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 9ab4ec9..489a7a7 100644 --- a/README.md +++ b/README.md @@ -136,11 +136,11 @@ Bearer Token 会被忽略。所有生成接口只接受文本。图片、音频 ### Chat Completions -支持字符串 `content`,也支持由 `{ "type": "text", "text": "..." }` 组成的数组。`stream` 省略或设为 `false` 时返回普通 JSON。`stream: true` 会立即建立 SSE 连接并发送 assistant role 块,随后发送最终回答轮次的原始文本 delta、`finish_reason: "stop"`、可选 usage 块和 `[DONE]`。传入 `stream_options.include_usage: true` 时,结束前返回 usage 块。 +支持字符串 `content`,也支持由 `{ "type": "text", "text": "..." }` 组成的数组。`stream` 省略或设为 `false` 时返回普通 JSON。`stream: true` 会立即建立 SSE 连接并发送 assistant role 块,随后在 Command Code 的 `text_delta` 到达时立即发送对应 SSE chunk,最后发送 `finish_reason: "stop"`、可选 usage 块和 `[DONE]`。传入 `stream_options.include_usage: true` 时,结束前返回 usage 块。 为兼容 Obsidian Copilot 和 LangChain OpenAI-format 客户端,Chat 接口还接受 `temperature`、`max_tokens`、`max_completion_tokens`、`top_p`、`frequency_penalty`、`presence_penalty` 和 `n: 1`。这些采样和输出限制参数会按 OpenAI 取值范围严格校验。Command Code CLI 1.12.0 没有对应的 headless 参数,因此 Bridge 不会把它们伪装成已生效:服务终端会输出警告,HTTP 响应带 `X-Command-Code-Ignored-Parameters`。其他未实现字段仍返回 `400 unsupported_parameter`。 -Chat Completions 和 Responses 共用 Command Code NDJSON 实时事件执行层。服务终端会在事件到达时立即显示状态、文本和工具调用。由于 Command Code 只有在 `turn_end` 才给出 `hadToolCalls`,Bridge 会按 turn 缓冲客户端文本,丢弃工具轮次,在确认 `hadToolCalls: false` 后按原 delta 边界写入 Chat SSE。连接会立即建立,最终文本不会混入工具轮次内容,文本首包仍受最终 turn 完成时间约束。 +Chat Completions 和 Responses 共用 Command Code NDJSON 实时事件执行层。服务终端会在事件到达时立即显示状态、文本和工具调用。Chat Completions 会把每个 `text_delta` 直接写入 SSE,实现生成过程中的打字机效果。Bridge 提示 Command Code 在工具调用 turn 不输出面向用户的文本;Command Code 只有在 `turn_end` 才给出 `hadToolCalls`,因此上游若仍在工具 turn 输出文本,该文本已经发送,SSE 无法撤回。 ### Responses @@ -174,7 +174,7 @@ Chat Completions 和 Responses 共用 Command Code NDJSON 实时事件执行层 所有事件都包含递增的 `sequence_number`。连接使用 `no-cache, no-transform`,收到客户端断开后会终止 Command Code 子进程组。 -Command Code 在 `turn_end` 之前不能保证当前 `text_delta` 属于最终回答,因为同一 turn 随后可能产生工具调用。Bridge 按 turn 缓冲文本,丢弃 `hadToolCalls: true` 的中间轮次,在 `hadToolCalls: false` 时立即按原 delta 边界发送。因此 SSE 连接、状态事件和最终 turn 输出是真实增量事件;文本 delta 会延迟到最终 turn 边界,不能承诺逐 token 的到达时延。结构化输出还会延迟到 JSON 校验完成,避免把随后需要修复的无效 JSON 发给客户端。 +Responses API 仍按 turn 缓冲文本,丢弃 `hadToolCalls: true` 的中间轮次,在 `hadToolCalls: false` 时按原 delta 边界发送。文本 delta 会延迟到最终 turn 边界。结构化输出还会延迟到 JSON 校验完成,避免把随后需要修复的无效 JSON 发给客户端。 ### 本地存储和响应链 @@ -319,7 +319,7 @@ command-code \ - 没有原始 Command Code TUI、颜色布局、动画和键盘交互。 - headless 无法在服务终端进行批准、拒绝、选项选择或文字回答;`ask_user_question` 不能由等待中的 HTTP 客户端处理。 - 终端事件渲染由本项目完成,格式接近日志,无法等同原始 TUI。 -- Chat Completions 和 Responses 的文本 delta 都需要等到 `turn_end.hadToolCalls: false` 才发送;SSE 连接和初始事件会立即建立。Responses 结构化输出需要再等 Bridge 校验完成。 +- Chat Completions 会实时转发 Command Code 文本 delta;上游若在工具 turn 输出文本,已经发送的 SSE 内容无法撤回。Responses 的文本 delta 需要等到 `turn_end.hadToolCalls: false` 才发送,结构化输出还需要等待 Bridge 校验完成。 - 只实现 Responses 文本子集,不实现图片、音频、文件、function calling、Computer Use、托管工具、原生 reasoning item、加密 reasoning 或隐藏思维过程。 - Responses 的本地文件存储只供本 Bridge 使用,没有跨进程锁、队列或多实例一致性保证。项目本身仍严格单并发。 - Responses Structured Outputs 是 Bridge 层约束,底层 Command Code 模型仍可能连续两次输出不合格 JSON;此时状态为 incomplete。 diff --git a/src/chat-stream.ts b/src/chat-stream.ts index 221e91a..3fe8a93 100644 --- a/src/chat-stream.ts +++ b/src/chat-stream.ts @@ -21,7 +21,17 @@ export class ChatCompletionSseWriter { commandEvent(event: Record): void { const chunks = this.accumulator.event(event); - if (chunks) this.addText(chunks); + if (event.type === "text_delta" && typeof event.delta === "string") { + this.addText([event.delta]); + return; + } + + if (event.type !== "turn_end") return; + if (event.hadToolCalls === true) { + this.outputText = ""; + return; + } + if (chunks && this.outputText === "") this.addText(chunks); } finish(finalText: string, usage?: CommandUsage, includeUsage = false): void { diff --git a/src/openai.ts b/src/openai.ts index a8e2124..3c9aed5 100644 --- a/src/openai.ts +++ b/src/openai.ts @@ -67,6 +67,7 @@ export function buildCommandPrompt(request: ChatCompletionRequest): string { "严格执行 system 消息和最后一条 user 消息中的具体指令,并结合此前消息理解上下文。", "调用方可能要求改写问题、生成检索词、提取数据、分类或输出特定格式。这些属于内部处理任务,也必须严格执行。", "如果最后一条 user 消息要求改写、压缩、提取或输出指定格式,只返回要求的结果,不回答消息中包含的问题。", + "如需调用工具,调用工具的 turn 只发起工具调用,不输出面向用户的文本;所有工具结束后,仅在不再调用工具的最终 turn 输出回答。", "不要复述 JSON,不要输出角色标签,不要添加指令未要求的解释或格式。", "JSON 数据开始:", JSON.stringify(envelope), diff --git a/src/server.ts b/src/server.ts index 6cbd70e..cbb26c1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -33,6 +33,14 @@ export async function createServer(config: BridgeConfig): Promise }); const coordinator = new RequestCoordinator(); + app.addHook("onRequest", async (_request, reply) => { + reply.raw.setHeader("Access-Control-Allow-Origin", "*"); + reply.raw.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); + reply.raw.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS"); + }); + + app.options("/*", async (_request, reply) => reply.status(204).send()); + app.setErrorHandler((error, _request, reply) => { const fastifyError = error as Error & { code?: string; statusCode?: number }; if (fastifyError.code === "FST_ERR_CTP_BODY_TOO_LARGE") {