Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

90 changes: 90 additions & 0 deletions src/app/service/agent/core/providers/openai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,96 @@ describe("parseOpenAIStream", () => {
}
});

it("应解析单个 chunk 内的 <think>...</think> 标签", async () => {
const reader = createMockReader([
'data: {"choices":[{"delta":{"content":"before<think>reasoning</think>after"}}]}\n\n',
"data: [DONE]\n\n",
]);

const events: ChatStreamEvent[] = [];
const controller = new AbortController();

await parseOpenAIStream(reader, (e) => events.push(e), controller.signal);

expect(events).toEqual([
{ type: "content_delta", delta: "before" },
{ type: "thinking_delta", delta: "reasoning" },
{ type: "content_delta", delta: "after" },
{ type: "done" },
]);
});

it("应处理 <think> 标签被 SSE chunk 拆开的情况", async () => {
// 标签跨 chunk:chunk1 以 "<th" 结尾,chunk2 以 "ink>" 开头
const reader = createMockReader([
'data: {"choices":[{"delta":{"content":"before<th"}}]}\n\n',
'data: {"choices":[{"delta":{"content":"ink>thought</think>after"}}]}\n\n',
"data: [DONE]\n\n",
]);

const events: ChatStreamEvent[] = [];
const controller = new AbortController();

await parseOpenAIStream(reader, (e) => events.push(e), controller.signal);

// 拼接所有 content_delta 与 thinking_delta 以验证内容未泄露标签片段
const contentParts = events.filter((e) => e.type === "content_delta").map((e: any) => e.delta);
const thinkingParts = events.filter((e) => e.type === "thinking_delta").map((e: any) => e.delta);
expect(contentParts.join("")).toBe("beforeafter");
expect(thinkingParts.join("")).toBe("thought");
});

it("应处理 </think> 标签被 SSE chunk 拆开的情况", async () => {
// 结束标签跨 chunk:chunk1 末尾是 "</thi",chunk2 开头是 "nk>"
const reader = createMockReader([
'data: {"choices":[{"delta":{"content":"<think>thinking</thi"}}]}\n\n',
'data: {"choices":[{"delta":{"content":"nk>normal"}}]}\n\n',
"data: [DONE]\n\n",
]);

const events: ChatStreamEvent[] = [];
const controller = new AbortController();

await parseOpenAIStream(reader, (e) => events.push(e), controller.signal);

const contentParts = events.filter((e) => e.type === "content_delta").map((e: any) => e.delta);
const thinkingParts = events.filter((e) => e.type === "thinking_delta").map((e: any) => e.delta);
expect(contentParts.join("")).toBe("normal");
expect(thinkingParts.join("")).toBe("thinking");
});

it("应处理 <think> 标签逐字符跨 chunk 到达", async () => {
// 每个字符独立到达,模拟 token 级别拆分
const chunks = "before<think>reasoning</think>after"
.split("")
.map((ch) => `data: {"choices":[{"delta":{"content":${JSON.stringify(ch)}}}]}\n\n`);
chunks.push("data: [DONE]\n\n");
const reader = createMockReader(chunks);

const events: ChatStreamEvent[] = [];
const controller = new AbortController();

await parseOpenAIStream(reader, (e) => events.push(e), controller.signal);

const contentParts = events.filter((e) => e.type === "content_delta").map((e: any) => e.delta);
const thinkingParts = events.filter((e) => e.type === "thinking_delta").map((e: any) => e.delta);
expect(contentParts.join("")).toBe("beforeafter");
expect(thinkingParts.join("")).toBe("reasoning");
});

it("流结束时仍停留在标签残片则原样作为 content 输出", async () => {
// 看起来像 <think> 的残片,但后续再也没有到达 -> 按内容输出
const reader = createMockReader(['data: {"choices":[{"delta":{"content":"hello <th"}}]}\n\n', "data: [DONE]\n\n"]);

const events: ChatStreamEvent[] = [];
const controller = new AbortController();

await parseOpenAIStream(reader, (e) => events.push(e), controller.signal);

const contentParts = events.filter((e) => e.type === "content_delta").map((e: any) => e.delta);
expect(contentParts.join("")).toBe("hello <th");
});

it("reasoning_content 后跟 tool_calls 应都正确解析", async () => {
const reader = createMockReader([
'data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":"分析页面"}}]}\n\n',
Expand Down
63 changes: 62 additions & 1 deletion src/app/service/agent/core/providers/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,17 @@ export function buildOpenAIRequest(
};
}

// 返回 input 末尾与 tag 前缀匹配的最长长度(用于跨 chunk 缓存被拆开的标签残片)
function longestTagPrefixSuffix(input: string, tag: string): number {
const max = Math.min(input.length, tag.length - 1);
for (let i = max; i > 0; i--) {
if (input.endsWith(tag.slice(0, i))) {
return i;
}
}
return 0;
}

// 解析 OpenAI SSE 流,生成 ChatStreamEvent
export function parseOpenAIStream(
reader: ReadableStreamDefaultReader<Uint8Array>,
Expand All @@ -139,11 +150,28 @@ export function parseOpenAIStream(
// 标记是否已通过 [DONE] 信号发出了 done 事件,避免 .then() 再次发出
let doneSent = false;

// 跨 chunk 追踪 <think>...</think> 块状态(用于把思考混在 content 里的模型)
let inThinkBlock = false;
Comment on lines +153 to +154
// 跨 chunk 保留可能属于标签前缀的残片(例如 chunk 末尾 "<th",等待下一个 chunk 的 "ink>")
let thinkTagCarry = "";

// 流结束时将未匹配到完整标签的残片原样输出,避免丢内容
const flushThinkCarry = () => {
if (thinkTagCarry.length > 0) {
onEvent({
type: inThinkBlock ? "thinking_delta" : "content_delta",
delta: thinkTagCarry,
});
thinkTagCarry = "";
}
};

return readSSEStream(
reader,
signal,
(sseEvent) => {
if (sseEvent.data === "[DONE]") {
flushThinkCarry();
doneSent = true;
onEvent({ type: "done", usage: lastUsage });
return true;
Expand Down Expand Up @@ -196,7 +224,39 @@ export function parseOpenAIStream(
}
}
} else {
onEvent({ type: "content_delta", delta: delta.content });
// 处理 <think>...</think> 内联标签(reasoning 模型)
// 思考内容路由为 thinking_delta,避免裸露标签出现在对话里
// 标签可能被 SSE chunk 拆开(如 "<th" + "ink>"),用 carry 保留末尾可能的标签前缀
let remaining: string = thinkTagCarry + delta.content;
thinkTagCarry = "";

while (remaining.length > 0) {
const tag = inThinkBlock ? "</think>" : "<think>";
const idx = remaining.indexOf(tag);
if (idx === -1) {
// 未找到完整标签,保留末尾可能匹配标签前缀的残片
const carryLen = longestTagPrefixSuffix(remaining, tag);
const emittable = remaining.slice(0, remaining.length - carryLen);
if (emittable.length > 0) {
onEvent({
type: inThinkBlock ? "thinking_delta" : "content_delta",
delta: emittable,
});
}
thinkTagCarry = remaining.slice(remaining.length - carryLen);
remaining = "";
} else {
// 找到标签:标签前的部分按当前状态输出,之后切换状态
if (idx > 0) {
onEvent({
type: inThinkBlock ? "thinking_delta" : "content_delta",
delta: remaining.slice(0, idx),
});
}
inThinkBlock = !inThinkBlock;
remaining = remaining.slice(idx + tag.length);
}
}
Comment on lines +227 to +259
Comment on lines 153 to +259
Comment on lines +227 to +259
}
}

Expand Down Expand Up @@ -245,6 +305,7 @@ export function parseOpenAIStream(
).then(() => {
// 流正常结束但没收到 [DONE](某些 API 可能如此)
if (!signal.aborted && !doneSent) {
flushThinkCarry();
onEvent({ type: "done", usage: lastUsage });
}
});
Expand Down
13 changes: 11 additions & 2 deletions src/pages/options/routes/AgentProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,9 @@ function AgentProvider() {
let chatUrl: string;
let body: string;

const systemMessage = "Reply in one brief sentence only. No thinking or reasoning.";
const userMessage = "Greet the user warmly in a short, concise sentence.";

if (editingModel.provider === "anthropic") {
chatUrl = `${baseUrl}/v1/messages`;
headers["x-api-key"] = editingModel.apiKey;
Expand All @@ -385,7 +388,9 @@ function AgentProvider() {
body = JSON.stringify({
model: editingModel.model || "claude-sonnet-4-20250514",
max_tokens: 256,
messages: [{ role: "user", content: "hi" }],
system: systemMessage,
messages: [{ role: "user", content: userMessage }],
stream: false,
});
} else {
chatUrl = `${baseUrl}/chat/completions`;
Expand All @@ -396,7 +401,11 @@ function AgentProvider() {
body = JSON.stringify({
model: editingModel.model || defaultModel,
max_tokens: 256,
messages: [{ role: "user", content: "hi" }],
messages: [
{ role: "system", content: systemMessage },
{ role: "user", content: userMessage },
],
stream: false,
});
Comment on lines 401 to 409
}

Expand Down
Loading