From 64469f9c1db7f414d0f045972be84322ca8f18ef Mon Sep 17 00:00:00 2001 From: Sirius Date: Wed, 5 Aug 2026 20:04:59 +0800 Subject: [PATCH] =?UTF-8?q?=E6=98=BE=E7=A4=BA=E6=80=9D=E8=80=83=E5=86=85?= =?UTF-8?q?=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 ++++-- config.example.yaml | 1 + config.yaml | 1 + src/chat-stream.ts | 68 ++++++++++++++++++++++++++++++++++++++++++++- src/config.ts | 1 + src/server.ts | 2 +- 6 files changed, 76 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 6d952e1..41b6f2b 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ command_code_working_directory: . timeout_seconds: 1800 max_request_bytes: 20971520 max_turns: 100 +stream_thinking: false response_store_directory: .command-code-openai-bridge/responses permission_mode: auto-accept dangerously_skip_permissions: true @@ -101,7 +102,7 @@ models: effort: max ``` -`command_code_working_directory` 决定 Command Code 能看到和操作的项目目录。`response_store_directory` 保存 `store: true` 的 Response;两个相对路径都以配置文件所在目录为基准。默认存储目录是隐藏目录 `.command-code-openai-bridge/responses`。`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`。`stream_thinking` 控制 Chat Completions 流是否把 Command Code 思考事件包装成 `` 块;默认关闭。`models..effort` 会传给 Command Code 的 `--effort`,Responses 请求中的 `reasoning.effort` 优先。可用 effort 由对应底层模型决定。服务强制只监听 `127.0.0.1`。客户端只能选择配置中的模型名,不能注入额外 CLI 参数。 ### Obsidian Copilot 流式输出 @@ -112,8 +113,9 @@ models: - API Key:任意非空文本 - CORS:关闭 - Streaming:开启 +- Reasoning capability:开启 -Bridge 会直接响应浏览器 CORS 预检,包括 Copilot 和 OpenAI SDK 发送的自定义请求头及本机 Private Network Access。关闭 Copilot 的 CORS 绕过后,请求使用原生 `fetch`,可以读取 Bridge 返回的 SSE 流。 +Bridge 会直接响应浏览器 CORS 预检,包括 Copilot 和 OpenAI SDK 发送的自定义请求头及本机 Private Network Access。关闭 Copilot 的 CORS 绕过后,请求使用原生 `fetch`,可以读取 Bridge 返回的 SSE 流。将 `config.yaml` 中的 `stream_thinking` 设为 `true` 后,Bridge 会把实时思考事件作为 `` 块发送,Copilot 将其显示为可折叠思考内容。思考中的 `Sources` 标记会被等效转义,避免 Copilot 把内部草稿误判为最终引用区并丢弃后续正文。 ## 启动、停止与重启 @@ -148,7 +150,7 @@ Bearer Token 会被忽略。所有生成接口只接受文本。图片、音频 ### Chat Completions -支持字符串 `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 块。 +支持字符串 `content`,也支持由 `{ "type": "text", "text": "..." }` 组成的数组。`stream` 省略或设为 `false` 时返回普通 JSON。`stream: true` 会立即建立 SSE 连接并发送 assistant role 块,随后在 Command Code 的 `text_delta` 到达时立即发送对应 SSE chunk,最后发送 `finish_reason: "stop"`、可选 usage 块和 `[DONE]`。`stream_thinking: true` 时,`thinking_delta` 会先作为 `` 内容流发送;该兼容格式不是原生 OpenAI reasoning item。传入 `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`。 diff --git a/config.example.yaml b/config.example.yaml index 84a82da..df1a938 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -5,6 +5,7 @@ command_code_working_directory: . timeout_seconds: 1800 max_request_bytes: 20971520 max_turns: 100 +stream_thinking: false response_store_directory: .command-code-openai-bridge/responses permission_mode: auto-accept dangerously_skip_permissions: true diff --git a/config.yaml b/config.yaml index fa515ee..24e2d39 100644 --- a/config.yaml +++ b/config.yaml @@ -5,6 +5,7 @@ command_code_working_directory: . timeout_seconds: 1800 max_request_bytes: 20971520 max_turns: 100 +stream_thinking: true response_store_directory: .command-code-openai-bridge/responses permission_mode: auto-accept dangerously_skip_permissions: true diff --git a/src/chat-stream.ts b/src/chat-stream.ts index 3fe8a93..2f947a1 100644 --- a/src/chat-stream.ts +++ b/src/chat-stream.ts @@ -9,10 +9,13 @@ export class ChatCompletionSseWriter { private readonly created = Math.floor(Date.now() / 1000); private readonly accumulator = new FinalTurnAccumulator(); private outputText = ""; + private thinkingOpen = false; + private thinkingBuffer = ""; constructor( private readonly response: ServerResponse, private readonly model: string, + private readonly streamThinking: boolean, ) {} begin(): void { @@ -21,12 +24,31 @@ export class ChatCompletionSseWriter { commandEvent(event: Record): void { const chunks = this.accumulator.event(event); + + if (this.streamThinking) { + if (event.type === "thinking_start") { + this.openThinking(); + return; + } + if (event.type === "thinking_delta" && typeof event.delta === "string") { + this.openThinking(); + this.addThinking(event.delta); + return; + } + if (event.type === "thinking_end") { + this.closeThinking(); + return; + } + } + if (event.type === "text_delta" && typeof event.delta === "string") { + this.closeThinking(); this.addText([event.delta]); return; } if (event.type !== "turn_end") return; + this.closeThinking(); if (event.hadToolCalls === true) { this.outputText = ""; return; @@ -35,6 +57,7 @@ export class ChatCompletionSseWriter { } finish(finalText: string, usage?: CommandUsage, includeUsage = false): void { + this.closeThinking(); this.ensureFinalText(finalText); this.chunk({}, "stop"); @@ -56,10 +79,48 @@ export class ChatCompletionSseWriter { } error(error: object): void { + this.closeThinking(); this.write(error); this.writeDone(); } + private openThinking(): void { + if (this.thinkingOpen) return; + this.thinkingOpen = true; + this.chunk({ content: "" }, null); + } + + private closeThinking(): void { + if (!this.thinkingOpen) return; + if (this.thinkingBuffer !== "") { + this.chunk({ content: escapeThinkingSources(this.thinkingBuffer) }, null); + this.thinkingBuffer = ""; + } + this.thinkingOpen = false; + this.chunk({ content: "\n\n" }, null); + } + + private addThinking(delta: string): void { + this.thinkingBuffer += delta; + let content = ""; + + while (this.thinkingBuffer.length > 6) { + const match = /sources/i.exec(this.thinkingBuffer); + if (match) { + content += this.thinkingBuffer.slice(0, match.index); + content += escapeThinkingSources(match[0]); + this.thinkingBuffer = this.thinkingBuffer.slice(match.index + match[0].length); + continue; + } + + const safeLength = this.thinkingBuffer.length - 6; + content += this.thinkingBuffer.slice(0, safeLength); + this.thinkingBuffer = this.thinkingBuffer.slice(safeLength); + } + + if (content !== "") this.chunk({ content }, null); + } + private addText(chunks: string[]): void { for (const delta of chunks) { if (delta === "") continue; @@ -104,9 +165,14 @@ export class ChatCompletionSseWriter { } } +function escapeThinkingSources(text: string): string { + return text.replace(/sources/gi, (word) => `${word.slice(0, 4)}\u200B${word.slice(4)}`); +} + export function startChatCompletionSse( reply: FastifyReply, model: string, + streamThinking: boolean, ): ChatCompletionSseWriter { reply.hijack(); reply.raw.writeHead(200, { @@ -116,7 +182,7 @@ export function startChatCompletionSse( "X-Accel-Buffering": "no", }); reply.raw.flushHeaders(); - const writer = new ChatCompletionSseWriter(reply.raw, model); + const writer = new ChatCompletionSseWriter(reply.raw, model, streamThinking); writer.begin(); return writer; } diff --git a/src/config.ts b/src/config.ts index 21bde20..86d3291 100644 --- a/src/config.ts +++ b/src/config.ts @@ -16,6 +16,7 @@ const configSchema = z.object({ timeout_seconds: z.number().int().positive().default(1800), max_request_bytes: z.number().int().positive().default(20 * 1024 * 1024), max_turns: z.number().int().positive().default(100), + stream_thinking: z.boolean().default(false), response_store_directory: z.string().min(1).default(".command-code-openai-bridge/responses"), permission_mode: z.enum(["default", "standard", "plan", "auto-accept", "dont-ask"]).default("auto-accept"), dangerously_skip_permissions: z.boolean().default(false), diff --git a/src/server.ts b/src/server.ts index e3ce95d..1dbec78 100644 --- a/src/server.ts +++ b/src/server.ts @@ -173,7 +173,7 @@ export async function createServer(config: BridgeConfig): Promise let writer: ChatCompletionSseWriter | undefined; try { - if (parsed.stream) writer = startChatCompletionSse(reply, parsed.model); + if (parsed.stream) writer = startChatCompletionSse(reply, parsed.model, config.stream_thinking); const result = await runCommandCode( config, model.cli_model,