lume-dsh-plugin 0.7.3 → 0.8.0

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.
Files changed (58) hide show
  1. package/CHANGELOG.md +460 -0
  2. package/README.md +245 -275
  3. package/lib/client.js +242 -181
  4. package/lib/core/card.js +11 -9
  5. package/lib/core/citations.js +235 -0
  6. package/lib/core/coverage.js +149 -0
  7. package/lib/core/dialogue-mining.js +49 -11
  8. package/lib/core/knowledge.js +165 -0
  9. package/lib/core/leak-detector.js +1 -3
  10. package/lib/core/ledger.js +129 -18
  11. package/lib/core/manifest.js +3 -1
  12. package/lib/core/memory-id.js +105 -0
  13. package/lib/core/metrics.js +270 -0
  14. package/lib/core/persona-limits.js +25 -0
  15. package/lib/core/scope.js +124 -0
  16. package/lib/core/signals.js +285 -5
  17. package/lib/core/task-memory.js +143 -0
  18. package/lib/core/text.js +45 -3
  19. package/lib/host/backfill.js +218 -0
  20. package/lib/host/bootstrap.js +130 -0
  21. package/lib/host/boundary.js +1 -3
  22. package/lib/host/clauses.js +180 -0
  23. package/lib/host/config.js +7 -0
  24. package/lib/host/diag.js +44 -7
  25. package/lib/host/distill-prompt.js +365 -0
  26. package/lib/host/distill.js +22 -348
  27. package/lib/host/extraction.js +11 -3
  28. package/lib/host/host-context.js +1 -0
  29. package/lib/host/host-events.js +100 -0
  30. package/lib/host/identity.js +12 -29
  31. package/lib/host/inbound.js +133 -0
  32. package/lib/host/injection.js +2 -6
  33. package/lib/host/llm-aux.js +130 -0
  34. package/lib/host/llm-route.js +3 -0
  35. package/lib/host/methods.js +182 -11
  36. package/lib/host/metrics-log.js +169 -0
  37. package/lib/host/notices.js +62 -0
  38. package/lib/host/project-access.js +210 -0
  39. package/lib/host/project.js +153 -2
  40. package/lib/host/prompt-blocks.js +113 -0
  41. package/lib/host/protocol.js +144 -11
  42. package/lib/host/reflection.js +28 -5
  43. package/lib/host/registry.js +0 -4
  44. package/lib/host/requirements-scan.js +108 -0
  45. package/lib/host/rpc-bridge.js +23 -4
  46. package/lib/host/rpc.js +1 -1
  47. package/lib/host/sections.js +48 -0
  48. package/lib/host/session-deps.js +27 -0
  49. package/lib/host/session-events.js +371 -0
  50. package/lib/host/session-runtime.js +18 -5
  51. package/lib/host/thinking.js +10 -1
  52. package/lib/host/tools.js +367 -0
  53. package/lib/host/triggers.js +30 -6
  54. package/lib/host/turn-boundary.js +116 -0
  55. package/lib/host/wiring.js +280 -0
  56. package/lib/host/workspace-map.js +81 -0
  57. package/lib/index.js +334 -836
  58. package/package.json +13 -4
package/lib/client.js CHANGED
@@ -83,6 +83,40 @@ function detectChatLog(text) {
83
83
  return chat ? chat.speakers : null;
84
84
  }
85
85
 
86
+ //#endregion
87
+ //#region src/client/distill-job.ts
88
+ /** 阶段顺序:客户端据此渲染进度点(宿主只给当前阶段名,不落文案)。 */
89
+ const STAGE_ORDER = [
90
+ "mining",
91
+ "contract",
92
+ "corpus"
93
+ ];
94
+ /** 素材上限(≈2 万字);超出由 RPC 层拒绝。 */
95
+ const TEXT_CAP = 2e4;
96
+ /** 聊天记录素材的宽容上限:原始文本含双人对白 + 时间戳,噪音过半。 */
97
+ const CHAT_TEXT_CAP = 2e5;
98
+ /** 轮询间隔:任务制兜住 10~90s 的不可控蒸馏耗时。 */
99
+ const POLL_INTERVAL_MS = 2e3;
100
+ function applyJobStatus(job) {
101
+ if (job === null || job === void 0) return { kind: "lost" };
102
+ if (job.status === "running") return job.stage ? {
103
+ kind: "stage",
104
+ stage: job.stage
105
+ } : { kind: "wait" };
106
+ if (job.status === "done") return job.card ? {
107
+ kind: "done",
108
+ card: {
109
+ ...job.card,
110
+ memory: job.card.memory ?? void 0
111
+ }
112
+ } : { kind: "wait" };
113
+ if (job.status === "error") return {
114
+ kind: "error",
115
+ reason: job.error ?? "unknown"
116
+ };
117
+ return { kind: "wait" };
118
+ }
119
+
86
120
  //#endregion
87
121
  //#region src/client/form-styles.ts
88
122
  /** 共享表单内联样式:原生 DOM 控件,视觉对齐官方原语(仓库既有模式)。 */
@@ -112,14 +146,7 @@ const labelStyle = {
112
146
  * (仓库既有模式,primitives 没有 Textarea)。轮询 2s 一次,任务制兜住 10~90s
113
147
  * 的不可控蒸馏耗时;宿主重启导致任务丢失时提示重蒸。
114
148
  */
115
- const STAGE_ORDER = [
116
- "mining",
117
- "contract",
118
- "corpus"
119
- ];
120
- const TEXT_CAP = 2e4;
121
149
  /** 聊天记录素材的宽容上限:原始文本含双人对话+时间戳,噪音过半。 */
122
- const CHAT_TEXT_CAP = 2e5;
123
150
  const miniBtn = {
124
151
  background: "none",
125
152
  border: "none",
@@ -189,32 +216,32 @@ function DistillModal({ open, onClose, onSaved, t, callRpc }) {
189
216
  const timer = setInterval(async () => {
190
217
  try {
191
218
  const res = await callRpc("distillStatus", { jobId });
192
- if (cancelled) return;
193
- if (!res?.ok) return;
194
- const job = res.value;
195
- if (job === null || job === void 0) {
196
- setError(t("distill.lost"));
197
- setPhase("input");
198
- return;
199
- }
200
- if (job.status === "running") {
201
- if (job.stage) setStage(job.stage);
202
- return;
203
- }
204
- if (job.status === "done" && job.card) {
205
- setCard({
206
- ...job.card,
207
- memory: job.card.memory ?? void 0
208
- });
209
- setStage("corpus");
210
- setPhase("preview");
211
- setShowComplete(true);
212
- } else if (job.status === "error") {
213
- setError(t("distill.failed", { message: job.error ?? "unknown" }));
214
- setPhase("input");
219
+ if (cancelled || !res?.ok) return;
220
+ switch (applyJobStatus(res.value).kind) {
221
+ case "lost":
222
+ setError(t("distill.lost"));
223
+ setPhase("input");
224
+ break;
225
+ case "stage":
226
+ setStage(res.value.stage ?? null);
227
+ break;
228
+ case "done": {
229
+ const action = applyJobStatus(res.value);
230
+ if (action.kind === "done") setCard(action.card);
231
+ setStage("corpus");
232
+ setPhase("preview");
233
+ setShowComplete(true);
234
+ break;
235
+ }
236
+ case "error": {
237
+ const action = applyJobStatus(res.value);
238
+ if (action.kind === "error") setError(t("distill.failed", { message: action.reason }));
239
+ setPhase("input");
240
+ break;
241
+ }
215
242
  }
216
243
  } catch {}
217
- }, 2e3);
244
+ }, POLL_INTERVAL_MS);
218
245
  return () => {
219
246
  cancelled = true;
220
247
  clearInterval(timer);
@@ -262,7 +289,7 @@ function DistillModal({ open, onClose, onSaved, t, callRpc }) {
262
289
  const importFile = async (file) => {
263
290
  if (!file) return;
264
291
  const content = await file.text();
265
- if (content.length > CHAT_TEXT_CAP) {
292
+ if (content.length > 2e5) {
266
293
  setError(t("distill.too.long", { cap }));
267
294
  return;
268
295
  }
@@ -749,31 +776,23 @@ function DistillModal({ open, onClose, onSaved, t, callRpc }) {
749
776
  }
750
777
 
751
778
  //#endregion
752
- //#region src/client/memory.tsx
753
- /**
754
- * 记忆星图:某个角色的长期记忆,以力导向星空图可视化。
755
- *
756
- * 渲染为自定义宽幅遮罩层(不依赖 Modal 原语——Modal 的 dialog 宽度上限 380px,
757
- * 对记忆星图这种需要横屏空间的内容过于局促,改为自绘遮罩 + 居中卡片)。
758
- *
759
- * - Canvas 渲染:星空背景 + 力导向布局 + 缓慢绕圈漂移 + 发光卡片 + 语义连线
760
- * - 核心记忆(身份/称呼类,宿主 isCoreMemory 判定)紫色 + ★,普通记忆青色
761
- * - 点击卡片 → 右侧详情面板(只读正文 + 关联记忆 + 编辑/删除)
762
- * - 编辑:textarea → updateMemory;删除:deleteMemory
763
- * - 顶部日期筛选:全部 / 最近 7 天 / 30 天 / 90 天
764
- */
779
+ //#region src/client/graph-layout.ts
765
780
  const CARD_W = 260;
766
781
  const CARD_H = 72;
767
- const CARD_R = 12;
768
782
  const CORE_COLOR = "#a78bfa";
769
783
  const NORMAL_COLOR = "#67e8f9";
770
- const OVERLAY_W = 960;
771
784
  const FILTER_MS = {
772
785
  all: 0,
773
786
  "7d": 6048e5,
774
787
  "30d": 2592e6,
775
788
  "90d": 7776e6
776
789
  };
790
+ /** 时间筛选:now 由调用方传入(纯函数,便于测试)。 */
791
+ function filterByAge(items, filter, now) {
792
+ const ms = FILTER_MS[filter];
793
+ return items.filter((m) => !ms || now - m.at <= ms);
794
+ }
795
+ /** 词法切分:英文按词、中文按二元组(让"相似"在中文上也可用)。 */
777
796
  function tokenize(text) {
778
797
  const tokens = [];
779
798
  const lowered = text.toLowerCase();
@@ -787,8 +806,10 @@ function tokenize(text) {
787
806
  }
788
807
  return tokens;
789
808
  }
809
+ /** Jaccard 相似度(基于 tokenize 的集合)。 */
790
810
  function jaccard(a, b) {
791
- const sa = new Set(tokenize(a)), sb = new Set(tokenize(b));
811
+ const sa = new Set(tokenize(a));
812
+ const sb = new Set(tokenize(b));
792
813
  if (sa.size === 0 || sb.size === 0) return 0;
793
814
  let hit = 0;
794
815
  sa.forEach((t) => {
@@ -796,8 +817,8 @@ function jaccard(a, b) {
796
817
  });
797
818
  return hit / (sa.size + sb.size - hit);
798
819
  }
799
- function relTime(ts) {
800
- const sec = Math.floor((Date.now() - ts) / 1e3);
820
+ function relTime(ts, now = Date.now()) {
821
+ const sec = Math.floor((now - ts) / 1e3);
801
822
  if (sec < 60) return "刚刚";
802
823
  const min = Math.floor(sec / 60);
803
824
  if (min < 60) return `${min} 分钟前`;
@@ -805,15 +826,17 @@ function relTime(ts) {
805
826
  if (hr < 24) return `${hr} 小时前`;
806
827
  return `${Math.floor(hr / 24)} 天前`;
807
828
  }
808
- function hexGlow(hex, alpha) {
809
- return `rgba(${parseInt(hex.slice(1, 3), 16)},${parseInt(hex.slice(3, 5), 16)},${parseInt(hex.slice(5, 7), 16)},${alpha})`;
810
- }
811
829
  function rgba(hex, alpha) {
812
830
  return `rgba(${parseInt(hex.slice(1, 3), 16)},${parseInt(hex.slice(3, 5), 16)},${parseInt(hex.slice(5, 7), 16)},${alpha})`;
813
831
  }
832
+ /** 星图的辉光色(与 rgba 同源,语义不同:一个是光晕,一个是描边)。 */
833
+ function hexGlow(hex, alpha) {
834
+ return rgba(hex, alpha);
835
+ }
814
836
  function brighten(hex) {
815
837
  return `rgb(${Math.min(255, parseInt(hex.slice(1, 3), 16) + 50)},${Math.min(255, parseInt(hex.slice(3, 5), 16) + 50)},${Math.min(255, parseInt(hex.slice(5, 7), 16) + 50)})`;
816
838
  }
839
+ /** 按 canvas 实测宽度折行(依赖传入的 ctx,便于用假 ctx 测试)。 */
817
840
  function wrapText(ctx, text, maxW) {
818
841
  const chars = text.split("");
819
842
  const lines = [];
@@ -828,6 +851,132 @@ function wrapText(ctx, text, maxW) {
828
851
  if (cur) lines.push(cur);
829
852
  return lines;
830
853
  }
854
+ const EDGE_MIN = .12;
855
+ const CORE_EDGE = .15;
856
+ const LINK_DIST = 160;
857
+ const LINK_K = .025;
858
+ const CENTER_PULL = .012;
859
+ const DAMPING = .86;
860
+ const MARGIN = 90;
861
+ /**
862
+ * 建图:节点随机落在中心区域,两两相似度 ≥0.12 连边(核心记忆与所有节点弱连)。
863
+ * rand 注入是为了可复现(测试喂固定序列)。
864
+ */
865
+ function buildGraph(items, opts) {
866
+ const rand = opts.rand ?? Math.random;
867
+ const W = opts.w, H = opts.h;
868
+ const nodes = items.map((m, id) => {
869
+ const x = W / 2 + (rand() - .5) * W * .22;
870
+ const y = H / 2 + (rand() - .5) * H * .22;
871
+ return {
872
+ id,
873
+ text: m.text,
874
+ at: m.at,
875
+ core: m.core,
876
+ x,
877
+ y,
878
+ ax: x,
879
+ ay: y,
880
+ orbitR: 3 + rand() * 5,
881
+ orbitPhase: rand() * Math.PI * 2,
882
+ orbitSpeed: .006 + rand() * .008,
883
+ vx: 0,
884
+ vy: 0,
885
+ pinned: false
886
+ };
887
+ });
888
+ const edges = [];
889
+ for (let i = 0; i < nodes.length; i++) for (let j = i + 1; j < nodes.length; j++) {
890
+ const w = jaccard(nodes[i].text, nodes[j].text);
891
+ if (w >= EDGE_MIN) edges.push({
892
+ source: i,
893
+ target: j,
894
+ weight: w
895
+ });
896
+ else if (nodes[i].core || nodes[j].core) edges.push({
897
+ source: i,
898
+ target: j,
899
+ weight: CORE_EDGE
900
+ });
901
+ }
902
+ return {
903
+ nodes,
904
+ edges
905
+ };
906
+ }
907
+ /** 一步力导向迭代:斥力 + 弹簧 + 向心力 + 阻尼 + 边界钳制(纯数学,就地改节点)。 */
908
+ function forceStep(nodes, edges, w, h, rand = Math.random) {
909
+ const cx = w / 2, cy = h / 2;
910
+ for (let i = 0; i < nodes.length; i++) {
911
+ const a = nodes[i];
912
+ if (a.pinned) continue;
913
+ for (let j = i + 1; j < nodes.length; j++) {
914
+ const b = nodes[j];
915
+ if (b.pinned) continue;
916
+ let dx = a.x - b.x, dy = a.y - b.y, d2 = dx * dx + dy * dy;
917
+ if (d2 < 1) {
918
+ dx = rand() - .5;
919
+ dy = rand() - .5;
920
+ d2 = 1;
921
+ }
922
+ const d = Math.sqrt(d2), f = 12100 / d;
923
+ a.vx += dx / d * f;
924
+ a.vy += dy / d * f;
925
+ b.vx -= dx / d * f;
926
+ b.vy -= dy / d * f;
927
+ }
928
+ }
929
+ for (const e of edges) {
930
+ const a = nodes[e.source], b = nodes[e.target];
931
+ const dx = b.x - a.x, dy = b.y - a.y;
932
+ const d = Math.sqrt(dx * dx + dy * dy) || 1;
933
+ const f = (d - LINK_DIST) * LINK_K * e.weight;
934
+ if (!a.pinned) {
935
+ a.vx += dx / d * f;
936
+ a.vy += dy / d * f;
937
+ }
938
+ if (!b.pinned) {
939
+ b.vx -= dx / d * f;
940
+ b.vy -= dy / d * f;
941
+ }
942
+ }
943
+ for (const n of nodes) {
944
+ if (n.pinned) continue;
945
+ n.vx += (cx - n.x) * CENTER_PULL;
946
+ n.vy += (cy - n.y) * CENTER_PULL;
947
+ n.x += n.vx;
948
+ n.y += n.vy;
949
+ n.vx *= DAMPING;
950
+ n.vy *= DAMPING;
951
+ if (n.x < MARGIN) n.x = MARGIN;
952
+ else if (n.x > w - MARGIN) n.x = w - MARGIN;
953
+ if (n.y < MARGIN) n.y = MARGIN;
954
+ else if (n.y > h - MARGIN) n.y = h - MARGIN;
955
+ }
956
+ }
957
+ /** 命中测试:从后往前(后来的在上层)。 */
958
+ function hitTestAt(nodes, mx, my) {
959
+ for (let i = nodes.length - 1; i >= 0; i--) {
960
+ const n = nodes[i];
961
+ if (mx >= n.x - 260 / 2 && mx <= n.x + 260 / 2 && my >= n.y - 72 / 2 && my <= n.y + 72 / 2) return i;
962
+ }
963
+ return -1;
964
+ }
965
+
966
+ //#endregion
967
+ //#region src/client/memory.tsx
968
+ /**
969
+ * 记忆星图:某个角色的长期记忆,以力导向星空图可视化。
970
+ *
971
+ * 渲染为自定义宽幅遮罩层(不依赖 Modal 原语——Modal 的 dialog 宽度上限 380px,
972
+ * 对记忆星图这种需要横屏空间的内容过于局促,改为自绘遮罩 + 居中卡片)。
973
+ *
974
+ * - Canvas 渲染:星空背景 + 力导向布局 + 缓慢绕圈漂移 + 发光卡片 + 语义连线
975
+ * - 核心记忆(身份/称呼类,宿主 isCoreMemory 判定)紫色 + ★,普通记忆青色
976
+ * - 点击卡片 → 右侧详情面板(只读正文 + 关联记忆 + 编辑/删除)
977
+ * - 编辑:textarea → updateMemory;删除:deleteMemory
978
+ * - 顶部日期筛选:全部 / 最近 7 天 / 30 天 / 90 天
979
+ */
831
980
  function MemoryStarMap({ open, onClose, personaName, personaLabel, t, callRpc }) {
832
981
  const canvasRef = (0, react.useRef)(null);
833
982
  const graphRef = (0, react.useRef)({
@@ -857,108 +1006,24 @@ function MemoryStarMap({ open, onClose, personaName, personaLabel, t, callRpc })
857
1006
  (0, react.useEffect)(() => {
858
1007
  if (open) load();
859
1008
  }, [open, personaName]);
860
- const filtered = memories.filter((m) => !FILTER_MS[filter] || Date.now() - m.at <= FILTER_MS[filter]);
1009
+ const filtered = filterByAge(memories, filter, Date.now());
861
1010
  (0, react.useEffect)(() => {
862
1011
  const canvas = canvasRef.current;
863
1012
  if (!canvas || !open) return;
864
1013
  const dpr = window.devicePixelRatio || 1;
865
- const W = OVERLAY_W, H = 544;
1014
+ const W = 960, H = 544;
866
1015
  canvas.width = W * dpr;
867
1016
  canvas.height = H * dpr;
868
- canvas.style.width = "960px";
869
- canvas.style.height = "544px";
1017
+ canvas.style.width = W + "px";
1018
+ canvas.style.height = H + "px";
870
1019
  const ctx = canvas.getContext("2d");
871
1020
  if (!ctx) return;
872
1021
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
873
- const nodes = filtered.map((m, id) => {
874
- const x = W / 2 + (Math.random() - .5) * W * .22;
875
- const y = H / 2 + (Math.random() - .5) * H * .22;
876
- return {
877
- id,
878
- text: m.text,
879
- at: m.at,
880
- core: m.core,
881
- x,
882
- y,
883
- ax: x,
884
- ay: y,
885
- orbitR: 3 + Math.random() * 5,
886
- orbitPhase: Math.random() * Math.PI * 2,
887
- orbitSpeed: .006 + Math.random() * .008,
888
- vx: 0,
889
- vy: 0,
890
- pinned: false
891
- };
1022
+ const { nodes, edges } = buildGraph(filtered, {
1023
+ w: W,
1024
+ h: H
892
1025
  });
893
- const edges = [];
894
- for (let i = 0; i < nodes.length; i++) for (let j = i + 1; j < nodes.length; j++) {
895
- const w = jaccard(nodes[i].text, nodes[j].text);
896
- if (w >= .12) edges.push({
897
- source: i,
898
- target: j,
899
- weight: w
900
- });
901
- else if (nodes[i].core) edges.push({
902
- source: i,
903
- target: j,
904
- weight: .15
905
- });
906
- else if (nodes[j].core) edges.push({
907
- source: i,
908
- target: j,
909
- weight: .15
910
- });
911
- }
912
- const forceStep = () => {
913
- const cx = W / 2, cy = H / 2;
914
- for (let i = 0; i < nodes.length; i++) {
915
- const a = nodes[i];
916
- if (a.pinned) continue;
917
- for (let j = i + 1; j < nodes.length; j++) {
918
- const b = nodes[j];
919
- if (b.pinned) continue;
920
- let dx = a.x - b.x, dy = a.y - b.y, d2 = dx * dx + dy * dy;
921
- if (d2 < 1) {
922
- dx = Math.random() - .5;
923
- dy = Math.random() - .5;
924
- d2 = 1;
925
- }
926
- const d = Math.sqrt(d2), f = 12100 / d;
927
- a.vx += dx / d * f;
928
- a.vy += dy / d * f;
929
- b.vx -= dx / d * f;
930
- b.vy -= dy / d * f;
931
- }
932
- }
933
- for (const e of edges) {
934
- const a = nodes[e.source], b = nodes[e.target];
935
- const dx = b.x - a.x, dy = b.y - a.y;
936
- const d = Math.sqrt(dx * dx + dy * dy) || 1;
937
- const f = (d - 160) * .025 * e.weight;
938
- if (!a.pinned) {
939
- a.vx += dx / d * f;
940
- a.vy += dy / d * f;
941
- }
942
- if (!b.pinned) {
943
- b.vx -= dx / d * f;
944
- b.vy -= dy / d * f;
945
- }
946
- }
947
- for (const n of nodes) {
948
- if (n.pinned) continue;
949
- n.vx += (cx - n.x) * .012;
950
- n.vy += (cy - n.y) * .012;
951
- n.x += n.vx;
952
- n.y += n.vy;
953
- n.vx *= .86;
954
- n.vy *= .86;
955
- if (n.x < 90) n.x = 90;
956
- else if (n.x > 870) n.x = 870;
957
- if (n.y < 90) n.y = 90;
958
- else if (n.y > 454) n.y = 454;
959
- }
960
- };
961
- for (let i = 0; i < 250; i++) forceStep();
1026
+ for (let i = 0; i < 250; i++) forceStep(nodes, edges, W, H);
962
1027
  nodes.forEach((n) => {
963
1028
  n.ax = n.x;
964
1029
  n.ay = n.y;
@@ -975,13 +1040,6 @@ function MemoryStarMap({ open, onClose, personaName, personaLabel, t, callRpc })
975
1040
  phase: Math.random() * Math.PI * 2
976
1041
  }));
977
1042
  let hovered = null, dragging = null, dragOffX = 0, dragOffY = 0, dragMoved = false;
978
- const hitTest = (mx, my) => {
979
- for (let i = nodes.length - 1; i >= 0; i--) {
980
- const n = nodes[i];
981
- if (mx >= n.x - CARD_W / 2 && mx <= n.x + CARD_W / 2 && my >= n.y - CARD_H / 2 && my <= n.y + CARD_H / 2) return i;
982
- }
983
- return -1;
984
- };
985
1043
  const onMove = (e) => {
986
1044
  const rect = canvas.getBoundingClientRect();
987
1045
  const mx = e.clientX - rect.left, my = e.clientY - rect.top;
@@ -993,7 +1051,7 @@ function MemoryStarMap({ open, onClose, personaName, personaLabel, t, callRpc })
993
1051
  dragMoved = true;
994
1052
  return;
995
1053
  }
996
- const idx = hitTest(mx, my);
1054
+ const idx = hitTestAt(nodes, mx, my);
997
1055
  hovered = idx >= 0 ? idx : null;
998
1056
  canvas.style.cursor = idx >= 0 ? "grab" : "default";
999
1057
  };
@@ -1001,7 +1059,7 @@ function MemoryStarMap({ open, onClose, personaName, personaLabel, t, callRpc })
1001
1059
  const rect = canvas.getBoundingClientRect();
1002
1060
  const mx = e.clientX - rect.left, my = e.clientY - rect.top;
1003
1061
  dragMoved = false;
1004
- const idx = hitTest(mx, my);
1062
+ const idx = hitTestAt(nodes, mx, my);
1005
1063
  if (idx >= 0) {
1006
1064
  dragging = nodes[idx];
1007
1065
  dragging.pinned = true;
@@ -1022,7 +1080,7 @@ function MemoryStarMap({ open, onClose, personaName, personaLabel, t, callRpc })
1022
1080
  const onClick = (e) => {
1023
1081
  if (dragMoved) return;
1024
1082
  const rect = canvas.getBoundingClientRect();
1025
- const idx = hitTest(e.clientX - rect.left, e.clientY - rect.top);
1083
+ const idx = hitTestAt(nodes, e.clientX - rect.left, e.clientY - rect.top);
1026
1084
  setSelected(idx >= 0 ? idx : null);
1027
1085
  if (idx >= 0) setEditing(false);
1028
1086
  };
@@ -1082,7 +1140,7 @@ function MemoryStarMap({ open, onClose, personaName, personaLabel, t, callRpc })
1082
1140
  const color = n.core ? CORE_COLOR : NORMAL_COLOR;
1083
1141
  const hover = hovered === n.id, sel = selected === n.id;
1084
1142
  const scale = hover ? 1.08 : 1;
1085
- const w = CARD_W * scale, h = CARD_H * scale;
1143
+ const w = 260 * scale, h = 72 * scale;
1086
1144
  const x = n.x - w / 2, y = n.y - h / 2;
1087
1145
  ctx.save();
1088
1146
  ctx.shadowColor = hexGlow(color, sel ? .7 : hover ? .5 : n.core ? .3 : .14);
@@ -1090,7 +1148,7 @@ function MemoryStarMap({ open, onClose, personaName, personaLabel, t, callRpc })
1090
1148
  const bg2 = ctx.createLinearGradient(x, y, x, y + h);
1091
1149
  bg2.addColorStop(0, "rgba(20,28,56,0.95)");
1092
1150
  bg2.addColorStop(1, "rgba(10,14,32,0.96)");
1093
- const r = CARD_R * scale;
1151
+ const r = 12 * scale;
1094
1152
  ctx.beginPath();
1095
1153
  ctx.moveTo(x + r, y);
1096
1154
  ctx.lineTo(x + w - r, y);
@@ -1213,7 +1271,7 @@ function MemoryStarMap({ open, onClose, personaName, personaLabel, t, callRpc })
1213
1271
  },
1214
1272
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1215
1273
  style: {
1216
- width: OVERLAY_W,
1274
+ width: 960,
1217
1275
  maxWidth: "96vw",
1218
1276
  background: "linear-gradient(160deg, rgba(20,28,58,0.97), rgba(10,14,32,0.99))",
1219
1277
  border: "1px solid rgba(110,180,255,0.28)",
@@ -1462,17 +1520,19 @@ function MemoryStarMap({ open, onClose, personaName, personaLabel, t, callRpc })
1462
1520
  }
1463
1521
 
1464
1522
  //#endregion
1465
- //#region src/client/manage.tsx
1523
+ //#region src/client/dom-utils.ts
1466
1524
  /**
1467
- * 管理自定义人设:列出全部条目(内置的编辑/删除置灰),支持编辑契约、删除、导出与导入。
1525
+ * client 侧的小工具(架构整理 ③)。
1468
1526
  *
1469
- * - 删除走行内二次确认(删除会连带记忆/风格/档案,不可恢复);
1470
- * - 编辑复用蒸馏预览的字段布局,键名是存储主键、创建后不可改;
1471
- * - 保存复用 saveCustomPersona 的 upsert 语义(带原 createdAt);
1472
- * - 导出任何人设(含内置)为自包含 JSON 卡片文件,可选是否包含记忆;
1473
- * - 导入 JSON 卡片文件,同名覆盖需二次确认。
1527
+ * 只放**与 UI 框架无关**的东西:DOM 下载这种一次性副作用、以及可以单测的纯函数。
1528
+ * 为什么单独成文件:manage 的导出与将来的其它导出入口共用同一套命名/下载逻辑,
1529
+ * 免得每处各写一遍 `createElement("a")`。
1474
1530
  */
1475
- /** 浏览器下载 JSON 文件(DSH webview 内可用)。 */
1531
+ /** 导出文件名:`<人设名>.lume.json`(人设名只保留安全字符,避免路径穿越/奇怪字符)。 */
1532
+ function exportFilename(personaName) {
1533
+ return `${personaName.trim().replace(/[\\/:*?"<>|\s]+/g, "_").replace(/^\.+/, "").slice(0, 60) || "persona"}.lume.json`;
1534
+ }
1535
+ /** 触发浏览器下载一段 JSON(DOM 副作用,不可单测的部分集中在这一行)。 */
1476
1536
  function downloadJson(filename, obj) {
1477
1537
  const blob = new Blob([JSON.stringify(obj, null, 2) + "\n"], { type: "application/json" });
1478
1538
  const a = document.createElement("a");
@@ -1481,6 +1541,19 @@ function downloadJson(filename, obj) {
1481
1541
  a.click();
1482
1542
  URL.revokeObjectURL(a.href);
1483
1543
  }
1544
+
1545
+ //#endregion
1546
+ //#region src/client/manage.tsx
1547
+ /**
1548
+ * 管理自定义人设:列出全部条目(内置的编辑/删除置灰),支持编辑契约、删除、导出与导入。
1549
+ *
1550
+ * - 删除走行内二次确认(删除会连带记忆/风格/档案,不可恢复);
1551
+ * - 编辑复用蒸馏预览的字段布局,键名是存储主键、创建后不可改;
1552
+ * - 保存复用 saveCustomPersona 的 upsert 语义(带原 createdAt);
1553
+ * - 导出任何人设(含内置)为自包含 JSON 卡片文件,可选是否包含记忆;
1554
+ * - 导入 JSON 卡片文件,同名覆盖需二次确认。
1555
+ */
1556
+ /** 浏览器下载 JSON 文件(DSH webview 内可用)。 */
1484
1557
  function ManageModal({ open, onClose, onSaved, t, callRpc, items }) {
1485
1558
  const [phase, setPhase] = (0, react.useState)("list");
1486
1559
  const [deleteTarget, setDeleteTarget] = (0, react.useState)(null);
@@ -1564,7 +1637,7 @@ function ManageModal({ open, onClose, onSaved, t, callRpc, items }) {
1564
1637
  });
1565
1638
  if (res?.ok && res.value) {
1566
1639
  const bundle = res.value;
1567
- downloadJson(`${name}.lume.json`, bundle);
1640
+ downloadJson(exportFilename(name), bundle);
1568
1641
  setExportTarget(null);
1569
1642
  setNotice(t("manage.exported", { persona: name }));
1570
1643
  } else setError(t("distill.failed", { message: "rejected" }));
@@ -1991,7 +2064,7 @@ const zh = {
1991
2064
  "trigger.aria": "选择人设,当前 {persona}",
1992
2065
  "menu.aria": "人设选择",
1993
2066
  "status.loading": "正在加载人设…",
1994
- "empty": "没有人设可用。",
2067
+ empty: "没有人设可用。",
1995
2068
  "menu.custom.suffix": "(自定义)",
1996
2069
  "distill.menu": "+ 蒸馏角色卡…",
1997
2070
  "distill.title": "蒸馏角色卡",
@@ -2075,7 +2148,7 @@ const en = {
2075
2148
  "trigger.aria": "Select persona, current {persona}",
2076
2149
  "menu.aria": "Persona selection",
2077
2150
  "status.loading": "Loading personas…",
2078
- "empty": "No personas available.",
2151
+ empty: "No personas available.",
2079
2152
  "menu.custom.suffix": " (custom)",
2080
2153
  "distill.menu": "+ Distill a character card…",
2081
2154
  "distill.title": "Distill a character card",
@@ -2157,18 +2230,6 @@ const en = {
2157
2230
 
2158
2231
  //#endregion
2159
2232
  //#region src/client/index.tsx
2160
- /**
2161
- * lume-dsh-plugin 客户端半边:输入栏左侧人设选择。
2162
- *
2163
- * 下拉用官方原语 @deepseek-ai/dsh-client-ui-primitives 的 Menu:
2164
- * - side="top":输入栏位于视口底端时菜单向上弹出(B 项修复的核心)
2165
- * - portal:渲染进 document.body 并随滚动/缩放跟随重定位,不受祖先 overflow 裁剪
2166
- * - selectedId / onSelect / onClose(外点 + Escape)全部内建,取代 v0.1.0 手写的
2167
- * document mousedown 监听与绝对定位样式
2168
- *
2169
- * 打包契约:本文件由 tsdown 包成 __ModuleLoader__ 工厂 bundle(见 tsdown.config.ts),
2170
- * react 系与 primitives 为外部 require,由模块图解析。
2171
- */
2172
2233
  const DISTILL_ITEM_ID = "lume-distill";
2173
2234
  const MANAGE_ITEM_ID = "lume-manage";
2174
2235
  /**
package/lib/core/card.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * 导入侧校验键名合法性、内置名保护、字段截断上限,但不对 promptText
6
6
  * 做语义校验(那是蒸馏管线的职责)。
7
7
  */
8
- import { BUILTIN_PERSONA_NAMES, CORPUS_CAP, CORPUS_LINE_CAP, MEMORY_CAP, STYLE_CAP, sanitizeCorpus } from "../host/identity.js";
8
+ import { BUILTIN_PERSONA_NAMES, CORPUS_CAP, CORPUS_LINE_CAP, MEMORY_CAP, STYLE_CAP, sanitizeCorpus } from "./persona-limits.js";
9
9
  export const CARD_FORMAT = "lume-persona-card";
10
10
  export const CARD_VERSION = 1;
11
11
  /** 序列化一张卡片。 */
@@ -58,16 +58,14 @@ export function parseCard(text) {
58
58
  corpus: sanitizeCorpus(persona.corpus),
59
59
  profileName: typeof persona.profileName === "string" && persona.profileName ? persona.profileName : null,
60
60
  styleRules: Array.isArray(persona.styleRules)
61
- ? persona.styleRules
62
- .filter((r) => typeof r?.rule === "string" && r.rule)
63
- .slice(-STYLE_CAP)
61
+ ? persona.styleRules.filter((r) => typeof r?.rule === "string" && r.rule).slice(-STYLE_CAP)
64
62
  : [],
65
63
  memory: Array.isArray(persona.memory)
66
- ? persona.memory
67
- .filter((m) => typeof m?.text === "string" && m.text)
68
- .slice(-MEMORY_CAP)
64
+ ? persona.memory.filter((m) => typeof m?.text === "string" && m.text).slice(-MEMORY_CAP)
65
+ : undefined,
66
+ signatureWords: Array.isArray(persona.signatureWords)
67
+ ? persona.signatureWords.filter((w) => typeof w === "string" && w.length > 0)
69
68
  : undefined,
70
- signatureWords: Array.isArray(persona.signatureWords) ? persona.signatureWords.filter((w) => typeof w === "string" && w.length > 0) : undefined,
71
69
  },
72
70
  },
73
71
  };
@@ -77,7 +75,11 @@ export function parseCard(text) {
77
75
  * 拒绝覆盖内置人设;返回规范化后的卡片(供导入写入)。
78
76
  */
79
77
  export function normalizeCard(card) {
80
- const name = card.name.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32);
78
+ const name = card.name
79
+ .toLowerCase()
80
+ .replace(/[^a-z0-9-]+/g, "-")
81
+ .replace(/^-+|-+$/g, "")
82
+ .slice(0, 32);
81
83
  if (!/^[a-z][a-z0-9-]*$/.test(name)) {
82
84
  return { ok: false, error: `人设键名 "${name}" 不合法:必须以小写字母开头,只含小写字母/数字/连字符。` };
83
85
  }