@sparkelf/dsh-knowledge-base 0.1.24 → 0.1.25

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
@@ -1281,6 +1281,399 @@ function localizedScopeForPath(relPath, locale = "zh") {
1281
1281
  return relPath;
1282
1282
  }
1283
1283
  //#endregion
1284
+ //#region src/graphTypes.ts
1285
+ /**
1286
+ * The read-only knowledge-graph shape shared by the Host scanner and the browser.
1287
+ *
1288
+ * A graph is an ordinary knowledge folder: `<scope>/知识图谱/<graph>/` with one
1289
+ * visible sub-folder per topic and the relation documents under `.kg/links/`. The
1290
+ * graph tab therefore speaks a DOMAIN model (scope → graph → topic) rather than the
1291
+ * file tree's path model. Keeping the shape in one dependency-free module is what
1292
+ * lets the Host build it and the canvas page render it without a second translation
1293
+ * layer that could disagree about a field name.
1294
+ *
1295
+ * Paths here are VIEW-relative (`global/知识图谱/…`, `department/…`,
1296
+ * `personal/知识图谱/…`): the personal owner segment is deliberately absent, exactly
1297
+ * as the file tree shows it. The Host resolves the authoritative path first; the
1298
+ * browser never needs to know the caller's uid.
1299
+ *
1300
+ * @module @sparkelf/dsh-knowledge-base/graph-types
1301
+ */
1302
+ /** The folder name that marks a knowledge graph inside a scope. */
1303
+ const KNOWLEDGE_GRAPH_FOLDER = "知识图谱";
1304
+ //#endregion
1305
+ //#region src/graph.ts
1306
+ /**
1307
+ * Read-only scanner behind the knowledge-graph tab.
1308
+ *
1309
+ * The graph tab is a NAVIGATION layer over the knowledge tree, not a second
1310
+ * document store. A graph is an ordinary folder under a scope:
1311
+ *
1312
+ * ```text
1313
+ * <scope>/知识图谱/<graph>/
1314
+ * <topic>/index.md # the topic card, visible
1315
+ * <topic>/<document>.md # documents, visible
1316
+ * .kg/links/<a>--<b>.md # relations, hidden from the file tree and search
1317
+ * ```
1318
+ *
1319
+ * Two rules shape this module:
1320
+ *
1321
+ * 1. **The permission answer is the Host's, one graph at a time.** DataOps resolves
1322
+ * each graph folder; a graph it does not resolve is dropped before anything about
1323
+ * it (name, topic, document) is returned. The browser never receives a full scope
1324
+ * and filters it, because a denied name is still a disclosure.
1325
+ * 2. **The scanner is told where the scopes are.** The authoritative personal and
1326
+ * department roots are resolved by the caller through `actualRelPathForView`, so
1327
+ * this module never invents `personal/<uid>/…`. It only reads the directories it
1328
+ * is handed and reports what is inside them.
1329
+ *
1330
+ * `.kg` is skipped by name everywhere a topic or document is listed. It is read on
1331
+ * purpose only for `<graph>/.kg/links/*.md`, which is exactly the structure the
1332
+ * design hides from the knowledge base.
1333
+ *
1334
+ * @module @sparkelf/dsh-knowledge-base/graph
1335
+ */
1336
+ /** How much of a card or document is read for its summary and body. */
1337
+ const GRAPH_TEXT_LIMIT = 262144;
1338
+ /** How many documents one topic reports; a card stack cannot show more. */
1339
+ const MAX_TOPIC_DOCUMENTS = 200;
1340
+ /** How many graphs one scope reports. */
1341
+ const MAX_SCOPE_GRAPHS = 200;
1342
+ /** How many topics one graph reports. */
1343
+ const MAX_GRAPH_TOPICS = 200;
1344
+ /**
1345
+ * Whether a relative path stays inside the root it will be joined to.
1346
+ *
1347
+ * The roots arrive from the Host's own resolver, so this is defence in depth
1348
+ * rather than the primary check: a `..` segment that slipped through a caller
1349
+ * would otherwise read outside the knowledge tree.
1350
+ */
1351
+ function isSafeRelative(relPath) {
1352
+ if (relPath === "" || relPath.startsWith("/") || relPath.includes("\0") || relPath.includes("\\")) return false;
1353
+ return relPath.split("/").every((segment) => segment !== "" && segment !== "." && segment !== "..");
1354
+ }
1355
+ /** Join a relative path onto a root, refusing an escape. */
1356
+ function under(root, relPath) {
1357
+ return isSafeRelative(relPath) ? join(root, relPath) : void 0;
1358
+ }
1359
+ /**
1360
+ * Split a Markdown document into its simple YAML front matter and body.
1361
+ *
1362
+ * The graph format uses one `key: value` per line and optional bracket arrays;
1363
+ * a full YAML parser would be a dependency this package does not need. Unknown
1364
+ * lines are ignored rather than failing the whole card, so a hand-edited
1365
+ * `index.md` still renders.
1366
+ */
1367
+ function parseGraphFrontMatter(text) {
1368
+ const normalized = text.replace(/\r\n/gu, "\n");
1369
+ if (!normalized.startsWith("---\n")) return {
1370
+ meta: {},
1371
+ body: normalized
1372
+ };
1373
+ const end = normalized.indexOf("\n---\n", 4);
1374
+ if (end < 0) return {
1375
+ meta: {},
1376
+ body: normalized
1377
+ };
1378
+ const meta = {};
1379
+ for (const rawLine of normalized.slice(4, end).split("\n")) {
1380
+ const line = rawLine.trim();
1381
+ if (line === "" || line.startsWith("#")) continue;
1382
+ const colon = line.indexOf(":");
1383
+ if (colon <= 0) continue;
1384
+ meta[line.slice(0, colon).trim()] = line.slice(colon + 1).trim();
1385
+ }
1386
+ return {
1387
+ meta,
1388
+ body: normalized.slice(end + 5)
1389
+ };
1390
+ }
1391
+ /** Read a bracketed or comma-separated list value from front matter. */
1392
+ function parseGraphList(value) {
1393
+ if (value === void 0) return [];
1394
+ const trimmed = value.trim();
1395
+ return (trimmed.startsWith("[") && trimmed.endsWith("]") ? trimmed.slice(1, -1) : trimmed).split(",").map((item) => item.trim().replace(/^['"]|['"]$/gu, "")).filter((item) => item !== "");
1396
+ }
1397
+ /** First H1 of a Markdown body, without the marker. */
1398
+ function firstHeading(body) {
1399
+ for (const line of body.split("\n")) if (line.startsWith("# ")) return line.slice(2).trim();
1400
+ }
1401
+ /**
1402
+ * The first paragraph after the H1: the text the card shows as its summary.
1403
+ *
1404
+ * Starting at the H1 rather than at the top is deliberate: the title is already
1405
+ * the card's heading, so repeating it as the first line of the summary would
1406
+ * render the same sentence twice.
1407
+ */
1408
+ function firstSummary(body) {
1409
+ const lines = body.split("\n");
1410
+ let index = lines.findIndex((line) => line.startsWith("# "));
1411
+ if (index < 0) index = 0;
1412
+ else index += 1;
1413
+ while (index < lines.length && lines[index]?.trim() === "") index += 1;
1414
+ const collected = [];
1415
+ while (index < lines.length) {
1416
+ const line = lines[index] ?? "";
1417
+ if (line.trim() === "" || line.startsWith("#")) break;
1418
+ collected.push(line.trim());
1419
+ index += 1;
1420
+ }
1421
+ return collected.join(" ");
1422
+ }
1423
+ /** Remove the H1 and the summary paragraph, leaving the card's free body. */
1424
+ function bodyAfterSummary(body) {
1425
+ const lines = body.split("\n");
1426
+ const heading = lines.findIndex((line) => line.startsWith("# "));
1427
+ if (heading < 0) return body.trim();
1428
+ let start = heading + 1;
1429
+ while (start < lines.length && lines[start]?.trim() === "") start += 1;
1430
+ let end = start;
1431
+ while (end < lines.length && (lines[end] ?? "").trim() !== "" && !(lines[end] ?? "").startsWith("#")) end += 1;
1432
+ return [...lines.slice(0, heading), ...lines.slice(end)].join("\n").trim();
1433
+ }
1434
+ /** Read a text file, returning undefined for binary or oversized content. */
1435
+ async function readGraphText(path) {
1436
+ try {
1437
+ const info = await stat(path);
1438
+ if (!info.isFile() || info.size > GRAPH_TEXT_LIMIT) return void 0;
1439
+ const text = await readFile(path, "utf8");
1440
+ return text.includes("\0") ? void 0 : text;
1441
+ } catch {
1442
+ return;
1443
+ }
1444
+ }
1445
+ /** ISO date (`YYYY-MM-DD`) of a file's modification instant. */
1446
+ async function dateOf(path) {
1447
+ try {
1448
+ return (await stat(path)).mtime.toISOString().slice(0, 10);
1449
+ } catch {
1450
+ return;
1451
+ }
1452
+ }
1453
+ /** Visible child directories of a directory, in name order. */
1454
+ async function visibleDirectories(path) {
1455
+ try {
1456
+ return (await readdir(path, { withFileTypes: true })).filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")).map((entry) => ({
1457
+ name: entry.name,
1458
+ path: join(path, entry.name)
1459
+ })).sort((a, b) => a.name.localeCompare(b.name, "zh-Hans-CN"));
1460
+ } catch {
1461
+ return [];
1462
+ }
1463
+ }
1464
+ /** Visible files of a directory, in name order. */
1465
+ async function visibleFiles(path) {
1466
+ try {
1467
+ return (await readdir(path, { withFileTypes: true })).filter((entry) => entry.isFile() && !entry.name.startsWith(".")).map((entry) => ({
1468
+ name: entry.name,
1469
+ path: join(path, entry.name)
1470
+ })).sort((a, b) => a.name.localeCompare(b.name, "zh-Hans-CN"));
1471
+ } catch {
1472
+ return [];
1473
+ }
1474
+ }
1475
+ /** Join one view-relative parent with a child name, keeping POSIX separators. */
1476
+ function viewJoin(parent, name) {
1477
+ return parent === "" ? name : parent + "/" + name;
1478
+ }
1479
+ /**
1480
+ * Read one topic folder into the canvas's topic shape.
1481
+ *
1482
+ * A missing `index.md` is not an error: the folder still names a topic, and the
1483
+ * card falls back to the folder name with an empty summary. That keeps a
1484
+ * half-written topic navigable instead of silently dropping it.
1485
+ */
1486
+ async function readGraphTopic(graphPath, viewGraphPath, folder) {
1487
+ const topicPath = join(graphPath, folder);
1488
+ const viewTopicPath = viewJoin(viewGraphPath, folder);
1489
+ const cardText = await readGraphText(join(topicPath, "index.md"));
1490
+ const card = cardText === void 0 ? void 0 : parseGraphFrontMatter(cardText);
1491
+ const body = card?.body ?? "";
1492
+ const meta = card?.meta ?? {};
1493
+ const documents = [];
1494
+ for (const file of (await visibleFiles(topicPath)).slice(0, MAX_TOPIC_DOCUMENTS)) {
1495
+ if (file.name === "index.md") continue;
1496
+ const text = await readGraphText(file.path);
1497
+ const documentBody = text === void 0 ? void 0 : parseGraphFrontMatter(text);
1498
+ const title = documentBody === void 0 ? file.name.replace(/\.[^.]+$/u, "") : firstHeading(documentBody.body) ?? file.name.replace(/\.[^.]+$/u, "");
1499
+ const date = await dateOf(file.path);
1500
+ const document = {
1501
+ title,
1502
+ file: file.name,
1503
+ path: viewJoin(viewTopicPath, file.name),
1504
+ ...date === void 0 ? {} : { date },
1505
+ ...documentBody === void 0 ? {} : { summary: firstSummary(documentBody.body) }
1506
+ };
1507
+ documents.push(document);
1508
+ }
1509
+ const depthValue = Number.parseInt(meta.depth ?? "", 10);
1510
+ return {
1511
+ id: (meta.id ?? "").trim() === "" ? folder : (meta.id ?? "").trim(),
1512
+ title: (meta.title ?? "").trim() === "" ? folder : (meta.title ?? "").trim(),
1513
+ status: (meta.status ?? "active").trim() === "" ? "active" : (meta.status ?? "active").trim(),
1514
+ depth: Number.isFinite(depthValue) ? Math.min(5, Math.max(1, depthValue)) : 1,
1515
+ ...(meta.domain ?? "").trim() === "" ? {} : { domain: (meta.domain ?? "").trim() },
1516
+ tags: parseGraphList(meta.tags),
1517
+ summary: firstSummary(body),
1518
+ body: bodyAfterSummary(body),
1519
+ folder,
1520
+ path: viewTopicPath,
1521
+ documents
1522
+ };
1523
+ }
1524
+ /**
1525
+ * Read `<graph>/.kg/links/*.md` into relation records.
1526
+ *
1527
+ * Links whose endpoints are not topics in this graph are dropped: the canvas
1528
+ * draws an edge between two cards, so an edge to a missing id would be an
1529
+ * invisible relation that still counted in the header. The relation FILE stays
1530
+ * on disk; this is a view decision, not a deletion.
1531
+ */
1532
+ async function readGraphLinks(graphPath, topicIds) {
1533
+ const directory = join(graphPath, ".kg", "links");
1534
+ const links = [];
1535
+ for (const file of await visibleFiles(directory)) {
1536
+ if (!file.name.endsWith(".md")) continue;
1537
+ const text = await readGraphText(file.path);
1538
+ if (text === void 0) continue;
1539
+ const { meta, body } = parseGraphFrontMatter(text);
1540
+ const fallback = file.name.replace(/\.md$/u, "").split("--");
1541
+ const a = (meta.a ?? fallback[0] ?? "").trim();
1542
+ const b = (meta.b ?? fallback[1] ?? "").trim();
1543
+ if (a === "" || b === "" || a === b) continue;
1544
+ if (!topicIds.has(a) || !topicIds.has(b)) continue;
1545
+ const label = (meta.label ?? meta.title ?? "").trim();
1546
+ links.push({
1547
+ a,
1548
+ b,
1549
+ ...label === "" ? {} : { label },
1550
+ ...(meta.updated ?? "").trim() === "" ? {} : { updated: (meta.updated ?? "").trim() },
1551
+ body: firstSummary(body)
1552
+ });
1553
+ }
1554
+ return links;
1555
+ }
1556
+ /**
1557
+ * Read one graph folder: its topics, documents and hidden relations.
1558
+ *
1559
+ * The permission question is asked by the caller BEFORE this runs, so this
1560
+ * function never sees a denied folder. It still treats unreadable paths as empty
1561
+ * rather than failing the whole graph: one removed folder should not blank the
1562
+ * canvas.
1563
+ */
1564
+ async function readGraph(actualRoot, root, folder, caller) {
1565
+ const actualGraphPath = under(actualRoot, viewJoin(root.actualPath, folder));
1566
+ if (actualGraphPath === void 0) return void 0;
1567
+ const viewGraphPath = viewJoin(root.viewPath, folder);
1568
+ const topics = [];
1569
+ for (const directory of (await visibleDirectories(actualGraphPath)).slice(0, MAX_GRAPH_TOPICS)) {
1570
+ if (directory.name === ".kg") continue;
1571
+ const topic = await readGraphTopic(actualGraphPath, viewGraphPath, directory.name);
1572
+ if (topic !== void 0) topics.push(topic);
1573
+ }
1574
+ const links = await readGraphLinks(actualGraphPath, new Set(topics.map((topic) => topic.id)));
1575
+ return {
1576
+ id: viewGraphPath,
1577
+ name: folder,
1578
+ scope: root.key,
1579
+ path: viewGraphPath,
1580
+ group: root.groupLabel,
1581
+ topics,
1582
+ links,
1583
+ counts: {
1584
+ topics: topics.length,
1585
+ links: links.length,
1586
+ documents: topics.reduce((total, topic) => total + topic.documents.length, 0)
1587
+ }
1588
+ };
1589
+ }
1590
+ /** Order scopes the way the tree shows them. */
1591
+ const SCOPE_ORDER = [
1592
+ "global",
1593
+ "department",
1594
+ "personal"
1595
+ ];
1596
+ /**
1597
+ * Scan every resolved scope root and build the permission-filtered domain tree.
1598
+ *
1599
+ * The loop asks `canRead` once per graph folder. It never asks about `.kg`: the
1600
+ * metadata folder is read only after the graph itself was allowed, and its
1601
+ * contents are relations whose endpoints are already readable topics.
1602
+ *
1603
+ * @param options - roots, caller, and the permission seam.
1604
+ * @returns The tree, with denied graph names absent and only counted.
1605
+ */
1606
+ async function scanKnowledgeGraphs(options) {
1607
+ const byScope = /* @__PURE__ */ new Map();
1608
+ for (const key of SCOPE_ORDER) byScope.set(key, /* @__PURE__ */ new Map());
1609
+ let scanned = 0;
1610
+ let visible = 0;
1611
+ for (const root of options.roots) {
1612
+ const groups = byScope.get(root.key);
1613
+ if (groups === void 0) continue;
1614
+ const groupKey = root.key === "department" ? root.groupKey : "";
1615
+ let group = groups.get(groupKey);
1616
+ if (group === void 0) {
1617
+ group = {
1618
+ key: groupKey,
1619
+ label: root.groupLabel,
1620
+ graphs: []
1621
+ };
1622
+ groups.set(groupKey, group);
1623
+ }
1624
+ const actualScopePath = under(options.actualRoot, root.actualPath);
1625
+ if (actualScopePath === void 0) continue;
1626
+ const folders = (await visibleDirectories(actualScopePath)).filter((directory) => directory.name !== ".kg").slice(0, MAX_SCOPE_GRAPHS);
1627
+ for (const folder of folders) {
1628
+ scanned += 1;
1629
+ let allowed = false;
1630
+ try {
1631
+ allowed = await options.canRead(viewJoin(root.actualPath, folder.name));
1632
+ } catch {
1633
+ allowed = false;
1634
+ }
1635
+ if (!allowed) continue;
1636
+ const graph = await readGraph(options.actualRoot, root, folder.name, options.caller);
1637
+ if (graph === void 0) continue;
1638
+ group.graphs.push(graph);
1639
+ visible += 1;
1640
+ }
1641
+ }
1642
+ return {
1643
+ scopes: SCOPE_ORDER.map((key) => {
1644
+ const groups = [...byScope.get(key)?.values() ?? []].filter((group) => group.graphs.length > 0).sort((a, b) => a.key.localeCompare(b.key, "zh-Hans-CN"));
1645
+ for (const group of groups) group.graphs.sort((a, b) => a.name.localeCompare(b.name, "zh-Hans-CN"));
1646
+ return {
1647
+ key,
1648
+ groups
1649
+ };
1650
+ }),
1651
+ caller: options.caller,
1652
+ actualRoot: options.actualRoot,
1653
+ scanned,
1654
+ visible,
1655
+ denied: Math.max(0, scanned - visible)
1656
+ };
1657
+ }
1658
+ //#endregion
1659
+ //#region src/graphCanvas.ts
1660
+ /**
1661
+ * The canvas-only knowledge-graph page, served to the panel's iframe.
1662
+ *
1663
+ * This is deliberately a standalone page rather than a React component for the
1664
+ * first version: the prototype's mind-map layout, pan/zoom and detail panel are
1665
+ * already proven, and embedding them keeps the read-only loop small. The page
1666
+ * owns no data and makes no requests; the panel posts the permission-filtered
1667
+ * graph payload into it with `postMessage`.
1668
+ *
1669
+ * The markup is generated from the design prototype with the prototype's rail,
1670
+ * mock table and mock icons removed -- those belong to the panel, which already
1671
+ * has real ones.
1672
+ *
1673
+ * @module @sparkelf/dsh-knowledge-base/graph-canvas
1674
+ */
1675
+ 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; }\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 <div id=\"zoomLabel\">100%</div>\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\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 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 centerOn(id){\n const t = byId.get(id); if (!t) return;\n const h = heights.get(id) || 200;\n const s = 1;\n const x = stage.clientWidth / 2 - (t.x + CARD_W / 2) * s;\n const y = stage.clientHeight / 2 - (t.y + h / 2) * s;\n tweenTo(x, y, s); userMoved = 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('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 if (window.innerWidth < 760){\n const t = byId.get('dsh-enterprise-app') || topics[0];\n const h = heights.get(t.id) || 200;\n view.s = 0.9;\n view.x = stage.clientWidth / 2 - (t.x + CARD_W / 2) * view.s;\n view.y = stage.clientHeight / 2 - (t.y + h / 2) * view.s;\n apply(); userMoved = true;\n } else {\n fit(false);\n }\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) fit(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";
1676
+ //#endregion
1284
1677
  //#region src/viewMetadata.ts
1285
1678
  /**
1286
1679
  * The names of the bookkeeping files this plugin writes into a caller's view.
@@ -10757,7 +11150,7 @@ async function resolveWorkflowActor(ctx, config) {
10757
11150
  * data is already scoped by the caller's grants.
10758
11151
  */
10759
11152
  function permissionForKbRoute(method) {
10760
- if (method === "list" || method === "read" || method === "search") return "kb:file:read";
11153
+ if (method === "list" || method === "read" || method === "search" || method === "graph") return "kb:file:read";
10761
11154
  if (method === "create" || method === "edit" || method === "move" || method === "upload" || method === "rename") return "kb:file:write";
10762
11155
  if (method === "trash" || method === "trash.remove" || method === "trash.restore") return "kb:file:delete";
10763
11156
  if (method === "index" || method === "extract.retry") return "kb:file:extract";
@@ -11451,6 +11844,93 @@ function apply(ctx, deploymentConfig) {
11451
11844
  });
11452
11845
  } catch {}
11453
11846
  switch (method) {
11847
+ case "graph.canvas":
11848
+ res.writeHead(200, {
11849
+ "content-type": "text/html; charset=utf-8",
11850
+ "cache-control": "no-store",
11851
+ "x-content-type-options": "nosniff"
11852
+ });
11853
+ res.end(GRAPH_CANVAS_HTML);
11854
+ return;
11855
+ case "graph": {
11856
+ let caller;
11857
+ try {
11858
+ caller = await fetchCaller(host, config);
11859
+ } catch (error) {
11860
+ return writeJson(res, 403, {
11861
+ error: "the permission service did not identify this caller",
11862
+ code: "forbidden",
11863
+ detail: describeError(error).message
11864
+ });
11865
+ }
11866
+ if (!isSafeUserId(caller.userId)) return writeJson(res, 403, {
11867
+ error: "unsafe caller id",
11868
+ code: "forbidden"
11869
+ });
11870
+ const plan = localConfig(config);
11871
+ const graphViewPath = (scope) => scope + "/知识图谱";
11872
+ /**
11873
+ * Resolve one scope root to its authoritative path.
11874
+ *
11875
+ * `actualRelPathForView` is the authority for a FILE: it matches the view
11876
+ * entry to the source by inode. A directory does not share that inode, so
11877
+ * for an existing scope directory the function falls back to its input.
11878
+ * The personal root therefore uses the uid from `fetchCaller` -- the same
11879
+ * identity `actualRelPathForView` reads -- and never a path supplied by the
11880
+ * browser. Department roots still go through the resolver because
11881
+ * `department/知识图谱` is not a view path: only the caller's chain is.
11882
+ */
11883
+ const scopeActual = async (scope) => {
11884
+ const viewPath = graphViewPath(scope);
11885
+ const resolved = await actualRelPathForView(host, config, viewPath, caller).catch(() => viewPath);
11886
+ if (scope === "personal") {
11887
+ const prefix = "personal/" + caller.userId + "/";
11888
+ return resolved.startsWith(prefix) ? resolved : prefix + KNOWLEDGE_GRAPH_FOLDER;
11889
+ }
11890
+ if (scope === "department" && resolved === viewPath) return void 0;
11891
+ return resolved;
11892
+ };
11893
+ const departmentCodes = caller.departmentPathCodes !== void 0 && caller.departmentPathCodes.length > 0 ? caller.departmentPathCodes : caller.departmentCode === void 0 || caller.departmentCode === "" ? [] : [caller.departmentCode];
11894
+ const roots = [];
11895
+ const globalRoot = await scopeActual("global");
11896
+ if (globalRoot !== void 0) roots.push({
11897
+ key: "global",
11898
+ actualPath: globalRoot,
11899
+ viewPath: graphViewPath("global"),
11900
+ groupKey: "",
11901
+ groupLabel: ""
11902
+ });
11903
+ if (departmentCodes.length > 0) {
11904
+ const departmentRoot = await scopeActual("department");
11905
+ if (departmentRoot !== void 0) roots.push({
11906
+ key: "department",
11907
+ actualPath: departmentRoot,
11908
+ viewPath: graphViewPath("department"),
11909
+ groupKey: departmentCodes.join("/"),
11910
+ groupLabel: (caller.departmentPathNames ?? []).join(" / ") || caller.departmentName || departmentCodes.join(" / ")
11911
+ });
11912
+ }
11913
+ const personalRoot = await scopeActual("personal");
11914
+ if (personalRoot !== void 0) roots.push({
11915
+ key: "personal",
11916
+ actualPath: personalRoot,
11917
+ viewPath: graphViewPath("personal"),
11918
+ groupKey: "",
11919
+ groupLabel: ""
11920
+ });
11921
+ const graphCaller = {
11922
+ userId: caller.userId,
11923
+ label: caller.label,
11924
+ ...caller.departmentPathCodes === void 0 ? {} : { departmentPathCodes: caller.departmentPathCodes },
11925
+ ...caller.departmentPathNames === void 0 ? {} : { departmentPathNames: caller.departmentPathNames }
11926
+ };
11927
+ return writeJson(res, 200, await scanKnowledgeGraphs({
11928
+ actualRoot: plan.actualRoot,
11929
+ roots,
11930
+ caller: graphCaller,
11931
+ canRead: (relPath) => canReadPath(host, config, relPath)
11932
+ }));
11933
+ }
11454
11934
  case "workflow.mine":
11455
11935
  if (!isTeamDeployment(config)) return writeJson(res, 200, { workflows: (await listWorkflowsForAuthor(publishStorePath(publishDirOf(config.publishDir)), "local")).map((record) => ({
11456
11936
  id: record.id,