显示思考内容
This commit is contained in:
@@ -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`。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user