@huicai/cli 0.5.1

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/src/agent.js ADDED
@@ -0,0 +1,348 @@
1
+ // 客户端 Agent 循环:以平台 /v1 为模型后端,模型决定调哪个工具,工具在本机执行,结果回灌,直至无工具调用。
2
+ // 核心机制:循环与工具都在客户端,平台只做模型推理与计费。
3
+ //
4
+ // 两种运行形态(按 config.model 自动判定):
5
+ // - 原始模型:本机工具循环(内置文件/命令工具 + todo_write + memory_write + extraTools[MCP/子智能体]);
6
+ // - 伪模型 agent-/workflow-/datahuman-:平台编排直连(不传 tools,纯流式多轮)——即智能体/数字人/工作流
7
+ // 分享页的对话能力,CLI/扩展/REPL/TUI 全部可用。
8
+ //
9
+ // 关键机制:
10
+ // - 连续的只读工具调用并行执行(结果仍按原顺序回灌);
11
+ // - 工具输出截断保头尾注入上下文;工具参数 JSON 解析失败时把错误回灌模型(而非静默空参数);
12
+ // - 中断时为未执行的 tool_calls 合成占位结果(协议配对完整);
13
+ // - pullQueued:用户回合中排队的消息在工具轮间隙/回合末注入;
14
+ // - 思考过程(reasoning)流式上抛(events.onReasoning / REPL 灰字),config.showReasoning=false 关闭;
15
+ // - 上下文压缩为**非破坏式**:摘要存 compaction 状态(随会话持久化),原消息/页面显示不受影响,
16
+ // 仅发给模型时经 buildModelMessages 组装视图;
17
+ // - 文本化工具调用防泄漏(与平台侧同款):模型把工具调用写成纯文本标记时(DeepSeek 系退化)
18
+ // 过滤不显示、不进会话,并注入纠正指令重试(一次要求改走 tool_calls 通道,二次禁工具强制作答)。
19
+
20
+ import { streamChat } from './api.js';
21
+ import { MarkupFilter, stripMarkup } from './markup-filter.js';
22
+ import { TOOL_SPECS, READONLY_TOOLS, runTool } from './tools.js';
23
+ import { toolLabel, indent } from './render.js';
24
+ import { compactIfNeeded, buildModelMessages } from './compaction.js';
25
+ import { makeTodoTool, formatTodos, TODO_TOOL_NAME } from './todo.js';
26
+ import { makeMemoryTool } from './memory.js';
27
+ import * as ide from './ide.js';
28
+ import { c } from './colors.js';
29
+
30
+ const DEFAULT_MAX_ITERS = 60;
31
+ const MAX_TOOL_RESULT_CHARS = 20_000;
32
+ const CAP_HEAD = 16_000;
33
+ const CAP_TAIL = 3_000;
34
+
35
+ // 「文本化工具调用」被拦截后的纠正指令:与平台侧 ToolCallingOrchestrator 同款两级策略。
36
+ // 第一次:要求改走规范 tool_calls 通道重试;第二次:禁用工具、强制直接作答(保证用户拿到干净答复)。
37
+ const MARKUP_RETRY_PROMPT =
38
+ '你刚才的输出把工具调用写成了文本标记,工具并未被执行,该内容也不会展示给用户。'
39
+ + '请重新处理:若需调用工具,必须使用规范的函数调用(tool_calls)通道发起,'
40
+ + '绝不要以任何文本形式书写工具调用标记;若无需工具,请直接用中文输出面向用户的最终答复。';
41
+ const MARKUP_FORCE_ANSWER_PROMPT =
42
+ '请停止调用任何工具。基于目前已获得的全部信息,直接用中文输出面向用户的完整最终答复,'
43
+ + '不要输出任何工具调用或协议标记。';
44
+
45
+ /** 伪模型(平台编排)判定:agent-/workflow-/datahuman- 前缀。 */
46
+ export function isPlatformModel(model) {
47
+ return /^(agent|workflow|datahuman)-/.test(String(model || ''));
48
+ }
49
+
50
+ function shortLine(s, max = 200) {
51
+ const first = String(s).split('\n')[0];
52
+ return first.length > max ? first.slice(0, max) + '…' : first;
53
+ }
54
+
55
+ /** 工具输出超长时截断(保头尾),防止单条结果撑爆上下文。 */
56
+ export function capToolResult(text) {
57
+ const s = String(text);
58
+ if (s.length <= MAX_TOOL_RESULT_CHARS) return s;
59
+ const omitted = s.length - CAP_HEAD - CAP_TAIL;
60
+ return s.slice(0, CAP_HEAD) + `\n…【工具输出过长,中间已省略 ${omitted} 字符】…\n` + s.slice(-CAP_TAIL);
61
+ }
62
+
63
+ /**
64
+ * 跑一个用户回合:mutate messages(追加 assistant/tool 消息),流式打印正文与工具进展。
65
+ *
66
+ * @param {object} p
67
+ * @param {{spec:object,run:Function,readOnly:boolean}[]} [p.extraTools] 动态外部工具(MCP / 子智能体)
68
+ * @param {number} [p.depth] 子智能体递归深度(0=顶层;todo/memory 仅顶层提供)
69
+ * @param {{write:(s:string)=>void}} [p.io] 输出通道(子智能体传静默通道)
70
+ * @param {object} [p.events] 结构化事件:onDelta/onReasoning/onAssistantText/onToolStart/onToolResult/onSkip/onInfo/onTodo/onMemory/onTurnEnd
71
+ * @param {{list:any[]}} [p.todoState] 任务清单状态(调用方持有可跨轮保留)
72
+ * @param {{upTo?:number, summary?:string}} [p.compaction] 压缩状态(随会话持久化;原消息不被修改)
73
+ * @param {() => (string|null)} [p.pullQueued] 取一条用户排队消息(无则 null)
74
+ * @param {number} [p.maxIters] 本回合最大工具轮数(默认 config.maxTurns 或 60)
75
+ * @returns {Promise<{aborted?:boolean, error?:string, usage:{prompt:number,completion:number}, lastPromptTokens:number}>}
76
+ */
77
+ export async function runTurn({
78
+ config, messages, permissions, cwd, ask, signal,
79
+ extraTools = [], depth = 0, io, events, todoState, compaction, pullQueued, maxIters,
80
+ }) {
81
+ const out = (io && io.write) || ((s) => process.stdout.write(s));
82
+ const ev = events || null; // 结构化事件(ink TUI / 扩展用);为空则走 out() 字符串输出(普通 REPL)
83
+ const info = (t) => {
84
+ if (ev) ev.onInfo?.(t);
85
+ else out(c.gray(' · ' + t) + '\n');
86
+ };
87
+ let prompt = 0;
88
+ let completion = 0;
89
+ let lastPromptTokens = 0;
90
+ const iterMax = Number(maxIters || config.maxTurns) || DEFAULT_MAX_ITERS;
91
+ const platformMode = isPlatformModel(config.model);
92
+ const compState = compaction || {}; // 未传则本回合内独立生效(不持久化)
93
+
94
+ // 顶层注入 todo_write / memory_write(平台编排模式无本机工具,均不注入)
95
+ const dynamicTools = [...extraTools];
96
+ if (depth === 0 && !platformMode && !dynamicTools.some((t) => t.spec?.function?.name === TODO_TOOL_NAME)) {
97
+ const state = todoState || { list: [] };
98
+ dynamicTools.push(
99
+ makeTodoTool({
100
+ state,
101
+ emit: (list) => {
102
+ if (ev) {
103
+ if (ev.onTodo) ev.onTodo(list.map((t) => ({ ...t })));
104
+ else ev.onInfo?.('任务清单:\n' + formatTodos(list));
105
+ } else {
106
+ out(c.gray(indent(formatTodos(list), ' ')) + '\n');
107
+ }
108
+ },
109
+ }),
110
+ );
111
+ }
112
+ if (depth === 0 && !platformMode && config.memoryEnabled !== false
113
+ && !dynamicTools.some((t) => t.spec?.function?.name === 'memory_write')) {
114
+ dynamicTools.push(
115
+ makeMemoryTool({
116
+ cwd,
117
+ emit: (t) => {
118
+ if (ev) {
119
+ if (ev.onMemory) ev.onMemory(t);
120
+ else ev.onInfo?.('已记入长期记忆:' + shortLine(t, 80));
121
+ } else {
122
+ out(c.gray(' ✎ 已记入长期记忆:' + shortLine(t, 80)) + '\n');
123
+ }
124
+ },
125
+ }),
126
+ );
127
+ }
128
+
129
+ const extraByName = new Map();
130
+ for (const t of dynamicTools) extraByName.set(t.spec.function.name, t);
131
+ const specs = platformMode ? undefined : [...TOOL_SPECS, ...dynamicTools.map((t) => t.spec)];
132
+
133
+ let markupRetries = 0; // 「文本化工具调用」纠正次数(首次要求改走 tool_calls 通道,二次禁工具收敛)
134
+ let toolsDisabled = false;
135
+ let correction = null; // 待注入的纠正指令:仅随本轮请求发给模型,不进会话记录(与平台侧临时 convo 等价)
136
+
137
+ for (let iter = 0; iter < iterMax; iter++) {
138
+ // 非破坏式上下文压缩:仅更新 compState(摘要独立存储),messages/页面内容不动
139
+ const compacted = await compactIfNeeded(messages, config, { state: compState, lastPromptTokens });
140
+ if (compacted > 0) info(`已压缩上下文(${compacted} 条较早消息已摘要,页面与会话记录不受影响)`);
141
+
142
+ let streamedAny = false;
143
+ let streamedReason = false;
144
+ const mf = new MarkupFilter(); // 文本化工具标记防泄漏:每轮模型输出独立实例
145
+ const emitDelta = (t) => {
146
+ if (!t) return;
147
+ if (!streamedAny && streamedReason && !ev) out('\n'); // 思考→正文换行
148
+ streamedAny = true;
149
+ if (ev) ev.onDelta?.(t);
150
+ else out(t);
151
+ };
152
+ const modelMessages = buildModelMessages(messages, compState);
153
+ let res;
154
+ try {
155
+ res = await streamChat(
156
+ {
157
+ baseUrl: config.baseUrl,
158
+ apiKey: config.apiKey,
159
+ model: config.model,
160
+ messages: correction ? [...modelMessages, { role: 'user', content: correction }] : modelMessages,
161
+ tools: toolsDisabled ? undefined : specs,
162
+ // 对话落库审计:来源与会话号(由 config 透传;扩展侧为 vscode)
163
+ client: config.client,
164
+ sessionId: config.sessionId,
165
+ },
166
+ {
167
+ onContent: (t) => emitDelta(mf.feed(t)),
168
+ onReasoning: (t) => {
169
+ if (config.showReasoning === false) return;
170
+ if (ev) {
171
+ ev.onReasoning?.(t);
172
+ } else {
173
+ if (!streamedReason) out(c.gray('\n[思考] '));
174
+ out(c.gray(t));
175
+ }
176
+ streamedReason = true;
177
+ },
178
+ onRetry: (n, waitMs, reason) => info(`模型响应异常,${Math.round(waitMs / 1000)}s 后自动重试(第 ${n} 次):${reason}`),
179
+ },
180
+ signal,
181
+ );
182
+ } catch (e) {
183
+ if (e.name === 'AbortError') {
184
+ if (ev) ev.onInfo?.('已中断');
185
+ else out(c.gray('\n[已中断]\n'));
186
+ return { aborted: true, usage: { prompt, completion }, lastPromptTokens };
187
+ }
188
+ if (ev) ev.onInfo?.('✗ 模型调用失败:' + e.message);
189
+ else out('\n' + c.red('✗ 模型调用失败:') + e.message + '\n');
190
+ return { error: e.message, usage: { prompt, completion }, lastPromptTokens };
191
+ }
192
+ emitDelta(mf.flush()); // 放行轮末仍持有的未确认内容(吞噬中则丢弃)
193
+
194
+ if (res.usage) {
195
+ prompt += res.usage.prompt_tokens || 0;
196
+ completion += res.usage.completion_tokens || 0;
197
+ lastPromptTokens = res.usage.prompt_tokens || lastPromptTokens;
198
+ }
199
+
200
+ // 全量内容按同一状态机二次清洗(与流式放行结果一致):协议标记绝不进入会话记录与后续上下文
201
+ const { text: cleanText, sawMarkup: sawInFull } = stripMarkup(res.content);
202
+ const markupRound = sawInFull || mf.sawMarkup;
203
+ correction = null; // 上一轮注入的纠正指令已被消费
204
+
205
+ const toolCalls = platformMode ? [] : res.toolCalls;
206
+ if (!toolCalls.length) {
207
+ // 「文本化工具调用」拦截后的纠正:本轮"正文"实为协议标记(已被过滤,未显示,工具也未执行)。
208
+ // 平台编排模式(伪模型)由服务端过滤与纠正,客户端不再重试。
209
+ if (markupRound && !cleanText.trim() && !platformMode) {
210
+ if (markupRetries < 2) {
211
+ markupRetries++;
212
+ if (markupRetries === 2) toolsDisabled = true;
213
+ correction = markupRetries === 1 ? MARKUP_RETRY_PROMPT : MARKUP_FORCE_ANSWER_PROMPT;
214
+ info(`已拦截模型的文本化工具调用标记(工具未被执行),纠正重试中(第 ${markupRetries} 次)`);
215
+ continue;
216
+ }
217
+ info('模型持续输出无效的工具调用标记,已全部拦截;本轮无有效答复(可换个说法重试或更换模型)');
218
+ }
219
+ if (streamedAny && !ev) out('\n');
220
+ // 记录最终答复进上下文,使后续用户回合能看到助手上一次回复(多轮对话记忆)
221
+ if (cleanText) messages.push({ role: 'assistant', content: cleanText });
222
+ if (ev) ev.onTurnEnd?.(cleanText);
223
+ // 用户在回合中排队了新消息 → 直接续跑,无需重新发起回合
224
+ const queued = pullQueued?.();
225
+ if (queued) {
226
+ messages.push({ role: 'user', content: queued });
227
+ continue;
228
+ }
229
+ return { usage: { prompt, completion }, lastPromptTokens };
230
+ }
231
+
232
+ if (streamedAny && !ev) out('\n');
233
+ if (ev && cleanText) ev.onAssistantText?.(cleanText); // 工具轮前的正文先落定
234
+ messages.push({ role: 'assistant', content: cleanText, tool_calls: toolCalls });
235
+
236
+ // 预解析全部调用(名称/参数/只读性);参数 JSON 解析失败记录错误(回灌模型重试,而非静默空参数)
237
+ const parsed = toolCalls.map((tc, idx) => {
238
+ const name = tc.function?.name || '';
239
+ let args = {};
240
+ let argsError = null;
241
+ const raw = tc.function?.arguments || '';
242
+ if (raw) {
243
+ try {
244
+ args = JSON.parse(raw);
245
+ } catch (e) {
246
+ argsError = `工具参数不是合法 JSON(${e.message}):${String(raw).slice(0, 200)}…请修正参数后重新调用。`;
247
+ }
248
+ }
249
+ const extra = extraByName.get(name);
250
+ // IDE 集成下,write_file/edit_file 的批准发生在编辑器的内联 diff 审阅里,跳过 CLI 自身的 y/n(避免双重确认)
251
+ const deferToIde = ide.isActive() && (name === 'write_file' || name === 'edit_file');
252
+ const readOnly = extra ? !!extra.readOnly : READONLY_TOOLS.has(name);
253
+ return { tc, idx, name, args, argsError, extra, readOnly, deferToIde };
254
+ });
255
+
256
+ // 中断时为剩余未执行的 tool_calls 合成占位结果,保证 assistant.tool_calls 配对完整(协议要求)
257
+ const abortRest = (from) => {
258
+ for (let k = from; k < parsed.length; k++) {
259
+ messages.push({ role: 'tool', tool_call_id: parsed[k].tc.id || `call_${k}`, content: '(用户中断,该工具未执行)' });
260
+ }
261
+ if (ev) ev.onInfo?.('已中断');
262
+ else out(c.gray('\n[已中断]\n'));
263
+ return { aborted: true, usage: { prompt, completion }, lastPromptTokens };
264
+ };
265
+
266
+ const execOne = async (item) => {
267
+ if (item.argsError) return item.argsError;
268
+ let result;
269
+ try {
270
+ result = item.extra ? await item.extra.run(item.args) : await runTool(item.name, item.args, cwd);
271
+ } catch (e) {
272
+ result = `工具执行失败:${e.message}`;
273
+ }
274
+ return capToolResult(String(result));
275
+ };
276
+ const emitStart = (item) => {
277
+ if (ev) ev.onToolStart?.(item.name, item.args);
278
+ else out(c.cyan(' ⏺ ') + toolLabel(item.name, item.args) + '\n');
279
+ };
280
+ const emitResult = (item, result) => {
281
+ if (ev) ev.onToolResult?.(item.name, String(result));
282
+ else out(c.gray(' ' + shortLine(result)) + '\n');
283
+ messages.push({ role: 'tool', tool_call_id: item.tc.id, content: String(result) });
284
+ };
285
+ const emitSkip = (item, reason) => {
286
+ if (ev) ev.onSkip?.(item.name, item.args);
287
+ else out(c.gray(` ⊘ ${toolLabel(item.name, item.args)} — 已跳过\n`));
288
+ messages.push({ role: 'tool', tool_call_id: item.tc.id, content: reason || '未授权,未执行。' });
289
+ };
290
+
291
+ let i = 0;
292
+ while (i < parsed.length) {
293
+ if (signal?.aborted) return abortRest(i);
294
+ const cur = parsed[i];
295
+
296
+ // 参数损坏:不请求授权、不执行,直接把解析错误回灌(模型可修正后重试)
297
+ if (cur.argsError) {
298
+ emitStart(cur);
299
+ emitResult(cur, cur.argsError);
300
+ i++;
301
+ continue;
302
+ }
303
+
304
+ if (cur.readOnly) {
305
+ // 连续只读段:并行执行(权限对只读即时放行),结果按原顺序回灌
306
+ let j = i;
307
+ while (j < parsed.length && parsed[j].readOnly && !parsed[j].argsError) j++;
308
+ const batch = parsed.slice(i, j);
309
+ const settled = await Promise.all(
310
+ batch.map(async (item) => {
311
+ const perm = await permissions.request(item.name, item.args, cwd, true);
312
+ if (!perm.allow) return { item, denied: perm.reason || '未授权,未执行。' };
313
+ emitStart(item);
314
+ return { item, result: await execOne(item) };
315
+ }),
316
+ );
317
+ for (const s of settled) {
318
+ if (s.denied != null) emitSkip(s.item, s.denied);
319
+ else emitResult(s.item, s.result);
320
+ }
321
+ i = j;
322
+ if (signal?.aborted) return abortRest(i);
323
+ continue;
324
+ }
325
+
326
+ // 写/执行类:顺序执行,逐个审批
327
+ const perm = await permissions.request(cur.name, cur.args, cwd, cur.readOnly || cur.deferToIde);
328
+ if (signal?.aborted) return abortRest(i);
329
+ if (!perm.allow) {
330
+ emitSkip(cur, perm.reason);
331
+ i++;
332
+ continue;
333
+ }
334
+ emitStart(cur);
335
+ const result = await execOne(cur);
336
+ emitResult(cur, result);
337
+ i++;
338
+ }
339
+
340
+ // 工具轮之间注入用户排队消息(协议合法:tool 配对已完整),模型下一轮即可看到
341
+ const queued = pullQueued?.();
342
+ if (queued) messages.push({ role: 'user', content: queued });
343
+ }
344
+
345
+ if (ev) ev.onInfo?.('已达最大工具轮数,停止');
346
+ else out(c.yellow('\n[已达最大工具轮数,停止]\n'));
347
+ return { usage: { prompt, completion }, lastPromptTokens };
348
+ }
package/src/api.js ADDED
@@ -0,0 +1,326 @@
1
+ // HuiCai_AI 平台 OpenAI 兼容 /v1 客户端(零依赖,用 Node 内置 fetch + Web Streams)。
2
+ //
3
+ // 对「原始模型 id」,平台 /v1/chat/completions 为透明代理:透传 tools、原样回传 tool_calls 与流式增量。
4
+ // 因此本客户端把平台当作「会做函数调用决策」的模型后端,工具在本机执行的智能体模式。
5
+ //
6
+ // 可靠性:首字节前的 429/5xx/网络错自动指数退避重试(尊重 Retry-After);流中途长时间无数据由
7
+ // 停滞看门狗中止,避免挂死。一旦开始收到增量则不再重试(防止重复输出/重复计费)。
8
+
9
+ const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
10
+ const MAX_RETRIES = 2; // 共尝试 1+2 次
11
+ const STALL_TIMEOUT_MS = 90_000; // 流中无任何字节超过该时长视为停滞
12
+ const REQUEST_TIMEOUT_MS = 15_000; // 非流式小请求超时
13
+
14
+ /** 从 Authorization 组装请求头。 */
15
+ function authHeaders(apiKey) {
16
+ return {
17
+ 'Content-Type': 'application/json',
18
+ Authorization: `Bearer ${apiKey}`,
19
+ };
20
+ }
21
+
22
+ /** 读取错误响应体并抽出可读信息与 OpenAI error.type(供识别用量窗口超限等终态)。 */
23
+ async function readError(res) {
24
+ let text = '';
25
+ try {
26
+ text = await res.text();
27
+ } catch {
28
+ /* ignore */
29
+ }
30
+ try {
31
+ const j = JSON.parse(text);
32
+ const type = j?.error?.type || j?.type || '';
33
+ if (j?.error?.message) return { message: j.error.message, type };
34
+ if (j?.message) return { message: j.message, type };
35
+ if (j?.msg) return { message: j.msg, type };
36
+ } catch {
37
+ /* not json */
38
+ }
39
+ return { message: text || `HTTP ${res.status}`, type: '' };
40
+ }
41
+
42
+ /** 把一批 tool_calls 增量按 index 累加到 acc。 */
43
+ function mergeToolCalls(acc, deltas) {
44
+ for (const tc of deltas) {
45
+ const i = typeof tc.index === 'number' ? tc.index : acc.length;
46
+ if (!acc[i]) acc[i] = { id: '', type: 'function', function: { name: '', arguments: '' } };
47
+ if (tc.id) acc[i].id = tc.id;
48
+ if (tc.type) acc[i].type = tc.type;
49
+ if (tc.function?.name) acc[i].function.name += tc.function.name;
50
+ if (tc.function?.arguments) acc[i].function.arguments += tc.function.arguments;
51
+ }
52
+ }
53
+
54
+ function sleep(ms, signal) {
55
+ return new Promise((resolve, reject) => {
56
+ if (signal?.aborted) return reject(abortError());
57
+ const t = setTimeout(() => {
58
+ signal?.removeEventListener?.('abort', onAbort);
59
+ resolve();
60
+ }, ms);
61
+ const onAbort = () => {
62
+ clearTimeout(t);
63
+ reject(abortError());
64
+ };
65
+ signal?.addEventListener?.('abort', onAbort, { once: true });
66
+ });
67
+ }
68
+
69
+ function abortError() {
70
+ const e = new Error('已中断');
71
+ e.name = 'AbortError';
72
+ return e;
73
+ }
74
+
75
+ /** 由重试次数与 Retry-After 头计算退避时长(毫秒)。 */
76
+ function backoffMs(attempt, res) {
77
+ const ra = res?.headers?.get?.('retry-after');
78
+ const raSec = ra != null ? Number(ra) : NaN;
79
+ if (Number.isFinite(raSec) && raSec >= 0) return Math.min(raSec * 1000, 30_000);
80
+ return Math.min(500 * 2 ** attempt + Math.floor(Math.random() * 250), 8_000);
81
+ }
82
+
83
+ /**
84
+ * 流式对话补全。逐块解析 SSE:content/reasoning 经 handlers 实时下发,tool_calls 累积后返回。
85
+ *
86
+ * @param {{baseUrl:string, apiKey:string, model:string, messages:any[], tools?:any[], temperature?:number, stallTimeoutMs?:number}} opts
87
+ * @param {{onContent?:(t:string)=>void, onReasoning?:(t:string)=>void, onRetry?:(attempt:number, waitMs:number, reason:string)=>void}} [handlers]
88
+ * @param {AbortSignal} [signal]
89
+ * @returns {Promise<{content:string, toolCalls:any[], finishReason:string|null, usage:any}>}
90
+ */
91
+ export async function streamChat(opts, handlers = {}, signal) {
92
+ const url = `${opts.baseUrl}/v1/chat/completions`;
93
+ const payload = {
94
+ model: opts.model,
95
+ messages: opts.messages,
96
+ stream: true,
97
+ stream_options: { include_usage: true },
98
+ };
99
+ if (opts.tools && opts.tools.length) {
100
+ payload.tools = opts.tools;
101
+ payload.tool_choice = 'auto';
102
+ }
103
+ if (typeof opts.temperature === 'number') payload.temperature = opts.temperature;
104
+ // 对话落库审计标识:来源(cli/vscode) + 会话号(平台按此归并同会话多轮,供后台审计)
105
+ const chatHeaders = authHeaders(opts.apiKey);
106
+ if (opts.client) chatHeaders['X-Huicai-Client'] = opts.client;
107
+ if (opts.sessionId) chatHeaders['X-Huicai-Session'] = opts.sessionId;
108
+ const body = JSON.stringify(payload);
109
+
110
+ // 重试仅发生在「拿到可读响应体之前」:状态可重试或网络错。开始读流后不再重试。
111
+ for (let attempt = 0; ; attempt++) {
112
+ let res;
113
+ try {
114
+ res = await fetch(url, { method: 'POST', headers: chatHeaders, body, signal });
115
+ } catch (e) {
116
+ if (e?.name === 'AbortError' || signal?.aborted) throw abortError();
117
+ if (attempt < MAX_RETRIES) {
118
+ const wait = backoffMs(attempt, null);
119
+ handlers.onRetry?.(attempt + 1, wait, '网络错误:' + (e?.message || e));
120
+ await sleep(wait, signal);
121
+ continue;
122
+ }
123
+ throw new Error('网络错误:' + (e?.message || e));
124
+ }
125
+ if (!res.ok || !res.body) {
126
+ const err = await readError(res);
127
+ // 用量窗口超限(5小时/7天滚动窗)为终态:不重试,直接抛出清晰提示(含重置时间),避免无谓退避重试
128
+ const usageLimited = err.type === 'usage_limit_exceeded';
129
+ if (!usageLimited && RETRYABLE_STATUS.has(res.status) && attempt < MAX_RETRIES) {
130
+ const wait = backoffMs(attempt, res);
131
+ handlers.onRetry?.(attempt + 1, wait, `HTTP ${res.status}:${err.message}`);
132
+ await sleep(wait, signal);
133
+ continue;
134
+ }
135
+ throw new Error(err.message);
136
+ }
137
+ return readStream(res, handlers, signal, opts.stallTimeoutMs ?? STALL_TIMEOUT_MS);
138
+ }
139
+ }
140
+
141
+ /** 读取 SSE 响应体:外部 signal 可中断;停滞看门狗防挂死。 */
142
+ async function readStream(res, handlers, signal, stallMs) {
143
+ const reader = res.body.getReader();
144
+ const decoder = new TextDecoder();
145
+ let buffer = '';
146
+ let content = '';
147
+ const toolCalls = [];
148
+ let finishReason = null;
149
+ let usage = null;
150
+
151
+ let stalled = false;
152
+ let stallTimer = null;
153
+ const armStall = () => {
154
+ if (stallTimer) clearTimeout(stallTimer);
155
+ stallTimer = setTimeout(() => {
156
+ stalled = true;
157
+ reader.cancel().catch(() => {});
158
+ }, stallMs);
159
+ };
160
+ const onAbort = () => reader.cancel().catch(() => {});
161
+ signal?.addEventListener?.('abort', onAbort, { once: true });
162
+
163
+ try {
164
+ armStall();
165
+ while (true) {
166
+ let chunk;
167
+ try {
168
+ chunk = await reader.read();
169
+ } catch (e) {
170
+ if (signal?.aborted) throw abortError();
171
+ if (stalled) throw new Error(`响应停滞超过 ${Math.round(stallMs / 1000)}s,已中止(可重试)`);
172
+ throw e;
173
+ }
174
+ if (signal?.aborted) throw abortError();
175
+ if (stalled) throw new Error(`响应停滞超过 ${Math.round(stallMs / 1000)}s,已中止(可重试)`);
176
+ const { done, value } = chunk;
177
+ if (done) break;
178
+ armStall();
179
+ buffer += decoder.decode(value, { stream: true });
180
+ const lines = buffer.split('\n');
181
+ buffer = lines.pop() ?? '';
182
+ for (const raw of lines) {
183
+ const line = raw.trim();
184
+ if (!line.startsWith('data:')) continue;
185
+ const data = line.slice(5).trim();
186
+ if (data === '[DONE]' || data === '') continue;
187
+ let json;
188
+ try {
189
+ json = JSON.parse(data);
190
+ } catch {
191
+ continue; // 半个 JSON(跨块),忽略;完整帧下轮再来
192
+ }
193
+ if (json.usage) usage = json.usage;
194
+ const choice = json.choices?.[0];
195
+ if (!choice) continue;
196
+ const delta = choice.delta || {};
197
+ const rc = delta.reasoning_content ?? delta.reasoning;
198
+ if (rc) handlers.onReasoning?.(rc);
199
+ if (delta.content) {
200
+ content += delta.content;
201
+ handlers.onContent?.(delta.content);
202
+ }
203
+ if (Array.isArray(delta.tool_calls)) mergeToolCalls(toolCalls, delta.tool_calls);
204
+ if (choice.finish_reason) finishReason = choice.finish_reason;
205
+ }
206
+ }
207
+ } finally {
208
+ if (stallTimer) clearTimeout(stallTimer);
209
+ signal?.removeEventListener?.('abort', onAbort);
210
+ }
211
+ return { content, toolCalls: toolCalls.filter(Boolean), finishReason, usage };
212
+ }
213
+
214
+ /** 列出可用模型(含平台已发布智能体伪模型 agent-/workflow-/datahuman-)。 */
215
+ export async function listModels(baseUrl, apiKey) {
216
+ const res = await fetch(`${baseUrl}/v1/models`, {
217
+ headers: authHeaders(apiKey),
218
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
219
+ });
220
+ if (!res.ok) throw new Error((await readError(res)).message);
221
+ const j = await res.json();
222
+ return Array.isArray(j?.data) ? j.data : [];
223
+ }
224
+
225
+ /**
226
+ * 读取客户端相关的平台默认配置(GET /v1/config)。
227
+ * @returns {Promise<{vision_bridge_model?:string}>} 失败时返回 {}(客户端自行回退)
228
+ */
229
+ export async function getPlatformConfig(baseUrl, apiKey) {
230
+ try {
231
+ const res = await fetch(`${baseUrl}/v1/config`, {
232
+ headers: authHeaders(apiKey),
233
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
234
+ });
235
+ if (!res.ok) return {};
236
+ const j = await res.json();
237
+ return j && typeof j === 'object' ? j : {};
238
+ } catch {
239
+ return {};
240
+ }
241
+ }
242
+
243
+ /**
244
+ * 拉取某已发布智能体的人格(供 --agent 本机执行模式采用)。
245
+ * @param {string} ref 分享令牌,或伪模型ID agent-{token}
246
+ * @returns {Promise<{name:string,description:string,systemPrompt:string,model:string,temperature:number,maxTokens:number}>}
247
+ */
248
+ export async function getAgentPersona(baseUrl, apiKey, ref) {
249
+ const res = await fetch(`${baseUrl}/v1/agents/${encodeURIComponent(ref)}/persona`, {
250
+ headers: authHeaders(apiKey),
251
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
252
+ });
253
+ if (!res.ok) throw new Error((await readError(res)).message);
254
+ return res.json();
255
+ }
256
+
257
+ /**
258
+ * 查询当前令牌用户的 Token 用量窗口(5小时/7天滚动窗,GET /v1/usage)。
259
+ * @returns {Promise<{allowed:boolean,used5h:number,limit5h:number,used7d:number,limit7d:number,resetAtEpochMs?:number|null,message?:string}>}
260
+ */
261
+ export async function fetchUsage(baseUrl, apiKey) {
262
+ const res = await fetch(`${baseUrl}/v1/usage`, {
263
+ headers: authHeaders(apiKey),
264
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
265
+ });
266
+ if (!res.ok) throw new Error((await readError(res)).message);
267
+ return res.json();
268
+ }
269
+
270
+ /**
271
+ * 解析文档附件为纯文本(POST /v1/files/extract:PDF/Word/Excel/PPT/RTF 及各类文本,Base64 传输)。
272
+ * 服务端与 Web 分享页同源解析(knowledge DocumentParser),扫描件/二进制会得到明确报错。
273
+ * 大文件解析较慢,超时放宽到 2 分钟。
274
+ * @param {{filename:string, data:string, maxChars?:number}} payload data=文件内容 Base64
275
+ * @returns {Promise<{name:string,ext:string,category:string,chars:number,truncated:boolean,pages?:number|null,text:string}>}
276
+ */
277
+ export async function extractDocument(baseUrl, apiKey, payload) {
278
+ const res = await fetch(`${baseUrl}/v1/files/extract`, {
279
+ method: 'POST',
280
+ headers: authHeaders(apiKey),
281
+ body: JSON.stringify(payload),
282
+ signal: AbortSignal.timeout(120_000),
283
+ });
284
+ if (!res.ok) throw new Error((await readError(res)).message);
285
+ return res.json();
286
+ }
287
+
288
+ /**
289
+ * 列出平台上我的文档技能(GET /v1/skills,云端技能库;供 huicai skills pull)。
290
+ * @returns {Promise<{id:number,name:string,description:string,version:string,updateTime:string}[]>}
291
+ */
292
+ export async function listPlatformSkills(baseUrl, apiKey) {
293
+ const res = await fetch(`${baseUrl}/v1/skills`, {
294
+ headers: authHeaders(apiKey),
295
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
296
+ });
297
+ if (!res.ok) throw new Error((await readError(res)).message);
298
+ const j = await res.json();
299
+ return Array.isArray(j?.data) ? j.data : [];
300
+ }
301
+
302
+ /** 平台技能详情(含 SKILL.md 全文与附件,GET /v1/skills/{id})。 */
303
+ export async function getPlatformSkill(baseUrl, apiKey, id) {
304
+ const res = await fetch(`${baseUrl}/v1/skills/${encodeURIComponent(id)}`, {
305
+ headers: authHeaders(apiKey),
306
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
307
+ });
308
+ if (!res.ok) throw new Error((await readError(res)).message);
309
+ return res.json();
310
+ }
311
+
312
+ /**
313
+ * 推送本地技能到平台(POST /v1/skills/import;同名冲突 skip/overwrite)。
314
+ * @param {{content:string,filename?:string,conflict?:string,files?:{path:string,content:string}[]}} payload
315
+ * @returns {Promise<{id:number,status:'imported'|'overwritten'|'skipped',warnings?:string[]}>}
316
+ */
317
+ export async function pushPlatformSkill(baseUrl, apiKey, payload) {
318
+ const res = await fetch(`${baseUrl}/v1/skills/import`, {
319
+ method: 'POST',
320
+ headers: { ...authHeaders(apiKey), 'Content-Type': 'application/json' },
321
+ body: JSON.stringify(payload),
322
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
323
+ });
324
+ if (!res.ok) throw new Error((await readError(res)).message);
325
+ return res.json();
326
+ }