feihong-code 0.2.3 → 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/.github/FUNDING.yml +4 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +37 -37
- package/.github/ISSUE_TEMPLATE/config.yml +14 -14
- package/.github/ISSUE_TEMPLATE/feature_request.md +28 -28
- package/.github/PULL_REQUEST_TEMPLATE.md +46 -46
- package/.github/SECURITY.md +67 -67
- package/.github/dependabot.yml +16 -0
- package/.github/workflows/ci.yml +1 -1
- package/AGENT-GUIDE.md +382 -237
- package/CHANGELOG.md +392 -0
- package/CODE_OF_CONDUCT.md +56 -56
- package/CONTRIBUTING.md +68 -68
- package/LICENSE +22 -22
- package/README.md +586 -522
- package/README.self-evolve.md +274 -0
- package/dist/agent/code-review.js +2 -0
- package/dist/agent/code-review.js.map +1 -1
- package/dist/agent/code-writer.js +74 -18
- package/dist/agent/code-writer.js.map +1 -1
- package/dist/agent/context-compactor.js +13 -4
- package/dist/agent/context-compactor.js.map +1 -1
- package/dist/agent/experience.js +319 -84
- package/dist/agent/experience.js.map +1 -1
- package/dist/agent/orchestrator.js +233 -71
- 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/quality-gate.js +10 -4
- package/dist/agent/quality-gate.js.map +1 -1
- package/dist/agent/repo-context.js +181 -0
- package/dist/agent/repo-context.js.map +1 -0
- package/dist/agent/repo-reader.js +92 -99
- package/dist/agent/repo-reader.js.map +1 -1
- package/dist/agent/self-heal.js +80 -60
- package/dist/agent/self-heal.js.map +1 -1
- package/dist/agent/self-improver.js +65 -61
- package/dist/agent/self-improver.js.map +1 -1
- package/dist/agent/subagent-summary.js +31 -0
- package/dist/agent/subagent-summary.js.map +1 -0
- package/dist/agent/subagent.js +52 -2
- package/dist/agent/subagent.js.map +1 -1
- package/dist/agent/symbol-index.js +160 -0
- package/dist/agent/symbol-index.js.map +1 -0
- package/dist/agent/team.js +193 -0
- package/dist/agent/team.js.map +1 -0
- package/dist/cli/commands.js +124 -143
- package/dist/cli/commands.js.map +1 -1
- package/dist/cli/index.js +113 -106
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/repl.js +82 -7
- package/dist/cli/repl.js.map +1 -1
- package/dist/cli/run.js +600 -95
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/tui.js +145 -0
- package/dist/cli/tui.js.map +1 -0
- package/dist/cli/version.js +1 -1
- package/dist/enterprise/audit.js +145 -23
- package/dist/enterprise/audit.js.map +1 -1
- package/dist/enterprise/index.js +14 -11
- package/dist/enterprise/index.js.map +1 -1
- package/dist/enterprise/policy.js +22 -10
- package/dist/enterprise/policy.js.map +1 -1
- package/dist/harness/executor.js +127 -0
- package/dist/harness/executor.js.map +1 -0
- package/dist/harness/harness.js +87 -0
- package/dist/harness/harness.js.map +1 -0
- package/dist/harness/index.js +31 -0
- package/dist/harness/index.js.map +1 -0
- package/dist/harness/loader.js +138 -0
- package/dist/harness/loader.js.map +1 -0
- package/dist/harness/reporter.js +34 -0
- package/dist/harness/reporter.js.map +1 -0
- package/dist/harness/types.js +10 -0
- package/dist/harness/types.js.map +1 -0
- package/dist/harness/verifier.js +48 -0
- package/dist/harness/verifier.js.map +1 -0
- package/dist/hello.js +14 -0
- package/dist/hello.js.map +1 -0
- package/dist/memory/auto-summarize.js +208 -0
- package/dist/memory/auto-summarize.js.map +1 -0
- package/dist/memory/index.js +228 -0
- package/dist/memory/index.js.map +1 -0
- package/dist/models/model-router.js +100 -27
- 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/plugins/plugin-loader.js +179 -0
- package/dist/plugins/plugin-loader.js.map +1 -0
- package/dist/runtime/event-log.js.map +1 -1
- package/dist/runtime/hooks.js +80 -0
- package/dist/runtime/hooks.js.map +1 -0
- package/dist/self-evolve/hook.js +60 -0
- package/dist/self-evolve/hook.js.map +1 -0
- 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/config.js +35 -3
- package/dist/shared/config.js.map +1 -1
- package/dist/shared/errors.js +6 -2
- package/dist/shared/errors.js.map +1 -1
- package/dist/shared/i18n.js +535 -0
- package/dist/shared/i18n.js.map +1 -0
- package/dist/shared/secure-store.js +117 -0
- package/dist/shared/secure-store.js.map +1 -0
- package/dist/skills/grill.js +2 -1
- package/dist/skills/grill.js.map +1 -1
- package/dist/skills/self-heal.js +73 -0
- package/dist/skills/self-heal.js.map +1 -0
- package/dist/skills/skill-loader.js +131 -0
- package/dist/skills/skill-loader.js.map +1 -0
- package/dist/skills/skill-market.js +195 -0
- package/dist/skills/skill-market.js.map +1 -0
- package/dist/tools/analysis/code-analyzer.js +45 -18
- package/dist/tools/analysis/code-analyzer.js.map +1 -1
- package/dist/tools/index.js +5 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/mcp/index.js +84 -0
- package/dist/tools/mcp/index.js.map +1 -0
- package/dist/tools/mcp/mcp-client.js +194 -0
- package/dist/tools/mcp/mcp-client.js.map +1 -0
- package/dist/tools/sandbox.js +126 -0
- package/dist/tools/sandbox.js.map +1 -0
- package/dist/tools/shell/exec.js +72 -3
- package/dist/tools/shell/exec.js.map +1 -1
- package/dist/tools/shell/run-shell.tool.js +37 -7
- package/dist/tools/shell/run-shell.tool.js.map +1 -1
- package/dist/tools/skills/load-skill.tool.js +36 -0
- package/dist/tools/skills/load-skill.tool.js.map +1 -0
- package/dist/tools/tool.interface.js.map +1 -1
- package/dist/tools/tool.registry.js +69 -1
- package/dist/tools/tool.registry.js.map +1 -1
- package/dist/tools/web/web.tool.js +139 -0
- package/dist/tools/web/web.tool.js.map +1 -0
- package/dist/web/auth.js +178 -3
- package/dist/web/auth.js.map +1 -1
- package/dist/web/channels.js +171 -0
- package/dist/web/channels.js.map +1 -0
- package/dist/web/public/css/style.css +1546 -0
- package/dist/web/public/index.html +804 -37
- 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 +937 -11
- package/dist/web/server.js.map +1 -1
- package/dist/web/task-queue.js +470 -0
- package/dist/web/task-queue.js.map +1 -0
- package/dist/web/web-config.js +143 -0
- package/dist/web/web-config.js.map +1 -0
- 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/Deployment_Guide_EN.md +288 -0
- package/docs/SELF-EVOLVE-GUIDE.md +313 -0
- package/docs/Technical_Manual_EN.md +216 -0
- package/docs/User_Manual_EN.md +314 -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/self-evolve-implementation.md +165 -0
- package/docs/self-evolve.md +166 -0
- package/docs//344/272/247/345/223/201/345/274/200/345/217/221/346/226/207/346/241/243.md +614 -614
- package/docs//344/274/201/344/270/232/351/203/250/347/275/262/344/270/216/345/220/210/350/247/204.md +267 -267
- package/docs//344/275/277/347/224/250/350/257/264/346/230/216/344/271/246.md +318 -358
- package/docs//345/270/270/350/247/201/351/227/256/351/242/230/344/270/216/346/225/205/351/232/234/346/216/222/346/237/245.md +130 -130
- package/docs//346/212/200/346/234/257/350/257/264/346/230/216/344/271/246.md +216 -303
- package/docs//346/236/266/346/236/204/344/270/216API.md +238 -238
- package/docs//347/224/250/346/210/267/346/211/213/345/206/214.md +196 -196
- package/docs//351/203/250/347/275/262/346/214/207/345/215/227.md +165 -165
- package/docs//351/203/250/347/275/262/350/257/264/346/230/216/344/271/246.md +288 -0
- package/docs//351/205/215/347/275/256/345/217/202/350/200/203.md +127 -127
- 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 +108 -109
- package/tool-schema.json +117 -46
package/dist/web/server.js
CHANGED
|
@@ -8,35 +8,65 @@ exports.startWebServer = startWebServer;
|
|
|
8
8
|
* 飞虹 Code (Muse Code 参照复刻)
|
|
9
9
|
* 晋江市飞虹智科技企业管理有限公司 · 飞扬企源研发中心 · 负责人:吴赐虹
|
|
10
10
|
*
|
|
11
|
-
* M5 Web 控制台:自包含 Express
|
|
12
|
-
*
|
|
11
|
+
* M5 Web 控制台:自包含 Express 服务骨架 + 多视图 API。
|
|
12
|
+
* - 任务队列(P4-1 / P5-2 / P6-4)
|
|
13
|
+
* - 技能市场(插件市场):聚合 ClawHub + Agent-Foundry
|
|
14
|
+
* - 自动化:快捷指令集(一键发起任务)
|
|
15
|
+
* - 模板库:内置 + 用户自定义
|
|
16
|
+
* - 办公助理:文档处理能力清单
|
|
17
|
+
* - 登录:手机号直登(无短信验证,本地会话令牌)
|
|
18
|
+
* - 文件/工作区:右侧任务详情可直接打开文件夹、浏览器、预览文件
|
|
19
|
+
* 鉴权:Bearer Token(fail-closed),主令牌(FH_WEB_TOKEN)与会话令牌并行。
|
|
13
20
|
*/
|
|
14
21
|
const express_1 = __importDefault(require("express"));
|
|
15
22
|
const crypto_1 = require("crypto");
|
|
16
23
|
const path_1 = require("path");
|
|
24
|
+
const fs_1 = require("fs");
|
|
25
|
+
const child_process_1 = require("child_process");
|
|
17
26
|
const auth_1 = require("./auth");
|
|
27
|
+
const task_queue_1 = require("./task-queue");
|
|
18
28
|
const version_1 = require("../cli/version");
|
|
29
|
+
const i18n_1 = require("../shared/i18n");
|
|
19
30
|
const enterprise_1 = require("../enterprise");
|
|
31
|
+
const config_1 = require("../shared/config");
|
|
32
|
+
const memory_1 = require("../memory");
|
|
33
|
+
const auto_summarize_1 = require("../memory/auto-summarize");
|
|
34
|
+
const secure_store_1 = require("../shared/secure-store");
|
|
20
35
|
/**
|
|
21
36
|
* 启动 Web 控制台。
|
|
22
37
|
* - 端口:opts.port > FH_WEB_PORT > 8080
|
|
23
38
|
* - 令牌:opts.token > FH_WEB_TOKEN > 自动生成(仅本次会话有效,fail-closed)
|
|
24
39
|
*/
|
|
25
40
|
function startWebServer(opts = {}) {
|
|
41
|
+
// Web 环境无法进行交互式审批,禁用审批检查
|
|
42
|
+
if (process.env.FH_REQUIRE_APPROVAL === undefined) {
|
|
43
|
+
process.env.FH_REQUIRE_APPROVAL = 'false';
|
|
44
|
+
}
|
|
26
45
|
const port = opts.port ?? Number(process.env.FH_WEB_PORT ?? 8080);
|
|
27
46
|
let token = opts.token ?? process.env.FH_WEB_TOKEN ?? '';
|
|
28
47
|
if (!token) {
|
|
29
48
|
token = (0, crypto_1.randomBytes)(24).toString('hex');
|
|
30
|
-
console.log(
|
|
49
|
+
console.log((0, i18n_1.t)('serve.tokenAuto', { token }));
|
|
31
50
|
}
|
|
32
51
|
const app = (0, express_1.default)();
|
|
33
|
-
//
|
|
34
|
-
app.use(express_1.default.json({ limit: '
|
|
35
|
-
//
|
|
36
|
-
const publicDir = (0, path_1.join)(__dirname, 'public');
|
|
37
|
-
app.use(express_1.default.static(publicDir));
|
|
38
|
-
//
|
|
39
|
-
|
|
52
|
+
// 放宽请求体上限以支持截图/图片 base64 上传(8MB),仍可有效防御内存耗尽
|
|
53
|
+
app.use(express_1.default.json({ limit: '8mb' }));
|
|
54
|
+
// 静态仪表盘(开发时直接从 src 读取,避免 dist 被锁)
|
|
55
|
+
const publicDir = process.env.FH_WEB_SRC_PUBLIC || (0, path_1.join)(__dirname, 'public');
|
|
56
|
+
app.use(express_1.default.static(publicDir, { maxAge: '0' }));
|
|
57
|
+
// 登录会话存储(进程内)
|
|
58
|
+
const sessions = new auth_1.SessionStore();
|
|
59
|
+
// 当前 Web 控制台工作区,任务提交缺省时使用
|
|
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
|
+
});
|
|
69
|
+
// 公开健康检查(仅暴露版本/状态等观测信息,无敏感数据)
|
|
40
70
|
app.get('/api/health', (_req, res) => {
|
|
41
71
|
res.json({
|
|
42
72
|
ok: true,
|
|
@@ -44,11 +74,876 @@ function startWebServer(opts = {}) {
|
|
|
44
74
|
version: version_1.VERSION,
|
|
45
75
|
signature: version_1.SIGNATURE,
|
|
46
76
|
enterprise: (0, enterprise_1.isEnterpriseEnabled)(),
|
|
77
|
+
lang: (0, i18n_1.getLang)(),
|
|
47
78
|
time: new Date().toISOString(),
|
|
48
79
|
});
|
|
49
80
|
});
|
|
81
|
+
// 手机号直登:无短信验证,生成本地会话令牌
|
|
82
|
+
app.post('/api/auth/login', (req, res) => {
|
|
83
|
+
const body = (req.body ?? {});
|
|
84
|
+
const phone = typeof body?.phone === 'string' ? body.phone.trim() : '';
|
|
85
|
+
if (!phone) {
|
|
86
|
+
res.status(400).json({ ok: false, error: '请输入手机号码' });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
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
|
+
});
|
|
119
|
+
});
|
|
120
|
+
// 鉴权:其余 /api 需 Bearer token(静态资源与 login/health 除外)
|
|
121
|
+
// 公开 API(无需认证)
|
|
122
|
+
app.get('/api/drives', (_req, res) => {
|
|
123
|
+
res.json({ ok: true, drives: getAvailableDrives() });
|
|
124
|
+
});
|
|
125
|
+
// 记忆管理 API(公开访问,无敏感信息)
|
|
126
|
+
const memoryConfig = (0, memory_1.getMemoryConfig)();
|
|
127
|
+
app.get('/api/memory/short', (_req, res) => {
|
|
128
|
+
const date = typeof _req.query.date === 'string' ? _req.query.date : undefined;
|
|
129
|
+
const content = (0, memory_1.readShortTerm)(memoryConfig, date ? new Date(date) : undefined);
|
|
130
|
+
res.json({ ok: true, content, date: date || new Date().toISOString().split('T')[0] });
|
|
131
|
+
});
|
|
132
|
+
app.get('/api/memory/long', (_req, res) => {
|
|
133
|
+
const content = (0, memory_1.readLongTerm)(memoryConfig);
|
|
134
|
+
res.json({ ok: true, content });
|
|
135
|
+
});
|
|
136
|
+
app.get('/api/memory/stats', (_req, res) => {
|
|
137
|
+
const stats = (0, memory_1.getMemoryStats)(memoryConfig);
|
|
138
|
+
res.json({ ok: true, ...stats });
|
|
139
|
+
});
|
|
140
|
+
app.post('/api/memory/summarize', async (req, res) => {
|
|
141
|
+
const body = req.body;
|
|
142
|
+
const forceDate = typeof body?.date === 'string' ? body.date : undefined;
|
|
143
|
+
try {
|
|
144
|
+
const result = await (0, auto_summarize_1.summarizeMemory)(memoryConfig, forceDate);
|
|
145
|
+
res.json(result);
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
res.status(500).json({ ok: false, error: e.message });
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
app.get('/api/memory/history', (_req, res) => {
|
|
152
|
+
const limit = typeof _req.query.limit === 'string' ? parseInt(_req.query.limit) || 30 : 30;
|
|
153
|
+
const history = (0, auto_summarize_1.getSummaryHistory)(memoryConfig, limit);
|
|
154
|
+
res.json({ ok: true, history });
|
|
155
|
+
});
|
|
156
|
+
app.use('/api', (0, auth_1.requireToken)(token, sessions));
|
|
157
|
+
app.get('/api/auth/me', (req, res) => {
|
|
158
|
+
const session = req.user;
|
|
159
|
+
res.json({
|
|
160
|
+
ok: true,
|
|
161
|
+
phone: session?.phone ?? null,
|
|
162
|
+
isFirstLogin: session?.isFirstLogin ?? false,
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
app.post('/api/tasks', (req, res) => {
|
|
166
|
+
const body = req.body;
|
|
167
|
+
const goal = typeof body?.goal === 'string' ? body.goal.trim() : '';
|
|
168
|
+
if (!goal) {
|
|
169
|
+
res.status(400).json({ ok: false, error: '缺少 goal 字段' });
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const agentType = typeof body?.agentType === 'string' ? body.agentType : undefined;
|
|
173
|
+
const permissions = typeof body?.permissions === 'object' && body?.permissions !== null
|
|
174
|
+
? body.permissions
|
|
175
|
+
: undefined;
|
|
176
|
+
const workspaceDir = typeof body?.workspaceDir === 'string' && body.workspaceDir.trim()
|
|
177
|
+
? body.workspaceDir.trim()
|
|
178
|
+
: serverWorkspaceDir;
|
|
179
|
+
const modelId = typeof body?.modelId === 'string' && body.modelId.trim() ? body.modelId.trim() : undefined;
|
|
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 });
|
|
185
|
+
res.status(201).json({ ok: true, task: publicTask(record, true) });
|
|
186
|
+
});
|
|
187
|
+
app.get('/api/tasks', (_req, res) => {
|
|
188
|
+
res.json({ ok: true, tasks: queue.list().map((t) => publicTask(t, false)) });
|
|
189
|
+
});
|
|
190
|
+
app.get('/api/tasks/:id', (req, res) => {
|
|
191
|
+
const record = queue.get(req.params.id);
|
|
192
|
+
if (!record) {
|
|
193
|
+
res.status(404).json({ ok: false, error: '任务不存在' });
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
res.json({ ok: true, task: publicTask(record, true) });
|
|
197
|
+
});
|
|
198
|
+
// 多轮续接:向当前任务追加一条用户消息,所有对话归属同一任务生命周期
|
|
199
|
+
app.post('/api/tasks/:id/messages', (req, res) => {
|
|
200
|
+
const body = req.body;
|
|
201
|
+
const message = typeof body?.message === 'string' ? body.message.trim() : '';
|
|
202
|
+
if (!message) {
|
|
203
|
+
res.status(400).json({ ok: false, error: '缺少 message 字段' });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
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);
|
|
211
|
+
if (!record) {
|
|
212
|
+
res.status(409).json({ ok: false, error: '任务不存在或正在执行中,请等待完成后再继续对话' });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
res.status(201).json({ ok: true, task: publicTask(record, true) });
|
|
216
|
+
});
|
|
217
|
+
app.delete('/api/tasks/:id', (req, res) => {
|
|
218
|
+
const success = queue.delete(req.params.id);
|
|
219
|
+
if (!success) {
|
|
220
|
+
res.status(400).json({ ok: false, error: '无法删除运行中的任务' });
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
res.json({ ok: true });
|
|
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
|
+
});
|
|
239
|
+
// P5-2:webhook 注册/查询
|
|
240
|
+
app.post('/api/webhook', (req, res) => {
|
|
241
|
+
const body = req.body;
|
|
242
|
+
const url = typeof body?.url === 'string' ? body.url.trim() : '';
|
|
243
|
+
if (!url) {
|
|
244
|
+
res.status(400).json({ ok: false, error: '缺少 url 字段' });
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
queue.setWebhookUrl(url);
|
|
248
|
+
res.json({ ok: true, webhookUrl: url });
|
|
249
|
+
});
|
|
250
|
+
app.get('/api/webhook', (_req, res) => {
|
|
251
|
+
res.json({ ok: true, webhookUrl: queue.getWebhookUrl() || null });
|
|
252
|
+
});
|
|
253
|
+
/* ========== 持久化辅助 ========== */
|
|
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);
|
|
258
|
+
function loadJsonFile(file, fallback) {
|
|
259
|
+
try {
|
|
260
|
+
if (!(0, fs_1.existsSync)(file))
|
|
261
|
+
return fallback;
|
|
262
|
+
return JSON.parse((0, fs_1.readFileSync)(file, 'utf8'));
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return fallback;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
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) {
|
|
281
|
+
try {
|
|
282
|
+
(0, fs_1.mkdirSync)((0, path_1.dirname)(file), { recursive: true });
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
/* ignore */
|
|
286
|
+
}
|
|
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
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
/* ========== 路径安全 ========== */
|
|
313
|
+
// 获取系统所有可用的 Windows 驱动器列表(同步,使用 fs.existsSync 检测)
|
|
314
|
+
function getAvailableDrives() {
|
|
315
|
+
if (process.platform !== 'win32')
|
|
316
|
+
return ['/'];
|
|
317
|
+
const drives = [];
|
|
318
|
+
for (let code = 65; code <= 90; code++) {
|
|
319
|
+
const drive = String.fromCharCode(code) + ':\\';
|
|
320
|
+
if ((0, fs_1.existsSync)(drive))
|
|
321
|
+
drives.push(drive);
|
|
322
|
+
}
|
|
323
|
+
return drives.length > 0 ? drives : [];
|
|
324
|
+
}
|
|
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
|
+
];
|
|
337
|
+
function isPathAllowed(target) {
|
|
338
|
+
const resolved = (0, path_1.resolve)(target);
|
|
339
|
+
for (const root of allowedRoots()) {
|
|
340
|
+
const rel = (0, path_1.relative)(root, resolved);
|
|
341
|
+
if (rel === '' || (!rel.startsWith('..') && !(0, path_1.isAbsolute)(rel))) {
|
|
342
|
+
return (0, fs_1.existsSync)(resolved);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
function assertPathAllowed(target, res) {
|
|
348
|
+
if (!isPathAllowed(target)) {
|
|
349
|
+
res.status(403).json({ ok: false, error: '路径不在允许范围内或不存在' });
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
/* ========== 工作区与文件浏览 ========== */
|
|
355
|
+
app.get('/api/workspace', (_req, res) => {
|
|
356
|
+
res.json({ ok: true, cwd: serverWorkspaceDir });
|
|
357
|
+
});
|
|
358
|
+
app.post('/api/workspace', (req, res) => {
|
|
359
|
+
const body = (req.body ?? {});
|
|
360
|
+
const cwd = typeof body?.cwd === 'string' ? body.cwd.trim() : '';
|
|
361
|
+
if (!cwd) {
|
|
362
|
+
res.status(400).json({ ok: false, error: '缺少 cwd 字段' });
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const resolved = (0, path_1.resolve)(cwd);
|
|
366
|
+
if (!(0, fs_1.existsSync)(resolved) || !(0, fs_1.statSync)(resolved).isDirectory()) {
|
|
367
|
+
res.status(400).json({ ok: false, error: '目录不存在' });
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
serverWorkspaceDir = resolved;
|
|
371
|
+
res.json({ ok: true, cwd: serverWorkspaceDir });
|
|
372
|
+
});
|
|
373
|
+
app.get('/api/workspace/list', (req, res) => {
|
|
374
|
+
const raw = typeof req.query.path === 'string' ? req.query.path.trim() : '';
|
|
375
|
+
// path 为空 / '.' 时回落到服务端工作区,避免 resolve('.') 指向进程 cwd 造成困惑
|
|
376
|
+
const rawPath = !raw || raw === '.' ? serverWorkspaceDir : raw;
|
|
377
|
+
const dir = (0, path_1.resolve)(rawPath);
|
|
378
|
+
if (!assertPathAllowed(dir, res))
|
|
379
|
+
return;
|
|
380
|
+
try {
|
|
381
|
+
// withFileTypes 失败时(部分网络盘/权限目录)退回普通 readdir
|
|
382
|
+
const names = (0, fs_1.readdirSync)(dir);
|
|
383
|
+
const entries = names
|
|
384
|
+
.map((name) => {
|
|
385
|
+
const full = (0, path_1.join)(dir, name);
|
|
386
|
+
try {
|
|
387
|
+
const st = (0, fs_1.lstatSync)(full);
|
|
388
|
+
return {
|
|
389
|
+
name,
|
|
390
|
+
path: full,
|
|
391
|
+
type: st.isDirectory() ? 'dir' : st.isFile() ? 'file' : 'other',
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
return null;
|
|
396
|
+
}
|
|
397
|
+
})
|
|
398
|
+
.filter(Boolean);
|
|
399
|
+
res.json({ ok: true, cwd: dir, entries });
|
|
400
|
+
}
|
|
401
|
+
catch (e) {
|
|
402
|
+
res.status(500).json({ ok: false, error: '读取目录失败: ' + e.message });
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
app.post('/api/files/read', (req, res) => {
|
|
406
|
+
const body = (req.body ?? {});
|
|
407
|
+
const file = typeof body?.path === 'string' ? body.path.trim() : '';
|
|
408
|
+
if (!file || !assertPathAllowed(file, res))
|
|
409
|
+
return;
|
|
410
|
+
try {
|
|
411
|
+
const st = (0, fs_1.statSync)(file);
|
|
412
|
+
if (!st.isFile()) {
|
|
413
|
+
res.status(400).json({ ok: false, error: '不是文件' });
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
if (st.size > 2 * 1024 * 1024) {
|
|
417
|
+
res.status(400).json({ ok: false, error: '文件超过 2MB,建议用本地编辑器打开' });
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const content = (0, fs_1.readFileSync)(file, 'utf8');
|
|
421
|
+
res.json({ ok: true, path: file, content });
|
|
422
|
+
}
|
|
423
|
+
catch (e) {
|
|
424
|
+
res.status(500).json({ ok: false, error: '读取失败: ' + e.message });
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
/* ========== 打开本地文件夹/浏览器 ========== */
|
|
428
|
+
app.post('/api/open/folder', (req, res) => {
|
|
429
|
+
const body = (req.body ?? {});
|
|
430
|
+
const dir = typeof body?.path === 'string' ? body.path.trim() : serverWorkspaceDir;
|
|
431
|
+
if (!dir || !assertPathAllowed(dir, res))
|
|
432
|
+
return;
|
|
433
|
+
try {
|
|
434
|
+
openFolder(dir);
|
|
435
|
+
res.json({ ok: true });
|
|
436
|
+
}
|
|
437
|
+
catch (e) {
|
|
438
|
+
res.status(500).json({ ok: false, error: '打开失败: ' + e.message });
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
app.post('/api/open/browser', (req, res) => {
|
|
442
|
+
const body = (req.body ?? {});
|
|
443
|
+
const url = typeof body?.url === 'string' ? body.url.trim() : '';
|
|
444
|
+
if (!url) {
|
|
445
|
+
res.status(400).json({ ok: false, error: '缺少 url 字段' });
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
try {
|
|
449
|
+
openBrowser(url);
|
|
450
|
+
res.json({ ok: true });
|
|
451
|
+
}
|
|
452
|
+
catch (e) {
|
|
453
|
+
res.status(500).json({ ok: false, error: '打开失败: ' + e.message });
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
/* ========== 上传文件/图片 ========== */
|
|
457
|
+
const uploadsDir = () => (0, path_1.join)(homeDir, 'uploads');
|
|
458
|
+
app.post('/api/upload', (req, res) => {
|
|
459
|
+
const body = (req.body ?? {});
|
|
460
|
+
const name = typeof body?.name === 'string' ? body.name.trim() : '';
|
|
461
|
+
const mime = typeof body?.mime === 'string' ? body.mime.trim() : 'application/octet-stream';
|
|
462
|
+
const data = typeof body?.dataBase64 === 'string' ? body.dataBase64.trim() : '';
|
|
463
|
+
if (!name || !data) {
|
|
464
|
+
res.status(400).json({ ok: false, error: '缺少 name 或 dataBase64 字段' });
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
try {
|
|
468
|
+
(0, fs_1.mkdirSync)(uploadsDir(), { recursive: true });
|
|
469
|
+
const safeName = name.replace(/[^a-zA-Z0-9_.\-]/g, '_');
|
|
470
|
+
const dest = (0, path_1.join)(uploadsDir(), `${Date.now()}_${safeName}`);
|
|
471
|
+
(0, fs_1.writeFileSync)(dest, Buffer.from(data, 'base64'));
|
|
472
|
+
res.json({ ok: true, path: dest, name, mime });
|
|
473
|
+
}
|
|
474
|
+
catch (e) {
|
|
475
|
+
res.status(500).json({ ok: false, error: '上传失败: ' + e.message });
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
/* ========== 技能市场(插件市场):聚合 ClawHub + Agent-Foundry ========== */
|
|
479
|
+
const CLAWHUB_API = 'https://clawhub.ai/api/v1/skills';
|
|
480
|
+
const AGENT_FOUNDRY_CATALOG = 'https://raw.githubusercontent.com/hebertzhu/agent-foundry/main/catalog/skills-catalog.json';
|
|
481
|
+
async function fetchWithRetry(url, timeoutMs = 15000, retries = 1) {
|
|
482
|
+
let lastErr;
|
|
483
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
484
|
+
const ctrl = new AbortController();
|
|
485
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
486
|
+
try {
|
|
487
|
+
const r = await fetch(url, {
|
|
488
|
+
signal: ctrl.signal,
|
|
489
|
+
headers: { 'User-Agent': 'fhcode-web/0.5.1' },
|
|
490
|
+
});
|
|
491
|
+
clearTimeout(timer);
|
|
492
|
+
return r;
|
|
493
|
+
}
|
|
494
|
+
catch (e) {
|
|
495
|
+
clearTimeout(timer);
|
|
496
|
+
lastErr = e;
|
|
497
|
+
if (attempt < retries)
|
|
498
|
+
await new Promise((res) => setTimeout(res, 500));
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
throw lastErr;
|
|
502
|
+
}
|
|
503
|
+
async function fetchClawHubSkills(keyword, limit) {
|
|
504
|
+
try {
|
|
505
|
+
const url = `${CLAWHUB_API}?limit=${limit}${keyword ? `&q=${encodeURIComponent(keyword)}` : ''}`;
|
|
506
|
+
const r = await fetchWithRetry(url);
|
|
507
|
+
if (!r.ok)
|
|
508
|
+
return [];
|
|
509
|
+
const data = (await r.json());
|
|
510
|
+
return (data.items || []).map((it) => ({
|
|
511
|
+
id: `clawhub:${String(it.slug || '')}`,
|
|
512
|
+
name: String(it.displayName || it.slug || ''),
|
|
513
|
+
description: String(it.summary || it.description || ''),
|
|
514
|
+
source: 'clawhub',
|
|
515
|
+
author: it.slug ? String(it.slug).split('/')[0] : undefined,
|
|
516
|
+
category: 'market',
|
|
517
|
+
tags: Array.isArray(it.topics) ? it.topics : [],
|
|
518
|
+
downloads: Number(it.stats?.downloads ?? 0),
|
|
519
|
+
installHint: `npx skills add ${String(it.slug || '')}`,
|
|
520
|
+
homepage: `https://clawhub.ai/${String(it.slug || '')}`,
|
|
521
|
+
rawUrl: `https://clawhub.ai/${String(it.slug || '')}`,
|
|
522
|
+
}));
|
|
523
|
+
}
|
|
524
|
+
catch (e) {
|
|
525
|
+
console.error('[clawhub] fetch failed:', e);
|
|
526
|
+
return [];
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
async function fetchAgentFoundrySkills(keyword, limit) {
|
|
530
|
+
try {
|
|
531
|
+
const r = await fetchWithRetry(AGENT_FOUNDRY_CATALOG);
|
|
532
|
+
if (!r.ok)
|
|
533
|
+
return [];
|
|
534
|
+
const data = (await r.json());
|
|
535
|
+
const arr = Array.isArray(data.skills) ? data.skills : [];
|
|
536
|
+
let list = arr.map((it) => {
|
|
537
|
+
const name = String(it.name || '');
|
|
538
|
+
const publicPath = String(it.public_path || '');
|
|
539
|
+
const category = String(it.category || 'market');
|
|
540
|
+
const rawUrl = publicPath
|
|
541
|
+
? `https://raw.githubusercontent.com/hebertzhu/agent-foundry/main/${publicPath}`
|
|
542
|
+
: `https://github.com/hebertzhu/agent-foundry`;
|
|
543
|
+
return {
|
|
544
|
+
id: `agent-foundry:${name}`,
|
|
545
|
+
name,
|
|
546
|
+
description: `Agent-Foundry 技能包 · 分类:${category}${publicPath ? ` · 源:${publicPath}` : ''}`,
|
|
547
|
+
source: 'agent-foundry',
|
|
548
|
+
author: 'hebertzhu',
|
|
549
|
+
category,
|
|
550
|
+
tags: Array.isArray(it.packs) ? it.packs : [category],
|
|
551
|
+
downloads: 0,
|
|
552
|
+
installHint: `npm i -g @agent-foundry/cli && agent-foundry install ${name}`,
|
|
553
|
+
homepage: `https://github.com/hebertzhu/agent-foundry`,
|
|
554
|
+
rawUrl,
|
|
555
|
+
};
|
|
556
|
+
});
|
|
557
|
+
if (keyword) {
|
|
558
|
+
const kw = keyword.toLowerCase();
|
|
559
|
+
list = list.filter((s) => s.name.toLowerCase().includes(kw) ||
|
|
560
|
+
s.description.toLowerCase().includes(kw) ||
|
|
561
|
+
(s.tags || []).some((t) => t.toLowerCase().includes(kw)));
|
|
562
|
+
}
|
|
563
|
+
return list.slice(0, limit);
|
|
564
|
+
}
|
|
565
|
+
catch (e) {
|
|
566
|
+
console.error('[agent-foundry] fetch failed:', e);
|
|
567
|
+
return [];
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
app.get('/api/skills/market', async (req, res) => {
|
|
571
|
+
const q = typeof req.query.q === 'string' ? req.query.q.trim() : '';
|
|
572
|
+
const source = typeof req.query.source === 'string' ? req.query.source : 'all';
|
|
573
|
+
const limit = Math.min(Number(req.query.limit) || 50, 200);
|
|
574
|
+
let results = [];
|
|
575
|
+
if (source === 'clawhub' || source === 'all')
|
|
576
|
+
results = results.concat(await fetchClawHubSkills(q, limit));
|
|
577
|
+
if (source === 'agent-foundry' || source === 'all')
|
|
578
|
+
results = results.concat(await fetchAgentFoundrySkills(q, limit));
|
|
579
|
+
results.sort((a, b) => (b.downloads || 0) - (a.downloads || 0));
|
|
580
|
+
res.json({ ok: true, total: results.length, skills: results.slice(0, limit) });
|
|
581
|
+
});
|
|
582
|
+
// 已安装技能持久化
|
|
583
|
+
const installedSkillsFile = (0, path_1.join)(homeDir, 'skills', 'installed.json');
|
|
584
|
+
app.post('/api/skills/install', async (req, res) => {
|
|
585
|
+
const body = req.body;
|
|
586
|
+
if (!body || !body.id || !body.name || !body.source) {
|
|
587
|
+
res.status(400).json({ ok: false, error: '缺少 id / name / source 字段' });
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
try {
|
|
591
|
+
const dir = (0, path_1.join)(homeDir, 'skills', body.id.replace(/[^a-zA-Z0-9_-]/g, '_'));
|
|
592
|
+
(0, fs_1.mkdirSync)(dir, { recursive: true });
|
|
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
|
+
}
|
|
597
|
+
const installed = loadJsonFile(installedSkillsFile, []);
|
|
598
|
+
if (!installed.find((s) => s.id === body.id)) {
|
|
599
|
+
installed.push(body);
|
|
600
|
+
if (!saveJsonFile(installedSkillsFile, installed)) {
|
|
601
|
+
res.status(500).json({ ok: false, error: '已安装列表写入失败' });
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
res.json({ ok: true, message: `技能「${body.name}」已记录为已安装` });
|
|
606
|
+
}
|
|
607
|
+
catch (e) {
|
|
608
|
+
res.status(500).json({ ok: false, error: '安装失败: ' + e.message });
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
app.get('/api/skills/installed', (_req, res) => {
|
|
612
|
+
res.json({ ok: true, skills: loadJsonFile(installedSkillsFile, []) });
|
|
613
|
+
});
|
|
614
|
+
const automationsFile = (0, path_1.join)(homeDir, 'automations.json');
|
|
615
|
+
app.get('/api/automations', (_req, res) => {
|
|
616
|
+
res.json({ ok: true, automations: loadJsonFile(automationsFile, []) });
|
|
617
|
+
});
|
|
618
|
+
app.post('/api/automations', async (req, res) => {
|
|
619
|
+
const body = (req.body ?? {});
|
|
620
|
+
const name = typeof body.name === 'string' ? body.name.trim() : '';
|
|
621
|
+
const goal = typeof body.goal === 'string' ? body.goal.trim() : '';
|
|
622
|
+
if (!name || !goal) {
|
|
623
|
+
res.status(400).json({ ok: false, error: '缺少 name 或 goal 字段' });
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
const list = loadJsonFile(automationsFile, []);
|
|
627
|
+
const rule = {
|
|
628
|
+
id: `auto_${Date.now()}_${(0, crypto_1.randomUUID)().slice(0, 4)}`,
|
|
629
|
+
name,
|
|
630
|
+
goal,
|
|
631
|
+
modelId: typeof body.modelId === 'string' && body.modelId.trim() ? body.modelId.trim() : undefined,
|
|
632
|
+
workspaceDir: typeof body.workspaceDir === 'string' && body.workspaceDir.trim()
|
|
633
|
+
? body.workspaceDir.trim()
|
|
634
|
+
: undefined,
|
|
635
|
+
createdAt: new Date().toISOString(),
|
|
636
|
+
runCount: 0,
|
|
637
|
+
};
|
|
638
|
+
list.push(rule);
|
|
639
|
+
if (!saveJsonFile(automationsFile, list)) {
|
|
640
|
+
res.status(500).json({ ok: false, error: '指令保存失败(磁盘不可写)' });
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
res.status(201).json({ ok: true, automation: rule });
|
|
644
|
+
});
|
|
645
|
+
// 注:当前 @types/express 版本缺失 delete 方法类型声明,运行时 Express 完全支持,故局部断言
|
|
646
|
+
app.delete('/api/automations/:id', async (req, res) => {
|
|
647
|
+
const list = loadJsonFile(automationsFile, []);
|
|
648
|
+
const next = list.filter((a) => a.id !== req.params.id);
|
|
649
|
+
if (next.length === list.length) {
|
|
650
|
+
res.status(404).json({ ok: false, error: '指令不存在' });
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
if (!saveJsonFile(automationsFile, next)) {
|
|
654
|
+
res.status(500).json({ ok: false, error: '指令删除失败(磁盘不可写)' });
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
res.json({ ok: true });
|
|
658
|
+
});
|
|
659
|
+
app.post('/api/automations/:id/run', async (req, res) => {
|
|
660
|
+
const list = loadJsonFile(automationsFile, []);
|
|
661
|
+
const rule = list.find((a) => a.id === req.params.id);
|
|
662
|
+
if (!rule) {
|
|
663
|
+
res.status(404).json({ ok: false, error: '指令不存在' });
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
const record = queue.submit(rule.goal, {
|
|
667
|
+
modelId: rule.modelId,
|
|
668
|
+
workspaceDir: rule.workspaceDir,
|
|
669
|
+
});
|
|
670
|
+
rule.runCount += 1;
|
|
671
|
+
rule.lastRunAt = new Date().toISOString();
|
|
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
|
+
});
|
|
680
|
+
});
|
|
681
|
+
const BUILTIN_TEMPLATES = [
|
|
682
|
+
{
|
|
683
|
+
id: 'tpl-code-gen',
|
|
684
|
+
title: '编写新功能',
|
|
685
|
+
category: '代码',
|
|
686
|
+
icon: '🧩',
|
|
687
|
+
goal: '请用 production-ready 的代码实现以下功能:\n\n(请描述功能点、输入输出、边界条件)',
|
|
688
|
+
},
|
|
689
|
+
{
|
|
690
|
+
id: 'tpl-debug',
|
|
691
|
+
title: '调试 Bug',
|
|
692
|
+
category: '调试',
|
|
693
|
+
icon: '🐛',
|
|
694
|
+
goal: '以下代码出现异常,请定位根因并修复,附回归验证:\n\n(粘贴错误日志或现象)',
|
|
695
|
+
},
|
|
696
|
+
{
|
|
697
|
+
id: 'tpl-refactor',
|
|
698
|
+
title: '重构优化',
|
|
699
|
+
category: '代码',
|
|
700
|
+
icon: '♻️',
|
|
701
|
+
goal: '请在不改变外部行为的前提下重构以下模块,提升可读性与性能:\n\n(粘贴代码或文件路径)',
|
|
702
|
+
},
|
|
703
|
+
{
|
|
704
|
+
id: 'tpl-test',
|
|
705
|
+
title: '生成测试',
|
|
706
|
+
category: '测试',
|
|
707
|
+
icon: '🧪',
|
|
708
|
+
goal: '请为以下代码生成单元测试与边界用例(覆盖率优先):\n\n(粘贴代码或文件路径)',
|
|
709
|
+
},
|
|
710
|
+
{
|
|
711
|
+
id: 'tpl-doc',
|
|
712
|
+
title: '撰写文档',
|
|
713
|
+
category: '文档',
|
|
714
|
+
icon: '📝',
|
|
715
|
+
goal: '请撰写一份技术/产品文档,包含背景、方案、步骤与注意事项:\n\n(说明文档主题)',
|
|
716
|
+
},
|
|
717
|
+
{
|
|
718
|
+
id: 'tpl-review',
|
|
719
|
+
title: '代码审查',
|
|
720
|
+
category: '质量',
|
|
721
|
+
icon: '🔍',
|
|
722
|
+
goal: '请对以下代码做静态/设计审查,列出问题与改进建议:\n\n(粘贴代码或文件路径)',
|
|
723
|
+
},
|
|
724
|
+
{
|
|
725
|
+
id: 'tpl-explain',
|
|
726
|
+
title: '解释代码',
|
|
727
|
+
category: '文档',
|
|
728
|
+
icon: '💡',
|
|
729
|
+
goal: '请用通俗语言解释以下代码的逻辑与关键点:\n\n(粘贴代码)',
|
|
730
|
+
},
|
|
731
|
+
{
|
|
732
|
+
id: 'tpl-perf',
|
|
733
|
+
title: '性能优化',
|
|
734
|
+
category: '质量',
|
|
735
|
+
icon: '⚡',
|
|
736
|
+
goal: '请分析并优化以下代码的性能瓶颈,给出前后对比:\n\n(粘贴代码或描述场景)',
|
|
737
|
+
},
|
|
738
|
+
];
|
|
739
|
+
const templatesFile = (0, path_1.join)(homeDir, 'templates.json');
|
|
740
|
+
app.get('/api/templates', (_req, res) => {
|
|
741
|
+
const user = loadJsonFile(templatesFile, []);
|
|
742
|
+
res.json({ ok: true, builtin: BUILTIN_TEMPLATES, user });
|
|
743
|
+
});
|
|
744
|
+
app.post('/api/templates', async (req, res) => {
|
|
745
|
+
const body = (req.body ?? {});
|
|
746
|
+
const title = typeof body.title === 'string' ? body.title.trim() : '';
|
|
747
|
+
const goal = typeof body.goal === 'string' ? body.goal.trim() : '';
|
|
748
|
+
if (!title || !goal) {
|
|
749
|
+
res.status(400).json({ ok: false, error: '缺少 title 或 goal 字段' });
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
const list = loadJsonFile(templatesFile, []);
|
|
753
|
+
const tpl = {
|
|
754
|
+
id: `tpl_${Date.now()}_${(0, crypto_1.randomUUID)().slice(0, 4)}`,
|
|
755
|
+
title,
|
|
756
|
+
category: typeof body.category === 'string' && body.category.trim() ? body.category.trim() : '自定义',
|
|
757
|
+
goal,
|
|
758
|
+
icon: typeof body.icon === 'string' && body.icon.trim() ? body.icon.trim() : '📄',
|
|
759
|
+
builtin: false,
|
|
760
|
+
};
|
|
761
|
+
list.push(tpl);
|
|
762
|
+
if (!saveJsonFile(templatesFile, list)) {
|
|
763
|
+
res.status(500).json({ ok: false, error: '模板保存失败(磁盘不可写)' });
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
res.status(201).json({ ok: true, template: tpl });
|
|
767
|
+
});
|
|
768
|
+
app.delete('/api/templates/:id', async (req, res) => {
|
|
769
|
+
const list = loadJsonFile(templatesFile, []);
|
|
770
|
+
const next = list.filter((t) => t.id !== req.params.id);
|
|
771
|
+
if (next.length === list.length) {
|
|
772
|
+
res.status(404).json({ ok: false, error: '模板不存在或不可删除' });
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
if (!saveJsonFile(templatesFile, next)) {
|
|
776
|
+
res.status(500).json({ ok: false, error: '模板删除失败(磁盘不可写)' });
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
res.json({ ok: true });
|
|
780
|
+
});
|
|
781
|
+
/* ========== 办公助理:内置能力清单 ========== */
|
|
782
|
+
app.get('/api/office/capabilities', (_req, res) => {
|
|
783
|
+
res.json({
|
|
784
|
+
ok: true,
|
|
785
|
+
capabilities: [
|
|
786
|
+
{
|
|
787
|
+
id: 'doc-summary',
|
|
788
|
+
icon: '📄',
|
|
789
|
+
title: '文档摘要',
|
|
790
|
+
desc: '粘贴长文档,生成结构化摘要与要点',
|
|
791
|
+
prompt: '请对以下文档做摘要,提取 3-5 个核心要点并列出待办:\n\n',
|
|
792
|
+
},
|
|
793
|
+
{
|
|
794
|
+
id: 'doc-translate',
|
|
795
|
+
icon: '🌐',
|
|
796
|
+
title: '中英互译',
|
|
797
|
+
desc: '技术文档/代码注释翻译',
|
|
798
|
+
prompt: '请将以下内容准确翻译(保留代码与术语):\n\n',
|
|
799
|
+
},
|
|
800
|
+
{
|
|
801
|
+
id: 'doc-rewrite',
|
|
802
|
+
icon: '✍️',
|
|
803
|
+
title: '润色改写',
|
|
804
|
+
desc: '把草稿改写为正式/简洁风格',
|
|
805
|
+
prompt: '请润色以下文本,使其更专业简洁:\n\n',
|
|
806
|
+
},
|
|
807
|
+
{
|
|
808
|
+
id: 'meeting-notes',
|
|
809
|
+
icon: '📋',
|
|
810
|
+
title: '会议纪要',
|
|
811
|
+
desc: '从聊天/录音转写生成行动项',
|
|
812
|
+
prompt: '请从以下记录中提取:决策、负责人、截止时间、待办:\n\n',
|
|
813
|
+
},
|
|
814
|
+
{
|
|
815
|
+
id: 'email-draft',
|
|
816
|
+
icon: '✉️',
|
|
817
|
+
title: '邮件起草',
|
|
818
|
+
desc: '按要点生成工作邮件',
|
|
819
|
+
prompt: '请起草一封邮件,主题/背景/诉求如下:\n\n',
|
|
820
|
+
},
|
|
821
|
+
{
|
|
822
|
+
id: 'excel-formula',
|
|
823
|
+
icon: '📊',
|
|
824
|
+
title: '表格公式',
|
|
825
|
+
desc: '描述需求生成 Excel/Sheets 公式',
|
|
826
|
+
prompt: '请写出实现以下需求的表格公式并解释:\n\n',
|
|
827
|
+
},
|
|
828
|
+
],
|
|
829
|
+
});
|
|
830
|
+
});
|
|
831
|
+
/* ========== 大模型配置(三重加密:apiKey AES-256-GCM 落盘加密 + RSA 加密传输) ========== */
|
|
832
|
+
const modelsFile = (0, path_1.join)(homeDir, 'models.json');
|
|
833
|
+
function loadModels() {
|
|
834
|
+
const list = loadJsonFile(modelsFile, []);
|
|
835
|
+
return Array.isArray(list) ? list : [];
|
|
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
|
+
}
|
|
847
|
+
function saveModels(list) {
|
|
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;
|
|
860
|
+
}
|
|
861
|
+
// 初始化模型提供列表,并注册到任务队列
|
|
862
|
+
const initialModels = loadModels();
|
|
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
|
+
});
|
|
874
|
+
app.get('/api/models', (_req, res) => {
|
|
875
|
+
const list = loadModels().map(publicModel);
|
|
876
|
+
res.json({ ok: true, models: list, defaultId: (list.find((m) => m.default) || {}).id || null });
|
|
877
|
+
});
|
|
878
|
+
app.post('/api/models', (req, res) => {
|
|
879
|
+
const body = req.body;
|
|
880
|
+
const name = typeof body?.name === 'string' ? body.name.trim() : '';
|
|
881
|
+
const apiBase = typeof body?.apiBase === 'string' ? body.apiBase.trim() : '';
|
|
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
|
+
}
|
|
889
|
+
const reasoning = typeof body?.reasoning === 'string' ? body.reasoning : '';
|
|
890
|
+
if (!name) {
|
|
891
|
+
res.status(400).json({ ok: false, error: '请填写模型名称' });
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
const list = loadModels();
|
|
895
|
+
const id = typeof body?.id === 'string' && body.id.trim() ? body.id.trim() : (0, crypto_1.randomUUID)();
|
|
896
|
+
const idx = list.findIndex((m) => m.id === id);
|
|
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
|
+
};
|
|
905
|
+
list[idx] = { ...list[idx], ...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) });
|
|
917
|
+
});
|
|
918
|
+
app.delete('/api/models/:id', (req, res) => {
|
|
919
|
+
let list = loadModels();
|
|
920
|
+
const target = list.find((m) => m.id === req.params.id);
|
|
921
|
+
list = list.filter((m) => m.id !== req.params.id);
|
|
922
|
+
// 若删除的是默认,且没有其它默认,则把第一个设为默认
|
|
923
|
+
if (target?.default && !list.some((m) => m.default) && list.length)
|
|
924
|
+
list[0].default = true;
|
|
925
|
+
if (!saveModels(list)) {
|
|
926
|
+
res.status(500).json({ ok: false, error: '模型配置保存失败(磁盘不可写,已保留内存态)' });
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
res.json({ ok: true });
|
|
930
|
+
});
|
|
931
|
+
app.post('/api/models/:id/default', (req, res) => {
|
|
932
|
+
const list = loadModels();
|
|
933
|
+
const target = list.find((m) => m.id === req.params.id);
|
|
934
|
+
if (!target) {
|
|
935
|
+
res.status(404).json({ ok: false, error: '模型不存在' });
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
list.forEach((m) => (m.default = m.id === req.params.id));
|
|
939
|
+
if (!saveModels(list)) {
|
|
940
|
+
res.status(500).json({ ok: false, error: '模型配置保存失败(磁盘不可写,已保留内存态)' });
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
res.json({ ok: true, defaultId: target.id });
|
|
944
|
+
});
|
|
50
945
|
const server = app.listen(port, () => {
|
|
51
|
-
console.log(
|
|
946
|
+
console.log((0, i18n_1.t)('serve.started', { port }));
|
|
52
947
|
});
|
|
53
948
|
return {
|
|
54
949
|
port,
|
|
@@ -57,4 +952,35 @@ function startWebServer(opts = {}) {
|
|
|
57
952
|
close: () => server.close(),
|
|
58
953
|
};
|
|
59
954
|
}
|
|
955
|
+
/** 对外暴露的任务视图。withSteps=true 时携带思维链路步骤与对话历史(仅单任务详情使用),列表接口剔除以降低负载 */
|
|
956
|
+
function publicTask(r, withSteps = false) {
|
|
957
|
+
if (withSteps)
|
|
958
|
+
return r;
|
|
959
|
+
const { steps: _steps, conversation: _conversation, ...rest } = r;
|
|
960
|
+
return rest;
|
|
961
|
+
}
|
|
962
|
+
/** 使用系统默认程序打开本地文件夹 */
|
|
963
|
+
function openFolder(dir) {
|
|
964
|
+
if (process.platform === 'win32') {
|
|
965
|
+
(0, child_process_1.spawn)('explorer', [dir], { detached: true, stdio: 'ignore' }).unref();
|
|
966
|
+
}
|
|
967
|
+
else if (process.platform === 'darwin') {
|
|
968
|
+
(0, child_process_1.spawn)('open', [dir], { detached: true, stdio: 'ignore' }).unref();
|
|
969
|
+
}
|
|
970
|
+
else {
|
|
971
|
+
(0, child_process_1.spawn)('xdg-open', [dir], { detached: true, stdio: 'ignore' }).unref();
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
/** 使用系统默认浏览器打开 URL */
|
|
975
|
+
function openBrowser(url) {
|
|
976
|
+
if (process.platform === 'win32') {
|
|
977
|
+
(0, child_process_1.spawn)('cmd', ['/c', 'start', '', url], { detached: true, stdio: 'ignore' }).unref();
|
|
978
|
+
}
|
|
979
|
+
else if (process.platform === 'darwin') {
|
|
980
|
+
(0, child_process_1.spawn)('open', [url], { detached: true, stdio: 'ignore' }).unref();
|
|
981
|
+
}
|
|
982
|
+
else {
|
|
983
|
+
(0, child_process_1.spawn)('xdg-open', [url], { detached: true, stdio: 'ignore' }).unref();
|
|
984
|
+
}
|
|
985
|
+
}
|
|
60
986
|
//# sourceMappingURL=server.js.map
|