@mzzsfy/dsh-context-manager 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.js ADDED
@@ -0,0 +1,1362 @@
1
+ // dsh-context-manager Client 半区:「插件」设置页可配置卡片(四启停开关)
2
+ // + 历史输入浮层(Alt+↑ 唤起,范围导航/搜索/收藏回填) + 插话撤回 + 对话 fork。
3
+ // 历史输入挂官方 conversation.input.dock 插槽(渲染为零高度锚点),回填走宿主公共
4
+ // 契约 inputActions.setDraft;插话撤回注入官方 pending steering 气泡操作图标排;
5
+ // fork 注入消息气泡操作排,分叉到该轮之前并把该轮用户输入回填子会话输入框(重试
6
+ // 语义),RPC 走宿主 sessions 服务面(fork+open),锚点经 remote.session.follow
7
+ // 开场帧建立轮号→{ 结束 seq, 首问文本 } 映射。浏览器半区经 webServer 路由
8
+ // ('/api/context/*')访问 Host。打包为单文件自包含格式,无法跨文件 require;
9
+ // 与 src/core.mjs 镜像的纯函数(filterHistoryInputs/forkFailureText/forkRetryText)
10
+ // 修改需两处同步。
11
+
12
+ window.__ModuleLoader__.load({
13
+ id: '@mzzsfy/dsh-context-manager',
14
+ factory(require) {
15
+ const React = require('react')
16
+ const { useState, useEffect, useRef } = React
17
+
18
+ // 导航图标声明:交给 dsh-settings-nav-icons 统一渲染;键 = 市场短名(发现页
19
+ // 收录显示形态);该插件未就绪时入队,由其启动时排空
20
+ const NAV_ICON = { 'dsh-context-manager': 'git' }
21
+ if (window.__navicIcons !== undefined) window.__navicIcons.register(NAV_ICON)
22
+ else if (Array.isArray(window.__navicIconQueue)) window.__navicIconQueue.push(NAV_ICON)
23
+ else window.__navicIconQueue = [NAV_ICON]
24
+
25
+ // 通知出口:公共依赖 @mzzsfy/dsh-toast 可选消费(占位条目全仓唯一,由
26
+ // dsh-session-manager 代挂;模块表缺失或导出缺 show 即通知通道置空)
27
+ let toast = () => {}
28
+ try {
29
+ toast = require('@mzzsfy/dsh-toast/client').show || (() => {})
30
+ } catch {
31
+ toast = () => {}
32
+ }
33
+
34
+ const CSS = [
35
+ // 家族版本环:与官方 28px icon 按钮同高同隙,chevron 用官方线形 svg,
36
+ // label 小号 caption 色,不另起风格
37
+ '.cx-family-ring { display:inline-flex; align-items:center; gap:2px; }',
38
+ '.cx-family-ring .cx-family-ring__label { font:var(--dsw-font-xxs-12); color:var(--dsw-alias-label-caption);',
39
+ ' min-width:24px; text-align:center; line-height:28px; }',
40
+ // 开关行(规约 switch 形态):track 胶囊 + thumb 圆点,状态选择器锚定 checkbox
41
+ '.cx-switch { display:inline-flex; align-items:center; gap:8px; margin-top:10px; cursor:pointer;',
42
+ ' font:var(--dsw-font-xxs-12, 12px/18px sans-serif); color:var(--dsw-alias-label-caption, rgba(127,127,127,.9)); }',
43
+ '.cx-switch input[type="checkbox"] { position:absolute; width:1px; height:1px; margin:-1px; opacity:0; }',
44
+ '.cx-switch__track { position:relative; flex:none; width:28px; height:16px; border-radius:999px;',
45
+ ' background:light-dark(rgba(15,17,21,.22), rgba(255,255,255,.26)); transition:background .15s; }',
46
+ '.cx-switch__thumb { position:absolute; left:2px; top:2px; width:12px; height:12px; border-radius:999px;',
47
+ ' background:#fff; box-shadow:0 1px 2px rgba(0,0,0,.25); transition:transform .15s; }',
48
+ '.cx-switch input[type="checkbox"]:checked + .cx-switch__track { background:#1677ff; }',
49
+ '.cx-switch input[type="checkbox"]:checked + .cx-switch__track .cx-switch__thumb { transform:translateX(12px); }',
50
+ '.cx-switch input[type="checkbox"]:focus-visible + .cx-switch__track { outline:2px solid #1677ff; outline-offset:1px; }',
51
+ '.cx-switch:hover { color:var(--dsw-alias-label-primary, inherit); }',
52
+ '@media (prefers-reduced-motion: reduce) { .cx-switch__track, .cx-switch__thumb { transition:none; } }',
53
+ // 设置分区容器
54
+ '.cx-panel { display:flex; flex-direction:column; gap:2px; align-items:flex-start; min-width:0;',
55
+ ' color:var(--dsw-alias-label-primary); font:var(--dsw-font-s-14); }',
56
+ '.cx-panel__hint { font:var(--dsw-font-xxs-12); color:var(--dsw-alias-label-caption); margin:10px 0 4px; }',
57
+ // 插件页折叠卡:视觉值与官方 PluginCard 同源实测(border/bg/radius/间距),
58
+ // open 态换底层色并加深边框,chevron 随开合旋转
59
+ '.cx-card { border:.5px solid var(--dsw-alias-border-l4); background:var(--dsw-alias-bg-layer-3);',
60
+ ' border-radius:16px; list-style:none; transition:border-color .16s, background .16s; width:100%; }',
61
+ '.cx-card:hover { border-color:var(--dsw-alias-label-dimmed); }',
62
+ '.cx-card--open { background:var(--dsw-alias-bg-layer-2); border-color:var(--dsw-alias-label-dimmed); }',
63
+ '.cx-card__header { appearance:none; width:100%; font:inherit; color:inherit; text-align:left; cursor:pointer;',
64
+ ' background:none; border:0; border-radius:12px; align-items:center; gap:12px; padding:14px 16px; display:flex; }',
65
+ '.cx-card__header:focus-visible { outline:2px solid var(--dsw-alias-brand-primary); outline-offset:-2px; }',
66
+ '.cx-card__head-text { display:flex; flex-direction:column; flex:1 1 0%; gap:4px; min-width:0; }',
67
+ '.cx-card__name { color:var(--dsw-alias-label-primary); font-size:15px; font-weight:600; line-height:1.4; }',
68
+ '.cx-card__description { color:var(--dsw-alias-label-tertiary); font-size:13px; line-height:1.5; }',
69
+ '.cx-card__chevron { color:var(--dsw-alias-label-tertiary); flex:0 0 auto; transition:transform .16s; display:inline-flex; }',
70
+ '.cx-card--open .cx-card__chevron { transform:rotate(180deg); }',
71
+ '.cx-card__body { border-top:.5px solid var(--dsw-alias-border-l2); margin:0 16px; padding:0 0 8px;',
72
+ ' display:flex; flex-direction:column; align-items:flex-start; gap:8px; }',
73
+ '.cx-card__body .cx-panel__hint { margin:10px 0 0; }',
74
+ // 历史输入:零高度锚点容器 + 浮层(Alt+↑ 唤起);浮层与输入框同宽对齐,
75
+ // 不透明实底 + 宿主同款卡片投影,与消息流明确区隔。
76
+ // 色值取自宿主实测(白底卡片/墨色文字/蓝色强调):dsw alias 变量在宿主为空,不可依赖
77
+ '.cx-hist { position:relative; height:0; }',
78
+ // z-index 须高于同插槽兄弟浮层(sm-hist 同为 50,DOM 序靠前时会被其盖住)
79
+ '.cx-hist__pop { position:absolute; left:0; right:0; bottom:10px; z-index:' + (50 + 1) + ';',
80
+ ' max-height:min(420px, 46vh); display:flex; flex-direction:column; overflow:hidden;',
81
+ ' color-scheme:light dark; color:light-dark(#0f1115, #e8eaed);',
82
+ ' background:light-dark(#fff, #1e1f22); border-radius:14px;',
83
+ ' box-shadow:0 0 0 0.5px light-dark(rgba(15,17,21,.18), rgba(255,255,255,.14)), 0 4px 16px rgba(0,0,0,.08), 0 16px 48px rgba(0,0,0,.16); }',
84
+ '.cx-hist__hint { display:flex; align-items:center; gap:10px; padding:9px 14px; flex:none;',
85
+ ' border-bottom:1px solid light-dark(rgba(15,17,21,.08), rgba(255,255,255,.1));',
86
+ ' font:var(--dsw-font-xxs-12, 12px/18px sans-serif); color:light-dark(rgba(15,17,21,.55), rgba(232,234,237,.55)); }',
87
+ '.cx-hist__scope { padding:2px 10px; border-radius:999px; flex:none;',
88
+ ' background:#1677ff; color:#fff; font-weight:600; }',
89
+ '.cx-hist__search { flex:1; min-width:0; padding:3px 10px; border-radius:8px; color-scheme:light dark;',
90
+ ' border:1px solid light-dark(rgba(15,17,21,.14), rgba(255,255,255,.18));',
91
+ ' background:transparent; color:inherit; font:var(--dsw-font-xxs-12, 12px/18px sans-serif); }',
92
+ '.cx-hist__search:focus { outline:2px solid #1677ff; outline-offset:-1px; }',
93
+ '.cx-hist__count { flex:none; font:12px/18px var(--ds-font-family-code, monospace);',
94
+ ' color:light-dark(rgba(15,17,21,.4), rgba(232,234,237,.4)); font-variant-numeric:tabular-nums; }',
95
+ '.cx-hist__list { overflow-y:auto; padding:6px; }',
96
+ '.cx-hist__row { position:relative; display:flex; align-items:center; gap:10px; width:100%; border:0;',
97
+ ' background:transparent; cursor:pointer; text-align:left; padding:8px 12px 8px 14px;',
98
+ ' border-radius:8px; min-width:0; }',
99
+ '.cx-hist__row:hover { background:light-dark(rgba(15,17,21,.05), rgba(255,255,255,.07)); }',
100
+ '.cx-hist__row--on, .cx-hist__row--on:hover { background:light-dark(rgba(22,119,255,.1), rgba(22,119,255,.22)); }',
101
+ '.cx-hist__row--on::before { content:""; position:absolute; left:0; top:6px; bottom:6px; width:3px;',
102
+ ' border-radius:2px; background:#1677ff; }',
103
+ '.cx-hist__row--on .cx-hist__text { color:#1677ff; font-weight:600; }',
104
+ '.cx-hist__row:focus-visible { outline:2px solid #1677ff; outline-offset:-2px; }',
105
+ '.cx-hist__text { flex:1; min-width:0; font:var(--dsw-font-s-14, 14px/22px sans-serif);',
106
+ ' color:inherit; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }',
107
+ '.cx-hist__time { font:12px/18px var(--ds-font-family-code, monospace);',
108
+ ' color:light-dark(rgba(15,17,21,.4), rgba(232,234,237,.4));',
109
+ ' font-variant-numeric:tabular-nums; flex:none; }',
110
+ '.cx-hist__star { border:0; background:transparent; cursor:pointer; flex:none;',
111
+ ' font-size:15px; line-height:1; padding:2px 4px; border-radius:6px;',
112
+ ' color:light-dark(rgba(15,17,21,.35), rgba(232,234,237,.35));',
113
+ ' opacity:0; transition:opacity .12s; }',
114
+ '.cx-hist__row:hover .cx-hist__star, .cx-hist__star:focus-visible { opacity:1; }',
115
+ '.cx-hist__star--on { opacity:1; color:#f5a623; }',
116
+ '.cx-hist__star:hover { color:#f5a623; }',
117
+ '.cx-hist__editbtn { border:0; background:transparent; cursor:pointer; flex:none; padding:2px 8px;',
118
+ ' border-radius:999px; font:12px/18px sans-serif; font-weight:600;',
119
+ ' color:#1677ff; background:light-dark(rgba(22,119,255,.1), rgba(22,119,255,.22)); }',
120
+ '.cx-hist__editbtn:hover { background:light-dark(rgba(22,119,255,.18), rgba(22,119,255,.3)); }',
121
+ '.cx-hist__editwrap { display:flex; flex-direction:column; flex:1; min-height:0; }',
122
+ '.cx-hist__editwrap .cx-hist__list { flex:1; min-height:0; }',
123
+ '.cx-hist__editrow { display:flex; align-items:center; gap:6px; padding:4px 8px; }',
124
+ '.cx-hist__editinput { flex:1; min-width:0; border:1px solid light-dark(rgba(15,17,21,.14), rgba(255,255,255,.18));',
125
+ ' border-radius:8px; padding:6px 10px; font:var(--dsw-font-s-14, 14px/22px sans-serif);',
126
+ ' color:inherit; background:transparent; }',
127
+ '.cx-hist__editinput:focus { outline:2px solid #1677ff; outline-offset:-1px; }',
128
+ '.cx-hist__del { border:0; background:transparent; cursor:pointer; flex:none;',
129
+ ' font-size:16px; line-height:1; padding:2px 8px; border-radius:6px;',
130
+ ' color:light-dark(rgba(15,17,21,.45), rgba(232,234,237,.45)); }',
131
+ '.cx-hist__del:hover { color:#e5484d; }',
132
+ '.cx-hist__addrow { display:flex; gap:6px; padding:8px; flex:none;',
133
+ ' border-top:1px solid light-dark(rgba(15,17,21,.08), rgba(255,255,255,.1)); }',
134
+ '.cx-hist__empty { padding:24px 14px; text-align:center; font:var(--dsw-font-s-14, 14px/22px sans-serif);',
135
+ ' color:light-dark(rgba(15,17,21,.5), rgba(232,234,237,.5)); }',
136
+ '.cx-hist__banner { padding:7px 14px; flex:none; text-align:center; font:var(--dsw-font-xxs-12, 12px/18px sans-serif);',
137
+ ' color:light-dark(rgba(15,17,21,.55), rgba(232,234,237,.55));',
138
+ ' background:light-dark(rgba(15,17,21,.04), rgba(255,255,255,.06)); }',
139
+ // 插话撤回 / fork:注入官方操作图标排,样式经克隆官方按钮类名原生同款;
140
+ // dock 本体零 DOM,仅承载注入逻辑
141
+ '.cx-steer-host { display:none; }',
142
+ '.cx-fork-host { display:none; }',
143
+ ].join('\n')
144
+
145
+ const INPUTS_URL = '/api/context/inputs'
146
+ const PROMPTS_TOGGLE_URL = '/api/context/prompts/toggle'
147
+ const HISTORY_ENABLED_URL = '/api/context/history-enabled'
148
+ const STEER_ENABLED_URL = '/api/context/steer-recall-enabled'
149
+ const FORK_ENABLED_URL = '/api/context/fork-enabled'
150
+ const FORK_AUTO_RESEND_URL = '/api/context/fork-auto-resend-enabled'
151
+
152
+ // 请求默认超时:host 被批量解压等同步任务阻塞时路由会迟滞数秒,
153
+ // 无超时则浮层停在「正在读取…」假死;超时按错误抛出,由调用方兜底,
154
+ // 轮询链继续,host 恢复后自动跟上
155
+ const API_TIMEOUT_MS = 8 * 1000
156
+
157
+ async function api(url, options) {
158
+ const controller = new AbortController()
159
+ const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS)
160
+ try {
161
+ const response = await fetch(url, {
162
+ headers: { 'content-type': 'application/json' },
163
+ signal: controller.signal,
164
+ ...options,
165
+ })
166
+ const payload = await response.json().catch(() => ({}))
167
+ if (!response.ok) throw new Error(payload && payload.error ? payload.error : 'HTTP ' + response.status)
168
+ return payload
169
+ } catch (error) {
170
+ if (error && error.name === 'AbortError') throw new Error('响应超时(' + Math.round(API_TIMEOUT_MS / 1000) + 's),宿主繁忙中')
171
+ throw error
172
+ } finally {
173
+ clearTimeout(timer)
174
+ }
175
+ }
176
+
177
+ function h(type, props) {
178
+ const children = Array.prototype.slice.call(arguments, 2)
179
+ return React.createElement.apply(React, [type, props || null].concat(children))
180
+ }
181
+
182
+ function fmtTime(ms) {
183
+ const t = Number(ms)
184
+ if (t !== t || !t) return '—'
185
+ const d = new Date(t)
186
+ const pad = (n) => (n < 10 ? '0' : '') + n
187
+ const date = d.getFullYear() === new Date().getFullYear()
188
+ ? pad(d.getMonth() + 1) + '/' + pad(d.getDate())
189
+ : d.getFullYear() + '/' + pad(d.getMonth() + 1) + '/' + pad(d.getDate())
190
+ return date + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes())
191
+ }
192
+
193
+ // 历史范围:按 ←/→ 切换序排列(索引 0 为常用提示词个人收藏,1 为当前会话默认落点,
194
+ // → 向更大范围,← 返回收藏);与 host core.mjs HISTORY_SCOPES 镜像,parity 测试锁定
195
+ const HISTORY_SCOPES = ['prompts', 'session', 'workspace', 'global']
196
+ const HISTORY_SCOPE_LABELS = ['常用', '当前会话', '本工作区', '全部工作区']
197
+ const HISTORY_SCOPE_DEFAULT = HISTORY_SCOPES.indexOf('session')
198
+ // 浮层打开期间固定轮询节奏:host 侧对齐是异步的,数据可能晚于首次响应到达
199
+ const HISTORY_REPULL_MS = 3 * 1000
200
+
201
+ // 历史浮层搜索镜像(与 core.mjs filterHistoryInputs 同步维护):
202
+ // 查询词去首尾空白后按不区分大小写子串匹配,空查询原样返回全量
203
+ function filterHistoryInputs(entries, query) {
204
+ const list = Array.isArray(entries) ? entries : []
205
+ const needle = String(query ?? '').trim().toLowerCase()
206
+ if (needle === '') return list
207
+ return list.filter((entry) => String((entry && entry.text) ?? '').toLowerCase().includes(needle))
208
+ }
209
+
210
+ // fork 错误文案映射镜像(与 core.mjs forkFailureText 同步维护)
211
+ function forkFailureText(code) {
212
+ if (code === 'session/fork-unavailable') return '该轮尚未完成,不可分叉'
213
+ if (code === 'session/workspace-attach-failed') return '分叉成功,但挂载到工作区失败'
214
+ return '分叉失败: ' + String(code ?? '未知错误')
215
+ }
216
+
217
+ // fork 重试文本提取镜像(与 core.mjs forkRetryText 同步维护):
218
+ // 仅用户本人消息,文本块按行拼接;空白/纯图返回 null——无法重试的轮不注入按钮
219
+ function forkRetryText(data) {
220
+ const message = data && typeof data === 'object' ? data : {}
221
+ if (!message.source || message.source.kind !== 'user') return null
222
+ const text = (Array.isArray(message.content) ? message.content : [])
223
+ .filter((block) => block && block.type === 'text' && block.text !== '')
224
+ .map((block) => block.text)
225
+ .join('\n')
226
+ return text.trim() === '' ? null : text
227
+ }
228
+
229
+ // 家族谱系投影镜像(与 core.mjs sessionFamilyMap/familyRing 同步维护):
230
+ // 客户端快照行主键为 id、父引用为 parentId(与 RPC wire 的 sessionId/parentSessionId 不同名),
231
+ // 编排派生会话(origin === 'subagent')剔除——家族只统计用户的 fork 分支;
232
+ // 断链视为独立根;环序按 updatedAt 升序
233
+ function sessionFamilyMap(items) {
234
+ const byId = new Map()
235
+ for (const item of Array.isArray(items) ? items : []) {
236
+ if (item && item.origin === 'subagent') continue
237
+ const id = item && item.id
238
+ if (typeof id === 'string' && id !== '') byId.set(id, item)
239
+ }
240
+ const chains = new Map()
241
+ for (const id of byId.keys()) {
242
+ const chain = []
243
+ let cursor = id
244
+ let root = cursor
245
+ while (cursor !== undefined) {
246
+ chain.unshift(cursor)
247
+ root = cursor
248
+ const parent = byId.get(cursor)
249
+ cursor = parent && typeof parent.parentId === 'string' && byId.has(parent.parentId)
250
+ ? parent.parentId
251
+ : undefined
252
+ if (chain.includes(cursor)) break
253
+ }
254
+ chains.set(id, { root, chain, item: byId.get(id) })
255
+ }
256
+ return chains
257
+ }
258
+
259
+ function familyRing(chains, sessionId) {
260
+ const entry = chains instanceof Map ? chains.get(sessionId) : undefined
261
+ if (!entry) return null
262
+ const members = [...chains.entries()]
263
+ .filter(([, value]) => value.root === entry.root)
264
+ .sort((left, right) => {
265
+ const at = (record) => (record && record.item && typeof record.item.updatedAt === 'number' ? record.item.updatedAt : 0)
266
+ return at(left[1]) - at(right[1])
267
+ })
268
+ .map(([id]) => id)
269
+ if (members.length < 2) return null
270
+ return { index: members.indexOf(sessionId) + 1, total: members.length, members }
271
+ }
272
+
273
+ function HistoryDock({ session, inputActions }) {
274
+ const [open, setOpen] = useState(false)
275
+ const [items, setItems] = useState(null)
276
+ const [cursor, setCursor] = useState(-1)
277
+ const [scopeIndex, setScopeIndex] = useState(HISTORY_SCOPE_DEFAULT)
278
+ const [aligning, setAligning] = useState(false)
279
+ const [loadError, setLoadError] = useState(false)
280
+ // 收藏集合:浮层打开时并行拉一次,行悬停星标据此显示实/空心;
281
+ // editing 仅常用范围可进入(行内改/删 + 底部新增)
282
+ const [collected, setCollected] = useState(() => new Set())
283
+ const [editing, setEditing] = useState(false)
284
+ // 搜索:query 驱动渲染,原始条目存 rawRef,items 恒为过滤后列表;
285
+ // 组合输入(IME)期间不过滤,上屏后才应用
286
+ const [query, setQuery] = useState('')
287
+ const composingRef = useRef(false)
288
+ const rootRef = React.useRef(null)
289
+ // 键盘层权威状态:监听器挂载一次(空依赖),读写全走 ref,规避 effect 重挂
290
+ // 时序造成的闭包陈旧;state 仅驱动渲染,变更处双写
291
+ const viewRef = React.useRef({ open: false, items: null, cursor: -1, scopeIndex: 0, aligning: false })
292
+ const sessionRef = React.useRef(session)
293
+ const inputActionsRef = React.useRef(inputActions)
294
+ const rawItemsRef = React.useRef(null)
295
+ const queryRef = React.useRef('')
296
+ // 历史浮层启停(设置开关):挂载拉取一次,停用即整体不渲染、键盘放行
297
+ const enabledRef = React.useRef(true)
298
+ const [historyEnabled, setHistoryEnabled] = useState(true)
299
+ sessionRef.current = session
300
+ inputActionsRef.current = inputActions
301
+ useEffect(() => {
302
+ api(HISTORY_ENABLED_URL)
303
+ .then((payload) => {
304
+ const on = payload ? payload.enabled !== false : true
305
+ enabledRef.current = on
306
+ setHistoryEnabled(on)
307
+ })
308
+ .catch(() => {})
309
+ }, [])
310
+
311
+ function syncView(patch) {
312
+ viewRef.current = { ...viewRef.current, ...patch }
313
+ }
314
+
315
+ // 应用搜索词:原始条目重新过滤并重置光标(过滤后集合上的选择位无意义)
316
+ function applyFilter(next) {
317
+ queryRef.current = next
318
+ const raw = rawItemsRef.current
319
+ const displayed = raw === null ? null : filterHistoryInputs(raw, next)
320
+ syncView({ items: displayed, cursor: -1 })
321
+ setItems(displayed)
322
+ setCursor(-1)
323
+ }
324
+
325
+ // host 响应信封 { inputs, aligned };解包并防御形态漂移,消费侧恒为数组。
326
+ // aligned=false 表示工作区缓存尚未建立(首次),host 已在后台对齐
327
+ function fetchInputs(scope) {
328
+ return api(INPUTS_URL + '?sessionId=' + encodeURIComponent(sessionRef.current.sessionId) + '&scope=' + scope)
329
+ .then((payload) => ({
330
+ inputs: (payload && Array.isArray(payload.inputs)) ? payload.inputs : [],
331
+ aligned: Boolean(payload && payload.aligned),
332
+ }))
333
+ }
334
+
335
+ // 收藏集合与浮层列表共用 inputs 路由(scope=prompts),毫秒级缓存读
336
+ function refreshCollected() {
337
+ return api(INPUTS_URL + '?sessionId=' + encodeURIComponent(sessionRef.current.sessionId) + '&scope=prompts')
338
+ .then((payload) => {
339
+ const list = payload && Array.isArray(payload.inputs) ? payload.inputs : []
340
+ setCollected(new Set(list.map((item) => item.text)))
341
+ })
342
+ .catch(() => {})
343
+ }
344
+
345
+ // 收藏切换:行悬停星标与编辑态删除共用;成功后同步星标集,
346
+ // 当前正处于常用范围时立即重拉列表(不等轮询)
347
+ function togglePrompt(text) {
348
+ return api(PROMPTS_TOGGLE_URL, { method: 'POST', body: JSON.stringify({ text }) })
349
+ .then((payload) => {
350
+ const on = Boolean(payload && payload.collected)
351
+ toast(on ? '已收藏到常用' : '已取消收藏')
352
+ setCollected((prev) => {
353
+ const next = new Set(prev)
354
+ if (on) next.add(text)
355
+ else next.delete(text)
356
+ return next
357
+ })
358
+ if (viewRef.current.open && viewRef.current.scopeIndex === HISTORY_SCOPES.indexOf('prompts')) requestScope(viewRef.current.scopeIndex)
359
+ return on
360
+ })
361
+ }
362
+
363
+ // 编辑态改文本 = 移除旧文本 + 收藏新文本(两次 toggle 原子性由"同文本去重"保证:
364
+ // 中断最坏留下旧文本,重改一次即自愈)
365
+ function renamePrompt(from, to) {
366
+ return togglePrompt(from)
367
+ .then(() => (to === '' ? Promise.resolve() : togglePrompt(to)))
368
+ }
369
+
370
+ // 应用一次响应:仅当范围未变时生效,防止快速切范围后迟到响应覆盖新范围。
371
+ // 原始条目深度不变的重复响应不重置光标(轮询期间保持用户选位);
372
+ // 落地后经当前搜索词过滤再呈现
373
+ function applyResult(scopeIdx, result) {
374
+ if (viewRef.current.scopeIndex !== scopeIdx) return
375
+ const prev = rawItemsRef.current
376
+ if (prev !== null && JSON.stringify(prev) === JSON.stringify(result.inputs)) {
377
+ syncView({ aligning: !result.aligned })
378
+ setAligning(!result.aligned)
379
+ return
380
+ }
381
+ rawItemsRef.current = result.inputs
382
+ syncView({ aligning: !result.aligned })
383
+ setAligning(!result.aligned)
384
+ applyFilter(queryRef.current)
385
+ }
386
+
387
+ // 浮层打开期间固定节奏轮询当前范围:host 侧对齐/焦点对齐是异步的,
388
+ // 数据可能晚于首次响应到达,一次性重拉预算耗尽后迟到的数据将永不出现
389
+ // (实机验证实测:API 已就绪而浮层停在旧列表)。轮询开销为毫秒级缓存读;
390
+ // 请求失败(含超时)也保持轮询,host 恢复后列表自愈,不假死
391
+ function requestScope(scopeIdx) {
392
+ const scope = HISTORY_SCOPES[scopeIdx]
393
+ const scheduleNext = () => {
394
+ if (!viewRef.current.open || viewRef.current.scopeIndex !== scopeIdx) return
395
+ setTimeout(() => {
396
+ if (viewRef.current.open && viewRef.current.scopeIndex === scopeIdx) requestScope(scopeIdx)
397
+ }, HISTORY_REPULL_MS)
398
+ }
399
+ fetchInputs(scope)
400
+ .then((result) => {
401
+ applyResult(scopeIdx, result)
402
+ scheduleNext()
403
+ })
404
+ .catch((error) => {
405
+ if (viewRef.current.scopeIndex !== scopeIdx) return
406
+ rawItemsRef.current = []
407
+ syncView({ items: [] })
408
+ setLoadError(true)
409
+ setItems([])
410
+ toast(error && error.message ? error.message : String(error), { kind: 'error' })
411
+ scheduleNext()
412
+ })
413
+ }
414
+
415
+ function fill(text) {
416
+ inputActionsRef.current.setDraft(text)
417
+ syncView({ open: false })
418
+ setOpen(false)
419
+ }
420
+
421
+ function openPopup() {
422
+ rawItemsRef.current = null
423
+ queryRef.current = ''
424
+ setQuery('')
425
+ syncView({ open: true, items: null, cursor: -1, scopeIndex: HISTORY_SCOPE_DEFAULT, aligning: false })
426
+ setOpen(true)
427
+ setScopeIndex(HISTORY_SCOPE_DEFAULT)
428
+ setCursor(-1)
429
+ setAligning(false)
430
+ setLoadError(false)
431
+ setItems(null)
432
+ setEditing(false)
433
+ refreshCollected()
434
+ requestScope(HISTORY_SCOPE_DEFAULT)
435
+ }
436
+
437
+ // 切换范围:→ 向大(工作区/全局),← 返回收藏;边界停住;搜索词清空后缓存直接返回
438
+ function switchScope(delta) {
439
+ const next = viewRef.current.scopeIndex + delta
440
+ if (next < 0 || next >= HISTORY_SCOPE_LABELS.length) return
441
+ rawItemsRef.current = null
442
+ queryRef.current = ''
443
+ setQuery('')
444
+ syncView({ scopeIndex: next, items: null, cursor: -1, aligning: false })
445
+ setScopeIndex(next)
446
+ setCursor(-1)
447
+ setAligning(false)
448
+ setLoadError(false)
449
+ setItems(null)
450
+ setEditing(false)
451
+ requestScope(next)
452
+ }
453
+
454
+ // Alt+↑ 唤起浮层;浮层开 = 菜单模态,捕获阶段拦截导航键,先于 Lexical 光标移动。
455
+ // 设置开关停用时监听器直接放行(读 ref,开关值挂载拉取后即时反映)
456
+ useEffect(() => {
457
+ function onKeyDown(event) {
458
+ if (enabledRef.current === false) return
459
+ const view = viewRef.current
460
+ if (event.isComposing) return
461
+ if (!view.open) {
462
+ if (event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey && event.key === 'ArrowUp') {
463
+ event.preventDefault()
464
+ event.stopPropagation()
465
+ openPopup()
466
+ }
467
+ return
468
+ }
469
+ // 编辑态输入(重命名/新增行)聚焦时导航键留给输入本身,仅保留 Esc 关闭;
470
+ // 搜索框不豁免:过滤后 ↑/↓ 选中与 Enter 回填正是搜索态的主路径
471
+ const root = rootRef.current
472
+ const searchEl = root && root.querySelector('.cx-hist__search')
473
+ const active = document.activeElement
474
+ if (root && active && active !== searchEl && root.contains(active)
475
+ && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) {
476
+ if (event.key !== 'Escape') return
477
+ }
478
+ if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
479
+ event.preventDefault()
480
+ event.stopPropagation()
481
+ if (view.items === null || view.items.length === 0) return
482
+ const delta = event.key === 'ArrowUp' ? -1 : 1
483
+ const next = Math.min(Math.max(view.cursor + delta, 0), view.items.length - 1)
484
+ syncView({ cursor: next })
485
+ setCursor(next)
486
+ return
487
+ }
488
+ if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
489
+ // 搜索框聚焦时方向键留给光标移动,不切范围
490
+ if (rootRef.current && rootRef.current.querySelector('.cx-hist__search') === document.activeElement) return
491
+ event.preventDefault()
492
+ event.stopPropagation()
493
+ switchScope(event.key === 'ArrowLeft' ? -1 : 1)
494
+ return
495
+ }
496
+ if (event.key === 'Enter') {
497
+ // 有选中条目即回填,搜索框聚焦同样成立(IME 组合已在函数头放行,
498
+ // 搜索词过滤后 ↑/↓ 选中 → Enter 是搜索态的主要回填路径)
499
+ if (view.items !== null && view.cursor >= 0 && view.cursor < view.items.length) {
500
+ event.preventDefault()
501
+ event.stopPropagation()
502
+ fill(view.items[view.cursor].text)
503
+ }
504
+ return
505
+ }
506
+ if (event.key === 'Escape') {
507
+ // 两级退出:搜索词非空先清搜索,再按才关闭;为空直接关闭(与现版一致)
508
+ event.preventDefault()
509
+ event.stopPropagation()
510
+ if (queryRef.current.trim() !== '') {
511
+ setQuery('')
512
+ applyFilter('')
513
+ return
514
+ }
515
+ syncView({ open: false })
516
+ setOpen(false)
517
+ }
518
+ }
519
+ document.addEventListener('keydown', onKeyDown, true)
520
+ return () => document.removeEventListener('keydown', onKeyDown, true)
521
+ }, [])
522
+
523
+ // 选中行滚入可视区:键盘移动后高亮行保持在视野内
524
+ useEffect(() => {
525
+ if (!open || cursor < 0) return
526
+ const row = rootRef.current && rootRef.current.querySelector('.cx-hist__row--on')
527
+ if (row) row.scrollIntoView({ block: 'nearest' })
528
+ }, [open, cursor, items])
529
+
530
+ // 浮层外点击关闭(双写:漏写 viewRef 会让键盘层误判浮层仍开,吞掉输入框方向键)
531
+ useEffect(() => {
532
+ if (!open) return undefined
533
+ function onPointerDown(event) {
534
+ if (rootRef.current && !rootRef.current.contains(event.target)) {
535
+ syncView({ open: false })
536
+ setOpen(false)
537
+ }
538
+ }
539
+ document.addEventListener('mousedown', onPointerDown, true)
540
+ return () => document.removeEventListener('mousedown', onPointerDown, true)
541
+ }, [open])
542
+
543
+ // 零高度锚点:平时不占任何界面空间,仅浮层打开时渲染;设置停用整体不渲染
544
+ if (session === undefined || inputActions === undefined || historyEnabled === false) return null
545
+ const promptsIdx = HISTORY_SCOPES.indexOf('prompts')
546
+ const inPrompts = scopeIndex === promptsIdx
547
+ // 行悬停星标:非常用范围显示,已收藏实星(点击取消),未收藏空心(点击收藏);
548
+ // stopPropagation 防止触发行回填
549
+ const starButton = (item) => h('button', {
550
+ className: 'cx-hist__star' + (collected.has(item.text) ? ' cx-hist__star--on' : ''),
551
+ title: collected.has(item.text) ? '取消收藏' : '收藏',
552
+ onClick: (event) => {
553
+ event.stopPropagation()
554
+ togglePrompt(item.text)
555
+ },
556
+ }, collected.has(item.text) ? '★' : '☆')
557
+ const rowButton = (item, index) => h('button', {
558
+ key: index + ':' + item.at,
559
+ className: 'cx-hist__row' + (index === cursor ? ' cx-hist__row--on' : ''),
560
+ onClick: () => fill(item.text),
561
+ },
562
+ h('span', { className: 'cx-hist__text', title: item.text }, item.text),
563
+ h('span', { className: 'cx-hist__time' }, fmtTime(item.at)),
564
+ !inPrompts ? starButton(item) : null,
565
+ )
566
+ // 编辑态行:key 用文本而非索引——常用范围内文本唯一,过滤后索引漂移不再重建
567
+ // 行节点(重建会让编辑中的 input 卸载,未保存的修改静默丢失)
568
+ const editRow = (item, index) => h('div', {
569
+ key: item.text,
570
+ className: 'cx-hist__editrow',
571
+ },
572
+ h('input', {
573
+ className: 'cx-hist__editinput',
574
+ defaultValue: item.text,
575
+ title: item.text,
576
+ onKeyDown: (event) => {
577
+ if (event.key === 'Enter') {
578
+ event.preventDefault()
579
+ renamePrompt(item.text, event.target.value.trim())
580
+ }
581
+ },
582
+ onBlur: (event) => {
583
+ const next = event.target.value.trim()
584
+ if (next !== item.text) renamePrompt(item.text, next)
585
+ },
586
+ }),
587
+ h('button', {
588
+ className: 'cx-hist__del',
589
+ title: '删除',
590
+ onClick: () => togglePrompt(item.text),
591
+ }, '×'),
592
+ )
593
+ const hitCount = query.trim() === '' ? null : (items === null ? 0 : items.length)
594
+ return h('div', { className: 'cx-hist', ref: rootRef },
595
+ open && h('div', { className: 'cx-hist__pop' },
596
+ h('div', { className: 'cx-hist__hint' },
597
+ h('span', { className: 'cx-hist__scope' }, HISTORY_SCOPE_LABELS[scopeIndex]),
598
+ inPrompts
599
+ ? h('button', {
600
+ className: 'cx-hist__editbtn',
601
+ onClick: () => setEditing(!editing),
602
+ }, editing ? '完成' : '编辑')
603
+ : null,
604
+ h('input', {
605
+ className: 'cx-hist__search',
606
+ placeholder: '搜索历史输入',
607
+ value: query,
608
+ onChange: (event) => {
609
+ const next = event.target.value
610
+ setQuery(next)
611
+ if (!composingRef.current) applyFilter(next)
612
+ },
613
+ onCompositionStart: () => { composingRef.current = true },
614
+ onCompositionEnd: (event) => {
615
+ composingRef.current = false
616
+ applyFilter(event.target.value)
617
+ },
618
+ }),
619
+ hitCount !== null ? h('span', { className: 'cx-hist__count' }, String(hitCount)) : null,
620
+ h('span', null, '↑/↓ 选择 · ←/→ 切换范围 · Enter 填入 · Esc 关闭')),
621
+ aligning && h('div', { className: 'cx-hist__banner' }, '首次对齐历史中,可能需要稍等'),
622
+ items === null
623
+ ? h('div', { className: 'cx-hist__empty' }, '正在读取历史输入…')
624
+ : editing && inPrompts
625
+ ? h('div', { className: 'cx-hist__editwrap' },
626
+ items.length === 0
627
+ ? h('div', { className: 'cx-hist__empty' }, '还没有常用提示词,可从历史行悬停收藏,或在下方添加')
628
+ : h('div', { className: 'cx-hist__list' }, items.map(editRow)),
629
+ h('div', { className: 'cx-hist__addrow' },
630
+ h('input', {
631
+ className: 'cx-hist__editinput',
632
+ placeholder: '输入常用提示词,Enter 添加',
633
+ ref: (el) => { if (el) el.dataset.addinput = '1' },
634
+ onKeyDown: (event) => {
635
+ if (event.key === 'Enter') {
636
+ event.preventDefault()
637
+ const text = event.target.value.trim()
638
+ if (text !== '') {
639
+ togglePrompt(text)
640
+ event.target.value = ''
641
+ }
642
+ }
643
+ },
644
+ }),
645
+ ),
646
+ )
647
+ : items.length === 0
648
+ ? h('div', { className: 'cx-hist__empty' }, loadError
649
+ ? '历史输入加载失败,可关闭后重试'
650
+ : (query.trim() !== '' ? '无匹配' : (inPrompts ? '还没有常用提示词' : '该范围内还没有历史输入')))
651
+ : h('div', { className: 'cx-hist__list' }, items.map(rowButton)),
652
+ ),
653
+ )
654
+ }
655
+
656
+ // 插话撤回投影:仅用户插话(宿主队列 placement=steering,已发出但未被 step 边界
657
+ // claim 应用);queued 由官方队列面板呈现,context 为注入上下文非用户消息
658
+ function steerRowsOf(queue) {
659
+ const rows = Array.isArray(queue) ? queue : []
660
+ return rows.filter((row) => row && row.placement === 'steering')
661
+ }
662
+
663
+ // 撤回失败文案:queue-item-not-found = 消息已被 step 边界 claim(应用),无法撤回;
664
+ // 其余透传宿主错误消息
665
+ function withdrawFailureText(result) {
666
+ const error = result && result.error
667
+ if (error && error.code === 'session/queue-item-not-found') return '插话已被应用,无法撤回'
668
+ return '撤回失败: ' + ((error && error.message) || '未知错误')
669
+ }
670
+
671
+ // 撤回动作:先宿主移除(inbox next-step 摘除,未应用即消失),成功才回填草稿,
672
+ // 顺序保证「移除失败绝不覆盖输入框」;actions 注入便于测试与组件解耦
673
+ async function withdrawSteer(row, actions) {
674
+ let result
675
+ try {
676
+ result = await actions.updateQueue(row.id, { kind: 'remove' })
677
+ } catch (error) {
678
+ actions.notify('撤回失败: ' + String(error && error.message || error), { kind: 'error' })
679
+ return false
680
+ }
681
+ if (!(result && result.ok)) {
682
+ actions.notify(withdrawFailureText(result), { kind: 'error' })
683
+ return false
684
+ }
685
+ try {
686
+ actions.setDraft(row.text)
687
+ } catch (error) {
688
+ // 摘除已成功,草稿回填失败(会话切换等竞态)时文本必须可达:通知兜底展示原文
689
+ actions.notify('草稿回填失败,原文: ' + row.text, { kind: 'error' })
690
+ return false
691
+ }
692
+ actions.notify('已撤回到输入框')
693
+ return true
694
+ }
695
+
696
+ // 撤回图标 svg(undo 弯箭头,stroke 继承 currentColor,尺寸由所克隆的官方按钮类控制)
697
+ function steerRecallSvg() {
698
+ const namespace = 'http://www.w3.org/2000/svg'
699
+ const svg = document.createElementNS(namespace, 'svg')
700
+ svg.setAttribute('viewBox', '0 0 16 16')
701
+ svg.setAttribute('fill', 'none')
702
+ svg.setAttribute('stroke', 'currentColor')
703
+ svg.setAttribute('stroke-width', '1.5')
704
+ svg.setAttribute('stroke-linecap', 'round')
705
+ svg.setAttribute('stroke-linejoin', 'round')
706
+ svg.setAttribute('aria-hidden', 'true')
707
+ const paths = ['M3 6.5h6.5a3.5 3.5 0 0 1 0 7H6', 'M6 3.5 3 6.5l3 3']
708
+ for (const d of paths) {
709
+ const path = document.createElementNS(namespace, 'path')
710
+ path.setAttribute('d', d)
711
+ svg.appendChild(path)
712
+ }
713
+ return svg
714
+ }
715
+
716
+ // fork 图标 svg:与官方分支按钮(IconBranchOutline16)同源,fill 细路径形态;
717
+ // path 数据采自官方运行时 DOM,官方图标升级时需同步
718
+ function forkSvg() {
719
+ const namespace = 'http://www.w3.org/2000/svg'
720
+ const svg = document.createElementNS(namespace, 'svg')
721
+ svg.setAttribute('width', '16')
722
+ svg.setAttribute('height', '16')
723
+ svg.setAttribute('viewBox', '0 0 16 16')
724
+ svg.setAttribute('fill', 'none')
725
+ svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
726
+ svg.setAttribute('aria-hidden', 'true')
727
+ const path = document.createElementNS(namespace, 'path')
728
+ path.setAttribute('fill-rule', 'evenodd')
729
+ path.setAttribute('clip-rule', 'evenodd')
730
+ path.setAttribute('fill', 'currentColor')
731
+ path.setAttribute('d', 'M13.0762 1.37207C14.0846 1.37228 14.9021 2.19077 14.9023 3.19922C14.9022 4.20772 14.0847 5.02518 13.0762 5.02539C12.2967 5.02539 11.6325 4.53691 11.3701 3.84961H4.35547C4.79397 4.26458 5.15861 4.7644 5.41699 5.33496L7.10645 9.06738C7.88526 10.7875 9.55104 11.9228 11.4189 12.0371C11.7085 11.4109 12.3411 10.9756 13.0762 10.9756C14.0843 10.9759 14.9023 11.7936 14.9023 12.8018C14.9023 13.81 14.0843 14.6277 13.0762 14.6279C12.2534 14.6279 11.5574 14.0832 11.3291 13.335C8.9868 13.1879 6.89981 11.7612 5.92285 9.60352L4.23242 5.87109C3.67503 4.64033 2.44878 3.84961 1.09766 3.84961V2.54883C1.10665 2.54883 1.11601 2.54975 1.125 2.5498L11.3701 2.54883C11.6326 1.86151 12.2969 1.37207 13.0762 1.37207ZM13.0762 12.2764C12.7858 12.2764 12.5508 12.5114 12.5508 12.8018C12.5508 13.0921 12.7858 13.3281 13.0762 13.3281C13.3664 13.3279 13.6025 13.092 13.6025 12.8018C13.6025 12.5115 13.3664 12.2766 13.0762 12.2764ZM13.0762 2.67285C12.7855 2.67285 12.55 2.90861 12.5498 3.19922C12.5499 3.48987 12.7855 3.72559 13.0762 3.72559C13.3667 3.72538 13.6024 3.48975 13.6025 3.19922C13.6023 2.90874 13.3666 2.67306 13.0762 2.67285Z')
732
+ svg.appendChild(path)
733
+ return svg
734
+ }
735
+
736
+ // 注入按钮标记:识别自家节点与官方按钮,防止重复注入与误删官方节点
737
+ const STEER_BTN_FLAG = 'data-cx-steer-recall'
738
+ const FORK_BTN_FLAG = 'data-cx-fork'
739
+ // 家族版本环标记:识别自家 ‹n/m› 计数器节点,防重复注入
740
+ const FAMILY_RING_FLAG = 'data-cx-family-ring'
741
+ // 官方 pending steering 气泡的语义标记(官方同构:UserStyleBubble data 属性)与
742
+ // 其操作图标排的 CSS module 类名后缀(哈希前缀随构建漂移,后缀稳定);
743
+ // 消息节点的轮号标记(fork 锚点输入)
744
+ const STEER_BUBBLE_SELECTOR = '[data-pending-steering]'
745
+ const STEER_ACTIONS_SUFFIX = '[class$="_actions"]'
746
+ const TURN_ATTR = 'data-chat-turn'
747
+ // 官方消息流气泡的语义类型标记:user=用户输入气泡(turn-tail=官方轮尾,官方分支按钮驻留处)
748
+ const FLOW_KIND_ATTR = 'data-chat-flow-kind'
749
+ // fork 锚点窗口:follow 开场帧回溯的事件数上限(长会话全量回溯的内存/耗时护栏;
750
+ // 窗口覆盖不到的更早轮与进行中的轮不注入分叉按钮,新近轮——fork 的主要目标——总在覆盖内)
751
+ const FORK_PAGE_MAX_MESSAGES = 2000
752
+
753
+ // 重试草稿通道:分叉成功后子会话尚未挂载,原输入先按子会话 id 暂存,
754
+ // 子会话的 dock 挂载(inputActions 就绪)时消费回填并清除
755
+ const pendingForkDrafts = new Map()
756
+ // 草稿消费重查:覆盖 open 切换期间「挂载先于登记」的窗口(单次即可,上限留裕量)
757
+ const FORK_DRAFT_POLL_MS = 400
758
+ const FORK_DRAFT_POLLS = 3
759
+
760
+ // 取 follow 开场帧(回溯窗口内的事件快照,自带 seq)后立即断开:
761
+ // for-await break 触发 iterator.return,流即关闭,不消费 live 帧
762
+ async function followOpening(remote, sessionId) {
763
+ const frames = remote.follow({ address: { kind: 'session', sessionId }, maxMessages: FORK_PAGE_MAX_MESSAGES })
764
+ for await (const frame of frames) {
765
+ if (frame && frame.type === 'snapshot') return Array.isArray(frame.records) ? frame.records : []
766
+ return []
767
+ }
768
+ return []
769
+ }
770
+
771
+ // 插话撤回:注入原生 pending steering 气泡的操作图标排(复制图标旁),样式经克隆
772
+ // 官方按钮 className 完全原生;气泡被应用后整棵卸载,注入按钮随之消失。
773
+ // dock 本体 display:none 零占位,仅承载观察与注入;官方 DOM 结构漂移时注入静默
774
+ // 跳过(不报错),干净禁用精神
775
+ function SteerRecallDock({ session, useSession, inputActions, updateQueue }) {
776
+ const queue = useSession((state) => state.queue)
777
+ const queueMutable = useSession((state) => state.subagent === null || state.subagent.address.mode === 'continuable')
778
+ const [busy, setBusy] = useState(false)
779
+ // 启停开关(设置页):挂载拉取一次,停用即不注入并移除已注入按钮;失败按启用兜底
780
+ const [enabled, setEnabled] = useState(true)
781
+ const enabledRef = useRef(true)
782
+ useEffect(() => {
783
+ api(STEER_ENABLED_URL)
784
+ .then((payload) => setEnabled(payload ? payload.enabled !== false : true))
785
+ .catch(() => {})
786
+ }, [])
787
+ // 条件 return 之前同步:停用路径也要让 scan 门控看到最新值
788
+ enabledRef.current = enabled
789
+ // 观察回调经由 ref 读最新状态,观察器只挂一次不随渲染重挂
790
+ const rowsRef = useRef([])
791
+ const mutableRef = useRef(true)
792
+ const busyRef = useRef(false)
793
+ const actionsRef = useRef(null)
794
+ rowsRef.current = steerRowsOf(queue)
795
+ mutableRef.current = queueMutable
796
+ busyRef.current = busy
797
+ actionsRef.current = { updateQueue, setDraft: inputActions ? inputActions.setDraft : undefined }
798
+ const scanRef = useRef(null)
799
+ useEffect(() => {
800
+ function withdraw(row) {
801
+ const actions = actionsRef.current
802
+ if (busyRef.current || !actions || typeof actions.updateQueue !== 'function') return
803
+ setBusy(true)
804
+ void withdrawSteer(row, { updateQueue: actions.updateQueue, setDraft: actions.setDraft, notify: toast })
805
+ .finally(() => setBusy(false))
806
+ }
807
+ function syncButton(button, row) {
808
+ const textOnly = row.text !== null
809
+ button.disabled = busyRef.current || !textOnly
810
+ button.title = textOnly ? '撤回到输入框: ' + row.preview : '含附件的插话不支持撤回'
811
+ }
812
+ function scan() {
813
+ // 停用即不注入并移除已注入按钮(开关可在挂载后才到达停用值)
814
+ if (!enabledRef.current) {
815
+ document.querySelectorAll('[' + STEER_BTN_FLAG + ']').forEach((button) => button.remove())
816
+ return
817
+ }
818
+ const bubbles = document.querySelectorAll(STEER_BUBBLE_SELECTOR)
819
+ const rows = mutableRef.current ? rowsRef.current : []
820
+ bubbles.forEach((bubble, index) => {
821
+ const actionsRow = bubble.querySelector(STEER_ACTIONS_SUFFIX)
822
+ if (!actionsRow) return
823
+ let button = actionsRow.querySelector('[' + STEER_BTN_FLAG + ']')
824
+ const row = rows[index]
825
+ if (!row) {
826
+ // 气泡尚存但行已被应用/撤回(帧差瞬态):按钮随气泡卸载,先禁用防误触
827
+ if (button) button.disabled = true
828
+ return
829
+ }
830
+ if (!button) {
831
+ const official = actionsRow.querySelector('button:not([' + STEER_BTN_FLAG + '])')
832
+ if (!official) return
833
+ button = document.createElement('button')
834
+ button.type = 'button'
835
+ button.setAttribute(STEER_BTN_FLAG, '')
836
+ // 克隆官方按钮类名:尺寸/hover/悬停显隐(reveal)全部原生
837
+ button.className = official.className
838
+ button.addEventListener('click', () => withdraw(row))
839
+ button.appendChild(steerRecallSvg())
840
+ actionsRow.appendChild(button)
841
+ }
842
+ syncButton(button, row)
843
+ })
844
+ }
845
+ scanRef.current = scan
846
+ const observer = new MutationObserver(scan)
847
+ observer.observe(document.documentElement, { childList: true, subtree: true })
848
+ scan()
849
+ return () => {
850
+ scanRef.current = null
851
+ observer.disconnect()
852
+ document.querySelectorAll('[' + STEER_BTN_FLAG + ']').forEach((button) => button.remove())
853
+ }
854
+ }, [])
855
+ // 行集/开关/忙碌变化即时重扫(观察器只覆盖 DOM 变更,状态变化需主动对账)
856
+ useEffect(() => {
857
+ if (scanRef.current) scanRef.current()
858
+ }, [queue, enabled, busy, queueMutable])
859
+ if (session === undefined || inputActions === undefined || updateQueue === undefined) return null
860
+ if (!enabled || !queueMutable) return null
861
+ return h('div', { className: 'cx-steer-host' })
862
+ }
863
+
864
+ // 对话 fork:重写式分叉——从消息气泡处分叉到该轮之前(该轮不带入子会话消息流),
865
+ // 该轮用户输入回填子会话输入框供编辑重发(与插话撤回同构,作用于 fork 场景,不动原会话)。
866
+ // 锚点通道:消息气泡 DOM 携带官方轮号标记 data-chat-turn,轮号 → { 结束 seq, 首问文本 }
867
+ // 映射经 remote.session.follow 开场帧一次拉取(点分门控,旧宿主缺失即整体不注册);
868
+ // 轮号无映射 = 该轮未完成,首问无文本(纯图等)= 无法重试,均不注入;
869
+ // 首轮无前锚(宿主 fork 边界必须落在 turn/end 上,复制零事件不可表达),也不注入。
870
+ // 分叉动作经宿主 sessions 服务面 fork+open(与官方 chat 同构),成功 toast 并切换
871
+ function ForkDockWithBootstrap({ session, inputActions, forkSession, cancelSession, openSession, loadFamily, submitPrompt, loadTurnEnds }) {
872
+ // 依赖键 = 会话 id:session 快照身份随每次投影更新漂移,不能作 effect 依赖;
873
+ // loadTurnEnds 闭包身份同样不稳定,经 ref 取用
874
+ const sessionId = session && session.sessionId
875
+ const loadRef = useRef(loadTurnEnds)
876
+ loadRef.current = loadTurnEnds
877
+ // 自动重发通道:宿主 prompt 调用经 apply 闭包注入(与 forkSession 同构),ref 取用
878
+ const submitRef = useRef(null)
879
+ submitRef.current = submitPrompt
880
+ const [turnEnds, setTurnEnds] = useState(null)
881
+ useEffect(() => {
882
+ if (sessionId === undefined) return undefined
883
+ let disposed = false
884
+ // 拉取前置空:会话切换后旧会话的映射不得继续服务新会话——轮号跨会话重叠,
885
+ // 旧 seq 会让分叉锚点错位;置空期间 ForkDock 以 null 短路,不注入按钮
886
+ setTurnEnds(null)
887
+ loadRef.current()
888
+ .then((map) => {
889
+ if (!disposed) setTurnEnds(map)
890
+ })
891
+ .catch((error) => {
892
+ // 映射拉取失败(网络/宿主繁忙):fork 无锚点可用,保持禁用不注入;
893
+ // 刷新页面重试,不做自动重试风暴
894
+ console.warn('[context-manager] fork 轮号映射拉取失败', error)
895
+ })
896
+ return () => { disposed = true }
897
+ }, [sessionId])
898
+ // 重试草稿消费:open 切到子会话后本 dock 随之挂载,inputActions 绑定子会话,
899
+ // 暂存文本此刻回填;消费即清除。open 失败时暂存保留,用户手动打开子会话仍兑现。
900
+ // 宿主 open 可能先于点击处登记草稿完成子会话 dock 挂载(实测 ~400ms 切换),
901
+ // 挂载时草稿未到即空跑一次且依赖不再变化——安排有限次延迟重查兜底。
902
+ // 自动重发(开关开):草稿带 autoSubmit 标记,setDraft 后经 remote.session.prompt
903
+ // 立即提交原文(重生成语义);prompt 失败仅 toast——文本已在输入框,可手动发送
904
+ const [draftPollTick, setDraftPollTick] = useState(0)
905
+ useEffect(() => {
906
+ if (sessionId === undefined || !inputActions || typeof inputActions.setDraft !== 'function') return undefined
907
+ const draft = pendingForkDrafts.get(sessionId)
908
+ if (draft === undefined) {
909
+ if (draftPollTick >= FORK_DRAFT_POLLS) return undefined
910
+ const timer = setTimeout(() => setDraftPollTick(draftPollTick + 1), FORK_DRAFT_POLL_MS)
911
+ return () => clearTimeout(timer)
912
+ }
913
+ pendingForkDrafts.delete(sessionId)
914
+ const text = draft && typeof draft === 'object' ? draft.text : draft
915
+ const autoSubmit = Boolean(draft && typeof draft === 'object' && draft.autoSubmit === true)
916
+ inputActions.setDraft(text)
917
+ if (autoSubmit && submitRef.current) {
918
+ Promise.resolve(submitRef.current({ sessionId, text })).catch((error) => {
919
+ toast('自动重发失败,文本已在输入框可手动发送: ' + String(error && error.message || error), { kind: 'error' })
920
+ })
921
+ }
922
+ return undefined
923
+ }, [sessionId, inputActions, draftPollTick])
924
+ return h(ForkDock, { session, forkSession, cancelSession, openSession, loadFamily, turnEnds })
925
+ }
926
+
927
+ function ForkDock({ session, forkSession, cancelSession, openSession, loadFamily, turnEnds }) {
928
+ const [enabled, setEnabled] = useState(true)
929
+ const [autoResend, setAutoResend] = useState(false)
930
+ const turnEndsRef = useRef(null)
931
+ const forkRef = useRef(null)
932
+ const cancelRef = useRef(null)
933
+ const familyRef = useRef(null)
934
+ const openRef = useRef(null)
935
+ const sessionRef = useRef(null)
936
+ const enabledRef = useRef(true)
937
+ const autoResendRef = useRef(false)
938
+ useEffect(() => {
939
+ api(FORK_ENABLED_URL)
940
+ .then((payload) => setEnabled(payload ? payload.enabled !== false : true))
941
+ .catch(() => {})
942
+ api(FORK_AUTO_RESEND_URL)
943
+ .then((payload) => setAutoResend(Boolean(payload && payload.enabled === true)))
944
+ .catch(() => {})
945
+ }, [])
946
+ // 条件 return 之前同步全部 ref:停用与映射置空窗口内 scan 门控也要看到最新值,
947
+ // 否则旧会话的映射会继续服务新 DOM(轮号跨会话重叠,锚点错位)
948
+ enabledRef.current = enabled
949
+ autoResendRef.current = autoResend
950
+ turnEndsRef.current = turnEnds
951
+ forkRef.current = forkSession
952
+ cancelRef.current = cancelSession
953
+ familyRef.current = loadFamily
954
+ openRef.current = openSession
955
+ sessionRef.current = session
956
+ useEffect(() => {
957
+ function removeAll() {
958
+ document.querySelectorAll('[' + FORK_BTN_FLAG + ']').forEach((button) => button.remove())
959
+ document.querySelectorAll('[' + FAMILY_RING_FLAG + ']').forEach((node) => node.remove())
960
+ }
961
+ function scan() {
962
+ // 停用即不注入并移除已注入按钮(开关可在挂载后才到达停用值)
963
+ if (!enabledRef.current) {
964
+ removeAll()
965
+ return
966
+ }
967
+ if (turnEndsRef.current === null) return
968
+ const bubbles = document.querySelectorAll('[' + TURN_ATTR + ']')
969
+ bubbles.forEach((bubble) => {
970
+ // 只注入用户输入气泡:重写该轮改的是该轮的输入;官方分支按钮已驻留在
971
+ // 轮尾操作排(含该轮的完整分叉),两者语义互补,位置也不重叠
972
+ if (bubble.getAttribute(FLOW_KIND_ATTR) !== 'user') return
973
+ const actionsRow = bubble.querySelector(STEER_ACTIONS_SUFFIX)
974
+ if (!actionsRow) return
975
+ if (actionsRow.querySelector('[' + FORK_BTN_FLAG + ']')) return
976
+ // 空属性串 Number('') = 0,会把无轮号气泡误判为第 0 轮,先判空
977
+ const rawTurn = bubble.getAttribute(TURN_ATTR)
978
+ if (rawTurn === null || rawTurn.trim() === '') return
979
+ const turn = Number(rawTurn)
980
+ if (!Number.isInteger(turn)) return
981
+ // 重试资格三查:映射缺失的轮(超窗口)不可分叉;首轮无前锚(宿主
982
+ // fork 边界必须落在 turn/end,复制零事件不可表达);无首问文本(纯图等)
983
+ // 回填无从谈起。open=true 的进行中轮 turn/end 未落账,同样可分叉——
984
+ // 锚点取其前一个闭合轮,该轮未完成的回复分叉后停掉(见点击处理器)。
985
+ // 按钮在场即暗示可用,禁用态文案无法区分成因,不注入即误导
986
+ const entry = turnEndsRef.current.get(turn)
987
+ if (entry === undefined || entry.text === null) return
988
+ let previous = null
989
+ for (const key of turnEndsRef.current.keys()) {
990
+ const candidate = turnEndsRef.current.get(key)
991
+ if (key < turn && candidate && candidate.seq !== null && (previous === null || key > previous)) previous = key
992
+ }
993
+ if (previous === null) return
994
+ const anchor = turnEndsRef.current.get(previous)
995
+ const official = actionsRow.querySelector('button:not([' + FORK_BTN_FLAG + ']):not([' + STEER_BTN_FLAG + '])')
996
+ if (!official) return
997
+ const button = document.createElement('button')
998
+ button.type = 'button'
999
+ button.setAttribute(FORK_BTN_FLAG, '')
1000
+ // 克隆官方按钮类名:尺寸/hover/悬停显隐(reveal)全部原生
1001
+ button.className = official.className
1002
+ button.title = entry.open
1003
+ ? '重写该轮(进行中):分叉到该轮之前,原输入回填输入框重新编辑;分叉后停止本会话该轮未完成的回复'
1004
+ : '重写该轮:分叉到该轮之前,原输入回填输入框重新编辑(官方「在新对话中分支」含该轮,两者互补)'
1005
+ button.addEventListener('click', () => {
1006
+ const forkSessionFn = forkRef.current
1007
+ const cancelSessionFn = cancelRef.current
1008
+ const autoResendOn = autoResendRef.current
1009
+ const current = sessionRef.current
1010
+ const currentEntry = turnEndsRef.current === null ? undefined : turnEndsRef.current.get(turn)
1011
+ const previousEntry = turnEndsRef.current === null || previous === null ? undefined : turnEndsRef.current.get(previous)
1012
+ if (!forkSessionFn || !currentEntry || !previousEntry || !current) {
1013
+ toast('该轮不可分叉', { kind: 'error' })
1014
+ return
1015
+ }
1016
+ forkSessionFn({ sessionId: current.sessionId, atSeq: previousEntry.seq, increaseTitle: true })
1017
+ .then((childId) => {
1018
+ // 暂存先于 toast:open 的子会话挂载可能紧随 resolve,回填承诺必须先就位;
1019
+ // open 失败时暂存保留,用户手动打开子会话仍兑现;
1020
+ // 自动重发开关开:登记带标记草稿,子会话消费时回填后经 prompt 提交
1021
+ if (typeof childId === 'string' && childId !== '') {
1022
+ pendingForkDrafts.set(childId, { text: currentEntry.text, autoSubmit: autoResendOn })
1023
+ }
1024
+ // 进行中轮分叉后原会话该轮无人再读,停掉止损;失败不影响分叉成功事实
1025
+ if (currentEntry.open === true && typeof cancelSessionFn === 'function') {
1026
+ Promise.resolve(cancelSessionFn({ sessionId: current.sessionId })).catch((error) => {
1027
+ console.warn('[context-manager] 分叉后停止原会话进行中回复失败', error)
1028
+ })
1029
+ }
1030
+ toast('已分叉,原输入已填入子会话输入框')
1031
+ return childId
1032
+ })
1033
+ .catch((error) => {
1034
+ const code = error && error.code
1035
+ toast(forkFailureText(code ?? (error && error.message)), { kind: 'error', sticky: true })
1036
+ })
1037
+ })
1038
+ button.appendChild(forkSvg())
1039
+ actionsRow.appendChild(button)
1040
+ })
1041
+ // 家族版本环:每个用户气泡操作排的 ‹n/m›,箭头在家族成员间跳转;
1042
+ // 单成员家族/快照面缺失不注入(与官方按钮并存,hover 显隐一致)
1043
+ const loadFamilyFn = familyRef.current
1044
+ const ring = (sessionRef.current && typeof loadFamilyFn === 'function')
1045
+ ? loadFamilyFn(sessionRef.current.sessionId)
1046
+ : null
1047
+ if (ring && ring.total >= 2) {
1048
+ bubbles.forEach((bubble) => {
1049
+ if (bubble.getAttribute(FLOW_KIND_ATTR) !== 'user') return
1050
+ const actionsRow = bubble.querySelector(STEER_ACTIONS_SUFFIX)
1051
+ if (!actionsRow || actionsRow.querySelector('[' + FAMILY_RING_FLAG + ']')) return
1052
+ const official = actionsRow.querySelector('button:not([' + FORK_BTN_FLAG + ']):not([' + STEER_BTN_FLAG + '])')
1053
+ if (!official) return
1054
+ const ringEl = document.createElement('span')
1055
+ ringEl.setAttribute(FAMILY_RING_FLAG, '')
1056
+ ringEl.className = 'cx-family-ring'
1057
+ ringEl.title = '家族版本:本会话在同源分叉家族中的序位,箭头在各版本间切换'
1058
+ const makeChevron = (direction, tip) => {
1059
+ const chevron = document.createElement('button')
1060
+ chevron.type = 'button'
1061
+ chevron.className = official.className
1062
+ chevron.setAttribute(FAMILY_RING_FLAG, '')
1063
+ chevron.title = tip
1064
+ chevron.setAttribute('aria-label', tip)
1065
+ chevron.addEventListener('click', () => jumpFamilyMember(direction))
1066
+ chevron.appendChild(familyChevronSvg(direction))
1067
+ return chevron
1068
+ }
1069
+ const label = document.createElement('span')
1070
+ label.setAttribute(FAMILY_RING_FLAG, '')
1071
+ label.className = 'cx-family-ring__label'
1072
+ label.textContent = ring.index + '/' + ring.total
1073
+ ringEl.appendChild(makeChevron(-1, '上一个家族版本'))
1074
+ ringEl.appendChild(label)
1075
+ ringEl.appendChild(makeChevron(1, '下一个家族版本'))
1076
+ actionsRow.appendChild(ringEl)
1077
+ })
1078
+ }
1079
+ }
1080
+ // 家族环 chevron 图标:与官方 outline chevron 同款线形(12px 视窗)
1081
+ function familyChevronSvg(direction) {
1082
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
1083
+ svg.setAttribute('width', '12')
1084
+ svg.setAttribute('height', '12')
1085
+ svg.setAttribute('viewBox', '0 0 14 14')
1086
+ svg.setAttribute('fill', 'none')
1087
+ svg.setAttribute('aria-hidden', 'true')
1088
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
1089
+ path.setAttribute('d', direction < 0 ? 'M8.75 3.5L5.25 7L8.75 10.5' : 'M5.25 3.5L8.75 7L5.25 10.5')
1090
+ path.setAttribute('stroke', 'currentColor')
1091
+ path.setAttribute('stroke-width', '1.2')
1092
+ path.setAttribute('stroke-linecap', 'round')
1093
+ path.setAttribute('stroke-linejoin', 'round')
1094
+ svg.appendChild(path)
1095
+ return svg
1096
+ }
1097
+ function jumpFamilyMember(offset) {
1098
+ const loadFamilyFn = familyRef.current
1099
+ const openFn = openRef.current
1100
+ const current = sessionRef.current
1101
+ if (!current || typeof loadFamilyFn !== 'function' || typeof openFn !== 'function') return
1102
+ const ring = loadFamilyFn(current.sessionId)
1103
+ if (!ring) return
1104
+ const nextIndex = ring.index - 1 + offset
1105
+ if (nextIndex < 0 || nextIndex >= ring.members.length) return
1106
+ const target = ring.members[nextIndex]
1107
+ if (target && target !== current.sessionId) openFn(target)
1108
+ }
1109
+ if (typeof MutationObserver === 'undefined' || typeof document.querySelectorAll !== 'function') return undefined
1110
+ const scanRef = { current: scan }
1111
+ const observer = new MutationObserver(() => { if (scanRef.current) scanRef.current() })
1112
+ observer.observe(document.documentElement, { childList: true, subtree: true })
1113
+ scan()
1114
+ return () => {
1115
+ scanRef.current = null
1116
+ observer.disconnect()
1117
+ removeAll()
1118
+ }
1119
+ }, [turnEnds])
1120
+ if (session === undefined || turnEnds === null) return null
1121
+ if (!enabled) return null
1122
+ return h('div', { className: 'cx-fork-host' })
1123
+ }
1124
+
1125
+ // 面板设置项悬停说明:原生 title(设置侧栏为滚动容器,CSS 气泡会被 overflow
1126
+ // 裁剪,JS 定位复杂度不成比例);文案与功能行为同源维护,由源码契约测试锁定
1127
+ const HISTORY_SWITCH_TITLE = '在输入框按 Alt+↑ 唤起历史输入浮层,浏览并回填历史输入;浮层内 ←/→ 切换范围(常用 / 当前会话 / 本工作区 / 全部工作区),顶部搜索框过滤条目,行悬停星标可收藏常用提示词。停用后快捷键与浮层整体关闭,刷新页面生效。'
1128
+ const STEER_SWITCH_TITLE = '插话发送后、尚未被智能体应用期间,在该插话气泡的操作图标排显示撤回按钮,点击撤回并把原文填回输入框(覆盖输入框现有草稿);含附件的插话不可撤回;消息被应用后按钮随气泡消失,恰在应用瞬间点击会提示已应用且不动草稿。停用即不再注入,刷新页面生效。'
1129
+ const FORK_SWITCH_TITLE = '消息气泡操作排显示分叉按钮,点击分叉出新会话到该轮之前(该轮不带入子会话),该轮的用户输入自动回填子会话输入框供编辑重发,子会话自动打开且标题尾号递增;进行中的轮(回复尚未完成)同样可分叉,分叉后自动停止本会话该轮未完成的回复;首轮(无更早上下文可继承,新建会话即为同义操作)与无文本输入的轮(纯图等,无从重发)不注入;停用即不再注入,刷新页面生效。'
1130
+ const FORK_AUTO_RESEND_SWITCH_TITLE = '分叉成功后自动把该轮原输入发送到子会话立即开跑(重生成语义,相当于原输入重跑一遍);关闭时分叉仅把原输入回填子会话输入框,由你编辑后再手动发送。'
1131
+
1132
+ // 启停开关行工厂:三个开关同构(受控 checkbox + cx-switch 形态),值存宿主 settings,
1133
+ // 切换经本插件路由中转,变更刷新页面生效
1134
+ function switchRow(url, label, title, okText) {
1135
+ function Row() {
1136
+ const [enabled, setEnabled] = useState(null)
1137
+ useEffect(() => {
1138
+ api(url)
1139
+ .then((payload) => setEnabled(payload ? payload.enabled !== false : true))
1140
+ .catch(() => setEnabled(true))
1141
+ }, [])
1142
+ // 受控 checkbox:onChange 内同步落 state(异步确认会让 DOM 与渲染竞态,
1143
+ // 视觉慢一拍),服务端响应仅用于失败回滚
1144
+ const flip = (event) => {
1145
+ const next = event.target.checked
1146
+ setEnabled(next)
1147
+ api(url, { method: 'POST', body: JSON.stringify({ enabled: next }) })
1148
+ .then((payload) => {
1149
+ toast(okText + (payload && payload.enabled !== false ? '已启用' : '已停用') + ',刷新页面后生效')
1150
+ })
1151
+ .catch(() => {
1152
+ setEnabled(!next)
1153
+ toast('切换失败', { kind: 'error' })
1154
+ })
1155
+ }
1156
+ return h('label', { className: 'cx-switch', title },
1157
+ h('input', { type: 'checkbox', checked: enabled !== false, onChange: flip }),
1158
+ h('span', { className: 'cx-switch__track' }, h('span', { className: 'cx-switch__thumb' })),
1159
+ h('span', { className: 'cx-switch__label', onClick: (event) => event.preventDefault() }, label),
1160
+ )
1161
+ }
1162
+ return Row
1163
+ }
1164
+
1165
+ const HistorySwitchRow = switchRow(HISTORY_ENABLED_URL, '历史输入浮层(Alt+↑)', HISTORY_SWITCH_TITLE, '历史输入浮层')
1166
+ const SteerSwitchRow = switchRow(STEER_ENABLED_URL, '插话撤回', STEER_SWITCH_TITLE, '插话撤回')
1167
+ const ForkSwitchRow = switchRow(FORK_ENABLED_URL, '对话 fork', FORK_SWITCH_TITLE, '对话 fork')
1168
+ const ForkAutoResendSwitchRow = switchRow(FORK_AUTO_RESEND_URL, '分叉后自动重发', FORK_AUTO_RESEND_SWITCH_TITLE, '分叉后自动重发')
1169
+
1170
+ // 「插件」设置页卡片:与官方 PluginCard 同款折叠卡(header 展开/收起 + body),
1171
+ // 视觉值取自宿主实测(见 CSS 注释),组件因缺宿主 i18n/表单状态而自绘
1172
+ function ContextPanel() {
1173
+ const [open, setOpen] = useState(false)
1174
+ return h('div', { className: 'cx-card' + (open ? ' cx-card--open' : '') },
1175
+ h('button', { type: 'button', className: 'cx-card__header', 'aria-expanded': open, onClick: () => setOpen(!open) },
1176
+ h('span', { className: 'cx-card__head-text' },
1177
+ h('span', { className: 'cx-card__name' }, '对话增强'),
1178
+ h('span', { className: 'cx-card__description' }, '历史输入、插话撤回与对话分叉的启停'),
1179
+ ),
1180
+ h('span', { className: 'cx-card__chevron', 'aria-hidden': 'true' },
1181
+ h('svg', { width: 14, height: 14, viewBox: '0 0 14 14', fill: 'none' },
1182
+ h('path', { d: 'M3.5 5.25L7 8.75L10.5 5.25', stroke: 'currentColor', 'stroke-width': '1.2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }),
1183
+ ),
1184
+ ),
1185
+ ),
1186
+ open && h('div', { className: 'cx-card__body' },
1187
+ h('span', { className: 'cx-panel__hint' }, '变更刷新页面生效。'),
1188
+ h(HistorySwitchRow),
1189
+ h(SteerSwitchRow),
1190
+ h(ForkSwitchRow),
1191
+ h(ForkAutoResendSwitchRow),
1192
+ ),
1193
+ )
1194
+ }
1195
+
1196
+ return {
1197
+ // remote.session 点分声明交 cordis 门控(规约禁 apply 内同步探测该面):
1198
+ // namespace 由宿主异步 $mount,fiber 等其就绪才激活,旧宿主无此 namespace
1199
+ // 即整体未激活(干净禁用,不阻塞 web 启动)
1200
+ inject: ['slots', 'sessions', 'workspaces', 'remote', 'remote.session'],
1201
+ apply(ctx) {
1202
+ const sessions = ctx.get('sessions')
1203
+ const workspaces = ctx.get('workspaces')
1204
+ const remoteSession = ctx.remote ? ctx.remote.session : undefined
1205
+ // 分叉动作通道:fork 成功即打开子会话;open 失败不影响分叉成功的事实,
1206
+ // 单独吞掉(警告日志),点击处不再误报「分叉失败」
1207
+ const forkService = (sessions && typeof sessions.fork === 'function' && typeof sessions.open === 'function')
1208
+ ? (opts) => sessions.fork(opts).then((childId) => {
1209
+ try {
1210
+ Promise.resolve(sessions.open(childId)).catch((error) => {
1211
+ console.warn('[context-manager] 分叉子会话已创建,但打开失败', error)
1212
+ })
1213
+ } catch (error) {
1214
+ console.warn('[context-manager] 分叉子会话已创建,但打开失败', error)
1215
+ }
1216
+ return childId
1217
+ })
1218
+ : null
1219
+
1220
+ // 家族环通道:会话列表快照(parentSessionId 字段)投影为当前会话的 ‹n/m› 计数;
1221
+ // 快照面缺失(旧宿主)时返回 null,计数器整体不注入
1222
+ const loadFamily = (sessionId) => {
1223
+ if (!sessions || !sessions.list || typeof sessions.list.getSnapshot !== 'function') return null
1224
+ const snapshot = sessions.list.getSnapshot()
1225
+ const rows = snapshot && snapshot.byId
1226
+ ? (Array.isArray(snapshot.ids) ? snapshot.ids : []).map((id) => snapshot.byId[id]).filter(Boolean)
1227
+ : (Array.isArray(snapshot) ? snapshot : [])
1228
+ if (rows.length === 0) return null
1229
+ return familyRing(sessionFamilyMap(rows), sessionId)
1230
+ }
1231
+
1232
+ // 自动重发通道:开启开关时分叉后以该轮原输入 prompt 子会话;
1233
+ // remote.session 缺失或缺 prompt(旧宿主/部分 stub)时返回 null,降级为仅回填;
1234
+ // 请求形态与官方 client face 同源:content 为 text 分段数组(宿主 hasPromptContent
1235
+ // 校验),mode=queue(无进行中轮即直接开跑),clientTimeZone 随本地时区
1236
+ const submitPrompt = (remoteSession && typeof remoteSession.prompt === 'function')
1237
+ ? ({ sessionId: targetId, text }) => remoteSession.prompt({
1238
+ requestId: (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : String(Date.now()),
1239
+ sessionId: targetId,
1240
+ mode: 'queue',
1241
+ content: [{ type: 'text', text }],
1242
+ clientTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
1243
+ })
1244
+ : null
1245
+
1246
+ // 样式挂载在宿主文档级:设置页未打开时面板不存在,
1247
+ // 样式若随面板注入则面板裸样式渲染
1248
+ ctx.effect(() => {
1249
+ const style = document.createElement('style')
1250
+ // 自带 data-plugin:缺失时宿主 claimStyles 会把它归属给后续材质化插件,其 HMR 重建即误删
1251
+ style.setAttribute('data-plugin', '@mzzsfy/dsh-context-manager')
1252
+ style.textContent = CSS
1253
+ document.head.appendChild(style)
1254
+ return () => style.remove()
1255
+ }, 'context-manager styles')
1256
+
1257
+ // 设置卡片:注册进官方「插件」设置页的可配置插件区(keyed slot,
1258
+ // key = 本插件 settings namespace),不占独立设置导航项
1259
+ ctx.slots.inject('settings.plugin.item', () =>
1260
+ ctx.slots.register(
1261
+ { name: 'settings.plugin.item', id: 'context', key: 'context' },
1262
+ () => React.createElement(ContextPanel),
1263
+ ))
1264
+
1265
+ // 历史输入回溯入口:官方 conversation.input.dock 插槽(dsh-client-ui-goal 同构)。
1266
+ // 宿主缺该插槽时注册抛错即禁用本功能,不阻塞其余能力
1267
+ try {
1268
+ ctx.slots.inject('conversation.input.dock', () =>
1269
+ ctx.slots.register(
1270
+ { name: 'conversation.input.dock', id: 'context-manager-history', order: 90 },
1271
+ HistoryDock,
1272
+ ))
1273
+ } catch (error) {
1274
+ console.warn('[context-manager] 历史输入入口未注册(宿主无 conversation.input.dock 插槽)', error)
1275
+ }
1276
+
1277
+ // 插话撤回入口:未应用的插话撤回到输入框重新编辑。RPC 走会话绑定面
1278
+ // binding.session.updateQueue(RemoteResult 不抛错,与官方 conversation 服务
1279
+ // 无耦合);会话面缺失或无 updateQueue(旧宿主)返回空 props,组件不渲染
1280
+ try {
1281
+ ctx.slots.inject('conversation.input.dock', () =>
1282
+ ctx.slots.register(
1283
+ {
1284
+ name: 'conversation.input.dock',
1285
+ id: 'context-manager-steer',
1286
+ order: 25,
1287
+ inject: (sessionId) => {
1288
+ const binding = sessions.binding(sessionId)
1289
+ const updateQueue = binding && binding.session && binding.session.updateQueue
1290
+ if (typeof updateQueue !== 'function') return {}
1291
+ return { updateQueue: (itemId, action) => binding.session.updateQueue(itemId, action) }
1292
+ },
1293
+ },
1294
+ SteerRecallDock,
1295
+ ))
1296
+ } catch (error) {
1297
+ console.warn('[context-manager] 插话撤回入口未注册(宿主无 conversation.input.dock 插槽)', error)
1298
+ }
1299
+
1300
+ // 对话 fork 入口:锚点映射经 remote.session.follow 开场帧一次拉取
1301
+ // (开场帧自带回溯窗口内的全部事件与 seq,取到即断开订阅,不做 live 消费),
1302
+ // 分叉动作经 sessions 服务面;分叉服务面缺失即整体不注册(干净禁用)。
1303
+ // inputActions 由插槽宿主按会话绑定注入,子会话挂载时消费重试草稿
1304
+ if (forkService) {
1305
+ try {
1306
+ ctx.slots.inject('conversation.input.dock', () =>
1307
+ ctx.slots.register(
1308
+ {
1309
+ name: 'conversation.input.dock',
1310
+ id: 'context-manager-fork',
1311
+ order: 24,
1312
+ inject: (sessionId) => ({
1313
+ forkSession: forkService,
1314
+ cancelSession: (typeof remoteSession.cancel === 'function')
1315
+ ? (opts) => remoteSession.cancel(opts)
1316
+ : null,
1317
+ openSession: (sessions && typeof sessions.open === 'function') ? (id) => sessions.open(id) : null,
1318
+ loadFamily: () => loadFamily(sessionId),
1319
+ submitPrompt,
1320
+ loadTurnEnds: () => followOpening(remoteSession, sessionId).then((records) => {
1321
+ // 轮号 → { 结束 seq, 该轮首问文本, open }:上个 turn/end 之后首条
1322
+ // 携带非空文本的 user 本人消息即该轮首问(轮内纯图消息跳过,
1323
+ // 后续文本插话可补位);turn/end 落账并重置;
1324
+ // 流末尾文本已累积而无 turn/end 的轮是进行中轮:seq=null +
1325
+ // open=true 登记(分叉锚点取其前一个闭合轮,分叉后可停原会话);
1326
+ // data 缺 turn 号时按出现顺序计数
1327
+ const map = new Map()
1328
+ let ordered = 0
1329
+ let turnText = null
1330
+ for (const record of records) {
1331
+ const event = record && record.event
1332
+ if (!event) continue
1333
+ if (event.type === 'user/message') {
1334
+ if (turnText === null) turnText = forkRetryText(event.data)
1335
+ continue
1336
+ }
1337
+ if (event.type !== 'turn/end') continue
1338
+ const turn = event.data && Number.isInteger(event.data.turn) ? event.data.turn : ordered
1339
+ map.set(turn, { seq: event.seq, text: turnText, open: false })
1340
+ turnText = null
1341
+ ordered += 1
1342
+ }
1343
+ if (turnText !== null) {
1344
+ const turn = map.size > 0 ? Math.max(...map.keys()) + 1 : 0
1345
+ map.set(turn, { seq: null, text: turnText, open: true })
1346
+ }
1347
+ return map
1348
+ }),
1349
+ }),
1350
+ },
1351
+ ForkDockWithBootstrap,
1352
+ ))
1353
+ } catch (error) {
1354
+ console.warn('[context-manager] 对话 fork 入口未注册(宿主无 conversation.input.dock 插槽)', error)
1355
+ }
1356
+ }
1357
+ },
1358
+ }
1359
+ },
1360
+ })
1361
+
1362
+