dsh-side-branch 0.1.1 → 0.2.2
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/AGENTS.md +103 -13
- package/README.md +92 -55
- package/docs/AGENTS.en.md +106 -14
- package/docs/CHANGELOG.md +82 -0
- package/docs/README.zh-CN.md +90 -53
- package/docs/TECHNICAL.md +105 -10
- package/docs/TECHNICAL.zh-CN.md +105 -10
- package/lib/client.js +618 -64
- package/lib/index.js +1041 -90
- package/package.json +10 -4
package/lib/client.js
CHANGED
|
@@ -440,6 +440,11 @@ window.__ModuleLoader__.load({
|
|
|
440
440
|
'.dsh-side-branch-quotebox{display:flex;align-items:center;gap:8px;border-left:2px solid var(--dsw-alias-border-l4,rgba(0,0,0,.16));padding-left:10px;margin:8px 0;color:var(--dsw-alias-label-secondary,#57585a);font-size:12px;line-height:18px;}',
|
|
441
441
|
'.dsh-side-branch-quotelabel{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}',
|
|
442
442
|
'.dsh-side-branch-quoteclose{width:20px;height:20px;font-size:14px;line-height:1;}',
|
|
443
|
+
// ★ 轮次里的引用块(引用随轮入档):与「思考」行同款折叠形态,左 2px 边 + 次要色,
|
|
444
|
+
// 内容区自己滚(引用上限 8000 字,不能把整轮撑开)。
|
|
445
|
+
'.dsh-side-branch-refblock{margin:2px 0 6px;border-left:2px solid var(--dsw-alias-border-l4,rgba(0,0,0,.16));padding-left:10px;color:var(--dsw-alias-label-secondary,#57585a);font-size:12px;line-height:18px;}',
|
|
446
|
+
'.dsh-side-branch-refsummary{cursor:pointer;color:var(--dsw-alias-label-tertiary,#8b8d90);}',
|
|
447
|
+
'.dsh-side-branch-refbody{margin:4px 0 0;white-space:pre-wrap;word-break:break-word;max-height:240px;overflow-y:auto;}',
|
|
443
448
|
// 错误框(官方无对应物 ⇒ token 化自建;对比度用 state-error-primary,随主题合规)
|
|
444
449
|
'.dsh-side-branch-errbox{border:1px solid var(--dsw-alias-state-error-primary,#e5484d);background:var(--dsw-alias-bg-overlay,rgba(229,72,77,.10));color:var(--dsw-alias-state-error-primary,#e5484d);border-radius:6px;padding:8px;margin-bottom:8px;font-family:monospace;font-size:12px;line-height:18px;white-space:pre-wrap;word-break:break-word;}',
|
|
445
450
|
// ⚠️ **不要给答案区加任何"运行中占位行"**(流式闪烁光标、`thinking…` 之类):官方主会话也没有。
|
|
@@ -447,6 +452,21 @@ window.__ModuleLoader__.load({
|
|
|
447
452
|
// 工具调用提示行(白名单放行 read/grep 等之后,模型会真的调工具 ⇒ 必须看得见,
|
|
448
453
|
// 否则面板会毫无理由地停住不动)。与推理行同字号,颜色更淡一档。
|
|
449
454
|
'.dsh-side-branch-toolline{color:var(--dsw-alias-label-caption,#a0a0a0);font-size:12px;line-height:18px;}',
|
|
455
|
+
// ── ★ 层 1 / 层 2:「继承了什么 / 注入了什么」
|
|
456
|
+
// 面板里**唯一**会展示"主会话内容"的地方;全部只读、只放内存(⛔ 不进持久化状态,见组件里那段注释)。
|
|
457
|
+
'.dsh-side-branch-meta{display:flex;flex-direction:column;gap:6px;margin-bottom:12px;}',
|
|
458
|
+
'.dsh-side-branch-metaline{display:block;width:100%;text-align:start;background:none;border:0;padding:0;margin:0;font:inherit;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,#8b8b8b);cursor:pointer;}',
|
|
459
|
+
'.dsh-side-branch-metaline:disabled{cursor:default;}',
|
|
460
|
+
'.dsh-side-branch-metaline:hover:not(:disabled){color:var(--dsw-alias-label-secondary,#57585a);}',
|
|
461
|
+
'.dsh-side-branch-inherited{border-left:2px solid var(--dsw-alias-border-l4,rgba(0,0,0,.16));padding-left:10px;margin:2px 0 6px;display:flex;flex-direction:column;gap:4px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#57585a);max-height:320px;overflow:auto;}',
|
|
462
|
+
'.dsh-side-branch-inhturn{font-size:12px;line-height:18px;}',
|
|
463
|
+
'.dsh-side-branch-inhturn>summary{cursor:pointer;color:var(--dsw-alias-label-tertiary,#8b8b8b);}',
|
|
464
|
+
// 继承内容里那一层「思考」(主会话的 reasoning)——比正文再淡一档
|
|
465
|
+
'.dsh-side-branch-inhthink{margin:2px 0 2px 12px;font-size:12px;line-height:18px;}',
|
|
466
|
+
'.dsh-side-branch-inhthink>summary{cursor:pointer;color:var(--dsw-alias-label-caption,#a0a0a0);}',
|
|
467
|
+
'.dsh-side-branch-inhrow{display:flex;gap:6px;padding:2px 0 2px 12px;white-space:pre-wrap;word-break:break-word;}',
|
|
468
|
+
'.dsh-side-branch-inhtag{flex:none;color:var(--dsw-alias-label-caption,#a0a0a0);}',
|
|
469
|
+
'.dsh-side-branch-notice{white-space:pre-wrap;word-break:break-word;margin:4px 0 0 12px;font-family:monospace;font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary,#8b8b8b);}',
|
|
450
470
|
// ── 多轮追问:每轮 = 问题气泡(在上)→ 可折叠「思考」行 → 答案
|
|
451
471
|
'.dsh-side-branch-rounds{display:flex;flex-direction:column;}',
|
|
452
472
|
'.dsh-side-branch-round{display:flex;flex-direction:column;gap:8px;margin-bottom:18px;}',
|
|
@@ -512,6 +532,81 @@ window.__ModuleLoader__.load({
|
|
|
512
532
|
* (`POST /side-branch/close` 释放侧会话)并把面板清干净 ⇒ 下次提问就是新的一段。
|
|
513
533
|
*/
|
|
514
534
|
|
|
535
|
+
// ──────────────────────────────────────────────────────────── 段落(多段回答)的形状与上限
|
|
536
|
+
//
|
|
537
|
+
// ⚠️⚠️ **这几个必须留在模块作用域(`createPanelStore` 与 `SideBranchBody` 之外)。**
|
|
538
|
+
// 它们**两边都要用**:store 里 `normalizeRound` 用它做持久化形状校验;
|
|
539
|
+
// 组件里 `appendSegment` / `replaceLastSegment` / `snapshot` 处理函数用它做实时写入。
|
|
540
|
+
// 踩过的坑:把它们 `const` 声明在 `createPanelStore()` **内部**,而组件在**另一个函数**里 ——
|
|
541
|
+
// 于是第一个 `delta` 一到就抛 `ReferenceError: MAX_SEGMENTS is not defined`,
|
|
542
|
+
// 被浏览器的事件派发**吞掉**(只在 console 里),现象就是"问了之后答案永远不出现"。
|
|
543
|
+
// `node --check`、`scripts/smoke.mjs` 的正则、端到端 harness(它只打 HTTP 路由)三处都查不出来
|
|
544
|
+
// ⇒ 所以补了 `scripts/client-check.mjs`(真的把客户端跑起来)。
|
|
545
|
+
// ⛔ 别把它们挪回任何一个函数内部。
|
|
546
|
+
/** 字符串收敛(超长截断;非字符串给空串)。 */
|
|
547
|
+
const clampString = (value, max) => (typeof value === 'string' ? value.slice(0, max) : '')
|
|
548
|
+
/** 段落种类白名单(与宿主 `createJob` 的 kind 同源)。 */
|
|
549
|
+
const SEGMENT_KINDS = new Set(['reasoning', 'text', 'tool'])
|
|
550
|
+
/** 一轮最多留多少段、每段多长、以及一**轮**的段落总字符预算
|
|
551
|
+
*(防止一份坏数据或一次狂长的多段回答把面板与 `sessionStorage` 配额撑爆)。 */
|
|
552
|
+
const MAX_SEGMENTS = 200
|
|
553
|
+
const MAX_SEGMENT_CHARS = 200000
|
|
554
|
+
const MAX_SEGMENTS_TOTAL_CHARS = 400000
|
|
555
|
+
/** 一「段」的形状校验(坏段直接丢弃,不整轮作废)。 */
|
|
556
|
+
const normalizeSegment = (raw) => {
|
|
557
|
+
if (raw === null || typeof raw !== 'object') return null
|
|
558
|
+
if (typeof raw.kind !== 'string' || SEGMENT_KINDS.has(raw.kind) !== true) return null
|
|
559
|
+
const out = { kind: raw.kind, text: clampString(raw.text, raw.kind === 'tool' ? 200 : MAX_SEGMENT_CHARS) }
|
|
560
|
+
// `turn`/`step` 只为"同一 step 重试时精确定位该丢哪几段"而留(很小,随段落一起落盘)
|
|
561
|
+
if (typeof raw.turn === 'number' && Number.isFinite(raw.turn)) out.turn = raw.turn
|
|
562
|
+
if (typeof raw.step === 'number' && Number.isFinite(raw.step)) out.step = raw.step
|
|
563
|
+
return out
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* 把段落数组的总字符数压到预算内:**从最早的那几段**开始砍(保留最近的,
|
|
567
|
+
* 与"面板上最该看得见的是最新输出"一致)。超预算时逐段缩短,最后必要时丢掉最早的整段。
|
|
568
|
+
* @param {Array<{kind: string, text: string}>} list - 段落数组
|
|
569
|
+
* @returns {Array<{kind: string, text: string}>}
|
|
570
|
+
*/
|
|
571
|
+
const trimSegments = (list) => {
|
|
572
|
+
let total = 0
|
|
573
|
+
for (const segment of list) total += segment.text.length
|
|
574
|
+
if (total <= MAX_SEGMENTS_TOTAL_CHARS) return list
|
|
575
|
+
const kept = list.slice()
|
|
576
|
+
let index = 0
|
|
577
|
+
while (total > MAX_SEGMENTS_TOTAL_CHARS && index < kept.length) {
|
|
578
|
+
const segment = kept[index]
|
|
579
|
+
const room = Math.max(0, segment.text.length - (total - MAX_SEGMENTS_TOTAL_CHARS))
|
|
580
|
+
if (room === 0) {
|
|
581
|
+
total -= segment.text.length
|
|
582
|
+
kept[index] = null
|
|
583
|
+
} else {
|
|
584
|
+
total -= segment.text.length - room
|
|
585
|
+
kept[index] = { ...segment, text: segment.text.slice(0, room) }
|
|
586
|
+
}
|
|
587
|
+
index += 1
|
|
588
|
+
}
|
|
589
|
+
return kept.filter((item) => item !== null)
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* ★ 段落的形状校验 + **向后兼容**。
|
|
593
|
+
*
|
|
594
|
+
* 老的持久化数据**没有** `segments`(那时 job 只有 `answer`/`reasoning` 两个槽位)
|
|
595
|
+
* ⇒ 由它们**合成**段落(`reasoning` 段 + `text` 段),别让刷新后的老面板变空。
|
|
596
|
+
* @param {unknown} raw - 持久化里的 `segments`
|
|
597
|
+
* @param {string} answer - 该轮的 `answer`(最后一段正文)
|
|
598
|
+
* @param {string} reasoning - 该轮的 `reasoning`(最后一段推理)
|
|
599
|
+
* @returns {Array<{kind: string, text: string}>}
|
|
600
|
+
*/
|
|
601
|
+
const normalizeSegments = (raw, answer, reasoning) => {
|
|
602
|
+
const list = Array.isArray(raw) ? raw.slice(-MAX_SEGMENTS).map(normalizeSegment).filter((item) => item !== null) : []
|
|
603
|
+
if (list.length > 0) return trimSegments(list)
|
|
604
|
+
const synthesized = []
|
|
605
|
+
if (reasoning !== '') synthesized.push({ kind: 'reasoning', text: reasoning })
|
|
606
|
+
if (answer !== '') synthesized.push({ kind: 'text', text: answer })
|
|
607
|
+
return synthesized
|
|
608
|
+
}
|
|
609
|
+
|
|
515
610
|
/**
|
|
516
611
|
* 面板状态 store:按 (sessionId, paneId) 分桶,切 tab / 切会话后仍能恢复。
|
|
517
612
|
* 极小实现(不引入官方 `dsh-client-store` 包):read/write/subscribe。
|
|
@@ -546,7 +641,10 @@ window.__ModuleLoader__.load({
|
|
|
546
641
|
question: '',
|
|
547
642
|
/**
|
|
548
643
|
* 多轮追问:**轮次数组**。
|
|
549
|
-
* 每轮 = `{ id, question, answer, reasoning, tool, phase, error, newSegment }`。
|
|
644
|
+
* 每轮 = `{ id, question, answer, reasoning, tool, segments, phase, error, newSegment }`。
|
|
645
|
+
* ★ `segments` = **有序段落**(`{kind:'reasoning'|'text'|'tool', text}`):一个回合里
|
|
646
|
+
* 模型可能"说一段 → 调工具 → 再说一段",面板按数组顺序渲染,中间的段落不再丢。
|
|
647
|
+
* `answer` / `reasoning` 仍保留(是最后一段的快捷引用,老数据也靠它们合成段落)。
|
|
550
648
|
* `phase` ∈ `running` | `done` | `stopped` | `error`。
|
|
551
649
|
*/
|
|
552
650
|
rounds: [],
|
|
@@ -569,6 +667,16 @@ window.__ModuleLoader__.load({
|
|
|
569
667
|
* ⇒ 切 tab / 切会话回来仍在(与本面板其它状态同源)。
|
|
570
668
|
*/
|
|
571
669
|
selection: null,
|
|
670
|
+
/**
|
|
671
|
+
* ★ 层 1:主会话**这次**贡献了多少(宿主在 `/start` 响应里给)。
|
|
672
|
+
* `{ turns, chars }`;`null` = 宿主还没说过(老数据 / 还没提过问)。
|
|
673
|
+
*/
|
|
674
|
+
synced: null,
|
|
675
|
+
/**
|
|
676
|
+
* ★ 层 1:本段**注入给模型的分支引导词全文**(宿主在第一轮告诉我们的)。
|
|
677
|
+
* 只用于"让用户看见到底注入了什么";模型看到的文本一律英文,与界面语言无关。
|
|
678
|
+
*/
|
|
679
|
+
notice: '',
|
|
572
680
|
})
|
|
573
681
|
/** key 的分隔符(`sessionId\0paneId`)。 */
|
|
574
682
|
const SEP = String.fromCharCode(0)
|
|
@@ -588,19 +696,32 @@ window.__ModuleLoader__.load({
|
|
|
588
696
|
}
|
|
589
697
|
return undefined
|
|
590
698
|
})()
|
|
591
|
-
/**
|
|
592
|
-
const
|
|
699
|
+
/** `synced`(`{turns, chars}`)的形状校验;坏数据当 `null`。 */
|
|
700
|
+
const normalizeSynced = (raw) => {
|
|
701
|
+
if (raw === null || raw === undefined || typeof raw !== 'object') return null
|
|
702
|
+
const turns = typeof raw.turns === 'number' && Number.isFinite(raw.turns) ? Math.max(0, Math.floor(raw.turns)) : 0
|
|
703
|
+
const chars = typeof raw.chars === 'number' && Number.isFinite(raw.chars) ? Math.max(0, Math.floor(raw.chars)) : 0
|
|
704
|
+
return { turns, chars }
|
|
705
|
+
}
|
|
593
706
|
/** 一轮的形状校验(补默认;坏数据不进面板)。 */
|
|
594
707
|
const normalizeRound = (raw) => {
|
|
595
708
|
const round = raw !== null && typeof raw === 'object' ? raw : {}
|
|
709
|
+
const answer = clampString(round.answer, 200000)
|
|
710
|
+
const reasoning = clampString(round.reasoning, 200000)
|
|
596
711
|
return {
|
|
597
712
|
id: clampString(round.id, 64),
|
|
598
713
|
question: clampString(round.question, 20000),
|
|
599
|
-
|
|
600
|
-
|
|
714
|
+
// ★ 引用原文(随轮入档)⇒ 刷新后引用块还在。
|
|
715
|
+
// 上限对齐 `MAX_REFERENCE_LENGTH`,再多留一点给截断标记
|
|
716
|
+
// (标记是在切片**之后**追加的,见 clipReference)。
|
|
717
|
+
reference: clampString(round.reference, MAX_REFERENCE_LENGTH + 64),
|
|
718
|
+
answer,
|
|
719
|
+
reasoning,
|
|
601
720
|
// ⚠️ `tool` 必须一起落盘:刷新页面后要**续上**未结束的流,
|
|
602
721
|
// 少了它,重连回来的那一轮就不显示「正在使用工具:…」了。
|
|
603
722
|
tool: clampString(round.tool, 200),
|
|
723
|
+
// ★ 多段:老数据没有它 ⇒ 由 `reasoning`/`answer` 合成(见 `normalizeSegments`)
|
|
724
|
+
segments: normalizeSegments(round.segments, answer, reasoning),
|
|
604
725
|
phase: typeof round.phase === 'string' ? round.phase : 'done',
|
|
605
726
|
error: clampString(round.error, 4000),
|
|
606
727
|
newSegment: round.newSegment === true,
|
|
@@ -628,6 +749,9 @@ window.__ModuleLoader__.load({
|
|
|
628
749
|
busy: raw.busy === true,
|
|
629
750
|
jobId: typeof raw.jobId === 'string' && raw.jobId !== '' ? raw.jobId : null,
|
|
630
751
|
selection: normalizeSelection(raw.selection),
|
|
752
|
+
// ★ 层 1 的两项:主会话贡献摘要 + 注入的分支引导词全文
|
|
753
|
+
synced: normalizeSynced(raw.synced),
|
|
754
|
+
notice: clampString(raw.notice, 4000),
|
|
631
755
|
}
|
|
632
756
|
}
|
|
633
757
|
/** 读回一次(坏 JSON / 坏形状一律当空;只读不写)。 */
|
|
@@ -880,8 +1004,27 @@ window.__ModuleLoader__.load({
|
|
|
880
1004
|
'panel.openTabHint': '无法自动打开右侧栏({message})——请手动展开,并在「临时会话」中继续',
|
|
881
1005
|
// 引用块左侧那颗「引用」chip 的文字
|
|
882
1006
|
'panel.quoteChip': '引用',
|
|
883
|
-
//
|
|
884
|
-
'panel.
|
|
1007
|
+
// ★ 轮次里那个可折叠的引用块(引用随轮入档)——与「思考 · N 字」同款形态
|
|
1008
|
+
'panel.referenceChip': '引用原文 · {n} 字',
|
|
1009
|
+
// 工具已经调过(多段渲染里工具是**段落序列**的一员,跑完仍要看得见)
|
|
1010
|
+
'panel.toolUsed': '工具:',
|
|
1011
|
+
// ★ 层 1:「继承了什么」那一行(re-fork:每轮追问都用主会话**最新**内容重建历史)
|
|
1012
|
+
'panel.inheritedLine': '已继承主会话 {turns} 轮 · 约 {chars} 字',
|
|
1013
|
+
'panel.inheritedNone': '尚未继承任何已完成的回合(主会话还没有答完一轮)',
|
|
1014
|
+
'panel.inheritedShow': '查看继承内容',
|
|
1015
|
+
'panel.inheritedHide': '收起继承内容',
|
|
1016
|
+
'panel.inheritedLoading': '正在读取继承内容…',
|
|
1017
|
+
'panel.inheritedTurn': '第 {n} 轮',
|
|
1018
|
+
'panel.inheritedAsk': '问',
|
|
1019
|
+
'panel.inheritedAnswer': '答',
|
|
1020
|
+
// ★ 主会话的**思考**也在继承里(种子里的 reasoning 块;会作为 thinking 发给模型)
|
|
1021
|
+
'panel.inheritedThink': '思考',
|
|
1022
|
+
'panel.inheritedChars': '{n} 字',
|
|
1023
|
+
'panel.inheritedTruncated': '(已截断,仅显示摘要)',
|
|
1024
|
+
'panel.inheritedNewer': '主会话此后又有新回合;下一次追问会带上它们',
|
|
1025
|
+
// ★ 层 1:注入给模型的分支引导词(本插件注入的**只有它**;系统提示词/工具声明是 DSH 自己组装的)
|
|
1026
|
+
'panel.noticeLine': '注入给模型的分支引导词',
|
|
1027
|
+
'panel.noticeHint': '(英文,与界面语言无关;改它会让本段的前缀缓存失效)',
|
|
885
1028
|
// 可折叠的「思考」行(数值/CSS 照官方 `ReasoningRow`)
|
|
886
1029
|
'panel.think': '思考',
|
|
887
1030
|
// ★ 「清空」= 输入栏左下角那个按钮(结束这一段并另起一段)。
|
|
@@ -941,7 +1084,25 @@ window.__ModuleLoader__.load({
|
|
|
941
1084
|
'panel.streamLost': 'Connection lost. Retry later or check logs.',
|
|
942
1085
|
'panel.openTabHint': 'Could not open the right sidebar automatically ({message}) — expand it and continue in "Side Ask"',
|
|
943
1086
|
'panel.quoteChip': 'Quote',
|
|
944
|
-
'panel.
|
|
1087
|
+
'panel.referenceChip': 'Quoted text · {n} chars',
|
|
1088
|
+
// 工具已经调过(多段渲染里工具是**段落序列**的一员,跑完仍要看得见)
|
|
1089
|
+
'panel.toolUsed': 'Tool: ',
|
|
1090
|
+
// ★ 层 1:「继承了什么」那一行(与中文词典**同键集**)
|
|
1091
|
+
'panel.inheritedLine': 'Inherited {turns} main-session turns · ~{chars} chars',
|
|
1092
|
+
'panel.inheritedNone': 'Nothing inherited yet (the main session has not finished a turn)',
|
|
1093
|
+
'panel.inheritedShow': 'Show inherited content',
|
|
1094
|
+
'panel.inheritedHide': 'Hide inherited content',
|
|
1095
|
+
'panel.inheritedLoading': 'Loading inherited content…',
|
|
1096
|
+
'panel.inheritedTurn': 'Turn {n}',
|
|
1097
|
+
'panel.inheritedAsk': 'Q',
|
|
1098
|
+
'panel.inheritedAnswer': 'A',
|
|
1099
|
+
'panel.inheritedThink': 'Thinking',
|
|
1100
|
+
'panel.inheritedChars': '{n} chars',
|
|
1101
|
+
'panel.inheritedTruncated': '(truncated to a summary)',
|
|
1102
|
+
'panel.inheritedNewer': 'The main session has newer turns; the next follow-up will include them',
|
|
1103
|
+
// ★ 层 1:注入给模型的分支引导词
|
|
1104
|
+
'panel.noticeLine': 'Branch notice injected into the model',
|
|
1105
|
+
'panel.noticeHint': '(English regardless of interface language; changing it invalidates this segment\u2019s prefix cache)',
|
|
945
1106
|
// 多轮追问(与中文词典**同键集**,两边必须同时改)
|
|
946
1107
|
'panel.think': 'Thinking',
|
|
947
1108
|
'panel.clearChat': 'Clear',
|
|
@@ -1119,6 +1280,8 @@ window.__ModuleLoader__.load({
|
|
|
1119
1280
|
const listModels = props.listModels
|
|
1120
1281
|
/** ★ 设置的读写(宿主半的 `/side-branch/settings`)。 */
|
|
1121
1282
|
const fetchSettings = props.fetchSettings
|
|
1283
|
+
/** ★ 层 2:按需读「继承到的主会话历史」(只读,结果只放内存)。 */
|
|
1284
|
+
const fetchInherited = props.fetchInherited
|
|
1122
1285
|
const saveSettings = props.saveSettings
|
|
1123
1286
|
|
|
1124
1287
|
/**
|
|
@@ -1151,6 +1314,10 @@ window.__ModuleLoader__.load({
|
|
|
1151
1314
|
const rounds = Array.isArray(state.rounds) ? state.rounds : []
|
|
1152
1315
|
/** 这一段临时会话在宿主侧的会话 id(`null` = 下次提问新开一段)。 */
|
|
1153
1316
|
const conversationId = typeof state.conversationId === 'string' && state.conversationId !== '' ? state.conversationId : null
|
|
1317
|
+
/** ★ 层 1:主会话这次贡献了多少(`{turns, chars}`;宿主在 `/start` 里给)。 */
|
|
1318
|
+
const synced = state.synced ?? null
|
|
1319
|
+
/** ★ 层 1:本段注入给模型的分支引导词全文(宿主在第一轮给)。 */
|
|
1320
|
+
const notice = typeof state.notice === 'string' ? state.notice : ''
|
|
1154
1321
|
const error = state.error
|
|
1155
1322
|
const busy = state.busy
|
|
1156
1323
|
/** 模型覆盖:`null` = 跟随会话 */
|
|
@@ -1176,6 +1343,99 @@ window.__ModuleLoader__.load({
|
|
|
1176
1343
|
return Array.isArray(list) && list.length > 0 ? list[list.length - 1] : undefined
|
|
1177
1344
|
}
|
|
1178
1345
|
|
|
1346
|
+
// ── ★ 多段(有序段落)的写入助手 ─────────────────────────────────────
|
|
1347
|
+
//
|
|
1348
|
+
// 为什么要有它们:一个回合里模型可能"说一段 → 调工具 → 再说一段",
|
|
1349
|
+
// 每一段都会开一次新的流式 attempt(宿主因此发 `segment` 事件)。
|
|
1350
|
+
// 渲染必须**按段落的数组顺序**,所以写入也只能往这个序列里追加,
|
|
1351
|
+
// 而不是像老版本那样只往 `answer` 这一个字符串上拼。
|
|
1352
|
+
/** 读当前轮的段落数组(永远是数组)。 */
|
|
1353
|
+
const currentSegments = () => {
|
|
1354
|
+
const current = currentRound()
|
|
1355
|
+
return current !== undefined && Array.isArray(current.segments) ? current.segments : []
|
|
1356
|
+
}
|
|
1357
|
+
/**
|
|
1358
|
+
* 段落数组 → 两个快捷引用(`answer`/`reasoning`)。
|
|
1359
|
+
*
|
|
1360
|
+
* ⚠️ 语义与**宿主完全一致**:= 本轮**全部**正文段落(或推理段落)按顺序拼接。
|
|
1361
|
+
* 两处算法必须是同一个,否则 `snapshot` 回来时会把面板上的正文"改短"。
|
|
1362
|
+
* @param {Array<object>} list - 段落数组
|
|
1363
|
+
* @returns {{answer: string, reasoning: string}}
|
|
1364
|
+
*/
|
|
1365
|
+
const shortcutsOf = (list) => {
|
|
1366
|
+
let answer = ''
|
|
1367
|
+
let reasoning = ''
|
|
1368
|
+
for (const segment of list) {
|
|
1369
|
+
if (segment.kind === 'text') answer += segment.text
|
|
1370
|
+
else if (segment.kind === 'reasoning') reasoning += segment.text
|
|
1371
|
+
}
|
|
1372
|
+
return { answer, reasoning }
|
|
1373
|
+
}
|
|
1374
|
+
/**
|
|
1375
|
+
* 往当前轮追加内容:与**最后一段**同 kind(且同 turn/step)⇒ 接着写这一段;否则**开新段**。
|
|
1376
|
+
*
|
|
1377
|
+
* ⚠️ **一次写入**(段落 + 快捷引用同一个 patch):`patchRound` 每调一次都会通知订阅者
|
|
1378
|
+
* 重渲染,逐词流式下一帧写两次会让每个 token 渲染两遍。
|
|
1379
|
+
* @param {'reasoning'|'text'|'tool'} kind - 段落种类
|
|
1380
|
+
* @param {string} text - 追加的文本(工具段是工具名)
|
|
1381
|
+
* @param {number|undefined} turn - 宿主给的归属(用于 `reset` 精确定位)
|
|
1382
|
+
* @param {number|undefined} step - 同上
|
|
1383
|
+
*/
|
|
1384
|
+
const appendSegment = (kind, text, turn, step) => {
|
|
1385
|
+
const current = currentRound()
|
|
1386
|
+
if (current === undefined) return
|
|
1387
|
+
const list = (Array.isArray(current.segments) ? current.segments : []).slice()
|
|
1388
|
+
const last = list[list.length - 1]
|
|
1389
|
+
const samePlace = last !== undefined && last.turn === turn && last.step === step
|
|
1390
|
+
if (last !== undefined && last.kind === kind && samePlace === true) {
|
|
1391
|
+
list[list.length - 1] = { ...last, text: last.text + text }
|
|
1392
|
+
} else {
|
|
1393
|
+
const item = { kind, text }
|
|
1394
|
+
if (turn !== undefined) item.turn = turn
|
|
1395
|
+
if (step !== undefined) item.step = step
|
|
1396
|
+
list.push(item)
|
|
1397
|
+
}
|
|
1398
|
+
const kept = list.slice(-MAX_SEGMENTS)
|
|
1399
|
+
patchRound({ segments: kept, ...shortcutsOf(kept), ...(kind === 'tool' ? { tool: text } : {}) })
|
|
1400
|
+
}
|
|
1401
|
+
/**
|
|
1402
|
+
* 把当前轮**最后一段同 kind** 的文本整段替换(宿主 `replace` 事件)。
|
|
1403
|
+
* 宿主只会在"终局文本 ≠ 累积文本"时发它。
|
|
1404
|
+
*/
|
|
1405
|
+
const replaceLastSegment = (kind, text) => {
|
|
1406
|
+
const current = currentRound()
|
|
1407
|
+
if (current === undefined) return
|
|
1408
|
+
const list = (Array.isArray(current.segments) ? current.segments : []).slice()
|
|
1409
|
+
let found = false
|
|
1410
|
+
for (let i = list.length - 1; i >= 0; i -= 1) {
|
|
1411
|
+
if (list[i].kind !== kind) continue
|
|
1412
|
+
list[i] = { ...list[i], text }
|
|
1413
|
+
found = true
|
|
1414
|
+
break
|
|
1415
|
+
}
|
|
1416
|
+
if (found !== true) list.push({ kind, text })
|
|
1417
|
+
const kept = list.slice(-MAX_SEGMENTS)
|
|
1418
|
+
patchRound({ segments: kept, ...shortcutsOf(kept) })
|
|
1419
|
+
}
|
|
1420
|
+
/**
|
|
1421
|
+
* 丢掉**当前 step** 已经产出的段落(宿主 `reset` 事件 = 同一个 step 内重试)。
|
|
1422
|
+
*
|
|
1423
|
+
* 判据优先用宿主给的 `turn`/`step`(**精确定位**);拿不到就退回"这一步段落的起点下标"
|
|
1424
|
+
* (`segmentMarkRef`,在收到 `segment` / `snapshot` 时记录)。
|
|
1425
|
+
* ⚠️ 绝不能整轮清空 —— 那样前面几个 step 的思考与正文又会被抹掉(就是那个 BUG)。
|
|
1426
|
+
* @param {number|undefined} turn - 宿主 `reset` 载荷里的回合号
|
|
1427
|
+
* @param {number|undefined} step - 同上
|
|
1428
|
+
*/
|
|
1429
|
+
const dropCurrentStepSegments = (turn, step) => {
|
|
1430
|
+
const list = currentSegments()
|
|
1431
|
+
const precise = typeof turn === 'number' && typeof step === 'number'
|
|
1432
|
+
const kept = precise
|
|
1433
|
+
? list.filter((segment) => !(segment.turn === turn && segment.step === step))
|
|
1434
|
+
: list.slice(0, typeof segmentMarkRef.current === 'number' ? segmentMarkRef.current : 0)
|
|
1435
|
+
patchRound({ segments: kept, ...shortcutsOf(kept), tool: '' })
|
|
1436
|
+
segmentMarkRef.current = kept.length
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1179
1439
|
// ── 模型选择器的 UI 状态(只有 `selection` 需要持久 ⇒ 那个在 store 里)
|
|
1180
1440
|
/** 官方模型目录快照;`null` = 还没拉过 */
|
|
1181
1441
|
const [models, setModels] = react.useState(null)
|
|
@@ -1197,6 +1457,39 @@ window.__ModuleLoader__.load({
|
|
|
1197
1457
|
/** 悬浮窗元素(用来量尺寸 / 判断"点到里面没有")。 */
|
|
1198
1458
|
const menuRef = react.useRef(null)
|
|
1199
1459
|
|
|
1460
|
+
// ── ★ 层 2:「继承来的主会话历史」按需展开 ───────────────────────────
|
|
1461
|
+
//
|
|
1462
|
+
// ⛔ **这份数据只放内存,绝不进 `rounds` / `normalizeRound` / 持久化状态**:
|
|
1463
|
+
// 一份主会话前缀可能有几十万字符,而面板状态走 `sessionStorage`(5–10 MB 配额),
|
|
1464
|
+
// 塞进去会立刻爆配额、坏掉"刷新不丢"这个现有特性。所以每次展开**按需向宿主拉一次**。
|
|
1465
|
+
const [inherited, setInherited] = react.useState(null)
|
|
1466
|
+
const [inheritedOpen, setInheritedOpen] = react.useState(false)
|
|
1467
|
+
const [inheritedBusy, setInheritedBusy] = react.useState(false)
|
|
1468
|
+
const [inheritedError, setInheritedError] = react.useState('')
|
|
1469
|
+
/** 换段(re-fork)之后旧的那份继承内容就作废 ⇒ 关掉并丢掉。 */
|
|
1470
|
+
react.useEffect(() => {
|
|
1471
|
+
setInherited(null)
|
|
1472
|
+
setInheritedError('')
|
|
1473
|
+
}, [conversationId])
|
|
1474
|
+
/** 展开时按需拉一次(收起不拉;`conversationId` 为空时没法拉)。 */
|
|
1475
|
+
const toggleInherited = async () => {
|
|
1476
|
+
const next = inheritedOpen !== true
|
|
1477
|
+
setInheritedOpen(next)
|
|
1478
|
+
if (next !== true || inherited !== null || inheritedBusy === true) return
|
|
1479
|
+
if (typeof conversationId !== 'string' || conversationId === '') return
|
|
1480
|
+
setInheritedBusy(true)
|
|
1481
|
+
setInheritedError('')
|
|
1482
|
+
try {
|
|
1483
|
+
const data = await fetchInherited(conversationId)
|
|
1484
|
+
if (data?.error !== undefined) throw new Error(String(data.error))
|
|
1485
|
+
setInherited(data)
|
|
1486
|
+
} catch (cause) {
|
|
1487
|
+
setInheritedError(errorText(cause, t))
|
|
1488
|
+
} finally {
|
|
1489
|
+
setInheritedBusy(false)
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1200
1493
|
// 官方行为:**点菜单外部就关**(`model-selection\lib\client.js:448-452` 的 closeOutside)。
|
|
1201
1494
|
//
|
|
1202
1495
|
// 用 `closest('[data-side-branch-menu],[data-side-branch-trigger]')` 判归属,而不是比 DOM 引用:
|
|
@@ -1277,6 +1570,12 @@ window.__ModuleLoader__.load({
|
|
|
1277
1570
|
const jobRef = react.useRef(state.jobId)
|
|
1278
1571
|
/** 防重入:不依赖 `busy` 渲染态(React 状态更新是异步的,极快双击会漏过按钮禁用) */
|
|
1279
1572
|
const inFlightRef = react.useRef(false)
|
|
1573
|
+
/**
|
|
1574
|
+
* ★ 当前 step 在**段落数组**里的起点下标(收到宿主 `segment` 事件时刷新)。
|
|
1575
|
+
* 用途只有一个:同一 step 内重试(宿主 `reset`)时**只截掉这一个 step** 的段落。
|
|
1576
|
+
* ⚠️ 只在内存里,不进 store(它是流式期的临时记账)。
|
|
1577
|
+
*/
|
|
1578
|
+
const segmentMarkRef = react.useRef(0)
|
|
1280
1579
|
|
|
1281
1580
|
// ── 键盘 / IME / 自动滚底
|
|
1282
1581
|
/**
|
|
@@ -1318,14 +1617,30 @@ window.__ModuleLoader__.load({
|
|
|
1318
1617
|
}
|
|
1319
1618
|
}
|
|
1320
1619
|
}, [sessionId])
|
|
1321
|
-
/**
|
|
1322
|
-
|
|
1620
|
+
/**
|
|
1621
|
+
* **真正可滚动的那一层**(`.dsh-side-branch-scroll{flex:1;min-height:0;overflow-y:auto}`)。
|
|
1622
|
+
*
|
|
1623
|
+
* ⚠️ 血的教训:这个 ref 原来挂在 `.dsh-side-branch-rounds`(轮次列表)上,而那个 div 是
|
|
1624
|
+
* `display:flex;flex-direction:column`、**不滚动** ⇒ `scrollTop = scrollHeight` 是**空操作**,
|
|
1625
|
+
* 自动滚底其实**从来没生效过**。以前内容短、不容易看出来;0.2.0 在轮次**上面**加了
|
|
1626
|
+
* 「已继承 / 注入」那一块(可以展开成很长)之后,新答案就落到可视区之外了 ——
|
|
1627
|
+
* 现象是"问了之后面板像卡住一样,回答不出现"(其实回答已经在 DOM 里,只是没滚下去)。
|
|
1628
|
+
* ⛔ 别把它挪回内层。
|
|
1629
|
+
*/
|
|
1630
|
+
const scrollRef = react.useRef(null)
|
|
1323
1631
|
react.useLayoutEffect(() => {
|
|
1324
|
-
const node =
|
|
1632
|
+
const node = scrollRef.current
|
|
1325
1633
|
if (node === null || node === undefined) return
|
|
1326
1634
|
node.scrollTop = node.scrollHeight
|
|
1327
|
-
// 依赖:最后一轮的正文
|
|
1328
|
-
|
|
1635
|
+
// 依赖:最后一轮的正文 / 推理 / **段落数**(多段下内容都长在最后一轮里),
|
|
1636
|
+
// 以及**继承区是否展开**(它也在滚动容器里,展开会把下方内容顶出去)。
|
|
1637
|
+
}, [
|
|
1638
|
+
rounds.length,
|
|
1639
|
+
rounds[rounds.length - 1]?.answer,
|
|
1640
|
+
rounds[rounds.length - 1]?.reasoning,
|
|
1641
|
+
rounds[rounds.length - 1]?.segments?.length,
|
|
1642
|
+
inheritedOpen,
|
|
1643
|
+
])
|
|
1329
1644
|
|
|
1330
1645
|
const write = (patch) => panelStore.write(sessionId, paneId, patch)
|
|
1331
1646
|
/** 把异常写进面板错误框(文案在这里统一走词典)。 */
|
|
@@ -1402,50 +1717,65 @@ window.__ModuleLoader__.load({
|
|
|
1402
1717
|
if (jobRef.current !== mine) return
|
|
1403
1718
|
const payload = parseEvent(event)
|
|
1404
1719
|
if (payload === undefined) return
|
|
1405
|
-
//
|
|
1720
|
+
// 补连/重连:把宿主已经累积的正文、推理**与有序段落**补进这一轮
|
|
1406
1721
|
const patch = {}
|
|
1407
1722
|
if (typeof payload.text === 'string' && payload.text !== '') patch.answer = payload.text
|
|
1408
1723
|
if (typeof payload.reasoning === 'string' && payload.reasoning !== '') patch.reasoning = payload.reasoning
|
|
1724
|
+
// ★ 段落是**权威形状**(刷新/重连后靠它还原"思考 → 正文 → 工具 → 思考 → 正文")
|
|
1725
|
+
if (Array.isArray(payload.segments)) {
|
|
1726
|
+
patch.segments = payload.segments.slice(-MAX_SEGMENTS).map(normalizeSegment).filter((item) => item !== null)
|
|
1727
|
+
}
|
|
1409
1728
|
if (payload.stats !== undefined && payload.stats !== null) patch.stats = payload.stats
|
|
1410
1729
|
if (Object.keys(patch).length > 0) patchRound(patch)
|
|
1730
|
+
// 重连时把"当前 step 的起点"对齐到段落末尾(后面的 reset 才有正确的截点)
|
|
1731
|
+
segmentMarkRef.current = Array.isArray(patch.segments) ? patch.segments.length : currentSegments().length
|
|
1411
1732
|
if (payload.status === 'error') {
|
|
1412
1733
|
settle({ error: payload.error ?? t('panel.childFailed') }, 'error')
|
|
1413
1734
|
} else if (payload.status === 'done' || payload.status === 'stopped') {
|
|
1414
1735
|
settle({}, payload.status)
|
|
1415
1736
|
}
|
|
1416
1737
|
})
|
|
1738
|
+
// ★ 多段:一个回合里进入下一个 step ⇒ 开一个新段(**绝不删旧的**)
|
|
1739
|
+
on('segment', (event) => {
|
|
1740
|
+
if (jobRef.current !== mine) return
|
|
1741
|
+
segmentMarkRef.current = currentSegments().length
|
|
1742
|
+
})
|
|
1417
1743
|
on('delta', (event) => {
|
|
1418
1744
|
if (jobRef.current !== mine) return
|
|
1419
1745
|
const payload = parseEvent(event)
|
|
1420
1746
|
if (payload === undefined || typeof payload.text !== 'string' || payload.text === '') return
|
|
1421
|
-
// ★
|
|
1422
|
-
|
|
1423
|
-
patchRound({ answer: (current?.answer ?? '') + payload.text })
|
|
1747
|
+
// ★ 追加到**当前段**(读当前值再拼,不依赖渲染态,避免丢字)
|
|
1748
|
+
appendSegment('text', payload.text, payload.turn, payload.step)
|
|
1424
1749
|
})
|
|
1425
1750
|
// ★ v2:推理增量(官方 `reasoning-delta`)——可折叠「思考」行的内容
|
|
1426
1751
|
on('reasoning', (event) => {
|
|
1427
1752
|
if (jobRef.current !== mine) return
|
|
1428
1753
|
const payload = parseEvent(event)
|
|
1429
1754
|
if (payload === undefined || typeof payload.text !== 'string' || payload.text === '') return
|
|
1430
|
-
|
|
1431
|
-
patchRound({ reasoning: (current?.reasoning ?? '') + payload.text })
|
|
1755
|
+
appendSegment('reasoning', payload.text, payload.turn, payload.step)
|
|
1432
1756
|
})
|
|
1433
|
-
on('reset', () => {
|
|
1757
|
+
on('reset', (event) => {
|
|
1434
1758
|
if (jobRef.current !== mine) return
|
|
1435
|
-
|
|
1436
|
-
|
|
1759
|
+
const payload = parseEvent(event)
|
|
1760
|
+
// ⚠️ **只丢当前 step**(宿主 `reset` 的语义 = 同一个 step 内重试)。
|
|
1761
|
+
// 老版本在这里清空整轮 ⇒ 前面几个 step 的思考与正文全没了(就是那个 BUG)。
|
|
1762
|
+
dropCurrentStepSegments(payload?.turn, payload?.step)
|
|
1437
1763
|
})
|
|
1438
1764
|
// ★ 工具调用提示:宿主只推工具名
|
|
1439
1765
|
on('tool', (event) => {
|
|
1440
1766
|
if (jobRef.current !== mine) return
|
|
1441
1767
|
const payload = parseEvent(event)
|
|
1442
1768
|
if (payload === undefined || typeof payload.name !== 'string' || payload.name === '') return
|
|
1443
|
-
|
|
1769
|
+
// 工具也进**段落序列**(跑完之后仍然看得见"这一步用了什么")
|
|
1770
|
+
appendSegment('tool', payload.name, payload.turn, payload.step)
|
|
1444
1771
|
})
|
|
1445
1772
|
on('replace', (event) => {
|
|
1446
1773
|
if (jobRef.current !== mine) return
|
|
1447
1774
|
const payload = parseEvent(event)
|
|
1448
|
-
if (payload !== undefined && typeof payload.text === 'string')
|
|
1775
|
+
if (payload !== undefined && typeof payload.text === 'string') {
|
|
1776
|
+
// ★ 只替换**当前段**(宿主也是这么发的):老版本整段替换会把前面几段覆盖掉
|
|
1777
|
+
replaceLastSegment('text', payload.text)
|
|
1778
|
+
}
|
|
1449
1779
|
})
|
|
1450
1780
|
// ⚠️ 终态事件**要带上载荷**(v2:里面有 `stats` 用量摘要),别写成 `settle({})`
|
|
1451
1781
|
on('done', (event) => settle(parseEvent(event) ?? {}, 'done'))
|
|
@@ -1654,13 +1984,22 @@ window.__ModuleLoader__.load({
|
|
|
1654
1984
|
inFlightRef.current = true
|
|
1655
1985
|
// 有引用时把原文包进结构化信封(动态围栏 + 8000 上限 + 反注入声明);
|
|
1656
1986
|
// 无引用时就是问题本身。实现见 buildReferenceEnvelope。
|
|
1657
|
-
|
|
1987
|
+
//
|
|
1988
|
+
// ★ 引用原文**只收窄一次**(`clipReference`),同一份既进 prompt 信封、
|
|
1989
|
+
// 又进这一轮的记录 ⇒ 面板上看到的引用与模型收到的**逐字一致**。
|
|
1990
|
+
// (`buildReferenceEnvelope` 内部也会 `clipReference`,但它对已收窄的文本
|
|
1991
|
+
// 是**幂等**的 —— 见那个函数的注释。)
|
|
1992
|
+
const reference = quoted === '' ? '' : clipReference(quoted).text
|
|
1993
|
+
const composed = buildReferenceEnvelope(text, reference, t)
|
|
1658
1994
|
closeStream()
|
|
1659
1995
|
jobRef.current = null
|
|
1660
1996
|
const round = {
|
|
1661
1997
|
id: 'r' + Date.now().toString(36),
|
|
1662
1998
|
// 面板上显示的是**原话**(引用信封不进界面)。⚠️ 问题必非空(ask 的守卫已挡住空问题)
|
|
1663
1999
|
question: text,
|
|
2000
|
+
// ★ 这一轮带进来的**引用原文**(空串 = 没引用)。随轮入档 ⇒ 渲染成可折叠的
|
|
2001
|
+
// 引用块,刷新后也还在;不再只是输入框上方那个"随时会消失"的临时槽。
|
|
2002
|
+
reference,
|
|
1664
2003
|
answer: '',
|
|
1665
2004
|
reasoning: '',
|
|
1666
2005
|
phase: 'running',
|
|
@@ -1670,6 +2009,11 @@ window.__ModuleLoader__.load({
|
|
|
1670
2009
|
}
|
|
1671
2010
|
// 草稿清空、追加新轮、清掉上一轮的错误框
|
|
1672
2011
|
write({ busy: true, error: '', question: '', rounds: [...rounds, round], jobId: null })
|
|
2012
|
+
// ★ 引用已经**随这一轮入档** ⇒ 引用槽立刻清空(下一轮从干净的输入框开始)。
|
|
2013
|
+
// ⚠️ 必须连 `quoteBus` 一起清:只清组件 state 的话,面板重挂载 / 换格时
|
|
2014
|
+
// 会把同一份引用**再捞回来**(与「清除引用」按钮是同一个动作)。
|
|
2015
|
+
quoteBus.clear(sessionId, paneId)
|
|
2016
|
+
setQuote('')
|
|
1673
2017
|
try {
|
|
1674
2018
|
if (sessionId === undefined || sessionId === null) throw new Error(t('panel.noSession'))
|
|
1675
2019
|
// 只有显式选过模型才把覆盖发下去(`null` ⇒ 宿主取父会话当下的模型)
|
|
@@ -1704,9 +2048,15 @@ window.__ModuleLoader__.load({
|
|
|
1704
2048
|
write({
|
|
1705
2049
|
jobId,
|
|
1706
2050
|
// 宿主确认/新建的侧会话 id —— 下一轮靠它继续。
|
|
1707
|
-
// ⚠️
|
|
1708
|
-
//
|
|
2051
|
+
// ⚠️ **re-fork:每轮追问宿主都会新建一段侧会话 ⇒ 这个 id 每轮都变**,
|
|
2052
|
+
// 所以这里必须**每轮都更新**它(否则下一轮拿着已释放的 id 去问,会被拒)。
|
|
2053
|
+
// 宿主每轮都会回 `handoff: true` ⇒ 下面那条"换段就画分隔线"的判据不会误触发,
|
|
2054
|
+
// 历史在视觉上是连续的(它**确实**是连续的:旧问答都被重放进新段了)。
|
|
1709
2055
|
conversationId: typeof started?.conversationId === 'string' ? started.conversationId : revived === true ? null : conversationId,
|
|
2056
|
+
// ★ 层 1:宿主告诉我们"这一次继承了多少",以及**注入的分支引导词全文**
|
|
2057
|
+
// (引导词只有**第一轮**会拼,所以只有那时候宿主才回它)
|
|
2058
|
+
...(started?.synced === undefined || started.synced === null ? {} : { synced: started.synced }),
|
|
2059
|
+
...(typeof started?.notice === 'string' && started.notice !== '' ? { notice: started.notice } : {}),
|
|
1710
2060
|
})
|
|
1711
2061
|
// 宿主说"这段已不存在 / 已回收",或**没带历史**地换了段(换了 id 且不是 handoff)
|
|
1712
2062
|
// ⇒ 上面那些轮次不再进模型,给他画一条分隔线,如实说明
|
|
@@ -1907,29 +2257,34 @@ window.__ModuleLoader__.load({
|
|
|
1907
2257
|
footnotes: t('panel.footnotes'),
|
|
1908
2258
|
}
|
|
1909
2259
|
/**
|
|
1910
|
-
*
|
|
1911
|
-
* @param {
|
|
1912
|
-
* @param {boolean} streaming -
|
|
2260
|
+
* 一段正文的渲染。
|
|
2261
|
+
* @param {string} text - 这一段的正文
|
|
2262
|
+
* @param {boolean} streaming - 这一段是否仍在流式(只有最后一轮的最后一段可能为真)
|
|
1913
2263
|
*/
|
|
1914
|
-
const renderAnswer = (
|
|
2264
|
+
const renderAnswer = (text, streaming) => {
|
|
1915
2265
|
// ⚠️ 判断条件必须是 `!= null`:`MarkdownText` 是 `React.memo` 的对象(见文件头注释),
|
|
1916
2266
|
// 用 `typeof === 'function'` 会永远为假 —— 会一直退回纯文本。
|
|
1917
2267
|
if (MarkdownText != null) {
|
|
1918
2268
|
return react.createElement(
|
|
1919
2269
|
// 安全网:官方组件抛异常时只让"这条答案"退回纯文本,不让整个面板白屏
|
|
1920
2270
|
MarkdownBoundary,
|
|
1921
|
-
{ key: 'md', text
|
|
1922
|
-
react.createElement(MarkdownText, { text
|
|
2271
|
+
{ key: 'md', text },
|
|
2272
|
+
react.createElement(MarkdownText, { text, streaming, labels }),
|
|
1923
2273
|
)
|
|
1924
2274
|
}
|
|
1925
2275
|
return react.createElement(
|
|
1926
2276
|
'div',
|
|
1927
2277
|
{ key: 'plain', style: { whiteSpace: 'pre-wrap', wordBreak: 'break-word' } },
|
|
1928
|
-
|
|
2278
|
+
text,
|
|
1929
2279
|
)
|
|
1930
2280
|
}
|
|
1931
2281
|
|
|
1932
|
-
/**
|
|
2282
|
+
/**
|
|
2283
|
+
* 一轮的完整渲染:问题气泡 → **按段落数组顺序**的(思考行 / 工具行 / 正文)→ 轮次小标签。
|
|
2284
|
+
*
|
|
2285
|
+
* ★ 为什么按数组顺序:一个回合里模型可能"说一段 → 调工具 → 再说一段",
|
|
2286
|
+
* 老版本只有 `reasoning`/`answer`/`tool` 三个槽位 ⇒ 中间的段落必然被覆盖掉。
|
|
2287
|
+
*/
|
|
1933
2288
|
const renderRound = (round, index) => {
|
|
1934
2289
|
const streaming = round.phase === 'running' && index === rounds.length - 1
|
|
1935
2290
|
const children = []
|
|
@@ -1937,34 +2292,61 @@ window.__ModuleLoader__.load({
|
|
|
1937
2292
|
// ① 宿主回 `conversationGone`(插件重载 / DSH 重启后,睡着的记录随内存消失)
|
|
1938
2293
|
// 后自动重开 ⇒ `ask` 补置 `newSegment`;
|
|
1939
2294
|
// ② 宿主没带 `handoff` 地换了段(`ask` 里的兜底判据,正常路径走不到)。
|
|
1940
|
-
//
|
|
1941
|
-
//
|
|
2295
|
+
// ⚠️ re-fork(每轮都用主会话最新内容重建历史)**带** `handoff: true` ⇒ **不画**:
|
|
2296
|
+
// 它的历史是接得上的,画了反而误导。
|
|
2297
|
+
// 「清空」把面板清空(`rounds` 为 0)、闲置唤醒是同一个 id ⇒ 这两种也不会画。
|
|
1942
2298
|
if (round.newSegment === true) {
|
|
1943
2299
|
children.push(react.createElement('div', { key: 'seg', className: 'dsh-side-branch-segbreak' }, t('panel.segmentBreak')))
|
|
1944
2300
|
}
|
|
2301
|
+
// ⓪ 这一轮带进来的**引用原文**(可折叠引用块;没有引用时**什么都不渲染**)。
|
|
2302
|
+
// 引用可能到 `MAX_REFERENCE_LENGTH` ⇒ 必须可折叠,且内容区自己滚。
|
|
2303
|
+
if (typeof round.reference === 'string' && round.reference !== '') {
|
|
2304
|
+
children.push(
|
|
2305
|
+
react.createElement(
|
|
2306
|
+
'details',
|
|
2307
|
+
{ key: 'ref', className: 'dsh-side-branch-refblock' },
|
|
2308
|
+
react.createElement('summary', { className: 'dsh-side-branch-refsummary' }, t('panel.referenceChip', { n: round.reference.length })),
|
|
2309
|
+
react.createElement('blockquote', { className: 'dsh-side-branch-refbody' }, round.reference),
|
|
2310
|
+
),
|
|
2311
|
+
)
|
|
2312
|
+
}
|
|
1945
2313
|
// ① 问题在**答案上方**(样式照官方主会话的用户气泡)
|
|
1946
2314
|
if (typeof round.question === 'string' && round.question !== '') {
|
|
1947
2315
|
children.push(react.createElement('div', { key: 'q', className: 'dsh-side-branch-qbubble' }, round.question))
|
|
1948
2316
|
}
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
2317
|
+
// ② **有序段落**(思考 → 正文 → 工具 → 思考 → 正文 …)
|
|
2318
|
+
const segments = Array.isArray(round.segments) ? round.segments : []
|
|
2319
|
+
segments.forEach((segment, i) => {
|
|
2320
|
+
const isLast = i === segments.length - 1
|
|
2321
|
+
if (segment.kind === 'reasoning') {
|
|
2322
|
+
children.push(
|
|
2323
|
+
react.createElement(ReasoningRow, {
|
|
2324
|
+
key: 'think' + i,
|
|
2325
|
+
text: segment.text,
|
|
2326
|
+
running: streaming === true && isLast === true,
|
|
2327
|
+
t,
|
|
2328
|
+
}),
|
|
2329
|
+
)
|
|
2330
|
+
return
|
|
2331
|
+
}
|
|
2332
|
+
if (segment.kind === 'tool') {
|
|
2333
|
+
// 工具行**跑完也保留**:它是"这一步干了什么"的记录(老版本只在流式时显示)
|
|
2334
|
+
children.push(
|
|
2335
|
+
react.createElement('div', { key: 'tool' + i, className: 'dsh-side-branch-toolline' }, t('panel.toolUsed') + segment.text),
|
|
2336
|
+
)
|
|
2337
|
+
return
|
|
2338
|
+
}
|
|
1955
2339
|
children.push(
|
|
1956
|
-
react.createElement(
|
|
2340
|
+
react.createElement(
|
|
2341
|
+
'div',
|
|
2342
|
+
{ key: 'a' + i },
|
|
2343
|
+
segment.text === '' ? '' : renderAnswer(segment.text, streaming === true && isLast === true),
|
|
2344
|
+
),
|
|
1957
2345
|
)
|
|
1958
|
-
}
|
|
1959
|
-
//
|
|
1960
|
-
children.push(
|
|
1961
|
-
|
|
1962
|
-
'div',
|
|
1963
|
-
{ key: 'a' },
|
|
1964
|
-
round.answer === '' ? '' : renderAnswer(round, streaming),
|
|
1965
|
-
),
|
|
1966
|
-
)
|
|
1967
|
-
// ④ 轮次小标签:已停止 / 出错(终态可见,而不是只留在错误框里)
|
|
2346
|
+
})
|
|
2347
|
+
// 还没有任何段落时也占一个位(运行状态由工具行的**停止键**表达)
|
|
2348
|
+
if (segments.length === 0) children.push(react.createElement('div', { key: 'a-empty' }, ''))
|
|
2349
|
+
// ③ 轮次小标签:已停止 / 出错(终态可见,而不是只留在错误框里)
|
|
1968
2350
|
if (round.phase === 'stopped' || round.phase === 'error') {
|
|
1969
2351
|
children.push(
|
|
1970
2352
|
react.createElement(
|
|
@@ -1977,11 +2359,122 @@ window.__ModuleLoader__.load({
|
|
|
1977
2359
|
return react.createElement('div', { key: round.id ?? String(index), className: 'dsh-side-branch-round' }, ...children)
|
|
1978
2360
|
}
|
|
1979
2361
|
|
|
2362
|
+
// ── ★ 层 1 / 层 2:「继承了什么 / 注入了什么」────────────────────────
|
|
2363
|
+
//
|
|
2364
|
+
// 分两样东西,别混:
|
|
2365
|
+
// · **继承来的主会话历史**(seed):每轮追问都会用主会话**最新**的已结算前缀重建 ⇒
|
|
2366
|
+
// 这一行告诉用户"这一次带上了多少",点开按需拉内容(层 2,只放内存)。
|
|
2367
|
+
// · **分支引导词**:本插件**唯一**注入给模型的东西(系统提示词/工具声明是 DSH 自己组装的,
|
|
2368
|
+
// 不属于本插件,显示它们会误导用户以为插件能改)。
|
|
2369
|
+
const metaChildren = []
|
|
2370
|
+
if (synced !== null && (synced.turns > 0 || synced.chars > 0)) {
|
|
2371
|
+
const toggleLabel = inheritedOpen === true ? t('panel.inheritedHide') : t('panel.inheritedShow')
|
|
2372
|
+
metaChildren.push(
|
|
2373
|
+
react.createElement(
|
|
2374
|
+
'button',
|
|
2375
|
+
{
|
|
2376
|
+
key: 'synced',
|
|
2377
|
+
type: 'button',
|
|
2378
|
+
className: 'dsh-side-branch-metaline',
|
|
2379
|
+
// ⚠️ 没有 `conversationId` 就没法按需拉内容(那说明这一段还没在宿主侧建立)
|
|
2380
|
+
disabled: conversationId === null,
|
|
2381
|
+
title: toggleLabel,
|
|
2382
|
+
'aria-label': toggleLabel,
|
|
2383
|
+
'aria-expanded': inheritedOpen === true,
|
|
2384
|
+
onClick: () => {
|
|
2385
|
+
void toggleInherited()
|
|
2386
|
+
},
|
|
2387
|
+
},
|
|
2388
|
+
(inheritedOpen === true ? '▾ ' : '▸ ') + t('panel.inheritedLine', { turns: synced.turns, chars: synced.chars }),
|
|
2389
|
+
),
|
|
2390
|
+
)
|
|
2391
|
+
} else if (rounds.length > 0) {
|
|
2392
|
+
metaChildren.push(react.createElement('div', { key: 'synced-none', className: 'dsh-side-branch-metaline' }, t('panel.inheritedNone')))
|
|
2393
|
+
}
|
|
2394
|
+
if (inheritedOpen === true) {
|
|
2395
|
+
if (inheritedBusy === true) {
|
|
2396
|
+
metaChildren.push(react.createElement('div', { key: 'inh-loading', className: 'dsh-side-branch-inherited' }, t('panel.inheritedLoading')))
|
|
2397
|
+
} else if (inheritedError !== '') {
|
|
2398
|
+
metaChildren.push(react.createElement('div', { key: 'inh-err', className: 'dsh-side-branch-inherited' }, inheritedError))
|
|
2399
|
+
} else if (inherited !== null) {
|
|
2400
|
+
const turns = Array.isArray(inherited.turns) ? inherited.turns : []
|
|
2401
|
+
const rows = turns.map((entry, i) => {
|
|
2402
|
+
// 最后一轮**默认展开**(全文);更早的每轮只有宿主给的一行摘要。
|
|
2403
|
+
const isLast = i === turns.length - 1
|
|
2404
|
+
const body = []
|
|
2405
|
+
if (entry.question !== '') {
|
|
2406
|
+
body.push(
|
|
2407
|
+
react.createElement(
|
|
2408
|
+
'div',
|
|
2409
|
+
{ key: 'q', className: 'dsh-side-branch-inhrow' },
|
|
2410
|
+
react.createElement('span', { className: 'dsh-side-branch-inhtag' }, t('panel.inheritedAsk')),
|
|
2411
|
+
react.createElement('span', null, entry.question),
|
|
2412
|
+
),
|
|
2413
|
+
)
|
|
2414
|
+
}
|
|
2415
|
+
// ★ 主会话的**思考**也在继承里(种子里的 `reasoning` 块,会作为 `thinking` 发给模型)
|
|
2416
|
+
// ⇒ 单独一层可折叠,别让它看起来像"没继承"。
|
|
2417
|
+
if (typeof entry.reasoning === 'string' && entry.reasoning !== '') {
|
|
2418
|
+
body.push(
|
|
2419
|
+
react.createElement(
|
|
2420
|
+
'details',
|
|
2421
|
+
{ key: 'think', className: 'dsh-side-branch-inhthink' },
|
|
2422
|
+
react.createElement(
|
|
2423
|
+
'summary',
|
|
2424
|
+
null,
|
|
2425
|
+
t('panel.inheritedThink') + ' · ' + t('panel.inheritedChars', { n: entry.reasoningChars ?? entry.reasoning.length }),
|
|
2426
|
+
),
|
|
2427
|
+
react.createElement('pre', { className: 'dsh-side-branch-notice' }, entry.reasoning),
|
|
2428
|
+
),
|
|
2429
|
+
)
|
|
2430
|
+
}
|
|
2431
|
+
if (entry.answer !== '') {
|
|
2432
|
+
body.push(
|
|
2433
|
+
react.createElement(
|
|
2434
|
+
'div',
|
|
2435
|
+
{ key: 'a', className: 'dsh-side-branch-inhrow' },
|
|
2436
|
+
react.createElement('span', { className: 'dsh-side-branch-inhtag' }, t('panel.inheritedAnswer')),
|
|
2437
|
+
react.createElement('span', null, entry.answer),
|
|
2438
|
+
),
|
|
2439
|
+
)
|
|
2440
|
+
}
|
|
2441
|
+
return react.createElement(
|
|
2442
|
+
'details',
|
|
2443
|
+
{ key: 'turn' + i, className: 'dsh-side-branch-inhturn', open: isLast },
|
|
2444
|
+
react.createElement(
|
|
2445
|
+
'summary',
|
|
2446
|
+
null,
|
|
2447
|
+
t('panel.inheritedTurn', { n: entry.turn }) + (entry.truncated === true ? ' ' + t('panel.inheritedTruncated') : ''),
|
|
2448
|
+
),
|
|
2449
|
+
...body,
|
|
2450
|
+
)
|
|
2451
|
+
})
|
|
2452
|
+
metaChildren.push(react.createElement('div', { key: 'inh', className: 'dsh-side-branch-inherited' }, ...rows))
|
|
2453
|
+
if (inherited.parentHasNewer === true) {
|
|
2454
|
+
metaChildren.push(
|
|
2455
|
+
react.createElement('div', { key: 'inh-newer', className: 'dsh-side-branch-metaline' }, t('panel.inheritedNewer')),
|
|
2456
|
+
)
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
if (notice !== '') {
|
|
2461
|
+
metaChildren.push(
|
|
2462
|
+
react.createElement(
|
|
2463
|
+
'details',
|
|
2464
|
+
{ key: 'notice', className: 'dsh-side-branch-inhturn' },
|
|
2465
|
+
react.createElement('summary', null, t('panel.noticeLine') + ' ' + t('panel.noticeHint')),
|
|
2466
|
+
// 引导词**逐字**展示(它就是模型看到的那份英文原文)
|
|
2467
|
+
react.createElement('pre', { className: 'dsh-side-branch-notice' }, notice),
|
|
2468
|
+
),
|
|
2469
|
+
)
|
|
2470
|
+
}
|
|
2471
|
+
const metaBlock =
|
|
2472
|
+
metaChildren.length === 0 ? null : react.createElement('div', { key: 'meta', className: 'dsh-side-branch-meta' }, ...metaChildren)
|
|
2473
|
+
|
|
1980
2474
|
const answerBlock = react.createElement(
|
|
1981
2475
|
'div',
|
|
1982
2476
|
{
|
|
1983
2477
|
key: 'answer',
|
|
1984
|
-
ref: answerRef,
|
|
1985
2478
|
className: 'dsh-side-branch-rounds',
|
|
1986
2479
|
// 可访问性:流式逐段到达,`role=status` = aria-live polite
|
|
1987
2480
|
role: 'status',
|
|
@@ -2470,7 +2963,7 @@ window.__ModuleLoader__.load({
|
|
|
2470
2963
|
onMouseDownCapture: claimActive,
|
|
2471
2964
|
onFocusCapture: claimActive,
|
|
2472
2965
|
},
|
|
2473
|
-
react.createElement('div', { className: 'dsh-side-branch-scroll' }, errorBlock, answerBlock),
|
|
2966
|
+
react.createElement('div', { className: 'dsh-side-branch-scroll', ref: scrollRef }, errorBlock, metaBlock, answerBlock),
|
|
2474
2967
|
composer,
|
|
2475
2968
|
settingsPanel,
|
|
2476
2969
|
usageLine,
|
|
@@ -2695,18 +3188,52 @@ window.__ModuleLoader__.load({
|
|
|
2695
3188
|
}
|
|
2696
3189
|
|
|
2697
3190
|
/**
|
|
2698
|
-
*
|
|
3191
|
+
* 选区是否落在**主会话正文区**里(「引用并提问」入口的作用域)。
|
|
3192
|
+
*
|
|
3193
|
+
* ★ 0.2.2 修的缺陷:入口的监听挂在 `document` 上(这样"在会话里划字就能用"),
|
|
3194
|
+
* 而 DSH 页面里除了主会话还有**右栏面板**(文件预览、设置、其它插件的面板、本插件面板)
|
|
3195
|
+
* 与**设置弹层**,它们都在同一棵 DOM 下 ⇒ 只靠输入框黑名单挡不住:
|
|
3196
|
+
* 用户在右栏打开的 md 里划字,也会弹出「引用并提问」,点下去就把**不属于主会话**的原文
|
|
3197
|
+
* 引进了侧枝的 prompt。
|
|
3198
|
+
*
|
|
3199
|
+
* 判据(官方 DOM 标记,已核对 0.2.1-rc.3 的 `dsh-client-ui-conversation`):
|
|
3200
|
+
* · 主会话正文 = `[data-conversation-content]`(其内是 `[data-conversation-scroll]`,
|
|
3201
|
+
* 消息列表与 composer 座位都在里面);
|
|
3202
|
+
* · composer 座位 = `[data-conversation-region="composer"]` ⇒ 排除(输入框另有黑名单);
|
|
3203
|
+
* · **右栏** = `[data-sidebar-right-panel]` ⇒ 排除。这一条不能省:
|
|
3204
|
+
* 子代理的嵌入会话(右栏里的 sidebar chat)**也带 `[data-conversation-content]`**,
|
|
3205
|
+
* 只判正文标记会把"子会话的正文"当成主会话。
|
|
3206
|
+
* · 找不到任何会话正文标记 ⇒ **不弹**(fail-closed)。宁可少一个入口,
|
|
3207
|
+
* 也不要引用来源不明的文字;旧版 DSH 上这个入口会因此不出现(已登记在 CHANGELOG)。
|
|
3208
|
+
*
|
|
3209
|
+
* @param {Element} element - 选区起点所在的元素
|
|
3210
|
+
* @returns {boolean} 是否允许弹出「引用并提问」
|
|
3211
|
+
*/
|
|
3212
|
+
function insideMainConversation(element) {
|
|
3213
|
+
// `[data-conversation-scroll]` 是兜底:0.1.7 一代只有它、没有 `-content`
|
|
3214
|
+
const surface = element.closest('[data-conversation-content], [data-conversation-scroll]')
|
|
3215
|
+
if (surface === null) return false
|
|
3216
|
+
// 右栏的三个标记都要判:panel = 右栏根;session = 同一根上的会话归属;
|
|
3217
|
+
// chat = 右栏里"带会话的标签页"(子代理的嵌入会话就在里面,它自己也有正文标记)
|
|
3218
|
+
if (element.closest('[data-sidebar-right-panel], [data-sidebar-right-session], [data-sidebar-chat]') !== null) return false
|
|
3219
|
+
if (element.closest('[data-conversation-region="composer"]') !== null) return false
|
|
3220
|
+
return true
|
|
3221
|
+
}
|
|
3222
|
+
|
|
3223
|
+
/**
|
|
3224
|
+
* 划选命中范围的**黑名单闸**(黑名单 + 主会话正文白名单,见 `insideMainConversation`)。
|
|
2699
3225
|
*
|
|
2700
3226
|
* 为什么必须有:composer(Lexical 输入区)与 `[data-conversation-scroll]` 在**同一个容器**内
|
|
2701
3227
|
* (`official-ui-conversation\lib\client.js:14953-14956`),
|
|
2702
3228
|
* 所以"在输入框里划字"会被当成"划选了会话原文"引用并发给子代理 —— **这是信任级缺陷**。
|
|
2703
3229
|
*
|
|
2704
3230
|
* 契约取舍:
|
|
2705
|
-
* ·
|
|
2706
|
-
* `
|
|
2707
|
-
*
|
|
2708
|
-
*
|
|
2709
|
-
*
|
|
3231
|
+
* · **不做"消息级"白名单**。`closest('[data-chat-flow-kind="assistant-step"|"assistant"|"user"]')`
|
|
3232
|
+
* 看着更准,但那是**私有契约**(官方核心包里存在、`.d.ts` 与 README 里零命中),
|
|
3233
|
+
* 而且粒度是"行"不是"消息";用它会把入口绑死在更细的内部结构上。
|
|
3234
|
+
* · **做"正文区级"白名单**(`insideMainConversation`):`[data-conversation-content]`
|
|
3235
|
+
* / `[data-conversation-scroll]` 是会话外壳自己的标记(右栏面板、设置弹层、左侧列表里都没有),
|
|
3236
|
+
* 恰好回答"这段文字属不属于某个会话的正文"。找不到标记时**不弹**(fail-closed)。
|
|
2710
3237
|
*
|
|
2711
3238
|
* @param selection - 当前 `window.getSelection()`(可能为 null)
|
|
2712
3239
|
* @returns 合法的选区文本(已 trim);不合法时为 `''`(调用方据此隐藏按钮)
|
|
@@ -2721,8 +3248,17 @@ window.__ModuleLoader__.load({
|
|
|
2721
3248
|
const startElement = anchorNode.nodeType === 1 ? anchorNode : anchorNode.parentElement
|
|
2722
3249
|
if (startElement === null || startElement === undefined) return ''
|
|
2723
3250
|
|
|
2724
|
-
|
|
3251
|
+
// ⚠️ `refused` 里那两段**本插件自己的**选择器是必需的,别删:
|
|
3252
|
+
// · `.dsh-side-branch-panel` = 临时会话面板的整棵子树(面板根就这一个类)。
|
|
3253
|
+
// 少了它,用户在**侧枝里**划选文字时也会弹出「引用并提问」—— 而这个按钮的语义是
|
|
3254
|
+
// "把**主会话**里的原文引用进来"。在侧枝里点它会把这**一段自己的**问题气泡/答案/
|
|
3255
|
+
// 引导词原文引用回**同一段**,形成自指循环(`quoteBus` 按最后聚焦的面板投递,
|
|
3256
|
+
// 所以它确实会投给自己)。侧枝要追问,直接在输入框里写就行,不需要引用入口。
|
|
3257
|
+
// · `[data-side-branch-menu]` = 模型选择悬浮窗(挂在 `document.body` 下,不在面板子树里)。
|
|
3258
|
+
const refused = 'input, textarea, button, [contenteditable]:not([contenteditable="false"]), [hidden], .dsh-side-branch-panel, [data-side-branch-menu]'
|
|
2725
3259
|
if (startElement.closest(refused) !== null) return ''
|
|
3260
|
+
// ★ 只允许**主会话正文区**(见 insideMainConversation;0.2.2 修的"右栏/设置里划字也弹")
|
|
3261
|
+
if (insideMainConversation(startElement) === false) return ''
|
|
2726
3262
|
|
|
2727
3263
|
const text = String(selection.toString()).trim()
|
|
2728
3264
|
return text
|
|
@@ -2941,6 +3477,24 @@ window.__ModuleLoader__.load({
|
|
|
2941
3477
|
return await response.json()
|
|
2942
3478
|
}
|
|
2943
3479
|
|
|
3480
|
+
/** 只读 GET(目前只有「继承来的主会话历史」那一处用它)。 */
|
|
3481
|
+
const getJson = async (path) => {
|
|
3482
|
+
const response = await fetch(path, { method: 'GET' })
|
|
3483
|
+
if (!response.ok) throw new Error('HTTP ' + response.status + ' ' + path)
|
|
3484
|
+
return await response.json()
|
|
3485
|
+
}
|
|
3486
|
+
|
|
3487
|
+
/**
|
|
3488
|
+
* ★ 层 2:按需读「这一段继承到的**主会话历史**」(只读路由 `GET /side-branch/inherited`)。
|
|
3489
|
+
*
|
|
3490
|
+
* 粒度由宿主决定:**最后一轮全文 + 更早每轮一行摘要**(服务端截断,不调模型)。
|
|
3491
|
+
* ⛔ 返回值**只放面板内存**(`useState`),绝不进 `rounds` / 持久化状态 —— 见面板里那段注释。
|
|
3492
|
+
* @param {string} conversationId - 这一段侧枝会话
|
|
3493
|
+
* @returns {Promise<object>} `{ turns, chars, truncated, parentHasNewer, … }`
|
|
3494
|
+
*/
|
|
3495
|
+
const fetchInherited = (conversationId) =>
|
|
3496
|
+
getJson(ROUTE + '/inherited?conversationId=' + encodeURIComponent(conversationId) + '&locale=' + localeId())
|
|
3497
|
+
|
|
2944
3498
|
/**
|
|
2945
3499
|
* 当前界面语言(`'zh'` / `'en'`),**随每次请求发给宿主**。
|
|
2946
3500
|
*
|
|
@@ -3227,7 +3781,7 @@ window.__ModuleLoader__.load({
|
|
|
3227
3781
|
// 官方做法:用 inject face 把能力作为 props 交给组件。
|
|
3228
3782
|
// 注意:它**不会**覆盖席位声明的 hooks —— 渲染器把 `contextual`
|
|
3229
3783
|
// (含 `useTabInfo`)排在 `injected` 之后展开(已核实)。
|
|
3230
|
-
inject: () => ({ startSideBranch, streamUrl, stopSideBranch, closeSideBranch, listModels, fetchSettings, saveSettings }),
|
|
3784
|
+
inject: () => ({ startSideBranch, streamUrl, stopSideBranch, closeSideBranch, listModels, fetchSettings, saveSettings, fetchInherited }),
|
|
3231
3785
|
},
|
|
3232
3786
|
SideBranchBody,
|
|
3233
3787
|
),
|