zen-gitsync 2.18.1 → 2.18.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1409 -1405
- package/package.json +2 -1
- package/src/cli/ai/agent.js +3 -0
- package/src/cli/ai/runtime.test.js +91 -2
- package/src/cli/ai/telemetry.js +3 -1
- package/src/cli/ai/termui.js +5 -1
- package/src/cli/ai/transport.js +215 -76
- package/src/cli/ai/turn.js +16 -2
- package/src/config.js +67 -0
- package/src/ui/public/assets/AgentView-2wrBHHhj.js +11 -0
- package/src/ui/public/assets/{AgentView-D04HxmFC.css → AgentView-BAd5T84b.css} +1 -1
- package/src/ui/public/assets/{AppVersionBadge-lW8vNngV.js → AppVersionBadge-CHDxEjbF.js} +2 -2
- package/src/ui/public/assets/{BranchSelector-BgbW1JiG.js → BranchSelector-D1g-2NKG.js} +1 -1
- package/src/ui/public/assets/{CommitForm-BNZ3C3Vq.js → CommitForm-FhN_BoYS.js} +1 -1
- package/src/ui/public/assets/{CommonDialog-CyBkT3AE.js → CommonDialog-BjcPO7gz.js} +1 -1
- package/src/ui/public/assets/{CopySessionButton-CwWaki0s.js → CopySessionButton-CPtwbVLu.js} +1 -1
- package/src/ui/public/assets/EditorView-BUKUax94.css +1 -0
- package/src/ui/public/assets/EditorView-DxmvG7N8.js +37 -0
- package/src/ui/public/assets/{FlowExecutionViewer-mdvuwP5d.js → FlowExecutionViewer-BcBdCL0t.js} +1 -1
- package/src/ui/public/assets/{FlowOrchestrationWorkspace-fqy0N4Xc.js → FlowOrchestrationWorkspace-CkSA1gaf.js} +1 -1
- package/src/ui/public/assets/{LogList-DvxNsohr.js → LogList-BojHLJ5Q.js} +4 -4
- package/src/ui/public/assets/{MindmapView-CDyg616o.js → MindmapView-BA7u4vhh.js} +1 -1
- package/src/ui/public/assets/{MonitorView-DzcTq-y3.js → MonitorView-DE6ePMZT.js} +1 -1
- package/src/ui/public/assets/{ProjectStartupButton-htka4EwQ.js → ProjectStartupButton-Dkx6YnGs.js} +1 -1
- package/src/ui/public/assets/{RecentDirectoriesChat-CarXD1-V.css → RecentDirectoriesChat-DBGoUlkn.css} +1 -1
- package/src/ui/public/assets/RecentDirectoriesChat-_yzf6Nu5.js +3 -0
- package/src/ui/public/assets/{RemoteManagerDialog-BFR7Jm5R.js → RemoteManagerDialog-Be3-3xKl.js} +1 -1
- package/src/ui/public/assets/{RemoteRepoCard-BEWOqwy-.js → RemoteRepoCard-DrL1qR0e.js} +1 -1
- package/src/ui/public/assets/{SourceMapView-BCLPm_9Z.js → SourceMapView-DmY7xaHI.js} +1 -1
- package/src/ui/public/assets/{SvgIcon-BBImoDdy.js → SvgIcon-DPkwl68W.js} +1 -1
- package/src/ui/public/assets/{UserInputNode-Cx5Ux_Zk.js → UserInputNode-uoTNgf0M.js} +1 -1
- package/src/ui/public/assets/{WorkbenchView-DEJHVCL1.css → WorkbenchView-3IW0OcIy.css} +1 -1
- package/src/ui/public/assets/{WorkbenchView-Dh-BXSrZ.js → WorkbenchView-B63oMMUD.js} +73 -73
- package/src/ui/public/assets/{_plugin-vue_export-helper-T5G-Ork1.js → _plugin-vue_export-helper-7_ovdUNe.js} +4 -4
- package/src/ui/public/assets/agentConversations-BWMhXq51.js +8 -0
- package/src/ui/public/assets/configStore-2tn284Z4.js +1 -0
- package/src/ui/public/assets/{index-DTe2ec7L.js → index-DlIQOSyv.js} +84 -84
- package/src/ui/public/assets/index-e9FElcZF.css +1 -0
- package/src/ui/public/index.html +6 -6
- package/src/ui/server/routes/config.js +14 -0
- package/src/ui/server/routes/workbench/agentChat.js +50 -15
- package/src/ui/server/routes/workbench/agentChatShared.test.js +105 -1
- package/src/ui/server/routes/workbench/agentRoutes.js +27 -4
- package/src/ui/public/assets/AgentView-lS8yZ1IO.js +0 -11
- package/src/ui/public/assets/EditorView-DLH-iHX0.css +0 -1
- package/src/ui/public/assets/EditorView-qhqZbWUW.js +0 -37
- package/src/ui/public/assets/RecentDirectoriesChat-BqKZOUER.js +0 -3
- package/src/ui/public/assets/agentConversations-S0q49dQw.js +0 -8
- package/src/ui/public/assets/configStore-BLuyXnKF.js +0 -1
- package/src/ui/public/assets/index-Ja0NzLzp.css +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zen-gitsync",
|
|
3
|
-
"version": "2.18.
|
|
3
|
+
"version": "2.18.2",
|
|
4
4
|
"description": "Auto commit, scheduled sync, and visual GUI for Git. Run `g` in any repo for one-key commit & push, AI commit messages, scheduled background sync, and a drag-and-drop workflow builder.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -79,6 +79,7 @@
|
|
|
79
79
|
"verify:wb-open-scroll-bottom": "node scripts/verify-workbench-open-scroll-bottom.cjs",
|
|
80
80
|
"verify:chat-rail": "node scripts/verify-chat-message-rail.cjs",
|
|
81
81
|
"verify:agent-session-copy": "node scripts/verify-agent-session-copy.cjs",
|
|
82
|
+
"verify:agent-retry": "node scripts/verify-agent-llm-retry.cjs",
|
|
82
83
|
"verify:editor-preview-scroll": "node scripts/verify-editor-preview-scroll.cjs",
|
|
83
84
|
"verify:editor-sidebar-width": "node scripts/verify-editor-sidebar-width.cjs",
|
|
84
85
|
"verify:editor-disk-sync": "node scripts/verify-editor-disk-sync.cjs",
|
package/src/cli/ai/agent.js
CHANGED
|
@@ -71,6 +71,8 @@ const STRINGS = {
|
|
|
71
71
|
unknownModel: (q) => `未找到匹配的模型: ${q},使用默认模型`,
|
|
72
72
|
waiting: '思考中…',
|
|
73
73
|
llmError: (msg) => `LLM 请求失败: ${msg}`,
|
|
74
|
+
// 传输层自动重试时的一行提示(失败原因原样带上,用户自己判断要不要 Ctrl+C 停掉)
|
|
75
|
+
llmRetrying: (attempt, max, reason) => `LLM 请求中断,正在重试(第 ${attempt}/${max} 次): ${reason}`,
|
|
74
76
|
toolIterLimit: (n) => `已达单轮最大工具调用次数(${n}),本轮结束。如需继续请再发一条消息。`,
|
|
75
77
|
toolRunning: (name) => `执行 ${name}…`,
|
|
76
78
|
busy: '任务执行中,请稍候,或按 Ctrl+C 停止当前任务。',
|
|
@@ -130,6 +132,7 @@ const STRINGS = {
|
|
|
130
132
|
unknownModel: (q) => `No model matching "${q}", falling back to default`,
|
|
131
133
|
waiting: 'Thinking…',
|
|
132
134
|
llmError: (msg) => `LLM request failed: ${msg}`,
|
|
135
|
+
llmRetrying: (attempt, max, reason) => `LLM request interrupted, retrying (${attempt}/${max}): ${reason}`,
|
|
133
136
|
toolIterLimit: (n) => `Hit max tool iterations (${n}) for this turn. Send another message to continue.`,
|
|
134
137
|
toolRunning: (name) => `Running ${name}…`,
|
|
135
138
|
busy: 'Agent is working. Wait or press Ctrl+C to stop the task.',
|
|
@@ -59,8 +59,11 @@ test('unsupported stream_options retries once without requesting usage', async (
|
|
|
59
59
|
assert.equal(result.usage, null)
|
|
60
60
|
})
|
|
61
61
|
|
|
62
|
+
// 这两条关心的都是"失败时怎么收场",不是"重试几次" —— 一律 maxRetries: 0,
|
|
63
|
+
// 否则要真等退避(最多 10 次、单次封顶 30s),一个用例就跑到分钟级。
|
|
64
|
+
// 重试本身的行为在文件末尾那组用例里单独验(那里注入 sleepFn,不真等)。
|
|
62
65
|
test('truncated streams do not yield executable tool calls', async () => {
|
|
63
|
-
await assert.rejects(streamChatOnce({ model, messages: [], fetchFn: async () => response([
|
|
66
|
+
await assert.rejects(streamChatOnce({ model, messages: [], maxRetries: 0, fetchFn: async () => response([
|
|
64
67
|
event({ choices: [{ delta: { tool_calls: [{ index: 0, id: 'x', function: { name: 'write_file', arguments: '{}' } }] } }] }),
|
|
65
68
|
]) }), /中断/)
|
|
66
69
|
})
|
|
@@ -71,12 +74,98 @@ test('timeout and explicit cancellation have different outcomes', async () => {
|
|
|
71
74
|
if (signal.aborted) abort()
|
|
72
75
|
else signal.addEventListener('abort', abort, { once: true })
|
|
73
76
|
})
|
|
74
|
-
await assert.rejects(streamChatOnce({ model, messages: [], fetchFn: hanging,
|
|
77
|
+
await assert.rejects(streamChatOnce({ model, messages: [], fetchFn: hanging, idleTimeoutMs: 10, maxRetries: 0 }), /超时/)
|
|
75
78
|
const result = await streamChatOnce({ model, messages: [], fetchFn: hanging, signal: AbortSignal.abort() })
|
|
76
79
|
assert.equal(result.aborted, true)
|
|
77
80
|
assert.deepEqual(result.toolCalls, [])
|
|
78
81
|
})
|
|
79
82
|
|
|
83
|
+
// ── 空闲超时 + 自动重试(2026-10-09)─────────────────────────────────
|
|
84
|
+
//
|
|
85
|
+
// 这一组钉的是两件事:
|
|
86
|
+
// 1. 超时的判据是**空闲**(多久没数据)而不是整条流的时长 —— 慢回答不该被掐
|
|
87
|
+
// 2. 失败会自己重试,而"重试也没用"的错误(4xx / 非法 index)不会白试十次
|
|
88
|
+
|
|
89
|
+
test('空闲超时只在"真的没有数据"时触发,慢回答活得下来', async () => {
|
|
90
|
+
// 整条流故意拖过 idleTimeoutMs,但每 10ms 吐一段 —— 旧口径(总时长上限)在这里必红
|
|
91
|
+
const slowButAlive = async (_url, init) => new Response(new ReadableStream({
|
|
92
|
+
async start(controller) {
|
|
93
|
+
const bytes = new TextEncoder()
|
|
94
|
+
for (let i = 0; i < 6; i++) {
|
|
95
|
+
await new Promise(r => setTimeout(r, 12))
|
|
96
|
+
controller.enqueue(bytes.encode(event({ choices: [{ delta: { content: `c${i}` } }] })))
|
|
97
|
+
}
|
|
98
|
+
controller.enqueue(bytes.encode(event({ choices: [{ delta: {}, finish_reason: 'stop' }] })))
|
|
99
|
+
controller.enqueue(bytes.encode('data: [DONE]\n\n'))
|
|
100
|
+
controller.close()
|
|
101
|
+
},
|
|
102
|
+
}))
|
|
103
|
+
const result = await streamChatOnce({ model, messages: [], fetchFn: slowButAlive, idleTimeoutMs: 40, maxRetries: 0 })
|
|
104
|
+
assert.equal(result.content, 'c0c1c2c3c4c5')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('可重试的失败会自己重试到成功,并把半截产物交给 onRetry', async () => {
|
|
108
|
+
let calls = 0
|
|
109
|
+
const retries = []
|
|
110
|
+
const flaky = async () => {
|
|
111
|
+
calls++
|
|
112
|
+
// 第一次:吐一段正文后流断掉(没有 finish_reason)—— 正是"重试能救"的那类
|
|
113
|
+
if (calls === 1) return response([event({ choices: [{ delta: { content: '半截' } }] })])
|
|
114
|
+
return response([event({ choices: [{ delta: { content: '好了' }, finish_reason: 'stop' }] }), 'data: [DONE]\n\n'])
|
|
115
|
+
}
|
|
116
|
+
const result = await streamChatOnce({
|
|
117
|
+
model, messages: [], fetchFn: flaky,
|
|
118
|
+
sleepFn: async () => {}, // 不真等退避
|
|
119
|
+
onRetry: info => retries.push(info),
|
|
120
|
+
})
|
|
121
|
+
assert.equal(calls, 2)
|
|
122
|
+
assert.equal(result.content, '好了')
|
|
123
|
+
assert.equal(retries.length, 1)
|
|
124
|
+
assert.equal(retries[0].attempt, 1)
|
|
125
|
+
assert.equal(retries[0].maxRetries, 10, '默认重试上限与 Claude Code 对齐(10)')
|
|
126
|
+
// 半截内容必须交出来 —— 调用方要靠它把界面上那半句丢掉,否则会看到两段拼在一起
|
|
127
|
+
assert.equal(retries[0].partial.content, '半截')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('重试次数用尽后抛出最后一次的错误', async () => {
|
|
131
|
+
let calls = 0
|
|
132
|
+
await assert.rejects(streamChatOnce({
|
|
133
|
+
model, messages: [], maxRetries: 3, sleepFn: async () => {},
|
|
134
|
+
fetchFn: async () => { calls++; return response([event({ choices: [{ delta: { content: 'x' } }] })]) },
|
|
135
|
+
}), /中断/)
|
|
136
|
+
assert.equal(calls, 4, '首次 + 3 次重试')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('4xx(重试也没用)不重试,网关原话照传', async () => {
|
|
140
|
+
let calls = 0
|
|
141
|
+
await assert.rejects(streamChatOnce({
|
|
142
|
+
model, messages: [], sleepFn: async () => {},
|
|
143
|
+
fetchFn: async () => { calls++; return new Response('{"error":{"message":"invalid api key"}}', { status: 401 }) },
|
|
144
|
+
}), /HTTP 401/)
|
|
145
|
+
assert.equal(calls, 1, '401 重试十次还是 401,不该白烧十次请求')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('用户点了停止就不再重试', async () => {
|
|
149
|
+
const controller = new AbortController()
|
|
150
|
+
let attempts = 0
|
|
151
|
+
const hanging = async (_url, { signal }) => {
|
|
152
|
+
attempts++
|
|
153
|
+
return new Promise((_, reject) => {
|
|
154
|
+
const abort = () => reject(new DOMException('Aborted', 'AbortError'))
|
|
155
|
+
if (signal.aborted) abort()
|
|
156
|
+
else signal.addEventListener('abort', abort, { once: true })
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
// 超时→正要重试的那一刻用户按了停止(onRetry 里 abort,最贴近真实时序)
|
|
160
|
+
const result = await streamChatOnce({
|
|
161
|
+
model, messages: [], fetchFn: hanging, signal: controller.signal,
|
|
162
|
+
idleTimeoutMs: 10, sleepFn: async () => { controller.abort() },
|
|
163
|
+
onRetry: () => { controller.abort() },
|
|
164
|
+
})
|
|
165
|
+
assert.equal(result.aborted, true)
|
|
166
|
+
assert.equal(attempts, 1, '中止后不该再发第二次请求')
|
|
167
|
+
})
|
|
168
|
+
|
|
80
169
|
const call = n => ({ id: `call${n}`, type: 'function', function: { name: 'read_file', arguments: '{}' } })
|
|
81
170
|
const askCall = (id = 'ask1') => ({ id, type: 'function', function: {
|
|
82
171
|
name: 'ask_user',
|
package/src/cli/ai/telemetry.js
CHANGED
|
@@ -28,7 +28,9 @@ export function createTurnStats() {
|
|
|
28
28
|
return {
|
|
29
29
|
startedAt: new Date().toISOString(), completedAt: null, status: 'running',
|
|
30
30
|
totalMs: 0, llmMs: 0, toolsMs: 0, firstTokenMs: null, firstAnswerMs: null,
|
|
31
|
-
|
|
31
|
+
// retries:本轮里被传输层自动重试掉的次数(已在 requests 里计过一次的额外请求)。
|
|
32
|
+
// 单独留一个数是为了让"请求数忽然变多"有解释,而不是看着像 bug(见 /stats)。
|
|
33
|
+
requests: 0, retries: 0, toolCalls: 0, usageRequests: 0, usage: null,
|
|
32
34
|
}
|
|
33
35
|
}
|
|
34
36
|
|
package/src/cli/ai/termui.js
CHANGED
|
@@ -657,9 +657,13 @@ export function renderTurnSummary(stats, { locale = 'zh-CN', session, width = Ma
|
|
|
657
657
|
const timing = [status + (time ? ` ${time}` : ''), `${zh ? '总耗时' : 'total'} ${formatDuration(stats.totalMs)}`]
|
|
658
658
|
if (stats.firstTokenMs != null) timing.push(`${zh ? '首响应' : 'first token'} ${formatDuration(stats.firstTokenMs)}`)
|
|
659
659
|
if (stats.firstAnswerMs != null) timing.push(`${zh ? '正文等待' : 'first answer'} ${formatDuration(stats.firstAnswerMs)}`)
|
|
660
|
+
// 「重试 N」只在真的重试过时才出现:正常的一轮多一个恒为 0 的字段纯属噪音,
|
|
661
|
+
// 而真重试过的那一轮,用户需要一个解释(请求数为什么比平时多)。
|
|
662
|
+
const requestLine = `${zh ? '模型' : 'model'} ${formatDuration(stats.llmMs)} · ${zh ? '工具' : 'tools'} ${formatDuration(stats.toolsMs)} (${stats.toolCalls}) · ${zh ? '请求' : 'requests'} ${stats.requests}`
|
|
663
|
+
+ (stats.retries > 0 ? ` · ${zh ? '重试' : 'retries'} ${stats.retries}` : '')
|
|
660
664
|
const lines = [
|
|
661
665
|
timing.join(' · '),
|
|
662
|
-
|
|
666
|
+
requestLine,
|
|
663
667
|
usageLine(stats.usage, stats.usageRequests, stats.requests),
|
|
664
668
|
]
|
|
665
669
|
if (session) lines.push(`${zh ? '会话累计' : 'Session'} (${session.turns}) · ${usageLine(session.usage, session.usageRequests, session.requests)}`)
|
package/src/cli/ai/transport.js
CHANGED
|
@@ -5,19 +5,110 @@ import { normalizeUsage } from './telemetry.js'
|
|
|
5
5
|
// Some compatible gateways reject stream_options. Remember that for this process.
|
|
6
6
|
const withoutStreamUsage = new Set()
|
|
7
7
|
|
|
8
|
+
// ── 超时与自动重试口径(2026-10-09 改) ────────────────────────────────
|
|
9
|
+
//
|
|
10
|
+
// **超时是"空闲"超时,不再是"整条流"上限。**
|
|
11
|
+
// 原先是一个从请求发出就开始跑的 300s 总时限:一次正常但比较慢的回答
|
|
12
|
+
// (长思考 / 长正文 / 大上下文的首字延迟)跑满 5 分钟就被判"模型响应超时"掐断,
|
|
13
|
+
// 而对面其实还在正常吐字 —— 用户看到的是"跑得好好的突然失败"。
|
|
14
|
+
// 现在只在**连续 idleTimeoutMs 毫秒收不到任何字节**时才判定连接已死:
|
|
15
|
+
// 每收到一段数据就重置计时(见 attemptOnce 里的 touch())。慢但活着的回答可以一直跑,
|
|
16
|
+
// 真挂死的连接照样会被发现。窗口里那句"模型响应超时"因此换了说法(见 stallError)。
|
|
17
|
+
//
|
|
18
|
+
// 判死的连接不再直接失败:交给下面这条自动重试接手,最多 MAX_LLM_RETRIES 次
|
|
19
|
+
// (口径对齐 Claude Code 的 MAX_RETRIES=10)。
|
|
20
|
+
//
|
|
21
|
+
// 外层的两个调用点(CLI 的 turn.js / Web 的 agentChat.js)通过 onRetry 拿到
|
|
22
|
+
// "这次尝试吐了哪些字"的快照,据此把界面上那半截丢掉再重来。
|
|
23
|
+
export const DEFAULT_IDLE_TIMEOUT_MS = 300_000
|
|
24
|
+
export const MAX_LLM_RETRIES = 10
|
|
25
|
+
const RETRY_BASE_DELAY_MS = 500
|
|
26
|
+
const RETRY_MAX_DELAY_MS = 30_000
|
|
27
|
+
|
|
28
|
+
// 值得再试一次的 HTTP 状态:限流 / 请求超时 / 服务端 5xx —— 都是"再试一次可能就好了"。
|
|
29
|
+
// 其余 4xx(400 参数错、401/403 鉴权、404 模型名不对、422 校验失败)再试十次也一样,
|
|
30
|
+
// 直接失败,并把网关的原话交给用户。
|
|
31
|
+
const RETRYABLE_STATUS = new Set([408, 409, 425, 429, 500, 502, 503, 504, 520, 522, 524])
|
|
32
|
+
|
|
8
33
|
// 把"网关返回了什么"翻成"用户该做什么"。400 且正文提到 tool/function 时,十有八九是
|
|
9
34
|
// 模型本身不支持 function calling —— 直接说清楚,比把网关 JSON 原样甩出来有用。
|
|
10
35
|
// 这条口径原先只在 Web 智能体面板里写了一份,现在收敛到此处,CLI 与 Web 共用。
|
|
11
|
-
|
|
36
|
+
//
|
|
37
|
+
// status 同时决定这件事**要不要重试**(retryable),所以错误对象上必须带住它 ——
|
|
38
|
+
// 重试判定发生在 catch 里,那时响应对象已经不在手上了。
|
|
39
|
+
function httpFailure(status, detail, statusText, { retryAfterMs = null } = {}) {
|
|
12
40
|
const suffix = detail || statusText || ''
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
41
|
+
const error = status === 400 && /tool|function/i.test(String(detail || ''))
|
|
42
|
+
? new Error(`HTTP 400: 当前模型可能不支持 function calling(${suffix})。请在设置中换用支持工具调用的模型。`)
|
|
43
|
+
: new Error(`HTTP ${status}: ${suffix}`)
|
|
44
|
+
error.status = status
|
|
45
|
+
error.retryable = RETRYABLE_STATUS.has(status)
|
|
46
|
+
if (Number.isFinite(retryAfterMs)) error.retryAfterMs = retryAfterMs
|
|
47
|
+
return error
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 网关给的 Retry-After(秒数或 HTTP 日期)。拿不到返回 null,别让解析失败影响重试。 */
|
|
51
|
+
function readRetryAfter(resp) {
|
|
52
|
+
let raw = null
|
|
53
|
+
try { raw = resp?.headers?.get?.('retry-after') } catch { return null }
|
|
54
|
+
if (!raw) return null
|
|
55
|
+
const secs = Number(raw)
|
|
56
|
+
if (Number.isFinite(secs)) return Math.max(0, secs * 1000)
|
|
57
|
+
const at = Date.parse(raw)
|
|
58
|
+
return Number.isFinite(at) ? Math.max(0, at - Date.now()) : null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 这个错误值不值得再试一次。
|
|
63
|
+
*
|
|
64
|
+
* 默认**值得**:传输层抛出来的绝大多数是网络/流层面的瞬时故障(连接被掐、网关 5xx、
|
|
65
|
+
* 流没跑完就断、长时间没数据),重试是唯一能自愈的手段。
|
|
66
|
+
* "重试也没用"的那几类在建错误时就打了 retryable=false:HTTP 4xx(除限流/超时那三个)、
|
|
67
|
+
* 模型给了非法的 tool call index(同样的请求发过去还是同一个非法值)。
|
|
68
|
+
*/
|
|
69
|
+
function isRetryable(err) {
|
|
70
|
+
if (!err) return false
|
|
71
|
+
if (typeof err.retryable === 'boolean') return err.retryable
|
|
72
|
+
return true
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 指数退避 + 抖动;网关给了 Retry-After 就听它的(封顶,免得被一个 3600 挂住半小时)。 */
|
|
76
|
+
function retryDelayMs(attempt, err) {
|
|
77
|
+
const exp = Math.min(RETRY_MAX_DELAY_MS, RETRY_BASE_DELAY_MS * 2 ** attempt)
|
|
78
|
+
const jittered = exp / 2 + Math.random() * (exp / 2)
|
|
79
|
+
const hinted = Number.isFinite(err?.retryAfterMs) ? err.retryAfterMs : 0
|
|
80
|
+
return Math.max(jittered, Math.min(hinted, RETRY_MAX_DELAY_MS * 2))
|
|
17
81
|
}
|
|
18
82
|
|
|
19
|
-
|
|
20
|
-
|
|
83
|
+
/** 可被 abort 打断的 sleep —— 用户点了停止就别在这儿干等下一次重试。 */
|
|
84
|
+
function defaultSleep(ms, signal) {
|
|
85
|
+
return new Promise(resolve => {
|
|
86
|
+
if (!(ms > 0) || signal?.aborted) return resolve()
|
|
87
|
+
const done = () => {
|
|
88
|
+
clearTimeout(timer)
|
|
89
|
+
signal?.removeEventListener('abort', done)
|
|
90
|
+
resolve()
|
|
91
|
+
}
|
|
92
|
+
const timer = setTimeout(done, ms)
|
|
93
|
+
signal?.addEventListener('abort', done, { once: true })
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 跑一次 LLM 流式请求(失败按上面的口径自动重试)。
|
|
99
|
+
*
|
|
100
|
+
* @param {object} o
|
|
101
|
+
* @param {number} [o.idleTimeoutMs] 连续多久收不到数据算连接已死(0 = 不判超时)
|
|
102
|
+
* @param {number} [o.maxRetries] 自动重试次数上限(不含首次尝试)
|
|
103
|
+
* @param {Function} [o.onRetry] 每次重试**之前**调用:
|
|
104
|
+
* `({ attempt, maxRetries, delayMs, error, partial })`,
|
|
105
|
+
* attempt 从 1 开始。调用方拿它把界面上那半截丢掉
|
|
106
|
+
* (partial = 这次尝试已经吐出去的正文/思考/工具调用)。
|
|
107
|
+
* @param {Function} [o.sleepFn] 退避等待(测试注入用,签名 (ms, signal) => Promise)
|
|
108
|
+
*/
|
|
109
|
+
export async function streamChatOnce({ model, messages, signal, onToken = () => {}, onRetry = null,
|
|
110
|
+
sessionId, extraTools, fetchFn = fetch, idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
|
|
111
|
+
maxRetries = MAX_LLM_RETRIES, sleepFn = defaultSleep }) {
|
|
21
112
|
const { url, headers } = buildAiChatRequest({ ...model, sessionId })
|
|
22
113
|
const providerKey = `${url}\n${model.model}`
|
|
23
114
|
// 内置 7 个工具是底座;extraTools 是 MCP 等外部能力的叠加(见 extensions.js)。
|
|
@@ -25,79 +116,127 @@ export async function streamChatOnce({ model, messages, signal, onToken = () =>
|
|
|
25
116
|
const tools = Array.isArray(extraTools) && extraTools.length ? [...TOOL_DEFINITIONS, ...extraTools] : TOOL_DEFINITIONS
|
|
26
117
|
const body = { model: model.model, messages, tools, temperature: 0.3, stream: true }
|
|
27
118
|
if (!withoutStreamUsage.has(providerKey)) body.stream_options = { include_usage: true }
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
if (signal?.aborted) controller.abort()
|
|
33
|
-
else signal?.addEventListener('abort', onAbort)
|
|
34
|
-
let content = '', reasoning = '', usage = null, finished = false
|
|
35
|
-
const toolCalls = []
|
|
119
|
+
const abortedByUser = () => Boolean(signal?.aborted)
|
|
120
|
+
const emptyResult = () => ({ content: '', reasoning: '', toolCalls: [], usage: null, aborted: true })
|
|
121
|
+
const stallError = () => new Error(
|
|
122
|
+
`模型响应超时:连续 ${Math.round(idleTimeoutMs / 1000)} 秒没有收到任何数据,请重试或切换模型。`)
|
|
36
123
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
// Events report snapshots, so replace fields instead of summing snapshots.
|
|
48
|
-
const nextUsage = normalizeUsage(evt.usage)
|
|
49
|
-
if (nextUsage) usage = Object.fromEntries(Object.entries(nextUsage).map(([key, value]) => [key, value ?? usage?.[key] ?? null]))
|
|
50
|
-
if (evt.choices?.[0]?.finish_reason) finished = true
|
|
51
|
-
const delta = evt.choices?.[0]?.delta || {}
|
|
52
|
-
const thinking = delta.reasoning_content || delta.reasoning || delta.reasoning_text || ''
|
|
53
|
-
if (thinking) { reasoning += thinking; onToken({ thinking }) }
|
|
54
|
-
if (delta.content) { content += delta.content; onToken({ content: delta.content }) }
|
|
55
|
-
for (const tc of delta.tool_calls || []) {
|
|
56
|
-
const i = tc.index ?? 0
|
|
57
|
-
if (!Number.isInteger(i) || i < 0 || i > 1024) throw new Error('Invalid tool call index')
|
|
58
|
-
if (!toolCalls[i]) toolCalls[i] = { id: '', type: 'function', function: { name: '', arguments: '' } }
|
|
59
|
-
if (tc.id) toolCalls[i].id += tc.id
|
|
60
|
-
if (tc.function?.name) toolCalls[i].function.name += tc.function.name
|
|
61
|
-
if (tc.function?.arguments) toolCalls[i].function.arguments += tc.function.arguments
|
|
62
|
-
}
|
|
63
|
-
if (delta.tool_calls?.length) onToken({ toolCalls: true })
|
|
64
|
-
}
|
|
65
|
-
try {
|
|
66
|
-
const request = () => fetchFn(url, { method: 'POST', headers, body: JSON.stringify(body), signal: controller.signal })
|
|
67
|
-
let resp = await request()
|
|
68
|
-
if (!resp.ok) {
|
|
69
|
-
const detail = describeAiHttpError(await resp.text().catch(() => ''), resp.status)
|
|
70
|
-
if ([400, 422].includes(resp.status) && body.stream_options && /stream_options|include_usage/i.test(detail)) {
|
|
71
|
-
delete body.stream_options
|
|
72
|
-
withoutStreamUsage.add(providerKey)
|
|
73
|
-
resp = await request()
|
|
74
|
-
} else throw httpFailure(resp.status, detail, resp.statusText)
|
|
124
|
+
// 一次尝试:建连接 → 收流 → 返回结果。失败时把"已经吐出去的半截"挂在错误上抛给调用方。
|
|
125
|
+
const attemptOnce = async () => {
|
|
126
|
+
const controller = new AbortController()
|
|
127
|
+
let timedOut = false
|
|
128
|
+
let timer = null
|
|
129
|
+
// 每收到一段数据就重置空闲计时(这就是"整条流上限"变"空闲上限"的全部实现)
|
|
130
|
+
const touch = () => {
|
|
131
|
+
if (!(idleTimeoutMs > 0)) return
|
|
132
|
+
if (timer) clearTimeout(timer)
|
|
133
|
+
timer = setTimeout(() => { timedOut = true; controller.abort() }, idleTimeoutMs)
|
|
75
134
|
}
|
|
76
|
-
|
|
77
|
-
|
|
135
|
+
const onAbort = () => controller.abort()
|
|
136
|
+
if (signal?.aborted) controller.abort()
|
|
137
|
+
else signal?.addEventListener('abort', onAbort)
|
|
138
|
+
let content = '', reasoning = '', usage = null, finished = false
|
|
139
|
+
const toolCalls = []
|
|
140
|
+
|
|
141
|
+
const consumeLine = line => {
|
|
142
|
+
const trimmed = line.trim()
|
|
143
|
+
if (!trimmed.startsWith('data:')) return
|
|
144
|
+
const payload = trimmed.slice(5).trim()
|
|
145
|
+
if (payload === '[DONE]') { finished = true; return }
|
|
146
|
+
if (!payload) return
|
|
147
|
+
let evt
|
|
148
|
+
try { evt = JSON.parse(payload) } catch { throw new Error('Invalid JSON in model stream') }
|
|
149
|
+
if (evt.error) throw new Error(evt.error.message || 'Model stream failed')
|
|
150
|
+
// Usage may arrive after finish_reason, in a final event with choices: [].
|
|
151
|
+
// Events report snapshots, so replace fields instead of summing snapshots.
|
|
152
|
+
const nextUsage = normalizeUsage(evt.usage)
|
|
153
|
+
if (nextUsage) usage = Object.fromEntries(Object.entries(nextUsage).map(([key, value]) => [key, value ?? usage?.[key] ?? null]))
|
|
154
|
+
if (evt.choices?.[0]?.finish_reason) finished = true
|
|
155
|
+
const delta = evt.choices?.[0]?.delta || {}
|
|
156
|
+
const thinking = delta.reasoning_content || delta.reasoning || delta.reasoning_text || ''
|
|
157
|
+
if (thinking) { reasoning += thinking; onToken({ thinking }) }
|
|
158
|
+
if (delta.content) { content += delta.content; onToken({ content: delta.content }) }
|
|
159
|
+
for (const tc of delta.tool_calls || []) {
|
|
160
|
+
const i = tc.index ?? 0
|
|
161
|
+
// 非法 index 是模型/网关给的脏数据,同样的请求重发还是同一个脏值 —— 明确不可重试
|
|
162
|
+
if (!Number.isInteger(i) || i < 0 || i > 1024) {
|
|
163
|
+
const err = new Error('Invalid tool call index')
|
|
164
|
+
err.retryable = false
|
|
165
|
+
throw err
|
|
166
|
+
}
|
|
167
|
+
if (!toolCalls[i]) toolCalls[i] = { id: '', type: 'function', function: { name: '', arguments: '' } }
|
|
168
|
+
if (tc.id) toolCalls[i].id += tc.id
|
|
169
|
+
if (tc.function?.name) toolCalls[i].function.name += tc.function.name
|
|
170
|
+
if (tc.function?.arguments) toolCalls[i].function.arguments += tc.function.arguments
|
|
171
|
+
}
|
|
172
|
+
if (delta.tool_calls?.length) onToken({ toolCalls: true })
|
|
78
173
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
// 首次计时在请求发出**之前**开始:连响应头都还没回来的那段时间同样算"没有数据"
|
|
177
|
+
touch()
|
|
178
|
+
const request = () => fetchFn(url, { method: 'POST', headers, body: JSON.stringify(body), signal: controller.signal })
|
|
179
|
+
let resp = await request()
|
|
180
|
+
if (!resp.ok) {
|
|
181
|
+
const detail = describeAiHttpError(await resp.text().catch(() => ''), resp.status)
|
|
182
|
+
if ([400, 422].includes(resp.status) && body.stream_options && /stream_options|include_usage/i.test(detail)) {
|
|
183
|
+
// 网关不认 stream_options:去掉它再来一次。这不是"重试"(请求本身没错),
|
|
184
|
+
// 所以不占重试次数,也不通知 onRetry。
|
|
185
|
+
delete body.stream_options
|
|
186
|
+
withoutStreamUsage.add(providerKey)
|
|
187
|
+
touch()
|
|
188
|
+
resp = await request()
|
|
189
|
+
} else throw httpFailure(resp.status, detail, resp.statusText, { retryAfterMs: readRetryAfter(resp) })
|
|
190
|
+
}
|
|
191
|
+
if (!resp.ok || !resp.body) {
|
|
192
|
+
throw httpFailure(resp.status, describeAiHttpError(await resp.text().catch(() => ''), resp.status), resp.statusText,
|
|
193
|
+
{ retryAfterMs: readRetryAfter(resp) })
|
|
194
|
+
}
|
|
195
|
+
const decoder = new TextDecoder()
|
|
196
|
+
let buf = ''
|
|
197
|
+
for await (const chunk of resp.body) {
|
|
198
|
+
touch()
|
|
199
|
+
buf += decoder.decode(chunk, { stream: true })
|
|
200
|
+
const lines = buf.split('\n')
|
|
201
|
+
buf = lines.pop() ?? ''
|
|
202
|
+
for (const line of lines) consumeLine(line)
|
|
203
|
+
}
|
|
204
|
+
buf += decoder.decode()
|
|
205
|
+
if (buf.trim()) consumeLine(buf)
|
|
206
|
+
if (controller.signal.aborted) throw new DOMException('Aborted', 'AbortError')
|
|
207
|
+
if (!finished) throw new Error('模型响应意外中断,请重试;未执行不完整的工具调用。')
|
|
208
|
+
return { content, reasoning, toolCalls: toolCalls.filter(Boolean), usage, aborted: false }
|
|
209
|
+
} catch (err) {
|
|
210
|
+
if (controller.signal.aborted && !timedOut) {
|
|
211
|
+
return { content, reasoning, toolCalls: [], usage, aborted: true }
|
|
212
|
+
}
|
|
213
|
+
const error = timedOut ? stallError() : err
|
|
214
|
+
error.usage = usage
|
|
215
|
+
// 这次尝试已经吐给 onToken 的东西。调用方在重试前必须把它丢掉,
|
|
216
|
+
// 否则界面上会出现"半截 + 重来一遍"拼在一起的两段话。
|
|
217
|
+
error.partial = { content, reasoning, toolCalls: toolCalls.filter(Boolean) }
|
|
218
|
+
throw error
|
|
219
|
+
} finally {
|
|
220
|
+
if (timer) clearTimeout(timer)
|
|
221
|
+
signal?.removeEventListener('abort', onAbort)
|
|
86
222
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if (
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
for (let attempt = 0; ; attempt++) {
|
|
226
|
+
if (abortedByUser()) return emptyResult()
|
|
227
|
+
try {
|
|
228
|
+
return await attemptOnce()
|
|
229
|
+
} catch (err) {
|
|
230
|
+
// 用户点的停止不是故障,绝不重试
|
|
231
|
+
if (abortedByUser()) throw err
|
|
232
|
+
if (!isRetryable(err) || attempt >= maxRetries) throw err
|
|
233
|
+
const delayMs = retryDelayMs(attempt, err)
|
|
234
|
+
try {
|
|
235
|
+
onRetry?.({ attempt: attempt + 1, maxRetries, delayMs, error: err, partial: err.partial })
|
|
236
|
+
} catch { /* onRetry 是调用方的界面钩子,它自己抛错不该吃掉这次重试 */ }
|
|
237
|
+
await sleepFn(delayMs, signal)
|
|
238
|
+
// 退避期间用户点了停止:当"已中止"收场,别再抛一个超时错误吓他
|
|
239
|
+
if (abortedByUser()) return emptyResult()
|
|
95
240
|
}
|
|
96
|
-
const error = timedOut ? new Error('模型响应超时,请重试或切换模型。') : err
|
|
97
|
-
error.usage = usage
|
|
98
|
-
throw error
|
|
99
|
-
} finally {
|
|
100
|
-
clearTimeout(timer)
|
|
101
|
-
signal?.removeEventListener('abort', onAbort)
|
|
102
241
|
}
|
|
103
242
|
}
|
package/src/cli/ai/turn.js
CHANGED
|
@@ -65,14 +65,18 @@ export async function runAgentTurn(state, userText, t, images = [], dependencies
|
|
|
65
65
|
for (let iter = 0; iter < maxIterations; iter++) {
|
|
66
66
|
if (cancelled()) { stats.status = 'cancelled'; return stats }
|
|
67
67
|
const spinner = ui.startSpinner(t.waiting)
|
|
68
|
-
|
|
68
|
+
// 一次请求可能被传输层自动重试多次(长静默 / 网关 5xx / 流中断)。
|
|
69
|
+
// 每次尝试都换一套 writer + filter:终端没法把已经打出去的字擦掉,
|
|
70
|
+
// 与其让两次尝试的输出拼在一起,不如把上一截收尾、重开一块新的(中间一行重试提示)。
|
|
71
|
+
const makeWriter = () => ui.createAssistantWriter({
|
|
69
72
|
showThinking: state.showThinking !== false,
|
|
70
73
|
thinkingHeader: t.thinkingLabel,
|
|
71
74
|
answerHeader: t.answerLabel,
|
|
72
75
|
thinkingLimit: state.thinkingMode === 'compact' ? terminal.THINKING_PREVIEW_LINES : Infinity,
|
|
73
76
|
thinkingHint: t.thinkingHint,
|
|
74
77
|
})
|
|
75
|
-
|
|
78
|
+
let writer = makeWriter()
|
|
79
|
+
let filter = createThinkFilter()
|
|
76
80
|
const render = seg => {
|
|
77
81
|
if (seg.content) {
|
|
78
82
|
if (seg.content.trim() && stats.firstAnswerMs === null) stats.firstAnswerMs = performance.now() - started
|
|
@@ -90,6 +94,16 @@ export async function runAgentTurn(state, userText, t, images = [], dependencies
|
|
|
90
94
|
const messages = prepareRequestMessages(state.messages, { locale: state.locale, ...(state.requestBudget || {}) })
|
|
91
95
|
result = await chat({ model: state.model, messages, signal: state.abortController?.signal,
|
|
92
96
|
sessionId: state.sessionId, extraTools: extensions?.tools,
|
|
97
|
+
onRetry: info => {
|
|
98
|
+
filter.flush().forEach(render)
|
|
99
|
+
writer.finish()
|
|
100
|
+
ui.printWarn(t.llmRetrying(info.attempt, info.maxRetries, info.error?.message || ''))
|
|
101
|
+
writer = makeWriter()
|
|
102
|
+
filter = createThinkFilter()
|
|
103
|
+
// 重试是一次**真的**请求,计入本轮请求数(/stats 里那个"请求数"要能对得上网关账单)
|
|
104
|
+
stats.requests++
|
|
105
|
+
stats.retries++
|
|
106
|
+
},
|
|
93
107
|
onToken: token => {
|
|
94
108
|
if (stats.firstTokenMs === null) stats.firstTokenMs = performance.now() - started
|
|
95
109
|
if (token.content || (token.thinking && state.showThinking !== false)) spinner.stop()
|
package/src/config.js
CHANGED
|
@@ -126,6 +126,56 @@ export function normalizeNotifySwitch(value) {
|
|
|
126
126
|
return typeof value === 'boolean' ? value : null;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
// 「预设提示词」(智能体视图欢迎页的快捷卡片)的上限。
|
|
130
|
+
// 只防"手改配置把 UI/请求写爆",不是产品硬约束:卡片区是自适应网格,
|
|
131
|
+
// 12 条在常见窗口里已经铺满;label 上限覆盖"一句话标题",prompt 上限
|
|
132
|
+
// 覆盖"一段能把要求说全的指令"。
|
|
133
|
+
const AGENT_PRESET_PROMPTS_MAX = 12;
|
|
134
|
+
const AGENT_PRESET_LABEL_MAX = 60;
|
|
135
|
+
const AGENT_PRESET_PROMPT_MAX = 4000;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 规范化「预设提示词」数组(全局配置,跨项目共享)。
|
|
139
|
+
*
|
|
140
|
+
* 语义约定(2026-10-09 新增):
|
|
141
|
+
* · undefined / null → null(没配过,调用方回落 defaultConfig,即内置 5 条)
|
|
142
|
+
* · [] → [](**合法值**:显式"恢复内置默认"就是这个表达)
|
|
143
|
+
* · 非数组 / 任一条目结构坏 → null(整体拒绝:宁可整组回落内置,也不生成
|
|
144
|
+
* "点了没反应"的半截卡片。UI 保存前会防呆,这条主要兜手改 config.json)
|
|
145
|
+
* · 正常数组 → 清洗后的副本:trim、去重/补 id、超长截断、超条数截断
|
|
146
|
+
* (超量与超长属于"手笔太大",夹取比整组丢掉更贴近用户意图,
|
|
147
|
+
* 与 normalizeAiMaxToolIterations 的越界夹取同一套语义)
|
|
148
|
+
*/
|
|
149
|
+
export function normalizeAgentPresetPrompts(value) {
|
|
150
|
+
if (value === undefined || value === null) return null;
|
|
151
|
+
if (!Array.isArray(value)) return null;
|
|
152
|
+
const out = [];
|
|
153
|
+
const seen = new Set();
|
|
154
|
+
for (const item of value) {
|
|
155
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) return null;
|
|
156
|
+
const label = typeof item.label === 'string' ? item.label.trim() : '';
|
|
157
|
+
const prompt = typeof item.prompt === 'string' ? item.prompt.trim() : '';
|
|
158
|
+
if (!label || !prompt) return null;
|
|
159
|
+
let id = typeof item.id === 'string' ? item.id.trim() : '';
|
|
160
|
+
// id 缺失或重复都无伤大雅(前端只拿它当 v-for key),补一个稳定且唯一的即可;
|
|
161
|
+
// 真正需要拒绝的是 label/prompt 缺失 —— 那会让卡片点了没反应。
|
|
162
|
+
if (!id || seen.has(id)) {
|
|
163
|
+
let n = out.length + 1;
|
|
164
|
+
id = `preset-${n}`;
|
|
165
|
+
while (seen.has(id)) id = `preset-${++n}-${Math.random().toString(36).slice(2, 5)}`;
|
|
166
|
+
}
|
|
167
|
+
seen.add(id);
|
|
168
|
+
out.push({
|
|
169
|
+
id,
|
|
170
|
+
label: label.slice(0, AGENT_PRESET_LABEL_MAX),
|
|
171
|
+
prompt: prompt.slice(0, AGENT_PRESET_PROMPT_MAX),
|
|
172
|
+
});
|
|
173
|
+
// 超出上限的部分直接放弃检查(反正要截断),避免为"手滑粘贴 500 条"做无谓校验
|
|
174
|
+
if (out.length >= AGENT_PRESET_PROMPTS_MAX) break;
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
|
|
129
179
|
/**
|
|
130
180
|
* 三个提示通道(页面提示 / 浏览器通知 / 提示音)的最终取值。
|
|
131
181
|
*
|
|
@@ -213,6 +263,10 @@ const defaultConfig = {
|
|
|
213
263
|
// 越界值夹取到 [20,000, 1,000,000]。解析公式见 cli/ai/context.js 的 resolveRequestBudget。
|
|
214
264
|
// 旧字段 aiMaxRequestChars(字符口径)由 resolveRequestTokens 迁移读取。
|
|
215
265
|
aiMaxRequestTokens: REQUEST_DEFAULT_MAX_TOKENS,
|
|
266
|
+
// 智能体视图欢迎页的「预设提示词」快捷卡片(全局配置,CLI 不消费、只有 Web 端用)。
|
|
267
|
+
// 空数组 = 使用前端内置的 5 条默认(zh/en 文案随界面语言走,见 utils/agentPresets.ts);
|
|
268
|
+
// 非空 = 用户自定义全量(每条 {id, label, prompt})。归一化见 normalizeAgentPresetPrompts。
|
|
269
|
+
agentPresetPrompts: [],
|
|
216
270
|
// 工作台任务执行器(claude | opencode | codex)。全局配置,跨项目共享。
|
|
217
271
|
// 决定「执行任务 / 执行子任务 / 从此处开始 / 简单任务续聊」这条链路
|
|
218
272
|
// 默认 spawn 哪个本地 CLI;执行入口可以按次覆盖(见 workbench 执行路由)。
|
|
@@ -559,6 +613,8 @@ async function loadConfig() {
|
|
|
559
613
|
?? defaultConfig.aiMaxToolIterations,
|
|
560
614
|
aiMaxRequestTokens: resolveRequestTokens(raw),
|
|
561
615
|
taskExecutor: normalizeTaskExecutor(raw.taskExecutor) ?? defaultConfig.taskExecutor,
|
|
616
|
+
agentPresetPrompts: normalizeAgentPresetPrompts(raw.agentPresetPrompts)
|
|
617
|
+
?? defaultConfig.agentPresetPrompts,
|
|
562
618
|
...resolveNotifySwitches(raw)
|
|
563
619
|
};
|
|
564
620
|
}
|
|
@@ -580,6 +636,8 @@ async function loadConfig() {
|
|
|
580
636
|
aiMaxRequestTokens: resolveRequestTokens(raw),
|
|
581
637
|
taskExecutor: normalizeTaskExecutor(raw?.taskExecutor) ?? defaultConfig.taskExecutor,
|
|
582
638
|
// 同 taskExecutor:全局配置,始终取顶层,防止被项目配置里的旧值覆盖
|
|
639
|
+
agentPresetPrompts: normalizeAgentPresetPrompts(raw?.agentPresetPrompts)
|
|
640
|
+
?? defaultConfig.agentPresetPrompts,
|
|
583
641
|
...resolveNotifySwitches(raw)
|
|
584
642
|
};
|
|
585
643
|
}
|
|
@@ -633,6 +691,7 @@ async function saveConfig(config) {
|
|
|
633
691
|
// 项目级是这一族键最容易踩的坑。解构出来本身不写回顶层(它已不再被任何地方读取)。
|
|
634
692
|
const {
|
|
635
693
|
theme, locale, models, ui, aiMaxToolIterations, aiMaxRequestTokens, taskExecutor,
|
|
694
|
+
agentPresetPrompts,
|
|
636
695
|
notifyOnTaskDone: legacyNotifyOnTaskDone,
|
|
637
696
|
notifyPageOnTaskDone, notifyBrowserOnTaskDone, notifySoundOnTaskDone,
|
|
638
697
|
...projectConfig
|
|
@@ -680,6 +739,12 @@ async function saveConfig(config) {
|
|
|
680
739
|
raw[key] = normalized;
|
|
681
740
|
}
|
|
682
741
|
}
|
|
742
|
+
// 预设提示词同属全局设置:空数组是**合法值**(= 恢复内置默认),照写;
|
|
743
|
+
// 非法值(结构坏)不落盘,保留磁盘旧值 —— 与上面各键同一套语义。
|
|
744
|
+
const normalizedPresets = normalizeAgentPresetPrompts(agentPresetPrompts);
|
|
745
|
+
if (normalizedPresets !== null) {
|
|
746
|
+
raw.agentPresetPrompts = normalizedPresets;
|
|
747
|
+
}
|
|
683
748
|
|
|
684
749
|
// 写入当前项目配置(在 defaultConfig 基础上合并,但不清空顶层其它键)
|
|
685
750
|
const existingProjectConfig = (raw.projects[key] && typeof raw.projects[key] === 'object') ? raw.projects[key] : {};
|
|
@@ -886,6 +951,8 @@ export default {
|
|
|
886
951
|
// 只在 `export function` 里命名导出是不够的:漏加会变成 undefined 调用 → 路由 500,
|
|
887
952
|
// 而且因为有 try/catch 兜底,前端只会看到"保存失败",不知道是哪个函数缺了。
|
|
888
953
|
normalizeNotifySwitch,
|
|
954
|
+
// 智能体视图「预设提示词」的规范化(GUI 保存前也要用,见 /api/config/save-ai-settings)
|
|
955
|
+
normalizeAgentPresetPrompts,
|
|
889
956
|
};
|
|
890
957
|
|
|
891
958
|
// 命名导出 — 用于测试与外部复用
|