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.
- package/LICENSE +21 -0
- package/README.md +246 -0
- package/assets/tokenizer-data.json.gz +0 -0
- package/docs/ARCHITECTURE.md +108 -0
- package/docs/CONFIG.md +257 -0
- package/docs/DESKTOP-EVALUATION.md +48 -0
- package/docs/PROVIDERS.md +98 -0
- package/docs/QA-REPORT.md +333 -0
- package/install.bat +8 -0
- package/install.ps1 +57 -0
- package/install.sh +154 -0
- package/package.json +61 -0
- package/skills/api-design/SKILL.md +21 -0
- package/skills/code-review/SKILL.md +31 -0
- package/skills/debugging/SKILL.md +21 -0
- package/skills/docker/SKILL.md +27 -0
- package/skills/docx/SKILL.md +28 -0
- package/skills/frontend-design/SKILL.md +31 -0
- package/skills/git-commit/SKILL.md +32 -0
- package/skills/pdf/SKILL.md +30 -0
- package/skills/pptx/SKILL.md +24 -0
- package/skills/refactoring/SKILL.md +24 -0
- package/skills/release-checklist/SKILL.md +20 -0
- package/skills/testing/SKILL.md +30 -0
- package/skills/webapp-testing/SKILL.md +27 -0
- package/skills/xlsx/SKILL.md +28 -0
- package/src/agent.js +472 -0
- package/src/audit.js +68 -0
- package/src/autostart.js +74 -0
- package/src/batch.js +182 -0
- package/src/cachestats.js +215 -0
- package/src/cli.js +1099 -0
- package/src/commands/key.js +75 -0
- package/src/commands/schedule.js +176 -0
- package/src/commands/skill.js +168 -0
- package/src/commands/sync.js +222 -0
- package/src/commands/update.js +157 -0
- package/src/commands/workspace.js +109 -0
- package/src/compact.js +112 -0
- package/src/config.js +134 -0
- package/src/context.js +86 -0
- package/src/cost-guard.js +58 -0
- package/src/credentials.js +69 -0
- package/src/hooks.js +123 -0
- package/src/index.js +42 -0
- package/src/mcp-presets.js +78 -0
- package/src/mcp.js +284 -0
- package/src/memory.js +242 -0
- package/src/model-discovery.js +153 -0
- package/src/models.js +186 -0
- package/src/notify.js +38 -0
- package/src/permissions.js +83 -0
- package/src/pricing.js +156 -0
- package/src/prompts.js +58 -0
- package/src/providers/index.js +135 -0
- package/src/providers/openai-compatible.js +171 -0
- package/src/routing.js +126 -0
- package/src/schedule.js +434 -0
- package/src/session-index.js +122 -0
- package/src/session.js +131 -0
- package/src/skill-lib.js +350 -0
- package/src/skill-registry.js +165 -0
- package/src/skills.js +135 -0
- package/src/sync-server.js +564 -0
- package/src/sync.js +479 -0
- package/src/tasks.js +126 -0
- package/src/titles.js +75 -0
- package/src/tokenizer.js +287 -0
- package/src/tools/bash.js +165 -0
- package/src/tools/fs-tools.js +346 -0
- package/src/tools/index.js +287 -0
- package/src/ui.js +643 -0
- package/src/update.js +222 -0
- package/src/web/attachments.js +49 -0
- package/src/web/index.html +993 -0
- package/src/web/server.js +1173 -0
- package/src/web/web-io.js +107 -0
- package/src/workspace.js +157 -0
package/src/memory.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// 长记忆与自主进化(借鉴 Hermes Agent):
|
|
2
|
+
// - 用户记忆:~/.mingdao/AGENTS.md(/memory add 手动 + 会话结束自动提取用户偏好,去重追加)
|
|
3
|
+
// - 会话日志:~/.mingdao/journal.jsonl(跨会话连续性;默认不注入系统提示——新会话全新开始,
|
|
4
|
+
// 避免串到历史会话上下文;WebUI「带上文」勾选 / CLI --journal 时注入最近 3 条)
|
|
5
|
+
// - 自动记忆提取用 executor 模型(约几十 token/会话),config.autoMemory 可关(默认开)
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { mingdaoHome, ensureHome } from './config.js';
|
|
10
|
+
|
|
11
|
+
export function memoryFile() {
|
|
12
|
+
return path.join(mingdaoHome(), 'AGENTS.md');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function journalFile() {
|
|
16
|
+
return path.join(mingdaoHome(), 'journal.jsonl');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function loadMemory() {
|
|
20
|
+
try {
|
|
21
|
+
return fs.readFileSync(memoryFile(), 'utf8');
|
|
22
|
+
} catch {
|
|
23
|
+
return '';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function appendMemory(lines) {
|
|
28
|
+
const add = lines.map((l) => l.trim()).filter(Boolean);
|
|
29
|
+
if (!add.length) return 0;
|
|
30
|
+
ensureHome();
|
|
31
|
+
// 审计 B9:与费用统计同口径,用北京时间自然日做记忆日期戳
|
|
32
|
+
let date = new Date().toISOString().slice(0, 10);
|
|
33
|
+
try {
|
|
34
|
+
const p = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' })
|
|
35
|
+
.formatToParts(new Date());
|
|
36
|
+
const g = (t) => p.find((x) => x.type === t)?.value;
|
|
37
|
+
date = `${g('year')}-${g('month')}-${g('day')}`;
|
|
38
|
+
} catch {}
|
|
39
|
+
fs.appendFileSync(memoryFile(), add.map((l) => (l.startsWith('-') ? `- [${date}] ${l.slice(1).trim()}` : `- [${date}] ${l}`)).join('\n') + '\n');
|
|
40
|
+
return add.length;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function backupMemory() {
|
|
44
|
+
try {
|
|
45
|
+
if (fs.existsSync(memoryFile())) fs.copyFileSync(memoryFile(), memoryFile() + '.bak');
|
|
46
|
+
} catch {}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 整体覆写(编辑面板保存用,自动备份)
|
|
50
|
+
export function writeMemory(content) {
|
|
51
|
+
backupMemory();
|
|
52
|
+
ensureHome();
|
|
53
|
+
fs.writeFileSync(memoryFile(), String(content ?? ''));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 去重:忽略日期前缀后内容相同的条目只保留第一条
|
|
57
|
+
export function dedupeMemory() {
|
|
58
|
+
const raw = loadMemory();
|
|
59
|
+
if (!raw.trim()) return 0;
|
|
60
|
+
const seen = new Set();
|
|
61
|
+
const kept = [];
|
|
62
|
+
let removed = 0;
|
|
63
|
+
for (const line of raw.split('\n')) {
|
|
64
|
+
const t = line.trim();
|
|
65
|
+
if (!t) continue;
|
|
66
|
+
const norm = t.replace(/^-\s*\[\d{4}-\d{2}-\d{2}\]\s*/, '').trim().toLowerCase();
|
|
67
|
+
if (seen.has(norm)) {
|
|
68
|
+
removed += 1;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
seen.add(norm);
|
|
72
|
+
kept.push(t);
|
|
73
|
+
}
|
|
74
|
+
if (removed > 0) {
|
|
75
|
+
backupMemory();
|
|
76
|
+
fs.writeFileSync(memoryFile(), kept.join('\n') + '\n');
|
|
77
|
+
}
|
|
78
|
+
return removed;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 删除包含关键词的条目
|
|
82
|
+
export function removeMemoryLines(keyword) {
|
|
83
|
+
const raw = loadMemory();
|
|
84
|
+
const kw = String(keyword).trim().toLowerCase();
|
|
85
|
+
if (!raw.trim() || !kw) return 0;
|
|
86
|
+
const kept = [];
|
|
87
|
+
let removed = 0;
|
|
88
|
+
for (const line of raw.split('\n')) {
|
|
89
|
+
if (line.trim() && line.toLowerCase().includes(kw)) {
|
|
90
|
+
removed += 1;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
kept.push(line);
|
|
94
|
+
}
|
|
95
|
+
if (removed > 0) {
|
|
96
|
+
backupMemory();
|
|
97
|
+
fs.writeFileSync(memoryFile(), kept.join('\n'));
|
|
98
|
+
}
|
|
99
|
+
return removed;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let journalCount = 0; // 内存计数(评估 P3-2):避免每次写入都整文件读一遍只为查行数
|
|
103
|
+
|
|
104
|
+
export function appendJournal(home, entry) {
|
|
105
|
+
try {
|
|
106
|
+
// journalFile() 已含 mingdaoHome()(home 参数仅为兼容旧签名保留)。
|
|
107
|
+
// 不能 path.join(home, dirname(journalFile())):journalFile() 是绝对路径,join 会拼出
|
|
108
|
+
// 「D:\a\D:\b」式非法路径——Windows 上 mkdir 抛错被静默吞掉,journal 整体失效(评估 D1)。
|
|
109
|
+
fs.mkdirSync(path.dirname(journalFile()), { recursive: true });
|
|
110
|
+
// 纯追加:并发会话收尾互不覆盖;崩溃最多丢最后一行
|
|
111
|
+
fs.appendFileSync(journalFile(), JSON.stringify(entry) + '\n');
|
|
112
|
+
journalCount += 1;
|
|
113
|
+
} catch (err) {
|
|
114
|
+
// 静默吞错面收窄(评估建议 3):调试开关可见原因,正常使用仍零打扰
|
|
115
|
+
if (process.env.MINGDAO_DEBUG) console.warn('[MingDao] journal 写入失败:' + (err?.message || err));
|
|
116
|
+
}
|
|
117
|
+
// 低频截断:超过 600 行时重写保留最近 500 行(跨过上限后每 200 条检查一次)
|
|
118
|
+
if (journalCount > 600 && journalCount % 200 === 0) {
|
|
119
|
+
try {
|
|
120
|
+
const raw = fs.readFileSync(journalFile(), 'utf8');
|
|
121
|
+
const lines = raw.split('\n').filter(Boolean);
|
|
122
|
+
if (lines.length > 600) {
|
|
123
|
+
fs.writeFileSync(journalFile(), lines.slice(-500).join('\n') + '\n');
|
|
124
|
+
journalCount = 500;
|
|
125
|
+
}
|
|
126
|
+
} catch {}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function recentJournal(home, n = 3) {
|
|
131
|
+
try {
|
|
132
|
+
const raw = fs.readFileSync(journalFile(), 'utf8');
|
|
133
|
+
return raw
|
|
134
|
+
.split('\n')
|
|
135
|
+
.filter(Boolean)
|
|
136
|
+
.map((l) => {
|
|
137
|
+
try {
|
|
138
|
+
return JSON.parse(l);
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
.filter(Boolean)
|
|
144
|
+
.slice(-n);
|
|
145
|
+
} catch {
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function recentJournalBlock(home) {
|
|
151
|
+
const entries = recentJournal(home, 3);
|
|
152
|
+
if (!entries.length) return '';
|
|
153
|
+
return (
|
|
154
|
+
'\n\n<recent_sessions>\n' +
|
|
155
|
+
entries
|
|
156
|
+
.map((e) => `- ${new Date(e.at).toISOString().slice(0, 10)} ${(e.workspace || '').replace(/[<>]/g, '')}:${(e.firstUser?.slice(0, 40) ?? '').replace(/[<>]/g, '')} → ${(e.outcome?.slice(0, 40) ?? '').replace(/[<>]/g, '')}`)
|
|
157
|
+
.join('\n') +
|
|
158
|
+
'\n</recent_sessions>'
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 自动记忆提取:从对话提炼值得长期记住的用户偏好/事实(去重:已有记忆会先提供给模型)
|
|
163
|
+
export async function extractMemory(provider, model, messages, existingMemory) {
|
|
164
|
+
try {
|
|
165
|
+
const convo = messages
|
|
166
|
+
.filter((m) => m.role === 'user' || m.role === 'assistant')
|
|
167
|
+
.slice(-20)
|
|
168
|
+
.map((m) => `${m.role === 'user' ? '用户' : 'MingDao'}:${String(m.content || '').slice(0, 500)}`)
|
|
169
|
+
.join('\n');
|
|
170
|
+
const msgs = [
|
|
171
|
+
{
|
|
172
|
+
role: 'system',
|
|
173
|
+
content:
|
|
174
|
+
'你是 MingDao 的记忆提取器。从对话中提取值得长期记住的用户偏好与事实(工具链、代码风格、项目背景、个人约定、常用指令等)。每条一行,以 - 开头,≤30 字,只输出新条目(与「已有记忆」重复或对话中未提及的不要输出);没有新增时 items 为空数组。\n已有记忆:\n' +
|
|
175
|
+
(existingMemory || '(空)'),
|
|
176
|
+
},
|
|
177
|
+
{ role: 'user', content: convo.slice(0, 8000) },
|
|
178
|
+
];
|
|
179
|
+
// 结构化输出(评估 4.2-4):json_object,maxTokens 300→200,解析零失败;网关不支持时回退纯文本
|
|
180
|
+
try {
|
|
181
|
+
const res = await provider.chat({ model, messages: msgs, tools: [], temperature: 0.2, maxTokens: 200, responseFormat: { type: 'json_object' } });
|
|
182
|
+
const j = JSON.parse(String(res.text || '').trim());
|
|
183
|
+
const items = Array.isArray(j?.items) ? j.items : [];
|
|
184
|
+
const clean = items
|
|
185
|
+
.map((l) => String(l).trim())
|
|
186
|
+
.filter(Boolean)
|
|
187
|
+
.map((l) => (/^[·•]/.test(l) ? '- ' + l.replace(/^[·•]\s*/, '') : l.startsWith('-') ? l : '- ' + l)) // 审计 Q4:非前缀条目规整为 "- ",不再二次回退调用
|
|
188
|
+
.slice(0, 10);
|
|
189
|
+
if (clean.length || items.length === 0) return clean; // 空数组 = 无新增;有内容即返回
|
|
190
|
+
} catch {}
|
|
191
|
+
const res = await provider.chat({
|
|
192
|
+
model,
|
|
193
|
+
messages: [
|
|
194
|
+
{
|
|
195
|
+
role: 'system',
|
|
196
|
+
content:
|
|
197
|
+
'你是 MingDao 的记忆提取器。从对话中提取值得长期记住的用户偏好与事实(工具链、代码风格、项目背景、个人约定、常用指令等)。每条一行,以 - 开头,≤30 字,只输出新条目(与「已有记忆」重复或对话中未提及的不要输出);没有新增就只输出「无新增」。\n已有记忆:\n' +
|
|
198
|
+
(existingMemory || '(空)'),
|
|
199
|
+
},
|
|
200
|
+
{ role: 'user', content: convo.slice(0, 8000) },
|
|
201
|
+
],
|
|
202
|
+
tools: [],
|
|
203
|
+
temperature: 0.2,
|
|
204
|
+
maxTokens: 300,
|
|
205
|
+
});
|
|
206
|
+
const text = String(res.text || '').trim();
|
|
207
|
+
if (!text || text.includes('无新增')) return [];
|
|
208
|
+
return text
|
|
209
|
+
.split('\n')
|
|
210
|
+
.map((l) => l.trim())
|
|
211
|
+
.filter((l) => l.startsWith('-') || /^[·•]/.test(l))
|
|
212
|
+
.map((l) => l.replace(/^[·•]\s*/, '- '))
|
|
213
|
+
.slice(0, 10);
|
|
214
|
+
} catch {
|
|
215
|
+
return [];
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 会话收尾:写日志 + 自动记忆(turns 太少的一次性会话只记日志不提取)
|
|
220
|
+
export async function finalizeSession({ cfg, provider, model, home, workingDir, messages, turns, lastText }) {
|
|
221
|
+
const firstUser = messages.find((m) => m.role === 'user')?.content || '';
|
|
222
|
+
try {
|
|
223
|
+
const wsName = null; // 由调用方通过 currentWorkspace 提供会更好,这里保持轻量
|
|
224
|
+
appendJournal(home, {
|
|
225
|
+
at: Date.now(),
|
|
226
|
+
workspace: wsName,
|
|
227
|
+
firstUser: firstUser.slice(0, 80),
|
|
228
|
+
outcome: lastText?.slice(0, 80) || '',
|
|
229
|
+
turns,
|
|
230
|
+
});
|
|
231
|
+
} catch {}
|
|
232
|
+
if (cfg?.autoMemory !== false && turns >= 3) {
|
|
233
|
+
try {
|
|
234
|
+
const existing = loadMemory();
|
|
235
|
+
// 辅助模型与当前模型分属不同服务商时按模型解析 provider(评估 P2-2)
|
|
236
|
+
const { helperProvider } = await import('./providers/index.js');
|
|
237
|
+
const memProvider = await helperProvider(cfg, model, provider);
|
|
238
|
+
const lines = await extractMemory(memProvider, model, messages, existing);
|
|
239
|
+
if (lines.length) appendMemory(lines);
|
|
240
|
+
} catch {}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// 模型动态发现:设置 API Key 后从服务商 /models 接口拉取真实可用模型,
|
|
2
|
+
// 模型名称以线上为准(不硬编码),预设仅作为无网络时的回退与价格/参数的补充。
|
|
3
|
+
// - 缓存 <home>/model-cache.json,TTL 1 小时,避免每次打开都请求
|
|
4
|
+
// - availableModels:只列出已设置 Key 的服务商(凭证库或环境变量),
|
|
5
|
+
// 动态名单优先,预设名单回退;自定义模型(config.customModels)恒列出
|
|
6
|
+
// - 未收录预设的线上模型走通用默认(Agent 有兜底参数),计价显示 n/a
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { mingdaoHome, ensureHome } from './config.js';
|
|
11
|
+
import { PROVIDERS, MODELS } from './models.js';
|
|
12
|
+
import { getStoredKey } from './credentials.js';
|
|
13
|
+
|
|
14
|
+
const TTL_MS = 60 * 60 * 1000;
|
|
15
|
+
|
|
16
|
+
export function modelCacheFile() {
|
|
17
|
+
return path.join(mingdaoHome(), 'model-cache.json');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function loadCache() {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(fs.readFileSync(modelCacheFile(), 'utf8'));
|
|
23
|
+
} catch {
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function saveCache(data) {
|
|
29
|
+
try {
|
|
30
|
+
ensureHome();
|
|
31
|
+
fs.writeFileSync(modelCacheFile(), JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
|
|
32
|
+
} catch {}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function providerHasKey(providerName) {
|
|
36
|
+
const pp = PROVIDERS[providerName];
|
|
37
|
+
if (!pp) return false;
|
|
38
|
+
if (getStoredKey(providerName)) return true;
|
|
39
|
+
if (pp.envKey && process.env[pp.envKey]) return true;
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function providerBaseUrl(cfg, providerName) {
|
|
44
|
+
const pp = PROVIDERS[providerName];
|
|
45
|
+
if (!pp) return '';
|
|
46
|
+
// 当前服务商的 baseUrl 覆盖优先
|
|
47
|
+
if (providerName === cfg?.provider && cfg?.baseUrl) return cfg.baseUrl;
|
|
48
|
+
return pp.baseUrl || '';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function providerApiKey(providerName) {
|
|
52
|
+
const pp = PROVIDERS[providerName];
|
|
53
|
+
const stored = getStoredKey(providerName);
|
|
54
|
+
if (stored) return stored;
|
|
55
|
+
if (pp?.envKey && process.env[pp.envKey]) return process.env[pp.envKey];
|
|
56
|
+
return '';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isChatModel(id) {
|
|
60
|
+
return !/embedding|rerank|moderation/i.test(id);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 拉取某服务商的真实模型名单(缓存优先;force 强制刷新)
|
|
64
|
+
// 返回 { models: [名称], fromCache, fetchedAt };失败返回 { error }
|
|
65
|
+
const inflight = new Map(); // 同服务商并发去重:合并为同一请求
|
|
66
|
+
|
|
67
|
+
export async function fetchProviderModels(cfg, providerName, { force = false } = {}) {
|
|
68
|
+
const base = providerBaseUrl(cfg, providerName).replace(/\/+$/, '');
|
|
69
|
+
const key = providerApiKey(providerName);
|
|
70
|
+
if (!base || !key) return { error: '该服务商未设置 API Key' };
|
|
71
|
+
const cache = loadCache();
|
|
72
|
+
const entry = cache[providerName];
|
|
73
|
+
if (!force && entry && Date.now() - entry.fetchedAt < TTL_MS && Array.isArray(entry.models)) {
|
|
74
|
+
return { models: entry.models, fromCache: true, fetchedAt: entry.fetchedAt };
|
|
75
|
+
}
|
|
76
|
+
if (!force && inflight.has(providerName)) return inflight.get(providerName);
|
|
77
|
+
const p = (async () => {
|
|
78
|
+
const ctrl = new AbortController();
|
|
79
|
+
const timer = setTimeout(() => ctrl.abort(), 8000);
|
|
80
|
+
try {
|
|
81
|
+
const res = await fetch(`${base}/models`, {
|
|
82
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
83
|
+
signal: ctrl.signal,
|
|
84
|
+
redirect: 'follow',
|
|
85
|
+
});
|
|
86
|
+
if (!res.ok) return { error: `HTTP ${res.status}` };
|
|
87
|
+
const j = await res.json().catch(() => null);
|
|
88
|
+
const list = (j?.data || [])
|
|
89
|
+
.map((m) => String(m?.id || '').trim())
|
|
90
|
+
.filter((id) => id && isChatModel(id))
|
|
91
|
+
.slice(0, 200);
|
|
92
|
+
if (!list.length) return { error: '接口未返回可用模型' };
|
|
93
|
+
const cache2 = loadCache(); // 重新读取:避免与并发写入互相覆盖
|
|
94
|
+
cache2[providerName] = { models: list, fetchedAt: Date.now() };
|
|
95
|
+
saveCache(cache2);
|
|
96
|
+
return { models: list, fromCache: false, fetchedAt: Date.now() };
|
|
97
|
+
} catch (e) {
|
|
98
|
+
return { error: e.name === 'AbortError' ? '请求超时(8s)' : e.message };
|
|
99
|
+
} finally {
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
}
|
|
102
|
+
})();
|
|
103
|
+
inflight.set(providerName, p);
|
|
104
|
+
try {
|
|
105
|
+
return await p;
|
|
106
|
+
} finally {
|
|
107
|
+
if (inflight.get(providerName) === p) inflight.delete(providerName);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 合并可用模型列表:只含已设置 Key 的服务商;动态名单优先、预设回退;自定义模型恒在。
|
|
112
|
+
export async function availableModels(cfg, currentModel) {
|
|
113
|
+
const out = [];
|
|
114
|
+
const seen = new Set();
|
|
115
|
+
for (const [pname, pp] of Object.entries(PROVIDERS)) {
|
|
116
|
+
if (pname === 'custom') continue;
|
|
117
|
+
if (!providerHasKey(pname)) continue;
|
|
118
|
+
const dynamic = await fetchProviderModels(cfg, pname);
|
|
119
|
+
const names = dynamic.models?.length ? dynamic.models : pp.models || [];
|
|
120
|
+
for (const n of names) {
|
|
121
|
+
if (seen.has(n)) continue;
|
|
122
|
+
seen.add(n);
|
|
123
|
+
const preset = MODELS[n];
|
|
124
|
+
out.push({
|
|
125
|
+
name: n,
|
|
126
|
+
label: preset ? `${n} — ${preset.label}` : `${n}(线上最新)`,
|
|
127
|
+
provider: pname,
|
|
128
|
+
providerLabel: pp.label,
|
|
129
|
+
dynamic: !MODELS[n],
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
for (const [cmName, cm] of Object.entries(cfg?.customModels || {})) {
|
|
134
|
+
if (seen.has(cmName)) continue;
|
|
135
|
+
seen.add(cmName);
|
|
136
|
+
out.push({
|
|
137
|
+
name: cmName,
|
|
138
|
+
label: `${cmName} — ${cm.label || '自定义模型'}`,
|
|
139
|
+
provider: 'custom',
|
|
140
|
+
providerLabel: '自定义',
|
|
141
|
+
custom: true,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
if (currentModel && !out.some((m) => m.name === currentModel)) {
|
|
145
|
+
out.unshift({
|
|
146
|
+
name: currentModel,
|
|
147
|
+
label: `${currentModel}(当前配置)`,
|
|
148
|
+
provider: 'current',
|
|
149
|
+
providerLabel: '当前',
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
package/src/models.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// 模型与服务商预设目录(MingDao 内置)。
|
|
2
|
+
// 所有字段都可以通过 ~/.mingdao/config.json 或环境变量覆盖,
|
|
3
|
+
// 也可以在 <mingdao-home>/providers/<name>.mjs 中注册完全自定义的 Provider 模块。
|
|
4
|
+
|
|
5
|
+
export const PROVIDERS = {
|
|
6
|
+
deepseek: {
|
|
7
|
+
label: 'DeepSeek 官方 API',
|
|
8
|
+
kind: 'openai-compatible',
|
|
9
|
+
baseUrl: 'https://api.deepseek.com/v1',
|
|
10
|
+
envKey: 'DEEPSEEK_API_KEY',
|
|
11
|
+
models: ['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-v4-flash-vision-exp'],
|
|
12
|
+
note: 'DeepSeek-V4 正式版(2026-08-17 起商用):384K 上下文,峰谷定价(高峰 9:00–14:00 为闲时 2 倍),支持工具调用 / Responses API / Anthropic 兼容接口。',
|
|
13
|
+
},
|
|
14
|
+
openai: {
|
|
15
|
+
label: 'OpenAI',
|
|
16
|
+
kind: 'openai-compatible',
|
|
17
|
+
baseUrl: 'https://api.openai.com/v1',
|
|
18
|
+
envKey: 'OPENAI_API_KEY',
|
|
19
|
+
models: ['gpt-5', 'gpt-5-mini', 'gpt-5-nano'],
|
|
20
|
+
},
|
|
21
|
+
qwen: {
|
|
22
|
+
label: '阿里云百炼(通义千问)',
|
|
23
|
+
kind: 'openai-compatible',
|
|
24
|
+
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
|
25
|
+
envKey: 'DASHSCOPE_API_KEY',
|
|
26
|
+
models: ['qwen3.7-max', 'qwen-plus'],
|
|
27
|
+
},
|
|
28
|
+
glm: {
|
|
29
|
+
label: '智谱 GLM',
|
|
30
|
+
kind: 'openai-compatible',
|
|
31
|
+
baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
|
|
32
|
+
envKey: 'ZHIPUAI_API_KEY',
|
|
33
|
+
models: ['glm-5', 'glm-4.5-air'],
|
|
34
|
+
},
|
|
35
|
+
moonshot: {
|
|
36
|
+
label: '月之暗面 Kimi',
|
|
37
|
+
kind: 'openai-compatible',
|
|
38
|
+
baseUrl: 'https://api.moonshot.cn/v1',
|
|
39
|
+
envKey: 'MOONSHOT_API_KEY',
|
|
40
|
+
models: ['kimi-latest', 'kimi-k2'],
|
|
41
|
+
},
|
|
42
|
+
custom: {
|
|
43
|
+
label: '自定义 OpenAI 兼容端点(任意模型网关/本地部署)',
|
|
44
|
+
kind: 'openai-compatible',
|
|
45
|
+
baseUrl: 'https://your-gateway.example.com/v1',
|
|
46
|
+
envKey: 'MINGDAO_API_KEY',
|
|
47
|
+
models: [],
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const MODELS = {
|
|
52
|
+
// —— DeepSeek-V4 系列(MingDao 首发优化目标)——
|
|
53
|
+
'deepseek-v4-pro': {
|
|
54
|
+
label: '旗舰推理 · 复杂架构、排障、规划',
|
|
55
|
+
provider: 'deepseek',
|
|
56
|
+
contextWindow: 384000, // 正式版规格:384K 上下文(2026-08-17 起商用)
|
|
57
|
+
budgetTokens: 200000, // 默认注入预算(省钱省时,可调高)
|
|
58
|
+
maxOutputTokens: 32768,
|
|
59
|
+
temperature: 0.4,
|
|
60
|
+
supportsReasoning: true,
|
|
61
|
+
// 峰谷定价(元/百万 tokens;input=缓存未命中,cacheHit=缓存命中约为未命中的 1/30)
|
|
62
|
+
pricing: {
|
|
63
|
+
offpeak: { input: 4.5, output: 13.5, cacheHit: 0.15 },
|
|
64
|
+
peak: { input: 9, output: 27, cacheHit: 0.3 },
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
'deepseek-v4-flash': {
|
|
68
|
+
label: '极速响应 · 日常问答与轻量任务',
|
|
69
|
+
provider: 'deepseek',
|
|
70
|
+
contextWindow: 384000, // 正式版规格:384K 上下文
|
|
71
|
+
budgetTokens: 128000,
|
|
72
|
+
maxOutputTokens: 8192,
|
|
73
|
+
temperature: 0.6,
|
|
74
|
+
supportsReasoning: false,
|
|
75
|
+
pricing: {
|
|
76
|
+
offpeak: { input: 1.5, output: 4.5, cacheHit: 0.05 },
|
|
77
|
+
peak: { input: 3, output: 9, cacheHit: 0.1 },
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
// 多模态视觉模型(2026-08-21 上线,实验版):文本 + 图片输入(JPEG/PNG/GIF/WebP),
|
|
81
|
+
// 图片转 token 计费(≤384 tokens/张),价格与 V4-Flash 完全一致。
|
|
82
|
+
'deepseek-v4-flash-vision-exp': {
|
|
83
|
+
label: '多模态视觉 · 图片描述/截图识别/图表分析(实验版)',
|
|
84
|
+
provider: 'deepseek',
|
|
85
|
+
contextWindow: 384000,
|
|
86
|
+
budgetTokens: 128000,
|
|
87
|
+
maxOutputTokens: 8192,
|
|
88
|
+
temperature: 0.6,
|
|
89
|
+
supportsReasoning: false,
|
|
90
|
+
supportsVision: true,
|
|
91
|
+
pricing: {
|
|
92
|
+
offpeak: { input: 1.5, output: 4.5, cacheHit: 0.05 },
|
|
93
|
+
peak: { input: 3, output: 9, cacheHit: 0.1 },
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
// —— 其他主流模型(OpenAI 兼容接入,按 2026-08 官方当前型号)——
|
|
97
|
+
'gpt-5': {
|
|
98
|
+
label: 'OpenAI 旗舰(官方别名,随最新版本)',
|
|
99
|
+
provider: 'openai',
|
|
100
|
+
contextWindow: 400000,
|
|
101
|
+
budgetTokens: 128000,
|
|
102
|
+
maxOutputTokens: 16384,
|
|
103
|
+
temperature: 0.6,
|
|
104
|
+
supportsReasoning: false,
|
|
105
|
+
},
|
|
106
|
+
'gpt-5-mini': {
|
|
107
|
+
label: 'OpenAI 性价比',
|
|
108
|
+
provider: 'openai',
|
|
109
|
+
contextWindow: 272000,
|
|
110
|
+
budgetTokens: 128000,
|
|
111
|
+
maxOutputTokens: 16384,
|
|
112
|
+
temperature: 0.7,
|
|
113
|
+
supportsReasoning: false,
|
|
114
|
+
},
|
|
115
|
+
'gpt-5-nano': {
|
|
116
|
+
label: 'OpenAI 最便宜',
|
|
117
|
+
provider: 'openai',
|
|
118
|
+
contextWindow: 272000,
|
|
119
|
+
budgetTokens: 96000,
|
|
120
|
+
maxOutputTokens: 16384,
|
|
121
|
+
temperature: 0.7,
|
|
122
|
+
supportsReasoning: false,
|
|
123
|
+
},
|
|
124
|
+
'qwen3.7-max': {
|
|
125
|
+
label: '通义千问旗舰',
|
|
126
|
+
provider: 'qwen',
|
|
127
|
+
contextWindow: 262144,
|
|
128
|
+
budgetTokens: 128000,
|
|
129
|
+
maxOutputTokens: 8192,
|
|
130
|
+
temperature: 0.6,
|
|
131
|
+
supportsReasoning: false,
|
|
132
|
+
},
|
|
133
|
+
'qwen-plus': {
|
|
134
|
+
label: '通义千问通用(官方别名,随最新版本)',
|
|
135
|
+
provider: 'qwen',
|
|
136
|
+
contextWindow: 131072,
|
|
137
|
+
budgetTokens: 96000,
|
|
138
|
+
maxOutputTokens: 8192,
|
|
139
|
+
temperature: 0.6,
|
|
140
|
+
supportsReasoning: false,
|
|
141
|
+
},
|
|
142
|
+
'glm-5': {
|
|
143
|
+
label: '智谱旗舰',
|
|
144
|
+
provider: 'glm',
|
|
145
|
+
contextWindow: 200000,
|
|
146
|
+
budgetTokens: 128000,
|
|
147
|
+
maxOutputTokens: 8192,
|
|
148
|
+
temperature: 0.6,
|
|
149
|
+
supportsReasoning: false,
|
|
150
|
+
},
|
|
151
|
+
'glm-4.5-air': {
|
|
152
|
+
label: '智谱免费轻量',
|
|
153
|
+
provider: 'glm',
|
|
154
|
+
contextWindow: 131072,
|
|
155
|
+
budgetTokens: 96000,
|
|
156
|
+
maxOutputTokens: 4096,
|
|
157
|
+
temperature: 0.7,
|
|
158
|
+
supportsReasoning: false,
|
|
159
|
+
},
|
|
160
|
+
'kimi-latest': {
|
|
161
|
+
label: 'Kimi 最新(官方别名,随最新版本)',
|
|
162
|
+
provider: 'moonshot',
|
|
163
|
+
contextWindow: 262144,
|
|
164
|
+
budgetTokens: 128000,
|
|
165
|
+
maxOutputTokens: 8192,
|
|
166
|
+
temperature: 0.7,
|
|
167
|
+
supportsReasoning: false,
|
|
168
|
+
},
|
|
169
|
+
'kimi-k2': {
|
|
170
|
+
label: 'Kimi K2 长文本',
|
|
171
|
+
provider: 'moonshot',
|
|
172
|
+
contextWindow: 262144,
|
|
173
|
+
budgetTokens: 128000,
|
|
174
|
+
maxOutputTokens: 8192,
|
|
175
|
+
temperature: 0.7,
|
|
176
|
+
supportsReasoning: false,
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
export function modelPreset(name) {
|
|
181
|
+
return MODELS[name] || null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function providerPreset(name) {
|
|
185
|
+
return PROVIDERS[name] || null;
|
|
186
|
+
}
|
package/src/notify.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// 桌面通知(零依赖):后台任务完成/失败时弹出系统通知。
|
|
2
|
+
// - Linux:notify-send(GNOME/KDE 等桌面环境自带)
|
|
3
|
+
// - macOS:osascript display notification
|
|
4
|
+
// - Windows:PowerShell WScript.Shell Popup(10 秒自动消失)
|
|
5
|
+
// config.notify 可关闭(默认开启);通知失败静默忽略,绝不影响任务流程。
|
|
6
|
+
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
|
|
9
|
+
export function notify(title, message) {
|
|
10
|
+
try {
|
|
11
|
+
const text = String(message).replace(/\s+/g, ' ').slice(0, 120);
|
|
12
|
+
// 审计 P2-12:title 同样转义(mac 反斜杠/引号、win 单引号),防注入破坏命令
|
|
13
|
+
const t = String(title).replace(/\s+/g, ' ').slice(0, 40);
|
|
14
|
+
if (process.platform === 'linux') {
|
|
15
|
+
const child = spawn('notify-send', [t, text], { detached: true, stdio: 'ignore' });
|
|
16
|
+
child.on('error', () => {});
|
|
17
|
+
child.unref();
|
|
18
|
+
} else if (process.platform === 'darwin') {
|
|
19
|
+
const safe = text.replace(/[\\"]/g, '\\$&');
|
|
20
|
+
const safeT = t.replace(/[\\"]/g, '\\$&');
|
|
21
|
+
const child = spawn('osascript', ['-e', `display notification "${safe}" with title "${safeT}"`], { detached: true, stdio: 'ignore' });
|
|
22
|
+
child.on('error', () => {});
|
|
23
|
+
child.unref();
|
|
24
|
+
} else if (process.platform === 'win32') {
|
|
25
|
+
const safe = text.replace(/'/g, "''");
|
|
26
|
+
const safeT = t.replace(/'/g, "''");
|
|
27
|
+
const child = spawn('powershell', ['-NoProfile', '-Command', `(New-Object -ComObject Wscript.Shell).Popup('${safe}', 10, '${safeT}', 64)`], { detached: true, stdio: 'ignore' });
|
|
28
|
+
child.on('error', () => {});
|
|
29
|
+
child.unref();
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
// 通知失败静默忽略
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function notifyTaskDone(question, status) {
|
|
37
|
+
notify('MingDao', `${status === 'done' ? '✓ 完成' : '✖ 失败'}:${String(question).slice(0, 60)}`);
|
|
38
|
+
}
|