feihong-code 0.5.1 → 0.6.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.
Files changed (68) hide show
  1. package/CHANGELOG.md +465 -405
  2. package/README.md +59 -0
  3. package/dist/agent/code-writer.js +6 -3
  4. package/dist/agent/code-writer.js.map +1 -1
  5. package/dist/agent/experience.js +105 -70
  6. package/dist/agent/experience.js.map +1 -1
  7. package/dist/agent/orchestrator.js +103 -21
  8. package/dist/agent/orchestrator.js.map +1 -1
  9. package/dist/agent/planner.js +30 -7
  10. package/dist/agent/planner.js.map +1 -1
  11. package/dist/agent/prompts.js +9 -0
  12. package/dist/agent/prompts.js.map +1 -1
  13. package/dist/agent/self-heal.js +43 -13
  14. package/dist/agent/self-heal.js.map +1 -1
  15. package/dist/cli/run.js +13 -14
  16. package/dist/cli/run.js.map +1 -1
  17. package/dist/cli/version.js +1 -1
  18. package/dist/enterprise/audit.js +70 -11
  19. package/dist/enterprise/audit.js.map +1 -1
  20. package/dist/models/model-router.js +83 -23
  21. package/dist/models/model-router.js.map +1 -1
  22. package/dist/models/model.dto.js +25 -3
  23. package/dist/models/model.dto.js.map +1 -1
  24. package/dist/models/providers/ollama.provider.js +12 -0
  25. package/dist/models/providers/ollama.provider.js.map +1 -1
  26. package/dist/models/providers/openai-compatible.provider.js +14 -1
  27. package/dist/models/providers/openai-compatible.provider.js.map +1 -1
  28. package/dist/self-evolve/hook.js +2 -2
  29. package/dist/self-evolve/hook.js.map +1 -1
  30. package/dist/self-evolve/hook.ts +80 -0
  31. package/dist/self-evolve/manager.d.ts +8 -0
  32. package/dist/self-evolve/manager.js +401 -0
  33. package/dist/shared/errors.js +6 -2
  34. package/dist/shared/errors.js.map +1 -1
  35. package/dist/shared/secure-store.js +117 -0
  36. package/dist/shared/secure-store.js.map +1 -0
  37. package/dist/tools/mcp/mcp-client.js +35 -13
  38. package/dist/tools/mcp/mcp-client.js.map +1 -1
  39. package/dist/tools/shell/exec.js +47 -3
  40. package/dist/tools/shell/exec.js.map +1 -1
  41. package/dist/tools/shell/run-shell.tool.js +33 -6
  42. package/dist/tools/shell/run-shell.tool.js.map +1 -1
  43. package/dist/tools/tool.registry.js +32 -0
  44. package/dist/tools/tool.registry.js.map +1 -1
  45. package/dist/web/auth.js +143 -7
  46. package/dist/web/auth.js.map +1 -1
  47. package/dist/web/public/css/style.css +1546 -0
  48. package/dist/web/public/index.html +806 -3253
  49. package/dist/web/public/js/api.js +309 -0
  50. package/dist/web/public/js/app.js +1472 -0
  51. package/dist/web/public/js/ui.js +832 -0
  52. package/dist/web/public/js/utils.js +170 -0
  53. package/dist/web/server.js +224 -77
  54. package/dist/web/server.js.map +1 -1
  55. package/dist/web/task-queue.js +128 -10
  56. package/dist/web/task-queue.js.map +1 -1
  57. package/docs/App/344/275/277/347/224/250/350/257/264/346/230/216/344/271/246.md +299 -0
  58. package/docs/App/346/212/200/346/234/257/350/257/264/346/230/216/344/271/246.md +554 -0
  59. package/docs/SELF-EVOLVE-GUIDE.md +313 -0
  60. package/docs/error-codes.md +198 -0
  61. package/docs/screenshots/cli-demo.png +0 -0
  62. package/docs/screenshots/feature-comparison.png +0 -0
  63. package/docs/screenshots/web-console.png +0 -0
  64. package/docs//351/241/265/351/235/242/345/212/237/350/203/275/345/244/215/347/233/230/344/270/216/345/206/222/347/203/237/346/265/213/350/257/225/346/212/245/345/221/212.html +117 -0
  65. package/package.json +9 -15
  66. package/tool-schema.json +198 -127
  67. package/dist/web/public/index.html.tmp +0 -3117
  68. package/dist/web/public/index_new.html +0 -3196
@@ -0,0 +1,170 @@
1
+ /**
2
+ * 工具函数模块:纯函数,无状态依赖,可被任意模块调用
3
+ */
4
+
5
+ function formatMarkdown(text) {
6
+ // 简单 Markdown 转换
7
+ return text
8
+ .replace(/&/g, '&')
9
+ .replace(/</g, '&lt;')
10
+ .replace(/>/g, '&gt;')
11
+ .replace(/^### (.*$)/gm, '<h3>$1</h3>')
12
+ .replace(/^## (.*$)/gm, '<h2>$1</h2>')
13
+ .replace(/^# (.*$)/gm, '<h1>$1</h1>')
14
+ .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
15
+ .replace(/\*(.*?)\*/g, '<em>$1</em>')
16
+ .replace(/`(.*?)`/g, '<code>$1</code>')
17
+ .replace(/\n/g, '<br>');
18
+ }
19
+
20
+ function getDirectoryName(path) {
21
+ if (!path || path === '' || path === '/') return '/';
22
+ if (/^[A-Za-z]:$/.test(path)) return path + '\\';
23
+ if (/^[A-Za-z]:\\$/.test(path)) return path;
24
+ const parts = path.split(/[/\\]/).filter(Boolean);
25
+ if (parts.length <= 1) {
26
+ const driveMatch = parts[0].match(/^([A-Za-z]):$/);
27
+ if (driveMatch) return driveMatch[1] + ':\\';
28
+ return parts[0] || '/';
29
+ }
30
+ const parentParts = parts.slice(0, -1);
31
+ if (/^[A-Za-z]:$/.test(parentParts[0])) {
32
+ return parentParts[0] + '\\' + parentParts.slice(1).join('\\');
33
+ }
34
+ return parentParts.join('\\');
35
+ }
36
+
37
+ /** 渲染空状态提示,减少重复的 innerHTML 赋值代码 */
38
+ function renderEmpty(el, text) {
39
+ if (el) el.innerHTML = '<div class="empty">' + (text || '') + '</div>';
40
+ }
41
+
42
+ function extractUrl(text) {
43
+ if (!text) return '';
44
+ const m = text.match(/(https?:\/\/[^\s]+)/);
45
+ return m ? m[1] : '';
46
+ }
47
+
48
+ function linkifyArtifacts(text) {
49
+ let html = escapeHtml(text);
50
+ // 占位符保护:先提取已识别的片段,避免后续正则把已插入的 <span> 标签二次包裹
51
+ const tokens = [];
52
+ const protect = (s) => { const k = '\u0000TOK' + tokens.length + '\u0000'; tokens.push(s); return k; };
53
+ // 1) URL:排除结尾标点与 HTML 实体
54
+ html = html.replace(/(https?:\/\/[^\s<>"'()]+)/g, (m) => protect('<span class="artifact" data-kind="url" data-path="' + m + '">' + m + '</span>'));
55
+ // 2) Windows 绝对路径(支持正/反斜杠、中文、空格,排除结尾标点)
56
+ html = html.replace(/([A-Za-z]:[\\\/][^\s<>"'()]+?)(?=[\s<>"',。;:、))]|$)/g, (m) => protect('<span class="artifact" data-kind="file" data-path="' + m + '">' + m + '</span>'));
57
+ // 3) Unix 风格绝对路径(至少两级目录,避免误匹配 "2023/2024"、"a/b" 等相对写法)
58
+ html = html.replace(/(\/(?:[A-Za-z0-9_\-.]+\/){1,}[A-Za-z0-9_\-.]*)/g, (m) => protect('<span class="artifact" data-kind="file" data-path="' + m + '">' + m + '</span>'));
59
+ tokens.forEach((s, i) => { html = html.split('\u0000TOK' + i + '\u0000').join(s); });
60
+ return html;
61
+ }
62
+
63
+ function formatSize(n) {
64
+ if (!Number.isFinite(n)) return '';
65
+ if (n < 1024) return n + ' B';
66
+ if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
67
+ return (n / 1024 / 1024).toFixed(2) + ' MB';
68
+ }
69
+
70
+ function estimateDataUrlSize(dataUrl) {
71
+ // base64 长度 × 0.75 估算字节
72
+ const b64 = (dataUrl || '').split(',')[1] || '';
73
+ return Math.round(b64.length * 0.75);
74
+ }
75
+
76
+ function formatToolArgs(args) {
77
+ if (!args || typeof args !== 'object') return '';
78
+ const keys = Object.keys(args);
79
+ if (!keys.length) return '';
80
+ const rows = keys.map(function (k) {
81
+ let v = args[k];
82
+ if (v == null) v = '';
83
+ else if (typeof v === 'object') v = JSON.stringify(v);
84
+ return '<div><span style="color:var(--ink-2,#6b7280);">' + escapeHtml(k) + ':</span> ' + escapeHtml(String(v)) + '</div>';
85
+ });
86
+ return '<div class="tc-args">' + rows.join('') + '</div>';
87
+ }
88
+
89
+ function renderMarkdown(text) {
90
+ let html = escapeHtml(text == null ? '' : text);
91
+ const codeBlocks = [];
92
+ html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, function (_, lang, code) {
93
+ const idx = codeBlocks.length;
94
+ codeBlocks.push('<pre style="background:var(--bg,#f5f6fa);padding:10px;border-radius:8px;overflow-x:auto;font-size:12px;margin:6px 0;white-space:pre-wrap;word-break:break-word;"><code>' + code + '</code></pre>');
95
+ return '\x00CB' + idx + '\x00';
96
+ });
97
+ html = html.replace(/`([^`]+)`/g, '<code style="background:var(--bg,#f5f6fa);padding:1px 5px;border-radius:4px;font-size:12px;">$1</code>');
98
+ html = html.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>');
99
+ html = html.replace(/^### (.*)$/gm, '<div style="font-weight:700;margin:8px 0 4px;">$1</div>');
100
+ html = html.replace(/^## (.*)$/gm, '<div style="font-weight:700;font-size:15px;margin:10px 0 4px;">$1</div>');
101
+ html = html.replace(/^# (.*)$/gm, '<div style="font-weight:700;font-size:16px;margin:10px 0 4px;">$1</div>');
102
+ html = html.replace(/^[-*] (.*)$/gm, '<div style="padding-left:14px;">• $1</div>');
103
+ html = html.replace(/^(\d+)\. (.*)$/gm, '<div style="padding-left:20px;">$1. $2</div>');
104
+ html = html.replace(/(https?:\/\/[^\s<>"'()]+)/g, '<a href="$1" style="color:var(--brand,#4f6ef7);text-decoration:underline;" target="_blank">$1</a>');
105
+ html = html.replace(/\n/g, '<br>');
106
+ html = html.replace(/\x00CB(\d+)\x00/g, function (_, idx) { return codeBlocks[parseInt(idx)]; });
107
+ return html;
108
+ }
109
+
110
+ function statusBadge(s) { return '<span class="badge ' + s + '">' + s + '</span>'; }
111
+
112
+ function closeModal(id) { document.getElementById(id).classList.remove('show'); }
113
+
114
+ function openModal(id) { document.getElementById(id).classList.add('show'); }
115
+
116
+ function toast(msg) {
117
+ const el = document.getElementById('toast');
118
+ el.textContent = msg; el.classList.add('show');
119
+ clearTimeout(el._t); el._t = setTimeout(() => el.classList.remove('show'), 2200);
120
+ }
121
+
122
+ function fmtTime(iso) { try { return new Date(iso).toLocaleString(); } catch { return iso || ''; } }
123
+
124
+ function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); }
125
+
126
+ function applyI18nToEl(el) {
127
+ const key = el.getAttribute('data-i18n');
128
+ if (!key) return;
129
+ const dict = I18N[currentLang] || I18N.zh;
130
+ const tag = el.tagName;
131
+
132
+ // 1) 输入类控件:只处理 placeholder,用 ph.<id> 专用键;缺失则保留中文原文
133
+ if (tag === 'INPUT' || tag === 'TEXTAREA') {
134
+ if (el._i18nPhZh === undefined) el._i18nPhZh = el.getAttribute('placeholder') || '';
135
+ const phKey = 'ph.' + (el.id || '');
136
+ if (dict[phKey] != null) el.placeholder = dict[phKey];
137
+ else el.placeholder = el._i18nPhZh;
138
+ return;
139
+ }
140
+
141
+ // 2) <select>:只翻 title,内容交给 renderModelSelect 等渲染函数管理
142
+ if (tag === 'SELECT') {
143
+ if (el._i18nTitleZh === undefined) el._i18nTitleZh = el.getAttribute('title') || '';
144
+ const tiKey = 'title.' + (el.id || '');
145
+ if (dict[tiKey] != null) el.title = dict[tiKey];
146
+ else el.title = el._i18nTitleZh;
147
+ return;
148
+ }
149
+
150
+ // 3) 含元素子节点:保留子元素,只替换 title 与直接文本节点
151
+ if (el.firstElementChild) {
152
+ if (el.hasAttribute('title')) el.title = t(key);
153
+ let done = false;
154
+ el.childNodes.forEach((node) => {
155
+ if (node.nodeType === 3 && node.nodeValue.trim()) {
156
+ node.nodeValue = done ? '' : t(key);
157
+ done = true;
158
+ }
159
+ });
160
+ return;
161
+ }
162
+
163
+ // 4) 纯文本元素:直接覆盖
164
+ el.textContent = t(key);
165
+ }
166
+
167
+ function t(key) {
168
+ const dict = I18N[currentLang] || I18N.zh;
169
+ return dict[key] || key;
170
+ }
@@ -31,6 +31,7 @@ const enterprise_1 = require("../enterprise");
31
31
  const config_1 = require("../shared/config");
32
32
  const memory_1 = require("../memory");
33
33
  const auto_summarize_1 = require("../memory/auto-summarize");
34
+ const secure_store_1 = require("../shared/secure-store");
34
35
  /**
35
36
  * 启动 Web 控制台。
36
37
  * - 端口:opts.port > FH_WEB_PORT > 8080
@@ -57,6 +58,14 @@ function startWebServer(opts = {}) {
57
58
  const sessions = new auth_1.SessionStore();
58
59
  // 当前 Web 控制台工作区,任务提交缺省时使用
59
60
  let serverWorkspaceDir = (0, path_1.resolve)(process.cwd());
61
+ // 任务队列(进程内;服务端静默执行)— 需在登录接口前初始化
62
+ const persistDir = process.env.FH_TASK_PERSIST_DIR?.trim() ||
63
+ (0, path_1.join)(process.env.FH_HOME?.trim() || (0, path_1.join)(require('os').homedir(), '.feihong-code'), 'tasks');
64
+ const queue = new task_queue_1.TaskQueue({
65
+ concurrency: Number(process.env.FH_TASK_CONCURRENCY ?? 2),
66
+ webhookUrl: process.env.FH_TASK_WEBHOOK_URL,
67
+ persistDir,
68
+ });
60
69
  // 公开健康检查(仅暴露版本/状态等观测信息,无敏感数据)
61
70
  app.get('/api/health', (_req, res) => {
62
71
  res.json({
@@ -77,16 +86,36 @@ function startWebServer(opts = {}) {
77
86
  res.status(400).json({ ok: false, error: '请输入手机号码' });
78
87
  return;
79
88
  }
80
- const sessionToken = sessions.create(phone);
81
- res.json({ ok: true, token: sessionToken, phone });
82
- });
83
- // 任务队列(进程内;服务端静默执行)
84
- const persistDir = process.env.FH_TASK_PERSIST_DIR?.trim() ||
85
- (0, path_1.join)(process.env.FH_HOME?.trim() || (0, path_1.join)(require('os').homedir(), '.feihong-code'), 'tasks');
86
- const queue = new task_queue_1.TaskQueue({
87
- concurrency: Number(process.env.FH_TASK_CONCURRENCY ?? 2),
88
- webhookUrl: process.env.FH_TASK_WEBHOOK_URL,
89
- persistDir,
89
+ // 使用新的登录方法,支持首次登录检测
90
+ const result = sessions.login(phone);
91
+ const session = sessions.get(result.token);
92
+ // 如果是首次登录,自动创建引导任务
93
+ let welcomeTasks = [];
94
+ if (result.isFirstLogin) {
95
+ // 标记会话为首次登录(用于后续接口识别)
96
+ if (session) {
97
+ session.isFirstLogin = true;
98
+ }
99
+ // 创建引导任务
100
+ for (const task of auth_1.WELCOME_TASKS) {
101
+ const record = queue.submit(task.goal, {
102
+ workspaceDir: serverWorkspaceDir,
103
+ agentType: 'general',
104
+ });
105
+ welcomeTasks.push({
106
+ ...task,
107
+ taskId: record.id,
108
+ status: record.status,
109
+ });
110
+ }
111
+ }
112
+ res.json({
113
+ ok: true,
114
+ token: result.token,
115
+ phone,
116
+ isFirstLogin: result.isFirstLogin,
117
+ welcomeTasks,
118
+ });
90
119
  });
91
120
  // 鉴权:其余 /api 需 Bearer token(静态资源与 login/health 除外)
92
121
  // 公开 API(无需认证)
@@ -127,7 +156,11 @@ function startWebServer(opts = {}) {
127
156
  app.use('/api', (0, auth_1.requireToken)(token, sessions));
128
157
  app.get('/api/auth/me', (req, res) => {
129
158
  const session = req.user;
130
- res.json({ ok: true, phone: session?.phone ?? null });
159
+ res.json({
160
+ ok: true,
161
+ phone: session?.phone ?? null,
162
+ isFirstLogin: session?.isFirstLogin ?? false,
163
+ });
131
164
  });
132
165
  app.post('/api/tasks', (req, res) => {
133
166
  const body = req.body;
@@ -144,7 +177,11 @@ function startWebServer(opts = {}) {
144
177
  ? body.workspaceDir.trim()
145
178
  : serverWorkspaceDir;
146
179
  const modelId = typeof body?.modelId === 'string' && body.modelId.trim() ? body.modelId.trim() : undefined;
147
- const record = queue.submit(goal, { modelId, workspaceDir, agentType, permissions });
180
+ // 附件:前端暂存区统一上传后的文件路径列表
181
+ const attachments = Array.isArray(body?.attachments)
182
+ ? body.attachments.filter((x) => typeof x === 'string' && x.trim()).map((x) => x.trim())
183
+ : [];
184
+ const record = queue.submit(goal, { modelId, workspaceDir, agentType, permissions, attachments });
148
185
  res.status(201).json({ ok: true, task: publicTask(record, true) });
149
186
  });
150
187
  app.get('/api/tasks', (_req, res) => {
@@ -166,7 +203,11 @@ function startWebServer(opts = {}) {
166
203
  res.status(400).json({ ok: false, error: '缺少 message 字段' });
167
204
  return;
168
205
  }
169
- const record = queue.continueTask(req.params.id, message);
206
+ // 附件:前端暂存区统一上传后的文件路径列表
207
+ const attachments = Array.isArray(body?.attachments)
208
+ ? body.attachments.filter((x) => typeof x === 'string' && x.trim()).map((x) => x.trim())
209
+ : [];
210
+ const record = queue.continueTask(req.params.id, message, attachments);
170
211
  if (!record) {
171
212
  res.status(409).json({ ok: false, error: '任务不存在或正在执行中,请等待完成后再继续对话' });
172
213
  return;
@@ -181,6 +222,20 @@ function startWebServer(opts = {}) {
181
222
  }
182
223
  res.json({ ok: true });
183
224
  });
225
+ // P9:停止单个指定任务(精准中止,不影响其他运行中的任务)
226
+ app.post('/api/tasks/:id/stop', (req, res) => {
227
+ const ok = queue.cancelTask(req.params.id);
228
+ if (!ok) {
229
+ res.status(409).json({ ok: false, error: '任务不存在或已结束,无法停止' });
230
+ return;
231
+ }
232
+ res.json({ ok: true, taskId: req.params.id, status: 'failed' });
233
+ });
234
+ // P9:停止所有任务(中断运行 + 清空队列,后续可继续提交新任务)
235
+ app.post('/api/tasks/stop', (_req, res) => {
236
+ queue.cancel();
237
+ res.json({ ok: true });
238
+ });
184
239
  // P5-2:webhook 注册/查询
185
240
  app.post('/api/webhook', (req, res) => {
186
241
  const body = req.body;
@@ -197,6 +252,9 @@ function startWebServer(opts = {}) {
197
252
  });
198
253
  /* ========== 持久化辅助 ========== */
199
254
  const homeDir = (0, config_1.resolveHomeDir)();
255
+ // 三重加密密钥体系:主密钥(AES 存储加密)+ RSA 密钥对(通信加密)
256
+ const masterKey = (0, secure_store_1.getMasterKey)(homeDir);
257
+ const rsaKeys = (0, secure_store_1.getRsaKeys)(homeDir);
200
258
  function loadJsonFile(file, fallback) {
201
259
  try {
202
260
  if (!(0, fs_1.existsSync)(file))
@@ -207,23 +265,49 @@ function startWebServer(opts = {}) {
207
265
  return fallback;
208
266
  }
209
267
  }
210
- async function saveJsonFile(file, data) {
268
+ /** 同步睡眠(避免引入异步复杂度;用 Atomics.wait 兼容 Node 12+) */
269
+ function sleepSync(ms) {
270
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
271
+ }
272
+ /**
273
+ * 原子写入 JSON 文件(tmp → renameSync)。
274
+ * - 真原子替换:rename 成功即生效,tmp 不会残留(区别于旧的"写 tmp 再写 file 再删 tmp",
275
+ * 旧方案在 Windows 下高频写会偶发 EPERM:Defender/句柄锁住 .tmp 文件导致 open 失败,
276
+ * 且崩溃时 tmp 残留,下次 open 又被锁 → 连锁失败)。
277
+ * - EPERM 瞬时锁:重试 3 次(50ms/100ms/150ms 退避)。
278
+ * - 绝不向上抛:失败返回 false,由调用方决定降级策略(内存态 / 500 响应)。
279
+ */
280
+ function saveJsonFile(file, data) {
211
281
  try {
212
282
  (0, fs_1.mkdirSync)((0, path_1.dirname)(file), { recursive: true });
213
283
  }
214
284
  catch {
215
285
  /* ignore */
216
286
  }
217
- const tmp = file + '.tmp';
218
- (0, fs_1.writeFileSync)(tmp, JSON.stringify(data, null, 2), 'utf8');
219
- // 原子替换,避免并发读写损坏
220
- (0, fs_1.writeFileSync)(file, JSON.stringify(data, null, 2), 'utf8');
221
- try {
222
- require('fs').unlinkSync(tmp);
223
- }
224
- catch {
225
- /* ignore */
287
+ // tmp 文件名带 PID:进程唯一,避免与历史残留(如被外部句柄锁住的 .tmp)或并发进程冲突
288
+ const tmp = file + '.' + process.pid + '.tmp';
289
+ for (let attempt = 0; attempt < 3; attempt++) {
290
+ try {
291
+ (0, fs_1.writeFileSync)(tmp, JSON.stringify(data, null, 2), 'utf8');
292
+ (0, fs_1.renameSync)(tmp, file);
293
+ return true;
294
+ }
295
+ catch (e) {
296
+ // 清理残留 tmp(unlink 也可能 EPERM,忽略)
297
+ try {
298
+ (0, fs_1.unlinkSync)(tmp);
299
+ }
300
+ catch {
301
+ /* ignore */
302
+ }
303
+ if (attempt === 2) {
304
+ console.warn('[fhcode] 配置文件写入失败(已重试 3 次,保持内存态)', file, e?.message);
305
+ return false;
306
+ }
307
+ sleepSync(50 * (attempt + 1));
308
+ }
226
309
  }
310
+ return false;
227
311
  }
228
312
  /* ========== 路径安全 ========== */
229
313
  // 获取系统所有可用的 Windows 驱动器列表(同步,使用 fs.existsSync 检测)
@@ -238,36 +322,18 @@ function startWebServer(opts = {}) {
238
322
  }
239
323
  return drives.length > 0 ? drives : [];
240
324
  }
241
- const allowedRoots = () => {
242
- const roots = [
243
- (0, path_1.resolve)(serverWorkspaceDir),
244
- (0, path_1.resolve)(homeDir),
245
- (0, path_1.resolve)(process.cwd()),
246
- ];
247
- // Windows 上,动态检测所有可用的本地驱动器根目录
248
- if (process.platform === 'win32') {
249
- try {
250
- const child = (0, child_process_1.spawn)('cmd', ['/c', 'wmic logicaldisk get device'], { stdio: ['ignore', 'pipe', 'ignore'] });
251
- let output = '';
252
- child.stdout.on('data', (d) => { output += d.toString(); });
253
- child.on('close', () => {
254
- output.split('\n').forEach((line) => {
255
- const m = line.trim().match(/^([A-Z])[:\\]$/);
256
- if (m)
257
- roots.push(m[1] + ':\\');
258
- });
259
- });
260
- }
261
- catch { }
262
- // 兜底:至少包含 A-Z
263
- for (let code = 65; code <= 90; code++) {
264
- const drive = String.fromCharCode(code) + ':\\';
265
- if (!roots.includes(drive))
266
- roots.push(drive);
267
- }
268
- }
269
- return roots;
270
- };
325
+ // 驱动器根目录只在启动时探测一次并缓存。
326
+ // 历史缺陷:此处曾对每次路径校验 spawn 一个 `cmd /c wmic logicaldisk`,
327
+ // 而 wmic 的 close 回调在函数 return 之后才触发(结果根本用不上),
328
+ // 等于每次浏览目录都白起一个进程;Win11 已移除 wmic,spawn 失败还会拖慢/打断请求。
329
+ // 现改为同步 existsSync 探测 + 进程级缓存,零子进程。
330
+ const driveRootsCache = getAvailableDrives();
331
+ const allowedRoots = () => [
332
+ (0, path_1.resolve)(serverWorkspaceDir),
333
+ (0, path_1.resolve)(homeDir),
334
+ (0, path_1.resolve)(process.cwd()),
335
+ ...driveRootsCache,
336
+ ];
271
337
  function isPathAllowed(target) {
272
338
  const resolved = (0, path_1.resolve)(target);
273
339
  for (const root of allowedRoots()) {
@@ -305,11 +371,14 @@ function startWebServer(opts = {}) {
305
371
  res.json({ ok: true, cwd: serverWorkspaceDir });
306
372
  });
307
373
  app.get('/api/workspace/list', (req, res) => {
308
- const rawPath = typeof req.query.path === 'string' ? req.query.path.trim() : serverWorkspaceDir;
374
+ const raw = typeof req.query.path === 'string' ? req.query.path.trim() : '';
375
+ // path 为空 / '.' 时回落到服务端工作区,避免 resolve('.') 指向进程 cwd 造成困惑
376
+ const rawPath = !raw || raw === '.' ? serverWorkspaceDir : raw;
309
377
  const dir = (0, path_1.resolve)(rawPath);
310
378
  if (!assertPathAllowed(dir, res))
311
379
  return;
312
380
  try {
381
+ // withFileTypes 失败时(部分网络盘/权限目录)退回普通 readdir
313
382
  const names = (0, fs_1.readdirSync)(dir);
314
383
  const entries = names
315
384
  .map((name) => {
@@ -521,11 +590,17 @@ function startWebServer(opts = {}) {
521
590
  try {
522
591
  const dir = (0, path_1.join)(homeDir, 'skills', body.id.replace(/[^a-zA-Z0-9_-]/g, '_'));
523
592
  (0, fs_1.mkdirSync)(dir, { recursive: true });
524
- await saveJsonFile((0, path_1.join)(dir, 'meta.json'), { ...body, installedAt: new Date().toISOString() });
593
+ if (!saveJsonFile((0, path_1.join)(dir, 'meta.json'), { ...body, installedAt: new Date().toISOString() })) {
594
+ res.status(500).json({ ok: false, error: '技能元数据写入失败' });
595
+ return;
596
+ }
525
597
  const installed = loadJsonFile(installedSkillsFile, []);
526
598
  if (!installed.find((s) => s.id === body.id)) {
527
599
  installed.push(body);
528
- await saveJsonFile(installedSkillsFile, installed);
600
+ if (!saveJsonFile(installedSkillsFile, installed)) {
601
+ res.status(500).json({ ok: false, error: '已安装列表写入失败' });
602
+ return;
603
+ }
529
604
  }
530
605
  res.json({ ok: true, message: `技能「${body.name}」已记录为已安装` });
531
606
  }
@@ -561,7 +636,10 @@ function startWebServer(opts = {}) {
561
636
  runCount: 0,
562
637
  };
563
638
  list.push(rule);
564
- await saveJsonFile(automationsFile, list);
639
+ if (!saveJsonFile(automationsFile, list)) {
640
+ res.status(500).json({ ok: false, error: '指令保存失败(磁盘不可写)' });
641
+ return;
642
+ }
565
643
  res.status(201).json({ ok: true, automation: rule });
566
644
  });
567
645
  // 注:当前 @types/express 版本缺失 delete 方法类型声明,运行时 Express 完全支持,故局部断言
@@ -572,7 +650,10 @@ function startWebServer(opts = {}) {
572
650
  res.status(404).json({ ok: false, error: '指令不存在' });
573
651
  return;
574
652
  }
575
- await saveJsonFile(automationsFile, next);
653
+ if (!saveJsonFile(automationsFile, next)) {
654
+ res.status(500).json({ ok: false, error: '指令删除失败(磁盘不可写)' });
655
+ return;
656
+ }
576
657
  res.json({ ok: true });
577
658
  });
578
659
  app.post('/api/automations/:id/run', async (req, res) => {
@@ -588,8 +669,14 @@ function startWebServer(opts = {}) {
588
669
  });
589
670
  rule.runCount += 1;
590
671
  rule.lastRunAt = new Date().toISOString();
591
- await saveJsonFile(automationsFile, list);
592
- res.status(201).json({ ok: true, task: publicTask(record, true), runCount: rule.runCount });
672
+ // 任务已入队,runCount 持久化失败不阻塞响应(带 persistWarning 提示)
673
+ const persistWarning = !saveJsonFile(automationsFile, list);
674
+ res.status(201).json({
675
+ ok: true,
676
+ task: publicTask(record, true),
677
+ runCount: rule.runCount,
678
+ persistWarning: persistWarning || undefined,
679
+ });
593
680
  });
594
681
  const BUILTIN_TEMPLATES = [
595
682
  {
@@ -672,7 +759,10 @@ function startWebServer(opts = {}) {
672
759
  builtin: false,
673
760
  };
674
761
  list.push(tpl);
675
- await saveJsonFile(templatesFile, list);
762
+ if (!saveJsonFile(templatesFile, list)) {
763
+ res.status(500).json({ ok: false, error: '模板保存失败(磁盘不可写)' });
764
+ return;
765
+ }
676
766
  res.status(201).json({ ok: true, template: tpl });
677
767
  });
678
768
  app.delete('/api/templates/:id', async (req, res) => {
@@ -682,7 +772,10 @@ function startWebServer(opts = {}) {
682
772
  res.status(404).json({ ok: false, error: '模板不存在或不可删除' });
683
773
  return;
684
774
  }
685
- await saveJsonFile(templatesFile, next);
775
+ if (!saveJsonFile(templatesFile, next)) {
776
+ res.status(500).json({ ok: false, error: '模板删除失败(磁盘不可写)' });
777
+ return;
778
+ }
686
779
  res.json({ ok: true });
687
780
  });
688
781
  /* ========== 办公助理:内置能力清单 ========== */
@@ -735,29 +828,64 @@ function startWebServer(opts = {}) {
735
828
  ],
736
829
  });
737
830
  });
738
- /* ========== 大模型配置 ========== */
831
+ /* ========== 大模型配置(三重加密:apiKey AES-256-GCM 落盘加密 + RSA 加密传输) ========== */
739
832
  const modelsFile = (0, path_1.join)(homeDir, 'models.json');
740
833
  function loadModels() {
741
834
  const list = loadJsonFile(modelsFile, []);
742
835
  return Array.isArray(list) ? list : [];
743
836
  }
837
+ /** 解析真实(解密)apiKey,供任务执行器使用 */
838
+ function resolveApiKey(m) {
839
+ if (!m.apiKey)
840
+ return '';
841
+ return (0, secure_store_1.isEncrypted)(m.apiKey) ? (0, secure_store_1.decryptText)(m.apiKey, masterKey) : m.apiKey;
842
+ }
843
+ /** 对外脱敏视图:任何接口都不返回密钥(明文或密文) */
844
+ function publicModel(m) {
845
+ return { ...m, apiKey: '' };
846
+ }
744
847
  function saveModels(list) {
745
- saveJsonFile(modelsFile, list);
746
- // 同步更新任务队列的模型配置,使后续任务使用最新配置
747
- queue.setModelProviders(list.map(m => ({ id: m.id, type: 'openai-compatible', baseURL: m.apiBase, apiKey: m.apiKey, model: m.name })));
848
+ // 明文 key 加密后再落盘(已加密的跳过,避免二次加密)
849
+ const encList = list.map((m) => m.apiKey && !(0, secure_store_1.isEncrypted)(m.apiKey) ? { ...m, apiKey: (0, secure_store_1.encryptText)(m.apiKey, masterKey) } : m);
850
+ const ok = saveJsonFile(modelsFile, encList);
851
+ // 同步更新任务队列的模型配置,使用解密后的真实 key(即使落盘失败也更新内存态)
852
+ queue.setModelProviders(encList.map((m) => ({
853
+ id: m.id,
854
+ type: 'openai-compatible',
855
+ baseURL: m.apiBase,
856
+ apiKey: resolveApiKey(m),
857
+ model: m.name,
858
+ })));
859
+ return ok;
748
860
  }
749
861
  // 初始化模型提供列表,并注册到任务队列
750
862
  const initialModels = loadModels();
751
- queue.setModelProviders(initialModels.map(m => ({ id: m.id, type: 'openai-compatible', baseURL: m.apiBase, apiKey: m.apiKey, model: m.name })));
863
+ queue.setModelProviders(initialModels.map((m) => ({
864
+ id: m.id,
865
+ type: 'openai-compatible',
866
+ baseURL: m.apiBase,
867
+ apiKey: resolveApiKey(m),
868
+ model: m.name,
869
+ })));
870
+ // 第二重(通信层):向客户端下发 RSA 公钥,用于加密敏感参数(如模型 API Key)传输
871
+ app.get('/api/security/public-key', (_req, res) => {
872
+ res.json({ ok: true, publicKey: rsaKeys.publicKey, algorithm: 'RSA-OAEP-2048-SHA256' });
873
+ });
752
874
  app.get('/api/models', (_req, res) => {
753
- const list = loadModels();
875
+ const list = loadModels().map(publicModel);
754
876
  res.json({ ok: true, models: list, defaultId: (list.find((m) => m.default) || {}).id || null });
755
877
  });
756
878
  app.post('/api/models', (req, res) => {
757
879
  const body = req.body;
758
880
  const name = typeof body?.name === 'string' ? body.name.trim() : '';
759
881
  const apiBase = typeof body?.apiBase === 'string' ? body.apiBase.trim() : '';
760
- const apiKey = typeof body?.apiKey === 'string' ? body.apiKey : '';
882
+ let apiKey = typeof body?.apiKey === 'string' ? body.apiKey : '';
883
+ // 支持 RSA 公钥加密传输的密钥(App 端使用,防窃听/防中间人截取明文 Key)
884
+ if (typeof body?.apiKeyEnc === 'string' && body.apiKeyEnc) {
885
+ const dec = (0, secure_store_1.rsaDecrypt)(body.apiKeyEnc, rsaKeys.privateKey);
886
+ if (dec)
887
+ apiKey = dec;
888
+ }
761
889
  const reasoning = typeof body?.reasoning === 'string' ? body.reasoning : '';
762
890
  if (!name) {
763
891
  res.status(400).json({ ok: false, error: '请填写模型名称' });
@@ -766,13 +894,26 @@ function startWebServer(opts = {}) {
766
894
  const list = loadModels();
767
895
  const id = typeof body?.id === 'string' && body.id.trim() ? body.id.trim() : (0, crypto_1.randomUUID)();
768
896
  const idx = list.findIndex((m) => m.id === id);
769
- const cfg = { id, name, apiBase, apiKey, reasoning };
770
- if (idx >= 0)
897
+ let saved;
898
+ if (idx >= 0) {
899
+ // 编辑场景:未提供新 key 则保留原密钥(前端不再回填明文)
900
+ const cfg = {
901
+ id, name, apiBase,
902
+ apiKey: apiKey || list[idx].apiKey,
903
+ reasoning,
904
+ };
771
905
  list[idx] = { ...list[idx], ...cfg };
772
- else
773
- list.push(cfg);
774
- saveModels(list);
775
- res.json({ ok: true, model: cfg });
906
+ saved = list[idx];
907
+ }
908
+ else {
909
+ saved = { id, name, apiBase, apiKey, reasoning };
910
+ list.push(saved);
911
+ }
912
+ if (!saveModels(list)) {
913
+ res.status(500).json({ ok: false, error: '模型配置保存失败(磁盘不可写,已保留内存态)' });
914
+ return;
915
+ }
916
+ res.json({ ok: true, model: publicModel(saved) });
776
917
  });
777
918
  app.delete('/api/models/:id', (req, res) => {
778
919
  let list = loadModels();
@@ -781,7 +922,10 @@ function startWebServer(opts = {}) {
781
922
  // 若删除的是默认,且没有其它默认,则把第一个设为默认
782
923
  if (target?.default && !list.some((m) => m.default) && list.length)
783
924
  list[0].default = true;
784
- saveModels(list);
925
+ if (!saveModels(list)) {
926
+ res.status(500).json({ ok: false, error: '模型配置保存失败(磁盘不可写,已保留内存态)' });
927
+ return;
928
+ }
785
929
  res.json({ ok: true });
786
930
  });
787
931
  app.post('/api/models/:id/default', (req, res) => {
@@ -792,7 +936,10 @@ function startWebServer(opts = {}) {
792
936
  return;
793
937
  }
794
938
  list.forEach((m) => (m.default = m.id === req.params.id));
795
- saveModels(list);
939
+ if (!saveModels(list)) {
940
+ res.status(500).json({ ok: false, error: '模型配置保存失败(磁盘不可写,已保留内存态)' });
941
+ return;
942
+ }
796
943
  res.json({ ok: true, defaultId: target.id });
797
944
  });
798
945
  const server = app.listen(port, () => {