mingdao-harness 0.1.54

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.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +246 -0
  3. package/assets/tokenizer-data.json.gz +0 -0
  4. package/docs/ARCHITECTURE.md +108 -0
  5. package/docs/CONFIG.md +257 -0
  6. package/docs/DESKTOP-EVALUATION.md +48 -0
  7. package/docs/PROVIDERS.md +98 -0
  8. package/docs/QA-REPORT.md +333 -0
  9. package/install.bat +8 -0
  10. package/install.ps1 +57 -0
  11. package/install.sh +154 -0
  12. package/package.json +61 -0
  13. package/skills/api-design/SKILL.md +21 -0
  14. package/skills/code-review/SKILL.md +31 -0
  15. package/skills/debugging/SKILL.md +21 -0
  16. package/skills/docker/SKILL.md +27 -0
  17. package/skills/docx/SKILL.md +28 -0
  18. package/skills/frontend-design/SKILL.md +31 -0
  19. package/skills/git-commit/SKILL.md +32 -0
  20. package/skills/pdf/SKILL.md +30 -0
  21. package/skills/pptx/SKILL.md +24 -0
  22. package/skills/refactoring/SKILL.md +24 -0
  23. package/skills/release-checklist/SKILL.md +20 -0
  24. package/skills/testing/SKILL.md +30 -0
  25. package/skills/webapp-testing/SKILL.md +27 -0
  26. package/skills/xlsx/SKILL.md +28 -0
  27. package/src/agent.js +472 -0
  28. package/src/audit.js +68 -0
  29. package/src/autostart.js +74 -0
  30. package/src/batch.js +182 -0
  31. package/src/cachestats.js +215 -0
  32. package/src/cli.js +1099 -0
  33. package/src/commands/key.js +75 -0
  34. package/src/commands/schedule.js +176 -0
  35. package/src/commands/skill.js +168 -0
  36. package/src/commands/sync.js +222 -0
  37. package/src/commands/update.js +157 -0
  38. package/src/commands/workspace.js +109 -0
  39. package/src/compact.js +112 -0
  40. package/src/config.js +134 -0
  41. package/src/context.js +86 -0
  42. package/src/cost-guard.js +58 -0
  43. package/src/credentials.js +69 -0
  44. package/src/hooks.js +123 -0
  45. package/src/index.js +42 -0
  46. package/src/mcp-presets.js +78 -0
  47. package/src/mcp.js +284 -0
  48. package/src/memory.js +242 -0
  49. package/src/model-discovery.js +153 -0
  50. package/src/models.js +186 -0
  51. package/src/notify.js +38 -0
  52. package/src/permissions.js +83 -0
  53. package/src/pricing.js +156 -0
  54. package/src/prompts.js +58 -0
  55. package/src/providers/index.js +135 -0
  56. package/src/providers/openai-compatible.js +171 -0
  57. package/src/routing.js +126 -0
  58. package/src/schedule.js +434 -0
  59. package/src/session-index.js +122 -0
  60. package/src/session.js +131 -0
  61. package/src/skill-lib.js +350 -0
  62. package/src/skill-registry.js +165 -0
  63. package/src/skills.js +135 -0
  64. package/src/sync-server.js +564 -0
  65. package/src/sync.js +479 -0
  66. package/src/tasks.js +126 -0
  67. package/src/titles.js +75 -0
  68. package/src/tokenizer.js +287 -0
  69. package/src/tools/bash.js +165 -0
  70. package/src/tools/fs-tools.js +346 -0
  71. package/src/tools/index.js +287 -0
  72. package/src/ui.js +643 -0
  73. package/src/update.js +222 -0
  74. package/src/web/attachments.js +49 -0
  75. package/src/web/index.html +993 -0
  76. package/src/web/server.js +1173 -0
  77. package/src/web/web-io.js +107 -0
  78. package/src/workspace.js +157 -0
@@ -0,0 +1,83 @@
1
+ // 权限引擎(借鉴 codewhale 的 execpolicy 独立分层思想)。
2
+ //
3
+ // 三种模式:
4
+ // - ask 默认。只读工具自动放行;写文件/执行命令逐次询问。
5
+ // - auto 全部自动允许(完全自主,注意风险)。
6
+ // - readonly 只允许只读工具。
7
+ // config.permission 也可以是对象:
8
+ // { mode: 'ask', allow: ['bash', 'bash:git *'], deny: ['write'] }
9
+ // 规则支持「工具名」或「工具名:参数前缀」模式匹配(* 通配)。
10
+ // check(name, args, label):label 用于区分执行方(如子代理的「(子任务)」前缀)。
11
+
12
+ import { style, C } from './ui.js';
13
+ import { READONLY_TOOLS } from './tools/index.js';
14
+
15
+ function summarize(name, args) {
16
+ try {
17
+ if (name === 'bash') return ` ${args.command ?? ''}`;
18
+ if (args.path) return ` ${args.path}`;
19
+ return '';
20
+ } catch {
21
+ return '';
22
+ }
23
+ }
24
+
25
+ function ruleMatches(rule, name, args) {
26
+ const idx = rule.indexOf(':');
27
+ if (idx > 0) {
28
+ const ruleName = rule.slice(0, idx);
29
+ if (ruleName !== name) return false;
30
+ const want = rule.slice(idx + 1).trim();
31
+ const have = summarize(name, args).trim();
32
+ // bash 前缀规则防链式命令绕过:含 && / ; / | / ` / $( 的复合命令不匹配前缀规则,回落权限确认
33
+ if (name === 'bash' && /&&|\|\||[;|`]|\$\(|\n/.test(have)) return false;
34
+ if (want.endsWith('*')) return have.startsWith(want.slice(0, -1));
35
+ return have === want;
36
+ }
37
+ return rule === name;
38
+ }
39
+
40
+ export function createPermission(rawMode, io) {
41
+ let mode = 'ask';
42
+ let allow = [];
43
+ let deny = [];
44
+ if (typeof rawMode === 'string') {
45
+ mode = rawMode;
46
+ } else if (rawMode && typeof rawMode === 'object') {
47
+ mode = rawMode.mode ?? 'ask';
48
+ allow = rawMode.allow ?? [];
49
+ deny = rawMode.deny ?? [];
50
+ }
51
+ if (!['ask', 'auto', 'readonly'].includes(mode)) mode = 'ask';
52
+
53
+ return {
54
+ mode,
55
+ async check(name, args = {}, label = '') {
56
+ const askOverride = async (question) => {
57
+ try {
58
+ const answer = await io.ask(style(question, C.yellow));
59
+ return /^y(es)?$/i.test(answer);
60
+ } catch {
61
+ return false; // 交互通道不可用(管道 EOF/静默 worker):按拒绝处理
62
+ }
63
+ };
64
+ // 需要特殊授权时弹出对话框与用户交互,而不是静默拒绝:
65
+ // 1) 被 deny 规则拦截 → 询问是否本次强制放行
66
+ if (deny.some((r) => ruleMatches(r, name, args))) {
67
+ return askOverride(`规则拦截了 ${name}${summarize(name, args)},是否本次强制放行?[y/N] `);
68
+ }
69
+ if (allow.some((r) => ruleMatches(r, name, args))) return true;
70
+ if (mode === 'auto') return true;
71
+ if (mode === 'readonly') {
72
+ // 2) 只读模式下的写操作 → 询问是否本次放行
73
+ if (READONLY_TOOLS.has(name)) return true;
74
+ return askOverride(`只读模式将拦截 ${name}${summarize(name, args)},是否本次放行?[y/N] `);
75
+ }
76
+ if (READONLY_TOOLS.has(name)) return true;
77
+ const answer = await io.ask(
78
+ style(`是否允许执行 ${label}${C.bold}${name}${C.reset}${summarize(name, args)} ?[y/N] `, C.yellow)
79
+ );
80
+ return /^y(es)?$/i.test(answer);
81
+ },
82
+ };
83
+ }
package/src/pricing.js ADDED
@@ -0,0 +1,156 @@
1
+ // 费用估算:统一计价逻辑(cli /status、/cost 与 ui/web 状态行共用)。
2
+ // DeepSeek V4 缓存感知计价:usage 提供 prompt_cache_hit_tokens / prompt_cache_miss_tokens 时,
3
+ // 命中部分按 cacheHit 价(约为未命中的 1/30),未命中按 input 价;否则按全未命中(保守上界)。
4
+ // 价格覆盖(P3-10):config.json 的 pricing.overrides.<模型名> 可覆盖内置价格表,
5
+ // 结构 { input, output, cacheHit, peak?: { input, output, cacheHit } }(单位:元/百万 tokens)。
6
+
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { modelPreset } from './models.js';
10
+ import { mingdaoHome } from './config.js';
11
+
12
+ // 内置价格表的数据时点(定价可能调整,配置覆盖可随时更新)
13
+ export const PRICE_DATA_AS_OF = '2026-08';
14
+
15
+ // 峰谷判断锚定北京时间(评估 P0-4:DeepSeek 按北京时间 9:00–14:00 高峰计价,
16
+ // 用本机时区判断会让海外用户计费错位)。config.pricing.timezone 可覆盖,mtime 缓存避免每次读盘。
17
+ let tzCache = { mtime: 0, timezone: 'Asia/Shanghai' };
18
+ function peakTimezone() {
19
+ try {
20
+ const file = path.join(mingdaoHome(), 'config.json');
21
+ const st = fs.statSync(file);
22
+ if (st.mtimeMs !== tzCache.mtime) {
23
+ const cfg = JSON.parse(fs.readFileSync(file, 'utf8'));
24
+ tzCache = { mtime: st.mtimeMs, timezone: String(cfg?.pricing?.timezone || 'Asia/Shanghai') };
25
+ }
26
+ } catch {}
27
+ return tzCache.timezone;
28
+ }
29
+
30
+ export function isPeakHour(date = new Date()) {
31
+ try {
32
+ const parts = new Intl.DateTimeFormat('en-US', {
33
+ timeZone: peakTimezone(),
34
+ weekday: 'short',
35
+ hour: 'numeric',
36
+ hourCycle: 'h23',
37
+ }).formatToParts(date);
38
+ const weekday = parts.find((p) => p.type === 'weekday')?.value;
39
+ // 周末全天按闲时计价(DeepSeek 官方邮件确认:周六/周日全天闲时低价,批量任务周末更划算)
40
+ if (weekday === 'Sat' || weekday === 'Sun') return false;
41
+ const hour = Number(parts.find((p) => p.type === 'hour')?.value);
42
+ return hour >= 9 && hour < 14;
43
+ } catch {
44
+ return date.getHours() >= 9 && date.getHours() < 14; // 非法时区回退本机
45
+ }
46
+ }
47
+
48
+ // —— 北京时间墙钟工具(避峰调度/费用护栏按天统计用,零依赖 Intl) ——
49
+ export function beijingParts(date = new Date()) {
50
+ let tz = 'Asia/Shanghai';
51
+ try {
52
+ tz = peakTimezone();
53
+ new Intl.DateTimeFormat('en-US', { timeZone: tz }).format(); // 非法时区在此抛错
54
+ } catch {
55
+ tz = 'Asia/Shanghai'; // 审计 B1:坏时区配置回退北京时间,绝不让计费/护栏崩溃
56
+ }
57
+ const parts = new Intl.DateTimeFormat('en-US', {
58
+ timeZone: tz,
59
+ year: 'numeric',
60
+ month: '2-digit',
61
+ day: '2-digit',
62
+ hour: '2-digit',
63
+ minute: '2-digit',
64
+ second: '2-digit',
65
+ hourCycle: 'h23',
66
+ }).formatToParts(date);
67
+ const g = (t) => Number(parts.find((p) => p.type === t)?.value);
68
+ return { year: g('year'), month: g('month'), day: g('day'), hour: g('hour'), minute: g('minute'), second: g('second') };
69
+ }
70
+
71
+ // 北京时间墙钟 → Date(UTC+8)
72
+ export function beijingToDate(parts) {
73
+ return new Date(Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second) - 8 * 3600 * 1000);
74
+ }
75
+
76
+ // 避峰:当前处于高峰(9:00–14:00)→ 顺延到当天 14:00;否则原时刻
77
+ export function deferToOffpeak(date = new Date()) {
78
+ if (!isPeakHour(date)) return date;
79
+ const p = beijingParts(date);
80
+ return beijingToDate({ ...p, hour: 14, minute: 0, second: 0 });
81
+ }
82
+
83
+ // 北京时间当日 0 点(费用护栏按自然日累计)
84
+ export function beijingDayStart(date = new Date()) {
85
+ const p = beijingParts(date);
86
+ return beijingToDate({ ...p, hour: 0, minute: 0, second: 0 });
87
+ }
88
+
89
+ // —— Batch API 半价计价:批量任务无缓存语义,按闲时全未命中 × 0.5 ——
90
+ export const BATCH_DISCOUNT = 0.5;
91
+ export function estimateBatchCost(modelName, promptTokens, completionTokens) {
92
+ const pricing = effectivePricing(modelName);
93
+ if (!pricing) return 0;
94
+ return ((promptTokens * pricing.offpeak.input + completionTokens * pricing.offpeak.output) / 1e6) * BATCH_DISCOUNT;
95
+ }
96
+
97
+ // usage 中的缓存拆分(DeepSeek 返回字段)
98
+ export function cacheSplit(usage) {
99
+ const hit = usage?.prompt_cache_hit_tokens;
100
+ const miss = usage?.prompt_cache_miss_tokens;
101
+ if (Number.isFinite(hit) && Number.isFinite(miss)) {
102
+ return { hit, miss, rate: hit + miss > 0 ? hit / (hit + miss) : 0 };
103
+ }
104
+ return null;
105
+ }
106
+
107
+ // 合并内置价与用户覆盖:offpeak 字段覆盖 offpeak,over.peak 覆盖 peak(缺省沿用 offpeak)
108
+ let overridesCache = { mtime: 0, overrides: {} };
109
+ function pricingOverrides() {
110
+ try {
111
+ const file = path.join(mingdaoHome(), 'config.json');
112
+ const st = fs.statSync(file);
113
+ if (st.mtimeMs !== overridesCache.mtime) {
114
+ const cfg = JSON.parse(fs.readFileSync(file, 'utf8'));
115
+ overridesCache = { mtime: st.mtimeMs, overrides: cfg?.pricing?.overrides || {} };
116
+ }
117
+ } catch {}
118
+ return overridesCache.overrides;
119
+ }
120
+ function effectivePricing(modelName) {
121
+ const preset = modelPreset(modelName);
122
+ if (!preset?.pricing) return null;
123
+ const over = pricingOverrides()[modelName] || {}; // 审计 Q3:mtime 缓存替代每轮读盘
124
+ const merge = (base, o = {}) => ({
125
+ input: Number(o.input ?? base?.input ?? 0),
126
+ output: Number(o.output ?? base?.output ?? 0),
127
+ cacheHit: Number(o.cacheHit ?? base?.cacheHit ?? 0),
128
+ });
129
+ return {
130
+ offpeak: merge(preset.pricing.offpeak, over),
131
+ peak: merge(preset.pricing.peak || preset.pricing.offpeak, over.peak),
132
+ };
133
+ }
134
+
135
+ export function estimateCost(modelName, promptTokens, completionTokens, cache = null, date = new Date()) {
136
+ const pricing = effectivePricing(modelName);
137
+ if (!pricing) return 0;
138
+ const price = isPeakHour(date) ? pricing.peak : pricing.offpeak;
139
+ if (cache && Number.isFinite(cache.hit) && Number.isFinite(cache.miss)) {
140
+ const hitPrice = price.cacheHit ?? 0;
141
+ // 网关 hit+miss 与 prompt_tokens 口径不一致时,余量按未命中计价,避免漏计
142
+ const miss = cache.miss + Math.max(0, promptTokens - cache.hit - cache.miss);
143
+ return (cache.hit * hitPrice + miss * price.input + completionTokens * price.output) / 1e6;
144
+ }
145
+ return (promptTokens * price.input + completionTokens * price.output) / 1e6;
146
+ }
147
+
148
+ export function estimateCostLabel(modelName, promptTokens, completionTokens, usage = null) {
149
+ const pricing = effectivePricing(modelName);
150
+ if (!pricing) return '';
151
+ const cache = cacheSplit(usage);
152
+ const yuan = estimateCost(modelName, promptTokens, completionTokens, cache).toFixed(5);
153
+ const hitPart =
154
+ cache && cache.hit + cache.miss > 0 ? ` · 缓存命中 ${(cache.rate * 100).toFixed(0)}%` : ' · 未计缓存折扣';
155
+ return ` ≈¥${yuan}(${isPeakHour() ? '高峰' : '闲时'}${hitPart})`;
156
+ }
package/src/prompts.js ADDED
@@ -0,0 +1,58 @@
1
+ // 系统提示词构建:基础角色 + 用户记忆 + 技能清单 + 项目 AGENTS.md。
2
+ // 借鉴 Claude Code 的记忆文件与 Codex 的技能渐进披露约定。
3
+
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { skillsRegistryBlock } from './skills.js';
7
+ import { mingdaoHome } from './config.js';
8
+ import { recentJournalBlock } from './memory.js';
9
+
10
+ const BASE = `你是 MingDao(明道),一个由 MingDao-Harness 驱动的 AI 编程助手。你在用户的电脑上工作:通过工具读写文件、搜索代码、执行命令,帮助用户完成编程、调试与自动化任务。
11
+
12
+ 工作准则:
13
+ 1. 先了解再动手:修改或创建代码前,先用 read / ls / glob / grep 查看相关文件,不要凭空猜测。
14
+ 2. 精准修改:优先用 edit 做小步精确替换;新建文件用 write;保持改动最小化;改错可用 undo 撤销。
15
+ 3. 任务管理:多步骤任务先建 todo 清单并逐项更新;与技能(skill)相关的任务先加载对应 SKILL.md。
16
+ 4. 委托与专注:独立、可并行的子问题(调研、复核、独立实现)用 task 委托子代理,把结果拿回来继续主线。
17
+ 5. 命令谨慎:执行 bash 前想清楚影响;命令失败时阅读错误输出并修复,同一问题最多重试 3 次。
18
+ 6. 回答风格:使用用户的语言(默认中文),简洁直接,先结论后说明。
19
+ 7. 诚实可靠:不编造不存在的文件、行号、API 或执行结果。
20
+ 8. 善用工具:能用工具确认的事实就用工具确认;多个只读工具可连续调用以提高效率。
21
+ 9. 交付收尾:完成工具操作后必须给出简短交付总结——生成了哪些文件(路径)、如何运行/使用(如「浏览器打开 angry-birds.html 即可游玩」或运行命令)、以及未完成或注意事项(若有)。绝不能不声不响结束。`;
22
+
23
+ function loadFile(p, cap) {
24
+ try {
25
+ const s = fs.readFileSync(p, 'utf8');
26
+ return s.length > cap ? s.slice(0, cap) + '\n…[过长已截断]' : s;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ export function buildSystemPrompt({ workingDir, withJournal = false }) {
33
+ // 前缀字节稳定性(评估 P1-1/P1-2,四份评估一致的最高价值项):
34
+ // 系统提示不含「当前模型」「当前日期」等易变字段——DeepSeek 上下文缓存按前缀字节匹配,
35
+ // 路由 pro⇄flash 翻转或跨天会改变前缀 → 整段历史按未命中价重计(命中价的 30 倍)。
36
+ // 现在同一工作空间内系统提示恒定(记忆/技能/AGENTS.md 只在用户显式改动时变化)。
37
+ let prompt = `${BASE}
38
+
39
+ 当前工作目录:${workingDir}`;
40
+
41
+ // 用户级记忆(~/.mingdao/AGENTS.md,/memory add 手动追加 + 会话结束自动提炼)
42
+ const memory = loadFile(path.join(mingdaoHome(), 'AGENTS.md'), 8000);
43
+ if (memory) prompt += `\n\n<user_memory>\n${memory}\n</user_memory>`;
44
+
45
+ // 最近会话日志(跨会话连续性):默认不注入——新会话应当全新开始,避免串到
46
+ // 上一次会话的上下文(曾出现「新会话却接着给上个会话的游戏升级」的混淆)。
47
+ // 仅当用户显式开启时注入(WebUI 勾选「带上文」/ CLI --journal)。
48
+ if (withJournal) prompt += recentJournalBlock(mingdaoHome());
49
+
50
+ // 技能清单(渐进披露:仅名称+描述,按需加载全文)
51
+ prompt += skillsRegistryBlock(workingDir);
52
+
53
+ // 项目约定(./AGENTS.md)
54
+ const agentsMd = loadFile(path.join(workingDir, 'AGENTS.md'), 20000);
55
+ if (agentsMd) prompt += `\n\n<agents_md>\n${agentsMd}\n</agents_md>`;
56
+
57
+ return prompt;
58
+ }
@@ -0,0 +1,135 @@
1
+ // Provider 注册表与工厂。
2
+ //
3
+ // 接入模型的三条路:
4
+ // 1. 内置 OpenAI 兼容端点 —— 在 config 里配 baseUrl/apiKey 即可(覆盖绝大多数模型);
5
+ // 2. 内置服务商预设 —— deepseek / openai / qwen / glm / moonshot / custom;
6
+ // 3. 自定义 Provider 模块 —— 在 <mingdao-home>/providers/<name>.mjs 中
7
+ // export async function createProvider(cfg),返回 { chat(opts) }。
8
+ // 适合 Anthropic 原生协议、本地推理框架等非 OpenAI 兼容场景。
9
+ // 详见 docs/PROVIDERS.md。
10
+
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { pathToFileURL } from 'node:url';
14
+ import { chat as openaiChat } from './openai-compatible.js';
15
+ import { modelPreset, providerPreset } from '../models.js';
16
+ import { mingdaoHome } from '../config.js';
17
+ import { resolveApiKey } from '../credentials.js';
18
+
19
+ export function resolveProviderConfig(cfg, modelName) {
20
+ // 自定义模型(config.customModels,WebUI 可增删改):优先于内置预设
21
+ const cm = cfg?.customModels?.[modelName];
22
+ if (cm) {
23
+ const envKeys = [cm.envKey, 'MINGDAO_API_KEY'].filter(Boolean);
24
+ const apiKey = resolveApiKey(cfg, `custom:${modelName}`, envKeys[0]) || resolveApiKey(cfg, 'custom', envKeys[0]);
25
+ return {
26
+ name: `custom:${modelName}`,
27
+ kind: 'openai-compatible',
28
+ baseUrl: cm.baseUrl || cfg?.baseUrl || '',
29
+ apiKey,
30
+ envHint: envKeys[0] || 'MINGDAO_API_KEY',
31
+ isCustom: true,
32
+ modelName,
33
+ };
34
+ }
35
+ const preset = modelPreset(modelName);
36
+ const name = preset?.provider || cfg?.provider || 'deepseek';
37
+ const pp = providerPreset(name) || { kind: 'openai-compatible' };
38
+ const baseUrl = cfg?.baseUrl || pp.baseUrl || '';
39
+ const apiKey = resolveApiKey(cfg, name, pp.envKey);
40
+ return {
41
+ name,
42
+ kind: pp.kind || 'openai-compatible',
43
+ baseUrl,
44
+ apiKey,
45
+ envHint: pp.envKey || 'MINGDAO_API_KEY',
46
+ isCustom: !providerPreset(name),
47
+ };
48
+ }
49
+
50
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
51
+
52
+ function isTransient(err) {
53
+ const status = err?.status;
54
+ if (status === 429 || (status >= 500 && status <= 504)) return true;
55
+ // 注意:不含 abort —— 用户 Ctrl+C 主动中断不应触发重试
56
+ return /timeout|超时|ECONNRESET|fetch failed/i.test(String(err?.message || ''));
57
+ }
58
+
59
+ export async function createProvider(cfg, modelName, { timeoutMs = 300000, retries = 2 } = {}) {
60
+ const pc = resolveProviderConfig(cfg, modelName);
61
+
62
+ // 自定义 Provider 模块优先(仅普通自定义端点;custom:<模型名> 走 OpenAI 兼容直连)
63
+ const customFile = path.join(mingdaoHome(), 'providers', pc.name + '.mjs');
64
+ if (pc.isCustom && !pc.name.includes(':') && fs.existsSync(customFile)) {
65
+ const mod = await import(pathToFileURL(customFile).href + `?v=${Date.now()}`);
66
+ if (typeof mod.createProvider !== 'function') {
67
+ throw new Error(`自定义 Provider 模块 ${customFile} 未导出 createProvider(cfg)。`);
68
+ }
69
+ const custom = await mod.createProvider(pc);
70
+ return { ...custom, name: pc.name, config: pc };
71
+ }
72
+
73
+ if (!pc.baseUrl) {
74
+ throw new Error(`服务商 "${pc.name}" 缺少 baseUrl,请运行 mingdao init 重新配置。`);
75
+ }
76
+
77
+ return {
78
+ name: pc.name,
79
+ config: pc,
80
+ async chat(opts) {
81
+ let attempt = 0;
82
+ for (;;) {
83
+ const ac = new AbortController();
84
+ let timedOut = false; // 审计 P2-6:用标志而非 name/字符串匹配识别内部超时
85
+ const timer = setTimeout(() => {
86
+ timedOut = true;
87
+ ac.abort(new Error('请求超时'));
88
+ }, timeoutMs);
89
+ // 转发外部信号(用户 Ctrl+C 中断),避免被内部超时信号覆盖
90
+ const onUserAbort = () => ac.abort(opts.signal?.reason);
91
+ if (opts.signal?.aborted) onUserAbort();
92
+ else opts.signal?.addEventListener('abort', onUserAbort, { once: true });
93
+ try {
94
+ return await openaiChat({
95
+ ...opts,
96
+ baseUrl: pc.baseUrl,
97
+ apiKey: pc.apiKey,
98
+ signal: ac.signal,
99
+ includeUsage: cfg?.includeUsage !== false,
100
+ });
101
+ } catch (err) {
102
+ // 内部超时经 abort 抛出,用标志识别(审计 P2-6);用户 Ctrl+C 的中断不算超时、不重试
103
+ const transient = (timedOut && !opts.signal?.aborted) || isTransient(err);
104
+ if (!transient || attempt >= retries) throw err;
105
+ attempt += 1;
106
+ // 指数退避 + 尊重 Retry-After(评估 P3-1):基础 1s/2s,服务端指定时取其值(封顶 30s)
107
+ let backoff = 1000 * attempt;
108
+ const ra = Number(err?.headers?.get?.('retry-after'));
109
+ if (Number.isFinite(ra) && ra > 0) backoff = Math.max(backoff, ra * 1000);
110
+ backoff = Math.min(backoff, 30000);
111
+ await sleep(backoff);
112
+ } finally {
113
+ clearTimeout(timer);
114
+ opts.signal?.removeEventListener('abort', onUserAbort);
115
+ }
116
+ }
117
+ },
118
+ };
119
+ }
120
+
121
+ // 辅助调用(标题生成/记忆提取等)的 provider 解析(评估 P2-2):辅助模型与当前模型
122
+ // 分属不同服务商时单独创建 provider——避免拿 deepseek-v4-flash 模型名去自定义网关请求
123
+ // 而 404 被静默吞掉(标题缺失、记忆永不沉淀且零感知)。
124
+ export async function helperProvider(cfg, helperModel, currentProvider) {
125
+ try {
126
+ const curName = String(currentProvider?.name || '');
127
+ const helperName = String(resolveProviderConfig(cfg, helperModel)?.name || '');
128
+ if (curName && curName === helperName) return currentProvider;
129
+ } catch {}
130
+ try {
131
+ return await createProvider(cfg, helperModel);
132
+ } catch {
133
+ return currentProvider;
134
+ }
135
+ }
@@ -0,0 +1,171 @@
1
+ // OpenAI 兼容协议的 HTTP + SSE 流式客户端。
2
+ // DeepSeek、OpenAI、Qwen、GLM、Moonshot 以及绝大多数模型网关都走这一层。
3
+
4
+ /**
5
+ * API 错误:Error 附带 HTTP 状态码与响应头(重试退避读 Retry-After 用)。
6
+ * @typedef {Error & { status?: number, headers?: Headers }} ApiError
7
+ */
8
+
9
+ export async function chat({ baseUrl, apiKey, model, messages, tools, temperature, maxTokens, signal, onDelta, includeUsage = true, responseFormat }) {
10
+ const url = String(baseUrl).replace(/\/+$/, '') + '/chat/completions';
11
+ const payload = { model, messages };
12
+ if (temperature != null) payload.temperature = temperature;
13
+ if (tools && tools.length) {
14
+ payload.tools = tools;
15
+ payload.tool_choice = 'auto';
16
+ }
17
+ if (maxTokens) payload.max_tokens = maxTokens;
18
+ if (responseFormat) payload.response_format = responseFormat;
19
+ payload.stream = true;
20
+ // 流式响应默认不带 usage,显式请求以便展示 token/费用统计
21
+ // (部分网关不支持该字段,可在 config.json 设 "includeUsage": false 关闭)
22
+ if (includeUsage) payload.stream_options = { include_usage: true };
23
+
24
+ let res;
25
+ try {
26
+ res = await fetch(url, {
27
+ method: 'POST',
28
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
29
+ body: JSON.stringify(payload),
30
+ signal,
31
+ });
32
+ } catch (err) {
33
+ /** @type {ApiError} */
34
+ const e = new Error(`网络请求失败:${err?.message || err}`);
35
+ e.status = 0;
36
+ throw e;
37
+ }
38
+
39
+ if (!res.ok) {
40
+ const raw = await res.text().catch(() => '');
41
+ let detail = raw.slice(0, 400);
42
+ try {
43
+ const j = JSON.parse(raw);
44
+ if (j?.error?.message) detail = j.error.message;
45
+ } catch {}
46
+ /** @type {ApiError} */
47
+ const e = new Error(`[${model}] API 错误 ${res.status}: ${detail}`);
48
+ e.status = res.status;
49
+ e.headers = res.headers; // 重试退避读取 Retry-After 用
50
+ throw e;
51
+ }
52
+
53
+ const contentType = res.headers.get('content-type') || '';
54
+ if (!contentType.includes('text/event-stream')) {
55
+ const json = await res.json().catch(() => null);
56
+ if (!json) throw new Error(`[${model}] 响应解析失败。`);
57
+ return parseNonStream(json, onDelta);
58
+ }
59
+ return parseStream(res.body, onDelta);
60
+ }
61
+
62
+ export function parseNonStream(json, onDelta) {
63
+ const choice = json?.choices?.[0];
64
+ const msg = choice?.message ?? {};
65
+ if (msg.content) onDelta?.({ text: msg.content });
66
+ if (msg.reasoning_content) onDelta?.({ reasoning: msg.reasoning_content });
67
+ const toolCalls = msg.tool_calls?.length ? msg.tool_calls : null;
68
+ return {
69
+ text: msg.content ?? '',
70
+ reasoning: msg.reasoning_content ?? '',
71
+ toolCalls,
72
+ usage: json?.usage ?? null,
73
+ finish: choice?.finish_reason ?? null,
74
+ };
75
+ }
76
+
77
+ // 解析 SSE 流:处理跨 chunk 断行、增量 content / reasoning_content / tool_calls。
78
+ export async function parseStream(body, onDelta) {
79
+ const reader = body.getReader();
80
+ const decoder = new TextDecoder();
81
+ let buf = '';
82
+ let content = '';
83
+ let reasoning = '';
84
+ const calls = new Map(); // key -> {id, name, args}(优先用 id,无 id 时按 index,再兜底自增)
85
+ const indexKeys = new Map(); // index -> key(id 只在首片出现,后续分片按 index 找回同一条目)
86
+ let autoKey = 0;
87
+ let usage = null;
88
+ let finish = null;
89
+
90
+ let doneFlag = false;
91
+ const handleLine = (lineRaw) => {
92
+ const line = lineRaw.trim();
93
+ if (!line.startsWith('data:')) return;
94
+ const data = line.slice(5).trim();
95
+ if (data === '[DONE]') {
96
+ finish = finish || 'stop';
97
+ doneFlag = true;
98
+ return true; // 通知外层终止读取(审计 P2-4:[DONE] 后不再消费残帧、不挂到超时)
99
+ }
100
+ let json;
101
+ try {
102
+ json = JSON.parse(data);
103
+ } catch {
104
+ return;
105
+ }
106
+ // 注意:DeepSeek/OpenAI 流式最后一块常是 usage-only(choices 为空),必须先取 usage 再判 choices
107
+ if (json.usage) usage = json.usage;
108
+ const choice = json?.choices?.[0];
109
+ if (!choice) return;
110
+ const d = choice.delta ?? {};
111
+ if (d.content && !doneFlag) {
112
+ content += d.content;
113
+ onDelta?.({ text: d.content });
114
+ }
115
+ if (d.reasoning_content && !doneFlag) {
116
+ reasoning += d.reasoning_content;
117
+ onDelta?.({ reasoning: d.reasoning_content });
118
+ }
119
+ if (Array.isArray(d.tool_calls)) {
120
+ for (const tc of d.tool_calls) {
121
+ let key;
122
+ if (tc.id) key = `id:${tc.id}`;
123
+ else if (tc.index != null && indexKeys.has(tc.index)) key = indexKeys.get(tc.index);
124
+ else key = `idx-${tc.index != null ? tc.index : autoKey++}`;
125
+ if (tc.index != null) indexKeys.set(tc.index, key);
126
+ let cur = calls.get(key);
127
+ if (!cur) {
128
+ cur = { id: tc.id ?? '', name: '', args: '' };
129
+ calls.set(key, cur);
130
+ }
131
+ if (tc.id) cur.id = tc.id;
132
+ // 部分网关会重复下发完整 name:保留最长版本,避免重复拼接
133
+ if (tc.function?.name && tc.function.name.length > cur.name.length) cur.name = tc.function.name;
134
+ if (tc.function?.arguments) cur.args += tc.function.arguments;
135
+ }
136
+ }
137
+ if (choice.finish_reason) finish = choice.finish_reason;
138
+ };
139
+
140
+ for (;;) {
141
+ const { done, value } = await reader.read();
142
+ if (done) break;
143
+ buf += decoder.decode(value, { stream: true });
144
+ let nl;
145
+ while ((nl = buf.indexOf('\n')) >= 0) {
146
+ const line = buf.slice(0, nl);
147
+ buf = buf.slice(nl + 1);
148
+ if (handleLine(line)) break;
149
+ }
150
+ }
151
+ // 收尾:残行(网关最后一帧不带换行)与多字节字符冲刷
152
+ content += decoder.decode();
153
+ if (buf.trim()) handleLine(buf); // [DONE] 后残留 usage 尾帧仍吸收(正文/推理已被 doneFlag 拦截)
154
+
155
+ const toolCalls = [...calls.entries()]
156
+ .sort((a, b) => {
157
+ const na = Number(String(a[0]).replace(/^idx-/, ''));
158
+ const nb = Number(String(b[0]).replace(/^idx-/, ''));
159
+ // 无 id 的 idx-N 按数字排序(审计 P2-5:idx-10 应在 idx-2 之后而非之前)
160
+ if (Number.isFinite(na) && Number.isFinite(nb)) return na - nb;
161
+ return String(a[0]).localeCompare(String(b[0]));
162
+ })
163
+ .map(([, c]) => ({
164
+ id: c.id || `call_${c.name || 'x'}`,
165
+ type: 'function',
166
+ function: { name: c.name, arguments: c.args || '{}' },
167
+ }))
168
+ .filter((c) => c.function.name);
169
+
170
+ return { text: content, reasoning, toolCalls: toolCalls.length ? toolCalls : null, usage, finish };
171
+ }