@ray1270/dsh-restart 0.0.0-stage → 0.3.7
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 +289 -2
- package/cordis.patch.yml +33 -0
- package/dev.patch.yml +24 -0
- package/lib/client.js +740 -0
- package/lib/helper.ps1 +280 -0
- package/lib/index.js +660 -0
- package/package.json +58 -4
package/lib/client.js
ADDED
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-restart — Client half (browser module).
|
|
3
|
+
*
|
|
4
|
+
* Four jobs, all in the page:
|
|
5
|
+
*
|
|
6
|
+
* 1. a restart entry in the Windows window-caption menubar, right beside 应用
|
|
7
|
+
* and 编辑 — where an app-wide action belongs
|
|
8
|
+
* 2. a refresh entry next to it: reload the page without touching the Host,
|
|
9
|
+
* which is what a client-plugin change actually needs
|
|
10
|
+
* 3. the same pair in the Session header utilities for every surface without
|
|
11
|
+
* that caption bar (macOS, Linux, the plain browser); it steps aside as
|
|
12
|
+
* soon as the caption entries are really on screen
|
|
13
|
+
* 4. putting the user back into the Session they were in before either action
|
|
14
|
+
*
|
|
15
|
+
* Restart and refresh are deliberately different shapes. A restart kills the
|
|
16
|
+
* shell and everything in flight, so it takes two clicks and shows what it
|
|
17
|
+
* would interrupt. A refresh only reloads this page — server-side work keeps
|
|
18
|
+
* running — so one click is the right weight. The only casualty is the unsent
|
|
19
|
+
* composer draft, and the tooltip says so instead of a modal stealing the click.
|
|
20
|
+
*
|
|
21
|
+
* The restart hand-off is server-side on purpose: the port changes on every
|
|
22
|
+
* restart, so the origin changes too and localStorage does not survive it. The
|
|
23
|
+
* refresh hand-off is the opposite case: the origin stays, so a localStorage
|
|
24
|
+
* marker is enough and the Host never has to hear about a reload.
|
|
25
|
+
*
|
|
26
|
+
* The caption menubar is not part of the Web client: the Desktop preload draws
|
|
27
|
+
* it into an open shadow root at the top of the window. A page module may only
|
|
28
|
+
* extend it by appending siblings, which is what happens here. The shape is
|
|
29
|
+
* probed, never assumed: without the host element or its shadow root the
|
|
30
|
+
* surface simply does not mount and the header pair stays.
|
|
31
|
+
*
|
|
32
|
+
* Written as a plain browser module (no JSX, no bundler) in the same
|
|
33
|
+
* `window.__ModuleLoader__` form the shipped client bundles use.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
window.__ModuleLoader__.load({
|
|
37
|
+
id: '@ray1270/dsh-restart',
|
|
38
|
+
factory(require) {
|
|
39
|
+
const React = require('react')
|
|
40
|
+
const h = React.createElement
|
|
41
|
+
|
|
42
|
+
/** How long the second click stays armed. */
|
|
43
|
+
const ARM_WINDOW_MS = 4000
|
|
44
|
+
/** How long to keep asking whether the restart actually took effect. */
|
|
45
|
+
const EFFECT_TIMEOUT_MS = 12000
|
|
46
|
+
/** How long a failure stays readable before the label relaxes back. */
|
|
47
|
+
const FAIL_HOLD_MS = 6000
|
|
48
|
+
/** How long to wait before a reload, long enough to repaint the label. */
|
|
49
|
+
const REFRESH_DELAY_MS = 80
|
|
50
|
+
/** How long a refresh marker stays believable; a reload is not a slow trip. */
|
|
51
|
+
const REFRESH_MARKER_TTL_MS = 2 * 60 * 1000
|
|
52
|
+
|
|
53
|
+
/** Attribute the Desktop preload puts on the caption menubar host. */
|
|
54
|
+
const CAPTION_HOST_ATTRIBUTE = 'data-windows-menu'
|
|
55
|
+
/** Attribute this module puts on the wrapper it appends there. */
|
|
56
|
+
const CAPTION_GROUP_ATTRIBUTE = 'data-dsh-restart-caption-group'
|
|
57
|
+
/** Attribute this module puts on the restart button. */
|
|
58
|
+
const CAPTION_BUTTON_ATTRIBUTE = 'data-dsh-restart-caption'
|
|
59
|
+
/** Attribute this module puts on the refresh button. */
|
|
60
|
+
const CAPTION_REFRESH_ATTRIBUTE = 'data-dsh-refresh-caption'
|
|
61
|
+
/** localStorage key carrying the Session across a reload (same origin). */
|
|
62
|
+
const REFRESH_MARKER_KEY = '@ray1270/dsh-restart:resume'
|
|
63
|
+
|
|
64
|
+
const COLOR_MUTED = 'var(--dsw-alias-label-secondary, rgba(128,128,128,0.85))'
|
|
65
|
+
const COLOR_DANGER = 'var(--dsw-alias-state-error-primary, #d43a3a)'
|
|
66
|
+
const COLOR_OK = 'var(--dsw-alias-state-success-primary, #1a9e55)'
|
|
67
|
+
const COLOR_BORDER = 'var(--dsw-alias-separator-primary, rgba(128,128,128,0.35))'
|
|
68
|
+
|
|
69
|
+
const REFRESH_TITLE = '刷新界面:重新加载页面,仍停在当前会话'
|
|
70
|
+
|
|
71
|
+
/* ------------------------------------------------------------------ *
|
|
72
|
+
* One restart interaction, shared by both surfaces.
|
|
73
|
+
* ------------------------------------------------------------------ */
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The state machine behind a restart entry.
|
|
77
|
+
*
|
|
78
|
+
* Phases: idle → checking → arming → running → failed. `failed` holds an
|
|
79
|
+
* explanation for a few seconds and then relaxes back to idle, so a
|
|
80
|
+
* transient problem does not leave a permanently red control.
|
|
81
|
+
*
|
|
82
|
+
* @param getSessionId - resolves the open Session at click time; the caption
|
|
83
|
+
* surface has no slot props, so the Session is read from the header
|
|
84
|
+
* control's tracking instead of captured when the machine is built.
|
|
85
|
+
*/
|
|
86
|
+
function createRestartMachine(getSessionId) {
|
|
87
|
+
let state = { phase: 'idle', activeWork: null, message: null }
|
|
88
|
+
const listeners = new Set()
|
|
89
|
+
let armTimer = null
|
|
90
|
+
let relaxTimer = null
|
|
91
|
+
let disposed = false
|
|
92
|
+
|
|
93
|
+
const publish = (patch) => {
|
|
94
|
+
if (disposed) return
|
|
95
|
+
state = { ...state, ...patch }
|
|
96
|
+
for (const listener of [...listeners]) {
|
|
97
|
+
try {
|
|
98
|
+
listener(state)
|
|
99
|
+
} catch (error) {
|
|
100
|
+
console.error('dsh-restart: state listener failed', error)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const clearArm = () => {
|
|
106
|
+
if (armTimer !== null) clearTimeout(armTimer)
|
|
107
|
+
armTimer = null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const clearRelax = () => {
|
|
111
|
+
if (relaxTimer !== null) clearTimeout(relaxTimer)
|
|
112
|
+
relaxTimer = null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const fail = (message) => {
|
|
116
|
+
clearArm()
|
|
117
|
+
clearRelax()
|
|
118
|
+
publish({ phase: 'failed', activeWork: null, message })
|
|
119
|
+
relaxTimer = setTimeout(() => {
|
|
120
|
+
relaxTimer = null
|
|
121
|
+
publish({ phase: 'idle', message: null })
|
|
122
|
+
}, FAIL_HOLD_MS)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const disarm = () => {
|
|
126
|
+
clearArm()
|
|
127
|
+
publish({ phase: 'idle', activeWork: null, message: null })
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Second click: the user has seen the counts and means it. */
|
|
131
|
+
const execute = async () => {
|
|
132
|
+
clearArm()
|
|
133
|
+
const sessionId = typeof getSessionId === 'function' ? getSessionId() : null
|
|
134
|
+
publish({ phase: 'running', message: null })
|
|
135
|
+
let scheduledAt = 0
|
|
136
|
+
try {
|
|
137
|
+
const response = await fetch('/api/dsh-restart/restart', {
|
|
138
|
+
method: 'POST',
|
|
139
|
+
headers: { 'content-type': 'application/json' },
|
|
140
|
+
body: JSON.stringify({ sessionId: typeof sessionId === 'string' ? sessionId : null, force: true }),
|
|
141
|
+
})
|
|
142
|
+
const payload = await response.json().catch(() => ({}))
|
|
143
|
+
if (!response.ok) throw new Error(payload?.error ?? `HTTP ${response.status}`)
|
|
144
|
+
scheduledAt = Date.now()
|
|
145
|
+
} catch (error) {
|
|
146
|
+
fail(`重启请求失败:${error && error.message ? error.message : String(error)}`)
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// The application should disappear shortly. If it still answers well past
|
|
151
|
+
// the helper's delay, the restart silently did not happen and saying so is
|
|
152
|
+
// better than a spinner that never resolves.
|
|
153
|
+
const watch = async () => {
|
|
154
|
+
if (Date.now() - scheduledAt > EFFECT_TIMEOUT_MS) {
|
|
155
|
+
fail('重启没有生效,应用仍在运行(详见 ~/.dsh/dsh-restart/helper.log)')
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
const response = await fetch('/api/dsh-restart/status', { cache: 'no-store' })
|
|
160
|
+
if (response.ok) {
|
|
161
|
+
setTimeout(watch, 800)
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
// Anything but 200 means the Host is going down; stop watching.
|
|
165
|
+
} catch {
|
|
166
|
+
// A refused connection is the expected outcome: the page is about to die.
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
setTimeout(watch, 2500)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** First click: pre-flight only. */
|
|
173
|
+
const preflight = async () => {
|
|
174
|
+
publish({ phase: 'checking', message: null })
|
|
175
|
+
try {
|
|
176
|
+
const response = await fetch('/api/dsh-restart/status', { cache: 'no-store' })
|
|
177
|
+
const payload = await response.json().catch(() => ({}))
|
|
178
|
+
if (!response.ok) throw new Error(payload?.error ?? `HTTP ${response.status}`)
|
|
179
|
+
if (payload?.canRestart !== true) {
|
|
180
|
+
fail(payload?.reason ?? '当前环境不支持重启')
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
publish({ phase: 'arming', activeWork: payload.activeWork ?? null })
|
|
184
|
+
armTimer = setTimeout(disarm, ARM_WINDOW_MS)
|
|
185
|
+
} catch (error) {
|
|
186
|
+
fail(`无法连接 Host:${error && error.message ? error.message : String(error)}`)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
get state() {
|
|
192
|
+
return state
|
|
193
|
+
},
|
|
194
|
+
subscribe(listener) {
|
|
195
|
+
listeners.add(listener)
|
|
196
|
+
return () => listeners.delete(listener)
|
|
197
|
+
},
|
|
198
|
+
click() {
|
|
199
|
+
if (state.phase === 'checking' || state.phase === 'running') return
|
|
200
|
+
if (state.phase === 'arming') {
|
|
201
|
+
void execute()
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
void preflight()
|
|
205
|
+
},
|
|
206
|
+
dispose() {
|
|
207
|
+
disposed = true
|
|
208
|
+
clearArm()
|
|
209
|
+
clearRelax()
|
|
210
|
+
listeners.clear()
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const workCount = (state) => state.activeWork?.total ?? 0
|
|
216
|
+
|
|
217
|
+
const HEADER_TITLE = (state, interrupting) => {
|
|
218
|
+
if (state.phase === 'arming') {
|
|
219
|
+
return interrupting > 0
|
|
220
|
+
? `重启会中断 ${interrupting} 项进行中的任务(再点一次确认,${ARM_WINDOW_MS / 1000} 秒内有效)`
|
|
221
|
+
: `再点一次确认重启(${ARM_WINDOW_MS / 1000} 秒内有效)`
|
|
222
|
+
}
|
|
223
|
+
if (state.phase === 'running') return '正在重启 DeepSeek Harness'
|
|
224
|
+
if (state.phase === 'checking') return '正在检查是否可以重启'
|
|
225
|
+
if (state.phase === 'failed') return state.message ?? '重启不可用'
|
|
226
|
+
return '重启 DeepSeek Harness'
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/* ------------------------------------------------------------------ *
|
|
230
|
+
* Refresh: a reload, not a restart.
|
|
231
|
+
* ------------------------------------------------------------------ */
|
|
232
|
+
|
|
233
|
+
/** The refresh marker, or null when there is none, it is stale, or storage is closed. */
|
|
234
|
+
function readRefreshMarker() {
|
|
235
|
+
if (typeof localStorage === 'undefined') return null
|
|
236
|
+
try {
|
|
237
|
+
const raw = localStorage.getItem(REFRESH_MARKER_KEY)
|
|
238
|
+
if (raw === null) return null
|
|
239
|
+
const marker = JSON.parse(raw)
|
|
240
|
+
const usable = marker !== null
|
|
241
|
+
&& typeof marker === 'object'
|
|
242
|
+
&& typeof marker.sessionId === 'string'
|
|
243
|
+
&& marker.sessionId.length > 0
|
|
244
|
+
&& typeof marker.at === 'number'
|
|
245
|
+
if (!usable || Date.now() - marker.at > REFRESH_MARKER_TTL_MS) {
|
|
246
|
+
localStorage.removeItem(REFRESH_MARKER_KEY)
|
|
247
|
+
return null
|
|
248
|
+
}
|
|
249
|
+
return marker
|
|
250
|
+
} catch {
|
|
251
|
+
// Storage may be closed (private mode, disabled cookies). Nothing to restore.
|
|
252
|
+
return null
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function writeRefreshMarker(sessionId) {
|
|
257
|
+
if (typeof localStorage === 'undefined') return
|
|
258
|
+
try {
|
|
259
|
+
localStorage.setItem(REFRESH_MARKER_KEY, JSON.stringify({ sessionId, at: Date.now() }))
|
|
260
|
+
} catch (error) {
|
|
261
|
+
// The reload still happens; it just lands on the home page. Not worth
|
|
262
|
+
// failing the click over, but it should be explainable in the console.
|
|
263
|
+
console.warn('dsh-restart: could not remember the Session across a refresh', error)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function clearRefreshMarker() {
|
|
268
|
+
if (typeof localStorage === 'undefined') return
|
|
269
|
+
try {
|
|
270
|
+
localStorage.removeItem(REFRESH_MARKER_KEY)
|
|
271
|
+
} catch {
|
|
272
|
+
/* nothing to clear */
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Reload the page and ask the next load to reopen the Session.
|
|
278
|
+
*
|
|
279
|
+
* Client-side only: a reload keeps the origin, so the hand-off fits in
|
|
280
|
+
* localStorage and the Host never has to know a refresh happened. A missing
|
|
281
|
+
* Session (home page) reloads the same way, just without a target.
|
|
282
|
+
*
|
|
283
|
+
* @param getSessionId - resolves the open Session at click time.
|
|
284
|
+
* @returns whether a reload was scheduled; false when this realm has no page.
|
|
285
|
+
*/
|
|
286
|
+
function refreshPage(getSessionId) {
|
|
287
|
+
const sessionId = typeof getSessionId === 'function' ? getSessionId() : null
|
|
288
|
+
if (typeof sessionId === 'string' && sessionId.length > 0) writeRefreshMarker(sessionId)
|
|
289
|
+
if (typeof location === 'undefined' || typeof location.reload !== 'function') return false
|
|
290
|
+
setTimeout(() => {
|
|
291
|
+
try {
|
|
292
|
+
location.reload()
|
|
293
|
+
} catch (error) {
|
|
294
|
+
console.error('dsh-restart: the page could not be reloaded', error)
|
|
295
|
+
}
|
|
296
|
+
}, REFRESH_DELAY_MS)
|
|
297
|
+
return true
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/* ------------------------------------------------------------------ *
|
|
301
|
+
* Surface 1: the Session header pair (fallback seat).
|
|
302
|
+
* ------------------------------------------------------------------ */
|
|
303
|
+
|
|
304
|
+
const STYLE_BUTTON = {
|
|
305
|
+
display: 'inline-flex',
|
|
306
|
+
alignItems: 'center',
|
|
307
|
+
gap: '6px',
|
|
308
|
+
height: '26px',
|
|
309
|
+
padding: '0 8px',
|
|
310
|
+
border: '1px solid transparent',
|
|
311
|
+
borderRadius: '6px',
|
|
312
|
+
background: 'transparent',
|
|
313
|
+
color: COLOR_MUTED,
|
|
314
|
+
font: 'inherit',
|
|
315
|
+
fontSize: '12px',
|
|
316
|
+
lineHeight: 1,
|
|
317
|
+
cursor: 'pointer',
|
|
318
|
+
whiteSpace: 'nowrap',
|
|
319
|
+
transition: 'color 120ms ease, border-color 120ms ease, background-color 120ms ease, opacity 120ms ease',
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/* ------------------------------------------------------------------ *
|
|
323
|
+
* Cross-surface state.
|
|
324
|
+
* ------------------------------------------------------------------ */
|
|
325
|
+
|
|
326
|
+
/** Whether the caption entries are really on screen; the header seat retreats while they are. */
|
|
327
|
+
const captionLive = {
|
|
328
|
+
value: false,
|
|
329
|
+
listeners: new Set(),
|
|
330
|
+
set(next) {
|
|
331
|
+
if (captionLive.value === next) return
|
|
332
|
+
captionLive.value = next
|
|
333
|
+
for (const listener of [...captionLive.listeners]) {
|
|
334
|
+
try {
|
|
335
|
+
listener(next)
|
|
336
|
+
} catch (error) {
|
|
337
|
+
console.error('dsh-restart: caption listener failed', error)
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
},
|
|
341
|
+
subscribe(listener) {
|
|
342
|
+
captionLive.listeners.add(listener)
|
|
343
|
+
return () => captionLive.listeners.delete(listener)
|
|
344
|
+
},
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* The open Session, as reported by the header seat.
|
|
349
|
+
*
|
|
350
|
+
* The header slot is the only surface that receives `sessionId`; the caption
|
|
351
|
+
* menubar is global. The header component keeps this value current for as
|
|
352
|
+
* long as a Conversation is on screen (it renders nothing once the caption
|
|
353
|
+
* entries took over), which makes a restart or refresh started from the
|
|
354
|
+
* caption bar land back on the same Session.
|
|
355
|
+
*/
|
|
356
|
+
const sessionTracker = { current: null }
|
|
357
|
+
|
|
358
|
+
function useCaptionLive() {
|
|
359
|
+
const [live, setLive] = React.useState(captionLive.value)
|
|
360
|
+
React.useEffect(() => captionLive.subscribe(setLive), [])
|
|
361
|
+
return live
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* The header pair: 刷新 and 重启, same words as the caption bar.
|
|
366
|
+
*
|
|
367
|
+
* @param props - standard Session slot face; `sessionId` drives both hand-offs.
|
|
368
|
+
*/
|
|
369
|
+
function HeaderActions(props) {
|
|
370
|
+
const propsRef = React.useRef(props)
|
|
371
|
+
propsRef.current = props
|
|
372
|
+
|
|
373
|
+
const machineRef = React.useRef(null)
|
|
374
|
+
if (machineRef.current === null) {
|
|
375
|
+
machineRef.current = createRestartMachine(() => propsRef.current?.sessionId)
|
|
376
|
+
}
|
|
377
|
+
const machine = machineRef.current
|
|
378
|
+
|
|
379
|
+
const caption = useCaptionLive()
|
|
380
|
+
const [state, setState] = React.useState(machine.state)
|
|
381
|
+
const [refreshing, setRefreshing] = React.useState(false)
|
|
382
|
+
|
|
383
|
+
React.useEffect(() => machine.subscribe(setState), [])
|
|
384
|
+
|
|
385
|
+
React.useEffect(() => () => machine.dispose(), [])
|
|
386
|
+
|
|
387
|
+
React.useEffect(() => {
|
|
388
|
+
const sessionId = props?.sessionId ?? null
|
|
389
|
+
sessionTracker.current = sessionId
|
|
390
|
+
return () => {
|
|
391
|
+
if (sessionTracker.current === sessionId) sessionTracker.current = null
|
|
392
|
+
}
|
|
393
|
+
}, [props?.sessionId])
|
|
394
|
+
|
|
395
|
+
// The caption entries are the primary seat; the header keeps only its
|
|
396
|
+
// Session tracking once they are on screen.
|
|
397
|
+
if (caption) return null
|
|
398
|
+
|
|
399
|
+
const refresh = () => {
|
|
400
|
+
if (refreshing) return
|
|
401
|
+
setRefreshing(true)
|
|
402
|
+
if (!refreshPage(() => propsRef.current?.sessionId)) {
|
|
403
|
+
setRefreshing(false)
|
|
404
|
+
return
|
|
405
|
+
}
|
|
406
|
+
// A blocked or cancelled reload must not leave a label that never resolves.
|
|
407
|
+
setTimeout(() => setRefreshing(false), 5000)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const interrupting = workCount(state)
|
|
411
|
+
const label = (() => {
|
|
412
|
+
if (state.phase === 'arming') return interrupting > 0 ? `确认重启 · ${interrupting} 项进行中` : '确认重启'
|
|
413
|
+
if (state.phase === 'running') return '重启中'
|
|
414
|
+
if (state.phase === 'checking') return '检查中'
|
|
415
|
+
if (state.phase === 'failed' && state.message) return state.message.length > 24 ? `${state.message.slice(0, 24)}…` : state.message
|
|
416
|
+
if (state.phase === 'failed') return '重启失败'
|
|
417
|
+
return '重启'
|
|
418
|
+
})()
|
|
419
|
+
|
|
420
|
+
const color = state.phase === 'arming' || state.phase === 'failed'
|
|
421
|
+
? COLOR_DANGER
|
|
422
|
+
: state.phase === 'running'
|
|
423
|
+
? COLOR_OK
|
|
424
|
+
: COLOR_MUTED
|
|
425
|
+
|
|
426
|
+
const title = HEADER_TITLE(state, interrupting)
|
|
427
|
+
|
|
428
|
+
return h(
|
|
429
|
+
'span',
|
|
430
|
+
{
|
|
431
|
+
'data-dsh-quick-actions': '',
|
|
432
|
+
style: { display: 'inline-flex', alignItems: 'center', gap: '2px' },
|
|
433
|
+
},
|
|
434
|
+
h(
|
|
435
|
+
'button',
|
|
436
|
+
{
|
|
437
|
+
type: 'button',
|
|
438
|
+
onClick: refresh,
|
|
439
|
+
title: REFRESH_TITLE,
|
|
440
|
+
'aria-label': REFRESH_TITLE,
|
|
441
|
+
'data-dsh-refresh-control': refreshing ? 'running' : 'idle',
|
|
442
|
+
disabled: refreshing,
|
|
443
|
+
style: {
|
|
444
|
+
...STYLE_BUTTON,
|
|
445
|
+
color: COLOR_MUTED,
|
|
446
|
+
cursor: refreshing ? 'default' : 'pointer',
|
|
447
|
+
opacity: refreshing ? 0.7 : 1,
|
|
448
|
+
},
|
|
449
|
+
},
|
|
450
|
+
refreshing ? '刷新中' : '刷新',
|
|
451
|
+
),
|
|
452
|
+
h(
|
|
453
|
+
'button',
|
|
454
|
+
{
|
|
455
|
+
type: 'button',
|
|
456
|
+
onClick: () => machine.click(),
|
|
457
|
+
title,
|
|
458
|
+
'aria-label': title,
|
|
459
|
+
'data-dsh-restart-control': state.phase,
|
|
460
|
+
disabled: state.phase === 'running' || state.phase === 'checking',
|
|
461
|
+
style: {
|
|
462
|
+
...STYLE_BUTTON,
|
|
463
|
+
color,
|
|
464
|
+
borderColor: state.phase === 'arming' || state.phase === 'failed' ? COLOR_BORDER : 'transparent',
|
|
465
|
+
background: state.phase === 'arming' ? 'color-mix(in srgb, currentColor 8%, transparent)' : 'transparent',
|
|
466
|
+
cursor: state.phase === 'running' || state.phase === 'checking' ? 'default' : 'pointer',
|
|
467
|
+
opacity: state.phase === 'running' || state.phase === 'checking' ? 0.75 : 1,
|
|
468
|
+
},
|
|
469
|
+
},
|
|
470
|
+
h('span', { style: { maxWidth: '220px', overflow: 'hidden', textOverflow: 'ellipsis' } }, label),
|
|
471
|
+
),
|
|
472
|
+
)
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/* ------------------------------------------------------------------ *
|
|
476
|
+
* Surface 2: the Windows caption menubar.
|
|
477
|
+
* ------------------------------------------------------------------ */
|
|
478
|
+
|
|
479
|
+
/** The caption restart label; counts stay visible so a restart is never blind. */
|
|
480
|
+
function captionLabel(state) {
|
|
481
|
+
const interrupting = workCount(state)
|
|
482
|
+
if (state.phase === 'arming') return interrupting > 0 ? `确认重启 · ${interrupting} 项` : '确认重启'
|
|
483
|
+
if (state.phase === 'running') return '重启中'
|
|
484
|
+
if (state.phase === 'checking') return '检查中'
|
|
485
|
+
if (state.phase === 'failed') return '重启失败'
|
|
486
|
+
return '重启'
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function captionTitle(state) {
|
|
490
|
+
const interrupting = workCount(state)
|
|
491
|
+
if (state.phase === 'arming') {
|
|
492
|
+
return interrupting > 0
|
|
493
|
+
? `再点一次确认:重启会中断 ${interrupting} 项进行中的任务(${ARM_WINDOW_MS / 1000} 秒内有效)`
|
|
494
|
+
: `再点一次确认重启(${ARM_WINDOW_MS / 1000} 秒内有效)`
|
|
495
|
+
}
|
|
496
|
+
if (state.phase === 'running') return '正在重启 DeepSeek Harness'
|
|
497
|
+
if (state.phase === 'checking') return '正在检查是否可以重启'
|
|
498
|
+
if (state.phase === 'failed') return state.message ?? '重启不可用'
|
|
499
|
+
return '重启 DeepSeek Harness'
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function paintCaptionButton(button, state) {
|
|
503
|
+
const danger = state.phase === 'arming' || state.phase === 'failed'
|
|
504
|
+
button.textContent = captionLabel(state)
|
|
505
|
+
button.title = captionTitle(state)
|
|
506
|
+
button.setAttribute('aria-label', button.title)
|
|
507
|
+
button.dataset.dshRestartPhase = state.phase
|
|
508
|
+
button.style.color = danger ? COLOR_DANGER : ''
|
|
509
|
+
button.style.background = state.phase === 'arming' ? 'color-mix(in srgb, currentColor 8%, transparent)' : ''
|
|
510
|
+
button.style.opacity = state.phase === 'checking' || state.phase === 'running' ? '0.7' : ''
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const REFRESH_LABELS = { idle: '刷新', running: '刷新中', failed: '刷新失败' }
|
|
514
|
+
|
|
515
|
+
function paintCaptionRefresh(button, phase) {
|
|
516
|
+
button.dataset.dshRefreshPhase = phase
|
|
517
|
+
button.textContent = REFRESH_LABELS[phase] ?? REFRESH_LABELS.idle
|
|
518
|
+
button.style.color = phase === 'failed' ? COLOR_DANGER : ''
|
|
519
|
+
button.style.opacity = phase === 'running' ? '0.7' : ''
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* The caption refresh button.
|
|
524
|
+
*
|
|
525
|
+
* One click, no arming: nothing server-side stops when the page reloads, so
|
|
526
|
+
* the second click would only be ceremony. The label turns busy for the few
|
|
527
|
+
* milliseconds before the reload and relaxes again if the reload never comes.
|
|
528
|
+
*/
|
|
529
|
+
function createCaptionRefresh(getSessionId) {
|
|
530
|
+
const button = document.createElement('button')
|
|
531
|
+
button.type = 'button'
|
|
532
|
+
button.setAttribute(CAPTION_REFRESH_ATTRIBUTE, '')
|
|
533
|
+
button.title = REFRESH_TITLE
|
|
534
|
+
button.setAttribute('aria-label', REFRESH_TITLE)
|
|
535
|
+
paintCaptionRefresh(button, 'idle')
|
|
536
|
+
|
|
537
|
+
let relaxTimer = null
|
|
538
|
+
button.addEventListener('click', () => {
|
|
539
|
+
if (button.dataset.dshRefreshPhase === 'running') return
|
|
540
|
+
paintCaptionRefresh(button, 'running')
|
|
541
|
+
if (relaxTimer !== null) clearTimeout(relaxTimer)
|
|
542
|
+
relaxTimer = setTimeout(() => {
|
|
543
|
+
relaxTimer = null
|
|
544
|
+
paintCaptionRefresh(button, 'idle')
|
|
545
|
+
}, FAIL_HOLD_MS)
|
|
546
|
+
if (refreshPage(getSessionId)) return
|
|
547
|
+
paintCaptionRefresh(button, 'failed')
|
|
548
|
+
})
|
|
549
|
+
return button
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** The open shadow root the Desktop preload draws its caption menubar into, if any. */
|
|
553
|
+
function findCaptionShadow() {
|
|
554
|
+
if (typeof document === 'undefined' || typeof document.querySelectorAll !== 'function') return null
|
|
555
|
+
for (const host of document.querySelectorAll(`[${CAPTION_HOST_ATTRIBUTE}]`)) {
|
|
556
|
+
const shadow = host.shadowRoot
|
|
557
|
+
if (shadow !== null && shadow !== undefined) return shadow
|
|
558
|
+
}
|
|
559
|
+
return null
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Append 刷新 and 重启 to the caption menubar.
|
|
564
|
+
*
|
|
565
|
+
* The preload mounts that bar when the frame is ready, which may be before or
|
|
566
|
+
* after this module runs, so a missing bar is waited for instead of treated
|
|
567
|
+
* as failure. Only direct children of `body` are watched: the bar is mounted
|
|
568
|
+
* there, and a subtree observer would walk the whole streaming conversation
|
|
569
|
+
* on every mutation.
|
|
570
|
+
*
|
|
571
|
+
* @param getSessionId - resolves the open Session when an entry is used.
|
|
572
|
+
* @returns disposer, or null when the page has no document at all.
|
|
573
|
+
*/
|
|
574
|
+
function mountCaptionSurface(getSessionId) {
|
|
575
|
+
if (typeof document === 'undefined') return null
|
|
576
|
+
|
|
577
|
+
let group = null
|
|
578
|
+
let refreshButton = null
|
|
579
|
+
let button = null
|
|
580
|
+
let machine = null
|
|
581
|
+
let observer = null
|
|
582
|
+
let disposed = false
|
|
583
|
+
|
|
584
|
+
const attach = () => {
|
|
585
|
+
if (disposed || group !== null) return
|
|
586
|
+
const shadow = findCaptionShadow()
|
|
587
|
+
if (shadow === null) return
|
|
588
|
+
// A reloaded module may find the entries its previous generation left
|
|
589
|
+
// behind; that generation is about to be disposed, so replace them
|
|
590
|
+
// instead of adopting nodes whose owner still holds a reference.
|
|
591
|
+
for (const stale of shadow.querySelectorAll(`[${CAPTION_GROUP_ATTRIBUTE}]`)) stale.remove()
|
|
592
|
+
for (const stale of shadow.querySelectorAll(`[${CAPTION_BUTTON_ATTRIBUTE}], [${CAPTION_REFRESH_ATTRIBUTE}]`)) stale.remove()
|
|
593
|
+
|
|
594
|
+
group = document.createElement('div')
|
|
595
|
+
group.setAttribute(CAPTION_GROUP_ATTRIBUTE, '')
|
|
596
|
+
// Matches the preload's own item rhythm: 2px between menu items, same
|
|
597
|
+
// font, height and hover from the shadow stylesheet, one gap away from 编辑.
|
|
598
|
+
group.style.display = 'flex'
|
|
599
|
+
group.style.gap = '2px'
|
|
600
|
+
group.style.marginLeft = '2px'
|
|
601
|
+
|
|
602
|
+
refreshButton = createCaptionRefresh(getSessionId)
|
|
603
|
+
|
|
604
|
+
button = document.createElement('button')
|
|
605
|
+
button.type = 'button'
|
|
606
|
+
button.setAttribute(CAPTION_BUTTON_ATTRIBUTE, '')
|
|
607
|
+
button.style.cursor = 'default'
|
|
608
|
+
|
|
609
|
+
machine = createRestartMachine(getSessionId)
|
|
610
|
+
machine.subscribe((state) => paintCaptionButton(button, state))
|
|
611
|
+
paintCaptionButton(button, machine.state)
|
|
612
|
+
button.addEventListener('click', () => machine.click())
|
|
613
|
+
|
|
614
|
+
group.append(refreshButton, button)
|
|
615
|
+
shadow.appendChild(group)
|
|
616
|
+
captionLive.set(true)
|
|
617
|
+
observer?.disconnect()
|
|
618
|
+
observer = null
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
attach()
|
|
622
|
+
if (group === null && typeof document.body?.appendChild === 'function') {
|
|
623
|
+
observer = new MutationObserver(attach)
|
|
624
|
+
observer.observe(document.body, { childList: true })
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
return {
|
|
628
|
+
dispose() {
|
|
629
|
+
disposed = true
|
|
630
|
+
observer?.disconnect()
|
|
631
|
+
observer = null
|
|
632
|
+
machine?.dispose()
|
|
633
|
+
machine = null
|
|
634
|
+
button = null
|
|
635
|
+
refreshButton = null
|
|
636
|
+
group?.remove()
|
|
637
|
+
group = null
|
|
638
|
+
},
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/* ------------------------------------------------------------------ *
|
|
643
|
+
* Session hand-off.
|
|
644
|
+
* ------------------------------------------------------------------ */
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Reopen the Session the user was in before a restart or a refresh.
|
|
648
|
+
*
|
|
649
|
+
* Runs once per page load. The refresh marker is checked first because it is
|
|
650
|
+
* the newest intent and costs nothing: it lives in localStorage, since a
|
|
651
|
+
* reload keeps the origin. A restart marker lives on the Host (file), since
|
|
652
|
+
* the port — and with it the origin — changes. Either way the marker is
|
|
653
|
+
* short-lived and single-use; every failure path just leaves the user on the
|
|
654
|
+
* home page, which is where a reload or restart lands anyway. `openSession`
|
|
655
|
+
* is retried because the workspace and Session catalog mount after the first
|
|
656
|
+
* client plugins do.
|
|
657
|
+
*
|
|
658
|
+
* @param ctx - client root context (services: uiWorkspace).
|
|
659
|
+
*/
|
|
660
|
+
function restorePendingSession(ctx) {
|
|
661
|
+
const MAX_ROUNDS = 20
|
|
662
|
+
let finished = false
|
|
663
|
+
|
|
664
|
+
const readRestartMarker = async () => {
|
|
665
|
+
const response = await fetch('/api/dsh-restart/pending', { cache: 'no-store' })
|
|
666
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
667
|
+
const pending = await response.json()
|
|
668
|
+
const sessionId = pending?.sessionId
|
|
669
|
+
return typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const round = async (index) => {
|
|
673
|
+
if (finished) return
|
|
674
|
+
const retry = () => {
|
|
675
|
+
if (index < MAX_ROUNDS) setTimeout(() => void round(index + 1), 700)
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
let sessionId = null
|
|
679
|
+
let origin = 'restart'
|
|
680
|
+
const refresh = readRefreshMarker()
|
|
681
|
+
if (refresh !== null) {
|
|
682
|
+
sessionId = refresh.sessionId
|
|
683
|
+
origin = 'refresh'
|
|
684
|
+
} else {
|
|
685
|
+
try {
|
|
686
|
+
sessionId = await readRestartMarker()
|
|
687
|
+
} catch {
|
|
688
|
+
retry()
|
|
689
|
+
return
|
|
690
|
+
}
|
|
691
|
+
if (sessionId === null) return
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const workspace = ctx.get('uiWorkspace')
|
|
695
|
+
if (!workspace || typeof workspace.openSession !== 'function') {
|
|
696
|
+
retry()
|
|
697
|
+
return
|
|
698
|
+
}
|
|
699
|
+
try {
|
|
700
|
+
workspace.openSession(sessionId)
|
|
701
|
+
} catch {
|
|
702
|
+
retry()
|
|
703
|
+
return
|
|
704
|
+
}
|
|
705
|
+
finished = true
|
|
706
|
+
if (origin === 'refresh') {
|
|
707
|
+
clearRefreshMarker()
|
|
708
|
+
return
|
|
709
|
+
}
|
|
710
|
+
// Consumed only after the navigation was accepted: a failed open leaves
|
|
711
|
+
// the marker behind for the next load instead of eating it.
|
|
712
|
+
fetch('/api/dsh-restart/pending/consume', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }).catch(() => {})
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Give the shell a moment to finish mounting panels before navigating.
|
|
716
|
+
setTimeout(() => void round(0), 1200)
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function apply(ctx) {
|
|
720
|
+
const slots = ctx.slots
|
|
721
|
+
const caption = mountCaptionSurface(() => sessionTracker.current)
|
|
722
|
+
ctx.effect(() => () => caption?.dispose(), 'dsh-restart caption control')
|
|
723
|
+
ctx.effect(
|
|
724
|
+
() => slots.inject('conversation.session.header.utilities', () => slots.register(
|
|
725
|
+
{
|
|
726
|
+
name: 'conversation.session.header.utilities',
|
|
727
|
+
id: 'dsh-restart',
|
|
728
|
+
order: 900,
|
|
729
|
+
label: () => '刷新与重启',
|
|
730
|
+
},
|
|
731
|
+
HeaderActions,
|
|
732
|
+
)),
|
|
733
|
+
'dsh-restart header control',
|
|
734
|
+
)
|
|
735
|
+
restorePendingSession(ctx)
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
return { inject: ['slots'], apply }
|
|
739
|
+
},
|
|
740
|
+
})
|