Skip to content

Commit 2f5b1b5

Browse files
committed
Merge branch 'release/v1.4-agent' into v1.4-agent-fix-arguments
2 parents b36191b + b0a45f6 commit 2f5b1b5

11 files changed

Lines changed: 618 additions & 34 deletions

File tree

pnpm-lock.yaml

Lines changed: 388 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/app/service/agent/core/providers/openai.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,96 @@ describe("parseOpenAIStream", () => {
431431
}
432432
});
433433

434+
it("应解析单个 chunk 内的 <think>...</think> 标签", async () => {
435+
const reader = createMockReader([
436+
'data: {"choices":[{"delta":{"content":"before<think>reasoning</think>after"}}]}\n\n',
437+
"data: [DONE]\n\n",
438+
]);
439+
440+
const events: ChatStreamEvent[] = [];
441+
const controller = new AbortController();
442+
443+
await parseOpenAIStream(reader, (e) => events.push(e), controller.signal);
444+
445+
expect(events).toEqual([
446+
{ type: "content_delta", delta: "before" },
447+
{ type: "thinking_delta", delta: "reasoning" },
448+
{ type: "content_delta", delta: "after" },
449+
{ type: "done" },
450+
]);
451+
});
452+
453+
it("应处理 <think> 标签被 SSE chunk 拆开的情况", async () => {
454+
// 标签跨 chunk:chunk1 以 "<th" 结尾,chunk2 以 "ink>" 开头
455+
const reader = createMockReader([
456+
'data: {"choices":[{"delta":{"content":"before<th"}}]}\n\n',
457+
'data: {"choices":[{"delta":{"content":"ink>thought</think>after"}}]}\n\n',
458+
"data: [DONE]\n\n",
459+
]);
460+
461+
const events: ChatStreamEvent[] = [];
462+
const controller = new AbortController();
463+
464+
await parseOpenAIStream(reader, (e) => events.push(e), controller.signal);
465+
466+
// 拼接所有 content_delta 与 thinking_delta 以验证内容未泄露标签片段
467+
const contentParts = events.filter((e) => e.type === "content_delta").map((e: any) => e.delta);
468+
const thinkingParts = events.filter((e) => e.type === "thinking_delta").map((e: any) => e.delta);
469+
expect(contentParts.join("")).toBe("beforeafter");
470+
expect(thinkingParts.join("")).toBe("thought");
471+
});
472+
473+
it("应处理 </think> 标签被 SSE chunk 拆开的情况", async () => {
474+
// 结束标签跨 chunk:chunk1 末尾是 "</thi",chunk2 开头是 "nk>"
475+
const reader = createMockReader([
476+
'data: {"choices":[{"delta":{"content":"<think>thinking</thi"}}]}\n\n',
477+
'data: {"choices":[{"delta":{"content":"nk>normal"}}]}\n\n',
478+
"data: [DONE]\n\n",
479+
]);
480+
481+
const events: ChatStreamEvent[] = [];
482+
const controller = new AbortController();
483+
484+
await parseOpenAIStream(reader, (e) => events.push(e), controller.signal);
485+
486+
const contentParts = events.filter((e) => e.type === "content_delta").map((e: any) => e.delta);
487+
const thinkingParts = events.filter((e) => e.type === "thinking_delta").map((e: any) => e.delta);
488+
expect(contentParts.join("")).toBe("normal");
489+
expect(thinkingParts.join("")).toBe("thinking");
490+
});
491+
492+
it("应处理 <think> 标签逐字符跨 chunk 到达", async () => {
493+
// 每个字符独立到达,模拟 token 级别拆分
494+
const chunks = "before<think>reasoning</think>after"
495+
.split("")
496+
.map((ch) => `data: {"choices":[{"delta":{"content":${JSON.stringify(ch)}}}]}\n\n`);
497+
chunks.push("data: [DONE]\n\n");
498+
const reader = createMockReader(chunks);
499+
500+
const events: ChatStreamEvent[] = [];
501+
const controller = new AbortController();
502+
503+
await parseOpenAIStream(reader, (e) => events.push(e), controller.signal);
504+
505+
const contentParts = events.filter((e) => e.type === "content_delta").map((e: any) => e.delta);
506+
const thinkingParts = events.filter((e) => e.type === "thinking_delta").map((e: any) => e.delta);
507+
expect(contentParts.join("")).toBe("beforeafter");
508+
expect(thinkingParts.join("")).toBe("reasoning");
509+
});
510+
511+
it("流结束时仍停留在标签残片则原样作为 content 输出", async () => {
512+
// 看起来像 <think> 的残片,但后续再也没有到达 -> 按内容输出
513+
const reader = createMockReader(['data: {"choices":[{"delta":{"content":"hello <th"}}]}\n\n', "data: [DONE]\n\n"]);
514+
515+
const events: ChatStreamEvent[] = [];
516+
const controller = new AbortController();
517+
518+
await parseOpenAIStream(reader, (e) => events.push(e), controller.signal);
519+
520+
const contentParts = events.filter((e) => e.type === "content_delta").map((e: any) => e.delta);
521+
expect(contentParts.join("")).toBe("hello <th");
522+
});
523+
434524
it("reasoning_content 后跟 tool_calls 应都正确解析", async () => {
435525
const reader = createMockReader([
436526
'data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":"分析页面"}}]}\n\n',

src/app/service/agent/core/providers/openai.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,17 @@ export function buildOpenAIRequest(
126126
};
127127
}
128128

129+
// 返回 input 末尾与 tag 前缀匹配的最长长度(用于跨 chunk 缓存被拆开的标签残片)
130+
function longestTagPrefixSuffix(input: string, tag: string): number {
131+
const max = Math.min(input.length, tag.length - 1);
132+
for (let i = max; i > 0; i--) {
133+
if (input.endsWith(tag.slice(0, i))) {
134+
return i;
135+
}
136+
}
137+
return 0;
138+
}
139+
129140
// 解析 OpenAI SSE 流,生成 ChatStreamEvent
130141
export function parseOpenAIStream(
131142
reader: ReadableStreamDefaultReader<Uint8Array>,
@@ -139,11 +150,28 @@ export function parseOpenAIStream(
139150
// 标记是否已通过 [DONE] 信号发出了 done 事件,避免 .then() 再次发出
140151
let doneSent = false;
141152

153+
// 跨 chunk 追踪 <think>...</think> 块状态(用于把思考混在 content 里的模型)
154+
let inThinkBlock = false;
155+
// 跨 chunk 保留可能属于标签前缀的残片(例如 chunk 末尾 "<th",等待下一个 chunk 的 "ink>")
156+
let thinkTagCarry = "";
157+
158+
// 流结束时将未匹配到完整标签的残片原样输出,避免丢内容
159+
const flushThinkCarry = () => {
160+
if (thinkTagCarry.length > 0) {
161+
onEvent({
162+
type: inThinkBlock ? "thinking_delta" : "content_delta",
163+
delta: thinkTagCarry,
164+
});
165+
thinkTagCarry = "";
166+
}
167+
};
168+
142169
return readSSEStream(
143170
reader,
144171
signal,
145172
(sseEvent) => {
146173
if (sseEvent.data === "[DONE]") {
174+
flushThinkCarry();
147175
doneSent = true;
148176
onEvent({ type: "done", usage: lastUsage });
149177
return true;
@@ -196,7 +224,39 @@ export function parseOpenAIStream(
196224
}
197225
}
198226
} else {
199-
onEvent({ type: "content_delta", delta: delta.content });
227+
// 处理 <think>...</think> 内联标签(reasoning 模型)
228+
// 思考内容路由为 thinking_delta,避免裸露标签出现在对话里
229+
// 标签可能被 SSE chunk 拆开(如 "<th" + "ink>"),用 carry 保留末尾可能的标签前缀
230+
let remaining: string = thinkTagCarry + delta.content;
231+
thinkTagCarry = "";
232+
233+
while (remaining.length > 0) {
234+
const tag = inThinkBlock ? "</think>" : "<think>";
235+
const idx = remaining.indexOf(tag);
236+
if (idx === -1) {
237+
// 未找到完整标签,保留末尾可能匹配标签前缀的残片
238+
const carryLen = longestTagPrefixSuffix(remaining, tag);
239+
const emittable = remaining.slice(0, remaining.length - carryLen);
240+
if (emittable.length > 0) {
241+
onEvent({
242+
type: inThinkBlock ? "thinking_delta" : "content_delta",
243+
delta: emittable,
244+
});
245+
}
246+
thinkTagCarry = remaining.slice(remaining.length - carryLen);
247+
remaining = "";
248+
} else {
249+
// 找到标签:标签前的部分按当前状态输出,之后切换状态
250+
if (idx > 0) {
251+
onEvent({
252+
type: inThinkBlock ? "thinking_delta" : "content_delta",
253+
delta: remaining.slice(0, idx),
254+
});
255+
}
256+
inThinkBlock = !inThinkBlock;
257+
remaining = remaining.slice(idx + tag.length);
258+
}
259+
}
200260
}
201261
}
202262

@@ -249,6 +309,7 @@ export function parseOpenAIStream(
249309
).then(() => {
250310
// 流正常结束但没收到 [DONE](某些 API 可能如此)
251311
if (!signal.aborted && !doneSent) {
312+
flushThinkCarry();
252313
onEvent({ type: "done", usage: lastUsage });
253314
}
254315
});

src/pages/components/CodeEditor/index.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,8 @@ const CodeEditor = React.forwardRef<{ editor: editor.IStandaloneCodeEditor | und
149149
colorDecorators: true,
150150
} as const;
151151

152+
let originalModel: editor.ITextModel | undefined;
153+
let modifiedModel: editor.ITextModel | undefined;
152154
if (diffCode) {
153155
edit = editor.createDiffEditor(container, {
154156
hideUnchangedRegions: { enabled: true },
@@ -158,9 +160,12 @@ const CodeEditor = React.forwardRef<{ editor: editor.IStandaloneCodeEditor | und
158160
diffWordWrap: "off",
159161
...commonEditorOptions,
160162
});
163+
// standalone model 不会随 editor.dispose 自动清理,需手动跟踪并在 cleanup 释放
164+
originalModel = editor.createModel(diffCode, "javascript");
165+
modifiedModel = editor.createModel(code, "javascript");
161166
edit.setModel({
162-
original: editor.createModel(diffCode, "javascript"),
163-
modified: editor.createModel(code, "javascript"),
167+
original: originalModel,
168+
modified: modifiedModel,
164169
});
165170
} else {
166171
edit = editor.create(container, {
@@ -177,6 +182,8 @@ const CodeEditor = React.forwardRef<{ editor: editor.IStandaloneCodeEditor | und
177182
// 目前会出现:Uncaught (in promise) Canceled: Canceled
178183
// 问题追踪:https://github.com/microsoft/monaco-editor/issues/4702
179184
edit?.dispose();
185+
originalModel?.dispose();
186+
modifiedModel?.dispose();
180187
};
181188
}, [id, code, diffCode, editable]);
182189

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
.modal-config .arco-tabs {
2+
position: static;
3+
}
4+
5+
.modal-config .arco-tabs-pane {
6+
max-height: calc(100vh - 400px);
7+
overflow: auto;
8+
}
9+
10+
.modal-config .arco-form-item:last-child {
11+
margin-bottom: 0;
12+
}

src/pages/components/UserConfigPanel/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { ValueClient } from "@App/app/service/service_worker/client";
2020
import { message } from "@App/pages/store/global";
2121
import type { TKeyValuePair } from "@App/pkg/utils/message_value";
2222
import { encodeRValue } from "@App/pkg/utils/message_value";
23+
import "./index.css";
2324

2425
const FormItem = Form.Item;
2526

@@ -52,6 +53,7 @@ const UserConfigPanel: React.FC<{
5253
return (
5354
<Modal
5455
visible={visible}
56+
className={"modal-config"}
5557
title={`${script.name} ${t("config")}`} // 替换为键值对应的英文文本
5658
okText={<Popover content={t("save_only_current_group")}>{t("save")}</Popover>}
5759
cancelText={t("close")} // 替换为键值对应的英文文本

src/pages/components/layout/MainLayout.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ const importByUrls = async (urls: string[]): Promise<TImportStat | undefined> =>
123123
const getSafePopupParent = (p: Element) => {
124124
p = (p.closest("button")?.parentNode as Element) || p; // 確保 ancestor 沒有 button 元素
125125
p = (p.closest("span")?.parentNode as Element) || p; // 確保 ancestor 沒有 span 元素
126+
p = (p.closest(".arco-form-item-control-children")?.parentNode as Element) || p; // 確保 ancestor 沒有 .form-item-control-children 元素
126127
p = (p.closest(".arco-collapse-item-content")?.parentNode as Element) || p; // 確保 ancestor 沒有 .arco-collapse-item-content 元素
127128
p = (p.closest(".arco-card")?.parentNode as Element) || p; // 確保 ancestor 沒有 .arco-card 元素
128129
p = (p.closest("aside")?.parentNode as Element) || p; // 確保 ancestor 沒有 aside 元素

src/pages/options/routes/AgentProvider.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,9 @@ function AgentProvider() {
377377
let chatUrl: string;
378378
let body: string;
379379

380+
const systemMessage = "Reply in one brief sentence only. No thinking or reasoning.";
381+
const userMessage = "Greet the user warmly in a short, concise sentence.";
382+
380383
if (editingModel.provider === "anthropic") {
381384
chatUrl = `${baseUrl}/v1/messages`;
382385
headers["x-api-key"] = editingModel.apiKey;
@@ -385,7 +388,9 @@ function AgentProvider() {
385388
body = JSON.stringify({
386389
model: editingModel.model || "claude-sonnet-4-20250514",
387390
max_tokens: 256,
388-
messages: [{ role: "user", content: "hi" }],
391+
system: systemMessage,
392+
messages: [{ role: "user", content: userMessage }],
393+
stream: false,
389394
});
390395
} else {
391396
chatUrl = `${baseUrl}/chat/completions`;
@@ -396,7 +401,11 @@ function AgentProvider() {
396401
body = JSON.stringify({
397402
model: editingModel.model || defaultModel,
398403
max_tokens: 256,
399-
messages: [{ role: "user", content: "hi" }],
404+
messages: [
405+
{ role: "system", content: systemMessage },
406+
{ role: "user", content: userMessage },
407+
],
408+
stream: false,
400409
});
401410
}
402411

src/pages/options/routes/script/ScriptEditor.tsx

Lines changed: 35 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { SCRIPT_TYPE_NORMAL, ScriptCodeDAO, ScriptDAO } from "@App/app/repo/scri
33
import CodeEditor from "@App/pages/components/CodeEditor";
44
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
55
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
6-
import type { editor } from "monaco-editor";
6+
import type { editor, IDisposable } from "monaco-editor";
77
import { KeyCode, KeyMod } from "monaco-editor";
88
import { Button, Dropdown, Grid, Input, Menu, Message, Modal, Space, Tabs, Tooltip } from "@arco-design/web-react";
99
import TabPane from "@arco-design/web-react/es/Tabs/tab-pane";
@@ -53,6 +53,15 @@ const Editor: React.FC<{
5353
},
5454
[node]
5555
);
56+
// 用 ref 拿到最新的 hotKeys/onChange/callbackEditor,避免 stale closure
57+
// 同时让 effect 仅在 editor 实例变化时重跑(不会因父组件重渲染重复 addAction)
58+
const hotKeysRef = useRef(hotKeys);
59+
const onChangeRef = useRef(onChange);
60+
const callbackEditorRef = useRef(callbackEditor);
61+
hotKeysRef.current = hotKeys;
62+
onChangeRef.current = onChange;
63+
callbackEditorRef.current = callbackEditor;
64+
5665
useEffect(() => {
5766
if (!node || !node.editor) {
5867
return;
@@ -62,24 +71,32 @@ const Editor: React.FC<{
6271
// @ts-ignore
6372
node.editor.uuid = id;
6473
}
65-
hotKeys.forEach((item) => {
66-
node.editor.addAction({
67-
id: item.id,
68-
label: item.title,
69-
keybindings: [item.hotKey],
70-
run(editor) {
71-
const script = getScript(id);
72-
if (script) {
73-
item.action(script, editor);
74-
}
75-
},
76-
});
77-
});
78-
node.editor.onKeyUp(() => {
79-
onChange(node.editor.getValue() || "");
74+
const disposables: IDisposable[] = [];
75+
hotKeysRef.current.forEach((item) => {
76+
disposables.push(
77+
node.editor.addAction({
78+
id: item.id,
79+
label: item.title,
80+
keybindings: [item.hotKey],
81+
run(editor) {
82+
const script = getScript(id);
83+
if (script) {
84+
item.action(script, editor);
85+
}
86+
},
87+
})
88+
);
8089
});
81-
callbackEditor(node.editor);
82-
return node.editor.dispose.bind(node.editor);
90+
disposables.push(
91+
node.editor.onKeyUp(() => {
92+
onChangeRef.current(node.editor.getValue() || "");
93+
})
94+
);
95+
callbackEditorRef.current(node.editor);
96+
// editor 实例本身由 CodeEditor 自身负责 dispose,这里仅清理本 effect 注册的 listener/action
97+
return () => {
98+
disposables.forEach((d) => d.dispose());
99+
};
83100
}, [node?.editor]);
84101

85102
return <CodeEditor key={id} id={id} ref={ref} className={className} code={code} diffCode="" editable />;

src/pkg/utils/monaco-editor/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ export function registerEditor() {
191191
}
192192

193193
actions.push({
194-
title: `将 '${globalName}' 声明为全局变量 (/* global */)`,
194+
title: multiLang.declareGlobal.replace("{0}", globalName),
195195
diagnostics: [val],
196196
kind: "quickfix",
197197
edit: { edits: [{ resource: model.uri, textEdit, versionId: undefined }] },

0 commit comments

Comments
 (0)