mingdao-harness 0.3.1 → 0.4.0
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/docs/CONFIG.md +24 -2
- package/docs/DEVELOPER.md +126 -0
- package/docs/PLAN-v0.3.2.md +65 -0
- package/docs/PLAN-v0.4.0.md +38 -0
- package/docs/STRATEGY-NEXT.md +175 -0
- package/package.json +2 -1
- package/presets/local-audit.json +10 -0
- package/src/agent.js +47 -4
- package/src/cli.js +37 -6
- package/src/commands/repl.js +42 -5
- package/src/compact.js +3 -1
- package/src/index.js +30 -4
- package/src/model-caps.js +69 -0
- package/src/presets.js +154 -0
- package/src/prompts.js +5 -2
- package/src/providers/index.js +45 -6
- package/src/providers/openai-compatible.js +8 -3
- package/src/tasks/worker.js +3 -0
- package/src/tools/index.js +113 -1
- package/src/web/app.js +15 -3
- package/src/web/index.html +15 -1
- package/src/web/routes/domains/config.js +26 -2
- package/src/web/routes/domains/misc.js +8 -0
- package/src/web/server.js +30 -4
package/src/cli.js
CHANGED
|
@@ -79,6 +79,7 @@ const HELP_LINES = [
|
|
|
79
79
|
[' mingdao --journal 新会话带上最近会话日志(默认不注入,新会话全新开始)', null],
|
|
80
80
|
[' mingdao --resume 从会话列表选择恢复', null],
|
|
81
81
|
[' mingdao --model <模型名> 指定模型,例如 deepseek-v4-pro', null],
|
|
82
|
+
[' mingdao --preset <名> 应用智能体预设(工具白名单/权限/参数,v0.4.0 契约化)', null],
|
|
82
83
|
[' mingdao init 初始化配置向导', null],
|
|
83
84
|
[' mingdao update [--check] 一键自更新(git 安装形态;--check 只对比版本)', null],
|
|
84
85
|
[' mingdao rollback 回滚到上次 update 之前的提交', null],
|
|
@@ -125,6 +126,8 @@ function parseArgs(/** @type {any} */ argv) {
|
|
|
125
126
|
else if (a === '-v' || a === '--version') opts.version = true;
|
|
126
127
|
else if (a === '-c' || a === '--continue') opts.continueSession = true;
|
|
127
128
|
else if (a === '--journal') opts.journal = true;
|
|
129
|
+
else if (a === '-p' || a === '--preset') opts.preset = argv[++i];
|
|
130
|
+
else if (a.startsWith('--preset=')) opts.preset = a.slice(9);
|
|
128
131
|
else if (a === '-r' || a === '--resume') opts.resume = true;
|
|
129
132
|
else if (a === '--init' || a === 'init') opts.init = true;
|
|
130
133
|
else if (a === '-m' || a === '--model') opts.model = argv[++i];
|
|
@@ -350,6 +353,29 @@ async function main() {
|
|
|
350
353
|
// 模型回退链(向导允许跳过模型选择 → cfg.model 可缺省):参数 > config > 该服务商首个预设模型 > flash
|
|
351
354
|
let modelName = opts.model || cfg.model || /** @type {any} */ (PROVIDERS)[cfg.provider]?.models?.[0] || 'deepseek-v4-flash';
|
|
352
355
|
const io = createIO();
|
|
356
|
+
const workingDir = process.cwd();
|
|
357
|
+
|
|
358
|
+
// v0.4.0 Agent Preset:--preset <名> 应用声明式预设(工具白名单/权限/模型/参数 + 系统提示定制段)。
|
|
359
|
+
// 预设覆盖优先级:CLI 显式参数 > 预设 > config.json。
|
|
360
|
+
// 会话级 overlay:不改写 cfg(否则 REPL /model、/think 的 saveConfig 会把预设字段持久化进 config.json)。
|
|
361
|
+
let activePreset = /** @type {any} */ (null);
|
|
362
|
+
let presetBlock = '';
|
|
363
|
+
let presetOverlay = /** @type {Record<string, any>} */ ({});
|
|
364
|
+
if (opts.preset) {
|
|
365
|
+
const { loadPreset, presetConfigOverrides, presetSystemBlock, listPresets } = await import('./presets.js');
|
|
366
|
+
activePreset = loadPreset(workingDir, opts.preset);
|
|
367
|
+
if (!activePreset) {
|
|
368
|
+
const names = listPresets(workingDir).map((/** @type {any} */ p) => p.name).join(', ') || '(无可用预设)';
|
|
369
|
+
io.print(style(`⚠ 预设 "${opts.preset}" 不存在。可用:${names}`, C.yellow));
|
|
370
|
+
} else {
|
|
371
|
+
presetOverlay = { ...presetConfigOverrides(activePreset), presetName: activePreset.name };
|
|
372
|
+
if (!opts.model && presetOverlay.model) modelName = presetOverlay.model;
|
|
373
|
+
presetBlock = presetSystemBlock(activePreset);
|
|
374
|
+
io.print(style(`▣ 已应用智能体预设:${activePreset.name}${activePreset.label ? '(' + activePreset.label + ')' : ''}`, C.cyan));
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
// agent 使用的配置 = cfg + 预设 overlay(presetTools/permission/参数按预设生效,cfg 本体保持干净)
|
|
378
|
+
const agentCfg = Object.keys(presetOverlay).length ? { ...cfg, ...presetOverlay } : cfg;
|
|
353
379
|
|
|
354
380
|
const pc0 = resolveProviderConfig(cfg, modelName);
|
|
355
381
|
if (!pc0.apiKey) {
|
|
@@ -367,8 +393,13 @@ async function main() {
|
|
|
367
393
|
}
|
|
368
394
|
|
|
369
395
|
let provider = await createProvider(cfg, modelName);
|
|
370
|
-
const permission = createPermission(
|
|
371
|
-
|
|
396
|
+
const permission = createPermission(agentCfg.permission ?? 'ask', io);
|
|
397
|
+
// v0.4.0 契约化:挂载 config.tools 声明式第三方工具(幂等,重启生效)
|
|
398
|
+
{
|
|
399
|
+
const { mountConfigTools } = await import('./tools/index.js');
|
|
400
|
+
const mounted = mountConfigTools(cfg);
|
|
401
|
+
if (mounted.length) io.print(style(`🔧 已挂载声明式工具(config.tools):${mounted.join(', ')}`, C.dim));
|
|
402
|
+
}
|
|
372
403
|
// 会话级 undo 备份仓:模型切换、子代理均共享,撤销记录不丢失
|
|
373
404
|
const sessionUndoStore = { backups: new Map() };
|
|
374
405
|
// MCP 服务器:后台启动(不阻塞交互),就绪后工具自动出现在后续轮次
|
|
@@ -410,7 +441,7 @@ async function main() {
|
|
|
410
441
|
io,
|
|
411
442
|
modelName,
|
|
412
443
|
workingDir,
|
|
413
|
-
cfg,
|
|
444
|
+
cfg: agentCfg,
|
|
414
445
|
undoStore: sessionUndoStore,
|
|
415
446
|
mcp: mcpFacade,
|
|
416
447
|
sessionRef,
|
|
@@ -440,7 +471,7 @@ async function main() {
|
|
|
440
471
|
const turnIo = jsonMode ? createIO({ quiet: true }) : io;
|
|
441
472
|
const session = createSession(home);
|
|
442
473
|
const messages = [
|
|
443
|
-
{ role: 'system', content: buildSystemPrompt({ modelName, workingDir, withJournal }) },
|
|
474
|
+
{ role: 'system', content: buildSystemPrompt({ modelName, workingDir, withJournal, presetBlock }) },
|
|
444
475
|
{ role: 'user', content: question },
|
|
445
476
|
];
|
|
446
477
|
let oneShotPersisted = messages.length;
|
|
@@ -451,7 +482,7 @@ async function main() {
|
|
|
451
482
|
io: turnIo,
|
|
452
483
|
modelName,
|
|
453
484
|
workingDir,
|
|
454
|
-
cfg,
|
|
485
|
+
cfg: agentCfg,
|
|
455
486
|
undoStore: sessionUndoStore,
|
|
456
487
|
mcp: mcpFacade,
|
|
457
488
|
sessionRef: oneShotRef,
|
|
@@ -528,7 +559,7 @@ async function main() {
|
|
|
528
559
|
|
|
529
560
|
// —— 交互式 TUI(Phase C C2:已抽取至 commands/repl.js) ——
|
|
530
561
|
const { runRepl } = await import('./commands/repl.js');
|
|
531
|
-
await runRepl({ io, cfg, home, pc0, opts, modelName, provider, permission, workingDir, sessionUndoStore, mcpFacade, mcpManager, sessionRef, agent, preset, withJournal, tuiState });
|
|
562
|
+
await runRepl({ io, cfg, agentCfg, home, pc0, opts, modelName, provider, permission, workingDir, sessionUndoStore, mcpFacade, mcpManager, sessionRef, agent, preset, withJournal, presetBlock, tuiState });
|
|
532
563
|
return;
|
|
533
564
|
|
|
534
565
|
}
|
package/src/commands/repl.js
CHANGED
|
@@ -98,6 +98,7 @@ const HELP_LINES = [
|
|
|
98
98
|
['会话内命令', C.bold + C.yellow],
|
|
99
99
|
[' /help 显示帮助 /clear 清空上下文', null],
|
|
100
100
|
[' /model <名> 切换模型 /mode pro/flash 快捷切换', null],
|
|
101
|
+
[' /preset 列出/切换智能体预设(v0.4.0 契约化)', null],
|
|
101
102
|
[' /compact 压缩上下文 /plan 计划模式(先计划后执行)', null],
|
|
102
103
|
[' /init 生成 AGENTS.md /memory add <内容> 追加用户记忆', null],
|
|
103
104
|
[' /skills 列出技能 /status 会话状态 · /cost 累计费用', null],
|
|
@@ -140,6 +141,10 @@ async function generatePlan(provider, modelName, task) {
|
|
|
140
141
|
*/
|
|
141
142
|
export async function runRepl(ctx) {
|
|
142
143
|
const { io, cfg, home, pc0, opts, permission, workingDir, sessionUndoStore, mcpFacade, sessionRef, preset, withJournal, tuiState } = ctx;
|
|
144
|
+
let presetBlock = ctx.presetBlock || ''; // v0.4.0 Agent Preset 系统提示定制段(/preset 可切换)
|
|
145
|
+
let permissionNow = permission; // /preset 可换权限模式
|
|
146
|
+
// v0.4.0 Agent Preset:agent 用 agentCfg(预设 overlay 会话级生效);cfg 保持干净供 saveConfig 持久化
|
|
147
|
+
let agentCfg = ctx.agentCfg || cfg;
|
|
143
148
|
let mcpManager = ctx.mcpManager;
|
|
144
149
|
let modelName = ctx.modelName;
|
|
145
150
|
let provider = ctx.provider;
|
|
@@ -216,7 +221,7 @@ export async function runRepl(ctx) {
|
|
|
216
221
|
sessionRef.name = path.basename(session.file);
|
|
217
222
|
io.print(style(`会话 ${path.basename(session.file)}`, C.dim));
|
|
218
223
|
|
|
219
|
-
const systemPrompt = buildSystemPrompt({ modelName, workingDir, withJournal });
|
|
224
|
+
const systemPrompt = buildSystemPrompt({ modelName, workingDir, withJournal, presetBlock });
|
|
220
225
|
// 恢复会话时刷新 system prompt(用户记忆 / AGENTS.md / 技能清单 / 时间戳以当前为准),
|
|
221
226
|
// 旧 system 消息保留在会话文件中,不影响追加历史。
|
|
222
227
|
const loadedMsgs = session.messages || [];
|
|
@@ -258,11 +263,11 @@ export async function runRepl(ctx) {
|
|
|
258
263
|
}
|
|
259
264
|
agent = createAgent({
|
|
260
265
|
provider,
|
|
261
|
-
permission,
|
|
266
|
+
permission: permissionNow,
|
|
262
267
|
io,
|
|
263
268
|
modelName,
|
|
264
269
|
workingDir,
|
|
265
|
-
cfg,
|
|
270
|
+
cfg: agentCfg, // v0.4.0:预设 overlay 恒生效(/model 后白名单不丢)
|
|
266
271
|
undoStore: sessionUndoStore,
|
|
267
272
|
mcp: mcpFacade,
|
|
268
273
|
sessionRef,
|
|
@@ -271,7 +276,7 @@ export async function runRepl(ctx) {
|
|
|
271
276
|
tuiState.persisted = msgs.length;
|
|
272
277
|
},
|
|
273
278
|
});
|
|
274
|
-
messages[0] = { role: 'system', content: buildSystemPrompt({ workingDir, withJournal }) };
|
|
279
|
+
messages[0] = { role: 'system', content: buildSystemPrompt({ workingDir, withJournal, presetBlock }) };
|
|
275
280
|
if (!silent) {
|
|
276
281
|
const p2 = modelPreset(modelName);
|
|
277
282
|
io.print(style(`✓ 已切换到 ${C.bold}${modelName}${C.reset}${p2 ? `(${p2.label})` : ''}`, C.green));
|
|
@@ -309,6 +314,38 @@ export async function runRepl(ctx) {
|
|
|
309
314
|
} catch {}
|
|
310
315
|
tuiState.persisted = messages.length;
|
|
311
316
|
io.print('已清空上下文(会话文件已同步重置)。');
|
|
317
|
+
} else if (cmd === '/preset') {
|
|
318
|
+
// v0.4.0 Agent Preset:列出/切换声明式智能体预设(工具白名单/权限/参数 + 系统提示定制段)
|
|
319
|
+
const { listPresets, loadPreset, presetConfigOverrides, presetSystemBlock } = await import('../presets.js');
|
|
320
|
+
if (!arg) {
|
|
321
|
+
const ps = listPresets(workingDir);
|
|
322
|
+
if (!ps.length) io.print(style('(无可用预设。目录:<项目>/.mingdao/presets/、~/.mingdao/presets/、内置 presets/)', C.dim));
|
|
323
|
+
else for (const p of ps) io.print(` - ${p.name}(${p.label || '无标签'},${p.source === 'project' ? '项目' : p.source === 'user' ? '用户' : '内置'})${p.description ? ':' + p.description : ''}`);
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
const pname = arg.split(/\s+/)[0];
|
|
327
|
+
const p = loadPreset(workingDir, pname);
|
|
328
|
+
if (!p) {
|
|
329
|
+
io.print(style(`预设 "${pname}" 不存在(/preset 列出全部)。`, C.yellow));
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
const over = presetConfigOverrides(p);
|
|
333
|
+
// 应用覆盖:权限/参数/工具白名单/模型建议——只进 agentCfg(会话级 overlay),
|
|
334
|
+
// 绝不改写 cfg:否则后续 /model、/think 的 saveConfig 会把预设字段持久化进 config.json。
|
|
335
|
+
agentCfg = { ...cfg, ...over, presetName: p.name };
|
|
336
|
+
if (over.model) await switchToModel(over.model, { silent: true, persist: false });
|
|
337
|
+
presetBlock = presetSystemBlock(p);
|
|
338
|
+
permissionNow = createPermission(agentCfg.permission ?? 'ask', io);
|
|
339
|
+
agent = createAgent({
|
|
340
|
+
provider, permission: permissionNow, io, modelName, workingDir, cfg: agentCfg,
|
|
341
|
+
undoStore: sessionUndoStore, mcp: mcpFacade, sessionRef,
|
|
342
|
+
onCompact: (/** @type {any} */ msgs) => {
|
|
343
|
+
rewriteSession(session.file, msgs);
|
|
344
|
+
tuiState.persisted = msgs.length;
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
messages[0] = { role: 'system', content: buildSystemPrompt({ workingDir, withJournal, presetBlock }) };
|
|
348
|
+
io.print(style(`▣ 已应用智能体预设:${p.name}${p.label ? '(' + p.label + ')' : ''}${over.presetTools ? `,工具白名单 ${over.presetTools.length} 个` : ''}`, C.green));
|
|
312
349
|
} else if (cmd === '/model') {
|
|
313
350
|
if (!arg) {
|
|
314
351
|
io.print(`当前模型:${modelName}`);
|
|
@@ -336,7 +373,7 @@ export async function runRepl(ctx) {
|
|
|
336
373
|
saveConfig(cfg);
|
|
337
374
|
} else { io.print(style('无效取值:low|high|max|off', C.red)); continue; }
|
|
338
375
|
agent = createAgent({
|
|
339
|
-
provider, permission, io, modelName, workingDir, cfg,
|
|
376
|
+
provider, permission: permissionNow, io, modelName, workingDir, cfg: agentCfg,
|
|
340
377
|
undoStore: sessionUndoStore, mcp: mcpFacade, sessionRef,
|
|
341
378
|
onCompact: (/** @type {any} */ msgs) => {
|
|
342
379
|
rewriteSession(session.file, msgs);
|
package/src/compact.js
CHANGED
|
@@ -65,7 +65,9 @@ export async function compactConversation(/** @type {any} */ { messages, budget,
|
|
|
65
65
|
total += t;
|
|
66
66
|
}
|
|
67
67
|
const trigger = Number.isFinite(Number(triggerRatio)) ? Number(triggerRatio) : DEFAULT_TRIGGER_RATIO;
|
|
68
|
-
|
|
68
|
+
// force(v0.3.2 边缘检测):逼近窗口时即便启发式计数低估(total 未达触发线)也强制压缩——
|
|
69
|
+
// 非 DeepSeek 模型启发式计数误差可达 ±2 倍,等它越过触发线时真实 prompt 可能已到窗口边缘。
|
|
70
|
+
if (total <= budget * trigger && !force) return null;
|
|
69
71
|
// 保留边界:保留段(boundary..end)≤ budget×TARGET_RATIO(system 恒保留)
|
|
70
72
|
let keepTokens = sizes[0] ?? 0;
|
|
71
73
|
let boundary = messages.length;
|
package/src/index.js
CHANGED
|
@@ -1,13 +1,35 @@
|
|
|
1
1
|
// MingDao-Harness 公共 API:供第三方程序/插件以库形式复用核心能力。
|
|
2
|
+
// 稳定性契约(v0.4.0 起):@stable 在 minor 版本内保持向后兼容;@experimental 可能变更。
|
|
3
|
+
// 详细契约与示例见 docs/DEVELOPER.md。
|
|
2
4
|
|
|
5
|
+
// —— @stable:Agent 内核 ——
|
|
3
6
|
export { createAgent } from './agent.js';
|
|
7
|
+
export { createPermission } from './permissions.js';
|
|
8
|
+
export { createIO, style, C } from './ui.js';
|
|
9
|
+
|
|
10
|
+
// —— @stable:Provider 与模型 ——
|
|
4
11
|
export { createProvider, resolveProviderConfig } from './providers/index.js';
|
|
5
|
-
export {
|
|
12
|
+
export { MODELS, PROVIDERS, modelPreset, providerPreset } from './models.js';
|
|
13
|
+
export { resolveModelCaps, safeBudget, isLocalBaseUrl } from './model-caps.js';
|
|
14
|
+
|
|
15
|
+
// —— @stable:工具(含 v0.4.0 第三方注册)——
|
|
16
|
+
export { toolSchemas, dispatch, registerTool, listRegisteredTools, mountConfigTools, buildToolSchemas } from './tools/index.js';
|
|
17
|
+
|
|
18
|
+
// —— @stable:Agent Preset(v0.4.0)——
|
|
19
|
+
export {
|
|
20
|
+
listPresets,
|
|
21
|
+
loadPreset,
|
|
22
|
+
validatePreset,
|
|
23
|
+
presetConfigOverrides,
|
|
24
|
+
presetSystemBlock,
|
|
25
|
+
presetDirs,
|
|
26
|
+
} from './presets.js';
|
|
27
|
+
|
|
28
|
+
// —— @stable:上下文与压缩 ——
|
|
6
29
|
export { trimMessages, approxTokens, clampText, TOOL_RESULT_LIMIT } from './context.js';
|
|
7
30
|
export { compactConversation, summarizeConversation } from './compact.js';
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
export { createIO, style, C } from './ui.js';
|
|
31
|
+
|
|
32
|
+
// —— @stable:配置与凭证 ——
|
|
11
33
|
export {
|
|
12
34
|
mingdaoHome,
|
|
13
35
|
ensureHome,
|
|
@@ -26,8 +48,12 @@ export {
|
|
|
26
48
|
maskKey,
|
|
27
49
|
resolveApiKey,
|
|
28
50
|
} from './credentials.js';
|
|
51
|
+
|
|
52
|
+
// —— @stable:计价与计量 ——
|
|
29
53
|
export { estimateCost, estimateCostLabel, isPeakHour, PRICE_DATA_AS_OF } from './pricing.js';
|
|
30
54
|
export { countTokens, heuristicTokens, makeTokenCounter, isTokenizable } from './tokenizer.js';
|
|
55
|
+
|
|
56
|
+
// —— @experimental:更新/审计/技能/会话(接口可能调整)——
|
|
31
57
|
export { updateCheck, mingdaoUpdate, mingdaoRollback, findRepoRoot } from './update.js';
|
|
32
58
|
export { writeAudit, listAudit, redactSecrets, auditFile } from './audit.js';
|
|
33
59
|
export { trustSkill, skillDirHash, readSourceMeta } from './skill-lib.js';
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// 模型能力解析(v0.3.2 本地模型自适应):
|
|
2
|
+
// 把「模型能装多少上下文、单次最多输出多少、是否本地部署」收敛成单一来源,
|
|
3
|
+
// 供预算推导、超时、工具截断统一引用——避免各层各自猜一份 128000 默认,
|
|
4
|
+
// 本地小模型(窗口小/内存少)自动收紧预算与超时,不撑爆、不误杀。
|
|
5
|
+
import { modelPreset } from './models.js';
|
|
6
|
+
|
|
7
|
+
// 兜底:未知模型默认上下文窗口。本地小模型宁可保守(不撑爆)也不乐观。
|
|
8
|
+
export const UNKNOWN_LOCAL_WINDOW = 32768;
|
|
9
|
+
export const UNKNOWN_REMOTE_WINDOW = 128000;
|
|
10
|
+
export const DEFAULT_MAX_OUTPUT = 8192;
|
|
11
|
+
// 输出余量:prompt 预算必须给模型输出留足空间,否则 prompt+output 越过窗口 → 服务端截断/拒绝。
|
|
12
|
+
export const OUTPUT_HEADROOM = 2048;
|
|
13
|
+
// 舒适区:prompt 预算最多占窗口 75%——逼近 75% 以上时 prefill 时间陡增(长上下文 dequant 开销),
|
|
14
|
+
// 留 25% 给输出 + 抗抖缓冲,从根上避免「prompt 到窗口边缘 → 首 token 等 200s+ 被客户端掐断」。
|
|
15
|
+
export const COMFORT_RATIO = 0.75;
|
|
16
|
+
// 边缘比:模型上报的真实 prompt_tokens 逼近窗口 85% 即视为「边缘」,本回合结束强制激进压缩。
|
|
17
|
+
export const EDGE_RATIO = 0.85;
|
|
18
|
+
|
|
19
|
+
/** 判断 baseUrl 是否指向本机/内网(本地推理框架部署)。 */
|
|
20
|
+
export function isLocalBaseUrl(/** @type {any} */ baseUrl) {
|
|
21
|
+
try {
|
|
22
|
+
const u = new URL(String(baseUrl || ''));
|
|
23
|
+
const h = u.hostname.toLowerCase();
|
|
24
|
+
if (!h) return false;
|
|
25
|
+
if (h === 'localhost' || h === '::1') return true;
|
|
26
|
+
const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
27
|
+
if (!m) return false;
|
|
28
|
+
const a = Number(m[1]);
|
|
29
|
+
const b = Number(m[2]);
|
|
30
|
+
return a === 10 || a === 127 || a === 0 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 解析模型能力。优先级:customModels.<name>.contextWindow/maxOutputTokens > 内置 preset > 兜底。
|
|
38
|
+
* @param {any} cfg
|
|
39
|
+
* @param {string} modelName
|
|
40
|
+
* @returns {{ contextWindow: number, maxOutputTokens: number, isLocal: boolean, budgetTokens: number|null, preset: any }}
|
|
41
|
+
*/
|
|
42
|
+
export function resolveModelCaps(/** @type {any} */ cfg, /** @type {any} */ modelName) {
|
|
43
|
+
const preset = modelPreset(modelName);
|
|
44
|
+
const cm = (cfg?.customModels || {})[modelName] || {};
|
|
45
|
+
const baseUrl = cm.baseUrl || cfg?.baseUrl || '';
|
|
46
|
+
const isLocal = isLocalBaseUrl(baseUrl);
|
|
47
|
+
const contextWindow =
|
|
48
|
+
Number(cm.contextWindow) > 0
|
|
49
|
+
? Number(cm.contextWindow)
|
|
50
|
+
: preset?.contextWindow || (isLocal ? UNKNOWN_LOCAL_WINDOW : UNKNOWN_REMOTE_WINDOW);
|
|
51
|
+
const maxOutputTokens =
|
|
52
|
+
Number(cm.maxOutputTokens) > 0
|
|
53
|
+
? Number(cm.maxOutputTokens)
|
|
54
|
+
: preset?.maxOutputTokens || Math.min(DEFAULT_MAX_OUTPUT, Math.max(1024, Math.floor(contextWindow / 8)));
|
|
55
|
+
const budgetTokens = preset?.budgetTokens || null;
|
|
56
|
+
return { contextWindow, maxOutputTokens, isLocal, budgetTokens, preset };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 安全 prompt 预算:min(用户配置/预设, 窗口×75% 舒适区, 窗口−输出−余量)。
|
|
61
|
+
* 保证 prompt + maxOutput + 余量 ≤ contextWindow,且 prompt 不越舒适区(prefill 不爆炸)。
|
|
62
|
+
*/
|
|
63
|
+
export function safeBudget(/** @type {any} */ cfg, /** @type {any} */ caps) {
|
|
64
|
+
const ceiling = Math.max(1024, caps.contextWindow - caps.maxOutputTokens - OUTPUT_HEADROOM);
|
|
65
|
+
const comfort = Math.max(1024, Math.floor(caps.contextWindow * COMFORT_RATIO));
|
|
66
|
+
const configured = Number(cfg?.contextBudget) > 0 ? Number(cfg.contextBudget) : null;
|
|
67
|
+
const base = configured ?? caps.budgetTokens ?? comfort;
|
|
68
|
+
return Math.max(1024, Math.min(base, ceiling, comfort));
|
|
69
|
+
}
|
package/src/presets.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Agent Preset(v0.4.0 契约化):声明式智能体预设——把「系统提示 + 工具集 + 权限 + 模型 + 参数」
|
|
2
|
+
// 打包成一个可安装、可复用、可分享的 JSON 单元,让开发者/用户不改源码就能定制自己的智能体。
|
|
3
|
+
//
|
|
4
|
+
// 发现顺序(同名后者遮蔽前者):
|
|
5
|
+
// 1. 项目级 <工作目录>/.mingdao/presets/*.json
|
|
6
|
+
// 2. 用户级 <mingdao-home>/presets/*.json
|
|
7
|
+
// 3. 内置 随 npm 包分发的 presets/ 目录(只读参考实现)
|
|
8
|
+
//
|
|
9
|
+
// 预设字段(全部可选,缺省时保持当前配置不变):
|
|
10
|
+
// name 唯一名(必填,字母/数字/-/_,1-64)
|
|
11
|
+
// label 展示名(可选,默认 name)
|
|
12
|
+
// description 一句话用途
|
|
13
|
+
// systemPrompt 追加到系统提示的定制段(角色/规则/上下文约定)
|
|
14
|
+
// tools 工具白名单(数组,省略=不限制)
|
|
15
|
+
// permission 权限模式 ask/auto/readonly(省略=当前配置)
|
|
16
|
+
// model 建议模型(省略=当前模型)
|
|
17
|
+
// temperature / maxOutputTokens / maxRounds / contextBudget 参数覆盖
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import { mingdaoHome, ensureHome } from './config.js';
|
|
22
|
+
|
|
23
|
+
const PRESET_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
24
|
+
// 合法字段白名单:未知字段报错(防拼写错误静默失效——契约化核心)
|
|
25
|
+
const KNOWN_FIELDS = new Set([
|
|
26
|
+
'name', 'label', 'description', 'systemPrompt', 'tools',
|
|
27
|
+
'permission', 'model', 'temperature', 'maxOutputTokens', 'maxRounds', 'contextBudget',
|
|
28
|
+
]);
|
|
29
|
+
const PERMISSION_MODES = ['ask', 'auto', 'readonly'];
|
|
30
|
+
|
|
31
|
+
/** 内置预设目录(随 npm 包分发,只读参考)。 */
|
|
32
|
+
export function builtinPresetDir() {
|
|
33
|
+
return path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'presets');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 发现目录与 source 标签对齐(遮蔽顺序:项目 → 用户 → 内置)。 */
|
|
37
|
+
function presetLocations(/** @type {any} */ workingDir) {
|
|
38
|
+
const locs = [];
|
|
39
|
+
if (workingDir) locs.push({ dir: path.join(String(workingDir), '.mingdao', 'presets'), source: 'project' });
|
|
40
|
+
locs.push({ dir: path.join(mingdaoHome(), 'presets'), source: 'user' });
|
|
41
|
+
locs.push({ dir: builtinPresetDir(), source: 'builtin' });
|
|
42
|
+
return locs;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @param {any} workingDir 预设发现目录(按遮蔽顺序:项目 → 用户 → 内置)。 */
|
|
46
|
+
export function presetDirs(/** @type {any} */ workingDir) {
|
|
47
|
+
return presetLocations(workingDir).map((/** @type {any} */ l) => l.dir);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** @param {any} obj 校验预设对象,返回 { ok, errors: string[] }。 */
|
|
51
|
+
export function validatePreset(/** @type {any} */ obj) {
|
|
52
|
+
const errors = [];
|
|
53
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return { ok: false, errors: ['预设必须是 JSON 对象'] };
|
|
54
|
+
const name = String(obj.name ?? '').trim();
|
|
55
|
+
if (!name) errors.push('缺少 name 字段');
|
|
56
|
+
else if (!PRESET_NAME_RE.test(name)) errors.push(`name 非法(${PRESET_NAME_RE}):${name}`);
|
|
57
|
+
for (const k of Object.keys(obj)) {
|
|
58
|
+
if (!KNOWN_FIELDS.has(k)) errors.push(`未知字段:${k}(合法:${[...KNOWN_FIELDS].join('/')})`);
|
|
59
|
+
}
|
|
60
|
+
if (obj.systemPrompt !== undefined && typeof obj.systemPrompt !== 'string') errors.push('systemPrompt 必须是字符串');
|
|
61
|
+
if (obj.tools !== undefined && (!Array.isArray(obj.tools) || obj.tools.some((/** @type {any} */ t) => typeof t !== 'string'))) {
|
|
62
|
+
errors.push('tools 必须是字符串数组');
|
|
63
|
+
}
|
|
64
|
+
if (obj.permission !== undefined && !PERMISSION_MODES.includes(String(obj.permission))) {
|
|
65
|
+
errors.push(`permission 必须是 ${PERMISSION_MODES.join('/')}`);
|
|
66
|
+
}
|
|
67
|
+
for (const k of ['temperature', 'maxOutputTokens', 'maxRounds', 'contextBudget']) {
|
|
68
|
+
if (obj[k] !== undefined && !(Number.isFinite(Number(obj[k])) && Number(obj[k]) > 0)) {
|
|
69
|
+
errors.push(`${k} 必须是正数`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { ok: errors.length === 0, errors };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 列出全部可用预设(发现顺序:项目遮蔽用户遮蔽内置,同名只留前者)。
|
|
77
|
+
* 返回 [{ name, label, description, source: 'project'|'user'|'builtin', file }]
|
|
78
|
+
*/
|
|
79
|
+
export function listPresets(/** @type {any} */ workingDir) {
|
|
80
|
+
ensureHome();
|
|
81
|
+
const seen = new Map();
|
|
82
|
+
const order = /** @type {string[]} */ ([]);
|
|
83
|
+
for (const { dir, source } of presetLocations(workingDir)) {
|
|
84
|
+
let files = [];
|
|
85
|
+
try {
|
|
86
|
+
files = fs.readdirSync(dir).filter((/** @type {any} */ f) => f.endsWith('.json'));
|
|
87
|
+
} catch {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
for (const f of files) {
|
|
91
|
+
if (seen.has(f)) continue;
|
|
92
|
+
seen.set(f, { dir, source });
|
|
93
|
+
order.push(f);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const out = [];
|
|
97
|
+
for (const f of order) {
|
|
98
|
+
const { dir, source } = seen.get(f);
|
|
99
|
+
try {
|
|
100
|
+
const obj = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
|
|
101
|
+
const v = validatePreset(obj);
|
|
102
|
+
if (!v.ok) continue; // 非法预设跳过并静默(不阻塞会话);diagnose 可查
|
|
103
|
+
out.push({
|
|
104
|
+
name: String(obj.name),
|
|
105
|
+
label: String(obj.label || obj.name),
|
|
106
|
+
description: String(obj.description || ''),
|
|
107
|
+
source,
|
|
108
|
+
file: path.join(dir, f),
|
|
109
|
+
...(obj.systemPrompt ? { systemPrompt: obj.systemPrompt } : {}),
|
|
110
|
+
...(Array.isArray(obj.tools) ? { tools: obj.tools } : {}),
|
|
111
|
+
...(obj.permission ? { permission: String(obj.permission) } : {}),
|
|
112
|
+
...(obj.model ? { model: String(obj.model) } : {}),
|
|
113
|
+
});
|
|
114
|
+
} catch {
|
|
115
|
+
// JSON 解析失败:跳过
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 按名解析单个预设(含全部字段),找不到返回 null。
|
|
123
|
+
* @param {any} workingDir @param {any} name
|
|
124
|
+
*/
|
|
125
|
+
export function loadPreset(/** @type {any} */ workingDir, /** @type {any} */ name) {
|
|
126
|
+
const all = listPresets(workingDir);
|
|
127
|
+
const hit = all.find((/** @type {any} */ p) => p.name === name || path.basename(String(p.file), '.json') === name);
|
|
128
|
+
if (!hit) return null;
|
|
129
|
+
try {
|
|
130
|
+
return JSON.parse(fs.readFileSync(hit.file, 'utf8'));
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 预设 → cfg 覆盖:只返回预设声明的参数键(其余键保持调用方当前配置)。
|
|
138
|
+
* tools 单独走 cfg.presetTools(白名单在 agent 的 toolsFor 处生效)。
|
|
139
|
+
*/
|
|
140
|
+
export function presetConfigOverrides(/** @type {any} */ preset) {
|
|
141
|
+
const out = /** @type {Record<string, any>} */ ({});
|
|
142
|
+
for (const k of ['permission', 'model', 'temperature', 'maxOutputTokens', 'maxRounds', 'contextBudget']) {
|
|
143
|
+
if (preset && preset[k] !== undefined) out[k] = preset[k];
|
|
144
|
+
}
|
|
145
|
+
if (preset && Array.isArray(preset.tools)) out.presetTools = [...preset.tools];
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 预设系统提示定制段(无则空串),插入系统提示 BASE 之后。 */
|
|
150
|
+
export function presetSystemBlock(/** @type {any} */ preset) {
|
|
151
|
+
const s = preset && typeof preset.systemPrompt === 'string' ? preset.systemPrompt.trim() : '';
|
|
152
|
+
if (!s) return '';
|
|
153
|
+
return `\n\n<preset_rules>\n${s}\n</preset_rules>`;
|
|
154
|
+
}
|
package/src/prompts.js
CHANGED
|
@@ -30,8 +30,8 @@ function loadFile(/** @type {any} */ p, /** @type {any} */ cap) {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
/** @param {{ workingDir: any, withJournal?: boolean, projectMemory?: string, [key: string]: any }} opts */
|
|
34
|
-
export function buildSystemPrompt({ workingDir, withJournal = false, projectMemory }) {
|
|
33
|
+
/** @param {{ workingDir: any, withJournal?: boolean, projectMemory?: string, presetBlock?: string, [key: string]: any }} opts */
|
|
34
|
+
export function buildSystemPrompt({ workingDir, withJournal = false, projectMemory, presetBlock }) {
|
|
35
35
|
// 前缀字节稳定性(评估 P1-1/P1-2,四份评估一致的最高价值项):
|
|
36
36
|
// 系统提示不含「当前模型」「当前日期」等易变字段——DeepSeek 上下文缓存按前缀字节匹配,
|
|
37
37
|
// 路由 pro⇄flash 翻转或跨天会改变前缀 → 整段历史按未命中价重计(命中价的 30 倍)。
|
|
@@ -40,6 +40,9 @@ export function buildSystemPrompt({ workingDir, withJournal = false, projectMemo
|
|
|
40
40
|
|
|
41
41
|
当前工作目录:${workingDir}`;
|
|
42
42
|
|
|
43
|
+
// v0.4.0 Agent Preset:预设定制的角色/规则段(会话内恒定,前缀稳定)
|
|
44
|
+
if (presetBlock) prompt += presetBlock;
|
|
45
|
+
|
|
43
46
|
// 用户级记忆(~/.mingdao/AGENTS.md,/memory add 手动追加 + 会话结束自动提炼)
|
|
44
47
|
const memory = loadFile(path.join(mingdaoHome(), 'AGENTS.md'), 8000);
|
|
45
48
|
if (memory) prompt += `\n\n<user_memory>\n${memory}\n</user_memory>`;
|
package/src/providers/index.js
CHANGED
|
@@ -15,6 +15,7 @@ import { chat as openaiChat } from './openai-compatible.js';
|
|
|
15
15
|
import { modelPreset, providerPreset } from '../models.js';
|
|
16
16
|
import { mingdaoHome } from '../config.js';
|
|
17
17
|
import { resolveApiKey } from '../credentials.js';
|
|
18
|
+
import { isLocalBaseUrl } from '../model-caps.js';
|
|
18
19
|
|
|
19
20
|
export function resolveProviderConfig(/** @type {any} */ cfg, /** @type {any} */ modelName) {
|
|
20
21
|
// 自定义模型(config.customModels,WebUI 可增删改):优先于内置预设
|
|
@@ -56,7 +57,7 @@ function isTransient(/** @type {any} */ err) {
|
|
|
56
57
|
return /timeout|超时|ECONNRESET|fetch failed/i.test(String(err?.message || ''));
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
export async function createProvider(/** @type {any} */ cfg, /** @type {any} */ modelName, { timeoutMs
|
|
60
|
+
export async function createProvider(/** @type {any} */ cfg, /** @type {any} */ modelName, /** @type {{ timeoutMs?: number, retries?: number }} */ { timeoutMs, retries = 2 } = {}) {
|
|
60
61
|
const pc = resolveProviderConfig(cfg, modelName);
|
|
61
62
|
|
|
62
63
|
// 自定义 Provider 模块优先(仅普通自定义端点;custom:<模型名> 走 OpenAI 兼容直连)
|
|
@@ -74,6 +75,18 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
|
|
|
74
75
|
throw new Error(`服务商 "${pc.name}" 缺少 baseUrl,请运行 mingdao init 重新配置。`);
|
|
75
76
|
}
|
|
76
77
|
|
|
78
|
+
// v0.3.2 本地模型自适应:本地推理框架(CPU/GPU 有限)长上下文 prefill 极慢(诊断实测 127k 上下文
|
|
79
|
+
// 首 token 需 196s+,q8 dequant 下 prefill 仅 ~165 tok/s)。默认超时按是否本地分层:
|
|
80
|
+
// - 首 token 等待:本地 600s / 远程 300s(覆盖慢 prefill,而非 189s 被掐断)
|
|
81
|
+
// - 流式空闲:有帧后 120s 无新帧即断(真正挂死才断,慢速吐字不误杀)
|
|
82
|
+
// - 总量:本地 30min / 远程 10min(长生成不误杀)
|
|
83
|
+
// 均可用 cfg.timeout.firstTokenMs / streamIdleMs / totalMs 覆盖。
|
|
84
|
+
const isLocal = isLocalBaseUrl(pc.baseUrl);
|
|
85
|
+
const tCfg = cfg?.timeout || {};
|
|
86
|
+
const firstTokenMs = Number(tCfg.firstTokenMs) > 0 ? Number(tCfg.firstTokenMs) : (isLocal ? 600000 : 300000);
|
|
87
|
+
const streamIdleMs = Number(tCfg.streamIdleMs) > 0 ? Number(tCfg.streamIdleMs) : 120000;
|
|
88
|
+
const totalMs = Number(tCfg.totalMs) > 0 ? Number(tCfg.totalMs) : (Number(timeoutMs) > 0 ? Number(timeoutMs) : (isLocal ? 1800000 : 600000));
|
|
89
|
+
|
|
77
90
|
return {
|
|
78
91
|
name: pc.name,
|
|
79
92
|
config: pc,
|
|
@@ -82,10 +95,32 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
|
|
|
82
95
|
for (;;) {
|
|
83
96
|
const ac = new AbortController();
|
|
84
97
|
let timedOut = false; // 审计 P2-6:用标志而非 name/字符串匹配识别内部超时
|
|
85
|
-
|
|
98
|
+
// 总量护栏:整次请求(prefill+生成)的绝对上限
|
|
99
|
+
const totalTimer = setTimeout(() => {
|
|
100
|
+
timedOut = true;
|
|
101
|
+
ac.abort(new Error(`请求总时长超限(${Math.round(totalMs / 1000)}s),已中断`));
|
|
102
|
+
}, totalMs);
|
|
103
|
+
// 首 token 等待:prefill 阶段无任何帧到达即断(覆盖长上下文慢 prefill)
|
|
104
|
+
let firstTokenTimer = /** @type {ReturnType<typeof setTimeout> | null} */ (setTimeout(() => {
|
|
86
105
|
timedOut = true;
|
|
87
|
-
ac.abort(new Error(
|
|
88
|
-
},
|
|
106
|
+
ac.abort(new Error(`首 token 等待超限(${Math.round(firstTokenMs / 1000)}s,本地模型长上下文 prefill 可能很慢)——可调大 config.timeout.firstTokenMs,或拆分任务/压缩上下文`));
|
|
107
|
+
}, firstTokenMs));
|
|
108
|
+
// 流式空闲:有帧后 120s 无新帧即断;每收到一帧重置
|
|
109
|
+
let idleTimer = /** @type {ReturnType<typeof setTimeout> | null} */ (null);
|
|
110
|
+
const armIdle = () => {
|
|
111
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
112
|
+
idleTimer = setTimeout(() => {
|
|
113
|
+
timedOut = true;
|
|
114
|
+
ac.abort(new Error(`流式响应空闲超限(${Math.round(streamIdleMs / 1000)}s 无新数据)`));
|
|
115
|
+
}, streamIdleMs);
|
|
116
|
+
};
|
|
117
|
+
const onActivity = () => {
|
|
118
|
+
if (firstTokenTimer) {
|
|
119
|
+
clearTimeout(firstTokenTimer);
|
|
120
|
+
firstTokenTimer = null;
|
|
121
|
+
}
|
|
122
|
+
armIdle();
|
|
123
|
+
};
|
|
89
124
|
// 转发外部信号(用户 Ctrl+C 中断),避免被内部超时信号覆盖
|
|
90
125
|
const onUserAbort = () => ac.abort(opts.signal?.reason);
|
|
91
126
|
if (opts.signal?.aborted) onUserAbort();
|
|
@@ -97,20 +132,24 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
|
|
|
97
132
|
apiKey: pc.apiKey,
|
|
98
133
|
signal: ac.signal,
|
|
99
134
|
includeUsage: cfg?.includeUsage !== false,
|
|
135
|
+
onActivity,
|
|
100
136
|
});
|
|
101
137
|
} catch (err) {
|
|
102
138
|
// 内部超时经 abort 抛出,用标志识别(审计 P2-6);用户 Ctrl+C 的中断不算超时、不重试
|
|
103
139
|
const transient = (timedOut && !opts.signal?.aborted) || isTransient(err);
|
|
104
140
|
if (!transient || attempt >= retries) throw err;
|
|
105
141
|
attempt += 1;
|
|
106
|
-
//
|
|
142
|
+
// 首 token 等待超时通常不是偶发网络抖动(是模型/上下文慢),重试价值低但保留一次机会;
|
|
143
|
+
// 其余瞬态错误指数退避 + 尊重 Retry-After(评估 P3-1):基础 1s/2s,封顶 30s
|
|
107
144
|
let backoff = 1000 * attempt;
|
|
108
145
|
const ra = Number((/** @type {any} */ (err))?.headers?.get?.('retry-after'));
|
|
109
146
|
if (Number.isFinite(ra) && ra > 0) backoff = Math.max(backoff, ra * 1000);
|
|
110
147
|
backoff = Math.min(backoff, 30000);
|
|
111
148
|
await sleep(backoff);
|
|
112
149
|
} finally {
|
|
113
|
-
clearTimeout(
|
|
150
|
+
clearTimeout(totalTimer);
|
|
151
|
+
if (firstTokenTimer) clearTimeout(firstTokenTimer);
|
|
152
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
114
153
|
opts.signal?.removeEventListener('abort', onUserAbort);
|
|
115
154
|
}
|
|
116
155
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* @typedef {Error & { status?: number, headers?: Headers }} ApiError
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
export async function chat(/** @type {any} */ { baseUrl, apiKey, model, messages, tools, temperature, maxTokens, signal, onDelta, includeUsage = true, responseFormat, reasoningEffort }) {
|
|
9
|
+
export async function chat(/** @type {any} */ { baseUrl, apiKey, model, messages, tools, temperature, maxTokens, signal, onDelta, onActivity, includeUsage = true, responseFormat, reasoningEffort }) {
|
|
10
10
|
const url = String(baseUrl).replace(/\/+$/, '') + '/chat/completions';
|
|
11
11
|
const payload = /** @type {Record<string, any>} */ ({ model, messages });
|
|
12
12
|
if (temperature != null) payload.temperature = temperature;
|
|
@@ -62,7 +62,7 @@ export async function chat(/** @type {any} */ { baseUrl, apiKey, model, messages
|
|
|
62
62
|
if (!json) throw new Error(`[${model}] 响应解析失败。`);
|
|
63
63
|
return parseNonStream(json, onDelta);
|
|
64
64
|
}
|
|
65
|
-
return parseStream(res.body, onDelta);
|
|
65
|
+
return parseStream(res.body, onDelta, onActivity);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
export function parseNonStream(/** @type {any} */ json, /** @type {any} */ onDelta) {
|
|
@@ -81,7 +81,9 @@ export function parseNonStream(/** @type {any} */ json, /** @type {any} */ onDel
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
// 解析 SSE 流:处理跨 chunk 断行、增量 content / reasoning_content / tool_calls。
|
|
84
|
-
|
|
84
|
+
// onActivity:每收到一个有效 SSE 数据帧即回调(含 usage-only 帧),供上层做「首 token 等待/流式空闲」超时仲裁——
|
|
85
|
+
// 长上下文 prefill 时服务端可能 200s+ 无正文,靠「有帧到达」而非「有正文」判定存活,避免误杀慢 prefill。
|
|
86
|
+
export async function parseStream(/** @type {any} */ body, /** @type {any} */ onDelta, /** @type {any} */ onActivity) {
|
|
85
87
|
const reader = body.getReader();
|
|
86
88
|
const decoder = new TextDecoder();
|
|
87
89
|
let buf = '';
|
|
@@ -151,6 +153,9 @@ export async function parseStream(/** @type {any} */ body, /** @type {any} */ on
|
|
|
151
153
|
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
152
154
|
const line = buf.slice(0, nl);
|
|
153
155
|
buf = buf.slice(nl + 1);
|
|
156
|
+
// 有帧到达即视为「活着」:prefill 阶段服务端可能先发 usage-only/空帧,正文迟迟不来,
|
|
157
|
+
// 靠帧到达刷新上层流式空闲计时器(首 token 等待则仍由「无任何帧」触发)
|
|
158
|
+
if (line.trim()) onActivity?.();
|
|
154
159
|
if (handleLine(line)) break;
|
|
155
160
|
}
|
|
156
161
|
}
|
package/src/tasks/worker.js
CHANGED
|
@@ -53,6 +53,9 @@ export async function runWorkerTask(id, question, { permission, model, offpeak }
|
|
|
53
53
|
}
|
|
54
54
|
const provider = await createProvider(cfg, modelName);
|
|
55
55
|
const io = createIO({ quiet: true });
|
|
56
|
+
// v0.4.0 契约化:后台任务同样挂载 config.tools 声明式工具(幂等)
|
|
57
|
+
const { mountConfigTools } = await import('../tools/index.js');
|
|
58
|
+
mountConfigTools(cfg);
|
|
56
59
|
const permissionObj = createPermission(perm, io);
|
|
57
60
|
/** @type {any} */
|
|
58
61
|
let mcpManager = null;
|