Files
dsh_ai1net_server/交付物/看板-离线快照预览-20260930.html
T
admin c1b5e4d966 chore(工作区): 全量入库 + 补齐 .gitignore(以工作区为准)
- 变更规模:新增 514 / 修改 62 / 重命名 155 / 删除 4(归档重组与文档轮次)
- .gitignore 修:`归档/**/db-cwd归一-备份-*/` —— 原规则写绝对层级(归档/db-cwd归一-…),
  目录搬进 归档/配置与备份/ 后**静默失效**,43 MB 的 DB 备份又变成未跟踪
- .gitignore 补:嵌套 git 内部数据(归档/内嵌git-20261008/、归档/skills-git-旧线-20261007/dotgit-原样移出/)
- .gitignore 补:运行态与部署副本(.workbuddy/collab/、.workbuddy/tools/、.workbuddy/.load-pending、.workbuddy/tmp-*)
- .gitignore 补:备份件(*.bak-*)
- 未跟踪文件从 2190 降到 890(其余为 归档/ 归档件与 .workbuddy/memory/ 知识文件,按口径入库)
2026-10-10 23:13:22 +08:00

859 lines
66 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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-30T10:39:36", "kind": "监督程序·心跳", "http": 200, "ok": true}, {"ts": "2026-09-30T10:44:19", "kind": "上报·唤醒", "http": 200, "ok": true}, {"ts": "2026-09-30T10:44:43", "kind": "监督程序·心跳", "http": 200, "ok": true}], "front": {"title": "链路前置", "tag": "组件状态", "chips": [{"label": "dsh-client · 设备接入 · 本地中转 :20090", "up": true, "ok": "在监听", "bad": "未监听", "tip": "看 127.0.0.1:20090 这个口有没有人在听(只读探测,不发请求;⚠️ 本机装了 Proxifier 会代管本机连接 ⇒ 不能只看能不能连上)"}, {"label": "dsh-client · 覆盖网络节点 · 中继客户端", "up": true, "ok": "已注册", "bad": "未注册", "tip": "中继客户端的原始读数:状态=up · 转发过=0 · 被拒=4 · 收=38.8 KB · 发=4.0 MB · 已在线 93 分钟 · lastError=closed by peer code=1006 (was up)"}], "paragraphs": [{"text": "链路:手机 → 覆盖网络中继 → 本机「设备接入 · 本地中转」(127.0.0.1:20090)→ 桌面 DSH 会话", "tone": "note"}, {"text": "两个组件都在线,链路还没通:中继客户端只是注册上了,转发计数=0 说明还没有请求被真正转发过(denied=4),端到端没验过。", "tone": "warn"}, {"text": "上报由 WorkBuddy 的钩子唤起(非常驻,跑完即退)⇒ 它是事件驱动的:整机完全没有活动时它不会被唤起。补这个缺口的正是架构图里主会话左边那格「心跳」——时间驱动,条件满足才把 idle 的主会话叫起来。", "tone": "note"}], "tip": "组件名写成「系统 · 模块 · 功能名」,不用自造简称。<br>状态从组件自己留下的时间记录和日志里读;只做探测,不发请求。<br>两个组件都在线,链路也可能是断的。只有中继真的转发过,才算从手机到桌面通了。", "triggers": [{"name": "唤醒", "detail": "通道已停用 · 待换自动化", "detail2": "上次唤醒:无记录", "label": "上次上报:824 分钟前 · 91f8baeb", "up": false, "edge": "唤醒", "tip": "🔴 通道已停用:网关定时任务在**桌面端**被产品关闭(宿主拼子进程环境时硬编码 CODEBUDDY_DISABLE_CRON=1 ⇒ 调度器不启动,durable 写入还退化成静默空操作)⇒ 它**一次都没触发过**;本格保留只表示「架构上还有没有叫醒器」,⛔ 不是在说它还活着;正解=换成自动化(循环最小 1 小时),待用户拍板。;协作机制 · 唤醒(每5分钟敲钟 → collabd --tick);cron */5 * * * *;循环;登记于 2026-09-30 11:53;上报到主会话 fe146dd9;来历:主会话创建的定时任务;「上次唤醒」=它自己每次响的时候写下的时间(写在 _wake.stamp 里),所以这是真读数;「上次上报」=它真的把话送到主会话的那一次(放在 wakeups.jsonl 台账里,2026-09-30T10:44:43 http=200,今天已送 0 次);⚠️ 它只在三个条件同时成立时才出声(主会话闲着 + 没有待处理的事 + 没有待反馈的),所以两次之间有间隔是正常的;⚠️ 但更要紧的是另一面:网关那条定时任务查不到状态(按 id 查是 404,列表里也永远不显示)⇒ 所以⛔ 别信「登记册里记着就还在跑」,只看上面两个时间;超过 15 分钟两个都没动,那一格就会变黄,意思就是「它没在响」。"}]}, "runtime": {"prog": {"up": true, "age_min": 1.6, "by": "钩子 --tick", "tick_age_min": 1.6, "proj_age_min": 3.7, "label": "在线", "how": "由 WorkBuddy 钩子按需唤起:--once(协作程序)/--tick(上报);最近一轮=钩子 --tick"}, "deliver": {"up": true, "stopped": true, "age_min": 1.6, "label": "就绪 · 1.6 分钟前跑过", "reason": ""}}, "queue": {"total": 4, "by": {"pending": 0, "running": 0, "done": 4, "blocked": 0, "other": 0}, "open": 0, "notify_n": 4}, "progress": {"last_progress_at": 1790768667.039, "goals_open": true, "main_busy": false, "prog_age_min": 283.3}, "notify": {"awaiting": "", "phase": "", "sent": "", "notice": "心跳"}, "warn": [], "meta": {"supervise_interval": 30, "queue_idle_min": 5, "wake_min_gap": 300, "workspace": "E:\\ProgramData\\AIProject\\ai1net-dsh-server", "inbox": "E:\\ProgramData\\AIProject\\ai1net-dsh-server\\tmp\\supervise-inbox"}};</script>
</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>