Files
dsh_ai1net_server/交付物/看板-离线快照预览-20260930.html
T

858 lines
66 KiB
HTML
Raw Normal View History

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>多会话协作 · 实时架构看板</title>
<script>/* 🔴 在样式生效前就套用上次选的风格 —— ⛔ 别等 load,否则会先闪一下当前风格 */
(function(){try{var s=localStorage.getItem('dsh-board-style');
if(s&&s!=='base'){document.documentElement.setAttribute('data-style',s)}}catch(e){}})();
</script>
<style>
/* ══════════════════════════════════════════════════════════════════════════════
🔴 风格系统(用户 2026-09-30:「画架构图参考 archify 的样式,右上角加风格切换(保留当前风格)」)
· **切换只换 `<html data-style="…">`** —— 颜色/字体/网格全部走令牌,SVG 里没有硬编码颜色
⇒ 换风格**不需要重画图**。
· 三套:`base`(**保留的当前风格**)/`archify-dark`/`archify-light`
(后两套令牌逐字取自 archify 的 `assets/template.html`,MIT)。
· ⛔ 新写样式一律走令牌,别在组件里写死颜色 —— 否则换风格会花。
══════════════════════════════════════════════════════════════════════════════ */
/* ── 通用(与风格无关的尺寸/字体/网格令牌) ─────────────────────────────── */
:root{
--radius-8:8px; --radius-12:12px; --radius-16:16px; --radius-pill:999px;
--font-ui:system-ui,-apple-system,"Segoe UI","Microsoft YaHei",sans-serif;
--node-stroke-w:1;
--shadow-pop:0 10px 28px rgba(0,0,0,.18);
}
/* ── 风格 1 · base =**当前风格**(跟随系统深浅色) ─────────────────────── */
:root{ /* base · light */
--color-bg-page:#f6f8f5; --color-bg-surface:#ffffff; --color-bg-subtle:#eef2ee;
--color-text-primary:#12261a; --color-text-secondary:#4a5c50; --color-text-tertiary:#7a8a80;
--color-border-subtle:#e2e9e2; --color-border-default:#cdd8ce; --color-border-strong:#a9b8ab;
--color-accent:#256d37; --color-accent-subtle:#e6f1e8; --color-on-accent:#ffffff;
--color-success:#1f7a3d; --color-success-subtle:#e6f4ea;
--color-warning:#8a5a00; --color-warning-subtle:#fdf3e0;
--color-danger:#b3261e; --color-danger-subtle:#fdecea;
--color-focus-ring:#256d37;
--color-canvas-dot:transparent; --dot-size:22px;
}
@media (prefers-color-scheme: dark){
:root:not([data-style]), :root[data-style="base"]{ /* base · dark */
--color-bg-page:#0f1412; --color-bg-surface:#161d1a; --color-bg-subtle:#1d2622;
--color-text-primary:#e8f1ea; --color-text-secondary:#adbcb2; --color-text-tertiary:#7f8f85;
--color-border-subtle:#243029; --color-border-default:#2f3d36; --color-border-strong:#455349;
--color-accent:#7fd3a0; --color-accent-subtle:#14301f; --color-on-accent:#08200f;
--color-success:#6ede8a; --color-success-subtle:#12301c;
--color-warning:#ffcf7a; --color-warning-subtle:#33260f;
--color-danger:#ff8f85; --color-danger-subtle:#3a1613;
--color-focus-ring:#7fd3a0;
}}
/* ── 风格 2 · Archify 暗(令牌取自 archify 的 dark 主题) ────────────────── */
:root[data-style="archify-dark"]{
--color-bg-page:#020617; --color-bg-surface:#0f172a; --color-bg-subtle:#1e293b;
--color-text-primary:#ffffff; --color-text-secondary:#94a3b8; --color-text-tertiary:#64748b;
--color-border-subtle:#1e293b; --color-border-default:#334155; --color-border-strong:#475569;
--color-accent:#34d399; /* archify --arrow-emphasis */
--color-accent-subtle:rgba(52,211,153,0.14); --color-on-accent:#052e16;
--color-success:#34d399; --color-success-subtle:rgba(52,211,153,0.14); /* backend-stroke */
--color-warning:#fbbf24; --color-warning-subtle:rgba(251,191,36,0.14); /* cloud-stroke */
--color-danger:#fb7185; --color-danger-subtle:rgba(251,113,133,0.16); /* security-stroke*/
--color-focus-ring:#34d399;
--color-canvas-dot:rgba(148,163,184,0.16); --dot-size:22px; /* --canvas-dot */
--font-ui:ui-monospace,"Cascadia Mono","JetBrains Mono",Consolas,"Microsoft YaHei",monospace;
--node-stroke-w:1.4;
}
/* ── 风格 3 · Archify 亮(令牌取自 archify 的 light 主题) ───────────────── */
:root[data-style="archify-light"]{
--color-bg-page:#f4f5f7; --color-bg-surface:#ffffff; --color-bg-subtle:#f1f5f9;
--color-text-primary:#111827; --color-text-secondary:#5b6474; --color-text-tertiary:#9aa3b2;
--color-border-subtle:#e5e7eb; --color-border-default:#cbd5e1; --color-border-strong:#94a3b8;
--color-accent:#059669;
--color-accent-subtle:rgba(5,150,105,0.10); --color-on-accent:#ffffff;
--color-success:#059669; --color-success-subtle:rgba(52,211,153,0.20);
--color-warning:#d97706; --color-warning-subtle:rgba(251,191,36,0.20);
--color-danger:#e11d48; --color-danger-subtle:rgba(251,113,133,0.15);
--color-focus-ring:#059669;
--color-canvas-dot:#d9dee5; --dot-size:22px;
--font-ui:ui-monospace,"Cascadia Mono","JetBrains Mono",Consolas,"Microsoft YaHei",monospace;
--node-stroke-w:1.4;
}
*{box-sizing:border-box}
body{margin:0;background:var(--color-bg-page);color:var(--color-text-primary);
font-family:var(--font-ui);font-size:14px;line-height:1.6;font-weight:400}
a,button{font:inherit;color:inherit}
:focus-visible{outline:2px solid var(--color-focus-ring);outline-offset:2px}
header{padding:24px 32px 16px;border-bottom:1px solid var(--color-border-subtle);
display:flex;flex-wrap:wrap;gap:16px;align-items:baseline}
h1{margin:0;font-size:20px;font-weight:500;line-height:1.3}
.lede{color:var(--color-text-secondary);font-size:13px}
.spacer{flex:1}
.stamp{color:var(--color-text-tertiary);font-size:12px;font-variant-numeric:tabular-nums}
.hd-left{display:flex;flex-direction:column;gap:2px}
.hd-left .lede strong,.hd-left .lede b{font-weight:500;color:var(--color-text-secondary)}
.hd-right{display:flex;flex-direction:column;align-items:flex-end;gap:4px}
.lag{font-size:12px;font-variant-numeric:tabular-nums;color:var(--color-text-tertiary)}
.lag.is-old{color:var(--color-warning)}
.ro{font-size:11px;color:var(--color-text-tertiary)}
.warnbar{margin:0 0 24px;border:1px solid var(--color-warning);background:var(--color-warning-subtle);
color:var(--color-warning);border-radius:var(--radius-12);padding:12px 16px;font-size:13px}
/* 「本项目」身份卡 —— 🔴 看板顶部第一眼就要说清"这是哪个需求目标项目" */
.card.proj{border-color:var(--color-accent);background:var(--color-accent-subtle)}
.projTitle{display:flex;align-items:center;gap:10px;flex-wrap:wrap;font-size:16px}
.projTitle b{font-weight:500}
.pill{display:inline-flex;align-items:center;padding:3px 12px;border-radius:var(--radius-pill);
font-size:12px;font-weight:500;color:var(--color-on-accent);background:var(--color-accent)}
.projMeta{margin-top:8px;color:var(--color-text-secondary);font-size:12px}
.crit{margin-top:12px;display:flex;flex-wrap:wrap;gap:8px;align-items:center}
.critLead{color:var(--color-text-secondary);font-size:12px}
.crit .chip{background:var(--color-bg-surface)}
/* 🆕 2026-09-30 任务类别行:一个工作区里可能有多个类别 ⇒ 每类一枚 chip,chip 里带**该类的主会话**。
⚠️ 这里 ⛔ **不**覆盖 `.chip` 的底色 —— 要保留 `.is-warn`(无主会话那种)的警示色。 */
.topics{margin-top:12px;display:flex;flex-wrap:wrap;gap:8px;align-items:center}
.accRow{margin-top:12px;display:flex;flex-wrap:wrap;gap:8px;align-items:center}
.accRow .critLead{color:var(--color-text-secondary)}
td .role{display:inline-block;padding:1px 8px;border-radius:var(--radius-pill);font-size:11px;
border:1px solid var(--color-border-default);color:var(--color-text-tertiary);white-space:nowrap}
td .role.is-main{color:var(--color-accent);border-color:var(--color-accent);background:var(--color-accent-subtle)}
main{padding:24px 32px 48px;max-width:1440px;margin:0 auto}
section{margin:0 0 24px}
.card{background:var(--color-bg-surface);border:1px solid var(--color-border-subtle);
border-radius:var(--radius-16);padding:20px}
.card > h2{margin:0 0 16px;font-size:14px;font-weight:500;color:var(--color-text-secondary);padding-right:30px}
.card > h2 .hint,.hero h2 .hint{font-weight:400;color:var(--color-text-tertiary);font-size:12px;margin-left:8px}
.hero{background:var(--color-bg-surface);border:1px solid var(--color-border-subtle);
border-radius:var(--radius-16);padding:24px}
.hero h2{margin:0 0 16px;font-size:16px;font-weight:500;padding-right:30px}
.hero p{margin:0 0 16px;color:var(--color-text-tertiary);font-size:12px}
svg.arch{width:100%;height:auto;display:block}
/* 画布点阵(archify 的 --canvas-dot):base 风格为 transparent ⇒ 无点阵 */
#archWrap{background-image:radial-gradient(var(--color-canvas-dot) 1px, transparent 1px);
background-size:var(--dot-size) var(--dot-size);background-position:0 0;
border-radius:var(--radius-12)}
/* ── 右上角「风格切换」(用户 2026-09-30 要求;默认=当前风格) ───────────── */
.stylesw{display:inline-flex;border:1px solid var(--color-border-default);border-radius:var(--radius-pill);
overflow:hidden;background:var(--color-bg-surface)}
.stylesw button{border:0;background:transparent;color:var(--color-text-secondary);cursor:pointer;
padding:4px 12px;font-size:12px;line-height:1.6;font-family:var(--font-ui);white-space:nowrap}
.stylesw button + button{border-left:1px solid var(--color-border-subtle)}
.stylesw button:hover{background:var(--color-bg-subtle);color:var(--color-text-primary)}
.stylesw button.on{background:var(--color-accent);color:var(--color-on-accent);font-weight:500}
.stylesw button:focus-visible{outline:2px solid var(--color-focus-ring);outline-offset:-2px}
/* ── 🔴 各板块右上角「?」:小字描述收进这里,鼠标移上/键盘聚焦才显示 ─────────────
(用户 2026-09-30:「把各板块的小字描述放到各板块对应右上角 ?号图标中,鼠标移上去显示
(**只保留标题,主体,类别标签**这类信息)」)
⛔ 别再往版面里摊大段说明文字 —— 一律进 .qtip。 */
.card,.hero{position:relative}
.qh{position:absolute;top:12px;right:14px;z-index:6;width:20px;height:20px;padding:0;
border-radius:50%;border:1px solid var(--color-border-default);background:var(--color-bg-subtle);
color:var(--color-text-secondary);font-size:12px;font-weight:500;line-height:18px;text-align:center;
cursor:help;font-family:var(--font-ui);user-select:none}
.qh:hover,.qh:focus-visible{background:var(--color-accent);color:var(--color-on-accent);
border-color:var(--color-accent);outline:none}
.qh .qtip{position:absolute;top:26px;right:0;z-index:30;width:min(440px,78vw);padding:12px 14px;
border-radius:var(--radius-12);border:1px solid var(--color-border-default);
background:var(--color-bg-surface);box-shadow:var(--shadow-pop);
color:var(--color-text-secondary);font-size:12px;font-weight:400;line-height:1.7;text-align:left;
white-space:normal;cursor:default;opacity:0;visibility:hidden;transition:opacity .12s ease}
.qh:hover .qtip,.qh:focus-visible .qtip{opacity:1;visibility:visible}
@media (prefers-reduced-motion: reduce){.qh .qtip{transition:none}}
.qtip b{color:var(--color-text-primary);font-weight:500}
.qtip .kt{display:inline-block;padding:0 6px;border-radius:var(--radius-pill);
border:1px solid var(--color-border-default);color:var(--color-text-tertiary);font-size:11px}
/* 「协作架构」板块右上角:延迟算式 + 保留的快照时刻(固定可见,⛔ 不收进 ?) */
.herometa{position:absolute;top:12px;right:52px;text-align:right;line-height:1.5;
font-size:12px;font-variant-numeric:tabular-nums;color:var(--color-text-tertiary)}
.herometa > span{display:block;white-space:nowrap} /* ⚠️ 只作用于直接子元素:写 `.herometa span` 会把内层 #lag 也变成块 ⇒ "当前 3 秒"被拆两行 */
.herometa b{color:var(--color-text-primary);font-weight:500}
.herometa .sec{color:var(--color-text-secondary)}
.herometa.is-old .sec{color:var(--color-warning)}
@media(max-width:820px){.herometa{position:static;text-align:left;margin:0 0 12px}}
.cols-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px}
.cols-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:24px}
@media(max-width:1100px){.cols-3,.cols-2{grid-template-columns:1fr}}
.chip{display:inline-flex;align-items:center;gap:4px;padding:4px 12px;border-radius:var(--radius-pill);
font-size:12px;font-weight:400;border:1px solid var(--color-border-default);
color:var(--color-text-secondary);background:var(--color-bg-subtle)}
.chip.is-ok{color:var(--color-success);background:var(--color-success-subtle);border-color:var(--color-success)}
.chip.is-bad{color:var(--color-danger);background:var(--color-danger-subtle);border-color:var(--color-danger)}
.chip.is-warn{color:var(--color-warning);background:var(--color-warning-subtle);border-color:var(--color-warning)}
.rowchips{display:flex;flex-wrap:wrap;gap:8px}
table{width:100%;border-collapse:collapse;font-size:13px}
th,td{padding:8px 8px;border-bottom:1px solid var(--color-border-subtle);text-align:left;vertical-align:top}
th{font-weight:500;color:var(--color-text-tertiary);font-size:12px}
tr:last-child td{border-bottom:0}
td.num{font-variant-numeric:tabular-nums;color:var(--color-text-tertiary);font-size:12px;white-space:nowrap}
td .ellip{display:block;max-width:34ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* 🔴 弱化说明(台账「任务类别」列里标旧值/项目卡里标类别来源):⛔ 只弱化,⛔ 不隐藏 */
.oldline{color:var(--color-text-tertiary);font-size:12px}
.warnTx{color:var(--color-warning);font-weight:600}
code{font-family:ui-monospace,Consolas,monospace;font-size:12px;color:var(--color-text-secondary)}
.empty{padding:16px 0;color:var(--color-text-tertiary);font-size:13px}
.empty strong{display:block;color:var(--color-text-secondary);font-weight:500;margin-bottom:4px}
.err{border-color:var(--color-danger);background:var(--color-danger-subtle);color:var(--color-danger);
border-radius:var(--radius-12);padding:16px;font-size:13px}
.skel{height:14px;border-radius:var(--radius-8);background:var(--color-bg-subtle);margin:8px 0}
.legend{display:flex;flex-wrap:wrap;gap:16px;color:var(--color-text-tertiary);font-size:12px;margin-top:8px}
.legend i{display:inline-block;width:12px;height:12px;border-radius:4px;margin-right:6px;
vertical-align:-1px;border:1px solid var(--color-border-default)}
.n-title{fill:var(--color-text-primary);font-size:16px;font-weight:500}
.n-title-sm{fill:var(--color-text-primary);font-size:13px;font-weight:500}
.n-sub{fill:var(--color-text-tertiary);font-size:11px}
.n-stat{fill:var(--color-accent);font-size:13px;font-weight:500}
.n-ok{fill:var(--color-success);font-size:12px;font-weight:500}
.n-idle{fill:var(--color-text-tertiary);font-size:12px}
.n-warn{fill:var(--color-warning);font-size:12px;font-weight:500}
.n-bad{fill:var(--color-danger);font-size:12px;font-weight:500}
.e-label{fill:var(--color-text-tertiary);font-size:12px}
.node-bg{fill:var(--color-bg-surface);stroke:var(--color-border-default)}
.node-on{fill:var(--color-success-subtle);stroke:var(--color-success)}
.node-warn{fill:var(--color-warning-subtle);stroke:var(--color-warning)}
.node-off{fill:var(--color-danger-subtle);stroke:var(--color-danger);stroke-dasharray:4 3}
.node-base{fill:var(--color-bg-subtle);stroke:var(--color-border-strong);stroke-dasharray:5 4}
.node-bg,.node-on,.node-warn,.node-off,.node-base{stroke-width:var(--node-stroke-w)}
/* 🔴 2026-09-30 用户:「有些线都看不到」——根因是灰线用了 `--color-border-strong`(**太浅**,
白底对比度仅约 1.9:1)+ 1.5px ⇒ 基本看不见;而唤醒→主会话/WorkBuddy→Hook/--once/--tick/
④收结果**全是灰线** ⇒ 整张图看着像"线没连全"。改用 `--color-text-tertiary`(约 3.5:1),
并与 `.e-label` 同色(标签本就在描述这条线)⇒ 灰色语义保留,可见性恢复。
⚠️ 注释里⛔ 不写具体色值 —— 有静态用例守着"组件里零硬编码色"。 */
.edge{stroke:var(--color-accent);stroke-width:1.5;fill:none}
.edge-grey{stroke:var(--color-text-tertiary);stroke-width:1.5;fill:none}
@media (prefers-reduced-motion: no-preference){
.live{stroke-dasharray:6 5;animation:flow 1.6s linear infinite}
@keyframes flow{to{stroke-dashoffset:-22}}
}
</style>
<script>window.__SNAP__ = {"ts": "2026-10-01 00:29:21", "epoch": 1790785761.4, "goal": {"title": "本机(桌面客户端)内的多会话协作", "short": "本机协作", "acceptance": {}}, "project": {"id": "local-collab", "short": "本机协作", "title": "本机(桌面客户端)内的多会话协作", "main_sid8": "a202550c", "criteria": ["主会话 a202550c(prefix:主控)", "本工作区另有主会话 1 条(共 2)", "标题含 [唤醒机制]", "显式声明 --declare --goal local-collab"], "workspace": "E:\\ProgramData\\AIProject\\ai1net-dsh-server", "topics": ["唤醒机制"], "topics_source": {"kind": "declared", "at": "2026-10-01T00:36", "by": "本线会话标题里的类别名(`主控 · 唤醒机制线 · …`)—— ⛔ 其余候选未经对话确认", "candidates": ["文档库治理", "规则载体", "IM"], "goal_declared_at": "", "goal_declared_by": ""}, "main_sids8": ["a202550c", "fe146dd9"], "main_by_topic": {"唤醒机制": "a202550c"}}, "others_running": 0, "tasks": {"N9": {"state": "done", "line": "ai1net-dsh-anywhere", "by": "[协作]-[手机接入]-N9本体", "artifact": "E:/ProgramData/AIProject/ai1net-dsh-anywhere/docs/实测单_N9_四闸复测与V2取到_20260930.md"}, "N10": {"state": "done", "line": "ai1net-dsh-anywhere", "by": "[协作]-[手机接入]-N10长测", "artifact": "E:/ProgramData/AIProject/ai1net-dsh-anywhere/docs/实测单_N10_长时稳定性_20260930.md"}, "V6": {"state": "done", "line": "ai1net-dsh-desktop", "by": "[协作]-[手机接入]-V6装载换挂", "artifact": "E:/ProgramData/AIProject/ai1net-dsh-desktop/docs/实测单_V6_装载换挂_20260930.md"}, "前置-20090": {"state": "done", "line": "ai1net-dsh-desktop", "by": "[协作]-[手机接入]-前置20090恢复", "artifact": "E:/ProgramData/AIProject/ai1net-dsh-desktop/docs/实测单_前置恢复_2026-09-30.md"}}, "sessions": [{"id8": "a202550c", "title": "主控 · 唤醒机制线 · 棒:worker 退出后能否被外部叫起(无人值守决定性一问)", "status": "working", "role": "主会话", "topic": "唤醒机制", "cwd_tail": "ai1net-dsh-server", "age_min": 0.0}, {"id8": "fe146dd9", "title": "接续 · 机制线(钩子锚点真实投递取证)", "status": "completed", "role": "主会话", "topic": "", "cwd_tail": "ai1net-dsh-server", "age_min": 731.2}], "labor": [{"line": "唤醒机制", "name": "唤醒机制", "items": {}, "done": 0, "total": 0, "open": 0, "latest": null, "running": [{"id8": "a202550c", "what": "主控 · 唤醒机制线 · 棒:worker 退出后能"}], "state": "busy", "kind": "topic"}, {"line": "ai1net-dsh-anywhere", "name": "ai1net-dsh-anywhere", "items": {"N10": "done", "N9": "done"}, "done": 2, "total": 2, "open": 0, "latest": {"item": "N10", "state": "done", "by": "[协作]-[手机接入]-N10长测", "age_min": 1334.9}, "running": [], "state": "idle", "kind": "legacy"}, {"line": "ai1net-dsh-desktop", "name": "ai1net-dsh-desktop", "items": {"V6": "done", "前置-20090": "done"}, "done": 2, "total": 2, "open": 0, "latest": {"item": "前置-20090", "state": "done", "by": "[协作]-[手机接入]-前置20090恢复", "age_min": 991.4}, "running": [], "state": "idle", "kind": "legacy"}], "orphan": {"lines": [{"line": "ai1net-dsh-anywhere", "name": "ai1net-dsh-anywhere", "total": 2, "done": 2, "open": 0}, {"line": "ai1net-dsh-desktop", "name": "ai1net-dsh-desktop", "total": 2, "done": 2, "open": 0}], "n": 2, "total": 4, "done": 4, "open": 0}, "wakeups": [{"ts": "2026-09-30T08:50:49", "kind": "监督程序·心跳", "http": 200, "ok": true}, {"ts": "2026-09-30T08:54:14", "kind": "监督程序·心跳", "http": 200, "ok": true}, {"ts": "2026-09-30T09:38:58", "kind": "监督程序·心跳", "http": 200, "ok": true}, {"ts": "2026-09-30T09:38:58", "kind": "监督程序·心跳", "http": 200, "ok": true}, {"ts": "2026-09-30T10:07:24", "kind": "监督程序·心跳", "http": 200, "ok": true}, {"ts": "2026-09-30T10:34:39", "kind": "监督程序·心跳", "http": 200, "ok": true}, {"ts": "2026-09-30T10:36:51", "kind": "上报·唤醒", "http": 200, "ok": true}, {"ts": "2026-09-30T
</head>
<body>
<div style="margin:0;padding:8px 14px;background:#3a2a00;color:#ffd88a;font:13px/1.6 system-ui,sans-serif;border-bottom:1px solid #6b5000">🔎 离线预览(快照时刻 2026-10-01 00:29:21)—— 渲染逻辑与线上看板<b>同一份 board.html</b>,只是数据内联、不走本地服务。</div>
<header>
<div class="hd-left">
<h1>多会话协作 · 实时架构看板</h1>
</div>
<span class="spacer"></span>
<div class="hd-right">
<!-- 🔴 右上角风格切换(你现在的风格 = 第一项,⛔ 不删) -->
<div class="stylesw" id="stylesw" role="group" aria-label="看板风格">
<button type="button" data-style="base" title="保留的原风格(跟随系统深浅色)">当前风格</button>
<button type="button" data-style="archify-dark" title="参考 archify 的暗色令牌">Archify 暗</button>
<button type="button" data-style="archify-light" title="参考 archify 的亮色令牌">Archify 亮</button>
</div>
</div>
</header>
<main>
<div class="err" id="err" hidden></div>
<div class="warnbar" id="warnbar" hidden></div>
<section class="card proj" id="projCard">
<h2>本项目</h2>
<span class="qh" tabindex="0" role="button" aria-label="本项目板块说明">?<span class="qtip">
这页的数据只属于这一个项目。
</span></span>
<div id="proj"><div class="skel"></div></div>
</section>
<section class="hero">
<h2>协作架构<span class="hint" id="archHint">用户 · 会话 · 分工 · 程序 · WorkBuddy</span></h2>
<!-- 🔴 延迟与"保留的快照时刻"固定在板块右上角(用户 2026-09-30)—— ⛔ 别收进 ? 里 -->
<div class="herometa" id="herometa">
<span><b id="ts2">—</b></span>
<span class="sec">延迟 <span id="lag">—</span></span>
</div>
<span class="qh" tabindex="0" role="button" aria-label="协作架构板块说明">?<span class="qtip">
第三层按<b>任务类别</b>分工,一格一个类别:该类的主会话是谁、最近做完的件、现在谁在做、还剩几件没完。<br>
任务类别是<b>同一个工作区</b>里用<b>会话名称前缀</b>区分的(每个类别各有自己的主会话)。<br>
状态都是实读的,会话读 WorkBuddy 的库,程序读它自己留下的时间记录。哪个类别停着不动,看格子颜色就知道。<br>
看板只读。后台每 <span id="iv">—</span> 秒生成一份快照,页面直接读缓存。
</span></span>
<div id="archWrap"><svg class="arch" id="arch" viewBox="0 20 1280 862" role="img"
aria-label="多会话协作架构:用户、主会话(左侧挂主会话创建的时间驱动源,如唤醒)、按任务类别分工的板块(每格含该类的主会话与最近协作任务)、协作程序与上报、WorkBuddy 在特定时机起来的 Hook进程、WorkBuddy"></svg></div>
<div class="legend">
<span><i style="background:var(--color-success-subtle);border-color:var(--color-success)"></i>有会话在跑</span>
<span><i style="background:var(--color-warning-subtle);border-color:var(--color-warning)"></i>有件没人在跑</span>
<span><i style="background:var(--color-bg-surface);border-color:var(--color-border-default)"></i>空闲 / 件已全完</span>
<span><i style="background:var(--color-danger-subtle);border-color:var(--color-danger)"></i>已停</span>
<span><i style="background:var(--color-bg-subtle);border-color:var(--color-border-strong)"></i>WorkBuddy</span>
</div>
<div class="legend">
<span>① 派活(自动化排期) ② 上报(--report) ③ 上报(reply/唤醒) ④ 收结果(读 WorkBuddy 库) · 队列(协作程序 → 上报)</span>
</div>
<div class="legend" id="archNote"></div>
</section>
<section class="cols-3">
<!-- 🔴 本板块的名称/标签/内容**全部由使用方提供**(board_ext)—— 技能侧不写死任何项目的东西 -->
<div class="card" id="frontCard"><h2>前置</h2><span class="qh" tabindex="0" role="button" aria-label="前置板块说明">?<span class="qtip" id="frontTip">
看什么、叫什么名字,由**使用方**的看板扩展(`board_ext.py`)决定。<br>
组件名一律写成「系统 · 模块 · 功能名」,不用自造简称。<br>
状态只做探测:读组件自己留下的时间记录和日志,不发请求、不改任何东西。
</span></span><div id="front"><div class="skel"></div></div></div>
<div class="card"><h2>队列通知 / 握手<span class="hint">上报</span></h2><div id="notify"><div class="skel"></div></div></div>
<div class="card"><h2>最近上报<span class="hint">wakeups.jsonl</span></h2><div id="wake"><div class="skel"></div></div></div>
</section>
<section class="cols-2">
<div class="card"><h2>需求台账<span class="hint">tasks.json</span></h2><div id="tasks"><div class="skel"></div></div></div>
<div class="card"><h2>会话明细<span class="hint">本项目 · 含历史</span></h2> <span class="qh" tabindex="0" role="button" aria-label="会话明细板块说明">?<span class="qtip">
分工看架构图第三层。本表列本项目全部会话,含已完成的。
</span></span><div id="sess"><div class="skel"></div></div></div>
</section>
</main>
<script>
var D=null, LIVE=(location.protocol==='http:'||location.protocol==='https:');
/* 🔴 board.html 的签名(服务端随快照下发)。布局与样式都在本文件里,而页面只轮询 board.json
⇒ 改了看板**不刷新页面就看不到**(用户 2026-09-30 为此连问两次:"怎么没有变化呢")。
⇒ 发现签名变了就**自动重载一次**,⛔ 不再要求用户手动 F5。 */
var HTML_SIG=null;
/* 🔴 风格切换:只改 <html data-style>。颜色/字体/网格全走 CSS 令牌 ⇒ **不用重画图**。
⛔ `base` 是"保留的当前风格" —— 选它时把属性**摘掉**,好让它继续跟随系统深浅色。 */
var STYLE_KEY='dsh-board-style';
function currentStyle(){
var s=document.documentElement.getAttribute('data-style');
return (s==='archify-dark'||s==='archify-light')?s:'base';
}
function applyStyle(s){
if(s!=='archify-dark'&&s!=='archify-light') s='base';
if(s==='base') document.documentElement.removeAttribute('data-style');
else document.documentElement.setAttribute('data-style',s);
try{ localStorage.setItem(STYLE_KEY,s) }catch(e){}
var btns=document.querySelectorAll('#stylesw button');
for(var i=0;i<btns.length;i++){
var on=(btns[i].getAttribute('data-style')===s);
btns[i].classList.toggle('on',on);
btns[i].setAttribute('aria-pressed', on?'true':'false');
}
}
function initStyleSwitcher(){
var box=document.getElementById('stylesw');
if(!box) return;
box.addEventListener('click',function(ev){
var b=ev.target.closest?ev.target.closest('button[data-style]'):null;
if(b) applyStyle(b.getAttribute('data-style'));
});
/* 键盘:方向键在三个按钮间走 */
box.addEventListener('keydown',function(ev){
if(ev.key!=='ArrowLeft'&&ev.key!=='ArrowRight') return;
var order=['base','archify-dark','archify-light'], cur=order.indexOf(currentStyle());
cur=(cur+(ev.key==='ArrowRight'?1:order.length-1))%order.length;
applyStyle(order[cur]);
var b=box.querySelector('button[data-style="'+order[cur]+'"]'); if(b) b.focus();
ev.preventDefault();
});
applyStyle(currentStyle()); /* 把按钮高亮同步到实际生效的风格 */
}
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){
return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]})}
function ago(iso){
if(!iso)return '—';
var d=new Date(String(iso).replace(' ','T')); if(isNaN(d))return String(iso);
var s=Math.max(0,(Date.now()-d.getTime())/1000);
return s<90?Math.round(s)+' 秒前':(s<5400?Math.round(s/60)+' 分钟前':Math.round(s/3600)+' 小时前');
}
function chip(t,cls,title){return '<span class="chip '+(cls||'')+'"'
+(title?(' title="'+esc(title)+'"'):'')+'>'+esc(t)+'</span>'}
function fmtB(n){
if(n==null||isNaN(n))return '—';
n=Number(n);
return n<1024?(n+' B'):(n<1048576?((n/1024).toFixed(1)+' KB'):((n/1048576).toFixed(1)+' MB'));
}
function emptyBox(title,action){
return '<div class="empty"><strong>'+esc(title)+'</strong>'+esc(action||'')+'</div>'}
function tailOf(p){return String(p==null?'':p).replace(/\\/g,'/').replace(/\/+$/,'').split('/').pop()||'—'}
/* ⛔ 界面上不出现裸的 done/running/completed(用户看的是中文) */
function zhState(st){return {done:'已完成',running:'进行中',todo:'待办',pending:'待办',
blocked:'受阻',claimed:'已认领'}[String(st||'')]||(st||'—')}
function zhSess(st){return {working:'执行中',completed:'已完成',error:'出错',archived:'已归档',
idle:'空闲'}[String(st||'')]||(st||'—')}
/* 🔴 顶部「本项目」卡 —— 看板第一眼就要说清:**这是哪个需求目标项目**(用户 2026-09-30 明示) */
function renderProject(d){
var p=d.project||{}, acc=d.goal.acceptance||{}, keys=Object.keys(acc).sort();
/* ⚠️ 2026-09-30:**没有有效验收判据时 ⛔ 不许显示成"无验收记录"那种中性字样** ——
判据是"**有没有有效项**",不是"字典空不空"(只写 `_说明` 行时旧代码会当成"全过")。 */
var a=keys.length
? keys.map(function(k){var v=String(acc[k]).toLowerCase();
return chip(k+':'+acc[k], v==='pass'?'is-ok':(v==='fail'?'is-bad':'is-warn'))}).join('')
: chip('⚠️ 未声明验收判据(⛔ 不因此判完成)','is-warn');
var crit=(p.criteria||[]).map(function(c){return '<span class="chip">'+esc(c)+'</span>'}).join('');
/* 🆕 任务类别(用户:「同一个工作区 多会话协作…**通过协作会话名称前缀**区分具体任务会话」)
⇒ 每个类别一枚 chip,且 chip 里带**该类的主会话** —— ⛔ 否则看不出"哪条管哪一类";
没有主会话的类别用警示色(那类的件投出去会被降级报告)。 */
var mbt=p.main_by_topic||{}, tops=p.topics||[];
var src=p.topics_source||{};
/* 🔴🔴 2026-10-01(用户:「**目标是 通过对话在调用 会话协作skill时说明的,不是固定的**」)
⇒ 这一行必须说得出**这批类别是怎么来的**:
`declared` = 在对话里说明过(带说明时间);
`fallback` = **没说明过**,只是回落目标简称 ⇒ 要**明说**,⛔ 不许让人以为"这就是定下来的类别";
`none` = 连简称都没有。
同族红线:**没有却说有 / 有却不说** —— 都属"少说一句话"。 */
var tp=tops.length
? tops.map(function(t){ var s=mbt[t]||'';
return '<span class="chip'+(s?'':' is-warn')+'">'+esc(t)
+(s?(' 主会话 <code>'+esc(s)+'</code>'):' ⚠️ 无主会话')+'</span>'}).join('')
: '<span class="chip is-warn">(未声明任务类别)</span>';
var tpLead = (src.kind==='fallback')
? '<span class="critLead">任务类别(<b class="warnTx">⚠ 未声明</b> ⇒ 暂回落目标简称):</span>'
: '<span class="critLead">任务类别(同一个工作区 · 按名称前缀区分):</span>';
var tpNote = (src.kind==='declared')
? '<span class="oldline">来源:调用时在对话里说明'+(src.at?(' · '+esc(src.at)):'')+(src.by?(' · '+esc(src.by)):'')+'</span>'
: (src.kind==='fallback'
? '<span class="oldline">⛔ 还没在对话里说明过 ⇒ 现按目标简称走单类别;说明方式:调用本技能时讲清「任务类别」</span>'
: '<span class="oldline">⛔ 目标与类别都还没声明(调用本技能时在对话里说明)</span>');
document.getElementById('proj').innerHTML=
'<div class="projTitle"><span class="pill">'+esc(p.short||'(未声明主题)')+'</span>'
+'<b>'+esc(p.title||'(未声明目标)')+'</b></div>'
+'<div class="projMeta">目标 id <code>'+esc(p.id||'—')+'</code> · 默认主会话 <code>'+esc(p.main_sid8||'—')+'</code>'
+' · 工作区 <code>'+esc(tailOf(p.workspace))+'</code></div>'
+'<div class="topics">'+tpLead+tp+tpNote+'</div>'
+'<div class="crit"><span class="critLead">归属判据(任一命中即算):</span>'+crit+'</div>'
+'<div class="accRow"><span class="critLead">验收:</span>'+a+'</div>';
}
/* 🔴🔴 架构图布局(**用户 2026-09-30 指定**)—— 纵向四层,从上到下:
① 用户 ② 主会话 ③ 协作会话1 … 协作会话n(**个数是活的**,按本项目在册会话渲染)
④ 协作程序 + 监督程序(并排) + 底部一条「宿主地基」带
每条边都走**空白带**(⛔ 不横穿节点框):
① 派活带 y=264 ② 上报带 y=404 ③ 上报走右侧竖井 x=1254 ④ 收结果走地基带上方
🔴 每格都带真状态:会话取宿主库 `status`,程序取状态戳 mtime(`board.json.runtime`)。 */
function fmtAge(m){
if(m==null)return '—';
m=Number(m);
return m<1?'刚刚':(m<90?Math.round(m)+' 分钟前':(m<2880?Math.round(m/60*10)/10+' 小时前':Math.round(m/1440*10)/10+' 天前'));
}
/* ⚠️ 中日韩字符 ≈ 1em(13px 字号 ⇒ 约 13px/字),ASCII 约 6.8px。
⛔ 别再用"每字符 8.4px"那种估法 —— 会把标题算长一倍、**文字溢出框外**(第一版就是这个毛病)。
下面按**逐字符实际宽度**累加,比"数够 N 个字符就切"稳得多。 */
function textW(s, cjk, ascii){
s=String(s==null?'':s); var w=0;
for(var i=0;i<s.length;i++){ w += (s.charCodeAt(i) > 0x2E80 ? cjk : ascii); }
return w;
}
function fitText(s, maxPx, cjk, ascii){
s=String(s==null?'':s);
if(textW(s,cjk,ascii)<=maxPx) return s;
var out='';
for(var i=0;i<s.length;i++){
var c=s.charAt(i), w=(s.charCodeAt(i) > 0x2E80 ? cjk : ascii);
if(textW(out,cjk,ascii)+w+ascii>maxPx) return out+'…';
out+=c;
}
return out;
}
/* 两行版:先按整宽切一行,剩下的切第二行(用于"标题较长、框较窄"的格子) */
function fitTwo(t, maxPx, cjk, ascii){
t=String(t==null?'':t);
if(textW(t,cjk,ascii)<=maxPx) return [t];
var cut=0, w=0;
for(var i=0;i<t.length;i++){
var cw=(t.charCodeAt(i)>0x2E80?cjk:ascii);
if(w+cw>maxPx) break;
w+=cw; cut=i+1;
}
return [t.slice(0,cut), fitText(t.slice(cut), maxPx, cjk, ascii)];
}
function drawArch(d){
var S=d.sessions||[], rt=d.runtime||{}, LB=d.labor||[];
/* 🆕 2026-09-30:**每个任务类别各自的主会话**(`{类别: sid8}`)—— 第三层按类别渲染要用它 */
var MBT=(d.project||{}).main_by_topic||{};
var main=(S.filter(function(x){return x.role==='主会话'})[0])||null;
var runningS=S.filter(function(x){return x.status==='working'});
var works=S.filter(function(x){return x.role!=='主会话'});
var MAXW=5; /* 4 个类别 + 1 个「未归类」折叠格 */
/* 🔴🔴 2026-09-30 修(跑整体验证时在真实工作区抓到):
类别清单已迁到**任务类别**,而台账里还躺着**跨工作区时代的旧线名**。旧版把这些行也当"分工位"
塞进 `LB`,又只画前 4 格 ⇒ **那几格连同里面的件整块消失**(实况:4 件已完成的活看不见,
图上只剩 4 个"件 0 · 完成 0")。形状=**不崩溃,只是少说一句话**。
⇒ 现在分两组:`kind==='topic'` 才占正式分工位;`kind==='legacy'` **折叠成一格「未归类」**
**照样画出来**(⛔ 不静默、也⛔ 不让工作区名冒充类别)。 */
var _lg=LB.filter(function(x){return x.kind==='legacy'});
var _tp=LB.filter(function(x){return x.kind!=='legacy'});
var _fold=null;
if(_lg.length){
var _ft=0,_fd=0,_fo=0,_fr=[];
_lg.forEach(function(x){_ft+=x.total||0;_fd+=x.done||0;_fo+=x.open||0;
_fr=_fr.concat(x.running||[])});
_fold={line:'(未归类)',name:'未归类',fold:_lg,total:_ft,done:_fd,open:_fo,
state:(_fr.length?'busy':(_fo?'gap':'idle')),running:_fr,latest:null};
}
var _cap=MAXW-(_fold?1:0), cells=_tp.slice(0,_cap), moreL=Math.max(0,_tp.length-_cap);
if(_fold) cells.push(_fold);
var working=S.filter(function(x){return x.status==='working'}).length;
var o=[];
/* 🔴 两类"让主会话动起来"的外部来源,⛔ 别混为一谈:
· **Hook进程**(R5 那格)—— **事件驱动**:WorkBuddy 在钩子时机起一个短命子进程,跑完即退;
· **唤醒 等**(画在 ② 主会话**左边**)—— **时间驱动**:主会话建的那个定时任务。
用户 2026-09-30 三连:「把心跳的节点也放到看板协作架构图中」→「把心跳 图框做小一点 放到
主会话左边就行 本来就是主会话创建的」→「把心跳 改为 唤醒 里面是每5分钟核对目标状态」。
⚠️ 它们**不由技能侧写死**(⛔ 技能里不出现任何项目的组件名)——
由**使用方**经 `front.triggers` 给,形状:
`[{name, detail, detail2, label, up, edge, tip}]`。空 ⇒ 主会话左边不画格子。 */
var TG=(d.front&&d.front.triggers)||[];
/* 🔴 行距(用户 2026-09-30:「摆不下就**加高**些,线都没连全 有些线都看不到」)
⇒ **只加高、⛔ 不加宽**:`svg.arch{width:100%}` ⇒ 缩放比由**宽度**定 ⇒ 加高**不会**把字变小,
只是每层之间更宽敞(走线带、拐点、标签都放得下)。 */
var R1={y:36,h:80}, R2={y:176,h:84}, R3={y:316,h:134}, R4={y:516,h:112},
R5={y:662,h:96}, RB={y:794,h:56}; /* R5 = Hook进程(时间驱动源另画在 主会话 左侧) */
/* ⚠️ R3 由 110 → **134**(2026-09-30):第三层从"按线"改成"按**任务类别**" ⇒
每格多一行「**该类别的主会话**」(用户:「同一个工作区 多会话协作…每类各有自己的主会话」
⇒ 一格必须先说清"这一类的件**投给谁**",再说"最近在做什么、谁在做")。 */
var CX=640, SX0=60, SX1=1220, CH=1254; // 会话行 60..1220;右侧竖井 1254
var B1=(R2.y+R2.h+R3.y)/2, B2=(R3.y+R3.h+R4.y)/2; // ① 派活带 / ② 上报带(自动取中,⛔ 别写死)
var B3=(R4.y+R4.h+R5.y)/2; // R4↔R5 之间的走线带(⛔ 别写死 554)
var PX=280, PW=300, UX=700; // 协作程序 280..580;监督程序 700..1000
function rect(x,y,w,h,tone){
var m={on:'node-on',off:'node-off',base:'node-base'};
o.push('<rect class="'+(m[tone]||'node-bg')+'" x="'+x+'" y="'+y+'" width="'+w+'" height="'+h+'" rx="14"/>');
}
function tx(x,y,t,cls,anchor){
o.push('<text class="'+(cls||'n-sub')+'" x="'+x+'" y="'+y+'"'+(anchor?' text-anchor="'+anchor+'"':'')+'>'+esc(t)+'</text>');
}
function edge(dstr,grey,noArrow){
o.push('<path class="edge'+(grey?'-grey':'')+(noArrow?'':' live')+'" d="'+dstr+'"'
+(noArrow?'':' marker-end="url(#ar)"')+'/>');
}
function lbl(x,y,t,anchor){tx(x,y,t,'e-label',anchor||'middle')}
function toneOf(st){return st==='working'?'n-ok':(st==='completed'?'n-idle':'n-warn')}
function wordOf(st){return st==='working'?'执行中':(st==='completed'?'已完成':(st||'—'))}
// ── ① 用户 —— 🔴 写**职责 + 可执行动作**(用户 2026-09-30 要求;⛔ 不写"你 · 一张看板"这种空话)
var w1=300, x1=CX-w1/2, in1=w1-40;
rect(x1,R1.y,w1,R1.h,'bg');
tx(x1+20,R1.y+28,'用户','n-title');
tx(x1+20,R1.y+50, fitText('职责:制定目标 · 调整方向 · 做决策', in1, 11, 6), 'n-sub');
tx(x1+20,R1.y+70, fitText('可做:看板 · 放行授权 · 改目标 · 喊停', in1, 11, 6), 'n-sub');
// ── ② 主会话 —— 🔴 显示**会话名**(用户 2026-09-30 要求),⛔ 不只给一个 id
var w2=380, x2=CX-w2/2, in2=w2-40;
var mOn=!!(main&&main.status==='working');
rect(x2,R2.y,w2,R2.h, main?(mOn?'on':'bg'):'bg');
tx(x2+20,R2.y+26,'主会话','n-title');
tx(x2+20,R2.y+50, main?fitText(main.title, in2, 13, 7):'(未声明主会话)', 'n-title-sm');
tx(x2+20,R2.y+70, main?fitText('判断 + 派活 · '+wordOf(main.status)+' · '+main.id8, in2, 11, 6)
:'——', main?(mOn?'n-ok':'n-idle'):'n-idle');
// 用户 → 主会话
edge('M'+CX+' '+(R1.y+R1.h)+' L'+CX+' '+R2.y);
lbl(CX+56, (R1.y+R1.h+R2.y)/2, '发消息 · 看板');
// ── ②′ 主会话**创建**的时间驱动触发源(用户 2026-09-30:「把心跳 图框做小一点 放到**主会话左边**
// 就行,本来就是主会话创建的」)⇒ 小框贴在主会话左侧,一条「创建」箭头从主会话指过去。
// 数据仍由**使用方**给(`front.triggers`),⛔ 技能里不写死"唤醒"这个名字。
// ⚠️ 左边界 SX0 之外放不下时就只画**最靠前的一个**,其余在图外注释里点名(⛔ 不硬塞、不压连线)。
if(TG.length){
var TGAP=64, TBH=78, TBR=x2-TGAP; // 右边界 = 主会话左边界 − 连接带宽度
var TBW=236, shown=TG;
if(TG.length>1 || TBR-TBW<SX0){ TBW=Math.max(150, TBR-SX0); shown=TG.slice(0,1); }
var TBY=R2.y+3, yc0=R2.y+R2.h/2;
shown.forEach(function(t){
var x=TBR-TBW, inner=TBW-28, up=!!t.up;
o.push('<rect class="'+(up?'node-on':'node-warn')+'" x="'+x+'" y="'+TBY+'" width="'+TBW+'" height="'+TBH+'" rx="14">'
+(t.tip?('<title>'+esc(t.tip)+'</title>'):'')+'</rect>');
tx(x+14,TBY+20, fitText(t.name||'(未命名触发源)', inner, 13, 7), 'n-title-sm');
tx(x+14,TBY+40, fitText(t.detail||'', inner, 11, 6), 'n-sub');
tx(x+14,TBY+56, fitText(t.detail2||'', inner, 11, 6), 'n-sub');
tx(x+14,TBY+72, fitText(t.label||'状态未知', inner, 10, 5.6), up?'n-ok':'n-warn');
// 它 → 主会话 —— 🔴 用户 2026-09-30:「唤醒和主会话 **强调的是唤醒的动作**,不是创建的动作」
// ⇒ 箭头由它**指向主会话**("它把主会话叫起来"),标签也用「唤醒」;
// "主会话创建的"只作**来历**写在框里一行,⛔ 不再画成箭头。
edge('M'+(x+TBW)+' '+yc0+' L'+x2+' '+yc0);
if(t.edge) lbl((x2+x+TBW)/2, yc0-8, fitText(t.edge, TGAP-6, 11, 6), 'middle');
});
}
// ── ③ 分工板块(按**任务类别**)—— 🔴 用户 2026-09-30:
// 「协作会话**不是历史记录**,是**展示分工**的板块」+
// 「之前的协作会话是**跨工作区**的,要能支持**同一个工作区** 多会话协作(主会话根据任务
// **自动梳理任务类别**:**通过协作会话名称前缀**的方式区分具体任务会话)」
// 每格=**一个任务类别**;格内五行:类别名 / **该类的主会话** / 最近在做什么 / 现在谁在做 / 件汇总。
var cx=[], n=cells.length;
if(n===0){
rect(SX0+40,R3.y,SX1-SX0-80,R3.h,'bg');
tx(CX,R3.y+66,'(本目标未声明任何任务类别,无分工位)','n-idle','middle');
lbl(CX,B1-8,'① 派活 · 自动化排期');
}else{
var gap=24, w=Math.min(340,(SX1-SX0-(n-1)*gap)/n), total=n*w+(n-1)*gap, sx=CX-total/2;
cells.forEach(function(L,i){
var x=sx+i*(w+gap); cx.push(x+w/2);
var inner=w-28;
var tone=(L.state==='busy')?'on':((L.state==='gap')?'warn':'bg');
rect(x,R3.y,w,R3.h,tone);
tx(x+14,R3.y+24, fitText(L.name, inner, 13, 7), 'n-title-sm');
/* 🔴 ① **这一类的主会话**(新)—— 一格先回答"这一类的件**投给谁**"。
⛔ 没有 ⇒ 明说"投递会降级报告"(⛔ 不许让格子看起来正常,实际没人收件)。
⚠️ **「未归类」折叠格没有"该类主会话"这回事** —— 它不是类别,⛔ 别回落成"⚠ 无该类主会话"
(那会让人以为"补一个主会话就好了");要说清**它压根不在分工体系里**。 */
if(L.fold){
tx(x+14,R3.y+46, '⚠ 不属任何当前任务类别', 'n-warn');
tx(x+14,R3.y+68, fitText(L.fold.length===1
? ('1 条线:'+L.fold[0].name)
: (L.fold.map(function(z){return z.name}).join('、')+'('+L.fold.length+' 条线)'), inner, 11, 6), 'n-warn');
}else{
var _m=MBT[L.line]||'';
tx(x+14,R3.y+46, _m?('主会话 '+_m):'⚠ 无该类主会话 ⇒ 投递降级报告', _m?'n-ok':'n-warn');
/* ② 「最近的协作任务」:台账有件取最新那件;**台账无件时回落到该类最近的会话**
(⛔ 别因为"台账没件"就写"暂无协作任务" —— 该类可能正在跑一件还没上报的活) */
var lt=L.latest;
tx(x+14,R3.y+68, lt?fitText((lt.from_session?'最近会话 ':'最近 ')+lt.item+' · '+zhState(lt.state)+' · '+fmtAge(lt.age_min), inner, 11, 6)
: '(该类还没有记录,也无可归到它的会话)',
lt? (lt.state==='done'?'n-idle':(lt.state==='working'?'n-ok':'n-warn')) : 'n-sub');
}
/* ③ 当前**承接**这条类的会话(=正在跑棒的那条,⚠️ 与 ① 的"主会话"**不是同一个概念**:
主会话是"收件的",承接会话是"干活的",可能相同也可能不同) */
var hd=(L.running&&L.running[0]);
tx(x+14,R3.y+90, hd?fitText('承接 '+hd.id8+' · '+hd.what, inner, 11, 6)
: (L.state==='gap'?'⚠ 有件没人在跑':'当前无会话在跑'),
hd?'n-ok':(L.state==='gap'?'n-warn':'n-idle'));
tx(x+14,R3.y+112, '件 '+L.total+' · 完成 '+L.done+(L.open?(' · 未完 '+L.open):''), 'n-sub');
});
// ① 派活:主会话 → 各分工位(走 y=264 带)
edge('M'+CX+' '+(R2.y+R2.h)+' L'+CX+' '+B1, null, true);
edge('M'+Math.min.apply(null,cx)+' '+B1+' L'+Math.max.apply(null,cx)+' '+B1, null, true);
cx.forEach(function(c){ edge('M'+c+' '+B1+' L'+c+' '+R3.y) });
lbl(Math.max.apply(null,cx), B1-8, '① 派活 · 自动化排期', 'end');
// ② 上报:各分工位 → 协作程序(走 y=404 带)
cx.forEach(function(c){ edge('M'+c+' '+(R3.y+R3.h)+' L'+c+' '+B2, null, true) });
edge('M'+Math.min.apply(null,cx.concat([PX+PW/2]))+' '+B2+' L'+Math.max.apply(null,cx.concat([PX+PW/2]))+' '+B2, null, true);
edge('M'+(PX+PW/2)+' '+B2+' L'+(PX+PW/2)+' '+R4.y);
lbl(Math.max.apply(null,cx), B2-8, '② 上报 · --report', 'end');
}
// ── ④ 协作程序 + 监督程序
var pr=rt.prog||{}, gu=rt.deliver||{}, q=d.queue||{}, qb=q.by||{}; /* gu = 上报(旧名「监督程序」,再早叫「投递」) */
// 协作程序:🔴 带**队列计数**(用户 2026-09-30:「协作程序 也要显示当前待验收队列数量」)
o.push('<rect class="'+(pr.up?'node-on':'node-off')+'" x="'+PX+'" y="'+R4.y+'" width="'+PW+'" height="'+R4.h+'" rx="14">'
+'<title>队列 = 需求台账 tasks.json 四态:待执行 '+((qb.pending)||0)+' / 执行中 '+((qb.running)||0)
+' / 已完成 '+((qb.done)||0)+' / 有阻碍 '+((qb.blocked)||0)+'。机制里没有「待验收」这个态。'
+'<br>'+esc(pr.how||'')+'</title></rect>');
tx(PX+18,R4.y+26,'协作程序','n-title');
tx(PX+18,R4.y+50, '队列 '+((q.total)||0)+' 件 · 已完成 '+((qb.done)||0)+' · 未完 '+((q.open)||0)
+(((qb.blocked)||0)?(' · 受阻 '+qb.blocked):''), ((q.open)||0)?'n-warn':'n-idle');
tx(PX+18,R4.y+70,'需求台账 / 队列(只维护,不上报)','n-sub');
tx(PX+18,R4.y+92, pr.label||'状态未知', pr.up?'n-ok':'n-bad');
// 监督程序:🔴 停着就把**停因**写在格子里(⛔ 不让用户只能猜"为什么停了")
var greason=String(gu.reason||'');
o.push('<rect class="'+(gu.up?'node-on':'node-warn')+'" x="'+UX+'" y="'+R4.y
+'" width="'+PW+'" height="'+R4.h+'" rx="14">'+(greason?('<title>'+esc(greason)+'</title>'):'')+'</rect>');
/* 🔴 这一格以前叫「监督程序」、并且显示一个"已停"的红框 ⇒ 让人以为有东西坏了。
实际上**它不是常驻进程**,是宿主钩子唤起的一次性 `--tick` ⇒ 没有"启动/停止"这回事。 */
tx(UX+18,R4.y+26,'上报','n-title');
tx(UX+18,R4.y+50, fitText(gu.label||'状态未知', PW-36, 12, 6.5), gu.up?'n-ok':'n-warn');
tx(UX+18,R4.y+70,'WorkBuddy 用时才喊它 · 不是一直开着','n-sub');
tx(UX+18,R4.y+92, fitText('它平时不占进程 ⇒ 没有"启动/停止"这回事', PW-36, 11, 6), 'n-sub');
// 队列:协作程序 → 监督程序
edge('M'+(PX+PW)+' '+(R4.y+R4.h/2)+' L'+UX+' '+(R4.y+R4.h/2));
lbl((PX+PW+UX)/2, R4.y+R4.h/2-10, '队列');
// ③ 上报 → 主会话(右侧竖井,⛔ 不横穿会话行)
var y4=R4.y+R4.h/2;
edge('M'+(UX+PW)+' '+y4+' L'+CH+' '+y4+' L'+CH+' '+(R2.y+R2.h/2)+' L'+(x2+w2)+' '+(R2.y+R2.h/2));
lbl(1050, R2.y+R2.h/2-10, '③ 上报 · reply/唤醒', 'middle');
// ── ⑤ 🔴 **Hook进程** —— 用户 2026-09-30 指出「图里是不是还缺少关键节点,比如宿主起的子进程」。
// 它**才是上报的真正执行者**:WorkBuddy 在钩子时机起一个短命子进程,跑完即退 ⇒ **事件驱动**。
// ⚠️ 时间驱动的那些(如「唤醒」)**不在这一行**,画在 ② 主会话左边(见上)—— 两者不是一回事:
// 整机没活动时 Hook 不会被唤起,「唤醒」补的正是这个缺口。
var hasTG=TG.length>0;
var hkX=400, hkW=480;
rect(hkX,R5.y,hkW,R5.h,'on');
tx(hkX+20,R5.y+26,'Hook进程','n-title');
tx(hkX+20,R5.y+48, fitText('WorkBuddy 在特定时机喊它 · 干完就退', hkW-36, 11, 6), 'n-sub');
tx(hkX+20,R5.y+66, fitText('它是 WorkBuddy 的子进程 ⇒ 拿得到网关口令', hkW-36, 11, 6), 'n-sub');
tx(hkX+20,R5.y+84, fitText('两个模式:--once(队列)/ --tick(上报)', hkW-36, 11, 6), 'n-sub');
// WorkBuddy → 钩子(它在钩子时机起它)
edge('M'+(hkX+70)+' '+RB.y+' L'+(hkX+70)+' '+(R5.y+R5.h), true);
lbl(hkX+82, (RB.y+R5.y+R5.h)/2, '用时才喊它', 'start');
// 钩子 → 协作程序(--once):直接向上
edge('M'+(hkX+40)+' '+(R5.y)+' L'+(hkX+40)+' '+(R4.y+R4.h), true);
lbl(hkX+52, (R5.y+R4.y+R4.h)/2, '--once', 'start');
// Hook进程 → 上报(--tick):⚠️ 走 R4/R5 **之间的空白带**(B3)再拐上去,⛔ 不斜穿节点框
edge('M'+(hkX+hkW-40)+' '+(R5.y)+' L'+(hkX+hkW-40)+' '+B3+' L'+(UX+PW/2)+' '+B3+' L'+(UX+PW/2)+' '+(R4.y+R4.h), true);
lbl(hkX+hkW-28, B3-6, '--tick', 'start');
// ── 最底一条:WorkBuddy
// (用户 2026-09-30:「**宿主地基 这个就叫 WorkBuddy** 不要用这些AI语言」⇒ ⛔ 不用自造词)
rect(SX0,RB.y,SX1-SX0,RB.h,'base');
tx(SX0+20,RB.y+33,'WorkBuddy · 只读它的数据库 / 排期 / 钩子 / 文件占用登记','n-sub');
// ④ 收结果:WorkBuddy → 协作程序(只读,灰线)。
// ⚠️ 走 x=340 —— **必须落在协作程序框底边(280..580)上**,否则线头悬空
// (2026-09-30 用户一眼看出:"WorkBuddy 左边那根线是要连到哪里?")。
// x=340 同时⛔ 不穿 R5 的 Hook进程框(400..880)。
edge('M340 '+RB.y+' L340 '+(R4.y+R4.h), true);
// ⚠️ 标签放线的**左边**(anchor end)—— 放右边会和「在钩子时机起它」挤成一行连读。
lbl(326, RB.y-14, '④ 收结果 · 读 WorkBuddy 库', 'end');
o.push('<defs><marker id="ar" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">'
+'<path d="M2 1L8 5L2 9" fill="none" stroke="var(--color-accent)" stroke-width="1.5" stroke-linecap="round"/></marker></defs>');
document.getElementById('arch').innerHTML=o.join('');
// 超出 MAXW 的分工位在**图外**提示(⛔ 塞进图里会压到连线)
var extra=[];
extra.push('第三层是<b>分工板块</b>(按<b>任务类别</b>,不是历史会话列表)'+(moreL>0?(',图上最多画 '+MAXW+' 个,另有 <b>'+moreL+'</b> 个类别未画'):''));
/* 🔴 「未归类」必须在**图外也说一遍**(⛔ 不许只活在格子的小字里):图上那一格是**折叠**的,
这里要把"几条线、多少件"摊开说清 —— 否则读者不知道那格里到底压着什么。 */
if(_fold) extra.push('<b>未归类</b>:<b>'+_lg.length+'</b> 条线('+_lg.map(function(z){return '<code>'+esc(z.name)+'</code>'}).join('、')
+')不在当前任务类别清单里,共 <b>'+_fold.total+'</b> 件 · 已完成 <b>'+_fold.done+'</b>'
+(moreL>0?',⛔ 图上只画了一格汇总':''));
extra.push('当前在跑的协作会话 <b>'+runningS.length+'</b> 个');
extra.push('历史会话(本项目 <b>'+works.length+'</b> 个)见下方「会话」表');
if(TG.length>1) extra.push('主会话创建的时间驱动源另有 <b>'+(TG.length-1)+'</b> 个未画(图上只画最靠前的一个)');
document.getElementById('archNote').innerHTML='· '+extra.join(' · ');
/* 标题提示:按"图上真画了哪几层"说,⛔ 不用自造词(用户 2026-09-30:
「宿主地基 这个就叫 WorkBuddy 不要用这些AI语言」) */
var _ah=document.getElementById('archHint');
if(_ah) _ah.textContent='用户 · 会话 · 分工 · 程序 · WorkBuddy';
}
function render(d){
D=d; document.getElementById('err').hidden=true;
var iv=(d.board&&d.board.refresh_interval)||3;
POLL=Math.max(1000, Math.round(iv*1000));
document.getElementById('iv').textContent=iv;
/* 🔴 本文件(布局/样式)改了就自动重载一次 —— 首次拿到当作基线,之后只在**变了**那一刻 reload。 */
var hs=(d.board&&d.board.html_sig)||'';
if(hs){
if(HTML_SIG===null) HTML_SIG=hs;
else if(HTML_SIG!==hs){ HTML_SIG=hs; location.reload(); return; }
}
/* ⏱「保留的快照时刻」只在这一处显示(协作架构右上角)⇒ ⛔ 不再往 header 放第二份 */
document.getElementById('ts2').textContent=(d.ts||'').slice(11)||'—';
paintAge();
var wb=document.getElementById('warnbar'), wn=(d.warn||[]);
if(wn.length){ wb.hidden=false; wb.textContent='⚠️ 有数据没读到(看板仍可用):'+wn.join(';'); }
else wb.hidden=true;
renderProject(d);
/* 🔴 「前置」整块**通用渲染** —— 技能侧⛔ 不认识任何具体组件。
内容全部来自**使用方**的 `board_ext.py`(经 board.json 的 `front`):
chips[{label,up,ok,bad,tip}] · paragraphs[{text,tone}] · tip
⛔ 技能里不许再出现某个项目的端口/路径/真名(用户 2026-09-30 定则:
「技能就是技能 程序就是程序,谁用产生的文件 放在他自己那里」)。 */
/* 🔴 `deliver` = 旧名「监督程序」的那条职责(**上报**)。2026-09-30 改名:
它不是"该常驻的进程",而是宿主钩子唤起的一次性 `--tick` ⇒ ⛔ 不再显示"已停"。 */
var f=d.front||{}, rt2=d.runtime||{}, pg=rt2.prog||{}, gd=rt2.deliver||{};
var chips='';
(f.chips||[]).forEach(function(c){
chips+=chip(String(c.label||'')+(c.up?' '+(c.ok||''):' '+(c.bad||'')),
c.up?'is-ok':'is-bad', c.tip||'');
});
var paras='';
(f.paragraphs||[]).forEach(function(p){
var t=String(p.text||'');
paras+='<p class="lede" style="margin:8px 0 0'+(p.tone==='warn'?';color:var(--color-warning)':'')+'">'+esc(t)+'</p>';
});
document.getElementById('front').innerHTML='<div class="rowchips">'
+chip('协作程序 '+(pg.label||'未知'), pg.up?'is-ok':'is-bad')
+chip('上报 '+(gd.label||'未知'), gd.up?'is-ok':'is-warn')
+'</div>'
+(chips?'<div class="rowchips" style="margin-top:8px">'+chips+'</div>':'')
+paras
+((f._missing||f._err)?('<p class="lede" style="margin:8px 0 0">看板扩展:'+esc(String(f._missing||f._err))+'</p>'):'');
/* 🔴 长解释一律进「?」—— ⛔ 不摊在版面上(用户 2026-09-30 要求) */
var tip=document.getElementById('frontTip');
if(tip){
if(!tip.dataset.base) tip.dataset.base=tip.innerHTML; /* 首轮把静态文案存下来 */
tip.innerHTML=tip.dataset.base; /* 每轮先还原,防重复追加 */
if(f.tip) tip.innerHTML+='<br><br>'+String(f.tip); /* 项目侧说明(允许含标签) */
if(gd.stopped && gd.reason){
tip.innerHTML+='<br><br><b>监督程序为什么是停的</b>(主会话主动止损,不是崩溃):'
+esc(String(gd.reason)).replace(/\*\*/g,'').replace(/`/g,'');
}
}
/* 板块标题与类别标签也由使用方给(⛔ 不在技能里写死"链路前置/组件状态") */
var fh=document.querySelector('#frontCard h2');
if(fh) fh.innerHTML=esc(f.title||'前置')+(f.tag?('<span class="hint">'+esc(f.tag)+'</span>'):'');
var n=d.notify||{};
document.getElementById('notify').innerHTML=
'<table><tr><th>本条待处理</th><td><code>'+esc(n.awaiting||'(无)')+'</code></td></tr>'
+'<tr><th>握手阶段</th><td>'+esc(n.phase||'—')+'</td></tr>'
+'<tr><th>最近发出</th><td><span class="ellip"><code>'+esc(n.sent||n.notice||'(无)')+'</code></span></td></tr></table>';
var w=(d.wakeups||[]).slice().reverse();
document.getElementById('wake').innerHTML = w.length
? '<table><tr><th>时间</th><th>类型</th><th>结果</th></tr>'+w.map(function(x){
return '<tr><td class="num">'+esc(ago(x.ts))+'</td><td>'+esc(x.kind)+'</td><td>'
+(x.ok?chip('HTTP '+x.http,'is-ok'):chip('HTTP '+x.http,'is-bad'))+'</td></tr>'}).join('')+'</table>'
: emptyBox('还没有上报记录','队列有变化,或者长时间没动静时,会出现第一条。');
var LN={}, LK={}; (d.labor||[]).forEach(function(L){ LN[L.line]=L.name; LK[L.line]=L.kind||'topic' });
/* 🔴 台账条目的 `line` 现在语义=**任务类别**。旧数据里存的是**跨工作区时代的工作区名**
⇒ 列名与值对不上。⛔ 不隐藏、⛔ 不改历史记录,只**如实标一句**(同族红线:读到了就要说)。 */
var TP={}; (((d.project||{}).topics)||[]).forEach(function(t){ TP[t]=1 });
var tk=Object.keys(d.tasks||{}).sort();
document.getElementById('tasks').innerHTML = tk.length
? '<table><tr><th>需求</th><th>状态</th><th>任务类别</th></tr>'+tk.map(function(k){var v=d.tasks[k];
var cls=v.state==='done'?'is-ok':(v.state==='blocked'?'is-bad':'is-warn');
var _ln=v.line||'', _stale=(_ln && LK[_ln]==='legacy') || (_ln && !TP[_ln] && LK[_ln]!=='topic');
return '<tr><td><code>'+esc(k)+'</code></td><td>'+chip(zhState(v.state),cls)+'</td><td>'
+esc(LN[_ln]||_ln||'—')
+(_stale?'<span class="oldline">(旧值 · 不在当前任务类别清单)</span>':'')
+'</td></tr>'}).join('')+'</table>'
: emptyBox('台账为空','协作会话用 --report 上报后,条目进这里。');
var ss=d.sessions||[];
document.getElementById('sess').innerHTML = (ss.length
? '<table><tr><th>会话</th><th>角色</th><th>任务类别</th><th>状态</th><th>活动</th></tr>'+ss.map(function(x){
return '<tr><td><code>'+esc(x.id8)+'</code> <span class="ellip">'+esc(x.title)+'</span></td>'
+'<td><span class="role'+(x.role==='主会话'?' is-main':'')+'">'+esc(x.role||'—')+'</span></td>'
+'<td>'+(x.topic?esc(x.topic):'—')+'</td><td>'
+(x.status==='working'?chip('执行中','is-ok'):chip(zhSess(x.status)))+'</td><td class="num">'+esc(x.age_min)+' 分钟前</td></tr>'}).join('')+'</table>'
: emptyBox('本项目暂无相关会话','只含主会话,和标题带 [任务类别] 的协作会话。'));
drawArch(d);
}
/* 数据延迟 = 现在 − 快照生成时刻(同机时钟可比)。⛔ 延迟不是错误 —— 是"看板不打扰程序"的代价。 */
function paintAge(){
if(!D||!D.epoch) return;
var s=Math.max(0, Math.round(Date.now()/1000 - D.epoch));
var el=document.getElementById('lag');
if(el) el.textContent=s+' 秒';
var w=document.getElementById('herometa'); /* 超 15 秒 ⇒ 整块转警示色 */
if(w) w.classList.toggle('is-old', s>15);
}
function load(){
if(window.__SNAP__){ try{ return render(window.__SNAP__); }catch(e){ var _e=document.getElementById('err'); if(_e){_e.hidden=false; _e.textContent='渲染异常:'+e.message;} return; } }
if(!LIVE){
document.getElementById('err').hidden=false;
document.getElementById('err').textContent='要经本地服务打开:先跑 python board.py --serve 8788,再开 http://127.0.0.1:8788/(直接 file:// 打不开,取不到数据)';
return;
}
fetch('board.json?t='+Date.now(),{cache:'no-store'}).then(function(r){
if(!r.ok) throw new Error('HTTP '+r.status);
return r.json();
}).then(render).catch(function(e){
var el=document.getElementById('err'); el.hidden=false;
el.textContent='快照读取失败:'+e.message+'(服务还开着吗?重跑 python board.py --serve 8788)';
});
}
/* 🔴 轮询节奏 = 服务的快照节奏(⛔ 页面不自行加频:加了只是空转,不会更快) */
var POLL=3000;
function loop(){ load(); setTimeout(loop, POLL); }
initStyleSwitcher();
if(LIVE){ loop(); setInterval(paintAge,1000); } else { load(); }
</script>
</body>
</html>