瀏覽代碼

feat: new static cap&strat

Talegorithm 16 小時之前
父節點
當前提交
d0a7ed4968

+ 1094 - 0
knowhub/frontend/public/viz_capability.html

@@ -0,0 +1,1094 @@
+<!doctype html>
+<html lang="zh">
+<head>
+<meta charset="utf-8">
+<title>能力 · 五层筛选</title>
+<style>
+  :root{
+    --bg:#f8fafc; --panel:#ffffff; --panel2:#f1f5f9; --border:#e2e8f0;
+    --fg:#0f172a; --muted:#64748b; --accent:#3b82f6; --accent2:#8b5cf6;
+    --tag-bg:#e2e8f0; --tag-bg-active:#3b82f6; --tag-fg-active:#fff;
+    --code:#f1f5f9; --warn:#eab308; --suggest:#8b5cf6; --inferred:#94a3b8;
+    --shi-bg:#dbeafe; --shi-fg:#1e40af;
+    --xing-bg:#ede9fe; --xing-fg:#6d28d9;
+    --both-bg:#fef3c7; --both-fg:#92400e;
+  }
+  *{box-sizing:border-box}
+  html,body{margin:0;height:100%;background:var(--bg);color:var(--fg);
+    font:13px/1.55 -apple-system,BlinkMacSystemFont,"PingFang SC","Helvetica Neue",sans-serif}
+  header{display:flex;align-items:center;gap:12px;padding:8px 14px;
+    border-bottom:1px solid var(--border);background:var(--panel);
+    position:sticky;top:0;z-index:5;flex-wrap:wrap}
+  h1{font-size:14px;margin:0;font-weight:600}
+  .stats{color:var(--muted);font-size:11px}
+  .clear-all{margin-left:auto;font-size:11px;color:var(--muted);cursor:pointer;
+    padding:4px 10px;border:1px solid var(--border);border-radius:4px;background:transparent}
+  .clear-all:hover{color:var(--fg);border-color:var(--accent)}
+  .clear-all.active{color:var(--warn);border-color:var(--warn)}
+
+  main{display:grid;grid-template-columns:160px 360px 220px 360px 1fr;
+    height:calc(100vh - 41px);min-width:1520px}
+  section{overflow:auto;border-right:1px solid var(--border);padding:8px 10px}
+  section:last-child{border-right:none}
+  section.fac1{background:var(--panel)}
+  section.fac2{background:#fafbfd}
+  section.fac3{background:var(--panel)}
+  section.fac4{background:#fafbfd}
+  section.detail{background:var(--panel)}
+
+  .col-title{font-size:10px;text-transform:uppercase;letter-spacing:.6px;
+    color:var(--muted);margin:2px 4px 8px;display:flex;justify-content:space-between;align-items:baseline}
+  .col-count{font-size:10px;color:var(--muted)}
+  .col-clear{font-size:10px;color:var(--accent);cursor:pointer;display:none}
+  .col-clear.show{display:inline}
+
+  .item{padding:6px 8px;border-radius:5px;cursor:pointer;margin-bottom:3px;
+    border:1px solid transparent;display:flex;justify-content:space-between;align-items:center;gap:6px}
+  .item:hover:not(.disabled):not(.active){background:var(--panel2)}
+  .item.active{background:var(--tag-bg-active);color:var(--tag-fg-active);border-color:var(--tag-bg-active)}
+  .item.disabled{opacity:.42;cursor:not-allowed}
+  .item-name{flex:1;min-width:0;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+  .item-count{font-size:10px;background:var(--bg);padding:1px 5px;border-radius:8px;
+    color:var(--muted);min-width:20px;text-align:center;flex-shrink:0}
+  .item.active .item-count{background:rgba(255,255,255,.3);color:#fff}
+  .item-def{display:block;font-size:10px;color:var(--muted);margin-top:2px;line-height:1.4}
+  .item.active .item-def{color:rgba(255,255,255,.85)}
+  .item-stack{flex:1;min-width:0}
+
+  /* col-2 tabs */
+  .scope-tabs{display:flex;gap:4px;margin:0 0 8px;border-bottom:1px solid var(--border);padding-bottom:5px}
+  .scope-tab{padding:4px 10px;border-radius:5px 5px 0 0;cursor:pointer;
+    font-size:12px;color:var(--muted);background:transparent;
+    border:1px solid transparent;border-bottom:none;user-select:none}
+  .scope-tab.active{color:var(--fg);background:var(--panel2);border-color:var(--border)}
+  .scope-tab:hover:not(.active){color:var(--fg)}
+  .scope-pane{display:none}
+  .scope-pane.active{display:block}
+  .scope-meta{font-size:10px;color:var(--muted);margin:0 4px 8px;line-height:1.4}
+
+  /* facets */
+  .facet-title{margin-top:10px;padding:4px 6px;font-size:11px;color:var(--muted);
+    font-weight:600;border-bottom:1px solid var(--border);text-transform:uppercase;letter-spacing:.5px}
+  .facet-title:first-child{margin-top:0}
+
+  /* itemsets */
+  .iset{padding:8px 10px;border-radius:5px;cursor:pointer;margin-bottom:5px;
+    border:1px solid var(--border);background:var(--panel)}
+  .iset:hover:not(.disabled):not(.active){border-color:var(--accent)}
+  .iset.active{border-color:var(--accent);background:#eff6ff;
+    box-shadow:0 0 0 1px var(--accent) inset}
+  .iset.disabled{opacity:.42;cursor:not-allowed}
+  .iset-head{display:flex;align-items:center;gap:6px;margin-bottom:5px}
+  .iset-sup{background:var(--accent);color:#fff;padding:1px 7px;border-radius:3px;
+    font-weight:700;font-size:11px}
+  .iset-k{background:var(--tag-bg);color:var(--fg);padding:1px 6px;border-radius:3px;
+    font-size:10px;letter-spacing:.3px}
+  .iset-meta{font-size:10px;color:var(--muted);margin-left:auto}
+  .iset-paths{display:flex;flex-direction:column;gap:3px}
+  .iset-path{display:flex;gap:5px;font-size:11px;line-height:1.4;align-items:flex-start}
+  .iset-fdot{width:18px;flex-shrink:0;font-size:9px;font-weight:700;text-align:center;
+    border-radius:3px;line-height:16px;height:16px;letter-spacing:0;margin-top:1px}
+  .iset-fdot.shi{background:var(--shi-bg);color:var(--shi-fg)}
+  .iset-fdot.xing{background:var(--xing-bg);color:var(--xing-fg)}
+  .iset-fdot.both{background:var(--both-bg);color:var(--both-fg)}
+  .iset-leaf{font-weight:600;color:var(--fg);word-break:break-all}
+  .iset-parent{color:var(--muted);font-size:10px;margin-top:1px;
+    word-break:break-all;line-height:1.3;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
+  .iset-caret{width:14px;display:inline-block;text-align:center;color:var(--muted);
+    cursor:pointer;user-select:none;flex-shrink:0;font-size:10px}
+  .iset-caret:hover{color:var(--fg)}
+  .iset-caret.invis{visibility:hidden}
+  .iset-children{margin-left:14px;border-left:1px dashed var(--border);padding-left:8px;
+    margin-top:-2px;margin-bottom:5px}
+  .iset-childcnt{font-size:10px;color:var(--muted);margin-left:4px}
+
+  /* tree */
+  .tnode{margin-left:0}
+  .tnode .tnode{margin-left:12px;border-left:1px dashed var(--border);padding-left:6px}
+  .tline{display:flex;align-items:center;gap:4px;padding:3px 4px;border-radius:4px;
+    cursor:pointer;font-size:12px;line-height:1.35}
+  .tline:hover:not(.disabled):not(.active){background:var(--panel2)}
+  .tline.active{background:var(--tag-bg-active);color:var(--tag-fg-active)}
+  .tline.disabled{opacity:.42;cursor:not-allowed}
+  .tline.suggested .tname{color:var(--suggest);font-style:italic}
+  .tline.suggested.active .tname{color:#fff}
+  .tline.inferred .tname{color:var(--inferred);font-style:italic}
+  .tline.inferred.active .tname{color:#fff}
+  .tcaret{width:12px;display:inline-block;text-align:center;color:var(--muted);
+    cursor:pointer;user-select:none;flex-shrink:0;font-size:10px}
+  .tcaret.invis{visibility:hidden}
+  .tname{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+  .tcount{font-size:10px;color:var(--muted);background:var(--bg);padding:1px 5px;border-radius:8px;flex-shrink:0}
+  .tline.active .tcount{background:rgba(255,255,255,.3);color:#fff}
+  .tcollapsed > .tnode{display:none}
+
+  /* col-4 frag view tabs */
+  .fv-tabs{display:flex;gap:4px;margin:0 0 6px;border-bottom:1px solid var(--border);padding-bottom:3px}
+  .fv-tab{padding:3px 10px;border-radius:4px 4px 0 0;cursor:pointer;
+    font-size:11px;color:var(--muted);background:transparent;
+    border:1px solid transparent;border-bottom:none;user-select:none}
+  .fv-tab.active{color:var(--fg);background:var(--panel2);border-color:var(--border)}
+  .fv-tab:hover:not(.active){color:var(--fg)}
+
+  /* fragments */
+  .frag{padding:7px 9px;border-radius:5px;cursor:pointer;margin-bottom:4px;
+    border:1px solid var(--border);background:var(--panel)}
+  .frag:hover:not(.active){background:var(--panel2)}
+  .frag.active{border-color:var(--accent);background:#eff6ff}
+  .frag-head{font-size:10px;color:var(--muted);margin-bottom:3px;display:flex;gap:6px;align-items:center;flex-wrap:wrap}
+  .case-badge{background:var(--accent);color:#fff;padding:1px 5px;border-radius:3px;
+    font-weight:600;font-size:10px}
+  .frag-badge{background:var(--accent2);color:#fff;padding:1px 5px;border-radius:3px;
+    font-weight:600;font-size:10px}
+  .act-badge{background:var(--warn);color:#fff;padding:1px 5px;border-radius:3px;
+    font-weight:600;font-size:10px}
+  .side-badge{padding:1px 5px;border-radius:3px;font-size:10px;font-weight:600}
+  .side-badge.shi{background:var(--shi-bg);color:var(--shi-fg)}
+  .side-badge.xing{background:var(--xing-bg);color:var(--xing-fg)}
+  .src-badge{background:var(--suggest);color:#fff;padding:1px 5px;border-radius:3px;
+    font-size:10px;font-weight:600}
+  .frag-sig{font-size:11px;color:var(--muted);margin-top:2px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
+  .frag-body{font-size:12px;color:#334155;margin-top:4px;line-height:1.45;
+    display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
+
+  /* excerpt rows */
+  .excerpt-row{padding:7px 9px;border-radius:5px;cursor:pointer;margin-bottom:4px;
+    border:1px solid var(--border);background:var(--panel)}
+  .excerpt-row:hover:not(.active){background:var(--panel2)}
+  .excerpt-row.active{border-color:var(--accent);background:#eff6ff}
+  .excerpt-path{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:10px;
+    color:var(--accent);margin-top:4px;word-break:break-all;line-height:1.35}
+  .excerpt-main{font-size:12.5px;color:var(--fg);margin-top:4px;line-height:1.5}
+  .excerpt-note{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.45}
+  .excerpt-body{font-size:11px;color:var(--muted);margin-top:3px;line-height:1.45;
+    display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
+
+  /* structured tab */
+  .frag-excerpts{display:flex;flex-direction:column;gap:4px;margin-top:4px}
+  .frag-excerpts .ex{font-size:12px;color:#334155;line-height:1.55;
+    background:var(--code);padding:5px 8px;border-radius:4px;
+    border-left:2px solid var(--accent2)}
+  .frag-excerpts.empty{font-size:12px;color:var(--muted);font-style:italic;
+    background:var(--code);padding:5px 8px;border-radius:4px}
+
+  /* detail */
+  .detail-empty{color:var(--muted);text-align:center;padding:60px 20px;font-size:13px}
+  .detail h2{font-size:14px;margin:0 0 8px;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
+  .detail-section{margin:12px 0}
+  .detail-section h3{font-size:10px;color:var(--muted);text-transform:uppercase;
+    letter-spacing:.6px;margin:0 0 6px;font-weight:600}
+  .body-text{font-size:12px;line-height:1.6;color:#334155;background:var(--code);
+    padding:8px 10px;border-radius:5px;white-space:pre-wrap}
+  .effect-card{background:var(--code);border-radius:5px;padding:7px 9px;margin-bottom:5px;
+    border-left:3px solid var(--accent2);font-size:12px}
+  .effect-stmt{font-weight:600;margin-bottom:4px}
+  .effect-meta{color:var(--muted);font-size:11px;margin-top:3px}
+  .pill{display:inline-block;padding:1px 6px;background:var(--tag-bg);border-radius:8px;
+    font-size:11px;margin-right:3px;margin-bottom:3px}
+  .pill.in{background:var(--shi-bg);color:var(--shi-fg)}
+  .pill.out{background:var(--xing-bg);color:var(--xing-fg)}
+  .pill.cfg{background:var(--both-bg);color:var(--both-fg)}
+  .io-row{margin-bottom:4px;font-size:11px}
+  .io-row .lbl{color:var(--muted);font-size:10px;margin-right:6px;text-transform:uppercase}
+  .pathline{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;
+    color:var(--accent);margin-bottom:2px;word-break:break-all}
+  .pathline.suggest{color:var(--suggest)}
+  .rationale{color:var(--muted);font-size:11px;margin-bottom:3px;margin-left:4px;line-height:1.45}
+  .excerpt-line{color:var(--muted);font-size:11px;margin-left:4px;line-height:1.45;margin-bottom:6px;
+    border-left:2px solid var(--border);padding-left:6px}
+  .excerpt-line .em{color:#334155}
+  .json-raw{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:10px;
+    background:var(--code);padding:8px;border-radius:5px;white-space:pre-wrap;
+    color:#334155;max-height:240px;overflow:auto}
+  details summary{cursor:pointer;color:var(--muted);font-size:10px;margin:6px 0;user-select:none}
+  details summary:hover{color:var(--fg)}
+
+  /* case-detail (original-post) section in detail panel */
+  .case-detail{margin:8px 0;padding-bottom:8px;border-bottom:1px solid var(--border)}
+  .case-detail summary{font-weight:bold;color:var(--accent);padding:6px 8px;
+    background:var(--panel2);border-radius:4px;font-size:11px;cursor:pointer;outline:none;margin:0}
+  .case-content{padding:10px;background:var(--code);border-radius:5px;
+    margin-top:4px;border:1px solid var(--border)}
+  .case-images{display:flex;gap:8px;overflow-x:auto;margin-bottom:8px;padding-bottom:4px}
+  .case-images img{height:160px;border-radius:5px;object-fit:contain;
+    background:var(--panel2);border:1px solid var(--border)}
+  .case-body{font-size:12px;line-height:1.6;color:var(--fg);white-space:pre-wrap;
+    margin-bottom:10px;max-height:250px;overflow-y:auto;padding-right:4px}
+  .case-link{font-size:11px;color:var(--accent);display:inline-block;
+    background:var(--panel2);padding:4px 8px;border-radius:4px;text-decoration:none}
+  .case-feedback{font-size:11px;color:var(--muted);margin-bottom:6px;display:flex;gap:12px;flex-wrap:wrap}
+  .case-feedback .fb{display:inline-flex;gap:3px;align-items:center}
+  .case-author{font-size:11px;color:var(--muted);margin-bottom:6px}
+
+  .empty-msg{color:var(--muted);text-align:center;padding:30px 10px;font-size:11px;font-style:italic}
+
+  /* modality side filter */
+  .mod-filter{margin:0 4px 8px;padding:6px 6px 4px;background:var(--code);
+    border:1px solid var(--border);border-radius:5px}
+  .mod-filter-row{display:flex;align-items:center;gap:6px;margin-bottom:4px}
+  .mod-filter-row:last-child{margin-bottom:0}
+  .mod-filter-lbl{font-size:10px;color:var(--muted);min-width:42px;flex-shrink:0}
+  .mod-chips{display:flex;gap:4px;flex-wrap:wrap}
+  .mod-chip{font-size:11px;padding:1px 7px;background:var(--tag-bg);border-radius:8px;
+    cursor:pointer;color:var(--fg);user-select:none;border:1px solid transparent}
+  .mod-chip:hover:not(.disabled):not(.active){background:var(--panel2);border-color:var(--border)}
+  .mod-chip.active{background:var(--tag-bg-active);color:var(--tag-fg-active)}
+  .mod-chip.disabled{opacity:.42;cursor:not-allowed}
+
+  /* selection summary chip */
+  .filter-chip{font-size:11px;padding:3px 8px;background:var(--tag-bg);border-radius:10px;
+    color:var(--fg);display:inline-flex;align-items:center;gap:6px;margin-right:4px}
+  .filter-chip .x{color:var(--muted);cursor:pointer;font-weight:bold}
+  .filter-chip .x:hover{color:var(--warn)}
+  .filter-chip .lbl{color:var(--muted);font-size:9px;text-transform:uppercase}
+
+  .loading{display:flex;align-items:center;justify-content:center;
+    height:100vh;font-size:14px;color:var(--muted)}
+</style>
+</head>
+<body>
+
+<div id="loading" class="loading">加载能力数据中…</div>
+
+<div id="appRoot" style="display:none">
+<header>
+  <h1>能力</h1>
+  <span id="chips"></span>
+  <button class="clear-all" id="clearAll">清除全部筛选</button>
+</header>
+
+<main>
+  <section class="fac1" id="actCol">
+    <div class="col-title"><span>① 动作</span><span class="col-count" id="actCnt"></span></div>
+    <div id="actList"></div>
+  </section>
+  <section class="fac2" id="scopeCol">
+    <div class="col-title">
+      <span>② 作用域</span>
+      <span class="col-count" id="scopeStat"></span>
+    </div>
+    <div class="scope-tabs">
+      <div class="scope-tab" data-mode="node" id="tabNode">node · 内容树</div>
+      <div class="scope-tab" data-mode="pattern" id="tabPattern">pattern · 频繁项集</div>
+      <span class="col-clear" id="scopeClear" style="margin-left:auto;align-self:center">清除</span>
+    </div>
+
+    <div class="scope-pane" id="paneNode">
+      <div class="facet-title">实质</div>
+      <div id="shizhiTree"></div>
+      <div class="facet-title">形式</div>
+      <div id="xingshiTree"></div>
+    </div>
+
+    <div class="scope-pane" id="panePattern">
+      <div class="scope-meta" id="isParams"></div>
+      <div id="isList"></div>
+    </div>
+  </section>
+  <section class="fac3" id="modCol">
+    <div class="col-title"><span>③ 输入 → 输出模态</span><span class="col-clear" id="modClear">清除</span></div>
+    <div class="mod-filter">
+      <div class="mod-filter-row">
+        <span class="mod-filter-lbl">输入含</span>
+        <span class="mod-chips" id="modInChips"></span>
+      </div>
+      <div class="mod-filter-row">
+        <span class="mod-filter-lbl">输出含</span>
+        <span class="mod-chips" id="modOutChips"></span>
+      </div>
+    </div>
+    <div id="modList"></div>
+  </section>
+  <section class="fac4" id="fragCol">
+    <div class="col-title"><span id="fragColTitle">④ 原子能力</span><span class="col-count" id="fragCnt"></span></div>
+    <div class="fv-tabs" id="fvTabs" style="display:none">
+      <div class="fv-tab" data-view="raw" id="fvRaw">raw</div>
+      <div class="fv-tab" data-view="structured" id="fvStructured">structured</div>
+    </div>
+    <div id="fragList"></div>
+  </section>
+  <section class="detail" id="detailCol">
+    <div class="col-title"><span>⑤ 能力详情</span></div>
+    <div id="detailBody"><div class="detail-empty">点击左侧能力查看详情</div></div>
+  </section>
+</main>
+</div>
+
+<script>
+let data = null;
+let casesByIndex = {};
+let SIDE_MODS = null;
+let shiDesc = null;
+let xingDesc = null;
+const fragPaths = new Map();
+
+const state = {
+  action: null,
+  scopeMode: 'node',
+  shizhiPath: null,
+  xingshiPath: null,
+  itemsetIdx: null,
+  patternView: 'raw',
+  modSig: null,
+  modInFilter: new Set(),
+  modOutFilter: new Set(),
+  fragKey: null,
+  collapsed: new Set(),
+  isetExpanded: new Set(),
+};
+
+function $(id){return document.getElementById(id)}
+function el(tag, attrs, ...kids){
+  const e = document.createElement(tag);
+  if (attrs) for (const k in attrs){
+    if (k === 'class') e.className = attrs[k];
+    else if (k === 'onClick') e.addEventListener('click', attrs[k]);
+    else if (k.startsWith('data-')) e.setAttribute(k, attrs[k]);
+    else if (k === 'title') e.title = attrs[k];
+    else if (k === 'style') e.setAttribute('style', attrs[k]);
+    else e[k] = attrs[k];
+  }
+  for (const kid of kids){
+    if (kid == null || kid === false) continue;
+    if (typeof kid === 'string') e.appendChild(document.createTextNode(kid));
+    else e.appendChild(kid);
+  }
+  return e;
+}
+
+const fragKey = f => f.case_index + ':' + f.fragment_id;
+
+function sigParts(sig){
+  const [inS, outS] = sig.split(' → ');
+  const parse = s => new Set(s.split('+').filter(x => x && x !== '∅'));
+  return { in: parse(inS), out: parse(outS) };
+}
+
+function buildDescMap(roots){
+  const map = new Map();
+  function walk(node){
+    const all = [node.path];
+    for (const c of node.children || []){
+      const sub = walk(c);
+      for (const p of sub) all.push(p);
+    }
+    map.set(node.path, all);
+    return all;
+  }
+  for (const r of roots) walk(r);
+  return map;
+}
+
+function fragPathSet(f){
+  const s = new Set();
+  for (const e of (f.apply_shizhi || [])) if (e.category_path) s.add(e.category_path);
+  for (const e of (f.apply_xingshi || [])) if (e.category_path) s.add(e.category_path);
+  return s;
+}
+
+function fragMatchesPath(f, key, selPath, descMap){
+  if (!selPath) return true;
+  const allowed = new Set(descMap.get(selPath) || [selPath]);
+  const list = (key === 'shizhi') ? f.apply_shizhi : f.apply_xingshi;
+  for (const e of (list || [])){
+    if (allowed.has(e.category_path)) return true;
+  }
+  return false;
+}
+
+function fragMatchesItemset(f, itemset){
+  if (!itemset) return true;
+  const ps = fragPaths.get(fragKey(f));
+  for (const p of itemset.items) if (!ps.has(p)) return false;
+  return true;
+}
+
+function scopeFilterActive(){
+  if (state.scopeMode === 'node') return !!(state.shizhiPath || state.xingshiPath);
+  if (state.scopeMode === 'pattern') return state.itemsetIdx != null;
+  return false;
+}
+
+function applyFilters(except){
+  return data.fragments.filter(f => {
+    if (except !== 'action' && state.action && f.action !== state.action) return false;
+    if (except !== 'scope' && scopeFilterActive()){
+      if (state.scopeMode === 'node'){
+        if (state.shizhiPath && !fragMatchesPath(f, 'shizhi', state.shizhiPath, shiDesc)) return false;
+        if (state.xingshiPath && !fragMatchesPath(f, 'xingshi', state.xingshiPath, xingDesc)) return false;
+      } else if (state.scopeMode === 'pattern'){
+        const sel = data.itemsets[state.itemsetIdx];
+        if (sel && !fragMatchesItemset(f, sel)) return false;
+      }
+    }
+    if (except !== 'mod' && state.modSig && f.modality_signature !== state.modSig) return false;
+    return true;
+  });
+}
+
+function renderActions(){
+  const baseFiltered = applyFilters('action');
+  const counts = {};
+  for (const f of baseFiltered) counts[f.action] = (counts[f.action]||0)+1;
+  const wrap = $('actList'); wrap.innerHTML = '';
+  for (const a of data.actions){
+    const c = counts[a.verb] || 0;
+    const node = el('div', {
+      class: 'item' + (state.action === a.verb ? ' active' : '') + (c === 0 && state.action !== a.verb ? ' disabled' : ''),
+      onClick: () => {
+        if (c === 0 && state.action !== a.verb) return;
+        state.action = (state.action === a.verb) ? null : a.verb;
+        renderAll();
+      }
+    });
+    const stack = el('div', {class:'item-stack'},
+      el('div', {class:'item-name'}, a.verb),
+      a.definition ? el('div', {class:'item-def'}, a.definition) : null);
+    node.appendChild(stack);
+    node.appendChild(el('span', {class:'item-count'}, String(c)));
+    wrap.appendChild(node);
+  }
+  $('actCnt').textContent = '共 ' + data.actions.length + ' 个';
+}
+
+function renderTreeFacet(roots, key, mountId, selPath, descMap){
+  const wrap = $(mountId); wrap.innerHTML = '';
+  const baseFiltered = applyFilters('scope');
+  const hitCounts = new Map();
+  for (const f of baseFiltered){
+    const list = (key === 'shizhi') ? f.apply_shizhi : f.apply_xingshi;
+    const seen = new Set();
+    for (const e of (list || [])){
+      const parts = (e.category_path || '').split('/').filter(Boolean);
+      for (let i = 1; i <= parts.length; i++){
+        const p = '/' + parts.slice(0,i).join('/');
+        if (seen.has(p)) continue;
+        seen.add(p);
+      }
+    }
+    for (const p of seen) hitCounts.set(p, (hitCounts.get(p)||0)+1);
+  }
+  function renderNode(node, parent){
+    const c = hitCounts.get(node.path) || 0;
+    const collapsed = state.collapsed.has(node.path);
+    const hasChildren = (node.children || []).length > 0;
+    const tnode = el('div', {class: 'tnode' + (collapsed ? ' tcollapsed' : '')});
+    const cls = ['tline'];
+    if (selPath === node.path) cls.push('active');
+    if (c === 0 && selPath !== node.path) cls.push('disabled');
+    if (node.is_suggested && !node.is_hit) cls.push('suggested');
+    if (node.is_inferred) cls.push('inferred');
+    const line = el('div', {class: cls.join(' '), title: node.path + (node.description ? '\n\n' + node.description : '')});
+    const caret = el('span', {
+      class: 'tcaret' + (hasChildren ? '' : ' invis'),
+      onClick: (ev) => {
+        ev.stopPropagation();
+        if (state.collapsed.has(node.path)) state.collapsed.delete(node.path);
+        else state.collapsed.add(node.path);
+        renderTrees();
+      }
+    }, hasChildren ? (collapsed ? '▶' : '▼') : '·');
+    line.appendChild(caret);
+    let label = node.name;
+    if (node.is_suggested && !node.is_hit) label += ' ✦';
+    if (node.is_inferred) label += ' (推断)';
+    line.appendChild(el('span', {class:'tname'}, label));
+    line.appendChild(el('span', {class:'tcount'}, String(c)));
+    line.addEventListener('click', () => {
+      if (c === 0 && selPath !== node.path) return;
+      const k = (key === 'shizhi') ? 'shizhiPath' : 'xingshiPath';
+      state[k] = (state[k] === node.path) ? null : node.path;
+      renderAll();
+    });
+    tnode.appendChild(line);
+    for (const ch of node.children || []) renderNode(ch, tnode);
+    parent.appendChild(tnode);
+  }
+  for (const r of roots) renderNode(r, wrap);
+}
+
+function renderTrees(){
+  renderTreeFacet(data.subtree.shizhi || [], 'shizhi', 'shizhiTree', state.shizhiPath, shiDesc);
+  renderTreeFacet(data.subtree.xingshi || [], 'xingshi', 'xingshiTree', state.xingshiPath, xingDesc);
+}
+
+function pathLeafAndParent(p){
+  const parts = p.split('/').filter(Boolean);
+  if (!parts.length) return {leaf:p, parent:''};
+  return {leaf: parts[parts.length-1], parent: '/' + parts.slice(0,-1).join('/')};
+}
+
+function renderItemsets(){
+  const wrap = $('isList'); wrap.innerHTML = '';
+  const visibleIdxSet = new Set();
+  data.itemsets.forEach((it, i) => { if (it.size >= 2) visibleIdxSet.add(i); });
+  $('isParams').textContent = `${visibleIdxSet.size} closed · k≥2 · min_support=${data.itemsetsParams.min_support}, k≤${data.itemsetsParams.max_k}, leaf-only`;
+
+  const baseFiltered = applyFilters('scope');
+  const roots = [];
+  for (const i of visibleIdxSet){
+    const ps = (data.itemsetParents[i] || []).filter(p => visibleIdxSet.has(p));
+    if (!ps.length) roots.push(i);
+  }
+  roots.sort((a, b) => {
+    const A = data.itemsets[a], B = data.itemsets[b];
+    return (B.support - A.support) || (B.size - A.size) || a - b;
+  });
+
+  for (const idx of roots){
+    renderItemsetNode(idx, -1, wrap, baseFiltered, visibleIdxSet, new Set());
+  }
+  if (visibleIdxSet.size === 0){
+    wrap.appendChild(el('div', {class:'empty-msg'}, '没有符合 k≥2 的项集'));
+  }
+}
+
+function renderItemsetNode(idx, parentIdx, mountEl, baseFiltered, visibleIdxSet, ancestorSet){
+  const iset = data.itemsets[idx];
+  const isActive = (state.itemsetIdx === idx);
+  const occKey = parentIdx + ':' + idx;
+  const childIdxs = (data.itemsetChildren[idx] || []).filter(c => visibleIdxSet.has(c) && !ancestorSet.has(c));
+  const isExpanded = state.isetExpanded.has(occKey);
+  const supportNow = baseFiltered.filter(f => fragMatchesItemset(f, iset)).length;
+
+  const cls = ['iset'];
+  if (isActive) cls.push('active');
+  if (supportNow === 0 && !isActive) cls.push('disabled');
+  const node = el('div', {
+    class: cls.join(' '),
+    onClick: () => {
+      if (supportNow === 0 && !isActive) return;
+      state.itemsetIdx = isActive ? null : idx;
+      renderAll();
+    }
+  });
+  const caret = el('span', {
+    class: 'iset-caret' + (childIdxs.length === 0 ? ' invis' : ''),
+    title: childIdxs.length ? (isExpanded ? '折叠' : '展开 ' + childIdxs.length + ' 个超集') : '',
+    onClick: (ev) => {
+      ev.stopPropagation();
+      if (!childIdxs.length) return;
+      if (state.isetExpanded.has(occKey)) state.isetExpanded.delete(occKey);
+      else state.isetExpanded.add(occKey);
+      renderItemsets();
+    }
+  }, childIdxs.length ? (isExpanded ? '▼' : '▶') : '·');
+  const head = el('div', {class:'iset-head'},
+    caret,
+    el('span', {class:'iset-sup', title:'当前筛选下的支持度'}, '×'+supportNow),
+    el('span', {class:'iset-k'}, 'k='+iset.size),
+    childIdxs.length ? el('span', {class:'iset-childcnt'}, '⊃'+childIdxs.length) : null,
+    el('span', {class:'iset-meta'}, '原始 sup ' + iset.support)
+  );
+  node.appendChild(head);
+  const paths = el('div', {class:'iset-paths'});
+  for (const p of iset.items){
+    const facet = data.pathToFacet[p] || '?';
+    const dot =
+      facet === '实质' ? el('span', {class:'iset-fdot shi', title:'实质'}, '实') :
+      facet === '形式' ? el('span', {class:'iset-fdot xing', title:'形式'}, '形') :
+                         el('span', {class:'iset-fdot both', title:'两侧都有'}, '双');
+    const lp = pathLeafAndParent(p);
+    const row = el('div', {class:'iset-path', title: p},
+      dot,
+      el('div', null,
+        el('span', {class:'iset-leaf'}, lp.leaf),
+        el('div', {class:'iset-parent'}, lp.parent)
+      )
+    );
+    paths.appendChild(row);
+  }
+  node.appendChild(paths);
+  mountEl.appendChild(node);
+
+  if (isExpanded && childIdxs.length){
+    const childWrap = el('div', {class:'iset-children'});
+    const newAnc = new Set(ancestorSet); newAnc.add(idx);
+    const sortedChildren = [...childIdxs].sort((a, b) => {
+      const A = data.itemsets[a], B = data.itemsets[b];
+      return (B.support - A.support) || (B.size - A.size) || a - b;
+    });
+    for (const c of sortedChildren){
+      renderItemsetNode(c, idx, childWrap, baseFiltered, visibleIdxSet, newAnc);
+    }
+    mountEl.appendChild(childWrap);
+  }
+}
+
+function renderScope(){
+  $('tabNode').classList.toggle('active', state.scopeMode === 'node');
+  $('tabPattern').classList.toggle('active', state.scopeMode === 'pattern');
+  $('paneNode').classList.toggle('active', state.scopeMode === 'node');
+  $('panePattern').classList.toggle('active', state.scopeMode === 'pattern');
+  if (state.scopeMode === 'node') {
+    $('scopeStat').textContent = '路径树(双 facet)';
+  } else {
+    const k2 = data.itemsets.filter(i => i.size >= 2).length;
+    $('scopeStat').textContent = `${k2} 项集 · k≥2`;
+  }
+  $('scopeClear').classList.toggle('show', scopeFilterActive());
+  if (state.scopeMode === 'node') renderTrees();
+  else renderItemsets();
+}
+
+function sigPassesChipFilter(sig){
+  const p = sigParts(sig);
+  for (const m of state.modInFilter) if (!p.in.has(m)) return false;
+  for (const m of state.modOutFilter) if (!p.out.has(m)) return false;
+  return true;
+}
+
+function renderModalityChips(){
+  const renderSide = (mountId, palette, selectedSet, side) => {
+    const wrap = $(mountId); wrap.innerHTML = '';
+    for (const m of palette){
+      const trial = new Set(selectedSet);
+      if (!trial.has(m)) trial.add(m);
+      let c = 0;
+      for (const sig of data.modalities){
+        const p = sigParts(sig.sig);
+        const inOK = side === 'in'
+          ? [...trial].every(x => p.in.has(x)) && [...state.modOutFilter].every(x => p.out.has(x))
+          : [...state.modInFilter].every(x => p.in.has(x)) && [...trial].every(x => p.out.has(x));
+        if (inOK) c++;
+      }
+      const isActive = selectedSet.has(m);
+      const chip = el('span', {
+        class: 'mod-chip' + (isActive ? ' active' : '') + (c === 0 && !isActive ? ' disabled' : ''),
+        title: m + (c === 0 && !isActive ? ' (无匹配)' : ''),
+        onClick: () => {
+          if (c === 0 && !isActive) return;
+          if (selectedSet.has(m)) selectedSet.delete(m);
+          else selectedSet.add(m);
+          if (state.modSig && !sigPassesChipFilter(state.modSig)) state.modSig = null;
+          renderAll();
+        }
+      }, m);
+      wrap.appendChild(chip);
+    }
+  };
+  renderSide('modInChips', SIDE_MODS.in, state.modInFilter, 'in');
+  renderSide('modOutChips', SIDE_MODS.out, state.modOutFilter, 'out');
+}
+
+function renderModalities(){
+  const baseFiltered = applyFilters('mod');
+  const counts = {};
+  for (const f of baseFiltered) counts[f.modality_signature] = (counts[f.modality_signature]||0)+1;
+  renderModalityChips();
+  const wrap = $('modList'); wrap.innerHTML = '';
+  const visible = data.modalities.filter(m => sigPassesChipFilter(m.sig));
+  const sorted = [...visible].sort((a,b) => (counts[b.sig]||0) - (counts[a.sig]||0));
+  if (sorted.length === 0){
+    wrap.appendChild(el('div', {class:'empty-msg'}, '当前 chip 筛选下没有匹配签名'));
+  }
+  for (const m of sorted){
+    const c = counts[m.sig] || 0;
+    const node = el('div', {
+      class: 'item' + (state.modSig === m.sig ? ' active' : '') + (c === 0 && state.modSig !== m.sig ? ' disabled' : ''),
+      onClick: () => {
+        if (c === 0 && state.modSig !== m.sig) return;
+        state.modSig = (state.modSig === m.sig) ? null : m.sig;
+        renderAll();
+      }
+    },
+      el('span', {class:'item-name'}, m.sig),
+      el('span', {class:'item-count'}, String(c))
+    );
+    wrap.appendChild(node);
+  }
+  $('modClear').classList.toggle('show', !!state.modSig || state.modInFilter.size > 0 || state.modOutFilter.size > 0);
+}
+
+function makeFragBodyRow(f){
+  const k = fragKey(f);
+  const head = el('div', {class:'frag-head'},
+    el('span', {class:'case-badge'}, '案例'+f.case_index),
+    el('span', {class:'frag-badge'}, f.fragment_id),
+    el('span', {class:'act-badge'}, f.action)
+  );
+  const sig = el('div', {class:'frag-sig'}, f.modality_signature);
+  const body = el('div', {class:'frag-body'}, f.body || '');
+  return el('div', {
+    class: 'frag' + (state.fragKey === k ? ' active' : ''),
+    onClick: () => { state.fragKey = (state.fragKey === k) ? null : k; renderFragments(); renderDetail(); }
+  }, head, sig, body);
+}
+
+function makeExcerptRow(frag, side, entry){
+  const k = fragKey(frag);
+  const head = el('div', {class:'frag-head'},
+    el('span', {class:'case-badge'}, '案例'+frag.case_index),
+    el('span', {class:'frag-badge'}, frag.fragment_id),
+    el('span', {class:'act-badge'}, frag.action),
+    el('span', {class:'side-badge ' + (side==='实质'?'shi':'xing')}, side),
+    entry.source === 'suggest' ? el('span', {class:'src-badge'}, '建议') : null
+  );
+  const node = el('div', {
+    class: 'excerpt-row' + (state.fragKey === k ? ' active' : ''),
+    onClick: () => { state.fragKey = (state.fragKey === k) ? null : k; renderFragments(); renderDetail(); }
+  });
+  node.appendChild(head);
+  node.appendChild(el('div', {class:'excerpt-path'}, entry.category_path));
+  if (entry.body_excerpt){
+    node.appendChild(el('div', {class:'excerpt-main'}, entry.body_excerpt));
+  }
+  if (entry.body_excerpt_note){
+    node.appendChild(el('div', {class:'excerpt-note'}, entry.body_excerpt_note));
+  }
+  if (frag.body){
+    node.appendChild(el('div', {class:'excerpt-body'}, frag.body));
+  }
+  return node;
+}
+
+function renderFragmentsNode(filtered, wrap){
+  const hasNodeSel = !!(state.shizhiPath || state.xingshiPath);
+  if (!hasNodeSel){
+    $('fragCnt').textContent = filtered.length + ' / ' + data.fragments.length;
+    if (!filtered.length){
+      wrap.appendChild(el('div', {class:'empty-msg'}, '当前筛选下没有命中的能力'));
+      return;
+    }
+    for (const f of filtered) wrap.appendChild(makeFragBodyRow(f));
+    return;
+  }
+  const rows = [];
+  for (const f of filtered){
+    if (state.shizhiPath){
+      const allowed = new Set(shiDesc.get(state.shizhiPath) || [state.shizhiPath]);
+      for (const e of (f.apply_shizhi || [])){
+        if (allowed.has(e.category_path)) rows.push({frag:f, side:'实质', entry:e});
+      }
+    }
+    if (state.xingshiPath){
+      const allowed = new Set(xingDesc.get(state.xingshiPath) || [state.xingshiPath]);
+      for (const e of (f.apply_xingshi || [])){
+        if (allowed.has(e.category_path)) rows.push({frag:f, side:'形式', entry:e});
+      }
+    }
+  }
+  $('fragCnt').textContent = rows.length + ' 条 · ' + filtered.length + ' 能力';
+  if (!rows.length){
+    wrap.appendChild(el('div', {class:'empty-msg'}, '当前筛选下没有命中的 excerpt'));
+    return;
+  }
+  for (const r of rows) wrap.appendChild(makeExcerptRow(r.frag, r.side, r.entry));
+}
+
+function renderFragmentsPatternRaw(filtered, wrap){
+  $('fragCnt').textContent = filtered.length + ' / ' + data.fragments.length;
+  if (!filtered.length){
+    wrap.appendChild(el('div', {class:'empty-msg'}, '当前筛选下没有命中的能力'));
+    return;
+  }
+  for (const f of filtered) wrap.appendChild(makeFragBodyRow(f));
+}
+
+function renderFragmentsPatternStructured(filtered, wrap){
+  $('fragCnt').textContent = filtered.length + ' / ' + data.fragments.length;
+  const sel = (state.itemsetIdx != null) ? data.itemsets[state.itemsetIdx] : null;
+  if (!sel){
+    wrap.appendChild(el('div', {class:'empty-msg'}, '请先在第二列选择一个项集'));
+    return;
+  }
+  if (!filtered.length){
+    wrap.appendChild(el('div', {class:'empty-msg'}, '当前筛选下没有命中的能力'));
+    return;
+  }
+  const itemSet = new Set(sel.items);
+  for (const f of filtered){
+    const k = fragKey(f);
+    const excerpts = [];
+    const seen = new Set();
+    const addFrom = (list) => {
+      for (const e of (list || [])){
+        if (!itemSet.has(e.category_path)) continue;
+        if (!e.body_excerpt) continue;
+        if (seen.has(e.body_excerpt)) continue;
+        seen.add(e.body_excerpt);
+        excerpts.push(e.body_excerpt);
+      }
+    };
+    addFrom(f.apply_shizhi);
+    addFrom(f.apply_xingshi);
+
+    const head = el('div', {class:'frag-head'},
+      el('span', {class:'case-badge'}, '案例'+f.case_index),
+      el('span', {class:'frag-badge'}, f.fragment_id),
+      el('span', {class:'act-badge'}, f.action)
+    );
+    const node = el('div', {
+      class: 'frag' + (state.fragKey === k ? ' active' : ''),
+      onClick: () => { state.fragKey = (state.fragKey === k) ? null : k; renderFragments(); renderDetail(); }
+    });
+    node.appendChild(head);
+    if (excerpts.length){
+      const wrapEx = el('div', {class:'frag-excerpts'});
+      for (const ex of excerpts) wrapEx.appendChild(el('div', {class:'ex'}, ex));
+      node.appendChild(wrapEx);
+    } else {
+      node.appendChild(el('div', {class:'frag-excerpts empty'}, '(本能力无 excerpt 命中该项集)'));
+    }
+    wrap.appendChild(node);
+  }
+}
+
+function renderFragments(){
+  const filtered = applyFilters(null);
+  const wrap = $('fragList'); wrap.innerHTML = '';
+  $('fragColTitle').textContent = '④ ' + (state.scopeMode === 'pattern' ? '分子能力' : '原子能力');
+  $('fvTabs').style.display = (state.scopeMode === 'pattern') ? 'flex' : 'none';
+  if (state.scopeMode === 'pattern'){
+    $('fvRaw').classList.toggle('active', state.patternView === 'raw');
+    $('fvStructured').classList.toggle('active', state.patternView === 'structured');
+  }
+  if (state.scopeMode === 'node'){
+    renderFragmentsNode(filtered, wrap);
+  } else {
+    if (state.patternView === 'structured') renderFragmentsPatternStructured(filtered, wrap);
+    else renderFragmentsPatternRaw(filtered, wrap);
+  }
+}
+
+function renderCaseDetail(wrap, caseIdx){
+  const c = casesByIndex[caseIdx];
+  if (!c) return;
+  const det = el('details', {class:'case-detail'});
+  const sumLabel = '📄 源: ' + (c.title || '案例 ' + caseIdx);
+  det.appendChild(el('summary', null, sumLabel));
+  const content = el('div', {class:'case-content'});
+
+  if (c.author) {
+    content.appendChild(el('div', {class:'case-author'}, '作者:' + c.author));
+  }
+  if (c.feedback && (c.feedback.like_count || c.feedback.comment_count || c.feedback.collect_count || c.feedback.share_count)){
+    const fb = c.feedback;
+    const row = el('div', {class:'case-feedback'});
+    if (fb.like_count != null) row.appendChild(el('span', {class:'fb'}, '👍 ' + fb.like_count));
+    if (fb.comment_count != null) row.appendChild(el('span', {class:'fb'}, '💬 ' + fb.comment_count));
+    if (fb.collect_count != null) row.appendChild(el('span', {class:'fb'}, '⭐ ' + fb.collect_count));
+    if (fb.share_count != null) row.appendChild(el('span', {class:'fb'}, '↗ ' + fb.share_count));
+    content.appendChild(row);
+  }
+
+  const imgs = (c.images && c.images.length) ? c.images : (c.cover ? [c.cover] : []);
+  if (imgs.length){
+    const wrapImg = el('div', {class:'case-images'});
+    for (const src of imgs) wrapImg.appendChild(el('img', {src, loading:'lazy'}));
+    content.appendChild(wrapImg);
+  }
+  if (c.body){
+    content.appendChild(el('div', {class:'case-body'}, c.body));
+  }
+  if (c.url){
+    content.appendChild(el('a', {href:c.url, target:'_blank', rel:'noopener', class:'case-link'}, '🔗 访问原始链接'));
+  }
+  det.appendChild(content);
+  wrap.appendChild(det);
+}
+
+function renderDetail(){
+  const wrap = $('detailBody'); wrap.innerHTML = '';
+  const f = data.fragments.find(x => fragKey(x) === state.fragKey);
+  if (!f){ wrap.appendChild(el('div', {class:'detail-empty'}, '点击左侧能力查看详情')); return; }
+
+  wrap.appendChild(el('h2', null,
+    el('span', {class:'case-badge'}, '案例 '+f.case_index),
+    el('span', {class:'frag-badge'}, f.fragment_id),
+    el('span', {class:'act-badge'}, f.action),
+    f.workflow_step_ref && f.workflow_step_ref.step_id ? el('span', {class:'pill'}, 'step '+f.workflow_step_ref.step_id) : null
+  ));
+
+  renderCaseDetail(wrap, f.case_index);
+
+  const ioSec = el('div', {class:'detail-section'}, el('h3', null, 'I/O 模态'));
+  ioSec.appendChild(el('div', {class:'frag-sig', style:'font-size:13px;margin-bottom:6px'}, f.modality_signature));
+  const ioRow = (lbl, arr, kind) => {
+    const row = el('div', {class:'io-row'}, el('span', {class:'lbl'}, lbl));
+    if (!arr || !arr.length){ row.appendChild(el('span', {style:'color:var(--muted)'}, '(无)')); return row; }
+    for (const x of arr){
+      const isCfg = x.modality === '模型' || x.modality === '参数';
+      const txt = (x.description||'') + (x.modality?'['+x.modality+']':'') + (x.relation?' '+x.relation:'');
+      row.appendChild(el('span', {class:'pill ' + (isCfg ? 'cfg' : kind)}, txt));
+    }
+    return row;
+  };
+  ioSec.appendChild(ioRow('IN', f.inputs, 'in'));
+  ioSec.appendChild(ioRow('OUT', f.outputs, 'out'));
+  wrap.appendChild(ioSec);
+
+  if (f.body){
+    wrap.appendChild(el('div', {class:'detail-section'},
+      el('h3', null, 'Body'),
+      el('div', {class:'body-text'}, f.body)
+    ));
+  }
+
+  let hotPaths = new Set();
+  if (state.scopeMode === 'pattern' && state.itemsetIdx != null){
+    const sel = data.itemsets[state.itemsetIdx];
+    if (sel) for (const p of sel.items) hotPaths.add(p);
+  }
+  const renderApply = (label, arr) => {
+    const sec = el('div', {class:'detail-section'}, el('h3', null, 'Apply to · ' + label));
+    if (!arr || !arr.length){ sec.appendChild(el('div', {class:'empty-msg', style:'padding:8px'}, '无')); return sec; }
+    for (const e of arr){
+      let isHighlighted = false;
+      if (state.scopeMode === 'node'){
+        isHighlighted =
+          (label === '实质' && state.shizhiPath && (shiDesc.get(state.shizhiPath)||[]).includes(e.category_path)) ||
+          (label === '形式' && state.xingshiPath && (xingDesc.get(state.xingshiPath)||[]).includes(e.category_path));
+      } else {
+        isHighlighted = hotPaths.has(e.category_path);
+      }
+      const isSuggest = e.source === 'suggest';
+      const pl = el('div', {class:'pathline' + (isSuggest ? ' suggest' : ''), style: isHighlighted ? 'color:#ca8a04;font-weight:600' : ''},
+        e.category_path + (isSuggest ? ' ✦' : ''));
+      sec.appendChild(pl);
+      if (e.body_excerpt){
+        sec.appendChild(el('div', {class:'excerpt-line'}, el('span', {class:'em'}, e.body_excerpt)));
+      }
+      if (e.body_excerpt_note){
+        sec.appendChild(el('div', {class:'rationale'}, '— ' + e.body_excerpt_note));
+      }
+      if (e.rationale){
+        sec.appendChild(el('div', {class:'rationale'}, '· ' + e.rationale));
+      }
+    }
+    return sec;
+  };
+  wrap.appendChild(renderApply('实质', f.apply_shizhi));
+  wrap.appendChild(renderApply('形式', f.apply_xingshi));
+
+  if (f.effects && f.effects.length){
+    const sec = el('div', {class:'detail-section'}, el('h3', null, 'Effects'));
+    f.effects.forEach((e,i)=>{
+      const card = el('div', {class:'effect-card'});
+      card.appendChild(el('div', {class:'effect-stmt'}, '#'+i+' ' + (e.statement || '-')));
+      if (e.criteria) card.appendChild(el('div', {class:'effect-meta'}, '判定标准:'+e.criteria));
+      if (e.judge_method) card.appendChild(el('div', {class:'effect-meta'}, '判定方式:'+e.judge_method));
+      if (e.negative_examples && e.negative_examples.length){
+        const ne = el('div', {class:'effect-meta'}, '反例:');
+        for (const n of e.negative_examples) ne.appendChild(el('div', {style:'margin-left:8px'}, '· '+n));
+        card.appendChild(ne);
+      }
+      sec.appendChild(card);
+    });
+    wrap.appendChild(sec);
+  }
+
+  const miscRows = [];
+  if (f.tools && f.tools.length) miscRows.push(['tools', f.tools.join(', ')]);
+  if (f.artifact_type) miscRows.push(['artifact_type', f.artifact_type]);
+  if (f.control_target && f.control_target.length) miscRows.push(['control_target', f.control_target.join(', ')]);
+  if (f.is_alternative_to && f.is_alternative_to.length) miscRows.push(['alt_to', f.is_alternative_to.join(', ')]);
+  if (miscRows.length){
+    const sec = el('div', {class:'detail-section'}, el('h3', null, '其他'));
+    for (const [k2,v2] of miscRows){
+      const row = el('div', {style:'font-size:11px;margin-bottom:3px'},
+        el('span', {style:'color:var(--muted);margin-right:8px'}, k2),
+        el('span', null, v2)
+      );
+      sec.appendChild(row);
+    }
+    wrap.appendChild(sec);
+  }
+
+  wrap.appendChild(el('details', null,
+    el('summary', null, '查看原始能力 JSON'),
+    el('pre', {class:'json-raw'}, JSON.stringify(f, null, 2))
+  ));
+}
+
+function renderChips(){
+  const c = $('chips'); c.innerHTML = '';
+  let any = false;
+  const mk = (lbl, val, onX) => {
+    any = true;
+    c.appendChild(el('span', {class:'filter-chip'},
+      el('span', {class:'lbl'}, lbl),
+      el('span', null, val),
+      el('span', {class:'x', onClick: () => { onX(); renderAll(); }}, '×')
+    ));
+  };
+  if (state.action) mk('动作', state.action, () => { state.action = null; });
+  if (state.scopeMode === 'node'){
+    if (state.shizhiPath) mk('实质', state.shizhiPath.split('/').pop() || state.shizhiPath, () => { state.shizhiPath = null; });
+    if (state.xingshiPath) mk('形式', state.xingshiPath.split('/').pop() || state.xingshiPath, () => { state.xingshiPath = null; });
+  } else if (state.scopeMode === 'pattern' && state.itemsetIdx != null){
+    const sel = data.itemsets[state.itemsetIdx];
+    const desc = sel.items.map(p => p.split('/').pop()).join(' + ');
+    mk('项集 k='+sel.size, desc, () => { state.itemsetIdx = null; });
+  }
+  if (state.modSig) mk('模态', state.modSig, () => { state.modSig = null; });
+  $('clearAll').classList.toggle('active', any);
+}
+
+function renderStats(){}
+
+function renderAll(){
+  renderActions();
+  renderScope();
+  renderModalities();
+  renderFragments();
+  renderDetail();
+  renderChips();
+}
+
+function init(){
+  for (const c of (data.cases || [])) casesByIndex[c.index] = c;
+
+  SIDE_MODS = (() => {
+    const inSet = new Set(), outSet = new Set();
+    for (const m of data.modalities){
+      const p = sigParts(m.sig);
+      for (const x of p.in) inSet.add(x);
+      for (const x of p.out) outSet.add(x);
+    }
+    return { in: [...inSet].sort(), out: [...outSet].sort() };
+  })();
+
+  shiDesc = buildDescMap(data.subtree.shizhi || []);
+  xingDesc = buildDescMap(data.subtree.xingshi || []);
+  for (const f of data.fragments) fragPaths.set(fragKey(f), fragPathSet(f));
+
+  $('loading').style.display = 'none';
+  $('appRoot').style.display = '';
+
+  $('clearAll').addEventListener('click', () => {
+    state.action = null; state.shizhiPath = null; state.xingshiPath = null;
+    state.itemsetIdx = null; state.modSig = null;
+    state.modInFilter.clear(); state.modOutFilter.clear();
+    renderAll();
+  });
+  $('scopeClear').addEventListener('click', () => {
+    if (state.scopeMode === 'node'){ state.shizhiPath = null; state.xingshiPath = null; }
+    else { state.itemsetIdx = null; }
+    renderAll();
+  });
+  $('tabNode').addEventListener('click', () => { state.scopeMode = 'node'; renderAll(); });
+  $('tabPattern').addEventListener('click', () => { state.scopeMode = 'pattern'; renderAll(); });
+  $('fvRaw').addEventListener('click', () => { state.patternView = 'raw'; renderFragments(); });
+  $('fvStructured').addEventListener('click', () => { state.patternView = 'structured'; renderFragments(); });
+  $('modClear').addEventListener('click', () => {
+    state.modSig = null; state.modInFilter.clear(); state.modOutFilter.clear();
+    renderAll();
+  });
+
+  renderStats();
+  renderAll();
+}
+
+fetch('/api/viz/data/capability')
+  .then(r => {
+    if (!r.ok) throw new Error('fetch failed: ' + r.status);
+    return r.json();
+  })
+  .then(d => { data = d; init(); })
+  .catch(err => {
+    $('loading').textContent = '加载失败:' + err.message + '。请先上传数据:POST /api/viz/data/capability';
+  });
+</script>
+</body>
+</html>

+ 878 - 0
knowhub/frontend/public/viz_workflow.html

@@ -0,0 +1,878 @@
+<!doctype html>
+<html lang="zh">
+<head>
+<meta charset="utf-8">
+<title>工序 · 五层筛选</title>
+<style>
+  :root{
+    --bg:#f8fafc; --panel:#ffffff; --panel2:#f1f5f9; --border:#e2e8f0;
+    --fg:#0f172a; --muted:#64748b; --accent:#3b82f6; --accent2:#8b5cf6;
+    --tag-bg:#e2e8f0; --tag-bg-active:#3b82f6; --tag-fg-active:#fff;
+    --code:#f1f5f9; --warn:#eab308; --suggest:#8b5cf6; --inferred:#94a3b8;
+    --shi-bg:#dbeafe; --shi-fg:#1e40af;
+    --xing-bg:#ede9fe; --xing-fg:#6d28d9;
+    --both-bg:#fef3c7; --both-fg:#92400e;
+  }
+  *{box-sizing:border-box}
+  html,body{margin:0;height:100%;background:var(--bg);color:var(--fg);
+    font:13px/1.55 -apple-system,BlinkMacSystemFont,"PingFang SC","Helvetica Neue",sans-serif}
+  header{display:flex;align-items:center;gap:12px;padding:8px 14px;
+    border-bottom:1px solid var(--border);background:var(--panel);
+    position:sticky;top:0;z-index:5;flex-wrap:wrap}
+  h1{font-size:14px;margin:0;font-weight:600}
+  .stats{color:var(--muted);font-size:11px}
+  .clear-all{margin-left:auto;font-size:11px;color:var(--muted);cursor:pointer;
+    padding:4px 10px;border:1px solid var(--border);border-radius:4px;background:transparent}
+  .clear-all:hover{color:var(--fg);border-color:var(--accent)}
+  .clear-all.active{color:var(--warn);border-color:var(--warn)}
+
+  main{display:grid;grid-template-columns:200px 260px 220px 380px 1fr;
+    height:calc(100vh - 41px);min-width:1520px}
+  section{overflow:auto;border-right:1px solid var(--border);padding:8px 10px}
+  section:last-child{border-right:none}
+  section.fac1{background:var(--panel)}
+  section.fac2{background:#fafbfd}
+  section.fac3{background:var(--panel)}
+  section.fac4{background:#fafbfd}
+  section.detail{background:var(--panel)}
+
+  .col-title{font-size:10px;text-transform:uppercase;letter-spacing:.6px;
+    color:var(--muted);margin:2px 4px 8px;display:flex;justify-content:space-between;align-items:baseline}
+  .col-count{font-size:10px;color:var(--muted)}
+  .col-clear{font-size:10px;color:var(--accent);cursor:pointer;display:none}
+  .col-clear.show{display:inline}
+
+  .item{padding:6px 8px;border-radius:5px;cursor:pointer;margin-bottom:3px;
+    border:1px solid transparent;display:flex;justify-content:space-between;align-items:center;gap:6px}
+  .item:hover:not(.disabled):not(.active){background:var(--panel2)}
+  .item.active{background:var(--tag-bg-active);color:var(--tag-fg-active);border-color:var(--tag-bg-active)}
+  .item.disabled{opacity:.42;cursor:not-allowed}
+  .item-name{flex:1;min-width:0;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+  .item-count{font-size:10px;background:var(--bg);padding:1px 5px;border-radius:8px;
+    color:var(--muted);min-width:20px;text-align:center;flex-shrink:0}
+  .item.active .item-count{background:rgba(255,255,255,.3);color:#fff}
+  .item-def{display:block;font-size:10px;color:var(--muted);margin-top:2px;line-height:1.4}
+  .item.active .item-def{color:rgba(255,255,255,.85)}
+  .item-stack{flex:1;min-width:0}
+
+  /* col-1 action tabs */
+  .act-tabs{display:flex;gap:4px;margin:0 0 8px;border-bottom:1px solid var(--border);padding-bottom:5px}
+  .act-tab{padding:4px 10px;border-radius:5px 5px 0 0;cursor:pointer;
+    font-size:12px;color:var(--muted);background:transparent;
+    border:1px solid transparent;border-bottom:none;user-select:none}
+  .act-tab.active{color:var(--fg);background:var(--panel2);border-color:var(--border)}
+  .act-tab:hover:not(.active){color:var(--fg)}
+  .act-pane{display:none}
+  .act-pane.active{display:block}
+  .act-meta{font-size:10px;color:var(--muted);margin:0 4px 8px;line-height:1.4}
+
+  /* phase signature list */
+  .phasesig{padding:7px 9px;border-radius:5px;cursor:pointer;margin-bottom:4px;
+    border:1px solid var(--border);background:var(--panel);font-size:11px;line-height:1.4}
+  .phasesig:hover:not(.disabled):not(.active){border-color:var(--accent)}
+  .phasesig.active{border-color:var(--accent);background:#eff6ff;
+    box-shadow:0 0 0 1px var(--accent) inset}
+  .phasesig.disabled{opacity:.42;cursor:not-allowed}
+  .phasesig-count{float:right;font-size:10px;color:var(--muted);
+    background:var(--bg);padding:1px 5px;border-radius:8px}
+  .phasesig.active .phasesig-count{background:rgba(255,255,255,.3);color:var(--accent)}
+
+  /* tree */
+  .tnode{margin-left:0}
+  .tnode .tnode{margin-left:12px;border-left:1px dashed var(--border);padding-left:6px}
+  .tline{display:flex;align-items:center;gap:4px;padding:3px 4px;border-radius:4px;
+    cursor:pointer;font-size:12px;line-height:1.35}
+  .tline:hover:not(.disabled):not(.active){background:var(--panel2)}
+  .tline.active{background:var(--tag-bg-active);color:var(--tag-fg-active)}
+  .tline.disabled{opacity:.42;cursor:not-allowed}
+  .tline.suggested .tname{color:var(--suggest);font-style:italic}
+  .tline.suggested.active .tname{color:#fff}
+  .tline.inferred .tname{color:var(--inferred);font-style:italic}
+  .tline.inferred.active .tname{color:#fff}
+  .tcaret{width:12px;display:inline-block;text-align:center;color:var(--muted);
+    cursor:pointer;user-select:none;flex-shrink:0;font-size:10px}
+  .tcaret.invis{visibility:hidden}
+  .tname{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+  .tcount{font-size:10px;color:var(--muted);background:var(--bg);padding:1px 5px;border-radius:8px;flex-shrink:0}
+  .tline.active .tcount{background:rgba(255,255,255,.3);color:#fff}
+  .tcollapsed > .tnode{display:none}
+  .facet-title{margin-top:10px;padding:4px 6px;font-size:11px;color:var(--muted);
+    font-weight:600;border-bottom:1px solid var(--border);text-transform:uppercase;letter-spacing:.5px}
+  .facet-title:first-child{margin-top:0}
+
+  /* workflow card */
+  .wf{padding:9px 10px;border-radius:6px;cursor:pointer;margin-bottom:6px;
+    border:1px solid var(--border);background:var(--panel)}
+  .wf:hover:not(.active){background:var(--panel2)}
+  .wf.active{border-color:var(--accent);background:#eff6ff;
+    box-shadow:0 0 0 1px var(--accent) inset}
+  .wf-head{display:flex;align-items:center;gap:6px;margin-bottom:5px;flex-wrap:wrap}
+  .case-badge{background:var(--accent);color:#fff;padding:1px 5px;border-radius:3px;
+    font-weight:600;font-size:10px}
+  .wf-badge{background:var(--accent2);color:#fff;padding:1px 5px;border-radius:3px;
+    font-weight:600;font-size:10px}
+  .wf-meta{font-size:10px;color:var(--muted);margin-left:auto}
+  .wf-title{font-size:12px;color:#334155;margin:3px 0;line-height:1.4;
+    display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
+  .wf-sig{font-size:10px;color:var(--muted);margin:3px 0;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
+  .wf-seq{display:flex;flex-wrap:wrap;gap:3px;margin-top:5px;align-items:center}
+  .vchip{font-size:10px;padding:2px 6px;border-radius:3px;font-weight:600;line-height:1.3;
+    border:1px solid transparent;white-space:nowrap}
+  .vsep{color:var(--muted);font-size:9px;line-height:1.3}
+
+  /* modality side filter */
+  .mod-filter{margin:0 4px 8px;padding:6px 6px 4px;background:var(--code);
+    border:1px solid var(--border);border-radius:5px}
+  .mod-filter-row{display:flex;align-items:center;gap:6px;margin-bottom:4px}
+  .mod-filter-row:last-child{margin-bottom:0}
+  .mod-filter-lbl{font-size:10px;color:var(--muted);min-width:42px;flex-shrink:0}
+  .mod-chips{display:flex;gap:4px;flex-wrap:wrap}
+  .mod-chip{font-size:11px;padding:1px 7px;background:var(--tag-bg);border-radius:8px;
+    cursor:pointer;color:var(--fg);user-select:none;border:1px solid transparent}
+  .mod-chip:hover:not(.disabled):not(.active){background:var(--panel2);border-color:var(--border)}
+  .mod-chip.active{background:var(--tag-bg-active);color:var(--tag-fg-active)}
+  .mod-chip.disabled{opacity:.42;cursor:not-allowed}
+
+  /* detail */
+  .detail-empty{color:var(--muted);text-align:center;padding:60px 20px;font-size:13px}
+  .detail h2{font-size:14px;margin:0 0 8px;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
+  .detail-section{margin:12px 0}
+  .detail-section h3{font-size:10px;color:var(--muted);text-transform:uppercase;
+    letter-spacing:.6px;margin:0 0 6px;font-weight:600}
+  .body-text{font-size:12px;line-height:1.6;color:#334155;background:var(--code);
+    padding:8px 10px;border-radius:5px;white-space:pre-wrap}
+  .pill{display:inline-block;padding:1px 6px;background:var(--tag-bg);border-radius:8px;
+    font-size:11px;margin-right:3px;margin-bottom:3px}
+  .pill.in{background:var(--shi-bg);color:var(--shi-fg)}
+  .pill.out{background:var(--xing-bg);color:var(--xing-fg)}
+  .pill.cfg{background:var(--both-bg);color:var(--both-fg)}
+  .io-row{margin-bottom:4px;font-size:11px}
+  .io-row .lbl{color:var(--muted);font-size:10px;margin-right:6px;text-transform:uppercase}
+  .pathline{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;
+    color:var(--accent);margin-bottom:2px;word-break:break-all}
+  .pathline.suggest{color:var(--suggest)}
+  .rationale{color:var(--muted);font-size:11px;margin-bottom:3px;margin-left:4px;line-height:1.45}
+  .excerpt-line{color:var(--muted);font-size:11px;margin-left:4px;line-height:1.45;margin-bottom:6px;
+    border-left:2px solid var(--border);padding-left:6px}
+  .excerpt-line .em{color:#334155}
+  .json-raw{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:10px;
+    background:var(--code);padding:8px;border-radius:5px;white-space:pre-wrap;
+    color:#334155;max-height:240px;overflow:auto}
+  details summary{cursor:pointer;color:var(--muted);font-size:10px;margin:6px 0;user-select:none}
+  details summary:hover{color:var(--fg)}
+
+  /* case-detail (original-post) section */
+  .case-detail{margin:8px 0;padding-bottom:8px;border-bottom:1px solid var(--border)}
+  .case-detail summary{font-weight:bold;color:var(--accent);padding:6px 8px;
+    background:var(--panel2);border-radius:4px;font-size:11px;cursor:pointer;outline:none;margin:0}
+  .case-content{padding:10px;background:var(--code);border-radius:5px;
+    margin-top:4px;border:1px solid var(--border)}
+  .case-images{display:flex;gap:8px;overflow-x:auto;margin-bottom:8px;padding-bottom:4px}
+  .case-images img{height:160px;border-radius:5px;object-fit:contain;
+    background:var(--panel2);border:1px solid var(--border)}
+  .case-body{font-size:12px;line-height:1.6;color:var(--fg);white-space:pre-wrap;
+    margin-bottom:10px;max-height:250px;overflow-y:auto;padding-right:4px}
+  .case-link{font-size:11px;color:var(--accent);display:inline-block;
+    background:var(--panel2);padding:4px 8px;border-radius:4px;text-decoration:none}
+  .case-feedback{font-size:11px;color:var(--muted);margin-bottom:6px;display:flex;gap:12px;flex-wrap:wrap}
+  .case-feedback .fb{display:inline-flex;gap:3px;align-items:center}
+  .case-author{font-size:11px;color:var(--muted);margin-bottom:6px}
+
+  /* step section */
+  .step-sec{margin:10px 0;border:1px solid var(--border);border-radius:6px;background:var(--panel)}
+  .step-head{display:flex;align-items:center;gap:8px;padding:6px 10px;
+    border-bottom:1px solid var(--border);font-size:11px;background:var(--panel2)}
+  .step-id{background:var(--accent);color:#fff;padding:1px 6px;border-radius:3px;font-weight:600;font-size:10px}
+  .step-phase{padding:1px 6px;border-radius:3px;font-size:10px;font-weight:600}
+  .step-order{color:var(--muted);font-size:10px}
+  .step-altnote{margin-left:auto;font-size:10px;color:var(--warn)}
+  .cap{padding:8px 10px;border-top:1px dashed var(--border)}
+  .cap:first-child{border-top:none}
+  .cap-head{display:flex;align-items:center;gap:6px;margin-bottom:4px;flex-wrap:wrap}
+  .cap-id{background:var(--accent2);color:#fff;padding:1px 5px;border-radius:3px;font-size:10px;font-weight:600}
+  .cap-act{background:var(--warn);color:#fff;padding:1px 5px;border-radius:3px;font-size:10px;font-weight:600}
+  .cap-sig{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:10px;color:var(--muted)}
+  .cap-body{font-size:12px;line-height:1.55;color:#334155;background:var(--code);
+    padding:6px 9px;border-radius:4px;white-space:pre-wrap;margin:4px 0}
+  .cap-meta{font-size:10px;color:var(--muted);margin-top:3px}
+
+  .empty-msg{color:var(--muted);text-align:center;padding:30px 10px;font-size:11px;font-style:italic}
+
+  .filter-chip{font-size:11px;padding:3px 8px;background:var(--tag-bg);border-radius:10px;
+    color:var(--fg);display:inline-flex;align-items:center;gap:6px;margin-right:4px}
+  .filter-chip .x{color:var(--muted);cursor:pointer;font-weight:bold}
+  .filter-chip .x:hover{color:var(--warn)}
+  .filter-chip .lbl{color:var(--muted);font-size:9px;text-transform:uppercase}
+
+  .loading{display:flex;align-items:center;justify-content:center;
+    height:100vh;font-size:14px;color:var(--muted)}
+</style>
+</head>
+<body>
+
+<div id="loading" class="loading">加载工序数据中…</div>
+
+<div id="appRoot" style="display:none">
+<header>
+  <h1>工序</h1>
+  <span id="chips"></span>
+  <button class="clear-all" id="clearAll">清除全部筛选</button>
+</header>
+
+<main>
+  <section class="fac1" id="actCol">
+    <div class="col-title">
+      <span>① 动作</span>
+      <span class="col-clear" id="actClear" style="margin-left:auto">清除</span>
+    </div>
+    <div class="act-tabs">
+      <div class="act-tab" data-mode="multiset" id="tabMulti">动作</div>
+      <div class="act-tab" data-mode="phase" id="tabPhase">结构</div>
+    </div>
+    <div class="act-pane" id="paneMulti">
+      <div class="act-meta">多选 · 工序须包含所有选中动作(AND)</div>
+      <div id="actList"></div>
+    </div>
+    <div class="act-pane" id="panePhase">
+      <div class="act-meta">单选 · 完整 phase 序列</div>
+      <div id="phaseList"></div>
+    </div>
+  </section>
+
+  <section class="fac2" id="scopeCol">
+    <div class="col-title">
+      <span>② 作用域</span>
+      <span class="col-count" id="scopeStat"></span>
+      <span class="col-clear" id="scopeClear" style="margin-left:auto;align-self:center">清除</span>
+    </div>
+    <div class="facet-title">实质</div>
+    <div id="shizhiTree"></div>
+    <div class="facet-title">形式</div>
+    <div id="xingshiTree"></div>
+  </section>
+
+  <section class="fac3" id="modCol">
+    <div class="col-title"><span>③ 输入 → 输出模态</span><span class="col-clear" id="modClear">清除</span></div>
+    <div class="mod-filter">
+      <div class="mod-filter-row">
+        <span class="mod-filter-lbl">输入含</span>
+        <span class="mod-chips" id="modInChips"></span>
+      </div>
+      <div class="mod-filter-row">
+        <span class="mod-filter-lbl">输出含</span>
+        <span class="mod-chips" id="modOutChips"></span>
+      </div>
+    </div>
+    <div id="modList"></div>
+  </section>
+
+  <section class="fac4" id="wfCol">
+    <div class="col-title"><span>④ 工序</span><span class="col-count" id="wfCnt"></span></div>
+    <div id="wfList"></div>
+  </section>
+
+  <section class="detail" id="detailCol">
+    <div class="col-title"><span>⑤ 工序详情</span></div>
+    <div id="detailBody"><div class="detail-empty">点击左侧工序查看详情</div></div>
+  </section>
+</main>
+</div>
+
+<script>
+let data = null;
+let casesByIndex = {};
+let SIDE_MODS = null;
+let shiDesc = null;
+let xingDesc = null;
+const fragByKey = new Map();
+let PHASE_COLORS = {};
+let PHASE_FG = {};
+
+const state = {
+  actionMode: 'multiset',
+  actionsSelected: new Set(),
+  phaseSig: null,
+  shizhiPath: null,
+  xingshiPath: null,
+  modSig: null,
+  modInFilter: new Set(),
+  modOutFilter: new Set(),
+  wfKey: null,
+  collapsed: new Set(),
+};
+
+function $(id){return document.getElementById(id)}
+function el(tag, attrs, ...kids){
+  const e = document.createElement(tag);
+  if (attrs) for (const k in attrs){
+    if (k === 'class') e.className = attrs[k];
+    else if (k === 'onClick') e.addEventListener('click', attrs[k]);
+    else if (k.startsWith('data-')) e.setAttribute(k, attrs[k]);
+    else if (k === 'title') e.title = attrs[k];
+    else if (k === 'style') e.setAttribute('style', attrs[k]);
+    else e[k] = attrs[k];
+  }
+  for (const kid of kids){
+    if (kid == null || kid === false) continue;
+    if (typeof kid === 'string') e.appendChild(document.createTextNode(kid));
+    else e.appendChild(kid);
+  }
+  return e;
+}
+
+function sigParts(sig){
+  const [inS, outS] = sig.split(' → ');
+  const parse = s => new Set(s.split('+').filter(x => x && x !== '∅'));
+  return { in: parse(inS), out: parse(outS) };
+}
+
+function buildDescMap(roots){
+  const map = new Map();
+  function walk(node){
+    const all = [node.path];
+    for (const c of node.children || []){
+      const sub = walk(c);
+      for (const p of sub) all.push(p);
+    }
+    map.set(node.path, all);
+    return all;
+  }
+  for (const r of roots) walk(r);
+  return map;
+}
+
+function workflowMatchesPath(w, key, selPath, descMap){
+  if (!selPath) return true;
+  const allowed = new Set(descMap.get(selPath) || [selPath]);
+  const list = (key === 'shizhi') ? w.apply_shizhi_paths : w.apply_xingshi_paths;
+  for (const p of (list || [])) if (allowed.has(p)) return true;
+  return false;
+}
+
+function actionFilterActive(){
+  if (state.actionMode === 'multiset') return state.actionsSelected.size > 0;
+  return !!state.phaseSig;
+}
+function scopeFilterActive(){
+  return !!(state.shizhiPath || state.xingshiPath);
+}
+
+function applyFilters(except){
+  return data.workflows.filter(w => {
+    if (except !== 'action' && actionFilterActive()){
+      if (state.actionMode === 'multiset'){
+        const set = new Set(w.actions_set);
+        for (const v of state.actionsSelected) if (!set.has(v)) return false;
+      } else {
+        if (w.phase_signature !== state.phaseSig) return false;
+      }
+    }
+    if (except !== 'scope' && scopeFilterActive()){
+      if (state.shizhiPath && !workflowMatchesPath(w, 'shizhi', state.shizhiPath, shiDesc)) return false;
+      if (state.xingshiPath && !workflowMatchesPath(w, 'xingshi', state.xingshiPath, xingDesc)) return false;
+    }
+    if (except !== 'mod' && state.modSig && w.io_signature !== state.modSig) return false;
+    return true;
+  });
+}
+
+function renderActionMultiset(){
+  const baseFiltered = applyFilters('action');
+  const counts = {};
+  for (const w of baseFiltered) for (const v of w.actions_set) counts[v] = (counts[v]||0)+1;
+  const wrap = $('actList'); wrap.innerHTML = '';
+  for (const a of data.actions){
+    const c = counts[a.verb] || 0;
+    const isActive = state.actionsSelected.has(a.verb);
+    const node = el('div', {
+      class: 'item' + (isActive ? ' active' : '') + (c === 0 && !isActive ? ' disabled' : ''),
+      onClick: () => {
+        if (c === 0 && !isActive) return;
+        if (state.actionsSelected.has(a.verb)) state.actionsSelected.delete(a.verb);
+        else state.actionsSelected.add(a.verb);
+        renderAll();
+      }
+    });
+    const stack = el('div', {class:'item-stack'},
+      el('div', {class:'item-name'}, a.verb),
+      a.definition ? el('div', {class:'item-def'}, a.definition) : null);
+    node.appendChild(stack);
+    node.appendChild(el('span', {class:'item-count'}, String(c)));
+    wrap.appendChild(node);
+  }
+}
+
+function renderActionPhase(){
+  const baseFiltered = applyFilters('action');
+  const counts = {};
+  for (const w of baseFiltered) counts[w.phase_signature] = (counts[w.phase_signature]||0)+1;
+  const wrap = $('phaseList'); wrap.innerHTML = '';
+  const sigs = [...data.phaseSignatures].sort((a, b) => (counts[b]||0) - (counts[a]||0) || a.localeCompare(b));
+  for (const sig of sigs){
+    const c = counts[sig] || 0;
+    const isActive = state.phaseSig === sig;
+    const node = el('div', {
+      class: 'phasesig' + (isActive ? ' active' : '') + (c === 0 && !isActive ? ' disabled' : ''),
+      onClick: () => {
+        if (c === 0 && !isActive) return;
+        state.phaseSig = isActive ? null : sig;
+        renderAll();
+      }
+    });
+    node.appendChild(el('span', {class:'phasesig-count'}, String(c)));
+    node.appendChild(document.createTextNode(sig));
+    wrap.appendChild(node);
+  }
+}
+
+function renderActions(){
+  $('tabMulti').classList.toggle('active', state.actionMode === 'multiset');
+  $('tabPhase').classList.toggle('active', state.actionMode === 'phase');
+  $('paneMulti').classList.toggle('active', state.actionMode === 'multiset');
+  $('panePhase').classList.toggle('active', state.actionMode === 'phase');
+  $('actClear').classList.toggle('show', actionFilterActive());
+  if (state.actionMode === 'multiset') renderActionMultiset();
+  else renderActionPhase();
+}
+
+function renderTreeFacet(roots, key, mountId, selPath, descMap){
+  const wrap = $(mountId); wrap.innerHTML = '';
+  const baseFiltered = applyFilters('scope');
+  const hitCounts = new Map();
+  for (const w of baseFiltered){
+    const list = (key === 'shizhi') ? w.apply_shizhi_paths : w.apply_xingshi_paths;
+    const seen = new Set();
+    for (const p of (list || [])){
+      const parts = (p || '').split('/').filter(Boolean);
+      for (let i = 1; i <= parts.length; i++){
+        const pp = '/' + parts.slice(0,i).join('/');
+        if (seen.has(pp)) continue;
+        seen.add(pp);
+      }
+    }
+    for (const pp of seen) hitCounts.set(pp, (hitCounts.get(pp)||0)+1);
+  }
+  function renderNode(node, parent){
+    const c = hitCounts.get(node.path) || 0;
+    const collapsed = state.collapsed.has(node.path);
+    const hasChildren = (node.children || []).length > 0;
+    const tnode = el('div', {class: 'tnode' + (collapsed ? ' tcollapsed' : '')});
+    const cls = ['tline'];
+    if (selPath === node.path) cls.push('active');
+    if (c === 0 && selPath !== node.path) cls.push('disabled');
+    if (node.is_suggested && !node.is_hit) cls.push('suggested');
+    if (node.is_inferred) cls.push('inferred');
+    const line = el('div', {class: cls.join(' '), title: node.path + (node.description ? '\n\n' + node.description : '')});
+    const caret = el('span', {
+      class: 'tcaret' + (hasChildren ? '' : ' invis'),
+      onClick: (ev) => {
+        ev.stopPropagation();
+        if (state.collapsed.has(node.path)) state.collapsed.delete(node.path);
+        else state.collapsed.add(node.path);
+        renderTrees();
+      }
+    }, hasChildren ? (collapsed ? '▶' : '▼') : '·');
+    line.appendChild(caret);
+    let label = node.name;
+    if (node.is_suggested && !node.is_hit) label += ' ✦';
+    if (node.is_inferred) label += ' (推断)';
+    line.appendChild(el('span', {class:'tname'}, label));
+    line.appendChild(el('span', {class:'tcount'}, String(c)));
+    line.addEventListener('click', () => {
+      if (c === 0 && selPath !== node.path) return;
+      const k = (key === 'shizhi') ? 'shizhiPath' : 'xingshiPath';
+      state[k] = (state[k] === node.path) ? null : node.path;
+      renderAll();
+    });
+    tnode.appendChild(line);
+    for (const ch of node.children || []) renderNode(ch, tnode);
+    parent.appendChild(tnode);
+  }
+  for (const r of roots) renderNode(r, wrap);
+}
+
+function renderTrees(){
+  renderTreeFacet(data.subtree.shizhi || [], 'shizhi', 'shizhiTree', state.shizhiPath, shiDesc);
+  renderTreeFacet(data.subtree.xingshi || [], 'xingshi', 'xingshiTree', state.xingshiPath, xingDesc);
+}
+
+function renderScope(){
+  $('scopeStat').textContent = '路径树(双 facet)';
+  $('scopeClear').classList.toggle('show', scopeFilterActive());
+  renderTrees();
+}
+
+function sigPassesChipFilter(sig){
+  const p = sigParts(sig);
+  for (const m of state.modInFilter) if (!p.in.has(m)) return false;
+  for (const m of state.modOutFilter) if (!p.out.has(m)) return false;
+  return true;
+}
+
+function renderModalityChips(){
+  const renderSide = (mountId, palette, selectedSet, side) => {
+    const wrap = $(mountId); wrap.innerHTML = '';
+    for (const m of palette){
+      const trial = new Set(selectedSet);
+      if (!trial.has(m)) trial.add(m);
+      let c = 0;
+      for (const sig of data.modalities){
+        const p = sigParts(sig.sig);
+        const inOK = side === 'in'
+          ? [...trial].every(x => p.in.has(x)) && [...state.modOutFilter].every(x => p.out.has(x))
+          : [...state.modInFilter].every(x => p.in.has(x)) && [...trial].every(x => p.out.has(x));
+        if (inOK) c++;
+      }
+      const isActive = selectedSet.has(m);
+      const chip = el('span', {
+        class: 'mod-chip' + (isActive ? ' active' : '') + (c === 0 && !isActive ? ' disabled' : ''),
+        title: m + (c === 0 && !isActive ? ' (无匹配)' : ''),
+        onClick: () => {
+          if (c === 0 && !isActive) return;
+          if (selectedSet.has(m)) selectedSet.delete(m);
+          else selectedSet.add(m);
+          if (state.modSig && !sigPassesChipFilter(state.modSig)) state.modSig = null;
+          renderAll();
+        }
+      }, m);
+      wrap.appendChild(chip);
+    }
+  };
+  renderSide('modInChips', SIDE_MODS.in, state.modInFilter, 'in');
+  renderSide('modOutChips', SIDE_MODS.out, state.modOutFilter, 'out');
+}
+
+function renderModalities(){
+  const baseFiltered = applyFilters('mod');
+  const counts = {};
+  for (const w of baseFiltered) counts[w.io_signature] = (counts[w.io_signature]||0)+1;
+  renderModalityChips();
+  const wrap = $('modList'); wrap.innerHTML = '';
+  const visible = data.modalities.filter(m => sigPassesChipFilter(m.sig));
+  const sorted = [...visible].sort((a,b) => (counts[b.sig]||0) - (counts[a.sig]||0));
+  if (sorted.length === 0){
+    wrap.appendChild(el('div', {class:'empty-msg'}, '当前 chip 筛选下没有匹配签名'));
+  }
+  for (const m of sorted){
+    const c = counts[m.sig] || 0;
+    const node = el('div', {
+      class: 'item' + (state.modSig === m.sig ? ' active' : '') + (c === 0 && state.modSig !== m.sig ? ' disabled' : ''),
+      onClick: () => {
+        if (c === 0 && state.modSig !== m.sig) return;
+        state.modSig = (state.modSig === m.sig) ? null : m.sig;
+        renderAll();
+      }
+    },
+      el('span', {class:'item-name'}, m.sig),
+      el('span', {class:'item-count'}, String(c))
+    );
+    wrap.appendChild(node);
+  }
+  $('modClear').classList.toggle('show', !!state.modSig || state.modInFilter.size > 0 || state.modOutFilter.size > 0);
+}
+
+function vchipStyle(phase){
+  // Neutral chip styling (color no longer encodes phase).
+  return 'background:var(--tag-bg);color:var(--fg);border-color:var(--border)';
+}
+
+function renderWorkflowCard(w){
+  const isActive = state.wfKey === w.workflow_key;
+  const node = el('div', {
+    class: 'wf' + (isActive ? ' active' : ''),
+    onClick: () => { state.wfKey = isActive ? null : w.workflow_key; renderWorkflows(); renderDetail(); }
+  });
+  const head = el('div', {class:'wf-head'},
+    el('span', {class:'case-badge'}, '案例'+w.case_index),
+    el('span', {class:'wf-badge'}, w.workflow_id),
+    el('span', {class:'wf-meta'}, w.step_count + ' step · ' + w.capability_count + ' cap')
+  );
+  node.appendChild(head);
+  if (w.case_title){
+    node.appendChild(el('div', {class:'wf-title'}, w.case_title));
+  }
+  node.appendChild(el('div', {class:'wf-sig'}, w.io_signature));
+  node.appendChild(el('div', {class:'wf-sig'}, w.phase_signature));
+
+  const seq = el('div', {class:'wf-seq'});
+  let prevStepIdx = -1;
+  w.steps.forEach((s, si) => {
+    if (!s.fragment_keys.length) return;
+    if (prevStepIdx >= 0) seq.appendChild(el('span', {class:'vsep'}, '›'));
+    prevStepIdx = si;
+    s.fragment_keys.forEach((fk, ci) => {
+      const f = fragByKey.get(fk);
+      if (!f) return;
+      if (ci > 0) seq.appendChild(el('span', {class:'vsep'}, '·'));
+      seq.appendChild(el('span', {
+        class:'vchip',
+        style: vchipStyle(s.phase),
+        title: 'step ' + s.step_id + ' / ' + s.phase + ' / ' + (f.action || '')
+      }, f.action || '?'));
+    });
+  });
+  node.appendChild(seq);
+  return node;
+}
+
+function renderWorkflows(){
+  const filtered = applyFilters(null);
+  const wrap = $('wfList'); wrap.innerHTML = '';
+  $('wfCnt').textContent = filtered.length + ' / ' + data.workflows.length;
+  if (!filtered.length){
+    wrap.appendChild(el('div', {class:'empty-msg'}, '当前筛选下没有命中的工序'));
+    return;
+  }
+  for (const w of filtered) wrap.appendChild(renderWorkflowCard(w));
+}
+
+function renderCaseDetail(wrap, caseIdx){
+  const c = casesByIndex[caseIdx];
+  if (!c) return;
+  const det = el('details', {class:'case-detail'});
+  const sumLabel = '📄 源: ' + (c.title || '案例 ' + caseIdx);
+  det.appendChild(el('summary', null, sumLabel));
+  const content = el('div', {class:'case-content'});
+
+  if (c.author) {
+    content.appendChild(el('div', {class:'case-author'}, '作者:' + c.author));
+  }
+  if (c.feedback && (c.feedback.like_count || c.feedback.comment_count || c.feedback.collect_count || c.feedback.share_count)){
+    const fb = c.feedback;
+    const row = el('div', {class:'case-feedback'});
+    if (fb.like_count != null) row.appendChild(el('span', {class:'fb'}, '👍 ' + fb.like_count));
+    if (fb.comment_count != null) row.appendChild(el('span', {class:'fb'}, '💬 ' + fb.comment_count));
+    if (fb.collect_count != null) row.appendChild(el('span', {class:'fb'}, '⭐ ' + fb.collect_count));
+    if (fb.share_count != null) row.appendChild(el('span', {class:'fb'}, '↗ ' + fb.share_count));
+    content.appendChild(row);
+  }
+
+  const imgs = (c.images && c.images.length) ? c.images : (c.cover ? [c.cover] : []);
+  if (imgs.length){
+    const wrapImg = el('div', {class:'case-images'});
+    for (const src of imgs) wrapImg.appendChild(el('img', {src, loading:'lazy'}));
+    content.appendChild(wrapImg);
+  }
+  if (c.body){
+    content.appendChild(el('div', {class:'case-body'}, c.body));
+  }
+  if (c.url){
+    content.appendChild(el('a', {href:c.url, target:'_blank', rel:'noopener', class:'case-link'}, '🔗 访问原始链接'));
+  }
+  det.appendChild(content);
+  wrap.appendChild(det);
+}
+
+function renderCap(f){
+  const cap = el('div', {class:'cap'});
+  cap.appendChild(el('div', {class:'cap-head'},
+    el('span', {class:'cap-id'}, f.fragment_id),
+    el('span', {class:'cap-act'}, f.action || '?'),
+    el('span', {class:'cap-sig'}, f.modality_signature)
+  ));
+  if (f.body) cap.appendChild(el('div', {class:'cap-body'}, f.body));
+
+  const ioPills = el('div', {style:'margin-top:4px'});
+  const ioRow = (lbl, arr, kind) => {
+    const row = el('div', {class:'io-row'}, el('span', {class:'lbl'}, lbl));
+    if (!arr || !arr.length){ row.appendChild(el('span', {style:'color:var(--muted)'}, '(无)')); return row; }
+    for (const x of arr){
+      const isCfg = x.modality === '模型' || x.modality === '参数';
+      const txt = (x.description||'') + (x.modality?'['+x.modality+']':'') + (x.relation?' '+x.relation:'');
+      row.appendChild(el('span', {class:'pill ' + (isCfg ? 'cfg' : kind)}, txt));
+    }
+    return row;
+  };
+  ioPills.appendChild(ioRow('IN', f.inputs, 'in'));
+  ioPills.appendChild(ioRow('OUT', f.outputs, 'out'));
+  cap.appendChild(ioPills);
+
+  const renderApply = (label, arr) => {
+    if (!arr || !arr.length) return null;
+    const sec = el('div', {style:'margin-top:6px'},
+      el('div', {style:'font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:3px'},
+        'Apply · ' + label));
+    for (const e of arr){
+      const isHighlighted =
+        (label === '实质' && state.shizhiPath && (shiDesc.get(state.shizhiPath)||[]).includes(e.category_path)) ||
+        (label === '形式' && state.xingshiPath && (xingDesc.get(state.xingshiPath)||[]).includes(e.category_path));
+      const isSuggest = e.source === 'suggest';
+      sec.appendChild(el('div', {
+        class:'pathline' + (isSuggest ? ' suggest' : ''),
+        style: isHighlighted ? 'color:#ca8a04;font-weight:600' : ''
+      }, e.category_path + (isSuggest ? ' ✦' : '')));
+      if (e.body_excerpt){
+        sec.appendChild(el('div', {class:'excerpt-line'}, el('span', {class:'em'}, e.body_excerpt)));
+      }
+      if (e.body_excerpt_note){
+        sec.appendChild(el('div', {class:'rationale'}, '— ' + e.body_excerpt_note));
+      }
+      if (e.rationale){
+        sec.appendChild(el('div', {class:'rationale'}, '· ' + e.rationale));
+      }
+    }
+    return sec;
+  };
+  const shi = renderApply('实质', f.apply_shizhi);
+  if (shi) cap.appendChild(shi);
+  const xing = renderApply('形式', f.apply_xingshi);
+  if (xing) cap.appendChild(xing);
+
+  if (f.tools && f.tools.length){
+    cap.appendChild(el('div', {class:'cap-meta'}, 'tools: ' + f.tools.join(', ')));
+  }
+  if (f.is_alternative_to && f.is_alternative_to.length){
+    cap.appendChild(el('div', {class:'cap-meta'}, 'alt_to: ' + f.is_alternative_to.join(', ')));
+  }
+  return cap;
+}
+
+function renderDetail(){
+  const wrap = $('detailBody'); wrap.innerHTML = '';
+  const w = data.workflows.find(x => x.workflow_key === state.wfKey);
+  if (!w){ wrap.appendChild(el('div', {class:'detail-empty'}, '点击左侧工序查看详情')); return; }
+
+  wrap.appendChild(el('h2', null,
+    el('span', {class:'case-badge'}, '案例 '+w.case_index),
+    el('span', {class:'wf-badge'}, w.workflow_id),
+    el('span', {class:'pill'}, w.step_count + ' step'),
+    el('span', {class:'pill'}, w.capability_count + ' cap')
+  ));
+
+  renderCaseDetail(wrap, w.case_index);
+
+  const sigSec = el('div', {class:'detail-section'}, el('h3', null, '签名'));
+  sigSec.appendChild(el('div', {style:'font-size:12px;margin-bottom:4px'},
+    el('span', {style:'color:var(--muted);margin-right:6px'}, 'IO'),
+    el('span', null, w.io_signature)));
+  sigSec.appendChild(el('div', {style:'font-size:12px;margin-bottom:4px'},
+    el('span', {style:'color:var(--muted);margin-right:6px'}, 'phase'),
+    el('span', null, w.phase_signature)));
+  sigSec.appendChild(el('div', {style:'font-size:12px'},
+    el('span', {style:'color:var(--muted);margin-right:6px'}, 'actions'),
+    el('span', null, Object.entries(w.actions_multiset).map(([v,c])=>v+(c>1?'×'+c:'')).join(' · '))));
+  wrap.appendChild(sigSec);
+
+  const stepsSec = el('div', {class:'detail-section'}, el('h3', null, '工序步骤'));
+  w.steps.forEach(s => {
+    const sec = el('div', {class:'step-sec'});
+    const head = el('div', {class:'step-head'},
+      el('span', {class:'step-id'}, 'step '+s.step_id),
+      el('span', {class:'step-phase', style: vchipStyle(s.phase)}, s.phase || '—'),
+      el('span', {class:'step-order'}, 'order '+s.order),
+      s.fragment_keys.length > 1 ? el('span', {class:'step-altnote'}, '⇆ '+s.fragment_keys.length+' 个替代方案') : null
+    );
+    sec.appendChild(head);
+    if (!s.fragment_keys.length){
+      sec.appendChild(el('div', {class:'empty-msg', style:'padding:10px'}, '(无 capability)'));
+    } else {
+      s.fragment_keys.forEach(fk => {
+        const f = fragByKey.get(fk);
+        if (!f) return;
+        sec.appendChild(renderCap(f));
+      });
+    }
+    stepsSec.appendChild(sec);
+  });
+  wrap.appendChild(stepsSec);
+
+  wrap.appendChild(el('details', null,
+    el('summary', null, '查看 workflow JSON'),
+    el('pre', {class:'json-raw'}, JSON.stringify(w, null, 2))
+  ));
+}
+
+function renderChips(){
+  const c = $('chips'); c.innerHTML = '';
+  let any = false;
+  const mk = (lbl, val, onX) => {
+    any = true;
+    c.appendChild(el('span', {class:'filter-chip'},
+      el('span', {class:'lbl'}, lbl),
+      el('span', null, val),
+      el('span', {class:'x', onClick: () => { onX(); renderAll(); }}, '×')
+    ));
+  };
+  if (state.actionMode === 'multiset' && state.actionsSelected.size){
+    mk('动作', [...state.actionsSelected].join(' + '), () => { state.actionsSelected.clear(); });
+  } else if (state.actionMode === 'phase' && state.phaseSig){
+    mk('结构', state.phaseSig, () => { state.phaseSig = null; });
+  }
+  if (state.shizhiPath) mk('实质', state.shizhiPath.split('/').pop() || state.shizhiPath, () => { state.shizhiPath = null; });
+  if (state.xingshiPath) mk('形式', state.xingshiPath.split('/').pop() || state.xingshiPath, () => { state.xingshiPath = null; });
+  if (state.modSig) mk('模态', state.modSig, () => { state.modSig = null; });
+  $('clearAll').classList.toggle('active', any);
+}
+
+function renderStats(){}
+
+function renderAll(){
+  renderActions();
+  renderScope();
+  renderModalities();
+  renderWorkflows();
+  renderDetail();
+  renderChips();
+}
+
+function init(){
+  for (const c of (data.cases || [])) casesByIndex[c.index] = c;
+  for (const f of data.fragments) fragByKey.set(f.case_index + ':' + f.fragment_id, f);
+  PHASE_COLORS = data.phaseColors || {};
+  PHASE_FG = data.phaseFg || {};
+
+  SIDE_MODS = (() => {
+    const inSet = new Set(), outSet = new Set();
+    for (const m of data.modalities){
+      const p = sigParts(m.sig);
+      for (const x of p.in) inSet.add(x);
+      for (const x of p.out) outSet.add(x);
+    }
+    return { in: [...inSet].sort(), out: [...outSet].sort() };
+  })();
+
+  shiDesc = buildDescMap(data.subtree.shizhi || []);
+  xingDesc = buildDescMap(data.subtree.xingshi || []);
+
+  $('loading').style.display = 'none';
+  $('appRoot').style.display = '';
+
+  $('clearAll').addEventListener('click', () => {
+    state.actionsSelected.clear(); state.phaseSig = null;
+    state.shizhiPath = null; state.xingshiPath = null;
+    state.modSig = null;
+    state.modInFilter.clear(); state.modOutFilter.clear();
+    renderAll();
+  });
+  $('actClear').addEventListener('click', () => {
+    if (state.actionMode === 'multiset') state.actionsSelected.clear();
+    else state.phaseSig = null;
+    renderAll();
+  });
+  $('scopeClear').addEventListener('click', () => {
+    state.shizhiPath = null; state.xingshiPath = null;
+    renderAll();
+  });
+  $('tabMulti').addEventListener('click', () => { state.actionMode = 'multiset'; renderAll(); });
+  $('tabPhase').addEventListener('click', () => { state.actionMode = 'phase'; renderAll(); });
+  $('modClear').addEventListener('click', () => {
+    state.modSig = null; state.modInFilter.clear(); state.modOutFilter.clear();
+    renderAll();
+  });
+
+  renderStats();
+  renderAll();
+}
+
+fetch('/api/viz/data/workflow')
+  .then(r => {
+    if (!r.ok) throw new Error('fetch failed: ' + r.status);
+    return r.json();
+  })
+  .then(d => { data = d; init(); })
+  .catch(err => {
+    $('loading').textContent = '加载失败:' + err.message + '。请先上传数据:POST /api/viz/data/workflow';
+  });
+</script>
+</body>
+</html>

+ 1 - 1
knowhub/frontend/src/components/capabilities/FragmentsVisualizer.tsx

@@ -25,7 +25,7 @@ export function FragmentsVisualizer({ isFullscreen = false, onExitFullscreen }:
       : "w-[calc(100%+3rem)] h-full -mx-6 -mb-6 relative"}>
       : "w-[calc(100%+3rem)] h-full -mx-6 -mb-6 relative"}>
       <iframe 
       <iframe 
         ref={iframeRef}
         ref={iframeRef}
-        src="/viz4.html" 
+        src="/viz_capability.html"
         className="w-full h-full border-0"
         className="w-full h-full border-0"
         title="Fragments Visualizer"
         title="Fragments Visualizer"
         onLoad={(e) => {
         onLoad={(e) => {

+ 37 - 0
knowhub/frontend/src/components/workflows/WorkflowsVisualizer.tsx

@@ -0,0 +1,37 @@
+import React from 'react';
+
+export function WorkflowsVisualizer({ isFullscreen = false, onExitFullscreen }: { isFullscreen?: boolean, onExitFullscreen?: () => void }) {
+  const iframeRef = React.useRef<HTMLIFrameElement>(null);
+
+  React.useEffect(() => {
+    const handleMessage = (e: MessageEvent) => {
+      if (e.data?.type === 'EXIT_FULLSCREEN') {
+        if (onExitFullscreen) onExitFullscreen();
+      }
+    };
+    window.addEventListener('message', handleMessage);
+    return () => window.removeEventListener('message', handleMessage);
+  }, [onExitFullscreen]);
+
+  React.useEffect(() => {
+    if (iframeRef.current && iframeRef.current.contentWindow) {
+      iframeRef.current.contentWindow.postMessage({ type: 'SET_FULLSCREEN', isFullscreen }, '*');
+    }
+  }, [isFullscreen]);
+
+  return (
+    <div className={isFullscreen
+      ? "w-[calc(100%+3rem)] h-[calc(100%+3rem)] -mx-6 -mb-6 -mt-6 relative"
+      : "w-[calc(100%+3rem)] h-full -mx-6 -mb-6 relative"}>
+      <iframe
+        ref={iframeRef}
+        src="/viz_workflow.html"
+        className="w-full h-full border-0"
+        title="Workflows Visualizer"
+        onLoad={(e) => {
+          (e.target as HTMLIFrameElement).contentWindow?.postMessage({ type: 'SET_FULLSCREEN', isFullscreen }, '*');
+        }}
+      />
+    </div>
+  );
+}

+ 3 - 3
knowhub/frontend/src/layouts/MainLayout.tsx

@@ -96,11 +96,11 @@ export function MainLayout({ activeTab, onTabChange, children }: MainLayoutProps
           {TAB_ORDER.map((tab) => (
           {TAB_ORDER.map((tab) => (
             <div
             <div
               key={tab}
               key={tab}
-              className={(tab === 'dashboard' || tab === 'capabilities') ? "shrink-0 h-full overflow-hidden" : "shrink-0 h-full overflow-y-auto"}
+              className={(tab === 'dashboard' || tab === 'capabilities' || tab === 'workflows') ? "shrink-0 h-full overflow-hidden" : "shrink-0 h-full overflow-y-auto"}
               style={{ width: `${100 / totalTabs}%` }}
               style={{ width: `${100 / totalTabs}%` }}
             >
             >
-              <div className={(tab === 'dashboard' || tab === 'capabilities') ? "flex justify-center h-full" : "flex justify-center pb-12"}>
-                <div className={(tab === 'dashboard' || tab === 'capabilities') ? "w-full h-full px-6 py-6 flex flex-col" : "w-full px-6 py-6"}>
+              <div className={(tab === 'dashboard' || tab === 'capabilities' || tab === 'workflows') ? "flex justify-center h-full" : "flex justify-center pb-12"}>
+                <div className={(tab === 'dashboard' || tab === 'capabilities' || tab === 'workflows') ? "w-full h-full px-6 py-6 flex flex-col" : "w-full px-6 py-6"}>
                   {children(tab)}
                   {children(tab)}
                 </div>
                 </div>
               </div>
               </div>

+ 49 - 6
knowhub/frontend/src/pages/Workflows.tsx

@@ -1,9 +1,11 @@
 import { useState, useEffect } from 'react';
 import { useState, useEffect } from 'react';
 import type { FormEvent } from 'react';
 import type { FormEvent } from 'react';
-import { Layers, Cpu, CheckCircle2, Target, Search, Activity, ChevronDown, ChevronRight } from 'lucide-react';
+import { Layers, Cpu, CheckCircle2, Target, Search, Activity, ChevronDown, ChevronRight, Maximize } from 'lucide-react';
 import { getStrategies, getCapabilities, getTools, getRequirements } from '../services/api';
 import { getStrategies, getCapabilities, getTools, getRequirements } from '../services/api';
 import { StatCard } from '../components/common/StatCard';
 import { StatCard } from '../components/common/StatCard';
 import { StatusBadge } from '../components/common/EntityTag';
 import { StatusBadge } from '../components/common/EntityTag';
+import { WorkflowsVisualizer } from '../components/workflows/WorkflowsVisualizer';
+import { cn } from '../lib/utils';
 
 
 interface WorkflowStep {
 interface WorkflowStep {
   title?: string;
   title?: string;
@@ -107,6 +109,8 @@ export function Workflows() {
   const [searchQuery, setSearchQuery] = useState("");
   const [searchQuery, setSearchQuery] = useState("");
   const [expandedReqs, setExpandedReqs] = useState<Record<string, boolean>>({});
   const [expandedReqs, setExpandedReqs] = useState<Record<string, boolean>>({});
   const [expandedSteps, setExpandedSteps] = useState<Record<string, boolean>>({});
   const [expandedSteps, setExpandedSteps] = useState<Record<string, boolean>>({});
+  const [activeTab, setActiveTab] = useState<'viz' | 'list'>('viz');
+  const [isFullscreen, setIsFullscreen] = useState(true);
   const LIMIT = 50;
   const LIMIT = 50;
   
   
   const loadStrategies = (currentOffset: number, isInit = false) => {
   const loadStrategies = (currentOffset: number, isInit = false) => {
@@ -182,12 +186,49 @@ export function Workflows() {
   };
   };
 
 
   return (
   return (
-    <div className="space-y-8 animate-in fade-in duration-500 pb-12">
-      <div>
-        <h1 className="text-2xl font-black text-slate-900 mb-1">工序库 (Workflows)</h1>
-        <p className="text-slate-500 text-sm">串联原子能力与业务执行流,标准化 SOP 作业模型。</p>
-      </div>
+    <div className="animate-in fade-in duration-500 h-full flex flex-col relative">
+      {!isFullscreen && (
+        <>
+          <div className="flex-none mb-6">
+            <h1 className="text-2xl font-black text-slate-900 mb-1">工序库 (Workflows)</h1>
+            <p className="text-slate-500 text-sm">串联原子能力与业务执行流,标准化 SOP 作业模型。</p>
+          </div>
 
 
+          <div className="flex justify-between items-end border-b border-slate-200 flex-none">
+            <div className="flex gap-6">
+              <button
+                onClick={() => setActiveTab('viz')}
+                className={cn("pb-3 text-sm font-bold transition-colors relative", activeTab === 'viz' ? "text-indigo-600" : "text-slate-500 hover:text-slate-800")}
+              >
+                工序图谱
+                {activeTab === 'viz' && <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-indigo-600 rounded-t-full"></div>}
+              </button>
+              <button
+                onClick={() => setActiveTab('list')}
+                className={cn("pb-3 text-sm font-bold transition-colors relative", activeTab === 'list' ? "text-indigo-600" : "text-slate-500 hover:text-slate-800")}
+              >
+                工序列表
+                {activeTab === 'list' && <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-indigo-600 rounded-t-full"></div>}
+              </button>
+            </div>
+            {activeTab === 'viz' && (
+              <button
+                onClick={() => setIsFullscreen(true)}
+                className="mb-2.5 text-xs font-bold text-slate-500 hover:text-indigo-600 flex items-center gap-1.5 transition-colors"
+              >
+                <Maximize size={14} /> 全屏展示
+              </button>
+            )}
+          </div>
+        </>
+      )}
+
+      {activeTab === 'viz' ? (
+        <div className={cn("flex-1 min-h-0", isFullscreen ? "" : "pt-6")}>
+          <WorkflowsVisualizer isFullscreen={isFullscreen} onExitFullscreen={() => setIsFullscreen(false)} />
+        </div>
+      ) : (
+      <div className="flex-1 min-h-0 space-y-8 overflow-y-auto custom-scrollbar pr-2 pb-12 pt-6">
       <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
       <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
         <StatCard title="可用工序总数" value={totalCount} subtext="检索返回总量" icon={Layers} iconBgColor="bg-indigo-50" iconColor="text-indigo-600" />
         <StatCard title="可用工序总数" value={totalCount} subtext="检索返回总量" icon={Layers} iconBgColor="bg-indigo-50" iconColor="text-indigo-600" />
         <StatCard title="成熟工序" value={activeCount} subtext="核心指标" icon={CheckCircle2} iconBgColor="bg-emerald-50" iconColor="text-emerald-600" />
         <StatCard title="成熟工序" value={activeCount} subtext="核心指标" icon={CheckCircle2} iconBgColor="bg-emerald-50" iconColor="text-emerald-600" />
@@ -445,6 +486,8 @@ export function Workflows() {
           </div>
           </div>
         )}
         )}
       </div>
       </div>
+      </div>
+      )}
     </div>
     </div>
   );
   );
 }
 }

+ 62 - 1
knowhub/server.py

@@ -19,7 +19,7 @@ from pathlib import Path
 import httpx
 import httpx
 from cryptography.hazmat.primitives.ciphers.aead import AESGCM
 from cryptography.hazmat.primitives.ciphers.aead import AESGCM
 
 
-from fastapi import FastAPI, HTTPException, Query, Header, Body, BackgroundTasks, Request
+from fastapi import FastAPI, HTTPException, Query, Header, Body, BackgroundTasks, Request, File, UploadFile
 from fastapi.responses import HTMLResponse, FileResponse
 from fastapi.responses import HTMLResponse, FileResponse
 from fastapi.staticfiles import StaticFiles
 from fastapi.staticfiles import StaticFiles
 from pydantic import BaseModel, Field
 from pydantic import BaseModel, Field
@@ -823,6 +823,67 @@ async def serve_itemsets():
     return {"error": "itemsets_all.json not found"}
     return {"error": "itemsets_all.json not found"}
 
 
 
 
+# --- Viz data store: capability / workflow payloads ---
+# Upload a pre-built JSON (POST), fetch the latest version (GET), or list versions.
+# Files live under data/viz/{kind}/ as v-{timestamp}.json plus a latest.txt pointer.
+
+_VIZ_KINDS = ("capability", "workflow")
+_VIZ_DATA_DIR = Path(__file__).parent / "data" / "viz"
+
+
+def _viz_kind_dir(kind: str) -> Path:
+    if kind not in _VIZ_KINDS:
+        raise HTTPException(status_code=400, detail=f"unknown kind: {kind!r}, must be one of {_VIZ_KINDS}")
+    d = _VIZ_DATA_DIR / kind
+    d.mkdir(parents=True, exist_ok=True)
+    return d
+
+
+@app.post("/api/viz/data/{kind}")
+async def upload_viz_data(kind: str, file: UploadFile = File(...)):
+    """Upload a viz payload JSON. Saves a timestamped version and updates 'latest'."""
+    d = _viz_kind_dir(kind)
+    raw = await file.read()
+    try:
+        payload = json.loads(raw)
+    except json.JSONDecodeError as e:
+        raise HTTPException(status_code=400, detail=f"invalid JSON: {e}")
+    ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
+    version = f"v-{ts}.json"
+    (d / version).write_bytes(raw)
+    (d / "latest.txt").write_text(version, encoding="utf-8")
+    return {
+        "kind": kind,
+        "version": version,
+        "size_bytes": len(raw),
+        "stats": payload.get("stats"),
+    }
+
+
+@app.get("/api/viz/data/{kind}")
+async def get_viz_data(kind: str):
+    """Return the latest uploaded viz payload as raw JSON."""
+    d = _viz_kind_dir(kind)
+    latest_file = d / "latest.txt"
+    if not latest_file.exists():
+        raise HTTPException(status_code=404, detail=f"no {kind} viz data uploaded yet")
+    version = latest_file.read_text(encoding="utf-8").strip()
+    target = d / version
+    if not target.exists():
+        raise HTTPException(status_code=500, detail=f"latest manifest points to missing file: {version}")
+    return FileResponse(target, media_type="application/json")
+
+
+@app.get("/api/viz/data/{kind}/versions")
+async def list_viz_versions(kind: str):
+    """List uploaded versions and the current 'latest' pointer."""
+    d = _viz_kind_dir(kind)
+    versions = sorted([p.name for p in d.glob("v-*.json")], reverse=True)
+    latest_file = d / "latest.txt"
+    latest = latest_file.read_text(encoding="utf-8").strip() if latest_file.exists() else None
+    return {"kind": kind, "versions": versions, "latest": latest}
+
+
 # --- 数据版本上下文中间件 ---
 # --- 数据版本上下文中间件 ---
 # 从请求头 X-KnowHub-Version 或 query ?version=xxx 读版本,写入 contextvar;
 # 从请求头 X-KnowHub-Version 或 query ?version=xxx 读版本,写入 contextvar;
 # 全链路的 store 查询都从 contextvar 读 active version 做过滤。
 # 全链路的 store 查询都从 contextvar 读 active version 做过滤。