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.
- package/CHANGELOG.md +465 -405
- package/README.md +59 -0
- package/dist/agent/code-writer.js +6 -3
- package/dist/agent/code-writer.js.map +1 -1
- package/dist/agent/experience.js +105 -70
- package/dist/agent/experience.js.map +1 -1
- package/dist/agent/orchestrator.js +103 -21
- package/dist/agent/orchestrator.js.map +1 -1
- package/dist/agent/planner.js +30 -7
- package/dist/agent/planner.js.map +1 -1
- package/dist/agent/prompts.js +9 -0
- package/dist/agent/prompts.js.map +1 -1
- package/dist/agent/self-heal.js +43 -13
- package/dist/agent/self-heal.js.map +1 -1
- package/dist/cli/run.js +13 -14
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/version.js +1 -1
- package/dist/enterprise/audit.js +70 -11
- package/dist/enterprise/audit.js.map +1 -1
- package/dist/models/model-router.js +83 -23
- package/dist/models/model-router.js.map +1 -1
- package/dist/models/model.dto.js +25 -3
- package/dist/models/model.dto.js.map +1 -1
- package/dist/models/providers/ollama.provider.js +12 -0
- package/dist/models/providers/ollama.provider.js.map +1 -1
- package/dist/models/providers/openai-compatible.provider.js +14 -1
- package/dist/models/providers/openai-compatible.provider.js.map +1 -1
- package/dist/self-evolve/hook.js +2 -2
- package/dist/self-evolve/hook.js.map +1 -1
- package/dist/self-evolve/hook.ts +80 -0
- package/dist/self-evolve/manager.d.ts +8 -0
- package/dist/self-evolve/manager.js +401 -0
- package/dist/shared/errors.js +6 -2
- package/dist/shared/errors.js.map +1 -1
- package/dist/shared/secure-store.js +117 -0
- package/dist/shared/secure-store.js.map +1 -0
- package/dist/tools/mcp/mcp-client.js +35 -13
- package/dist/tools/mcp/mcp-client.js.map +1 -1
- package/dist/tools/shell/exec.js +47 -3
- package/dist/tools/shell/exec.js.map +1 -1
- package/dist/tools/shell/run-shell.tool.js +33 -6
- package/dist/tools/shell/run-shell.tool.js.map +1 -1
- package/dist/tools/tool.registry.js +32 -0
- package/dist/tools/tool.registry.js.map +1 -1
- package/dist/web/auth.js +143 -7
- package/dist/web/auth.js.map +1 -1
- package/dist/web/public/css/style.css +1546 -0
- package/dist/web/public/index.html +806 -3253
- package/dist/web/public/js/api.js +309 -0
- package/dist/web/public/js/app.js +1472 -0
- package/dist/web/public/js/ui.js +832 -0
- package/dist/web/public/js/utils.js +170 -0
- package/dist/web/server.js +224 -77
- package/dist/web/server.js.map +1 -1
- package/dist/web/task-queue.js +128 -10
- package/dist/web/task-queue.js.map +1 -1
- package/docs/App/344/275/277/347/224/250/350/257/264/346/230/216/344/271/246.md +299 -0
- package/docs/App/346/212/200/346/234/257/350/257/264/346/230/216/344/271/246.md +554 -0
- package/docs/SELF-EVOLVE-GUIDE.md +313 -0
- package/docs/error-codes.md +198 -0
- package/docs/screenshots/cli-demo.png +0 -0
- package/docs/screenshots/feature-comparison.png +0 -0
- package/docs/screenshots/web-console.png +0 -0
- 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
- package/package.json +9 -15
- package/tool-schema.json +198 -127
- package/dist/web/public/index.html.tmp +0 -3117
- 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, '<')
|
|
10
|
+
.replace(/>/g, '>')
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
+
}
|
package/dist/web/server.js
CHANGED
|
@@ -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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
592
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
746
|
-
|
|
747
|
-
|
|
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 => ({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
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, () => {
|