显示思考内容

This commit is contained in:
Sirius
2026-08-05 20:04:59 +08:00
parent 98c6664701
commit 64469f9c1d
6 changed files with 76 additions and 5 deletions
+5 -3
View File
@@ -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.<name>.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 思考事件包装成 `<think>` 块;默认关闭。`models.<name>.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 会把实时思考事件作为 `<think>` 块发送,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` 会先作为 `<think>` 内容流发送;该兼容格式不是原生 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`
+1
View File
@@ -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
+1
View File
@@ -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
+67 -1
View File
@@ -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<string, unknown>): 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: "<think>" }, 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: "</think>\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;
}
+1
View File
@@ -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),
+1 -1
View File
@@ -173,7 +173,7 @@ export async function createServer(config: BridgeConfig): Promise<BridgeServer>
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,