dsh-memory-engine 0.7.0-beta.4 → 0.7.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.
package/lib/client.js CHANGED
@@ -22,15 +22,18 @@ window.__ModuleLoader__.load({
22
22
  if (!ctx) throw new Error('Canvas 2D 不可用');
23
23
  const colors = { '人格':[255,215,106], '印象':[143,216,255], '知识':[126,231,178], '日记':[190,160,255], '对话':[255,160,140], '情绪':[255,90,90] };
24
24
  const fallback = [145,205,255];
25
- let nodes = [], links = [], hubs = [], linkTwinkle = [];
25
+ let nodes = [], links = [], hubs = [], linkTwinkle = [], visibleLinks = [];
26
26
  const byId = new Map(), adjacency = new Map(), sprites = new Map();
27
27
  const pulses = [], firing = new Map(), selections = new Set();
28
+ // 默认不画连线(数量太大会卡),只画选中光点周围的线
29
+ let selectedId=null, selectedNeighbours=new Set();
28
30
  if (typeof options.onSelect === 'function') selections.add(options.onSelect);
29
31
  let seed = 987654321;
30
32
  const random = () => ((seed = seed * 16807 % 2147483647) - 1) / 2147483646;
31
33
  let width=0,height=0,dpr=1,rotX=0.3,rotY=0.15,zoom=data.nodes.length<50?1.6:1,autoSpin=true;
32
34
  let spinSpeed=data.spinSpeed??0.35,resumeAt=0;
33
- let drag=null,nextFire=0,lastTime=0,rafId=null,running=false,destroyed=false,generation=0,observer=null;
35
+ const frameInterval=Number.isFinite(options.frameInterval)?Math.max(0,options.frameInterval):0;
36
+ let drag=null,nextFire=0,lastTime=0,lastDrawAt=0,rafId=null,running=false,destroyed=false,generation=0,observer=null;
34
37
 
35
38
  function glowSprite(rgb) {
36
39
  const off = document.createElement('canvas');
@@ -62,6 +65,8 @@ function glowSprite(rgb) {
62
65
  nodes = nextData.nodes.map(node => ({ ...node, pos: { ...node.pos } }));
63
66
  links = nextData.links.map(link => [...link]);
64
67
  byId.clear(); adjacency.clear(); sprites.clear(); pulses.length = 0; firing.clear();
68
+ selectedId=typeof options.selectedId==='string'?options.selectedId:null;
69
+ selectedNeighbours=new Set(selectedId?(adjacency.get(selectedId)||[]):[]);
65
70
  seed = 987654321; nextFire = 0;
66
71
  for (const node of nextData.nodes) byId.set(node.id, node);
67
72
  for (const node of nodes) adjacency.set(node.id, []);
@@ -87,6 +92,23 @@ function glowSprite(rgb) {
87
92
  jitterEnd: ((Math.floor(h / 977) % 100) / 100 - 0.5) * 1.6,
88
93
  };
89
94
  });
95
+ // 连线数量会随记忆增长爆炸(332 条时 383 根,479 条时 1738 根)。
96
+ // 每帧全画会卡,所以按"两端留存"排序,最多画 LINK_DRAW_CAP 根最亮的,
97
+ // 其余的不进这一帧(数据没丢,只是不画,缩放/点击仍按全量算)。
98
+ const LINK_DRAW_CAP = 1200;
99
+ if (links.length <= LINK_DRAW_CAP) {
100
+ visibleLinks = links.map((_, index) => index);
101
+ } else {
102
+ const byIdLocal = new Map(nodes.map(node => [node.id, node]));
103
+ const strength = index => {
104
+ const [left, right] = links[index];
105
+ const a = byIdLocal.get(left), b = byIdLocal.get(right);
106
+ return Math.min(a?.retention ?? 0, b?.retention ?? 0) + Math.min(a?.degree ?? 0, b?.degree ?? 0) * 0.002;
107
+ };
108
+ visibleLinks = links.map((_, index) => index)
109
+ .sort((x, y) => strength(y) - strength(x))
110
+ .slice(0, LINK_DRAW_CAP);
111
+ }
90
112
  for (const star of nodes) {
91
113
  star._rgb = colors[star.kind] || fallback;
92
114
  const count = Math.min(9, 4 + Math.round(Math.sqrt(star.degree) * 1.2));
@@ -123,17 +145,19 @@ function project(pos) {
123
145
  function fire(id,now) {
124
146
  firing.set(id,1);
125
147
  const neighbours=adjacency.get(id)||[];
126
- // 不再"点亮整个扇面":只随机挑 2~4 条邻居。
127
- // 一次把几十条边同时点亮,看上去就是全图一起闪。
128
- const count=Math.min(neighbours.length,2+Math.floor(Math.random()*3));
148
+ // 选中某个光点时,把它的关联线都点亮几条脉冲(最多 24 条,避免又糊成一片);
149
+ // 未选中时的随机心跳只挑 2~4 条,避免整片区域同时亮。
150
+ const selected = id===selectedId;
151
+ const limit = selected ? 24 : 2+Math.floor(Math.random()*3);
152
+ const count=Math.min(neighbours.length,limit);
129
153
  const picked=new Set();
130
154
  let guard=0;
131
- while (picked.size<count && neighbours.length && guard++<200) {
155
+ while (picked.size<count && neighbours.length && guard++<400) {
132
156
  picked.add(neighbours[Math.floor(Math.random()*neighbours.length)]);
133
157
  }
134
158
  for (const other of picked) {
135
159
  // 邻居亮度也压低:原来 0.65 会让整片区域同时亮起来
136
- firing.set(other,Math.max(0.26,firing.get(other)||0));
160
+ firing.set(other,Math.max(selected?0.4:0.26,firing.get(other)||0));
137
161
  pulses.push({from:id,to:other,born:now,life:700+Math.random()*2400});
138
162
  }
139
163
  }
@@ -149,6 +173,8 @@ function fire(id,now) {
149
173
  if (score<bestScore) {bestScore=score;best=star;}
150
174
  }
151
175
  if (!best) return;
176
+ selectedId = best.id;
177
+ selectedNeighbours = new Set(adjacency.get(best.id) || []);
152
178
  fire(best.id,performance.now());
153
179
  for (const callback of selections) callback(byId.get(best.id));
154
180
  }
@@ -187,10 +213,16 @@ function frame(now) {
187
213
  if (!autoSpin && !drag && now>=resumeAt) autoSpin=true;
188
214
  if (autoSpin) rotY+=0.0011*spinSpeed*3*delta/16.7;
189
215
  if (now>nextFire) {
190
- // 心跳节奏别太机械:间隔随机,偶尔连发两个,偶尔安静久一点
191
- const burst=Math.random()<0.22 ? 2 : 1;
192
- for (let i=0;i<burst && hubs.length;i++) {
193
- fire(hubs[Math.floor(Math.random()*hubs.length)].id,now);
216
+ // 默认不画连线,所以也不撒"沿线的脉冲"——否则会出现飘来飘去的小点,找不到来源。
217
+ // 选中光点后,脉冲只在它的关联线上跑。
218
+ if (selectedId) {
219
+ fire(selectedId,now);
220
+ } else {
221
+ // 没选中时只让枢纽光点自己亮一下,不动线
222
+ if (hubs.length) {
223
+ const hub=hubs[Math.floor(Math.random()*hubs.length)];
224
+ firing.set(hub.id,1);
225
+ }
194
226
  }
195
227
  nextFire=now+1400+Math.random()*3200;
196
228
  }
@@ -208,37 +240,37 @@ function frame(now) {
208
240
  }
209
241
  points.sort((left,right)=>left.star.isCore?1:right.star.isCore?-1:right.p.depth-left.p.depth);
210
242
  const screen=new Map(points.map(point=>[point.star.id,point]));
211
- ctx.globalCompositeOperation='lighter';
212
- for (let linkIndex=0;linkIndex<links.length;linkIndex++) {
213
- const [left,right]=links[linkIndex];
214
- const first=screen.get(left),second=screen.get(right);
215
- if (!first||!second) continue;
216
- const act=Math.max(firing.get(left)||0,firing.get(right)||0);
217
- // 连线自己也会呼吸:每条线有独立相位和频率,不再"齐刷刷"地一起闪。
218
- const twinkle=linkTwinkle[linkIndex];
219
- const breathe=twinkle ? (0.5+0.5*Math.sin(now*twinkle.freq+twinkle.phase))*twinkle.amp : 0;
220
- // 度数补偿:枢纽节点周围几十条线会糊成一块光斑,
221
- // 按两端度数把这类线整体压暗,让"一条线"和"一堆线"的观感接近。
222
- const degSum=(first.star.degree||0)+(second.star.degree||0);
223
- const damp=1/(1+0.07*Math.max(0,degSum-6));
224
- const alpha=Math.min(.85,(.1*Math.min(first.star.retention,second.star.retention)+.12*act+breathe)*damp);
225
- if (alpha<.01) continue;
226
- // 端点沿法线错开,多条线进入同一节点时散成扇形
227
- const dx=second.p.x-first.p.x, dy=second.p.y-first.p.y;
228
- const len=Math.hypot(dx,dy)||1;
229
- const nx=-dy/len, ny=dx/len;
230
- const r1=Math.max(2,first.star.radius*first.p.scale*.55);
231
- const r2=Math.max(2,second.star.radius*second.p.scale*.55);
232
- const sx=first.p.x+nx*r1*0.9*(twinkle?twinkle.jitterStart:0);
233
- const sy=first.p.y+ny*r1*0.9*(twinkle?twinkle.jitterStart:0);
234
- const ex=second.p.x+nx*r2*0.9*(twinkle?twinkle.jitterEnd:0);
235
- const ey=second.p.y+ny*r2*0.9*(twinkle?twinkle.jitterEnd:0);
236
- // 弯曲方向和弯度都按连线各自的哈希来,避免所有线朝同一侧弯成漩涡
237
- const bow=twinkle?twinkle.bow:0.18;
238
- const mx=(sx+ex)/2+nx*len*bow*0.5;
239
- const my=(sy+ey)/2+ny*len*bow*0.5;
240
- ctx.strokeStyle='rgba(110,190,255,'+alpha+')';ctx.lineWidth=.7+act*1.6+breathe*1.4;
241
- ctx.beginPath();ctx.moveTo(sx,sy);ctx.quadraticCurveTo(mx,my,ex,ey);ctx.stroke();
243
+ // 连线只在"选中某个光点"时画,且只画和它相连的那些。
244
+ // 全量 1738 根每帧画会卡;默认干干净净,点谁看谁的关联。
245
+ const linkPaths=[];
246
+ if (selectedId) {
247
+ for (const [left,right] of links) {
248
+ if (left!==selectedId && right!==selectedId) continue;
249
+ const first=screen.get(left),second=screen.get(right);
250
+ if (!first||!second) continue;
251
+ const target = left===selectedId ? second : first;
252
+ const ret = Math.min(first.star.retention, second.star.retention);
253
+ const alpha = Math.min(.9, .22 + ret*.35);
254
+ const dx=second.p.x-first.p.x, dy=second.p.y-first.p.y;
255
+ const len=Math.hypot(dx,dy)||1;
256
+ const nx=-dy/len, ny=dx/len;
257
+ // 朝选中点那一侧弯,形成"从中心散开"的观感
258
+ const bow = (target===second ? 1 : -1) * .16;
259
+ const mx=(first.p.x+second.p.x)/2+nx*len*bow*.5;
260
+ const my=(first.p.y+second.p.y)/2+ny*len*bow*.5;
261
+ linkPaths.push([first.p.x,first.p.y,mx,my,second.p.x,second.p.y,alpha]);
262
+ }
263
+ }
264
+ if (linkPaths.length) {
265
+ ctx.strokeStyle='rgba(110,190,255,0.30)';
266
+ ctx.lineWidth=1;
267
+ ctx.beginPath();
268
+ for (const p of linkPaths) { ctx.moveTo(p[0],p[1]); ctx.quadraticCurveTo(p[2],p[3],p[4],p[5]); }
269
+ ctx.stroke();
270
+ // 更亮的一层,让关联线看得清
271
+ ctx.strokeStyle='rgba(160,220,255,0.22)';
272
+ ctx.lineWidth=2;
273
+ ctx.stroke();
242
274
  }
243
275
  for (let index=pulses.length-1;index>=0;index--) {
244
276
  const pulse=pulses[index],fraction=(now-pulse.born)/pulse.life;
@@ -323,6 +355,13 @@ function frame(now) {
323
355
  function tick(now) {
324
356
  if (!running || destroyed || currentGeneration !== generation) return;
325
357
  rafId=null;
358
+ // 帧率上限靠 options.frameInterval 控制(默认 0 = 不限)。
359
+ // 界面里按 40fps 传;测试里不传,保持逐帧确定性。
360
+ if (frameInterval > 0 && now - lastDrawAt < frameInterval) {
361
+ rafId=window.requestAnimationFrame(tick);
362
+ return;
363
+ }
364
+ lastDrawAt = now;
326
365
  frame(now);
327
366
  if (running && !destroyed && currentGeneration === generation) rafId=window.requestAnimationFrame(tick);
328
367
  }
@@ -364,7 +403,7 @@ function frame(now) {
364
403
 
365
404
 
366
405
 
367
- var defaultSettings = { hideButton: false, showGuideOnEmpty: true };
406
+ var defaultSettings = { hideButton: false, showGuideOnEmpty: true, autoMemory: { enabled: false, maxPerTurn: 2, dailyCap: 40 }, showAutoMemoryCostHint: true };
368
407
  var actionStyle = { padding: "7px 12px", borderRadius: "8px", border: "1px solid #6999b8", background: "#102236", color: "#e2f4ff", font: "inherit", cursor: "pointer" };
369
408
  var fieldStyle = { boxSizing: "border-box", padding: "10px 12px", borderRadius: "8px", border: "1px solid #35536b", background: "#091422", color: "#e2f4ff", font: "inherit", minWidth: 0 };
370
409
  var kinds = ["知识", "印象", "对话", "事件", "日记"];
@@ -537,7 +576,7 @@ function frame(now) {
537
576
  setError("");
538
577
  readApi("/memory-engine/api/map", controller.signal).then(function (nextData) {
539
578
  if (!active) return;
540
- renderer = createRenderer(canvas.current, nextData);
579
+ renderer = createRenderer(canvas.current, nextData, { frameInterval: 25 });
541
580
  props.rendererRef.current = renderer;
542
581
  renderer.onSelect(function (node) { props.selectionVersion.current++; props.onSelect(node); });
543
582
  renderer.start();
@@ -577,7 +616,7 @@ function frame(now) {
577
616
  React.createElement("span", { style: { width: "52px", height: "5px", borderRadius: "5px", background: "#335066", overflow: "hidden" } },
578
617
  React.createElement("i", { style: { display: "block", height: "100%", background: "#6bc9fa", width: Math.min(100, Math.max(0, item.retention * 100)) + "%" } })));
579
618
  })) : null,
580
- React.createElement("div", { style: { position: "absolute", right: "18px", bottom: "18px", pointerEvents: "none", color: "#7b99ad", font: "13px system-ui,sans-serif" } }, "拖拽旋转 · 滚轮缩放 · 点击选中神经元"),
619
+ React.createElement("div", { style: { position: "absolute", right: "18px", bottom: "18px", pointerEvents: "none", color: "#7b99ad", font: "13px system-ui,sans-serif" } }, "拖拽旋转 · 滚轮缩放 · 点光点看关联"),
581
620
  error ? React.createElement("div", { role: "alert", style: { position: "absolute", inset: "40% 20% auto", textAlign: "center", color: "#d8e9f8" } },
582
621
  React.createElement("p", null, "图谱加载失败:" + error),
583
622
  React.createElement("button", { type: "button", style: actionStyle, onClick: function () { setRetry(function (value) { return value + 1; }); } }, "重试")) : null);
@@ -784,6 +823,17 @@ function frame(now) {
784
823
  onClick: function () { setOffset(page.items.length); }, style: Object.assign({}, actionStyle, { marginLeft: "12px", opacity: loading ? 0.5 : 1 }) }, "加载更多") : null));
785
824
  }
786
825
 
826
+ function AutoMemoryToggle(props) {
827
+ var enabled = props.settings.autoMemory && props.settings.autoMemory.enabled === true;
828
+ var maximum = props.settings.autoMemory && props.settings.autoMemory.maxPerTurn || 2;
829
+ return React.createElement("button", {
830
+ type: "button", role: "switch", "aria-checked": enabled, "aria-label": "自动记忆",
831
+ "data-memory-engine-auto-memory": props.location, disabled: props.busy, onClick: props.onToggle,
832
+ title: enabled ? "当前:开(每轮对话后会抽 0~" + maximum + " 条记忆)" : "当前:关(不会自动调用模型抽取记忆)",
833
+ style: Object.assign({}, actionStyle, { color: enabled ? "#e2f4ff" : "#8195aa", borderColor: enabled ? "#64bca0" : "#35536b", opacity: props.busy ? 0.6 : 1 }),
834
+ }, React.createElement("span", { "aria-hidden": "true", style: { color: enabled ? "#89f0bb" : "#8195aa", textShadow: enabled ? "0 0 8px #56d890" : "none" } }, enabled ? "●" : "○"), " 自动记忆");
835
+ }
836
+
787
837
  function MemorySettings(props) {
788
838
  var statsState = React.useState(null);
789
839
  var stats = statsState[0], setStats = statsState[1];
@@ -849,6 +899,10 @@ function frame(now) {
849
899
  return React.createElement("section", { "aria-label": "记忆设置", "data-memory-engine-settings": "true",
850
900
  style: { position: "absolute", inset: "76px 0 0", overflowY: "auto", padding: "0 24px 32px", color: "#dbeaf8", font: "14px/1.7 system-ui,sans-serif" } },
851
901
  React.createElement("div", { style: { maxWidth: "900px", margin: "0 auto", padding: "28px", border: "1px solid #263f54", borderRadius: "14px", background: "#0b1624" } },
902
+ React.createElement("section", { style: { marginBottom: "24px", paddingBottom: "20px", borderBottom: "1px solid #263f54" } },
903
+ React.createElement("h2", { style: { margin: "0 0 12px", fontSize: "20px", color: "#b7e9ff" } }, "自动记忆"),
904
+ React.createElement(AutoMemoryToggle, { settings: props.settings, busy: props.configBusy, onToggle: props.onToggle, location: "settings" }),
905
+ React.createElement("p", { style: { color: "#9fb7ce", marginBottom: 0 } }, "默认关闭。开启后每轮对话会额外调用一次模型,抽取值得记住的事实;通常最多 2 条,可随时关闭。大约每轮几分钱,实际费用以模型及计费方案为准。自动导入是独立设置,不受此开关控制。")),
852
906
  React.createElement("h2", { style: { margin: "0 0 20px", fontSize: "20px", color: "#b7e9ff" } }, "数据管理"),
853
907
  stats ? React.createElement(React.Fragment, null,
854
908
  React.createElement("div", { style: { display: "flex", alignItems: "center", flexWrap: "wrap", gap: "12px" } },
@@ -875,10 +929,18 @@ function frame(now) {
875
929
  }
876
930
 
877
931
  function MemoryButton(props) {
878
- var settings = props.settings;
879
932
  var state = React.useState(false);
880
933
  var open = state[0];
881
934
  var setOpen = state[1];
935
+ var settingsState = React.useState(Object.assign({}, defaultSettings, props.settings));
936
+ var settings = settingsState[0], setSettings = settingsState[1];
937
+ var configBusyState = React.useState(false);
938
+ var configBusy = configBusyState[0], setConfigBusy = configBusyState[1];
939
+ var configErrorState = React.useState("");
940
+ var configError = configErrorState[0], setConfigError = configErrorState[1];
941
+ var costHintState = React.useState(false);
942
+ var costHint = costHintState[0], setCostHint = costHintState[1];
943
+ var configRequest = React.useRef(null);
882
944
  var guideState = React.useState(false);
883
945
  var guideOpen = guideState[0];
884
946
  var setGuideOpen = guideState[1];
@@ -901,6 +963,43 @@ function frame(now) {
901
963
  var pointer = React.useRef(null);
902
964
  var help = React.useRef(null);
903
965
  var dismissed = React.useRef(false);
966
+ React.useEffect(function () {
967
+ return function () { if (configRequest.current) configRequest.current.abort(); };
968
+ }, []);
969
+ React.useEffect(function () {
970
+ if (!open) return undefined;
971
+ var controller = new AbortController();
972
+ setConfigBusy(true);
973
+ readApi("/memory-engine/api/config", controller.signal).then(function (data) {
974
+ if (!controller.signal.aborted) { setSettings(Object.assign({}, defaultSettings, data)); setConfigError(""); }
975
+ }).catch(function (failure) {
976
+ if (!controller.signal.aborted) setConfigError("读取配置失败:" + failure.message);
977
+ }).finally(function () { if (!controller.signal.aborted) setConfigBusy(false); });
978
+ return function () { controller.abort(); };
979
+ }, [open]);
980
+ function saveAutoMemory(enabled) {
981
+ if (configRequest.current) return;
982
+ var previous = settings;
983
+ var controller = new AbortController();
984
+ var timer = setTimeout(function () { controller.abort(); }, 10000);
985
+ configRequest.current = controller;
986
+ setConfigBusy(true); setConfigError(""); setCostHint(false);
987
+ setSettings(Object.assign({}, settings, { autoMemory: Object.assign({}, settings.autoMemory, { enabled: enabled }) }));
988
+ requestApi("/memory-engine/api/config", { method: "PUT", headers: { "Content-Type": "application/json" },
989
+ body: JSON.stringify({ autoMemory: { enabled: enabled } }), signal: controller.signal }).then(function (data) {
990
+ setSettings(Object.assign({}, defaultSettings, data));
991
+ }).catch(function (failure) {
992
+ setSettings(previous);
993
+ setConfigError("保存失败:" + (controller.signal.aborted ? "请求超时或已中断,请刷新确认状态" : failure.message));
994
+ }).finally(function () { clearTimeout(timer); configRequest.current = null; setConfigBusy(false); });
995
+ }
996
+ function toggleAutoMemory() {
997
+ if (configBusy) return;
998
+ var enabled = settings.autoMemory && settings.autoMemory.enabled === true;
999
+ setConfigError("");
1000
+ if (!enabled && settings.showAutoMemoryCostHint !== false) setCostHint(true);
1001
+ else saveAutoMemory(!enabled);
1002
+ }
904
1003
  function closeGuide() {
905
1004
  dismissed.current = true;
906
1005
  setGuideOpen(false);
@@ -913,7 +1012,7 @@ function frame(now) {
913
1012
  setTab("map");
914
1013
  setOpen(Date.now());
915
1014
  }
916
- function closeMap() { setSelected(null); setOpen(false); }
1015
+ function closeMap() { setSelected(null); setCostHint(false); setOpen(false); }
917
1016
  function startPointer(event) {
918
1017
  if (event.target.closest("[data-memory-engine-detail], [data-memory-engine-guide-layer], [data-memory-engine-controls]")) { pointer.current = null; return; }
919
1018
  pointer.current = { x: event.clientX, y: event.clientY, moved: 0, version: selectionVersion.current };
@@ -977,7 +1076,8 @@ function frame(now) {
977
1076
  style: { position: "fixed", inset: 0, maxWidth: "none", maxHeight: "none", zIndex: 9999, background: "#03060c" },
978
1077
  }, tab === "map" ? React.createElement(MemoryCanvas, { key: open, rendererRef: renderer, selectionVersion: selectionVersion, onSelect: setSelected })
979
1078
  : tab === "memories" ? React.createElement(MemoryList, { onChanged: function () { setRetry(function (value) { return value + 1; }); } })
980
- : React.createElement(MemorySettings, { onCleared: function () { openMap(); setGuideOpen(true); } }),
1079
+ : React.createElement(MemorySettings, { settings: settings, configBusy: configBusy, onToggle: toggleAutoMemory,
1080
+ onCleared: function () { openMap(); setGuideOpen(true); } }),
981
1081
  tab !== "map" ? React.createElement("strong", { style: { position: "absolute", top: "23px", left: "24px", font: "600 18px system-ui,sans-serif", color: "#b7e9ff" } }, "记忆管理") : null,
982
1082
  React.createElement("style", null, "@keyframes dsh-memory-detail-in{from{opacity:0;transform:translateX(24px)}to{opacity:1;transform:translateX(0)}}@media(prefers-reduced-motion:reduce){[data-memory-engine-detail]{animation:none!important}}"),
983
1083
  React.createElement("div", {
@@ -988,7 +1088,7 @@ function frame(now) {
988
1088
  return React.createElement("button", { key: entry[0], type: "button", role: "tab", "aria-selected": tab === entry[0], "data-memory-engine-tab": entry[0],
989
1089
  onClick: function () { setSelected(null); setTab(entry[0]); },
990
1090
  style: Object.assign({}, actionStyle, { background: tab === entry[0] ? "#204762" : "#091422", borderColor: tab === entry[0] ? "#8acded" : "#35536b" }) }, entry[1]);
991
- })), React.createElement("button", {
1091
+ })), React.createElement(AutoMemoryToggle, { settings: settings, busy: configBusy, onToggle: toggleAutoMemory, location: "header" }), React.createElement("button", {
992
1092
  ref: help, type: "button", onClick: function () { setGuideOpen(true); },
993
1093
  "aria-label": "查看使用指南", title: "使用指南", style: actionStyle,
994
1094
  }, "?"), React.createElement("button", {
@@ -996,7 +1096,11 @@ function frame(now) {
996
1096
  onClick: closeMap,
997
1097
  "aria-label": "关闭记忆图谱",
998
1098
  style: actionStyle,
999
- }, "关闭 ×")), selected ? React.createElement(MemoryDetails, {
1099
+ }, "关闭 ×")), configError ? React.createElement("p", { role: "alert", "data-memory-engine-config-error": "true",
1100
+ style: { position: "absolute", top: "50px", right: "18px", zIndex: 2, color: "#ff9c9c", background: "#271923", padding: "8px 12px", borderRadius: "8px", maxWidth: "min(600px,90vw)" } }, configError) : null,
1101
+ costHint ? React.createElement(ConfirmationDialog, { title: "开启自动记忆", message: "自动记忆会在每轮对话后调用一次模型,大约每轮几分钱。可以随时在这里关掉。实际费用以模型及计费方案为准。",
1102
+ submitLabel: "知道了,开启", busy: configBusy, onClose: function () { setCostHint(false); }, onConfirm: function () { saveAutoMemory(true); } }) : null,
1103
+ selected ? React.createElement(MemoryDetails, {
1000
1104
  node: selected, onClose: function () { setSelected(null); },
1001
1105
  onHighlight: function () { if (renderer.current) renderer.current.fireNeuron(selected.id); },
1002
1106
  }) : null, guideOpen ? React.createElement(GuideCard, {
package/lib/client.src.js CHANGED
@@ -15,7 +15,7 @@ window.__ModuleLoader__.load({
15
15
 
16
16
  /* __CANVAS_RENDERER__ */
17
17
 
18
- var defaultSettings = { hideButton: false, showGuideOnEmpty: true };
18
+ var defaultSettings = { hideButton: false, showGuideOnEmpty: true, autoMemory: { enabled: false, maxPerTurn: 2, dailyCap: 40 }, showAutoMemoryCostHint: true };
19
19
  var actionStyle = { padding: "7px 12px", borderRadius: "8px", border: "1px solid #6999b8", background: "#102236", color: "#e2f4ff", font: "inherit", cursor: "pointer" };
20
20
  var fieldStyle = { boxSizing: "border-box", padding: "10px 12px", borderRadius: "8px", border: "1px solid #35536b", background: "#091422", color: "#e2f4ff", font: "inherit", minWidth: 0 };
21
21
  var kinds = ["知识", "印象", "对话", "事件", "日记"];
@@ -188,7 +188,7 @@ window.__ModuleLoader__.load({
188
188
  setError("");
189
189
  readApi("/memory-engine/api/map", controller.signal).then(function (nextData) {
190
190
  if (!active) return;
191
- renderer = createRenderer(canvas.current, nextData);
191
+ renderer = createRenderer(canvas.current, nextData, { frameInterval: 25 });
192
192
  props.rendererRef.current = renderer;
193
193
  renderer.onSelect(function (node) { props.selectionVersion.current++; props.onSelect(node); });
194
194
  renderer.start();
@@ -228,7 +228,7 @@ window.__ModuleLoader__.load({
228
228
  React.createElement("span", { style: { width: "52px", height: "5px", borderRadius: "5px", background: "#335066", overflow: "hidden" } },
229
229
  React.createElement("i", { style: { display: "block", height: "100%", background: "#6bc9fa", width: Math.min(100, Math.max(0, item.retention * 100)) + "%" } })));
230
230
  })) : null,
231
- React.createElement("div", { style: { position: "absolute", right: "18px", bottom: "18px", pointerEvents: "none", color: "#7b99ad", font: "13px system-ui,sans-serif" } }, "拖拽旋转 · 滚轮缩放 · 点击选中神经元"),
231
+ React.createElement("div", { style: { position: "absolute", right: "18px", bottom: "18px", pointerEvents: "none", color: "#7b99ad", font: "13px system-ui,sans-serif" } }, "拖拽旋转 · 滚轮缩放 · 点光点看关联"),
232
232
  error ? React.createElement("div", { role: "alert", style: { position: "absolute", inset: "40% 20% auto", textAlign: "center", color: "#d8e9f8" } },
233
233
  React.createElement("p", null, "图谱加载失败:" + error),
234
234
  React.createElement("button", { type: "button", style: actionStyle, onClick: function () { setRetry(function (value) { return value + 1; }); } }, "重试")) : null);
@@ -435,6 +435,17 @@ window.__ModuleLoader__.load({
435
435
  onClick: function () { setOffset(page.items.length); }, style: Object.assign({}, actionStyle, { marginLeft: "12px", opacity: loading ? 0.5 : 1 }) }, "加载更多") : null));
436
436
  }
437
437
 
438
+ function AutoMemoryToggle(props) {
439
+ var enabled = props.settings.autoMemory && props.settings.autoMemory.enabled === true;
440
+ var maximum = props.settings.autoMemory && props.settings.autoMemory.maxPerTurn || 2;
441
+ return React.createElement("button", {
442
+ type: "button", role: "switch", "aria-checked": enabled, "aria-label": "自动记忆",
443
+ "data-memory-engine-auto-memory": props.location, disabled: props.busy, onClick: props.onToggle,
444
+ title: enabled ? "当前:开(每轮对话后会抽 0~" + maximum + " 条记忆)" : "当前:关(不会自动调用模型抽取记忆)",
445
+ style: Object.assign({}, actionStyle, { color: enabled ? "#e2f4ff" : "#8195aa", borderColor: enabled ? "#64bca0" : "#35536b", opacity: props.busy ? 0.6 : 1 }),
446
+ }, React.createElement("span", { "aria-hidden": "true", style: { color: enabled ? "#89f0bb" : "#8195aa", textShadow: enabled ? "0 0 8px #56d890" : "none" } }, enabled ? "●" : "○"), " 自动记忆");
447
+ }
448
+
438
449
  function MemorySettings(props) {
439
450
  var statsState = React.useState(null);
440
451
  var stats = statsState[0], setStats = statsState[1];
@@ -500,6 +511,10 @@ window.__ModuleLoader__.load({
500
511
  return React.createElement("section", { "aria-label": "记忆设置", "data-memory-engine-settings": "true",
501
512
  style: { position: "absolute", inset: "76px 0 0", overflowY: "auto", padding: "0 24px 32px", color: "#dbeaf8", font: "14px/1.7 system-ui,sans-serif" } },
502
513
  React.createElement("div", { style: { maxWidth: "900px", margin: "0 auto", padding: "28px", border: "1px solid #263f54", borderRadius: "14px", background: "#0b1624" } },
514
+ React.createElement("section", { style: { marginBottom: "24px", paddingBottom: "20px", borderBottom: "1px solid #263f54" } },
515
+ React.createElement("h2", { style: { margin: "0 0 12px", fontSize: "20px", color: "#b7e9ff" } }, "自动记忆"),
516
+ React.createElement(AutoMemoryToggle, { settings: props.settings, busy: props.configBusy, onToggle: props.onToggle, location: "settings" }),
517
+ React.createElement("p", { style: { color: "#9fb7ce", marginBottom: 0 } }, "默认关闭。开启后每轮对话会额外调用一次模型,抽取值得记住的事实;通常最多 2 条,可随时关闭。大约每轮几分钱,实际费用以模型及计费方案为准。自动导入是独立设置,不受此开关控制。")),
503
518
  React.createElement("h2", { style: { margin: "0 0 20px", fontSize: "20px", color: "#b7e9ff" } }, "数据管理"),
504
519
  stats ? React.createElement(React.Fragment, null,
505
520
  React.createElement("div", { style: { display: "flex", alignItems: "center", flexWrap: "wrap", gap: "12px" } },
@@ -526,10 +541,18 @@ window.__ModuleLoader__.load({
526
541
  }
527
542
 
528
543
  function MemoryButton(props) {
529
- var settings = props.settings;
530
544
  var state = React.useState(false);
531
545
  var open = state[0];
532
546
  var setOpen = state[1];
547
+ var settingsState = React.useState(Object.assign({}, defaultSettings, props.settings));
548
+ var settings = settingsState[0], setSettings = settingsState[1];
549
+ var configBusyState = React.useState(false);
550
+ var configBusy = configBusyState[0], setConfigBusy = configBusyState[1];
551
+ var configErrorState = React.useState("");
552
+ var configError = configErrorState[0], setConfigError = configErrorState[1];
553
+ var costHintState = React.useState(false);
554
+ var costHint = costHintState[0], setCostHint = costHintState[1];
555
+ var configRequest = React.useRef(null);
533
556
  var guideState = React.useState(false);
534
557
  var guideOpen = guideState[0];
535
558
  var setGuideOpen = guideState[1];
@@ -552,6 +575,43 @@ window.__ModuleLoader__.load({
552
575
  var pointer = React.useRef(null);
553
576
  var help = React.useRef(null);
554
577
  var dismissed = React.useRef(false);
578
+ React.useEffect(function () {
579
+ return function () { if (configRequest.current) configRequest.current.abort(); };
580
+ }, []);
581
+ React.useEffect(function () {
582
+ if (!open) return undefined;
583
+ var controller = new AbortController();
584
+ setConfigBusy(true);
585
+ readApi("/memory-engine/api/config", controller.signal).then(function (data) {
586
+ if (!controller.signal.aborted) { setSettings(Object.assign({}, defaultSettings, data)); setConfigError(""); }
587
+ }).catch(function (failure) {
588
+ if (!controller.signal.aborted) setConfigError("读取配置失败:" + failure.message);
589
+ }).finally(function () { if (!controller.signal.aborted) setConfigBusy(false); });
590
+ return function () { controller.abort(); };
591
+ }, [open]);
592
+ function saveAutoMemory(enabled) {
593
+ if (configRequest.current) return;
594
+ var previous = settings;
595
+ var controller = new AbortController();
596
+ var timer = setTimeout(function () { controller.abort(); }, 10000);
597
+ configRequest.current = controller;
598
+ setConfigBusy(true); setConfigError(""); setCostHint(false);
599
+ setSettings(Object.assign({}, settings, { autoMemory: Object.assign({}, settings.autoMemory, { enabled: enabled }) }));
600
+ requestApi("/memory-engine/api/config", { method: "PUT", headers: { "Content-Type": "application/json" },
601
+ body: JSON.stringify({ autoMemory: { enabled: enabled } }), signal: controller.signal }).then(function (data) {
602
+ setSettings(Object.assign({}, defaultSettings, data));
603
+ }).catch(function (failure) {
604
+ setSettings(previous);
605
+ setConfigError("保存失败:" + (controller.signal.aborted ? "请求超时或已中断,请刷新确认状态" : failure.message));
606
+ }).finally(function () { clearTimeout(timer); configRequest.current = null; setConfigBusy(false); });
607
+ }
608
+ function toggleAutoMemory() {
609
+ if (configBusy) return;
610
+ var enabled = settings.autoMemory && settings.autoMemory.enabled === true;
611
+ setConfigError("");
612
+ if (!enabled && settings.showAutoMemoryCostHint !== false) setCostHint(true);
613
+ else saveAutoMemory(!enabled);
614
+ }
555
615
  function closeGuide() {
556
616
  dismissed.current = true;
557
617
  setGuideOpen(false);
@@ -564,7 +624,7 @@ window.__ModuleLoader__.load({
564
624
  setTab("map");
565
625
  setOpen(Date.now());
566
626
  }
567
- function closeMap() { setSelected(null); setOpen(false); }
627
+ function closeMap() { setSelected(null); setCostHint(false); setOpen(false); }
568
628
  function startPointer(event) {
569
629
  if (event.target.closest("[data-memory-engine-detail], [data-memory-engine-guide-layer], [data-memory-engine-controls]")) { pointer.current = null; return; }
570
630
  pointer.current = { x: event.clientX, y: event.clientY, moved: 0, version: selectionVersion.current };
@@ -628,7 +688,8 @@ window.__ModuleLoader__.load({
628
688
  style: { position: "fixed", inset: 0, maxWidth: "none", maxHeight: "none", zIndex: 9999, background: "#03060c" },
629
689
  }, tab === "map" ? React.createElement(MemoryCanvas, { key: open, rendererRef: renderer, selectionVersion: selectionVersion, onSelect: setSelected })
630
690
  : tab === "memories" ? React.createElement(MemoryList, { onChanged: function () { setRetry(function (value) { return value + 1; }); } })
631
- : React.createElement(MemorySettings, { onCleared: function () { openMap(); setGuideOpen(true); } }),
691
+ : React.createElement(MemorySettings, { settings: settings, configBusy: configBusy, onToggle: toggleAutoMemory,
692
+ onCleared: function () { openMap(); setGuideOpen(true); } }),
632
693
  tab !== "map" ? React.createElement("strong", { style: { position: "absolute", top: "23px", left: "24px", font: "600 18px system-ui,sans-serif", color: "#b7e9ff" } }, "记忆管理") : null,
633
694
  React.createElement("style", null, "@keyframes dsh-memory-detail-in{from{opacity:0;transform:translateX(24px)}to{opacity:1;transform:translateX(0)}}@media(prefers-reduced-motion:reduce){[data-memory-engine-detail]{animation:none!important}}"),
634
695
  React.createElement("div", {
@@ -639,7 +700,7 @@ window.__ModuleLoader__.load({
639
700
  return React.createElement("button", { key: entry[0], type: "button", role: "tab", "aria-selected": tab === entry[0], "data-memory-engine-tab": entry[0],
640
701
  onClick: function () { setSelected(null); setTab(entry[0]); },
641
702
  style: Object.assign({}, actionStyle, { background: tab === entry[0] ? "#204762" : "#091422", borderColor: tab === entry[0] ? "#8acded" : "#35536b" }) }, entry[1]);
642
- })), React.createElement("button", {
703
+ })), React.createElement(AutoMemoryToggle, { settings: settings, busy: configBusy, onToggle: toggleAutoMemory, location: "header" }), React.createElement("button", {
643
704
  ref: help, type: "button", onClick: function () { setGuideOpen(true); },
644
705
  "aria-label": "查看使用指南", title: "使用指南", style: actionStyle,
645
706
  }, "?"), React.createElement("button", {
@@ -647,7 +708,11 @@ window.__ModuleLoader__.load({
647
708
  onClick: closeMap,
648
709
  "aria-label": "关闭记忆图谱",
649
710
  style: actionStyle,
650
- }, "关闭 ×")), selected ? React.createElement(MemoryDetails, {
711
+ }, "关闭 ×")), configError ? React.createElement("p", { role: "alert", "data-memory-engine-config-error": "true",
712
+ style: { position: "absolute", top: "50px", right: "18px", zIndex: 2, color: "#ff9c9c", background: "#271923", padding: "8px 12px", borderRadius: "8px", maxWidth: "min(600px,90vw)" } }, configError) : null,
713
+ costHint ? React.createElement(ConfirmationDialog, { title: "开启自动记忆", message: "自动记忆会在每轮对话后调用一次模型,大约每轮几分钱。可以随时在这里关掉。实际费用以模型及计费方案为准。",
714
+ submitLabel: "知道了,开启", busy: configBusy, onClose: function () { setCostHint(false); }, onConfirm: function () { saveAutoMemory(true); } }) : null,
715
+ selected ? React.createElement(MemoryDetails, {
651
716
  node: selected, onClose: function () { setSelected(null); },
652
717
  onHighlight: function () { if (renderer.current) renderer.current.fireNeuron(selected.id); },
653
718
  }) : null, guideOpen ? React.createElement(GuideCard, {
@@ -0,0 +1,74 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { readState, writeState } from './automation-state.js';
4
+ import { readUiConfig } from './config.js';
5
+
6
+ const listeners = new Map();
7
+ const writable = new Map([
8
+ ['autoMemory.enabled', value => typeof value === 'boolean'],
9
+ ['autoImport.enabled', value => typeof value === 'boolean'],
10
+ ['autoMemory.maxPerTurn', value => Number.isSafeInteger(value) && value >= 1 && value <= 5],
11
+ ['autoMemory.dailyCap', value => Number.isSafeInteger(value) && value >= 0 && value <= 500],
12
+ ['spinSpeed', value => Number.isFinite(value) && value >= 0.05 && value <= 1.5],
13
+ ['showGuideOnEmpty', value => typeof value === 'boolean'],
14
+ ['hideButton', value => typeof value === 'boolean'],
15
+ ]);
16
+ const keyFor = home => path.resolve(home).replaceAll('\\', '/').toLowerCase();
17
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
18
+
19
+ export function onConfigUpdate(home, listener) {
20
+ const key = keyFor(home);
21
+ if (!listeners.has(key)) listeners.set(key, new Set());
22
+ const group = listeners.get(key);
23
+ group.add(listener);
24
+ return () => { group.delete(listener); if (!group.size) listeners.delete(key); };
25
+ }
26
+
27
+ export function updateConfig(home, changes, { now = Date.now, warn = () => {} } = {}) {
28
+ const invalid = message => { throw Object.assign(new Error(message), { status: 400 }); };
29
+ if (!object(changes)) invalid('配置修改必须是 JSON 对象');
30
+ const fields = [];
31
+ for (const [key, value] of Object.entries(changes)) {
32
+ if (key === 'autoMemory' || key === 'autoImport') {
33
+ if (!object(value) || !Object.keys(value).length) invalid(`${key} 必须包含可修改字段`);
34
+ for (const [name, item] of Object.entries(value)) fields.push([`${key}.${name}`, item]);
35
+ } else fields.push([key, value]);
36
+ }
37
+ if (!fields.length) invalid('至少提供一个可修改字段');
38
+ for (const [key, value] of fields) {
39
+ if (!writable.has(key) || Object.hasOwn(changes, key) && key.includes('.')) invalid(`不支持修改 ${key}`);
40
+ if (!writable.get(key)(value)) invalid(`${key} 类型或范围错误`);
41
+ }
42
+ const directory = path.join(home, 'memory-engine');
43
+ const file = path.join(directory, 'config.json');
44
+ const current = readState(file, {});
45
+ if (!object(current)) throw new Error('现有配置必须是 JSON 对象');
46
+ const next = { ...current };
47
+ for (const key of Object.keys(changes)) {
48
+ if (key === 'autoMemory' || key === 'autoImport') next[key] = { ...current[key], ...changes[key] };
49
+ else next[key] = changes[key];
50
+ }
51
+ if (changes.autoMemory?.enabled === true) next.showAutoMemoryCostHint = false;
52
+ fs.mkdirSync(directory, { recursive: true });
53
+ const prefix = `config.json.bak-${now()}`;
54
+ const suffixes = fs.readdirSync(directory).filter(name => name.startsWith(prefix + '-'))
55
+ .map(name => Number(name.slice(prefix.length + 1))).filter(Number.isSafeInteger);
56
+ let suffix = suffixes.length ? Math.max(...suffixes) + 1 : 0;
57
+ let name = suffix ? `${prefix}-${suffix}` : prefix;
58
+ while (fs.existsSync(path.join(directory, name))) name = `${prefix}-${++suffix}`;
59
+ const backup = path.join(directory, name);
60
+ if (fs.existsSync(file)) fs.copyFileSync(file, backup, fs.constants.COPYFILE_EXCL);
61
+ else fs.writeFileSync(backup, '{}\n', { flag: 'wx', mode: 0o600 });
62
+ writeState(file, next);
63
+ for (const listener of listeners.get(keyFor(home)) ?? []) {
64
+ try { listener(changes); } catch (error) { try { warn(`配置已保存,任务状态更新失败:${error.message}`); } catch {} }
65
+ }
66
+ const backups = fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
67
+ const match = /^config\.json\.bak-(\d+)(?:-(\d+))?$/u.exec(entry.name);
68
+ return entry.isFile() && match ? [{ name: entry.name, time: Number(match[1]), suffix: Number(match[2] ?? 0) }] : [];
69
+ }).sort((left, right) => right.time - left.time || right.suffix - left.suffix);
70
+ for (const old of backups.slice(5)) {
71
+ try { fs.unlinkSync(path.join(directory, old.name)); } catch (error) { try { warn(`配置已保存,清理旧备份失败:${error.message}`); } catch {} }
72
+ }
73
+ return { ...readUiConfig(home, { warn }), backup: name };
74
+ }
package/lib/config.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
+ import { automationConfig } from './automation-config.js';
5
+ import { readLastError } from './auto-log.js';
4
6
 
5
7
  export const defaultHome = () => process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
6
8
 
@@ -11,7 +13,11 @@ export function resolveDiaryDir({ home = defaultHome(), envDir = process.env.QQ_
11
13
  export function readUiConfig(home = defaultHome(), options = {}) {
12
14
  const config = readConfig(home);
13
15
  return { hideButton: config.hideButton === true, showGuideOnEmpty: config.showGuideOnEmpty !== false,
14
- hasPersona: readPersona(config, options) !== null, personaTitle: config.personaTitle, spinSpeed: config.spinSpeed };
16
+ hasPersona: readPersona(config, options) !== null, personaTitle: config.personaTitle, spinSpeed: config.spinSpeed,
17
+ autoMemory: { enabled: config.autoMemory.enabled, maxPerTurn: config.autoMemory.maxPerTurn, dailyCap: config.autoMemory.dailyCap },
18
+ showAutoMemoryCostHint: config.showAutoMemoryCostHint !== false,
19
+ autoImport: { enabled: config.autoImport.enabled, sourceCount: config.autoImport.sources.length },
20
+ lastError: readLastError(path.join(home, 'memory-engine', 'auto-log.json'), options.warn) };
15
21
  }
16
22
 
17
23
  export function readPersona(config, { now = Date.now(), warn = message => console.warn(`[dsh-memory-engine] ${message}`) } = {}) {
@@ -34,7 +40,7 @@ export function readPersona(config, { now = Date.now(), warn = message => consol
34
40
  }
35
41
  }
36
42
 
37
- export function readConfig(home = defaultHome()) {
43
+ export function readConfig(home = defaultHome(), { warn } = {}) {
38
44
  const file = path.join(home, 'memory-engine', 'config.json');
39
45
  try {
40
46
  const value = JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/u, ''));
@@ -46,9 +52,9 @@ export function readConfig(home = defaultHome()) {
46
52
  if (value.spinSpeed !== undefined && (!Number.isFinite(value.spinSpeed) || value.spinSpeed < 0.05 || value.spinSpeed > 1.5)) {
47
53
  throw new Error('spinSpeed 必须是 0.05~1.5 的数字');
48
54
  }
49
- return { ...value, personaTitle: value.personaTitle?.trim() || '角色卡', spinSpeed: value.spinSpeed ?? 0.35 };
55
+ return { ...value, personaTitle: value.personaTitle?.trim() || '角色卡', spinSpeed: value.spinSpeed ?? 0.35, ...automationConfig(value, warn) };
50
56
  } catch (error) {
51
- if (error.code === 'ENOENT') return { personaTitle: '角色卡', spinSpeed: 0.35 };
57
+ if (error.code === 'ENOENT') return { personaTitle: '角色卡', spinSpeed: 0.35, ...automationConfig({}, warn) };
52
58
  throw new Error(`无法读取 ${file}:${error.message}`);
53
59
  }
54
60
  }