@better-than-coca/dsh-session-handoff 0.0.0-stage → 0.5.5
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/LICENSE +21 -0
- package/README.md +159 -2
- package/client.js +837 -0
- package/cordis.patch.yml +31 -0
- package/lib/archive.js +224 -0
- package/lib/checkpoint.js +20 -0
- package/lib/config.js +90 -0
- package/lib/debug.js +13 -0
- package/lib/handoff.js +178 -0
- package/lib/history.js +103 -0
- package/lib/http.js +193 -0
- package/lib/index.js +722 -0
- package/lib/pressure.js +46 -0
- package/lib/route-card.js +130 -0
- package/lib/settings.js +155 -0
- package/lib/summary-engine.js +308 -0
- package/package.json +54 -4
package/client.js
ADDED
|
@@ -0,0 +1,837 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client half of dsh-session-handoff: one icon button in the assistant message
|
|
3
|
+
* action row, the row that already carries copy / rating / branch / usage.
|
|
4
|
+
*
|
|
5
|
+
* The button only asks; it never summarizes in the browser. Clicking it POSTs to
|
|
6
|
+
* this plugin's Host half, which decides between "run now" (idle session) and
|
|
7
|
+
* "queue" (mid-turn), using exactly the same rules as the session_handoff tool.
|
|
8
|
+
*
|
|
9
|
+
* The row is rendered once per finalized assistant message, so every button
|
|
10
|
+
* shares one snapshot store and one refresh loop instead of polling per row.
|
|
11
|
+
*
|
|
12
|
+
* Contract with the Host half:
|
|
13
|
+
* GET /api/dsh-session-handoff/status?sessionId=... -> status payload
|
|
14
|
+
* POST /api/dsh-session-handoff/handoff { sessionId } -> { ok, running|queued }
|
|
15
|
+
*/
|
|
16
|
+
window.__ModuleLoader__.load({
|
|
17
|
+
id: '@better-than-coca/dsh-session-handoff',
|
|
18
|
+
factory(require) {
|
|
19
|
+
const React = require('react')
|
|
20
|
+
const h = React.createElement
|
|
21
|
+
|
|
22
|
+
const STATUS_URL = '/api/dsh-session-handoff/status'
|
|
23
|
+
const HANDOFF_URL = '/api/dsh-session-handoff/handoff'
|
|
24
|
+
/** How often an accepted handoff is re-checked, and its budget. */
|
|
25
|
+
const POLL_MS = 2000
|
|
26
|
+
const POLL_TICKS = 90
|
|
27
|
+
/** Idle re-read cadence so the dot appears without a click. */
|
|
28
|
+
const IDLE_REFRESH_MS = 20000
|
|
29
|
+
|
|
30
|
+
const TEXT = {
|
|
31
|
+
zh: {
|
|
32
|
+
idle: '交接上下文',
|
|
33
|
+
attention: '上下文 {p}% · 点这里交接',
|
|
34
|
+
busy: '正在生成摘要…',
|
|
35
|
+
done: '摘要已就绪 · 开新对话即可接收',
|
|
36
|
+
dryRun: '试运行:未做任何改动',
|
|
37
|
+
refused: '暂时不能交接:{e}',
|
|
38
|
+
failed: '交接失败:{e}',
|
|
39
|
+
off: '插件已关闭',
|
|
40
|
+
dormant: '重启后这个会话还没跑过一轮,先在这里发一句话再点',
|
|
41
|
+
summarizing: '正在生成摘要…',
|
|
42
|
+
opening: '摘要好了,正在开新会话…',
|
|
43
|
+
opened: '已在新会话继续',
|
|
44
|
+
manual: '摘要已就绪,请新建对话取走',
|
|
45
|
+
unavailable: '宿主没提供新建会话的通道,请手动新建对话',
|
|
46
|
+
'no-cwd': '拿不到工作目录,请手动新建对话',
|
|
47
|
+
slow: '新会话还没开出来,稍后重开对话就能取走',
|
|
48
|
+
'create-failed': '新会话没建成:{e}',
|
|
49
|
+
'prompt-failed': '新会话建好了,但第一条消息没发出去:{e}'
|
|
50
|
+
},
|
|
51
|
+
en: {
|
|
52
|
+
idle: 'Hand off context',
|
|
53
|
+
attention: 'Context at {p}% · click to hand off',
|
|
54
|
+
busy: 'Summarizing…',
|
|
55
|
+
done: 'Checkpoint ready · open a new chat to receive it',
|
|
56
|
+
dryRun: 'Dry run: nothing changed',
|
|
57
|
+
refused: 'Cannot hand off: {e}',
|
|
58
|
+
failed: 'Handoff failed: {e}',
|
|
59
|
+
off: 'Plugin is off',
|
|
60
|
+
dormant: 'No turn has run in this chat since the host started — send one message, then click',
|
|
61
|
+
summarizing: 'Summarizing…',
|
|
62
|
+
opening: 'Checkpoint ready, opening a new session…',
|
|
63
|
+
opened: 'Continued in a new session',
|
|
64
|
+
manual: 'Checkpoint ready — open a new chat to receive it',
|
|
65
|
+
unavailable: 'This host offers no session channel — start a new chat yourself',
|
|
66
|
+
'no-cwd': 'No working directory to open a session in — start one yourself',
|
|
67
|
+
slow: 'The new session has not appeared yet; a new chat will still pick the checkpoint up',
|
|
68
|
+
'create-failed': 'Could not create the new session: {e}',
|
|
69
|
+
'prompt-failed': 'Session created, but the first message was not accepted: {e}'
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const lang = () => {
|
|
74
|
+
const raw = (typeof document !== 'undefined' && document.documentElement.lang) || navigator.language || 'en'
|
|
75
|
+
return String(raw).toLowerCase().startsWith('zh') ? 'zh' : 'en'
|
|
76
|
+
}
|
|
77
|
+
const fill = (template, params) => {
|
|
78
|
+
let out = template
|
|
79
|
+
for (const key of Object.keys(params || {})) out = out.split('{' + key + '}').join(String(params[key]))
|
|
80
|
+
return out
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Turn one follow-up outcome into the label the button shows. */
|
|
84
|
+
const noteFor = followUp => {
|
|
85
|
+
const dict = TEXT[lang()]
|
|
86
|
+
if (followUp === null || followUp === undefined) return dict.slow
|
|
87
|
+
const template = dict[followUp.code] || dict.failed
|
|
88
|
+
return followUp.detail === undefined ? template : fill(template, { e: followUp.detail })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ----------------------------------------------------------------- store
|
|
92
|
+
const store = { snapshot: null, listeners: new Set(), inflight: null, pollTimer: null, ticking: false }
|
|
93
|
+
|
|
94
|
+
const emit = () => {
|
|
95
|
+
for (const listener of [...store.listeners]) {
|
|
96
|
+
try {
|
|
97
|
+
listener(store.snapshot)
|
|
98
|
+
} catch {
|
|
99
|
+
/* one bad subscriber must not stop the others */
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const refresh = sessionId => {
|
|
105
|
+
if (store.inflight !== null) return store.inflight
|
|
106
|
+
const url = sessionId === undefined || sessionId === null || sessionId === ''
|
|
107
|
+
? STATUS_URL
|
|
108
|
+
: STATUS_URL + '?sessionId=' + encodeURIComponent(String(sessionId))
|
|
109
|
+
store.inflight = fetch(url, { headers: { accept: 'application/json' } })
|
|
110
|
+
.then(response => response.json())
|
|
111
|
+
.then(body => {
|
|
112
|
+
if (body && body.ok === true) {
|
|
113
|
+
store.snapshot = body
|
|
114
|
+
emit()
|
|
115
|
+
}
|
|
116
|
+
return body
|
|
117
|
+
})
|
|
118
|
+
.catch(() => null)
|
|
119
|
+
.finally(() => {
|
|
120
|
+
store.inflight = null
|
|
121
|
+
})
|
|
122
|
+
return store.inflight
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const stopPolling = () => {
|
|
126
|
+
if (store.pollTimer !== null) {
|
|
127
|
+
clearInterval(store.pollTimer)
|
|
128
|
+
store.pollTimer = null
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const startPolling = sessionId => {
|
|
133
|
+
stopPolling()
|
|
134
|
+
let ticks = 0
|
|
135
|
+
store.pollTimer = setInterval(() => {
|
|
136
|
+
ticks += 1
|
|
137
|
+
const done = store.snapshot !== null && store.snapshot.running !== true
|
|
138
|
+
&& store.snapshot.pending === 'waiting'
|
|
139
|
+
if (done || ticks > POLL_TICKS) {
|
|
140
|
+
stopPolling()
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
void refresh(sessionId)
|
|
144
|
+
}, POLL_MS)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ------------------------------------------------- follow-up session
|
|
148
|
+
/**
|
|
149
|
+
* Navigation service the Host exposes to Client plugins. Captured only when
|
|
150
|
+
* it resolves: switching to the new Session is a convenience — the Session
|
|
151
|
+
* itself is created by the host half, not here.
|
|
152
|
+
*/
|
|
153
|
+
let workspace = null
|
|
154
|
+
|
|
155
|
+
const captureServices = ctx => {
|
|
156
|
+
try {
|
|
157
|
+
ctx.inject(['uiWorkspace'], scoped => {
|
|
158
|
+
workspace = scoped.get('uiWorkspace') ?? scoped.uiWorkspace ?? null
|
|
159
|
+
})
|
|
160
|
+
} catch {
|
|
161
|
+
/* no session navigation service */
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** How long the button watches for the host's follow-up Session. */
|
|
166
|
+
const FOLLOW_UP_WAIT_MS = 240000
|
|
167
|
+
const FOLLOW_UP_TICK_MS = 900
|
|
168
|
+
|
|
169
|
+
const sleep = ms => new Promise(resolve => {
|
|
170
|
+
setTimeout(resolve, ms)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
const statusNow = sessionId => {
|
|
174
|
+
const url = sessionId === undefined || sessionId === null || sessionId === ''
|
|
175
|
+
? STATUS_URL
|
|
176
|
+
: STATUS_URL + '?sessionId=' + encodeURIComponent(String(sessionId))
|
|
177
|
+
return fetch(url, { headers: { accept: 'application/json' } })
|
|
178
|
+
.then(response => response.json())
|
|
179
|
+
.catch(() => null)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Wait for the host half to report how the follow-up Session ended.
|
|
184
|
+
* @param sessionId - the session being handed off.
|
|
185
|
+
* @returns the outcome record, or null when it never arrived.
|
|
186
|
+
*/
|
|
187
|
+
const waitForFollowUp = async sessionId => {
|
|
188
|
+
const until = Date.now() + FOLLOW_UP_WAIT_MS
|
|
189
|
+
while (Date.now() < until) {
|
|
190
|
+
const status = await statusNow(sessionId)
|
|
191
|
+
const followUp = status === null || status === undefined ? null : status.followUp
|
|
192
|
+
if (followUp !== null && followUp !== undefined && typeof followUp.code === 'string') return followUp
|
|
193
|
+
await sleep(FOLLOW_UP_TICK_MS)
|
|
194
|
+
}
|
|
195
|
+
return null
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Switch to the Session the host opened, when navigation is available. */
|
|
199
|
+
const revealSession = sessionId => {
|
|
200
|
+
if (workspace === null || typeof workspace.openSession !== 'function') return
|
|
201
|
+
try {
|
|
202
|
+
workspace.openSession(sessionId)
|
|
203
|
+
} catch {
|
|
204
|
+
/* the sidebar lists the new Session either way */
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function useSnapshot() {
|
|
209
|
+
const [snapshot, setSnapshot] = React.useState(store.snapshot)
|
|
210
|
+
React.useEffect(() => {
|
|
211
|
+
const listener = value => setSnapshot(value)
|
|
212
|
+
store.listeners.add(listener)
|
|
213
|
+
return () => {
|
|
214
|
+
store.listeners.delete(listener)
|
|
215
|
+
}
|
|
216
|
+
}, [])
|
|
217
|
+
return snapshot
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ------------------------------------------------------------------ icon
|
|
221
|
+
const svg = (children, extra) => h('svg', {
|
|
222
|
+
viewBox: '0 0 24 24',
|
|
223
|
+
fill: 'none',
|
|
224
|
+
stroke: 'currentColor',
|
|
225
|
+
strokeWidth: 1.6,
|
|
226
|
+
strokeLinecap: 'round',
|
|
227
|
+
strokeLinejoin: 'round',
|
|
228
|
+
'aria-hidden': 'true',
|
|
229
|
+
...extra
|
|
230
|
+
}, children)
|
|
231
|
+
|
|
232
|
+
/** Candidate A: old session box -> arrow -> new session box. */
|
|
233
|
+
const HANDOFF_ICON = svg([
|
|
234
|
+
h('rect', { key: 'a', x: 2.5, y: 5.5, width: 7, height: 13, rx: 1.6 }),
|
|
235
|
+
h('path', { key: 'b', d: 'M12.2 12h6.6' }),
|
|
236
|
+
h('path', { key: 'c', d: 'M16.3 9.6 18.9 12l-2.6 2.4' }),
|
|
237
|
+
h('rect', { key: 'd', x: 20, y: 8.5, width: 1.6, height: 7, rx: 0.8, fill: 'currentColor', stroke: 'none' })
|
|
238
|
+
])
|
|
239
|
+
|
|
240
|
+
const CHECK_ICON = svg([h('path', { key: 'a', d: 'M4 12.5 9.5 18 20 6.5' })], { strokeWidth: 1.8 })
|
|
241
|
+
|
|
242
|
+
const SPINNER_ICON = svg([h('path', { key: 'a', d: 'M12 3a9 9 0 1 1-9 9' })], { className: 'dsh-handoff-spin' })
|
|
243
|
+
|
|
244
|
+
// ------------------------------------------------------------------- css
|
|
245
|
+
const CSS = [
|
|
246
|
+
'.dsh-handoff-btn{position:relative;width:calc(28px + var(--dsh-content-font-delta,0px));height:calc(28px + var(--dsh-content-font-delta,0px));padding:6px;border:none;border-radius:var(--dsw-radius-sm,6px);background:transparent;color:var(--dsw-alias-label-tertiary,#8f8f88);cursor:pointer;display:inline-flex;align-items:center;justify-content:center}',
|
|
247
|
+
'.dsh-handoff-btn svg{width:calc(15px + var(--dsh-content-font-delta,0px));height:calc(15px + var(--dsh-content-font-delta,0px));display:block}',
|
|
248
|
+
'.dsh-handoff-btn:hover{background:var(--dsw-alias-fill-l1,rgba(0,0,0,.05));color:var(--dsw-alias-label-secondary,#5c5c57)}',
|
|
249
|
+
'.dsh-handoff-btn:focus-visible{outline:2px solid var(--dsw-alias-line-focus,#7aa7d8);outline-offset:1px}',
|
|
250
|
+
'.dsh-handoff-btn.is-attention{color:#b8862b}',
|
|
251
|
+
'.dsh-handoff-btn.is-done{color:#4f8a6d}',
|
|
252
|
+
'.dsh-handoff-btn.is-busy{cursor:progress}',
|
|
253
|
+
'.dsh-handoff-dot{position:absolute;top:5px;right:5px;width:5px;height:5px;border-radius:999px;background:#d98a2b}',
|
|
254
|
+
'.dsh-handoff-btn[data-tip]:hover::after{content:attr(data-tip);position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);background:#2a2a27;color:#fff;font-size:11.5px;line-height:1.45;padding:4px 8px;border-radius:5px;white-space:nowrap;pointer-events:none;z-index:60}',
|
|
255
|
+
'@keyframes dsh-handoff-spin{to{transform:rotate(360deg)}}',
|
|
256
|
+
'.dsh-handoff-spin{animation:dsh-handoff-spin .9s linear infinite;transform-origin:center}'
|
|
257
|
+
].join('\n')
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
// ------------------------------------------------------------ settings
|
|
261
|
+
const SETTINGS_URL = '/api/dsh-session-handoff/settings'
|
|
262
|
+
const PANEL_ID = 'session-handoff-panel'
|
|
263
|
+
|
|
264
|
+
const COPY = {
|
|
265
|
+
zh: {
|
|
266
|
+
title: '会话交接',
|
|
267
|
+
blurb: '上下文到阈值时把这一会话摘要出来,交给一个新会话接着做。旧会话永远不会被压缩或改动。',
|
|
268
|
+
live: '本会话上下文',
|
|
269
|
+
close: '关闭',
|
|
270
|
+
secTrigger: '触发',
|
|
271
|
+
secTriggerNote: '什么时候提醒你,要不要自动做',
|
|
272
|
+
secBehaviour: '行为',
|
|
273
|
+
secBehaviourNote: '交接本身怎么执行',
|
|
274
|
+
save: '已保存',
|
|
275
|
+
reset: '恢复默认',
|
|
276
|
+
ready: '可交接',
|
|
277
|
+
waiting: '没有等待中的摘要',
|
|
278
|
+
last: '最近一次',
|
|
279
|
+
label: '会话交接',
|
|
280
|
+
hint: {
|
|
281
|
+
mode: '询问=到阈值时提醒你;自动=达到自动阈值直接交接;关闭=只保留消息行里的手动按钮',
|
|
282
|
+
askRatio: '上下文占用超过这个比例就提醒你一次',
|
|
283
|
+
autoRatio: '必须高于询问阈值,否则自动交接会抢在提醒之前发生',
|
|
284
|
+
noticeCooldownMs: '同一会话里两次提醒之间至少隔这么久,避免反复打断',
|
|
285
|
+
dryRun: '只报告会做什么,不真的调用摘要模型',
|
|
286
|
+
maxHandoffsPerSession: '防止一个会话被反反复复地交接出去',
|
|
287
|
+
includeHistoryTool: '给模型 handoff_history,可以只读搜索旧会话日志',
|
|
288
|
+
autoOpenSession: '交接完成后自动开一个新会话、注入摘要并切过去'
|
|
289
|
+
},
|
|
290
|
+
name: {
|
|
291
|
+
mode: '模式', askRatio: '询问阈值', autoRatio: '自动阈值', noticeCooldownMs: '提醒冷却',
|
|
292
|
+
dryRun: '试运行', maxHandoffsPerSession: '每个会话最多交接次数', includeHistoryTool: '提供原文检索工具',
|
|
293
|
+
autoOpenSession: '自动开新会话'
|
|
294
|
+
},
|
|
295
|
+
modes: { ask: '询问', auto: '自动', off: '关闭' },
|
|
296
|
+
minute: '分'
|
|
297
|
+
},
|
|
298
|
+
en: {
|
|
299
|
+
title: 'Session handoff',
|
|
300
|
+
blurb: 'Summarize this session at a threshold and continue in a new one. The old session is never compacted or modified.',
|
|
301
|
+
live: 'This session',
|
|
302
|
+
close: 'Close',
|
|
303
|
+
secTrigger: 'Trigger',
|
|
304
|
+
secTriggerNote: 'when to ask, and whether to act alone',
|
|
305
|
+
secBehaviour: 'Behaviour',
|
|
306
|
+
secBehaviourNote: 'how the handoff itself runs',
|
|
307
|
+
save: 'Saved',
|
|
308
|
+
reset: 'Reset to defaults',
|
|
309
|
+
ready: 'Eligible',
|
|
310
|
+
waiting: 'No waiting checkpoint',
|
|
311
|
+
last: 'Last one',
|
|
312
|
+
label: 'Session handoff',
|
|
313
|
+
hint: {
|
|
314
|
+
mode: 'ask = check in at the threshold; auto = hand off at the auto threshold; off = keep only the manual button',
|
|
315
|
+
askRatio: 'How full the context must get before you are asked',
|
|
316
|
+
autoRatio: 'Must stay above the ask threshold, or auto mode would act before asking',
|
|
317
|
+
noticeCooldownMs: 'Minimum gap between two notices in one session',
|
|
318
|
+
dryRun: 'Report what would happen without calling the summarizer',
|
|
319
|
+
maxHandoffsPerSession: 'Stops one session being handed off over and over',
|
|
320
|
+
includeHistoryTool: 'Give the model handoff_history to search the old log read-only',
|
|
321
|
+
autoOpenSession: 'After the summary, open a new session, inject it there and switch over'
|
|
322
|
+
},
|
|
323
|
+
name: {
|
|
324
|
+
mode: 'Mode', askRatio: 'Ask threshold', autoRatio: 'Auto threshold', noticeCooldownMs: 'Notice cooldown',
|
|
325
|
+
dryRun: 'Dry run', maxHandoffsPerSession: 'Max handoffs per session', includeHistoryTool: 'Archive search tool',
|
|
326
|
+
autoOpenSession: 'Open the new session'
|
|
327
|
+
},
|
|
328
|
+
modes: { ask: 'Ask', auto: 'Auto', off: 'Off' },
|
|
329
|
+
minute: 'min'
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const PANEL_CSS = [
|
|
334
|
+
'.dsh-ho-panel{height:100%;display:flex;flex-direction:column;background:#fff;color:#1c1c1a;font-size:13px;line-height:1.5;--ho-ink:#1c1c1a;--ho-ink2:#57574f;--ho-ink3:#8b8b82;--ho-ink4:#a8a89f;--ho-line:#e6e5df;--ho-line-soft:#efeee9;--ho-sunken:#faf9f6;--ho-hover:#f4f3ee;--ho-accent:#6b7f72;--ho-accent-soft:#eef2ef;--ho-ok:#4f8a6d}',
|
|
335
|
+
'.dsh-ho-scroll{flex:1;min-height:0;overflow:auto}',
|
|
336
|
+
'.dsh-ho-head{padding:16px 26px 13px;border-bottom:1px solid var(--ho-line-soft)}',
|
|
337
|
+
'.dsh-ho-title{display:flex;align-items:center;gap:9px}',
|
|
338
|
+
'.dsh-ho-title h1{font-size:15px;font-weight:600;letter-spacing:-.01em;margin:0}',
|
|
339
|
+
'.dsh-ho-badge{font-size:10.5px;font-weight:500;letter-spacing:.03em;color:var(--ho-accent);background:var(--ho-accent-soft);border-radius:4px;padding:2px 6px}',
|
|
340
|
+
'.dsh-ho-x{margin-left:auto;width:26px;height:26px;border:0;background:none;border-radius:6px;color:var(--ho-ink3);cursor:pointer;display:flex;align-items:center;justify-content:center}',
|
|
341
|
+
'.dsh-ho-x:hover{background:var(--ho-hover);color:var(--ho-ink)}',
|
|
342
|
+
'.dsh-ho-blurb{margin:4px 0 0;font-size:12px;color:var(--ho-ink3);max-width:56ch}',
|
|
343
|
+
'.dsh-ho-live{padding:13px 26px 15px;background:var(--ho-sunken);border-bottom:1px solid var(--ho-line-soft)}',
|
|
344
|
+
'.dsh-ho-liverow{display:flex;align-items:baseline;gap:10px;margin-bottom:9px}',
|
|
345
|
+
'.dsh-ho-livelab{font-size:11px;font-weight:600;letter-spacing:.06em;color:var(--ho-ink3)}',
|
|
346
|
+
'.dsh-ho-liveval{margin-left:auto;font-family:ui-monospace,Consolas,monospace;font-size:12.5px;font-weight:600;font-variant-numeric:tabular-nums}',
|
|
347
|
+
'.dsh-ho-liveval small{font-weight:400;color:var(--ho-ink4);font-family:inherit}',
|
|
348
|
+
'.dsh-ho-meter{position:relative;height:8px;border-radius:4px;background:#e9e8e2}',
|
|
349
|
+
'.dsh-ho-fill{position:absolute;inset:0 auto 0 0;border-radius:4px;background:linear-gradient(90deg,#82968a,#6b7f72)}',
|
|
350
|
+
'.dsh-ho-tick{position:absolute;top:-3px;bottom:-3px;width:1.5px;background:var(--ho-ink4);border-radius:1px}',
|
|
351
|
+
'.dsh-ho-tickcap{position:absolute;top:13px;font-size:10.5px;color:var(--ho-ink3);transform:translateX(-50%);white-space:nowrap}',
|
|
352
|
+
'.dsh-ho-chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:26px}',
|
|
353
|
+
'.dsh-ho-chip{display:inline-flex;align-items:center;gap:5px;font-size:11.5px;color:var(--ho-ink2);background:#fff;border:1px solid var(--ho-line);border-radius:99px;padding:3px 9px}',
|
|
354
|
+
'.dsh-ho-chip b{font-weight:500;font-variant-numeric:tabular-nums;color:var(--ho-ink)}',
|
|
355
|
+
'.dsh-ho-dot{width:6px;height:6px;border-radius:99px;background:var(--ho-ink4)}',
|
|
356
|
+
'.dsh-ho-dot.on{background:var(--ho-ok)}',
|
|
357
|
+
'.dsh-ho-body{padding:4px 26px 16px}',
|
|
358
|
+
'.dsh-ho-sec{padding:15px 0}',
|
|
359
|
+
'.dsh-ho-sec + .dsh-ho-sec{border-top:1px solid var(--ho-line-soft)}',
|
|
360
|
+
'.dsh-ho-sechead{display:flex;align-items:baseline;gap:8px;margin-bottom:2px}',
|
|
361
|
+
'.dsh-ho-sechead h2{font-size:11px;font-weight:600;letter-spacing:.07em;color:var(--ho-ink3);margin:0;text-transform:uppercase}',
|
|
362
|
+
'.dsh-ho-sechead span{font-size:11px;color:var(--ho-ink4)}',
|
|
363
|
+
'.dsh-ho-field{padding:9px 0}',
|
|
364
|
+
'.dsh-ho-fieldtop{display:flex;align-items:baseline;gap:12px}',
|
|
365
|
+
'.dsh-ho-name{font-size:13px;font-weight:500;flex:1;min-width:0}',
|
|
366
|
+
'.dsh-ho-hint{font-size:11.5px;color:var(--ho-ink3);margin-top:1px}',
|
|
367
|
+
'.dsh-ho-out{font-family:ui-monospace,Consolas,monospace;font-size:12.5px;font-variant-numeric:tabular-nums;background:var(--ho-sunken);border:1px solid var(--ho-line);border-radius:5px;padding:1px 7px;min-width:50px;text-align:center}',
|
|
368
|
+
'.dsh-ho-inline{display:flex;align-items:center;gap:12px}',
|
|
369
|
+
'.dsh-ho-inline .dsh-ho-txt{flex:1;min-width:0}',
|
|
370
|
+
'.dsh-ho-seg{display:grid;grid-template-columns:repeat(3,1fr);gap:3px;background:var(--ho-sunken);border:1px solid var(--ho-line);border-radius:8px;padding:3px;margin-top:8px}',
|
|
371
|
+
'.dsh-ho-seg button{border:0;background:none;font-family:inherit;font-size:12.5px;color:var(--ho-ink2);padding:6px 0;border-radius:6px;cursor:pointer}',
|
|
372
|
+
'.dsh-ho-seg button:hover{color:var(--ho-ink)}',
|
|
373
|
+
'.dsh-ho-seg button[aria-pressed=true]{background:#fff;color:var(--ho-ink);font-weight:500;box-shadow:0 1px 2px rgba(28,28,26,.1),0 0 0 1px rgba(28,28,26,.05)}',
|
|
374
|
+
'.dsh-ho-range{appearance:none;-webkit-appearance:none;width:100%;height:18px;background:transparent;margin:7px 0 0;display:block;cursor:pointer}',
|
|
375
|
+
'.dsh-ho-range::-webkit-slider-runnable-track{height:5px;border-radius:3px;background:linear-gradient(90deg,#8c9a92 0%,#8c9a92 var(--p,50%),#e9e8e2 var(--p,50%),#e9e8e2 100%)}',
|
|
376
|
+
'.dsh-ho-range::-webkit-slider-thumb{-webkit-appearance:none;width:14px;height:14px;border-radius:99px;background:#fff;border:1.5px solid #6b7f72;box-shadow:0 1px 3px rgba(28,28,26,.22);margin-top:-4.5px}',
|
|
377
|
+
'.dsh-ho-range::-moz-range-track{height:5px;border-radius:3px;background:#e9e8e2}',
|
|
378
|
+
'.dsh-ho-range::-moz-range-thumb{width:13px;height:13px;border-radius:99px;background:#fff;border:1.5px solid #6b7f72}',
|
|
379
|
+
'.dsh-ho-tg{width:34px;height:19px;border-radius:99px;background:#dcdbd4;border:0;padding:0;cursor:pointer;position:relative;flex:0 0 auto;transition:background .14s}',
|
|
380
|
+
'.dsh-ho-tg i{position:absolute;top:2.5px;left:2.5px;width:14px;height:14px;border-radius:99px;background:#fff;box-shadow:0 1px 2px rgba(28,28,26,.25);transition:left .14s}',
|
|
381
|
+
'.dsh-ho-tg[aria-checked=true]{background:#7d9186}',
|
|
382
|
+
'.dsh-ho-tg[aria-checked=true] i{left:17.5px}',
|
|
383
|
+
'.dsh-ho-steps{display:inline-flex;align-items:center;border:1px solid var(--ho-line);border-radius:6px;background:#fff;overflow:hidden}',
|
|
384
|
+
'.dsh-ho-steps button{width:27px;height:26px;border:0;background:none;color:var(--ho-ink2);cursor:pointer;font-size:14px;line-height:1;font-family:inherit}',
|
|
385
|
+
'.dsh-ho-steps button:hover:not(:disabled){background:var(--ho-hover)}',
|
|
386
|
+
'.dsh-ho-steps button:disabled{color:var(--ho-ink4);cursor:not-allowed}',
|
|
387
|
+
'.dsh-ho-steps b{min-width:30px;text-align:center;font-family:ui-monospace,Consolas,monospace;font-size:12.5px;font-weight:400;border-left:1px solid var(--ho-line-soft);border-right:1px solid var(--ho-line-soft);line-height:26px}',
|
|
388
|
+
'.dsh-ho-foot{display:flex;align-items:center;gap:12px;padding:10px 26px;border-top:1px solid var(--ho-line-soft);background:var(--ho-sunken)}',
|
|
389
|
+
'.dsh-ho-saved{font-size:11.5px;color:var(--ho-ok);opacity:0;transition:opacity .15s}',
|
|
390
|
+
'.dsh-ho-saved.show{opacity:1}',
|
|
391
|
+
'.dsh-ho-path{flex:1;font-family:ui-monospace,Consolas,monospace;font-size:11px;color:var(--ho-ink4);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
392
|
+
'.dsh-ho-btn{font-family:inherit;font-size:11.5px;color:var(--ho-ink2);background:#fff;border:1px solid var(--ho-line);border-radius:6px;padding:5px 11px;cursor:pointer}',
|
|
393
|
+
'.dsh-ho-btn:hover{background:var(--ho-hover);color:var(--ho-ink)}'
|
|
394
|
+
].join('\n')
|
|
395
|
+
|
|
396
|
+
const injectCss = () => {
|
|
397
|
+
if (typeof document === 'undefined') return
|
|
398
|
+
if (document.getElementById('dsh-handoff-style') !== null) return
|
|
399
|
+
const style = document.createElement('style')
|
|
400
|
+
style.id = 'dsh-handoff-style'
|
|
401
|
+
style.textContent = CSS + '\n' + PANEL_CSS
|
|
402
|
+
document.head.appendChild(style)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// -------------------------------------------------------------- component
|
|
406
|
+
const toneOf = snapshot => {
|
|
407
|
+
if (snapshot === null) return 'idle'
|
|
408
|
+
if (snapshot.mode === 'off') return 'off'
|
|
409
|
+
if (snapshot.running === true) return 'busy'
|
|
410
|
+
if (snapshot.pending === 'waiting') return 'done'
|
|
411
|
+
const ratio = snapshot.pressure && typeof snapshot.pressure.ratio === 'number' ? snapshot.pressure.ratio : 0
|
|
412
|
+
const ask = typeof snapshot.askRatio === 'number' ? snapshot.askRatio : 0.55
|
|
413
|
+
return ratio >= ask ? 'attention' : 'idle'
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const percentOf = snapshot => {
|
|
417
|
+
const ratio = snapshot && snapshot.pressure && typeof snapshot.pressure.ratio === 'number' ? snapshot.pressure.ratio : 0
|
|
418
|
+
return Math.round(ratio * 100)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function HandoffAction(props) {
|
|
422
|
+
const sessionId = props.sessionId
|
|
423
|
+
const snapshot = useSnapshot()
|
|
424
|
+
const tone = toneOf(snapshot)
|
|
425
|
+
const [note, setNote] = React.useState(null)
|
|
426
|
+
const clicking = React.useRef(false)
|
|
427
|
+
|
|
428
|
+
// One idle refresh loop for the whole row set; the dot should appear on its
|
|
429
|
+
// own without the user hovering or clicking anything.
|
|
430
|
+
React.useEffect(() => {
|
|
431
|
+
void refresh(sessionId)
|
|
432
|
+
const timer = setInterval(() => {
|
|
433
|
+
if (store.pollTimer === null) void refresh(sessionId)
|
|
434
|
+
}, IDLE_REFRESH_MS)
|
|
435
|
+
return () => clearInterval(timer)
|
|
436
|
+
}, [sessionId])
|
|
437
|
+
|
|
438
|
+
React.useEffect(() => () => stopPolling(), [])
|
|
439
|
+
|
|
440
|
+
const dict = TEXT[lang()]
|
|
441
|
+
let label
|
|
442
|
+
if (note !== null) label = note
|
|
443
|
+
else if (tone === 'attention') label = fill(dict.attention, { p: percentOf(snapshot) })
|
|
444
|
+
else label = dict[tone] || dict.idle
|
|
445
|
+
|
|
446
|
+
const onClick = () => {
|
|
447
|
+
if (clicking.current || tone === 'busy' || tone === 'off') return
|
|
448
|
+
clicking.current = true
|
|
449
|
+
const payload = sessionId === undefined || sessionId === null || sessionId === ''
|
|
450
|
+
? {}
|
|
451
|
+
: { sessionId: String(sessionId) }
|
|
452
|
+
fetch(HANDOFF_URL, {
|
|
453
|
+
method: 'POST',
|
|
454
|
+
headers: { 'content-type': 'application/json' },
|
|
455
|
+
body: JSON.stringify(payload)
|
|
456
|
+
})
|
|
457
|
+
.then(response => response.json())
|
|
458
|
+
.then(body => {
|
|
459
|
+
const d = TEXT[lang()]
|
|
460
|
+
if (body && body.ok === true) {
|
|
461
|
+
if (body.dryRun === true) setNote(d.dryRun)
|
|
462
|
+
else if (body.running === true || body.queued === true) {
|
|
463
|
+
startPolling(sessionId)
|
|
464
|
+
if (body.autoOpen === false) {
|
|
465
|
+
setNote(null)
|
|
466
|
+
} else {
|
|
467
|
+
setNote(d.summarizing)
|
|
468
|
+
void waitForFollowUp(sessionId).then(followUp => {
|
|
469
|
+
if (followUp !== null && followUp !== undefined && followUp.code === 'opened'
|
|
470
|
+
&& typeof followUp.sessionId === 'string') {
|
|
471
|
+
revealSession(followUp.sessionId)
|
|
472
|
+
}
|
|
473
|
+
setNote(noteFor(followUp))
|
|
474
|
+
void refresh(sessionId)
|
|
475
|
+
})
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
} else if (body && body.code === 'session-dormant') {
|
|
479
|
+
// The Session is real but its live surface is not loaded yet: the
|
|
480
|
+
// host creates an Agent on the first turn, and only then can the
|
|
481
|
+
// summarizer replay it.
|
|
482
|
+
setNote(d.dormant)
|
|
483
|
+
} else {
|
|
484
|
+
const reason = body && (body.error || body.code) ? String(body.error || body.code) : 'unknown'
|
|
485
|
+
setNote(fill(d.refused, { e: reason }))
|
|
486
|
+
}
|
|
487
|
+
void refresh(sessionId)
|
|
488
|
+
})
|
|
489
|
+
.catch(error => {
|
|
490
|
+
setNote(fill(TEXT[lang()].failed, { e: error instanceof Error ? error.message : String(error) }))
|
|
491
|
+
})
|
|
492
|
+
.finally(() => {
|
|
493
|
+
clicking.current = false
|
|
494
|
+
})
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const className = 'dsh-handoff-btn' + (tone === 'idle' ? '' : ' is-' + tone)
|
|
498
|
+
return h('button', {
|
|
499
|
+
type: 'button',
|
|
500
|
+
className,
|
|
501
|
+
'data-tip': label,
|
|
502
|
+
'aria-label': label,
|
|
503
|
+
'data-handoff-state': tone,
|
|
504
|
+
onClick
|
|
505
|
+
},
|
|
506
|
+
tone === 'busy' ? SPINNER_ICON : tone === 'done' ? CHECK_ICON : HANDOFF_ICON,
|
|
507
|
+
tone === 'attention' ? h('span', { className: 'dsh-handoff-dot' }) : null
|
|
508
|
+
)
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
// ------------------------------------------------------- settings panel
|
|
513
|
+
const copy = () => COPY[lang()] || COPY.en
|
|
514
|
+
|
|
515
|
+
const ratioText = value => Math.round(value * 100) + '%'
|
|
516
|
+
|
|
517
|
+
const durationText = value => {
|
|
518
|
+
const minutes = Math.round(value / 60000)
|
|
519
|
+
return minutes + ' ' + copy().minute
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const barrier = (value, control) => {
|
|
523
|
+
const span = control.max - control.min
|
|
524
|
+
if (!(span > 0)) return '0%'
|
|
525
|
+
return ((value - control.min) / span * 100).toFixed(1) + '%'
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const svgPanel = (children, extra) => h('svg', {
|
|
529
|
+
viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.6,
|
|
530
|
+
strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true', ...extra
|
|
531
|
+
}, children)
|
|
532
|
+
|
|
533
|
+
function SidebarIcon({ size }) {
|
|
534
|
+
return h('span', { style: { display: 'inline-flex' } },
|
|
535
|
+
h('svg', {
|
|
536
|
+
viewBox: '0 0 24 24', width: size || 17, height: size || 17, fill: 'none',
|
|
537
|
+
stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round'
|
|
538
|
+
},
|
|
539
|
+
h('rect', { key: 'a', x: 2.5, y: 5.5, width: 7, height: 13, rx: 1.6 }),
|
|
540
|
+
h('path', { key: 'b', d: 'M12.2 12h6.6' }),
|
|
541
|
+
h('path', { key: 'c', d: 'M16.3 9.6 18.9 12l-2.6 2.4' }),
|
|
542
|
+
h('rect', { key: 'd', x: 20, y: 8.5, width: 1.6, height: 7, rx: 0.8, fill: 'currentColor', stroke: 'none' })
|
|
543
|
+
)
|
|
544
|
+
)
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function SettingsPanel(props) {
|
|
548
|
+
const c = copy()
|
|
549
|
+
const [data, setData] = React.useState(null)
|
|
550
|
+
const [draft, setDraft] = React.useState(null)
|
|
551
|
+
const [saved, setSaved] = React.useState(false)
|
|
552
|
+
const timer = React.useRef(null)
|
|
553
|
+
const snapshot = useSnapshot()
|
|
554
|
+
|
|
555
|
+
React.useEffect(() => {
|
|
556
|
+
let alive = true
|
|
557
|
+
fetch(SETTINGS_URL, { headers: { accept: 'application/json' } })
|
|
558
|
+
.then(response => response.json())
|
|
559
|
+
.then(body => {
|
|
560
|
+
if (!alive || !body || body.ok !== true) return
|
|
561
|
+
setData(body)
|
|
562
|
+
setDraft(body.values)
|
|
563
|
+
})
|
|
564
|
+
.catch(() => {})
|
|
565
|
+
void refresh()
|
|
566
|
+
return () => {
|
|
567
|
+
alive = false
|
|
568
|
+
if (timer.current !== null) clearTimeout(timer.current)
|
|
569
|
+
}
|
|
570
|
+
}, [])
|
|
571
|
+
|
|
572
|
+
const flash = () => {
|
|
573
|
+
setSaved(true)
|
|
574
|
+
if (timer.current !== null) clearTimeout(timer.current)
|
|
575
|
+
timer.current = setTimeout(() => setSaved(false), 1400)
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const post = body => fetch(SETTINGS_URL, {
|
|
579
|
+
method: 'POST',
|
|
580
|
+
headers: { 'content-type': 'application/json' },
|
|
581
|
+
body: JSON.stringify(body)
|
|
582
|
+
})
|
|
583
|
+
.then(response => response.json())
|
|
584
|
+
.then(payload => {
|
|
585
|
+
if (!payload || payload.ok !== true) return
|
|
586
|
+
setData(payload)
|
|
587
|
+
setDraft(payload.values)
|
|
588
|
+
flash()
|
|
589
|
+
})
|
|
590
|
+
.catch(() => {})
|
|
591
|
+
|
|
592
|
+
const commit = patch => {
|
|
593
|
+
setDraft(current => ({ ...current, ...patch }))
|
|
594
|
+
void post({ values: patch })
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const close = () => {
|
|
598
|
+
try {
|
|
599
|
+
if (props.close !== undefined) props.close()
|
|
600
|
+
} catch {
|
|
601
|
+
/* a host without the layout service simply keeps the panel open */
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (data === null || draft === null) {
|
|
606
|
+
return h('div', { className: 'dsh-ho-panel' },
|
|
607
|
+
h('div', { className: 'dsh-ho-head' }, h('div', { className: 'dsh-ho-title' }, h('h1', null, c.title)))
|
|
608
|
+
)
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const controls = new Map(data.controls.map(control => [control.key, control]))
|
|
612
|
+
const get = key => (draft[key] === undefined ? controls.get(key).fallback : draft[key])
|
|
613
|
+
|
|
614
|
+
const ratio = snapshot && snapshot.pressure && typeof snapshot.pressure.ratio === 'number'
|
|
615
|
+
? snapshot.pressure.ratio : null
|
|
616
|
+
const ask = get('askRatio')
|
|
617
|
+
const auto = get('autoRatio')
|
|
618
|
+
|
|
619
|
+
const nameOf = key => (c.name[key] || key)
|
|
620
|
+
const hintOf = key => (c.hint[key] || '')
|
|
621
|
+
|
|
622
|
+
const field = (control, body, inline) => h('div', { className: 'dsh-ho-field', key: control.key },
|
|
623
|
+
inline === true
|
|
624
|
+
? h('div', { className: 'dsh-ho-inline' },
|
|
625
|
+
h('div', { className: 'dsh-ho-txt' },
|
|
626
|
+
h('div', { className: 'dsh-ho-name' }, nameOf(control.key)),
|
|
627
|
+
h('div', { className: 'dsh-ho-hint' }, hintOf(control.key))
|
|
628
|
+
),
|
|
629
|
+
body
|
|
630
|
+
)
|
|
631
|
+
: h('div', null,
|
|
632
|
+
h('div', { className: 'dsh-ho-fieldtop' },
|
|
633
|
+
h('span', { className: 'dsh-ho-name' }, nameOf(control.key)),
|
|
634
|
+
h('span', { className: 'dsh-ho-out' }, body.label)
|
|
635
|
+
),
|
|
636
|
+
h('div', { className: 'dsh-ho-hint' }, hintOf(control.key)),
|
|
637
|
+
body.input
|
|
638
|
+
)
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
const sliderField = control => {
|
|
642
|
+
const value = get(control.key)
|
|
643
|
+
const input = h('input', {
|
|
644
|
+
key: 'r', type: 'range', className: 'dsh-ho-range',
|
|
645
|
+
min: control.min, max: control.max, step: control.step, value,
|
|
646
|
+
'aria-label': nameOf(control.key),
|
|
647
|
+
style: { '--p': barrier(value, control) },
|
|
648
|
+
onChange: event => setDraft(current => ({ ...current, [control.key]: Number(event.target.value) })),
|
|
649
|
+
onPointerUp: event => commit({ [control.key]: Number(event.target.value) }),
|
|
650
|
+
onKeyUp: event => commit({ [control.key]: Number(event.target.value) })
|
|
651
|
+
})
|
|
652
|
+
const label = control.kind === 'duration' ? durationText(value) : ratioText(value)
|
|
653
|
+
return field(control, { label, input })
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const modeField = control => {
|
|
657
|
+
const value = get(control.key)
|
|
658
|
+
const seg = h('div', { className: 'dsh-ho-seg', role: 'group', 'aria-label': nameOf(control.key) },
|
|
659
|
+
control.options.map(option => h('button', {
|
|
660
|
+
key: option, type: 'button',
|
|
661
|
+
'aria-pressed': value === option,
|
|
662
|
+
onClick: () => commit({ [control.key]: option })
|
|
663
|
+
}, c.modes[option] || option))
|
|
664
|
+
)
|
|
665
|
+
return h('div', { className: 'dsh-ho-field', key: control.key },
|
|
666
|
+
h('div', { className: 'dsh-ho-name' }, nameOf(control.key)),
|
|
667
|
+
h('div', { className: 'dsh-ho-hint' }, hintOf(control.key)),
|
|
668
|
+
seg
|
|
669
|
+
)
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const toggleField = control => {
|
|
673
|
+
const value = get(control.key) === true
|
|
674
|
+
return field(control, h('button', {
|
|
675
|
+
key: 't', type: 'button', className: 'dsh-ho-tg', role: 'switch',
|
|
676
|
+
'aria-checked': value, 'aria-label': nameOf(control.key),
|
|
677
|
+
onClick: () => commit({ [control.key]: !value })
|
|
678
|
+
}, h('i', null)), true)
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const countField = control => {
|
|
682
|
+
const value = get(control.key)
|
|
683
|
+
return field(control, h('div', { className: 'dsh-ho-steps' },
|
|
684
|
+
h('button', {
|
|
685
|
+
key: 'm', type: 'button', disabled: value <= control.min,
|
|
686
|
+
'aria-label': '−', onClick: () => commit({ [control.key]: Math.max(control.min, value - 1) })
|
|
687
|
+
}, '−'),
|
|
688
|
+
h('b', { key: 'v' }, String(value)),
|
|
689
|
+
h('button', {
|
|
690
|
+
key: 'p', type: 'button', disabled: value >= control.max,
|
|
691
|
+
'aria-label': '+', onClick: () => commit({ [control.key]: Math.min(control.max, value + 1) })
|
|
692
|
+
}, '+')
|
|
693
|
+
), true)
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const renderControl = key => {
|
|
697
|
+
const control = controls.get(key)
|
|
698
|
+
if (control === undefined) return null
|
|
699
|
+
if (control.kind === 'enum') return modeField(control)
|
|
700
|
+
if (control.kind === 'boolean') return toggleField(control)
|
|
701
|
+
if (control.kind === 'count') return countField(control)
|
|
702
|
+
return sliderField(control)
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const last = snapshot && snapshot.lastHandoff
|
|
706
|
+
|
|
707
|
+
return h('div', { className: 'dsh-ho-panel' },
|
|
708
|
+
h('div', { className: 'dsh-ho-scroll' },
|
|
709
|
+
h('div', { className: 'dsh-ho-head' },
|
|
710
|
+
h('div', { className: 'dsh-ho-title' },
|
|
711
|
+
h('h1', null, c.title),
|
|
712
|
+
h('span', { className: 'dsh-ho-badge' }, '0.5.3'),
|
|
713
|
+
h('button', { type: 'button', className: 'dsh-ho-x', 'aria-label': c.close, onClick: close },
|
|
714
|
+
svgPanel([h('path', { key: 'x', d: 'M6 6l12 12M18 6L6 18' })], { strokeWidth: 1.7 }))
|
|
715
|
+
),
|
|
716
|
+
h('p', { className: 'dsh-ho-blurb' }, c.blurb)
|
|
717
|
+
),
|
|
718
|
+
|
|
719
|
+
h('div', { className: 'dsh-ho-live' },
|
|
720
|
+
h('div', { className: 'dsh-ho-liverow' },
|
|
721
|
+
h('span', { className: 'dsh-ho-livelab' }, c.live),
|
|
722
|
+
h('span', { className: 'dsh-ho-liveval' },
|
|
723
|
+
ratio === null ? '—' : ratioText(ratio),
|
|
724
|
+
snapshot && snapshot.pressure && snapshot.pressure.text
|
|
725
|
+
? h('small', null, ' · ' + snapshot.pressure.text.replace(/^[\d.]+%\s*/, ''))
|
|
726
|
+
: null
|
|
727
|
+
)
|
|
728
|
+
),
|
|
729
|
+
h('div', { className: 'dsh-ho-meter' },
|
|
730
|
+
ratio === null ? null : h('div', { className: 'dsh-ho-fill', style: { width: ratioText(Math.min(1, ratio)) } }),
|
|
731
|
+
h('div', { className: 'dsh-ho-tick', style: { left: ratioText(ask) } }),
|
|
732
|
+
h('div', { className: 'dsh-ho-tickcap', style: { left: ratioText(ask) } }, c.name.askRatio + ' ' + ratioText(ask)),
|
|
733
|
+
h('div', { className: 'dsh-ho-tick', style: { left: ratioText(auto) } }),
|
|
734
|
+
h('div', { className: 'dsh-ho-tickcap', style: { left: ratioText(auto) } }, c.name.autoRatio + ' ' + ratioText(auto))
|
|
735
|
+
),
|
|
736
|
+
h('div', { className: 'dsh-ho-chips' },
|
|
737
|
+
h('span', { className: 'dsh-ho-chip' },
|
|
738
|
+
h('span', { className: 'dsh-ho-dot' + (snapshot && snapshot.eligible ? ' on' : '') }),
|
|
739
|
+
snapshot && snapshot.eligible ? c.ready : (snapshot && snapshot.reason ? snapshot.reason : '—')
|
|
740
|
+
),
|
|
741
|
+
h('span', { className: 'dsh-ho-chip' },
|
|
742
|
+
h('span', { className: 'dsh-ho-dot' + (snapshot && snapshot.pending === 'waiting' ? ' on' : '') }),
|
|
743
|
+
snapshot && snapshot.pending === 'waiting' ? '等待新会话取走' : c.waiting
|
|
744
|
+
),
|
|
745
|
+
last === undefined || last === null
|
|
746
|
+
? null
|
|
747
|
+
: h('span', { className: 'dsh-ho-chip' }, c.last + ' ',
|
|
748
|
+
h('b', null, String(Math.round(last.ratio || 0)) + ' : 1'),
|
|
749
|
+
last.summaryTokens ? ' · ' + last.summaryTokens + ' tokens' : '')
|
|
750
|
+
)
|
|
751
|
+
),
|
|
752
|
+
|
|
753
|
+
h('div', { className: 'dsh-ho-body' },
|
|
754
|
+
h('div', { className: 'dsh-ho-sec' },
|
|
755
|
+
h('div', { className: 'dsh-ho-sechead' },
|
|
756
|
+
h('h2', null, c.secTrigger),
|
|
757
|
+
h('span', null, c.secTriggerNote)
|
|
758
|
+
),
|
|
759
|
+
renderControl('mode'),
|
|
760
|
+
renderControl('askRatio'),
|
|
761
|
+
renderControl('autoRatio'),
|
|
762
|
+
renderControl('noticeCooldownMs')
|
|
763
|
+
),
|
|
764
|
+
h('div', { className: 'dsh-ho-sec' },
|
|
765
|
+
h('div', { className: 'dsh-ho-sechead' },
|
|
766
|
+
h('h2', null, c.secBehaviour),
|
|
767
|
+
h('span', null, c.secBehaviourNote)
|
|
768
|
+
),
|
|
769
|
+
renderControl('dryRun'),
|
|
770
|
+
renderControl('maxHandoffsPerSession'),
|
|
771
|
+
renderControl('includeHistoryTool'),
|
|
772
|
+
renderControl('autoOpenSession')
|
|
773
|
+
)
|
|
774
|
+
)
|
|
775
|
+
),
|
|
776
|
+
h('div', { className: 'dsh-ho-foot' },
|
|
777
|
+
h('span', { className: 'dsh-ho-saved' + (saved ? ' show' : '') }, '✓ ' + c.save),
|
|
778
|
+
h('span', { className: 'dsh-ho-path' }, data.path),
|
|
779
|
+
h('button', {
|
|
780
|
+
type: 'button', className: 'dsh-ho-btn',
|
|
781
|
+
onClick: () => { void post({ reset: true }) }
|
|
782
|
+
}, c.reset)
|
|
783
|
+
)
|
|
784
|
+
)
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
return {
|
|
788
|
+
inject: ['slots'],
|
|
789
|
+
apply(ctx) {
|
|
790
|
+
injectCss()
|
|
791
|
+
// Capture the Host services the follow-up session needs; the button still
|
|
792
|
+
// works (manually) when they are absent.
|
|
793
|
+
captureServices(ctx)
|
|
794
|
+
ctx.slots.inject('conversation.chat.assistant-actions', () => ctx.slots.register({
|
|
795
|
+
name: 'conversation.chat.assistant-actions',
|
|
796
|
+
id: 'handoff',
|
|
797
|
+
order: 20,
|
|
798
|
+
inject: sessionId => ({ sessionId })
|
|
799
|
+
}, HandoffAction))
|
|
800
|
+
|
|
801
|
+
// The settings page is a sidebar panel of its own, next to the Memory
|
|
802
|
+
// System (order 30) and Skills (order 31). A layout without native panel
|
|
803
|
+
// seats simply never declares these two slots, so nothing is registered.
|
|
804
|
+
const closePanel = () => {
|
|
805
|
+
try {
|
|
806
|
+
if (ctx.layout !== undefined && typeof ctx.layout.selectPanel === 'function') {
|
|
807
|
+
ctx.layout.selectPanel(null)
|
|
808
|
+
}
|
|
809
|
+
} catch {
|
|
810
|
+
/* a host without the layout service keeps the panel open */
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
ctx.slots.inject('main', () => ctx.slots.inject('sidebar.panellist', () => {
|
|
814
|
+
const stopMain = ctx.slots.register({
|
|
815
|
+
name: 'main',
|
|
816
|
+
key: PANEL_ID,
|
|
817
|
+
inject: () => ({ close: closePanel })
|
|
818
|
+
}, SettingsPanel)
|
|
819
|
+
const stopIcon = ctx.slots.register({
|
|
820
|
+
name: 'sidebar.panellist',
|
|
821
|
+
id: PANEL_ID,
|
|
822
|
+
order: 32,
|
|
823
|
+
label: () => copy().label
|
|
824
|
+
}, SidebarIcon)
|
|
825
|
+
return () => {
|
|
826
|
+
stopIcon()
|
|
827
|
+
stopMain()
|
|
828
|
+
}
|
|
829
|
+
}))
|
|
830
|
+
},
|
|
831
|
+
HandoffAction,
|
|
832
|
+
SettingsPanel,
|
|
833
|
+
toneOf,
|
|
834
|
+
store
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
})
|