dsh-plugin-agentflow 0.0.0-stage → 0.2.1
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/README.md +88 -2
- package/cordis.patch.yml +195 -0
- package/index.js +448 -0
- package/lib/bridge.js +858 -0
- package/lib/client.js +4 -0
- package/package.json +42 -4
- package/runtime/boards.mjs +486 -0
- package/runtime/bridge.mjs +230 -0
- package/runtime/dsh-source.mjs +289 -0
- package/runtime/engine.mjs +352 -0
- package/runtime/index.html +3299 -0
- package/runtime/llm.mjs +252 -0
- package/runtime/mcp.mjs +823 -0
- package/runtime/preset-gen.mjs +1117 -0
- package/runtime/presets.mjs +399 -0
- package/runtime/run.mjs +76 -0
- package/runtime/server.mjs +1151 -0
- package/runtime/skills.mjs +131 -0
- package/runtime/store.mjs +103 -0
- package/runtime/verify.mjs +219 -0
- package/runtime/web/dist/assets/index-CkM6YPl4.js +348 -0
- package/runtime/web/dist/assets/index-DEZcpxgr.css +1 -0
- package/runtime/web/dist/index.html +13 -0
- package/scripts/build-client.mjs +36 -0
- package/scripts/sync-runtime.mjs +84 -0
|
@@ -0,0 +1,1151 @@
|
|
|
1
|
+
// server.mjs —— HTTP + SSE,零依赖。
|
|
2
|
+
//
|
|
3
|
+
// 多画布(契约:docs/boards-contract.md):
|
|
4
|
+
// GET /api/boards → 画布列表
|
|
5
|
+
// POST /api/boards → 新建画布
|
|
6
|
+
// POST /api/boards/import-preset→ 从预设生成画布(P1,当前 501)
|
|
7
|
+
// GET /api/boards/:id → 读画布
|
|
8
|
+
// PUT /api/boards/:id → 存画布(正式保存,顺手清草稿)
|
|
9
|
+
// PATCH /api/boards/:id → 改元信息(name)
|
|
10
|
+
// DELETE /api/boards/:id → 删画布(最后一张不许删)
|
|
11
|
+
// GET/PUT/DELETE /api/boards/:id/draft → 草稿(手动保存前的未落定修改)
|
|
12
|
+
// GET/PUT /api/board → 旧接口过渡:读写 flow-agentflow
|
|
13
|
+
//
|
|
14
|
+
// 其余路由:
|
|
15
|
+
// GET / → index.html
|
|
16
|
+
// POST /api/nodes → 新建节点(带 from 时自动建边)
|
|
17
|
+
// PATCH /api/nodes/:id → 改节点(含 content)
|
|
18
|
+
// DELETE /api/nodes/:id → 删节点 + 关联边
|
|
19
|
+
// POST /api/run → SSE 跑图
|
|
20
|
+
// GET /api/config → 配置(模型目录来自 DSH bridge,只读展示)
|
|
21
|
+
// PUT /api/config → 存配置
|
|
22
|
+
// GET /api/models → DSH 模型目录(透传 bridge 的 ≤10s 缓存)
|
|
23
|
+
// GET /api/bridge → bridge 连接状态(前端显示「模型通道」)
|
|
24
|
+
//
|
|
25
|
+
// 节点/运行类接口用 ?board=<id> 定位画布;不传就用索引里的第一张。
|
|
26
|
+
|
|
27
|
+
import http from 'node:http';
|
|
28
|
+
import fs from 'node:fs/promises';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import os from 'node:os';
|
|
31
|
+
import { fileURLToPath } from 'node:url';
|
|
32
|
+
|
|
33
|
+
import { runBoard } from './engine.mjs';
|
|
34
|
+
import { loadConfig, saveConfig } from './store.mjs';
|
|
35
|
+
import {
|
|
36
|
+
FLOW_BOARD_ID,
|
|
37
|
+
listBoards,
|
|
38
|
+
createBoard,
|
|
39
|
+
saveBoard,
|
|
40
|
+
renameBoard,
|
|
41
|
+
deleteBoard,
|
|
42
|
+
resolveBoard,
|
|
43
|
+
markPresetSynced,
|
|
44
|
+
getBoard,
|
|
45
|
+
mutateBoardById,
|
|
46
|
+
ensureDefaultBoard,
|
|
47
|
+
saveDraft,
|
|
48
|
+
getDraft,
|
|
49
|
+
deleteDraft,
|
|
50
|
+
httpError,
|
|
51
|
+
} from './boards.mjs';
|
|
52
|
+
import { normalizeConfig, makeCaller } from './llm.mjs';
|
|
53
|
+
import { listSkills, defaultSkillDirs } from './skills.mjs';
|
|
54
|
+
import { askJev, selfVerify } from './verify.mjs';
|
|
55
|
+
import {
|
|
56
|
+
readDshSource,
|
|
57
|
+
readDshSkillDirs,
|
|
58
|
+
readDshModelCatalog,
|
|
59
|
+
dshCatalogAgeMs,
|
|
60
|
+
} from './dsh-source.mjs';
|
|
61
|
+
import { readBridgeInfo, bridgeUnavailableReason, bridgeInfoPath } from './bridge.mjs';
|
|
62
|
+
|
|
63
|
+
const ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
64
|
+
// bridge 发现文件在 <BRIDGE_DIR>/data/dsh-bridge.json。
|
|
65
|
+
// 插件默认写到 ~/.dsh/agent-flow;本仓库通常就在那里,DSH_HOME 指到别处时跟着走。
|
|
66
|
+
const BRIDGE_DIR = process.env.AGENTFLOW_BRIDGE_DIR
|
|
67
|
+
|| (process.env.DSH_HOME ? path.join(process.env.DSH_HOME, 'agent-flow') : ROOT);
|
|
68
|
+
|
|
69
|
+
/** 静态资源 MIME 映射(不用第三方库) */
|
|
70
|
+
const MIME = {
|
|
71
|
+
'.html': 'text/html; charset=utf-8',
|
|
72
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
73
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
74
|
+
'.css': 'text/css; charset=utf-8',
|
|
75
|
+
'.json': 'application/json; charset=utf-8',
|
|
76
|
+
'.svg': 'image/svg+xml',
|
|
77
|
+
'.png': 'image/png',
|
|
78
|
+
'.jpg': 'image/jpeg',
|
|
79
|
+
'.webp': 'image/webp',
|
|
80
|
+
'.ico': 'image/x-icon',
|
|
81
|
+
'.woff2': 'font/woff2',
|
|
82
|
+
};
|
|
83
|
+
const PORT = Number(process.env.PORT || 8791);
|
|
84
|
+
const HOST = process.env.HOST || '127.0.0.1';
|
|
85
|
+
|
|
86
|
+
// ---------- 初始画布 ----------
|
|
87
|
+
|
|
88
|
+
function demoBoard() {
|
|
89
|
+
const mk = (id, label, model, x, y, task, criteria) => ({
|
|
90
|
+
id, label, type: 'llm', model, fallback: [],
|
|
91
|
+
x, y, task, criteria,
|
|
92
|
+
skills: [], tools: [], verify: '', verdict: null,
|
|
93
|
+
content: '', status: 'idle', usage: null, error: null,
|
|
94
|
+
});
|
|
95
|
+
return {
|
|
96
|
+
id: 'demo',
|
|
97
|
+
name: 'AgentFlow',
|
|
98
|
+
nodes: [
|
|
99
|
+
mk('n1', '大纲', 'deepseek', 60, 150,
|
|
100
|
+
'根据给定的题材,写一个三幕剧的简要大纲,每幕两三句话。',
|
|
101
|
+
'三幕结构清晰,每幕有一个明确转折'),
|
|
102
|
+
mk('n2', '人物版', 'deepseek', 430, 40,
|
|
103
|
+
'基于大纲,塑造主要人物的动机与成长弧线。',
|
|
104
|
+
'主角有明确欲望、阻碍与转变'),
|
|
105
|
+
mk('n3', '对白版', 'deepseek', 430, 300,
|
|
106
|
+
'基于大纲,为关键场景写几句有张力的对白。',
|
|
107
|
+
'对白有潜台词,不要直白解释情绪'),
|
|
108
|
+
mk('n4', '合并', 'deepseek', 800, 170,
|
|
109
|
+
'把两份材料合并成一份完整的三幕剧梗概,约 300 字。',
|
|
110
|
+
'读起来是一个连贯故事,不是拼接'),
|
|
111
|
+
],
|
|
112
|
+
edges: [
|
|
113
|
+
{ from: 'n1', to: 'n2' },
|
|
114
|
+
{ from: 'n1', to: 'n3' },
|
|
115
|
+
{ from: 'n2', to: 'n4' },
|
|
116
|
+
{ from: 'n3', to: 'n4' },
|
|
117
|
+
],
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 老数据没有 skills/tools 字段,读出来补上默认值,前端不用到处判空 */
|
|
122
|
+
function withNodeDefaults(board) {
|
|
123
|
+
board.nodes = (board.nodes || []).map((n) => ({
|
|
124
|
+
skills: [],
|
|
125
|
+
tools: [],
|
|
126
|
+
...n,
|
|
127
|
+
}));
|
|
128
|
+
board.edges = board.edges || [];
|
|
129
|
+
return board;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 按画布 id 找当前画布;id 为空时用索引第一张。
|
|
134
|
+
* 找不到抛 404(server 外层 catch 会翻成响应码)。
|
|
135
|
+
*/
|
|
136
|
+
async function resolveBoardCtx(requested) {
|
|
137
|
+
const resolved = await resolveBoard(requested || null);
|
|
138
|
+
if (!resolved) {
|
|
139
|
+
throw httpError(404, requested ? `画布不存在:${requested}` : '还没有画布');
|
|
140
|
+
}
|
|
141
|
+
return { id: resolved.id, board: withNodeDefaults(resolved.board) };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---------- 配置:DSH 版 ----------
|
|
145
|
+
//
|
|
146
|
+
// 模型目录来自 DSH 内核的 llm bridge,MCP 服务器来自 DSH 的 patch;
|
|
147
|
+
// 本地 config.json 只保留 mock 开关和 jev 凭据。
|
|
148
|
+
// 所以「DSH 里没配的东西,AgentFlow 里也用不了」是设计约束,不是缺陷。
|
|
149
|
+
async function buildCfg() {
|
|
150
|
+
const [dsh, catalog] = await Promise.all([readDshSource(), readDshModelCatalog(BRIDGE_DIR)]);
|
|
151
|
+
const local = normalizeConfig(await loadConfig());
|
|
152
|
+
// providers 只供 /api/config 展示:没有 baseURL、没有凭据 —— 模型调用一律走 bridge。
|
|
153
|
+
const providers = {};
|
|
154
|
+
for (const p of catalog.providers) {
|
|
155
|
+
providers[p.id] = {
|
|
156
|
+
label: p.label,
|
|
157
|
+
baseURL: '',
|
|
158
|
+
model: p.models[0]?.id || '',
|
|
159
|
+
hasKey: !p.error && p.models.length > 0,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
providers,
|
|
164
|
+
catalog,
|
|
165
|
+
bridgeDir: BRIDGE_DIR,
|
|
166
|
+
mock: local.mock,
|
|
167
|
+
mcpServers: dsh.mcpServers,
|
|
168
|
+
skillDirs: dsh.skillDirs,
|
|
169
|
+
jev: local.jev,
|
|
170
|
+
dsh,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// 最近一次 health/catalog 失败的原因,成功时清空;/api/bridge 的 lastError 用它。
|
|
175
|
+
let lastBridgeError = null;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* bridge 状态:发现文件在不在、健康检查过不过、目录里读到几个 provider / 模型。
|
|
179
|
+
* 给 /api/bridge 用:前端「模型通道:DSH 内核」那行细字。
|
|
180
|
+
* 只做一次目录读取(healthy 时),不再额外打 DSH。
|
|
181
|
+
*/
|
|
182
|
+
async function bridgeStatus() {
|
|
183
|
+
const info = await readBridgeInfo(BRIDGE_DIR).catch(() => null);
|
|
184
|
+
const reason = await bridgeUnavailableReason(BRIDGE_DIR).catch(
|
|
185
|
+
(err) => `bridge 健康检查失败:${String((err && err.message) || err)}`,
|
|
186
|
+
);
|
|
187
|
+
let providers = 0;
|
|
188
|
+
let modelCount = 0;
|
|
189
|
+
if (!reason) {
|
|
190
|
+
const catalog = await readDshModelCatalog(BRIDGE_DIR);
|
|
191
|
+
providers = catalog.providers.length;
|
|
192
|
+
// 只在 healthy 时统计模型总数(拿的就是上面这次读取,不额外请求)
|
|
193
|
+
modelCount = catalog.providers.reduce((n, p) => n + (p.models?.length || 0), 0);
|
|
194
|
+
lastBridgeError = catalog.error || null;
|
|
195
|
+
} else {
|
|
196
|
+
lastBridgeError = reason;
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
configured: Boolean(info),
|
|
200
|
+
healthy: !reason,
|
|
201
|
+
reason,
|
|
202
|
+
providers,
|
|
203
|
+
catalogAgeMs: dshCatalogAgeMs(),
|
|
204
|
+
dir: BRIDGE_DIR,
|
|
205
|
+
infoPath: bridgeInfoPath(BRIDGE_DIR),
|
|
206
|
+
version: info ? info.version : null,
|
|
207
|
+
startedAt: info ? info.startedAt : null,
|
|
208
|
+
modelCount,
|
|
209
|
+
lastError: lastBridgeError,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---------- MCP ----------
|
|
214
|
+
|
|
215
|
+
// 连接池懒加载:mcp.mjs 可能还没就绪,这时返回明确状态而不是崩掉
|
|
216
|
+
let mcpPool = null;
|
|
217
|
+
|
|
218
|
+
async function ensureMcpPool() {
|
|
219
|
+
if (mcpPool) return mcpPool;
|
|
220
|
+
try {
|
|
221
|
+
const mod = await import('./mcp.mjs');
|
|
222
|
+
mcpPool = mod.createMcpPool();
|
|
223
|
+
return mcpPool;
|
|
224
|
+
} catch (err) {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** 给 /api/mcp 用的快照:配置了哪些 server、连没连上、有哪些工具 */
|
|
230
|
+
/** 当前可用的全部技能名与全部 MCP 工具名(根节点默认全选) */
|
|
231
|
+
async function allSkillsAndTools() {
|
|
232
|
+
let skills = [];
|
|
233
|
+
try {
|
|
234
|
+
const dirs = (await buildCfg()).skillDirs?.length ? (await buildCfg()).skillDirs : defaultSkillDirs();
|
|
235
|
+
const listed = await listSkills(dirs);
|
|
236
|
+
skills = (Array.isArray(listed) ? listed : []).map((x) => x.name);
|
|
237
|
+
} catch {
|
|
238
|
+
skills = [];
|
|
239
|
+
}
|
|
240
|
+
let tools = [];
|
|
241
|
+
try {
|
|
242
|
+
const snap = await mcpSnapshot();
|
|
243
|
+
for (const server of snap.servers || []) {
|
|
244
|
+
for (const tool of server.tools || []) tools.push(`${server.name}.${tool.name}`);
|
|
245
|
+
}
|
|
246
|
+
} catch {
|
|
247
|
+
tools = [];
|
|
248
|
+
}
|
|
249
|
+
return { skills, tools };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function mcpSnapshot() {
|
|
253
|
+
const cfg = await buildCfg();
|
|
254
|
+
const specs = cfg.mcpServers || {};
|
|
255
|
+
const names = Object.keys(specs);
|
|
256
|
+
if (!names.length) return { servers: [] };
|
|
257
|
+
|
|
258
|
+
const pool = await ensureMcpPool();
|
|
259
|
+
if (!pool) {
|
|
260
|
+
return {
|
|
261
|
+
servers: names.map((n) => ({ name: n, ok: false, error: 'MCP 模块未就绪', tools: [] })),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
for (const n of names) {
|
|
265
|
+
try {
|
|
266
|
+
await pool.connect(n, specs[n]);
|
|
267
|
+
} catch {
|
|
268
|
+
// 连接失败的状态由 pool.status() 反映,这里不重复报
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return { servers: pool.status() };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ---------- 小工具 ----------
|
|
275
|
+
|
|
276
|
+
const json = (res, code, obj) => {
|
|
277
|
+
const body = JSON.stringify(obj);
|
|
278
|
+
res.writeHead(code, {
|
|
279
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
280
|
+
'Content-Length': Buffer.byteLength(body),
|
|
281
|
+
'Cache-Control': 'no-store',
|
|
282
|
+
});
|
|
283
|
+
res.end(body);
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
async function readBody(req, limit = 2 * 1024 * 1024) {
|
|
287
|
+
const chunks = [];
|
|
288
|
+
let size = 0;
|
|
289
|
+
for await (const c of req) {
|
|
290
|
+
size += c.length;
|
|
291
|
+
if (size > limit) throw new Error('请求体过大');
|
|
292
|
+
chunks.push(c);
|
|
293
|
+
}
|
|
294
|
+
if (!size) return {};
|
|
295
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const nowId = () => 'n' + Math.random().toString(36).slice(2, 8);
|
|
299
|
+
|
|
300
|
+
// PUT /api/board 只允许改这些字段:拖动不该把物料冲掉
|
|
301
|
+
const LAYOUT_FIELDS = ['x', 'y', 'label', 'task', 'criteria', 'model', 'provider', 'reasoningEffort', 'fallback', 'fallbackEfforts', 'type', 'skills', 'tools', 'verify', 'tool', 'args'];
|
|
302
|
+
|
|
303
|
+
// ---------- 导出 DSH 预设 ----------
|
|
304
|
+
|
|
305
|
+
// 懒加载:preset-gen.mjs 由另一个任务并行写,文件暂时不存在时不能把服务带崩
|
|
306
|
+
let presetGen = null;
|
|
307
|
+
async function getPresetGen() {
|
|
308
|
+
if (presetGen) return presetGen;
|
|
309
|
+
presetGen = await import('./preset-gen.mjs');
|
|
310
|
+
return presetGen;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** 给用户照着敲的安装命令。不自动改 profile —— 改 profile 的风险太大,踩过坑。 */
|
|
314
|
+
// ---------- 把生成的预设装进 profile ----------
|
|
315
|
+
|
|
316
|
+
// 预设写到哪个 profile:默认 DSH 桌面版的 desktop,允许用 AGENTFLOW_PROFILE 覆盖
|
|
317
|
+
const PRESET_PROFILE = () =>
|
|
318
|
+
path.join(os.homedir(), '.dsh', 'profiles', process.env.AGENTFLOW_PROFILE || 'desktop');
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* 把生成好的 preset bundle 装进 desktop profile。
|
|
322
|
+
* 幂等:已经装过就只更新 link 和文件,不会重复追加 patch 行。
|
|
323
|
+
* 每次动手前都备份 profile 的两个文件 —— 这里踩过「清空 profile」的坑。
|
|
324
|
+
*/
|
|
325
|
+
function buildInstallHint(dir, slug) {
|
|
326
|
+
const pkg = `dsh-plugin-flow-${slug}`;
|
|
327
|
+
const profile = path.join(os.homedir(), '.dsh', 'profiles', 'desktop');
|
|
328
|
+
const addDep = `node -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.dependencies['${pkg}']='link:${dir}';fs.writeFileSync('package.json',JSON.stringify(p,null,2)+String.fromCharCode(10))"`;
|
|
329
|
+
return [
|
|
330
|
+
`cd ${profile}`,
|
|
331
|
+
'# 1. 把插件加进 dependencies(保留原有内容)',
|
|
332
|
+
addDep,
|
|
333
|
+
'# 2. 建符号链接',
|
|
334
|
+
`ln -sfn ${dir} node_modules/${pkg}`,
|
|
335
|
+
'# 3. 追加 patch 行',
|
|
336
|
+
"cat >> cordis.patch.yml <<'YAML'",
|
|
337
|
+
'- insert:',
|
|
338
|
+
` - id: preset-flow-${slug}`,
|
|
339
|
+
` name: '${pkg}'`,
|
|
340
|
+
'YAML',
|
|
341
|
+
'# 4. 重启 DSH,新会话的预设列表里会出现它',
|
|
342
|
+
].join(String.fromCharCode(10));
|
|
343
|
+
}
|
|
344
|
+
// ---------- 跑图 ----------
|
|
345
|
+
|
|
346
|
+
let running = false;
|
|
347
|
+
|
|
348
|
+
async function handleRun(req, res, boardId) {
|
|
349
|
+
if (running) {
|
|
350
|
+
return json(res, 409, { error: '已经有一次运行在进行中,等它结束再来' });
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
let payload = {};
|
|
354
|
+
try {
|
|
355
|
+
payload = await readBody(req);
|
|
356
|
+
} catch (err) {
|
|
357
|
+
return json(res, 400, { error: `请求体解析失败:${err.message}` });
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// 先定画布再开 SSE:画布不存在要回普通 JSON 404,不能已经开始推流
|
|
361
|
+
let ctx;
|
|
362
|
+
try {
|
|
363
|
+
ctx = await resolveBoardCtx(boardId);
|
|
364
|
+
} catch (err) {
|
|
365
|
+
return json(res, Number(err && err.status) || 500, { error: String((err && err.message) || err) });
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
running = true;
|
|
369
|
+
|
|
370
|
+
res.writeHead(200, {
|
|
371
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
372
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
373
|
+
Connection: 'keep-alive',
|
|
374
|
+
'X-Accel-Buffering': 'no',
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
const send = (ev) => {
|
|
378
|
+
if (res.writableEnded) return;
|
|
379
|
+
res.write(`data: ${JSON.stringify(ev)}\n\n`);
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
// 心跳,防止中间层掐断
|
|
383
|
+
const heartbeat = setInterval(() => {
|
|
384
|
+
try {
|
|
385
|
+
if (!res.writableEnded) res.write(': ping\n\n');
|
|
386
|
+
} catch {}
|
|
387
|
+
}, 15000);
|
|
388
|
+
|
|
389
|
+
// 注意:不能监听 req 的 close —— POST 请求体读完后 req 就会 close,
|
|
390
|
+
// 那是「请求流结束」而不是「客户端断开」。要监听 res。
|
|
391
|
+
let closed = false;
|
|
392
|
+
res.on('close', () => { closed = true; });
|
|
393
|
+
|
|
394
|
+
try {
|
|
395
|
+
const board = ctx.board;
|
|
396
|
+
const cfg = await buildCfg();
|
|
397
|
+
// 挂了工具才需要 MCP 连接池;mock 模式不连
|
|
398
|
+
let pool = null;
|
|
399
|
+
if (!cfg.mock) {
|
|
400
|
+
const wanted = new Set();
|
|
401
|
+
for (const n of board.nodes) {
|
|
402
|
+
for (const t of n.tools || []) wanted.add(String(t).split('.')[0]);
|
|
403
|
+
// MCP 节点用的是单数 tool 字段,别漏了它
|
|
404
|
+
if (n.type === 'mcp' && n.tool) wanted.add(String(n.tool).split('.')[0]);
|
|
405
|
+
}
|
|
406
|
+
if (wanted.size) {
|
|
407
|
+
pool = await ensureMcpPool();
|
|
408
|
+
if (pool) {
|
|
409
|
+
for (const name of wanted) {
|
|
410
|
+
const spec = cfg.mcpServers[name];
|
|
411
|
+
if (!spec) continue;
|
|
412
|
+
try {
|
|
413
|
+
await pool.connect(name, spec);
|
|
414
|
+
} catch {
|
|
415
|
+
// 连不上就让这个工具在调用时返回错误,不阻塞整图
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
const callLLM = makeCaller({
|
|
422
|
+
bridgeDir: cfg.bridgeDir,
|
|
423
|
+
mock: cfg.mock,
|
|
424
|
+
mcpPool: pool,
|
|
425
|
+
skillDirs: cfg.skillDirs,
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
const targets = Array.isArray(payload.targets) && payload.targets.length
|
|
429
|
+
? payload.targets
|
|
430
|
+
: null;
|
|
431
|
+
|
|
432
|
+
// 先把目标节点重置成 idle,界面上才能看到重新开始
|
|
433
|
+
const verdicts = new Map();
|
|
434
|
+
const cleared = { ...board, nodes: board.nodes.map((n) => {
|
|
435
|
+
if (targets && !targets.includes(n.id)) return n;
|
|
436
|
+
return { ...n, status: 'idle', error: null };
|
|
437
|
+
}) };
|
|
438
|
+
send({ type: 'run:start', targets: targets || 'all', mock: cfg.mock });
|
|
439
|
+
|
|
440
|
+
const result = await runBoard(cleared, {
|
|
441
|
+
targets: targets || undefined,
|
|
442
|
+
// MCP 节点要它做确定性调用(不经过模型)
|
|
443
|
+
mcpPool: pool,
|
|
444
|
+
callLLM: async (node, prompt) => {
|
|
445
|
+
if (closed) throw new Error('客户端已断开');
|
|
446
|
+
return callLLM(node, prompt);
|
|
447
|
+
},
|
|
448
|
+
// 结果判断:human 挂起等人,self 让模型自评,jev 调 TypeSafe
|
|
449
|
+
verifyNode: async (node, text) => {
|
|
450
|
+
const mode = node.verify;
|
|
451
|
+
if (!mode) return null;
|
|
452
|
+
if (mode === 'human') return { pending: true };
|
|
453
|
+
if (mode === 'self') {
|
|
454
|
+
const v = await selfVerify(callLLM, node, text);
|
|
455
|
+
return { by: 'self', pass: v.pass, reason: v.reason, at: Date.now() };
|
|
456
|
+
}
|
|
457
|
+
if (mode === 'jev') {
|
|
458
|
+
try {
|
|
459
|
+
const v = await askJev({
|
|
460
|
+
apiKey: cfg.jev?.apiKey,
|
|
461
|
+
proxy: cfg.jev?.proxy,
|
|
462
|
+
content: text,
|
|
463
|
+
criteria: node.criteria,
|
|
464
|
+
});
|
|
465
|
+
return {
|
|
466
|
+
by: 'jev',
|
|
467
|
+
pass: v.pass,
|
|
468
|
+
reason: `score=${v.score.toFixed(2)}`,
|
|
469
|
+
at: Date.now(),
|
|
470
|
+
};
|
|
471
|
+
} catch (err) {
|
|
472
|
+
return { by: 'jev', pass: false, reason: String(err.message || err), at: Date.now() };
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return null;
|
|
476
|
+
},
|
|
477
|
+
emit: (ev) => {
|
|
478
|
+
send(ev);
|
|
479
|
+
if (ev.type === 'node:done' && ev.verdict) verdicts.set(ev.id, ev.verdict);
|
|
480
|
+
if (ev.type === 'node:start') {
|
|
481
|
+
mutateBoardById(ctx.id, (b) => {
|
|
482
|
+
const n = b.nodes.find((x) => x.id === ev.id);
|
|
483
|
+
if (n) n.status = 'running';
|
|
484
|
+
return b;
|
|
485
|
+
}).catch(() => {});
|
|
486
|
+
}
|
|
487
|
+
},
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
// 把结果写回画布(这是物料落地的地方)
|
|
491
|
+
await mutateBoardById(ctx.id, (b) => {
|
|
492
|
+
for (const n of b.nodes) {
|
|
493
|
+
if (!result.state.has(n.id)) continue;
|
|
494
|
+
const st = result.state.get(n.id);
|
|
495
|
+
if (targets && !targets.includes(n.id) && st === 'blocked') {
|
|
496
|
+
// 不在目标里被当成 blocked 的,别污染原状态
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
if (st === 'done' || st === 'review') n.status = st;
|
|
500
|
+
else if (st === 'failed') n.status = 'failed';
|
|
501
|
+
else if (st === 'blocked') n.status = 'blocked';
|
|
502
|
+
|
|
503
|
+
const c = result.content.get(n.id);
|
|
504
|
+
if ((st === 'done' || st === 'review') && c) n.content = c;
|
|
505
|
+
if (result.usedModel.get(n.id)) {
|
|
506
|
+
n.usage = { model: result.usedModel.get(n.id), ...(result.usage.get(n.id) || {}) };
|
|
507
|
+
}
|
|
508
|
+
if (st === 'failed') n.error = '该节点所有模型都失败';
|
|
509
|
+
if (verdicts.has(n.id)) n.verdict = verdicts.get(n.id);
|
|
510
|
+
else if (st === 'review') n.verdict = null;
|
|
511
|
+
}
|
|
512
|
+
return b;
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
// run:end 由引擎在 runBoard 末尾 emit,这里不再重复发
|
|
516
|
+
} catch (err) {
|
|
517
|
+
send({ type: 'run:error', error: String(err && err.message || err) });
|
|
518
|
+
} finally {
|
|
519
|
+
clearInterval(heartbeat);
|
|
520
|
+
running = false;
|
|
521
|
+
if (!res.writableEnded) res.end();
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ---------- 路由 ----------
|
|
526
|
+
|
|
527
|
+
const server = http.createServer(async (req, res) => {
|
|
528
|
+
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
529
|
+
const p = url.pathname;
|
|
530
|
+
const method = req.method;
|
|
531
|
+
|
|
532
|
+
// DSH 的客户端插件在 dsh-app:// 里跑,来调本机 API 属于跨源调用。
|
|
533
|
+
// 只放行本机壳与本机页面,不放 *:导出预设会改写 profile,不能让任意网页触发。
|
|
534
|
+
const origin = String(req.headers.origin || '');
|
|
535
|
+
const originOk = !origin
|
|
536
|
+
|| origin.startsWith('dsh-app://')
|
|
537
|
+
|| /^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(origin);
|
|
538
|
+
if (p.startsWith('/api/')) {
|
|
539
|
+
if (originOk && origin) {
|
|
540
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
541
|
+
res.setHeader('Vary', 'Origin');
|
|
542
|
+
}
|
|
543
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
|
544
|
+
res.setHeader('Access-Control-Allow-Headers', 'content-type, authorization');
|
|
545
|
+
res.setHeader('Access-Control-Max-Age', '600');
|
|
546
|
+
if (method === 'OPTIONS') {
|
|
547
|
+
res.writeHead(originOk ? 204 : 403);
|
|
548
|
+
return res.end();
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
try {
|
|
553
|
+
// --- 静态页 ---
|
|
554
|
+
// 优先用 React 版(web/dist,npm run build 的产物);
|
|
555
|
+
// 没构建过就退回老的单文件 index.html,保证随时能开。
|
|
556
|
+
if (method === 'GET' && !p.startsWith('/api/')) {
|
|
557
|
+
const dist = path.join(ROOT, 'web', 'dist');
|
|
558
|
+
const distIndex = path.join(dist, 'index.html');
|
|
559
|
+
const hasDist = await fs
|
|
560
|
+
.access(distIndex)
|
|
561
|
+
.then(() => true)
|
|
562
|
+
.catch(() => false);
|
|
563
|
+
|
|
564
|
+
if (hasDist) {
|
|
565
|
+
// 真实资源文件(/assets/xxx.js 等)
|
|
566
|
+
if (p !== '/' && p !== '/index.html') {
|
|
567
|
+
const target = path.join(dist, decodeURIComponent(p));
|
|
568
|
+
// 防目录穿越:解析后的路径必须还在 dist 里
|
|
569
|
+
if (target.startsWith(dist)) {
|
|
570
|
+
const file = await fs.readFile(target).catch(() => null);
|
|
571
|
+
if (file) {
|
|
572
|
+
const type = MIME[path.extname(target)] || 'application/octet-stream';
|
|
573
|
+
res.writeHead(200, { 'Content-Type': type });
|
|
574
|
+
return res.end(file);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
const html = await fs.readFile(distIndex);
|
|
579
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
580
|
+
return res.end(html);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (p === '/' || p === '/index.html') {
|
|
584
|
+
const html = await fs.readFile(path.join(ROOT, 'index.html'), 'utf8').catch(() => null);
|
|
585
|
+
if (html == null) {
|
|
586
|
+
res.writeHead(503, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
587
|
+
return res.end('<h1>前端还没构建</h1><p>cd web && npm run build</p>');
|
|
588
|
+
}
|
|
589
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
590
|
+
return res.end(html);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// --- 多画布(契约 docs/boards-contract.md)---
|
|
595
|
+
if (p === '/api/boards' && method === 'GET') {
|
|
596
|
+
return json(res, 200, { boards: await listBoards() });
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (p === '/api/boards' && method === 'POST') {
|
|
600
|
+
const body = await readBody(req).catch(() => ({}));
|
|
601
|
+
const out = await createBoard({ name: body.name, presetId: body.presetId });
|
|
602
|
+
return json(res, 200, { board: out.board, meta: out.meta });
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// 从预设生成单卡片画布:P1 再做(需要 presets.mjs 暴露 persona/plugins,当前没有)
|
|
606
|
+
if (p === '/api/boards/import-preset' && method === 'POST') {
|
|
607
|
+
const body = await readBody(req).catch(() => ({}));
|
|
608
|
+
const presetId = body && typeof body.presetId === 'string' ? body.presetId.trim() : '';
|
|
609
|
+
if (!presetId) return json(res, 400, { error: '需要 presetId' });
|
|
610
|
+
|
|
611
|
+
const boardsMod = await import('./boards.mjs');
|
|
612
|
+
const { exportPresetSpec } = await import('./presets.mjs');
|
|
613
|
+
|
|
614
|
+
// 幂等:已有同 id 画布就直接返回
|
|
615
|
+
const existing = await boardsMod.getBoard(presetId).catch(() => null);
|
|
616
|
+
if (existing) return json(res, 200, { board: existing, reused: true });
|
|
617
|
+
|
|
618
|
+
try {
|
|
619
|
+
const spec = await exportPresetSpec(PRESET_PROFILE(), presetId);
|
|
620
|
+
|
|
621
|
+
// 技能:本地全部勾上(用户自建预设本来就是全套)
|
|
622
|
+
let skills = [];
|
|
623
|
+
try {
|
|
624
|
+
const skillsMod = await import('./skills.mjs');
|
|
625
|
+
const dirs = (await buildCfg()).skillDirs?.length
|
|
626
|
+
? (await buildCfg()).skillDirs
|
|
627
|
+
: skillsMod.defaultSkillDirs();
|
|
628
|
+
// listSkills 返回数组,不是 { skills }
|
|
629
|
+
const listed = await skillsMod.listSkills(dirs);
|
|
630
|
+
skills = (Array.isArray(listed) ? listed : []).map((x) => x.name);
|
|
631
|
+
} catch {
|
|
632
|
+
skills = [];
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// MCP 工具:能列出来的全勾(连不上就空着,不阻塞)
|
|
636
|
+
let tools = [];
|
|
637
|
+
try {
|
|
638
|
+
const snap = await mcpSnapshot();
|
|
639
|
+
for (const server of snap.servers || []) {
|
|
640
|
+
for (const tool of server.tools || []) tools.push(`${server.name}.${tool.name}`);
|
|
641
|
+
}
|
|
642
|
+
} catch {
|
|
643
|
+
tools = [];
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// 模型:目录里第一个可用路由的第一个模型
|
|
647
|
+
let provider = '';
|
|
648
|
+
let model = '';
|
|
649
|
+
try {
|
|
650
|
+
const catalog = await readDshModelCatalog(BRIDGE_DIR);
|
|
651
|
+
const first = (catalog.providers || []).find((x) => (x.models || []).length);
|
|
652
|
+
if (first) {
|
|
653
|
+
provider = first.id;
|
|
654
|
+
model = first.models[0].id;
|
|
655
|
+
}
|
|
656
|
+
} catch {
|
|
657
|
+
/* 目录拿不到就留空,节点里再选 */
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const created = await boardsMod.createBoard({ name: spec.name || presetId, presetId });
|
|
661
|
+
const node = {
|
|
662
|
+
id: 'n1',
|
|
663
|
+
label: spec.name || presetId,
|
|
664
|
+
type: 'llm',
|
|
665
|
+
provider,
|
|
666
|
+
model,
|
|
667
|
+
reasoningEffort: '',
|
|
668
|
+
fallback: [],
|
|
669
|
+
fallbackEfforts: {},
|
|
670
|
+
task: spec.persona || spec.description || '',
|
|
671
|
+
criteria: spec.description || '',
|
|
672
|
+
skills,
|
|
673
|
+
tools,
|
|
674
|
+
verify: '',
|
|
675
|
+
content: '',
|
|
676
|
+
status: 'idle',
|
|
677
|
+
usage: null,
|
|
678
|
+
error: null,
|
|
679
|
+
verdict: null,
|
|
680
|
+
x: 80,
|
|
681
|
+
y: 80,
|
|
682
|
+
};
|
|
683
|
+
const board = {
|
|
684
|
+
id: presetId,
|
|
685
|
+
name: spec.name || presetId,
|
|
686
|
+
nodes: [node],
|
|
687
|
+
edges: [],
|
|
688
|
+
};
|
|
689
|
+
await boardsMod.saveBoard(presetId, board);
|
|
690
|
+
return json(res, 200, {
|
|
691
|
+
board,
|
|
692
|
+
meta: (created && created.meta) || null,
|
|
693
|
+
source: spec.source,
|
|
694
|
+
created: true,
|
|
695
|
+
skills: skills.length,
|
|
696
|
+
tools: tools.length,
|
|
697
|
+
});
|
|
698
|
+
} catch (err) {
|
|
699
|
+
return json(res, err && err.status ? err.status : 400, {
|
|
700
|
+
error: String((err && err.message) || err),
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// --- 草稿:手动保存前的未落定修改 ---
|
|
706
|
+
{
|
|
707
|
+
const draftMatch = p.match(/^\/api\/boards\/([^/]+)\/draft$/);
|
|
708
|
+
if (draftMatch) {
|
|
709
|
+
let boardId = '';
|
|
710
|
+
try {
|
|
711
|
+
boardId = decodeURIComponent(draftMatch[1]);
|
|
712
|
+
} catch {
|
|
713
|
+
boardId = '';
|
|
714
|
+
}
|
|
715
|
+
if (method === 'GET') {
|
|
716
|
+
const rec = await getDraft(boardId);
|
|
717
|
+
if (!rec) return json(res, 404, { error: `没有草稿:${boardId}` });
|
|
718
|
+
return json(res, 200, { draft: rec.board, updatedAt: rec.updatedAt });
|
|
719
|
+
}
|
|
720
|
+
if (method === 'PUT') {
|
|
721
|
+
const body = await readBody(req);
|
|
722
|
+
const incoming = body && typeof body.board === 'object' && body.board ? body.board : null;
|
|
723
|
+
if (!incoming) return json(res, 400, { error: '需要 { board }' });
|
|
724
|
+
const out = await saveDraft(boardId, incoming);
|
|
725
|
+
return json(res, 200, { ok: true, updatedAt: out.updatedAt });
|
|
726
|
+
}
|
|
727
|
+
if (method === 'DELETE') {
|
|
728
|
+
await deleteDraft(boardId);
|
|
729
|
+
return json(res, 200, { ok: true });
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
{
|
|
735
|
+
const boardMatch = p.match(/^\/api\/boards\/([^/]+)$/);
|
|
736
|
+
if (boardMatch) {
|
|
737
|
+
let boardId = '';
|
|
738
|
+
try {
|
|
739
|
+
boardId = decodeURIComponent(boardMatch[1]);
|
|
740
|
+
} catch {
|
|
741
|
+
boardId = '';
|
|
742
|
+
}
|
|
743
|
+
if (method === 'GET') {
|
|
744
|
+
const ctx = await resolveBoardCtx(boardId);
|
|
745
|
+
return json(res, 200, { board: ctx.board });
|
|
746
|
+
}
|
|
747
|
+
if (method === 'PUT') {
|
|
748
|
+
const body = await readBody(req);
|
|
749
|
+
// 契约允许 { board } 或裸 board
|
|
750
|
+
const incoming = body && typeof body.board === 'object' && body.board ? body.board : body;
|
|
751
|
+
// 根节点保护:自动保存也不能把调度卡弄丢(它是对话入口)
|
|
752
|
+
const before = await getBoard(boardId).catch(() => null);
|
|
753
|
+
const roots = (before?.nodes || []).filter((n) => n.type === 'orchestrator').map((n) => n.id);
|
|
754
|
+
if (roots.length) {
|
|
755
|
+
const incomingIds = new Set((incoming?.nodes || []).map((n) => n.id));
|
|
756
|
+
const lost = roots.filter((id) => !incomingIds.has(id));
|
|
757
|
+
if (lost.length) {
|
|
758
|
+
return json(res, 400, { error: `根节点(调度卡)不能删除:${lost.join(', ')}` });
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
try {
|
|
762
|
+
const gen = await import('./preset-gen.mjs');
|
|
763
|
+
gen.assertRootHasNoUpstream(incoming);
|
|
764
|
+
} catch (err) {
|
|
765
|
+
if (err && err.status) return json(res, 400, { error: String(err.message) });
|
|
766
|
+
}
|
|
767
|
+
const out = await saveBoard(boardId, incoming);
|
|
768
|
+
return json(res, 200, { ok: true, meta: out.meta });
|
|
769
|
+
}
|
|
770
|
+
if (method === 'PATCH') {
|
|
771
|
+
const body = await readBody(req);
|
|
772
|
+
const name = body && typeof body === 'object' ? (body.name ?? body.board?.name) : undefined;
|
|
773
|
+
const out = await renameBoard(boardId, name);
|
|
774
|
+
return json(res, 200, { ok: true, meta: out.meta });
|
|
775
|
+
}
|
|
776
|
+
if (method === 'DELETE') {
|
|
777
|
+
await deleteBoard(boardId);
|
|
778
|
+
return json(res, 200, { ok: true });
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// --- 旧 /api/board:过渡期只读写 flow-agentflow ---
|
|
784
|
+
if (p === '/api/board' && method === 'GET') {
|
|
785
|
+
const ctx = await resolveBoardCtx(FLOW_BOARD_ID);
|
|
786
|
+
return json(res, 200, ctx.board);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
if (p === '/api/board' && method === 'PUT') {
|
|
790
|
+
const incoming = await readBody(req);
|
|
791
|
+
const out = await mutateBoardById(FLOW_BOARD_ID, (b) => {
|
|
792
|
+
// 只吃布局类字段;content/status/usage 是物料与运行时状态,不接受前端覆盖
|
|
793
|
+
for (const inc of incoming.nodes || []) {
|
|
794
|
+
const n = b.nodes.find((x) => x.id === inc.id);
|
|
795
|
+
if (!n) continue;
|
|
796
|
+
for (const f of LAYOUT_FIELDS) {
|
|
797
|
+
if (inc[f] !== undefined) n[f] = inc[f];
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
if (Array.isArray(incoming.edges)) b.edges = incoming.edges;
|
|
801
|
+
if (incoming.name) b.name = incoming.name;
|
|
802
|
+
return b;
|
|
803
|
+
});
|
|
804
|
+
return json(res, 200, out.board);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// --- 节点(?board=<id> 定位画布,缺省第一张)---
|
|
808
|
+
if (p === '/api/nodes' && method === 'POST') {
|
|
809
|
+
const ctx = await resolveBoardCtx(url.searchParams.get('board'));
|
|
810
|
+
const body = await readBody(req);
|
|
811
|
+
const node = {
|
|
812
|
+
id: nowId(),
|
|
813
|
+
label: body.label || '新节点',
|
|
814
|
+
type: body.type || 'llm',
|
|
815
|
+
model: body.model || '',
|
|
816
|
+
provider: body.provider || '',
|
|
817
|
+
reasoningEffort: body.reasoningEffort || '',
|
|
818
|
+
fallbackEfforts: body.fallbackEfforts && typeof body.fallbackEfforts === 'object' ? body.fallbackEfforts : {},
|
|
819
|
+
fallback: body.fallback || [],
|
|
820
|
+
x: Number(body.x) || 0,
|
|
821
|
+
y: Number(body.y) || 0,
|
|
822
|
+
task: body.task || '',
|
|
823
|
+
criteria: body.criteria || '',
|
|
824
|
+
skills: Array.isArray(body.skills) ? body.skills : [],
|
|
825
|
+
tools: Array.isArray(body.tools) ? body.tools : [],
|
|
826
|
+
// MCP 节点用这两个字段(单数 tool = server.tool,args 是参数模板)
|
|
827
|
+
tool: body.tool || '',
|
|
828
|
+
args: body.args && typeof body.args === 'object' ? body.args : {},
|
|
829
|
+
verify: body.verify || '',
|
|
830
|
+
verdict: null,
|
|
831
|
+
content: '',
|
|
832
|
+
status: 'idle',
|
|
833
|
+
usage: null,
|
|
834
|
+
error: null,
|
|
835
|
+
};
|
|
836
|
+
const out = await mutateBoardById(ctx.id, (b) => {
|
|
837
|
+
b.nodes.push(node);
|
|
838
|
+
// from:新节点是下游(from → new);to:新节点是上游(new → to)
|
|
839
|
+
if (body.from) b.edges.push({ from: String(body.from), to: node.id });
|
|
840
|
+
if (body.to) b.edges.push({ from: node.id, to: String(body.to) });
|
|
841
|
+
return b;
|
|
842
|
+
});
|
|
843
|
+
return json(res, 200, { node, board: out.board });
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const nodeMatch = p.match(/^\/api\/nodes\/([^/]+)$/);
|
|
847
|
+
if (nodeMatch && method === 'PATCH') {
|
|
848
|
+
const ctx = await resolveBoardCtx(url.searchParams.get('board'));
|
|
849
|
+
const id = decodeURIComponent(nodeMatch[1]);
|
|
850
|
+
const patch = await readBody(req);
|
|
851
|
+
// 根节点的类型是固定的(对话入口),不允许改
|
|
852
|
+
const currentBoard = await getBoard(ctx.id).catch(() => null);
|
|
853
|
+
const current = (currentBoard?.nodes || []).find((n) => n.id === id);
|
|
854
|
+
if (current && current.type === 'orchestrator' && patch.type !== undefined && patch.type !== 'orchestrator') {
|
|
855
|
+
return json(res, 400, { error: '根节点(调度卡)的类型固定为「调度(根)」,不能修改' });
|
|
856
|
+
}
|
|
857
|
+
let found = null;
|
|
858
|
+
await mutateBoardById(ctx.id, (b) => {
|
|
859
|
+
const n = b.nodes.find((x) => x.id === id);
|
|
860
|
+
if (!n) return b;
|
|
861
|
+
found = n;
|
|
862
|
+
for (const k of ['label', 'task', 'criteria', 'model', 'provider', 'reasoningEffort', 'fallback', 'fallbackEfforts', 'content', 'x', 'y', 'skills', 'tools', 'verify', 'tool', 'args', 'type']) {
|
|
863
|
+
if (patch[k] !== undefined) n[k] = patch[k];
|
|
864
|
+
}
|
|
865
|
+
return b;
|
|
866
|
+
});
|
|
867
|
+
if (!found) return json(res, 404, { error: '节点不存在' });
|
|
868
|
+
return json(res, 200, found);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
if (nodeMatch && method === 'DELETE') {
|
|
872
|
+
const ctx = await resolveBoardCtx(url.searchParams.get('board'));
|
|
873
|
+
const id = decodeURIComponent(nodeMatch[1]);
|
|
874
|
+
const guard = await getBoard(ctx.id).catch(() => null);
|
|
875
|
+
const target = (guard?.nodes || []).find((n) => n.id === id);
|
|
876
|
+
if (target && target.type === 'orchestrator') {
|
|
877
|
+
return json(res, 400, { error: '根节点(调度卡)是对话入口,不能删除' });
|
|
878
|
+
}
|
|
879
|
+
const out = await mutateBoardById(ctx.id, (b) => {
|
|
880
|
+
b.nodes = b.nodes.filter((n) => n.id !== id);
|
|
881
|
+
b.edges = b.edges.filter((e) => e.from !== id && e.to !== id);
|
|
882
|
+
return b;
|
|
883
|
+
});
|
|
884
|
+
return json(res, 200, out.board);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// --- 人工确认:处理 review 状态的节点 ---
|
|
888
|
+
const verifyMatch = p.match(/^\/api\/verify\/([^/]+)$/);
|
|
889
|
+
if (verifyMatch && method === 'POST') {
|
|
890
|
+
const ctx = await resolveBoardCtx(url.searchParams.get('board'));
|
|
891
|
+
const id = decodeURIComponent(verifyMatch[1]);
|
|
892
|
+
const body = await readBody(req);
|
|
893
|
+
const pass = Boolean(body.pass);
|
|
894
|
+
let found = null;
|
|
895
|
+
await mutateBoardById(ctx.id, (b) => {
|
|
896
|
+
const n = b.nodes.find((x) => x.id === id);
|
|
897
|
+
if (!n) return b;
|
|
898
|
+
found = n;
|
|
899
|
+
n.verdict = {
|
|
900
|
+
by: 'human',
|
|
901
|
+
pass,
|
|
902
|
+
reason: body.reason || (pass ? '人工通过' : '人工打回'),
|
|
903
|
+
at: Date.now(),
|
|
904
|
+
};
|
|
905
|
+
// 通过 → 正常完成;打回 → 回到 idle,内容保留,人可以改了再重跑
|
|
906
|
+
n.status = pass ? 'done' : 'idle';
|
|
907
|
+
return b;
|
|
908
|
+
});
|
|
909
|
+
if (!found) return json(res, 404, { error: '节点不存在' });
|
|
910
|
+
return json(res, 200, found);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// --- 插件加载打卡(诊断用)---
|
|
914
|
+
if (p === '/api/plugin-alive') {
|
|
915
|
+
const stage = (url.searchParams.get('stage') || '?');
|
|
916
|
+
const detail = url.searchParams.get('detail') || '';
|
|
917
|
+
console.log(`[client-plugin] 打卡: ${stage} @ ${new Date().toISOString()}`);
|
|
918
|
+
try {
|
|
919
|
+
await fs.appendFile('/tmp/agentflow-plugin-alive.log', `${new Date().toISOString()} ${stage}${detail ? ' :: ' + detail : ''}\n`);
|
|
920
|
+
} catch {}
|
|
921
|
+
res.writeHead(204, { 'Access-Control-Allow-Origin': '*' });
|
|
922
|
+
return res.end();
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// --- 用量预估(只算 token,不算钱)---
|
|
926
|
+
if (p === '/api/estimate' && method === 'GET') {
|
|
927
|
+
const ctx = await resolveBoardCtx(url.searchParams.get('board'));
|
|
928
|
+
try {
|
|
929
|
+
const { estimateTokens } = await import('./engine.mjs');
|
|
930
|
+
return json(res, 200, estimateTokens(ctx.board));
|
|
931
|
+
} catch {
|
|
932
|
+
return json(res, 200, { total: 0, perNode: {} });
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// --- 导出 DSH 预设:直接写进插件包,不再生成独立包 ---
|
|
937
|
+
if (p === '/api/export/preset' && method === 'POST') {
|
|
938
|
+
const ctx = await resolveBoardCtx(url.searchParams.get('board'));
|
|
939
|
+
const profileDir = PRESET_PROFILE();
|
|
940
|
+
const profileOk = await fs
|
|
941
|
+
.stat(profileDir)
|
|
942
|
+
.then((st) => st.isDirectory())
|
|
943
|
+
.catch(() => false);
|
|
944
|
+
if (!profileOk) {
|
|
945
|
+
return json(res, 400, {
|
|
946
|
+
error: `找不到 profile:${profileDir}(可用 AGENTFLOW_PROFILE 指定 profile 名)`,
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
const board = ctx.board;
|
|
950
|
+
const body = await readBody(req).catch(() => ({}));
|
|
951
|
+
// 自定义预设显示名(可选):只影响预设名,presetId 仍由画布决定
|
|
952
|
+
const presetName = body && typeof body.name === 'string' ? body.name.trim() : '';
|
|
953
|
+
if (!board.nodes || board.nodes.length === 0) {
|
|
954
|
+
return json(res, 400, { error: '画布是空的,先摆几个节点' });
|
|
955
|
+
}
|
|
956
|
+
let gen;
|
|
957
|
+
try {
|
|
958
|
+
gen = await getPresetGen();
|
|
959
|
+
} catch {
|
|
960
|
+
return json(res, 500, { error: '预设生成器还没就绪(preset-gen.mjs 缺失)' });
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// 调度根节点 = 对话入口 = persona 来源。导出前先补齐:
|
|
964
|
+
// 多节点无调度卡时自动补一张并写回画布;已有只允许一张,多了直接拒绝。
|
|
965
|
+
let ensured;
|
|
966
|
+
try {
|
|
967
|
+
const { skills, tools } = await allSkillsAndTools();
|
|
968
|
+
ensured = gen.ensureOrchestrator(board, { skills, tools });
|
|
969
|
+
gen.assertRootHasNoUpstream(ensured.board);
|
|
970
|
+
} catch (err) {
|
|
971
|
+
return json(res, 400, { error: String((err && err.message) || err) });
|
|
972
|
+
}
|
|
973
|
+
const exportBoard = ensured.board;
|
|
974
|
+
const addedOrchestrator = ensured.added
|
|
975
|
+
? { id: ensured.added.id, label: ensured.added.label }
|
|
976
|
+
: null;
|
|
977
|
+
const personaSource = gen.personaSource(board);
|
|
978
|
+
if (addedOrchestrator || ensured.refreshed) {
|
|
979
|
+
// 先写回画布再生成:根节点的人格/技能是固定模板,刷新后页面能看到最新内容
|
|
980
|
+
await saveBoard(ctx.id, exportBoard);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
try {
|
|
984
|
+
const out = await gen.installPresetIntoProfile(exportBoard, {
|
|
985
|
+
patchFile: path.join(PRESET_PROFILE(), 'cordis.patch.yml'),
|
|
986
|
+
...(presetName ? { name: presetName } : {}),
|
|
987
|
+
});
|
|
988
|
+
await markPresetSynced(ctx.id).catch(() => {});
|
|
989
|
+
return json(res, 200, {
|
|
990
|
+
ok: true,
|
|
991
|
+
profileDir: PRESET_PROFILE(),
|
|
992
|
+
file: out.file,
|
|
993
|
+
presetId: out.presetId,
|
|
994
|
+
presetName: out.name,
|
|
995
|
+
replaced: out.replaced,
|
|
996
|
+
personaPreview: gen.renderPersona(exportBoard),
|
|
997
|
+
addedOrchestrator,
|
|
998
|
+
personaSource,
|
|
999
|
+
needRestart: true,
|
|
1000
|
+
});
|
|
1001
|
+
} catch (err) {
|
|
1002
|
+
return json(res, 400, { error: String((err && err.message) || err) });
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// --- 自定义预设列表 / 复制 / 删除(设置页「AgentFlow」分区用)---
|
|
1007
|
+
if (p === '/api/presets' && method === 'GET') {
|
|
1008
|
+
try {
|
|
1009
|
+
const { listPresets } = await import('./presets.mjs');
|
|
1010
|
+
const presets = await listPresets(PRESET_PROFILE());
|
|
1011
|
+
return json(res, 200, { presets });
|
|
1012
|
+
} catch (err) {
|
|
1013
|
+
return json(res, 500, { error: String((err && err.message) || err) });
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
{
|
|
1018
|
+
const presetMatch = /^\/api\/presets\/([^/]+)(\/copy)?$/.exec(p);
|
|
1019
|
+
if (presetMatch) {
|
|
1020
|
+
const presetId = decodeURIComponent(presetMatch[1]);
|
|
1021
|
+
const isCopy = Boolean(presetMatch[2]);
|
|
1022
|
+
if (isCopy && method === 'POST') {
|
|
1023
|
+
const body = await readBody(req).catch(() => ({}));
|
|
1024
|
+
try {
|
|
1025
|
+
const { copyPreset } = await import('./presets.mjs');
|
|
1026
|
+
const out = await copyPreset(PRESET_PROFILE(), presetId, { name: body && body.name });
|
|
1027
|
+
return json(res, 200, { ok: true, ...out, needRestart: true });
|
|
1028
|
+
} catch (err) {
|
|
1029
|
+
return json(res, 400, { error: String((err && err.message) || err) });
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
if (!isCopy && method === 'DELETE') {
|
|
1033
|
+
try {
|
|
1034
|
+
const { deletePreset } = await import('./presets.mjs');
|
|
1035
|
+
const out = await deletePreset(PRESET_PROFILE(), presetId);
|
|
1036
|
+
return json(res, 200, { ok: true, ...out, needRestart: true });
|
|
1037
|
+
} catch (err) {
|
|
1038
|
+
return json(res, err && err.status ? err.status : 400, {
|
|
1039
|
+
error: String((err && err.message) || err),
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// --- 跑图 ---
|
|
1047
|
+
if (p === '/api/run' && method === 'POST') {
|
|
1048
|
+
return handleRun(req, res, url.searchParams.get('board'));
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// --- 技能列表 ---
|
|
1052
|
+
if (p === '/api/skills' && method === 'GET') {
|
|
1053
|
+
const cfg = await buildCfg();
|
|
1054
|
+
const dirs = cfg.skillDirs.length ? cfg.skillDirs : defaultSkillDirs();
|
|
1055
|
+
const skills = await listSkills(dirs).catch(() => []);
|
|
1056
|
+
return json(res, 200, { dirs, skills });
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// --- MCP 服务器与工具列表 ---
|
|
1060
|
+
if (p === '/api/mcp' && method === 'GET') {
|
|
1061
|
+
return json(res, 200, await mcpSnapshot());
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// --- 模型目录(只走 bridge;缓存由 bridge 侧承担,这里不再加一层) ---
|
|
1065
|
+
if (p === '/api/models' && method === 'GET') {
|
|
1066
|
+
return json(res, 200, await readDshModelCatalog(BRIDGE_DIR));
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// --- bridge 连接状态(前端「模型通道」那行细字) ---
|
|
1070
|
+
if (p === '/api/bridge' && method === 'GET') {
|
|
1071
|
+
return json(res, 200, await bridgeStatus());
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// --- 配置 ---
|
|
1075
|
+
if (p === '/api/config' && method === 'GET') {
|
|
1076
|
+
const cfg = await buildCfg();
|
|
1077
|
+
const providers = {};
|
|
1078
|
+
for (const [name, v] of Object.entries(cfg.providers)) {
|
|
1079
|
+
providers[name] = {
|
|
1080
|
+
label: v.label || name,
|
|
1081
|
+
baseURL: v.baseURL || '',
|
|
1082
|
+
model: v.model || '',
|
|
1083
|
+
hasKey: Boolean(v.hasKey),
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
const mcpServers = Object.keys(cfg.mcpServers || {}).map((name) => ({
|
|
1087
|
+
name,
|
|
1088
|
+
kind: cfg.mcpServers[name].url ? 'http' : 'stdio',
|
|
1089
|
+
}));
|
|
1090
|
+
return json(res, 200, {
|
|
1091
|
+
source: 'dsh',
|
|
1092
|
+
readOnly: true,
|
|
1093
|
+
mock: cfg.mock,
|
|
1094
|
+
providers,
|
|
1095
|
+
mcpServers,
|
|
1096
|
+
skillDirs: cfg.skillDirs,
|
|
1097
|
+
dshPaths: cfg.dsh?.paths || {},
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
if (p === '/api/config' && method === 'PUT') {
|
|
1102
|
+
const body = await readBody(req);
|
|
1103
|
+
const current = await loadConfig();
|
|
1104
|
+
const cfg = normalizeConfig(current);
|
|
1105
|
+
// DSH 版:模型与 MCP 由 DSH 配置决定,这里只保存 mock 开关和 jev 凭据
|
|
1106
|
+
const next = {
|
|
1107
|
+
mock: body.mock !== undefined ? Boolean(body.mock) : cfg.mock,
|
|
1108
|
+
jev: { ...(cfg.jev || {}) },
|
|
1109
|
+
};
|
|
1110
|
+
// Jev(TypeSafe)配置:apiKey / proxy 都由前端写,传 null 表示删掉
|
|
1111
|
+
if (body.jev && typeof body.jev === 'object') {
|
|
1112
|
+
for (const [k, v] of Object.entries(body.jev)) {
|
|
1113
|
+
if (v === null) delete next.jev[k];
|
|
1114
|
+
else next.jev[k] = v;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
await saveConfig(next);
|
|
1118
|
+
return json(res, 200, { ok: true, mock: next.mock });
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
return json(res, 404, { error: `没有这个路由:${method} ${p}` });
|
|
1122
|
+
} catch (err) {
|
|
1123
|
+
// httpError 带 status(404/400/409…),其余按 500 处理
|
|
1124
|
+
const status = Number(err && err.status) || 500;
|
|
1125
|
+
if (!res.headersSent) {
|
|
1126
|
+
json(res, status, { error: String(err && err.message || err) });
|
|
1127
|
+
} else {
|
|
1128
|
+
res.end();
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
// ---------- 启动 ----------
|
|
1134
|
+
// 先把画布目录准备好(迁移 / 首次建默认画布),再开始监听:
|
|
1135
|
+
// 否则就绪检查通过后立刻来的请求可能看到空索引。
|
|
1136
|
+
const boot = await ensureDefaultBoard(demoBoard);
|
|
1137
|
+
const bootBoards = await listBoards();
|
|
1138
|
+
const bootCurrent = await resolveBoard(null);
|
|
1139
|
+
|
|
1140
|
+
server.listen(PORT, HOST, async () => {
|
|
1141
|
+
console.log(`AgentFlow 已启动 http://${HOST}:${PORT}`);
|
|
1142
|
+
const cur = bootCurrent?.board;
|
|
1143
|
+
console.log(
|
|
1144
|
+
cur
|
|
1145
|
+
? `画布:${bootBoards.length} 张(当前「${cur.name}」,${cur.nodes.length} 节点 / ${cur.edges.length} 连线)`
|
|
1146
|
+
: `画布:${bootBoards.length} 张`,
|
|
1147
|
+
);
|
|
1148
|
+
if (boot.migrated) console.log('已从旧 data/board.json 迁移出 flow-agentflow(原文件保留)');
|
|
1149
|
+
const cfg = await buildCfg();
|
|
1150
|
+
console.log(`模式:${cfg.mock ? 'mock(不调真模型)' : '真实调用(模型通道:DSH 内核)'}`);
|
|
1151
|
+
});
|