@fanchao8609/agent_brain_sync 1.9.1 → 1.9.2

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.
Files changed (3) hide show
  1. package/bin/abs.js +2 -48
  2. package/package.json +1 -1
  3. package/src/serve.js +0 -506
package/bin/abs.js CHANGED
@@ -91,13 +91,10 @@ const FLAG_SPEC = {
91
91
  'repair': { type: 'boolean' },
92
92
  'no-mcp': { type: 'boolean' },
93
93
  'no-skill': { type: 'boolean' },
94
- // serve 用: --no-open 不自动开浏览器(open 命令仅 macOS)。坑: 曾漏声明 → serve 判
95
- // opts.open !== false 恒真, --no-open 接不上。
96
- 'no-open': { type: 'boolean' },
97
94
  };
98
95
 
99
96
  /** 已被"规范键"接管的 raw flag:不再原样漏出(见 parseArgv 返回处的白名单注释)。 */
100
- const KNOWN_RAW = new Set(['keep-days', 'dry-run', 'no-mcp', 'no-skill', 'no-open']);
97
+ const KNOWN_RAW = new Set(['keep-days', 'dry-run', 'no-mcp', 'no-skill']);
101
98
 
102
99
  function parseArgv(args) {
103
100
  // 坑: parseArgs 会把**任何** `-` 开头的 token 当选项,连正文一起吃:
@@ -170,7 +167,6 @@ function parseArgv(args) {
170
167
  dryRun: !!values['dry-run'],
171
168
  mcp: !values['no-mcp'],
172
169
  skill: !values['no-skill'],
173
- open: !values['no-open'],
174
170
  };
175
171
  // 未声明的 `--unknown` 静默收下(旧行为:不进位置参数,也不报错)。
176
172
  // 这条保留是因为 rejectExtra 只看位置参数 —— 把未知 flag 放进去会改成报错,
@@ -200,7 +196,6 @@ const usage = `abs — 跨会话记忆工具(.brain/ 图谱)
200
196
  supersede <页名> [--by 页] 标经验已失效
201
197
  lint 图谱体检 (死链/超限)
202
198
  rule [add "一句话"] 硬规则读写
203
- serve [--port N] [--no-open] 浏览 .brain/ (仅本机; 端口默认自动选)
204
199
  install|uninstall [--agent <宿主>] [--no-mcp] [--no-skill]
205
200
  update 升级并刷新 hook/skill
206
201
  config [set user <名字>] 使用者姓名 (写操作需先设置)
@@ -267,21 +262,6 @@ const subUsage = {
267
262
  ' • 只删 abs 自己装的东西,保留你其它的 hook / MCP / 配置字段',
268
263
  ' • 配置文件无法解析时会跳过该文件(不覆盖)但仍清理 abs 的脚本与 skill',
269
264
  ].join('\n'),
270
- serve: [
271
- 'abs serve — 把 .brain/ 挂成只读网页(左目录树 + 右正文 + [[双链]]跳转)',
272
- '',
273
- '用法:',
274
- ' abs serve [--port N] [--no-open] [--dir <项目根>]',
275
- '',
276
- '参数:',
277
- ' --port N 端口。默认 0 = 自动选空闲端口(多项目可同时开各的)',
278
- ' --no-open 不自动打开浏览器(open 仅 macOS)',
279
- ' --dir <路径> 服务别的项目的 .brain/',
280
- '',
281
- '说明:',
282
- ' • 只读、仅绑定 127.0.0.1,外部访问不了',
283
- ' • 主题/明暗切换在网页右上角;最后一页会记住',
284
- ].join('\n'),
285
265
  todo: [
286
266
  'abs todo — 任务看板(Todo / Done 两区;未完成都带行首状态标记)',
287
267
  '',
@@ -393,7 +373,7 @@ async function main() {
393
373
  const opts = parseArgv(rest);
394
374
  if (RENAMED[cmd]) throw new Error(RENAMED[cmd]());
395
375
  // 子命令级 --help: 有用法页的命令在此一处拦截(此前只在 install/uninstall 分支里判,
396
- // 其余命令的 --help 被忽略 —— serve --help 曾直接把服务起起来挂住终端)。
376
+ // 其余命令的 --help 被忽略 —— 曾出现子命令 --help 直接执行动作、挂住终端)。
397
377
  if (opts.help && subUsage[cmd] && !(cmd === 'todo' && opts._.length)) {
398
378
  console.log(subUsage[cmd]);
399
379
  } else {
@@ -427,32 +407,6 @@ async function main() {
427
407
  rejectExtra(opts._, 'abs status');
428
408
  console.log(await cmdStatus({ dir: opts.dir }));
429
409
  break;
430
- // abs serve —— 把 .brain/ 挂成只读网页(浏览器无法自己列目录,所以必须有服务端)
431
- case 'serve': {
432
- rejectExtra(opts._, 'abs serve');
433
- const { serve } = await import('../src/serve.js');
434
- const { requireBrain, brainPath } = await import('../src/index.js');
435
- // 坑(2026-09-16 实测): requireBrain 返回的是【项目根】, .brain/ 在它下面 ——
436
- // 直接把项目根当服务根会扫到 node_modules。必须走 brainPath()。
437
- const root = brainPath(await requireBrain(opts.dir || process.cwd()));
438
- // 默认 0 = 让系统挑空闲端口:固定 7777 第二个项目就起不来了。
439
- // --port abc 曾静默变 NaN → server listen 报怪错;在此拦下。
440
- const port = opts.port ? Number(opts.port) : 0;
441
- if (!Number.isInteger(port) || port < 0 || port > 65535) {
442
- throw new Error(`✗ --port 需为 0-65535 的整数(收到 "${opts.port}")。0 = 自动选空闲端口`);
443
- }
444
- // --no-open 显式声明在 FLAG_SPEC(type:boolean),parseArgv 归一化为 open:false。
445
- // 坑(2026-09-16 审计#6): 曾判 opts.open !== false 但 FLAG_SPEC 无 open →
446
- // 条件恒真且 --no-open 接不上(open 命令也仅 macOS)。
447
- const { url, server } = await serve({ root, port });
448
- console.log(`📖 abs serve → ${url}`);
449
- console.log(` 根: ${root}`);
450
- console.log(' (只读,仅本机可访;Ctrl+C 停止)');
451
- if (opts.open) runCmd('open', [url]);
452
- // 不断开进程:服务要活着才有用
453
- await new Promise(() => {});
454
- break;
455
- }
456
410
  // abs todo —— 无子命令=看板;带子命令=任务写操作
457
411
  case 'todo': {
458
412
  const [sub, id, ...rest2] = opts._;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fanchao8609/agent_brain_sync",
3
- "version": "1.9.1",
3
+ "version": "1.9.2",
4
4
  "description": "agent-brain-sync: 跨会话 AI 编码记忆 — hook 纯触发 + CLI/MCP 读写 .brain markdown 图谱, 防并发写保护。",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/serve.js DELETED
@@ -1,506 +0,0 @@
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
- }