@fieldwangai/agentflow 0.1.34 → 0.1.36

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.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * 本地 HTTP:静态 UI + /api/flows(GET/POST/HEAD)、/api/flows/import(POST multipart 导入 .yaml/.zip)、/api/flow/archive(POST)、/api/flow/delete(POST 永久删除)、/api/model-lists、/api/ui-context、/api/pipeline-recent-runs、/api/run-node-statuses(GET 某次 run 各节点磁盘状态)、/api/workspace-tree(GET 工作区目录树)、/api/nodes、/api/flow(GET/POST)、
3
- * /api/flow-editor-sync(POST 通知画布刷新)、/api/flow-editor-sync-events(GET SSE)、/api/flow/run(POST NDJSON 流式执行 agentflow apply --machine-readable)、/api/flow/run/stop(POST 终止运行)、
3
+ * /api/flow-editor-sync(POST 通知画布刷新)、/api/flow-editor-sync-events(GET SSE)、/api/flow/run(POST NDJSON 流式执行 agentflow apply --machine-readable)、/api/flow/run/stop(POST 终止运行)、/api/workspace/run/stop(POST 终止 Workspace 临时运行)、
4
4
  * /api/composer-agent(POST NDJSON;有 flow 时结束后 validate-flow,失败则自动 agent 修复至多 5 次)、
5
5
  * /api/agentflow-config(GET/POST 读写 ~/agentflow/config.json 的 opencodeProvider;POST 后执行 update-model-lists)、/api/update-model-lists(POST 可选 JSON body.opencodeProvider 覆盖本次拉取用的 Provider,未保存 config 也可用);
6
6
  * listen 后后台 updateModelLists
@@ -89,8 +89,10 @@ import {
89
89
  buildClearSessionCookie,
90
90
  buildSessionCookie,
91
91
  getAuthUserFromRequest,
92
+ isAuthUserAllowed,
92
93
  loginOrCreateUser,
93
94
  logoutRequest,
95
+ readUserAllowlist,
94
96
  } from "./auth.mjs";
95
97
  import { readUserEnvObject, readUserEnvRows, writeUserEnvRows } from "./user-env.mjs";
96
98
 
@@ -297,6 +299,45 @@ function writeSkillCollectionConfig(userCtx = {}, payload = {}, availableSkills
297
299
  return config;
298
300
  }
299
301
 
302
+ function upsertSkillhubCollectionGroup(userCtx = {}, collectionId = "", beforeSkills = [], afterSkills = [], collectionName = "") {
303
+ const rawCollectionId = String(collectionId || "").trim();
304
+ if (!rawCollectionId) return null;
305
+ const beforeKeys = new Set((Array.isArray(beforeSkills) ? beforeSkills : []).map((skill) => String(skill?.key || "")).filter(Boolean));
306
+ const addedKeys = (Array.isArray(afterSkills) ? afterSkills : [])
307
+ .map((skill) => String(skill?.key || "").trim())
308
+ .filter((key) => key && !beforeKeys.has(key));
309
+ const config = readSkillCollectionConfig(userCtx, afterSkills);
310
+ const groupId = slugifySkillCollectionId(`skillhub-collection-${rawCollectionId}`, "skillhub-collection");
311
+ const now = Date.now();
312
+ const existing = config.collections.find((collection) => collection.id === groupId);
313
+ const existingKeys = Array.isArray(existing?.skillKeys) ? existing.skillKeys : [];
314
+ const mergedKeys = Array.from(new Set([...existingKeys, ...addedKeys]));
315
+ const nextCollections = config.collections.filter((collection) => collection.id !== groupId);
316
+ nextCollections.push({
317
+ id: groupId,
318
+ name: String(collectionName || "").trim() || `SkillHub Collection ${rawCollectionId}`,
319
+ skillKeys: mergedKeys,
320
+ builtin: false,
321
+ createdAt: Number.isFinite(existing?.createdAt) ? existing.createdAt : now,
322
+ updatedAt: now,
323
+ });
324
+ return writeSkillCollectionConfig(userCtx, { version: 1, collections: nextCollections }, afterSkills);
325
+ }
326
+
327
+ function removeSkillhubCollectionGroup(userCtx = {}, collectionId = "", root = process.cwd()) {
328
+ const rawCollectionId = String(collectionId || "").trim();
329
+ if (!rawCollectionId) return null;
330
+ const availableSkills = listComposerSkills(PACKAGE_ROOT, root);
331
+ const config = readSkillCollectionConfig(userCtx, availableSkills);
332
+ const groupId = slugifySkillCollectionId(`skillhub-collection-${rawCollectionId}`, "skillhub-collection");
333
+ if (!config.collections.some((collection) => collection.id === groupId)) return config;
334
+ return writeSkillCollectionConfig(
335
+ userCtx,
336
+ { version: 1, collections: config.collections.filter((collection) => collection.id !== groupId) },
337
+ availableSkills,
338
+ );
339
+ }
340
+
300
341
  function runtimeEnvForUser(userCtx = {}, extra = {}) {
301
342
  return {
302
343
  ...process.env,
@@ -317,6 +358,448 @@ function readAgentflowUserConfigObject() {
317
358
  }
318
359
  }
319
360
 
361
+ function cursorMcpConfigPath() {
362
+ return path.join(os.homedir(), ".cursor", "mcp.json");
363
+ }
364
+
365
+ function readCursorMcpConfig() {
366
+ const p = cursorMcpConfigPath();
367
+ try {
368
+ if (!fs.existsSync(p)) return { mcpServers: {} };
369
+ const data = JSON.parse(fs.readFileSync(p, "utf-8"));
370
+ return data && typeof data === "object" && !Array.isArray(data) ? data : { mcpServers: {} };
371
+ } catch {
372
+ return { mcpServers: {} };
373
+ }
374
+ }
375
+
376
+ function userMcpPrivatePath(userCtx = {}) {
377
+ return path.join(getAgentflowUserDataRoot(userCtx.userId), "mcp-private.json");
378
+ }
379
+
380
+ function readUserMcpPrivate(userCtx = {}) {
381
+ const p = userMcpPrivatePath(userCtx);
382
+ try {
383
+ if (!fs.existsSync(p)) return { version: 1, servers: {} };
384
+ const data = JSON.parse(fs.readFileSync(p, "utf-8"));
385
+ const servers = data?.servers && typeof data.servers === "object" && !Array.isArray(data.servers) ? data.servers : {};
386
+ return { version: 1, servers };
387
+ } catch {
388
+ return { version: 1, servers: {} };
389
+ }
390
+ }
391
+
392
+ function writeUserMcpPrivate(userCtx = {}, data = {}) {
393
+ const p = userMcpPrivatePath(userCtx);
394
+ const servers = data?.servers && typeof data.servers === "object" && !Array.isArray(data.servers) ? data.servers : {};
395
+ fs.mkdirSync(path.dirname(p), { recursive: true });
396
+ fs.writeFileSync(p, JSON.stringify({ version: 1, servers }, null, 2) + "\n", "utf-8");
397
+ return { version: 1, servers };
398
+ }
399
+
400
+ function normalizeMcpPrivateKeys(keys) {
401
+ return new Set((Array.isArray(keys) ? keys : []).map((key) => String(key || "").trim()).filter(Boolean));
402
+ }
403
+
404
+ function pickObjectKeys(obj, keys) {
405
+ const out = {};
406
+ for (const key of keys) {
407
+ if (obj && Object.prototype.hasOwnProperty.call(obj, key)) out[key] = String(obj[key] ?? "");
408
+ }
409
+ return out;
410
+ }
411
+
412
+ function omitObjectKeys(obj, keys) {
413
+ const out = {};
414
+ for (const [key, value] of Object.entries(obj && typeof obj === "object" ? obj : {})) {
415
+ if (!keys.has(key)) out[key] = value;
416
+ }
417
+ return out;
418
+ }
419
+
420
+ function privateKeyMetadataFromConfig(configValue = {}) {
421
+ const meta = configValue?.__agentflowPrivateKeys;
422
+ const env = Array.isArray(meta?.env) ? meta.env.map((key) => String(key || "").trim()).filter(Boolean) : [];
423
+ const headers = Array.isArray(meta?.headers) ? meta.headers.map((key) => String(key || "").trim()).filter(Boolean) : [];
424
+ return { env: Array.from(new Set(env)), headers: Array.from(new Set(headers)) };
425
+ }
426
+
427
+ function withPrivatePlaceholders(obj, keys) {
428
+ const out = { ...(obj && typeof obj === "object" && !Array.isArray(obj) ? obj : {}) };
429
+ for (const key of keys) {
430
+ if (key && !Object.prototype.hasOwnProperty.call(out, key)) out[key] = "";
431
+ }
432
+ return out;
433
+ }
434
+
435
+ function normalizeMcpServerConfig(value) {
436
+ const raw = value && typeof value === "object" && !Array.isArray(value) ? value : {};
437
+ const next = {};
438
+ const url = typeof raw.url === "string" ? raw.url.trim() : "";
439
+ const command = typeof raw.command === "string" ? raw.command.trim() : "";
440
+ const description = typeof raw.description === "string" ? raw.description.trim() : "";
441
+ if (url) next.url = url;
442
+ if (command) next.command = command;
443
+ if (Array.isArray(raw.args)) next.args = raw.args.map((x) => String(x)).filter((x) => x.length > 0);
444
+ if (raw.env && typeof raw.env === "object" && !Array.isArray(raw.env)) {
445
+ const env = {};
446
+ for (const [k, v] of Object.entries(raw.env)) {
447
+ const key = String(k || "").trim();
448
+ if (key) env[key] = String(v ?? "");
449
+ }
450
+ if (Object.keys(env).length) next.env = env;
451
+ }
452
+ if (raw.headers && typeof raw.headers === "object" && !Array.isArray(raw.headers)) {
453
+ const headers = {};
454
+ for (const [k, v] of Object.entries(raw.headers)) {
455
+ const key = String(k || "").trim();
456
+ if (key) headers[key] = String(v ?? "");
457
+ }
458
+ if (Object.keys(headers).length) next.headers = headers;
459
+ }
460
+ if (description) next.description = description;
461
+ for (const [k, v] of Object.entries(raw)) {
462
+ if (["url", "command", "args", "env", "headers", "description"].includes(k)) continue;
463
+ next[k] = v;
464
+ }
465
+ return next;
466
+ }
467
+
468
+ function readCursorMcpServers(userCtx = {}) {
469
+ const config = readCursorMcpConfig();
470
+ const privateConfig = readUserMcpPrivate(userCtx);
471
+ const rawServers = config.mcpServers && typeof config.mcpServers === "object" && !Array.isArray(config.mcpServers)
472
+ ? config.mcpServers
473
+ : {};
474
+ const servers = Object.entries(rawServers).map(([name, value]) => {
475
+ const publicValue = normalizeMcpServerConfig(value);
476
+ const privateValue = privateConfig.servers?.[name] && typeof privateConfig.servers[name] === "object" ? privateConfig.servers[name] : {};
477
+ const privateEnv = privateValue.env && typeof privateValue.env === "object" && !Array.isArray(privateValue.env) ? privateValue.env : {};
478
+ const privateHeaders = privateValue.headers && typeof privateValue.headers === "object" && !Array.isArray(privateValue.headers) ? privateValue.headers : {};
479
+ const privateMeta = privateKeyMetadataFromConfig(publicValue);
480
+ const privateEnvKeys = Array.from(new Set([...privateMeta.env, ...Object.keys(privateEnv)]));
481
+ const privateHeaderKeys = Array.from(new Set([...privateMeta.headers, ...Object.keys(privateHeaders)]));
482
+ const configValue = {
483
+ ...publicValue,
484
+ env: { ...withPrivatePlaceholders(publicValue.env || {}, privateEnvKeys), ...privateEnv },
485
+ headers: { ...withPrivatePlaceholders(publicValue.headers || {}, privateHeaderKeys), ...privateHeaders },
486
+ };
487
+ return {
488
+ name,
489
+ type: configValue.url ? "url" : "command",
490
+ url: typeof configValue.url === "string" ? configValue.url : "",
491
+ command: typeof configValue.command === "string" ? configValue.command : "",
492
+ args: Array.isArray(configValue.args) ? configValue.args : [],
493
+ env: configValue.env && typeof configValue.env === "object" ? configValue.env : {},
494
+ headers: configValue.headers && typeof configValue.headers === "object" ? configValue.headers : {},
495
+ description: typeof configValue.description === "string" ? configValue.description : "",
496
+ raw: configValue,
497
+ privateEnvKeys,
498
+ privateHeaderKeys,
499
+ };
500
+ }).sort((a, b) => a.name.localeCompare(b.name));
501
+ return { path: cursorMcpConfigPath(), servers };
502
+ }
503
+
504
+ function writeCursorMcpServer(payload = {}, userCtx = {}) {
505
+ const name = String(payload?.name || "").trim();
506
+ const nextName = String(payload?.nextName || payload?.name || "").trim();
507
+ if (!/^[A-Za-z0-9_.-]+$/.test(nextName)) throw new Error("Invalid MCP name");
508
+ const server = normalizeMcpServerConfig(payload?.server);
509
+ if (!server.url && !server.command) throw new Error("MCP server requires url or command");
510
+ const privateEnvKeys = normalizeMcpPrivateKeys(payload?.privateEnvKeys);
511
+ const privateHeaderKeys = normalizeMcpPrivateKeys(payload?.privateHeaderKeys);
512
+ const privateEnv = pickObjectKeys(server.env || {}, privateEnvKeys);
513
+ const privateHeaders = pickObjectKeys(server.headers || {}, privateHeaderKeys);
514
+ const publicServer = {
515
+ ...server,
516
+ env: omitObjectKeys(server.env || {}, privateEnvKeys),
517
+ headers: omitObjectKeys(server.headers || {}, privateHeaderKeys),
518
+ };
519
+ publicServer.env = withPrivatePlaceholders(publicServer.env, privateEnvKeys);
520
+ publicServer.headers = withPrivatePlaceholders(publicServer.headers, privateHeaderKeys);
521
+ if (privateEnvKeys.size || privateHeaderKeys.size) {
522
+ publicServer.__agentflowPrivateKeys = {
523
+ ...(privateEnvKeys.size ? { env: Array.from(privateEnvKeys) } : {}),
524
+ ...(privateHeaderKeys.size ? { headers: Array.from(privateHeaderKeys) } : {}),
525
+ };
526
+ } else {
527
+ delete publicServer.__agentflowPrivateKeys;
528
+ }
529
+ if (!Object.keys(publicServer.env).length) delete publicServer.env;
530
+ if (!Object.keys(publicServer.headers).length) delete publicServer.headers;
531
+ const p = cursorMcpConfigPath();
532
+ const config = readCursorMcpConfig();
533
+ const mcpServers = config.mcpServers && typeof config.mcpServers === "object" && !Array.isArray(config.mcpServers)
534
+ ? { ...config.mcpServers }
535
+ : {};
536
+ if (name && name !== nextName) delete mcpServers[name];
537
+ mcpServers[nextName] = publicServer;
538
+ const next = { ...config, mcpServers };
539
+ fs.mkdirSync(path.dirname(p), { recursive: true });
540
+ fs.writeFileSync(p, JSON.stringify(next, null, 2) + "\n", "utf-8");
541
+ const privateConfig = readUserMcpPrivate(userCtx);
542
+ const privateServers = { ...(privateConfig.servers || {}) };
543
+ if (name && name !== nextName) delete privateServers[name];
544
+ if (Object.keys(privateEnv).length || Object.keys(privateHeaders).length) {
545
+ privateServers[nextName] = {
546
+ ...(Object.keys(privateEnv).length ? { env: privateEnv } : {}),
547
+ ...(Object.keys(privateHeaders).length ? { headers: privateHeaders } : {}),
548
+ };
549
+ } else {
550
+ delete privateServers[nextName];
551
+ }
552
+ writeUserMcpPrivate(userCtx, { servers: privateServers });
553
+ return readCursorMcpServers(userCtx);
554
+ }
555
+
556
+ function deleteCursorMcpServer(name, userCtx = {}) {
557
+ const key = String(name || "").trim();
558
+ if (!key) throw new Error("Missing MCP name");
559
+ const p = cursorMcpConfigPath();
560
+ const config = readCursorMcpConfig();
561
+ const mcpServers = config.mcpServers && typeof config.mcpServers === "object" && !Array.isArray(config.mcpServers)
562
+ ? { ...config.mcpServers }
563
+ : {};
564
+ delete mcpServers[key];
565
+ const next = { ...config, mcpServers };
566
+ fs.mkdirSync(path.dirname(p), { recursive: true });
567
+ fs.writeFileSync(p, JSON.stringify(next, null, 2) + "\n", "utf-8");
568
+ const privateConfig = readUserMcpPrivate(userCtx);
569
+ const privateServers = { ...(privateConfig.servers || {}) };
570
+ delete privateServers[key];
571
+ writeUserMcpPrivate(userCtx, { servers: privateServers });
572
+ return readCursorMcpServers(userCtx);
573
+ }
574
+
575
+ function compactErrorMessage(error) {
576
+ const text = String(error?.message || error || "").trim();
577
+ return text.length > 260 ? `${text.slice(0, 257)}...` : text;
578
+ }
579
+
580
+ function parseMcpSsePayload(text) {
581
+ const events = [];
582
+ let data = [];
583
+ for (const rawLine of String(text || "").split(/\r?\n/g)) {
584
+ const line = rawLine.trimEnd();
585
+ if (!line) {
586
+ if (data.length) {
587
+ const joined = data.join("\n").trim();
588
+ if (joined) events.push(joined);
589
+ data = [];
590
+ }
591
+ continue;
592
+ }
593
+ if (line.startsWith("data:")) data.push(line.slice(5).trimStart());
594
+ }
595
+ if (data.length) events.push(data.join("\n").trim());
596
+ for (const event of events) {
597
+ try {
598
+ const parsed = JSON.parse(event);
599
+ if (parsed && typeof parsed === "object") return parsed;
600
+ } catch {}
601
+ }
602
+ return null;
603
+ }
604
+
605
+ async function mcpHttpRequest(url, headers, body, sessionId = "") {
606
+ const controller = new AbortController();
607
+ const timer = setTimeout(() => controller.abort(), 8000);
608
+ try {
609
+ const response = await fetch(url, {
610
+ method: "POST",
611
+ headers: {
612
+ "Accept": "application/json, text/event-stream",
613
+ "Content-Type": "application/json",
614
+ ...(headers || {}),
615
+ ...(sessionId ? { "Mcp-Session-Id": sessionId } : {}),
616
+ },
617
+ body: JSON.stringify(body),
618
+ signal: controller.signal,
619
+ });
620
+ const text = await response.text();
621
+ if (!response.ok) throw new Error(`${response.status} ${response.statusText}: ${text.slice(0, 180)}`);
622
+ const contentType = String(response.headers.get("content-type") || "").toLowerCase();
623
+ const parsed = contentType.includes("text/event-stream") ? parseMcpSsePayload(text) : JSON.parse(text || "{}");
624
+ return { message: parsed, sessionId: response.headers.get("mcp-session-id") || sessionId };
625
+ } finally {
626
+ clearTimeout(timer);
627
+ }
628
+ }
629
+
630
+ async function checkMcpHttpServer(server) {
631
+ const url = String(server?.raw?.url || server?.url || "").trim();
632
+ if (!url) throw new Error("Missing MCP URL");
633
+ const headers = server?.raw?.headers && typeof server.raw.headers === "object" ? server.raw.headers : {};
634
+ const init = await mcpHttpRequest(url, headers, {
635
+ jsonrpc: "2.0",
636
+ id: 1,
637
+ method: "initialize",
638
+ params: {
639
+ protocolVersion: "2024-11-05",
640
+ capabilities: {},
641
+ clientInfo: { name: "agentflow", version: "0.1.0" },
642
+ },
643
+ });
644
+ if (init.message?.error) throw new Error(init.message.error.message || "MCP initialize failed");
645
+ await mcpHttpRequest(url, headers, {
646
+ jsonrpc: "2.0",
647
+ method: "notifications/initialized",
648
+ params: {},
649
+ }, init.sessionId).catch(() => null);
650
+ const tools = await mcpHttpRequest(url, headers, {
651
+ jsonrpc: "2.0",
652
+ id: 2,
653
+ method: "tools/list",
654
+ params: {},
655
+ }, init.sessionId);
656
+ if (tools.message?.error) throw new Error(tools.message.error.message || "MCP tools/list failed");
657
+ return Array.isArray(tools.message?.result?.tools) ? tools.message.result.tools : [];
658
+ }
659
+
660
+ async function checkMcpStdioServer(server) {
661
+ const command = String(server?.raw?.command || server?.command || "").trim();
662
+ if (!command) throw new Error("Missing MCP command");
663
+ const args = Array.isArray(server?.raw?.args) ? server.raw.args.map(String) : [];
664
+ const env = server?.raw?.env && typeof server.raw.env === "object" ? server.raw.env : {};
665
+ const child = spawn(command, args, {
666
+ cwd: os.homedir(),
667
+ env: { ...process.env, ...env },
668
+ stdio: ["pipe", "pipe", "pipe"],
669
+ });
670
+ let buffer = "";
671
+ let stderr = "";
672
+ let processError = null;
673
+ const pending = new Map();
674
+ let nextId = 1;
675
+ const cleanup = () => {
676
+ for (const [, request] of pending) clearTimeout(request.timer);
677
+ pending.clear();
678
+ if (!child.killed) child.kill("SIGTERM");
679
+ };
680
+ const rejectPending = (error) => {
681
+ for (const [, request] of pending) {
682
+ clearTimeout(request.timer);
683
+ request.reject(error);
684
+ }
685
+ pending.clear();
686
+ };
687
+ child.on("error", (error) => {
688
+ processError = error;
689
+ rejectPending(error);
690
+ });
691
+ child.stdin.on("error", (error) => {
692
+ processError = error;
693
+ rejectPending(error);
694
+ });
695
+ child.stdout.setEncoding("utf8");
696
+ child.stderr.setEncoding("utf8");
697
+ child.stderr.on("data", (chunk) => {
698
+ stderr += String(chunk || "");
699
+ if (stderr.length > 2000) stderr = stderr.slice(-2000);
700
+ });
701
+ child.stdout.on("data", (chunk) => {
702
+ buffer += String(chunk || "");
703
+ const lines = buffer.split(/\r?\n/g);
704
+ buffer = lines.pop() || "";
705
+ for (const line of lines) {
706
+ const text = line.trim();
707
+ if (!text) continue;
708
+ let message = null;
709
+ try {
710
+ message = JSON.parse(text);
711
+ } catch {
712
+ continue;
713
+ }
714
+ const request = pending.get(message.id);
715
+ if (request) {
716
+ pending.delete(message.id);
717
+ clearTimeout(request.timer);
718
+ request.resolve(message);
719
+ }
720
+ }
721
+ });
722
+ const send = (method, params = {}, timeoutMs = 8000) => new Promise((resolve, reject) => {
723
+ if (processError) {
724
+ reject(processError);
725
+ return;
726
+ }
727
+ const id = nextId++;
728
+ const timer = setTimeout(() => {
729
+ pending.delete(id);
730
+ reject(new Error(`${method} timed out${stderr.trim() ? `: ${stderr.trim().slice(-220)}` : ""}`));
731
+ }, timeoutMs);
732
+ pending.set(id, { resolve, reject, timer });
733
+ child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n", (error) => {
734
+ if (!error) return;
735
+ pending.delete(id);
736
+ clearTimeout(timer);
737
+ reject(error);
738
+ });
739
+ });
740
+ const notify = (method, params = {}) => {
741
+ child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n");
742
+ };
743
+ try {
744
+ const init = await send("initialize", {
745
+ protocolVersion: "2024-11-05",
746
+ capabilities: {},
747
+ clientInfo: { name: "agentflow", version: "0.1.0" },
748
+ });
749
+ if (init?.error) throw new Error(init.error.message || "MCP initialize failed");
750
+ notify("notifications/initialized", {});
751
+ const tools = await send("tools/list", {}, 8000);
752
+ if (tools?.error) throw new Error(tools.error.message || "MCP tools/list failed");
753
+ return Array.isArray(tools?.result?.tools) ? tools.result.tools : [];
754
+ } finally {
755
+ cleanup();
756
+ }
757
+ }
758
+
759
+ async function checkMcpServer(server) {
760
+ const startedAt = Date.now();
761
+ try {
762
+ const tools = server?.type === "url" || server?.raw?.url
763
+ ? await checkMcpHttpServer(server)
764
+ : await checkMcpStdioServer(server);
765
+ return {
766
+ name: server.name,
767
+ ok: true,
768
+ status: "enabled",
769
+ toolCount: tools.length,
770
+ tools: tools.map((tool) => ({
771
+ name: String(tool?.name || ""),
772
+ description: String(tool?.description || ""),
773
+ })).filter((tool) => tool.name),
774
+ checkedAt: new Date().toISOString(),
775
+ elapsedMs: Date.now() - startedAt,
776
+ };
777
+ } catch (error) {
778
+ return {
779
+ name: server?.name || "",
780
+ ok: false,
781
+ status: "error",
782
+ error: compactErrorMessage(error),
783
+ toolCount: 0,
784
+ tools: [],
785
+ checkedAt: new Date().toISOString(),
786
+ elapsedMs: Date.now() - startedAt,
787
+ };
788
+ }
789
+ }
790
+
791
+ async function checkCursorMcpServers(name = "", userCtx = {}) {
792
+ const { servers } = readCursorMcpServers(userCtx);
793
+ const targetName = String(name || "").trim();
794
+ const targets = targetName ? servers.filter((server) => server.name === targetName) : servers;
795
+ if (targetName && targets.length === 0) throw new Error("MCP server not found");
796
+ const results = [];
797
+ for (const server of targets) {
798
+ results.push(await checkMcpServer(server));
799
+ }
800
+ return { results };
801
+ }
802
+
320
803
  function readModelListsFromDisk(workspaceRoot) {
321
804
  const p = getModelListsAbs();
322
805
  const empty = {
@@ -344,6 +827,8 @@ function readModelListsFromDisk(workspaceRoot) {
344
827
  }
345
828
 
346
829
  const SKILLHUB_TIMEOUT_MS = 60_000;
830
+ const SKILLHUB_API_BASE = String(process.env.SKILLHUB_API_BASE || "https://skillhub.bigo.sg/api/v1").replace(/\/+$/, "");
831
+ const skillhubCollectionInfoCache = new Map();
347
832
 
348
833
  function runSkillhub(args, opts = {}) {
349
834
  return new Promise((resolve) => {
@@ -369,6 +854,72 @@ function runSkillhub(args, opts = {}) {
369
854
  });
370
855
  }
371
856
 
857
+ function readSkillhubAuthToken() {
858
+ try {
859
+ const p = path.join(os.homedir(), ".skillhub", "auth.json");
860
+ if (!fs.existsSync(p)) return "";
861
+ const data = JSON.parse(fs.readFileSync(p, "utf-8"));
862
+ return String(data?.token || data?.accessToken || data?.access_token || "").trim();
863
+ } catch {
864
+ return "";
865
+ }
866
+ }
867
+
868
+ function normalizeSkillhubCollectionInfo(raw, collectionId) {
869
+ const data = raw?.data && typeof raw.data === "object"
870
+ ? raw.data
871
+ : raw?.collection && typeof raw.collection === "object"
872
+ ? raw.collection
873
+ : raw?.item && typeof raw.item === "object"
874
+ ? raw.item
875
+ : raw && typeof raw === "object"
876
+ ? raw
877
+ : {};
878
+ const id = String(data.id ?? collectionId ?? "").trim();
879
+ const name = String(data.name ?? data.displayName ?? data.display_name ?? data.title ?? "").trim();
880
+ const summary = String(data.description ?? data.summary ?? data.subtitle ?? "").trim();
881
+ const version = String(data.version ?? data.latestVersion ?? data.latest_version ?? "").trim();
882
+ const tags = Array.isArray(data.tags) ? data.tags.map(String).filter(Boolean) : [];
883
+ if (!id && !name) return null;
884
+ return {
885
+ id: id || String(collectionId || ""),
886
+ collection: id || String(collectionId || ""),
887
+ kind: "collection",
888
+ slug: "",
889
+ name: name || `Collection ${collectionId}`,
890
+ summary: summary || "按 Collection ID 安装该合集中的全部 Skills。",
891
+ version,
892
+ tags,
893
+ };
894
+ }
895
+
896
+ async function fetchSkillhubCollectionInfo(collectionId) {
897
+ const id = String(collectionId || "").trim();
898
+ if (!id) return null;
899
+ const cached = skillhubCollectionInfoCache.get(id);
900
+ if (cached) return cached;
901
+ if (typeof fetch !== "function") return null;
902
+ const controller = new AbortController();
903
+ const timer = setTimeout(() => controller.abort(), 8000);
904
+ try {
905
+ const token = readSkillhubAuthToken();
906
+ const headers = token ? { Authorization: `Bearer ${token}` } : {};
907
+ const r = await fetch(`${SKILLHUB_API_BASE}/collections/${encodeURIComponent(id)}`, {
908
+ headers,
909
+ signal: controller.signal,
910
+ });
911
+ if (!r.ok) return null;
912
+ const raw = await r.json().catch(() => null);
913
+ const info = normalizeSkillhubCollectionInfo(raw, id);
914
+ if (info) skillhubCollectionInfoCache.set(id, info);
915
+ return info;
916
+ } catch {
917
+ return null;
918
+ } finally {
919
+ clearTimeout(timer);
920
+ }
921
+ }
922
+
372
923
  function parseJsonText(text, fallback = null) {
373
924
  const s = String(text || "").trim();
374
925
  if (!s) return fallback;
@@ -394,11 +945,13 @@ function normalizeSkillhubSearchPayload(raw) {
394
945
  const slug = String(x.slug ?? x.name ?? x.displayName ?? x.display_name ?? id ?? "").trim();
395
946
  return {
396
947
  id: String(id || slug),
948
+ skillId: String(id || ""),
397
949
  slug,
398
950
  name: String(x.displayName ?? x.display_name ?? x.name ?? slug),
399
951
  summary: String(x.summary ?? x.description ?? ""),
400
952
  version: String(x.version ?? x.latestVersion ?? x.latest_version ?? ""),
401
953
  tags: Array.isArray(x.tags) ? x.tags.map(String) : [],
954
+ kind: "skill",
402
955
  };
403
956
  }).filter((x) => x.slug || x.name),
404
957
  };
@@ -602,6 +1155,21 @@ function resolveWorkspaceScopeRoot(workspaceRoot, params = {}, opts = {}) {
602
1155
  return { root: path.resolve(result.path), flowId, flowSource, archived };
603
1156
  }
604
1157
 
1158
+ function workspaceSearchGuardrailsBlock() {
1159
+ return [
1160
+ "## 检索约束",
1161
+ "",
1162
+ "默认不要读取、搜索或 Glob 历史运行产物;除非用户明确要求分析历史 run/log,否则必须排除:",
1163
+ "- `**/runBuild/**`",
1164
+ "- `**/logs/**`",
1165
+ "- `.workspace/agentflow/**/runBuild/**`",
1166
+ "- `~/agentflow/runBuild/**`",
1167
+ "- `node_modules/**`、`dist/**` 等依赖或构建产物",
1168
+ "",
1169
+ "使用 grep/rg/find/Glob 等工具时,应把上述路径作为 exclude/glob ignore;不要从历史 runBuild/logs 中推断业务事实、指标资产或 skill 文档。",
1170
+ ].join("\n");
1171
+ }
1172
+
605
1173
  function buildWorkspaceGeneratePrompt(payload) {
606
1174
  const userPrompt = String(payload?.prompt || "").trim();
607
1175
  const outputKind = String(payload?.outputKind || payload?.kind || "markdown").trim().toLowerCase();
@@ -652,6 +1220,7 @@ function buildWorkspaceGeneratePrompt(payload) {
652
1220
  allowFlowYaml
653
1221
  ? "用户已允许你考虑正式 flow.yaml;如需修改仍必须明确说明影响。"
654
1222
  : "默认不要修改正式 flow.yaml;优先在 workspace 文件、workspace.graph.json 或回复内容中完成任务。",
1223
+ workspaceSearchGuardrailsBlock(),
655
1224
  workspaceGraph ? `\n## 当前 workspace graph\n\n${JSON.stringify(workspaceGraph, null, 2)}` : "",
656
1225
  selectedNodeIds.length > 0 ? `\n## 当前用户选中的 workspace 节点\n\n${selectedNodeIds.map((id) => `- ${id}`).join("\n")}` : "",
657
1226
  skillsBlock ? `\n## Selected Skills\n\n${skillsBlock}` : "",
@@ -661,6 +1230,47 @@ function buildWorkspaceGeneratePrompt(payload) {
661
1230
  ].filter(Boolean).join("\n");
662
1231
  }
663
1232
 
1233
+ function buildWorkspaceNodeChatPrompt(payload) {
1234
+ const node = payload?.node && typeof payload.node === "object" ? payload.node : {};
1235
+ const userMessage = String(payload?.message || "").trim();
1236
+ const currentContent = String(payload?.currentContent || "").trim();
1237
+ const nodeKind = String(payload?.nodeKind || payload?.kind || "markdown").trim().toLowerCase();
1238
+ const history = Array.isArray(payload?.messages) ? payload.messages : [];
1239
+ const historyBlock = history
1240
+ .slice(-8)
1241
+ .map((msg) => {
1242
+ const role = String(msg?.role || "user").trim() === "assistant" ? "assistant" : "user";
1243
+ const text = String(msg?.text || "").trim();
1244
+ return text ? `${role}: ${text}` : "";
1245
+ })
1246
+ .filter(Boolean)
1247
+ .join("\n\n");
1248
+ const outputRule =
1249
+ nodeKind === "html"
1250
+ ? "只输出完整或片段 HTML,不要解释,不要包裹 Markdown 代码围栏。"
1251
+ : nodeKind === "image"
1252
+ ? "只输出新的图片 src,可以是 URL、data URL 或文件路径,不要解释。"
1253
+ : nodeKind === "mermaid"
1254
+ ? "只输出 Mermaid 源码,不要解释,不要包裹 Markdown 代码围栏。"
1255
+ : nodeKind === "ascii"
1256
+ ? "只输出 ASCII 正文,不要解释,不要包裹 Markdown 代码围栏。"
1257
+ : "只输出新的 Markdown 正文,不要解释,不要包裹 Markdown 代码围栏。";
1258
+ return [
1259
+ "你正在微调 AgentFlow Workspace 画布中的单个展示节点。",
1260
+ "根据用户 follow-up 和当前节点内容,生成一个可直接替换当前节点展示内容的候选版本。",
1261
+ outputRule,
1262
+ "",
1263
+ "## 当前节点",
1264
+ `- id: ${String(node.id || "").trim() || "(unknown)"}`,
1265
+ `- label: ${String(node.label || "").trim() || "(unnamed)"}`,
1266
+ `- definitionId: ${String(node.definitionId || "").trim() || "(unknown)"}`,
1267
+ `- kind: ${nodeKind}`,
1268
+ currentContent ? `\n## 当前展示内容\n\n${currentContent}` : "",
1269
+ historyBlock ? `\n## 本节点对话历史\n\n${historyBlock}` : "",
1270
+ `\n## 用户 follow-up\n\n${userMessage}`,
1271
+ ].filter(Boolean).join("\n");
1272
+ }
1273
+
664
1274
  function workspaceSlotValue(slot) {
665
1275
  if (!slot || typeof slot !== "object") return "";
666
1276
  for (const key of ["value", "default"]) {
@@ -717,9 +1327,82 @@ function workspaceDisplayKind(definitionId) {
717
1327
  if (id === "display_ascii") return "ascii";
718
1328
  if (id === "display_html") return "html";
719
1329
  if (id === "display_image") return "image";
1330
+ if (id === "display_chart") return "chart";
720
1331
  return "";
721
1332
  }
722
1333
 
1334
+ function normalizeHtmlDisplayContent(content) {
1335
+ let text = String(content || "").trim();
1336
+ if (!text) return "";
1337
+ const fenced = text.match(/```(?:html|HTML)?\s*\n?([\s\S]*?)```/);
1338
+ if (fenced && fenced[1]) text = fenced[1].trim();
1339
+ else {
1340
+ const openFence = text.match(/```(?:html|HTML)?\s*\n?([\s\S]*)$/);
1341
+ if (openFence && openFence[1]) text = openFence[1].trim();
1342
+ }
1343
+ text = text.replace(/^html\s*\n/i, "").replace(/```\s*$/g, "").trim();
1344
+ const markerPatterns = [
1345
+ /<!doctype\b/i,
1346
+ /<html\b/i,
1347
+ /<head\b/i,
1348
+ /<body\b/i,
1349
+ /<style\b/i,
1350
+ /<script\b/i,
1351
+ /<main\b/i,
1352
+ /<section\b/i,
1353
+ /<article\b/i,
1354
+ /<div\b/i,
1355
+ /<svg\b/i,
1356
+ /<canvas\b/i,
1357
+ ];
1358
+ const firstHtmlIndex = markerPatterns.reduce((best, pattern) => {
1359
+ const match = pattern.exec(text);
1360
+ if (!match) return best;
1361
+ return best < 0 ? match.index : Math.min(best, match.index);
1362
+ }, -1);
1363
+ if (firstHtmlIndex > 0) text = text.slice(firstHtmlIndex).trim();
1364
+ return text;
1365
+ }
1366
+
1367
+ function workspaceDownstreamDisplayRequirements(graph, nodeId) {
1368
+ const instances = graph?.instances && typeof graph.instances === "object" ? graph.instances : {};
1369
+ const edges = Array.isArray(graph?.edges) ? graph.edges : [];
1370
+ const kinds = new Set();
1371
+ for (const edge of edges) {
1372
+ if (String(edge?.source || "") !== String(nodeId)) continue;
1373
+ const target = instances[String(edge?.target || "")];
1374
+ const kind = workspaceDisplayKind(target?.definitionId);
1375
+ if (kind) kinds.add(kind);
1376
+ }
1377
+ if (kinds.size === 0) return "";
1378
+ const rules = [];
1379
+ if (kinds.has("html")) {
1380
+ rules.push("- 下游连接了 HTML 展示节点:输出可直接放入 iframe 渲染的 HTML。可以是完整 HTML 文档或 HTML fragment;不要使用 Markdown 代码围栏;不要解释生成过程。");
1381
+ }
1382
+ if (kinds.has("markdown")) {
1383
+ rules.push("- 下游连接了 Markdown 展示节点:输出 Markdown 正文;不要包裹在代码围栏中,除非正文确实需要代码块。");
1384
+ }
1385
+ if (kinds.has("mermaid")) {
1386
+ rules.push("- 下游连接了 Mermaid 展示节点:只输出 Mermaid 图表代码,例如 flowchart/sequenceDiagram;不要使用 Markdown 代码围栏;不要附加解释。");
1387
+ }
1388
+ if (kinds.has("ascii")) {
1389
+ rules.push("- 下游连接了 ASCII 展示节点:输出纯文本/ASCII 图或表格;不要输出 HTML 或 Markdown 装饰。");
1390
+ }
1391
+ if (kinds.has("image")) {
1392
+ rules.push("- 下游连接了图片展示节点:输出可作为 img src 使用的图片地址、data URL 或 base64 data URL;不要输出 Markdown 图片语法或解释文字。");
1393
+ }
1394
+ if (kinds.has("chart")) {
1395
+ rules.push('- 下游连接了 Chart 展示节点:只输出 ChartSpec JSON 对象,不要 Markdown 代码围栏,不要解释文字。格式必须包含 `"type":"chart"`、`"version":"1.0"`、`"renderer":"echarts"`、`"option"`;`option.series[].type` 只使用 line/bar/pie/scatter/radar/heatmap/tree/treemap/sunburst/sankey/graph/gauge/funnel;不要输出 HTML、script、iframe 或 JS 函数。');
1396
+ }
1397
+ return [
1398
+ "## 下游输出要求",
1399
+ "",
1400
+ ...rules,
1401
+ "",
1402
+ "如果用户任务与下游展示格式没有冲突,优先满足上述格式要求;如果用户明确指定了其他格式,以用户任务为准。",
1403
+ ].join("\n");
1404
+ }
1405
+
723
1406
  function workspaceRunPlan(graph, runNodeId) {
724
1407
  const instances = graph?.instances && typeof graph.instances === "object" ? graph.instances : {};
725
1408
  const edges = Array.isArray(graph?.edges) ? graph.edges : [];
@@ -786,6 +1469,37 @@ function workspaceUpstreamText(graph, nodeId, outputs) {
786
1469
  return workspaceInstanceText(instances[sourceId]);
787
1470
  }
788
1471
 
1472
+ function workspaceHandleIndex(handle, prefix) {
1473
+ const match = String(handle || "").match(new RegExp(`^${prefix}-(\\d+)$`));
1474
+ return match ? Number(match[1]) : 0;
1475
+ }
1476
+
1477
+ function workspaceTargetSlotForEdge(graph, edge) {
1478
+ const instances = graph?.instances && typeof graph.instances === "object" ? graph.instances : {};
1479
+ const target = instances[String(edge?.target || "")];
1480
+ const input = Array.isArray(target?.input) ? target.input : [];
1481
+ return input[workspaceHandleIndex(edge?.targetHandle, "input")] || null;
1482
+ }
1483
+
1484
+ function isWorkspaceSemanticInputSlot(slot) {
1485
+ const name = String(slot?.name || "");
1486
+ const type = String(slot?.type || "");
1487
+ return type === "node" || name === "prev" || name === "next" || name === "skillsContext" || name === "mcpContext" || name === "workspaceContext" || name === "gitContext";
1488
+ }
1489
+
1490
+ function workspaceTaskUpstreamText(graph, nodeId, outputs) {
1491
+ const edges = Array.isArray(graph?.edges) ? graph.edges : [];
1492
+ const instances = graph?.instances && typeof graph.instances === "object" ? graph.instances : {};
1493
+ const incoming = edges.filter((edge) => String(edge?.target || "") === String(nodeId));
1494
+ const contentEdges = incoming.filter((edge) => !isWorkspaceSemanticInputSlot(workspaceTargetSlotForEdge(graph, edge)));
1495
+ const contentEdge = contentEdges.find((edge) => String(edge?.targetHandle || "") === "input-1") || contentEdges[0];
1496
+ if (!contentEdge) return "";
1497
+ const sourceId = String(contentEdge.source || "");
1498
+ const out = outputs.get(sourceId);
1499
+ if (out != null && String(out).trim()) return String(out);
1500
+ return workspaceInstanceText(instances[sourceId]);
1501
+ }
1502
+
789
1503
  function parseWorkspaceSkillKeys(raw) {
790
1504
  const text = String(raw || "").trim();
791
1505
  if (!text) return [];
@@ -806,19 +1520,119 @@ function selectedSkillKeysFromInstance(instance) {
806
1520
  return parseWorkspaceSkillKeys(workspaceSlotValue(slot) || "");
807
1521
  }
808
1522
 
1523
+ function selectedMcpServerNamesFromInstance(instance) {
1524
+ const bodyNames = parseWorkspaceSkillKeys(instance?.body || "");
1525
+ if (bodyNames.length > 0) return bodyNames;
1526
+ const slots = [...(Array.isArray(instance?.input) ? instance.input : []), ...(Array.isArray(instance?.output) ? instance.output : [])];
1527
+ const slot = slots.find((item) => item?.name === "mcpContext") || slots.find((item) => item?.name === "serverNames");
1528
+ return parseWorkspaceSkillKeys(workspaceSlotValue(slot) || "");
1529
+ }
1530
+
809
1531
  function workspaceUpstreamSkillBlocks(graph, nodeId, outputs) {
810
1532
  const edges = Array.isArray(graph?.edges) ? graph.edges : [];
811
- return edges
1533
+ const blocks = edges
812
1534
  .filter((edge) => String(edge?.target || "") === String(nodeId))
1535
+ .filter((edge) => {
1536
+ const slot = workspaceTargetSlotForEdge(graph, edge);
1537
+ return String(slot?.name || "") === "skillsContext";
1538
+ })
813
1539
  .map((edge) => String(outputs.get(String(edge.source || "")) || ""))
814
- .filter((text) => text.includes("##") || text.includes("Skill"))
815
- .join("\n\n---\n\n");
1540
+ .filter((text) => text.includes("Skill") || text.includes("skill"))
1541
+ .flatMap((text) => text.split(/\n\s*---\s*\n/g))
1542
+ .map((text) => text.trim())
1543
+ .filter(Boolean);
1544
+ return Array.from(new Set(blocks)).join("\n\n---\n\n");
1545
+ }
1546
+
1547
+ function workspaceUpstreamMcpBlocks(graph, nodeId, outputs) {
1548
+ const edges = Array.isArray(graph?.edges) ? graph.edges : [];
1549
+ const blocks = edges
1550
+ .filter((edge) => String(edge?.target || "") === String(nodeId))
1551
+ .filter((edge) => {
1552
+ const slot = workspaceTargetSlotForEdge(graph, edge);
1553
+ return String(slot?.name || "") === "mcpContext";
1554
+ })
1555
+ .map((edge) => String(outputs.get(String(edge.source || "")) || ""))
1556
+ .filter((text) => text.includes("MCP") || text.includes("mcp"))
1557
+ .flatMap((text) => text.split(/\n\s*---\s*\n/g))
1558
+ .map((text) => text.trim())
1559
+ .filter(Boolean);
1560
+ return Array.from(new Set(blocks)).join("\n\n---\n\n");
1561
+ }
1562
+
1563
+ function mergeWorkspaceSkillBlocks(...values) {
1564
+ const blocks = values
1565
+ .map((value) => String(value || ""))
1566
+ .filter(Boolean)
1567
+ .flatMap((text) => text.split(/\n\s*---\s*\n/g))
1568
+ .map((text) => text.trim())
1569
+ .filter(Boolean);
1570
+ return Array.from(new Set(blocks)).join("\n\n---\n\n");
1571
+ }
1572
+
1573
+ function buildWorkspaceSkillManifestBlock(skills, selectedKeys = []) {
1574
+ const normalizedKeys = Array.from(new Set((selectedKeys || []).map((x) => String(x || "").trim()).filter(Boolean)));
1575
+ const rows = (Array.isArray(skills) ? skills : []).map((skill) => {
1576
+ const id = String(skill?.id || "").trim();
1577
+ const absPath = String(skill?.absPath || "").trim();
1578
+ if (!id && !absPath) return "";
1579
+ return `- \`${id || path.basename(absPath)}\`${absPath ? `: ${absPath}` : ""}`;
1580
+ }).filter(Boolean);
1581
+ if (!rows.length && !normalizedKeys.length) return "";
1582
+ return [
1583
+ "### Workspace Skills Manifest",
1584
+ "",
1585
+ "这些 skills 已在当前 workspace 中可用。不要默认展开或复述其内容;仅当节点任务明确需要时,按路径 Read 对应 SKILL.md。",
1586
+ "",
1587
+ ...(
1588
+ rows.length
1589
+ ? rows
1590
+ : normalizedKeys.map((key) => `- \`${key}\``)
1591
+ ),
1592
+ ].join("\n");
1593
+ }
1594
+
1595
+ function buildWorkspaceMcpManifestBlock(results, servers = [], selectedNames = []) {
1596
+ const serverByName = new Map((Array.isArray(servers) ? servers : []).map((server) => [String(server?.name || ""), server]));
1597
+ const normalizedNames = Array.from(new Set((selectedNames || []).map((x) => String(x || "").trim()).filter(Boolean)));
1598
+ const targets = (Array.isArray(results) ? results : []).filter((item) => !normalizedNames.length || normalizedNames.includes(String(item?.name || "")));
1599
+ const rows = [];
1600
+ for (const result of targets) {
1601
+ const name = String(result?.name || "").trim();
1602
+ if (!name) continue;
1603
+ const server = serverByName.get(name) || {};
1604
+ const description = String(server?.description || "").trim();
1605
+ if (!result?.ok) {
1606
+ rows.push(`- MCP server \`${name}\`: unavailable${result?.error ? ` (${String(result.error)})` : ""}`);
1607
+ continue;
1608
+ }
1609
+ rows.push(`- MCP server \`${name}\`${description ? `: ${description}` : ""}`);
1610
+ const tools = Array.isArray(result?.tools) ? result.tools : [];
1611
+ if (!tools.length) {
1612
+ rows.push(" - no tools reported");
1613
+ continue;
1614
+ }
1615
+ for (const tool of tools.slice(0, 80)) {
1616
+ const toolName = String(tool?.name || "").trim();
1617
+ if (!toolName) continue;
1618
+ const toolDescription = String(tool?.description || "").trim();
1619
+ rows.push(` - tool \`${toolName}\`${toolDescription ? `: ${toolDescription}` : ""}`);
1620
+ }
1621
+ }
1622
+ if (!rows.length && !normalizedNames.length) return "";
1623
+ return [
1624
+ "### Workspace MCP Manifest",
1625
+ "",
1626
+ "这些 MCP servers/tools 已在当前 Agent 运行器中可用。需要外部工具能力时,优先使用下列 MCP 工具;不要声称调用了工具,除非实际工具调用成功。",
1627
+ "",
1628
+ ...(rows.length ? rows : normalizedNames.map((name) => `- MCP server \`${name}\``)),
1629
+ ].join("\n");
816
1630
  }
817
1631
 
818
1632
  function workspaceWriteDisplayContent(instance, content) {
819
1633
  const next = { ...(instance || {}) };
820
- const text = String(content || "");
821
1634
  const kind = workspaceDisplayKind(next.definitionId);
1635
+ const text = kind === "html" ? normalizeHtmlDisplayContent(content) : String(content || "");
822
1636
  const primaryName = kind === "image" ? "src" : "content";
823
1637
  next.body = text;
824
1638
  next.input = (Array.isArray(next.input) ? next.input : []).map((slot) => (
@@ -849,15 +1663,19 @@ function workspaceUpdateDirectDisplays(graph, sourceId, content) {
849
1663
  return updated;
850
1664
  }
851
1665
 
852
- function workspaceNodePrompt(graph, nodeId, upstreamText, skillsBlock) {
1666
+ function workspaceNodePrompt(graph, nodeId, upstreamText, skillsBlock, mcpBlock = "") {
853
1667
  const instance = graph.instances[nodeId] || {};
854
1668
  const body = String(instance.body || "").trim();
855
1669
  const label = String(instance.label || nodeId).trim();
1670
+ const downstreamRequirements = workspaceDownstreamDisplayRequirements(graph, nodeId);
856
1671
  return [
857
1672
  "你正在执行 AgentFlow Workspace 画布中的一个临时节点。",
858
1673
  "只输出该节点要传给下游展示/后续节点的正文,不要解释运行过程。",
859
- skillsBlock ? `\n## Selected Skills\n\n${skillsBlock}` : "",
1674
+ workspaceSearchGuardrailsBlock(),
1675
+ skillsBlock ? `\n## Available Skills\n\n${skillsBlock}` : "",
1676
+ mcpBlock ? `\n## Available MCP\n\n${mcpBlock}` : "",
860
1677
  upstreamText ? `\n## 上游上下文\n\n${upstreamText}` : "",
1678
+ downstreamRequirements ? `\n${downstreamRequirements}` : "",
861
1679
  `\n## 当前节点\n\n- id: ${nodeId}\n- label: ${label}\n- definitionId: ${instance.definitionId || ""}`,
862
1680
  `\n## 节点任务\n\n${body || upstreamText}`,
863
1681
  ].filter(Boolean).join("\n");
@@ -867,6 +1685,14 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
867
1685
  const graph = normalizeWorkspaceGraphPayload(payload.graph || {});
868
1686
  const runNodeId = String(payload?.runNodeId || "").trim();
869
1687
  const { order, pauseNodeIds } = workspaceRunPlan(graph, runNodeId);
1688
+ const signal = opts.signal || null;
1689
+ const throwIfAborted = () => {
1690
+ if (signal?.aborted) {
1691
+ const err = new Error("Workspace run stopped");
1692
+ err.code = "WORKSPACE_RUN_ABORTED";
1693
+ throw err;
1694
+ }
1695
+ };
870
1696
  const fallbackSelectedSkillKeys = Array.isArray(payload?.selectedSkills)
871
1697
  ? payload.selectedSkills.map((x) => String(x || "").trim()).filter(Boolean)
872
1698
  : [];
@@ -879,21 +1705,42 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
879
1705
  ? loadResourcesForSkillKeys(normalized, PACKAGE_ROOT, scopedRoot)
880
1706
  : { skills: [], references: [] };
881
1707
  const block = normalized.length > 0
882
- ? buildSkillCompactInjectionBlock(selectedSkillResources.skills, selectedSkillResources.references)
1708
+ ? buildWorkspaceSkillManifestBlock(selectedSkillResources.skills, normalized)
883
1709
  : "";
884
1710
  skillsBlockCache.set(cacheKey, block);
885
1711
  return block;
886
1712
  };
1713
+ const mcpBlockCache = new Map();
1714
+ const loadMcpBlockForNames = async (names) => {
1715
+ const normalized = Array.from(new Set((names || []).map((x) => String(x || "").trim()).filter(Boolean)));
1716
+ if (!normalized.length) return "";
1717
+ const cacheKey = normalized.join("\n");
1718
+ if (mcpBlockCache.has(cacheKey)) return mcpBlockCache.get(cacheKey);
1719
+ const { servers } = readCursorMcpServers(userCtx);
1720
+ const results = [];
1721
+ for (const name of normalized) {
1722
+ const checked = await checkCursorMcpServers(name, userCtx);
1723
+ results.push(...(Array.isArray(checked.results) ? checked.results : []));
1724
+ }
1725
+ const block = buildWorkspaceMcpManifestBlock(results, servers, normalized);
1726
+ mcpBlockCache.set(cacheKey, block);
1727
+ return block;
1728
+ };
887
1729
  const outputs = new Map();
888
1730
  const events = [];
889
1731
  const emit = (event) => {
890
1732
  events.push(event);
891
1733
  if (typeof opts.onEvent === "function") opts.onEvent(event);
892
1734
  };
1735
+ const emitTiming = (nodeId, label, startedAt, extra = {}) => {
1736
+ const elapsedMs = Math.max(0, Date.now() - startedAt);
1737
+ emit({ type: "status", nodeId, line: `Timing ${label}: ${elapsedMs}ms`, timing: { label, elapsedMs, ...extra } });
1738
+ };
893
1739
  let cwd = scopedRoot;
894
1740
  const modelKey = typeof payload?.model === "string" ? payload.model.trim() : "";
895
1741
 
896
1742
  for (const nodeId of order) {
1743
+ throwIfAborted();
897
1744
  const instance = graph.instances[nodeId];
898
1745
  if (!instance) continue;
899
1746
  const defId = String(instance.definitionId || "");
@@ -904,9 +1751,11 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
904
1751
  }
905
1752
 
906
1753
  if (defId === "control_load_skills") {
1754
+ const skillStartedAt = Date.now();
907
1755
  const nodeSkillKeys = selectedSkillKeysFromInstance(instance);
908
1756
  const activeSkillKeys = nodeSkillKeys.length > 0 ? nodeSkillKeys : fallbackSelectedSkillKeys;
909
1757
  const skillsBlock = loadSkillsBlockForKeys(activeSkillKeys);
1758
+ emitTiming(nodeId, "load-skills", skillStartedAt, { skillCount: activeSkillKeys.length, charCount: skillsBlock.length });
910
1759
  graph.instances[nodeId] = {
911
1760
  ...instance,
912
1761
  output: (Array.isArray(instance.output) ? instance.output : []).map((slot) => (
@@ -922,6 +1771,26 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
922
1771
  continue;
923
1772
  }
924
1773
 
1774
+ if (defId === "control_load_mcp") {
1775
+ const mcpStartedAt = Date.now();
1776
+ const serverNames = selectedMcpServerNamesFromInstance(instance);
1777
+ const mcpBlock = await loadMcpBlockForNames(serverNames);
1778
+ emitTiming(nodeId, "load-mcp", mcpStartedAt, { serverCount: serverNames.length, charCount: mcpBlock.length });
1779
+ graph.instances[nodeId] = {
1780
+ ...instance,
1781
+ output: (Array.isArray(instance.output) ? instance.output : []).map((slot) => (
1782
+ String(slot?.name || "") === "mcpContext" || String(slot?.type || "") === "text"
1783
+ ? { ...slot, default: mcpBlock, value: mcpBlock }
1784
+ : slot
1785
+ )),
1786
+ };
1787
+ outputs.set(nodeId, mcpBlock);
1788
+ workspaceUpdateDirectDisplays(graph, nodeId, mcpBlock);
1789
+ emit({ type: "graph", nodeId, graph });
1790
+ emit({ type: "node-done", nodeId, definitionId: defId });
1791
+ continue;
1792
+ }
1793
+
925
1794
  if (workspaceDisplayKind(defId)) {
926
1795
  const content = workspaceUpstreamText(graph, nodeId, outputs);
927
1796
  graph.instances[nodeId] = workspaceWriteDisplayContent(instance, content);
@@ -1057,7 +1926,10 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
1057
1926
  const rawWorktreePath = workspaceSlotValue(workspaceSlotByName(instance, "worktreePath")).trim();
1058
1927
  const worktreePath = rawWorktreePath ? workspaceResolvePath(cwd, rawWorktreePath) : (gitContext?.worktreePath ? path.resolve(gitContext.worktreePath) : "");
1059
1928
  const previousCwd = cwd;
1060
- const result = loadGitWorktree({ repoPath, branch, worktreePath, pipelineWorkspace: scopedRoot });
1929
+ const force = ["true", "1", "yes", "on"].includes(workspaceSlotValue(workspaceSlotByName(instance, "force")).trim().toLowerCase());
1930
+ const pruneMissingRaw = workspaceSlotValue(workspaceSlotByName(instance, "pruneMissing")).trim().toLowerCase();
1931
+ const pruneMissing = pruneMissingRaw !== "false";
1932
+ const result = loadGitWorktree({ repoPath, branch, worktreePath, pipelineWorkspace: scopedRoot, force, pruneMissing });
1061
1933
  const outGitContext = buildGitContext({
1062
1934
  repoPath: result.repoRoot,
1063
1935
  worktreePath: result.worktreePath,
@@ -1154,18 +2026,25 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
1154
2026
  continue;
1155
2027
  }
1156
2028
 
1157
- const upstreamText = workspaceUpstreamText(graph, nodeId, outputs);
2029
+ const prepareStartedAt = Date.now();
2030
+ const upstreamText = workspaceTaskUpstreamText(graph, nodeId, outputs);
1158
2031
  const body = String(instance.body || "").trim();
1159
2032
  if (defId === "agent_subAgent" && !body && !String(upstreamText || "").trim()) {
1160
2033
  throw new Error(`Workspace node ${nodeId} has no task. Fill the node body or connect upstream text.`);
1161
2034
  }
1162
2035
  const upstreamSkillBlocks = workspaceUpstreamSkillBlocks(graph, nodeId, outputs);
1163
- const prompt = workspaceNodePrompt(graph, nodeId, upstreamText, upstreamSkillBlocks || loadSkillsBlockForKeys(fallbackSelectedSkillKeys));
2036
+ const promptSkillsBlock = mergeWorkspaceSkillBlocks(upstreamSkillBlocks, upstreamSkillBlocks ? "" : loadSkillsBlockForKeys(fallbackSelectedSkillKeys));
2037
+ const promptMcpBlock = workspaceUpstreamMcpBlocks(graph, nodeId, outputs);
2038
+ const prompt = workspaceNodePrompt(graph, nodeId, upstreamText, promptSkillsBlock, promptMcpBlock);
2039
+ emitTiming(nodeId, "prepare-agent-prompt", prepareStartedAt, { promptChars: prompt.length, upstreamChars: String(upstreamText || "").length, skillsChars: promptSkillsBlock.length, mcpChars: promptMcpBlock.length });
2040
+ emit({ type: "natural", kind: "prompt", nodeId, text: prompt });
1164
2041
  let content = "";
1165
2042
  const maxAttempts = 3;
1166
2043
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1167
2044
  let attemptContent = "";
1168
2045
  try {
2046
+ const spawnStartedAt = Date.now();
2047
+ let firstAgentEventSeen = false;
1169
2048
  const handle = startComposerAgent({
1170
2049
  uiWorkspaceRoot: scopedRoot,
1171
2050
  cliWorkspace: cwd,
@@ -1173,21 +2052,36 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
1173
2052
  modelKey,
1174
2053
  agentflowUserId: userCtx.userId || "",
1175
2054
  onStreamEvent: (ev) => {
2055
+ if (!firstAgentEventSeen) {
2056
+ firstAgentEventSeen = true;
2057
+ emitTiming(nodeId, "agent-first-event", spawnStartedAt, { attempt, firstType: ev?.type || "" });
2058
+ }
1176
2059
  emit({ ...ev, nodeId });
1177
2060
  if (ev?.type === "natural" && ev.kind === "assistant" && typeof ev.text === "string") {
1178
2061
  attemptContent += (attemptContent ? "\n" : "") + ev.text;
1179
- const updatedDisplays = workspaceUpdateDirectDisplays(graph, nodeId, attemptContent);
1180
- if (updatedDisplays.length) emit({ type: "graph", nodeId, displayNodeIds: updatedDisplays, graph });
1181
2062
  }
1182
2063
  },
2064
+ onToolCall: (subtype, toolName) => {
2065
+ const sub = subtype ? String(subtype) : "";
2066
+ const tool = toolName ? String(toolName) : "";
2067
+ emit({ type: "status", nodeId, line: `工具 ${tool || "thinking"}${sub ? ` (${sub})` : ""}` });
2068
+ },
1183
2069
  });
1184
- await handle.finished;
2070
+ if (typeof opts.onActiveChild === "function") opts.onActiveChild(handle.child || null);
2071
+ emitTiming(nodeId, "spawn-agent", spawnStartedAt, { attempt });
2072
+ try {
2073
+ await handle.finished;
2074
+ } finally {
2075
+ if (typeof opts.onActiveChild === "function") opts.onActiveChild(null);
2076
+ }
2077
+ throwIfAborted();
1185
2078
  content = attemptContent.trim();
1186
2079
  break;
1187
2080
  } catch (e) {
2081
+ if (signal?.aborted || e?.code === "WORKSPACE_RUN_ABORTED") throwIfAborted();
1188
2082
  if (attempt < maxAttempts && isTransientAgentNetworkError(e)) {
1189
2083
  emit({ type: "status", nodeId, line: `Workspace node retry ${attempt + 1}/${maxAttempts} after network error` });
1190
- await sleepMs(Math.min(1500 * attempt, 5000));
2084
+ await sleepMs(Math.min(1500 * attempt, 5000), signal);
1191
2085
  continue;
1192
2086
  }
1193
2087
  throw e;
@@ -1205,6 +2099,10 @@ async function runWorkspaceGraph(root, scopedRoot, payload, userCtx = {}, opts =
1205
2099
  return { graph, events, order, pauseNodeIds };
1206
2100
  }
1207
2101
 
2102
+ function isWorkspaceRunAbortError(err) {
2103
+ return err?.code === "WORKSPACE_RUN_ABORTED" || /Workspace run stopped/i.test(String(err?.message || ""));
2104
+ }
2105
+
1208
2106
  function isTransientAgentNetworkError(err) {
1209
2107
  const text = [
1210
2108
  err?.message,
@@ -1221,8 +2119,17 @@ function isTransientAgentNetworkError(err) {
1221
2119
  /socket hang up/i.test(text);
1222
2120
  }
1223
2121
 
1224
- function sleepMs(ms) {
1225
- return new Promise((resolve) => setTimeout(resolve, ms));
2122
+ function sleepMs(ms, signal = null) {
2123
+ if (signal?.aborted) return Promise.resolve();
2124
+ return new Promise((resolve) => {
2125
+ const timer = setTimeout(resolve, ms);
2126
+ if (signal) {
2127
+ signal.addEventListener("abort", () => {
2128
+ clearTimeout(timer);
2129
+ resolve();
2130
+ }, { once: true });
2131
+ }
2132
+ });
1226
2133
  }
1227
2134
 
1228
2135
  /** ZIP 本地头:PK\x03\x04 / \x05\x06 / \x07\x08 */
@@ -1327,6 +2234,12 @@ function broadcastFlowEditorSync(flowId, flowSource, flowArchived = false, userI
1327
2234
 
1328
2235
  /** 正在执行的 flow run(flowId → { child, runUuid });同一 flow 只允许一个 run */
1329
2236
  const activeFlowRuns = new Map();
2237
+ /** 正在执行的 Workspace 临时 run(flowId → { controller, child });同一 flow 只允许一个 run */
2238
+ const activeWorkspaceRuns = new Map();
2239
+
2240
+ function workspaceRunKey(userCtx, flowSource, flowId) {
2241
+ return `${userCtx?.userId || ""}:${flowSource || "user"}:${flowId}`;
2242
+ }
1330
2243
 
1331
2244
  /** Cursor/OpenCode 执行目录统一使用当前 UI 启动 workspace。 */
1332
2245
  function composerCliWorkspaceForFlowDir(workspaceRoot, _flowDir) {
@@ -1461,7 +2374,16 @@ export function startUiServer({
1461
2374
 
1462
2375
  if (url.pathname === "/api/auth/me" && req.method === "GET") {
1463
2376
  const user = getAuthUserFromRequest(req);
1464
- json(res, 200, { authenticated: Boolean(user), user: user || null, setupRequired: authSetupRequired() });
2377
+ const allowed = user ? isAuthUserAllowed(user) : true;
2378
+ const allowlist = readUserAllowlist();
2379
+ json(res, 200, {
2380
+ authenticated: Boolean(user && allowed),
2381
+ user: user && allowed ? user : null,
2382
+ setupRequired: authSetupRequired(),
2383
+ allowlistEnabled: allowlist.enabled,
2384
+ forbidden: Boolean(user && !allowed),
2385
+ error: user && !allowed ? "用户不在白名单中,请联系管理员开通访问权限" : "",
2386
+ });
1465
2387
  return;
1466
2388
  }
1467
2389
 
@@ -1475,7 +2397,7 @@ export function startUiServer({
1475
2397
  }
1476
2398
  const result = loginOrCreateUser(payload?.username, payload?.password);
1477
2399
  if (!result.ok) {
1478
- json(res, 401, { error: result.error || "Login failed", setupRequired: authSetupRequired() });
2400
+ json(res, result.forbidden ? 403 : 401, { error: result.error || "Login failed", setupRequired: authSetupRequired() });
1479
2401
  return;
1480
2402
  }
1481
2403
  const body = JSON.stringify({ authenticated: true, user: result.user, setupRequired: false, migration: result.migration || null });
@@ -1506,6 +2428,10 @@ export function startUiServer({
1506
2428
  json(res, 401, { error: "Authentication required", setupRequired: authSetupRequired() });
1507
2429
  return;
1508
2430
  }
2431
+ if (url.pathname.startsWith("/api/") && authUser && !isAuthUserAllowed(authUser)) {
2432
+ json(res, 403, { error: "用户不在白名单中,请联系管理员开通访问权限" });
2433
+ return;
2434
+ }
1509
2435
 
1510
2436
  if (url.pathname === "/api/flows") {
1511
2437
  if (req.method === "GET") {
@@ -1841,6 +2767,34 @@ export function startUiServer({
1841
2767
  return;
1842
2768
  }
1843
2769
  const wantsStream = /\bapplication\/x-ndjson\b/i.test(req.headers.accept || "") || payload.stream === true;
2770
+ const flowId = String(payload.flowId || "").trim();
2771
+ if (!flowId) {
2772
+ json(res, 400, { error: "Missing flowId" });
2773
+ return;
2774
+ }
2775
+ const runKey = workspaceRunKey(userCtx, scoped.flowSource || payload.flowSource || "user", flowId);
2776
+ if (activeWorkspaceRuns.has(runKey)) {
2777
+ json(res, 409, { error: "该 Workspace 正在运行" });
2778
+ return;
2779
+ }
2780
+ const controller = new AbortController();
2781
+ const runEntry = {
2782
+ controller,
2783
+ child: null,
2784
+ stopChild() {
2785
+ if (this.child && !this.child.killed) {
2786
+ try { this.child.kill("SIGTERM"); } catch (_) {}
2787
+ }
2788
+ },
2789
+ };
2790
+ activeWorkspaceRuns.set(runKey, runEntry);
2791
+ const setActiveChild = (child) => {
2792
+ runEntry.child = child || null;
2793
+ if (controller.signal.aborted) runEntry.stopChild();
2794
+ };
2795
+ const clearActiveRun = () => {
2796
+ if (activeWorkspaceRuns.get(runKey) === runEntry) activeWorkspaceRuns.delete(runKey);
2797
+ };
1844
2798
  if (wantsStream) {
1845
2799
  const graphPath = workspaceGraphPath(scoped.root);
1846
2800
  res.writeHead(200, {
@@ -1849,29 +2803,77 @@ export function startUiServer({
1849
2803
  "X-Accel-Buffering": "no",
1850
2804
  });
1851
2805
  const writeEvent = (event) => {
1852
- res.write(JSON.stringify(event) + "\n");
2806
+ try { res.write(JSON.stringify(event) + "\n"); } catch (_) {}
1853
2807
  };
1854
2808
  try {
1855
- const result = await runWorkspaceGraph(root, scoped.root, payload, userCtx, { onEvent: writeEvent });
2809
+ const result = await runWorkspaceGraph(root, scoped.root, payload, userCtx, {
2810
+ onEvent: writeEvent,
2811
+ signal: controller.signal,
2812
+ onActiveChild: setActiveChild,
2813
+ });
1856
2814
  fs.writeFileSync(graphPath, JSON.stringify(result.graph, null, 2) + "\n", "utf-8");
1857
2815
  writeEvent({ type: "done", ok: true, path: graphPath, graph: result.graph, order: result.order, pauseNodeIds: result.pauseNodeIds || [] });
1858
2816
  res.end();
1859
2817
  } catch (e) {
1860
- writeEvent({ type: "error", error: (e && e.message) || String(e) });
2818
+ if (isWorkspaceRunAbortError(e) || controller.signal.aborted) {
2819
+ writeEvent({ type: "stopped", ok: false, stopped: true, message: "Workspace run stopped" });
2820
+ } else {
2821
+ writeEvent({ type: "error", error: (e && e.message) || String(e) });
2822
+ }
1861
2823
  res.end();
2824
+ } finally {
2825
+ clearActiveRun();
1862
2826
  }
1863
2827
  return;
1864
2828
  }
1865
- const result = await runWorkspaceGraph(root, scoped.root, payload, userCtx);
1866
- const graphPath = workspaceGraphPath(scoped.root);
1867
- fs.writeFileSync(graphPath, JSON.stringify(result.graph, null, 2) + "\n", "utf-8");
1868
- json(res, 200, { ok: true, path: graphPath, ...result });
2829
+ try {
2830
+ const result = await runWorkspaceGraph(root, scoped.root, payload, userCtx, {
2831
+ signal: controller.signal,
2832
+ onActiveChild: setActiveChild,
2833
+ });
2834
+ const graphPath = workspaceGraphPath(scoped.root);
2835
+ fs.writeFileSync(graphPath, JSON.stringify(result.graph, null, 2) + "\n", "utf-8");
2836
+ json(res, 200, { ok: true, path: graphPath, ...result });
2837
+ } catch (e) {
2838
+ if (isWorkspaceRunAbortError(e) || controller.signal.aborted) {
2839
+ json(res, 200, { ok: false, stopped: true, message: "Workspace run stopped" });
2840
+ } else {
2841
+ throw e;
2842
+ }
2843
+ } finally {
2844
+ clearActiveRun();
2845
+ }
1869
2846
  } catch (e) {
1870
2847
  json(res, 500, { error: (e && e.message) || String(e) });
1871
2848
  }
1872
2849
  return;
1873
2850
  }
1874
2851
 
2852
+ if (req.method === "POST" && url.pathname === "/api/workspace/run/stop") {
2853
+ let payload;
2854
+ try {
2855
+ payload = JSON.parse(await readBody(req));
2856
+ } catch {
2857
+ json(res, 400, { error: "Invalid JSON body" });
2858
+ return;
2859
+ }
2860
+ const flowId = typeof payload.flowId === "string" ? payload.flowId.trim() : "";
2861
+ if (!flowId) {
2862
+ json(res, 400, { error: "Missing flowId" });
2863
+ return;
2864
+ }
2865
+ const runKey = workspaceRunKey(userCtx, payload.flowSource || "user", flowId);
2866
+ const entry = activeWorkspaceRuns.get(runKey);
2867
+ if (!entry) {
2868
+ json(res, 404, { error: "该 Workspace 未在运行" });
2869
+ return;
2870
+ }
2871
+ try { entry.controller?.abort(); } catch (_) {}
2872
+ try { entry.stopChild?.(); } catch (_) {}
2873
+ json(res, 200, { ok: true, stopped: true });
2874
+ return;
2875
+ }
2876
+
1875
2877
  if (req.method === "GET" && url.pathname === "/api/workspace/file") {
1876
2878
  try {
1877
2879
  const scoped = resolveWorkspaceScopeRoot(root, {
@@ -2091,6 +3093,61 @@ export function startUiServer({
2091
3093
  return;
2092
3094
  }
2093
3095
 
3096
+ if (req.method === "POST" && url.pathname === "/api/workspace/node-chat") {
3097
+ let payload;
3098
+ try {
3099
+ payload = JSON.parse(await readBody(req));
3100
+ } catch {
3101
+ json(res, 400, { error: "Invalid JSON body" });
3102
+ return;
3103
+ }
3104
+ const message = String(payload?.message || "").trim();
3105
+ if (!message) {
3106
+ json(res, 400, { error: "Missing message" });
3107
+ return;
3108
+ }
3109
+ try {
3110
+ const scoped = resolveWorkspaceScopeRoot(root, {
3111
+ flowId: payload.flowId || "",
3112
+ flowSource: payload.flowSource || "user",
3113
+ archived: payload.archived === true || payload.flowArchived === true,
3114
+ }, userCtx);
3115
+ if (scoped.error) {
3116
+ json(res, 400, { error: scoped.error });
3117
+ return;
3118
+ }
3119
+ const promptText = buildWorkspaceNodeChatPrompt(payload);
3120
+ const modelKey = typeof payload?.model === "string" ? payload.model.trim() : "";
3121
+ let content = "";
3122
+ const events = [];
3123
+ const handle = startComposerAgent({
3124
+ uiWorkspaceRoot: scoped.root,
3125
+ cliWorkspace: scoped.root,
3126
+ prompt: promptText,
3127
+ modelKey,
3128
+ agentflowUserId: userCtx.userId || "",
3129
+ onStreamEvent: (ev) => {
3130
+ events.push(ev);
3131
+ if (ev?.type === "natural" && ev.kind === "assistant" && typeof ev.text === "string") {
3132
+ content += (content ? "\n" : "") + ev.text;
3133
+ }
3134
+ },
3135
+ });
3136
+ await handle.finished;
3137
+ const candidateContent = content.trim();
3138
+ json(res, 200, {
3139
+ ok: true,
3140
+ sessionId: String(payload?.sessionId || "") || `nodechat_${Date.now()}`,
3141
+ reply: candidateContent,
3142
+ candidateContent,
3143
+ events,
3144
+ });
3145
+ } catch (e) {
3146
+ json(res, 500, { error: (e && e.message) || String(e) });
3147
+ }
3148
+ return;
3149
+ }
3150
+
2094
3151
  if (req.method === "GET" && url.pathname === "/api/pipeline-files") {
2095
3152
  const flowId = url.searchParams.get("flowId");
2096
3153
  const flowSource = url.searchParams.get("flowSource") || "user";
@@ -2307,6 +3364,64 @@ export function startUiServer({
2307
3364
  return;
2308
3365
  }
2309
3366
 
3367
+ if (req.method === "GET" && url.pathname === "/api/mcps") {
3368
+ try {
3369
+ json(res, 200, readCursorMcpServers(userCtx));
3370
+ } catch (e) {
3371
+ json(res, 500, { error: (e && e.message) || String(e) });
3372
+ }
3373
+ return;
3374
+ }
3375
+
3376
+ if (req.method === "POST" && url.pathname === "/api/mcps") {
3377
+ let payload;
3378
+ try {
3379
+ payload = JSON.parse(await readBody(req));
3380
+ } catch {
3381
+ json(res, 400, { error: "Invalid JSON body" });
3382
+ return;
3383
+ }
3384
+ try {
3385
+ json(res, 200, writeCursorMcpServer(payload, userCtx));
3386
+ } catch (e) {
3387
+ json(res, 400, { error: (e && e.message) || String(e) });
3388
+ }
3389
+ return;
3390
+ }
3391
+
3392
+ if (req.method === "POST" && url.pathname === "/api/mcps/delete") {
3393
+ let payload;
3394
+ try {
3395
+ payload = JSON.parse(await readBody(req));
3396
+ } catch {
3397
+ json(res, 400, { error: "Invalid JSON body" });
3398
+ return;
3399
+ }
3400
+ try {
3401
+ json(res, 200, deleteCursorMcpServer(payload?.name, userCtx));
3402
+ } catch (e) {
3403
+ json(res, 400, { error: (e && e.message) || String(e) });
3404
+ }
3405
+ return;
3406
+ }
3407
+
3408
+ if (req.method === "POST" && url.pathname === "/api/mcps/check") {
3409
+ let payload;
3410
+ try {
3411
+ const raw = await readBody(req);
3412
+ payload = raw && String(raw).trim() ? JSON.parse(raw) : {};
3413
+ } catch {
3414
+ json(res, 400, { error: "Invalid JSON body" });
3415
+ return;
3416
+ }
3417
+ try {
3418
+ json(res, 200, await checkCursorMcpServers(payload?.name || "", userCtx));
3419
+ } catch (e) {
3420
+ json(res, 400, { error: (e && e.message) || String(e) });
3421
+ }
3422
+ return;
3423
+ }
3424
+
2310
3425
  if (req.method === "GET" && url.pathname === "/api/user-env") {
2311
3426
  try {
2312
3427
  json(res, 200, { env: readUserEnvRows(userCtx.userId) });
@@ -2384,16 +3499,41 @@ export function startUiServer({
2384
3499
 
2385
3500
  if (req.method === "GET" && url.pathname === "/api/skillhub/search") {
2386
3501
  const q = (url.searchParams.get("q") || "").trim();
3502
+ const mode = (url.searchParams.get("mode") || "keyword").trim();
2387
3503
  if (!q) {
2388
3504
  json(res, 200, { total: 0, items: [] });
2389
3505
  return;
2390
3506
  }
3507
+ if (mode === "collectionId") {
3508
+ const info = await fetchSkillhubCollectionInfo(q);
3509
+ json(res, 200, {
3510
+ total: 1,
3511
+ mode,
3512
+ items: [info || {
3513
+ id: `collection:${q}`,
3514
+ collection: q,
3515
+ kind: "collection",
3516
+ slug: "",
3517
+ name: `Collection ${q}`,
3518
+ summary: "按 Collection ID 安装该合集中的全部 Skills。",
3519
+ version: "",
3520
+ tags: [],
3521
+ }],
3522
+ });
3523
+ return;
3524
+ }
2391
3525
  const result = await runSkillhub(["search", "-q", q], { cwd: root });
2392
3526
  if (!result.ok) {
2393
3527
  json(res, 500, { error: result.error, stdout: result.stdout });
2394
3528
  return;
2395
3529
  }
2396
- json(res, 200, normalizeSkillhubSearchPayload(parseJsonText(result.stdout, {})));
3530
+ const payload = normalizeSkillhubSearchPayload(parseJsonText(result.stdout, {}));
3531
+ if (mode === "skillId") {
3532
+ const filtered = payload.items.filter((item) => item.skillId === q || item.id === q);
3533
+ json(res, 200, { ...payload, mode, total: filtered.length, items: filtered });
3534
+ return;
3535
+ }
3536
+ json(res, 200, { ...payload, mode });
2397
3537
  return;
2398
3538
  }
2399
3539
 
@@ -2410,12 +3550,19 @@ export function startUiServer({
2410
3550
  json(res, 400, { error: "Missing skill slug or collection" });
2411
3551
  return;
2412
3552
  }
3553
+ const beforeSkills = payload?.collection ? listComposerSkills(PACKAGE_ROOT, root) : [];
2413
3554
  const result = await runSkillhub(args, { cwd: root, timeoutMs: 180_000, maxBuffer: 4 * 1024 * 1024 });
2414
3555
  if (!result.ok) {
2415
3556
  json(res, 500, { error: result.error, stdout: result.stdout });
2416
3557
  return;
2417
3558
  }
2418
- json(res, 200, { ok: true, stdout: result.stdout });
3559
+ let skillCollections = null;
3560
+ if (payload?.collection) {
3561
+ const afterSkills = listComposerSkills(PACKAGE_ROOT, root);
3562
+ const collectionName = String(payload.collectionName || payload.name || "").trim();
3563
+ skillCollections = upsertSkillhubCollectionGroup(userCtx, payload.collection, beforeSkills, afterSkills, collectionName);
3564
+ }
3565
+ json(res, 200, { ok: true, stdout: result.stdout, skillCollections });
2419
3566
  return;
2420
3567
  }
2421
3568
 
@@ -2437,7 +3584,8 @@ export function startUiServer({
2437
3584
  json(res, 500, { error: result.error, stdout: result.stdout });
2438
3585
  return;
2439
3586
  }
2440
- json(res, 200, { ok: true, stdout: result.stdout });
3587
+ const skillCollections = payload?.collection ? removeSkillhubCollectionGroup(userCtx, payload.collection, root) : null;
3588
+ json(res, 200, { ok: true, stdout: result.stdout, skillCollections });
2441
3589
  return;
2442
3590
  }
2443
3591