@xiaobuyu/nodesign 0.0.2 → 0.0.4
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.en.md +184 -0
- package/README.md +19 -25
- package/package.json +2 -1
- package/server/api/assets/helpers.js +20 -0
- package/server/api/assets/notes.js +50 -0
- package/server/api/assets.js +6 -11
- package/server/api/board.js +78 -1
- package/server/engine/agent/events.js +1 -0
- package/server/engine/agent/hooks/user-prompt-submit.js +43 -0
- package/server/engine/agent/prompts/nodesign-prelude.md +53 -1
- package/server/engine/agent/session-loop.js +11 -0
- package/server/engine/mcp/index.js +12 -0
- package/server/engine/mcp/tools/arrange-on-board.js +4 -4
- package/server/engine/mcp/tools/create-on-board.js +9 -12
- package/server/engine/mcp/tools/edit-sketch.js +167 -0
- package/server/engine/mcp/tools/look-at-board.js +124 -0
- package/server/engine/mcp/tools/read-board.js +121 -34
- package/server/engine/mcp/tools/read-user-view.js +59 -0
- package/server/engine/mcp/tools/relate-on-board.js +5 -3
- package/server/engine/mcp/tools/sketch-on-board.js +337 -0
- package/server/engine/mcp/tools/write-on-board.js +130 -0
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/SKILL.md +7 -0
- package/server/engine/plugins/nodesign/skills/docx-craft/SKILL.md +7 -0
- package/server/engine/plugins/nodesign/skills/rp-craft/SKILL.md +7 -0
- package/server/engine/plugins/nodesign/skills/site-craft/SKILL.md +7 -0
- package/server/engine/runs/board-tasklist.js +130 -0
- package/server/index.js +3 -0
- package/server/lib/binding-types.js +12 -0
- package/server/lib/board-graph-export.js +191 -0
- package/server/lib/board-groups.js +167 -0
- package/server/lib/board-hero.js +76 -0
- package/server/lib/board-kind-sizes.js +23 -0
- package/server/lib/board-relations.js +1 -1
- package/server/lib/chalk.js +121 -0
- package/server/lib/sketch-layout.js +277 -0
- package/server/projects/board-limits.js +12 -0
- package/server/projects/board-sanitize.js +252 -0
- package/server/projects/board-store.js +72 -205
- package/server/projects/ui-config.js +6 -0
- package/server/projects/viewpoint-store.js +78 -0
- package/server/ws/broker.js +3 -0
- package/web/dist/assets/{BrowserWindow-BYCgizHw.js → BrowserWindow-P_vIdh-G.js} +2 -2
- package/web/dist/assets/{DeckWindow-pNydEM4L.js → DeckWindow-BpLv5082.js} +3 -3
- package/web/dist/assets/{DocxWindow-PPgzh3Gq.js → DocxWindow-DSAU1-tD.js} +4 -4
- package/web/dist/assets/MermaidBlock-DR1GikCv.js +313 -0
- package/web/dist/assets/SiteWindow-92uBuRRR.js +18 -0
- package/web/dist/assets/abnfDiagram-VCTEODGH-uA8Esix4.js +1 -0
- package/web/dist/assets/arc-D_khH_xN.js +1 -0
- package/web/dist/assets/architectureDiagram-5GKGNRK7-DydJHqOt.js +36 -0
- package/web/dist/assets/blockDiagram-NRAW4CY4-B7wBbKiW.js +129 -0
- package/web/dist/assets/c4Diagram-UCG6FXSJ-6mrH0x7Y.js +38 -0
- package/web/dist/assets/channel-DGkccMO1.js +1 -0
- package/web/dist/assets/chunk-2Q5K7J3B-Cuikho5Q.js +1 -0
- package/web/dist/assets/chunk-5VM5RSS4-3-KZ5AUz.js +15 -0
- package/web/dist/assets/chunk-F27PBJKO-Bkwq2gGU.js +1 -0
- package/web/dist/assets/chunk-G27WJ6UU-BLyQzdGE.js +231 -0
- package/web/dist/assets/chunk-JWPE2WC7-B7HxxQds.js +1 -0
- package/web/dist/assets/chunk-LCL6LL3I-CtXrnUwF.js +206 -0
- package/web/dist/assets/chunk-POPQ4Y6H-dxddZlXt.js +1 -0
- package/web/dist/assets/chunk-SVP7TREG-DpkmrgHn.js +88 -0
- package/web/dist/assets/chunk-XXDRQBXY-BwZ7RbpQ.js +1 -0
- package/web/dist/assets/classDiagram-DTDB5LWJ-BGscw3OD.js +1 -0
- package/web/dist/assets/classDiagram-v2-JRS7N3AN-BGscw3OD.js +1 -0
- package/web/dist/assets/cose-bilkent-JH36ORCC-DGFOsMUt.js +1 -0
- package/web/dist/assets/cynefin-OW5HDTMX-DUwn3ehS.js +178 -0
- package/web/dist/assets/cynefinDiagram-5FMLGOSQ-DlLAOZ9x.js +62 -0
- package/web/dist/assets/cytoscape.esm-BjAbmLdb.js +331 -0
- package/web/dist/assets/dagre-3AP2YEHR-CyourLfz.js +4 -0
- package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/web/dist/assets/diagram-S7CK7UJ4-BTMbx2np.js +30 -0
- package/web/dist/assets/diagram-UQ7AKVKN-UK94_1-P.js +41 -0
- package/web/dist/assets/diagram-VSXAHHWV-C7zS-v9v.js +3 -0
- package/web/dist/assets/diagram-VX7I27RA-DKJRa6CK.js +24 -0
- package/web/dist/assets/diagram-Z3DM3KII-kUbAfGAp.js +24 -0
- package/web/dist/assets/ebnfDiagram-PWID7BFC-CVW16_vb.js +1 -0
- package/web/dist/assets/erDiagram-SSCWMZ5O-BRQMFQ5y.js +99 -0
- package/web/dist/assets/flowDiagram-A5DVABFB-S34LpLtK.js +168 -0
- package/web/dist/assets/ganttDiagram-EL5Y4UJY-BFiqk1-G.js +292 -0
- package/web/dist/assets/gitGraphDiagram-WWUBYQGX-LZsrdyyv.js +106 -0
- package/web/dist/assets/index-GAamh1p4.js +2152 -0
- package/web/dist/assets/infoDiagram-RXCK75RN-0DvN6KIp.js +2 -0
- package/web/dist/assets/init-Gi6I4Gst.js +1 -0
- package/web/dist/assets/ishikawaDiagram-5VMMS53U-CaY3A-Bx.js +70 -0
- package/web/dist/assets/journeyDiagram-EYS64GPL-BBeiaCrj.js +139 -0
- package/web/dist/assets/kanban-definition-3QL26DDD-ux42DRXQ.js +89 -0
- package/web/dist/assets/layout-CnXiF_NB.js +1 -0
- package/web/dist/assets/linear-BMW0u6G6.js +1 -0
- package/web/dist/assets/mindmap-definition-FBJOCRG2-hUxSpdKn.js +96 -0
- package/web/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/web/dist/assets/pegDiagram-XKGWAZYB-aCaQPH0w.js +1 -0
- package/web/dist/assets/{pending-edit-apply-C0I13CbD.js → pending-edit-apply-AcdOQ9Ij.js} +6 -6
- package/web/dist/assets/pieDiagram-E7YTZNPT-CmOEZ8zg.js +39 -0
- package/web/dist/assets/quadrantDiagram-AXDQQJYC-VW6fAhsV.js +7 -0
- package/web/dist/assets/railroadDiagram-O6MQD6OU-CKJ7Ba5K.js +1 -0
- package/web/dist/assets/requirementDiagram-EFPCY7ZU-pNGkq9Vw.js +84 -0
- package/web/dist/assets/{rotate-cw-B1UQ8Hld.js → rotate-cw-KAfChbGV.js} +1 -1
- package/web/dist/assets/sankeyDiagram-P5KCCOFB-BIMmDTfZ.js +40 -0
- package/web/dist/assets/sequenceDiagram-WJ2MYXX4-BBW7wzKY.js +162 -0
- package/web/dist/assets/sizeCapture-X5ZJPWSS-DdkXmLYR.js +1 -0
- package/web/dist/assets/{square-dashed-mouse-pointer-8lrgqmfv.js → square-dashed-mouse-pointer-CDUlhUP7.js} +1 -1
- package/web/dist/assets/stateDiagram-HBIQ2CUA-W313Uz16.js +1 -0
- package/web/dist/assets/stateDiagram-v2-4QOOHH4V-DDhJBZzf.js +1 -0
- package/web/dist/assets/swimlanes-XN3QIQJK-C29TL26K.js +1 -0
- package/web/dist/assets/swimlanesDiagram-VK2B7HYN-BfN-Xdmu.js +8 -0
- package/web/dist/assets/timeline-definition-24CTP7MA-CF0JMObX.js +120 -0
- package/web/dist/assets/vennDiagram-4TSXK5OY-DrDFsaH3.js +34 -0
- package/web/dist/assets/wardleyDiagram-VM6X3IG4-CVrnRIS_.js +78 -0
- package/web/dist/assets/xychartDiagram-S5SC5T6Z-BvkN1zsT.js +7 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/SiteWindow-DM0LQPCO.js +0 -18
- package/web/dist/assets/index-BHTBJlpY.js +0 -2125
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* server/lib/board-groups.js —— 画布的「组」与「小地图」(2026-08-23 黑板)
|
|
3
|
+
*
|
|
4
|
+
* 黑板不设几何容器(用户拍板:frame 给黑板开小灶,终局产物卡也要走同一套)。
|
|
5
|
+
* 「组」是派生的:**被线连在一起的一群东西算一组**(连通分量),再加上显式
|
|
6
|
+
* `tag` 字段把没连线的也归到一起。read_board 按组分段输出、用户按组整选、渲染
|
|
7
|
+
* 按组画包络,三处读同一个判据 —— 入座算法里「关系组独占成行」用的也是连通
|
|
8
|
+
* 分量,没有第二份真相。
|
|
9
|
+
*
|
|
10
|
+
* 小地图:把一层的物件投到一张 ≤ 48×16 的字符网格上。模型读网格的整体感远比
|
|
11
|
+
* 读三十行坐标强;从 board.json 直接算,零成本。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 连通分量 + tag 合并。
|
|
16
|
+
* @param {string[]} ids 这一层的物件 id
|
|
17
|
+
* @param {object} bindings board.bindings
|
|
18
|
+
* @param {(id:string)=>string|null} tagOf
|
|
19
|
+
* @returns {Array<{ members: string[], tags: Set<string>, edges: string[] }>} 大组在前
|
|
20
|
+
*/
|
|
21
|
+
export function groupObjects(ids, bindings, tagOf = () => null) {
|
|
22
|
+
const parent = new Map(ids.map(id => [id, id]));
|
|
23
|
+
const find = (x) => {
|
|
24
|
+
while (parent.get(x) !== x) { parent.set(x, parent.get(parent.get(x))); x = parent.get(x); }
|
|
25
|
+
return x;
|
|
26
|
+
};
|
|
27
|
+
const union = (a, b) => {
|
|
28
|
+
if (!parent.has(a) || !parent.has(b)) return;
|
|
29
|
+
const ra = find(a); const rb = find(b);
|
|
30
|
+
if (ra !== rb) parent.set(ra, rb);
|
|
31
|
+
};
|
|
32
|
+
const edgesIn = [];
|
|
33
|
+
const cross = [];
|
|
34
|
+
for (const [bid, b] of Object.entries(bindings || {})) {
|
|
35
|
+
if (b.by === 'auto' && b.type === 'ref') continue; // 自动取材边不成组(蜘蛛网)
|
|
36
|
+
if (!parent.has(b.from) || !parent.has(b.to)) continue;
|
|
37
|
+
// tag 优先:两端 tag 不同(或一端有一端没有)的线是"组间线",不把两张图粘成一组
|
|
38
|
+
// (08-23 真踩:两张草图之间一条 ref 线让 read_board 把它们报成一组 6 列)
|
|
39
|
+
const ta = tagOf(b.from); const tb = tagOf(b.to);
|
|
40
|
+
if (ta !== tb) { cross.push([bid, b]); continue; }
|
|
41
|
+
union(b.from, b.to); edgesIn.push([bid, b]);
|
|
42
|
+
}
|
|
43
|
+
// 同 tag 归一组
|
|
44
|
+
const byTag = new Map();
|
|
45
|
+
for (const id of ids) {
|
|
46
|
+
const t = tagOf(id);
|
|
47
|
+
if (!t) continue;
|
|
48
|
+
if (!byTag.has(t)) byTag.set(t, id); else union(byTag.get(t), id);
|
|
49
|
+
}
|
|
50
|
+
const groups = new Map();
|
|
51
|
+
for (const id of ids) {
|
|
52
|
+
const r = find(id);
|
|
53
|
+
if (!groups.has(r)) groups.set(r, { members: [], tags: new Set(), edges: [] });
|
|
54
|
+
const g = groups.get(r);
|
|
55
|
+
g.members.push(id);
|
|
56
|
+
const t = tagOf(id); if (t) g.tags.add(t);
|
|
57
|
+
}
|
|
58
|
+
for (const [bid, b] of edgesIn) {
|
|
59
|
+
const g = groups.get(find(b.from));
|
|
60
|
+
if (g) g.edges.push(bid);
|
|
61
|
+
}
|
|
62
|
+
const out = [...groups.values()].sort((a, b) => b.members.length - a.members.length);
|
|
63
|
+
out.cross = cross.map(([bid]) => bid); // 组间线 id(调用方愿意列就列)
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const GLYPHS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* ASCII 小地图。
|
|
71
|
+
* @param {Array<{id:string,x:number,y:number,w:number,h:number}>} rects
|
|
72
|
+
* @param {{ cols?: number, rows?: number, viewport?: {x,y,w,h}|null }} opts
|
|
73
|
+
* @returns {{ grid: string, legend: Array<[string,string]>, bbox: {x,y,w,h} }|null}
|
|
74
|
+
*/
|
|
75
|
+
export function asciiMinimap(rects, { cols = 48, rows = 16, viewport = null } = {}) {
|
|
76
|
+
if (!rects.length) return null;
|
|
77
|
+
let x0 = Infinity; let y0 = Infinity; let x1 = -Infinity; let y1 = -Infinity;
|
|
78
|
+
for (const r of rects) {
|
|
79
|
+
x0 = Math.min(x0, r.x); y0 = Math.min(y0, r.y);
|
|
80
|
+
x1 = Math.max(x1, r.x + r.w); y1 = Math.max(y1, r.y + r.h);
|
|
81
|
+
}
|
|
82
|
+
if (viewport) {
|
|
83
|
+
x0 = Math.min(x0, viewport.x); y0 = Math.min(y0, viewport.y);
|
|
84
|
+
x1 = Math.max(x1, viewport.x + viewport.w); y1 = Math.max(y1, viewport.y + viewport.h);
|
|
85
|
+
}
|
|
86
|
+
const W = Math.max(1, x1 - x0); const H = Math.max(1, y1 - y0);
|
|
87
|
+
// 等比:一个字符格近似正方(字符高宽比约 2:1,所以横向给两倍格数)
|
|
88
|
+
const scale = Math.max(W / cols, H / (rows * 2));
|
|
89
|
+
const gw = Math.max(1, Math.ceil(W / scale));
|
|
90
|
+
const gh = Math.max(1, Math.ceil(H / (scale * 2)));
|
|
91
|
+
const grid = Array.from({ length: gh }, () => Array(gw).fill('·'));
|
|
92
|
+
const legend = [];
|
|
93
|
+
if (viewport) {
|
|
94
|
+
// 视口边框用 +─│ 画在底下,物件盖在上面
|
|
95
|
+
const cx0 = Math.floor((viewport.x - x0) / scale); const cx1 = Math.min(gw - 1, Math.floor((viewport.x + viewport.w - x0) / scale));
|
|
96
|
+
const cy0 = Math.floor((viewport.y - y0) / (scale * 2)); const cy1 = Math.min(gh - 1, Math.floor((viewport.y + viewport.h - y0) / (scale * 2)));
|
|
97
|
+
for (let x = cx0; x <= cx1; x += 1) { if (grid[cy0]) grid[cy0][x] = '─'; if (grid[cy1]) grid[cy1][x] = '─'; }
|
|
98
|
+
for (let y = cy0; y <= cy1; y += 1) { if (grid[y]) { grid[y][cx0] = '│'; grid[y][cx1] = '│'; } }
|
|
99
|
+
if (grid[cy0]) { grid[cy0][cx0] = '┌'; grid[cy0][cx1] = '┐'; }
|
|
100
|
+
if (grid[cy1]) { grid[cy1][cx0] = '└'; grid[cy1][cx1] = '┘'; }
|
|
101
|
+
}
|
|
102
|
+
rects.forEach((r, i) => {
|
|
103
|
+
const g = i < GLYPHS.length ? GLYPHS[i] : '#';
|
|
104
|
+
legend.push([g, r.id]);
|
|
105
|
+
const cx0 = Math.floor((r.x - x0) / scale); const cx1 = Math.min(gw - 1, Math.max(cx0, Math.ceil((r.x + r.w - x0) / scale) - 1));
|
|
106
|
+
const cy0 = Math.floor((r.y - y0) / (scale * 2)); const cy1 = Math.min(gh - 1, Math.max(cy0, Math.ceil((r.y + r.h - y0) / (scale * 2)) - 1));
|
|
107
|
+
for (let y = cy0; y <= cy1; y += 1) for (let x = cx0; x <= cx1; x += 1) if (grid[y]) grid[y][x] = g;
|
|
108
|
+
});
|
|
109
|
+
return {
|
|
110
|
+
grid: grid.map(row => row.join('')).join('\n'),
|
|
111
|
+
legend,
|
|
112
|
+
bbox: { x: Math.round(x0), y: Math.round(y0), w: Math.round(W), h: Math.round(H) },
|
|
113
|
+
cell: Math.round(scale),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* ────────────────────────────────────────────────────────────────────────
|
|
118
|
+
* 相对位置(2026-08-23 晚,用户问「我们有没有告诉 agent 各模块的相对位置」——没有,
|
|
119
|
+
* 之前只给绝对坐标和一张小地图;模型对"组 2 在组 1 左侧、同高"这种话的理解远好于
|
|
120
|
+
* 读坐标)。下面把组的包围盒之间、组与用户视口之间的关系说成人话;组内再认一下
|
|
121
|
+
* 列(x 聚类)给出阅读顺序提示。
|
|
122
|
+
* ──────────────────────────────────────────────────────────────────────── */
|
|
123
|
+
|
|
124
|
+
export function bboxOfRects(rects) {
|
|
125
|
+
let x0 = Infinity; let y0 = Infinity; let x1 = -Infinity; let y1 = -Infinity;
|
|
126
|
+
for (const r of rects) { x0 = Math.min(x0, r.x); y0 = Math.min(y0, r.y); x1 = Math.max(x1, r.x + r.w); y1 = Math.max(y1, r.y + r.h); }
|
|
127
|
+
return Number.isFinite(x0) ? { x: x0, y: y0, w: x1 - x0, h: y1 - y0 } : null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** b 相对 a 在哪(以 a 为参照):左/右/上/下 + 距离;重叠则说重叠 */
|
|
131
|
+
export function relationOf(a, b) {
|
|
132
|
+
if (!a || !b) return null;
|
|
133
|
+
const gapX = b.x >= a.x + a.w ? b.x - (a.x + a.w) : (b.x + b.w <= a.x ? a.x - (b.x + b.w) : -1);
|
|
134
|
+
const gapY = b.y >= a.y + a.h ? b.y - (a.y + a.h) : (b.y + b.h <= a.y ? a.y - (b.y + b.h) : -1);
|
|
135
|
+
if (gapX < 0 && gapY < 0) return '重叠';
|
|
136
|
+
const horiz = gapX >= 0 && (gapY < 0 || gapX >= gapY);
|
|
137
|
+
if (horiz) {
|
|
138
|
+
const side = b.x >= a.x + a.w ? '右侧' : '左侧';
|
|
139
|
+
const vAlign = Math.abs(b.y - a.y) < 60 ? '顶齐' : (b.y > a.y ? '偏下' : '偏上');
|
|
140
|
+
return `${side} ${Math.round(gapX)}px(${vAlign})`;
|
|
141
|
+
}
|
|
142
|
+
const side = b.y >= a.y + a.h ? '下方' : '上方';
|
|
143
|
+
const hAlign = Math.abs(b.x - a.x) < 60 ? '左齐' : (b.x > a.x ? '偏右' : '偏左');
|
|
144
|
+
return `${side} ${Math.round(gapY)}px(${hAlign})`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** 组内的列:按左边缘聚类(容差 60px),返回每列的成员数(自左向右) */
|
|
148
|
+
export function columnsOf(rects) {
|
|
149
|
+
const xs = [...rects].sort((a, b) => a.x - b.x);
|
|
150
|
+
const cols = [];
|
|
151
|
+
for (const r of xs) {
|
|
152
|
+
const c = cols.find(k => Math.abs(k.x - r.x) < 60);
|
|
153
|
+
if (c) { c.n += 1; c.x = (c.x * (c.n - 1) + r.x) / c.n; } else cols.push({ x: r.x, n: 1 });
|
|
154
|
+
}
|
|
155
|
+
return cols;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** 视口与一块区域的关系 */
|
|
159
|
+
export function viewportRelation(vp, box) {
|
|
160
|
+
if (!vp || !box) return null;
|
|
161
|
+
const ix = Math.max(0, Math.min(vp.x + vp.w, box.x + box.w) - Math.max(vp.x, box.x));
|
|
162
|
+
const iy = Math.max(0, Math.min(vp.y + vp.h, box.y + box.h) - Math.max(vp.y, box.y));
|
|
163
|
+
const cover = (ix * iy) / Math.max(1, box.w * box.h);
|
|
164
|
+
if (cover >= 0.95) return '整块在用户视口里';
|
|
165
|
+
if (cover > 0) return `用户视口盖住它约 ${Math.round(cover * 100)}%`;
|
|
166
|
+
return `在用户视口之外(视口${relationOf(box, vp) || '别处'})`;
|
|
167
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* server/lib/board-hero.js —— 主角判断的服务端镜像(2026-08-23 黑板)
|
|
3
|
+
*
|
|
4
|
+
* 前端 `web/src/lib/hero.js` 的 pickHero 决定哪张产物卡放大 1.5 倍。服务端摆东西
|
|
5
|
+
* (sketch_on_board / create_on_board 的避让、read_board 的尺寸、look_at_board 的
|
|
6
|
+
* 取景)在这之前只认基础尺寸,于是"锚在主角卡右边"会落进主角卡的身体里
|
|
7
|
+
* (08-23 首跑真踩:纸本站是主角 960 宽,草图从 640 处起算,整张压进去)。
|
|
8
|
+
*
|
|
9
|
+
* **镜像不是重写**:下面 pickHero 的函数体必须与 web/src/lib/hero.js 逐字相同,
|
|
10
|
+
* board-hero.test.js 读两份源码比对。npm 包不带 web/src,所以不能 import 过来。
|
|
11
|
+
*/
|
|
12
|
+
import { DECK_EMBED_W, ARTIFACT_HEADER_H, ARTIFACT_PREVIEW_H } from './board-kind-sizes.js';
|
|
13
|
+
import { layerOf } from './canvas-id.js';
|
|
14
|
+
|
|
15
|
+
// ── 以下到 END-MIRROR 与 web/src/lib/hero.js 逐字一致 ──
|
|
16
|
+
const ELIGIBLE = new Set(['deck', 'site', 'docx']);
|
|
17
|
+
const FOCUS_PER_EDGE = 0.5;
|
|
18
|
+
const FOCUS_CAP = 1.5;
|
|
19
|
+
export function pickHero(items, bindings) {
|
|
20
|
+
const score = new Map();
|
|
21
|
+
for (const it of items || []) {
|
|
22
|
+
if (ELIGIBLE.has(it.type)) score.set(String(it.id), 3);
|
|
23
|
+
}
|
|
24
|
+
if (!score.size) return null;
|
|
25
|
+
const bump = (id, d) => { if (score.has(id)) score.set(id, score.get(id) + d); };
|
|
26
|
+
const focus = new Map();
|
|
27
|
+
for (const b of Object.values(bindings || {})) {
|
|
28
|
+
if (b.type === 'derives-from') { bump(b.from, 2); bump(b.to, -3); }
|
|
29
|
+
if (b.type === 'ref' && b.by === 'auto') bump(b.to, -3);
|
|
30
|
+
if (b.by === 'user' || b.by === 'agent') {
|
|
31
|
+
for (const end of [b.from, b.to]) {
|
|
32
|
+
if (!score.has(end)) continue;
|
|
33
|
+
const used = focus.get(end) || 0;
|
|
34
|
+
if (used >= FOCUS_CAP) continue;
|
|
35
|
+
focus.set(end, used + FOCUS_PER_EDGE);
|
|
36
|
+
bump(end, FOCUS_PER_EDGE);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const ranked = [...score.entries()].sort((a, z) => z[1] - a[1] || a[0].localeCompare(z[0]));
|
|
41
|
+
if (ranked.length === 1) return ranked[0][0];
|
|
42
|
+
if (ranked[0][1] === ranked[1][1]) return null; // 并列 = 没证据
|
|
43
|
+
return ranked[0][0];
|
|
44
|
+
}
|
|
45
|
+
// ── END-MIRROR ──
|
|
46
|
+
|
|
47
|
+
export const HERO_SCALE = 1.5; // = web board-kinds.js HERO_SCALE
|
|
48
|
+
|
|
49
|
+
const TYPE_RE = /^(deck|site|docx):/;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 这块板此刻的主角 id(只看桌面根层,与前端入座同口径):board.hero 显式覆盖,
|
|
53
|
+
* 否则按关系线推。前端还会把谱系收叠藏起来的旧版排除在外 —— 那些卡本来就被
|
|
54
|
+
* 改自边重罚,结果一致,这里不重复实现收叠。
|
|
55
|
+
*/
|
|
56
|
+
export function boardHeroId(board) {
|
|
57
|
+
const known = new Set(Object.keys(board?.zones || {}));
|
|
58
|
+
const items = [];
|
|
59
|
+
for (const [id, e] of Object.entries(board?.objects || {})) {
|
|
60
|
+
if (!Number.isFinite(e?.x) || e.kind) continue;
|
|
61
|
+
if (layerOf(id, e, known) !== '') continue;
|
|
62
|
+
const m = TYPE_RE.exec(id);
|
|
63
|
+
if (m) items.push({ id, type: m[1] });
|
|
64
|
+
}
|
|
65
|
+
if (board?.hero && items.some(it => it.id === board.hero)) return board.hero;
|
|
66
|
+
return pickHero(items, board?.bindings || {});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function heroSize(id) {
|
|
70
|
+
const m = TYPE_RE.exec(String(id || ''));
|
|
71
|
+
if (!m) return null;
|
|
72
|
+
return {
|
|
73
|
+
w: Math.round(DECK_EMBED_W * HERO_SCALE),
|
|
74
|
+
h: ARTIFACT_HEADER_H + Math.round(ARTIFACT_PREVIEW_H[m[1]] * HERO_SCALE),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { KINDS } from './kinds/index.js';
|
|
13
|
+
import { boardHeroId, heroSize } from './board-hero.js';
|
|
13
14
|
|
|
14
15
|
export const DECK_EMBED_W = 640;
|
|
15
16
|
export const ARTIFACT_HEADER_H = 28;
|
|
@@ -60,3 +61,25 @@ export function estimateSize(id, entry) {
|
|
|
60
61
|
if (/\.(md|txt)$/i.test(s)) return KIND_SIZES.note;
|
|
61
62
|
return KIND_SIZES.file;
|
|
62
63
|
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 按**这块板**估尺寸:主角卡(board-hero 的判断)放大 1.5 倍,其余同 estimateSize。
|
|
67
|
+
* 凡是拿尺寸去避让/取景/列座次的地方都该用它 —— 主角不是特例,是版面常态。
|
|
68
|
+
* heroId 按 board 对象缓存一次(同一次工具调用里反复问不重算)。
|
|
69
|
+
*/
|
|
70
|
+
const heroCache = new WeakMap();
|
|
71
|
+
export function estimateSizeOn(board, id, entry) {
|
|
72
|
+
if (entry && Number.isFinite(entry.w) && Number.isFinite(entry.h)) return { w: entry.w, h: entry.h };
|
|
73
|
+
if (board && typeof board === 'object') {
|
|
74
|
+
let hero = heroCache.get(board);
|
|
75
|
+
if (hero === undefined) {
|
|
76
|
+
hero = boardHeroId(board);
|
|
77
|
+
heroCache.set(board, hero);
|
|
78
|
+
}
|
|
79
|
+
if (hero && hero === id) {
|
|
80
|
+
const hs = heroSize(id);
|
|
81
|
+
if (hs) return hs;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return estimateSize(id, entry);
|
|
85
|
+
}
|
|
@@ -38,7 +38,7 @@ const DIR_KIND_PREFIXES = Object.entries(KINDS)
|
|
|
38
38
|
export function describeEndpoint(end, board) {
|
|
39
39
|
const obj = board?.objects?.[end];
|
|
40
40
|
if (obj?.kind === 'text') {
|
|
41
|
-
const t = String(obj.data?.t || '').replace(/\s+/g, ' ').trim().slice(0,
|
|
41
|
+
const t = String(obj.data?.t || '').replace(/\s+/g, ' ').trim().slice(0, 48);
|
|
42
42
|
return `手写字「${t}」`;
|
|
43
43
|
}
|
|
44
44
|
if (obj?.kind === 'scribble') return '一笔涂鸦(笔画内容要看得截图画布)';
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* server/lib/chalk.js —— 板书(2026-08-23 黑板三期:真相住文件)
|
|
3
|
+
*
|
|
4
|
+
* 板书 = agent(或用户)写在画布上的一段话,**本体是 `notes/板书/<stamp>-<slug>.md`**,
|
|
5
|
+
* board.json 只存它摆在哪、跟谁连线。为什么是文件而不是 board.json 里的 text 物件:
|
|
6
|
+
* - agent 要能 Read/Grep/Edit 自己说过的话,git 要有历史(板书是对话,不是记号)
|
|
7
|
+
* - board.json 512KB 一把锁整份重写,当对话容器撑不住
|
|
8
|
+
* - 跟「kind 文件即真相」一致:删文件即消失,改名/搬家走同一套对账
|
|
9
|
+
* 草图里的节点仍是画布原生 text(它们是几何的一部分,不是散文)—— 两种东西本来
|
|
10
|
+
* 就不一样,不硬凑。
|
|
11
|
+
*
|
|
12
|
+
* frontmatter(只认这几个键,其余原样当正文):
|
|
13
|
+
* nd: chalk 标记(没有它就是普通便利贴)
|
|
14
|
+
* by: agent|user
|
|
15
|
+
* at: ISO 时间
|
|
16
|
+
* anchor: <canvas id> 这段话关于谁(落盘时顺手画 annotates 线)
|
|
17
|
+
* reply_to: <path> 回应哪一条板书(线程)
|
|
18
|
+
* tag: <tag> 归哪一组
|
|
19
|
+
* session: <sid> (沿用便利贴的归属字段)
|
|
20
|
+
*/
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { promises as fs } from 'node:fs';
|
|
23
|
+
|
|
24
|
+
export const CHALK_DIR = 'notes/板书';
|
|
25
|
+
const FM_RE = /^---\n([\s\S]{0,800}?)\n---\n?/;
|
|
26
|
+
|
|
27
|
+
export function parseChalk(raw) {
|
|
28
|
+
const m = FM_RE.exec(raw);
|
|
29
|
+
if (!m) return { body: raw, chalk: null, sessionId: null };
|
|
30
|
+
const head = m[1];
|
|
31
|
+
const get = (k) => { const r = new RegExp(`(?:^|\\n)${k}:\\s*(.+?)\\s*(?:\\n|$)`).exec(head); return r ? r[1].trim() : null; };
|
|
32
|
+
const body = raw.slice(m[0].length).replace(/^\n+/, '').replace(/\n+$/, '');
|
|
33
|
+
if (get('nd') !== 'chalk') return { body, chalk: null, sessionId: get('session') };
|
|
34
|
+
return {
|
|
35
|
+
body,
|
|
36
|
+
sessionId: get('session'),
|
|
37
|
+
chalk: {
|
|
38
|
+
by: get('by') === 'user' ? 'user' : 'agent',
|
|
39
|
+
at: get('at'),
|
|
40
|
+
anchor: get('anchor'),
|
|
41
|
+
replyTo: get('reply_to'),
|
|
42
|
+
tag: get('tag'),
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function renderChalk({ body, by = 'agent', at = new Date().toISOString(), anchor = null, replyTo = null, tag = null, sessionId = null }) {
|
|
48
|
+
const lines = ['---', 'nd: chalk', `by: ${by}`, `at: ${at}`];
|
|
49
|
+
if (anchor) lines.push(`anchor: ${anchor}`);
|
|
50
|
+
if (replyTo) lines.push(`reply_to: ${replyTo}`);
|
|
51
|
+
if (tag) lines.push(`tag: ${tag}`);
|
|
52
|
+
if (sessionId) lines.push(`session: ${sessionId}`);
|
|
53
|
+
lines.push('---', '', String(body).trim(), '');
|
|
54
|
+
return lines.join('\n');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 文件名:时间戳 + 正文首行做的短名(只留字母数字中日文,≤24),保证可读可排序 */
|
|
58
|
+
export function chalkFileName(body, now = new Date()) {
|
|
59
|
+
const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, '').replace('T', '-');
|
|
60
|
+
const first = String(body || '').split('\n').find(l => l.trim()) || '';
|
|
61
|
+
const slug = first.replace(/^#+\s*/, '').replace(/[*_`>#\[\]()]/g, '').replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-+|-+$/g, '').slice(0, 24) || 'chalk';
|
|
62
|
+
return `${stamp}-${slug}.md`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 写一条板书到工作区;返回相对路径 */
|
|
66
|
+
export async function writeChalkFile(sharedRoot, fileName, content, { overwrite = false } = {}) {
|
|
67
|
+
const dir = path.join(sharedRoot, CHALK_DIR);
|
|
68
|
+
await fs.mkdir(dir, { recursive: true });
|
|
69
|
+
let name = fileName;
|
|
70
|
+
if (!overwrite) {
|
|
71
|
+
// 同一秒两条同首行的板书会撞名(fable 08-23 P2):存在就加序号,绝不静默覆盖
|
|
72
|
+
for (let i = 2; i < 100; i += 1) {
|
|
73
|
+
try { await fs.access(path.join(dir, name)); } catch { break; }
|
|
74
|
+
name = fileName.replace(/\.md$/, `-${i}.md`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const abs = path.join(dir, name);
|
|
78
|
+
await fs.writeFile(abs, content, 'utf8');
|
|
79
|
+
return `${CHALK_DIR}/${name}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 最近的板书(给注入用):按文件名(=时间戳)倒序,读前几条的首行 */
|
|
83
|
+
export async function recentChalk(sharedRoot, { limit = 8 } = {}) {
|
|
84
|
+
const dir = path.join(sharedRoot, CHALK_DIR);
|
|
85
|
+
let names;
|
|
86
|
+
try { names = (await fs.readdir(dir)).filter(n => n.endsWith('.md') && !n.startsWith('.')); } catch { return []; }
|
|
87
|
+
names.sort().reverse();
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const n of names.slice(0, limit)) {
|
|
90
|
+
try {
|
|
91
|
+
const raw = await fs.readFile(path.join(dir, n), 'utf8');
|
|
92
|
+
const { body, chalk } = parseChalk(raw);
|
|
93
|
+
if (!chalk) continue;
|
|
94
|
+
const first = (body.split('\n').find(l => l.trim()) || '').replace(/^#+\s*/, '').slice(0, 80);
|
|
95
|
+
out.push({ path: `${CHALK_DIR}/${n}`, first, ...chalk });
|
|
96
|
+
} catch { /* 单条读不到就跳 */ }
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 给一批画布 id 里的板书读首行(read_board 用):Map<id, {first, by, anchor, replyTo}> */
|
|
102
|
+
export async function chalkExcerpts(sharedRoot, ids) {
|
|
103
|
+
const out = new Map();
|
|
104
|
+
const base = path.resolve(sharedRoot, CHALK_DIR);
|
|
105
|
+
for (const id of ids) {
|
|
106
|
+
if (typeof id !== 'string' || !id.startsWith(`${CHALK_DIR}/`)) continue;
|
|
107
|
+
const name = id.slice(CHALK_DIR.length + 1);
|
|
108
|
+
// 读侧同一道闸:单段文件名、不越界(fable 08-23:id 可控,别拿它直接 join)
|
|
109
|
+
if (!name || name.includes('/') || name.includes('..') || name.startsWith('.')) continue;
|
|
110
|
+
const abs = path.resolve(base, name);
|
|
111
|
+
if (!abs.startsWith(base + path.sep)) continue;
|
|
112
|
+
try {
|
|
113
|
+
const raw = await fs.readFile(abs, 'utf8');
|
|
114
|
+
const { body, chalk } = parseChalk(raw);
|
|
115
|
+
if (!chalk) continue;
|
|
116
|
+
const first = (body.split('\n').find(l => l.trim()) || '').replace(/^#+\s*/, '').replace(/\*\*/g, '').slice(0, 60);
|
|
117
|
+
out.set(id, { first, ...chalk });
|
|
118
|
+
} catch { /* 文件没了:留给对账 */ }
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|