@musnows/scriverse 0.3.5 → 0.3.6

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.
@@ -0,0 +1,49 @@
1
+ export function racePathLabel(race) {
2
+ const names = Array.isArray(race?.lineage)
3
+ ? race.lineage.map((item) => String(item?.name ?? "").trim()).filter(Boolean)
4
+ : [];
5
+ return names.length ? names.join(" / ") : String(race?.name ?? "").trim();
6
+ }
7
+
8
+ export function raceDescendantIds(races, raceId) {
9
+ const childrenByParent = new Map();
10
+ for (const race of races) {
11
+ const parentRaceId = race?.parentRaceId == null ? null : String(race.parentRaceId);
12
+ const children = childrenByParent.get(parentRaceId) ?? [];
13
+ children.push(race);
14
+ childrenByParent.set(parentRaceId, children);
15
+ }
16
+ const descendants = new Set();
17
+ const pending = [...(childrenByParent.get(String(raceId)) ?? [])];
18
+ while (pending.length) {
19
+ const race = pending.pop();
20
+ const id = String(race?.id ?? "");
21
+ if (!id || descendants.has(id)) continue;
22
+ descendants.add(id);
23
+ pending.push(...(childrenByParent.get(id) ?? []));
24
+ }
25
+ return descendants;
26
+ }
27
+
28
+ export function eligibleRaceParents(races, currentRaceId = null) {
29
+ if (!currentRaceId) return [...races];
30
+ const excluded = raceDescendantIds(races, currentRaceId);
31
+ excluded.add(String(currentRaceId));
32
+ return races.filter((race) => !excluded.has(String(race?.id ?? "")));
33
+ }
34
+
35
+ export function buildRaceForest(races) {
36
+ const nodes = new Map(races.map((race) => [String(race.id), { ...race, children: [] }]));
37
+ const roots = [];
38
+ for (const node of nodes.values()) {
39
+ const parent = node.parentRaceId ? nodes.get(String(node.parentRaceId)) : null;
40
+ if (parent && parent !== node) parent.children.push(node);
41
+ else roots.push(node);
42
+ }
43
+ const sort = (items) => {
44
+ items.sort((left, right) => String(left.name).localeCompare(String(right.name), "zh-CN"));
45
+ for (const item of items) sort(item.children);
46
+ return items;
47
+ };
48
+ return sort(roots);
49
+ }
@@ -21,12 +21,29 @@ const NETWORK_LAYOUTS = Object.freeze({
21
21
  standard: Object.freeze({ width: 1200, height: 640, marginX: 48, marginY: 42, desiredEdgeLength: 196, repulsionStrength: 16800 }),
22
22
  expanded: Object.freeze({ width: 1600, height: 900, marginX: 64, marginY: 56, desiredEdgeLength: 236, repulsionStrength: 22800 })
23
23
  });
24
+ const RELATIONSHIP_EDGE_GAP = 24;
25
+ let relationshipRendererSequence = 0;
26
+ const GALAXY_CELESTIAL_PALETTES = Object.freeze([
27
+ Object.freeze({ key: "solar", hue: 42, saturation: 96, lightness: 68, color: "#ffc95f", core: "#fff8d4", rim: "#9f3c18", atmosphere: "rgba(255,184,72,.58)", ring: "rgba(255,222,151,.72)" }),
28
+ Object.freeze({ key: "azure", hue: 211, saturation: 94, lightness: 68, color: "#61b8ff", core: "#effaff", rim: "#173b85", atmosphere: "rgba(79,156,255,.56)", ring: "rgba(164,214,255,.68)" }),
29
+ Object.freeze({ key: "violet", hue: 263, saturation: 82, lightness: 72, color: "#b58cff", core: "#f7edff", rim: "#4d237c", atmosphere: "rgba(151,92,255,.54)", ring: "rgba(219,190,255,.7)" }),
30
+ Object.freeze({ key: "rose", hue: 342, saturation: 91, lightness: 70, color: "#ff739d", core: "#fff0f5", rim: "#7f1e42", atmosphere: "rgba(255,91,139,.52)", ring: "rgba(255,190,210,.68)" }),
31
+ Object.freeze({ key: "emerald", hue: 158, saturation: 67, lightness: 58, color: "#4ed49e", core: "#e7fff5", rim: "#145f4b", atmosphere: "rgba(50,211,154,.5)", ring: "rgba(166,244,214,.66)" }),
32
+ Object.freeze({ key: "ice", hue: 190, saturation: 88, lightness: 76, color: "#9beaff", core: "#f4fdff", rim: "#26627c", atmosphere: "rgba(116,224,255,.52)", ring: "rgba(206,246,255,.7)" }),
33
+ Object.freeze({ key: "copper", hue: 22, saturation: 74, lightness: 61, color: "#df8851", core: "#ffe8cf", rim: "#672b1c", atmosphere: "rgba(226,103,55,.48)", ring: "rgba(239,179,125,.66)" }),
34
+ Object.freeze({ key: "pearl", hue: 47, saturation: 31, lightness: 84, color: "#e7dfc7", core: "#ffffff", rim: "#6c6b78", atmosphere: "rgba(207,217,240,.46)", ring: "rgba(237,231,216,.72)" })
35
+ ]);
36
+ const GALAXY_CELESTIAL_TYPES = Object.freeze({
37
+ core: Object.freeze(["star", "star", "gas-giant", "ringed"]),
38
+ active: Object.freeze(["gas-giant", "ringed", "ocean", "ice", "volcanic"]),
39
+ outer: Object.freeze(["rocky", "ocean", "ice", "volcanic", "dwarf", "ringed"])
40
+ });
24
41
  export const GALAXY_ROTATION_RADIANS_PER_MS = 0.000012;
25
42
  export const GALAXY_LAYOUT_CONFIG = Object.freeze({
26
- minimumRadius: 165,
27
- radialSpan: 690,
28
- repulsionStrength: 5200,
29
- desiredEdgeLength: 210
43
+ minimumRadius: 220,
44
+ radialSpan: 830,
45
+ repulsionStrength: 9200,
46
+ desiredEdgeLength: 285
30
47
  });
31
48
 
32
49
  export function formatRelationshipLabel(edge, separator = " · ") {
@@ -37,6 +54,17 @@ export function formatRelationshipLabel(edge, separator = " · ") {
37
54
  return [subtype, ...keywords].filter(Boolean).join(separator) || "关系";
38
55
  }
39
56
 
57
+ export function formatRelationshipStatusNote(edge) {
58
+ const statuses = [];
59
+ if (String(edge?.confirmationStatus ?? "pending") === "pending") statuses.push("待确认");
60
+ if (String(edge?.category ?? "") === "uncertain") statuses.push("关系类型未确定");
61
+ return statuses.length ? `(${statuses.join(" · ")})` : "";
62
+ }
63
+
64
+ export function formatRelationshipDetailLabel(edge) {
65
+ return `${formatRelationshipLabel(edge)}${formatRelationshipStatusNote(edge)}`;
66
+ }
67
+
40
68
  export function groupRelationshipDetailsByCharacterName(graph, nodeId) {
41
69
  const groups = new Map();
42
70
  for (const edge of graph.edges) {
@@ -56,21 +84,90 @@ export function getRelationshipEdgeSelection(graph, edgeId) {
56
84
  if (!edge) return null;
57
85
  return {
58
86
  edgeId: edge.id,
87
+ directed: edge.directed,
59
88
  endpointIds: [edge.source, edge.target],
60
89
  endpointNames: [graph.nodeById.get(edge.source)?.name ?? "未知角色", graph.nodeById.get(edge.target)?.name ?? "未知角色"],
61
90
  label: formatRelationshipLabel(edge)
62
91
  };
63
92
  }
64
93
 
94
+ export function assignRelationshipEdgeCurves(edges, gap = RELATIONSHIP_EDGE_GAP) {
95
+ const groups = new Map();
96
+ for (const edge of edges) {
97
+ const endpoints = [String(edge.source), String(edge.target)].sort((left, right) => left.localeCompare(right));
98
+ const key = endpoints.join("\u0000");
99
+ const group = groups.get(key) ?? [];
100
+ group.push(edge);
101
+ groups.set(key, group);
102
+ }
103
+ const offsets = new Map();
104
+ for (const group of groups.values()) {
105
+ const ordered = [...group].sort((left, right) => String(left.id).localeCompare(String(right.id)));
106
+ ordered.forEach((edge, index) => {
107
+ const canonicalOffset = (index - (ordered.length - 1) / 2) * gap;
108
+ const followsCanonicalDirection = String(edge.source).localeCompare(String(edge.target)) <= 0;
109
+ offsets.set(String(edge.id), canonicalOffset * (followsCanonicalDirection ? 1 : -1));
110
+ });
111
+ }
112
+ return offsets;
113
+ }
114
+
115
+ export function getRelationshipEdgeGeometry(from, to, sourceRadius = 0, targetRadius = 0, curveOffset = 0) {
116
+ const dx = Number(to?.x ?? 0) - Number(from?.x ?? 0);
117
+ const dy = Number(to?.y ?? 0) - Number(from?.y ?? 0);
118
+ const distance = Math.max(1, Math.hypot(dx, dy));
119
+ const normalX = -dy / distance;
120
+ const normalY = dx / distance;
121
+ const control = {
122
+ x: Number(from?.x ?? 0) + dx / 2 + normalX * curveOffset * 2,
123
+ y: Number(from?.y ?? 0) + dy / 2 + normalY * curveOffset * 2
124
+ };
125
+ const sourceTangentX = control.x - Number(from?.x ?? 0);
126
+ const sourceTangentY = control.y - Number(from?.y ?? 0);
127
+ const sourceTangentLength = Math.max(1, Math.hypot(sourceTangentX, sourceTangentY));
128
+ const targetTangentX = Number(to?.x ?? 0) - control.x;
129
+ const targetTangentY = Number(to?.y ?? 0) - control.y;
130
+ const targetTangentLength = Math.max(1, Math.hypot(targetTangentX, targetTangentY));
131
+ const startClearance = Math.max(0, Number(sourceRadius) || 0) + 2;
132
+ const endClearance = Math.max(0, Number(targetRadius) || 0) + 2;
133
+ const start = {
134
+ x: Number(from?.x ?? 0) + sourceTangentX / sourceTangentLength * startClearance,
135
+ y: Number(from?.y ?? 0) + sourceTangentY / sourceTangentLength * startClearance
136
+ };
137
+ const end = {
138
+ x: Number(to?.x ?? 0) - targetTangentX / targetTangentLength * endClearance,
139
+ y: Number(to?.y ?? 0) - targetTangentY / targetTangentLength * endClearance
140
+ };
141
+ const curved = Math.abs(curveOffset) > 0.01;
142
+ const labelPoint = curved
143
+ ? {
144
+ x: (start.x + end.x) / 4 + control.x / 2,
145
+ y: (start.y + end.y) / 4 + control.y / 2
146
+ }
147
+ : { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
148
+ return {
149
+ path: curved
150
+ ? `M ${start.x.toFixed(1)} ${start.y.toFixed(1)} Q ${control.x.toFixed(1)} ${control.y.toFixed(1)} ${end.x.toFixed(1)} ${end.y.toFixed(1)}`
151
+ : `M ${start.x.toFixed(1)} ${start.y.toFixed(1)} L ${end.x.toFixed(1)} ${end.y.toFixed(1)}`,
152
+ labelX: labelPoint.x,
153
+ labelY: labelPoint.y,
154
+ angle: Math.atan2(end.y - start.y, end.x - start.x) * 180 / Math.PI
155
+ };
156
+ }
157
+
65
158
  export function resolveRelationshipNodeGroup(node) {
66
159
  const organizations = Array.isArray(node?.organizations) ? node.organizations : [];
67
160
  const orgName = organizations
68
161
  .map((item) => String(item?.name ?? item ?? "").trim())
69
162
  .find(Boolean);
70
163
  const species = String(node?.species ?? "").trim();
164
+ const rootSpecies = String(node?.rootSpecies ?? "").trim();
71
165
  const identity = String(node?.identity ?? "").trim();
72
166
  if (orgName) return { type: "organization", key: `org:${orgName}`, label: orgName };
73
- if (species) return { type: "species", key: `species:${species}`, label: species };
167
+ if (rootSpecies || species) {
168
+ const groupSpecies = rootSpecies || species;
169
+ return { type: "species", key: `species:${groupSpecies}`, label: groupSpecies };
170
+ }
74
171
  if (identity) return { type: "identity", key: `identity:${identity}`, label: identity };
75
172
  return { type: "default", key: "default", label: "未分组" };
76
173
  }
@@ -100,6 +197,15 @@ function hashString(value) {
100
197
  return hash >>> 0;
101
198
  }
102
199
 
200
+ function mixHash(value) {
201
+ let mixed = Number(value) >>> 0;
202
+ mixed ^= mixed >>> 16;
203
+ mixed = Math.imul(mixed, 0x7feb352d);
204
+ mixed ^= mixed >>> 15;
205
+ mixed = Math.imul(mixed, 0x846ca68b);
206
+ return (mixed ^ (mixed >>> 16)) >>> 0;
207
+ }
208
+
103
209
  function seededRandom(seed) {
104
210
  let value = seed || 1;
105
211
  return () => {
@@ -126,6 +232,7 @@ export function buildRelationshipGraph(characters, relationships) {
126
232
  name: String(character.name),
127
233
  aliases: Array.isArray(character.aliases) ? character.aliases : [],
128
234
  species: String(character.species ?? ""),
235
+ rootSpecies: String(character.race?.lineage?.[0]?.name ?? character.species ?? ""),
129
236
  identity: String(character.attributes?.identity ?? ""),
130
237
  organizations,
131
238
  locked: Array.isArray(character.lockedFields) && character.lockedFields.length > 0,
@@ -673,15 +780,41 @@ export function renderRelationshipMindMap(container, graph, options = {}) {
673
780
  svg.setAttribute("viewBox", `0 0 ${layout.width} ${layout.height}`);
674
781
  svg.setAttribute("preserveAspectRatio", "none");
675
782
  svg.setAttribute("aria-label", "人物关系连线");
783
+ const arrowMarkerId = `relationship-edge-arrow-${++relationshipRendererSequence}`;
784
+ const definitions = document.createElementNS("http://www.w3.org/2000/svg", "defs");
785
+ const arrowMarker = document.createElementNS("http://www.w3.org/2000/svg", "marker");
786
+ arrowMarker.id = arrowMarkerId;
787
+ arrowMarker.setAttribute("viewBox", "0 0 8 8");
788
+ arrowMarker.setAttribute("markerWidth", "8");
789
+ arrowMarker.setAttribute("markerHeight", "8");
790
+ arrowMarker.setAttribute("refX", "7");
791
+ arrowMarker.setAttribute("refY", "4");
792
+ arrowMarker.setAttribute("markerUnits", "userSpaceOnUse");
793
+ arrowMarker.setAttribute("orient", "auto");
794
+ const arrowHead = document.createElementNS("http://www.w3.org/2000/svg", "path");
795
+ arrowHead.classList.add("relationship-edge-arrow");
796
+ arrowHead.setAttribute("d", "M 0 0 L 8 4 L 0 8 Z");
797
+ arrowHead.setAttribute("fill", "context-stroke");
798
+ arrowMarker.append(arrowHead);
799
+ definitions.append(arrowMarker);
800
+ svg.append(definitions);
676
801
  const edgeElements = [];
677
802
  const edgeElementsByNode = new Map(graph.nodes.map((node) => [node.id, []]));
803
+ const edgeCurveOffsets = assignRelationshipEdgeCurves(graph.edges);
804
+ const nodeVisualRadii = new Map(graph.nodes.map((node) => [node.id, Math.max(4, Number(node.nodeSize) / 2 || 4)]));
678
805
  const updateEdgeGeometry = ({ edge, hitPath, path }, { includeHit = true } = {}) => {
679
806
  const from = positions.get(edge.source);
680
807
  const to = positions.get(edge.target);
681
808
  if (!from || !to) return;
682
- const geometry = `M ${from.x.toFixed(1)} ${from.y.toFixed(1)} L ${to.x.toFixed(1)} ${to.y.toFixed(1)}`;
683
- path.setAttribute("d", geometry);
684
- if (includeHit) hitPath.setAttribute("d", geometry);
809
+ const geometry = getRelationshipEdgeGeometry(
810
+ from,
811
+ to,
812
+ nodeVisualRadii.get(edge.source),
813
+ nodeVisualRadii.get(edge.target),
814
+ edgeCurveOffsets.get(edge.id)
815
+ );
816
+ path.setAttribute("d", geometry.path);
817
+ if (includeHit) hitPath.setAttribute("d", geometry.path);
685
818
  };
686
819
  const updateLabelGeometry = (edge) => {
687
820
  const from = positions.get(edge.source);
@@ -690,9 +823,16 @@ export function renderRelationshipMindMap(container, graph, options = {}) {
690
823
  const dx = to.x - from.x;
691
824
  const dy = to.y - from.y;
692
825
  const distance = Math.max(1, Math.hypot(dx, dy));
693
- const middleX = (from.x + to.x) / 2;
694
- const middleY = (from.y + to.y) / 2 - 4;
695
- let angle = Math.atan2(dy, dx) * 180 / Math.PI;
826
+ const geometry = getRelationshipEdgeGeometry(
827
+ from,
828
+ to,
829
+ nodeVisualRadii.get(edge.source),
830
+ nodeVisualRadii.get(edge.target),
831
+ edgeCurveOffsets.get(edge.id)
832
+ );
833
+ const middleX = geometry.labelX;
834
+ const middleY = geometry.labelY - 4;
835
+ let angle = geometry.angle;
696
836
  if (angle > 90 || angle < -90) angle += 180;
697
837
  const fullLabel = label.dataset.fullLabel || label.textContent || "";
698
838
  // 按连线长度截断,短边只显示极短摘要,完整内容在底部详情
@@ -719,6 +859,13 @@ export function renderRelationshipMindMap(container, graph, options = {}) {
719
859
  path.style.setProperty("--edge-opacity", "0.24");
720
860
  path.style.setProperty("--edge-width", "1");
721
861
  if (edge.confirmationStatus === "pending") path.classList.add("is-pending");
862
+ if (edge.directed) {
863
+ path.classList.add("is-directed");
864
+ path.setAttribute("marker-end", `url(#${arrowMarkerId})`);
865
+ }
866
+ const sourceName = graph.nodeById.get(edge.source)?.name ?? "未知角色";
867
+ const targetName = graph.nodeById.get(edge.target)?.name ?? "未知角色";
868
+ hitPath.setAttribute("aria-label", `选择 ${sourceName}${edge.directed ? " 指向 " : " 与 "}${targetName} 的关系:${formatRelationshipLabel(edge)}`);
722
869
  svg.append(hitPath, path);
723
870
  const edgeElement = { edge, hitPath, path };
724
871
  edgeElements.push(edgeElement);
@@ -1023,11 +1170,12 @@ export function renderRelationshipMindMap(container, graph, options = {}) {
1023
1170
  label.dataset.fullLabel = fullLabel;
1024
1171
  label.classList.remove("hidden");
1025
1172
  updateLabelGeometry(edgeElement.edge);
1026
- focusText.textContent = `关系:${selection.endpointNames[0]} ${selection.endpointNames[1]}`;
1173
+ const direction = selection.directed ? "→" : "";
1174
+ focusText.textContent = `关系:${selection.endpointNames[0]} ${direction} ${selection.endpointNames[1]}`;
1027
1175
  const heading = document.createElement("b");
1028
- heading.textContent = `${selection.endpointNames[0]}与${selection.endpointNames[1]}`;
1176
+ heading.textContent = `${selection.endpointNames[0]} ${direction} ${selection.endpointNames[1]}`;
1029
1177
  const detailText = document.createElement("span");
1030
- detailText.textContent = selection.label;
1178
+ detailText.textContent = formatRelationshipDetailLabel(edgeElement.edge);
1031
1179
  edgeDetail.replaceChildren(heading, detailText);
1032
1180
  edgeDetail.classList.remove("hidden");
1033
1181
  };
@@ -1348,17 +1496,19 @@ export function createGalaxyStarfield(seed, count = 3600) {
1348
1496
  const stars = [];
1349
1497
  const armCount = 4;
1350
1498
  for (let index = 0; index < count; index += 1) {
1351
- const radius = 55 + Math.pow(random(), 0.62) * 1120;
1499
+ const radius = 55 + Math.pow(random(), 0.62) * 1380;
1352
1500
  const arm = index % armCount;
1353
1501
  const armAngle = arm / armCount * Math.PI * 2;
1354
1502
  const angle = armAngle + radius * 0.0065 + (random() - 0.5) * (0.42 + radius / 1100);
1355
1503
  const thickness = 22 + radius * 0.105;
1504
+ const temperature = random();
1356
1505
  stars.push({
1357
1506
  x: Math.cos(angle) * radius + (random() - 0.5) * 62,
1358
1507
  y: (random() + random() + random() - 1.5) * thickness,
1359
1508
  z: Math.sin(angle) * radius + (random() - 0.5) * 62,
1360
1509
  size: random() > 0.965 ? 1.7 + random() * 1.4 : 0.45 + random() * 0.85,
1361
- brightness: 0.22 + random() * 0.78
1510
+ brightness: 0.22 + random() * 0.78,
1511
+ color: temperature < 0.2 ? "255,218,176" : temperature > 0.78 ? "174,211,255" : "226,237,255"
1362
1512
  });
1363
1513
  }
1364
1514
  return stars;
@@ -1404,22 +1554,37 @@ export function getGalaxyNodeAppearance(node, maxDegree) {
1404
1554
  const weightedDegree = Math.max(0, Number(node?.weightedDegree) || 0);
1405
1555
  const confidenceBoost = clamp(weightedDegree / Math.max(1, degree) / 1.35, 0, 1);
1406
1556
  const intensity = clamp(normalizedDegree * 0.8 + confidenceBoost * 0.2, 0, 1);
1407
- const hue = Math.round(218 - intensity * 166);
1408
- const saturation = Math.round(58 + intensity * 35);
1409
- const lightness = Math.round(47 + intensity * 27);
1410
1557
  const brightness = (0.7 + intensity * 0.68).toFixed(3);
1411
1558
  const glow = (0.26 + intensity * 0.74).toFixed(3);
1412
1559
  const tier = intensity >= 0.7 ? "core" : intensity >= 0.34 ? "active" : "outer";
1560
+ const appearanceSeed = mixHash(hashString([
1561
+ String(node?.id ?? ""),
1562
+ String(node?.name ?? ""),
1563
+ String(node?.groupKey ?? ""),
1564
+ String(node?.species ?? ""),
1565
+ String(node?.identity ?? "")
1566
+ ].join("|")));
1567
+ const palette = GALAXY_CELESTIAL_PALETTES[appearanceSeed % GALAXY_CELESTIAL_PALETTES.length];
1568
+ const celestialTypes = GALAXY_CELESTIAL_TYPES[tier];
1569
+ const celestialType = celestialTypes[Math.floor(appearanceSeed / GALAXY_CELESTIAL_PALETTES.length) % celestialTypes.length];
1570
+ const sizeScale = ({ star: 1.18, "gas-giant": 1.12, ringed: 1.08, ocean: 1, ice: 0.96, volcanic: 1.02, rocky: 0.92, dwarf: 0.76 })[celestialType] ?? 1;
1413
1571
  return {
1414
1572
  degree,
1415
1573
  intensity,
1416
- hue,
1417
- saturation,
1418
- lightness,
1574
+ hue: palette.hue,
1575
+ saturation: palette.saturation,
1576
+ lightness: palette.lightness,
1419
1577
  brightness,
1420
1578
  glow,
1421
1579
  tier,
1422
- color: `hsl(${hue} ${saturation}% ${lightness}%)`
1580
+ palette: palette.key,
1581
+ celestialType,
1582
+ sizeScale,
1583
+ color: palette.color,
1584
+ coreColor: palette.core,
1585
+ rimColor: palette.rim,
1586
+ atmosphereColor: palette.atmosphere,
1587
+ ringColor: palette.ring
1423
1588
  };
1424
1589
  }
1425
1590
 
@@ -1427,6 +1592,10 @@ export function getGalaxyNodeMarkerCenterOffset(nodeSize) {
1427
1592
  return 8 + Math.max(0, Number(nodeSize) || 0) / 2;
1428
1593
  }
1429
1594
 
1595
+ export function getGalaxyNodeDepthOpacity(depth) {
1596
+ return clamp(1.28 - Math.max(0, Number(depth) || 0) / 4800, 0.72, 1);
1597
+ }
1598
+
1430
1599
  export function distanceToGalaxyEdge(point, from, to) {
1431
1600
  const deltaX = to.x - from.x;
1432
1601
  const deltaY = to.y - from.y;
@@ -1457,7 +1626,7 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
1457
1626
  const layout = layoutGalaxy(graph, seed);
1458
1627
  const stars = createGalaxyStarfield(`${seed}|stars`);
1459
1628
  const initialNodePositions = new Map(layout.nodes.map((node) => [node.id, { x: node.x, y: node.y, z: node.z }]));
1460
- const initialCamera = Object.freeze({ yaw: -0.38, pitch: 0.72, distance: 1420, focalRatio: 1.72, zoom: 1, targetX: 0, targetY: 0, targetZ: 0 });
1629
+ const initialCamera = Object.freeze({ yaw: -0.38, pitch: 0.72, distance: 1560, focalRatio: 1.72, zoom: 1, targetX: 0, targetY: 0, targetZ: 0 });
1461
1630
  const camera = { ...initialCamera };
1462
1631
  const nodeElements = new Map();
1463
1632
  const cleanups = [];
@@ -1556,7 +1725,7 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
1556
1725
  const radius = star.size * perspective;
1557
1726
  const twinkle = 0.82 + Math.sin(index * 12.9898 + camera.yaw * 5) * 0.18;
1558
1727
  const alpha = clamp(star.brightness * twinkle * perspective, 0.08, 0.92);
1559
- context.fillStyle = `rgba(216,235,255,${alpha})`;
1728
+ context.fillStyle = `rgba(${star.color},${alpha})`;
1560
1729
  context.beginPath();
1561
1730
  context.arc(point.x, point.y, Math.max(0.28, radius), 0, Math.PI * 2);
1562
1731
  context.fill();
@@ -1662,7 +1831,7 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
1662
1831
  element.style.transformOrigin = `50% ${markerCenterOffset}px`;
1663
1832
  element.style.transform = `translate3d(${point.x}px, ${point.y}px, 0) translate(-50%, -${markerCenterOffset}px) scale(${perspective * selectedScale})`;
1664
1833
  element.style.zIndex = String(10000 - Math.round(point.depth));
1665
- element.style.setProperty("--depth-opacity", String(clamp(1.45 - point.depth / 2300, 0.38, 1)));
1834
+ element.style.setProperty("--depth-opacity", String(getGalaxyNodeDepthOpacity(point.depth)));
1666
1835
  element.dataset.worldX = node.x.toFixed(2);
1667
1836
  element.dataset.worldY = node.y.toFixed(2);
1668
1837
  element.dataset.worldZ = node.z.toFixed(2);
@@ -1773,7 +1942,7 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
1773
1942
  category.className = edge.category;
1774
1943
  return category;
1775
1944
  });
1776
- const labels = [...new Set(group.edges.map((edge) => formatRelationshipLabel(edge)))];
1945
+ const labels = [...new Set(group.edges.map((edge) => formatRelationshipDetailLabel(edge)))];
1777
1946
  item.append(...categories, document.createTextNode(`${group.name} · ${labels.join(";")}`));
1778
1947
  list.append(item);
1779
1948
  }
@@ -1786,11 +1955,11 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
1786
1955
  detail.classList.remove("hidden");
1787
1956
  detail.replaceChildren();
1788
1957
  const heading = document.createElement("strong");
1789
- heading.textContent = selection.endpointNames.join(" ↔ ");
1958
+ heading.textContent = selection.endpointNames.join(selection.directed ? " → " : " ↔ ");
1790
1959
  const category = document.createElement("small");
1791
1960
  category.textContent = RELATION_STYLE[edge.category].label;
1792
1961
  const description = document.createElement("p");
1793
- description.textContent = selection.label;
1962
+ description.textContent = formatRelationshipDetailLabel(edge);
1794
1963
  detail.append(heading, category, description);
1795
1964
  shell.dataset.selectedEdgeSource = selection.endpointIds[0];
1796
1965
  shell.dataset.selectedEdgeTarget = selection.endpointIds[1];
@@ -1807,17 +1976,22 @@ export function createGalaxyRenderer(dialog, graph, options = {}) {
1807
1976
  button.className = "galaxy-node";
1808
1977
  button.dataset.galaxyNode = node.id;
1809
1978
  button.dataset.relationshipTier = appearance.tier;
1810
- const nodeSize = 10 + Math.sqrt(node.degree / maxDegree) * 28;
1979
+ button.dataset.celestialType = appearance.celestialType;
1980
+ button.dataset.celestialPalette = appearance.palette;
1981
+ const nodeSize = clamp((10 + Math.sqrt(node.degree / maxDegree) * 28) * appearance.sizeScale, 8, 48);
1811
1982
  button.style.setProperty("--node-size", `${nodeSize}px`);
1812
1983
  button.dataset.nodeSize = nodeSize.toFixed(3);
1813
1984
  button.style.setProperty("--node-color", appearance.color);
1985
+ button.style.setProperty("--node-core", appearance.coreColor);
1986
+ button.style.setProperty("--node-rim", appearance.rimColor);
1987
+ button.style.setProperty("--node-atmosphere", appearance.atmosphereColor);
1988
+ button.style.setProperty("--node-ring", appearance.ringColor);
1814
1989
  button.style.setProperty("--node-brightness", appearance.brightness);
1815
1990
  button.style.setProperty("--node-glow", appearance.glow);
1816
1991
  const marker = document.createElement("i");
1817
1992
  const label = document.createElement("span");
1818
1993
  label.textContent = node.name;
1819
1994
  button.append(marker, label);
1820
- button.title = `${node.degree} 条关系 · ${appearance.tier === "core" ? "核心高亮" : appearance.tier === "active" ? "活跃连接" : "外围连接"}`;
1821
1995
  button.setAttribute("aria-label", `${node.name},${node.degree} 条关系,${appearance.tier === "core" ? "核心高亮" : appearance.tier === "active" ? "活跃连接" : "外围连接"}${node.aliases.length ? `,别名 ${node.aliases.join("、")}` : ""}`);
1822
1996
  button.setAttribute("aria-grabbed", "false");
1823
1997
  let nodeDrag = null;
@@ -788,6 +788,19 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
788
788
  .record-card small { color: var(--accent); font-size: 9px; letter-spacing: .1em; text-transform: uppercase; }
789
789
  .character-card { cursor: pointer; }
790
790
  .character-card:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
791
+ .character-audit-panel { display: flex; align-items: center; justify-content: space-between; gap: 18px; margin-bottom: 18px; padding: 15px 17px; border: 1px solid var(--line); background: var(--surface-soft); }
792
+ .character-audit-panel div { display: grid; gap: 5px; }
793
+ .character-audit-panel small { max-width: 760px; color: var(--muted); line-height: 1.55; }
794
+ .character-duplicate-review { grid-column: 1 / -1; }
795
+ .character-duplicate-pair { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 14px 0; }
796
+ .character-duplicate-pair section { display: grid; align-content: start; gap: 8px; min-width: 0; padding: 13px; border: 1px solid var(--line); background: var(--surface-soft); }
797
+ .character-duplicate-pair section > small { color: var(--muted); }
798
+ .character-duplicate-pair section p { margin: 0; }
799
+ .character-duplicate-evidence { display: grid; gap: 8px; margin: 13px 0; padding: 0; list-style: none; }
800
+ .character-duplicate-evidence li { display: grid; gap: 5px; padding: 10px 12px; border-left: 2px solid var(--accent); background: var(--surface-soft); }
801
+ .character-duplicate-evidence q { color: var(--text); line-height: 1.55; }
802
+ .character-duplicate-evidence small, .review-resolution-note { color: var(--muted); }
803
+ .character-duplicate-actions { flex-wrap: wrap; }
791
804
  .card-actions { display: flex; gap: 6px; margin-top: 15px; }
792
805
  .card-actions button { border: 1px solid var(--line); background: transparent; border-radius: 3px; font-size: 10px; padding: 5px 8px; }
793
806
  .card-actions .primary-button { border-color: var(--accent); background: var(--accent); color: #fff; padding: 8px 13px; font-size: 12px; }
@@ -802,6 +815,19 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
802
815
  .pill { display: inline-block; padding: 3px 7px; margin: 0 4px 4px 0; border-radius: 12px; background: var(--paper-deep); color: var(--muted); font-size: 9px; }
803
816
  .organization-links { display: flex; flex-wrap: wrap; gap: 5px; align-items: center; margin: 8px 0 6px; }.organization-links b { color: var(--muted); font-size: 9px; font-weight: 500; }.organization-links .pill { margin: 0; }.organization-empty { color: var(--muted); font-size: 9px; }.organization-pill { color: var(--accent-dark); background: rgba(139,61,44,.1); }.organization-settings { margin: 10px 0 6px; }.organization-members { margin-top: 9px !important; }.relationship-keyword { white-space: nowrap; }
804
817
  .race-settings { display: flex; flex-wrap: wrap; gap: 5px; margin: 10px 0 6px; }.race-settings .pill { margin: 0; }.race-members { margin-top: 9px !important; }
818
+ .race-tree { display: grid; gap: 12px; }
819
+ .race-tree-node { min-width: 0; }
820
+ .race-tree-node > summary { display: flex; align-items: center; gap: 10px; min-height: 38px; padding: 8px 12px; border: 1px solid var(--line); border-radius: 4px; background: var(--surface); cursor: pointer; }
821
+ .race-tree-node > summary::marker { color: var(--accent); }
822
+ .race-tree-node > summary span { font-size: 14px; font-weight: 600; }
823
+ .race-tree-node > summary small { margin-left: auto; color: var(--muted); }
824
+ .race-tree-branch { display: grid; gap: 10px; margin: 8px 0 0 14px; padding-left: 14px; border-left: 1px solid var(--line); }
825
+ .race-tree-children { display: grid; gap: 10px; }
826
+ .race-card { min-height: 0; }
827
+ .race-path { margin: 2px 0 10px; color: var(--accent); font-family: var(--font-latin), monospace; font-size: 11px; }
828
+ .race-settings .pill { display: inline-flex; align-items: center; gap: 5px; }
829
+ .race-settings .pill small { color: inherit; font-size: 8px; letter-spacing: 0; text-transform: none; opacity: .72; }
830
+ .race-settings .pill.inherited { border-style: dashed; }
805
831
  .config-section { margin-top: 28px; padding-top: 24px; border-top: 1px solid var(--line); }
806
832
  .config-section:first-child { margin-top: 0; padding-top: 0; border-top: 0; }
807
833
  .platform-system-prompt-section { margin-bottom: 36px; }
@@ -955,6 +981,8 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
955
981
  }
956
982
  .relationship-network .mind-edge,
957
983
  .relationship-network .obsidian-edge { stroke: rgba(255,255,255,.26); stroke-width: 1px; opacity: 1; transition: opacity .12s ease, stroke .12s ease; }
984
+ .relationship-network .mind-edge.is-directed { stroke: rgba(214,218,232,.5); }
985
+ .relationship-network .relationship-edge-arrow { pointer-events: none; }
958
986
  .relationship-network .mind-edge.family,
959
987
  .relationship-network .mind-edge.social,
960
988
  .relationship-network .mind-edge.emotional,
@@ -1094,7 +1122,19 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
1094
1122
  .galaxy-shell.is-rotating-camera { cursor: grabbing; }.galaxy-background, .galaxy-graph, .galaxy-node-layer { position: absolute; inset: 0; width: 100%; height: 100%; }.galaxy-background { opacity: 1; transition: opacity .2s ease; }.galaxy-background.hidden-stars { opacity: .96; }
1095
1123
  .galaxy-graph { z-index: 2; }.galaxy-node-layer { z-index: 3; pointer-events: none; }
1096
1124
  .galaxy-node { --node-size: 12px; position: absolute; left: 0; top: 0; display: grid; grid-template-rows: var(--node-size) auto; justify-items: center; align-items: start; gap: 5px; padding: 8px; border: 0; background: transparent; color: #edf7ff; cursor: grab; touch-action: none; pointer-events: auto; transform-origin: center; opacity: var(--depth-opacity, 1); will-change: transform, opacity; }
1097
- .galaxy-node i { width: var(--node-size); height: var(--node-size); border-radius: 50%; background: radial-gradient(circle at 34% 30%, #fff 0 7%, color-mix(in srgb, var(--node-color) 54%, #fff) 19%, var(--node-color) 51%, color-mix(in srgb, var(--node-color) 52%, #07101f) 100%); box-shadow: 0 0 4px rgba(255,255,255,.88), 0 0 calc(7px + var(--node-glow) * 11px) var(--node-color), 0 0 calc(15px + var(--node-glow) * 25px) color-mix(in srgb, var(--node-color) 72%, transparent); filter: brightness(var(--node-brightness)); transition: filter .18s ease, box-shadow .18s ease; }.galaxy-node span { opacity: 0; max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 3px 6px; border-radius: 4px; background: rgba(5,7,13,.68); font-size: 10px; text-align: center; text-shadow: 0 1px 3px #000; transition: opacity .14s ease; }
1125
+ .galaxy-node i { position: relative; isolation: isolate; width: var(--node-size); height: var(--node-size); border-radius: 50%; background: radial-gradient(circle at 32% 27%, var(--node-core, #fff) 0 7%, color-mix(in srgb, var(--node-color) 62%, #fff) 18%, var(--node-color) 52%, var(--node-rim, #07101f) 100%); box-shadow: inset -3px -4px 7px color-mix(in srgb, var(--node-rim, #07101f) 74%, transparent), 0 0 4px rgba(255,255,255,.82), 0 0 calc(7px + var(--node-glow) * 11px) var(--node-color), 0 0 calc(15px + var(--node-glow) * 25px) var(--node-atmosphere, color-mix(in srgb, var(--node-color) 72%, transparent)); filter: brightness(var(--node-brightness)); transition: filter .18s ease, box-shadow .18s ease; }
1126
+ .galaxy-node i::before, .galaxy-node i::after { content: ""; position: absolute; pointer-events: none; }
1127
+ .galaxy-node i::before { z-index: -1; inset: -42%; border-radius: 50%; background: radial-gradient(circle, var(--node-atmosphere) 0, transparent 68%); opacity: .64; }
1128
+ .galaxy-node[data-celestial-type="star"] i { background: radial-gradient(circle at 38% 34%, #fff 0 10%, var(--node-core) 22%, var(--node-color) 53%, var(--node-rim) 100%); box-shadow: 0 0 5px #fff, 0 0 calc(10px + var(--node-glow) * 15px) var(--node-color), 0 0 calc(24px + var(--node-glow) * 34px) var(--node-atmosphere); }
1129
+ .galaxy-node[data-celestial-type="star"] i::before { inset: -72%; opacity: .92; background: radial-gradient(circle, var(--node-atmosphere) 0 12%, transparent 66%); }
1130
+ .galaxy-node[data-celestial-type="gas-giant"] i { background: linear-gradient(168deg, transparent 0 17%, color-mix(in srgb, var(--node-core) 78%, transparent) 18% 25%, transparent 26% 40%, color-mix(in srgb, var(--node-rim) 64%, transparent) 41% 50%, transparent 51% 68%, color-mix(in srgb, var(--node-core) 54%, transparent) 69% 75%, transparent 76%), radial-gradient(circle at 34% 28%, var(--node-core) 0 7%, var(--node-color) 42%, var(--node-rim) 100%); }
1131
+ .galaxy-node[data-celestial-type="ringed"] i::after { z-index: 2; left: 50%; top: 50%; width: 168%; height: 48%; border: 1px solid var(--node-ring); border-left-color: color-mix(in srgb, var(--node-ring) 28%, transparent); border-right-color: color-mix(in srgb, var(--node-ring) 88%, #fff); border-radius: 50%; box-shadow: 0 0 3px var(--node-atmosphere); transform: translate(-50%, -50%) rotate(-19deg); }
1132
+ .galaxy-node[data-celestial-type="ocean"] i { background: radial-gradient(ellipse at 67% 63%, color-mix(in srgb, var(--node-core) 48%, transparent) 0 10%, transparent 12%), radial-gradient(circle at 28% 24%, var(--node-core) 0 6%, var(--node-color) 38%, color-mix(in srgb, var(--node-color) 58%, #0b2f62) 72%, var(--node-rim) 100%); }
1133
+ .galaxy-node[data-celestial-type="ice"] i { background: conic-gradient(from 35deg at 48% 52%, var(--node-core), var(--node-color), color-mix(in srgb, var(--node-color) 58%, #fff), var(--node-rim), var(--node-core)); }
1134
+ .galaxy-node[data-celestial-type="volcanic"] i { background: radial-gradient(circle at 66% 61%, #ffd37a 0 3%, #ff7438 5%, transparent 9%), radial-gradient(circle at 37% 72%, #ff9b48 0 3%, transparent 8%), radial-gradient(circle at 30% 25%, var(--node-core) 0 5%, var(--node-color) 38%, var(--node-rim) 100%); }
1135
+ .galaxy-node[data-celestial-type="rocky"] i { background: radial-gradient(circle at 67% 31%, color-mix(in srgb, var(--node-rim) 74%, transparent) 0 8%, transparent 10%), radial-gradient(circle at 34% 68%, color-mix(in srgb, var(--node-rim) 58%, transparent) 0 11%, transparent 13%), radial-gradient(circle at 30% 26%, var(--node-core) 0 5%, var(--node-color) 45%, var(--node-rim) 100%); }
1136
+ .galaxy-node[data-celestial-type="dwarf"] i { box-shadow: inset -2px -3px 5px var(--node-rim), 0 0 3px rgba(255,255,255,.65), 0 0 calc(5px + var(--node-glow) * 8px) var(--node-atmosphere); }
1137
+ .galaxy-node span { opacity: 0; max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 3px 6px; border-radius: 4px; background: rgba(5,7,13,.68); font-size: 10px; text-align: center; text-shadow: 0 1px 3px #000; transition: opacity .14s ease; }
1098
1138
  .galaxy-node.show-label span, .galaxy-node:hover span, .galaxy-node:focus-visible span, .galaxy-node.is-selected span, .galaxy-node.is-related span, .galaxy-node.is-edge-endpoint span { opacity: 1; }.galaxy-node.is-selected i { background: #fff4bd; box-shadow: 0 0 5px #fff, 0 0 18px #ffc86b, 0 0 42px rgba(255,173,66,.82); }.galaxy-node.is-related i { background: #d8f4ff; box-shadow: 0 0 4px #fff, 0 0 15px #438cff, 0 0 28px rgba(67,140,255,.56); }.galaxy-node.is-edge-endpoint i { background: #fff; box-shadow: 0 0 6px #fff, 0 0 22px #6fcaff, 0 0 48px rgba(67,140,255,.92); }.galaxy-node.is-dimmed { opacity: .16; }.galaxy-node.is-selected, .galaxy-node.is-related, .galaxy-node.is-edge-endpoint { z-index: 2; }
1099
1139
  .galaxy-node.is-dragging { cursor: grabbing; z-index: 6; }
1100
1140
  .galaxy-close { position: fixed; z-index: 8; top: 22px; right: 25px; width: 42px; height: 42px; border: 1px solid rgba(255,255,255,.22); border-radius: 50%; background: rgba(5,7,13,.62); color: #fff; font-size: 25px; }
@@ -1152,8 +1192,8 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
1152
1192
  .message-action-icon { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
1153
1193
  .message-body > :first-child { margin-top: 0; }.message-body > :last-child { margin-bottom: 0; }
1154
1194
  .message-body p { margin: 0 0 10px; white-space: normal; }
1155
- .message-body h1, .message-body h2, .message-body h3, .message-body h4 { margin: 14px 0 7px; color: inherit; line-height: 1.35; }
1156
- .message-body h1 { font-size: 17px; }.message-body h2 { font-size: 15px; }.message-body h3, .message-body h4 { font-size: 13px; }
1195
+ .message-body h1, .message-body h2, .message-body h3, .message-body h4, .message-body h5, .message-body h6 { margin: 14px 0 7px; color: inherit; line-height: 1.35; }
1196
+ .message-body h1 { font-size: 17px; }.message-body h2 { font-size: 15px; }.message-body h3, .message-body h4 { font-size: 13px; }.message-body h5, .message-body h6 { font-size: 11px; }
1157
1197
  .message-body ul, .message-body ol { margin: 7px 0 11px; padding-left: 21px; }.message-body li { margin: 4px 0; }
1158
1198
  .message-body li.markdown-depth-1 { margin-left: 14px; }.message-body li.markdown-depth-2 { margin-left: 28px; }.message-body li.markdown-depth-3 { margin-left: 42px; }
1159
1199
  .message-body blockquote { margin: 9px 0; padding: 6px 9px; border-left: 3px solid currentColor; background: rgba(255,255,255,.35); opacity: .82; }
@@ -1170,6 +1210,9 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
1170
1210
  .message-body .markdown-table-scroll .markdown-align-left { text-align: left; }.message-body .markdown-table-scroll .markdown-align-center { text-align: center; }.message-body .markdown-table-scroll .markdown-align-right { text-align: right; }
1171
1211
  .message-body a { color: var(--accent-dark); text-decoration: underline; text-underline-offset: 2px; }.user-message .message-body a { color: inherit; }
1172
1212
  .message-body hr { margin: 12px 0; border: 0; border-top: 1px solid currentColor; opacity: .22; }
1213
+ .message-body .markdown-image { display: grid; justify-items: center; gap: 6px; margin: 14px 0; }
1214
+ .message-body .markdown-image img { display: block; max-width: 100%; height: auto; border: 1px solid var(--line); border-radius: 5px; background: var(--surface-soft); }
1215
+ .message-body .markdown-image small { color: var(--muted); font-size: 9px; line-height: 1.45; text-align: center; }
1173
1216
  .message-meta { margin-top: 8px; color: var(--muted); font-size: 9px; }
1174
1217
  .ai-process-details { margin: 0 0 10px; overflow: hidden; border: 1px solid var(--line); border-radius: 5px; background: color-mix(in srgb, var(--surface-soft) 82%, transparent); }
1175
1218
  .ai-process-details > summary { display: flex; align-items: center; justify-content: space-between; gap: 9px; padding: 8px 10px; color: var(--muted); cursor: pointer; font-size: 10px; list-style: none; }
@@ -1390,6 +1433,45 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
1390
1433
  .character-editor-empty-field { display: grid; align-content: start; gap: 7px; color: var(--muted); font-size: 11px; }
1391
1434
  .character-editor-empty-field span { padding: 11px; border: 1px dashed var(--line); border-radius: 4px; font-size: 10px; }
1392
1435
  .character-editor-field-help { margin: -8px 0 0; color: var(--muted); font-size: 9px; }
1436
+ .character-markdown-sections { display: grid; grid-column: 1 / -1; gap: 14px; min-width: 0; }
1437
+ .character-markdown-list-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
1438
+ .character-markdown-list-toolbar > div { display: grid; gap: 3px; }
1439
+ .character-markdown-list-toolbar b { color: var(--ink); font-size: 12px; }
1440
+ .character-markdown-list-toolbar span, .character-markdown-toolbar span { color: var(--muted); font-size: 9px; }
1441
+ .character-markdown-section { min-width: 0; overflow: hidden; border: 1px solid var(--line); border-radius: 6px; background: var(--surface); }
1442
+ .character-markdown-section > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 15px 17px; border-bottom: 1px solid var(--line); background: var(--surface-soft); }
1443
+ .character-markdown-section > header > div:first-child { display: grid; gap: 4px; min-width: 0; }
1444
+ .character-markdown-section > header span { color: var(--accent-dark); font-size: 9px; letter-spacing: .08em; }
1445
+ .character-markdown-section > header h4 { margin: 0; font-size: 16px; }
1446
+ .character-markdown-section > header p { margin: 0; color: var(--muted); font-size: 10px; line-height: 1.5; }
1447
+ .character-markdown-section > header > div:last-child { display: flex; flex: 0 0 auto; gap: 5px; }
1448
+ .character-markdown-section > header button, .character-markdown-version-list button { padding: 5px 8px; border: 1px solid var(--line); border-radius: 4px; background: transparent; color: var(--muted); font-size: 9px; }
1449
+ .character-markdown-document { min-width: 0; padding: 20px clamp(18px, 3vw, 34px); color: var(--ink); font-size: 13px; line-height: 1.75; overflow-wrap: anywhere; }
1450
+ .character-markdown-document h1, .character-markdown-document h2, .character-markdown-document h3, .character-markdown-document h4, .character-markdown-document h5, .character-markdown-document h6 { color: var(--ink); }
1451
+ .character-markdown-document blockquote { background: var(--paper-deep); }
1452
+ .character-markdown-empty, .character-markdown-status { margin: 0; padding: 18px; border: 1px dashed var(--line); border-radius: 5px; color: var(--muted); font-size: 10px; text-align: center; }
1453
+ .character-markdown-editor { display: grid; gap: 16px; padding: 17px; border: 1px solid var(--line); border-radius: 6px; background: var(--surface-soft); }
1454
+ .character-markdown-editor-meta { display: grid; grid-template-columns: minmax(150px, .6fr) minmax(220px, 1.4fr); gap: 12px; }
1455
+ .character-markdown-editor label { display: grid; gap: 6px; color: var(--muted); font-size: 10px; }
1456
+ .character-markdown-editor input, .character-markdown-editor select, .character-markdown-editor textarea { width: 100%; padding: 9px 10px; border: 1px solid var(--line); border-radius: 4px; background: var(--surface); color: var(--ink); font-size: 12px; }
1457
+ .character-markdown-summary-field { grid-column: 1 / -1; }
1458
+ .character-markdown-summary-field textarea { min-height: 72px; }
1459
+ .character-markdown-toolbar { display: flex; align-items: center; gap: 10px; }
1460
+ .character-markdown-toolbar .ghost-button { display: inline-flex; width: auto; padding: 7px 10px; border: 1px solid var(--line); border-radius: 4px; color: var(--ink); cursor: pointer; }
1461
+ .character-markdown-compose { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 12px; min-height: 430px; }
1462
+ .character-markdown-compose > label, .character-markdown-compose > div { min-width: 0; }
1463
+ .character-markdown-compose textarea { height: 100%; min-height: 430px; resize: vertical; font-family: var(--font-latin), var(--font-cjk), monospace; line-height: 1.65; tab-size: 2; }
1464
+ .character-markdown-compose > div { display: grid; grid-template-rows: auto minmax(0, 1fr); overflow: hidden; border: 1px solid var(--line); border-radius: 4px; background: var(--surface); }
1465
+ .character-markdown-preview-label { padding: 8px 10px; border-bottom: 1px solid var(--line); color: var(--muted); font-size: 10px; }
1466
+ .character-markdown-compose .character-markdown-document { max-height: 620px; overflow-y: auto; padding: 14px 16px; }
1467
+ .character-markdown-change-note { max-width: 620px; }
1468
+ .character-markdown-editor-actions { display: flex; justify-content: flex-end; gap: 8px; }
1469
+ .character-markdown-version-list { display: grid; gap: 7px; padding: 10px 16px 16px; border-top: 1px solid var(--line); background: var(--surface-soft); }
1470
+ .character-markdown-version-list article { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 5px 12px; padding: 9px 10px; border: 1px solid var(--line); border-radius: 4px; background: var(--surface); }
1471
+ .character-markdown-version-list article > div { display: flex; gap: 8px; align-items: center; }
1472
+ .character-markdown-version-list time, .character-markdown-version-list p { color: var(--muted); font-size: 9px; }
1473
+ .character-markdown-version-list p { grid-column: 1; margin: 0; }
1474
+ .character-markdown-version-list button { grid-column: 2; grid-row: 1 / 3; align-self: center; }
1393
1475
  .character-history-panel { min-width: 0; overflow-y: auto; border-left: 1px solid var(--line); background: var(--surface-soft); }
1394
1476
  .character-history-heading { position: sticky; z-index: 2; top: 0; display: flex; align-items: flex-start; justify-content: space-between; padding: 18px 16px 14px; border-bottom: 1px solid var(--line); background: var(--panel); }
1395
1477
  .character-history-heading h3 { margin: 2px 0 0; font-size: 16px; }
@@ -1475,6 +1557,9 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
1475
1557
  .character-editor-section-fields > label, .character-editor-section-fields > .form-field, .character-editor-field-help { grid-column: 1; }
1476
1558
  .character-editor-section > header { align-items: flex-start; flex-direction: column; gap: 7px; }
1477
1559
  .character-editor-section > header p { text-align: left; }
1560
+ .character-markdown-compose { grid-template-columns: minmax(0, 1fr); }
1561
+ .character-markdown-editor-meta { grid-template-columns: minmax(0, 1fr); }
1562
+ .character-markdown-summary-field { grid-column: 1; }
1478
1563
  .character-editor-actions { grid-template-columns: minmax(0, 1fr); gap: 10px; }
1479
1564
  .character-editor-actions > div { justify-content: flex-end; }
1480
1565
  .character-editor-workspace.history-open .character-history-panel { width: min(360px, 86vw); }
@@ -13,6 +13,7 @@ export async function startLocalServer(options) {
13
13
  security = resolveRuntimeSecurity(options.env);
14
14
  runtime = createRuntime({
15
15
  databasePath: options.databasePath,
16
+ attachmentDirectory: join(options.dataDirectory, "attachments"),
16
17
  masterSecret: loadMasterSecret(join(options.dataDirectory, "master.key"), options.env.AI_NOVEL_MASTER_KEY),
17
18
  publicPath,
18
19
  security