@sparkelf/dsh-knowledge-base 0.1.30 → 0.1.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1695,7 +1695,7 @@ async function scanKnowledgeGraphs(options) {
1695
1695
  *
1696
1696
  * @module @sparkelf/dsh-knowledge-base/graph-canvas
1697
1697
  */
1698
- const GRAPH_CANVAS_HTML = "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>知识图谱 · 画布</title>\n<style>\n:root{\n --bg:#fbfbfc;\n --bg-soft:#f6f7f9;\n --surface:#ffffff;\n --ink:#0f1115;\n --ink-2:#3c3f47;\n --secondary:#61666b;\n --muted:#81858c;\n --line:rgba(0,0,0,.10);\n --line-2:rgba(0,0,0,.06);\n --blue:#4176e6;\n --blue-deep:#2555c4;\n --blue-soft:#eef4ff;\n --green:#22c55e;\n --amber:#f59e0b;\n --radius:12px;\n --shadow:0 1px 2px rgba(15,17,21,.04), 0 8px 24px rgba(15,17,21,.06);\n --sans:-apple-system,BlinkMacSystemFont,\"Segoe UI\",\"PingFang SC\",\"Hiragino Sans GB\",\"Microsoft YaHei\",sans-serif;\n --mono:\"SF Mono\",\"JetBrains Mono\",Menlo,Consolas,monospace;\n}\n*{box-sizing:border-box}\nhtml,body{height:100%}\nbody{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);\n font-size:14px;line-height:1.6;overflow:hidden;-webkit-font-smoothing:antialiased}\nbutton{font-family:inherit;color:inherit}\n\n#stage{position:fixed;left:248px;right:0;top:0;bottom:0;overflow:hidden;cursor:grab;\n background:\n radial-gradient(900px 620px at 50% 38%, #ffffff 0%, rgba(255,255,255,0) 70%),\n var(--bg)}\n#stage::before{content:\"\";position:absolute;inset:0;pointer-events:none;\n background-image:radial-gradient(rgba(15,17,21,.055) 1px, transparent 1px);\n background-size:22px 22px;\n -webkit-mask-image:radial-gradient(ellipse 70% 70% at 50% 45%, #000 0%, transparent 78%);\n mask-image:radial-gradient(ellipse 70% 70% at 50% 45%, #000 0%, transparent 78%)}\n#stage.grabbing{cursor:grabbing}\n#world{position:absolute;left:0;top:0;transform-origin:0 0;will-change:transform}\n#edges{position:absolute;left:0;top:0;overflow:visible;pointer-events:none}\n\n#topbar{position:fixed;left:248px;right:0;top:0;display:flex;align-items:flex-start;justify-content:space-between;\n padding:18px 22px;pointer-events:none;z-index:10}\n#brand h1{margin:0;font-size:16px;font-weight:600;letter-spacing:-.01em}\n#brand p{margin:4px 0 0;font-size:12px;color:var(--muted)}\n#brand b{color:var(--secondary);font-weight:500}\n#hint{font-size:11.5px;color:var(--muted);text-align:right;line-height:1.9;transition:opacity .2s}\n#hint kbd{font-family:var(--mono);font-size:10.5px;border:1px solid var(--line);border-radius:4px;\n padding:1px 5px;color:var(--secondary);background:var(--surface)}\nbody.panel-open #hint{opacity:0}\n\n.card{position:absolute;width:300px;padding:15px 16px 13px;border-radius:var(--radius);cursor:grab;\n user-select:none;background:var(--surface);border:1px solid var(--line);box-shadow:var(--shadow);\n transition:border-color .16s,box-shadow .16s,opacity .16s}\n.card:hover{border-color:rgba(0,0,0,.18)}\n.card.dragging{cursor:grabbing;transition:none}\n.card.selected{border-color:var(--blue);\n box-shadow:0 0 0 2px rgba(65,118,230,.14),0 10px 28px rgba(15,17,21,.10)}\n.card.dim{opacity:.38}\n.card:focus-visible{outline:2px solid var(--blue);outline-offset:3px}\n.chip{font-size:11px;letter-spacing:.02em;color:var(--secondary);background:var(--bg-soft);\n border:1px solid var(--line-2);border-radius:6px;padding:1px 7px}\n.status{display:inline-flex;align-items:center;gap:6px;font-size:11px;color:var(--muted)}\n.dot{width:6px;height:6px;border-radius:50%;background:var(--green)}\n.dot.paused{background:var(--amber)}\n.dot.done{background:#c4c7cc}\n.card h3{margin:0 0 9px;font-size:17px;font-weight:600;line-height:1.35;letter-spacing:-.01em}\n.cover{position:relative;height:124px;margin:0 0 10px}\nbody[data-doc-layout=\"landscape\"] .cover{height:86px;margin-bottom:9px}\nbody[data-doc-layout=\"landscape\"] .doc{width:132px;height:76px;border-radius:5px}\nbody[data-doc-layout=\"landscape\"] .doc-empty{width:140px;height:80px}\n.stack{position:absolute;inset:0}\n.doc{position:absolute;left:50%;top:50%;width:86px;height:116px;border-radius:6px;\n border:1px solid rgba(0,0,0,.08);box-shadow:0 8px 18px rgba(15,17,21,.14);background:#fff;\n object-fit:cover;\n transform:translate(-50%,-50%) translate(var(--dx),var(--dy)) rotate(var(--rot)) scale(var(--sc));\n transition:transform .22s cubic-bezier(.22,1,.36,1)}\n.cover:hover .doc{transform:translate(-50%,-50%) translate(calc(var(--dx) * 1.4),var(--dy)) rotate(calc(var(--rot) * 1.35)) scale(var(--sc))}\n.doc-empty{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:96px;height:120px;\n border:1px dashed var(--line);border-radius:6px;display:flex;align-items:center;justify-content:center;\n color:#b3b7bd;font-size:10.5px;background:var(--bg-soft)}\n.more{position:absolute;right:2px;bottom:0;font-size:10px;color:var(--muted);background:var(--surface);\n border:1px solid var(--line-2);border-radius:999px;padding:0 6px}\n.tags{display:flex;flex-wrap:wrap;gap:5px;margin-top:9px;margin-bottom:2px}\n.tags span{font-size:10px;color:var(--secondary);background:var(--bg-soft);border:1px solid var(--line-2);\n border-radius:999px;padding:1px 7px}\n.one{margin:0;font-size:12.5px;line-height:1.65;color:var(--ink-2);\n display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}\n\n.dots{display:inline-flex;gap:3px}\n.dots i{width:5px;height:5px;border-radius:50%;background:#dcdfe3}\n.dots i.on{background:var(--ink)}\n\n.edge{fill:none;stroke:rgba(15,17,21,.32);stroke-width:1.2}\n.edge.cross{stroke:rgba(15,17,21,.18);stroke-dasharray:4 6}\n.edge.hot{stroke:var(--blue);stroke-width:1.6}\n.edge.cold{opacity:.22}\n\n.pill{cursor:pointer;pointer-events:auto}\n.pill rect{fill:var(--surface);stroke:var(--line);stroke-width:1;transition:stroke .16s,fill .16s}\n.pill text{font:10.5px var(--sans);fill:var(--secondary);letter-spacing:.01em}\n.pill:hover rect{stroke:rgba(0,0,0,.28)}\n.pill.hot rect{stroke:var(--blue);fill:var(--blue-soft)}\n.pill.hot text{fill:var(--blue-deep)}\n.pill.cold{opacity:.25}\n.pill:focus-visible{outline:none}\n.pill:focus-visible rect{stroke:var(--blue);stroke-width:1.6}\n\n#controls{position:fixed;left:266px;bottom:18px;display:flex;flex-direction:column;gap:6px;z-index:12}\n#controls button{width:34px;height:34px;border-radius:8px;border:1px solid var(--line);\n background:var(--surface);color:var(--ink-2);font-size:15px;line-height:1;cursor:pointer;\n box-shadow:0 1px 2px rgba(15,17,21,.04);transition:background .15s,color .15s}\n#controls button:hover{background:var(--bg-soft);color:var(--ink)}\n#controls button.on{background:var(--ink);color:#fff;border-color:var(--ink)}\n#controls button:focus-visible{outline:2px solid var(--blue);outline-offset:2px}\n#zoomLabel{position:fixed;left:308px;bottom:23px;font-family:var(--mono);font-size:10.5px;\n color:var(--muted);z-index:12}\n\n#panel{position:fixed;top:0;right:0;width:392px;height:100%;z-index:30;\n background:var(--surface);border-left:1px solid var(--line);\n box-shadow:-8px 0 28px rgba(15,17,21,.06);\n transform:translateX(102%);transition:transform .28s cubic-bezier(.22,1,.36,1);\n display:flex;flex-direction:column}\n#panel.open{transform:none}\n.p-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;\n padding:22px 22px 15px;border-bottom:1px solid var(--line-2)}\n.p-kicker{font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}\n.p-head h2{margin:7px 0 0;font-size:22px;font-weight:600;line-height:1.35;letter-spacing:-.01em}\n#close{width:30px;height:30px;flex:0 0 auto;border-radius:8px;border:1px solid var(--line);\n background:var(--surface);color:var(--muted);font-size:16px;cursor:pointer;line-height:1}\n#close:hover{color:var(--ink);background:var(--bg-soft)}\n.p-body{padding:16px 22px 40px;overflow:auto}\n.p-field{margin:0 0 14px}\n.p-field b{display:block;font-size:11px;letter-spacing:.08em;text-transform:uppercase;\n color:var(--muted);font-weight:500;margin-bottom:3px}\n.p-field span{display:block;font-size:13px;line-height:1.7;color:var(--ink-2)}\n.p-rel-title{margin:24px 0 9px;font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}\n.rel{display:block;width:100%;text-align:left;background:var(--bg-soft);\n border:1px solid var(--line-2);border-radius:8px;padding:10px 11px;margin:0 0 7px;cursor:pointer;\n transition:background .15s,border-color .15s}\n.rel:hover{background:#f1f3f5;border-color:var(--line)}\n.rel b{display:block;font-size:12.5px;color:var(--ink);margin-bottom:2px;font-weight:600}\n.rel span{display:block;font-size:11.5px;line-height:1.6;color:var(--muted);\n display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}\n.p-docs{font-size:11.5px;color:var(--muted);margin-top:12px}\n.p-doc-item{display:flex;gap:10px;align-items:center;padding:7px 0;border-bottom:1px solid var(--line-2)}\n.p-doc-item img{width:34px;height:46px;border-radius:3px;border:1px solid var(--line-2);object-fit:cover;flex:0 0 auto}\n.p-doc-item .m b{display:block;font-size:12.5px;font-weight:600;color:var(--ink)}\n.p-doc-item .m span{display:block;font-size:10.5px;color:var(--muted)}\n.p-doc{font-size:13px;line-height:1.8;color:var(--ink-2)}\n.p-doc h2{font-size:14px;font-weight:600;color:var(--ink);margin:22px 0 6px}\n.p-doc h3{font-size:13px;font-weight:600;color:var(--ink);margin:18px 0 5px}\n.p-doc p{margin:0 0 10px}\n.p-doc ul,.p-doc ol{margin:0 0 10px;padding-left:18px}\n.p-doc li{margin:2px 0}\n.p-doc blockquote{margin:0 0 10px;padding:2px 0 2px 12px;border-left:2px solid var(--line);color:var(--muted)}\n.p-doc code{font-family:var(--mono);font-size:.86em;background:var(--bg-soft);border:1px solid var(--line-2);padding:0 4px;border-radius:4px}\n.p-doc pre{margin:0 0 10px;padding:10px 12px;background:var(--bg-soft);border:1px solid var(--line-2);border-radius:8px;overflow:auto}\n.p-doc pre code{background:none;border:0;padding:0}\n.p-doc strong{color:var(--ink);font-weight:600}\n.p-doc a{color:var(--blue);text-decoration:none}\n\n\n/* ---------- tree ---------- */\n#tree{position:fixed;left:0;top:0;bottom:0;width:248px;z-index:20;display:flex;flex-direction:column;\n background:var(--surface);border-right:1px solid var(--line)}\n.tree-head{padding:18px 16px 13px;border-bottom:1px solid var(--line-2)}\n.tree-title{font-size:14px;font-weight:600}\n.tree-sub{font-size:11px;color:var(--muted);margin-top:2px}\n#treeBody{flex:1;overflow:auto;padding:8px 8px 20px}\n.tree-scope{font-size:10.5px;letter-spacing:.1em;color:var(--muted);padding:12px 10px 5px}\n.tree-group{font-size:11.5px;color:var(--secondary);padding:6px 10px 3px}\n.tree-graph{display:flex;align-items:center;justify-content:space-between;gap:8px;\n padding:5px 10px;border-radius:6px;font-size:12.5px;color:var(--ink-2);cursor:pointer}\n.tree-graph:hover{background:var(--bg-soft)}\n.tree-graph.active{background:var(--blue-soft);color:var(--blue-deep)}\n.tree-graph .n{font-size:10.5px;color:var(--muted);font-variant-numeric:tabular-nums}\n.tree-graph.active .n{color:var(--blue)}\n.tree-topic{padding:4px 10px 4px 24px;font-size:12px;color:var(--muted);border-radius:6px;cursor:pointer;\n white-space:nowrap;overflow:hidden;text-overflow:ellipsis}\n.tree-topic:hover{background:var(--bg-soft);color:var(--ink)}\n.tree-empty{padding:2px 10px 4px 24px;font-size:11.5px;color:#b3b7bd}\n\n@media (max-width:760px){\n #tree{display:none}\n #stage{left:0}\n #topbar{left:0}\n #controls{left:18px}\n #zoomLabel{left:60px}\n #hint{display:none}\n .only-wide{display:none}\n #brand h1{font-size:15px}\n .card{width:270px}\n #panel{width:100%;height:72%;top:auto;bottom:0;border-left:0;border-top:1px solid var(--line);\n transform:translateY(102%);border-radius:16px 16px 0 0}\n #panel.open{transform:none}\n}\n</style>\n<style>\n:root{\n --bg:#151517; --bg-soft:#232324; --surface:#232324; --ink:#f5f5f7; --ink-2:#cfd3d6;\n --secondary:#adb2b8; --muted:#8a8d95; --line:rgba(255,255,255,.12); --line-2:rgba(255,255,255,.06);\n --blue:#4d93f8; --blue-deep:#9db9ff; --blue-soft:rgba(77,147,248,.14);\n --green:#22c55e; --amber:#f59e0b;\n --shadow:0 1px 2px rgba(0,0,0,.3), 0 10px 30px rgba(0,0,0,.28);\n}\nhtml,body{height:100%}\nbody{display:flex;overflow:hidden;background:var(--bg);color:var(--ink)}\n#rail{width:280px;flex:0 0 280px;background:#141416;border-right:.5px solid rgba(255,255,255,.10);\n display:flex;flex-direction:column;min-height:0}\n.rail-top{height:36px;flex:0 0 36px;display:flex;align-items:center;gap:8px;padding:0 6px 0 12px}\n.rail-brand{display:flex;align-items:center;gap:8px;font-size:14px;font-weight:600;color:var(--ink)}\n.rail-brand svg{width:16px;height:16px;color:var(--secondary)}\n.rail-fold{margin-left:auto;width:28px;height:28px;border:0;background:none;border-radius:6px;\n color:var(--muted);cursor:pointer;display:flex;align-items:center;justify-content:center}\n.rail-fold:hover{background:rgba(255,255,255,.06);color:var(--ink)}\n.rail-search{margin:6px 12px 4px;height:32px;display:flex;align-items:center;gap:8px;padding:0 10px;\n background:#1f1f22;border:.5px solid rgba(255,255,255,.10);border-radius:8px}\n.rail-search svg{width:14px;height:14px;color:var(--muted)}\n.rail-search input{flex:1;min-width:0;background:none;border:0;outline:none;color:var(--ink);font-size:12.5px}\n.rail-search input::placeholder{color:#6f737a}\n.rail-nav{padding:4px 8px;display:flex;flex-direction:column;gap:1px}\n.nav-item{height:34px;display:flex;align-items:center;gap:10px;padding:0 10px;border:0;border-radius:6px;\n background:none;color:var(--secondary);font-size:13.5px;text-align:left;cursor:pointer}\n.nav-item svg{width:15px;height:15px;color:var(--muted)}\n.nav-item:hover{background:rgba(255,255,255,.06)}\n.nav-item.active{background:rgba(255,255,255,.08);color:var(--ink)}\n.nav-item.active svg{color:var(--secondary)}\n.tree-head{height:36px;flex:0 0 36px;display:flex;align-items:center;gap:4px;padding:0 8px 0 12px}\n.rail-tabs{display:flex;align-items:center;gap:18px;height:36px}\n.rail-tab{height:36px;padding:0;border:0;background:none;color:var(--muted);font-size:14px;\n cursor:pointer;position:relative}\n.rail-tab:hover{color:var(--secondary)}\n.rail-tab.active{color:var(--ink)}\n.rail-tab.active::after{content:\"\";position:absolute;left:0;right:0;bottom:0;height:2px;\n border-radius:1px;background:var(--blue)}\n.tree-tools{margin-left:auto;display:flex;align-items:center;gap:2px}\n.tree-tools button{width:28px;height:28px;border:0;background:none;border-radius:6px;\n color:var(--secondary);display:flex;align-items:center;justify-content:center;cursor:pointer}\n.tree-tools button:hover{background:rgba(255,255,255,.06);color:var(--ink)}\n#treeBody{flex:1;min-height:0;overflow:auto;padding:4px 8px 16px}\n.tree-scope{font-size:10.5px;letter-spacing:.1em;color:var(--muted);padding:10px 10px 4px}\n.tree-group{font-size:11.5px;color:var(--secondary);padding:4px 10px 2px}\n.tree-graph{display:flex;justify-content:space-between;align-items:center;height:28px;padding:0 10px;\n border-radius:6px;font-size:12.5px;color:var(--secondary);cursor:pointer}\n.tree-graph:hover{background:rgba(255,255,255,.06)}\n.tree-graph.active{background:var(--blue-soft);color:var(--blue-deep)}\n.tree-graph .n{font-size:10.5px;color:#6f737a;font-variant-numeric:tabular-nums}\n.tree-topic{height:26px;display:flex;align-items:center;padding:0 10px 0 24px;border-radius:6px;\n font-size:12px;color:var(--muted);cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}\n.tree-topic:hover{background:rgba(255,255,255,.06);color:var(--ink)}\n.tree-empty{padding:2px 10px 4px 24px;font-size:11.5px;color:#5f636a}\n.tree-kb{display:flex;align-items:center;gap:8px;height:28px;padding:0 10px;border-radius:6px;\n font-size:12.5px;color:var(--secondary);cursor:pointer}\n.tree-kb:hover{background:rgba(255,255,255,.06)}\n.tree-kb svg{width:14px;height:14px;color:#d9a441;flex:0 0 auto}\n.tree-kb.file svg{color:#8a8d95}\n.tree-kb .date{margin-left:auto;font-size:10.5px;color:#6f737a}\n#main{flex:1;min-width:0;display:flex;flex-direction:column;position:relative;background:#1a1a1c}\n#mainhead{height:48px;flex:0 0 48px;display:flex;align-items:center;gap:12px;padding:0 18px;\n border-bottom:.5px solid rgba(255,255,255,.10)}\n.crumb{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--muted);min-width:0}\n.crumb b{color:var(--ink);font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}\n.crumb i{font-style:normal;color:#5f636a}\n#headMeta{font-size:11.5px;color:#7f838a;white-space:nowrap}\n.head-actions{margin-left:auto;display:flex;gap:8px}\n.head-actions button{height:28px;padding:0 10px;border:.5px solid rgba(255,255,255,.12);border-radius:6px;\n background:#232324;color:var(--secondary);font-size:12px;cursor:pointer}\n.head-actions button:hover{background:#2c2c2e;color:var(--ink)}\n#stage{position:relative;left:auto;right:auto;top:auto;bottom:auto;flex:1;min-height:0;overflow:hidden;cursor:grab;\n background:radial-gradient(900px 620px at 50% 38%,rgba(255,255,255,.03),transparent 70%),#1a1a1c}\n#stage::before{background-image:radial-gradient(rgba(255,255,255,.055) 1px,transparent 1px)}\n#controls{position:absolute;left:16px;bottom:16px;z-index:12}\n#controls button{background:#232324;border-color:rgba(255,255,255,.12);color:var(--secondary)}\n#controls button:hover{background:#2c2c2e;color:var(--ink)}\n#controls button.on{background:var(--blue);border-color:var(--blue);color:#0b1220}\n#zoomLabel{position:absolute;left:60px;bottom:21px;color:var(--muted)}\n.card{background:#232324;border-color:rgba(255,255,255,.10);\n box-shadow:0 1px 2px rgba(0,0,0,.3),0 10px 30px rgba(0,0,0,.28)}\n.card:hover{border-color:rgba(255,255,255,.20)}\n.card.selected{border-color:var(--blue);box-shadow:0 0 0 2px rgba(77,147,248,.20),0 10px 30px rgba(0,0,0,.35)}\n.card.dim{opacity:.32}\n.card h3{color:var(--ink)}\n.one{color:var(--secondary)}\n.tags span{background:#2c2c2e;border-color:rgba(255,255,255,.08);color:var(--secondary)}\n.doc{border-color:rgba(255,255,255,.12);box-shadow:0 8px 18px rgba(0,0,0,.40)}\n.edge{stroke:rgba(255,255,255,.22)}\n.edge.cross{stroke:rgba(255,255,255,.12)}\n.edge.hot{stroke:var(--blue)}\n.pill rect{fill:#232324;stroke:rgba(255,255,255,.14)}\n.pill text{fill:var(--secondary)}\n.pill.hot rect{fill:rgba(77,147,248,.16);stroke:var(--blue)}\n.pill.hot text{fill:var(--blue-deep)}\n#panel{background:#1e1e21;border-left-color:rgba(255,255,255,.10);box-shadow:-8px 0 28px rgba(0,0,0,.40)}\n.p-head{border-bottom-color:rgba(255,255,255,.06)}\n.p-kicker{color:var(--muted)}\n.p-head h2{color:var(--ink)}\n#close{background:#232324;border-color:rgba(255,255,255,.12);color:var(--muted)}\n#close:hover{background:#2c2c2e;color:var(--ink)}\n.p-field b{color:var(--muted)}\n.p-field span{color:var(--secondary)}\n.p-doc{color:var(--secondary)}\n.p-doc h2,.p-doc h3{color:var(--ink)}\n.p-doc code{background:#2c2c2e;border-color:rgba(255,255,255,.08)}\n.p-doc pre{background:#1a1a1c;border-color:rgba(255,255,255,.08)}\n.rel{background:#262629;border-color:rgba(255,255,255,.07)}\n.rel:hover{background:#2c2c2e;border-color:rgba(255,255,255,.12)}\n.rel b{color:var(--ink)}\n.rel span{color:var(--muted)}\n.p-doc-item{border-bottom-color:rgba(255,255,255,.06)}\n.p-doc-item .m b{color:var(--ink)}\n.p-doc-item .m span{color:var(--muted)}\n#kbView{flex:1;min-height:0;overflow:auto;padding:18px}\n.kb-cards{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:13px}\n.kb-card{display:flex;gap:13px;align-items:center;height:63px;padding:0 13px;border:.5px solid rgba(255,255,255,.10);\n border-radius:10px;background:#232324;text-align:left;cursor:pointer;color:var(--ink)}\n.kb-card:hover{background:#262629}\n.kb-card .plate{width:22px;height:22px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:13px;flex:0 0 auto}\n.kb-card b{display:block;font-size:13.5px;color:var(--ink);font-weight:600}\n.kb-card small{display:block;font-size:11.5px;color:var(--muted);margin-top:2px}\n.kb-tabs{display:flex;gap:8px;margin:16px 0 6px;border-bottom:.5px solid rgba(255,255,255,.08)}\n.kb-tab{padding:8px 10px;font-size:12.5px;color:var(--muted);background:none;border:0;cursor:pointer;border-bottom:2px solid transparent}\n.kb-tab.active{color:var(--ink);border-bottom-color:var(--blue)}\n.kb-table{width:100%;border-collapse:collapse;font-size:12.5px}\n.kb-table th{text-align:left;font-weight:500;color:#7f838a;font-size:11px;padding:8px 10px;border-bottom:.5px solid rgba(255,255,255,.08)}\n.kb-table td{padding:9px 10px;border-bottom:.5px solid rgba(255,255,255,.06);color:var(--secondary)}\n.kb-table td.muted{color:var(--muted)}\n.kb-table tr:hover td{background:rgba(255,255,255,.03)}\n[hidden]{display:none !important}\n@media (max-width:900px){#rail{display:none}}\n</style>\n<style>\n/* ---- embedding page: no rail, no page header; the plugin owns both ---- */\n#main { width: 100%; }\n#kg-empty { position: absolute; inset: 0; z-index: 8; display: flex; flex-direction: column;\n align-items: center; justify-content: center; gap: 6px; background: #1a1a1c; color: #8a8d95;\n font-family: var(--sans); text-align: center; padding: 24px; }\n#kg-empty strong { font-size: 14px; font-weight: 600; color: #cfd3d6; }\n#kg-empty span { font-size: 12px; }\n</style>\n<style>\n/* ---- host theme bridge ----\n The canvas is a separate document, so it cannot inherit the host's custom\n properties. The parent posts every --dsw-alias-* value below into this page and\n writes it on :root. These fallbacks are only the first-paint values before that\n message lands; the live values always come from the host. */\n:root {\n --bg: var(--dsw-alias-bg-base, #fff);\n --bg-soft: var(--dsw-alias-bg-layer-1, #f6f7f9);\n --surface: var(--dsw-alias-bg-layer-2, #fff);\n --ink: var(--dsw-alias-label-primary, #0f1115);\n --ink-2: var(--dsw-alias-label-secondary, #3c3f47);\n --secondary: var(--dsw-alias-label-secondary, #61666b);\n /* 80% tertiary + 20% primary keeps the third level distinguishable while\n reaching 4.5:1 on the light surface (the raw light tertiary does not). */\n --muted: color-mix(in srgb, var(--dsw-alias-label-tertiary, #81858c) 80%, var(--dsw-alias-label-primary, #0f1115));\n --line: var(--dsw-alias-border-l2, rgba(0, 0, 0, .10));\n --line-2: var(--dsw-alias-border-l1, rgba(0, 0, 0, .06));\n --blue: var(--dsw-alias-state-business-primary, #4176e6);\n /* Accent text needs 4.5:1, and the raw link token is 4.2:1 on white. A 78/22 mix\n with the primary ink keeps the brand hue while clearing the body-text floor. */\n --blue-deep: color-mix(in srgb, var(--dsw-alias-state-business-primary, #4176e6) 78%, var(--dsw-alias-label-primary, #0f1115));\n --blue-soft: color-mix(in srgb, var(--dsw-alias-state-business-primary, #4176e6) 16%, transparent);\n --green: var(--dsw-alias-state-success-primary, #22c55e);\n --amber: var(--dsw-alias-state-warn-primary, #f59e0b);\n --shadow: var(--dsw-elevation-panel, 0 1px 2px rgba(0, 0, 0, .10));\n --stage-bg: color-mix(in srgb, var(--dsw-alias-bg-base, #fff) 96%, var(--dsw-alias-label-primary, #0f1115));\n --soft: color-mix(in srgb, var(--dsw-alias-label-primary, #0f1115) 6%, transparent);\n --dot: color-mix(in srgb, var(--dsw-alias-label-primary, #0f1115) 9%, transparent);\n --edge: color-mix(in srgb, var(--dsw-alias-label-primary, #0f1115) 24%, transparent);\n --edge-soft: color-mix(in srgb, var(--dsw-alias-label-primary, #0f1115) 14%, transparent);\n}\n\n#stage {\n background:\n radial-gradient(900px 620px at 50% 38%, color-mix(in srgb, var(--surface) 60%, transparent), transparent 70%),\n var(--stage-bg) !important;\n}\n#stage::before { background-image: radial-gradient(var(--dot) 1px, transparent 1px) !important; }\n#kg-empty { background: var(--stage-bg) !important; color: var(--muted) !important; }\n#kg-empty strong { color: var(--ink) !important; }\n.card:focus-visible, #controls button:focus-visible { outline-color: var(--blue) !important; }\n.p-doc-item img { border-color: var(--line) !important; }\n.card { background: var(--surface) !important; border-color: var(--line) !important; box-shadow: var(--shadow) !important; }\n.card:hover { border-color: color-mix(in srgb, var(--ink) 22%, transparent) !important; }\n.card.selected { border-color: var(--blue) !important; box-shadow: 0 0 0 2px var(--blue-soft), var(--shadow) !important; }\n/* Dimming is focus, not disappearance: 32% made secondary text unreadable. */\n.card.dim { opacity: .55 !important; }\n.card h3 { color: var(--ink) !important; }\n.one { color: var(--secondary) !important; font-size: 13px; line-height: 1.6; }\n.tags span {\n background: var(--soft) !important; border-color: var(--line-2) !important;\n color: var(--secondary) !important; font-size: 11px; padding: 2px 7px;\n}\n.dots i { background: var(--line) !important; }\n.dots i.on { background: var(--ink) !important; }\n.doc { border-color: var(--line) !important; box-shadow: var(--shadow) !important; }\n.doc-empty { border-color: var(--line) !important; color: var(--muted) !important; background: var(--bg-soft) !important; }\n.docGenerated {\n display: flex; flex-direction: column; gap: 3px; padding: 7px 8px;\n background: var(--surface) !important; color: var(--ink);\n border: 1px solid var(--line) !important; border-radius: 6px;\n overflow: hidden;\n}\n.docGenerated .docMark {\n color: var(--blue-deep); font: 600 7.5px/1 var(--mono); letter-spacing: .08em;\n}\n.docGenerated b {\n color: var(--ink); font-size: 8.5px; line-height: 1.25; font-weight: 600;\n overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;\n}\n.docGenerated i { margin-top: auto; color: var(--muted); font: normal 7px/1 var(--sans); }\n.docGenerated.wide { gap: 4px; padding: 9px 11px; }\n.docGenerated.wide .docMark { font-size: 9px; }\n.docGenerated.wide b { font-size: 12px; -webkit-line-clamp: 2; }\n.docGenerated.wide i { font-size: 8px; }\n.edge { stroke: var(--edge) !important; }\n.edge.cross { stroke: var(--edge-soft) !important; }\n.edge.hot { stroke: var(--blue) !important; }\n.pill rect { fill: var(--surface) !important; stroke: var(--line) !important; }\n.pill text { fill: var(--secondary) !important; }\n.pill.hot rect { fill: var(--blue-soft) !important; stroke: var(--blue) !important; }\n.pill.hot text { fill: var(--blue-deep) !important; }\n#controls button {\n background: var(--surface) !important; border-color: var(--line) !important;\n color: var(--ink-2) !important; box-shadow: var(--shadow) !important;\n}\n#controls button:hover { background: var(--bg-soft) !important; color: var(--ink) !important; }\n#controls button.on { background: var(--blue) !important; border-color: var(--blue) !important; color: var(--bg) !important; }\n#zoomLabel { color: var(--muted) !important; background: none !important; border: 0 !important;\n padding: 2px 4px !important; cursor: pointer; font-family: var(--mono); }\n#zoomLabel:hover { color: var(--ink) !important; }\n#panel { background: var(--surface) !important; border-left-color: var(--line) !important;\n box-shadow: -8px 0 28px color-mix(in srgb, var(--ink) 12%, transparent) !important; }\n#close { background: var(--surface) !important; border-color: var(--line) !important; color: var(--muted) !important; }\n#close:hover { background: var(--bg-soft) !important; color: var(--ink) !important; }\n.p-head { border-bottom-color: var(--line-2) !important; }\n.p-kicker { color: var(--muted) !important; }\n.p-head h2 { color: var(--ink) !important; }\n.p-field b { color: var(--muted) !important; }\n.p-field span, .p-doc { color: var(--secondary) !important; }\n.p-rel-title { color: var(--muted) !important; }\n.rel { background: var(--soft) !important; border-color: var(--line-2) !important; }\n.rel:hover { background: var(--bg-soft) !important; border-color: var(--line) !important; }\n.rel b { color: var(--ink) !important; }\n.rel span { color: var(--secondary) !important; }\n.p-docs { color: var(--muted) !important; }\n.p-doc-item { border-bottom-color: var(--line-2) !important; }\n.p-doc-item .m b { color: var(--ink) !important; }\n.p-doc-item .m span { color: var(--muted) !important; }\n.p-doc-thumb {\n display: flex; align-items: center; justify-content: center; flex: 0 0 auto;\n width: 34px; height: 46px; border-radius: 4px;\n border: 1px solid var(--line); background: var(--soft); color: var(--blue-deep);\n font: 600 8px/1 var(--mono); letter-spacing: .06em;\n}\n.p-doc h2, .p-doc h3 { color: var(--ink) !important; }\n.p-doc code { background: var(--soft) !important; border-color: var(--line-2) !important; }\n.p-doc pre { background: var(--bg) !important; border-color: var(--line-2) !important; }\n</style>\n</head>\n<body data-doc-layout=\"landscape\" data-view=\"graph\">\n<main id=\"main\">\n <div id=\"stage\">\n <div id=\"world\">\n <svg id=\"edges\" width=\"4200\" height=\"3200\" viewBox=\"0 0 4200 3200\" aria-hidden=\"true\"></svg>\n </div>\n </div>\n <div id=\"controls\" role=\"group\" aria-label=\"画布控制\">\n <button id=\"zin\" aria-label=\"放大\">+</button>\n <button id=\"zout\" aria-label=\"缩小\">−</button>\n <button id=\"fit\" aria-label=\"适应画布\">⤢</button>\n <button id=\"cross\" aria-label=\"显示交叉关系\" title=\"显示交叉关系\">⇄</button>\n <button id=\"relayout\" aria-label=\"重排\" title=\"重排\">↺</button>\n </div>\n <button id=\"zoomLabel\" type=\"button\" aria-label=\"重置缩放为 100%\" title=\"重置缩放为 100%\">100%</button>\n <div id=\"kg-empty\" hidden><strong>还没有可用的图谱</strong><span>在「知识图谱」文件夹里建好主题后,画布会出现在这里。</span></div>\n</main>\n\n<aside id=\"panel\" aria-label=\"主题详情\" aria-hidden=\"true\">\n <div class=\"p-head\">\n <div>\n <div class=\"p-kicker\" id=\"p-kicker\"></div>\n <h2 id=\"p-title\"></h2>\n </div>\n <button id=\"close\" aria-label=\"关闭\">×</button>\n </div>\n <div class=\"p-body\" id=\"p-body\"></div>\n</aside>\n<script>\nconst DATA = { graphs: [] };\n\n(function(){\n \"use strict\";\n const stage = document.getElementById('stage');\n const world = document.getElementById('world');\n const svg = document.getElementById('edges');\n const panel = document.getElementById('panel');\n const zoomLabel = document.getElementById('zoomLabel');\n\n const NS = 'http://www.w3.org/2000/svg';\n let topics = [];\n let links = [];\n let byId = new Map();\n let activeGraph = 0;\n let viewMode = 'graph';\n const cardEl = new Map();\n const view = {x:0, y:0, s:1};\n const CARD_W = 300, H_GAP = 110, V_GAP = 34, PAD = 40;\n const LAND = document.body.dataset.docLayout === 'landscape';\n\n let mode = null;\n let activeTopic = null;\n let activeLink = null;\n let userMoved = false;\n let children = new Map();\n let parent = new Map();\n let treeKeys = new Set();\n let pos = new Map();\n let heights = new Map(), subH = new Map();\n let showCross = false;\n let layoutRootId = null;\n\n function esc(s){\n return String(s == null ? '' : s).replace(/[&<>\"]/g, function(c){\n return {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;'}[c];\n });\n }\n function statusText(s){ return s === 'active' ? '进行中' : s === 'paused' ? '暂停' : '已完成'; }\n function linkKey(l){ return l.a + '--' + l.b; }\n function pairKey(a, b){ return a < b ? a + '--' + b : b + '--' + a; }\n\n // ---------- cards ----------\n\n // Documents in the knowledge tree have no stored thumbnail. A generated cover\n // uses the host's own theme variables (set from the parent document), so a light\n // host gets ink-on-white covers and a dark host gets the inverse. No bitmap and\n // no fixed palette: the cover is markup, not an image.\n function coverFor(d, idx, wide, w, h, dx, dy, rot, sc, z){\n const style = 'width:' + w + 'px;height:' + h + 'px;--dx:' + dx + 'px;--dy:' + dy +\n 'px;--rot:' + rot + 'deg;--sc:' + sc + ';z-index:' + z;\n if (d.thumb) {\n const src = (wide && d.thumbWide) ? d.thumbWide : d.thumb;\n return '<img class=\"doc\" src=\"' + src + '\" alt=\"' + esc(d.title) + '\" style=\"' + style + '\">';\n }\n const ext = String(d.file || 'doc').split('.').pop().slice(0, 4).toUpperCase();\n return '<div class=\"doc docGenerated' + (wide ? ' wide' : '') + '\" style=\"' + style + '\" title=\"' + esc(d.title) + '\">' +\n '<span class=\"docMark\">' + esc(ext) + '</span>' +\n '<b>' + esc(d.title || d.file || '') + '</b>' +\n '<i>' + esc(d.date || '') + '</i></div>';\n }\n\n // The detail panel renders the same cover inline, at a fixed 34x46 slot.\n function inlineCover(d){\n if (d.thumb) return '<img src=\"' + d.thumb + '\" alt=\"\">';\n const ext = String(d.file || 'doc').split('.').pop().slice(0, 4).toUpperCase();\n return '<span class=\"p-doc-thumb\"><i>' + esc(ext) + '</i></span>';\n }\n\n function buildCard(t){\n const el = document.createElement('article');\n el.className = 'card';\n el.dataset.id = t.id;\n el.tabIndex = 0;\n el.style.left = '0px';\n el.style.top = '0px';\n el.style.visibility = 'hidden';\n el.setAttribute('role', 'button');\n el.setAttribute('aria-label', t.title + ',' + statusText(t.status) + ',深度 ' + t.depth);\n\n const dots = [1,2,3,4,5].map(i =>\n '<i class=\"' + (i <= t.depth ? 'on' : '') + '\"></i>').join('');\n const docs = t.documents || [];\n const shown = docs.slice(0, 4);\n const mid = (shown.length - 1) / 2;\n let docW = 86, docH = 116, step = 31, rotStep = 6.5, dyStep = 2, scStep = 0.03, coverH = 124;\n if (LAND){\n if (shown.length <= 1){ docW = 240; docH = 84; step = 0; rotStep = 0; dyStep = 0; scStep = 0; coverH = 92; }\n else if (shown.length === 2){ docW = 142; docH = 78; step = 58; rotStep = 5; dyStep = 3; scStep = 0.02; coverH = 88; }\n else if (shown.length === 3){ docW = 132; docH = 76; step = 46; rotStep = 4.2; dyStep = 3; scStep = 0.02; coverH = 86; }\n else { docW = 118; docH = 70; step = 42; rotStep = 4; dyStep = 3; scStep = 0.02; coverH = 80; }\n }\n let stack = '';\n shown.forEach(function(d, i){\n const dist = Math.abs(i - mid);\n const dx = (i - mid) * step;\n const dy = dist * dyStep;\n const rot = (i - mid) * rotStep;\n const sc = 1 - dist * scStep;\n const z = 10 - dist;\n stack += coverFor(d, i, shown.length === 1, docW, docH, dx, dy, rot, sc, z);\n });\n if (docs.length > 4) stack += '<span class=\"more\">+' + (docs.length - 4) + '</span>';\n if (!docs.length) stack = '<div class=\"doc-empty\">docs/ 还是空的</div>';\n const tagList = [t.domain].concat(t.tags || []).filter(function(x, i, arr){\n return x && arr.indexOf(x) === i;\n }).slice(0, 4);\n const tags = tagList.map(function(x){ return '<span>' + esc(x) + '</span>'; }).join('');\n\n el.innerHTML =\n '<h3>' + esc(t.title) + '</h3>' +\n '<div class=\"cover\" style=\"height:' + coverH + 'px\"><div class=\"stack\">' + stack + '</div></div>' +\n '<p class=\"one\">' + esc(t.summary) + '</p>' +\n '<div class=\"tags\">' + tags + '</div>';\n\n el.addEventListener('pointerdown', onCardDown);\n el.addEventListener('keydown', function(e){\n if (e.key === 'Enter' || e.key === ' '){ e.preventDefault(); selectTopic(t.id); }\n });\n world.appendChild(el);\n cardEl.set(t.id, el);\n }\n\n // ---------- spanning tree ----------\n function degree(id){\n return links.filter(function(l){ return l.a === id || l.b === id; }).length;\n }\n function buildTree(rootId){\n children = new Map(); parent = new Map(); treeKeys = new Set();\n const visited = new Set([rootId]);\n const queue = [rootId];\n topics.forEach(function(t){ children.set(t.id, []); });\n while (queue.length){\n const id = queue.shift();\n const neighbors = [];\n links.forEach(function(l){\n if (l.a === id && !visited.has(l.b)) neighbors.push(l.b);\n else if (l.b === id && !visited.has(l.a)) neighbors.push(l.a);\n });\n neighbors.sort(function(a, b){ return degree(b) - degree(a); });\n neighbors.forEach(function(n){\n visited.add(n);\n parent.set(n, id);\n children.get(id).push(n);\n treeKeys.add(pairKey(id, n));\n queue.push(n);\n });\n }\n topics.forEach(function(t){\n if (!visited.has(t.id)){\n parent.set(t.id, rootId);\n children.get(rootId).push(t.id);\n treeKeys.add(pairKey(rootId, t.id));\n visited.add(t.id);\n }\n });\n }\n\n // ---------- mind-map layout ----------\n function calcSub(id){\n const h = heights.get(id) || 200;\n const kids = children.get(id) || [];\n if (!kids.length){ subH.set(id, h); return h; }\n let total = 0;\n kids.forEach(function(k, i){\n total += calcSub(k);\n if (i) total += V_GAP;\n });\n subH.set(id, Math.max(h, total));\n return subH.get(id);\n }\n\n function placeNode(id, x, yTop, dir){\n const h = heights.get(id) || 200;\n const sub = subH.get(id) || h;\n const cy = yTop + sub / 2;\n pos.set(id, {x:x, y:cy - h / 2, dir:dir});\n const kids = children.get(id) || [];\n let cursor = yTop;\n kids.forEach(function(k){\n const kh = subH.get(k) || 200;\n const kx = dir > 0 ? x + CARD_W + H_GAP : x - CARD_W - H_GAP;\n placeNode(k, kx, cursor, dir);\n cursor += kh + V_GAP;\n });\n }\n\n function layout(){\n heights = new Map();\n topics.forEach(function(t){ heights.set(t.id, cardEl.get(t.id).offsetHeight || 200); });\n\n let rootId = topics[0].id, best = -1;\n topics.forEach(function(t){\n const d = degree(t.id);\n if (d > best){ best = d; rootId = t.id; }\n });\n if (byId.has('dsh-enterprise-app')) rootId = 'dsh-enterprise-app';\n layoutRootId = rootId;\n buildTree(rootId);\n calcSub(rootId);\n\n const first = (children.get(rootId) || []).slice();\n first.sort(function(a, b){ return (subH.get(b) || 0) - (subH.get(a) || 0); });\n\n const left = [], right = [];\n let lh = 0, rh = 0;\n first.forEach(function(id){\n if (rh <= lh){ right.push(id); rh += subH.get(id); }\n else { left.push(id); lh += subH.get(id); }\n });\n\n const rootH = heights.get(rootId);\n pos.set(rootId, {x:0, y:-rootH / 2, dir:0});\n\n function placeSide(list, dir){\n let total = 0;\n list.forEach(function(id, i){ total += subH.get(id); if (i) total += V_GAP; });\n let cursor = -total / 2;\n list.forEach(function(id){\n const kh = subH.get(id);\n const x = dir > 0 ? CARD_W + H_GAP : -CARD_W - H_GAP;\n placeNode(id, x, cursor, dir);\n cursor += kh + V_GAP;\n });\n }\n placeSide(left, -1);\n placeSide(right, 1);\n\n let minX = Infinity, minY = Infinity;\n pos.forEach(function(p){ minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); });\n pos.forEach(function(p, id){\n p.x = p.x - minX + PAD;\n p.y = p.y - minY + PAD;\n const t = byId.get(id);\n t.x = p.x; t.y = p.y;\n const el = cardEl.get(id);\n el.style.left = p.x + 'px';\n el.style.top = p.y + 'px';\n el.style.visibility = '';\n });\n }\n\n // ---------- edges & pills ----------\n function center(id){\n const p = pos.get(id), h = heights.get(id) || 200;\n return {x:p.x + CARD_W / 2, y:p.y + h / 2};\n }\n function anchor(id, tx, ty){\n const c = center(id), h = heights.get(id) || 200;\n const dx = tx - c.x, dy = ty - c.y;\n if (!dx && !dy) return c;\n if (Math.abs(dx) * h > Math.abs(dy) * CARD_W){\n const k = (CARD_W / 2) / Math.abs(dx);\n return {x:c.x + dx * k, y:c.y + dy * k};\n }\n const k = (h / 2) / Math.abs(dy);\n return {x:c.x + dx * k, y:c.y + dy * k};\n }\n function qpoint(p0, c, p1, t){\n const u = 1 - t;\n return {x:u*u*p0.x + 2*u*t*c.x + t*t*p1.x, y:u*u*p0.y + 2*u*t*c.y + t*t*p1.y};\n }\n function cpoint(p0, c1, c2, p1, t){\n const u = 1 - t, a = u*u*u, b = 3*u*u*t, c = 3*u*t*t, d = t*t*t;\n return {x:a*p0.x + b*c1.x + c*c2.x + d*p1.x,\n y:a*p0.y + b*c1.y + c*c2.y + d*p1.y};\n }\n function hitsCard(x, y, w, h){\n for (let i = 0; i < topics.length; i++){\n const t = topics[i], th = heights.get(t.id) || 200;\n if (x < t.x + CARD_W + 6 && x + w > t.x - 6 &&\n y < t.y + th + 6 && y + h > t.y - 6) return true;\n }\n return false;\n }\n function freePointOnQuad(p0, c, p1){\n const ts = [0.5, 0.4, 0.6, 0.3, 0.7, 0.22, 0.78];\n for (let i = 0; i < ts.length; i++){\n const p = qpoint(p0, c, p1, ts[i]);\n if (!hitsCard(p.x, p.y)) return p;\n }\n return qpoint(p0, c, p1, 0.5);\n }\n function freePointOnCubic(p0, c1, c2, p1){\n const ts = [0.5, 0.42, 0.58, 0.34, 0.66, 0.26, 0.74];\n for (let i = 0; i < ts.length; i++){\n const p = cpoint(p0, c1, c2, p1, ts[i]);\n if (!hitsCard(p.x, p.y)) return p;\n }\n return cpoint(p0, c1, c2, p1, 0.5);\n }\n\n function overlaps(p, w, placed){\n for (let i = 0; i < placed.length; i++){\n const q = placed[i];\n if (Math.abs(p.x - q.x) < (w + q.w) / 2 + 8 && Math.abs(p.y - q.y) < 24) return true;\n }\n return false;\n }\n function placePill(sample, w, placed){\n const ts = [0.5, 0.56, 0.44, 0.62, 0.38, 0.68, 0.32, 0.74, 0.26, 0.8];\n for (let i = 0; i < ts.length; i++){\n const p = sample(ts[i]);\n if (!hitsCard(p.x - w / 2, p.y - 10, w, 20) && !overlaps(p, w, placed)){\n placed.push({x:p.x, y:p.y, w:w});\n return p;\n }\n }\n const p = sample(0.5);\n placed.push({x:p.x, y:p.y, w:w});\n return p;\n }\n\n function drawEdges(){\n while (svg.firstChild) svg.removeChild(svg.firstChild);\n const placed = [];\n\n links.forEach(function(l){\n const a = byId.get(l.a), b = byId.get(l.b);\n if (!a || !b) return;\n const key = linkKey(l);\n const isTree = treeKeys.has(pairKey(l.a, l.b));\n const isLink = mode === 'link' && activeLink === key;\n const isTopic = mode === 'topic' && (l.a === activeTopic || l.b === activeTopic);\n const hot = isLink || isTopic;\n const cold = !!mode && !hot;\n if (!isTree && !showCross && !hot) return;\n\n let d, sample;\n if (isTree){\n const pid = parent.get(l.b) === l.a ? l.a : l.b;\n const cid = pid === l.a ? l.b : l.a;\n const pp = pos.get(pid), pc = center(pid), cc = center(cid);\n const dir = pp.dir || (cc.x >= pc.x ? 1 : -1);\n const x1 = dir > 0 ? pp.x + CARD_W : pp.x;\n const x2 = dir > 0 ? pos.get(cid).x : pos.get(cid).x + CARD_W;\n const y1 = pc.y, y2 = cc.y;\n const bend = Math.max(34, Math.abs(x2 - x1) * 0.42);\n const p0 = {x:x1, y:y1};\n const c1 = {x:x1 + dir * bend, y:y1};\n const c2 = {x:x2 - dir * bend, y:y2};\n const p1 = {x:x2, y:y2};\n d = 'M' + x1 + ',' + y1 + ' C' + c1.x + ',' + c1.y + ' ' + c2.x + ',' + c2.y + ' ' + x2 + ',' + y2;\n sample = function(t){ return cpoint(p0, c1, c2, p1, t); };\n } else {\n const pa = pos.get(l.a), pb = pos.get(l.b);\n let p1, p2, c;\n if (pa.dir === pb.dir && pa.dir !== 0){\n const dir = pa.dir;\n p1 = {x: dir < 0 ? pa.x + CARD_W : pa.x, y: center(l.a).y};\n p2 = {x: dir < 0 ? pb.x + CARD_W : pb.x, y: center(l.b).y};\n const outX = dir < 0 ? Math.max(p1.x, p2.x) + 64 : Math.min(p1.x, p2.x) - 64;\n c = {x: outX, y: (p1.y + p2.y) / 2};\n } else {\n const ca = center(l.a), cb = center(l.b);\n p1 = {x: ca.x, y: pa.y};\n p2 = {x: cb.x, y: pb.y};\n c = {x: (p1.x + p2.x) / 2, y: Math.min(p1.y, p2.y) - 112};\n }\n d = 'M' + p1.x + ',' + p1.y + ' Q' + c.x + ',' + c.y + ' ' + p2.x + ',' + p2.y;\n sample = function(t){ return qpoint(p1, c, p2, t); };\n }\n\n const path = document.createElementNS(NS, 'path');\n path.setAttribute('d', d);\n path.setAttribute('class', 'edge' + (isTree ? '' : ' cross') + (hot ? ' hot' : cold ? ' cold' : ''));\n svg.appendChild(path);\n\n const label = l.label || (a.title + ' ↔ ' + b.title);\n const w = Math.max(42, label.length * 10.8 + 16);\n const pillAt = placePill(sample, w, placed);\n\n const g = document.createElementNS(NS, 'g');\n g.setAttribute('class', 'pill' + (hot ? ' hot' : cold ? ' cold' : ''));\n g.setAttribute('data-key', key);\n g.setAttribute('tabindex', '0');\n g.setAttribute('role', 'button');\n g.setAttribute('aria-label', '关系:' + label);\n\n const rect = document.createElementNS(NS, 'rect');\n rect.setAttribute('x', pillAt.x - w / 2); rect.setAttribute('y', pillAt.y - 10);\n rect.setAttribute('width', w); rect.setAttribute('height', 20);\n rect.setAttribute('rx', 10);\n const text = document.createElementNS(NS, 'text');\n text.setAttribute('x', pillAt.x); text.setAttribute('y', pillAt.y + 3.6);\n text.setAttribute('text-anchor', 'middle');\n text.textContent = label;\n\n g.appendChild(rect); g.appendChild(text);\n g.addEventListener('click', function(e){ e.stopPropagation(); selectLink(key); });\n g.addEventListener('keydown', function(e){\n if (e.key === 'Enter' || e.key === ' '){ e.preventDefault(); selectLink(key); }\n });\n svg.appendChild(g);\n });\n }\n\n // ---------- camera ----------\n function apply(){\n world.style.transform = 'translate(' + view.x + 'px,' + view.y + 'px) scale(' + view.s + ')';\n zoomLabel.textContent = Math.round(view.s * 100) + '%';\n }\n function clampScale(s){ return Math.max(0.22, Math.min(2.0, s)); }\n function zoomAt(px, py, k){\n const s2 = clampScale(view.s * k);\n if (s2 === view.s) return;\n view.x = px - (px - view.x) * (s2 / view.s);\n view.y = py - (py - view.y) * (s2 / view.s);\n view.s = s2;\n apply(); userMoved = true;\n }\n function bbox(){\n let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity;\n topics.forEach(function(t){\n const h = heights.get(t.id) || 200;\n x1 = Math.min(x1, t.x); y1 = Math.min(y1, t.y);\n x2 = Math.max(x2, t.x + CARD_W); y2 = Math.max(y2, t.y + h);\n });\n return {x1:x1, y1:y1, x2:x2, y2:y2};\n }\n function fit(animate){\n const b = bbox();\n const pad = 70;\n const vw = stage.clientWidth, vh = stage.clientHeight;\n const s = clampScale(Math.min((vw - pad * 2) / (b.x2 - b.x1), (vh - pad * 2) / (b.y2 - b.y1), 1));\n const nx = (vw - (b.x2 - b.x1) * s) / 2 - b.x1 * s;\n const ny = (vh - (b.y2 - b.y1) * s) / 2 - b.y1 * s;\n if (animate) tweenTo(nx, ny, s); else { view.x = nx; view.y = ny; view.s = s; apply(); }\n userMoved = false;\n }\n let raf = null;\n function tweenTo(x2, y2, s2){\n cancelAnimationFrame(raf);\n const x1 = view.x, y1 = view.y, s1 = view.s, t0 = performance.now(), dur = 320;\n function step(now){\n const p = Math.min(1, (now - t0) / dur);\n const e = 1 - Math.pow(1 - p, 3);\n view.x = x1 + (x2 - x1) * e;\n view.y = y1 + (y2 - y1) * e;\n view.s = s1 + (s2 - s1) * e;\n apply();\n if (p < 1) raf = requestAnimationFrame(step);\n }\n raf = requestAnimationFrame(step);\n }\n function centerOnScale(id, s, animate){\n const t = byId.get(id); if (!t) return;\n const h = heights.get(id) || 200;\n const x = stage.clientWidth / 2 - (t.x + CARD_W / 2) * s;\n const y = stage.clientHeight / 2 - (t.y + h / 2) * s;\n if (animate) tweenTo(x, y, s);\n else { view.x = x; view.y = y; view.s = s; apply(); }\n userMoved = true;\n }\n\n // A topic jump keeps whatever zoom the reader chose; only the explicit reset\n // control and a graph switch change the scale.\n function centerOn(id){\n centerOnScale(id, view.s, true);\n }\n\n function resetZoom(){\n const target = (mode === 'topic' && activeTopic) ? activeTopic : layoutRootId;\n centerOnScale(target, 1, true);\n }\n\n // ---------- pan & zoom ----------\n let panning = false, panMoved = false, px = 0, py = 0;\n stage.addEventListener('pointerdown', function(e){\n if (e.target.closest('.card') || e.target.closest('.pill') || e.target.closest('#panel')) return;\n panning = true; panMoved = false; px = e.clientX; py = e.clientY;\n stage.classList.add('grabbing');\n stage.setPointerCapture(e.pointerId);\n });\n stage.addEventListener('pointermove', function(e){\n if (!panning) return;\n if (Math.abs(e.clientX - px) + Math.abs(e.clientY - py) > 3) panMoved = true;\n view.x += e.clientX - px; view.y += e.clientY - py;\n px = e.clientX; py = e.clientY;\n apply(); userMoved = true;\n });\n function endPan(e){\n if (!panning) return;\n panning = false; stage.classList.remove('grabbing');\n try { stage.releasePointerCapture(e.pointerId); } catch(_){}\n if (!panMoved && mode) closePanel();\n }\n stage.addEventListener('pointerup', endPan);\n stage.addEventListener('pointercancel', endPan);\n stage.addEventListener('wheel', function(e){\n e.preventDefault();\n zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.0011));\n }, {passive:false});\n document.getElementById('zin').addEventListener('click', function(){\n zoomAt(stage.clientWidth / 2, stage.clientHeight / 2, 1.22);\n });\n document.getElementById('zout').addEventListener('click', function(){\n zoomAt(stage.clientWidth / 2, stage.clientHeight / 2, 1 / 1.22);\n });\n document.getElementById('fit').addEventListener('click', function(){ fit(true); });\n document.getElementById('zoomLabel').addEventListener('click', resetZoom);\n document.getElementById('relayout').addEventListener('click', function(){\n layout(); fit(true); drawEdges();\n });\n document.getElementById('cross').addEventListener('click', function(){\n showCross = !showCross;\n this.classList.toggle('on', showCross);\n this.setAttribute('aria-pressed', showCross ? 'true' : 'false');\n drawEdges();\n });\n stage.addEventListener('dblclick', function(e){ if (!e.target.closest('.card')) fit(true); });\n\n // ---------- card drag ----------\n let drag = null;\n function onCardDown(e){\n if (e.button !== 0) return;\n e.stopPropagation();\n const id = e.currentTarget.dataset.id;\n const t = byId.get(id);\n const el = cardEl.get(id);\n drag = {id:id, sx:e.clientX, sy:e.clientY, ox:t.x, oy:t.y, moved:false, el:el};\n el.classList.add('dragging');\n window.addEventListener('pointermove', onDragMove);\n window.addEventListener('pointerup', onDragUp);\n }\n function onDragMove(e){\n if (!drag) return;\n const dx = (e.clientX - drag.sx) / view.s;\n const dy = (e.clientY - drag.sy) / view.s;\n if (Math.abs(dx) + Math.abs(dy) > 3) drag.moved = true;\n const t = byId.get(drag.id);\n t.x = drag.ox + dx; t.y = drag.oy + dy;\n const p = pos.get(drag.id); p.x = t.x; p.y = t.y;\n drag.el.style.left = t.x + 'px';\n drag.el.style.top = t.y + 'px';\n drawEdges();\n }\n function onDragUp(){\n window.removeEventListener('pointermove', onDragMove);\n window.removeEventListener('pointerup', onDragUp);\n if (!drag) return;\n drag.el.classList.remove('dragging');\n const id = drag.id, moved = drag.moved;\n drag = null;\n if (!moved) selectTopic(id);\n userMoved = true;\n }\n\n // ---------- selection ----------\n function paint(){\n cardEl.forEach(function(el, cid){\n let sel = false, dim = false;\n if (mode === 'topic'){ sel = cid === activeTopic; dim = !sel; }\n else if (mode === 'link'){\n const l = links.find(function(x){ return linkKey(x) === activeLink; });\n sel = !!l && (cid === l.a || cid === l.b);\n dim = !sel;\n }\n el.classList.toggle('selected', sel);\n el.classList.toggle('dim', dim);\n });\n }\n function notifySelection(topicId){\n try {\n parent.postMessage({\n type: 'kg:selection',\n graphId: (DATA.graphs[activeGraph] || {}).id || null,\n topicId: topicId || null\n }, '*');\n } catch (_) {}\n }\n function selectTopic(id){\n if (mode === 'topic' && activeTopic === id){ closePanel(); return; }\n mode = 'topic'; activeTopic = id; activeLink = null;\n paint(); drawEdges(); openTopicPanel(id); setHash(id);\n notifySelection(id);\n }\n function selectLink(key){\n if (mode === 'link' && activeLink === key){ closePanel(); return; }\n mode = 'link'; activeLink = key; activeTopic = null;\n paint(); drawEdges(); openLinkPanel(key); setHash(key);\n }\n function closePanel(){\n mode = null; activeTopic = null; activeLink = null;\n paint(); drawEdges();\n panel.classList.remove('open');\n panel.setAttribute('aria-hidden', 'true');\n document.body.classList.remove('panel-open');\n setHash(null);\n notifySelection(null);\n }\n document.getElementById('close').addEventListener('click', closePanel);\n document.addEventListener('keydown', function(e){ if (e.key === 'Escape') closePanel(); });\n\n function showPanel(kicker, title, html){\n document.getElementById('p-kicker').textContent = kicker;\n document.getElementById('p-title').textContent = title;\n const body = document.getElementById('p-body');\n body.innerHTML = html;\n body.scrollTop = 0;\n body.querySelectorAll('.rel').forEach(function(btn){\n btn.addEventListener('click', function(){\n const to = btn.dataset.to;\n selectTopic(to); centerOn(to);\n });\n });\n panel.classList.add('open');\n panel.setAttribute('aria-hidden', 'false');\n document.body.classList.add('panel-open');\n }\n function inline(s){\n s = esc(s);\n s = s.replace(/`([^`]+)`/g, '<code>$1</code>');\n s = s.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>');\n s = s.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, '<a href=\"$2\" target=\"_blank\" rel=\"noopener\">$1</a>');\n return s;\n }\n function renderMarkdown(md){\n const lines = String(md || '').split('\\n');\n let html = '', inCode = false, code = [], list = null;\n function closeList(){ if (list){ html += '</' + list + '>'; list = null; } }\n lines.forEach(function(line){\n if (/^```/.test(line)){\n if (inCode){ html += '<pre><code>' + esc(code.join('\\n')) + '</code></pre>'; code = []; inCode = false; }\n else { closeList(); inCode = true; }\n return;\n }\n if (inCode){ code.push(line); return; }\n if (!line.trim()){ closeList(); return; }\n let m;\n if ((m = line.match(/^(#{1,3})\\s+(.*)$/))){\n closeList();\n const lv = Math.min(3, m[1].length);\n html += '<h' + lv + '>' + inline(m[2]) + '</h' + lv + '>';\n } else if ((m = line.match(/^\\s*[-*]\\s+(.*)$/))){\n if (list !== 'ul'){ closeList(); html += '<ul>'; list = 'ul'; }\n html += '<li>' + inline(m[1]) + '</li>';\n } else if ((m = line.match(/^\\s*\\d+\\.\\s+(.*)$/))){\n if (list !== 'ol'){ closeList(); html += '<ol>'; list = 'ol'; }\n html += '<li>' + inline(m[1]) + '</li>';\n } else if ((m = line.match(/^>\\s?(.*)$/))){\n closeList();\n html += '<blockquote>' + inline(m[1]) + '</blockquote>';\n } else {\n closeList();\n html += '<p>' + inline(line) + '</p>';\n }\n });\n if (inCode) html += '<pre><code>' + esc(code.join('\\n')) + '</code></pre>';\n closeList();\n return html;\n }\n function field(k, v){\n return '<div class=\"p-field\"><b>' + k + '</b><span>' + esc(v || '未登记') + '</span></div>';\n }\n function openTopicPanel(id){\n const t = byId.get(id);\n const rels = links.filter(function(l){ return l.a === id || l.b === id; });\n let html = '<div class=\"p-doc\">' + renderMarkdown(t.body || t.summary || '') + '</div>';\n if (t.documents && t.documents.length){\n html += '<div class=\"p-rel-title\">文档 · ' + t.documents.length + ' 篇</div>';\n t.documents.forEach(function(d){\n html += '<div class=\"p-doc-item\">' + inlineCover(d) + '<div class=\"m\">' +\n '<b>' + esc(d.title) + '</b><span>' + esc(d.file) + ' · ' + esc(d.date) + '</span></div></div>';\n });\n }\n html += '<div class=\"p-rel-title\">关系 · ' + rels.length + ' 篇</div>';\n rels.forEach(function(l){\n const other = l.a === id ? l.b : l.a;\n const o = byId.get(other);\n html += '<button class=\"rel\" data-to=\"' + esc(other) + '\"><b>' + esc(o ? o.title : other) +\n '</b><span>' + esc(l.body || '') + '</span></button>';\n });\n if (!rels.length) html += '<div class=\"p-docs\">还没有关系文档。孤立的线价值很低。</div>';\n html += '<div class=\"p-docs\">文档:' + ((t.documents || []).length) + ' 个文件</div>';\n showPanel((t.domain ? t.domain + ' · ' : '') + statusText(t.status) + ' · depth ' + t.depth + '/5', t.title, html);\n }\n function openLinkPanel(key){\n const l = links.find(function(x){ return linkKey(x) === key; });\n if (!l) return;\n const a = byId.get(l.a), b = byId.get(l.b);\n let html = field('关系', l.body);\n html += '<div class=\"p-rel-title\">两端</div>';\n html += '<button class=\"rel\" data-to=\"' + esc(l.a) + '\"><b>' + esc(a ? a.title : l.a) +\n '</b><span>' + esc(a ? a.summary : '') + '</span></button>';\n html += '<button class=\"rel\" data-to=\"' + esc(l.b) + '\"><b>' + esc(b ? b.title : l.b) +\n '</b><span>' + esc(b ? b.summary : '') + '</span></button>';\n showPanel('关系文档' + (l.updated ? ' · ' + l.updated : ''),\n (a ? a.title : l.a) + ' ↔ ' + (b ? b.title : l.b), html);\n }\n function setHash(v){\n try { history.replaceState(null, '', v ? '#' + v : location.pathname + location.search); } catch(_){}\n }\n\n // ---------- graph switching ----------\n function loadGraph(idx){\n const g = DATA.graphs[idx];\n if (!g) return;\n closePanel();\n activeGraph = idx;\n topics = g.topics;\n links = g.links;\n byId = new Map(topics.map(function(t){ return [t.id, t]; }));\n cardEl.forEach(function(el){ el.remove(); });\n cardEl.clear();\n while (svg.firstChild) svg.removeChild(svg.firstChild);\n heights = new Map(); subH = new Map(); pos = new Map();\n\n topics.forEach(buildCard);\n layout();\n apply();\n\n requestAnimationFrame(function(){\n // First entry and every graph switch start at 1:1. The reader asked for a\n // true-scale canvas; fitting the whole graph on open made every card's\n // 12-13px copy render at 64%. Panning still reaches the rest.\n centerOnScale(layoutRootId, 1, false);\n drawEdges();\n });\n }\n\n // ---------- the embedding host ----------\n const emptyEl = document.getElementById('kg-empty');\n\n // The host's alias tokens, read from the parent document (same origin) and kept\n // live by the panel's postMessage. Reading the parent on boot avoids a flash of\n // the wrong scheme before the first message; the message is still required for\n // a later theme switch.\n const THEME_TOKENS = [\n '--dsw-alias-bg-base', '--dsw-alias-bg-layer-1', '--dsw-alias-bg-layer-2',\n '--dsw-alias-label-primary', '--dsw-alias-label-secondary', '--dsw-alias-label-tertiary',\n '--dsw-alias-border-l1', '--dsw-alias-border-l2',\n '--dsw-alias-state-business-primary', '--dsw-alias-link',\n '--dsw-alias-state-success-primary', '--dsw-alias-state-warn-primary',\n '--dsw-elevation-panel'\n ];\n\n function readHostTheme(){\n try {\n const doc = parent.document;\n const bodyStyle = parent.getComputedStyle(doc.body);\n const rootStyle = parent.getComputedStyle(doc.documentElement);\n const tokens = {};\n THEME_TOKENS.forEach(function(name){\n const value = (bodyStyle.getPropertyValue(name) || rootStyle.getPropertyValue(name) || '').trim();\n if (value !== '') tokens[name] = value;\n });\n return { tokens: tokens, dark: doc.body.hasAttribute('data-ds-dark-theme') };\n } catch (_) {\n return null;\n }\n }\n\n function applyTheme(theme){\n if (!theme || !theme.tokens) return;\n const root = document.documentElement;\n Object.keys(theme.tokens).forEach(function(name){\n root.style.setProperty(name, theme.tokens[name]);\n });\n root.style.colorScheme = theme.dark ? 'dark' : 'light';\n document.body.toggleAttribute('data-ds-dark-theme', !!theme.dark);\n }\n\n applyTheme(readHostTheme());\n\n function showEmpty(show){\n if (emptyEl) emptyEl.hidden = !show;\n const stageEl = document.getElementById('stage');\n if (stageEl) stageEl.style.visibility = show ? 'hidden' : 'visible';\n const controlsEl = document.getElementById('controls');\n if (controlsEl) controlsEl.hidden = show;\n const zoomEl = document.getElementById('zoomLabel');\n if (zoomEl) zoomEl.hidden = show;\n }\n\n function postReady(){\n try { parent.postMessage({ type: 'kg:ready' }, '*'); } catch (_) {}\n }\n\n function applyPayload(payload){\n if (!payload || !Array.isArray(payload.graphs)) return;\n DATA.graphs = payload.graphs;\n if (!DATA.graphs.length){\n showEmpty(true);\n postReady();\n return;\n }\n showEmpty(false);\n const wanted = payload.activeGraphId\n ? DATA.graphs.findIndex(function(g){ return g.id === payload.activeGraphId; })\n : -1;\n const idx = wanted >= 0 ? wanted : 0;\n loadGraph(idx);\n if (payload.selectedTopicId){\n requestAnimationFrame(function(){ focusTopic(payload.selectedTopicId); });\n }\n postReady();\n }\n\n function focusTopic(id){\n const t = byId.get(id);\n if (!t) return;\n selectTopic(id);\n centerOn(id);\n }\n\n function selectGraphById(id){\n const idx = DATA.graphs.findIndex(function(g){ return g.id === id; });\n if (idx < 0) return;\n showEmpty(false);\n loadGraph(idx);\n }\n\n window.addEventListener('message', function(event){\n const msg = event.data || {};\n if (msg.type === 'kg:set-graphs') applyPayload(msg);\n else if (msg.type === 'kg:select-graph') selectGraphById(msg.graphId);\n else if (msg.type === 'kg:select-topic') focusTopic(msg.topicId);\n else if (msg.type === 'kg:theme') applyTheme(msg);\n });\n\n showEmpty(true);\n postReady();\n window.addEventListener('resize', function(){\n if (!userMoved) centerOnScale(layoutRootId, view.s, false);\n drawEdges();\n });\n\n window.__KG_VIEW__ = {data:DATA, selectTopic:selectTopic, selectLink:selectLink, fit:fit};\n})();\n<\/script>\n</body>\n</html>\n";
1698
+ const GRAPH_CANVAS_HTML = "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>知识图谱 · 画布</title>\n<style>\n:root{\n --bg:#fbfbfc;\n --bg-soft:#f6f7f9;\n --surface:#ffffff;\n --ink:#0f1115;\n --ink-2:#3c3f47;\n --secondary:#61666b;\n --muted:#81858c;\n --line:rgba(0,0,0,.10);\n --line-2:rgba(0,0,0,.06);\n --blue:#4176e6;\n --blue-deep:#2555c4;\n --blue-soft:#eef4ff;\n --green:#22c55e;\n --amber:#f59e0b;\n --radius:12px;\n --shadow:0 1px 2px rgba(15,17,21,.04), 0 8px 24px rgba(15,17,21,.06);\n --sans:-apple-system,BlinkMacSystemFont,\"Segoe UI\",\"PingFang SC\",\"Hiragino Sans GB\",\"Microsoft YaHei\",sans-serif;\n --mono:\"SF Mono\",\"JetBrains Mono\",Menlo,Consolas,monospace;\n}\n*{box-sizing:border-box}\nhtml,body{height:100%}\nbody{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);\n font-size:14px;line-height:1.6;overflow:hidden;-webkit-font-smoothing:antialiased}\nbutton{font-family:inherit;color:inherit}\n\n#stage{position:fixed;left:248px;right:0;top:0;bottom:0;overflow:hidden;cursor:grab;\n background:\n radial-gradient(900px 620px at 50% 38%, #ffffff 0%, rgba(255,255,255,0) 70%),\n var(--bg)}\n#stage::before{content:\"\";position:absolute;inset:0;pointer-events:none;\n background-image:radial-gradient(rgba(15,17,21,.055) 1px, transparent 1px);\n background-size:22px 22px;\n -webkit-mask-image:radial-gradient(ellipse 70% 70% at 50% 45%, #000 0%, transparent 78%);\n mask-image:radial-gradient(ellipse 70% 70% at 50% 45%, #000 0%, transparent 78%)}\n#stage.grabbing{cursor:grabbing}\n#world{position:absolute;left:0;top:0;transform-origin:0 0;will-change:transform}\n#edges{position:absolute;left:0;top:0;overflow:visible;pointer-events:none}\n\n#topbar{position:fixed;left:248px;right:0;top:0;display:flex;align-items:flex-start;justify-content:space-between;\n padding:18px 22px;pointer-events:none;z-index:10}\n#brand h1{margin:0;font-size:16px;font-weight:600;letter-spacing:-.01em}\n#brand p{margin:4px 0 0;font-size:12px;color:var(--muted)}\n#brand b{color:var(--secondary);font-weight:500}\n#hint{font-size:11.5px;color:var(--muted);text-align:right;line-height:1.9;transition:opacity .2s}\n#hint kbd{font-family:var(--mono);font-size:10.5px;border:1px solid var(--line);border-radius:4px;\n padding:1px 5px;color:var(--secondary);background:var(--surface)}\nbody.panel-open #hint{opacity:0}\n\n.card{position:absolute;width:300px;padding:15px 16px 13px;border-radius:var(--radius);cursor:grab;\n user-select:none;background:var(--surface);border:1px solid var(--line);box-shadow:var(--shadow);\n transition:border-color .16s,box-shadow .16s,opacity .16s}\n.card:hover{border-color:rgba(0,0,0,.18)}\n.card.dragging{cursor:grabbing;transition:none}\n.card.selected{border-color:var(--blue);\n box-shadow:0 0 0 2px rgba(65,118,230,.14),0 10px 28px rgba(15,17,21,.10)}\n.card.dim{opacity:.38}\n.card:focus-visible{outline:2px solid var(--blue);outline-offset:3px}\n.chip{font-size:11px;letter-spacing:.02em;color:var(--secondary);background:var(--bg-soft);\n border:1px solid var(--line-2);border-radius:6px;padding:1px 7px}\n.status{display:inline-flex;align-items:center;gap:6px;font-size:11px;color:var(--muted)}\n.dot{width:6px;height:6px;border-radius:50%;background:var(--green)}\n.dot.paused{background:var(--amber)}\n.dot.done{background:#c4c7cc}\n.card h3{margin:0 0 9px;font-size:17px;font-weight:600;line-height:1.35;letter-spacing:-.01em}\n.cover{position:relative;height:124px;margin:0 0 10px}\nbody[data-doc-layout=\"landscape\"] .cover{height:86px;margin-bottom:9px}\nbody[data-doc-layout=\"landscape\"] .doc{width:132px;height:76px;border-radius:5px}\nbody[data-doc-layout=\"landscape\"] .doc-empty{width:140px;height:80px}\n.stack{position:absolute;inset:0}\n.doc{position:absolute;left:50%;top:50%;width:86px;height:116px;border-radius:6px;\n border:1px solid rgba(0,0,0,.08);box-shadow:0 8px 18px rgba(15,17,21,.14);background:#fff;\n object-fit:cover;\n transform:translate(-50%,-50%) translate(var(--dx),var(--dy)) rotate(var(--rot)) scale(var(--sc));\n transition:transform .22s cubic-bezier(.22,1,.36,1)}\n.cover:hover .doc{transform:translate(-50%,-50%) translate(calc(var(--dx) * 1.4),var(--dy)) rotate(calc(var(--rot) * 1.35)) scale(var(--sc))}\n.doc-empty{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:96px;height:120px;\n border:1px dashed var(--line);border-radius:6px;display:flex;align-items:center;justify-content:center;\n color:#b3b7bd;font-size:10.5px;background:var(--bg-soft)}\n.more{position:absolute;right:2px;bottom:0;font-size:10px;color:var(--muted);background:var(--surface);\n border:1px solid var(--line-2);border-radius:999px;padding:0 6px}\n.tags{display:flex;flex-wrap:wrap;gap:5px;margin-top:9px;margin-bottom:2px}\n.tags span{font-size:10px;color:var(--secondary);background:var(--bg-soft);border:1px solid var(--line-2);\n border-radius:999px;padding:1px 7px}\n.one{margin:0;font-size:12.5px;line-height:1.65;color:var(--ink-2);\n display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}\n\n.dots{display:inline-flex;gap:3px}\n.dots i{width:5px;height:5px;border-radius:50%;background:#dcdfe3}\n.dots i.on{background:var(--ink)}\n\n.edge{fill:none;stroke:rgba(15,17,21,.32);stroke-width:1.2}\n.edge.cross{stroke:rgba(15,17,21,.18);stroke-dasharray:4 6}\n.edge.hot{stroke:var(--blue);stroke-width:1.6}\n.edge.cold{opacity:.22}\n\n.pill{cursor:pointer;pointer-events:auto}\n.pill rect{fill:var(--surface);stroke:var(--line);stroke-width:1;transition:stroke .16s,fill .16s}\n.pill text{font:10.5px var(--sans);fill:var(--secondary);letter-spacing:.01em}\n.pill:hover rect{stroke:rgba(0,0,0,.28)}\n.pill.hot rect{stroke:var(--blue);fill:var(--blue-soft)}\n.pill.hot text{fill:var(--blue-deep)}\n.pill.cold{opacity:.25}\n.pill:focus-visible{outline:none}\n.pill:focus-visible rect{stroke:var(--blue);stroke-width:1.6}\n\n#kg-crumb{position:fixed;left:16px;top:14px;z-index:14;display:flex;align-items:center;gap:6px;\n max-width:calc(100% - 32px);padding:6px 11px;border-radius:999px;overflow:hidden;\n background:color-mix(in srgb,var(--surface) 78%,transparent);backdrop-filter:blur(10px);\n border:1px solid var(--line);pointer-events:auto}\n#kg-crumb .seg{border:0;background:none;padding:0;font-size:12px;color:var(--secondary);\n white-space:nowrap;cursor:pointer}\n#kg-crumb .seg:hover{color:var(--ink)}\n#kg-crumb .sep{color:var(--muted);font-size:11px}\n#kg-crumb b{color:var(--ink);font-weight:600;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}\n#controls{position:fixed;left:266px;bottom:18px;display:flex;flex-direction:column;gap:6px;z-index:12}\n#controls button{width:34px;height:34px;border-radius:8px;border:1px solid var(--line);\n background:var(--surface);color:var(--ink-2);font-size:15px;line-height:1;cursor:pointer;\n box-shadow:0 1px 2px rgba(15,17,21,.04);transition:background .15s,color .15s}\n#controls button:hover{background:var(--bg-soft);color:var(--ink)}\n#controls button.on{background:var(--ink);color:#fff;border-color:var(--ink)}\n#controls button:focus-visible{outline:2px solid var(--blue);outline-offset:2px}\n#zoomLabel{position:fixed;left:308px;bottom:23px;font-family:var(--mono);font-size:10.5px;\n color:var(--muted);z-index:12}\n\n#panel{position:fixed;top:0;right:0;width:392px;height:100%;z-index:30;\n background:var(--surface);border-left:1px solid var(--line);\n box-shadow:-8px 0 28px rgba(15,17,21,.06);\n transform:translateX(102%);transition:transform .28s cubic-bezier(.22,1,.36,1);\n display:flex;flex-direction:column}\n#panel.open{transform:none}\n.p-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;\n padding:22px 22px 15px;border-bottom:1px solid var(--line-2)}\n.p-kicker{font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}\n.p-head h2{margin:7px 0 0;font-size:22px;font-weight:600;line-height:1.35;letter-spacing:-.01em}\n#close{width:30px;height:30px;flex:0 0 auto;border-radius:8px;border:1px solid var(--line);\n background:var(--surface);color:var(--muted);font-size:16px;cursor:pointer;line-height:1}\n#close:hover{color:var(--ink);background:var(--bg-soft)}\n.p-body{padding:16px 22px 40px;overflow:auto}\n.p-field{margin:0 0 14px}\n.p-field b{display:block;font-size:11px;letter-spacing:.08em;text-transform:uppercase;\n color:var(--muted);font-weight:500;margin-bottom:3px}\n.p-field span{display:block;font-size:13px;line-height:1.7;color:var(--ink-2)}\n.p-rel-title{margin:24px 0 9px;font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}\n.rel{display:block;width:100%;text-align:left;background:var(--bg-soft);\n border:1px solid var(--line-2);border-radius:8px;padding:10px 11px;margin:0 0 7px;cursor:pointer;\n transition:background .15s,border-color .15s}\n.rel:hover{background:#f1f3f5;border-color:var(--line)}\n.rel b{display:block;font-size:12.5px;color:var(--ink);margin-bottom:2px;font-weight:600}\n.rel span{display:block;font-size:11.5px;line-height:1.6;color:var(--muted);\n display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}\n.p-docs{font-size:11.5px;color:var(--muted);margin-top:12px}\n.p-doc-item{display:flex;gap:10px;align-items:center;padding:7px 0;border-bottom:1px solid var(--line-2)}\n.p-doc-item img{width:34px;height:46px;border-radius:3px;border:1px solid var(--line-2);object-fit:cover;flex:0 0 auto}\n.p-doc-item .m b{display:block;font-size:12.5px;font-weight:600;color:var(--ink)}\n.p-doc-item .m span{display:block;font-size:10.5px;color:var(--muted)}\n.p-doc{font-size:13px;line-height:1.8;color:var(--ink-2)}\n.p-doc h2{font-size:14px;font-weight:600;color:var(--ink);margin:22px 0 6px}\n.p-doc h3{font-size:13px;font-weight:600;color:var(--ink);margin:18px 0 5px}\n.p-doc p{margin:0 0 10px}\n.p-doc ul,.p-doc ol{margin:0 0 10px;padding-left:18px}\n.p-doc li{margin:2px 0}\n.p-doc blockquote{margin:0 0 10px;padding:2px 0 2px 12px;border-left:2px solid var(--line);color:var(--muted)}\n.p-doc code{font-family:var(--mono);font-size:.86em;background:var(--bg-soft);border:1px solid var(--line-2);padding:0 4px;border-radius:4px}\n.p-doc pre{margin:0 0 10px;padding:10px 12px;background:var(--bg-soft);border:1px solid var(--line-2);border-radius:8px;overflow:auto}\n.p-doc pre code{background:none;border:0;padding:0}\n.p-doc strong{color:var(--ink);font-weight:600}\n.p-doc a{color:var(--blue);text-decoration:none}\n\n\n/* ---------- tree ---------- */\n#tree{position:fixed;left:0;top:0;bottom:0;width:248px;z-index:20;display:flex;flex-direction:column;\n background:var(--surface);border-right:1px solid var(--line)}\n.tree-head{padding:18px 16px 13px;border-bottom:1px solid var(--line-2)}\n.tree-title{font-size:14px;font-weight:600}\n.tree-sub{font-size:11px;color:var(--muted);margin-top:2px}\n#treeBody{flex:1;overflow:auto;padding:8px 8px 20px}\n.tree-scope{font-size:10.5px;letter-spacing:.1em;color:var(--muted);padding:12px 10px 5px}\n.tree-group{font-size:11.5px;color:var(--secondary);padding:6px 10px 3px}\n.tree-graph{display:flex;align-items:center;justify-content:space-between;gap:8px;\n padding:5px 10px;border-radius:6px;font-size:12.5px;color:var(--ink-2);cursor:pointer}\n.tree-graph:hover{background:var(--bg-soft)}\n.tree-graph.active{background:var(--blue-soft);color:var(--blue-deep)}\n.tree-graph .n{font-size:10.5px;color:var(--muted);font-variant-numeric:tabular-nums}\n.tree-graph.active .n{color:var(--blue)}\n.tree-topic{padding:4px 10px 4px 24px;font-size:12px;color:var(--muted);border-radius:6px;cursor:pointer;\n white-space:nowrap;overflow:hidden;text-overflow:ellipsis}\n.tree-topic:hover{background:var(--bg-soft);color:var(--ink)}\n.tree-empty{padding:2px 10px 4px 24px;font-size:11.5px;color:#b3b7bd}\n\n@media (max-width:760px){\n #tree{display:none}\n #stage{left:0}\n #topbar{left:0}\n #controls{left:18px}\n #zoomLabel{left:60px}\n #hint{display:none}\n .only-wide{display:none}\n #brand h1{font-size:15px}\n .card{width:270px}\n #panel{width:100%;height:72%;top:auto;bottom:0;border-left:0;border-top:1px solid var(--line);\n transform:translateY(102%);border-radius:16px 16px 0 0}\n #panel.open{transform:none}\n}\n</style>\n<style>\n:root{\n --bg:#151517; --bg-soft:#232324; --surface:#232324; --ink:#f5f5f7; --ink-2:#cfd3d6;\n --secondary:#adb2b8; --muted:#8a8d95; --line:rgba(255,255,255,.12); --line-2:rgba(255,255,255,.06);\n --blue:#4d93f8; --blue-deep:#9db9ff; --blue-soft:rgba(77,147,248,.14);\n --green:#22c55e; --amber:#f59e0b;\n --shadow:0 1px 2px rgba(0,0,0,.3), 0 10px 30px rgba(0,0,0,.28);\n}\nhtml,body{height:100%}\nbody{display:flex;overflow:hidden;background:var(--bg);color:var(--ink)}\n#rail{width:280px;flex:0 0 280px;background:#141416;border-right:.5px solid rgba(255,255,255,.10);\n display:flex;flex-direction:column;min-height:0}\n.rail-top{height:36px;flex:0 0 36px;display:flex;align-items:center;gap:8px;padding:0 6px 0 12px}\n.rail-brand{display:flex;align-items:center;gap:8px;font-size:14px;font-weight:600;color:var(--ink)}\n.rail-brand svg{width:16px;height:16px;color:var(--secondary)}\n.rail-fold{margin-left:auto;width:28px;height:28px;border:0;background:none;border-radius:6px;\n color:var(--muted);cursor:pointer;display:flex;align-items:center;justify-content:center}\n.rail-fold:hover{background:rgba(255,255,255,.06);color:var(--ink)}\n.rail-search{margin:6px 12px 4px;height:32px;display:flex;align-items:center;gap:8px;padding:0 10px;\n background:#1f1f22;border:.5px solid rgba(255,255,255,.10);border-radius:8px}\n.rail-search svg{width:14px;height:14px;color:var(--muted)}\n.rail-search input{flex:1;min-width:0;background:none;border:0;outline:none;color:var(--ink);font-size:12.5px}\n.rail-search input::placeholder{color:#6f737a}\n.rail-nav{padding:4px 8px;display:flex;flex-direction:column;gap:1px}\n.nav-item{height:34px;display:flex;align-items:center;gap:10px;padding:0 10px;border:0;border-radius:6px;\n background:none;color:var(--secondary);font-size:13.5px;text-align:left;cursor:pointer}\n.nav-item svg{width:15px;height:15px;color:var(--muted)}\n.nav-item:hover{background:rgba(255,255,255,.06)}\n.nav-item.active{background:rgba(255,255,255,.08);color:var(--ink)}\n.nav-item.active svg{color:var(--secondary)}\n.tree-head{height:36px;flex:0 0 36px;display:flex;align-items:center;gap:4px;padding:0 8px 0 12px}\n.rail-tabs{display:flex;align-items:center;gap:18px;height:36px}\n.rail-tab{height:36px;padding:0;border:0;background:none;color:var(--muted);font-size:14px;\n cursor:pointer;position:relative}\n.rail-tab:hover{color:var(--secondary)}\n.rail-tab.active{color:var(--ink)}\n.rail-tab.active::after{content:\"\";position:absolute;left:0;right:0;bottom:0;height:2px;\n border-radius:1px;background:var(--blue)}\n.tree-tools{margin-left:auto;display:flex;align-items:center;gap:2px}\n.tree-tools button{width:28px;height:28px;border:0;background:none;border-radius:6px;\n color:var(--secondary);display:flex;align-items:center;justify-content:center;cursor:pointer}\n.tree-tools button:hover{background:rgba(255,255,255,.06);color:var(--ink)}\n#treeBody{flex:1;min-height:0;overflow:auto;padding:4px 8px 16px}\n.tree-scope{font-size:10.5px;letter-spacing:.1em;color:var(--muted);padding:10px 10px 4px}\n.tree-group{font-size:11.5px;color:var(--secondary);padding:4px 10px 2px}\n.tree-graph{display:flex;justify-content:space-between;align-items:center;height:28px;padding:0 10px;\n border-radius:6px;font-size:12.5px;color:var(--secondary);cursor:pointer}\n.tree-graph:hover{background:rgba(255,255,255,.06)}\n.tree-graph.active{background:var(--blue-soft);color:var(--blue-deep)}\n.tree-graph .n{font-size:10.5px;color:#6f737a;font-variant-numeric:tabular-nums}\n.tree-topic{height:26px;display:flex;align-items:center;padding:0 10px 0 24px;border-radius:6px;\n font-size:12px;color:var(--muted);cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}\n.tree-topic:hover{background:rgba(255,255,255,.06);color:var(--ink)}\n.tree-empty{padding:2px 10px 4px 24px;font-size:11.5px;color:#5f636a}\n.tree-kb{display:flex;align-items:center;gap:8px;height:28px;padding:0 10px;border-radius:6px;\n font-size:12.5px;color:var(--secondary);cursor:pointer}\n.tree-kb:hover{background:rgba(255,255,255,.06)}\n.tree-kb svg{width:14px;height:14px;color:#d9a441;flex:0 0 auto}\n.tree-kb.file svg{color:#8a8d95}\n.tree-kb .date{margin-left:auto;font-size:10.5px;color:#6f737a}\n#main{flex:1;min-width:0;display:flex;flex-direction:column;position:relative;background:#1a1a1c}\n#mainhead{height:48px;flex:0 0 48px;display:flex;align-items:center;gap:12px;padding:0 18px;\n border-bottom:.5px solid rgba(255,255,255,.10)}\n.crumb{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--muted);min-width:0}\n.crumb b{color:var(--ink);font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}\n.crumb i{font-style:normal;color:#5f636a}\n#headMeta{font-size:11.5px;color:#7f838a;white-space:nowrap}\n.head-actions{margin-left:auto;display:flex;gap:8px}\n.head-actions button{height:28px;padding:0 10px;border:.5px solid rgba(255,255,255,.12);border-radius:6px;\n background:#232324;color:var(--secondary);font-size:12px;cursor:pointer}\n.head-actions button:hover{background:#2c2c2e;color:var(--ink)}\n#stage{position:relative;left:auto;right:auto;top:auto;bottom:auto;flex:1;min-height:0;overflow:hidden;cursor:grab;\n background:radial-gradient(900px 620px at 50% 38%,rgba(255,255,255,.03),transparent 70%),#1a1a1c}\n#stage::before{background-image:radial-gradient(rgba(255,255,255,.055) 1px,transparent 1px)}\n#controls{position:absolute;left:16px;bottom:16px;z-index:12}\n#controls button{background:#232324;border-color:rgba(255,255,255,.12);color:var(--secondary)}\n#controls button:hover{background:#2c2c2e;color:var(--ink)}\n#controls button.on{background:var(--blue);border-color:var(--blue);color:#0b1220}\n#zoomLabel{position:absolute;left:60px;bottom:21px;color:var(--muted)}\n.card{background:#232324;border-color:rgba(255,255,255,.10);\n box-shadow:0 1px 2px rgba(0,0,0,.3),0 10px 30px rgba(0,0,0,.28)}\n.card:hover{border-color:rgba(255,255,255,.20)}\n.card.selected{border-color:var(--blue);box-shadow:0 0 0 2px rgba(77,147,248,.20),0 10px 30px rgba(0,0,0,.35)}\n.card.dim{opacity:.32}\n.card h3{color:var(--ink)}\n.one{color:var(--secondary)}\n.tags span{background:#2c2c2e;border-color:rgba(255,255,255,.08);color:var(--secondary)}\n.doc{border-color:rgba(255,255,255,.12);box-shadow:0 8px 18px rgba(0,0,0,.40)}\n.edge{stroke:rgba(255,255,255,.22)}\n.edge.cross{stroke:rgba(255,255,255,.12)}\n.edge.hot{stroke:var(--blue)}\n.pill rect{fill:#232324;stroke:rgba(255,255,255,.14)}\n.pill text{fill:var(--secondary)}\n.pill.hot rect{fill:rgba(77,147,248,.16);stroke:var(--blue)}\n.pill.hot text{fill:var(--blue-deep)}\n#panel{background:#1e1e21;border-left-color:rgba(255,255,255,.10);box-shadow:-8px 0 28px rgba(0,0,0,.40)}\n.p-head{border-bottom-color:rgba(255,255,255,.06)}\n.p-kicker{color:var(--muted)}\n.p-head h2{color:var(--ink)}\n#close{background:#232324;border-color:rgba(255,255,255,.12);color:var(--muted)}\n#close:hover{background:#2c2c2e;color:var(--ink)}\n.p-field b{color:var(--muted)}\n.p-field span{color:var(--secondary)}\n.p-doc{color:var(--secondary)}\n.p-doc h2,.p-doc h3{color:var(--ink)}\n.p-doc code{background:#2c2c2e;border-color:rgba(255,255,255,.08)}\n.p-doc pre{background:#1a1a1c;border-color:rgba(255,255,255,.08)}\n.rel{background:#262629;border-color:rgba(255,255,255,.07)}\n.rel:hover{background:#2c2c2e;border-color:rgba(255,255,255,.12)}\n.rel b{color:var(--ink)}\n.rel span{color:var(--muted)}\n.p-doc-item{border-bottom-color:rgba(255,255,255,.06)}\n.p-doc-item .m b{color:var(--ink)}\n.p-doc-item .m span{color:var(--muted)}\n#kbView{flex:1;min-height:0;overflow:auto;padding:18px}\n.kb-cards{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:13px}\n.kb-card{display:flex;gap:13px;align-items:center;height:63px;padding:0 13px;border:.5px solid rgba(255,255,255,.10);\n border-radius:10px;background:#232324;text-align:left;cursor:pointer;color:var(--ink)}\n.kb-card:hover{background:#262629}\n.kb-card .plate{width:22px;height:22px;border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:13px;flex:0 0 auto}\n.kb-card b{display:block;font-size:13.5px;color:var(--ink);font-weight:600}\n.kb-card small{display:block;font-size:11.5px;color:var(--muted);margin-top:2px}\n.kb-tabs{display:flex;gap:8px;margin:16px 0 6px;border-bottom:.5px solid rgba(255,255,255,.08)}\n.kb-tab{padding:8px 10px;font-size:12.5px;color:var(--muted);background:none;border:0;cursor:pointer;border-bottom:2px solid transparent}\n.kb-tab.active{color:var(--ink);border-bottom-color:var(--blue)}\n.kb-table{width:100%;border-collapse:collapse;font-size:12.5px}\n.kb-table th{text-align:left;font-weight:500;color:#7f838a;font-size:11px;padding:8px 10px;border-bottom:.5px solid rgba(255,255,255,.08)}\n.kb-table td{padding:9px 10px;border-bottom:.5px solid rgba(255,255,255,.06);color:var(--secondary)}\n.kb-table td.muted{color:var(--muted)}\n.kb-table tr:hover td{background:rgba(255,255,255,.03)}\n[hidden]{display:none !important}\n@media (max-width:900px){#rail{display:none}}\n</style>\n<style>\n/* ---- embedding page: no rail, no page header; the plugin owns both ---- */\n#tree { display: none !important; }\n#stage { left: 0 !important; }\n#controls { left: 18px !important; }\n#zoomLabel { left: 62px !important; }\n#main { width: 100%; }\n#kg-empty { position: absolute; inset: 0; z-index: 8; display: flex; flex-direction: column;\n align-items: center; justify-content: center; gap: 6px; background: #1a1a1c; color: #8a8d95;\n font-family: var(--sans); text-align: center; padding: 24px; }\n#kg-empty strong { font-size: 14px; font-weight: 600; color: #cfd3d6; }\n#kg-empty span { font-size: 12px; }\n</style>\n<style>\n/* ---- host theme bridge ----\n The canvas is a separate document, so it cannot inherit the host's custom\n properties. The parent posts every --dsw-alias-* value below into this page and\n writes it on :root. These fallbacks are only the first-paint values before that\n message lands; the live values always come from the host. */\n:root {\n --bg: var(--dsw-alias-bg-base, #fff);\n --bg-soft: var(--dsw-alias-bg-layer-1, #f6f7f9);\n --surface: var(--dsw-alias-bg-layer-2, #fff);\n --ink: var(--dsw-alias-label-primary, #0f1115);\n --ink-2: var(--dsw-alias-label-secondary, #3c3f47);\n --secondary: var(--dsw-alias-label-secondary, #61666b);\n /* 80% tertiary + 20% primary keeps the third level distinguishable while\n reaching 4.5:1 on the light surface (the raw light tertiary does not). */\n --muted: color-mix(in srgb, var(--dsw-alias-label-tertiary, #81858c) 80%, var(--dsw-alias-label-primary, #0f1115));\n --line: var(--dsw-alias-border-l2, rgba(0, 0, 0, .10));\n --line-2: var(--dsw-alias-border-l1, rgba(0, 0, 0, .06));\n --blue: var(--dsw-alias-state-business-primary, #4176e6);\n /* Accent text needs 4.5:1, and the raw link token is 4.2:1 on white. A 78/22 mix\n with the primary ink keeps the brand hue while clearing the body-text floor. */\n --blue-deep: color-mix(in srgb, var(--dsw-alias-state-business-primary, #4176e6) 78%, var(--dsw-alias-label-primary, #0f1115));\n --blue-soft: color-mix(in srgb, var(--dsw-alias-state-business-primary, #4176e6) 16%, transparent);\n --green: var(--dsw-alias-state-success-primary, #22c55e);\n --amber: var(--dsw-alias-state-warn-primary, #f59e0b);\n --shadow: var(--dsw-elevation-panel, 0 1px 2px rgba(0, 0, 0, .10));\n --stage-bg: color-mix(in srgb, var(--dsw-alias-bg-base, #fff) 96%, var(--dsw-alias-label-primary, #0f1115));\n --soft: color-mix(in srgb, var(--dsw-alias-label-primary, #0f1115) 6%, transparent);\n --dot: color-mix(in srgb, var(--dsw-alias-label-primary, #0f1115) 9%, transparent);\n --edge: color-mix(in srgb, var(--dsw-alias-label-primary, #0f1115) 24%, transparent);\n --edge-soft: color-mix(in srgb, var(--dsw-alias-label-primary, #0f1115) 14%, transparent);\n}\n\n#stage {\n background:\n radial-gradient(900px 620px at 50% 38%, color-mix(in srgb, var(--surface) 60%, transparent), transparent 70%),\n var(--stage-bg) !important;\n}\n#stage::before { background-image: radial-gradient(var(--dot) 1px, transparent 1px) !important; }\n#kg-empty { background: var(--stage-bg) !important; color: var(--muted) !important; }\n#kg-empty strong { color: var(--ink) !important; }\n.card:focus-visible, #controls button:focus-visible { outline-color: var(--blue) !important; }\n.p-doc-item img { border-color: var(--line) !important; }\n.card { background: var(--surface) !important; border-color: var(--line) !important; box-shadow: var(--shadow) !important; }\n.card:hover { border-color: color-mix(in srgb, var(--ink) 22%, transparent) !important; }\n.card.selected { border-color: var(--blue) !important; box-shadow: 0 0 0 2px var(--blue-soft), var(--shadow) !important; }\n/* Dimming is focus, not disappearance: 32% made secondary text unreadable. */\n.card.dim { opacity: .55 !important; }\n.card h3 { color: var(--ink) !important; }\n.one { color: var(--secondary) !important; font-size: 13px; line-height: 1.6; }\n.tags span {\n background: var(--soft) !important; border-color: var(--line-2) !important;\n color: var(--secondary) !important; font-size: 11px; padding: 2px 7px;\n}\n.dots i { background: var(--line) !important; }\n.dots i.on { background: var(--ink) !important; }\n.doc { border-color: var(--line) !important; box-shadow: var(--shadow) !important; }\n.doc-empty { border-color: var(--line) !important; color: var(--muted) !important; background: var(--bg-soft) !important; }\n.docGenerated {\n display: flex; flex-direction: column; gap: 3px; padding: 7px 8px;\n background: var(--surface) !important; color: var(--ink);\n border: 1px solid var(--line) !important; border-radius: 6px;\n overflow: hidden;\n}\n.docGenerated .docMark {\n color: var(--blue-deep); font: 600 7.5px/1 var(--mono); letter-spacing: .08em;\n}\n.docGenerated b {\n color: var(--ink); font-size: 8.5px; line-height: 1.25; font-weight: 600;\n overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;\n}\n.docGenerated i { margin-top: auto; color: var(--muted); font: normal 7px/1 var(--sans); }\n.docGenerated.wide { gap: 4px; padding: 9px 11px; }\n.docGenerated.wide .docMark { font-size: 9px; }\n.docGenerated.wide b { font-size: 12px; -webkit-line-clamp: 2; }\n.docGenerated.wide i { font-size: 8px; }\n.edge { stroke: var(--edge) !important; }\n.edge.cross { stroke: var(--edge-soft) !important; }\n.edge.hot { stroke: var(--blue) !important; }\n.pill rect { fill: var(--surface) !important; stroke: var(--line) !important; }\n.pill text { fill: var(--secondary) !important; }\n.pill.hot rect { fill: var(--blue-soft) !important; stroke: var(--blue) !important; }\n.pill.hot text { fill: var(--blue-deep) !important; }\n#controls button {\n background: var(--surface) !important; border-color: var(--line) !important;\n color: var(--ink-2) !important; box-shadow: var(--shadow) !important;\n}\n#controls button:hover { background: var(--bg-soft) !important; color: var(--ink) !important; }\n#controls button.on { background: var(--blue) !important; border-color: var(--blue) !important; color: var(--bg) !important; }\n#zoomLabel { color: var(--muted) !important; background: none !important; border: 0 !important;\n padding: 2px 4px !important; cursor: pointer; font-family: var(--mono); }\n#zoomLabel:hover { color: var(--ink) !important; }\n#panel { background: var(--surface) !important; border-left-color: var(--line) !important;\n box-shadow: -8px 0 28px color-mix(in srgb, var(--ink) 12%, transparent) !important; }\n#close { background: var(--surface) !important; border-color: var(--line) !important; color: var(--muted) !important; }\n#close:hover { background: var(--bg-soft) !important; color: var(--ink) !important; }\n.p-head { border-bottom-color: var(--line-2) !important; }\n.p-kicker { color: var(--muted) !important; }\n.p-head h2 { color: var(--ink) !important; }\n.p-field b { color: var(--muted) !important; }\n.p-field span, .p-doc { color: var(--secondary) !important; }\n.p-rel-title { color: var(--muted) !important; }\n.rel { background: var(--soft) !important; border-color: var(--line-2) !important; }\n.rel:hover { background: var(--bg-soft) !important; border-color: var(--line) !important; }\n.rel b { color: var(--ink) !important; }\n.rel span { color: var(--secondary) !important; }\n.p-docs { color: var(--muted) !important; }\n.p-doc-item { border-bottom-color: var(--line-2) !important; }\n.p-doc-item .m b { color: var(--ink) !important; }\n.p-doc-item .m span { color: var(--muted) !important; }\n.p-doc-thumb {\n display: flex; align-items: center; justify-content: center; flex: 0 0 auto;\n width: 34px; height: 46px; border-radius: 4px;\n border: 1px solid var(--line); background: var(--soft); color: var(--blue-deep);\n font: 600 8px/1 var(--mono); letter-spacing: .06em;\n}\n.p-doc h2, .p-doc h3 { color: var(--ink) !important; }\n.p-doc code { background: var(--soft) !important; border-color: var(--line-2) !important; }\n.p-doc pre { background: var(--bg) !important; border-color: var(--line-2) !important; }\n</style>\n</head>\n<body data-doc-layout=\"landscape\" data-view=\"graph\">\n<main id=\"main\">\n <div id=\"kg-crumb\" hidden></div>\n <div id=\"stage\">\n <div id=\"world\">\n <svg id=\"edges\" width=\"4200\" height=\"3200\" viewBox=\"0 0 4200 3200\" aria-hidden=\"true\"></svg>\n </div>\n </div>\n <div id=\"controls\" role=\"group\" aria-label=\"画布控制\">\n <button id=\"zin\" aria-label=\"放大\">+</button>\n <button id=\"zout\" aria-label=\"缩小\">−</button>\n <button id=\"fit\" aria-label=\"适应画布\">⤢</button>\n <button id=\"cross\" aria-label=\"显示交叉关系\" title=\"显示交叉关系\">⇄</button>\n <button id=\"relayout\" aria-label=\"重排\" title=\"重排\">↺</button>\n </div>\n <button id=\"zoomLabel\" type=\"button\" aria-label=\"重置缩放为 100%\" title=\"重置缩放为 100%\">100%</button>\n <div id=\"kg-empty\" hidden><strong>还没有可用的图谱</strong><span>在「知识图谱」文件夹里建好主题后,画布会出现在这里。</span></div>\n</main>\n\n<aside id=\"panel\" aria-label=\"主题详情\" aria-hidden=\"true\">\n <div class=\"p-head\">\n <div>\n <div class=\"p-kicker\" id=\"p-kicker\"></div>\n <h2 id=\"p-title\"></h2>\n </div>\n <button id=\"close\" aria-label=\"关闭\">×</button>\n </div>\n <div class=\"p-body\" id=\"p-body\"></div>\n</aside>\n<script>\nconst DATA = { graphs: [] };\n\n(function(){\n \"use strict\";\n const stage = document.getElementById('stage');\n const world = document.getElementById('world');\n const svg = document.getElementById('edges');\n const panel = document.getElementById('panel');\n const zoomLabel = document.getElementById('zoomLabel');\n\n const NS = 'http://www.w3.org/2000/svg';\n let topics = [];\n let links = [];\n let byId = new Map();\n let activeGraph = 0;\n let viewMode = 'graph';\n const cardEl = new Map();\n const view = {x:0, y:0, s:1};\n const CARD_W = 300, H_GAP = 110, V_GAP = 34, PAD = 40;\n const LAND = document.body.dataset.docLayout === 'landscape';\n\n let mode = null;\n let activeTopic = null;\n let activeLink = null;\n let userMoved = false;\n let children = new Map();\n let parent = new Map();\n let treeKeys = new Set();\n let pos = new Map();\n let heights = new Map(), subH = new Map();\n let showCross = false;\n let layoutRootId = null;\n\n function esc(s){\n return String(s == null ? '' : s).replace(/[&<>\"]/g, function(c){\n return {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;'}[c];\n });\n }\n function statusText(s){ return s === 'active' ? '进行中' : s === 'paused' ? '暂停' : '已完成'; }\n function linkKey(l){ return l.a + '--' + l.b; }\n function pairKey(a, b){ return a < b ? a + '--' + b : b + '--' + a; }\n\n // ---------- cards ----------\n\n // Documents in the knowledge tree have no stored thumbnail. A generated cover\n // uses the host's own theme variables (set from the parent document), so a light\n // host gets ink-on-white covers and a dark host gets the inverse. No bitmap and\n // no fixed palette: the cover is markup, not an image.\n function coverFor(d, idx, wide, w, h, dx, dy, rot, sc, z){\n const style = 'width:' + w + 'px;height:' + h + 'px;--dx:' + dx + 'px;--dy:' + dy +\n 'px;--rot:' + rot + 'deg;--sc:' + sc + ';z-index:' + z;\n if (d.thumb) {\n const src = (wide && d.thumbWide) ? d.thumbWide : d.thumb;\n return '<img class=\"doc\" src=\"' + src + '\" alt=\"' + esc(d.title) + '\" style=\"' + style + '\">';\n }\n const ext = String(d.file || 'doc').split('.').pop().slice(0, 4).toUpperCase();\n return '<div class=\"doc docGenerated' + (wide ? ' wide' : '') + '\" style=\"' + style + '\" title=\"' + esc(d.title) + '\">' +\n '<span class=\"docMark\">' + esc(ext) + '</span>' +\n '<b>' + esc(d.title || d.file || '') + '</b>' +\n '<i>' + esc(d.date || '') + '</i></div>';\n }\n\n // The detail panel renders the same cover inline, at a fixed 34x46 slot.\n function inlineCover(d){\n if (d.thumb) return '<img src=\"' + d.thumb + '\" alt=\"\">';\n const ext = String(d.file || 'doc').split('.').pop().slice(0, 4).toUpperCase();\n return '<span class=\"p-doc-thumb\"><i>' + esc(ext) + '</i></span>';\n }\n\n function buildCard(t){\n const el = document.createElement('article');\n el.className = 'card';\n el.dataset.id = t.id;\n el.tabIndex = 0;\n el.style.left = '0px';\n el.style.top = '0px';\n el.style.visibility = 'hidden';\n el.setAttribute('role', 'button');\n el.setAttribute('aria-label', t.title + ',' + statusText(t.status) + ',深度 ' + t.depth);\n\n const dots = [1,2,3,4,5].map(i =>\n '<i class=\"' + (i <= t.depth ? 'on' : '') + '\"></i>').join('');\n const docs = t.documents || [];\n const shown = docs.slice(0, 4);\n const mid = (shown.length - 1) / 2;\n let docW = 86, docH = 116, step = 31, rotStep = 6.5, dyStep = 2, scStep = 0.03, coverH = 124;\n if (LAND){\n if (shown.length <= 1){ docW = 240; docH = 84; step = 0; rotStep = 0; dyStep = 0; scStep = 0; coverH = 92; }\n else if (shown.length === 2){ docW = 142; docH = 78; step = 58; rotStep = 5; dyStep = 3; scStep = 0.02; coverH = 88; }\n else if (shown.length === 3){ docW = 132; docH = 76; step = 46; rotStep = 4.2; dyStep = 3; scStep = 0.02; coverH = 86; }\n else { docW = 118; docH = 70; step = 42; rotStep = 4; dyStep = 3; scStep = 0.02; coverH = 80; }\n }\n let stack = '';\n shown.forEach(function(d, i){\n const dist = Math.abs(i - mid);\n const dx = (i - mid) * step;\n const dy = dist * dyStep;\n const rot = (i - mid) * rotStep;\n const sc = 1 - dist * scStep;\n const z = 10 - dist;\n stack += coverFor(d, i, shown.length === 1, docW, docH, dx, dy, rot, sc, z);\n });\n if (docs.length > 4) stack += '<span class=\"more\">+' + (docs.length - 4) + '</span>';\n if (!docs.length) stack = '<div class=\"doc-empty\">docs/ 还是空的</div>';\n const tagList = [t.domain].concat(t.tags || []).filter(function(x, i, arr){\n return x && arr.indexOf(x) === i;\n }).slice(0, 4);\n const tags = tagList.map(function(x){ return '<span>' + esc(x) + '</span>'; }).join('');\n\n el.innerHTML =\n '<h3>' + esc(t.title) + '</h3>' +\n '<div class=\"cover\" style=\"height:' + coverH + 'px\"><div class=\"stack\">' + stack + '</div></div>' +\n '<p class=\"one\">' + esc(t.summary) + '</p>' +\n '<div class=\"tags\">' + tags + '</div>';\n\n el.addEventListener('pointerdown', onCardDown);\n el.addEventListener('keydown', function(e){\n if (e.key === 'Enter' || e.key === ' '){ e.preventDefault(); selectTopic(t.id); }\n });\n world.appendChild(el);\n cardEl.set(t.id, el);\n }\n\n // ---------- spanning tree ----------\n function degree(id){\n return links.filter(function(l){ return l.a === id || l.b === id; }).length;\n }\n function buildTree(rootId){\n children = new Map(); parent = new Map(); treeKeys = new Set();\n const visited = new Set([rootId]);\n const queue = [rootId];\n topics.forEach(function(t){ children.set(t.id, []); });\n while (queue.length){\n const id = queue.shift();\n const neighbors = [];\n links.forEach(function(l){\n if (l.a === id && !visited.has(l.b)) neighbors.push(l.b);\n else if (l.b === id && !visited.has(l.a)) neighbors.push(l.a);\n });\n neighbors.sort(function(a, b){ return degree(b) - degree(a); });\n neighbors.forEach(function(n){\n visited.add(n);\n parent.set(n, id);\n children.get(id).push(n);\n treeKeys.add(pairKey(id, n));\n queue.push(n);\n });\n }\n topics.forEach(function(t){\n if (!visited.has(t.id)){\n parent.set(t.id, rootId);\n children.get(rootId).push(t.id);\n treeKeys.add(pairKey(rootId, t.id));\n visited.add(t.id);\n }\n });\n }\n\n // ---------- mind-map layout ----------\n function calcSub(id){\n const h = heights.get(id) || 200;\n const kids = children.get(id) || [];\n if (!kids.length){ subH.set(id, h); return h; }\n let total = 0;\n kids.forEach(function(k, i){\n total += calcSub(k);\n if (i) total += V_GAP;\n });\n subH.set(id, Math.max(h, total));\n return subH.get(id);\n }\n\n function placeNode(id, x, yTop, dir){\n const h = heights.get(id) || 200;\n const sub = subH.get(id) || h;\n const cy = yTop + sub / 2;\n pos.set(id, {x:x, y:cy - h / 2, dir:dir});\n const kids = children.get(id) || [];\n let cursor = yTop;\n kids.forEach(function(k){\n const kh = subH.get(k) || 200;\n const kx = dir > 0 ? x + CARD_W + H_GAP : x - CARD_W - H_GAP;\n placeNode(k, kx, cursor, dir);\n cursor += kh + V_GAP;\n });\n }\n\n function layout(){\n heights = new Map();\n topics.forEach(function(t){ heights.set(t.id, cardEl.get(t.id).offsetHeight || 200); });\n\n let rootId = topics[0].id, best = -1;\n topics.forEach(function(t){\n const d = degree(t.id);\n if (d > best){ best = d; rootId = t.id; }\n });\n if (byId.has('dsh-enterprise-app')) rootId = 'dsh-enterprise-app';\n layoutRootId = rootId;\n buildTree(rootId);\n calcSub(rootId);\n\n const first = (children.get(rootId) || []).slice();\n first.sort(function(a, b){ return (subH.get(b) || 0) - (subH.get(a) || 0); });\n\n const left = [], right = [];\n let lh = 0, rh = 0;\n first.forEach(function(id){\n if (rh <= lh){ right.push(id); rh += subH.get(id); }\n else { left.push(id); lh += subH.get(id); }\n });\n\n const rootH = heights.get(rootId);\n pos.set(rootId, {x:0, y:-rootH / 2, dir:0});\n\n function placeSide(list, dir){\n let total = 0;\n list.forEach(function(id, i){ total += subH.get(id); if (i) total += V_GAP; });\n let cursor = -total / 2;\n list.forEach(function(id){\n const kh = subH.get(id);\n const x = dir > 0 ? CARD_W + H_GAP : -CARD_W - H_GAP;\n placeNode(id, x, cursor, dir);\n cursor += kh + V_GAP;\n });\n }\n placeSide(left, -1);\n placeSide(right, 1);\n\n let minX = Infinity, minY = Infinity;\n pos.forEach(function(p){ minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); });\n pos.forEach(function(p, id){\n p.x = p.x - minX + PAD;\n p.y = p.y - minY + PAD;\n const t = byId.get(id);\n t.x = p.x; t.y = p.y;\n const el = cardEl.get(id);\n el.style.left = p.x + 'px';\n el.style.top = p.y + 'px';\n el.style.visibility = '';\n });\n }\n\n // ---------- edges & pills ----------\n function center(id){\n const p = pos.get(id), h = heights.get(id) || 200;\n return {x:p.x + CARD_W / 2, y:p.y + h / 2};\n }\n function anchor(id, tx, ty){\n const c = center(id), h = heights.get(id) || 200;\n const dx = tx - c.x, dy = ty - c.y;\n if (!dx && !dy) return c;\n if (Math.abs(dx) * h > Math.abs(dy) * CARD_W){\n const k = (CARD_W / 2) / Math.abs(dx);\n return {x:c.x + dx * k, y:c.y + dy * k};\n }\n const k = (h / 2) / Math.abs(dy);\n return {x:c.x + dx * k, y:c.y + dy * k};\n }\n function qpoint(p0, c, p1, t){\n const u = 1 - t;\n return {x:u*u*p0.x + 2*u*t*c.x + t*t*p1.x, y:u*u*p0.y + 2*u*t*c.y + t*t*p1.y};\n }\n function cpoint(p0, c1, c2, p1, t){\n const u = 1 - t, a = u*u*u, b = 3*u*u*t, c = 3*u*t*t, d = t*t*t;\n return {x:a*p0.x + b*c1.x + c*c2.x + d*p1.x,\n y:a*p0.y + b*c1.y + c*c2.y + d*p1.y};\n }\n function hitsCard(x, y, w, h){\n for (let i = 0; i < topics.length; i++){\n const t = topics[i], th = heights.get(t.id) || 200;\n if (x < t.x + CARD_W + 6 && x + w > t.x - 6 &&\n y < t.y + th + 6 && y + h > t.y - 6) return true;\n }\n return false;\n }\n function freePointOnQuad(p0, c, p1){\n const ts = [0.5, 0.4, 0.6, 0.3, 0.7, 0.22, 0.78];\n for (let i = 0; i < ts.length; i++){\n const p = qpoint(p0, c, p1, ts[i]);\n if (!hitsCard(p.x, p.y)) return p;\n }\n return qpoint(p0, c, p1, 0.5);\n }\n function freePointOnCubic(p0, c1, c2, p1){\n const ts = [0.5, 0.42, 0.58, 0.34, 0.66, 0.26, 0.74];\n for (let i = 0; i < ts.length; i++){\n const p = cpoint(p0, c1, c2, p1, ts[i]);\n if (!hitsCard(p.x, p.y)) return p;\n }\n return cpoint(p0, c1, c2, p1, 0.5);\n }\n\n function overlaps(p, w, placed){\n for (let i = 0; i < placed.length; i++){\n const q = placed[i];\n if (Math.abs(p.x - q.x) < (w + q.w) / 2 + 8 && Math.abs(p.y - q.y) < 24) return true;\n }\n return false;\n }\n function placePill(sample, w, placed){\n const ts = [0.5, 0.56, 0.44, 0.62, 0.38, 0.68, 0.32, 0.74, 0.26, 0.8];\n for (let i = 0; i < ts.length; i++){\n const p = sample(ts[i]);\n if (!hitsCard(p.x - w / 2, p.y - 10, w, 20) && !overlaps(p, w, placed)){\n placed.push({x:p.x, y:p.y, w:w});\n return p;\n }\n }\n const p = sample(0.5);\n placed.push({x:p.x, y:p.y, w:w});\n return p;\n }\n\n function drawEdges(){\n while (svg.firstChild) svg.removeChild(svg.firstChild);\n const placed = [];\n\n links.forEach(function(l){\n const a = byId.get(l.a), b = byId.get(l.b);\n if (!a || !b) return;\n const key = linkKey(l);\n const isTree = treeKeys.has(pairKey(l.a, l.b));\n const isLink = mode === 'link' && activeLink === key;\n const isTopic = mode === 'topic' && (l.a === activeTopic || l.b === activeTopic);\n const hot = isLink || isTopic;\n const cold = !!mode && !hot;\n if (!isTree && !showCross && !hot) return;\n\n let d, sample;\n if (isTree){\n const pid = parent.get(l.b) === l.a ? l.a : l.b;\n const cid = pid === l.a ? l.b : l.a;\n const pp = pos.get(pid), pc = center(pid), cc = center(cid);\n const dir = pp.dir || (cc.x >= pc.x ? 1 : -1);\n const x1 = dir > 0 ? pp.x + CARD_W : pp.x;\n const x2 = dir > 0 ? pos.get(cid).x : pos.get(cid).x + CARD_W;\n const y1 = pc.y, y2 = cc.y;\n const bend = Math.max(34, Math.abs(x2 - x1) * 0.42);\n const p0 = {x:x1, y:y1};\n const c1 = {x:x1 + dir * bend, y:y1};\n const c2 = {x:x2 - dir * bend, y:y2};\n const p1 = {x:x2, y:y2};\n d = 'M' + x1 + ',' + y1 + ' C' + c1.x + ',' + c1.y + ' ' + c2.x + ',' + c2.y + ' ' + x2 + ',' + y2;\n sample = function(t){ return cpoint(p0, c1, c2, p1, t); };\n } else {\n const pa = pos.get(l.a), pb = pos.get(l.b);\n let p1, p2, c;\n if (pa.dir === pb.dir && pa.dir !== 0){\n const dir = pa.dir;\n p1 = {x: dir < 0 ? pa.x + CARD_W : pa.x, y: center(l.a).y};\n p2 = {x: dir < 0 ? pb.x + CARD_W : pb.x, y: center(l.b).y};\n const outX = dir < 0 ? Math.max(p1.x, p2.x) + 64 : Math.min(p1.x, p2.x) - 64;\n c = {x: outX, y: (p1.y + p2.y) / 2};\n } else {\n const ca = center(l.a), cb = center(l.b);\n p1 = {x: ca.x, y: pa.y};\n p2 = {x: cb.x, y: pb.y};\n c = {x: (p1.x + p2.x) / 2, y: Math.min(p1.y, p2.y) - 112};\n }\n d = 'M' + p1.x + ',' + p1.y + ' Q' + c.x + ',' + c.y + ' ' + p2.x + ',' + p2.y;\n sample = function(t){ return qpoint(p1, c, p2, t); };\n }\n\n const path = document.createElementNS(NS, 'path');\n path.setAttribute('d', d);\n path.setAttribute('class', 'edge' + (isTree ? '' : ' cross') + (hot ? ' hot' : cold ? ' cold' : ''));\n svg.appendChild(path);\n\n const label = l.label || (a.title + ' ↔ ' + b.title);\n const w = Math.max(42, label.length * 10.8 + 16);\n const pillAt = placePill(sample, w, placed);\n\n const g = document.createElementNS(NS, 'g');\n g.setAttribute('class', 'pill' + (hot ? ' hot' : cold ? ' cold' : ''));\n g.setAttribute('data-key', key);\n g.setAttribute('tabindex', '0');\n g.setAttribute('role', 'button');\n g.setAttribute('aria-label', '关系:' + label);\n\n const rect = document.createElementNS(NS, 'rect');\n rect.setAttribute('x', pillAt.x - w / 2); rect.setAttribute('y', pillAt.y - 10);\n rect.setAttribute('width', w); rect.setAttribute('height', 20);\n rect.setAttribute('rx', 10);\n const text = document.createElementNS(NS, 'text');\n text.setAttribute('x', pillAt.x); text.setAttribute('y', pillAt.y + 3.6);\n text.setAttribute('text-anchor', 'middle');\n text.textContent = label;\n\n g.appendChild(rect); g.appendChild(text);\n g.addEventListener('click', function(e){ e.stopPropagation(); selectLink(key); });\n g.addEventListener('keydown', function(e){\n if (e.key === 'Enter' || e.key === ' '){ e.preventDefault(); selectLink(key); }\n });\n svg.appendChild(g);\n });\n }\n\n // ---------- camera ----------\n function apply(){\n world.style.transform = 'translate(' + view.x + 'px,' + view.y + 'px) scale(' + view.s + ')';\n zoomLabel.textContent = Math.round(view.s * 100) + '%';\n }\n function clampScale(s){ return Math.max(0.22, Math.min(2.0, s)); }\n function zoomAt(px, py, k){\n const s2 = clampScale(view.s * k);\n if (s2 === view.s) return;\n view.x = px - (px - view.x) * (s2 / view.s);\n view.y = py - (py - view.y) * (s2 / view.s);\n view.s = s2;\n apply(); userMoved = true;\n }\n function bbox(){\n let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity;\n topics.forEach(function(t){\n const h = heights.get(t.id) || 200;\n x1 = Math.min(x1, t.x); y1 = Math.min(y1, t.y);\n x2 = Math.max(x2, t.x + CARD_W); y2 = Math.max(y2, t.y + h);\n });\n return {x1:x1, y1:y1, x2:x2, y2:y2};\n }\n function fit(animate){\n const b = bbox();\n const pad = 70;\n const vw = stage.clientWidth, vh = stage.clientHeight;\n const s = clampScale(Math.min((vw - pad * 2) / (b.x2 - b.x1), (vh - pad * 2) / (b.y2 - b.y1), 1));\n const nx = (vw - (b.x2 - b.x1) * s) / 2 - b.x1 * s;\n const ny = (vh - (b.y2 - b.y1) * s) / 2 - b.y1 * s;\n if (animate) tweenTo(nx, ny, s); else { view.x = nx; view.y = ny; view.s = s; apply(); }\n userMoved = false;\n }\n let raf = null;\n function tweenTo(x2, y2, s2){\n cancelAnimationFrame(raf);\n const x1 = view.x, y1 = view.y, s1 = view.s, t0 = performance.now(), dur = 320;\n function step(now){\n const p = Math.min(1, (now - t0) / dur);\n const e = 1 - Math.pow(1 - p, 3);\n view.x = x1 + (x2 - x1) * e;\n view.y = y1 + (y2 - y1) * e;\n view.s = s1 + (s2 - s1) * e;\n apply();\n if (p < 1) raf = requestAnimationFrame(step);\n }\n raf = requestAnimationFrame(step);\n }\n function centerOnScale(id, s, animate){\n const t = byId.get(id); if (!t) return;\n const h = heights.get(id) || 200;\n const x = stage.clientWidth / 2 - (t.x + CARD_W / 2) * s;\n const y = stage.clientHeight / 2 - (t.y + h / 2) * s;\n if (animate) tweenTo(x, y, s);\n else { view.x = x; view.y = y; view.s = s; apply(); }\n userMoved = true;\n }\n\n // A topic jump keeps whatever zoom the reader chose; only the explicit reset\n // control and a graph switch change the scale.\n function centerOn(id){\n centerOnScale(id, view.s, true);\n }\n\n function resetZoom(){\n const target = (mode === 'topic' && activeTopic) ? activeTopic : layoutRootId;\n centerOnScale(target, 1, true);\n }\n\n // ---------- pan & zoom ----------\n let panning = false, panMoved = false, px = 0, py = 0;\n stage.addEventListener('pointerdown', function(e){\n if (e.target.closest('.card') || e.target.closest('.pill') || e.target.closest('#panel')) return;\n panning = true; panMoved = false; px = e.clientX; py = e.clientY;\n stage.classList.add('grabbing');\n stage.setPointerCapture(e.pointerId);\n });\n stage.addEventListener('pointermove', function(e){\n if (!panning) return;\n if (Math.abs(e.clientX - px) + Math.abs(e.clientY - py) > 3) panMoved = true;\n view.x += e.clientX - px; view.y += e.clientY - py;\n px = e.clientX; py = e.clientY;\n apply(); userMoved = true;\n });\n function endPan(e){\n if (!panning) return;\n panning = false; stage.classList.remove('grabbing');\n try { stage.releasePointerCapture(e.pointerId); } catch(_){}\n if (!panMoved && mode) closePanel();\n }\n stage.addEventListener('pointerup', endPan);\n stage.addEventListener('pointercancel', endPan);\n stage.addEventListener('wheel', function(e){\n e.preventDefault();\n zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.0011));\n }, {passive:false});\n document.getElementById('zin').addEventListener('click', function(){\n zoomAt(stage.clientWidth / 2, stage.clientHeight / 2, 1.22);\n });\n document.getElementById('zout').addEventListener('click', function(){\n zoomAt(stage.clientWidth / 2, stage.clientHeight / 2, 1 / 1.22);\n });\n document.getElementById('fit').addEventListener('click', function(){ fit(true); });\n document.getElementById('zoomLabel').addEventListener('click', resetZoom);\n document.getElementById('relayout').addEventListener('click', function(){\n layout(); fit(true); drawEdges();\n });\n document.getElementById('cross').addEventListener('click', function(){\n showCross = !showCross;\n this.classList.toggle('on', showCross);\n this.setAttribute('aria-pressed', showCross ? 'true' : 'false');\n drawEdges();\n });\n stage.addEventListener('dblclick', function(e){ if (!e.target.closest('.card')) fit(true); });\n\n // ---------- card drag ----------\n let drag = null;\n function onCardDown(e){\n if (e.button !== 0) return;\n e.stopPropagation();\n const id = e.currentTarget.dataset.id;\n const t = byId.get(id);\n const el = cardEl.get(id);\n drag = {id:id, sx:e.clientX, sy:e.clientY, ox:t.x, oy:t.y, moved:false, el:el};\n el.classList.add('dragging');\n window.addEventListener('pointermove', onDragMove);\n window.addEventListener('pointerup', onDragUp);\n }\n function onDragMove(e){\n if (!drag) return;\n const dx = (e.clientX - drag.sx) / view.s;\n const dy = (e.clientY - drag.sy) / view.s;\n if (Math.abs(dx) + Math.abs(dy) > 3) drag.moved = true;\n const t = byId.get(drag.id);\n t.x = drag.ox + dx; t.y = drag.oy + dy;\n const p = pos.get(drag.id); p.x = t.x; p.y = t.y;\n drag.el.style.left = t.x + 'px';\n drag.el.style.top = t.y + 'px';\n drawEdges();\n }\n function onDragUp(){\n window.removeEventListener('pointermove', onDragMove);\n window.removeEventListener('pointerup', onDragUp);\n if (!drag) return;\n drag.el.classList.remove('dragging');\n const id = drag.id, moved = drag.moved;\n drag = null;\n if (!moved) selectTopic(id);\n userMoved = true;\n }\n\n // ---------- selection ----------\n function paint(){\n cardEl.forEach(function(el, cid){\n let sel = false, dim = false;\n if (mode === 'topic'){ sel = cid === activeTopic; dim = !sel; }\n else if (mode === 'link'){\n const l = links.find(function(x){ return linkKey(x) === activeLink; });\n sel = !!l && (cid === l.a || cid === l.b);\n dim = !sel;\n }\n el.classList.toggle('selected', sel);\n el.classList.toggle('dim', dim);\n });\n }\n function notifySelection(topicId){\n try {\n parent.postMessage({\n type: 'kg:selection',\n graphId: (DATA.graphs[activeGraph] || {}).id || null,\n topicId: topicId || null\n }, '*');\n } catch (_) {}\n }\n function selectTopic(id){\n if (mode === 'topic' && activeTopic === id){ closePanel(); return; }\n mode = 'topic'; activeTopic = id; activeLink = null;\n paint(); drawEdges(); openTopicPanel(id); setHash(id);\n notifySelection(id);\n }\n function selectLink(key){\n if (mode === 'link' && activeLink === key){ closePanel(); return; }\n mode = 'link'; activeLink = key; activeTopic = null;\n paint(); drawEdges(); openLinkPanel(key); setHash(key);\n }\n function closePanel(){\n mode = null; activeTopic = null; activeLink = null;\n paint(); drawEdges();\n panel.classList.remove('open');\n panel.setAttribute('aria-hidden', 'true');\n document.body.classList.remove('panel-open');\n setHash(null);\n notifySelection(null);\n }\n document.getElementById('close').addEventListener('click', closePanel);\n document.addEventListener('keydown', function(e){ if (e.key === 'Escape') closePanel(); });\n\n function showPanel(kicker, title, html){\n document.getElementById('p-kicker').textContent = kicker;\n document.getElementById('p-title').textContent = title;\n const body = document.getElementById('p-body');\n body.innerHTML = html;\n body.scrollTop = 0;\n body.querySelectorAll('.rel').forEach(function(btn){\n btn.addEventListener('click', function(){\n const to = btn.dataset.to;\n selectTopic(to); centerOn(to);\n });\n });\n body.querySelectorAll('.p-doc-item[data-doc-path]').forEach(function(item){\n item.addEventListener('click', function(){\n try { parent.postMessage({ type: 'kg:open-document', path: item.dataset.docPath }, '*'); } catch (_) {}\n });\n });\n panel.classList.add('open');\n panel.setAttribute('aria-hidden', 'false');\n document.body.classList.add('panel-open');\n }\n function inline(s){\n s = esc(s);\n s = s.replace(/`([^`]+)`/g, '<code>$1</code>');\n s = s.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>');\n s = s.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, '<a href=\"$2\" target=\"_blank\" rel=\"noopener\">$1</a>');\n return s;\n }\n function renderMarkdown(md){\n const lines = String(md || '').split('\\n');\n let html = '', inCode = false, code = [], list = null;\n function closeList(){ if (list){ html += '</' + list + '>'; list = null; } }\n lines.forEach(function(line){\n if (/^```/.test(line)){\n if (inCode){ html += '<pre><code>' + esc(code.join('\\n')) + '</code></pre>'; code = []; inCode = false; }\n else { closeList(); inCode = true; }\n return;\n }\n if (inCode){ code.push(line); return; }\n if (!line.trim()){ closeList(); return; }\n let m;\n if ((m = line.match(/^(#{1,3})\\s+(.*)$/))){\n closeList();\n const lv = Math.min(3, m[1].length);\n html += '<h' + lv + '>' + inline(m[2]) + '</h' + lv + '>';\n } else if ((m = line.match(/^\\s*[-*]\\s+(.*)$/))){\n if (list !== 'ul'){ closeList(); html += '<ul>'; list = 'ul'; }\n html += '<li>' + inline(m[1]) + '</li>';\n } else if ((m = line.match(/^\\s*\\d+\\.\\s+(.*)$/))){\n if (list !== 'ol'){ closeList(); html += '<ol>'; list = 'ol'; }\n html += '<li>' + inline(m[1]) + '</li>';\n } else if ((m = line.match(/^>\\s?(.*)$/))){\n closeList();\n html += '<blockquote>' + inline(m[1]) + '</blockquote>';\n } else {\n closeList();\n html += '<p>' + inline(line) + '</p>';\n }\n });\n if (inCode) html += '<pre><code>' + esc(code.join('\\n')) + '</code></pre>';\n closeList();\n return html;\n }\n function field(k, v){\n return '<div class=\"p-field\"><b>' + k + '</b><span>' + esc(v || '未登记') + '</span></div>';\n }\n function openTopicPanel(id){\n const t = byId.get(id);\n const rels = links.filter(function(l){ return l.a === id || l.b === id; });\n let html = '<div class=\"p-doc\">' + renderMarkdown(t.body || t.summary || '') + '</div>';\n if (t.documents && t.documents.length){\n html += '<div class=\"p-rel-title\">文档 · ' + t.documents.length + ' 篇</div>';\n t.documents.forEach(function(d){\n html += '<div class=\"p-doc-item\" data-doc-path=\"' + esc(d.path) + '\" role=\"button\" tabindex=\"0\" style=\"cursor:pointer\">' + inlineCover(d) + '<div class=\"m\">' +\n '<b>' + esc(d.title) + '</b><span>' + esc(d.file) + ' · ' + esc(d.date) + '</span></div></div>';\n });\n }\n html += '<div class=\"p-rel-title\">关系 · ' + rels.length + ' 篇</div>';\n rels.forEach(function(l){\n const other = l.a === id ? l.b : l.a;\n const o = byId.get(other);\n html += '<button class=\"rel\" data-to=\"' + esc(other) + '\"><b>' + esc(o ? o.title : other) +\n '</b><span>' + esc(l.body || '') + '</span></button>';\n });\n if (!rels.length) html += '<div class=\"p-docs\">还没有关系文档。孤立的线价值很低。</div>';\n html += '<div class=\"p-docs\">文档:' + ((t.documents || []).length) + ' 个文件</div>';\n showPanel((t.domain ? t.domain + ' · ' : '') + statusText(t.status) + ' · depth ' + t.depth + '/5', t.title, html);\n }\n function openLinkPanel(key){\n const l = links.find(function(x){ return linkKey(x) === key; });\n if (!l) return;\n const a = byId.get(l.a), b = byId.get(l.b);\n let html = field('关系', l.body);\n html += '<div class=\"p-rel-title\">两端</div>';\n html += '<button class=\"rel\" data-to=\"' + esc(l.a) + '\"><b>' + esc(a ? a.title : l.a) +\n '</b><span>' + esc(a ? a.summary : '') + '</span></button>';\n html += '<button class=\"rel\" data-to=\"' + esc(l.b) + '\"><b>' + esc(b ? b.title : l.b) +\n '</b><span>' + esc(b ? b.summary : '') + '</span></button>';\n showPanel('关系文档' + (l.updated ? ' · ' + l.updated : ''),\n (a ? a.title : l.a) + ' ↔ ' + (b ? b.title : l.b), html);\n }\n function setHash(v){\n try { history.replaceState(null, '', v ? '#' + v : location.pathname + location.search); } catch(_){}\n }\n\n // ---------- graph switching ----------\n /** Floating breadcrumb: graph scope / group / graph name. Parent segments close\n the detail panel and return to the graph root; there is no separate list view. */\n function renderCrumb(){\n const el = document.getElementById('kg-crumb');\n if (!el) return;\n const g = DATA.graphs[activeGraph];\n if (!g) { el.hidden = true; return; }\n const parts = ['<button class=\"seg\" data-level=\"0\">知识图谱</button>'];\n if (g.group) parts.push('<span class=\"sep\">/</span><button class=\"seg\" data-level=\"1\">' + esc(g.group) + '</button>');\n parts.push('<span class=\"sep\">/</span><b>' + esc(g.name) + '</b>');\n el.innerHTML = parts.join('');\n el.hidden = false;\n el.querySelectorAll('button[data-level]').forEach(function(btn){\n btn.addEventListener('click', function(){\n closePanel();\n centerOnScale(layoutRootId, view.s, true);\n });\n });\n }\n\n function loadGraph(idx){\n const g = DATA.graphs[idx];\n if (!g) return;\n closePanel();\n activeGraph = idx;\n topics = g.topics;\n links = g.links;\n byId = new Map(topics.map(function(t){ return [t.id, t]; }));\n cardEl.forEach(function(el){ el.remove(); });\n cardEl.clear();\n while (svg.firstChild) svg.removeChild(svg.firstChild);\n heights = new Map(); subH = new Map(); pos = new Map();\n\n topics.forEach(buildCard);\n layout();\n apply();\n renderCrumb();\n\n requestAnimationFrame(function(){\n // First entry and every graph switch start at 1:1. The reader asked for a\n // true-scale canvas; fitting the whole graph on open made every card's\n // 12-13px copy render at 64%. Panning still reaches the rest.\n centerOnScale(layoutRootId, 1, false);\n drawEdges();\n });\n }\n\n // ---------- the embedding host ----------\n const emptyEl = document.getElementById('kg-empty');\n\n // The host's alias tokens, read from the parent document (same origin) and kept\n // live by the panel's postMessage. Reading the parent on boot avoids a flash of\n // the wrong scheme before the first message; the message is still required for\n // a later theme switch.\n const THEME_TOKENS = [\n '--dsw-alias-bg-base', '--dsw-alias-bg-layer-1', '--dsw-alias-bg-layer-2',\n '--dsw-alias-label-primary', '--dsw-alias-label-secondary', '--dsw-alias-label-tertiary',\n '--dsw-alias-border-l1', '--dsw-alias-border-l2',\n '--dsw-alias-state-business-primary', '--dsw-alias-link',\n '--dsw-alias-state-success-primary', '--dsw-alias-state-warn-primary',\n '--dsw-elevation-panel'\n ];\n\n function readHostTheme(){\n try {\n const doc = parent.document;\n const bodyStyle = parent.getComputedStyle(doc.body);\n const rootStyle = parent.getComputedStyle(doc.documentElement);\n const tokens = {};\n THEME_TOKENS.forEach(function(name){\n const value = (bodyStyle.getPropertyValue(name) || rootStyle.getPropertyValue(name) || '').trim();\n if (value !== '') tokens[name] = value;\n });\n return { tokens: tokens, dark: doc.body.hasAttribute('data-ds-dark-theme') };\n } catch (_) {\n return null;\n }\n }\n\n function applyTheme(theme){\n if (!theme || !theme.tokens) return;\n const root = document.documentElement;\n Object.keys(theme.tokens).forEach(function(name){\n root.style.setProperty(name, theme.tokens[name]);\n });\n root.style.colorScheme = theme.dark ? 'dark' : 'light';\n document.body.toggleAttribute('data-ds-dark-theme', !!theme.dark);\n }\n\n applyTheme(readHostTheme());\n\n function showEmpty(show){\n if (emptyEl) emptyEl.hidden = !show;\n const stageEl = document.getElementById('stage');\n if (stageEl) stageEl.style.visibility = show ? 'hidden' : 'visible';\n const controlsEl = document.getElementById('controls');\n if (controlsEl) controlsEl.hidden = show;\n const zoomEl = document.getElementById('zoomLabel');\n if (zoomEl) zoomEl.hidden = show;\n }\n\n function postReady(){\n try { parent.postMessage({ type: 'kg:ready' }, '*'); } catch (_) {}\n }\n\n function applyPayload(payload){\n if (!payload || !Array.isArray(payload.graphs)) return;\n DATA.graphs = payload.graphs;\n if (!DATA.graphs.length){\n showEmpty(true);\n postReady();\n return;\n }\n showEmpty(false);\n const wanted = payload.activeGraphId\n ? DATA.graphs.findIndex(function(g){ return g.id === payload.activeGraphId; })\n : -1;\n const idx = wanted >= 0 ? wanted : 0;\n loadGraph(idx);\n if (payload.selectedTopicId){\n requestAnimationFrame(function(){ focusTopic(payload.selectedTopicId); });\n }\n postReady();\n }\n\n function focusTopic(id){\n const t = byId.get(id);\n if (!t) return;\n selectTopic(id);\n centerOn(id);\n }\n\n function selectGraphById(id){\n const idx = DATA.graphs.findIndex(function(g){ return g.id === id; });\n if (idx < 0) return;\n showEmpty(false);\n loadGraph(idx);\n }\n\n window.addEventListener('message', function(event){\n const msg = event.data || {};\n if (msg.type === 'kg:set-graphs') applyPayload(msg);\n else if (msg.type === 'kg:select-graph') selectGraphById(msg.graphId);\n else if (msg.type === 'kg:select-topic') focusTopic(msg.topicId);\n else if (msg.type === 'kg:theme') applyTheme(msg);\n });\n\n showEmpty(true);\n postReady();\n window.addEventListener('resize', function(){\n if (!userMoved) centerOnScale(layoutRootId, view.s, false);\n drawEdges();\n });\n\n window.__KG_VIEW__ = {data:DATA, selectTopic:selectTopic, selectLink:selectLink, fit:fit};\n})();\n<\/script>\n</body>\n</html>\n";
1699
1699
  //#endregion
1700
1700
  //#region src/viewMetadata.ts
1701
1701
  /**
@@ -11798,6 +11798,7 @@ function permissionForKbRoute(method) {
11798
11798
  if (method === "create" || method === "edit" || method === "move" || method === "upload" || method === "rename") return "kb:file:write";
11799
11799
  if (method === "trash" || method === "trash.remove" || method === "trash.restore") return "kb:file:delete";
11800
11800
  if (method === "index" || method === "extract.retry") return "kb:file:extract";
11801
+ if (method === "index.status") return "kb:file:read";
11801
11802
  if (method === "audit.list") return "kb:audit:read";
11802
11803
  if (method === "grants" || method === "grant" || method === "revoke") return "kb:grant:manage";
11803
11804
  if (method === "pending.list" || method === "pending.approve" || method === "pending.reject") return "kb:review:decide";
@@ -13399,6 +13400,32 @@ function apply(ctx, deploymentConfig) {
13399
13400
  });
13400
13401
  }
13401
13402
  case "extract.status": return writeJson(res, 200, extractionQueue.snapshot());
13403
+ case "index.status": {
13404
+ const body = await readJsonBody(req);
13405
+ const relPath = typeof body.path === "string" ? body.path.trim() : "";
13406
+ const docId = Number(body.docId);
13407
+ const db = new DatabaseSync(indexPathFor(config.indexDir));
13408
+ try {
13409
+ let row;
13410
+ if (Number.isInteger(docId) && docId > 0) row = db.prepare("select id, path, scope, mtime from kb_docs where id = ?").get(docId);
13411
+ else if (relPath !== "") {
13412
+ const name = relPath.slice(relPath.lastIndexOf("/") + 1);
13413
+ row = db.prepare("select id, path, scope, mtime from kb_docs where path = ?").get(relPath) ?? db.prepare("select id, path, scope, mtime from kb_docs where path = ? order by mtime desc limit 1").get(name) ?? db.prepare("select id, path, scope, mtime from kb_docs where path like ? order by mtime desc limit 1").get("%/" + relPath) ?? db.prepare("select id, path, scope, mtime from kb_docs where path like ? order by mtime desc limit 1").get("%" + relPath) ?? db.prepare("select id, path, scope, mtime from kb_docs where path like ? order by mtime desc limit 1").get("%/" + name);
13414
+ }
13415
+ if (row === void 0) return writeJson(res, 200, { found: false });
13416
+ const count = db.prepare("select count(*) as n from kb_chunks where doc_id = ?").get(row.id);
13417
+ return writeJson(res, 200, {
13418
+ found: true,
13419
+ docId: row.id,
13420
+ path: row.path,
13421
+ scope: row.scope,
13422
+ chunks: Number(count?.n ?? 0),
13423
+ indexedAt: row.mtime
13424
+ });
13425
+ } finally {
13426
+ db.close();
13427
+ }
13428
+ }
13402
13429
  case "extract.retry": {
13403
13430
  const body = await readJsonBody(req);
13404
13431
  const relPath = String(body.relPath ?? "");
@@ -13413,32 +13440,33 @@ function apply(ctx, deploymentConfig) {
13413
13440
  error: "path escapes the knowledge root",
13414
13441
  refused: [relPath]
13415
13442
  });
13416
- if (isAlreadyMarkdown(relPath)) {
13417
- extractionQueue.enqueue(relPath, "");
13443
+ const actualRelPath = await actualRelPathForView(host, config, relPath).catch(() => relPath);
13444
+ if (isAlreadyMarkdown(actualRelPath)) {
13445
+ extractionQueue.enqueue(actualRelPath, "");
13418
13446
  return writeJson(res, 200, {
13419
13447
  outcome: "already-markdown",
13420
- relPath,
13448
+ relPath: actualRelPath,
13421
13449
  message: "this document is already Markdown"
13422
13450
  });
13423
13451
  }
13424
- if (!isExtractable(relPath)) return writeJson(res, 200, {
13452
+ if (!isExtractable(actualRelPath)) return writeJson(res, 200, {
13425
13453
  outcome: "unsupported",
13426
- relPath,
13454
+ relPath: actualRelPath,
13427
13455
  message: "this file type has no extractor"
13428
13456
  });
13429
13457
  const localRoot = localConfig(config);
13430
- const target = join(localRoot.actualRoot, relPath);
13458
+ const target = join(localRoot.actualRoot, actualRelPath);
13431
13459
  const source = await stat(target).catch(() => void 0);
13432
13460
  if (source === void 0 || !source.isFile()) return writeJson(res, 404, { error: "no such file" });
13433
- if (!await canWritePath(host, config, relPath)) return writeJson(res, 403, {
13461
+ if (!await canWritePath(host, config, actualRelPath)) return writeJson(res, 403, {
13434
13462
  error: "write access is required to extract here",
13435
13463
  refused: [relPath]
13436
13464
  });
13437
- const sibling = markdownSiblingOf(relPath);
13465
+ const sibling = markdownSiblingOf(actualRelPath);
13438
13466
  const existing = await stat(join(localRoot.actualRoot, sibling)).catch(() => void 0);
13439
13467
  if (existing !== void 0 && existing.isFile()) return writeJson(res, 200, {
13440
13468
  outcome: "exists",
13441
- relPath,
13469
+ relPath: actualRelPath,
13442
13470
  markdown: sibling
13443
13471
  });
13444
13472
  const settings = await readSettings(viewPathFor({
@@ -13446,13 +13474,13 @@ function apply(ctx, deploymentConfig) {
13446
13474
  viewRoot: localRoot.viewRoot,
13447
13475
  userId: (await fetchCaller(host, config).catch(() => void 0))?.userId ?? ""
13448
13476
  }));
13449
- const accepted = extractionQueue.enqueue(relPath, settings.mineruBaseUrl);
13477
+ const accepted = extractionQueue.enqueue(actualRelPath, settings.mineruBaseUrl);
13450
13478
  if (body.wait === false) return writeJson(res, 202, {
13451
13479
  outcome: "queued",
13452
- relPath,
13480
+ relPath: actualRelPath,
13453
13481
  extraction: accepted
13454
13482
  });
13455
- const settled = await extractionQueue.settled(relPath);
13483
+ const settled = await extractionQueue.settled(actualRelPath);
13456
13484
  const outcome = settled === void 0 ? {
13457
13485
  state: "failed",
13458
13486
  reason: "the extraction task was not recorded"
@@ -138,6 +138,7 @@ export declare const KB_API: {
138
138
  readonly extractStatus: "extract.status";
139
139
  /** Re-run extraction for one document that could not finish. */
140
140
  readonly extractRetry: "extract.retry";
141
+ readonly indexStatus: "index.status";
141
142
  /** Read the caller's pinned paths. */
142
143
  readonly favoritesGet: "favorites.get";
143
144
  readonly favoritesSet: "favorites.set";
@@ -25,6 +25,8 @@ interface HomeRow {
25
25
  location: string;
26
26
  /** Last-modified instant in Unix ms, which is the tree's only timestamp. */
27
27
  mtimeMs?: number;
28
+ /** Host's answer: a `.md` sibling exists beside this non-text document. */
29
+ hasMarkdown?: boolean;
28
30
  }
29
31
  /** Props for the Home surface. */
30
32
  export interface KnowledgeHomeProps {
@@ -60,10 +60,18 @@ export interface KnowledgeProgressPopoverProps {
60
60
  * exists to remove. The page owns the store; this component only draws it.
61
61
  */
62
62
  failures?: readonly FailedExtractionView[];
63
+ /**
64
+ * Keep the persistence/polling work but render no bell or modal.
65
+ *
66
+ * The queue now lives in the Task Center's notifications tab, which reads the same
67
+ * localStorage key; the Knowledge page only needs this component mounted to keep
68
+ * writing observations.
69
+ */
70
+ headless?: boolean;
63
71
  }
64
72
  /**
65
73
  * The notification button and its progress panel.
66
74
  * @param props - the upload batch, whether to watch extraction, and the copy resolver.
67
75
  * @returns The button, plus the panel while it is open.
68
76
  */
69
- export declare function KnowledgeProgressPopover(props: KnowledgeProgressPopoverProps): import("react").JSX.Element;
77
+ export declare function KnowledgeProgressPopover(props: KnowledgeProgressPopoverProps): import("react").JSX.Element | null;
@@ -27,6 +27,8 @@ export interface KnowledgeGraphViewProps {
27
27
  activeTopicId?: string;
28
28
  /** Retry a failed read. */
29
29
  onRetry: () => void;
30
+ /** Open one document (view-relative path) in the right sidebar. */
31
+ onOpenDocument?: (path: string) => void;
30
32
  }
31
33
  /**
32
34
  * Render the canvas, or the state that explains why there is none.
@@ -55,7 +55,6 @@ export declare const en: {
55
55
  readonly renameFailed: "Could not rename: the file may already exist or you may not have write access.";
56
56
  readonly downloadFailed: "Could not download this file.";
57
57
  readonly 'menu.home': "Home";
58
- readonly 'menu.permissions': "Permissions";
59
58
  readonly 'menu.indexing': "Directories & counts";
60
59
  readonly 'menu.trash': "Trash";
61
60
  readonly brand: "Wanxiang Knowledge";
@@ -115,6 +114,24 @@ export declare const en: {
115
114
  readonly homeColOwnerUnset: "Not recorded";
116
115
  readonly homeColCreated: "Created";
117
116
  readonly homeColOpened: "Last opened";
117
+ readonly homeColStatus: "Status";
118
+ readonly homeStatusExtracted: "Extracted";
119
+ readonly homeStatusNotExtracted: "Not extracted";
120
+ readonly homeStatusIndexed: "Indexed";
121
+ readonly homeStatusNotIndexed: "Not indexed";
122
+ readonly statusFileName: "File";
123
+ readonly statusPath: "Path";
124
+ readonly statusType: "Type";
125
+ readonly statusTypeText: "Text";
126
+ readonly statusTypeNonText: "Document";
127
+ readonly statusExtract: "Extraction";
128
+ readonly statusExtractTime: "Extracted at";
129
+ readonly statusMdSize: ".md size";
130
+ readonly statusIndex: "Index";
131
+ readonly statusIndexTime: "Indexed at";
132
+ readonly statusChunks: "Chunks / docId";
133
+ readonly statusFailure: "Failure reason";
134
+ readonly statusClose: "Close";
118
135
  readonly homeEmpty: "Nothing here yet.";
119
136
  readonly trashEmpty: "Trash is empty";
120
137
  readonly trashPending: "Deleting a document is not enabled yet. The knowledge tree is shared, and how long a deletion is kept is still being decided.";
@@ -325,6 +342,10 @@ export declare const en: {
325
342
  readonly auditActionUpload: "Upload";
326
343
  readonly auditActionMove: "Move";
327
344
  readonly auditActionEdit: "Edit";
345
+ readonly auditActionDelete: "Delete";
346
+ readonly auditActionExternalChange: "External change";
347
+ readonly auditActorExternalChange: "External process";
348
+ readonly auditActorSystem: "System";
328
349
  readonly auditOutcomeApplied: "Applied";
329
350
  readonly auditOutcomePending: "Pending review";
330
351
  readonly storageProtocol: "Protocol";
@@ -373,6 +394,7 @@ export declare const en: {
373
394
  readonly progressBadgeMore: "99+";
374
395
  readonly progressOpen: "Upload and extraction progress";
375
396
  readonly progressClose: "Close";
397
+ readonly progressClear: "Clear history";
376
398
  readonly dropRefusedNoCaller: "Cannot place this upload";
377
399
  readonly dropRefusedNoCallerReason: "The host has not reported your account yet, so the personal folder cannot be resolved. Reload and try again.";
378
400
  readonly progressResize: "Drag to resize";
@@ -466,7 +488,14 @@ export declare const en: {
466
488
  readonly refPickerBatchTitle: "Reference {n} items in a chat";
467
489
  readonly refPickerFailed: "Could not insert the reference into that chat.";
468
490
  readonly dropHomeHint: "Drop to upload into your personal folder";
469
- readonly progressTitle: "Progress";
491
+ readonly progressTitle: "Task queue";
492
+ readonly progressQueueTitle: "Tasks";
493
+ readonly progressUploadDone: "Uploaded";
494
+ readonly progressExtractDone: "Extraction done";
495
+ readonly progressStepUploading: "Uploading";
496
+ readonly progressStepUploaded: "Uploaded";
497
+ readonly progressStepExtracting: "Extracting";
498
+ readonly progressStepExtracted: "Extracted";
470
499
  readonly progressRoot: "Root";
471
500
  readonly overviewByRole: "By role";
472
501
  readonly sourceDirect: "Direct";
@@ -496,7 +525,7 @@ export declare const en: {
496
525
  readonly progressExtractGroup: "Extractions";
497
526
  readonly progressUploading: "Uploading {done}/{total}";
498
527
  readonly progressPending: "Queued";
499
- readonly progressExtracting: "Extracting";
528
+ readonly progressExtracting: "Extracting…";
500
529
  readonly progressStepParse: "Parsing document";
501
530
  readonly progressStepChunk: "Chunking text";
502
531
  readonly progressStepIndex: "Writing FTS5 index";
@@ -87,6 +87,10 @@ export interface UploadTaskView {
87
87
  state: 'waiting' | 'uploading' | 'done' | 'failed';
88
88
  /** Why it failed, in the Host's own words. */
89
89
  reason?: string;
90
+ /**
91
+ * When this row was enqueued, ISO-8601. Used to order the unified task queue.
92
+ */
93
+ at?: string;
90
94
  /**
91
95
  * Bytes sent, as 0-100.
92
96
  *
@@ -112,9 +116,12 @@ export interface UploadTaskView {
112
116
  */
113
117
  export declare function pendingCount(uploads: readonly {
114
118
  state: string;
119
+ at?: string;
115
120
  }[], extractions: readonly {
116
121
  state: string;
117
122
  }[]): number;
123
+ /** 上传完成后继续轮询提取任务的窗口。 */
124
+ export declare const UPLOAD_EXTRACTION_GRACE_MS = 30000;
118
125
  /**
119
126
  * How long an optimistic extraction entry may wait for the Host to confirm it.
120
127
  *