dsh-lite-memory 0.3.0 → 0.15.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 (39) hide show
  1. package/README.md +73 -17
  2. package/TRIGGERS.md +18 -14
  3. package/anchor.js +518 -0
  4. package/bench/bench.mjs +145 -0
  5. package/bench/evolution.mjs +123 -0
  6. package/client.js +1519 -33
  7. package/docs/BENCH.md +74 -0
  8. package/docs/RELEASE-0.11.0.md +27 -0
  9. package/docs/RELEASE-0.11.1.md +11 -0
  10. package/docs/RELEASE-0.12.0.md +15 -0
  11. package/docs/RELEASE-0.12.1.md +17 -0
  12. package/docs/RELEASE-0.12.2.md +17 -0
  13. package/docs/RELEASE-0.13.0.md +7 -0
  14. package/docs/RELEASE-0.14.0.md +7 -0
  15. package/docs/RELEASE-0.15.0.md +8 -0
  16. package/index.js +2080 -76
  17. package/learning.js +585 -0
  18. package/package.json +10 -2
  19. package/state.js +1476 -0
  20. package/store.js +2967 -244
  21. package/test/anchor.test.mjs +436 -0
  22. package/test/client.test.mjs +114 -0
  23. package/test/contract.test.mjs +880 -0
  24. package/test/control.test.mjs +278 -0
  25. package/test/crossing.test.mjs +372 -0
  26. package/test/evolution-read.test.mjs +448 -0
  27. package/test/fixtures/legacy-v1/archive/mem_444444444444.json +18 -0
  28. package/test/fixtures/legacy-v1/entries/mem_111111111111.md +2 -0
  29. package/test/fixtures/legacy-v1/entries/mem_222222222222.md +1 -0
  30. package/test/fixtures/legacy-v1/entries/mem_333333333333.md +1 -0
  31. package/test/fixtures/legacy-v1/index.json +63 -0
  32. package/test/learning.test.mjs +772 -0
  33. package/test/lexical.test.mjs +33 -0
  34. package/test/recall.test.mjs +449 -0
  35. package/test/scenario.test.mjs +184 -0
  36. package/test/state.test.mjs +485 -0
  37. package/test/store.test.mjs +816 -115
  38. package/test/view.test.mjs +257 -0
  39. package/view.js +616 -0
package/client.js CHANGED
@@ -13,7 +13,8 @@ window.__ModuleLoader__.load({
13
13
  memory_forget: 'memory · dropping',
14
14
  };
15
15
 
16
- const KEEP = { idle: 'keep', saving: 'saving', saved: 'kept' };
16
+ const KEEP = { idle: '保留', saving: '保存中', saved: '已保留', failed: '重试' };
17
+ const EMPTY = [];
17
18
  const ROW = { display: 'flex', alignItems: 'center', gap: '6px', padding: '1px 0', fontSize: '12px', lineHeight: '1.6', opacity: 0.7 };
18
19
  const DOT = { flex: '0 0 auto', opacity: 0.8 };
19
20
  const LABEL = { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
@@ -21,6 +22,11 @@ window.__ModuleLoader__.load({
21
22
  const KEEP_ON = { ...KEEP_STYLE, opacity: 0.9, cursor: 'pointer' };
22
23
  const KEEP_OFF = { ...KEEP_STYLE, opacity: 0.45, cursor: 'default' };
23
24
  const DROP = { flex: '0 0 auto', border: 'none', background: 'transparent', color: 'inherit', padding: '0 2px', fontSize: '12px', lineHeight: '18px', opacity: 0.55, cursor: 'pointer' };
25
+ const DOCK = { display: 'flex', flexDirection: 'column', gap: '2px', minWidth: 0, fontSize: '12px', lineHeight: '1.6' };
26
+ const TAIL = { ...DOCK, margin: '4px 0 2px', opacity: 0.9 };
27
+ const CHIP = { alignSelf: 'flex-start', border: 'none', background: 'transparent', color: 'inherit', padding: 0, fontSize: '12px', lineHeight: '1.6', opacity: 0.6, cursor: 'pointer', maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
28
+ const ITEM = { alignSelf: 'flex-start', border: 'none', background: 'transparent', color: 'inherit', padding: 0, fontSize: '12px', lineHeight: '1.6', opacity: 0.75, cursor: 'pointer', textAlign: 'left', width: '100%', minWidth: 0, maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
29
+ const BODY = { margin: '2px 0 4px', padding: '4px 0 4px 8px', borderLeft: '1px solid currentColor', opacity: 0.7, whiteSpace: 'pre-wrap', wordBreak: 'break-word', maxHeight: '180px', overflowY: 'auto' };
24
30
 
25
31
  const settledText = (block) => {
26
32
  if (block === undefined || block === null || !('kind' in block)) return null;
@@ -35,82 +41,1562 @@ window.__ModuleLoader__.load({
35
41
 
36
42
  const firstLine = (value) => (typeof value === 'string' ? value.split('\n')[0] : null);
37
43
 
38
- /** 结果行里带标题时把它取出来,用于点按钮后原路回传。 */
39
- const quoted = (value) => {
40
- const match = typeof value === 'string' ? /"([^"]+)"/.exec(value) : null;
41
- return match ? match[1] : null;
42
- };
44
+ const newOperationId = () => `op_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
43
45
 
44
- /** 只在产生这行的那一轮显示。 */
45
- const useLive = (phase) => {
46
- const live = React.useRef(phase !== 'result');
47
- if (phase !== 'result') live.current = true;
48
- return live.current;
49
- };
46
+ /** 会话 id 可能落在不同字段上。 */
47
+ const sessionKeyOf = (props) =>
48
+ (typeof props.sessionId === 'string' && props.sessionId) ||
49
+ (props.session && typeof props.session.id === 'string' && props.session.id) ||
50
+ '';
50
51
 
51
- const post = (payload) => {
52
- if (typeof fetch !== 'function') return Promise.resolve(null);
53
- return fetch('/lite-memory/keep', {
52
+ const post = (path, payload) => {
53
+ if (typeof fetch !== 'function') return Promise.resolve({ ok: false, status: 0, body: null });
54
+ return fetch(path, {
54
55
  method: 'POST',
55
56
  headers: { 'content-type': 'application/json' },
56
57
  body: JSON.stringify(payload),
57
58
  })
58
- .then((response) => (response.ok ? response.json() : null))
59
- .catch(() => null);
59
+ .then(async (response) => {
60
+ let body = null;
61
+ try {
62
+ body = await response.json();
63
+ } catch {
64
+ body = null;
65
+ }
66
+ return { ok: response.ok, status: response.status, body };
67
+ })
68
+ .catch(() => ({ ok: false, status: 0, body: null }));
69
+ };
70
+
71
+ /** 失败不装作成功:把服务端那一句原样放回行里。 */
72
+ const failure = (result, fallback) => (result && result.body && typeof result.body.reason === 'string' && result.body.reason ? result.body.reason : fallback);
73
+
74
+ /** 真成功要 HTTP 与响应体同时确认;2xx 但 ok:false 仍按失败显示。 */
75
+ const succeeded = (result) => Boolean(result && result.ok && result.body && result.body.ok === true);
76
+
77
+ /** 已落定、带元数据的调用块;旧行只读。 */
78
+ const metaOf = (block) => {
79
+ if (block === undefined || block === null || !('kind' in block) || block.isError === true) return null;
80
+ const meta = block.meta;
81
+ if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) return null;
82
+ return meta;
83
+ };
84
+
85
+ const isMemoryId = (value) => typeof value === 'string' && /^mem_[a-z0-9]{6,}$/.test(value);
86
+ const isCandidateId = (value) => typeof value === 'string' && /^sug_[a-z0-9]{6,}$/.test(value);
87
+
88
+ /** 一条引用的规范形态:只有定位与版次。 */
89
+ const citationOf = (meta, kind) => {
90
+ if (!meta || !isMemoryId(meta.id)) return null;
91
+ if (kind === 'memory-save' && meta.changed !== true) return null;
92
+ return {
93
+ id: meta.id,
94
+ title: typeof meta.title === 'string' ? meta.title : '',
95
+ scope: meta.scope === 'global' ? 'global' : 'project',
96
+ revision: Number.isInteger(meta.revision) ? meta.revision : 1,
97
+ status: typeof meta.status === 'string' ? meta.status : 'ok',
98
+ origin: meta.origin === 'project_evolution' ? 'project_evolution' : 'user_explicit',
99
+ kind,
100
+ exposure: kind === 'memory-save' || kind === 'memory-forget' ? 'update' : 'reference',
101
+ };
102
+ };
103
+
104
+ const citationsOfMeta = (meta) => {
105
+ if (!meta || typeof meta !== 'object') return EMPTY;
106
+ if (meta.kind === 'memory-search') {
107
+ if (!Array.isArray(meta.refs)) return EMPTY;
108
+ return meta.refs.map((ref) => citationOf(ref, 'memory-search')).filter(Boolean);
109
+ }
110
+ if (meta.kind === 'memory-read') {
111
+ const one = citationOf(meta, 'memory-read');
112
+ return one ? [one] : EMPTY;
113
+ }
114
+ if (meta.kind === 'memory-save' || meta.kind === 'memory-forget') {
115
+ const one = citationOf(meta, meta.kind);
116
+ return one ? [one] : EMPTY;
117
+ }
118
+ return EMPTY;
119
+ };
120
+
121
+ /** 一轮的工具节点:结果块本身,以及 PTC 子调用的结果。 */
122
+ const citationsOfTurn = (nodes) => {
123
+ if (!Array.isArray(nodes)) return EMPTY;
124
+ const out = [];
125
+ for (const node of nodes) {
126
+ const root = node && node.root;
127
+ if (root === null || typeof root !== 'object') continue;
128
+ for (const block of [root, ...(Array.isArray(root.subCalls) ? root.subCalls : [])]) {
129
+ const meta = metaOf(block);
130
+ if (!meta) continue;
131
+ out.push(...citationsOfMeta(meta));
132
+ }
133
+ }
134
+ return out;
135
+ };
136
+
137
+ /** 同轮同 id 同用途去重:位置取首次,内容取最后。 */
138
+ const dedupe = (items) => {
139
+ const seen = new Map();
140
+ const out = [];
141
+ for (const item of items) {
142
+ const key = `${item.scope}:${item.id}:${item.exposure}`;
143
+ const at = seen.get(key);
144
+ if (at === undefined) {
145
+ seen.set(key, out.length);
146
+ out.push(item);
147
+ } else {
148
+ out[at] = item;
149
+ }
150
+ }
151
+ return out;
152
+ };
153
+
154
+ const sameList = (left, right) => {
155
+ if (left === right) return true;
156
+ if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
157
+ for (let i = 0; i < left.length; i += 1) {
158
+ if (left[i] !== right[i]) return false;
159
+ }
160
+ return true;
161
+ };
162
+
163
+ /**
164
+ * 一轮的引用源:会话节点里冻结的工具结果。
165
+ * 关闭、切轮时不复用旧结果——结果只认自己那次请求。
166
+ */
167
+ const turnSource = (getChat, turnKey) => {
168
+ let underlying = null;
169
+ let off = null;
170
+ let lastNodes = null;
171
+ let snapshot = EMPTY;
172
+ const listeners = new Set();
173
+ const read = () => {
174
+ if (!underlying) {
175
+ const chat = (() => { try { return getChat(); } catch { return null; } })();
176
+ let nodes = null;
177
+ try {
178
+ const state = chat && chat.getSnapshot();
179
+ nodes = state && state.nodes;
180
+ } catch {
181
+ nodes = null;
182
+ }
183
+ if (!nodes || typeof nodes.turnDataSource !== 'function') return EMPTY;
184
+ try {
185
+ underlying = nodes.turnDataSource(Number(turnKey), 'tool-call');
186
+ } catch {
187
+ return EMPTY;
188
+ }
189
+ }
190
+ let value;
191
+ try {
192
+ value = underlying.getSnapshot();
193
+ } catch {
194
+ return snapshot;
195
+ }
196
+ if (value !== lastNodes) {
197
+ lastNodes = value;
198
+ const next = dedupe(citationsOfTurn(value));
199
+ if (!sameList(next, snapshot)) snapshot = next;
200
+ }
201
+ return snapshot;
202
+ };
203
+ const notify = () => {
204
+ const before = snapshot;
205
+ read();
206
+ if (snapshot !== before) {
207
+ for (const listener of [...listeners]) listener();
208
+ }
209
+ };
210
+ return {
211
+ getSnapshot: read,
212
+ subscribe(listener) {
213
+ listeners.add(listener);
214
+ if (!underlying) read();
215
+ if (underlying && typeof underlying.subscribe === 'function' && off === null) {
216
+ try {
217
+ off = underlying.subscribe(notify);
218
+ } catch {
219
+ off = null;
220
+ }
221
+ }
222
+ return () => {
223
+ listeners.delete(listener);
224
+ if (listeners.size === 0 && off !== null) {
225
+ try {
226
+ off();
227
+ } catch {
228
+ /* the source may already be gone */
229
+ }
230
+ off = null;
231
+ }
232
+ };
233
+ },
234
+ };
60
235
  };
61
236
 
62
237
  const Row = (props) => {
63
- if (!useLive(props.phase)) return null;
64
238
  const line = props.phase === 'preparing' ? null : firstLine(settledText(props.block));
65
239
  return h('div', { 'data-tool': props.toolName, style: ROW }, h('span', { style: DOT }, '·'), h('span', { style: LABEL }, line ?? RUNNING[props.toolName] ?? 'memory'));
66
240
  };
67
241
 
68
- /** 建议行:一个「keep」按钮,点了直接写盘。 */
242
+ /** 建议行:候选 id 来自持久元数据,不读文案。 */
69
243
  const SuggestRow = (props) => {
70
- if (!useLive(props.phase)) return null;
244
+ const meta = metaOf(props.block);
71
245
  const [state, setState] = React.useState('idle');
246
+ const [note, setNote] = React.useState('');
247
+ const operationId = React.useRef('');
72
248
  const text = props.phase === 'preparing' ? null : settledText(props.block);
73
- const label = firstLine(text) ?? RUNNING.memory_suggest;
74
- const title = quoted(text);
249
+ const line = firstLine(text) ?? RUNNING.memory_suggest;
250
+ const candidateId = meta && meta.kind === 'memory-suggest' && meta.asked === true && isCandidateId(meta.id) ? meta.id : '';
75
251
  const cwd = typeof props.cwd === 'string' ? props.cwd : '';
76
- const canKeep = Boolean(title) && Boolean(cwd) && state === 'idle';
252
+ const canKeep = Boolean(candidateId) && Boolean(cwd) && (state === 'idle' || state === 'failed');
77
253
  const keep = () => {
78
254
  if (!canKeep) return;
255
+ if (!operationId.current) operationId.current = newOperationId();
79
256
  setState('saving');
80
- post({ op: 'keep', title, cwd }).then((value) => setState(value && value.ok ? 'saved' : 'idle'));
257
+ post('/lite-memory/keep', { op: 'keep', candidateId, operationId: operationId.current, cwd, sessionId: sessionKeyOf(props) }).then((result) => {
258
+ if (succeeded(result)) {
259
+ setState('saved');
260
+ return;
261
+ }
262
+ setNote(failure(result, '没有保存'));
263
+ setState('failed');
264
+ });
81
265
  };
82
- const button = title
266
+ const button = candidateId && state !== 'saved'
83
267
  ? h('button', { type: 'button', onClick: keep, disabled: !canKeep, 'aria-label': '保留这条记忆', style: canKeep ? KEEP_ON : KEEP_OFF }, KEEP[state] ?? KEEP.idle)
84
268
  : null;
269
+ const label = state === 'saved' ? 'memory · kept' : note || line;
85
270
  return h('div', { 'data-tool': props.toolName, style: ROW }, h('span', { style: DOT }, '·'), h('span', { style: LABEL }, label), button);
86
271
  };
87
272
 
88
- /** 自动写入行:一个「✕」,点了把它移入归档。 */
273
+ /** 撤销只认真正写入且版本一致的那次保存。 */
274
+ const undoRefOf = (meta, cwd) => (meta
275
+ && meta.kind === 'memory-save'
276
+ && meta.changed === true
277
+ && isMemoryId(meta.id)
278
+ && Number.isInteger(meta.revision)
279
+ && typeof cwd === 'string'
280
+ && cwd.length > 0
281
+ ? meta
282
+ : null);
283
+
284
+ /** 写入行:只有真的改了东西才给撤销,且带着当时的版本。 */
89
285
  const SavedRow = (props) => {
90
- if (!useLive(props.phase)) return null;
286
+ const meta = metaOf(props.block);
91
287
  const [state, setState] = React.useState('idle');
288
+ const [note, setNote] = React.useState('');
92
289
  const text = props.phase === 'preparing' ? null : settledText(props.block);
93
- const title = quoted(text);
94
290
  const cwd = typeof props.cwd === 'string' ? props.cwd : '';
95
- const canDrop = Boolean(title) && Boolean(cwd) && state === 'idle';
291
+ const changed = undoRefOf(meta, cwd);
292
+ const canDrop = Boolean(changed) && (state === 'idle' || state === 'failed');
96
293
  const drop = () => {
97
294
  if (!canDrop) return;
98
295
  setState('saving');
99
- post({ op: 'undo', title, cwd }).then((value) => setState(value && value.ok ? 'dropped' : 'idle'));
296
+ post('/lite-memory/undo', { op: 'undo', id: changed.id, revision: changed.revision, cwd }).then((result) => {
297
+ if (succeeded(result)) {
298
+ setState('dropped');
299
+ setNote(result.body && result.body.mode === 'restored' ? 'memory · restored' : 'memory · archived');
300
+ return;
301
+ }
302
+ if (result.status === 409) {
303
+ setNote(failure(result, '这条记忆已经改过'));
304
+ setState('conflict');
305
+ return;
306
+ }
307
+ setNote(failure(result, '没有撤销'));
308
+ setState('failed');
309
+ });
100
310
  };
101
- const label = state === 'dropped' ? 'memory · dropped' : firstLine(text) ?? RUNNING.memory_save;
102
- const button = title && state !== 'dropped' ? h('button', { type: 'button', onClick: drop, disabled: !canDrop, 'aria-label': '删除这条记忆', style: DROP }, '✕') : null;
311
+ const label = note || (firstLine(text) ?? RUNNING.memory_save);
312
+ const button = changed && state !== 'dropped'
313
+ ? h('button', { type: 'button', onClick: drop, disabled: !canDrop, 'aria-label': '撤销此次修改', style: state === 'idle' || state === 'failed' ? DROP : { ...DROP, opacity: 0.25, cursor: 'default' } }, '✕')
314
+ : null;
103
315
  return h('div', { 'data-tool': props.toolName, style: ROW }, h('span', { style: DOT }, '·'), h('span', { style: LABEL }, label), button);
104
316
  };
105
317
 
318
+ const versionLine = (shown) => {
319
+ const scope = shown.scopeLabel || (shown.scope === 'global' ? '全局' : '项目');
320
+ const archived = shown.status === 'archived' ? ' · 已归档' : '';
321
+ return `${scope}${archived}`;
322
+ };
323
+
324
+ /** 一段引用:点开是名字,再点是当前正文。 */
325
+ const RefSection = (props) => {
326
+ const items = Array.isArray(props.items) ? props.items : EMPTY;
327
+ const sessionId = typeof props.sessionId === 'string' ? props.sessionId : '';
328
+ const [open, setOpen] = React.useState(false);
329
+ const [shown, setShown] = React.useState(null);
330
+ const [note, setNote] = React.useState('');
331
+ const generation = React.useRef(0);
332
+ const alive = React.useRef(true);
333
+ React.useEffect(() => () => {
334
+ alive.current = false;
335
+ generation.current += 1;
336
+ }, []);
337
+ React.useEffect(() => {
338
+ generation.current += 1;
339
+ setOpen(false);
340
+ setShown(null);
341
+ setNote('');
342
+ }, [sessionId, props.turnKey]);
343
+ if (items.length === 0) return null;
344
+ const close = () => {
345
+ generation.current += 1;
346
+ setShown(null);
347
+ setNote('');
348
+ setOpen(false);
349
+ };
350
+ const show = (item) => {
351
+ if (shown && shown.id === item.id) {
352
+ close();
353
+ return;
354
+ }
355
+ const request = generation.current + 1;
356
+ generation.current = request;
357
+ setOpen(true);
358
+ setShown({ id: item.id, ref: item, loading: true });
359
+ setNote('');
360
+ post('/lite-memory/body', { id: item.id, revision: item.revision, sessionId }).then((result) => {
361
+ if (!alive.current || generation.current !== request) return;
362
+ const payload = result.ok && result.body ? result.body : null;
363
+ if (!payload || payload.ok !== true) {
364
+ setShown({ id: item.id, ref: item, invalid: true });
365
+ return;
366
+ }
367
+ if (payload.status === 'gone') {
368
+ setShown({ id: item.id, ref: item, gone: true });
369
+ return;
370
+ }
371
+ setShown({
372
+ id: item.id,
373
+ ref: item,
374
+ title: typeof payload.title === 'string' ? payload.title : '',
375
+ when: typeof payload.when === 'string' ? payload.when : '',
376
+ scope: payload.scope === 'global' ? 'global' : 'project',
377
+ scopeLabel: typeof payload.scopeLabel === 'string' ? payload.scopeLabel : '',
378
+ revision: Number.isInteger(payload.revision) ? payload.revision : item.revision,
379
+ status: typeof payload.status === 'string' ? payload.status : 'ok',
380
+ body: typeof payload.body === 'string' ? payload.body : '',
381
+ current: payload.revision === item.revision,
382
+ });
383
+ });
384
+ };
385
+ const rows = open ? items.map((item) => h('button', {
386
+ key: `${item.scope}|${item.id}|${item.revision}`, // ui-copy-ignore
387
+ type: 'button', // ui-copy-ignore
388
+ onClick: () => show(item), // ui-copy-ignore
389
+ 'aria-label': `查看记忆 ${item.title || item.id}`,
390
+ title: item.title || item.id,
391
+ style: ITEM, // ui-copy-ignore
392
+ }, `${item.title || item.id}${item.origin === 'project_evolution' ? ' · 实验' : ''}`)) : [];
393
+ const detail = shown && shown.ref && items.some((item) => item.id === shown.id && item.revision === shown.ref.revision)
394
+ ? h('div', { style: BODY },
395
+ shown.loading ? '读取中' : null,
396
+ shown.gone ? '这条记忆已删除' : null,
397
+ shown.invalid ? '读取失败,点名字重试' : null,
398
+ !shown.loading && !shown.gone && !shown.invalid ? h(React.Fragment, null,
399
+ shown.title && shown.title !== shown.ref.title ? h('div', { style: { opacity: 0.9 } }, shown.title) : null,
400
+ h('div', null, versionLine(shown)),
401
+ shown.when ? h('div', null, shown.when) : null,
402
+ shown.body ? h('div', null, shown.body) : null,
403
+ ) : null,
404
+ )
405
+ : null;
406
+ return h('div', { style: DOCK, 'data-memory-section': props.label },
407
+ h('button', {
408
+ type: 'button',
409
+ onClick: () => { if (open) close(); else setOpen(true); },
410
+ 'aria-expanded': open,
411
+ 'aria-label': `${open ? '收起' : '展开'}${props.label}`,
412
+ style: CHIP,
413
+ }, `${props.label} ${items.length} 条${open ? ' · 收起' : ''}`),
414
+ ...rows,
415
+ detail,
416
+ note ? h('div', { style: BODY }, note) : null,
417
+ );
418
+ };
419
+
420
+ const EVO_BUTTON = { flex: '0 0 auto', width: '28px', height: '28px', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', border: 'none', background: 'transparent', color: 'inherit', borderRadius: '999px', padding: 0, cursor: 'pointer' };
421
+
422
+ /** 大脑:两瓣轮廓加一条中线,状态只靠透明度和面板文字。 */
423
+ const BrainIcon = () => h('svg', {
424
+ width: 18, height: 18, viewBox: '0 0 24 24', fill: 'none',
425
+ stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round',
426
+ 'aria-hidden': 'true', focusable: 'false',
427
+ },
428
+ h('path', { d: 'M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z' }),
429
+ h('path', { d: 'M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z' }),
430
+ h('path', { d: 'M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4' }));
431
+
432
+ const CloseIcon = () => h('svg', {
433
+ width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none',
434
+ stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round',
435
+ 'aria-hidden': 'true', focusable: 'false',
436
+ }, h('path', { d: 'M6 6l12 12M18 6L6 18' }));
437
+
438
+ const EVO_STATE = { pending: '候选', trial: '试用', active: '生效', paused: '暂停' };
439
+ const KIND_LABEL = {
440
+ preference: '偏好', convention: '约定', procedure: '流程', pointer: '位置',
441
+ pitfall: '坑', credential_hint: '凭据位置', note: '笔记',
442
+ };
443
+ const CONDITION_LABEL = {
444
+ seriesId: '系列', taskKind: '任务', targetOs: '系统', shell: '终端', runtime: '运行时',
445
+ machine: '设备', projectRoot: '项目路径', pathPrefix: '路径前缀', artifactKey: '产物', phase: '阶段',
446
+ };
447
+ const EVO_DATE = (value) => (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}/.test(value) ? value.slice(0, 10) : '');
448
+ const SHORT = (value, size) => {
449
+ const text = typeof value === 'string' ? value : '';
450
+ return text.length > size ? `${text.slice(0, size)}…` : text;
451
+ };
452
+ /** 面板开关。 */
453
+ const panelUi = { open: false, sessionId: '' };
454
+ const panelListeners = new Set();
455
+ const updatePanel = (patch) => {
456
+ Object.assign(panelUi, patch);
457
+ for (const listener of panelListeners) listener();
458
+ };
459
+ const usePanelUi = () => {
460
+ const [, bump] = React.useState(0);
461
+ React.useEffect(() => {
462
+ const listener = () => bump((value) => value + 1);
463
+ panelListeners.add(listener);
464
+ return () => { panelListeners.delete(listener); };
465
+ }, []);
466
+ return panelUi;
467
+ };
468
+
469
+ /** 会话头:只留一个图标按钮,开合由浮层里的面板负责。 */
470
+ const ProjectEvolution = (props) => {
471
+ const sessionId = sessionKeyOf(props);
472
+ const ui = usePanelUi();
473
+
474
+ React.useEffect(() => {
475
+ if (!sessionId) return undefined;
476
+ if (panelUi.sessionId && panelUi.sessionId !== sessionId) updatePanel({ open: false });
477
+ updatePanel({ sessionId });
478
+ return undefined;
479
+ }, [sessionId]);
480
+
481
+ if (!sessionId) return null;
482
+ return h('button', {
483
+ type: 'button',
484
+ onClick: () => updatePanel({ open: true, sessionId }),
485
+ 'aria-haspopup': 'dialog',
486
+ 'aria-expanded': ui.open === true && ui.sessionId === sessionId,
487
+ 'aria-label': '记忆面板',
488
+ title: '记忆面板',
489
+ style: { ...EVO_BUTTON, opacity: 0.72 },
490
+ }, h(BrainIcon));
491
+ };
492
+
493
+ const PANEL_CARD = {
494
+ background: 'var(--dsw-alias-bg-layer-1)',
495
+ border: '1px solid var(--dsw-alias-border-l1)',
496
+ borderRadius: '12px',
497
+ padding: '12px',
498
+ minWidth: 0,
499
+ };
500
+ const PANEL_MUTED = { color: 'var(--dsw-alias-label-secondary)', fontSize: '11px', lineHeight: '1.5' };
501
+ const PANEL_LABEL = { color: 'var(--dsw-alias-label-secondary)', fontSize: '11px', lineHeight: '1.4', letterSpacing: '0.02em' };
502
+ const PANEL_LINK = {
503
+ border: 'none', background: 'transparent', color: 'inherit', padding: 0,
504
+ fontSize: '11px', lineHeight: '18px', opacity: 0.72, cursor: 'pointer',
505
+ };
506
+ const PANEL_INPUT = {
507
+ width: '100%', boxSizing: 'border-box', height: '30px', padding: '0 9px',
508
+ border: '1px solid var(--dsw-alias-border-l1)', borderRadius: '8px',
509
+ background: 'var(--dsw-alias-bg-base)', color: 'var(--dsw-alias-label-primary)',
510
+ fontSize: '12px', outline: 'none',
511
+ };
512
+ const PANEL_BACKDROP = {
513
+ position: 'fixed', inset: 0, zIndex: 1000, display: 'flex', alignItems: 'center',
514
+ justifyContent: 'center', padding: '24px', pointerEvents: 'auto',
515
+ background: 'color-mix(in srgb, var(--dsw-alias-bg-base) 58%, transparent)',
516
+ };
517
+ const PANEL_DIALOG = {
518
+ width: 'min(1240px, 100%)', height: 'min(780px, 100%)', display: 'flex',
519
+ flexDirection: 'column', background: 'var(--dsw-alias-bg-base)',
520
+ color: 'var(--dsw-alias-label-primary)', border: '1px solid var(--dsw-alias-border-l1)',
521
+ borderRadius: '16px', boxShadow: '0 24px 70px rgba(0, 0, 0, 0.28)', overflow: 'hidden',
522
+ };
523
+
524
+ const hashOf = (value) => {
525
+ let hash = 2166136261;
526
+ const text = String(value || '');
527
+ for (let index = 0; index < text.length; index += 1) {
528
+ hash ^= text.charCodeAt(index);
529
+ hash = Math.imul(hash, 16777619);
530
+ }
531
+ return Math.abs(hash);
532
+ };
533
+
534
+ /**
535
+ * 图谱只画真实关系:显式关联,以及记录自己的标签 / 主题。
536
+ * 位置由 id 决定,同一份记忆每次打开都在同一处。
537
+ */
538
+ /** 位置由 id 决定;同一份数据每次打开都在同一处。 */
539
+ const layoutGraph = (nodes, edges, primaryKind) => {
540
+ const degree = new Map();
541
+ for (const edge of edges) {
542
+ degree.set(edge.from, (degree.get(edge.from) || 0) + 1);
543
+ degree.set(edge.to, (degree.get(edge.to) || 0) + 1);
544
+ }
545
+ const placed = new Map();
546
+ const primaries = nodes.filter((node) => node.kind === primaryKind);
547
+ const count = primaries.length;
548
+ const spread = 0.55 + Math.min(0.45, count * 0.06);
549
+ primaries.forEach((node, index) => {
550
+ const seed = hashOf(node.id);
551
+ const ring = count <= 1 ? 0 : count <= 8 ? 128 : count <= 18 ? 168 : 206;
552
+ const wobble = count <= 8 ? 1 : 0.64 + ((seed % 100) / 100) * 0.36;
553
+ const angle = count <= 1 ? 0 : (index / count) * Math.PI * 2 - Math.PI / 2 + ((seed % 17) / 17) * 0.16;
554
+ placed.set(node.id, {
555
+ ...node,
556
+ x: 320 + Math.cos(angle) * ring * 1.22 * wobble * spread,
557
+ y: 236 + Math.sin(angle) * ring * 0.8 * wobble * spread,
558
+ radius: 5.5 + Math.min(4, (degree.get(node.id) || 0) * 0.7),
559
+ });
560
+ });
561
+ const hubs = nodes.filter((node) => node.kind !== primaryKind);
562
+ const kindOf = new Map(nodes.map((node) => [node.id, node.kind]));
563
+ const linkedOf = new Map(hubs.map((node) => [node.id, new Set()]));
564
+ for (const edge of edges) {
565
+ if (linkedOf.has(edge.from) && kindOf.get(edge.to) === primaryKind) linkedOf.get(edge.from).add(edge.to);
566
+ if (linkedOf.has(edge.to) && kindOf.get(edge.from) === primaryKind) linkedOf.get(edge.to).add(edge.from);
567
+ }
568
+ const groups = new Map();
569
+ for (const node of hubs) {
570
+ const linked = [...linkedOf.get(node.id)].sort();
571
+ const key = linked.length > 0 ? linked.join('|') : `solo:${node.id}`;
572
+ if (!groups.has(key)) groups.set(key, { linked, hubs: [] });
573
+ groups.get(key).hubs.push(node);
574
+ }
575
+ for (const group of groups.values()) {
576
+ const points = group.linked.map((id) => placed.get(id)).filter(Boolean);
577
+ const cx = points.length > 0 ? points.reduce((sum, point) => sum + point.x, 0) / points.length : 320;
578
+ const cy = points.length > 0 ? points.reduce((sum, point) => sum + point.y, 0) / points.length : 236;
579
+ const outward = Math.hypot(cx - 320, cy - 236);
580
+ group.hubs.forEach((node, index) => {
581
+ const seed = hashOf(node.id);
582
+ const base = outward > 12
583
+ ? Math.atan2(cy - 236, cx - 320)
584
+ : ((seed % 360) / 180) * Math.PI;
585
+ const angle = base + (index - (group.hubs.length - 1) / 2) * 0.6;
586
+ const distance = group.linked.length >= 3 && group.hubs.length === 1 ? 0 : 48 + group.hubs.length * 4;
587
+ placed.set(node.id, {
588
+ ...node,
589
+ x: cx + Math.cos(angle) * distance,
590
+ y: cy + Math.sin(angle) * distance * 0.82,
591
+ radius: node.kind === 'topic' ? 5 : 4,
592
+ });
593
+ });
594
+ }
595
+ return {
596
+ nodes: [...placed.values()],
597
+ edges: edges.filter((edge) => placed.has(edge.from) && placed.has(edge.to)),
598
+ people: count,
599
+ };
600
+ };
601
+
602
+ const graphOf = (records) => {
603
+ const rows = (Array.isArray(records) ? records : EMPTY).slice(0, 60);
604
+ const byId = new Map(rows.map((record) => [record.id, record]));
605
+ const tagCount = new Map();
606
+ const topicCount = new Map();
607
+ for (const record of rows) {
608
+ for (const tag of Array.isArray(record.tags) ? record.tags : EMPTY) tagCount.set(tag, (tagCount.get(tag) || 0) + 1);
609
+ if (record.topicKey) topicCount.set(record.topicKey, (topicCount.get(record.topicKey) || 0) + 1);
610
+ }
611
+ const tags = [...tagCount.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 8);
612
+ const topics = [...topicCount.entries()].filter(([, count]) => count > 1).sort((a, b) => b[1] - a[1]).slice(0, 4);
613
+ const nodes = [];
614
+ const edges = [];
615
+ const addEdge = (from, to, type) => {
616
+ if (!from || !to || from === to) return;
617
+ edges.push({ from, to, type });
618
+ };
619
+ for (const record of rows) {
620
+ nodes.push({ id: record.id, kind: 'memory', label: record.title || record.id, experimental: record.experimental === true });
621
+ }
622
+ for (const [tag, count] of tags) {
623
+ nodes.push({ id: `tag:${tag}`, kind: 'tag', label: `#${tag}`, count });
624
+ for (const record of rows) {
625
+ if (Array.isArray(record.tags) && record.tags.includes(tag)) addEdge(record.id, `tag:${tag}`, 'tag');
626
+ }
627
+ }
628
+ for (const [topic, count] of topics) {
629
+ nodes.push({ id: `topic:${topic}`, kind: 'topic', label: topic, count });
630
+ for (const record of rows) {
631
+ if (record.topicKey === topic) addEdge(record.id, `topic:${topic}`, 'topic');
632
+ }
633
+ }
634
+ for (const record of rows) {
635
+ for (const link of Array.isArray(record.links) ? record.links : EMPTY) {
636
+ if (byId.has(link.id)) addEdge(record.id, link.id, 'link');
637
+ }
638
+ }
639
+ return layoutGraph(nodes, edges, 'memory');
640
+ };
641
+
642
+ /** 经验图谱只连共享条件。 */
643
+ const ruleGraphOf = (rules) => {
644
+ const rows = (Array.isArray(rules) ? rules : EMPTY).slice(0, 60);
645
+ const conditionCount = new Map();
646
+ for (const rule of rows) {
647
+ const seen = new Set();
648
+ for (const condition of Array.isArray(rule.conditions) ? rule.conditions : EMPTY) {
649
+ if (!condition || !condition.key || !condition.value) continue;
650
+ const id = `condition:${condition.key}:${condition.value}`;
651
+ if (seen.has(id)) continue;
652
+ seen.add(id);
653
+ conditionCount.set(id, (conditionCount.get(id) || 0) + 1);
654
+ }
655
+ }
656
+ const shared = [...conditionCount.entries()]
657
+ .filter(([, count]) => count > 1)
658
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
659
+ .slice(0, 10);
660
+ const nodes = [];
661
+ const edges = [];
662
+ for (const rule of rows) {
663
+ nodes.push({
664
+ id: rule.id, kind: 'rule', label: rule.title || rule.id,
665
+ state: rule.state, experimental: rule.state === 'pending' || rule.state === 'trial',
666
+ });
667
+ }
668
+ for (const [id, count] of shared) {
669
+ const [, key, ...rest] = id.split(':');
670
+ const value = rest.join(':');
671
+ nodes.push({
672
+ id, kind: 'condition', label: `#${value}`, count,
673
+ title: `${CONDITION_LABEL[key] || key} ${value}`,
674
+ });
675
+ for (const rule of rows) {
676
+ if ((Array.isArray(rule.conditions) ? rule.conditions : EMPTY).some((item) => item && item.key === key && item.value === value)) {
677
+ edges.push({ from: rule.id, to: id, type: 'condition' });
678
+ }
679
+ }
680
+ }
681
+ return layoutGraph(nodes, edges, 'rule');
682
+ };
683
+
684
+ /** 关系图只画真实关系;节点沿用原始 id,位置由 id 决定。 */
685
+ const MemoryGraph = (props) => {
686
+ const primaryKind = props.primaryKind || 'memory';
687
+ const graph = React.useMemo(() => (props.graphOf || graphOf)(props.records), [props.records, props.graphOf]);
688
+ const [zoom, setZoom] = React.useState(1);
689
+ const selectedId = props.selectedId;
690
+ const labelSet = graph.people <= 10;
691
+ const stepZoom = (delta) => setZoom((value) => Math.min(2.5, Math.max(0.75, Math.round((value + delta) * 100) / 100)));
692
+ const frame = React.useMemo(() => {
693
+ const list = graph.nodes;
694
+ if (list.length === 0) return { x: 0, y: 0, width: 640, height: 472 };
695
+ let minX = Infinity; let maxX = -Infinity; let minY = Infinity; let maxY = -Infinity;
696
+ for (const node of list) {
697
+ const half = node.kind === primaryKind ? 62 : 38;
698
+ minX = Math.min(minX, node.x - half);
699
+ maxX = Math.max(maxX, node.x + half);
700
+ minY = Math.min(minY, node.y - 16);
701
+ maxY = Math.max(maxY, node.y + (node.kind === primaryKind ? 28 : 24));
702
+ }
703
+ const width = Math.max(380, maxX - minX + 36);
704
+ const height = Math.max(250, maxY - minY + 36);
705
+ return { x: (minX + maxX) / 2 - width / 2, y: (minY + maxY) / 2 - height / 2, width, height };
706
+ }, [graph, primaryKind]);
707
+ if (!props.records || props.records.length === 0) {
708
+ return h('div', {
709
+ style: { flex: 1, minHeight: '220px', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--dsw-alias-label-secondary)', fontSize: '12px' },
710
+ }, props.emptyText || '还没有记忆');
711
+ }
712
+ const viewWidth = frame.width / zoom;
713
+ const viewHeight = frame.height / zoom;
714
+ const zoomButton = (label, delta, icon) => h('button', {
715
+ type: 'button', onClick: () => stepZoom(delta),
716
+ disabled: delta > 0 ? zoom >= 2.5 : zoom <= 0.75,
717
+ 'aria-label': label, title: label,
718
+ style: {
719
+ width: '26px', height: '26px', padding: 0, display: 'inline-flex',
720
+ alignItems: 'center', justifyContent: 'center', border: 'none',
721
+ borderRadius: '6px', background: 'transparent', color: 'inherit',
722
+ opacity: delta > 0 ? (zoom >= 2.5 ? 0.28 : 0.68) : (zoom <= 0.75 ? 0.28 : 0.68),
723
+ cursor: 'pointer',
724
+ },
725
+ }, icon);
726
+ const magnifier = (sign) => h('svg', {
727
+ width: '15', height: '15', viewBox: '0 0 16 16', fill: 'none',
728
+ stroke: 'currentColor', strokeWidth: '1.4', strokeLinecap: 'round',
729
+ 'aria-hidden': 'true',
730
+ },
731
+ h('circle', { cx: '6.8', cy: '6.8', r: '4.5' }),
732
+ h('line', { x1: '10.1', y1: '10.1', x2: '14', y2: '14' }),
733
+ h('line', { x1: '4.8', y1: '6.8', x2: '8.8', y2: '6.8' }),
734
+ sign > 0 ? h('line', { x1: '6.8', y1: '4.8', x2: '6.8', y2: '8.8' }) : null);
735
+ return h('div', { style: { position: 'relative', flex: 1, minHeight: 0, display: 'flex' } },
736
+ h('div', {
737
+ style: {
738
+ position: 'absolute', top: '6px', right: '6px', zIndex: 2,
739
+ display: 'flex', alignItems: 'center', gap: '1px', padding: '2px',
740
+ border: '1px solid var(--dsw-alias-border-l1)', borderRadius: '8px',
741
+ background: 'var(--dsw-alias-bg-layer-1)',
742
+ },
743
+ },
744
+ zoomButton('缩小图谱', -0.25, magnifier(-1)),
745
+ zoomButton('放大图谱', 0.25, magnifier(1))),
746
+ h('svg', {
747
+ viewBox: `${frame.x + frame.width / 2 - viewWidth / 2} ${frame.y + frame.height / 2 - viewHeight / 2} ${viewWidth} ${viewHeight}`,
748
+ preserveAspectRatio: 'xMidYMid meet',
749
+ style: { width: '100%', height: '100%', flex: 1, minHeight: 0, display: 'block' },
750
+ role: 'img', 'aria-label': props.ariaLabel || '关系图',
751
+ },
752
+ h('g', null, graph.edges.map((edge, index) => {
753
+ const from = graph.nodes.find((node) => node.id === edge.from);
754
+ const to = graph.nodes.find((node) => node.id === edge.to);
755
+ if (!from || !to) return null;
756
+ const linked = selectedId && (edge.from === selectedId || edge.to === selectedId);
757
+ return h('line', {
758
+ key: `e${index}`, x1: from.x, y1: from.y, x2: to.x, y2: to.y,
759
+ stroke: linked ? 'var(--dsw-alias-brand-primary)' : 'var(--dsw-alias-label-secondary)',
760
+ strokeWidth: linked ? 1.4 : 1,
761
+ opacity: linked ? 0.65 : edge.type === 'link' ? 0.38 : 0.16,
762
+ strokeDasharray: edge.type === 'link' ? undefined : '2 4',
763
+ });
764
+ })),
765
+ h('g', null, graph.nodes.map((node) => {
766
+ const isPrimary = node.kind === primaryKind;
767
+ const active = node.id === selectedId;
768
+ const fill = !isPrimary ? 'var(--dsw-alias-label-secondary)'
769
+ : node.state ? (STAGE_TONE[node.state] || 'var(--dsw-alias-brand-primary)')
770
+ : node.experimental ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-brand-primary)';
771
+ const showLabel = active || (isPrimary && labelSet) || !isPrimary;
772
+ return h('g', {
773
+ key: node.id,
774
+ transform: `translate(${node.x} ${node.y})`,
775
+ onClick: isPrimary ? () => props.onPick(node) : undefined,
776
+ style: { cursor: isPrimary ? 'pointer' : 'default' },
777
+ },
778
+ active ? h('circle', { r: node.radius + 5, fill: 'none', stroke: 'var(--dsw-alias-brand-primary)', strokeWidth: 1.4, opacity: 0.5 }) : null,
779
+ h('circle', { r: node.radius, fill, opacity: isPrimary ? 0.9 : 0.5 }),
780
+ showLabel ? h('text', {
781
+ y: node.radius + 12, textAnchor: 'middle',
782
+ fill: active ? 'var(--dsw-alias-label-primary)' : 'var(--dsw-alias-label-secondary)',
783
+ fontSize: isPrimary ? 10 : 8.5, opacity: active ? 0.95 : 0.68,
784
+ }, SHORT(node.label, isPrimary ? 9 : 10)) : null,
785
+ h('title', null, node.title || node.label),
786
+ );
787
+ }))),
788
+ );
789
+ };
790
+
791
+ const Toggle = (props) => h('button', {
792
+ type: 'button', role: 'switch', 'aria-checked': props.on === true, 'aria-label': props.ariaLabel || props.label,
793
+ onClick: props.onClick, disabled: props.busy,
794
+ style: {
795
+ width: '38px', height: '22px', flex: '0 0 auto', position: 'relative', padding: 0,
796
+ border: '1px solid var(--dsw-alias-border-l2)', borderRadius: '999px',
797
+ background: props.on ? 'var(--dsw-alias-brand-primary)' : 'var(--dsw-alias-bg-base)',
798
+ cursor: props.busy ? 'default' : 'pointer', opacity: props.busy ? 0.5 : 1,
799
+ },
800
+ }, h('span', {
801
+ style: {
802
+ position: 'absolute', top: '2px', left: props.on ? '18px' : '2px', width: '16px', height: '16px',
803
+ borderRadius: '999px', background: props.on ? 'var(--dsw-alias-bg-base)' : 'var(--dsw-alias-label-secondary)',
804
+ transition: 'left 120ms ease',
805
+ },
806
+ }));
807
+
808
+ const STAGE = { pending: '候选', trial: '试用', active: '生效', paused: '暂停' };
809
+ const STAGE_TONE = {
810
+ pending: 'var(--dsw-alias-state-warn-primary)',
811
+ trial: 'var(--dsw-alias-brand-primary)',
812
+ active: 'var(--dsw-alias-state-success-primary)',
813
+ paused: 'var(--dsw-alias-label-secondary)',
814
+ };
815
+ const StagePill = (props) => h('span', {
816
+ style: {
817
+ flex: '0 0 auto', padding: '0 6px', border: '1px solid var(--dsw-alias-border-l1)',
818
+ borderRadius: '999px', color: STAGE_TONE[props.state] || 'inherit',
819
+ fontSize: '11px', lineHeight: '18px',
820
+ },
821
+ }, STAGE[props.state] || '未定');
822
+
823
+ const Action = (props) => h('button', {
824
+ type: 'button', onClick: props.onClick, disabled: props.disabled,
825
+ style: {
826
+ ...PANEL_LINK, opacity: props.disabled ? 0.35 : 0.82,
827
+ color: props.danger ? 'var(--dsw-alias-state-error-primary)' : 'inherit',
828
+ cursor: props.disabled ? 'default' : 'pointer',
829
+ },
830
+ }, props.children);
831
+
832
+ const Field = (props) => h('label', { style: { display: 'flex', flexDirection: 'column', gap: '4px', minWidth: 0 } },
833
+ h('span', { style: PANEL_LABEL }, props.label),
834
+ h('input', {
835
+ value: props.value, onChange: (event) => props.onChange(event.target.value),
836
+ 'aria-label': props.label,
837
+ style: { ...PANEL_INPUT, height: '28px' },
838
+ }),
839
+ );
840
+
841
+ const countOf = (counts, key) => {
842
+ if (!counts || typeof counts !== 'object') return null;
843
+ const value = counts[key];
844
+ return Number.isInteger(value) && value >= 0 ? value : null;
845
+ };
846
+
847
+ /** 学习状态只是一段经历;能不能用由这里单独说。 */
848
+ const usabilityOf = (rule, evolution, memoryOn) => {
849
+ if (!memoryOn) return '记忆已关闭,暂不参与回答。';
850
+ if (!evolution || evolution.enabled !== true || evolution.effectiveEnabled === false) return '自进化已关闭,暂不参与回答。';
851
+ if (rule.state === 'paused') return '已暂停,不参与回答。';
852
+ const unresolved = rule.applicability === 'unresolved'
853
+ || (rule.applicability === 'conditional' && (!Array.isArray(rule.conditions) || rule.conditions.length === 0));
854
+ if (unresolved) return '适用范围待确认,暂不参与回答。';
855
+ if (rule.state === 'pending') return '尚未参与回答,确认后才会试用。';
856
+ if (rule.state === 'trial') return '在条件匹配的任务中参与参考,到期后暂停。';
857
+ if (rule.state === 'active') return '在适用任务中作为默认参考,当前明确要求优先。';
858
+ return '';
859
+ };
860
+
861
+ const scopeLineOf = (rule) => {
862
+ const conditions = Array.isArray(rule.conditions) ? rule.conditions : EMPTY;
863
+ if (rule.applicability === 'unresolved') return '待确认';
864
+ if (rule.applicability === 'conditional' || conditions.length > 0) {
865
+ if (conditions.length === 0) return '待确认';
866
+ return conditions.map((item) => `${CONDITION_LABEL[item.key] || item.key} ${item.value}`).join('、');
867
+ }
868
+ return '所有项目';
869
+ };
870
+
871
+ const evidenceTextOf = (row) => {
872
+ if (typeof row === 'string') return row;
873
+ if (!row || typeof row !== 'object') return '';
874
+ return typeof row.text === 'string' ? row.text : (typeof row.note === 'string' ? row.note : '');
875
+ };
876
+
877
+ const MemoryRow = (props) => {
878
+ const record = props.record;
879
+ return h('button', {
880
+ type: 'button', onClick: props.onClick,
881
+ style: {
882
+ display: 'flex', flexDirection: 'column', gap: '2px', width: '100%', textAlign: 'left',
883
+ border: 'none', borderBottom: '1px solid var(--dsw-alias-border-l1)',
884
+ background: props.active ? 'var(--dsw-alias-bg-layer-2)' : 'transparent',
885
+ color: 'inherit', padding: '8px 7px', cursor: 'pointer', minWidth: 0,
886
+ },
887
+ },
888
+ h('span', { style: { fontSize: '12px', lineHeight: '1.45', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', opacity: 0.92 } }, record.title || record.id),
889
+ h('span', { style: { ...PANEL_MUTED, display: 'flex', alignItems: 'center', gap: '5px', minWidth: 0 } },
890
+ h('span', null, KIND_LABEL[record.kind] || record.kind),
891
+ record.experimental ? h('span', { style: { color: 'var(--dsw-alias-state-warn-primary)' } }, '实验') : null,
892
+ h('span', { style: { marginLeft: 'auto', flex: '0 0 auto', fontVariantNumeric: 'tabular-nums' } }, EVO_DATE(record.updated)),
893
+ ),
894
+ );
895
+ };
896
+
897
+ const EvolutionDetail = (props) => {
898
+ const rule = props.rule;
899
+ const ruleId = rule ? rule.id : '';
900
+ const [confirming, setConfirming] = React.useState(false);
901
+ const [editing, setEditing] = React.useState(false);
902
+ const [open, setOpen] = React.useState('');
903
+ const [title, setTitle] = React.useState('');
904
+ const [body, setBody] = React.useState('');
905
+ React.useEffect(() => {
906
+ setConfirming(false);
907
+ setEditing(false);
908
+ setOpen('');
909
+ setTitle(rule && typeof rule.title === 'string' ? rule.title : '');
910
+ setBody(rule && typeof rule.body === 'string' ? rule.body : '');
911
+ }, [ruleId]);
912
+ if (!rule) return null;
913
+ const checkpoints = Array.isArray(rule.checkpoints) ? rule.checkpoints : EMPTY;
914
+ const samples = Array.isArray(rule.samples) ? rule.samples : EMPTY;
915
+ const evidence = Array.isArray(rule.learning?.evidence) ? rule.learning.evidence : EMPTY;
916
+ const versions = Array.isArray(rule.learning?.versions) ? rule.learning.versions : EMPTY;
917
+ const history = [
918
+ ...checkpoints.map((row) => ({ at: row.at || row.updated || '', text: typeof row.text === 'string' ? row.text : '' })),
919
+ ...versions.map((row) => ({ at: row.at || row.updated || '', text: typeof row.title === 'string' ? row.title : '修改' })),
920
+ ].filter((row) => row.text).sort((a, b) => String(b.at).localeCompare(String(a.at))).slice(0, 6);
921
+ const canManage = props.canManage && !props.busy;
922
+ const act = (name, extra) => { if (canManage) props.onManage(name, extra || {}); };
923
+ const actions = props.canManage
924
+ ? h('div', { style: { display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap', paddingTop: '3px' } },
925
+ rule.state === 'pending' || rule.state === 'trial'
926
+ ? h(Action, { disabled: !canManage, onClick: () => act('adopt') }, '采纳') : null,
927
+ rule.state === 'paused'
928
+ ? h(Action, { disabled: !canManage, onClick: () => act('resume') }, '恢复')
929
+ : (rule.state === 'trial' || rule.state === 'active'
930
+ ? h(Action, { disabled: !canManage, onClick: () => act('pause') }, '暂停')
931
+ : null),
932
+ rule.state !== 'paused'
933
+ ? h(Action, { disabled: !canManage, onClick: () => setEditing((value) => !value) }, '修改') : null,
934
+ rule.state === 'paused'
935
+ ? h(Action, { disabled: !canManage, onClick: () => setOpen((value) => value === 'history' ? '' : 'history') }, '查看记录') : null,
936
+ rule.state === 'pending'
937
+ ? (confirming
938
+ ? h(React.Fragment, null,
939
+ h(Action, { disabled: !canManage, danger: true, onClick: () => act('discard') }, '确认放弃'),
940
+ h(Action, { disabled: !canManage, onClick: () => setConfirming(false) }, '取消'))
941
+ : h(Action, { disabled: !canManage, onClick: () => setConfirming(true) }, '放弃'))
942
+ : null,
943
+ )
944
+ : h('div', { style: { ...PANEL_MUTED, paddingTop: '2px' } }, '只读');
945
+
946
+ const editor = editing
947
+ ? h('div', { style: { display: 'flex', flexDirection: 'column', gap: '8px', paddingTop: '2px' } },
948
+ h(Field, { label: '标题', value: title, onChange: setTitle }),
949
+ h('label', { style: { display: 'flex', flexDirection: 'column', gap: '4px' } },
950
+ h('span', { style: PANEL_LABEL }, '经验'),
951
+ h('textarea', {
952
+ value: body, onChange: (event) => setBody(event.target.value), 'aria-label': '经验',
953
+ rows: 6,
954
+ style: { ...PANEL_INPUT, height: 'auto', minHeight: '110px', padding: '7px 9px', resize: 'vertical', lineHeight: '1.6' },
955
+ }),
956
+ ),
957
+ h('div', { style: { display: 'flex', gap: '12px', flexWrap: 'wrap' } },
958
+ h(Action, { disabled: !canManage || !title.trim() || !body.trim(), onClick: () => { act('edit', { title: title.trim(), body: body.trim() }); setEditing(false); } }, '保存'),
959
+ rule.state === 'pending' || rule.state === 'trial'
960
+ ? h(Action, { disabled: !canManage || !title.trim() || !body.trim(), onClick: () => { act('adopt', { title: title.trim(), body: body.trim() }); setEditing(false); } }, '保存并采纳')
961
+ : null,
962
+ h(Action, { disabled: !canManage, onClick: () => setEditing(false) }, '取消'),
963
+ ),
964
+ )
965
+ : null;
966
+
967
+ const evidencePane = open === 'evidence'
968
+ ? h('div', { style: { ...PANEL_MUTED, display: 'flex', flexDirection: 'column', gap: '4px', paddingTop: '2px' } },
969
+ evidence.length === 0 && samples.length === 0
970
+ ? '暂无学习依据'
971
+ : [
972
+ ...evidence.slice(0, 3).map((row, index) => h('div', { key: `e${index}` }, evidenceTextOf(row))),
973
+ ...samples.slice(0, 2).map((row, index) => h('div', { key: `s${index}` }, evidenceTextOf(row) || '样例')),
974
+ ].filter(Boolean))
975
+ : null;
976
+ const historyPane = open === 'history'
977
+ ? h('div', { style: { ...PANEL_MUTED, display: 'flex', flexDirection: 'column', gap: '4px', paddingTop: '2px' } },
978
+ history.length === 0
979
+ ? '暂无变化记录'
980
+ : history.map((row, index) => h('div', { key: `h${index}`, style: { display: 'flex', gap: '8px' } },
981
+ h('span', { style: { flex: '0 0 auto', fontVariantNumeric: 'tabular-nums' } }, EVO_DATE(row.at) || '—'),
982
+ h('span', { style: { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, row.text))))
983
+ : null;
984
+
985
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '10px' } },
986
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: '4px' } },
987
+ h('div', { style: PANEL_LABEL }, '当前经验'),
988
+ h('div', { style: { fontSize: '13px', lineHeight: '1.45', fontWeight: 600, wordBreak: 'break-word' } }, rule.title),
989
+ h('div', { style: { fontSize: '12px', lineHeight: '1.65', whiteSpace: 'pre-wrap', wordBreak: 'break-word', opacity: 0.88 } }, rule.body || '没有正文'),
990
+ ),
991
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: '3px' } },
992
+ h('div', { style: PANEL_LABEL }, '适用范围'),
993
+ h('div', { style: { fontSize: '12px', lineHeight: '1.55', wordBreak: 'break-word' } }, scopeLineOf(rule)),
994
+ ),
995
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: '4px' } },
996
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px' } },
997
+ h('span', { style: PANEL_LABEL }, '当前状态'),
998
+ h(StagePill, { state: rule.state }),
999
+ ),
1000
+ h('div', { style: { fontSize: '12px', lineHeight: '1.55', opacity: 0.82 } }, usabilityOf(rule, props.evolution, props.memoryOn)),
1001
+ actions,
1002
+ ),
1003
+ editor,
1004
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: '12px', borderTop: '1px solid var(--dsw-alias-border-l1)', paddingTop: '6px' } },
1005
+ h(Action, { onClick: () => setOpen((value) => value === 'evidence' ? '' : 'evidence') }, `学习依据 ${evidence.length + samples.length}`),
1006
+ h(Action, { onClick: () => setOpen((value) => value === 'history' ? '' : 'history') }, `变化记录 ${history.length}`),
1007
+ ),
1008
+ evidencePane,
1009
+ historyPane,
1010
+ );
1011
+ };
1012
+
1013
+ const MemoryPanel = (props) => {
1014
+ const sessionId = props.sessionId;
1015
+ const [data, setData] = React.useState(null);
1016
+ const [loading, setLoading] = React.useState(true);
1017
+ const [failed, setFailed] = React.useState(false);
1018
+ const [note, setNote] = React.useState('');
1019
+ const [query, setQuery] = React.useState('');
1020
+ const [tab, setTab] = React.useState('memory');
1021
+ const [memoryView, setMemoryView] = React.useState('list');
1022
+ const [evolutionView, setEvolutionView] = React.useState('list');
1023
+ const [stage, setStage] = React.useState('all');
1024
+ const [selectedMemory, setSelectedMemory] = React.useState('');
1025
+ const [selectedRule, setSelectedRule] = React.useState('');
1026
+ const [detail, setDetail] = React.useState(null);
1027
+ const [detailNote, setDetailNote] = React.useState('');
1028
+ const [busy, setBusy] = React.useState(false);
1029
+ const [showReason, setShowReason] = React.useState(false);
1030
+ const alive = React.useRef(true);
1031
+ const generation = React.useRef(0);
1032
+ const scopeRef = React.useRef('');
1033
+ React.useEffect(() => {
1034
+ alive.current = true;
1035
+ return () => { alive.current = false; };
1036
+ }, []);
1037
+
1038
+ const load = (scopeKey = '', focusRule = '') => {
1039
+ const request = generation.current + 1;
1040
+ generation.current = request;
1041
+ setLoading(true);
1042
+ setFailed(false);
1043
+ setNote('');
1044
+ post('/lite-memory/overview', { sessionId, scopeKey }).then((result) => {
1045
+ if (!alive.current || generation.current !== request) return;
1046
+ setLoading(false);
1047
+ if (result.ok && result.body && result.body.ok) {
1048
+ const next = result.body;
1049
+ const nextKey = next.selected && next.selected.key ? next.selected.key : '';
1050
+ if (scopeRef.current !== nextKey) {
1051
+ scopeRef.current = nextKey;
1052
+ setQuery('');
1053
+ }
1054
+ setData(next);
1055
+ setSelectedMemory('');
1056
+ setSelectedRule(focusRule);
1057
+ setDetail(null);
1058
+ setDetailNote('');
1059
+ setShowReason(false);
1060
+ } else {
1061
+ setFailed(true);
1062
+ setNote(failure(result, '读不到记忆'));
1063
+ }
1064
+ });
1065
+ };
1066
+
1067
+ React.useEffect(() => { load(); }, [sessionId]);
1068
+
1069
+ React.useEffect(() => {
1070
+ if (typeof window === 'undefined') return undefined;
1071
+ const onKey = (event) => { if (event.key === 'Escape') { event.stopPropagation(); props.onClose(); } };
1072
+ window.addEventListener('keydown', onKey);
1073
+ const previous = typeof document !== 'undefined' ? document.body.style.overflow : '';
1074
+ if (typeof document !== 'undefined') document.body.style.overflow = 'hidden';
1075
+ return () => {
1076
+ window.removeEventListener('keydown', onKey);
1077
+ if (typeof document !== 'undefined') document.body.style.overflow = previous;
1078
+ };
1079
+ }, []);
1080
+
1081
+ React.useEffect(() => {
1082
+ if (!selectedMemory || !data) { setDetail(null); setDetailNote(''); return undefined; }
1083
+ let live = true;
1084
+ setDetail(null);
1085
+ setDetailNote('');
1086
+ post('/lite-memory/body', { sessionId, id: selectedMemory, scopeKey: data.selected.key }).then((result) => {
1087
+ if (!live) return;
1088
+ if (result.ok && result.body && result.body.ok) {
1089
+ if (result.body.status === 'gone') setDetailNote('这条记忆已删除');
1090
+ else setDetail(result.body);
1091
+ } else setDetailNote(failure(result, '读不到正文'));
1092
+ });
1093
+ return () => { live = false; };
1094
+ }, [selectedMemory, data && data.selected.key, sessionId]);
1095
+
1096
+ const current = data ? data.current : null;
1097
+ const selected = data ? data.selected : null;
1098
+ const records = selected && Array.isArray(selected.records) ? selected.records : EMPTY;
1099
+ const total = selected && selected.stats && Number.isInteger(selected.stats.total) ? selected.stats.total : records.length;
1100
+ const currentKey = current ? current.key : '';
1101
+ const selectedKey = selected ? selected.key : '';
1102
+ const control = selected && selected.control
1103
+ ? selected.control
1104
+ : (current && selected && selected.key === current.key && current.control ? current.control : { enabled: true });
1105
+ const memoryOn = control.enabled !== false;
1106
+ const evolution = selected && selected.evolution
1107
+ ? selected.evolution
1108
+ : (current && selected && selected.key === current.key ? current.evolution : null);
1109
+ const rules = evolution && Array.isArray(evolution.rules) ? evolution.rules : EMPTY;
1110
+ const counts = evolution && evolution.counts && typeof evolution.counts === 'object' ? evolution.counts : null;
1111
+ const effectiveEnabled = evolution
1112
+ ? (typeof evolution.effectiveEnabled === 'boolean' ? evolution.effectiveEnabled : (memoryOn && evolution.enabled === true))
1113
+ : false;
1114
+ const canToggle = Boolean(selectedKey) && selectedKey === currentKey;
1115
+ const canManage = canToggle && memoryOn;
1116
+ const globalView = Boolean(selected && selected.kind === 'global');
1117
+ const activeTab = globalView ? 'memory' : tab;
1118
+ React.useEffect(() => { if (globalView) setTab('memory'); }, [globalView]);
1119
+
1120
+ const activity = evolution && evolution.activity ? evolution.activity : null;
1121
+ const latestReceipt = evolution && evolution.economy && Array.isArray(evolution.economy.recent) ? evolution.economy.recent[0] : null;
1122
+ const countsUnknown = Boolean(evolution) && (!counts || ['pending', 'trial', 'active', 'paused'].some((key) => countOf(counts, key) === null));
1123
+ const activityCode = activity && typeof activity === 'object'
1124
+ ? String(activity.code || activity.state || '')
1125
+ : (typeof activity === 'string' ? activity : '');
1126
+ const changedCount = activity && typeof activity === 'object' && Number.isInteger(activity.changed)
1127
+ ? activity.changed
1128
+ : (Number.isInteger(activity && activity.count) ? activity.count : null);
1129
+ let activityText = '';
1130
+ if (!evolution && memoryOn) {
1131
+ activityText = '读不到自进化状态';
1132
+ } else if (!memoryOn || !evolution || evolution.enabled !== true || (typeof evolution.effectiveEnabled === 'boolean' && !evolution.effectiveEnabled)) {
1133
+ activityText = '自进化已关闭,已有成果保留';
1134
+ } else if (activityCode === 'running' || activityCode === 'organizing') {
1135
+ activityText = '正在整理本次反馈';
1136
+ } else if (activityCode === 'updated' || activityCode === 'applied') {
1137
+ activityText = changedCount === null || changedCount < 1 ? '最近整理完成' : `最近整理完成,更新了 ${changedCount} 条经验`;
1138
+ } else if (activityCode === 'no_change' || activityCode === 'none') {
1139
+ activityText = '本次反馈未形成新的经验';
1140
+ } else if (activityCode === 'scope_unconfirmed' || activityCode === 'unknown_scope') {
1141
+ activityText = '暂未整理:适用范围待确认';
1142
+ } else if (activityCode === 'quota' || activityCode === 'exhausted') {
1143
+ activityText = '本轮整理额度已用完';
1144
+ } else if (activityCode === 'failed' || activityCode === 'error') {
1145
+ activityText = '最近整理失败,可查看原因';
1146
+ } else if (activityCode === 'waiting' || activityCode === 'idle' || activityCode === 'empty') {
1147
+ activityText = '等待可学习的反馈';
1148
+ }
1149
+ if (!activityText) {
1150
+ if (latestReceipt && latestReceipt.status === 'applied') {
1151
+ activityText = Number.isInteger(latestReceipt.changed) && latestReceipt.changed > 0 ? `最近整理完成,更新了 ${latestReceipt.changed} 条经验` : '最近整理完成';
1152
+ }
1153
+ else if (latestReceipt && latestReceipt.status === 'no_change') activityText = '本次反馈未形成新的经验';
1154
+ else if (latestReceipt && latestReceipt.status === 'failed') activityText = '最近整理失败,可查看原因';
1155
+ else if (rules.length === 0) activityText = countsUnknown ? '等待可学习的反馈' : '还没有整理出经验';
1156
+ else activityText = '暂无整理记录';
1157
+ }
1158
+ const activityReason = activity && typeof activity === 'object' && typeof activity.reason === 'string'
1159
+ ? activity.reason
1160
+ : (latestReceipt && typeof latestReceipt.reason === 'string' ? latestReceipt.reason : '');
1161
+
1162
+ const filtered = React.useMemo(() => {
1163
+ const needle = query.trim().toLowerCase();
1164
+ if (!needle) return records;
1165
+ return records.filter((record) => {
1166
+ const tags = Array.isArray(record.tags) ? record.tags.join(' ') : '';
1167
+ return `${record.title} ${record.summary} ${tags} ${KIND_LABEL[record.kind] || ''}`.toLowerCase().includes(needle);
1168
+ });
1169
+ }, [records, query]);
1170
+ const filteredRules = React.useMemo(() => {
1171
+ if (stage === 'all') return rules;
1172
+ return rules.filter((rule) => rule.state === stage);
1173
+ }, [rules, stage]);
1174
+ const selectedRuleRow = selectedRule ? rules.find((rule) => rule.id === selectedRule) : null;
1175
+ const detailRecord = selectedMemory ? records.find((record) => record.id === selectedMemory) : null;
1176
+
1177
+ const onPickNode = (node) => {
1178
+ if (node.kind !== 'memory') return;
1179
+ setSelectedMemory(node.id);
1180
+ setSelectedRule('');
1181
+ };
1182
+ const onPickRuleNode = (node) => {
1183
+ if (node.kind !== 'rule') return;
1184
+ setSelectedRule(node.id);
1185
+ setSelectedMemory('');
1186
+ setShowReason(false);
1187
+ };
1188
+
1189
+ const manage = (learningAction, extra) => {
1190
+ if (busy || !selectedRuleRow || !canManage) return;
1191
+ setBusy(true);
1192
+ const payload = {
1193
+ sessionId,
1194
+ learningAction,
1195
+ id: selectedRuleRow.id,
1196
+ expectedRevision: selectedRuleRow.revision,
1197
+ operationId: newOperationId(),
1198
+ ...extra,
1199
+ };
1200
+ if (learningAction === 'pause') payload.ids = [selectedRuleRow.id];
1201
+ post('/lite-memory/evolution', payload).then((result) => {
1202
+ if (!alive.current) return;
1203
+ setBusy(false);
1204
+ if (succeeded(result)) load(selectedKey, selectedRuleRow.id);
1205
+ else setNote(failure(result, '这次没有改动'));
1206
+ });
1207
+ };
1208
+
1209
+ const toggleControl = () => {
1210
+ if (busy || !canToggle) return;
1211
+ setBusy(true);
1212
+ post('/lite-memory/control', { sessionId, enabled: !memoryOn, operationId: newOperationId() }).then((result) => {
1213
+ if (!alive.current) return;
1214
+ setBusy(false);
1215
+ if (succeeded(result)) load(selectedKey);
1216
+ else setNote(failure(result, '开关没有改动'));
1217
+ });
1218
+ };
1219
+
1220
+ const toggleEvolution = () => {
1221
+ if (busy || !evolution || !canToggle || !memoryOn) return;
1222
+ setBusy(true);
1223
+ post('/lite-memory/evolution', { sessionId, enabled: evolution.enabled !== true, operationId: newOperationId() }).then((result) => {
1224
+ if (!alive.current) return;
1225
+ setBusy(false);
1226
+ if (succeeded(result)) load(selectedKey);
1227
+ else setNote(failure(result, '开关没有改动'));
1228
+ });
1229
+ };
1230
+
1231
+ const closeButton = h('button', {
1232
+ type: 'button', onClick: props.onClose, 'aria-label': '关闭',
1233
+ style: { width: '30px', height: '30px', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', border: 'none', borderRadius: '8px', background: 'transparent', color: 'inherit', opacity: 0.65, cursor: 'pointer' },
1234
+ }, h(CloseIcon));
1235
+
1236
+ const header = h('div', {
1237
+ style: { display: 'flex', alignItems: 'center', gap: '10px', padding: '14px 18px', borderBottom: '1px solid var(--dsw-alias-border-l1)', flex: '0 0 auto' },
1238
+ },
1239
+ h('span', { style: { width: '32px', height: '32px', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', borderRadius: '9px', background: 'var(--dsw-alias-bg-layer-2)', border: '1px solid var(--dsw-alias-border-l1)', color: 'var(--dsw-alias-brand-primary)' } }, h(BrainIcon)),
1240
+ h('span', { style: { display: 'flex', flexDirection: 'column', minWidth: 0 } },
1241
+ h('span', { style: { fontSize: '14px', lineHeight: '1.35', fontWeight: 600 } }, '记忆'),
1242
+ h('span', { style: { ...PANEL_MUTED, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, selected ? `${selected.label} · ${total} 条` : '读取中…'),
1243
+ ),
1244
+ h('span', { style: { marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '8px' } },
1245
+ activeTab === 'memory' ? h('select', {
1246
+ value: selectedKey,
1247
+ onChange: (event) => load(event.target.value),
1248
+ 'aria-label': '记忆范围',
1249
+ disabled: loading || !data,
1250
+ style: { height: '30px', maxWidth: '190px', padding: '0 8px', border: '1px solid var(--dsw-alias-border-l1)', borderRadius: '8px', background: 'var(--dsw-alias-bg-layer-2)', color: 'inherit', fontSize: '12px' },
1251
+ },
1252
+ data && Array.isArray(data.scopes) ? data.scopes.map((scope) => h('option', { key: scope.key, value: scope.key }, scope.kind === 'global' ? '全局' : scope.label)) : null) : null,
1253
+ closeButton,
1254
+ ),
1255
+ );
1256
+
1257
+ const memorySwitch = h('div', { style: { ...PANEL_CARD, flex: '1 1 300px', display: 'flex', flexDirection: 'column', gap: '4px', padding: '9px 11px' } },
1258
+ h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px' } },
1259
+ h('span', { style: { fontSize: '12px', lineHeight: '1.4', fontWeight: 600 } }, '启用记忆'),
1260
+ h(Toggle, { ariaLabel: '启用记忆', on: memoryOn, busy: busy || !canToggle, onClick: canToggle ? toggleControl : undefined }),
1261
+ ),
1262
+ h('div', { style: PANEL_MUTED }, '读取项目和全局记忆'), // ui-copy-ignore 用户指定文案
1263
+ );
1264
+ const evolutionSwitch = h('div', { style: { ...PANEL_CARD, flex: '1 1 300px', display: 'flex', flexDirection: 'column', gap: '4px', padding: '9px 11px', opacity: memoryOn ? 1 : 0.55 } },
1265
+ h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px' } },
1266
+ h('span', { style: { fontSize: '12px', lineHeight: '1.4', fontWeight: 600 } }, '自进化'),
1267
+ h(Toggle, { ariaLabel: '自进化', on: Boolean(memoryOn && evolution && evolution.enabled === true), busy: busy || !evolution || !canToggle || !memoryOn, onClick: canToggle && memoryOn ? toggleEvolution : undefined }),
1268
+ ),
1269
+ h('div', { style: PANEL_MUTED }, '从反馈中整理经验,按需使用'), // ui-copy-ignore 用户指定文案
1270
+ h('div', { style: PANEL_MUTED }, '实验功能'), // ui-copy-ignore 用户指定文案
1271
+ );
1272
+ const switchBar = h('div', { style: { display: 'flex', alignItems: 'stretch', gap: '10px', flexWrap: 'wrap', padding: '12px 18px', borderBottom: '1px solid var(--dsw-alias-border-l1)', flex: '0 0 auto' } },
1273
+ globalView ? h('div', { style: { ...PANEL_MUTED, padding: '2px 0' } }, '全局记忆只读') : memorySwitch,
1274
+ globalView ? null : evolutionSwitch,
1275
+ !memoryOn && !globalView ? h('div', { style: { ...PANEL_MUTED, flexBasis: '100%' } }, '记忆已关闭,内容只读') : null,
1276
+ !globalView && !canToggle ? h('div', { style: { ...PANEL_MUTED, flexBasis: '100%' } }, '只读,切换回当前项目后可修改') : null,
1277
+ note ? h('div', { style: { ...PANEL_MUTED, flexBasis: '100%', color: 'var(--dsw-alias-state-error-primary)' } }, note) : null,
1278
+ );
1279
+
1280
+ const switchTab = (key) => {
1281
+ if (key === 'evolution' && selectedKey !== currentKey) load(currentKey);
1282
+ setTab(key);
1283
+ };
1284
+ const tabButton = (key, text) => h('button', {
1285
+ type: 'button', role: 'tab', 'aria-selected': activeTab === key,
1286
+ onClick: () => switchTab(key),
1287
+ style: {
1288
+ ...PANEL_LINK, flex: '0 0 auto', height: '34px', padding: '0 18px', borderRadius: 0,
1289
+ background: 'transparent',
1290
+ opacity: activeTab === key ? 0.95 : 0.55,
1291
+ border: 'none',
1292
+ borderBottom: activeTab === key ? '2px solid var(--dsw-alias-brand-primary)' : '2px solid transparent',
1293
+ },
1294
+ }, text);
1295
+
1296
+ const tabBar = h('div', { role: 'tablist', 'aria-label': '记忆内容', style: { display: 'flex', alignItems: 'center', gap: '2px', flex: '0 0 auto', borderBottom: '1px solid var(--dsw-alias-border-l1)' } },
1297
+ tabButton('memory', '记忆'),
1298
+ globalView ? null : tabButton('evolution', '自进化'),
1299
+ );
1300
+
1301
+ const memoryList = loading
1302
+ ? h('div', { style: { ...PANEL_MUTED, padding: '12px 4px' } }, '读取中…')
1303
+ : filtered.length === 0
1304
+ ? h('div', { style: { ...PANEL_MUTED, padding: '12px 4px' } }, query ? '没有匹配的记忆' : '还没有记忆')
1305
+ : filtered.map((record) => h(MemoryRow, {
1306
+ key: record.id, record, active: record.id === selectedMemory,
1307
+ onClick: () => { setSelectedMemory(record.id); setSelectedRule(''); },
1308
+ }));
1309
+
1310
+ const memoryDetail = selectedMemory
1311
+ ? h('div', { style: { ...PANEL_CARD, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '6px' } },
1312
+ detail
1313
+ ? h('div', { style: { display: 'flex', flexDirection: 'column', gap: '6px' } },
1314
+ h('div', { style: { display: 'flex', alignItems: 'flex-start', gap: '8px' } },
1315
+ h('div', { style: { flex: 1, minWidth: 0, fontSize: '13px', lineHeight: '1.45', fontWeight: 600, wordBreak: 'break-word' } }, detail.title),
1316
+ h(Action, { onClick: () => setSelectedMemory('') }, '关闭'),
1317
+ ),
1318
+ h('div', { style: PANEL_MUTED }, `${KIND_LABEL[detailRecord && detailRecord.kind] || '记忆'} · ${detail.scopeLabel || ''}`),
1319
+ Array.isArray(detailRecord && detailRecord.tags) && detailRecord.tags.length
1320
+ ? h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: '4px' } },
1321
+ detailRecord.tags.slice(0, 6).map((tag) => h('span', { key: tag, style: { ...PANEL_MUTED, padding: '0 6px', border: '1px solid var(--dsw-alias-border-l1)', borderRadius: '999px' } }, `#${tag}`))) : null,
1322
+ h('div', { style: { fontSize: '12px', lineHeight: '1.65', whiteSpace: 'pre-wrap', wordBreak: 'break-word' } }, detail.body || '没有正文'),
1323
+ )
1324
+ : h('div', { style: PANEL_MUTED }, detailNote || '读取中…'),
1325
+ )
1326
+ : null;
1327
+
1328
+ const viewToggleOf = (current, setValue, ariaLabel) => {
1329
+ const button = (key, label) => h('button', {
1330
+ key, type: 'button', onClick: () => setValue(key),
1331
+ 'aria-pressed': current === key,
1332
+ style: {
1333
+ ...PANEL_LINK, height: '24px', padding: '0 7px', borderRadius: '6px',
1334
+ background: current === key ? 'var(--dsw-alias-bg-layer-2)' : 'transparent',
1335
+ opacity: current === key ? 0.9 : 0.5,
1336
+ },
1337
+ }, label);
1338
+ return h('div', {
1339
+ role: 'group', 'aria-label': ariaLabel,
1340
+ style: {
1341
+ display: 'flex', alignItems: 'center', gap: '2px', flex: '0 0 auto',
1342
+ height: '30px', padding: '2px', boxSizing: 'border-box',
1343
+ border: '1px solid var(--dsw-alias-border-l1)', borderRadius: '8px',
1344
+ background: 'var(--dsw-alias-bg-base)',
1345
+ },
1346
+ }, button('list', '列表'), button('graph', '图谱'));
1347
+ };
1348
+ const memoryToggle = viewToggleOf(memoryView, setMemoryView, '记忆视图');
1349
+
1350
+ const memoryListPane = h('div', {
1351
+ style: {
1352
+ ...PANEL_CARD, padding: '2px 4px', minHeight: 0, overflowY: 'auto',
1353
+ },
1354
+ }, memoryList);
1355
+ const memoryGraphPane = h('div', {
1356
+ style: { ...PANEL_CARD, padding: 0, minHeight: 0, overflow: 'hidden', display: 'flex' },
1357
+ },
1358
+ h(MemoryGraph, { records: filtered, selectedId: selectedMemory, onPick: onPickNode, emptyText: query ? '没有匹配的记忆' : '还没有记忆', ariaLabel: '记忆关系图' }));
1359
+ const memoryTab = h('div', { role: 'tabpanel', 'aria-label': '记忆', style: { flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', gap: '9px' } },
1360
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', flex: '0 0 auto' } },
1361
+ h('input', {
1362
+ value: query, onChange: (event) => setQuery(event.target.value),
1363
+ placeholder: '搜索标题或标签', 'aria-label': '搜索记忆',
1364
+ style: { ...PANEL_INPUT, width: 'auto', flex: '1 1 0', minWidth: 0 },
1365
+ }),
1366
+ memoryToggle,
1367
+ ),
1368
+ h('div', {
1369
+ style: {
1370
+ flex: 1, minHeight: 0, display: 'grid', gap: '10px',
1371
+ gridTemplateColumns: selectedMemory ? 'minmax(0, 1fr) minmax(280px, 340px)' : 'minmax(0, 1fr)',
1372
+ },
1373
+ },
1374
+ memoryView === 'graph' ? memoryGraphPane : memoryListPane,
1375
+ memoryDetail),
1376
+ );
1377
+
1378
+ const stageFilters = [
1379
+ ['all', '全部'],
1380
+ ['pending', '候选'],
1381
+ ['trial', '试用'],
1382
+ ['active', '生效'],
1383
+ ['paused', '暂停'],
1384
+ ];
1385
+ const countText = (key) => {
1386
+ if (!counts || countsUnknown) return '—';
1387
+ if (key === 'all') return String(['pending', 'trial', 'active', 'paused'].reduce((sum, name) => sum + (countOf(counts, name) || 0), 0));
1388
+ const value = countOf(counts, key);
1389
+ return value === null ? '—' : String(value);
1390
+ };
1391
+ const filterBar = h('div', { style: { display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap', flex: '0 0 auto' } },
1392
+ stageFilters.map(([key, label]) => h('button', {
1393
+ key, type: 'button', onClick: () => setStage(key),
1394
+ style: {
1395
+ ...PANEL_LINK, opacity: stage === key ? 0.92 : 0.5,
1396
+ borderBottom: stage === key ? '1px solid currentColor' : '1px solid transparent',
1397
+ fontVariantNumeric: 'tabular-nums',
1398
+ },
1399
+ }, `${label} ${countText(key)}`)),
1400
+ h('span', { style: { marginLeft: 'auto' } }, viewToggleOf(evolutionView, setEvolutionView, '自进化视图')),
1401
+ );
1402
+
1403
+ const stageLabel = (stageFilters.find(([key]) => key === stage) || ['all', '全部'])[1];
1404
+ const evolutionEmptyText = !memoryOn ? '记忆已关闭'
1405
+ : !evolution ? '读不到自进化状态'
1406
+ : evolution.enabled !== true ? '自进化已关闭'
1407
+ : rules.length === 0 ? (countsUnknown ? '数量未知' : '还没有整理出经验')
1408
+ : `没有${stageLabel}的经验`;
1409
+ const evolutionList = filteredRules.length === 0
1410
+ ? h('div', { style: { ...PANEL_MUTED, padding: '12px 4px' } }, evolutionEmptyText)
1411
+ : filteredRules.map((rule) => h('button', {
1412
+ key: rule.id, type: 'button', onClick: () => { setSelectedRule(rule.id); setSelectedMemory(''); setShowReason(false); },
1413
+ style: {
1414
+ display: 'flex', flexDirection: 'column', gap: '3px', width: '100%', textAlign: 'left',
1415
+ border: 'none', borderBottom: '1px solid var(--dsw-alias-border-l1)',
1416
+ background: rule.id === selectedRule ? 'var(--dsw-alias-bg-layer-2)' : 'transparent',
1417
+ color: 'inherit', padding: '8px 7px', cursor: 'pointer', minWidth: 0,
1418
+ },
1419
+ },
1420
+ h('span', { style: { fontSize: '12px', lineHeight: '1.45', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', opacity: 0.92 } }, rule.title),
1421
+ h('span', { style: { ...PANEL_MUTED, display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0 } },
1422
+ h(StagePill, { state: rule.state }),
1423
+ h('span', { style: { marginLeft: 'auto', flex: '0 0 auto', fontVariantNumeric: 'tabular-nums' } }, EVO_DATE(rule.updated)),
1424
+ )));
1425
+
1426
+ const evolutionReady = selectedKey === currentKey;
1427
+ const evolutionFailed = !evolutionReady || failed
1428
+ ? h('div', { style: { ...PANEL_CARD, padding: '9px 11px', display: 'flex', alignItems: 'center', gap: '8px', flex: '0 0 auto' } },
1429
+ h('span', { style: { ...PANEL_MUTED, minWidth: 0 } }, !evolutionReady && loading ? '读取中…' : (note || '读不到自进化状态')),
1430
+ !evolutionReady && loading ? null : h(Action, { onClick: () => load(currentKey) }, '重试'),
1431
+ )
1432
+ : null;
1433
+ const evolutionDetailPane = selectedRuleRow
1434
+ ? h('div', { style: { ...PANEL_CARD, padding: 0, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' } },
1435
+ h('div', { style: { display: 'flex', justifyContent: 'flex-end', padding: '8px 10px 0', flex: '0 0 auto' } },
1436
+ h(Action, { onClick: () => setSelectedRule('') }, '关闭')),
1437
+ h('div', { style: { minHeight: 0, overflowY: 'auto', padding: '0 10px 10px' } },
1438
+ h(EvolutionDetail, {
1439
+ key: selectedRuleRow.id, rule: selectedRuleRow, evolution, memoryOn,
1440
+ canManage, busy, onManage: manage,
1441
+ })))
1442
+ : null;
1443
+ const evolutionListPane = h('div', {
1444
+ style: {
1445
+ ...PANEL_CARD, padding: '2px 4px', minHeight: 0, overflowY: 'auto',
1446
+ },
1447
+ }, evolutionList);
1448
+ const evolutionGraphPane = h('div', {
1449
+ style: { ...PANEL_CARD, padding: 0, minHeight: 0, overflow: 'hidden', display: 'flex' },
1450
+ },
1451
+ h(MemoryGraph, {
1452
+ records: filteredRules, graphOf: ruleGraphOf, primaryKind: 'rule',
1453
+ selectedId: selectedRule, onPick: onPickRuleNode,
1454
+ emptyText: evolutionEmptyText, ariaLabel: '经验关系图',
1455
+ }));
1456
+ const evolutionTab = evolutionFailed ? evolutionFailed : h('div', { role: 'tabpanel', 'aria-label': '自进化', style: { flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', gap: '9px' } },
1457
+ h('div', { style: { ...PANEL_CARD, padding: '9px 11px', display: 'flex', alignItems: 'flex-start', gap: '8px', flex: '0 0 auto' } },
1458
+ h('span', { style: { flex: '0 0 auto', width: '6px', height: '6px', marginTop: '5px', borderRadius: '999px', background: effectiveEnabled ? 'var(--dsw-alias-brand-primary)' : 'var(--dsw-alias-label-secondary)', opacity: 0.8 } }),
1459
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: '2px', minWidth: 0 } },
1460
+ h('div', { style: { fontSize: '12px', lineHeight: '1.55' } }, activityText || (rules.length === 0 ? '还没有整理出经验' : '暂无整理记录')),
1461
+ countsUnknown ? h('div', { style: PANEL_MUTED }, '数量未知') : null,
1462
+ activityReason
1463
+ ? (showReason
1464
+ ? h('div', { style: { ...PANEL_MUTED, whiteSpace: 'pre-wrap', wordBreak: 'break-word' } }, activityReason)
1465
+ : h(Action, { onClick: () => setShowReason(true) }, '查看原因'))
1466
+ : null,
1467
+ ),
1468
+ ),
1469
+ filterBar,
1470
+ h('div', {
1471
+ style: {
1472
+ flex: 1, minHeight: 0, display: 'grid', gap: '10px',
1473
+ gridTemplateColumns: selectedRuleRow ? 'minmax(0, 1fr) minmax(280px, 340px)' : 'minmax(0, 1fr)',
1474
+ },
1475
+ },
1476
+ evolutionView === 'graph' ? evolutionGraphPane : evolutionListPane,
1477
+ evolutionDetailPane),
1478
+ );
1479
+
1480
+ const content = h('section', { style: { flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, minHeight: 0, padding: '14px', gap: '10px' } },
1481
+ tabBar,
1482
+ activeTab === 'memory' ? memoryTab : evolutionTab,
1483
+ );
1484
+
1485
+ const shell = (children) => h('div', {
1486
+ style: PANEL_BACKDROP,
1487
+ onClick: (event) => { if (event.target === event.currentTarget) props.onClose(); },
1488
+ },
1489
+ h('div', {
1490
+ role: 'dialog', 'aria-modal': 'true', 'aria-label': '记忆面板',
1491
+ style: { ...PANEL_DIALOG, width: 'min(1120px, 100%)', height: 'min(760px, 100%)', boxSizing: 'border-box' },
1492
+ }, ...children));
1493
+
1494
+ if (!sessionId) return null;
1495
+ if (!data) {
1496
+ return shell([
1497
+ header,
1498
+ h('div', { style: { flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '10px', color: 'var(--dsw-alias-label-secondary)', fontSize: '12px' } },
1499
+ h('span', null, loading ? '读取中…' : '读不到记忆'),
1500
+ loading || !note ? null : h(Action, { onClick: () => load() }, '重试'),
1501
+ note ? h('span', null, note) : null,
1502
+ ),
1503
+ ]);
1504
+ }
1505
+
1506
+ return shell([header, switchBar, content]);
1507
+ };
1508
+
1509
+ /** 面板浮层。 */
1510
+ const MemoryOverlay = () => {
1511
+ const ui = usePanelUi();
1512
+ if (!ui.open || !ui.sessionId) return null;
1513
+ return h(MemoryPanel, {
1514
+ key: ui.sessionId,
1515
+ sessionId: ui.sessionId,
1516
+ onClose: () => updatePanel({ open: false }),
1517
+ });
1518
+ };
1519
+
1520
+ /** 输出末尾那几条:只算本轮的。 */
1521
+ const TurnMemory = (props) => {
1522
+ const sessionId = sessionKeyOf(props);
1523
+ const turn = props.turn && typeof props.turn === 'object' && props.turn.turn !== undefined
1524
+ ? String(props.turn.turn)
1525
+ : props.turn !== undefined && props.turn !== null ? String(props.turn) : '';
1526
+ const useRefs = props.useRefs;
1527
+ const refs = typeof useRefs === 'function' && turn ? useRefs(turn) : EMPTY;
1528
+ const groups = React.useMemo(() => {
1529
+ const list = Array.isArray(refs) ? refs : EMPTY;
1530
+ return {
1531
+ reference: list.filter((item) => item.exposure === 'reference'),
1532
+ update: list.filter((item) => item.exposure === 'update'),
1533
+ };
1534
+ }, [refs]);
1535
+ if (!sessionId && refs.length === 0) return null;
1536
+ if (groups.reference.length === 0 && groups.update.length === 0) return null;
1537
+ return h('div', { style: TAIL },
1538
+ h(RefSection, { label: '本轮参考', items: groups.reference, sessionId, turnKey: turn }),
1539
+ h(RefSection, { label: '本轮更新', items: groups.update, sessionId, turnKey: turn }),
1540
+ );
1541
+ };
1542
+
1543
+ const sourceCache = new Map();
1544
+
1545
+ // 测试缝:Node 里验引用投影,浏览器里没有任何副作用。
1546
+ if (typeof globalThis.__LITE_MEMORY_TEST__ === 'function') {
1547
+ globalThis.__LITE_MEMORY_TEST__({ metaOf, citationOf, citationsOfMeta, citationsOfTurn, dedupe, undoRefOf, graphOf, ruleGraphOf, MemoryPanel });
1548
+ }
1549
+
106
1550
  return {
107
- inject: ['slots'],
1551
+ inject: ['slots', 'sessions', 'uiConversation'],
108
1552
  apply(ctx) {
109
1553
  for (const name of ['memory_search', 'memory_read', 'memory_forget']) {
110
1554
  ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: name }, Row));
111
1555
  }
112
1556
  ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: 'memory_suggest' }, SuggestRow));
113
1557
  ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: 'memory_save' }, SavedRow));
1558
+ ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({
1559
+ name: 'conversation.session.header.utilities',
1560
+ id: 'lite-memory:project-evolution',
1561
+ order: 30, // ui-copy-ignore
1562
+ }, ProjectEvolution));
1563
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({
1564
+ name: 'shell.overlay',
1565
+ id: 'lite-memory:panel',
1566
+ order: 30, // ui-copy-ignore
1567
+ }, MemoryOverlay));
1568
+ ctx.slots.inject('conversation.chat.turnTail', () => {
1569
+ try {
1570
+ return ctx.slots.register({
1571
+ name: 'conversation.chat.turnTail',
1572
+ id: 'lite-memory', // ui-copy-ignore
1573
+ order: 30, // ui-copy-ignore
1574
+ inject: (sessionId) => ({ // ui-copy-ignore
1575
+ keyedHooks: {
1576
+ refs: (turn) => { // ui-copy-ignore
1577
+ const key = `${sessionId}|${turn}`;
1578
+ let source = sourceCache.get(key);
1579
+ if (!source) {
1580
+ const getChat = () => {
1581
+ const binding = ctx.sessions.binding(sessionId);
1582
+ if (binding === undefined) return null;
1583
+ return ctx.uiConversation.binding(binding).target('chat');
1584
+ };
1585
+ source = turnSource(getChat, String(turn));
1586
+ sourceCache.set(key, source);
1587
+ while (sourceCache.size > 200) {
1588
+ sourceCache.delete(sourceCache.keys().next().value);
1589
+ }
1590
+ }
1591
+ return source;
1592
+ },
1593
+ },
1594
+ }),
1595
+ }, TurnMemory);
1596
+ } catch {
1597
+ return () => {};
1598
+ }
1599
+ });
114
1600
  },
115
1601
  };
116
1602
  },