diff --git a/frontend-web/.tmp-tests/flow-graph.test.mjs b/frontend-web/.tmp-tests/flow-graph.test.mjs new file mode 100644 index 00000000..289bea27 --- /dev/null +++ b/frontend-web/.tmp-tests/flow-graph.test.mjs @@ -0,0 +1,319 @@ +// src/roundtable-planning/lib/flow-graph.test.ts +import assert from "node:assert/strict"; +import test from "node:test"; + +// src/roundtable-planning/lib/flow-graph.ts +var FLOW_NODE_TYPE_META = { + start: { label: "\u5F00\u59CB", bg: "#f1f5f9", border: "#94a3b8", text: "#334155", dot: "#64748b" }, + end: { label: "\u7ED3\u675F", bg: "#f1f5f9", border: "#94a3b8", text: "#334155", dot: "#64748b" }, + stage: { label: "\u9636\u6BB5", bg: "#eff6ff", border: "#60a5fa", text: "#1d4ed8", dot: "#3b82f6" }, + task: { label: "\u4EFB\u52A1", bg: "#ecfeff", border: "#22d3ee", text: "#0e7490", dot: "#06b6d4" }, + decision: { label: "\u5224\u65AD", bg: "#fffbeb", border: "#fbbf24", text: "#b45309", dot: "#f59e0b" }, + risk: { label: "\u98CE\u9669", bg: "#fef2f2", border: "#f87171", text: "#b91c1c", dot: "#ef4444" }, + deliverable: { label: "\u4EA4\u4ED8\u7269", bg: "#f0fdf4", border: "#4ade80", text: "#15803d", dot: "#22c55e" }, + role: { label: "\u89D2\u8272", bg: "#faf5ff", border: "#c084fc", text: "#7e22ce", dot: "#a855f7" } +}; +var VALID_TYPES = new Set(Object.keys(FLOW_NODE_TYPE_META)); +var FLOW_FENCE_OPEN = "```flow-json"; +function collectObjects(text, key) { + const keyIdx = text.indexOf(key); + if (keyIdx < 0) return []; + const arrStart = text.indexOf("[", keyIdx); + if (arrStart < 0) return []; + const out = []; + let depth = 0; + let objStart = -1; + let inStr = false; + let esc = false; + for (let i = arrStart + 1; i < text.length; i++) { + const ch = text[i]; + if (inStr) { + if (esc) esc = false; + else if (ch === "\\") esc = true; + else if (ch === '"') inStr = false; + continue; + } + if (ch === '"') { + inStr = true; + continue; + } + if (ch === "{") { + if (depth === 0) objStart = i; + depth++; + continue; + } + if (ch === "}") { + depth--; + if (depth === 0 && objStart >= 0) { + try { + const parsed = JSON.parse(text.slice(objStart, i + 1)); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + out.push(parsed); + } + } catch { + } + objStart = -1; + } + continue; + } + if (ch === "]" && depth === 0) break; + } + return out; +} +function asString(v) { + return typeof v === "string" ? v.trim() : ""; +} +function toNode(raw) { + const id = asString(raw.id); + const label = asString(raw.label) || asString(raw.name); + if (!id || !label) return null; + const rawType = asString(raw.type).toLowerCase(); + const type = VALID_TYPES.has(rawType) ? rawType : "task"; + const detail = asString(raw.detail) || asString(raw.description) || void 0; + return { id, label, type, detail }; +} +function edgesFromNode(raw, nodeId) { + const from = raw.from ?? raw.prev; + if (!Array.isArray(from)) return []; + const out = []; + for (const entry of from) { + if (typeof entry === "string" && entry.trim()) { + out.push({ from: entry.trim(), to: nodeId }); + } else if (entry && typeof entry === "object") { + const o = entry; + const fromId = asString(o.id) || asString(o.from); + if (!fromId) continue; + const label = asString(o.label) || void 0; + out.push({ from: fromId, to: nodeId, label }); + } + } + return out; +} +function toEdge(raw) { + const from = asString(raw.from) || asString(raw.source); + const to = asString(raw.to) || asString(raw.target); + if (!from || !to) return null; + const label = asString(raw.label) || void 0; + return { from, to, label }; +} +function extractFlowGraph(text) { + const start = text.lastIndexOf(FLOW_FENCE_OPEN); + if (start < 0) return null; + let body = text.slice(start + FLOW_FENCE_OPEN.length); + const close = body.indexOf("```"); + if (close >= 0) body = body.slice(0, close); + const titleMatch = /"title"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(body); + let title = ""; + if (titleMatch) { + try { + title = JSON.parse(`"${titleMatch[1]}"`); + } catch { + title = titleMatch[1]; + } + } + const rawNodes = collectObjects(body, '"nodes"'); + const nodes = []; + const seen = /* @__PURE__ */ new Set(); + const edges = []; + for (const raw of rawNodes) { + const node = toNode(raw); + if (!node || seen.has(node.id)) continue; + seen.add(node.id); + nodes.push(node); + edges.push(...edgesFromNode(raw, node.id)); + } + for (const raw of collectObjects(body, '"edges"')) { + const edge = toEdge(raw); + if (edge) edges.push(edge); + } + if (nodes.length === 0) return null; + const edgeKeys = /* @__PURE__ */ new Set(); + const validEdges = edges.filter((e) => { + if (!seen.has(e.from) || !seen.has(e.to) || e.from === e.to) return false; + const key = `${e.from}->${e.to}`; + if (edgeKeys.has(key)) return false; + edgeKeys.add(key); + return true; + }); + return { title, nodes, edges: validEdges }; +} +var FLOW_INTENT_PATTERNS = [ + "\u6D41\u7A0B\u56FE", + "\u53EF\u89C6\u5316\u6D41\u7A0B", + "\u7ED3\u6784\u5316\u8F93\u51FA", + "\u7ED3\u6784\u5316\u6570\u636E", + "\u7ED3\u6784\u5316\u6D41\u7A0B", + "\u7ED3\u6784\u5316\u603B\u7ED3", + "\u683C\u5F0F\u5316\u8F93\u51FA", + "\u683C\u5F0F\u5316\u603B\u7ED3", + "flow-json", + "flowchart" +]; +function detectFlowIntent(text) { + if (!text) return false; + const lower = text.toLowerCase(); + return FLOW_INTENT_PATTERNS.some((p) => lower.includes(p)); +} +function stripFlowFence(text, streaming) { + const start = text.lastIndexOf(FLOW_FENCE_OPEN); + if (start < 0) return text; + const after = text.slice(start + FLOW_FENCE_OPEN.length); + const close = after.indexOf("```"); + const tail = close >= 0 ? after.slice(close + 3) : ""; + const placeholder = close < 0 && streaming ? "\n\n> \u{1F4CA} \u6B63\u5728\u8F93\u51FA\u7ED3\u6784\u5316\u6D41\u7A0B\u6570\u636E\uFF0C\u6D41\u7A0B\u56FE\u753B\u5E03\u540C\u6B65\u7ED8\u5236\u4E2D\u2026" : "\n\n> \u{1F4CA} \u5DF2\u751F\u6210\u7ED3\u6784\u5316\u6D41\u7A0B\u6570\u636E\uFF0C\u53EF\u5728\u53F3\u4FA7\u300C\u6D41\u7A0B\u56FE\u300D\u753B\u5E03\u67E5\u770B\uFF08\u70B9\u51FB\u8282\u70B9\u770B\u8BE6\u60C5\uFF09\u3002"; + return `${text.slice(0, start).trimEnd()}${placeholder}${tail}`; +} +var X_GAP = 224; +var Y_GAP = 132; +function layoutFlowGraph(data) { + const preds = /* @__PURE__ */ new Map(); + for (const e of data.edges) { + const list = preds.get(e.to); + if (list) list.push(e.from); + else preds.set(e.to, [e.from]); + } + const memo = /* @__PURE__ */ new Map(); + const visiting = /* @__PURE__ */ new Set(); + const layerOf = (id) => { + const cached = memo.get(id); + if (cached !== void 0) return cached; + if (visiting.has(id)) return 0; + visiting.add(id); + const ps = preds.get(id) ?? []; + const layer = ps.length === 0 ? 0 : Math.max(...ps.map(layerOf)) + 1; + visiting.delete(id); + memo.set(id, layer); + return layer; + }; + const byLayer = /* @__PURE__ */ new Map(); + for (const n of data.nodes) { + const layer = layerOf(n.id); + const list = byLayer.get(layer); + if (list) list.push(n); + else byLayer.set(layer, [n]); + } + const pos = /* @__PURE__ */ new Map(); + for (const [layer, nodes] of byLayer) { + const rowWidth = (nodes.length - 1) * X_GAP; + nodes.forEach((n, i) => { + pos.set(n.id, { x: i * X_GAP - rowWidth / 2, y: layer * Y_GAP }); + }); + } + return data.nodes.map((n) => ({ ...n, ...pos.get(n.id) })); +} + +// src/roundtable-planning/lib/flow-graph.test.ts +var FULL = [ + "\u62A5\u544A\u6B63\u6587\u2026\u2026", + "```flow-json", + '{"title":"\u65B9\u6848\u6267\u884C\u6D41\u7A0B","nodes":[', + '{"id":"n1","label":"\u73AF\u5883\u8BC4\u4F30","type":"stage","detail":"\u8BC4\u4F30\u73B0\u573A\u73AF\u5883"},', + '{"id":"n2","label":"\u7F16\u5236\u65B9\u6848","type":"task","from":["n1"]},', + '{"id":"n3","label":"\u662F\u5426\u53EF\u884C","type":"decision","from":[{"id":"n2","label":"\u8BC4\u5BA1"}]}', + "]}", + "```" +].join("\n"); +test("extractFlowGraph\uFF1A\u5B8C\u6574\u5757\u89E3\u6790\u51FA\u8282\u70B9/\u8FB9/\u6807\u9898", () => { + const g = extractFlowGraph(FULL); + assert.ok(g); + assert.equal(g.title, "\u65B9\u6848\u6267\u884C\u6D41\u7A0B"); + assert.equal(g.nodes.length, 3); + assert.equal(g.nodes[0].type, "stage"); + assert.deepEqual(g.edges, [ + { from: "n1", to: "n2" }, + { from: "n2", to: "n3", label: "\u8BC4\u5BA1" } + ]); +}); +test("extractFlowGraph\uFF1A\u6D41\u5F0F\u534A\u622A\uFF08fence \u672A\u95ED\u5408 + \u5C3E\u5BF9\u8C61\u622A\u65AD\uFF09\u53EA\u53D6\u5B8C\u6574\u8282\u70B9", () => { + const partial = [ + "\u6B63\u6587", + "```flow-json", + '{"title":"T","nodes":[', + '{"id":"a","label":"A","type":"start"},', + '{"id":"b","label":"B","type":"task","from":["a"]},', + '{"id":"c","label":"C","ty' + // 截断 + ].join("\n"); + const g = extractFlowGraph(partial); + assert.ok(g); + assert.equal(g.nodes.length, 2); + assert.deepEqual(g.edges, [{ from: "a", to: "b" }]); +}); +test("extractFlowGraph\uFF1A\u672A\u77E5 type \u843D task\u3001\u91CD\u590D id \u53BB\u91CD\u3001\u60AC\u7A7A\u8FB9\u4E22\u5F03", () => { + const text = [ + "```flow-json", + '{"nodes":[', + '{"id":"x","label":"X","type":"banana"},', + '{"id":"x","label":"X-dup","type":"task"},', + '{"id":"y","label":"Y","type":"risk","from":["x","ghost"]}', + "]}", + "```" + ].join("\n"); + const g = extractFlowGraph(text); + assert.ok(g); + assert.equal(g.nodes.length, 2); + assert.equal(g.nodes[0].type, "task"); + assert.equal(g.nodes[0].label, "X"); + assert.deepEqual(g.edges, [{ from: "x", to: "y" }]); +}); +test("extractFlowGraph\uFF1A\u65E0 fence / \u65E0\u5B8C\u6574\u8282\u70B9\u8FD4\u56DE null", () => { + assert.equal(extractFlowGraph("\u666E\u901A\u56DE\u590D\uFF0C\u6CA1\u6709\u7ED3\u6784\u5316\u6570\u636E"), null); + assert.equal(extractFlowGraph('```flow-json\n{"nodes":[{"id":"a"'), null); +}); +test("extractFlowGraph\uFF1A\u517C\u5BB9\u72EC\u7ACB edges \u6570\u7EC4\uFF08source/target \u522B\u540D\uFF09", () => { + const text = [ + "```flow-json", + '{"nodes":[{"id":"a","label":"A"},{"id":"b","label":"B"}],', + '"edges":[{"source":"a","target":"b","label":"\u8D70"}]}', + "```" + ].join("\n"); + const g = extractFlowGraph(text); + assert.ok(g); + assert.deepEqual(g.edges, [{ from: "a", to: "b", label: "\u8D70" }]); +}); +test("stripFlowFence\uFF1A\u95ED\u5408\u5757\u66FF\u6362\u4E3A\u5F15\u5BFC\u8BED\u5E76\u4FDD\u7559\u524D\u540E\u6587", () => { + const out = stripFlowFence(FULL, false); + assert.ok(out.includes("\u62A5\u544A\u6B63\u6587")); + assert.ok(!out.includes("```flow-json")); + assert.ok(out.includes("\u5DF2\u751F\u6210\u7ED3\u6784\u5316\u6D41\u7A0B\u6570\u636E")); +}); +test("stripFlowFence\uFF1A\u672A\u95ED\u5408\uFF08\u6D41\u5F0F\u4E2D\uFF09\u7528\u300C\u6B63\u5728\u8F93\u51FA\u300D\u63AA\u8F9E", () => { + const out = stripFlowFence('\u6B63\u6587\n```flow-json\n{"nodes":[', true); + assert.ok(out.includes("\u6B63\u5728\u8F93\u51FA\u7ED3\u6784\u5316\u6D41\u7A0B\u6570\u636E")); + assert.ok(!out.includes("```flow-json")); +}); +test("layoutFlowGraph\uFF1A\u6309\u524D\u9A71\u5206\u5C42\uFF08\u6839 0 \u5C42\uFF0C\u5B50\u5C42\u9012\u589E\uFF09\uFF0C\u4FDD\u6301\u539F\u59CB\u987A\u5E8F", () => { + const g = extractFlowGraph(FULL); + const positioned = layoutFlowGraph(g); + assert.equal(positioned[0].y, 0); + assert.ok(positioned[1].y > positioned[0].y); + assert.ok(positioned[2].y > positioned[1].y); + assert.deepEqual(positioned.map((n) => n.id), ["n1", "n2", "n3"]); +}); +test("detectFlowIntent\uFF1A\u660E\u786E\u77ED\u8BED\u547D\u4E2D\uFF08\u7F16\u6392\u63D0\u793A / \u8F93\u5165\u6846\u6D88\u606F\uFF09", () => { + assert.ok(detectFlowIntent("\u6700\u7EC8\u603B\u7ED3\u8BF7\u7528\u7ED3\u6784\u5316\u8F93\u51FA")); + assert.ok(detectFlowIntent("\u603B\u7ED3\u6210\u683C\u5F0F\u5316\u8F93\u51FA")); + assert.ok(detectFlowIntent("\u7ED9\u6211\u753B\u4E00\u5F20\u6D41\u7A0B\u56FE")); + assert.ok(detectFlowIntent("\u8F93\u51FA\u53EF\u89C6\u5316\u6D41\u7A0B")); + assert.ok(detectFlowIntent("\u8BF7\u6309 flow-json \u5951\u7EA6\u8F93\u51FA")); + assert.ok(detectFlowIntent("\u91CD\u70B9\u603B\u7ED3\u65B9\u6848\u8BBE\u8BA1\u5E2D\u4F4D\uFF0C\u6700\u540E\u7ED9\u7ED3\u6784\u5316\u6570\u636E")); +}); +test("detectFlowIntent\uFF1A\u666E\u901A\u63AA\u8F9E\u4E0D\u8BEF\u89E6\u53D1", () => { + assert.equal(detectFlowIntent("\u7528\u8868\u683C\u628A\u8981\u70B9\u7ED3\u6784\u5316\u5448\u73B0"), false); + assert.equal(detectFlowIntent("\u628A\u6267\u884C\u8DEF\u7EBF\u5199\u5F97\u66F4\u7EC6\u4E00\u4E9B"), false); + assert.equal(detectFlowIntent("\u9762\u5411\u9AD8\u7BA1\u6C47\u62A5\uFF0C\u63A7\u5236\u5728\u4E00\u9875\u5185"), false); + assert.equal(detectFlowIntent(""), false); + assert.equal(detectFlowIntent(null), false); + assert.equal(detectFlowIntent(void 0), false); +}); +test("layoutFlowGraph\uFF1A\u73AF\u4E0D\u6B7B\u5FAA\u73AF", () => { + const text = [ + "```flow-json", + '{"nodes":[{"id":"a","label":"A","from":["b"]},{"id":"b","label":"B","from":["a"]}]}', + "```" + ].join("\n"); + const g = extractFlowGraph(text); + const positioned = layoutFlowGraph(g); + assert.equal(positioned.length, 2); + positioned.forEach((n) => assert.ok(Number.isFinite(n.x) && Number.isFinite(n.y))); +});