dsh-compact-relay 0.5.1 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -1129,6 +1129,11 @@ window.__ModuleLoader__.load({
1129
1129
  const previewRef = useRef(null);
1130
1130
  const previewAbortRef = useRef(null);
1131
1131
  const runAbortRef = useRef(null);
1132
+ // 0.6 预览卡收藏区:只在「这张卡里点成功的收藏」记局部状态(seq → 行数据),
1133
+ // 不引入全局星标快照、不改服务端预览数据;失败提示也留在卡内。
1134
+ const [favoriteMarks, setFavoriteMarks] = useState({});
1135
+ const [favoritePendingSeq, setFavoritePendingSeq] = useState(null);
1136
+ const [favoriteError, setFavoriteError] = useState(null);
1132
1137
  // M6:交接单列表(菜单入口,只读)。
1133
1138
  const [docs, setDocs] = useState(null);
1134
1139
  const docsRef = useRef(null);
@@ -1752,7 +1757,7 @@ window.__ModuleLoader__.load({
1752
1757
  nativeRatio === null
1753
1758
  ? 'DSH 原生自动压缩已关闭'
1754
1759
  : `DSH 原生自动压缩线在 ${(nativeRatio * 100).toFixed(1)}%,到那之前先压缩或交接更稳`;
1755
- return `这是上下文指示灯:点它压缩旧会话、锁定重要消息、交接新会话。${nativePart}。环上的小缺口是我判断「该压缩」的位置(模型判断或到线自动出现)。重要的结论/路径先锁定,压缩时才不会被压掉(原生压缩不认锁定,真被压走会标「已失效」)。交接后会默认自动归档旧会话(侧栏收起,可取消勾选「交接后归档」;旧会话在侧栏置顶时会先问你,因为归档会取消置顶)。`;
1760
+ return `这是上下文指示灯:点它压缩旧会话、收藏重要回复、交接新会话。${nativePart}。环上的小缺口是我判断「该压缩」的位置(模型判断或到线自动出现)。重要的结论/路径先点星收藏,压缩时会把原文写进摘要目录和快照(DSH 原生自动压缩不认收藏,被它压走的消息建议交接前留档)。交接后会默认自动归档旧会话(侧栏收起,可取消勾选「交接后归档」;旧会话在侧栏置顶时会先问你,因为归档会取消置顶)。`;
1756
1761
  }, [state.native]);
1757
1762
 
1758
1763
  /** 播放一次引导:呼吸光晕 + 提示。手动重播用更长时间(用户要关掉设置页才看得到)。 */
@@ -1803,19 +1808,6 @@ window.__ModuleLoader__.load({
1803
1808
  return () => window.removeEventListener('compact-relay:replay-onboarding', onReplay);
1804
1809
  }, [playOnboarding]);
1805
1810
 
1806
- // 0.5.1 批 2:原生压缩即将覆盖锁定消息 —— 事前主动提示一次(同一风险签名只弹一次)。
1807
- const pinRiskToastRef = useRef('');
1808
- useEffect(() => {
1809
- if (pinRisk === null) return;
1810
- const signature = `${state.sessionId ?? ''}:${pinRisk.seqs.join(',')}`;
1811
- if (signature === pinRiskToastRef.current) return;
1812
- pinRiskToastRef.current = signature;
1813
- showToast(
1814
- `⚠ 原生压缩预计覆盖你锁定的 ${pinRisk.count} 条消息(通常在本轮回复收尾时触发)。建议先交接(/handoff)保留原文,或解锁让压缩带走。`,
1815
- 12000,
1816
- );
1817
- }, [pinRisk, state.sessionId, showToast]);
1818
-
1819
1811
  // 0.4.2(P1):换模型后首次发现「交接线已晚」弹一次提示(同路由不重复打扰)。
1820
1812
  // 只在「观察到路由变化」(localStorage 记着上一次的 provider/model)时弹;
1821
1813
  // 首次见到某路由不弹(不是换模型导致的),设置页有常驻提示兜底。
@@ -1878,6 +1870,9 @@ window.__ModuleLoader__.load({
1878
1870
  setOpen(false);
1879
1871
  setHandoffPreview(null);
1880
1872
  setPreview({ loading: true });
1873
+ setFavoriteMarks({});
1874
+ setFavoritePendingSeq(null);
1875
+ setFavoriteError(null);
1881
1876
  const controller = typeof AbortController === 'function' ? new AbortController() : null;
1882
1877
  previewAbortRef.current = controller;
1883
1878
  try {
@@ -2173,6 +2168,52 @@ window.__ModuleLoader__.load({
2173
2168
  [sessionId, waitAndOpen, preview, startPreview],
2174
2169
  );
2175
2170
 
2171
+ // 0.6 预览卡「收藏」按钮:只有用户点击才发请求(绝不自动收藏)。
2172
+ // 走既有 action:'favorite-mark'(按 sessionId+seq,含用户发言、不需要活 agent);
2173
+ // 显式 active:true = 只加不切换——即使预览数据过期/重复点,也不会误删已有收藏。
2174
+ // 成功只在本卡做局部标记(该行变「已收藏 ✓」、计数本地更新),不重拉预览、不碰压缩计划。
2175
+ const markFavoriteFromPreview = useCallback(
2176
+ async (item) => {
2177
+ const seq = item !== null && typeof item === 'object' && Number.isInteger(item.seq) ? item.seq : null;
2178
+ if (seq === null || favoritePendingSeq !== null) return;
2179
+ if (typeof sessionId !== 'string' || sessionId.length === 0) {
2180
+ setFavoriteError('拿不到会话 id,先等会话就绪再收藏');
2181
+ return;
2182
+ }
2183
+ setFavoritePendingSeq(seq);
2184
+ setFavoriteError(null);
2185
+ try {
2186
+ const res = await fetch('/api/compact-relay-action', {
2187
+ method: 'POST',
2188
+ headers: { 'content-type': 'application/json' },
2189
+ body: JSON.stringify({ action: 'favorite-mark', sessionId, seq, active: true }),
2190
+ });
2191
+ const data = await res.json().catch(() => ({}));
2192
+ if (data !== null && typeof data === 'object' && data.ok !== false && data.active === true) {
2193
+ setFavoriteMarks((prev) => ({
2194
+ ...prev,
2195
+ [seq]: {
2196
+ seq,
2197
+ role: typeof item.role === 'string' ? item.role : '',
2198
+ title: typeof item.title === 'string' && item.title.length > 0 ? item.title : typeof item.snippet === 'string' ? item.snippet : '',
2199
+ },
2200
+ }));
2201
+ return;
2202
+ }
2203
+ setFavoriteError(
2204
+ data !== null && typeof data === 'object' && typeof data.message === 'string' && data.message.length > 0
2205
+ ? data.message
2206
+ : '收藏失败,请重试',
2207
+ );
2208
+ } catch (error) {
2209
+ setFavoriteError('收藏失败:网络或宿主不可用');
2210
+ } finally {
2211
+ setFavoritePendingSeq(null);
2212
+ }
2213
+ },
2214
+ [sessionId, favoritePendingSeq],
2215
+ );
2216
+
2176
2217
  const adviceText = adviseHandoff ? '建议交接' : adviseCompact ? '建议压缩' : '正常';
2177
2218
  // 0.3.1:模型判断的断点位置(「这里适合压缩」);DSH 原生自动压缩线(报警最高阈值)。
2178
2219
  const compressHintText =
@@ -2202,9 +2243,6 @@ window.__ModuleLoader__.load({
2202
2243
  (compressHintText !== '' ? `|${compressHintText}` : '') +
2203
2244
  (nativeLineText !== '' ? `|${nativeLineText}` : '') +
2204
2245
  (handoffWarnText !== '' ? `|${handoffWarnText}` : '') +
2205
- (pins.count > 0 ? `|已锁定 ${pins.count} 条` : '') +
2206
- (pinStaleCount > 0 ? `|${staleLabel}` : '') +
2207
- (pinRisk !== null ? `|⚠ ${pinRiskLabel}(建议先交接或解锁)` : '') +
2208
2246
  (effectiveBusy ? '|处理中…' : '');
2209
2247
 
2210
2248
  // 浮层定位:fixed(不受 composer 裁剪)+ 视口左右收边 + 上方空间不够时翻到下方。
@@ -2261,20 +2299,8 @@ window.__ModuleLoader__.load({
2261
2299
  h(
2262
2300
  'div',
2263
2301
  { style: { color: theme.subText } },
2264
- `已压缩 ${state.summaries} 次 · ${adviceText}${compressHintText !== '' ? ` · ${compressHintText}` : ''}${nativeLineText !== '' ? ` · ${nativeLineText}` : ''}${pins.count > 0 ? ` · 已锁定 ${pins.count} 条` : ''}${effectiveBusy ? ' · 处理中…' : ''}`,
2302
+ `已压缩 ${state.summaries} 次 · ${adviceText}${compressHintText !== '' ? ` · ${compressHintText}` : ''}${nativeLineText !== '' ? ` · ${nativeLineText}` : ''}${effectiveBusy ? ' · 处理中…' : ''}`,
2265
2303
  ),
2266
- // 0.5.1 批 2:失效 pin 明确标出来(不假装还在、不消失)。
2267
- pinStaleCount > 0
2268
- ? h('div', { 'data-cr-pin-stale': '1', style: { marginTop: 2, color: theme.subText } }, staleLabel)
2269
- : null,
2270
- // 0.5.1 批 2:下一次原生自动压缩会覆盖锁定消息 → 事前提示(先交接/保留或解锁)。
2271
- pinRisk !== null
2272
- ? h(
2273
- 'div',
2274
- { 'data-cr-pin-risk': '1', style: { marginTop: 2, color: palette.handoff } },
2275
- `⚠ ${pinRiskLabel}:下一次原生压缩(通常在本轮回复收尾时)会压掉它们。建议先交接(/handoff)保留,或解锁让压缩带走。`,
2276
- )
2277
- : null,
2278
2304
  // 0.4.2:交接线晚于原生压缩时,悬停卡里给一行显式警示(只告警,不改设置)。
2279
2305
  handoffWarnText !== ''
2280
2306
  ? h('div', { 'data-cr-handoff-warn-text': '1', style: { marginTop: 2, color: palette.handoff } }, handoffWarnText)
@@ -2435,15 +2461,8 @@ window.__ModuleLoader__.load({
2435
2461
  };
2436
2462
 
2437
2463
  // 0.5.1 批 2:交接菜单项的提示(原生压缩预判风险优先于「交接线已晚」,两条都有就并排)。
2438
- const handoffItemNote =
2439
- pinRisk !== null
2440
- ? `⚠ ${pinRiskLabel}:建议先交接保留`
2441
- : handoffLate
2442
- ? handoffWarnText
2443
- : '';
2444
- const handoffItemTitle =
2445
- [pinRisk !== null ? `原生压缩会在下一次触发时(通常本轮回复收尾)压掉你锁定的 ${pinRisk.count} 条消息。先交接可保留原文;也可以解锁让压缩带走。` : '',
2446
- handoffLate ? handoffGuardInfo.message : ''].filter((text) => text !== '').join('\n') || undefined;
2464
+ const handoffItemNote = handoffLate ? handoffWarnText : '';
2465
+ const handoffItemTitle = handoffLate ? handoffGuardInfo.message : undefined;
2447
2466
  // undefined=宿主没上报(老版本/未知);null=明确没有可压范围;object=可压多少。
2448
2467
  const rangeInfo = state.compactRange;
2449
2468
  // 0.3.5:有可压范围、但会话还没有活 agent(passivized live session)——preview 需要
@@ -2488,10 +2507,7 @@ window.__ModuleLoader__.load({
2488
2507
  ...(rangeInfo === null
2489
2508
  ? {
2490
2509
  disabled: true,
2491
- note:
2492
- pins.count > 0
2493
- ? `暂无可压内容:有 ${pins.count} 条消息被锁定保护(先点「解锁消息」)`
2494
- : '暂无可压缩内容(会话太短,或近期会话需保留原文)',
2510
+ note: '暂无可压缩内容(会话太短,或近期会话需保留原文)',
2495
2511
  }
2496
2512
  : passiveRangeNote !== null
2497
2513
  ? { disabled: true, title: passiveRangeNote, note: passiveRangeNote }
@@ -2503,35 +2519,6 @@ window.__ModuleLoader__.load({
2503
2519
  },
2504
2520
  },
2505
2521
  ),
2506
- item(
2507
- pins.count > 0
2508
- ? `解锁消息(${pins.count} 条)`
2509
- : pinStaleCount > 0
2510
- ? `解除失效锁定(${pinStaleCount} 条)`
2511
- : '锁定重要消息',
2512
- pins.count > 0
2513
- ? '解除锁定,允许后续压缩合并这些消息'
2514
- : pinStaleCount > 0
2515
- ? `${staleLabel}:原文已不在当前上下文,点此清空锁定记录`
2516
- : '锁定的消息压缩时不会碰(重要结论/路径先锁住,也可 /pin list 挑一条)',
2517
- 'pin',
2518
- null,
2519
- hasSession
2520
- ? {
2521
- oneLine: true,
2522
- right:
2523
- pins.count > 0
2524
- ? `已锁 ${pins.count} 条${pinStaleCount > 0 ? ` · 失效 ${pinStaleCount}` : ''}`
2525
- : pinStaleCount > 0
2526
- ? `失效 ${pinStaleCount} 条`
2527
- : '未锁定',
2528
- ...(pinRisk !== null ? { note: `⚠ ${pinRiskLabel}(建议先交接)`, title: pinRiskLabel } : {}),
2529
- onClick: () => {
2530
- void togglePins();
2531
- },
2532
- }
2533
- : { oneLine: true, disabled: true, right: '未就绪', note: '会话还没就绪' },
2534
- ),
2535
2522
  item(
2536
2523
  '交接新会话',
2537
2524
  '生成交接单并开新会话接着聊,旧会话保留(/handoff)',
@@ -2575,12 +2562,42 @@ window.__ModuleLoader__.load({
2575
2562
  const previewRange = preview?.data?.preview ?? null;
2576
2563
  // 0.3.1:范围太小时预览阶段就拦下(服务端已按 800 tokens 判定),不让用户白确认一次。
2577
2564
  const previewTooSmall = previewRange !== null && previewRange.tooSmall === true;
2578
- // 0.4 P1-1/P1-2:建议锁定候选 + pin 命中数(宿主已算好,客户端只渲染;不自动锁)。
2579
- const pinSuggest =
2580
- previewRange !== null && previewRange.pinSuggest !== null && typeof previewRange.pinSuggest === 'object' && Array.isArray(previewRange.pinSuggest.items)
2581
- ? previewRange.pinSuggest
2565
+ // 0.6 预览卡收藏区:服务端 additive 字段 favoritePreview(老宿主没有 → 整块不渲染)。
2566
+ // 只读渲染;「建议补」= 本段未收藏的候选(服务端已用 pinSuggest 同一套识别过滤过)。
2567
+ const favoritePreview =
2568
+ previewRange !== null && previewRange.favoritePreview !== null && typeof previewRange.favoritePreview === 'object'
2569
+ ? previewRange.favoritePreview
2582
2570
  : null;
2583
- const pinHits = previewRange !== null && previewRange.pinHits !== null && typeof previewRange.pinHits === 'object' ? previewRange.pinHits : null;
2571
+ const favoriteRowOf = (item) =>
2572
+ item !== null && typeof item === 'object' && Number.isInteger(item.seq)
2573
+ ? {
2574
+ seq: item.seq,
2575
+ role: typeof item.role === 'string' ? item.role : '',
2576
+ title: typeof item.title === 'string' ? item.title : '',
2577
+ reason: typeof item.reason === 'string' ? item.reason : '',
2578
+ snippet: typeof item.snippet === 'string' ? item.snippet : '',
2579
+ }
2580
+ : null;
2581
+ const favoriteBaseItems =
2582
+ favoritePreview !== null && Array.isArray(favoritePreview.favorited)
2583
+ ? favoritePreview.favorited.map(favoriteRowOf).filter((item) => item !== null)
2584
+ : [];
2585
+ const favoriteSuggestList =
2586
+ favoritePreview !== null && Array.isArray(favoritePreview.suggest)
2587
+ ? favoritePreview.suggest.map(favoriteRowOf).filter((item) => item !== null)
2588
+ : [];
2589
+ const favoriteRangeTotal =
2590
+ favoritePreview !== null && Number.isInteger(favoritePreview.total) ? favoritePreview.total : (previewRange?.nodes ?? 0);
2591
+ // 卡内点成功的收藏:补进「本段已收藏」列表,并从「建议补」计数里扣掉(本地即时,不重拉预览)。
2592
+ const favoriteExtraItems = favoriteSuggestList
2593
+ .filter((item) => favoriteMarks[item.seq] !== undefined)
2594
+ .map((item) => ({ seq: item.seq, role: item.role, title: item.snippet }));
2595
+ const favoriteDisplayItems = [...favoriteBaseItems, ...favoriteExtraItems];
2596
+ const favoriteFavoritedCount =
2597
+ (favoritePreview !== null && Number.isInteger(favoritePreview.favoritedCount)
2598
+ ? favoritePreview.favoritedCount
2599
+ : favoriteBaseItems.length) + favoriteExtraItems.length;
2600
+ const favoriteSuggestRemaining = favoriteSuggestList.length - favoriteExtraItems.length;
2584
2601
  const confirmPanel =
2585
2602
  preview === null
2586
2603
  ? null
@@ -2630,43 +2647,139 @@ window.__ModuleLoader__.load({
2630
2647
  h('br'),
2631
2648
  '只估算范围和容量;摘要内容确认后才生成,不在这里展示。压缩后旧会话将替换为摘要,无法还原为原文。',
2632
2649
  ),
2633
- pinSuggest !== null && pinSuggest.items.length > 0
2650
+ favoritePreview !== null
2634
2651
  ? h(
2635
2652
  'div',
2636
2653
  {
2637
- 'data-cr-pin-suggest': '1',
2654
+ 'data-cr-favorite-preview': '1',
2638
2655
  style: {
2639
2656
  display: 'flex',
2640
2657
  flexDirection: 'column',
2641
- gap: 2,
2658
+ gap: 4,
2642
2659
  padding: '6px 8px',
2643
2660
  borderRadius: 8,
2644
2661
  border: `1px solid ${theme.panelBorder}`,
2645
2662
  background: theme.hover,
2646
2663
  },
2647
2664
  },
2648
- h('div', { style: { fontSize: 11, fontWeight: 700, color: theme.panelFg } }, `建议锁定 ${pinSuggest.items.length} 条(先 /pin 再压)`),
2649
- ...pinSuggest.items.map((item) =>
2650
- h(
2651
- 'div',
2652
- { key: `pin-suggest-${item.seq}`, style: { fontSize: 11, lineHeight: '16px', color: theme.subText } },
2653
- `${item.role} · ${item.reason}:${item.snippet}`,
2654
- ),
2655
- ),
2656
2665
  h(
2657
2666
  'div',
2658
- { style: { fontSize: 10, lineHeight: '14px', color: theme.subText } },
2659
- '只建议、不自动锁定;锁定后这些消息会原样保留(/pin list 可按序号挑)。',
2667
+ {
2668
+ 'data-cr-favorite-summary': '1',
2669
+ style: { fontSize: 11, fontWeight: 700, color: theme.panelFg },
2670
+ },
2671
+ `本段 ${favoriteRangeTotal} 条 · 已收藏 ${favoriteFavoritedCount} 条 · 建议补 ${favoriteSuggestRemaining} 条`,
2660
2672
  ),
2661
- )
2662
- : null,
2663
- pinHits !== null && pinHits.total > 0
2664
- ? h(
2665
- 'div',
2666
- { 'data-cr-pin-hits': '1', style: { fontSize: 11, lineHeight: '16px', color: theme.subText } },
2667
- pinHits.inRange > 0
2668
- ? `pin 命中 ${pinHits.inRange} 条:本次范围里被锁定的消息已跳过(会话共 ${pinHits.total} 条锁定)。`
2669
- : `pin 命中 0 条:本次范围内没有锁定消息(会话共 ${pinHits.total} 条锁定,均在范围外)。`,
2673
+ favoriteDisplayItems.length > 0
2674
+ ? h(
2675
+ 'div',
2676
+ { 'data-cr-favorite-list': '1', style: { display: 'flex', flexDirection: 'column', gap: 2 } },
2677
+ h('div', { style: { fontSize: 10, fontWeight: 700, color: theme.subText } }, '本段已收藏'),
2678
+ ...favoriteDisplayItems.map((item) =>
2679
+ h(
2680
+ 'div',
2681
+ {
2682
+ key: `favorite-item-${item.seq}`,
2683
+ 'data-cr-favorite-item': String(item.seq),
2684
+ style: {
2685
+ fontSize: 11,
2686
+ lineHeight: '16px',
2687
+ color: theme.subText,
2688
+ whiteSpace: 'normal',
2689
+ wordBreak: 'break-word',
2690
+ },
2691
+ },
2692
+ `[${item.role}] ${item.title}`,
2693
+ ),
2694
+ ),
2695
+ favoriteFavoritedCount > favoriteDisplayItems.length
2696
+ ? h(
2697
+ 'div',
2698
+ { style: { fontSize: 10, lineHeight: '14px', color: theme.subText } },
2699
+ `…另有 ${favoriteFavoritedCount - favoriteDisplayItems.length} 条,用 /favorite list 查看`,
2700
+ )
2701
+ : null,
2702
+ )
2703
+ : null,
2704
+ favoriteSuggestList.length > 0
2705
+ ? h(
2706
+ 'div',
2707
+ { 'data-cr-favorite-suggest': '1', style: { display: 'flex', flexDirection: 'column', gap: 3 } },
2708
+ h('div', { style: { fontSize: 10, fontWeight: 700, color: theme.subText } }, '建议先收藏(本段未收藏)'),
2709
+ ...favoriteSuggestList.map((item) => {
2710
+ const done = favoriteMarks[item.seq] !== undefined;
2711
+ const pending = favoritePendingSeq === item.seq;
2712
+ return h(
2713
+ 'div',
2714
+ {
2715
+ key: `favorite-suggest-${item.seq}`,
2716
+ 'data-cr-favorite-suggest-row': String(item.seq),
2717
+ style: { display: 'flex', alignItems: 'flex-start', gap: 6 },
2718
+ },
2719
+ h(
2720
+ 'span',
2721
+ {
2722
+ style: {
2723
+ flex: 1,
2724
+ minWidth: 0,
2725
+ fontSize: 11,
2726
+ lineHeight: '16px',
2727
+ color: theme.subText,
2728
+ whiteSpace: 'normal',
2729
+ wordBreak: 'break-word',
2730
+ },
2731
+ },
2732
+ `[${item.role}] ${item.reason}:${item.snippet}`,
2733
+ ),
2734
+ done
2735
+ ? h(
2736
+ 'span',
2737
+ {
2738
+ 'data-cr-favorite-done': String(item.seq),
2739
+ style: { flex: 'none', fontSize: 10, fontWeight: 600, lineHeight: '16px', color: '#2fb344' },
2740
+ },
2741
+ '已收藏 ✓',
2742
+ )
2743
+ : h(
2744
+ 'button',
2745
+ {
2746
+ type: 'button',
2747
+ 'data-cr-favorite-mark': String(item.seq),
2748
+ disabled: favoritePendingSeq !== null,
2749
+ onClick: () => {
2750
+ void markFavoriteFromPreview(item);
2751
+ },
2752
+ style: {
2753
+ flex: 'none',
2754
+ border: `1px solid ${theme.panelBorder}`,
2755
+ borderRadius: 6,
2756
+ padding: '1px 8px',
2757
+ fontSize: 10,
2758
+ lineHeight: '16px',
2759
+ cursor: favoritePendingSeq === null ? 'pointer' : 'default',
2760
+ background: 'transparent',
2761
+ color: theme.panelFg,
2762
+ opacity: favoritePendingSeq === null ? 1 : 0.5,
2763
+ },
2764
+ },
2765
+ pending ? '收藏中…' : '收藏',
2766
+ ),
2767
+ );
2768
+ }),
2769
+ h(
2770
+ 'div',
2771
+ { 'data-cr-favorite-note': '1', style: { fontSize: 10, lineHeight: '14px', color: theme.subText } },
2772
+ '只提示、不自动收藏;点「收藏」只写一条收藏标记,不改压缩范围。',
2773
+ ),
2774
+ )
2775
+ : null,
2776
+ favoriteError !== null
2777
+ ? h(
2778
+ 'div',
2779
+ { 'data-cr-favorite-error': '1', style: { fontSize: 10, lineHeight: '14px', color: COLORS.error } },
2780
+ favoriteError,
2781
+ )
2782
+ : null,
2670
2783
  )
2671
2784
  : null,
2672
2785
  h(
@@ -2790,7 +2903,10 @@ window.__ModuleLoader__.load({
2790
2903
  ? h('div', { style: { fontSize: 11, color: theme.subText } }, '正在生成九段草稿并做就绪检查…(只预览,不写盘、不建会话)')
2791
2904
  : h(
2792
2905
  'div',
2793
- { style: { display: 'flex', flexDirection: 'column', gap: 8, minHeight: 0 } },
2906
+ {
2907
+ 'data-cr-handoff-body': '1',
2908
+ style: { display: 'flex', flexDirection: 'column', gap: 8, minHeight: 0, flexShrink: 0 },
2909
+ },
2794
2910
  h(
2795
2911
  'div',
2796
2912
  {
@@ -2810,11 +2926,13 @@ window.__ModuleLoader__.load({
2810
2926
  h(
2811
2927
  'div',
2812
2928
  {
2929
+ 'data-cr-handoff-draft': '1',
2813
2930
  style: {
2814
2931
  fontSize: 11,
2815
2932
  lineHeight: '16px',
2816
2933
  overflow: 'auto',
2817
2934
  maxHeight: 220,
2935
+ flexShrink: 0,
2818
2936
  border: `1px solid ${theme.panelBorder}`,
2819
2937
  borderRadius: 8,
2820
2938
  padding: '6px 8px',
@@ -3435,6 +3553,34 @@ window.__ModuleLoader__.load({
3435
3553
  return typeof data.parentArchived === 'boolean' ? data.parentArchived : null;
3436
3554
  }
3437
3555
 
3556
+ /**
3557
+ * 0.5.2 L 项:宿主有没有强制归档的接缝(`readState.capabilities.forceArchive`)。
3558
+ *
3559
+ * 三重取值:true / false / null(老插件没有这个字段 → 不知道,仍显示入口;
3560
+ * 调用失败会给可读错误,不静默)。false = 宿主没有 `archiveSession` → 隐藏死入口。
3561
+ * @param {object|null|undefined} data `/api/compact-relay-state` 的响应
3562
+ * @returns {boolean|null}
3563
+ */
3564
+ function relayBarForceArchiveCapabilityOf(data) {
3565
+ if (data === null || typeof data !== 'object' || data.ok === false) return null;
3566
+ const capabilities = data.capabilities;
3567
+ if (capabilities === null || typeof capabilities !== 'object') return null;
3568
+ return typeof capabilities.forceArchive === 'boolean' ? capabilities.forceArchive : null;
3569
+ }
3570
+
3571
+ /**
3572
+ * 0.5.2 L 项:链上旧会话(入站 parentId)是否在侧栏置顶(`readState.parentPinned`)。
3573
+ *
3574
+ * 置顶与归档互斥:宿主 `archiveSession` 在同一笔写盘里把该 id 从置顶集合移除。
3575
+ * 强制归档的二次确认要在置顶时明确写「会同时取消置顶」,所以这里只读判断。
3576
+ * @param {object|null|undefined} data `/api/compact-relay-state` 的响应
3577
+ * @returns {boolean|null}
3578
+ */
3579
+ function relayBarParentPinnedOf(data) {
3580
+ if (data === null || typeof data !== 'object' || data.ok === false) return null;
3581
+ return typeof data.parentPinned === 'boolean' ? data.parentPinned : null;
3582
+ }
3583
+
3438
3584
  /**
3439
3585
  * 接力条摘要:新链「历史 N:原名」,旧链「交接第 N 次:原名」。
3440
3586
  * @param {object} chain relayBarChainOf 的结果
@@ -3620,6 +3766,15 @@ window.__ModuleLoader__.load({
3620
3766
  // 0.5.1 DCR-03:服务端能不能恢复归档会话(readState.capabilities.unarchive)。
3621
3767
  // null = 老插件没有这个字段(不知道 → 仍显示入口,恢复调用幂等)。
3622
3768
  const [serverUnarchive, setServerUnarchive] = useState(null);
3769
+ // 0.5.2 L 项:服务端能不能强制归档(readState.capabilities.forceArchive)。
3770
+ // null = 老插件没有这个字段(不知道 → 仍显示入口,失败会给可读错误)。
3771
+ const [serverForceArchive, setServerForceArchive] = useState(null);
3772
+ // 0.5.2 L 项:旧会话(入站 parentId)是否在侧栏置顶(readState.parentPinned)。
3773
+ // 置顶时二次确认要提前说明「归档会同时取消置顶」。
3774
+ const [parentPinned, setParentPinned] = useState(null);
3775
+ // 0.5.2 L 项:强制归档面板 —— null = 关;否则
3776
+ // {phase:'confirm'|'busy'|'error'|'done', status?, note?, message?}。
3777
+ const [forceView, setForceView] = useState(null);
3623
3778
  // 0.5.1 DCR-03:打开菜单时 +1,触发一次静默刷新(别处归档/取消归档能立刻反映)。
3624
3779
  const [refreshKey, setRefreshKey] = useState(0);
3625
3780
  // 0.5.1 批 3:完整交接链(null = 没展开;点「完整交接链」后才逐级去读)。
@@ -3666,6 +3821,7 @@ window.__ModuleLoader__.load({
3666
3821
  setOpen(false);
3667
3822
  setDoc(null);
3668
3823
  setNotice(null);
3824
+ setForceView(null);
3669
3825
  }, [abortDoc, closeChainView]);
3670
3826
 
3671
3827
  // 从交接单详情回到菜单列表:取消读取,避免「返回后又被晚到的响应撑开」。
@@ -3689,6 +3845,9 @@ window.__ModuleLoader__.load({
3689
3845
  setParentArchived(null);
3690
3846
  setNextArchived(null);
3691
3847
  setServerUnarchive(null);
3848
+ setServerForceArchive(null);
3849
+ setParentPinned(null);
3850
+ setForceView(null);
3692
3851
  setOpen(false);
3693
3852
  setDoc(null);
3694
3853
  setNotice(null);
@@ -3758,6 +3917,9 @@ window.__ModuleLoader__.load({
3758
3917
  setParentArchived(relayBarParentArchivedOf(data));
3759
3918
  setNextArchived(relayBarNextArchivedOf(data));
3760
3919
  setServerUnarchive(relayBarUnarchiveCapabilityOf(data));
3920
+ // 0.5.2 L 项:强制归档的能力位与旧会话置顶状态(二次确认文案用)。
3921
+ setServerForceArchive(relayBarForceArchiveCapabilityOf(data));
3922
+ setParentPinned(relayBarParentPinnedOf(data));
3761
3923
  // F3 的子会话记录不带 path(交接单路径只写在父记录里):向父会话读回
3762
3924
  // 同一条链,childId 指回本会话且链号一致时才采用它的 path。只读、失败
3763
3925
  // 就保持「暂无交接单」;不碰宿主逻辑,也不改交接流程。
@@ -3775,6 +3937,8 @@ window.__ModuleLoader__.load({
3775
3937
  setParentArchived(null);
3776
3938
  setNextArchived(null);
3777
3939
  setServerUnarchive(null);
3940
+ setServerForceArchive(null);
3941
+ setParentPinned(null);
3778
3942
  }
3779
3943
  } else schedule();
3780
3944
  } catch {
@@ -3864,6 +4028,8 @@ window.__ModuleLoader__.load({
3864
4028
  abortDoc();
3865
4029
  setNotice(null);
3866
4030
  setDoc(null);
4031
+ // 0.5.2 L 项:重开菜单一律回到主列表(强制归档确认面板不残留、不误触)。
4032
+ setForceView(null);
3867
4033
  // 每次开/关都把完整链收掉:重开菜单从主列表开始(不预加载,也不留旧会话的链)。
3868
4034
  closeChainView();
3869
4035
  // 0.5.1 DCR-03:打开菜单前刷新一次 state —— 归档集合可能在别处被改(侧栏
@@ -4373,6 +4539,96 @@ window.__ModuleLoader__.load({
4373
4539
  // 两个方向都没有 → 什么都不渲染(不占 composerStack 的高度)。
4374
4540
  if (chain === null && next === null) return null;
4375
4541
 
4542
+ // ------------------------------------------------------------------
4543
+ // 0.5.2 L 项:强制归档旧会话(显式二次确认;唯一会 stopActivity 的用户入口)
4544
+ // ------------------------------------------------------------------
4545
+
4546
+ /**
4547
+ * 打开强制归档的二次确认面板(只改本地视图,不发请求)。
4548
+ *
4549
+ * 默认行为不变:自动归档遇到活跃会话仍然「保留可见不归档」;只有用户在这个
4550
+ * 确认面板里再点一次「强制归档(停止任务)」才会调用服务端。
4551
+ * @param {string} target 旧会话 id(入站 parentId)
4552
+ */
4553
+ const openForceConfirm = useCallback(
4554
+ (target) => {
4555
+ if (typeof target !== 'string' || target.length === 0) return;
4556
+ abortDoc();
4557
+ setNotice(null);
4558
+ setDoc(null);
4559
+ closeChainView();
4560
+ setForceView({ phase: 'confirm', target });
4561
+ },
4562
+ [abortDoc, closeChainView],
4563
+ );
4564
+
4565
+ /**
4566
+ * 执行强制归档:POST `action:'force-archive'`(服务端 → registry.archiveSession(id,{stopActivity:true}))。
4567
+ *
4568
+ * 只有用户点过确认才会走到这里。成功时复用 `handoffArchiveNoteOf` 的现有三套文案
4569
+ * (archived / already / pinned,含「归档会取消置顶」的置顶提示);失败时可读错误
4570
+ * 写进面板,不静默。`pinned` 不是失败:它表示宿主里旧会话其实还置顶、需要用户
4571
+ * 再确认一次「归档并取消置顶」,面板会给出这个出口。
4572
+ *
4573
+ * @param {string} target 旧会话 id(入站 parentId)
4574
+ * @param {{confirmUnpin?:boolean}} [options] true = 用户已确认「归档会取消置顶」
4575
+ */
4576
+ const runForceArchive = useCallback((target, options = {}) => {
4577
+ const confirmUnpin = options.confirmUnpin === true;
4578
+ if (typeof target !== 'string' || target.length === 0) return Promise.resolve();
4579
+ setNotice(null);
4580
+ setForceView({ phase: 'busy', target, confirmUnpin });
4581
+ return (async () => {
4582
+ try {
4583
+ const res = await fetch('/api/compact-relay-action', {
4584
+ method: 'POST',
4585
+ headers: { 'content-type': 'application/json' },
4586
+ cache: 'no-store',
4587
+ body: JSON.stringify({
4588
+ action: 'force-archive',
4589
+ sessionId: target,
4590
+ // 二次确认的凭证:服务端没有 confirmStop:true 一律拒绝(不会静默停任务)。
4591
+ confirmStop: true,
4592
+ ...(confirmUnpin ? { confirmUnpin: true } : {}),
4593
+ }),
4594
+ });
4595
+ const data = await res.json().catch(() => null);
4596
+ if (!res.ok || data === null || typeof data !== 'object' || data.ok === false) {
4597
+ const message =
4598
+ data !== null && typeof data === 'object' && typeof data.message === 'string' && data.message.length > 0
4599
+ ? data.message
4600
+ : '强制归档接口不可用';
4601
+ throw new Error(message);
4602
+ }
4603
+ // archived / already 都意味着旧会话已在归档集合里(already 幂等,没有重复停任务)。
4604
+ if (data.status === 'archived' || data.status === 'already') setParentArchived(true);
4605
+ setForceView({
4606
+ phase: 'done',
4607
+ target,
4608
+ status: typeof data.status === 'string' ? data.status : 'archived',
4609
+ note:
4610
+ typeof data.note === 'string' && data.note.length > 0
4611
+ ? data.note
4612
+ : '旧会话已归档(可在新会话输入框上方「已接力自 …」→「恢复旧会话」找回)。',
4613
+ });
4614
+ } catch (error) {
4615
+ setForceView({
4616
+ phase: 'error',
4617
+ target,
4618
+ confirmUnpin,
4619
+ message: '强制归档失败:' + (error && error.message ? error.message : '未知错误'),
4620
+ });
4621
+ }
4622
+ })();
4623
+ }, []);
4624
+
4625
+ /** 从确认/错误/结果面板回到主菜单列表(不关整个菜单,用户可继续用其他入口)。 */
4626
+ const backFromForce = useCallback(() => {
4627
+ setNotice(null);
4628
+ setForceView(null);
4629
+ }, []);
4630
+
4631
+
4376
4632
  const hasParent = chain !== null && chain.parentId !== null;
4377
4633
  const hasNext = next !== null && next.childId !== null;
4378
4634
  // 父会话侧(只有出站记录)这一行的摘要文案:它没有「已接力自」的来路。
@@ -4384,11 +4640,17 @@ window.__ModuleLoader__.load({
4384
4640
  // 都显示——恢复调用幂等,宁可多一个入口,也不让用户的旧会话找不回来。
4385
4641
  // 0.5.1 DCR-03:宿主客户端与服务端都没有恢复能力时**隐藏**入口(点了必然失败)。
4386
4642
  const showRestore = hasParent && parentArchived !== false && canRestore;
4643
+ // 0.5.2 L 项:强制归档入口 —— 旧会话**还没归档**时才需要它(已归档没什么可强制的);
4644
+ // 服务端明确说没有 archiveSession 能力(false)时隐藏死入口;null(老插件没字段)
4645
+ // 仍显示,失败时会给出可读错误。
4646
+ const canForceArchive = serverForceArchive !== false;
4647
+ const showForceArchive = hasParent && parentArchived !== true && canForceArchive;
4387
4648
  const vw = typeof window === 'undefined' ? 1280 : window.innerWidth;
4388
4649
  const vh = typeof window === 'undefined' ? 900 : window.innerHeight;
4389
4650
  const chainOpen = chainView !== null && doc === null;
4390
- const menuWidth = Math.min(doc !== null ? 420 : chainOpen ? 336 : 272, Math.max(240, vw - 16));
4391
- const estimatedHeight = doc !== null ? 360 : chainOpen ? 380 : 168;
4651
+ const forceOpen = forceView !== null && doc === null && chainView === null;
4652
+ const menuWidth = Math.min(doc !== null ? 420 : chainOpen ? 336 : forceOpen ? 320 : 272, Math.max(240, vw - 16));
4653
+ const estimatedHeight = doc !== null ? 360 : chainOpen ? 380 : forceOpen ? 300 : 168;
4392
4654
  const anchorTop = anchor === null ? vh - 96 : anchor.top;
4393
4655
  const anchorBottom = anchor === null ? vh - 40 : anchor.bottom;
4394
4656
  const openUp = anchorTop >= Math.min(estimatedHeight, vh - 16) + 12;
@@ -4635,6 +4897,219 @@ window.__ModuleLoader__.load({
4635
4897
  ),
4636
4898
  );
4637
4899
 
4900
+ /**
4901
+ * 0.5.2 L 项:强制归档的二次确认 / 执行中 / 失败 / 结果面板。
4902
+ *
4903
+ * 破坏性后果写在标题下的第一段(「会停止旧会话上正在跑的任务」),而不是只靠
4904
+ * 按钮文案;置顶时再补一行「归档会同时取消置顶(宿主行为)」,与交接前的置顶
4905
+ * 确认同一口径。只有「强制归档(停止任务)」这个按钮会真的发请求。
4906
+ */
4907
+ const forcePanel =
4908
+ forceView === null
4909
+ ? null
4910
+ : h(
4911
+ 'div',
4912
+ {
4913
+ role: 'dialog',
4914
+ 'aria-label': '强制归档旧会话',
4915
+ 'data-cr-force-archive': '1',
4916
+ 'data-cr-force-phase': forceView.phase,
4917
+ 'data-cr-force-target': typeof forceView.target === 'string' ? forceView.target : '',
4918
+ style: { display: 'flex', flexDirection: 'column', minHeight: 0 },
4919
+ },
4920
+ h(
4921
+ 'div',
4922
+ {
4923
+ style: {
4924
+ display: 'flex',
4925
+ alignItems: 'center',
4926
+ gap: 8,
4927
+ padding: '7px 10px',
4928
+ borderBottom: `1px solid ${theme.panelBorder}`,
4929
+ fontSize: 12,
4930
+ fontWeight: 600,
4931
+ },
4932
+ },
4933
+ h('span', { style: { flex: 1, minWidth: 0 } }, '强制归档旧会话'),
4934
+ forceView.phase === 'busy'
4935
+ ? null
4936
+ : h(
4937
+ 'button',
4938
+ {
4939
+ type: 'button',
4940
+ 'data-cr-force-back': '1',
4941
+ onClick: backFromForce,
4942
+ style: { border: `1px solid ${theme.panelBorder}`, borderRadius: 6, padding: '2px 8px', fontSize: 11, cursor: 'pointer', background: 'transparent', color: theme.panelFg },
4943
+ },
4944
+ '← 返回',
4945
+ ),
4946
+ ),
4947
+ forceView.phase === 'confirm'
4948
+ ? h(
4949
+ 'div',
4950
+ {
4951
+ 'data-cr-force-confirm': '1',
4952
+ style: { display: 'flex', flexDirection: 'column', gap: 8, padding: '10px 12px', fontSize: 11, lineHeight: '16px' },
4953
+ },
4954
+ h('div', { style: { fontWeight: 600, color: theme.panelFg } }, `将强制归档旧会话「${label}」。`),
4955
+ h(
4956
+ 'div',
4957
+ {
4958
+ 'data-cr-force-warning': '1',
4959
+ style: {
4960
+ padding: '6px 8px',
4961
+ borderRadius: 8,
4962
+ border: `1px solid ${COLORS.error}`,
4963
+ color: COLORS.error,
4964
+ background: theme.hover,
4965
+ },
4966
+ },
4967
+ '会停止旧会话上正在跑的任务(turn / 子代理 / 后台任务 / 定时任务),中断后无法恢复。',
4968
+ ),
4969
+ parentPinned === true
4970
+ ? h('div', { 'data-cr-force-pin': '1', style: { color: theme.subText } }, '旧会话已在侧栏置顶:归档会同时取消置顶(宿主行为)。')
4971
+ : parentPinned === null
4972
+ ? h('div', { 'data-cr-force-pin': '1', style: { color: theme.subText } }, '宿主没提供置顶状态:若旧会话在侧栏置顶,归档会同时取消置顶(宿主行为)。')
4973
+ : null,
4974
+ h(
4975
+ 'div',
4976
+ { style: { color: theme.subText } },
4977
+ '这是显式操作:默认自动归档遇到活跃会话会「保留可见、不停止任务」,只有你在这里确认后才会停止旧会话上的任务。',
4978
+ ),
4979
+ h(
4980
+ 'div',
4981
+ { style: { display: 'flex', justifyContent: 'flex-end', gap: 8 } },
4982
+ h(
4983
+ 'button',
4984
+ {
4985
+ type: 'button',
4986
+ 'data-cr-force-cancel': '1',
4987
+ onClick: backFromForce,
4988
+ style: { border: `1px solid ${theme.panelBorder}`, borderRadius: 7, padding: '4px 10px', fontSize: 11, cursor: 'pointer', background: 'transparent', color: theme.panelFg },
4989
+ },
4990
+ '取消',
4991
+ ),
4992
+ h(
4993
+ 'button',
4994
+ {
4995
+ type: 'button',
4996
+ 'data-cr-force-run': '1',
4997
+ onClick: () => {
4998
+ void runForceArchive(forceView.target, { confirmUnpin: parentPinned === true });
4999
+ },
5000
+ style: {
5001
+ border: 'none',
5002
+ borderRadius: 7,
5003
+ padding: '4px 10px',
5004
+ fontSize: 11,
5005
+ fontWeight: 600,
5006
+ cursor: 'pointer',
5007
+ background: COLORS.error,
5008
+ color: '#ffffff',
5009
+ },
5010
+ },
5011
+ parentPinned === true ? '强制归档并取消置顶(停止任务)' : '强制归档(停止任务)',
5012
+ ),
5013
+ ),
5014
+ )
5015
+ : forceView.phase === 'busy'
5016
+ ? h(
5017
+ 'div',
5018
+ { 'data-cr-force-busy': '1', style: { padding: '12px', fontSize: 11, lineHeight: '16px', color: theme.subText } },
5019
+ '正在强制归档并停止旧会话上的任务…',
5020
+ )
5021
+ : forceView.phase === 'error'
5022
+ ? h(
5023
+ 'div',
5024
+ { 'data-cr-force-error': '1', style: { display: 'flex', flexDirection: 'column', gap: 8, padding: '10px 12px', fontSize: 11, lineHeight: '16px' } },
5025
+ h('div', { style: { color: COLORS.error } }, String(forceView.message)),
5026
+ h(
5027
+ 'div',
5028
+ { style: { display: 'flex', justifyContent: 'flex-end', gap: 8 } },
5029
+ h(
5030
+ 'button',
5031
+ {
5032
+ type: 'button',
5033
+ 'data-cr-force-error-back': '1',
5034
+ onClick: backFromForce,
5035
+ style: { border: `1px solid ${theme.panelBorder}`, borderRadius: 7, padding: '4px 10px', fontSize: 11, cursor: 'pointer', background: 'transparent', color: theme.panelFg },
5036
+ },
5037
+ '返回菜单',
5038
+ ),
5039
+ h(
5040
+ 'button',
5041
+ {
5042
+ type: 'button',
5043
+ 'data-cr-force-retry': '1',
5044
+ onClick: () => {
5045
+ void runForceArchive(forceView.target, { confirmUnpin: forceView.confirmUnpin === true });
5046
+ },
5047
+ style: { border: `1px solid ${theme.panelBorder}`, borderRadius: 7, padding: '4px 10px', fontSize: 11, fontWeight: 600, cursor: 'pointer', background: 'transparent', color: theme.panelFg },
5048
+ },
5049
+ '重试',
5050
+ ),
5051
+ ),
5052
+ )
5053
+ : h(
5054
+ 'div',
5055
+ { 'data-cr-force-done': '1', style: { display: 'flex', flexDirection: 'column', gap: 8, padding: '10px 12px', fontSize: 11, lineHeight: '16px' } },
5056
+ h('div', { 'data-cr-force-note': '1', style: { color: theme.panelFg } }, String(forceView.note)),
5057
+ // pinned = 宿主里旧会话其实还置顶(状态过期):不静默取消置顶,
5058
+ // 给一个明确的二次出口,用户点了才带 confirmUnpin 重发。
5059
+ forceView.status === 'pinned'
5060
+ ? h(
5061
+ 'div',
5062
+ { style: { display: 'flex', justifyContent: 'flex-end', gap: 8 } },
5063
+ h(
5064
+ 'button',
5065
+ {
5066
+ type: 'button',
5067
+ 'data-cr-force-close': '1',
5068
+ onClick: closeMenu,
5069
+ style: { border: `1px solid ${theme.panelBorder}`, borderRadius: 7, padding: '4px 10px', fontSize: 11, cursor: 'pointer', background: 'transparent', color: theme.panelFg },
5070
+ },
5071
+ '关闭',
5072
+ ),
5073
+ h(
5074
+ 'button',
5075
+ {
5076
+ type: 'button',
5077
+ 'data-cr-force-unpin': '1',
5078
+ onClick: () => {
5079
+ void runForceArchive(forceView.target, { confirmUnpin: true });
5080
+ },
5081
+ style: { border: 'none', borderRadius: 7, padding: '4px 10px', fontSize: 11, fontWeight: 600, cursor: 'pointer', background: COLORS.error, color: '#ffffff' },
5082
+ },
5083
+ '仍然归档(取消置顶)',
5084
+ ),
5085
+ )
5086
+ : h(
5087
+ 'div',
5088
+ { style: { display: 'flex', justifyContent: 'flex-end', gap: 8 } },
5089
+ h(
5090
+ 'button',
5091
+ {
5092
+ type: 'button',
5093
+ 'data-cr-force-close': '1',
5094
+ onClick: closeMenu,
5095
+ style: { border: `1px solid ${theme.panelBorder}`, borderRadius: 7, padding: '4px 10px', fontSize: 11, cursor: 'pointer', background: 'transparent', color: theme.panelFg },
5096
+ },
5097
+ '关闭',
5098
+ ),
5099
+ h(
5100
+ 'button',
5101
+ {
5102
+ type: 'button',
5103
+ 'data-cr-force-done-back': '1',
5104
+ onClick: backFromForce,
5105
+ style: { border: `1px solid ${theme.panelBorder}`, borderRadius: 7, padding: '4px 10px', fontSize: 11, fontWeight: 600, cursor: 'pointer', background: 'transparent', color: theme.panelFg },
5106
+ },
5107
+ '返回菜单',
5108
+ ),
5109
+ ),
5110
+ ),
5111
+ );
5112
+
4638
5113
  const menu =
4639
5114
  open === true
4640
5115
  ? h(
@@ -4653,7 +5128,7 @@ window.__ModuleLoader__.load({
4653
5128
  display: 'flex',
4654
5129
  flexDirection: 'column',
4655
5130
  overflow: 'hidden',
4656
- padding: doc === null && chainView === null ? 5 : 0,
5131
+ padding: doc === null && chainView === null && forceView === null ? 5 : 0,
4657
5132
  borderRadius: 12,
4658
5133
  ...panelChrome(theme),
4659
5134
  color: theme.panelFg,
@@ -4668,9 +5143,10 @@ window.__ModuleLoader__.load({
4668
5143
  notice,
4669
5144
  )
4670
5145
  : null,
4671
- // 交接单面板 / 完整链面板都替换主列表;「← 返回」按来源回到链列表或主菜单。
5146
+ // 交接单面板 / 完整链面板 / 强制归档确认面板都替换主列表;「← 返回」按来源回主菜单。
4672
5147
  docPanel ??
4673
5148
  chainPanel ??
5149
+ forcePanel ??
4674
5150
  h(
4675
5151
  'div',
4676
5152
  { style: { display: 'flex', flexDirection: 'column' } },
@@ -4712,6 +5188,22 @@ window.__ModuleLoader__.load({
4712
5188
  },
4713
5189
  )
4714
5190
  : null,
5191
+ hasParent && showForceArchive
5192
+ ? relayBarItem(
5193
+ '强制归档旧会话(会停止后台任务)',
5194
+ parentPinned === true
5195
+ ? '旧会话已置顶:归档会同时取消置顶;会先二次确认'
5196
+ : '旧会话还有任务在跑也能收起;会先二次确认',
5197
+ 'force-archive',
5198
+ () => {
5199
+ openForceConfirm(chain.parentId);
5200
+ },
5201
+ {
5202
+ right: '›',
5203
+ title: `强制归档旧会话 ${chain.parentId}:二次确认后会停止它上面正在跑的任务再归档`,
5204
+ },
5205
+ )
5206
+ : null,
4715
5207
  hasParent
4716
5208
  ? relayBarItem('完整交接链', '一次读取整条链,回看前几次会话与交接单', 'chain', openChainList, {
4717
5209
  right: '›',
@@ -4844,6 +5336,288 @@ window.__ModuleLoader__.load({
4844
5336
  menu,
4845
5337
  );
4846
5338
  }
5339
+ /**
5340
+ * 收藏星标(0.6)—— 挂在助手消息底部的动作行(conversation.chat.assistant-actions)。
5341
+ *
5342
+ * 数据:GET /api/compact-relay-state?sessionId=… 的 favorites.messageIds(判断是否已收藏);
5343
+ * 点击:POST /api/compact-relay-action { action:'favorite-toggle', messageId, sessionId }。
5344
+ *
5345
+ * 2026-10-29 真机 BUG:以前 state 请求不带 sessionId,宿主回「最后一个活 agent」的会话
5346
+ * (多工作区同时开着时那是别的会话)→ 点星 POST 到错会话 → 「找不到这条消息」→ 点了没反应。
5347
+ * 现在会话 id 只认 slot 的 session 作用域注入(register 的 inject),state 也按它请求。
5348
+ * 另外:点失败要出声(宿主可能回「已被压缩」/「没有活动会话」),不再静默。
5349
+ */
5350
+ const FAVORITE_STAR_PATH = 'M12 2.8l2.82 5.72 6.31.92-4.57 4.45 1.08 6.29L12 17.2l-5.64 2.98 1.08-6.29-4.57-4.45 6.31-.92z';
5351
+
5352
+ // 收藏状态共享快照:几十个星标组件共用一个请求(避免每条助手消息各发一次 GET)。
5353
+ // 30s 内复用;并发挂载共用同一个 pending Promise。快照按 sessionId 区分(切会话不会串)。
5354
+ const FAVORITE_SNAPSHOT_TTL_MS = 30000;
5355
+ let favoriteSnapshot = null; // { sessionId, ids, suggested, freshUntil }
5356
+ let favoriteSnapshotPending = null;
5357
+ let favoriteSnapshotPendingSession = null;
5358
+ /** 点亮色(配置 colors.base.favorite;设计稿到位后改配置值即可)。 */
5359
+ let favoriteTint = '#F5C542';
5360
+
5361
+ /**
5362
+ * 推荐态样式只注入一次:金色呼吸光晕(0.5.3 UI)。
5363
+ * 静态 1.5px 细环在长消息流里太容易漏看;改成 2.4s 呼吸 + 更亮的峰值。
5364
+ * 系统开了「减少动态效果」就退回静态加亮版本。
5365
+ */
5366
+ function ensureFavoriteStyle() {
5367
+ try {
5368
+ if (typeof document === 'undefined' || document.head === null) return;
5369
+ if (document.getElementById('dcr-favorite-style') !== null) return;
5370
+ const style = document.createElement('style');
5371
+ style.id = 'dcr-favorite-style';
5372
+ style.textContent =
5373
+ '@keyframes dcr-favorite-pulse {' +
5374
+ '0%,100% { box-shadow: 0 0 0 2px rgba(245,197,66,0.78), 0 0 6px rgba(245,197,66,0.45); }' +
5375
+ '50% { box-shadow: 0 0 0 2.5px rgba(245,197,66,0.98), 0 0 12px rgba(245,197,66,0.85); }' +
5376
+ '}' +
5377
+ // 与宿主助手消息动作行(._action)完全对齐:基础用 label-tertiary,
5378
+ // hover 切 label-secondary + 交互背景(与邻座逐项相同)。
5379
+ '.dcr-favorite-star { background: transparent; color: var(--dsw-alias-label-tertiary, currentColor); }' +
5380
+ '.dcr-favorite-star:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16)); color: var(--dsw-alias-label-secondary, currentColor); }' +
5381
+ '@media (prefers-reduced-motion: reduce) { .dcr-favorite-star { animation: none !important; } }';
5382
+ document.head.appendChild(style);
5383
+ } catch (error) {
5384
+ /* 样式注入失败不影响收藏功能 */
5385
+ }
5386
+ }
5387
+ ensureFavoriteStyle();
5388
+
5389
+ /** 轻提示:收藏失败/被压缩时告诉用户一声(不依赖宿主 toast API)。 */
5390
+ function favoriteNotify(text) {
5391
+ try {
5392
+ if (typeof document === 'undefined' || document.body === null) return;
5393
+ const el = document.createElement('div');
5394
+ el.textContent = text;
5395
+ el.style.cssText =
5396
+ 'position:fixed;left:50%;bottom:32px;transform:translateX(-50%);z-index:2147483000;max-width:80vw;' +
5397
+ 'padding:8px 14px;border-radius:8px;background:rgba(30,30,30,0.92);color:#fff;font-size:13px;' +
5398
+ 'line-height:1.4;box-shadow:0 6px 24px rgba(0,0,0,0.25);pointer-events:none;';
5399
+ document.body.appendChild(el);
5400
+ setTimeout(() => {
5401
+ try {
5402
+ el.remove();
5403
+ } catch (error) {
5404
+ /* ignore */
5405
+ }
5406
+ }, 4000);
5407
+ } catch (error) {
5408
+ /* 提示失败不影响收藏 */
5409
+ }
5410
+ }
5411
+
5412
+ /**
5413
+ * 收藏列表(按会话缓存)。
5414
+ * @param {string|null} sessionId 当前会话(来自 slot 注入;拿不到就退回旧行为)
5415
+ * @returns {Promise<string[]|null>}
5416
+ */
5417
+ function favoriteIdsOf(sessionId) {
5418
+ const sid = typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null;
5419
+ if (favoriteSnapshot !== null && favoriteSnapshot.sessionId === sid && Date.now() < favoriteSnapshot.freshUntil) {
5420
+ return Promise.resolve(favoriteSnapshot.ids);
5421
+ }
5422
+ if (favoriteSnapshotPending !== null && favoriteSnapshotPendingSession === sid) return favoriteSnapshotPending;
5423
+ favoriteSnapshotPendingSession = sid;
5424
+ favoriteSnapshotPending = (async () => {
5425
+ try {
5426
+ const url =
5427
+ sid === null ? '/api/compact-relay-state' : `/api/compact-relay-state?sessionId=${encodeURIComponent(sid)}`;
5428
+ const res = await fetch(url);
5429
+ const data = await res.json();
5430
+ const fav = data !== null && typeof data === 'object' ? data.favorites : null;
5431
+ const ids = fav !== null && typeof fav === 'object' && Array.isArray(fav.messageIds) ? fav.messageIds : [];
5432
+ // 0.5.3 P1-6:推荐收藏建议(只点亮星标,不写收藏)。按 messageId 建索引,组件 O(1) 查。
5433
+ const suggested = {};
5434
+ const suggestList =
5435
+ data !== null && typeof data === 'object' && Array.isArray(data.favoriteSuggest) ? data.favoriteSuggest : [];
5436
+ for (const item of suggestList) {
5437
+ if (item === null || typeof item !== 'object') continue;
5438
+ if (typeof item.messageId !== 'string' || item.messageId.length === 0) continue;
5439
+ suggested[item.messageId] = {
5440
+ score: typeof item.score === 'number' ? item.score : null,
5441
+ reason: typeof item.reason === 'string' ? item.reason : '',
5442
+ };
5443
+ }
5444
+ // 只有「没带 sessionId」的兜底请求才信响应里的会话;带了就以请求的为准。
5445
+ const effectiveSid =
5446
+ sid !== null ? sid : data !== null && typeof data === 'object' && typeof data.sessionId === 'string' ? data.sessionId : null;
5447
+ const color = data !== null && typeof data === 'object' && data.colors && data.colors.base ? data.colors.base.favorite : null;
5448
+ if (typeof color === 'string' && color.length > 0) favoriteTint = color;
5449
+ favoriteSnapshot = {
5450
+ sessionId: effectiveSid,
5451
+ ids: ids.slice(),
5452
+ suggested,
5453
+ freshUntil: Date.now() + FAVORITE_SNAPSHOT_TTL_MS,
5454
+ };
5455
+ return favoriteSnapshot.ids;
5456
+ } catch (error) {
5457
+ return null;
5458
+ } finally {
5459
+ favoriteSnapshotPending = null;
5460
+ favoriteSnapshotPendingSession = null;
5461
+ }
5462
+ })();
5463
+ return favoriteSnapshotPending;
5464
+ }
5465
+
5466
+ // 点击后同步共享快照,避免同屏其它星标(或稍后挂载的)显示旧状态。
5467
+ function rememberFavorite(messageId, active) {
5468
+ if (favoriteSnapshot === null) return;
5469
+ const ids = favoriteSnapshot.ids;
5470
+ const index = ids.indexOf(messageId);
5471
+ if (active && index === -1) ids.push(messageId);
5472
+ else if (!active && index !== -1) ids.splice(index, 1);
5473
+ // 已收藏的条目不再算「推荐」(取消收藏后本回合内也不重新点亮,等下一条回复)。
5474
+ if (active && favoriteSnapshot.suggested !== null && typeof favoriteSnapshot.suggested === 'object') {
5475
+ delete favoriteSnapshot.suggested[messageId];
5476
+ }
5477
+ favoriteSnapshot.freshUntil = Date.now() + FAVORITE_SNAPSHOT_TTL_MS;
5478
+ }
5479
+
5480
+ /** 查某条消息是否有推荐(当前快照会话内);返回 {score, reason} 或 null。 */
5481
+ function favoriteSuggestionOf(sessionId, messageId) {
5482
+ if (favoriteSnapshot === null || messageId === null) return null;
5483
+ if (favoriteSnapshot.sessionId !== sessionId) return null;
5484
+ const suggested = favoriteSnapshot.suggested;
5485
+ if (suggested === null || typeof suggested !== 'object') return null;
5486
+ const hit = suggested[messageId];
5487
+ return hit !== undefined && hit !== null ? hit : null;
5488
+ }
5489
+
5490
+ function FavoriteStar(props) {
5491
+ const messageId =
5492
+ props !== null && props !== undefined && typeof props.messageId === 'string' ? props.messageId : null;
5493
+ const sessionId =
5494
+ props !== null && props !== undefined && typeof props.sessionId === 'string' && props.sessionId.length > 0
5495
+ ? props.sessionId
5496
+ : null;
5497
+ const [active, setActive] = useState(false);
5498
+ const [busy, setBusy] = useState(false);
5499
+ const [tint, setTint] = useState('#F5C542');
5500
+ const [suggested, setSuggested] = useState(null);
5501
+
5502
+ useEffect(() => {
5503
+ if (messageId === null) return undefined;
5504
+ let cancelled = false;
5505
+ (async () => {
5506
+ const ids = await favoriteIdsOf(sessionId);
5507
+ if (cancelled || ids === null) return;
5508
+ setActive(ids.includes(messageId));
5509
+ setSuggested(favoriteSuggestionOf(sessionId, messageId));
5510
+ setTint(favoriteTint);
5511
+ })();
5512
+ return () => {
5513
+ cancelled = true;
5514
+ };
5515
+ }, [messageId, sessionId]);
5516
+
5517
+ const toggle = useCallback(async () => {
5518
+ if (messageId === null || busy) return undefined;
5519
+ setBusy(true);
5520
+ try {
5521
+ const res = await fetch('/api/compact-relay-action', {
5522
+ method: 'POST',
5523
+ headers: { 'content-type': 'application/json' },
5524
+ body: JSON.stringify({
5525
+ action: 'favorite-toggle',
5526
+ messageId,
5527
+ ...(sessionId === null ? {} : { sessionId }),
5528
+ }),
5529
+ });
5530
+ const data = await res.json();
5531
+ if (data !== null && typeof data === 'object' && typeof data.active === 'boolean') {
5532
+ setActive(data.active);
5533
+ if (data.active) setSuggested(null);
5534
+ rememberFavorite(messageId, data.active);
5535
+ return undefined;
5536
+ }
5537
+ // 失败要出声:宿主回「找不到这条消息(会话可能被压缩过)」等具体原因。
5538
+ const message =
5539
+ data !== null && typeof data === 'object' && typeof data.message === 'string' && data.message.length > 0
5540
+ ? data.message
5541
+ : '收藏失败:请重试';
5542
+ favoriteNotify(message);
5543
+ return undefined;
5544
+ } catch (error) {
5545
+ favoriteNotify('收藏失败:网络或宿主不可用');
5546
+ return undefined;
5547
+ } finally {
5548
+ setBusy(false);
5549
+ }
5550
+ }, [messageId, sessionId, busy]);
5551
+
5552
+ if (messageId === null) return null;
5553
+ const isSuggested = active !== true && suggested !== null;
5554
+ const label = active
5555
+ ? '取消收藏'
5556
+ : isSuggested
5557
+ ? `推荐收藏${suggested.score !== null ? `(${suggested.score} 分)` : ''}`
5558
+ : '收藏这条';
5559
+ return h(
5560
+ 'button',
5561
+ {
5562
+ type: 'button',
5563
+ className: 'dcr-favorite-star',
5564
+ title: label,
5565
+ 'aria-label': label,
5566
+ 'aria-pressed': active ? 'true' : 'false',
5567
+ onClick: toggle,
5568
+ disabled: busy,
5569
+ style: {
5570
+ display: 'inline-flex',
5571
+ alignItems: 'center',
5572
+ justifyContent: 'center',
5573
+ width: isSuggested ? 'auto' : '28px',
5574
+ height: '28px',
5575
+ gap: isSuggested ? '4px' : '0',
5576
+ padding: isSuggested ? '6px 8px 6px 6px' : '6px',
5577
+ margin: '0',
5578
+ border: 'none',
5579
+ cursor: busy ? 'default' : 'pointer',
5580
+ ...(active || isSuggested ? { color: tint } : {}),
5581
+ opacity: busy ? 0.5 : 1,
5582
+ borderRadius: '8px',
5583
+ // 推荐态:金色光晕(只点亮、不自动收藏;点击仍走同一 toggle)。
5584
+ boxShadow: isSuggested ? '0 0 0 2px rgba(245,197,66,0.9), 0 0 8px rgba(245,197,66,0.6)' : 'none',
5585
+ animation: isSuggested ? 'dcr-favorite-pulse 2.4s ease-in-out infinite' : 'none',
5586
+ lineHeight: 1,
5587
+ verticalAlign: 'middle',
5588
+ },
5589
+ },
5590
+ h(
5591
+ 'svg',
5592
+ {
5593
+ width: 16,
5594
+ height: 16,
5595
+ viewBox: '0 0 24 24',
5596
+ fill: active ? 'currentColor' : 'none',
5597
+ stroke: 'currentColor',
5598
+ strokeWidth: active ? 0 : 1.8,
5599
+ strokeLinejoin: 'round',
5600
+ },
5601
+ h('path', { d: FAVORITE_STAR_PATH }),
5602
+ ),
5603
+ // 推荐态:加一个金色「推荐」小标签(用户确认要提高可见度,允许轻微打断阅读)。
5604
+ // 标签在按钮内部 —— 点击任意位置都走同一个 toggle,不新增交互面。
5605
+ isSuggested
5606
+ ? h(
5607
+ 'span',
5608
+ {
5609
+ style: {
5610
+ fontSize: '11px',
5611
+ lineHeight: '1.5',
5612
+ letterSpacing: '0.02em',
5613
+ whiteSpace: 'nowrap',
5614
+ },
5615
+ },
5616
+ '推荐',
5617
+ )
5618
+ : null,
5619
+ );
5620
+ }
4847
5621
 
4848
5622
  /** 交接结果的一行式展示(放在聊天流里的 /handoff 命令卡)。 */
4849
5623
  function handoffRow(text, tone, action) {
@@ -5129,6 +5903,10 @@ window.__ModuleLoader__.load({
5129
5903
  const [nativeBusy, setNativeBusy] = useState(false);
5130
5904
  /** UI-003:结果提示就地显示在开关旁(以前只写页面底部,用户看不到)。 */
5131
5905
  const [nativeStatus, setNativeStatus] = useState(null);
5906
+ // 0.5.3 P1-7:推荐收藏「试算」的输入与结果(只读,不写收藏)。
5907
+ const [trialText, setTrialText] = useState('');
5908
+ const [trialResult, setTrialResult] = useState(null);
5909
+ const [trialBusy, setTrialBusy] = useState(false);
5132
5910
  /** 作者 2026-10-20:设置页「再看一次初次引导」的就地提示。 */
5133
5911
  const [onboardingNotice, setOnboardingNotice] = useState(null);
5134
5912
  const nativeConfirmRef = useRef(null);
@@ -5296,6 +6074,11 @@ window.__ModuleLoader__.load({
5296
6074
 
5297
6075
  /** 表单与已保存快照不一致 → 有未保存修改。 */
5298
6076
  const dirty = form !== null && savedSnapshot !== null && JSON.stringify(form) !== savedSnapshot;
6077
+ // 0.5.3 P1-6:推荐收藏表单(旧设置 payload 可能没有 favorite 块 → 用默认值兜底,不崩)。
6078
+ const favoriteForm =
6079
+ form !== null && typeof form === 'object' && form.favorite !== null && form.favorite !== undefined && typeof form.favorite === 'object'
6080
+ ? form.favorite
6081
+ : { enabled: true, level: 'standard', mustWords: [], muteWords: [] };
5299
6082
 
5300
6083
  /**
5301
6084
  * 保存设置。0.4.2:body 带上 `nativeRatio`(当前模型实际触发线),
@@ -5399,6 +6182,64 @@ window.__ModuleLoader__.load({
5399
6182
  [nativeBusy, load],
5400
6183
  );
5401
6184
 
6185
+ /** 0.5.3 P1-7:词表输入(逗号/空格分隔)→ 规范化数组。 */
6186
+ const parseFavoriteWords = (text) =>
6187
+ String(text)
6188
+ .split(/[,,、\s]+/)
6189
+ .map((word) => word.trim())
6190
+ .filter((word) => word.length > 0)
6191
+ .slice(0, 20);
6192
+
6193
+ /** 0.5.3 P1-7:试算——按当前表单里的档位/词表跑一次打分,只读结果。 */
6194
+ const runTrial = useCallback(async () => {
6195
+ if (trialBusy) return;
6196
+ const text = trialText.trim();
6197
+ if (text.length === 0) {
6198
+ setTrialResult({ error: true, text: '先粘一段文本再试算' });
6199
+ return;
6200
+ }
6201
+ setTrialBusy(true);
6202
+ setTrialResult(null);
6203
+ try {
6204
+ // 试算需要模型路由:先取当前活会话 id(设置页不是会话作用域)。
6205
+ const stateRes = await fetch('/api/compact-relay-state', { cache: 'no-store' });
6206
+ const state = await stateRes.json().catch(() => ({}));
6207
+ const sessionId = state !== null && typeof state === 'object' && typeof state.sessionId === 'string' ? state.sessionId : '';
6208
+ if (sessionId.length === 0) throw new Error('没有活动会话,先发一条消息唤醒 agent 再试算');
6209
+ const res = await fetch('/api/compact-relay-action', {
6210
+ method: 'POST',
6211
+ headers: { 'content-type': 'application/json' },
6212
+ body: JSON.stringify({
6213
+ action: 'favorite-score-trial',
6214
+ sessionId,
6215
+ text,
6216
+ level: favoriteForm.level,
6217
+ mustWords: favoriteForm.mustWords,
6218
+ muteWords: favoriteForm.muteWords,
6219
+ }),
6220
+ });
6221
+ const data = await res.json().catch(() => ({}));
6222
+ if (!res.ok || data.ok === false) throw new Error(data && data.message ? String(data.message) : '试算失败');
6223
+ const scoreText = typeof data.score === 'number' ? `${data.score} 分` : '没拿到分数';
6224
+ const verdict = data.suggest === true ? '会点亮' : '不点亮';
6225
+ const why =
6226
+ data.decisionReason === 'mute-word'
6227
+ ? `(命中屏蔽词「${data.matched}」)`
6228
+ : data.decisionReason === 'must-word'
6229
+ ? `(命中必亮词「${data.matched}」)`
6230
+ : data.decisionReason === 'too-short'
6231
+ ? '(太短)'
6232
+ : data.decisionReason === 'unknown-score'
6233
+ ? '(模型没给分)'
6234
+ : '';
6235
+ setTrialResult({ text: `${scoreText} → ${verdict}${why}` });
6236
+ } catch (error) {
6237
+ setTrialResult({ error: true, text: error && error.message ? error.message : '试算失败' });
6238
+ } finally {
6239
+ setTrialBusy(false);
6240
+ }
6241
+ }, [trialBusy, trialText, favoriteForm]);
6242
+
5402
6243
  // UI-003:确认块出现时滚进视野(以前它渲染在设置页底部以外,用户以为「点了没反应」)。
5403
6244
  useEffect(() => {
5404
6245
  if (confirmNative !== null && nativeConfirmRef.current && typeof nativeConfirmRef.current.scrollIntoView === 'function') {
@@ -5570,7 +6411,7 @@ window.__ModuleLoader__.load({
5570
6411
  h(
5571
6412
  'p',
5572
6413
  { style: { margin: '0 0 4px', fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-label-secondary, rgba(127,127,127,0.95))' } },
5573
- '功能:看上下文容量、到线提醒并帮你压缩旧会话 / 交接新会话、锁定重要消息;边界:只动本插件的压缩与交接历史,不改模型回复,也不接管 DSH 原生自动压缩(只在本页开关、改了要重启)。',
6414
+ '功能:看上下文容量、到线提醒并帮你压缩旧会话 / 交接新会话、收藏重要回复;边界:只动本插件的压缩与交接历史,不改模型回复,也不接管 DSH 原生自动压缩(只在本页开关、改了要重启)。',
5574
6415
  ),
5575
6416
  h('p', { style: { ...settingsHintStyle, marginBottom: 10 } }, '这里改的设置立即生效,不需要重启。阈值按窗口比例算:换不同上下文窗口的模型会自动缩放。'),
5576
6417
  banner,
@@ -5750,6 +6591,81 @@ window.__ModuleLoader__.load({
5750
6591
  ),
5751
6592
  ),
5752
6593
  ),
6594
+ sectionHeading('推荐收藏'),
6595
+ h(
6596
+ 'div',
6597
+ { style: { display: 'flex', flexDirection: 'column', paddingLeft: 16, borderLeft: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.2))' }, 'data-cr-group': '推荐收藏' },
6598
+ settingsRow(
6599
+ '推荐收藏',
6600
+ '助手回复落地后,用一个小模型打分(0-100);够分的星标会金色微亮,提示「这条值得收藏」。',
6601
+ '只点亮、**绝不自动收藏**;确认仍要你点星或 /favorite mark。关掉后不再打分(也就不花这份钱)。',
6602
+ h('label', { style: { display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12 } },
6603
+ h('input', {
6604
+ type: 'checkbox', 'aria-label': '开启推荐收藏', checked: favoriteForm.enabled !== false,
6605
+ onChange: (event) => patch(['favorite', 'enabled'], event.target.checked),
6606
+ }),
6607
+ '开启推荐收藏',
6608
+ ),
6609
+ ),
6610
+ settingsRow(
6611
+ '推荐档位',
6612
+ '分高才亮:宽松 60 / 标准 75 / 严格 85。',
6613
+ '标准档每 4 条回复亮 1 次(准确率约 76%);严格档更少但更准(约 89%)。',
6614
+ h('select', {
6615
+ 'aria-label': '推荐档位',
6616
+ value: favoriteForm.level ?? 'standard',
6617
+ style: { ...settingsInputStyle, width: 120 },
6618
+ onChange: (event) => patch(['favorite', 'level'], event.target.value),
6619
+ },
6620
+ h('option', { value: 'loose' }, '宽松(60)'),
6621
+ h('option', { value: 'standard' }, '标准(75)'),
6622
+ h('option', { value: 'strict' }, '严格(85)'),
6623
+ ),
6624
+ ),
6625
+ settingsRow(
6626
+ '必亮词',
6627
+ '出现这些词就直接点亮,跳过打分(模型失败也亮)。',
6628
+ '多个词用逗号或空格分隔,最多 20 个;适合「定案」「结论」这类固定说法。',
6629
+ h('input', {
6630
+ type: 'text', 'aria-label': '必亮词', placeholder: '例如:定案,结论,就按这个',
6631
+ value: (favoriteForm.mustWords ?? []).join(','),
6632
+ style: { ...settingsInputStyle, width: '100%' },
6633
+ onChange: (event) => patch(['favorite', 'mustWords'], parseFavoriteWords(event.target.value)),
6634
+ }),
6635
+ ),
6636
+ settingsRow(
6637
+ '屏蔽词',
6638
+ '出现这些词就绝不点亮(优先于必亮词和分数)。',
6639
+ '屏蔽词优先;比如「测试」「随便试试」这类不该进收藏的说法。',
6640
+ h('input', {
6641
+ type: 'text', 'aria-label': '屏蔽词', placeholder: '例如:测试,随便',
6642
+ value: (favoriteForm.muteWords ?? []).join(','),
6643
+ style: { ...settingsInputStyle, width: '100%' },
6644
+ onChange: (event) => patch(['favorite', 'muteWords'], parseFavoriteWords(event.target.value)),
6645
+ }),
6646
+ ),
6647
+ settingsRow(
6648
+ '试算',
6649
+ '粘一段助手回复原文,按上面的档位/词表跑一次真实打分(只读,不写收藏)。',
6650
+ '试算会真的调一次小模型(约 1-2 秒、少量 token);结果只用来调档位。',
6651
+ h('div', { style: { display: 'flex', flexDirection: 'column', gap: 6, width: '100%' } },
6652
+ h('textarea', {
6653
+ 'aria-label': '试算文本',
6654
+ rows: 3,
6655
+ placeholder: '粘贴一段助手回复原文…',
6656
+ value: trialText,
6657
+ style: { ...settingsInputStyle, width: '100%', boxSizing: 'border-box', resize: 'vertical', minHeight: 60 },
6658
+ onChange: (event) => setTrialText(event.target.value),
6659
+ }),
6660
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
6661
+ h('button', { type: 'button', style: settingsButtonStyle, disabled: trialBusy, onClick: runTrial }, trialBusy ? '试算中…' : '试算'),
6662
+ trialResult === null
6663
+ ? null
6664
+ : h('span', { style: { ...settingsHintStyle, color: trialResult.error ? 'var(--dsw-alias-state-warning-primary, #b45309)' : undefined } }, trialResult.text),
6665
+ ),
6666
+ ),
6667
+ ),
6668
+ ),
5753
6669
  sectionHeading('压缩行为'),
5754
6670
  h(
5755
6671
  'div',
@@ -6193,7 +7109,7 @@ window.__ModuleLoader__.load({
6193
7109
  h('p', { style: settingsHintStyle }, `版本:${version || '—'}`),
6194
7110
  h('p', { style: settingsHintStyle, 'data-cr-ledger': '1' }, ledgerLine),
6195
7111
  h('p', { style: settingsHintStyle }, `设置文件:${settingsPath || '—'}`),
6196
- h('p', { style: { ...settingsHintStyle, marginTop: 8 } }, '输入框左侧的上下文指示灯:看容量、提醒压缩/交接、锁住重要消息;到线了用 /bc 或 /handoff,重要内容用 /pin 锁住。'),
7112
+ h('p', { style: { ...settingsHintStyle, marginTop: 8 } }, '输入框左侧的上下文指示灯:看容量、提醒压缩/交接、收藏重要回复;到线了用 /bc 或 /handoff,重要内容用 /fin 锁住。'),
6197
7113
  h('p', { style: settingsHintStyle }, '遇到 bug 或想提改进:走上面「帮助」里的 GitHub Issues;也欢迎直接发邮件。'),
6198
7114
  h('p', { style: settingsHintStyle }, '联系方式:cfanzx@136.com(合作、安全问题,以及不便公开讨论的事项)。'),
6199
7115
  h('p', { style: settingsHintStyle }, 'npm 包页面:https://www.npmjs.com/package/dsh-compact-relay'),
@@ -6517,6 +7433,22 @@ window.__ModuleLoader__.load({
6517
7433
  }
6518
7434
  };
6519
7435
 
7436
+ // 0.6 收藏星标:挂助手消息底部动作行(宿主只对已定稿助手消息渲染)。
7437
+ // 会话 id 必须由 slot 的 session 作用域注入,不能靠 state 接口猜「当前会话」:
7438
+ // 多工作区同时活着时宿主会回「最后一个活 agent」的会话(2026-10-29 真机 BUG:点星静默失败)。
7439
+ ctx.slots.inject('conversation.chat.assistant-actions', () =>
7440
+ ctx.slots.register(
7441
+ {
7442
+ name: 'conversation.chat.assistant-actions',
7443
+ id: 'compact-relay-favorite',
7444
+ inject: (sessionId) => ({
7445
+ sessionId: typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : undefined,
7446
+ }),
7447
+ },
7448
+ FavoriteStar,
7449
+ ),
7450
+ );
7451
+
6520
7452
  ctx.slots.inject('conversation.input.left', () =>
6521
7453
  ctx.slots.register(
6522
7454
  {