@fanchao8609/agent_brain_sync 1.8.8 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/serve.js ADDED
@@ -0,0 +1,506 @@
1
+ /**
2
+ * abs serve —— 把 .brain/ 挂成一个只读网页。
3
+ *
4
+ * 为什么需要 http 服务(而不是一个能双击打开的 html):
5
+ * 浏览器不允许 JS 列出本地目录,file:// 下 fetch 也被 CORS 禁。
6
+ * 要"自动列出文件夹里的 md",就必须有一个能读目录的东西 —— 服务端。
7
+ * 这是浏览器安全模型决定的,不是实现选择。
8
+ *
9
+ * 渲染与样式全部走 CDN(marked 转 md、highlight.js 高亮)——
10
+ * 不自己写解析器、不内嵌资源,页面本体只有布局和交互。
11
+ * CDN 用 lib.baomitu.com(360 源,国内可达)。
12
+ *
13
+ * 边界(刻意收窄):
14
+ * · 只绑 127.0.0.1 —— 不对外网开放
15
+ * · 只读 —— 没有任何写接口;图谱仍由 CLI 改
16
+ * · 只服务 .brain/ 内部 —— 路径穿越被拒
17
+ * · 零依赖 —— 只用 node 内置 http/fs;前端库由浏览器从 CDN 取
18
+ */
19
+
20
+ import { promises as fs } from 'node:fs';
21
+ import { join, normalize, extname, relative, sep } from 'node:path';
22
+ import http from 'node:http';
23
+
24
+ const MIME = {
25
+ '.md': 'text/markdown; charset=utf-8',
26
+ '.json': 'application/json; charset=utf-8',
27
+ '.html': 'text/html; charset=utf-8',
28
+ '.txt': 'text/plain; charset=utf-8',
29
+ };
30
+
31
+ /** CDN 基址。换源只改这一行。 */
32
+ const CDN = 'https://lib.baomitu.com';
33
+
34
+ /** 递归收集 .brain/ 下的 md(相对 root 的路径)。 */
35
+ /**
36
+ * 组名展示映射(2026-09-16 用户定):
37
+ * · 根组 → 用【项目目录名】(.brain/ 的父目录),不看泛的"根"
38
+ * · 标准子目录 → 中文名,网页上比英文目录名直观
39
+ * 未列出的目录用原名(用户自建的目录不该被改名)。
40
+ */
41
+ export const GROUP_NAMES = {
42
+ concepts: '概念', entities: '实体', syntheses: '综合',
43
+ sources: '来源', sessions: '会话',
44
+ };
45
+
46
+ /** 把内部组 key 映射成展示名。rootLabel = 根组的显示名。 */
47
+ export function displayGroup(key, rootLabel) {
48
+ if (key === ROOT_KEY) return rootLabel;
49
+ return GROUP_NAMES[key] || key;
50
+ }
51
+
52
+ /** 内部约定:根组固定用这个 key 参与排序,展示时再换成目录名。 */
53
+ export const ROOT_KEY = '__root__';
54
+
55
+ export async function scanTree(root, { maxDepth = 3 } = {}) {
56
+ const groups = {};
57
+ async function walk(dir, depth) {
58
+ if (depth > maxDepth) return;
59
+ let ents;
60
+ try { ents = await fs.readdir(dir, { withFileTypes: true }); } catch { return; }
61
+ for (const e of ents) {
62
+ if (e.name.startsWith('.')) continue;
63
+ const p = join(dir, e.name);
64
+ if (e.isDirectory()) { await walk(p, depth + 1); continue; }
65
+ if (!e.name.endsWith('.md')) continue;
66
+ const parts = relative(root, p).split(sep);
67
+ const group = parts.length > 1 ? parts[0] : ROOT_KEY;
68
+ (groups[group] ||= []).push({
69
+ path: parts.join('/'),
70
+ name: e.name.replace(/\.md$/, ''),
71
+ });
72
+ }
73
+ }
74
+ await walk(root, 0);
75
+ for (const g of Object.keys(groups)) groups[g].sort((a, b) => a.name.localeCompare(b.name));
76
+
77
+ // 顶部置顶的顺序:看板/日志/索引是每次开工先看的三个。
78
+ // 它们本来就在根目录,但会被其它组插在中间 —— 显式排序而不靠目录遍历顺序。
79
+ const ROOT_ORDER = ['index', 'todo', 'log'];
80
+ const ordered = {};
81
+ if (groups[ROOT_KEY]) {
82
+ groups[ROOT_KEY].sort((a, b) => {
83
+ const ia = ROOT_ORDER.indexOf(a.name), ib = ROOT_ORDER.indexOf(b.name);
84
+ return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib) || a.name.localeCompare(b.name);
85
+ });
86
+ ordered[ROOT_KEY] = groups[ROOT_KEY];
87
+ }
88
+ // 知识页在前(常用的),流水在后
89
+ const DIR_ORDER = ['concepts', 'entities', 'syntheses', 'sources', 'sessions'];
90
+ for (const d of DIR_ORDER) if (groups[d]) ordered[d] = groups[d];
91
+ for (const g of Object.keys(groups)) if (!(g in ordered)) ordered[g] = groups[g];
92
+ return ordered;
93
+ }
94
+
95
+ /** 解析请求路径 → .brain/ 内的绝对路径;越界返回 null。 */
96
+ export function safeResolve(root, urlPath) {
97
+ const clean = decodeURIComponent(String(urlPath).split('?')[0]);
98
+ const rel = normalize(clean).replace(/^(\.\.(\/|\\|$))+/, '');
99
+ const abs = join(root, rel);
100
+ const r = relative(root, abs);
101
+ if (r.startsWith('..') || r.includes(`..${sep}`)) return null;
102
+ return abs;
103
+ }
104
+
105
+ /** 浏览器页面。布局与交互在此,渲染/高亮由 CDN 库负责。 */
106
+ export const PAGE = `<!DOCTYPE html>
107
+ <html lang="zh-CN"><head><meta charset="utf-8">
108
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
109
+ <meta name="color-scheme" content="light dark">
110
+ <title>brain</title>
111
+ <link rel="stylesheet" href="${CDN}/highlight.js/11.4.0/styles/github.min.css" data-hl="light">
112
+ <link rel="stylesheet" href="${CDN}/highlight.js/11.4.0/styles/github-dark.min.css" data-hl="dark">
113
+ <style>
114
+ /* ============================================================
115
+ 主题:5 套配色 × 2 档明暗 = 一套变量切换。
116
+ 不引外部 CSS 表 —— 那些是整站样式,会和侧栏布局打架。
117
+ 变量只覆盖颜色与字体,布局与结构不受影响。
118
+ ============================================================ */
119
+ :root{
120
+ --fs:16px; --lh:1.75;
121
+ --radius:9px;
122
+ --serif:"Songti SC","SimSun",Georgia,serif;
123
+ --sans:-apple-system,BlinkMacSystemFont,"PingFang SC","Hiragino Sans GB","Microsoft YaHei","Source Han Sans SC","Noto Sans CJK SC","Segoe UI",sans-serif;
124
+ --mono:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;
125
+ }
126
+
127
+ /* --- 主题 1: github(中性,默认) --- */
128
+ [data-theme="github"]{
129
+ --bg:#fff;--fg:#1f2328;--dim:#656d76;--side:#f6f8fa;--line:#d8dee4;
130
+ --link:#0969da;--sel:#dbeafe;--code-bg:#f6f8fa;--mark:#fff3bf;--hl-line:#eaeef2;
131
+ --f-body:var(--sans);--f-head:var(--sans);
132
+ }
133
+ [data-theme="github"][data-mode="dark"]{
134
+ --bg:#0d1117;--fg:#e6edf3;--dim:#8b949e;--side:#161b22;--line:#30363d;
135
+ --link:#4493f8;--sel:#1f3352;--code-bg:#161b22;--mark:#4a3d1a;--hl-line:#21262d;
136
+ }
137
+
138
+ /* --- 主题 2: 阅读(衬线、宽行距、暖底,适合长文) --- */
139
+ [data-theme="read"]{
140
+ --bg:#fdfcf8;--fg:#2b2b2b;--dim:#8a8078;--side:#f6f2ea;--line:#e6dfd4;
141
+ --link:#b45309;--sel:#fde9c8;--code-bg:#f6f2ea;--mark:#ffe9a8;--hl-line:#efe9dd;
142
+ --fs:17px;--lh:1.9;
143
+ --f-body:var(--serif);--f-head:var(--serif);
144
+ }
145
+ [data-theme="read"][data-mode="dark"]{
146
+ --bg:#1a1815;--fg:#ddd6cc;--dim:#8a8078;--side:#221f1b;--line:#37322b;
147
+ --link:#e0a458;--sel:#3d3528;--code-bg:#221f1b;--mark:#4d3f1e;--hl-line:#2a2621;
148
+ }
149
+
150
+ /* --- 主题 3: 松(冷绿、紧凑、信息密度高) --- */
151
+ [data-theme="pine"]{
152
+ --bg:#ffffff;--fg:#24292f;--dim:#57606a;--side:#eef4f1;--line:#cfe0d9;
153
+ --link:#0f766e;--sel:#cceee7;--code-bg:#eef4f1;--mark:#d3f2c4;--hl-line:#e3ede9;
154
+ --fs:15.5px;--lh:1.68;
155
+ --f-body:var(--sans);--f-head:var(--sans);
156
+ }
157
+ [data-theme="pine"][data-mode="dark"]{
158
+ --bg:#0e1512;--fg:#d5e0db;--dim:#7d9189;--side:#141d19;--line:#26362f;
159
+ --link:#5eead4;--sel:#1b3a33;--code-bg:#141d19;--mark:#33471f;--hl-line:#1a2620;
160
+ }
161
+
162
+ /* --- 主题 4: 墨(黑白灰、极简、无彩色) --- */
163
+ [data-theme="ink"]{
164
+ --bg:#fff;--fg:#111;--dim:#888;--side:#fafafa;--line:#e0e0e0;
165
+ --link:#111;--sel:#ededed;--code-bg:#f7f7f7;--mark:#ffe97a;--hl-line:#f0f0f0;
166
+ --fs:16px;--lh:1.78;
167
+ --f-body:var(--sans);--f-head:var(--sans);
168
+ }
169
+ [data-theme="ink"][data-mode="dark"]{
170
+ --bg:#111;--fg:#eee;--dim:#8a8a8a;--side:#1a1a1a;--line:#2e2e2e;
171
+ --link:#fff;--sel:#2a2a2a;--code-bg:#1a1a1a;--mark:#5a5024;--hl-line:#242424;
172
+ }
173
+
174
+ /* --- 主题 5: 蓝调(深蓝底,低对比护眼) --- */
175
+ [data-theme="ocean"]{
176
+ --bg:#f4f7fb;--fg:#1e293b;--dim:#64748b;--side:#e8eef7;--line:#cbd8e8;
177
+ --link:#1d4ed8;--sel:#d5e3fb;--code-bg:#e8eef7;--mark:#cfe0ff;--hl-line:#dfe8f4;
178
+ --fs:16px;--lh:1.78;
179
+ --f-body:var(--sans);--f-head:var(--sans);
180
+ }
181
+ [data-theme="ocean"][data-mode="dark"]{
182
+ --bg:#0a1120;--fg:#dbe4f0;--dim:#7f8ea4;--side:#111c30;--line:#1f2f47;
183
+ --link:#7ea6ff;--sel:#1c2c4a;--code-bg:#111c30;--mark:#2f4573;--hl-line:#16233a;
184
+ }
185
+
186
+ /* 未指定主题 / 跟随系统时的兜底 = github */
187
+ html:not([data-theme]){ --bg:#fff;--fg:#1f2328;--dim:#656d76;--side:#f6f8fa;--line:#d8dee4;--link:#0969da;--sel:#dbeafe;--code-bg:#f6f8fa;--mark:#fff3bf;--f-body:var(--sans);--f-head:var(--sans) }
188
+ @media(prefers-color-scheme:dark){
189
+ html:not([data-theme]):not([data-mode="light"]){ --bg:#0d1117;--fg:#e6edf3;--dim:#8b949e;--side:#161b22;--line:#30363d;--link:#4493f8;--sel:#1f3352;--code-bg:#161b22;--mark:#4a3d1a }
190
+ }
191
+
192
+ *{box-sizing:border-box}
193
+ html,body{height:100%}
194
+ body{
195
+ margin:0;background:var(--bg);color:var(--fg);
196
+ font:var(--fs)/var(--lh) var(--f-body);
197
+ text-spacing-trim:space-first; /* 中西文自动间距,中文整齐的关键 */
198
+ -webkit-text-size-adjust:100%;
199
+ overflow-x:hidden;
200
+ transition:background .18s,color .18s;
201
+ }
202
+
203
+ /* ---- 侧栏 ---- */
204
+ aside{width:290px;flex:0 0 290px;background:var(--side);border-right:1px solid var(--line);
205
+ height:100vh;position:sticky;top:0;display:flex;flex-direction:column;
206
+ transition:transform .22s ease}
207
+ #wrap{display:flex;min-height:100%}
208
+ #hd{padding:12px 14px 10px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:6px}
209
+ #hd .brand{flex:1;font-size:12.5px;font-weight:700;letter-spacing:.1em;color:var(--dim)}
210
+ #hd button{border:1px solid var(--line);background:var(--bg);color:var(--dim);border-radius:6px;
211
+ width:30px;height:30px;line-height:1;cursor:pointer;font-size:14px;padding:0;flex:0 0 auto}
212
+ #hd button:hover{color:var(--fg);border-color:var(--link)}
213
+ #q{margin:10px 14px 0;padding:7px 10px;border:1px solid var(--line);border-radius:7px;
214
+ background:var(--bg);color:var(--fg);font-size:13px;font-family:inherit;width:calc(100% - 28px)}
215
+ #q:focus{outline:none;border-color:var(--link);box-shadow:0 0 0 3px var(--sel)}
216
+ #tree{overflow-y:auto;flex:1;padding:6px 0 24px}
217
+ .g{margin:10px 0 3px;padding:5px 14px;font-size:11.5px;color:var(--dim);font-weight:700;letter-spacing:.05em;
218
+ display:flex;align-items:center;gap:5px}
219
+ .g.fold{cursor:pointer;user-select:none}
220
+ .g.fold:hover{background:var(--sel);color:var(--fg)}
221
+ .g .cnt{margin-left:auto;opacity:.6;font-weight:500}
222
+ #tree a{display:block;padding:5px 14px 5px 22px;color:var(--fg);text-decoration:none;font-size:13.5px;
223
+ border-left:2px solid transparent;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
224
+ #tree a:hover{background:var(--sel)}
225
+ #tree a.on{background:var(--sel);border-left-color:var(--link);color:var(--link);font-weight:600}
226
+ #mask{display:none}
227
+
228
+ /* ---- 正文 ---- */
229
+ main{flex:1;min-width:0;padding:34px 44px 96px;max-width:900px}
230
+ main h1,main h2{border-bottom:1px solid var(--line);padding-bottom:.32em;line-height:1.42;font-family:var(--f-head)}
231
+ main h1{font-size:1.75em;margin:.2em 0 .8em;font-weight:700}
232
+ main h2{font-size:1.34em;margin:1.7em 0 .7em;font-weight:700}
233
+ main h3{font-size:1.14em;margin:1.5em 0 .6em;font-weight:600;font-family:var(--f-head)}
234
+ main h4{font-size:1.02em;margin:1.3em 0 .5em;font-weight:600}
235
+ main p{margin:.85em 0}
236
+ main ul,main ol{padding-left:1.6em;margin:.85em 0}
237
+ main li{margin:.3em 0}
238
+ main table{border-collapse:collapse;margin:1.1em 0;display:block;overflow-x:auto;font-size:.93em}
239
+ main th,main td{border:1px solid var(--line);padding:7px 12px;line-height:1.55}
240
+ main th{background:var(--side);font-weight:600;white-space:nowrap}
241
+ main blockquote{margin:1em 0;padding:.1em 1em;border-left:.25em solid var(--line);color:var(--dim)}
242
+ main hr{border:none;border-top:1px solid var(--line);margin:1.8em 0}
243
+ main a{color:var(--link);text-decoration:none}
244
+ main a:hover{text-decoration:underline}
245
+ main code{font-family:var(--mono);font-size:.86em}
246
+ main :not(pre)>code{background:var(--code-bg);padding:.18em .42em;border-radius:5px;border:1px solid var(--line)}
247
+ main pre{border-radius:var(--radius);overflow-x:auto;border:1px solid var(--line);margin:1em 0}
248
+ main pre code{border:none;background:none;padding:0;font-size:.84em;line-height:1.62}
249
+ main img{max-width:100%;height:auto;border-radius:6px}
250
+ a.wl{cursor:pointer;border-bottom:1px dotted currentColor}
251
+ a.wl:hover{background:var(--mark)}
252
+ a.wl.miss{opacity:.4;text-decoration:line-through}
253
+ #empty{color:var(--dim);padding:80px 0;text-align:center;font-size:14px}
254
+ main>*:first-child{margin-top:0}
255
+
256
+ /* ---- 主题选择面板 ---- */
257
+ #tp{position:absolute;right:10px;top:50px;z-index:30;background:var(--bg);border:1px solid var(--line);
258
+ border-radius:10px;padding:10px;box-shadow:0 6px 24px rgba(0,0,0,.16);display:none;min-width:150px}
259
+ #tp.on{display:block}
260
+ #tp .t{font-size:11px;color:var(--dim);font-weight:700;letter-spacing:.05em;margin:0 0 6px 2px}
261
+ #tp .row{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;cursor:pointer;font-size:13px}
262
+ #tp .row:hover{background:var(--sel)}
263
+ #tp .row.cur{background:var(--sel);font-weight:600}
264
+ #tp .sw{width:14px;height:14px;border-radius:4px;border:1px solid var(--line);flex:0 0 auto}
265
+ #hdr{position:relative}
266
+
267
+ /* ---- 手机:侧栏变抽屉 ---- */
268
+ @media(max-width:820px){
269
+ aside{position:fixed;left:0;top:0;z-index:20;width:min(84vw,320px);flex-basis:auto;
270
+ transform:translateX(-100%);box-shadow:2px 0 18px rgba(0,0,0,.2)}
271
+ body.nav-open aside{transform:translateX(0)}
272
+ body.nav-open #mask{display:block;position:fixed;inset:0;background:rgba(0,0,0,.44);z-index:10}
273
+ main{padding:16px 18px 90px;max-width:100%}
274
+ main h1{font-size:1.42em}main h2{font-size:1.2em}
275
+ :root{--fs:15.5px;--lh:1.8}
276
+ main table{font-size:.88em}
277
+ }
278
+ #fab{display:none}
279
+ @media(max-width:820px){
280
+ #fab{display:flex;position:fixed;left:14px;bottom:calc(16px + env(safe-area-inset-bottom));z-index:9;
281
+ width:46px;height:46px;border-radius:50%;border:1px solid var(--line);background:var(--side);color:var(--fg);
282
+ align-items:center;justify-content:center;font-size:19px;cursor:pointer;box-shadow:0 3px 14px rgba(0,0,0,.22)}
283
+ }
284
+ </style></head>
285
+ <body>
286
+ <div id="wrap">
287
+ <aside>
288
+ <div id="hd">
289
+ <span class="brand">BRAIN</span>
290
+ <button id="mode" title="明/暗">◐</button>
291
+ <button id="theme" title="主题">🎨</button>
292
+ <button id="close" title="收起" style="display:none">✕</button>
293
+ </div>
294
+ <div id="hdr">
295
+ <div id="tp"></div>
296
+ </div>
297
+ <input id="q" placeholder="筛选文件名…">
298
+ <div id="tree"></div>
299
+ </aside>
300
+ <div id="mask"></div>
301
+ <main id="main"><div id="empty">← 从左边选一个文件</div></main>
302
+ <button id="fab" title="目录">☰</button>
303
+ </div>
304
+ <script src="${CDN}/marked/4.0.2/marked.min.js"></script>
305
+ <script src="${CDN}/highlight.js/11.4.0/highlight.min.js"></script>
306
+ <script>
307
+ const $ = (s) => document.querySelector(s);
308
+ const FILES = {};
309
+ let TREE = {};
310
+ const isMobile = () => matchMedia('(max-width:820px)').matches;
311
+
312
+ /* 主题清单:id / 名称 / 预览色(给面板画色块) */
313
+ const THEMES = [
314
+ ['github', 'GitHub', '#ffffff', '#0969da'],
315
+ ['read', '阅读', '#fdfcf8', '#b45309'],
316
+ ['pine', '松', '#eef4f1', '#0f766e'],
317
+ ['ink', '墨', '#ffffff', '#111111'],
318
+ ['ocean', '蓝调', '#f4f7fb', '#1d4ed8'],
319
+ ];
320
+
321
+ /* --- 主题与明暗:存 localStorage --- */
322
+ function applyTheme(t) {
323
+ document.documentElement.dataset.theme = t;
324
+ localStorage.setItem('brain-theme', t);
325
+ renderThemePanel();
326
+ }
327
+ function applyMode(m) {
328
+ document.documentElement.dataset.mode = m;
329
+ localStorage.setItem('brain-mode', m);
330
+ // highlight.js 主题跟着切(深色主题用 github-dark)
331
+ document.querySelectorAll('link[data-hl]').forEach((l) => { l.disabled = l.dataset.hl !== m; });
332
+ }
333
+ function currentMode() {
334
+ return document.documentElement.dataset.mode ||
335
+ (matchMedia('(prefers-color-scheme:dark)').matches ? 'dark' : 'light');
336
+ }
337
+ function renderThemePanel() {
338
+ const box = $('#tp');
339
+ const cur = document.documentElement.dataset.theme || 'github';
340
+ box.innerHTML = '<div class="t">主题</div>' + THEMES.map(([id, name, bg, acc]) =>
341
+ '<div class="row' + (id === cur ? ' cur' : '') + '" data-t="' + id + '">' +
342
+ '<span class="sw" style="background:' + bg + ';border-color:' + acc + '"></span>' + name + '</div>'
343
+ ).join('');
344
+ box.querySelectorAll('.row').forEach((r) => {
345
+ r.onclick = () => { applyTheme(r.dataset.t); box.classList.remove('on'); };
346
+ });
347
+ }
348
+ (() => {
349
+ const t = localStorage.getItem('brain-theme') || 'github';
350
+ document.documentElement.dataset.theme = t;
351
+ const m = localStorage.getItem('brain-mode');
352
+ if (m) document.documentElement.dataset.mode = m;
353
+ applyMode(m || (matchMedia('(prefers-color-scheme:dark)').matches ? 'dark' : 'light'));
354
+ $('#theme').onclick = (e) => { e.stopPropagation(); $('#tp').classList.toggle('on'); };
355
+ $('#mode').onclick = () => applyMode(currentMode() === 'dark' ? 'light' : 'dark');
356
+ document.addEventListener('click', (e) => {
357
+ if (!$('#tp').contains(e.target) && e.target !== $('#theme')) $('#tp').classList.remove('on');
358
+ });
359
+ renderThemePanel();
360
+ })();
361
+
362
+ /* --- 抽屉 --- */
363
+ function openNav(on) {
364
+ document.body.classList.toggle('nav-open', on);
365
+ $('#close').style.display = isMobile() && on ? '' : 'none';
366
+ }
367
+ $('#fab').onclick = () => openNav(true);
368
+ $('#close').onclick = () => openNav(false);
369
+ $('#mask').onclick = () => openNav(false);
370
+ matchMedia('(max-width:820px)').addEventListener('change', () => openNav(false));
371
+
372
+ /** [[双链]] 扩展 */
373
+ const wlExt = {
374
+ name: 'wl', level: 'inline',
375
+ start(src) { return src.indexOf('[['); },
376
+ tokenizer(src) {
377
+ const m = src.match(/^\\[\\[([^\\]\\n]+)\\]\\]/);
378
+ if (m) return { type: 'wl', raw: m[0], slug: m[1].trim() };
379
+ },
380
+ renderer(t) { return '<a class="wl" data-slug="' + t.slug + '">' + t.slug + '</a>'; },
381
+ };
382
+ marked.use({ extensions: [wlExt], mangle: false, headerIds: false });
383
+
384
+ function renderTree(filter) {
385
+ const box = $('#tree'); box.innerHTML = '';
386
+ let n = 0, idx = 0;
387
+ for (const [g, arr] of Object.entries(TREE)) {
388
+ const hit = arr.filter((f) => !filter || f.name.toLowerCase().includes(filter));
389
+ if (!hit.length) continue;
390
+ // 第一个组(项目名)常看:不折叠;其他文件夹默认收起
391
+ const collapsible = idx > 0;
392
+ const open = !!filter || !collapsible;
393
+ const h = document.createElement('div');
394
+ h.className = 'g' + (collapsible ? ' fold' : '');
395
+ h.innerHTML = (collapsible ? '<span class="ar">' + (open ? '▾' : '▸') + '</span>' : '') +
396
+ '<span>' + g + '</span><span class="cnt">' + hit.length + '</span>';
397
+ const body = document.createElement('div');
398
+ body.style.display = open ? '' : 'none';
399
+ if (collapsible) h.onclick = () => {
400
+ const on = body.style.display === 'none';
401
+ body.style.display = on ? '' : 'none';
402
+ h.querySelector('.ar').textContent = on ? '▾' : '▸';
403
+ };
404
+ box.appendChild(h); box.appendChild(body); idx++;
405
+ for (const f of hit) {
406
+ FILES[f.name] = f.path; FILES[f.path] = f.path;
407
+ const a = document.createElement('a');
408
+ a.textContent = f.name; a.dataset.path = f.path; a.title = f.path;
409
+ a.onclick = () => load(f.path, a);
410
+ body.appendChild(a); n++;
411
+ }
412
+ }
413
+ if (!n) box.innerHTML = '<div style="padding:16px;color:var(--dim);font-size:13px">无匹配</div>';
414
+ }
415
+
416
+ async function load(path, el) {
417
+ document.querySelectorAll('#tree a.on').forEach((a) => a.classList.remove('on'));
418
+ if (el) el.classList.add('on');
419
+ const text = await (await fetch('/raw/' + path)).text();
420
+ const main = $('#main');
421
+ main.innerHTML = marked.parse(text);
422
+ main.querySelectorAll('pre code').forEach((b) => { try { hljs.highlightElement(b); } catch {} });
423
+ main.querySelectorAll('a.wl').forEach((a) => {
424
+ a.onclick = () => {
425
+ const s = a.dataset.slug;
426
+ const p = FILES[s] || FILES[s.split('/').pop()];
427
+ if (p) {
428
+ const link = [...document.querySelectorAll('#tree a')].find((x) => x.dataset.path === p);
429
+ load(p, link);
430
+ } else { a.classList.add('miss'); a.title = '图谱里没有这一页'; }
431
+ };
432
+ });
433
+ document.title = path.split('/').pop() + ' · brain';
434
+ localStorage.setItem('brain-last', path);
435
+ if (isMobile()) openNav(false);
436
+ window.scrollTo(0, 0);
437
+ }
438
+
439
+ (async () => {
440
+ const meta = await (await fetch('/files.json')).json();
441
+ TREE = meta.groups || meta;
442
+ document.querySelector('#hd .brand').textContent = (meta.rootLabel || 'BRAIN').toUpperCase();
443
+ renderTree('');
444
+ $('#q').oninput = (e) => renderTree(e.target.value.trim().toLowerCase());
445
+ if (!isMobile()) {
446
+ const last = localStorage.getItem('brain-last');
447
+ const target = last && [...document.querySelectorAll('#tree a')].find((x) => x.dataset.path === last)
448
+ ? last : Object.values(TREE)[0]?.[0]?.path;
449
+ if (target) {
450
+ const link = [...document.querySelectorAll('#tree a')].find((x) => x.dataset.path === target);
451
+ load(target, link);
452
+ }
453
+ }
454
+ })();
455
+ </script></body></html>`;
456
+
457
+
458
+
459
+
460
+
461
+ /**
462
+ * 起服务。
463
+ * @param {{root:string, port?:number, host?:string}} cfg root = .brain/ 绝对路径
464
+ * port = 0(默认)让系统挑空闲端口,多个项目可同时开。
465
+ */
466
+ export async function serve({ root, port = 0, host = '127.0.0.1' }) {
467
+ const server = http.createServer(async (req, res) => {
468
+ try {
469
+ const url = req.url || '/';
470
+ if (url === '/' || url === '/index.html') {
471
+ res.writeHead(200, { 'Content-Type': MIME['.html'] });
472
+ return res.end(PAGE);
473
+ }
474
+ if (url.startsWith('/files.json')) {
475
+ const tree = await scanTree(root);
476
+ // 把内部组 key 换成展示名,并按展示名重排。
477
+ // 根组用项目目录名(.brain/ 的父目录)—— "根" 太泛,看不出是哪个项目。
478
+ const { basename, dirname } = await import('node:path');
479
+ const projName = basename(dirname(root)) || '项目';
480
+ const out = {};
481
+ for (const [k, arr] of Object.entries(tree)) out[displayGroup(k, projName)] = arr;
482
+ res.writeHead(200, { 'Content-Type': MIME['.json'] });
483
+ return res.end(JSON.stringify({ rootLabel: projName, groups: out }));
484
+ }
485
+ if (url.startsWith('/raw/')) {
486
+ const abs = safeResolve(root, url.slice('/raw'.length));
487
+ if (!abs) { res.writeHead(403); return res.end('forbidden'); }
488
+ const body = await fs.readFile(abs).catch(() => null);
489
+ if (body === null) { res.writeHead(404); return res.end('not found'); }
490
+ res.writeHead(200, { 'Content-Type': MIME[extname(abs)] || MIME['.txt'] });
491
+ return res.end(body);
492
+ }
493
+ res.writeHead(404);
494
+ res.end('not found');
495
+ } catch (e) {
496
+ res.writeHead(500);
497
+ res.end(String(e && e.message ? e.message : e));
498
+ }
499
+ });
500
+ await new Promise((resolve, reject) => {
501
+ server.once('error', reject);
502
+ server.listen(port, host, resolve);
503
+ });
504
+ const actual = server.address().port;
505
+ return { server, port: actual, url: `http://${host}:${actual}/` };
506
+ }