dsh-lite-memory 0.16.4 → 0.18.1

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/client.js CHANGED
@@ -23,7 +23,7 @@ window.__ModuleLoader__.load({
23
23
  const KEEP_OFF = { ...KEEP_STYLE, opacity: 0.45, cursor: 'default' };
24
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
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 };
26
+ const TAIL = { ...DOCK, margin: '4px 0 2px' };
27
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
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
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' };
@@ -98,6 +98,18 @@ window.__ModuleLoader__.load({
98
98
  origin: meta.origin === 'project_evolution' ? 'project_evolution' : 'user_explicit',
99
99
  kind,
100
100
  exposure: kind === 'memory-save' || kind === 'memory-forget' ? 'update' : 'reference',
101
+ /** 条目类型:读正文看 recordKind,检索看 ref 自己的 kind;工具名不是类型。 */
102
+ entryKind: typeof meta.kind === 'string' && Object.prototype.hasOwnProperty.call(KIND_LABEL, meta.kind)
103
+ ? meta.kind
104
+ : (typeof meta.recordKind === 'string' ? meta.recordKind : ''),
105
+ updated: typeof meta.updated === 'string' ? meta.updated : '',
106
+ reason: typeof meta.reason === 'string' ? meta.reason : '',
107
+ reasonCode: typeof meta.reasonCode === 'string' ? meta.reasonCode : '',
108
+ noTriggerReason: typeof meta.noTriggerReason === 'string' ? meta.noTriggerReason : '',
109
+ sourceExcerpt: meta.sourceExcerpt,
110
+ sourceRefs: meta.sourceRefs,
111
+ evidence: meta.evidence,
112
+ processingStatus: processingStatusOf(meta),
101
113
  };
102
114
  };
103
115
 
@@ -122,15 +134,22 @@ window.__ModuleLoader__.load({
122
134
  const citationsOfTurn = (nodes) => {
123
135
  if (!Array.isArray(nodes)) return EMPTY;
124
136
  const out = [];
137
+ let noTriggerReason = '';
125
138
  for (const node of nodes) {
126
139
  const root = node && node.root;
127
140
  if (root === null || typeof root !== 'object') continue;
128
141
  for (const block of [root, ...(Array.isArray(root.subCalls) ? root.subCalls : [])]) {
129
142
  const meta = metaOf(block);
130
143
  if (!meta) continue;
131
- out.push(...citationsOfMeta(meta));
144
+ const citations = citationsOfMeta(meta);
145
+ out.push(...citations);
146
+ if (citations.length === 0 && meta.kind === 'memory-search') {
147
+ const reason = [meta.noTriggerReason, meta.reason].find((value) => typeof value === 'string' && value);
148
+ if (reason) noTriggerReason = reason;
149
+ }
132
150
  }
133
151
  }
152
+ if (noTriggerReason) Object.defineProperty(out, 'noTriggerReason', { value: noTriggerReason, enumerable: false, configurable: true });
134
153
  return out;
135
154
  };
136
155
 
@@ -148,6 +167,9 @@ window.__ModuleLoader__.load({
148
167
  out[at] = item;
149
168
  }
150
169
  }
170
+ if (items && typeof items.noTriggerReason === 'string' && items.noTriggerReason) {
171
+ Object.defineProperty(out, 'noTriggerReason', { value: items.noTriggerReason, enumerable: false, configurable: true });
172
+ }
151
173
  return out;
152
174
  };
153
175
 
@@ -196,7 +218,9 @@ window.__ModuleLoader__.load({
196
218
  if (value !== lastNodes) {
197
219
  lastNodes = value;
198
220
  const next = dedupe(citationsOfTurn(value));
199
- if (!sameList(next, snapshot)) snapshot = next;
221
+ const nextReason = next.noTriggerReason || '';
222
+ const lastReason = snapshot.noTriggerReason || '';
223
+ if (!sameList(next, snapshot) || nextReason !== lastReason) snapshot = next;
200
224
  }
201
225
  return snapshot;
202
226
  };
@@ -308,7 +332,9 @@ window.__ModuleLoader__.load({
308
332
  setState('failed');
309
333
  });
310
334
  };
311
- const label = note || (firstLine(text) ?? RUNNING.memory_save);
335
+ const processing = processingStatusOf(meta);
336
+ const baseLabel = firstLine(text) ?? RUNNING.memory_save;
337
+ const label = note || `${baseLabel}${processing ? ` · ${PROCESSING_LABEL[processing]}` : ''}`;
312
338
  const button = changed && state !== 'dropped'
313
339
  ? h('button', { type: 'button', onClick: drop, disabled: !canDrop, 'aria-label': '撤销此次修改', style: state === 'idle' || state === 'failed' ? DROP : { ...DROP, opacity: 0.25, cursor: 'default' } }, '✕')
314
340
  : null;
@@ -316,16 +342,272 @@ window.__ModuleLoader__.load({
316
342
  };
317
343
 
318
344
  const scopeTag = (scope) => (scope === 'global' ? '[全局]' : '[项目]');
345
+
346
+ /** 处理状态只认服务端枚举,绝不从中文 reason 反推。 */
347
+ const PROCESSING = ['applied', 'unchanged', 'superseded', 'failed'];
348
+ const PROCESSING_LABEL = {
349
+ applied: '已应用', unchanged: '无变化', superseded: '已被新版本取代', failed: '处理失败',
350
+ };
351
+ const processingStatusOf = (source) => {
352
+ if (!source || typeof source !== 'object') return '';
353
+ for (const field of ['processingStatus', 'processing', 'outcome', 'status']) {
354
+ const value = source[field];
355
+ if (typeof value === 'string' && PROCESSING.includes(value)) return value;
356
+ if (value && typeof value === 'object' && typeof value.status === 'string' && PROCESSING.includes(value.status)) return value.status;
357
+ }
358
+ return '';
359
+ };
360
+
361
+ const recordOfPayload = (payload) =>
362
+ (payload && payload.record && typeof payload.record === 'object' && !Array.isArray(payload.record) ? payload.record : null);
363
+
364
+ const bodyTextOf = (value) => {
365
+ if (typeof value === 'string') return value;
366
+ if (!value || typeof value !== 'object') return '';
367
+ for (const field of ['body', 'text', 'value']) {
368
+ if (typeof value[field] === 'string') return value[field];
369
+ }
370
+ return '';
371
+ };
372
+
373
+ /** 当前正文与历史版本分开,历史不拼进正文。 */
374
+ const currentBodyOf = (payload) => {
375
+ if (!payload || typeof payload !== 'object') return '';
376
+ if (typeof payload.currentBody === 'string') return payload.currentBody;
377
+ const record = recordOfPayload(payload);
378
+ if (record) {
379
+ const recordBody = bodyTextOf(record.current);
380
+ if (recordBody) return recordBody;
381
+ if (typeof record.body === 'string') return record.body;
382
+ }
383
+ if (payload.current !== undefined && typeof payload.current !== 'boolean') {
384
+ const current = bodyTextOf(payload.current);
385
+ if (current) return current;
386
+ }
387
+ return typeof payload.body === 'string' ? payload.body : '';
388
+ };
389
+
390
+ const historyOfPayload = (payload) => {
391
+ const record = recordOfPayload(payload);
392
+ const current = payload && payload.current && typeof payload.current === 'object' ? payload.current : null;
393
+ const lists = [
394
+ payload && payload.versions,
395
+ payload && payload.history,
396
+ record && record.versions,
397
+ record && record.history,
398
+ current && current.versions,
399
+ ];
400
+ for (const list of lists) {
401
+ if (Array.isArray(list)) return list.filter((row) => row !== null && row !== undefined);
402
+ }
403
+ return EMPTY;
404
+ };
405
+
406
+ /** 历史行统一成 old → new,缺前后值用相邻版本补。 */
407
+ const historyDiffsOf = (payload) => {
408
+ const rows = historyOfPayload(payload);
409
+ if (rows.length === 0) return EMPTY;
410
+ const currentBody = currentBodyOf(payload);
411
+ const normalized = rows.map((row, index) => {
412
+ const value = row && typeof row === 'object' && !Array.isArray(row) ? row : { body: row };
413
+ const explicitOld = [value.old, value.from, value.before, value.previousBody]
414
+ .find((item) => typeof item === 'string');
415
+ const explicitNew = [value.next, value.to, value.after, value.newBody]
416
+ .find((item) => typeof item === 'string');
417
+ return {
418
+ key: `${Number.isInteger(value.revision) ? value.revision : 'v'}-${index}`,
419
+ at: typeof value.at === 'string' ? value.at : (typeof value.when === 'string' ? value.when : ''),
420
+ revision: Number.isInteger(value.revision) ? value.revision : null,
421
+ status: typeof value.status === 'string' ? value.status : '',
422
+ body: bodyTextOf(value), // ui-copy-ignore
423
+ old: explicitOld || '', // ui-copy-ignore
424
+ next: explicitNew || '', // ui-copy-ignore
425
+ };
426
+ });
427
+ const byRevision = normalized.every((row) => row.revision !== null);
428
+ if (byRevision) normalized.sort((a, b) => a.revision - b.revision);
429
+ else if (normalized.every((row) => Boolean(row.at))) normalized.sort((a, b) => a.at.localeCompare(b.at));
430
+ return normalized.map((row, index) => ({
431
+ ...row,
432
+ old: row.old || row.body || '',
433
+ next: row.next || (index + 1 < normalized.length ? normalized[index + 1].body : currentBody),
434
+ }));
435
+ };
436
+
437
+ const payloadFieldOf = (payload, field) => {
438
+ const record = recordOfPayload(payload);
439
+ const current = payload && payload.current && typeof payload.current === 'object' ? payload.current : null;
440
+ for (const owner of [payload, record, current]) {
441
+ if (owner && owner[field] !== undefined) return owner[field];
442
+ }
443
+ return undefined;
444
+ };
445
+
446
+ const sourceExcerptOf = (payload) => bodyTextOf(payloadFieldOf(payload, 'sourceExcerpt'));
447
+ const refsOfPayload = (payload) => {
448
+ const value = payloadFieldOf(payload, 'sourceRefs');
449
+ if (typeof value === 'string' && value) return [value];
450
+ if (Array.isArray(value)) return value;
451
+ if (value && typeof value === 'object') return [value];
452
+ return EMPTY;
453
+ };
454
+ const evidenceOfPayload = (payload) => {
455
+ const value = payloadFieldOf(payload, 'evidence');
456
+ if (typeof value === 'string' && value) return [value];
457
+ if (Array.isArray(value)) return value;
458
+ if (value && typeof value === 'object') return [value];
459
+ return EMPTY;
460
+ };
461
+
462
+ const refLabelOf = (ref, index) => {
463
+ if (typeof ref === 'string') return ref;
464
+ if (!ref || typeof ref !== 'object') return '';
465
+ if (typeof ref.label === 'string' && ref.label) return ref.label;
466
+ if (typeof ref.title === 'string' && ref.title) return ref.title;
467
+ if (typeof ref.text === 'string' && ref.text) return ref.text;
468
+ if (ref.message !== undefined) return `消息 ${ref.message}`;
469
+ if (ref.messageId !== undefined) return `消息 ${ref.messageId}`;
470
+ if (ref.turn !== undefined) return `第 ${ref.turn} 轮`;
471
+ if (ref.turnIndex !== undefined) return `第 ${ref.turnIndex} 轮`;
472
+ return `引用 ${index + 1}`;
473
+ };
474
+ const refHrefOf = (ref) => (ref && typeof ref === 'object'
475
+ ? [ref.href, ref.url, ref.link].find((value) => typeof value === 'string' && value)
476
+ : '');
477
+
478
+ const linkStyle = { color: 'inherit', textDecoration: 'underline', textUnderlineOffset: '2px', wordBreak: 'break-all' };
479
+
480
+ const HistoryVersions = (props) => {
481
+ const rows = Array.isArray(props.rows) ? props.rows : EMPTY;
482
+ const [open, setOpen] = React.useState(false);
483
+ if (rows.length === 0) return null;
484
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '2px' } },
485
+ h('button', {
486
+ type: 'button',
487
+ onClick: () => setOpen((value) => !value),
488
+ 'aria-expanded': open,
489
+ 'aria-label': `${open ? '收起' : '展开'}历史版本`,
490
+ style: CHIP,
491
+ }, `历史 ${rows.length}${open ? ' · 收起' : ''}`),
492
+ open ? h('div', { style: BODY }, rows.map((row) => h('div', {
493
+ key: row.key, style: { margin: '0 0 4px', minWidth: 0 },
494
+ },
495
+ h('div', { style: { opacity: 0.65, fontSize: '11px' } },
496
+ [row.at, row.revision === null ? '' : `v${row.revision}`, row.status].filter(Boolean).join(' · ')),
497
+ h('div', null,
498
+ h('span', { style: { opacity: 0.6 } }, SHORT(row.old, 120) || '—'),
499
+ ' → ',
500
+ h('span', null, SHORT(row.next, 120) || '—'))))) : null,
501
+ );
502
+ };
503
+
504
+ const DetailExtras = (props) => {
505
+ const payload = props.payload;
506
+ const excerpt = sourceExcerptOf(payload);
507
+ const refs = refsOfPayload(payload);
508
+ const evidence = evidenceOfPayload(payload);
509
+ const history = historyDiffsOf(payload);
510
+ if (!excerpt && refs.length === 0 && evidence.length === 0 && history.length === 0) return null;
511
+ const links = (items, label) => (items.length === 0 ? null : h('div', { style: { display: 'flex', gap: '6px', minWidth: 0 } },
512
+ h('span', { style: { opacity: 0.6, flex: '0 0 auto' } }, label),
513
+ h('span', { style: { minWidth: 0, wordBreak: 'break-word' } }, items.map((item, index) => {
514
+ const href = refHrefOf(item);
515
+ const text = refLabelOf(item, index);
516
+ const key = `${index}-${text}`;
517
+ if (!href) return h('span', { key }, index > 0 ? ' · ' : '', text);
518
+ return h('span', { key }, index > 0 ? ' · ' : '', h('a', {
519
+ href, target: '_blank', rel: 'noreferrer', style: linkStyle,
520
+ }, text));
521
+ }))));
522
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '4px' } },
523
+ excerpt ? h('div', { style: BODY }, h('span', { style: { opacity: 0.65 } }, '来源'), ' ', excerpt) : null,
524
+ links(refs, '消息'),
525
+ links(evidence, '依据'),
526
+ h(HistoryVersions, { rows: history }));
527
+ };
528
+
319
529
  const versionLine = (shown) => {
320
530
  const scope = shown.scopeLabel || (shown.scope === 'global' ? '全局' : '项目');
321
531
  const archived = shown.status === 'archived' ? ' · 已归档' : '';
322
- return `${scope}${archived}`;
532
+ const processing = processingStatusOf(shown.processingStatus ? { processingStatus: shown.processingStatus } : shown);
533
+ return `${scope}${archived}${processing ? ` · ${PROCESSING_LABEL[processing]}` : ''}`;
534
+ };
535
+
536
+ /**
537
+ * 引用三层的色调:中性 / 强调 / 成功。
538
+ * 三档色阶:中性 / 强调 / 成功;正文用主文本色。
539
+ */
540
+ const REF_TONE = {
541
+ found: 'var(--dsw-alias-label-secondary)',
542
+ provided: 'var(--dsw-alias-brand-primary)',
543
+ update: 'var(--dsw-alias-state-success-primary)',
544
+ };
545
+ const REF_META = { display: 'flex', alignItems: 'center', gap: '6px', margin: '0 0 2px 2px', fontSize: '11px', lineHeight: '18px', color: 'var(--dsw-alias-label-secondary)' };
546
+ const REF_BODY = { margin: '2px 0 3px', padding: '3px 0 3px 9px', display: 'flex', flexDirection: 'column', gap: '3px', fontSize: '12px', lineHeight: '1.65', maxHeight: '180px', overflowY: 'auto' };
547
+
548
+ /** 出处一句话:类型、项目/全局、来源、状态。 */
549
+ /** 只管最近一个月:更早或没时间就不占位。 */
550
+ const agoLabel = (value) => {
551
+ const at = Date.parse(value);
552
+ if (!Number.isFinite(at)) return '';
553
+ const days = Math.floor((Date.now() - at) / 86_400_000);
554
+ if (days <= 0) return '今天更新';
555
+ if (days === 1) return '昨天更新';
556
+ if (days < 30) return `${days} 天前更新`;
557
+ return '';
558
+ };
559
+ const refMetaOf = (item) => {
560
+ const kind = KIND_LABEL[item.entryKind] || '';
561
+ const parts = kind
562
+ ? [kind, item.scope === 'global' ? '全局' : '项目', item.origin === 'project_evolution' ? '自进化' : '你记的']
563
+ : [item.scope === 'global' ? '全局' : '项目', item.origin === 'project_evolution' ? '自进化' : '你记的'];
564
+ const ago = agoLabel(item.updated);
565
+ if (ago) parts.push(ago);
566
+ if (item.status === 'archived') parts.push('已归档');
567
+ const processing = PROCESSING_LABEL[item.processingStatus] || '';
568
+ if (processing) parts.push(processing);
569
+ return parts.join(' · ');
570
+ };
571
+
572
+ const RefChevron = (props) => h('svg', {
573
+ width: 8, height: 8, viewBox: '0 0 8 8', 'aria-hidden': 'true', focusable: 'false',
574
+ style: { flex: '0 0 auto', opacity: 0.65, transform: props.open ? 'rotate(90deg)' : 'none' },
575
+ }, h('path', { d: 'M2.5 1 L5.5 4 L2.5 7', fill: 'none', stroke: 'currentColor', 'stroke-width': '1.2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }));
576
+
577
+ /** 一条引用:标题一行、原因与来源一行,窄栏也不抢宽度。 */
578
+ const RefRow = (props) => {
579
+ const [hover, setHover] = React.useState(false);
580
+ const item = props.item;
581
+ const reason = item.noTriggerReason || item.reason || '';
582
+ return h('button', {
583
+ type: 'button', // ui-copy-ignore
584
+ onMouseEnter: () => setHover(true),
585
+ onMouseLeave: () => setHover(false),
586
+ onClick: props.onShow, // ui-copy-ignore
587
+ 'aria-label': `查看记忆 ${item.title || '记忆'}${reason ? `,${reason}` : ''}`,
588
+ title: reason ? `${item.title || '记忆'},${reason}` : (item.title || '记忆'),
589
+ style: { // ui-copy-ignore
590
+ display: 'flex', alignItems: 'flex-start', gap: '7px', width: '100%', minWidth: 0, textAlign: 'left',
591
+ border: 'none', borderRadius: '8px', padding: '3px 7px', cursor: 'pointer', color: 'inherit',
592
+ background: props.active || hover ? 'var(--dsw-alias-bg-layer-2)' : 'transparent',
593
+ fontSize: '12px', lineHeight: '18px',
594
+ },
595
+ },
596
+ h('span', { 'aria-hidden': 'true', style: { flex: '0 0 auto', alignSelf: 'stretch', width: '2px', minHeight: '10px', borderRadius: '999px', background: props.tone, opacity: 0.85 } }),
597
+ h('span', { style: { display: 'flex', flexDirection: 'column', gap: '1px', flex: '1 1 auto', minWidth: 0 } },
598
+ h('span', { style: { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, item.title || '记忆'),
599
+ h('span', { style: { display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0, fontSize: '11px', lineHeight: '16px', color: 'var(--dsw-alias-label-secondary)' } },
600
+ reason ? h('span', { style: { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, reason) : null,
601
+ h('span', { style: { marginLeft: 'auto', flex: '0 0 auto', fontVariantNumeric: 'tabular-nums' } }, refMetaOf(item)),
602
+ ),
603
+ ));
323
604
  };
324
605
 
325
606
  /** 一段引用:点开是名字,再点是当前正文。 */
326
607
  const RefSection = (props) => {
327
608
  const items = Array.isArray(props.items) ? props.items : EMPTY;
328
609
  const sessionId = typeof props.sessionId === 'string' ? props.sessionId : '';
610
+ const tone = REF_TONE[props.tone] || REF_TONE.found;
329
611
  const [open, setOpen] = React.useState(false);
330
612
  const [shown, setShown] = React.useState(null);
331
613
  const [note, setNote] = React.useState('');
@@ -341,7 +623,10 @@ window.__ModuleLoader__.load({
341
623
  setShown(null);
342
624
  setNote('');
343
625
  }, [sessionId, props.turnKey]);
344
- if (items.length === 0) return null;
626
+ if (items.length === 0) {
627
+ const reason = typeof props.noTriggerReason === 'string' ? props.noTriggerReason : '';
628
+ return reason ? h('div', { style: { ...REF_META, margin: 0 } }, reason) : null;
629
+ }
345
630
  const close = () => {
346
631
  generation.current += 1;
347
632
  setShown(null);
@@ -378,30 +663,35 @@ window.__ModuleLoader__.load({
378
663
  scopeLabel: typeof payload.scopeLabel === 'string' ? payload.scopeLabel : '',
379
664
  revision: Number.isInteger(payload.revision) ? payload.revision : item.revision,
380
665
  status: typeof payload.status === 'string' ? payload.status : 'ok',
381
- body: typeof payload.body === 'string' ? payload.body : '',
666
+ body: currentBodyOf(payload),
667
+ processingStatus: processingStatusOf(payload) || item.processingStatus || '',
668
+ payload,
382
669
  current: payload.revision === item.revision,
383
670
  });
384
671
  });
385
672
  };
386
- const rows = open ? items.map((item) => h('button', {
673
+ const rows = open ? items.map((item) => h(RefRow, {
387
674
  key: `${item.scope}|${item.id}|${item.revision}`, // ui-copy-ignore
388
- type: 'button', // ui-copy-ignore
389
- onClick: () => show(item), // ui-copy-ignore
390
- 'aria-label': `查看记忆 ${item.title || item.id}`,
391
- title: item.title || item.id,
392
- style: ITEM, // ui-copy-ignore
393
- }, `${scopeTag(item.scope)} ${item.title || item.id}${item.origin === 'project_evolution' ? ' · 实验' : ''}`)) : [];
675
+ item, tone, active: Boolean(shown && shown.id === item.id),
676
+ onShow: () => show(item),
677
+ })) : [];
394
678
  const detail = shown && shown.ref && items.some((item) => item.id === shown.id && item.revision === shown.ref.revision)
395
- ? h('div', { style: BODY },
396
- shown.loading ? '读取中' : null,
397
- shown.gone ? '这条记忆已删除' : null,
398
- shown.invalid ? '读取失败,点名字重试' : null,
679
+ ? h('div', { style: { ...REF_BODY, borderLeft: `2px solid ${tone}` } },
680
+ shown.loading ? h('span', { style: REF_META }, '读取中…') : null,
681
+ shown.gone ? h('span', { style: REF_META }, '这条记忆已删除') : null,
682
+ shown.invalid ? h('span', { style: REF_META }, '读不到正文,点名字重试') : null,
399
683
  !shown.loading && !shown.gone && !shown.invalid ? h(React.Fragment, null,
400
- shown.title && shown.title !== shown.ref.title ? h('div', { style: { opacity: 0.9 } }, shown.title) : null,
401
- h('div', null, versionLine(shown)),
402
- shown.when ? h('div', null, shown.when) : null,
403
- shown.body ? h('div', null, shown.body) : null,
684
+ shown.title && shown.title !== shown.ref.title
685
+ ? h('div', { style: { fontSize: '12px', lineHeight: '1.55', fontWeight: 600 } }, shown.title)
686
+ : null,
687
+ h('div', { style: REF_META }, versionLine(shown)),
688
+ shown.when ? h('div', { style: REF_META }, shown.when) : null,
689
+ shown.body
690
+ ? h('div', { style: { color: 'var(--dsw-alias-label-primary)', whiteSpace: 'pre-wrap', wordBreak: 'break-word' } }, shown.body)
691
+ : null,
692
+ h(DetailExtras, { payload: { ...shown.ref, ...shown.payload } }),
404
693
  ) : null,
694
+ note ? h('span', { style: REF_META }, note) : null,
405
695
  )
406
696
  : null;
407
697
  return h('div', { style: DOCK, 'data-memory-section': props.label },
@@ -409,12 +699,20 @@ window.__ModuleLoader__.load({
409
699
  type: 'button',
410
700
  onClick: () => { if (open) close(); else setOpen(true); },
411
701
  'aria-expanded': open,
412
- 'aria-label': `${open ? '收起' : '展开'}${props.label}`,
413
- style: CHIP,
414
- }, `${props.label} ${items.length} 条${open ? ' · 收起' : ''}`),
702
+ 'aria-label': `${open ? '收起' : '展开'}${props.label},${items.length} 条`,
703
+ style: {
704
+ alignSelf: 'flex-start', display: 'inline-flex', alignItems: 'center', gap: '6px',
705
+ border: 'none', background: 'transparent', padding: '1px 0', cursor: 'pointer',
706
+ color: 'var(--dsw-alias-label-secondary)', fontSize: '11px', lineHeight: '18px',
707
+ },
708
+ },
709
+ h('span', { 'aria-hidden': 'true', style: { width: '2px', height: '10px', borderRadius: '999px', background: tone, opacity: 0.9 } }),
710
+ h('span', null, props.label),
711
+ h('span', { style: { fontVariantNumeric: 'tabular-nums', opacity: 0.8 } }, String(items.length)),
712
+ h(RefChevron, { open }),
713
+ ),
415
714
  ...rows,
416
715
  detail,
417
- note ? h('div', { style: BODY }, note) : null,
418
716
  );
419
717
  };
420
718
 
@@ -423,7 +721,7 @@ window.__ModuleLoader__.load({
423
721
  /** 大脑:两瓣轮廓加一条中线,状态只靠透明度和面板文字。 */
424
722
  const BrainIcon = () => h('svg', {
425
723
  width: 18, height: 18, viewBox: '0 0 24 24', fill: 'none',
426
- stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round',
724
+ stroke: 'currentColor', 'stroke-width': 1.5, 'stroke-linecap': 'round', 'stroke-linejoin': 'round',
427
725
  'aria-hidden': 'true', focusable: 'false',
428
726
  },
429
727
  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' }),
@@ -432,7 +730,7 @@ window.__ModuleLoader__.load({
432
730
 
433
731
  const CloseIcon = () => h('svg', {
434
732
  width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none',
435
- stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round',
733
+ stroke: 'currentColor', 'stroke-width': 1.6, 'stroke-linecap': 'round',
436
734
  'aria-hidden': 'true', focusable: 'false',
437
735
  }, h('path', { d: 'M6 6l12 12M18 6L6 18' }));
438
736
 
@@ -450,6 +748,224 @@ window.__ModuleLoader__.load({
450
748
  const text = typeof value === 'string' ? value : '';
451
749
  return text.length > size ? `${text.slice(0, size)}…` : text;
452
750
  };
751
+
752
+ /** 显示设置只决定画不画,不改学习状态;按项目 key 存在本机。 */
753
+ const DISPLAY_DEFAULTS = Object.freeze({ graph: false, palace: false });
754
+ const displayKeyOf = (projectKey) => `lite-memory:display:${projectKey || 'default'}`;
755
+ const displayStoreOf = () => {
756
+ try {
757
+ const store = globalThis.localStorage;
758
+ return store && typeof store.getItem === 'function' ? store : null;
759
+ } catch {
760
+ return null;
761
+ }
762
+ };
763
+ const readDisplaySettings = (projectKey) => {
764
+ const store = displayStoreOf();
765
+ const next = { ...DISPLAY_DEFAULTS };
766
+ if (!store) return next;
767
+ try {
768
+ const raw = store.getItem(displayKeyOf(projectKey));
769
+ if (!raw) return next;
770
+ const saved = JSON.parse(raw);
771
+ if (saved && typeof saved === 'object') {
772
+ next.graph = saved.graph === true;
773
+ next.palace = saved.palace === true;
774
+ }
775
+ } catch {
776
+ return next;
777
+ }
778
+ return next;
779
+ };
780
+ const writeDisplaySettings = (projectKey, next) => {
781
+ const store = displayStoreOf();
782
+ if (!store) return false;
783
+ try {
784
+ store.setItem(displayKeyOf(projectKey), JSON.stringify({
785
+ graph: next && next.graph === true,
786
+ palace: next && next.palace === true,
787
+ }));
788
+ return true;
789
+ } catch {
790
+ return false;
791
+ }
792
+ };
793
+ /**
794
+ * 解释模式只在本机、按会话记;默认关,零命中原因不打扰。
795
+ * 面板开关与对话末尾共用同一条广播。
796
+ */
797
+ const explainBus = (() => {
798
+ const key = '__liteMemoryExplainBus'; // ui-copy-ignore
799
+ if (!(globalThis[key] instanceof Set)) globalThis[key] = new Set();
800
+ return globalThis[key];
801
+ })();
802
+ const explainKeyOf = (sessionId) => `lite-memory:explain:${sessionId || 'default'}`;
803
+ const explainModeOf = (sessionId) => {
804
+ const store = displayStoreOf();
805
+ if (!store) return false;
806
+ try {
807
+ return store.getItem(explainKeyOf(sessionId)) === '1';
808
+ } catch {
809
+ return false;
810
+ }
811
+ };
812
+ const setExplainMode = (sessionId, on) => {
813
+ const store = displayStoreOf();
814
+ if (!store) return false;
815
+ try {
816
+ store.setItem(explainKeyOf(sessionId), on === true ? '1' : '0');
817
+ } catch {
818
+ return false;
819
+ }
820
+ for (const listener of [...explainBus]) {
821
+ try {
822
+ listener();
823
+ } catch {
824
+ // 单个订阅坏掉不影响其它面板。
825
+ }
826
+ }
827
+ return true;
828
+ };
829
+ const watchExplain = (listener) => {
830
+ explainBus.add(listener);
831
+ return () => explainBus.delete(listener);
832
+ };
833
+ /** 功能关闭时视图统一回列表,不保留一个画不出来的页签。 */
834
+ const graphViewOf = (enabled, view) => (enabled === true && view === 'graph' ? 'graph' : 'list');
835
+ const viewModeOf = (display, view) => {
836
+ if (view === 'graph' && display && display.graph === true) return 'graph';
837
+ if (view === 'palace' && display && display.palace === true) return 'palace';
838
+ return 'list';
839
+ };
840
+ const reduceMotionOf = () => {
841
+ try {
842
+ const query = globalThis.matchMedia && globalThis.matchMedia('(prefers-reduced-motion: reduce)');
843
+ return Boolean(query && query.matches === true);
844
+ } catch {
845
+ return false;
846
+ }
847
+ };
848
+ /** 跟随系统偏好切换,打开面板期间改设置也能立即生效。 */
849
+ const useReducedMotion = () => {
850
+ const [reduced, setReduced] = React.useState(reduceMotionOf);
851
+ React.useEffect(() => {
852
+ let query = null;
853
+ try {
854
+ query = globalThis.matchMedia && globalThis.matchMedia('(prefers-reduced-motion: reduce)');
855
+ } catch {
856
+ query = null;
857
+ }
858
+ if (!query || typeof query.addEventListener !== 'function') return undefined;
859
+ const update = () => setReduced(query.matches === true);
860
+ query.addEventListener('change', update);
861
+ return () => query.removeEventListener('change', update);
862
+ }, []);
863
+ return reduced;
864
+ };
865
+
866
+ /** 同标题(去空白后)出现几次;体检与重复标题筛选共用。 */
867
+ const titleCountsOf = (records) => {
868
+ const counts = new Map();
869
+ (Array.isArray(records) ? records : EMPTY).forEach((record) => {
870
+ const title = typeof record.title === 'string' ? record.title.trim() : '';
871
+ if (title) counts.set(title, (counts.get(title) || 0) + 1);
872
+ });
873
+ return counts;
874
+ };
875
+ /** 标签/话题按精确值筛选;只有自由搜索才用包含匹配。 */
876
+ const filterRecords = (records, query, hub) => {
877
+ const rows = Array.isArray(records) ? records : EMPTY;
878
+ const kind = hub && typeof hub.kind === 'string' ? hub.kind : '';
879
+ const value = hub && typeof hub.value === 'string' ? hub.value : '';
880
+ if (kind === 'tag' && value) {
881
+ return rows.filter((record) => Array.isArray(record.tags) && record.tags.includes(value));
882
+ }
883
+ if (kind === 'topic' && value) {
884
+ return rows.filter((record) => record.topicKey === value);
885
+ }
886
+ if (kind === 'duplicate' || kind === 'unread' || kind === 'noWhen') {
887
+ const counts = titleCountsOf(rows);
888
+ return rows.filter((record) => {
889
+ if (kind === 'unread') return !record.lastUsed;
890
+ if (kind === 'noWhen') return !record.when;
891
+ const title = typeof record.title === 'string' ? record.title.trim() : '';
892
+ return Boolean(title) && counts.get(title) > 1;
893
+ });
894
+ }
895
+ const needle = typeof query === 'string' ? query.trim().toLowerCase() : '';
896
+ if (!needle) return rows;
897
+ return rows.filter((record) => {
898
+ const tags = Array.isArray(record.tags) ? record.tags.join(' ') : '';
899
+ return `${record.title || ''} ${record.summary || ''} ${tags} ${record.topicKey || ''} ${KIND_LABEL[record.kind] || ''}`.toLowerCase().includes(needle);
900
+ });
901
+ };
902
+
903
+ /** 结构体检:只看现行记录,缺的数据给 null,不猜。 */
904
+ const checkupOf = (records, scopes, globalSelected) => {
905
+ const rows = Array.isArray(records) ? records : EMPTY;
906
+ const counts = titleCountsOf(rows);
907
+ let duplicate = 0;
908
+ counts.forEach((count) => { if (count > 1) duplicate += 1; });
909
+ const globalRow = (Array.isArray(scopes) ? scopes : EMPTY).find((scope) => scope && scope.kind === 'global');
910
+ return {
911
+ duplicate,
912
+ unread: rows.filter((record) => !record.lastUsed).length,
913
+ globalEntries: globalRow && Number.isInteger(globalRow.entries) ? globalRow.entries : null,
914
+ globalMissingWhen: globalSelected ? rows.filter((record) => !record.when).length : null,
915
+ };
916
+ };
917
+
918
+ /** 记忆宫殿:房间固定,槽位固定,换项目只换陈列内容。 */
919
+ const PALACE_PAGE_SIZE = 24;
920
+ const PALACE_PREVIEW = 4;
921
+ const PALACE_ROOMS = Object.freeze([
922
+ Object.freeze({ id: 'habits', name: '习惯与规范', kinds: Object.freeze(['preference', 'convention']) }),
923
+ Object.freeze({ id: 'methods', name: '方法与经验', kinds: Object.freeze(['procedure', 'pitfall']) }),
924
+ Object.freeze({ id: 'entries', name: '资料与入口', kinds: Object.freeze(['pointer', 'credential_hint']) }),
925
+ Object.freeze({ id: 'notes', name: '其他记录', kinds: Object.freeze(['note']) }),
926
+ ]);
927
+ const PALACE_ROOM_OF_KIND = Object.freeze({
928
+ preference: 'habits', convention: 'habits',
929
+ procedure: 'methods', pitfall: 'methods',
930
+ pointer: 'entries', credential_hint: 'entries',
931
+ note: 'notes',
932
+ });
933
+ const palaceRoomIdOf = (kind) => PALACE_ROOM_OF_KIND[kind] || 'notes';
934
+ const palaceRoomsOf = (records) => {
935
+ const rows = Array.isArray(records) ? records : EMPTY;
936
+ return PALACE_ROOMS.map((room) => {
937
+ const items = rows.filter((record) => palaceRoomIdOf(record && record.kind) === room.id);
938
+ return { id: room.id, name: room.name, items, total: items.length, pages: Math.max(1, Math.ceil(items.length / PALACE_PAGE_SIZE)) };
939
+ });
940
+ };
941
+ const palaceRoomPageOf = (records, roomId, page) => {
942
+ const room = palaceRoomsOf(records).find((row) => row.id === roomId);
943
+ if (!room) return { items: EMPTY, total: 0, pages: 0, page: 1 };
944
+ const current = Math.min(room.pages, Math.max(1, Number.isFinite(page) ? Math.floor(page) : 1));
945
+ return {
946
+ items: room.items.slice((current - 1) * PALACE_PAGE_SIZE, current * PALACE_PAGE_SIZE),
947
+ total: room.total, pages: room.pages, page: current,
948
+ };
949
+ };
950
+ /** 页码最多铺 7 个,当前页始终在窗口里。 */
951
+ const palacePageWindow = (page, pages) => {
952
+ const total = Math.max(1, Math.floor(Number.isFinite(pages) ? pages : 1));
953
+ const current = Math.min(total, Math.max(1, Math.floor(Number.isFinite(page) ? page : 1)));
954
+ if (total <= 7) return Array.from({ length: total }, (_, index) => index + 1);
955
+ const start = Math.max(1, Math.min(current - 3, total - 6));
956
+ return Array.from({ length: 7 }, (_, index) => start + index);
957
+ };
958
+ const palaceToggleOf = (expanded, roomId) => (expanded === roomId ? '' : roomId);
959
+ const palaceMotionOf = (reduced) => ({ duration: reduced === true ? 0 : 180, easing: 'cubic-bezier(0.2, 0.7, 0.3, 1)' });
960
+
961
+ /** 失败回执分「当前」和「过去」:当前失败要修,过去的只说明发生过。 */
962
+ const failureStateOf = (evolution) => {
963
+ const rows = evolution && evolution.economy && Array.isArray(evolution.economy.recent) ? evolution.economy.recent : EMPTY;
964
+ if (rows.length === 0) return '';
965
+ if (rows[0] && rows[0].status === 'failed') return 'current';
966
+ return rows.slice(1).some((row) => row && row.status === 'failed') ? 'past' : '';
967
+ };
968
+
453
969
  /** 面板开关。 */
454
970
  const panelUi = { open: false, sessionId: '' };
455
971
  const panelListeners = new Set();
@@ -534,74 +1050,124 @@ window.__ModuleLoader__.load({
534
1050
 
535
1051
  /**
536
1052
  * 图谱只画真实关系:显式关联,以及记录自己的标签 / 主题。
537
- * 位置由 id 决定,同一份记忆每次打开都在同一处。
1053
+ * 标签 / 主题是组:枢纽画大点,成员围在它周围画小点。
1054
+ * 位置由 id 与成员关系决定,同一份数据每次打开都在同一处。
538
1055
  */
539
- /** 位置由 id 决定;同一份数据每次打开都在同一处。 */
540
1056
  const layoutGraph = (nodes, edges, primaryKind) => {
1057
+ const kindOf = new Map(nodes.map((node) => [node.id, node.kind]));
541
1058
  const degree = new Map();
1059
+ const linkedOf = new Map();
1060
+ const membersOf = new Map();
1061
+ const addLink = (primaryId, hubId) => {
1062
+ if (!linkedOf.has(primaryId)) linkedOf.set(primaryId, new Set());
1063
+ linkedOf.get(primaryId).add(hubId);
1064
+ if (!membersOf.has(hubId)) membersOf.set(hubId, new Set());
1065
+ membersOf.get(hubId).add(primaryId);
1066
+ };
542
1067
  for (const edge of edges) {
543
1068
  degree.set(edge.from, (degree.get(edge.from) || 0) + 1);
544
1069
  degree.set(edge.to, (degree.get(edge.to) || 0) + 1);
1070
+ if (kindOf.get(edge.from) === primaryKind && kindOf.get(edge.to) !== primaryKind) addLink(edge.from, edge.to);
1071
+ if (kindOf.get(edge.to) === primaryKind && kindOf.get(edge.from) !== primaryKind) addLink(edge.to, edge.from);
545
1072
  }
546
- const placed = new Map();
547
1073
  const primaries = nodes.filter((node) => node.kind === primaryKind);
548
- const count = primaries.length;
549
- const spread = 0.55 + Math.min(0.45, count * 0.06);
550
- primaries.forEach((node, index) => {
551
- const seed = hashOf(node.id);
552
- const ring = count <= 1 ? 0 : count <= 8 ? 128 : count <= 18 ? 168 : 206;
553
- const wobble = count <= 8 ? 1 : 0.64 + ((seed % 100) / 100) * 0.36;
554
- const angle = count <= 1 ? 0 : (index / count) * Math.PI * 2 - Math.PI / 2 + ((seed % 17) / 17) * 0.16;
555
- placed.set(node.id, {
556
- ...node,
557
- x: 320 + Math.cos(angle) * ring * 1.22 * wobble * spread,
558
- y: 236 + Math.sin(angle) * ring * 0.8 * wobble * spread,
559
- radius: 5.5 + Math.min(4, (degree.get(node.id) || 0) * 0.7),
560
- });
561
- });
562
1074
  const hubs = nodes.filter((node) => node.kind !== primaryKind);
563
- const kindOf = new Map(nodes.map((node) => [node.id, node.kind]));
564
- const linkedOf = new Map(hubs.map((node) => [node.id, new Set()]));
565
- for (const edge of edges) {
566
- if (linkedOf.has(edge.from) && kindOf.get(edge.to) === primaryKind) linkedOf.get(edge.from).add(edge.to);
567
- if (linkedOf.has(edge.to) && kindOf.get(edge.from) === primaryKind) linkedOf.get(edge.to).add(edge.from);
568
- }
1075
+ const weightOf = new Map(hubs.map((hub) => [hub.id, (membersOf.get(hub.id) || EMPTY).size]));
569
1076
  const groups = new Map();
570
- for (const node of hubs) {
571
- const linked = [...linkedOf.get(node.id)].sort();
572
- const key = linked.length > 0 ? linked.join('|') : `solo:${node.id}`;
573
- if (!groups.has(key)) groups.set(key, { linked, hubs: [] });
574
- groups.get(key).hubs.push(node);
1077
+ const assignedHub = new Map();
1078
+ const loose = [];
1079
+ for (const node of primaries) {
1080
+ const options = [...(linkedOf.get(node.id) || EMPTY)]
1081
+ .filter((id) => (weightOf.get(id) || 0) > 1)
1082
+ .sort((a, b) => ((weightOf.get(b) || 0) - (weightOf.get(a) || 0)) || a.localeCompare(b));
1083
+ const hubId = options[0];
1084
+ if (!hubId) { loose.push(node); continue; }
1085
+ if (!groups.has(hubId)) groups.set(hubId, []);
1086
+ groups.get(hubId).push(node);
1087
+ assignedHub.set(node.id, hubId);
575
1088
  }
576
- for (const group of groups.values()) {
577
- const points = group.linked.map((id) => placed.get(id)).filter(Boolean);
578
- const cx = points.length > 0 ? points.reduce((sum, point) => sum + point.x, 0) / points.length : 320;
579
- const cy = points.length > 0 ? points.reduce((sum, point) => sum + point.y, 0) / points.length : 236;
580
- const outward = Math.hypot(cx - 320, cy - 236);
581
- group.hubs.forEach((node, index) => {
1089
+ const placed = new Map();
1090
+ if (primaryKind === 'memory') {
1091
+ for (const [hubId, members] of [...groups]) {
1092
+ if (members.length > 1) continue;
1093
+ for (const member of members) {
1094
+ loose.push(member);
1095
+ assignedHub.delete(member.id);
1096
+ }
1097
+ groups.delete(hubId);
1098
+ }
1099
+ }
1100
+ const clusterList = [...groups.entries()]
1101
+ .sort((a, b) => (b[1].length - a[1].length) || a[0].localeCompare(b[0]));
1102
+ const groupCount = clusterList.length;
1103
+ const ring = groupCount <= 1 ? 0 : groupCount <= 4 ? 150 : groupCount <= 8 ? 182 : 212;
1104
+ const hubRadius = (size) => 8 + Math.min(10, Math.sqrt(size) * 3);
1105
+ const placePrimary = (node, x, y) => {
1106
+ placed.set(node.id, {
1107
+ ...node, x, y,
1108
+ radius: 3.2 + Math.min(2.2, (degree.get(node.id) || 0) * 0.35),
1109
+ });
1110
+ };
1111
+ clusterList.forEach(([hubId, members], index) => {
1112
+ const seed = hashOf(hubId);
1113
+ const angle = groupCount <= 1
1114
+ ? 0
1115
+ : (index / groupCount) * Math.PI * 2 - Math.PI / 2 + ((seed % 13) / 13) * 0.12;
1116
+ const cx = 320 + Math.cos(angle) * ring * 1.22;
1117
+ const cy = 236 + Math.sin(angle) * ring * 0.8;
1118
+ const hub = hubs.find((node) => node.id === hubId);
1119
+ const radius = hubRadius(members.length);
1120
+ placed.set(hubId, { ...hub, x: cx, y: cy, radius });
1121
+ const orbit = radius + 20 + Math.sqrt(members.length) * 13;
1122
+ const start = ((seed % 360) / 180) * Math.PI;
1123
+ members.forEach((node, memberIndex) => {
1124
+ const step = (memberIndex / members.length) * Math.PI * 2;
1125
+ const wobble = 0.88 + ((hashOf(node.id) % 25) / 100);
1126
+ placePrimary(node, cx + Math.cos(start + step) * orbit * wobble, cy + Math.sin(start + step) * orbit * 0.82 * wobble);
1127
+ });
1128
+ });
1129
+ if (loose.length > 0) {
1130
+ const orbit = groupCount === 0
1131
+ ? (loose.length <= 1 ? 0 : loose.length <= 8 ? 128 : loose.length <= 18 ? 168 : 206)
1132
+ : ring + 96;
1133
+ loose.forEach((node, index) => {
582
1134
  const seed = hashOf(node.id);
583
- const base = outward > 12
584
- ? Math.atan2(cy - 236, cx - 320)
585
- : ((seed % 360) / 180) * Math.PI;
586
- const angle = base + (index - (group.hubs.length - 1) / 2) * 0.6;
587
- const distance = group.linked.length >= 3 && group.hubs.length === 1 ? 0 : 48 + group.hubs.length * 4;
588
- placed.set(node.id, {
589
- ...node,
590
- x: cx + Math.cos(angle) * distance,
591
- y: cy + Math.sin(angle) * distance * 0.82,
592
- radius: node.kind === 'topic' ? 5 : 4,
593
- });
1135
+ const angle = loose.length <= 1
1136
+ ? 0
1137
+ : (index / loose.length) * Math.PI * 2 - Math.PI / 2 + ((seed % 17) / 17) * 0.14;
1138
+ const wobble = loose.length <= 8 ? 1 : 0.64 + ((seed % 100) / 100) * 0.36;
1139
+ placePrimary(node, 320 + Math.cos(angle) * orbit * 1.22 * wobble, 236 + Math.sin(angle) * orbit * 0.8 * wobble);
594
1140
  });
595
1141
  }
596
1142
  return {
597
1143
  nodes: [...placed.values()],
598
- edges: edges.filter((edge) => placed.has(edge.from) && placed.has(edge.to)),
599
- people: count,
1144
+ edges: edges.filter((edge) => {
1145
+ if (!placed.has(edge.from) || !placed.has(edge.to)) return false;
1146
+ const fromPrimary = kindOf.get(edge.from) === primaryKind;
1147
+ const toPrimary = kindOf.get(edge.to) === primaryKind;
1148
+ if (fromPrimary && toPrimary) return true;
1149
+ if (primaryKind !== 'memory') return true;
1150
+ if (fromPrimary) return assignedHub.get(edge.from) === edge.to;
1151
+ if (toPrimary) return assignedHub.get(edge.to) === edge.from;
1152
+ return false;
1153
+ }),
1154
+ people: primaries.length,
600
1155
  };
601
1156
  };
602
1157
 
603
- const graphOf = (records) => {
604
- const rows = (Array.isArray(records) ? records : EMPTY).slice(0, 60);
1158
+ /** 60 条投影之外也要保留当前选中项,点开的节点不能从图里消失。 */
1159
+ const projectionOf = (all, selectedId) => {
1160
+ const rows = all.slice(0, 60);
1161
+ if (selectedId && !rows.some((row) => row.id === selectedId)) {
1162
+ const selected = all.find((row) => row.id === selectedId);
1163
+ if (selected) rows[rows.length - 1] = selected;
1164
+ }
1165
+ return rows;
1166
+ };
1167
+
1168
+ const graphOf = (records, selectedId) => {
1169
+ const all = Array.isArray(records) ? records : EMPTY;
1170
+ const rows = projectionOf(all, selectedId);
605
1171
  const byId = new Map(rows.map((record) => [record.id, record]));
606
1172
  const tagCount = new Map();
607
1173
  const topicCount = new Map();
@@ -609,7 +1175,7 @@ window.__ModuleLoader__.load({
609
1175
  for (const tag of Array.isArray(record.tags) ? record.tags : EMPTY) tagCount.set(tag, (tagCount.get(tag) || 0) + 1);
610
1176
  if (record.topicKey) topicCount.set(record.topicKey, (topicCount.get(record.topicKey) || 0) + 1);
611
1177
  }
612
- const tags = [...tagCount.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 8);
1178
+ const tags = [...tagCount.entries()].filter(([, count]) => count > 1).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 8);
613
1179
  const topics = [...topicCount.entries()].filter(([, count]) => count > 1).sort((a, b) => b[1] - a[1]).slice(0, 4);
614
1180
  const nodes = [];
615
1181
  const edges = [];
@@ -618,7 +1184,7 @@ window.__ModuleLoader__.load({
618
1184
  edges.push({ from, to, type });
619
1185
  };
620
1186
  for (const record of rows) {
621
- nodes.push({ id: record.id, kind: 'memory', label: record.title || record.id, experimental: record.experimental === true });
1187
+ nodes.push({ id: record.id, kind: 'memory', label: record.title || '记忆', experimental: record.experimental === true });
622
1188
  }
623
1189
  for (const [tag, count] of tags) {
624
1190
  nodes.push({ id: `tag:${tag}`, kind: 'tag', label: `#${tag}`, count });
@@ -637,12 +1203,17 @@ window.__ModuleLoader__.load({
637
1203
  if (byId.has(link.id)) addEdge(record.id, link.id, 'link');
638
1204
  }
639
1205
  }
640
- return layoutGraph(nodes, edges, 'memory');
1206
+ return {
1207
+ ...layoutGraph(nodes, edges, 'memory'),
1208
+ shown: rows.length,
1209
+ total: all.length,
1210
+ };
641
1211
  };
642
1212
 
643
1213
  /** 经验图谱只连共享条件。 */
644
- const ruleGraphOf = (rules) => {
645
- const rows = (Array.isArray(rules) ? rules : EMPTY).slice(0, 60);
1214
+ const ruleGraphOf = (rules, selectedId) => {
1215
+ const all = Array.isArray(rules) ? rules : EMPTY;
1216
+ const rows = projectionOf(all, selectedId);
646
1217
  const conditionCount = new Map();
647
1218
  for (const rule of rows) {
648
1219
  const seen = new Set();
@@ -679,34 +1250,134 @@ window.__ModuleLoader__.load({
679
1250
  }
680
1251
  }
681
1252
  }
682
- return layoutGraph(nodes, edges, 'rule');
1253
+ return {
1254
+ ...layoutGraph(nodes, edges, 'rule'),
1255
+ shown: rows.length,
1256
+ total: all.length,
1257
+ };
683
1258
  };
684
1259
 
685
1260
  /** 关系图只画真实关系;节点沿用原始 id,位置由 id 决定。 */
1261
+ /** 图谱缩放:28px 命中区,悬停/聚焦才给底色。 */
1262
+ const ZoomButton = (props) => {
1263
+ const [hover, setHover] = React.useState(false);
1264
+ return h('button', {
1265
+ type: 'button', onClick: props.onClick, disabled: props.disabled,
1266
+ 'aria-label': props.label, title: props.label,
1267
+ onMouseEnter: () => setHover(true),
1268
+ onMouseLeave: () => setHover(false),
1269
+ onFocus: () => setHover(true),
1270
+ onBlur: () => setHover(false),
1271
+ style: {
1272
+ width: '28px', height: '28px', padding: 0, display: 'inline-flex',
1273
+ alignItems: 'center', justifyContent: 'center', border: 'none',
1274
+ borderRadius: '8px', background: hover && !props.disabled ? 'var(--dsw-alias-bg-layer-2)' : 'transparent',
1275
+ color: 'inherit', opacity: props.disabled ? 0.35 : hover ? 0.95 : 0.72,
1276
+ cursor: props.disabled ? 'default' : 'pointer',
1277
+ },
1278
+ }, props.children);
1279
+ };
1280
+
686
1281
  const MemoryGraph = (props) => {
687
1282
  const primaryKind = props.primaryKind || 'memory';
688
- const graph = React.useMemo(() => (props.graphOf || graphOf)(props.records), [props.records, props.graphOf]);
1283
+ const selectedId = props.selectedId;
1284
+ const graph = React.useMemo(() => (props.graphOf || graphOf)(props.records, selectedId), [props.records, props.graphOf, selectedId]);
689
1285
  const [zoom, setZoom] = React.useState(1);
1286
+ const [pan, setPan] = React.useState({ x: 0, y: 0 });
1287
+ const [dragging, setDragging] = React.useState(false);
690
1288
  const [hoveredId, setHoveredId] = React.useState('');
691
- const selectedId = props.selectedId;
1289
+ const svgRef = React.useRef(null);
1290
+ const dragRef = React.useRef(null);
692
1291
  const labelSet = graph.people <= 40;
1292
+ const hubCount = graph.nodes.length - graph.people;
1293
+ const shownCount = Number.isInteger(graph.shown) ? graph.shown : graph.people;
1294
+ const totalCount = Number.isInteger(props.total) && props.total >= shownCount
1295
+ ? props.total
1296
+ : (Number.isInteger(graph.total) ? graph.total : shownCount);
1297
+ const truncated = totalCount > shownCount;
1298
+ const nearIds = React.useMemo(() => {
1299
+ const set = new Set();
1300
+ if (!selectedId) return set;
1301
+ set.add(selectedId);
1302
+ for (const edge of graph.edges) {
1303
+ if (edge.from === selectedId) set.add(edge.to);
1304
+ if (edge.to === selectedId) set.add(edge.from);
1305
+ }
1306
+ return set;
1307
+ }, [graph.edges, selectedId]);
1308
+ const orderedEdges = selectedId
1309
+ ? [...graph.edges].sort((a, b) => Number(a.from === selectedId || a.to === selectedId) - Number(b.from === selectedId || b.to === selectedId))
1310
+ : graph.edges;
1311
+ const orderedNodes = selectedId
1312
+ ? [...graph.nodes].sort((a, b) => (Number(nearIds.has(a.id)) - Number(nearIds.has(b.id))) || (Number(a.id === selectedId) - Number(b.id === selectedId)))
1313
+ : graph.nodes;
693
1314
  const pickHub = props.onPickHub;
694
- const stepZoom = (delta) => setZoom((value) => Math.min(2.5, Math.max(0.75, Math.round((value + delta) * 100) / 100)));
1315
+ const MIN_ZOOM = 0.2;
1316
+ const MAX_ZOOM = 2.5;
1317
+ const stepZoom = (delta) => setZoom((value) => {
1318
+ const next = delta > 0 ? value * 1.25 : value / 1.25;
1319
+ return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, Math.round(next * 100) / 100));
1320
+ });
1321
+ const pointAt = (event, matrix) => {
1322
+ const svg = svgRef.current;
1323
+ if (!svg || !matrix) return null;
1324
+ const point = svg.createSVGPoint();
1325
+ point.x = event.clientX;
1326
+ point.y = event.clientY;
1327
+ return point.matrixTransform(matrix);
1328
+ };
1329
+ const startPan = (event) => {
1330
+ if (event.button !== 0) return;
1331
+ if (event.target && event.target.closest && event.target.closest('[data-node]')) return;
1332
+ const svg = svgRef.current;
1333
+ if (!svg) return;
1334
+ const ctm = svg.getScreenCTM();
1335
+ if (!ctm) return;
1336
+ const inverse = ctm.inverse();
1337
+ const point = pointAt(event, inverse);
1338
+ if (!point) return;
1339
+ dragRef.current = [event.pointerId, inverse, point.x, point.y, pan.x, pan.y];
1340
+ setDragging(true);
1341
+ if (svg.setPointerCapture) svg.setPointerCapture(event.pointerId);
1342
+ event.preventDefault();
1343
+ };
1344
+ const movePan = (event) => {
1345
+ const drag = dragRef.current;
1346
+ if (!drag || drag[0] !== event.pointerId) return;
1347
+ const point = pointAt(event, drag[1]);
1348
+ if (!point) return;
1349
+ setPan({ x: drag[4] - (point.x - drag[2]), y: drag[5] - (point.y - drag[3]) });
1350
+ event.preventDefault();
1351
+ };
1352
+ const endPan = (event) => {
1353
+ const drag = dragRef.current;
1354
+ if (!drag || (event && drag[0] !== event.pointerId)) return;
1355
+ dragRef.current = null;
1356
+ setDragging(false);
1357
+ const svg = svgRef.current;
1358
+ if (event && svg && svg.releasePointerCapture && svg.hasPointerCapture && svg.hasPointerCapture(event.pointerId)) {
1359
+ svg.releasePointerCapture(event.pointerId);
1360
+ }
1361
+ };
1362
+ React.useEffect(() => {
1363
+ setPan({ x: 0, y: 0 });
1364
+ }, [props.records, primaryKind]);
695
1365
  const frame = React.useMemo(() => {
696
1366
  const list = graph.nodes;
697
1367
  if (list.length === 0) return { x: 0, y: 0, width: 640, height: 472 };
698
1368
  let minX = Infinity; let maxX = -Infinity; let minY = Infinity; let maxY = -Infinity;
699
1369
  for (const node of list) {
700
- const half = node.kind === primaryKind ? 62 : 38;
1370
+ const wide = node.kind === primaryKind && (graph.people <= 12 || hubCount === 0 || node.id === selectedId);
1371
+ const half = node.kind === primaryKind ? (wide ? 62 : 54) : 38;
701
1372
  minX = Math.min(minX, node.x - half);
702
1373
  maxX = Math.max(maxX, node.x + half);
703
1374
  minY = Math.min(minY, node.y - 16);
704
- maxY = Math.max(maxY, node.y + (node.kind === primaryKind ? 28 : 24));
1375
+ maxY = Math.max(maxY, node.y + (node.kind === primaryKind ? (wide ? 30 : 28) : 26));
705
1376
  }
706
1377
  const width = Math.max(380, maxX - minX + 36);
707
1378
  const height = Math.max(250, maxY - minY + 36);
708
1379
  return { x: (minX + maxX) / 2 - width / 2, y: (minY + maxY) / 2 - height / 2, width, height };
709
- }, [graph, primaryKind]);
1380
+ }, [graph, primaryKind, selectedId]);
710
1381
  if (!props.records || props.records.length === 0) {
711
1382
  return h('div', {
712
1383
  style: { flex: 1, minHeight: '220px', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--dsw-alias-label-secondary)', fontSize: '12px' },
@@ -714,102 +1385,184 @@ window.__ModuleLoader__.load({
714
1385
  }
715
1386
  const viewWidth = frame.width / zoom;
716
1387
  const viewHeight = frame.height / zoom;
717
- const zoomButton = (label, delta, icon) => h('button', {
718
- type: 'button', onClick: () => stepZoom(delta),
719
- disabled: delta > 0 ? zoom >= 2.5 : zoom <= 0.75,
720
- 'aria-label': label, title: label,
721
- style: {
722
- width: '26px', height: '26px', padding: 0, display: 'inline-flex',
723
- alignItems: 'center', justifyContent: 'center', border: 'none',
724
- borderRadius: '6px', background: 'transparent', color: 'inherit',
725
- opacity: delta > 0 ? (zoom >= 2.5 ? 0.28 : 0.68) : (zoom <= 0.75 ? 0.28 : 0.68),
726
- cursor: 'pointer',
727
- },
1388
+ const zoomButton = (label, delta, icon) => h(ZoomButton, {
1389
+ key: label, label, disabled: delta > 0 ? zoom >= MAX_ZOOM : zoom <= MIN_ZOOM,
1390
+ onClick: () => stepZoom(delta),
728
1391
  }, icon);
729
1392
  const magnifier = (sign) => h('svg', {
730
1393
  width: '15', height: '15', viewBox: '0 0 16 16', fill: 'none',
731
- stroke: 'currentColor', strokeWidth: '1.4', strokeLinecap: 'round',
1394
+ stroke: 'currentColor', 'stroke-width': '1.4', 'stroke-linecap': 'round',
732
1395
  'aria-hidden': 'true',
733
1396
  },
734
1397
  h('circle', { cx: '6.8', cy: '6.8', r: '4.5' }),
735
1398
  h('line', { x1: '10.1', y1: '10.1', x2: '14', y2: '14' }),
736
1399
  h('line', { x1: '4.8', y1: '6.8', x2: '8.8', y2: '6.8' }),
737
1400
  sign > 0 ? h('line', { x1: '6.8', y1: '4.8', x2: '6.8', y2: '8.8' }) : null);
1401
+ /** 标签避让:hub 常显、选中/悬停优先,记忆标签按度数贪心排布,重叠就省掉(悬停仍出标题)。 */
1402
+ const labelLayout = React.useMemo(() => {
1403
+ const nodes = graph.nodes;
1404
+ const fsOf = (primary) => (primary ? 10.5 : 10);
1405
+ const widthOf = (text, fs) => {
1406
+ let width = 0;
1407
+ for (const ch of text) width += ch.charCodeAt(0) > 255 ? fs : fs * 0.56;
1408
+ return width;
1409
+ };
1410
+ const rank = (node) => (node.id === selectedId ? 0 : node.id === hoveredId ? 1
1411
+ : nearIds.has(node.id) ? 2 : node.kind === primaryKind ? 4 : 3);
1412
+ const boxes = [];
1413
+ const placed = new Map();
1414
+ const hits = (box) => boxes.some((other) => !(box.x2 < other.x1 || box.x1 > other.x2 || box.y2 < other.y1 || box.y1 > other.y2));
1415
+ const list = nodes.slice().sort((a, b) => rank(a) - rank(b) || (b.degree || 0) - (a.degree || 0));
1416
+ /** 条目多时不全标,用度数最高的十条记忆给方向感。 */
1417
+ const topIds = new Set(list.filter((node) => node.kind === primaryKind).slice(0, 10).map((node) => node.id));
1418
+ for (const node of list) {
1419
+ const isPrimary = node.kind === primaryKind;
1420
+ if (isPrimary && !labelSet && rank(node) > 2 && !topIds.has(node.id)) continue;
1421
+ const chars = rank(node) <= 1 ? 16 : isPrimary ? (labelSet ? 10 : 8) : 13;
1422
+ const text = SHORT(node.label, chars);
1423
+ const fs = fsOf(isPrimary);
1424
+ const width = widthOf(text, fs);
1425
+ const gap = isPrimary ? 12 : 13;
1426
+ let spot = null;
1427
+ for (const below of [true, false]) {
1428
+ const baseline = below ? node.y + node.radius + gap : node.y - node.radius - gap + fs;
1429
+ const box = { x1: node.x - width / 2 - 3, x2: node.x + width / 2 + 3, y1: baseline - fs - 2, y2: baseline + 3 };
1430
+ if (box.y1 < 3 || box.y2 > graph.height - 3) continue;
1431
+ if (hits(box)) continue;
1432
+ spot = { box, baseline, below };
1433
+ break;
1434
+ }
1435
+ if (!spot) continue;
1436
+ boxes.push(spot.box);
1437
+ placed.set(node.id, { text, isPrimary, baseline: spot.baseline, fs, width });
1438
+ }
1439
+ return placed;
1440
+ }, [graph, primaryKind, selectedId, hoveredId, labelSet, nearIds]);
738
1441
  return h('div', { style: { position: 'relative', flex: 1, minHeight: 0, display: 'flex' } },
739
1442
  h('div', {
740
1443
  style: {
741
- position: 'absolute', top: '6px', right: '6px', zIndex: 2,
742
- display: 'flex', alignItems: 'center', gap: '1px', padding: '2px',
1444
+ position: 'absolute', top: '8px', right: '8px', zIndex: 2,
1445
+ display: 'flex', alignItems: 'center', gap: '2px', padding: '2px',
743
1446
  border: '1px solid var(--dsw-alias-border-l1)', borderRadius: '8px',
744
1447
  background: 'var(--dsw-alias-bg-layer-1)',
745
1448
  },
746
1449
  },
747
1450
  zoomButton('缩小图谱', -0.25, magnifier(-1)),
748
- zoomButton('放大图谱', 0.25, magnifier(1))),
1451
+ zoomButton('放大图谱', 0.25, magnifier(1)),
1452
+ h('button', {
1453
+ type: 'button', onClick: () => setZoom(1),
1454
+ 'aria-label': '重置缩放', title: '重置缩放',
1455
+ style: {
1456
+ minWidth: '44px', height: '28px', padding: '0 6px', border: 'none', borderRadius: '8px',
1457
+ background: 'transparent', color: 'var(--dsw-alias-label-secondary)',
1458
+ fontSize: '11px', lineHeight: '28px', fontVariantNumeric: 'tabular-nums', cursor: 'pointer',
1459
+ },
1460
+ }, `${Math.round(zoom * 100)}%`)),
749
1461
  h('svg', {
750
- viewBox: `${frame.x + frame.width / 2 - viewWidth / 2} ${frame.y + frame.height / 2 - viewHeight / 2} ${viewWidth} ${viewHeight}`,
1462
+ ref: svgRef,
1463
+ viewBox: `${frame.x + frame.width / 2 - viewWidth / 2 + pan.x} ${frame.y + frame.height / 2 - viewHeight / 2 + pan.y} ${viewWidth} ${viewHeight}`,
751
1464
  preserveAspectRatio: 'xMidYMid meet',
752
- style: { width: '100%', height: '100%', flex: 1, minHeight: 0, display: 'block' },
1465
+ style: {
1466
+ width: '100%', height: '100%', flex: 1, minHeight: 0, display: 'block',
1467
+ cursor: dragging ? 'grabbing' : 'grab', touchAction: 'none',
1468
+ userSelect: 'none', WebkitUserSelect: 'none',
1469
+ },
1470
+ onPointerDown: startPan,
1471
+ onPointerMove: movePan,
1472
+ onPointerUp: endPan,
1473
+ onPointerCancel: endPan,
1474
+ onLostPointerCapture: endPan,
1475
+ onDragStart: (event) => event.preventDefault(),
753
1476
  role: 'img', 'aria-label': props.ariaLabel || '关系图',
754
1477
  },
755
- h('g', null, graph.edges.map((edge, index) => {
1478
+ h('g', null, orderedEdges.map((edge, index) => {
756
1479
  const from = graph.nodes.find((node) => node.id === edge.from);
757
1480
  const to = graph.nodes.find((node) => node.id === edge.to);
758
1481
  if (!from || !to) return null;
759
1482
  const linked = selectedId && (edge.from === selectedId || edge.to === selectedId);
1483
+ const dimmed = selectedId && !nearIds.has(edge.from) && !nearIds.has(edge.to);
760
1484
  return h('line', {
761
1485
  key: `e${index}`, x1: from.x, y1: from.y, x2: to.x, y2: to.y,
762
1486
  stroke: linked ? 'var(--dsw-alias-brand-primary)' : 'var(--dsw-alias-label-secondary)',
763
- strokeWidth: linked ? 1.4 : 1,
764
- opacity: linked ? 0.65 : edge.type === 'link' ? 0.38 : 0.16,
765
- strokeDasharray: edge.type === 'link' ? undefined : '2 4',
1487
+ 'stroke-width': linked ? 1.4 : 1,
1488
+ opacity: linked ? 0.72 : dimmed ? 0.08 : edge.type === 'link' ? 0.38 : 0.16,
1489
+ 'stroke-dasharray': edge.type === 'link' ? undefined : '2 4',
766
1490
  });
767
1491
  })),
768
- h('g', null, graph.nodes.map((node) => {
1492
+ h('g', null, orderedNodes.map((node) => {
769
1493
  const isPrimary = node.kind === primaryKind;
770
1494
  const active = node.id === selectedId;
771
1495
  const hovered = node.id === hoveredId;
1496
+ const near = nearIds.has(node.id);
1497
+ const dimmed = Boolean(selectedId) && !near;
772
1498
  const pick = isPrimary ? props.onPick : (pickHub || undefined);
773
- const fill = !isPrimary ? 'var(--dsw-alias-label-secondary)'
774
- : node.state ? (STAGE_TONE[node.state] || 'var(--dsw-alias-brand-primary)')
775
- : node.experimental ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-brand-primary)';
776
- const showLabel = active || hovered || (isPrimary && labelSet) || !isPrimary;
777
- const nameChars = active || hovered ? 14 : isPrimary ? 7 : 10;
1499
+ /** 记忆是主体:实心、可读;主题/标签 hub 是背景:空心、只留轮廓。 */
1500
+ const fill = active ? 'var(--dsw-alias-brand-primary)'
1501
+ : isPrimary ? 'var(--dsw-alias-label-secondary)'
1502
+ : 'none';
1503
+ const stroke = active ? 'none'
1504
+ : hovered || near ? 'var(--dsw-alias-brand-primary)'
1505
+ : node.experimental ? 'var(--dsw-alias-state-warn-primary)'
1506
+ : isPrimary ? 'none' : 'var(--dsw-alias-label-secondary)';
1507
+ const strokeWidth = !active && (hovered || near) ? 1.3 : node.experimental ? 1 : isPrimary ? 0 : 1;
1508
+ const label = labelLayout.get(node.id) || null;
778
1509
  return h('g', {
779
1510
  key: node.id,
1511
+ 'data-node': 'true',
780
1512
  transform: `translate(${node.x} ${node.y})`,
781
1513
  onClick: pick ? () => pick(node) : undefined,
1514
+ onKeyDown: pick ? (event) => {
1515
+ if (event.key === 'Enter' || event.key === ' ' || event.key === 'Spacebar') {
1516
+ event.preventDefault();
1517
+ pick(node);
1518
+ }
1519
+ } : undefined,
1520
+ onFocus: () => setHoveredId(node.id),
1521
+ onBlur: () => setHoveredId((value) => (value === node.id ? '' : value)),
782
1522
  onMouseEnter: () => setHoveredId(node.id),
783
1523
  onMouseLeave: () => setHoveredId((value) => (value === node.id ? '' : value)),
784
- style: { cursor: pick ? 'pointer' : 'default' },
1524
+ tabIndex: pick ? 0 : undefined,
1525
+ role: pick ? 'button' : undefined,
1526
+ focusable: pick ? 'true' : 'false',
1527
+ 'aria-label': pick ? (node.title || node.label) : undefined,
1528
+ style: { cursor: pick ? 'pointer' : 'default', outline: 'none' },
785
1529
  },
786
- h('circle', { r: Math.max(12, node.radius + 6), fill: 'transparent' }),
787
- active ? h('circle', { r: node.radius + 5, fill: 'none', stroke: 'var(--dsw-alias-brand-primary)', strokeWidth: 1.4, opacity: 0.5 }) : null,
1530
+ h('circle', { r: Math.max(isPrimary ? 14 : 18, node.radius + 6), fill: 'transparent' }),
1531
+ active ? h('circle', { r: node.radius + 5, fill: 'none', stroke: 'var(--dsw-alias-brand-primary)', 'stroke-width': 1.4, opacity: 0.45 }) : null,
788
1532
  h('circle', {
789
1533
  r: node.radius, fill,
790
- opacity: active ? 1 : isPrimary ? 0.9 : 0.5,
791
- stroke: hovered ? 'var(--dsw-alias-brand-primary)' : 'none',
792
- strokeWidth: hovered ? 1.2 : 0,
1534
+ opacity: active ? 1 : dimmed ? (isPrimary ? 0.32 : 0.14) : isPrimary ? 0.92 : 0.34,
1535
+ stroke, 'stroke-width': strokeWidth,
793
1536
  }),
794
- showLabel ? h('text', {
795
- y: node.radius + 12, textAnchor: 'middle',
796
- fill: active ? 'var(--dsw-alias-label-primary)' : 'var(--dsw-alias-label-secondary)',
797
- fontSize: isPrimary ? 9.5 : 8.5, opacity: active ? 0.95 : 0.72,
798
- paintOrder: 'stroke', stroke: 'var(--dsw-alias-bg-layer-1)', strokeWidth: 3,
799
- strokeLinejoin: 'round',
800
- }, SHORT(node.label, nameChars)) : null,
1537
+ label ? h('text', {
1538
+ y: label.baseline, 'text-anchor': 'middle',
1539
+ fill: label.isPrimary ? 'var(--dsw-alias-label-primary)' : 'var(--dsw-alias-label-secondary)',
1540
+ 'font-size': label.fs,
1541
+ 'font-weight': 500,
1542
+ opacity: active ? 1 : dimmed ? 0.2 : label.isPrimary ? 0.9 : 0.72,
1543
+ stroke: 'var(--dsw-alias-bg-layer-1)', 'stroke-width': 2.6, 'stroke-linejoin': 'round', 'paint-order': 'stroke',
1544
+ }, label.text) : null,
801
1545
  h('title', null, node.title || node.label),
802
1546
  );
803
1547
  }))),
804
1548
  h('div', {
805
1549
  style: {
806
1550
  position: 'absolute', left: '10px', bottom: '8px', zIndex: 1,
807
- maxWidth: 'calc(100% - 60px)', padding: '2px 6px', borderRadius: '6px',
1551
+ maxWidth: truncated ? 'calc(100% - 178px)' : 'calc(100% - 60px)', padding: '2px 6px', borderRadius: '6px',
808
1552
  background: 'var(--dsw-alias-bg-layer-1)',
809
1553
  color: 'var(--dsw-alias-label-secondary)', fontSize: '11px', lineHeight: '1.5',
810
1554
  pointerEvents: 'none',
811
1555
  },
812
- }, props.legend || '圆点 记忆 · 小点 标签/主题 · 虚线 关联'),
1556
+ }, props.legend || '实心 记忆 · 空心 标签/主题 · 虚线 归类'),
1557
+ truncated ? h('div', {
1558
+ style: {
1559
+ position: 'absolute', right: '10px', bottom: '8px', zIndex: 1,
1560
+ padding: '2px 6px', borderRadius: '6px',
1561
+ background: 'var(--dsw-alias-bg-layer-1)',
1562
+ color: 'var(--dsw-alias-label-secondary)', fontSize: '11px', lineHeight: '1.5',
1563
+ pointerEvents: 'none', whiteSpace: 'nowrap',
1564
+ },
1565
+ }, `显示 ${shownCount} / 共 ${totalCount} 条`) : null,
813
1566
  );
814
1567
  };
815
1568
 
@@ -826,7 +1579,7 @@ window.__ModuleLoader__.load({
826
1579
  style: {
827
1580
  position: 'absolute', top: '2px', left: props.on ? '18px' : '2px', width: '16px', height: '16px',
828
1581
  borderRadius: '999px', background: props.on ? 'var(--dsw-alias-bg-base)' : 'var(--dsw-alias-label-secondary)',
829
- transition: 'left 120ms ease',
1582
+ transition: reduceMotionOf() ? 'none' : 'left 120ms ease',
830
1583
  },
831
1584
  }));
832
1585
 
@@ -896,22 +1649,41 @@ window.__ModuleLoader__.load({
896
1649
  const evidenceTextOf = (row) => {
897
1650
  if (typeof row === 'string') return row;
898
1651
  if (!row || typeof row !== 'object') return '';
899
- return typeof row.text === 'string' ? row.text : (typeof row.note === 'string' ? row.note : '');
1652
+ for (const key of ['sourceExcerpt', 'excerpt', 'text', 'note']) {
1653
+ if (typeof row[key] === 'string' && row[key].trim()) return row[key];
1654
+ }
1655
+ return '';
1656
+ };
1657
+ /** 样例和证据都带版本/核对信息,不拿「样例」二字占位。 */
1658
+ const evidenceMetaOf = (row) => {
1659
+ if (!row || typeof row !== 'object') return '';
1660
+ const bits = [];
1661
+ if (Number.isInteger(row.artifactVersion)) bits.push(`第 ${row.artifactVersion} 版`);
1662
+ else if (typeof row.artifactVersion === 'string' && row.artifactVersion) bits.push(`第 ${row.artifactVersion} 版`);
1663
+ if (typeof row.dimension === 'string' && row.dimension) bits.push(row.dimension);
1664
+ if (row.verified === true) bits.push('已核对');
1665
+ else if (row.verified === false) bits.push('未核对');
1666
+ if (row.at) bits.push(EVO_DATE(row.at));
1667
+ return bits.join(' · ');
900
1668
  };
901
1669
 
902
1670
  const MemoryRow = (props) => {
903
1671
  const record = props.record;
1672
+ const [hover, setHover] = React.useState(false);
904
1673
  return h('button', {
905
1674
  type: 'button', onClick: props.onClick,
1675
+ onMouseEnter: () => setHover(true),
1676
+ onMouseLeave: () => setHover(false),
906
1677
  style: {
907
- display: 'flex', flexDirection: 'column', gap: '2px', width: '100%', textAlign: 'left',
908
- border: 'none', borderBottom: '1px solid var(--dsw-alias-border-l1)',
909
- background: props.active ? 'var(--dsw-alias-bg-layer-2)' : 'transparent',
910
- color: 'inherit', padding: '8px 7px', cursor: 'pointer', minWidth: 0,
1678
+ display: 'flex', flexDirection: 'column', gap: 0, width: '100%', textAlign: 'left',
1679
+ border: 'none', borderBottom: '1px solid var(--dsw-alias-border-l1)', borderRadius: '6px',
1680
+ borderLeft: `2px solid ${props.active ? 'var(--dsw-alias-brand-primary)' : 'transparent'}`,
1681
+ background: props.active || hover ? 'var(--dsw-alias-bg-layer-2)' : 'transparent',
1682
+ color: 'inherit', padding: '4px 8px', cursor: 'pointer', minWidth: 0,
911
1683
  },
912
1684
  },
913
- h('span', { style: { fontSize: '12px', lineHeight: '1.45', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', opacity: 0.92 } }, record.title || record.id),
914
- h('span', { style: { ...PANEL_MUTED, display: 'flex', alignItems: 'center', gap: '5px', minWidth: 0 } },
1685
+ h('span', { style: { fontSize: '12px', lineHeight: '16px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, record.title || '记忆'),
1686
+ h('span', { style: { ...PANEL_MUTED, display: 'flex', alignItems: 'center', gap: '5px', minWidth: 0, lineHeight: '14px' } },
915
1687
  h('span', null, KIND_LABEL[record.kind] || record.kind),
916
1688
  record.experimental ? h('span', { style: { color: 'var(--dsw-alias-state-warn-primary)' } }, '实验') : null,
917
1689
  h('span', { style: { marginLeft: 'auto', flex: '0 0 auto', fontVariantNumeric: 'tabular-nums' } }, EVO_DATE(record.updated)),
@@ -919,6 +1691,29 @@ window.__ModuleLoader__.load({
919
1691
  );
920
1692
  };
921
1693
 
1694
+ /** 一条经验:状态用小标签,标题独占一行。 */
1695
+ const EvolutionRow = (props) => {
1696
+ const rule = props.rule;
1697
+ const [hover, setHover] = React.useState(false);
1698
+ return h('button', {
1699
+ type: 'button', onClick: props.onClick,
1700
+ onMouseEnter: () => setHover(true),
1701
+ onMouseLeave: () => setHover(false),
1702
+ style: {
1703
+ display: 'flex', flexDirection: 'column', gap: '1px', width: '100%', textAlign: 'left',
1704
+ border: 'none', borderRadius: '8px', minWidth: 0,
1705
+ borderLeft: `2px solid ${props.active ? 'var(--dsw-alias-brand-primary)' : 'transparent'}`,
1706
+ background: props.active || hover ? 'var(--dsw-alias-bg-layer-2)' : 'transparent',
1707
+ color: 'inherit', padding: '6px 9px', cursor: 'pointer',
1708
+ },
1709
+ },
1710
+ h('span', { style: { fontSize: '12px', lineHeight: '18px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, rule.title),
1711
+ h('span', { style: { ...PANEL_MUTED, display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0, lineHeight: '16px' } },
1712
+ h(StagePill, { state: rule.state }),
1713
+ h('span', { style: { marginLeft: 'auto', flex: '0 0 auto', fontVariantNumeric: 'tabular-nums' } }, EVO_DATE(rule.updated)),
1714
+ ));
1715
+ };
1716
+
922
1717
  const EvolutionDetail = (props) => {
923
1718
  const rule = props.rule;
924
1719
  const ruleId = rule ? rule.id : '';
@@ -941,8 +1736,12 @@ window.__ModuleLoader__.load({
941
1736
  const versions = Array.isArray(rule.learning?.versions) ? rule.learning.versions : EMPTY;
942
1737
  const history = [
943
1738
  ...checkpoints.map((row) => ({ at: row.at || row.updated || '', text: typeof row.text === 'string' ? row.text : '' })),
944
- ...versions.map((row) => ({ at: row.at || row.updated || '', text: typeof row.title === 'string' ? row.title : '修改' })),
1739
+ ...versions.map((row, index) => ({
1740
+ at: row.at || row.updated || '',
1741
+ text: `第 ${versions.length - index} 版${row.body ? ` ${SHORT(row.body, 42)}` : row.digest ? ` ${SHORT(String(row.digest), 16)}` : ''}`,
1742
+ })),
945
1743
  ].filter((row) => row.text).sort((a, b) => String(b.at).localeCompare(String(a.at))).slice(0, 6);
1744
+ const failureState = failureStateOf(props.evolution);
946
1745
  const canManage = props.canManage && !props.busy;
947
1746
  const act = (name, extra) => { if (canManage) props.onManage(name, extra || {}); };
948
1747
  const actions = props.canManage
@@ -994,8 +1793,24 @@ window.__ModuleLoader__.load({
994
1793
  evidence.length === 0 && samples.length === 0
995
1794
  ? '暂无学习依据'
996
1795
  : [
997
- ...evidence.slice(0, 3).map((row, index) => h('div', { key: `e${index}` }, evidenceTextOf(row))),
998
- ...samples.slice(0, 2).map((row, index) => h('div', { key: `s${index}` }, evidenceTextOf(row) || '样例')),
1796
+ ...evidence.slice(0, 3).map((row, index) => {
1797
+ const text = evidenceTextOf(row);
1798
+ const meta = evidenceMetaOf(row);
1799
+ return text
1800
+ ? h('div', { key: `e${index}`, style: { display: 'flex', flexDirection: 'column', gap: '1px' } },
1801
+ h('span', { style: { color: 'var(--dsw-alias-label-primary)', opacity: 0.8, whiteSpace: 'pre-wrap', wordBreak: 'break-word' } }, text),
1802
+ meta ? h('span', null, meta) : null)
1803
+ : null;
1804
+ }),
1805
+ ...samples.slice(0, 2).map((row, index) => {
1806
+ const text = evidenceTextOf(row);
1807
+ const meta = evidenceMetaOf(row);
1808
+ return text || meta
1809
+ ? h('div', { key: `s${index}`, style: { display: 'flex', flexDirection: 'column', gap: '1px' } },
1810
+ text ? h('span', { style: { whiteSpace: 'pre-wrap', wordBreak: 'break-word' } }, text) : null,
1811
+ meta ? h('span', null, meta) : null)
1812
+ : null;
1813
+ }),
999
1814
  ].filter(Boolean))
1000
1815
  : null;
1001
1816
  const historyPane = open === 'history'
@@ -1023,6 +1838,10 @@ window.__ModuleLoader__.load({
1023
1838
  h(StagePill, { state: rule.state }),
1024
1839
  ),
1025
1840
  h('div', { style: { fontSize: '12px', lineHeight: '1.55', opacity: 0.82 } }, usabilityOf(rule, props.evolution, props.memoryOn)),
1841
+ failureState
1842
+ ? h('div', { style: { ...PANEL_MUTED, color: failureState === 'current' ? 'var(--dsw-alias-state-warn-primary)' : undefined } },
1843
+ failureState === 'current' ? '最近一次整理失败,现有经验未更新' : '以前有整理失败,最近一次已完成')
1844
+ : null,
1026
1845
  actions,
1027
1846
  ),
1028
1847
  editor,
@@ -1035,6 +1854,178 @@ window.__ModuleLoader__.load({
1035
1854
  );
1036
1855
  };
1037
1856
 
1857
+
1858
+ const PalaceSlot = (props) => {
1859
+ const record = props.record;
1860
+ if (!record) {
1861
+ return h('div', {
1862
+ 'aria-hidden': 'true',
1863
+ style: { height: '46px', border: '1px dashed var(--dsw-alias-border-l1)', borderRadius: '8px', opacity: 0.28 },
1864
+ });
1865
+ }
1866
+ const title = String(record.title || '记忆');
1867
+ return h('button', {
1868
+ type: 'button', title, 'aria-label': title,
1869
+ onClick: () => props.onPick(record.id),
1870
+ style: {
1871
+ height: '46px', minWidth: 0, display: 'flex', flexDirection: 'column', justifyContent: 'center',
1872
+ padding: '4px 7px', textAlign: 'left', cursor: 'pointer',
1873
+ border: `1px solid ${props.active ? 'var(--dsw-alias-brand-primary)' : 'var(--dsw-alias-border-l1)'}`,
1874
+ borderRadius: '8px', color: 'inherit',
1875
+ background: props.active ? 'var(--dsw-alias-bg-layer-2)' : 'var(--dsw-alias-bg-base)',
1876
+ },
1877
+ },
1878
+ h('span', {
1879
+ style: {
1880
+ fontSize: '11px', lineHeight: '15px', height: '30px', overflow: 'hidden',
1881
+ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', wordBreak: 'break-word',
1882
+ },
1883
+ }, title),
1884
+ );
1885
+ };
1886
+
1887
+ const PalaceRoomCard = (props) => {
1888
+ const room = props.room;
1889
+ const empty = room.total === 0;
1890
+ const preview = room.items.slice(0, PALACE_PREVIEW);
1891
+ const slots = Array.from({ length: PALACE_PREVIEW }, (_, index) => preview[index] || null);
1892
+ return h('div', {
1893
+ role: 'listitem',
1894
+ style: { ...PANEL_CARD, padding: 0, position: 'relative', overflow: 'hidden', display: 'flex', width: '100%', flexDirection: 'column' },
1895
+ },
1896
+ h('div', { style: { position: 'relative', display: 'flex', flexDirection: 'column', gap: '8px', padding: '9px 10px 10px', boxSizing: 'border-box' } },
1897
+ h('button', {
1898
+ type: 'button', 'aria-expanded': empty ? undefined : props.expanded, 'aria-controls': empty ? undefined : `lite-palace-room-${room.id}`,
1899
+ disabled: empty,
1900
+ onClick: () => { if (!empty) props.onToggle(room.id); },
1901
+ style: { ...PANEL_LINK, width: '100%', display: 'flex', alignItems: 'center', gap: '7px', textAlign: 'left', opacity: 1 },
1902
+ },
1903
+ h('span', { style: { fontSize: '12px', lineHeight: '18px', fontWeight: 600, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, room.name),
1904
+ h('span', { style: { ...PANEL_MUTED, flex: '0 0 auto', fontVariantNumeric: 'tabular-nums' } }, `${room.total} 条`),
1905
+ empty ? null : h('span', { style: { ...PANEL_MUTED, flex: '0 0 auto', marginLeft: 'auto', opacity: 0.6 } }, props.expanded ? '收起' : '展开'),
1906
+ ),
1907
+ empty
1908
+ ? h('span', { style: { ...PANEL_MUTED, padding: '2px 0 4px' } }, '还没有记录')
1909
+ : h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(96px, 1fr))', gap: '6px' } },
1910
+ slots.map((record, index) => h(PalaceSlot, {
1911
+ key: record ? record.id : `slot-${room.id}-${index}`,
1912
+ record, active: Boolean(record && record.id === props.selectedId), onPick: props.onPick,
1913
+ }))),
1914
+ ),
1915
+ );
1916
+ };
1917
+
1918
+ const MemoryPalace = (props) => {
1919
+ const rows = Array.isArray(props.records) ? props.records : EMPTY;
1920
+ const [expanded, setExpanded] = React.useState('');
1921
+ const [page, setPage] = React.useState(1);
1922
+ const panelRef = React.useRef(null);
1923
+ const reduced = useReducedMotion();
1924
+ const motion = palaceMotionOf(reduced);
1925
+ const rooms = React.useMemo(() => palaceRoomsOf(rows), [rows]);
1926
+ const activeRoom = rooms.find((room) => room.id === expanded) || null;
1927
+ const pageInfo = activeRoom ? palaceRoomPageOf(rows, activeRoom.id, page) : null;
1928
+
1929
+ React.useEffect(() => {
1930
+ if (expanded && !rooms.some((room) => room.id === expanded)) setExpanded('');
1931
+ }, [rooms, expanded]);
1932
+ React.useEffect(() => {
1933
+ const node = panelRef.current;
1934
+ if (!expanded || !node || motion.duration === 0 || typeof node.animate !== 'function') return;
1935
+ node.animate([
1936
+ { opacity: 0, transform: 'translateY(-6px)' },
1937
+ { opacity: 1, transform: 'translateY(0)' },
1938
+ ], { duration: motion.duration, easing: motion.easing });
1939
+ }, [expanded, page, motion.duration, motion.easing]);
1940
+
1941
+ if (rows.length === 0) {
1942
+ return h('div', { style: { ...PANEL_CARD, padding: '14px', minHeight: 0 } },
1943
+ h('div', { style: PANEL_MUTED }, props.emptyText || '这个项目还没有记忆'));
1944
+ }
1945
+ const toggle = (roomId) => {
1946
+ setExpanded((current) => palaceToggleOf(current, roomId));
1947
+ setPage(1);
1948
+ };
1949
+ const goPage = (target) => {
1950
+ if (!pageInfo) return;
1951
+ setPage(Math.min(pageInfo.pages, Math.max(1, target)));
1952
+ };
1953
+ const pageButton = (label, target, disabled, current) => h('button', {
1954
+ key: `page-${target}-${label}`, type: 'button', disabled,
1955
+ 'aria-current': current ? 'page' : undefined, 'aria-label': `第 ${target} 页`,
1956
+ onClick: () => goPage(target),
1957
+ style: {
1958
+ ...PANEL_LINK, minWidth: '22px', height: '22px', padding: '0 5px', borderRadius: '6px',
1959
+ border: 'none', opacity: disabled ? 0.28 : current ? 0.85 : 0.5,
1960
+ background: current ? 'var(--dsw-alias-bg-layer-2)' : 'transparent',
1961
+ fontVariantNumeric: 'tabular-nums', cursor: disabled ? 'default' : 'pointer',
1962
+ },
1963
+ }, label);
1964
+ const pageSlots = pageInfo
1965
+ ? Array.from({ length: pageInfo.items.length ? Math.ceil(pageInfo.items.length / 6) * 6 : 0 }, (_, index) => pageInfo.items[index] || null)
1966
+ : EMPTY;
1967
+ return h('div', {
1968
+ role: 'group', 'aria-label': '记忆宫殿',
1969
+ onKeyDown: (event) => {
1970
+ if (!pageInfo || pageInfo.pages <= 1) return;
1971
+ if (event.key === 'ArrowRight' && pageInfo.page < pageInfo.pages) {
1972
+ event.preventDefault();
1973
+ goPage(pageInfo.page + 1);
1974
+ }
1975
+ if (event.key === 'ArrowLeft' && pageInfo.page > 1) {
1976
+ event.preventDefault();
1977
+ goPage(pageInfo.page - 1);
1978
+ }
1979
+ if (event.key === 'Home' && pageInfo.page > 1) {
1980
+ event.preventDefault();
1981
+ goPage(1);
1982
+ }
1983
+ if (event.key === 'End' && pageInfo.page < pageInfo.pages) {
1984
+ event.preventDefault();
1985
+ goPage(pageInfo.pages);
1986
+ }
1987
+ },
1988
+ style: { ...PANEL_CARD, padding: '10px', minHeight: 0, flex: '0 0 auto', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '10px' },
1989
+ },
1990
+ h('div', {
1991
+ role: 'list',
1992
+ 'aria-label': '记忆房间',
1993
+ style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(232px, 1fr))', gap: '9px' },
1994
+ },
1995
+ rooms.map((room) => h(PalaceRoomCard, {
1996
+ key: room.id, room, expanded: room.id === expanded, selectedId: props.selectedId,
1997
+ onPick: props.onPick, onToggle: toggle,
1998
+ }))),
1999
+ h('div', { 'aria-live': 'polite', style: { ...PANEL_MUTED, marginTop: '-2px' } }, `${rooms.length} 个房间 · ${rows.length} 条`),
2000
+ activeRoom && activeRoom.total > 0 && pageInfo
2001
+ ? h('section', {
2002
+ id: `lite-palace-room-${activeRoom.id}`, 'aria-label': activeRoom.name, ref: panelRef,
2003
+ style: { borderTop: '1px solid var(--dsw-alias-border-l1)', paddingTop: '9px', display: 'flex', flexDirection: 'column', gap: '8px' },
2004
+ },
2005
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
2006
+ h('span', { style: { fontSize: '12px', lineHeight: '1.4', fontWeight: 600 } }, activeRoom.name),
2007
+ h('span', { style: PANEL_MUTED, 'aria-live': 'polite', 'aria-atomic': 'true' }, `${pageInfo.total} 条${pageInfo.pages > 1 ? ` · 第 ${pageInfo.page} / ${pageInfo.pages} 页` : ''}`),
2008
+ h('button', {
2009
+ type: 'button', onClick: () => toggle(activeRoom.id),
2010
+ style: { ...PANEL_LINK, marginLeft: 'auto', height: '24px', padding: '0 7px', borderRadius: '6px', border: '1px solid var(--dsw-alias-border-l1)' },
2011
+ }, '收起'),
2012
+ ),
2013
+ h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(96px, 1fr))', gap: '6px' } },
2014
+ pageSlots.map((record, index) => h(PalaceSlot, {
2015
+ key: record ? record.id : `page-${pageInfo.page}-slot-${index}`,
2016
+ record, active: Boolean(record && record.id === props.selectedId), onPick: props.onPick,
2017
+ }))),
2018
+ pageInfo.pages > 1
2019
+ ? h('div', { role: 'navigation', 'aria-label': '分页', style: { display: 'flex', alignItems: 'center', gap: '4px', flexWrap: 'wrap' } },
2020
+ pageButton('上一页', pageInfo.page - 1, pageInfo.page <= 1, false),
2021
+ palacePageWindow(pageInfo.page, pageInfo.pages).map((number) => pageButton(String(number), number, false, number === pageInfo.page)),
2022
+ pageButton('下一页', pageInfo.page + 1, pageInfo.page >= pageInfo.pages, false))
2023
+ : null,
2024
+ )
2025
+ : null,
2026
+ );
2027
+ };
2028
+
1038
2029
  const MemoryPanel = (props) => {
1039
2030
  const sessionId = props.sessionId;
1040
2031
  const [data, setData] = React.useState(null);
@@ -1052,9 +2043,16 @@ window.__ModuleLoader__.load({
1052
2043
  const [detailNote, setDetailNote] = React.useState('');
1053
2044
  const [busy, setBusy] = React.useState(false);
1054
2045
  const [showReason, setShowReason] = React.useState(false);
2046
+ const [showSettings, setShowSettings] = React.useState(false);
2047
+ const [explainOn, setExplainOn] = React.useState(explainModeOf(sessionId));
2048
+ const [display, setDisplay] = React.useState({ ...DISPLAY_DEFAULTS });
2049
+ const [hubFilter, setHubFilter] = React.useState(null);
2050
+ const [checkup, setCheckup] = React.useState(false);
1055
2051
  const alive = React.useRef(true);
1056
2052
  const generation = React.useRef(0);
1057
2053
  const scopeRef = React.useRef('');
2054
+ /** 先算出当前范围键,显示设置的读取/落盘都按它来,不能等后面的派生变量。 */
2055
+ const selectedKey = data && data.selected && data.selected.key ? data.selected.key : '';
1058
2056
  React.useEffect(() => {
1059
2057
  alive.current = true;
1060
2058
  return () => { alive.current = false; };
@@ -1091,6 +2089,22 @@ window.__ModuleLoader__.load({
1091
2089
 
1092
2090
  React.useEffect(() => { load(); }, [sessionId]);
1093
2091
 
2092
+ /** 显示设置跟着当前项目走;换项目或换范围时重新读。 */
2093
+ React.useEffect(() => {
2094
+ setDisplay(readDisplaySettings(selectedKey));
2095
+ setShowSettings(false);
2096
+ setHubFilter(null);
2097
+ }, [selectedKey]);
2098
+ React.useEffect(() => {
2099
+ setMemoryView((value) => viewModeOf(display, value));
2100
+ setEvolutionView((value) => viewModeOf(display, value));
2101
+ }, [display.graph, display.palace]);
2102
+ const setDisplayFlag = (name, value) => {
2103
+ const next = { ...display, [name]: value === true };
2104
+ setDisplay(next);
2105
+ writeDisplaySettings(selectedKey, next);
2106
+ };
2107
+
1094
2108
  React.useEffect(() => {
1095
2109
  if (typeof window === 'undefined') return undefined;
1096
2110
  const onKey = (event) => { if (event.key === 'Escape') { event.stopPropagation(); props.onClose(); } };
@@ -1123,7 +2137,6 @@ window.__ModuleLoader__.load({
1123
2137
  const records = selected && Array.isArray(selected.records) ? selected.records : EMPTY;
1124
2138
  const total = selected && selected.stats && Number.isInteger(selected.stats.total) ? selected.stats.total : records.length;
1125
2139
  const currentKey = current ? current.key : '';
1126
- const selectedKey = selected ? selected.key : '';
1127
2140
  const control = selected && selected.control
1128
2141
  ? selected.control
1129
2142
  : (current && selected && selected.key === current.key && current.control ? current.control : { enabled: true });
@@ -1185,19 +2198,31 @@ window.__ModuleLoader__.load({
1185
2198
  else if (rules.length === 0) activityText = countsUnknown ? '等待可学习的反馈' : '还没有整理出经验';
1186
2199
  else activityText = '暂无整理记录';
1187
2200
  }
1188
- const filtered = React.useMemo(() => {
1189
- const needle = query.trim().toLowerCase();
1190
- if (!needle) return records;
1191
- return records.filter((record) => {
1192
- const tags = Array.isArray(record.tags) ? record.tags.join(' ') : '';
1193
- return `${record.title} ${record.summary} ${tags} ${record.topicKey || ''} ${KIND_LABEL[record.kind] || ''}`.toLowerCase().includes(needle);
1194
- });
1195
- }, [records, query]);
2201
+ const filtered = React.useMemo(() => filterRecords(records, query, hubFilter), [records, query, hubFilter]);
1196
2202
  const filteredRules = React.useMemo(() => {
1197
2203
  if (stage === 'all') return rules;
1198
2204
  return rules.filter((rule) => rule.state === stage);
1199
2205
  }, [rules, stage]);
1200
2206
  const selectedRuleRow = selectedRule ? rules.find((rule) => rule.id === selectedRule) : null;
2207
+ const checkupData = checkupOf(records, data && data.scopes, globalView);
2208
+ const globalScopeRow = (data && Array.isArray(data.scopes) ? data.scopes : EMPTY).find((scope) => scope && scope.kind === 'global');
2209
+ const checkupJump = (hub) => { setCheckup(false); setTab('memory'); setQuery(''); setHubFilter(hub); };
2210
+ const checkupJumpGlobal = (hub) => {
2211
+ setCheckup(false); setQuery(''); setHubFilter(hub || null);
2212
+ if (globalScopeRow) load(globalScopeRow.key);
2213
+ else setTab('memory');
2214
+ };
2215
+ const checkupRowOf = (key, label, value, unit, onClick) => h('button', {
2216
+ key, type: 'button', onClick,
2217
+ style: {
2218
+ display: 'flex', alignItems: 'center', gap: '8px', width: '100%', minWidth: 0,
2219
+ padding: '7px 9px', border: 'none', background: 'transparent', color: 'inherit',
2220
+ font: 'inherit', textAlign: 'left', cursor: 'pointer',
2221
+ borderBottom: '1px solid var(--dsw-alias-border-l1)',
2222
+ },
2223
+ },
2224
+ h('span', { style: { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, label),
2225
+ h('span', { style: { marginLeft: 'auto', flex: '0 0 auto', fontVariantNumeric: 'tabular-nums', opacity: 0.72 } }, value === null || value === undefined ? '—' : `${value} ${unit}`));
1201
2226
  const detailRecord = selectedMemory ? records.find((record) => record.id === selectedMemory) : null;
1202
2227
 
1203
2228
  const onPickNode = (node) => {
@@ -1205,6 +2230,19 @@ window.__ModuleLoader__.load({
1205
2230
  setSelectedMemory(node.id);
1206
2231
  setSelectedRule('');
1207
2232
  };
2233
+ /** 点 hub 用精确值筛选,标签不发散成子串匹配。 */
2234
+ const onPickHub = (node) => {
2235
+ if (!node || typeof node.id !== 'string') return;
2236
+ const cut = node.id.indexOf(':');
2237
+ const kind = cut > 0 ? node.id.slice(0, cut) : '';
2238
+ const value = cut > 0 ? node.id.slice(cut + 1) : '';
2239
+ if ((kind !== 'tag' && kind !== 'topic') || !value) return;
2240
+ setHubFilter({ kind, value, display: kind === 'tag' ? `#${value}` : value });
2241
+ setQuery('');
2242
+ setSelectedMemory('');
2243
+ setSelectedRule('');
2244
+ setMemoryView('list');
2245
+ };
1208
2246
  const onPickRuleNode = (node) => {
1209
2247
  if (node.kind !== 'rule') return;
1210
2248
  setSelectedRule(node.id);
@@ -1276,31 +2314,60 @@ window.__ModuleLoader__.load({
1276
2314
  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' },
1277
2315
  },
1278
2316
  data && Array.isArray(data.scopes) ? data.scopes.map((scope) => h('option', { key: scope.key, value: scope.key }, scope.kind === 'global' ? '全局' : scope.label)) : null) : null,
2317
+ h('button', {
2318
+ type: 'button', 'aria-expanded': showSettings, 'aria-label': '显示设置',
2319
+ onClick: () => setShowSettings((value) => !value),
2320
+ style: {
2321
+ ...PANEL_LINK, height: '30px', padding: '0 9px', borderRadius: '8px',
2322
+ border: '1px solid var(--dsw-alias-border-l1)',
2323
+ background: showSettings ? 'var(--dsw-alias-bg-layer-2)' : 'transparent',
2324
+ opacity: showSettings ? 0.92 : 0.68,
2325
+ },
2326
+ }, '显示设置'),
1279
2327
  closeButton,
1280
2328
  ),
1281
2329
  );
1282
2330
 
1283
- const memorySwitch = h('div', { style: { ...PANEL_CARD, flex: '1 1 300px', display: 'flex', flexDirection: 'column', gap: '4px', padding: '9px 11px' } },
1284
- h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px' } },
1285
- h('span', { style: { fontSize: '12px', lineHeight: '1.4', fontWeight: 600 } }, '启用记忆'),
1286
- h(Toggle, { ariaLabel: '启用记忆', on: memoryOn, busy: busy || !canToggle, onClick: canToggle ? toggleControl : undefined }),
1287
- ),
1288
- h('div', { style: PANEL_MUTED }, '读取项目和全局记忆'), // ui-copy-ignore 用户指定文案
2331
+ const displayToggle = (label, key) => switchItem(
2332
+ label, '', display[key] === true, !selectedKey,
2333
+ selectedKey ? () => setDisplayFlag(key, display[key] !== true) : undefined,
1289
2334
  );
1290
- 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 } },
1291
- h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px' } },
1292
- h('span', { style: { fontSize: '12px', lineHeight: '1.4', fontWeight: 600 } }, '自进化'),
1293
- h(Toggle, { ariaLabel: '自进化', on: Boolean(memoryOn && evolution && evolution.enabled === true), busy: busy || !evolution || !canToggle || !memoryOn, onClick: canToggle && memoryOn ? toggleEvolution : undefined }),
1294
- ),
1295
- h('div', { style: PANEL_MUTED }, '从反馈中整理经验,按需使用'), // ui-copy-ignore 用户指定文案
1296
- h('div', { style: PANEL_MUTED }, '实验功能,会增加 token 消耗'), // ui-copy-ignore 用户指定文案
2335
+
2336
+ const explainToggle = () => switchItem(
2337
+ '解释模式',
2338
+ explainOn ? '零命中时说明原因' : '零命中时不出提示',
2339
+ explainOn,
2340
+ !sessionId,
2341
+ sessionId ? () => {
2342
+ const next = explainOn !== true;
2343
+ setExplainOn(next);
2344
+ setExplainMode(sessionId, next);
2345
+ } : undefined,
1297
2346
  );
1298
- 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' } },
1299
- globalView ? h('div', { style: { ...PANEL_MUTED, padding: '2px 0' } }, '全局记忆只读') : memorySwitch,
1300
- globalView ? null : evolutionSwitch,
1301
- !memoryOn && !globalView ? h('div', { style: { ...PANEL_MUTED, flexBasis: '100%' } }, '记忆已关闭,内容只读') : null,
1302
- !globalView && !canToggle ? h('div', { style: { ...PANEL_MUTED, flexBasis: '100%' } }, '只读,切换回当前项目后可修改') : null,
1303
- note ? h('div', { style: { ...PANEL_MUTED, flexBasis: '100%', color: 'var(--dsw-alias-state-error-primary)' } }, note) : null,
2347
+
2348
+ const switchItem = (label, hint, on, disabled, onClick) => h('div', {
2349
+ style: { display: 'flex', flexDirection: 'column', gap: '1px', minWidth: 0, opacity: disabled && !on ? 0.55 : 1 },
2350
+ },
2351
+ h('span', { style: { display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 } },
2352
+ h('span', { style: { fontSize: '12px', lineHeight: '20px', fontWeight: 600, whiteSpace: 'nowrap' } }, label),
2353
+ h(Toggle, { ariaLabel: label, on, busy: busy || disabled, onClick: disabled ? undefined : onClick }),
2354
+ ),
2355
+ hint ? h('span', { style: { ...PANEL_MUTED, lineHeight: '16px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, hint) : null);
2356
+ const evolutionOn = Boolean(memoryOn && evolution && evolution.enabled === true);
2357
+ const switchBar = h('div', {
2358
+ style: { display: 'flex', flexDirection: 'column', gap: '3px', padding: '7px 18px 8px', borderBottom: '1px solid var(--dsw-alias-border-l1)', flex: '0 0 auto' },
2359
+ },
2360
+ globalView
2361
+ ? h('div', { style: { ...PANEL_MUTED, lineHeight: '20px' } }, '全局记忆只读')
2362
+ : h('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(190px, 1fr))', gap: '4px 18px' } },
2363
+ switchItem('启用记忆', memoryOn ? '读取项目和全局记忆' : '记忆已关闭,内容只读', memoryOn, busy || !canToggle, toggleControl),
2364
+ switchItem('自进化', evolutionOn ? '实验功能,会增加 token 消耗' : '从反馈里整理经验', evolutionOn, busy || !evolution || !canToggle || !memoryOn, toggleEvolution), // ui-copy-ignore 用户指定文案
2365
+ showSettings ? displayToggle('启用图谱', 'graph') : null,
2366
+ showSettings ? displayToggle('启用记忆宫殿', 'palace') : null,
2367
+ showSettings ? explainToggle() : null,
2368
+ ),
2369
+ !globalView && !canToggle ? h('div', { style: { ...PANEL_MUTED, lineHeight: '18px' } }, '只读,切换回当前项目后可修改') : null,
2370
+ note ? h('div', { style: { ...PANEL_MUTED, lineHeight: '18px', color: 'var(--dsw-alias-state-error-primary)' } }, note) : null,
1304
2371
  );
1305
2372
 
1306
2373
  const switchTab = (key) => {
@@ -1324,15 +2391,20 @@ window.__ModuleLoader__.load({
1324
2391
  globalView ? null : tabButton('evolution', '自进化'),
1325
2392
  );
1326
2393
 
2394
+ const filteredNow = Boolean(query || hubFilter);
1327
2395
  const memoryList = loading
1328
- ? h('div', { style: { ...PANEL_MUTED, padding: '12px 4px' } }, '读取中…')
2396
+ ? h('div', { style: { ...PANEL_MUTED, padding: '14px 9px' } }, '读取中…')
1329
2397
  : filtered.length === 0
1330
- ? h('div', { style: { ...PANEL_MUTED, padding: '12px 4px' } }, query ? '没有匹配的记忆' : '还没有记忆')
2398
+ ? h('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', padding: '14px 9px', minWidth: 0 } },
2399
+ h('span', { style: { ...PANEL_MUTED, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, filteredNow ? '没有匹配的记忆' : '还没有记忆'),
2400
+ filteredNow ? h(Action, { onClick: () => { setHubFilter(null); setQuery(''); } }, '清除搜索') : null,
2401
+ )
1331
2402
  : filtered.map((record) => h(MemoryRow, {
1332
2403
  key: record.id, record, active: record.id === selectedMemory,
1333
2404
  onClick: () => { setSelectedMemory(record.id); setSelectedRule(''); },
1334
2405
  }));
1335
2406
 
2407
+ const detailProcessing = detail ? processingStatusOf(detail) : '';
1336
2408
  const memoryDetail = selectedMemory
1337
2409
  ? h('div', { style: { ...PANEL_CARD, minHeight: 0, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '6px' } },
1338
2410
  detail
@@ -1341,17 +2413,18 @@ window.__ModuleLoader__.load({
1341
2413
  h('div', { style: { flex: 1, minWidth: 0, fontSize: '13px', lineHeight: '1.45', fontWeight: 600, wordBreak: 'break-word' } }, detail.title),
1342
2414
  h(Action, { onClick: () => setSelectedMemory('') }, '关闭'),
1343
2415
  ),
1344
- h('div', { style: PANEL_MUTED }, `${KIND_LABEL[detailRecord && detailRecord.kind] || '记忆'} · ${detail.scopeLabel || ''}`),
2416
+ h('div', { style: PANEL_MUTED }, `${KIND_LABEL[detailRecord && detailRecord.kind] || '记忆'} · ${detail.scopeLabel || ''}${detailProcessing ? ` · ${PROCESSING_LABEL[detailProcessing]}` : ''}`),
1345
2417
  Array.isArray(detailRecord && detailRecord.tags) && detailRecord.tags.length
1346
2418
  ? h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: '4px' } },
1347
2419
  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,
1348
- h('div', { style: { fontSize: '12px', lineHeight: '1.65', whiteSpace: 'pre-wrap', wordBreak: 'break-word' } }, detail.body || '没有正文'),
2420
+ h('div', { style: { fontSize: '12px', lineHeight: '1.65', whiteSpace: 'pre-wrap', wordBreak: 'break-word' } }, currentBodyOf(detail) || '没有正文'),
2421
+ h(DetailExtras, { payload: detail }),
1349
2422
  )
1350
2423
  : h('div', { style: PANEL_MUTED }, detailNote || '读取中…'),
1351
2424
  )
1352
2425
  : null;
1353
2426
 
1354
- const viewToggleOf = (current, setValue, ariaLabel) => {
2427
+ const viewToggleOf = (current, setValue, ariaLabel, modes) => {
1355
2428
  const button = (key, label) => h('button', {
1356
2429
  key, type: 'button', onClick: () => setValue(key),
1357
2430
  'aria-pressed': current === key,
@@ -1361,6 +2434,7 @@ window.__ModuleLoader__.load({
1361
2434
  opacity: current === key ? 0.9 : 0.5,
1362
2435
  },
1363
2436
  }, label);
2437
+ const options = Array.isArray(modes) ? modes : [['list', '列表'], ['graph', '图谱']];
1364
2438
  return h('div', {
1365
2439
  role: 'group', 'aria-label': ariaLabel,
1366
2440
  style: {
@@ -1369,31 +2443,41 @@ window.__ModuleLoader__.load({
1369
2443
  border: '1px solid var(--dsw-alias-border-l1)', borderRadius: '8px',
1370
2444
  background: 'var(--dsw-alias-bg-base)',
1371
2445
  },
1372
- }, button('list', '列表'), button('graph', '图谱'));
2446
+ }, options.map(([key, label]) => button(key, label)));
1373
2447
  };
1374
- const memoryToggle = viewToggleOf(memoryView, setMemoryView, '记忆视图');
2448
+ const memoryModes = [['list', '列表']];
2449
+ if (display.graph) memoryModes.push(['graph', '图谱']);
2450
+ if (display.palace) memoryModes.push(['palace', '宫殿']);
2451
+ const memoryToggle = memoryModes.length > 1 ? viewToggleOf(memoryView, setMemoryView, '记忆视图', memoryModes) : null;
2452
+ const memoryViewActive = viewModeOf(display, memoryView);
2453
+ const evolutionViewActive = graphViewOf(display.graph, evolutionView);
1375
2454
 
1376
2455
  const memoryListPane = h('div', {
1377
2456
  style: {
1378
2457
  ...PANEL_CARD, padding: '2px 4px', minHeight: 0, overflowY: 'auto',
2458
+ display: 'flex', flexDirection: 'column', gap: 0,
1379
2459
  },
1380
2460
  }, memoryList);
1381
2461
  const memoryGraphPane = h('div', {
1382
2462
  style: { ...PANEL_CARD, padding: 0, minHeight: 0, overflow: 'hidden', display: 'flex' },
1383
2463
  },
1384
2464
  h(MemoryGraph, {
1385
- records: filtered, selectedId: selectedMemory, onPick: onPickNode,
1386
- onPickHub: (node) => {
1387
- setQuery(node.kind === 'tag' ? node.label.replace(/^#/, '') : node.label);
1388
- setMemoryView('list');
1389
- },
1390
- emptyText: query ? '没有匹配的记忆' : '还没有记忆', ariaLabel: '记忆关系图',
1391
- legend: '圆点 记忆 · 小点 标签/主题 · 虚线 关联 · 点任意点筛选或查看',
2465
+ records: filtered, total: hubFilter ? filtered.length : total,
2466
+ selectedId: selectedMemory, onPick: onPickNode,
2467
+ onPickHub,
2468
+ emptyText: query || hubFilter ? '没有匹配的记忆' : '还没有记忆', ariaLabel: '记忆关系图',
2469
+ legend: '实心 记忆 · 空心 标签/主题 · 虚线 归类 · 点标签筛选,点记忆看正文',
1392
2470
  }));
2471
+ const memoryPalacePane = h(MemoryPalace, {
2472
+ key: selectedKey, records: filtered, selectedId: selectedMemory,
2473
+ onPick: (id) => { setSelectedMemory(id); setSelectedRule(''); },
2474
+ emptyText: query || hubFilter ? '没有匹配的记忆' : '这个项目还没有记忆',
2475
+ });
1393
2476
  const memoryTab = h('div', { role: 'tabpanel', 'aria-label': '记忆', style: { flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', gap: '9px' } },
1394
2477
  h('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', flex: '0 0 auto' } },
1395
2478
  h('input', {
1396
- value: query, onChange: (event) => setQuery(event.target.value),
2479
+ value: hubFilter ? hubFilter.display : query,
2480
+ onChange: (event) => { setHubFilter(null); setQuery(event.target.value); },
1397
2481
  placeholder: '搜索标题或标签', 'aria-label': '搜索记忆',
1398
2482
  style: { ...PANEL_INPUT, width: 'auto', flex: '1 1 0', minWidth: 0 },
1399
2483
  }),
@@ -1405,7 +2489,7 @@ window.__ModuleLoader__.load({
1405
2489
  gridTemplateColumns: selectedMemory ? 'minmax(0, 1fr) minmax(280px, 340px)' : 'minmax(0, 1fr)',
1406
2490
  },
1407
2491
  },
1408
- memoryView === 'graph' ? memoryGraphPane : memoryListPane,
2492
+ memoryViewActive === 'graph' ? memoryGraphPane : memoryViewActive === 'palace' ? memoryPalacePane : memoryListPane,
1409
2493
  memoryDetail),
1410
2494
  );
1411
2495
 
@@ -1431,7 +2515,15 @@ window.__ModuleLoader__.load({
1431
2515
  fontVariantNumeric: 'tabular-nums',
1432
2516
  },
1433
2517
  }, `${label} ${countText(key)}`)),
1434
- h('span', { style: { marginLeft: 'auto' } }, viewToggleOf(evolutionView, setEvolutionView, '自进化视图')),
2518
+ h('span', { style: { marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '8px' } },
2519
+ h('button', {
2520
+ type: 'button', 'aria-pressed': checkup, onClick: () => setCheckup((value) => !value),
2521
+ style: {
2522
+ ...PANEL_LINK, height: '30px', opacity: checkup ? 0.92 : 0.68,
2523
+ borderBottom: checkup ? '1px solid currentColor' : '1px solid transparent',
2524
+ },
2525
+ }, '结构体检'),
2526
+ display.graph ? viewToggleOf(evolutionView, setEvolutionView, '自进化视图') : null),
1435
2527
  );
1436
2528
 
1437
2529
  const stageLabel = (stageFilters.find(([key]) => key === stage) || ['all', '全部'])[1];
@@ -1441,21 +2533,14 @@ window.__ModuleLoader__.load({
1441
2533
  : rules.length === 0 ? (countsUnknown ? '数量未知' : '还没有整理出经验')
1442
2534
  : `没有${stageLabel}的经验`;
1443
2535
  const evolutionList = filteredRules.length === 0
1444
- ? h('div', { style: { ...PANEL_MUTED, padding: '12px 4px' } }, evolutionEmptyText)
1445
- : filteredRules.map((rule) => h('button', {
1446
- key: rule.id, type: 'button', onClick: () => { setSelectedRule(rule.id); setSelectedMemory(''); setShowReason(false); },
1447
- style: {
1448
- display: 'flex', flexDirection: 'column', gap: '3px', width: '100%', textAlign: 'left',
1449
- border: 'none', borderBottom: '1px solid var(--dsw-alias-border-l1)',
1450
- background: rule.id === selectedRule ? 'var(--dsw-alias-bg-layer-2)' : 'transparent',
1451
- color: 'inherit', padding: '8px 7px', cursor: 'pointer', minWidth: 0,
1452
- },
1453
- },
1454
- h('span', { style: { fontSize: '12px', lineHeight: '1.45', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', opacity: 0.92 } }, rule.title),
1455
- h('span', { style: { ...PANEL_MUTED, display: 'flex', alignItems: 'center', gap: '6px', minWidth: 0 } },
1456
- h(StagePill, { state: rule.state }),
1457
- h('span', { style: { marginLeft: 'auto', flex: '0 0 auto', fontVariantNumeric: 'tabular-nums' } }, EVO_DATE(rule.updated)),
1458
- )));
2536
+ ? h('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', padding: '14px 9px', minWidth: 0 } },
2537
+ h('span', { style: { ...PANEL_MUTED, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, evolutionEmptyText),
2538
+ stage !== 'all' && (memoryOn && evolution && evolution.enabled === true) ? h(Action, { onClick: () => setStage('all') }, '看全部') : null,
2539
+ )
2540
+ : filteredRules.map((rule) => h(EvolutionRow, {
2541
+ key: rule.id, rule, active: rule.id === selectedRule,
2542
+ onClick: () => { setSelectedRule(rule.id); setSelectedMemory(''); setShowReason(false); },
2543
+ }));
1459
2544
 
1460
2545
  const evolutionReady = selectedKey === currentKey;
1461
2546
  const evolutionFailed = !evolutionReady || failed
@@ -1476,18 +2561,31 @@ window.__ModuleLoader__.load({
1476
2561
  : null;
1477
2562
  const evolutionListPane = h('div', {
1478
2563
  style: {
1479
- ...PANEL_CARD, padding: '2px 4px', minHeight: 0, overflowY: 'auto',
2564
+ ...PANEL_CARD, padding: '4px', minHeight: 0, overflowY: 'auto',
2565
+ display: 'flex', flexDirection: 'column', gap: '2px',
1480
2566
  },
1481
2567
  }, evolutionList);
1482
2568
  const evolutionGraphPane = h('div', {
1483
2569
  style: { ...PANEL_CARD, padding: 0, minHeight: 0, overflow: 'hidden', display: 'flex' },
1484
2570
  },
1485
2571
  h(MemoryGraph, {
1486
- records: filteredRules, graphOf: ruleGraphOf, primaryKind: 'rule',
2572
+ records: filteredRules, total: filteredRules.length, graphOf: ruleGraphOf, primaryKind: 'rule',
1487
2573
  selectedId: selectedRule, onPick: onPickRuleNode,
1488
2574
  emptyText: evolutionEmptyText, ariaLabel: '经验关系图',
1489
- legend: '圆点 经验 · 小点 条件 · 虚线 共用条件 · 点圆点查看',
2575
+ legend: '实心 经验 · 空心 条件 · 虚线 共用条件 · 点经验查看',
1490
2576
  }));
2577
+ const evolutionCheckupPane = h('div', {
2578
+ style: {
2579
+ ...PANEL_CARD, padding: '4px', flex: '0 0 auto', minHeight: 0, overflowY: 'auto',
2580
+ display: 'flex', flexDirection: 'column', gap: 0,
2581
+ },
2582
+ },
2583
+ checkupRowOf('duplicate', '重复标题', checkupData.duplicate, '组', () => checkupJump({ kind: 'duplicate', display: '重复标题' })),
2584
+ checkupRowOf('global', '全局条目超 200', checkupData.globalEntries, '条', () => checkupJumpGlobal()),
2585
+ checkupRowOf('globalWhen', '全局缺适用条件', checkupData.globalMissingWhen, '条',
2586
+ () => checkupJumpGlobal({ kind: 'noWhen', display: '全局缺适用条件' })),
2587
+ checkupRowOf('unread', '从未读过', checkupData.unread, '条', () => checkupJump({ kind: 'unread', display: '从未读过' })));
2588
+
1491
2589
  const evolutionTab = evolutionFailed ? evolutionFailed : h('div', { role: 'tabpanel', 'aria-label': '自进化', style: { flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', gap: '9px' } },
1492
2590
  h('div', { style: { ...PANEL_CARD, padding: '9px 11px', display: 'flex', alignItems: 'flex-start', gap: '8px', flex: '0 0 auto' } },
1493
2591
  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 } }),
@@ -1505,14 +2603,20 @@ window.__ModuleLoader__.load({
1505
2603
  h('div', {
1506
2604
  style: {
1507
2605
  flex: 1, minHeight: 0, display: 'grid', gap: '10px',
1508
- gridTemplateColumns: selectedRuleRow ? 'minmax(0, 1fr) minmax(280px, 340px)' : 'minmax(0, 1fr)',
2606
+ gridTemplateColumns: !checkup && selectedRuleRow ? 'minmax(0, 1fr) minmax(280px, 340px)' : 'minmax(0, 1fr)',
1509
2607
  },
1510
2608
  },
1511
- evolutionView === 'graph' ? evolutionGraphPane : evolutionListPane,
1512
- evolutionDetailPane),
2609
+ checkup ? evolutionCheckupPane : (evolutionViewActive === 'graph' ? evolutionGraphPane : evolutionListPane),
2610
+ checkup ? null : evolutionDetailPane),
1513
2611
  );
1514
2612
 
1515
- const content = h('section', { style: { flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, minHeight: 0, padding: '14px', gap: '10px' } },
2613
+ const palaceLoose = activeTab === 'memory' && viewModeOf(display, memoryView) === 'palace' && !selectedMemory;
2614
+ const content = h('section', {
2615
+ style: {
2616
+ flex: palaceLoose ? '0 1 auto' : 1, display: 'flex', flexDirection: 'column', minWidth: 0, minHeight: 0,
2617
+ padding: '12px 18px 14px', gap: '10px', overflowY: palaceLoose ? 'auto' : 'visible',
2618
+ },
2619
+ },
1516
2620
  tabBar,
1517
2621
  activeTab === 'memory' ? memoryTab : evolutionTab,
1518
2622
  );
@@ -1523,7 +2627,10 @@ window.__ModuleLoader__.load({
1523
2627
  },
1524
2628
  h('div', {
1525
2629
  role: 'dialog', 'aria-modal': 'true', 'aria-label': '记忆面板',
1526
- style: { ...PANEL_DIALOG, width: 'min(1120px, 100%)', height: 'min(760px, 100%)', boxSizing: 'border-box' },
2630
+ style: {
2631
+ ...PANEL_DIALOG, width: 'min(1120px, 100%)', boxSizing: 'border-box',
2632
+ height: palaceLoose ? 'auto' : 'min(760px, 100%)', maxHeight: 'min(760px, 100%)',
2633
+ },
1527
2634
  }, ...children));
1528
2635
 
1529
2636
  if (!sessionId) return null;
@@ -1568,12 +2675,24 @@ window.__ModuleLoader__.load({
1568
2675
  update: list.filter((item) => item.exposure === 'update'),
1569
2676
  };
1570
2677
  }, [refs]);
2678
+ const [explainOn, setExplainOn] = React.useState(explainModeOf(sessionId));
2679
+ React.useEffect(() => watchExplain(() => setExplainOn(explainModeOf(sessionId))), [sessionId]);
2680
+ const noTriggerReason = typeof props.noTriggerReason === 'string' && props.noTriggerReason
2681
+ ? props.noTriggerReason
2682
+ : (typeof refs.noTriggerReason === 'string' ? refs.noTriggerReason : '');
1571
2683
  if (!sessionId && refs.length === 0) return null;
1572
- if (groups.found.length === 0 && groups.provided.length === 0 && groups.update.length === 0) return null;
2684
+ if (groups.found.length === 0 && groups.provided.length === 0 && groups.update.length === 0) {
2685
+ return explainOn && noTriggerReason
2686
+ ? h('div', { style: TAIL }, h(RefSection, { label: '搜索命中', tone: 'found', items: EMPTY, sessionId, turnKey: turn, noTriggerReason }))
2687
+ : null;
2688
+ }
2689
+ const section = (label, items, tone) => (items.length === 0
2690
+ ? null
2691
+ : h(RefSection, { key: label, label, tone, items, sessionId, turnKey: turn }));
1573
2692
  return h('div', { style: TAIL },
1574
- h(RefSection, { label: '搜索命中', items: groups.found, sessionId, turnKey: turn }),
1575
- h(RefSection, { label: '读过正文', items: groups.provided, sessionId, turnKey: turn }),
1576
- h(RefSection, { label: '本轮更新', items: groups.update, sessionId, turnKey: turn }),
2693
+ section('搜索命中', groups.found, 'found'),
2694
+ section('读过正文', groups.provided, 'provided'),
2695
+ section('本轮更新', groups.update, 'update'),
1577
2696
  );
1578
2697
  };
1579
2698
 
@@ -1581,7 +2700,7 @@ window.__ModuleLoader__.load({
1581
2700
 
1582
2701
  // 测试缝:Node 里验引用投影,浏览器里没有任何副作用。
1583
2702
  if (typeof globalThis.__LITE_MEMORY_TEST__ === 'function') {
1584
- globalThis.__LITE_MEMORY_TEST__({ metaOf, citationOf, citationsOfMeta, citationsOfTurn, dedupe, undoRefOf, graphOf, ruleGraphOf, MemoryPanel });
2703
+ globalThis.__LITE_MEMORY_TEST__({ metaOf, citationOf, citationsOfMeta, citationsOfTurn, dedupe, undoRefOf, graphOf, ruleGraphOf, processingStatusOf, currentBodyOf, historyDiffsOf, MemoryPanel, MemoryGraph, MemoryPalace, TurnMemory, RefSection, displayKeyOf, readDisplaySettings, writeDisplaySettings, graphViewOf, viewModeOf, filterRecords, projectionOf, failureStateOf, evidenceTextOf, evidenceMetaOf, palaceRoomsOf, palaceRoomPageOf, palacePageWindow, palaceToggleOf, palaceMotionOf, palaceRoomIdOf, refMetaOf, explainKeyOf, explainModeOf, setExplainMode, agoLabel, checkupOf, titleCountsOf });
1585
2704
  }
1586
2705
 
1587
2706
  return {