Talegorithm 1 час назад
Родитель
Сommit
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"}>
       <iframe 
         ref={iframeRef}
-        src="/viz4.html" 
+        src="/viz_capability.html"
         className="w-full h-full border-0"
         title="Fragments Visualizer"
         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) => (
             <div
               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}%` }}
             >
-              <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)}
                 </div>
               </div>

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

@@ -1,9 +1,11 @@
 import { useState, useEffect } 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 { StatCard } from '../components/common/StatCard';
 import { StatusBadge } from '../components/common/EntityTag';
+import { WorkflowsVisualizer } from '../components/workflows/WorkflowsVisualizer';
+import { cn } from '../lib/utils';
 
 interface WorkflowStep {
   title?: string;
@@ -107,6 +109,8 @@ export function Workflows() {
   const [searchQuery, setSearchQuery] = useState("");
   const [expandedReqs, setExpandedReqs] = 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 loadStrategies = (currentOffset: number, isInit = false) => {
@@ -182,12 +186,49 @@ export function Workflows() {
   };
 
   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">
         <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" />
@@ -445,6 +486,8 @@ export function Workflows() {
           </div>
         )}
       </div>
+      </div>
+      )}
     </div>
   );
 }

+ 62 - 1
knowhub/server.py

@@ -19,7 +19,7 @@ from pathlib import Path
 import httpx
 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.staticfiles import StaticFiles
 from pydantic import BaseModel, Field
@@ -823,6 +823,67 @@ async def serve_itemsets():
     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;
 # 全链路的 store 查询都从 contextvar 读 active version 做过滤。