dsh-live-browser 0.4.0 → 0.5.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/README.en.md +38 -14
- package/README.md +35 -16
- package/client.js +413 -124
- package/lib/agent-script.js +723 -723
- package/lib/skills.js +11 -1
- package/lib/tools.js +66 -3
- package/package.json +1 -1
package/client.js
CHANGED
|
@@ -26,7 +26,29 @@
|
|
|
26
26
|
'use strict'
|
|
27
27
|
|
|
28
28
|
/** Live guests are found by this attribute, owned by the official plugin. */
|
|
29
|
-
const GUEST_SELECTOR =
|
|
29
|
+
const GUEST_SELECTOR =
|
|
30
|
+
'webview[data-sidebar-browser-frame="webview"], iframe[data-sidebar-browser-frame="iframe"]'
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* What to tell a user whose sidebar rendered the Web build's iframe.
|
|
34
|
+
*
|
|
35
|
+
* An iframe is cross-origin to the DSH page, so the parent cannot read its
|
|
36
|
+
* document or inject the agent script. That is a browser security boundary,
|
|
37
|
+
* not a missing feature in this plugin; the only full-capability path is the
|
|
38
|
+
* Desktop build, whose browser tab is an Electron <webview>.
|
|
39
|
+
*/
|
|
40
|
+
const IFRAME_HINT =
|
|
41
|
+
'当前右侧栏打开的是 Web 版 iframe 浏览器,页面与 DSH 跨域,浏览器同源策略禁止插件注入脚本,点击/输入/读取/执行脚本都无法工作。' +
|
|
42
|
+
'请改用 DSH 桌面版的 webview 浏览器:1) 关闭浏览器里的 DSH 页面(地址形如 http://127.0.0.1:54249);' +
|
|
43
|
+
'2) 启动 DeepSeek Harness 桌面客户端(Windows 开始菜单里的 DeepSeek Harness,或 D:\\软件\\deepseekHARNESS\\DeepSeek Harness.exe);' +
|
|
44
|
+
'3) 在桌面客户端打开同一个会话,点右侧栏「浏览器」标签页。桌面版浏览器是 Electron <webview>,browser_* 工具即可正常操作。'
|
|
45
|
+
|
|
46
|
+
const guestKind = (element) =>
|
|
47
|
+
typeof element?.tagName === 'string' && element.tagName.toLowerCase() === 'iframe'
|
|
48
|
+
? 'iframe'
|
|
49
|
+
: 'webview'
|
|
50
|
+
|
|
51
|
+
const isIframeGuest = (element) => guestKind(element) === 'iframe'
|
|
30
52
|
|
|
31
53
|
/** One per conversation: DSH keeps a sidebar mounted for each chat it visited. */
|
|
32
54
|
const SESSION_SELECTOR = '[data-sidebar-right-session]'
|
|
@@ -107,19 +129,67 @@
|
|
|
107
129
|
*
|
|
108
130
|
* @param {{ bridge: ReturnType<typeof createRuntime>, loadSource: () => Promise<string>, openPanel?: (url?: string, sessionId?: string) => Promise<object>, documentRef?: Document, currentSessionId?: () => string | undefined }} options
|
|
109
131
|
*/
|
|
110
|
-
function createDriver({ bridge, loadSource, openPanel, documentRef, currentSessionId }) {
|
|
111
|
-
|
|
132
|
+
function createDriver({ bridge, loadSource, openPanel, documentRef, currentSessionId, listSessions }) {
|
|
133
|
+
/** Stable prefix; each conversation gets its own panel id below. */
|
|
134
|
+
const basePanelId = `panel-${Math.random().toString(36).slice(2, 10)}`
|
|
112
135
|
/** @type {Map<Element, {ready: boolean, installed: boolean, url: string, failure?: string}>} */
|
|
113
136
|
const known = new Map()
|
|
137
|
+
/**
|
|
138
|
+
* One long-poll per conversation.
|
|
139
|
+
*
|
|
140
|
+
* A single poller used to follow whatever chat was on screen, so switching
|
|
141
|
+
* chats silently re-bound the only panel and commands queued for the other
|
|
142
|
+
* chat were rejected. DSH keeps each chat's sidebar mounted; giving every
|
|
143
|
+
* mounted sidebar its own panel keeps background conversations working.
|
|
144
|
+
*
|
|
145
|
+
* @type {Map<string, {panelId: string, timer: any, lastSeen: number, stopped: boolean}>}
|
|
146
|
+
*/
|
|
147
|
+
const workers = new Map()
|
|
114
148
|
let source = ''
|
|
115
149
|
let stopped = false
|
|
116
|
-
let
|
|
117
|
-
let timer
|
|
118
|
-
/** Set when the last poll round threw, so the loop backs off once. */
|
|
119
|
-
let tickFailed = false
|
|
150
|
+
let discoveryTimer
|
|
120
151
|
|
|
121
152
|
const scope = () => documentRef ?? (typeof document === 'undefined' ? undefined : document)
|
|
122
153
|
|
|
154
|
+
/** One small, dismissible notice for the Web build's iframe browser. */
|
|
155
|
+
let iframeNotice
|
|
156
|
+
let iframeNoticeDismissed = false
|
|
157
|
+
const hideIframeNotice = () => {
|
|
158
|
+
if (iframeNotice !== undefined && iframeNotice.isConnected) iframeNotice.remove()
|
|
159
|
+
iframeNotice = undefined
|
|
160
|
+
}
|
|
161
|
+
const dismissIframeNotice = () => {
|
|
162
|
+
iframeNoticeDismissed = true
|
|
163
|
+
hideIframeNotice()
|
|
164
|
+
}
|
|
165
|
+
const showIframeNotice = () => {
|
|
166
|
+
if (iframeNoticeDismissed) return
|
|
167
|
+
if (iframeNotice !== undefined && iframeNotice.isConnected) return
|
|
168
|
+
const doc = scope()
|
|
169
|
+
if (doc === undefined || doc.body === undefined || doc.body === null) return
|
|
170
|
+
const notice = doc.createElement('div')
|
|
171
|
+
notice.dataset.dshLiveBrowserIframeNotice = ''
|
|
172
|
+
notice.setAttribute('role', 'status')
|
|
173
|
+
notice.style.cssText =
|
|
174
|
+
'position:fixed;right:16px;bottom:16px;z-index:2147483647;max-width:440px;' +
|
|
175
|
+
'padding:12px 14px;border:1px solid #ffd591;border-radius:8px;background:#fff7e6;' +
|
|
176
|
+
'color:#613400;box-shadow:0 6px 20px rgba(0,0,0,.16);font:13px/1.55 system-ui,sans-serif;'
|
|
177
|
+
const text = doc.createElement('div')
|
|
178
|
+
text.textContent =
|
|
179
|
+
'当前右侧栏是 iframe 浏览器,插件无法执行点击/输入/读取。请改用 DSH 桌面版 webview:' +
|
|
180
|
+
'关闭浏览器里的 DSH 页面 → 启动 DeepSeek Harness 桌面客户端 → 打开同一会话 → 点右侧栏「浏览器」。'
|
|
181
|
+
const close = doc.createElement('button')
|
|
182
|
+
close.type = 'button'
|
|
183
|
+
close.textContent = '知道了'
|
|
184
|
+
close.style.cssText =
|
|
185
|
+
'margin-top:8px;padding:3px 10px;border:1px solid #ffd591;border-radius:5px;' +
|
|
186
|
+
'background:#fff;color:#613400;font:12px system-ui,sans-serif;cursor:pointer;'
|
|
187
|
+
close.addEventListener('click', dismissIframeNotice)
|
|
188
|
+
notice.append(text, close)
|
|
189
|
+
doc.body.appendChild(notice)
|
|
190
|
+
iframeNotice = notice
|
|
191
|
+
}
|
|
192
|
+
|
|
123
193
|
/**
|
|
124
194
|
* The sidebar the human is looking at.
|
|
125
195
|
*
|
|
@@ -137,10 +207,17 @@
|
|
|
137
207
|
* on. Without a mounted sidebar the shell's current conversation is the only
|
|
138
208
|
* source left, and it is exactly where a cold start has to open the tab.
|
|
139
209
|
*/
|
|
140
|
-
const
|
|
210
|
+
const currentSession = () => {
|
|
211
|
+
// The shell's own selection is authoritative for the visible tab. It is
|
|
212
|
+
// only used to decide which sidebar is on screen (and to make sure the
|
|
213
|
+
// visible chat has a worker before its first browser tab exists); every
|
|
214
|
+
// worker below polls for a fixed session id, so background chats keep
|
|
215
|
+
// working while the user is looking elsewhere.
|
|
216
|
+
const current = currentSessionId?.()
|
|
217
|
+
if (current !== undefined) return current
|
|
141
218
|
const shown = view()
|
|
142
219
|
if (shown !== undefined) return viewSessionId(shown)
|
|
143
|
-
return
|
|
220
|
+
return undefined
|
|
144
221
|
}
|
|
145
222
|
|
|
146
223
|
/**
|
|
@@ -149,14 +226,33 @@
|
|
|
149
226
|
* A command names its conversation; the guest has to come from that same
|
|
150
227
|
* chat's sidebar even when the user has switched to another one meanwhile.
|
|
151
228
|
*/
|
|
229
|
+
const viewsFor = (target) => {
|
|
230
|
+
const all = sessionViews(scope())
|
|
231
|
+
if (target === undefined) {
|
|
232
|
+
const shown = view()
|
|
233
|
+
return shown === undefined ? [] : [shown]
|
|
234
|
+
}
|
|
235
|
+
return all.filter((candidate) => viewSessionId(candidate) === target)
|
|
236
|
+
}
|
|
237
|
+
|
|
152
238
|
const viewFor = (target) => {
|
|
153
|
-
|
|
154
|
-
|
|
239
|
+
const candidates = viewsFor(target)
|
|
240
|
+
// The same conversation can expose more than one
|
|
241
|
+
// [data-sidebar-right-session] node (the session wrapper and the panel
|
|
242
|
+
// itself, which may be portaled). Prefer the one that actually holds the
|
|
243
|
+
// guest, otherwise a wrapper without a browser would shadow the panel.
|
|
244
|
+
return (
|
|
245
|
+
candidates.find((candidate) => candidate.querySelector(GUEST_SELECTOR) !== null) ??
|
|
246
|
+
candidates[0]
|
|
247
|
+
)
|
|
155
248
|
}
|
|
156
249
|
|
|
157
250
|
const findGuests = (target) => {
|
|
158
|
-
const
|
|
159
|
-
|
|
251
|
+
const guests = []
|
|
252
|
+
for (const candidate of viewsFor(target)) {
|
|
253
|
+
guests.push(...candidate.querySelectorAll(GUEST_SELECTOR))
|
|
254
|
+
}
|
|
255
|
+
return [...new Set(guests)]
|
|
160
256
|
}
|
|
161
257
|
|
|
162
258
|
/**
|
|
@@ -184,8 +280,13 @@
|
|
|
184
280
|
* that is not on screen.
|
|
185
281
|
*/
|
|
186
282
|
const targetGuest = (target) => {
|
|
187
|
-
const
|
|
188
|
-
if (
|
|
283
|
+
const all = findGuests(target)
|
|
284
|
+
if (all.length === 0) return undefined
|
|
285
|
+
// A desktop build may have both kinds mounted while switching profiles.
|
|
286
|
+
// Prefer a webview: it is the one this plugin can actually drive.
|
|
287
|
+
const guests = all.some((element) => !isIframeGuest(element))
|
|
288
|
+
? all.filter((element) => !isIframeGuest(element))
|
|
289
|
+
: all
|
|
189
290
|
const doc = scope()
|
|
190
291
|
const focused = guests.find((element) => element === doc?.activeElement)
|
|
191
292
|
if (focused !== undefined) return focused
|
|
@@ -207,7 +308,11 @@
|
|
|
207
308
|
for (const candidate of findGuests(target)) {
|
|
208
309
|
if (!known.has(candidate)) track(candidate)
|
|
209
310
|
}
|
|
210
|
-
const
|
|
311
|
+
const candidates = findGuests(target)
|
|
312
|
+
const ready =
|
|
313
|
+
candidates.find(
|
|
314
|
+
(candidate) => !isIframeGuest(candidate) && known.get(candidate)?.ready === true,
|
|
315
|
+
) ?? candidates.find((candidate) => known.get(candidate)?.ready === true)
|
|
211
316
|
if (ready !== undefined) return ready
|
|
212
317
|
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
213
318
|
}
|
|
@@ -218,6 +323,24 @@
|
|
|
218
323
|
const track = (element) => {
|
|
219
324
|
const state = { ready: false, installed: false, url: '', failure: undefined }
|
|
220
325
|
known.set(element, state)
|
|
326
|
+
if (isIframeGuest(element)) {
|
|
327
|
+
// An iframe has none of the <webview> methods. Its href is readable from
|
|
328
|
+
// the parent even when its document is not; that is enough to report
|
|
329
|
+
// "this tab is an iframe" and hand the caller the Desktop hint.
|
|
330
|
+
const refreshFrame = () => {
|
|
331
|
+
try {
|
|
332
|
+
state.url = typeof element.src === 'string' ? element.src : ''
|
|
333
|
+
} catch {
|
|
334
|
+
state.url = ''
|
|
335
|
+
}
|
|
336
|
+
state.ready = true
|
|
337
|
+
state.installed = false
|
|
338
|
+
state.failure = undefined
|
|
339
|
+
}
|
|
340
|
+
element.addEventListener('load', refreshFrame)
|
|
341
|
+
refreshFrame()
|
|
342
|
+
return
|
|
343
|
+
}
|
|
221
344
|
const refresh = () => {
|
|
222
345
|
try {
|
|
223
346
|
state.url = element.getURL()
|
|
@@ -258,6 +381,10 @@
|
|
|
258
381
|
* re-evaluating the whole script on every SPA route change.
|
|
259
382
|
*/
|
|
260
383
|
const ensureInstalled = async (element) => {
|
|
384
|
+
// There is no cross-origin scripting surface for an iframe. Returning
|
|
385
|
+
// false lets `state`/`tabs` answer with the iframe description instead of
|
|
386
|
+
// pretending the page is merely loading.
|
|
387
|
+
if (isIframeGuest(element)) return false
|
|
261
388
|
const state = known.get(element)
|
|
262
389
|
if (state === undefined) return false
|
|
263
390
|
if (state.installed) return true
|
|
@@ -277,6 +404,28 @@
|
|
|
277
404
|
}
|
|
278
405
|
|
|
279
406
|
const describeGuest = (element) => {
|
|
407
|
+
if (isIframeGuest(element)) {
|
|
408
|
+
let sameOrigin = false
|
|
409
|
+
try {
|
|
410
|
+
sameOrigin = element.contentDocument !== null
|
|
411
|
+
} catch {
|
|
412
|
+
sameOrigin = false
|
|
413
|
+
}
|
|
414
|
+
return {
|
|
415
|
+
kind: 'iframe',
|
|
416
|
+
mode: 'iframe',
|
|
417
|
+
actionRequired: 'switch-to-desktop-webview',
|
|
418
|
+
id: typeof element.src === 'string' ? element.src : '',
|
|
419
|
+
url: typeof element.src === 'string' ? element.src : '',
|
|
420
|
+
title: element.getAttribute('title') ?? '',
|
|
421
|
+
canGoBack: false,
|
|
422
|
+
canGoForward: false,
|
|
423
|
+
loading: false,
|
|
424
|
+
sameOrigin,
|
|
425
|
+
automation: false,
|
|
426
|
+
hint: IFRAME_HINT,
|
|
427
|
+
}
|
|
428
|
+
}
|
|
280
429
|
const state = known.get(element)
|
|
281
430
|
let url = state?.url ?? ''
|
|
282
431
|
let title = ''
|
|
@@ -292,7 +441,7 @@
|
|
|
292
441
|
} catch {
|
|
293
442
|
/* the element is between documents */
|
|
294
443
|
}
|
|
295
|
-
return { id: url, url, title, canGoBack, canGoForward, loading }
|
|
444
|
+
return { kind: 'webview', id: url, url, title, canGoBack, canGoForward, loading }
|
|
296
445
|
}
|
|
297
446
|
|
|
298
447
|
/** Perform one command on the chosen guest. */
|
|
@@ -305,6 +454,16 @@
|
|
|
305
454
|
return bridge.tabCommand(method, command.params ?? {})
|
|
306
455
|
}
|
|
307
456
|
|
|
457
|
+
// A Web-build iframe has no scriptable guest. It can still answer what it
|
|
458
|
+
// is (so browser_status can explain the situation), but every command that
|
|
459
|
+
// needs the page must stop with the Desktop/webview hint instead of
|
|
460
|
+
// reporting a generic "still loading".
|
|
461
|
+
if (isIframeGuest(element)) {
|
|
462
|
+
if (method === 'state') return describeGuest(element)
|
|
463
|
+
if (method === 'tabs') return { tabs: findGuests(target).map(describeGuest) }
|
|
464
|
+
throw new Error(IFRAME_HINT)
|
|
465
|
+
}
|
|
466
|
+
|
|
308
467
|
// Browser-level commands drive the element, like the toolbar does.
|
|
309
468
|
if (method in GUEST_METHODS) {
|
|
310
469
|
element[GUEST_METHODS[method]]()
|
|
@@ -341,115 +500,237 @@
|
|
|
341
500
|
return element.executeJavaScript(expression, true)
|
|
342
501
|
}
|
|
343
502
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
503
|
+
/** Refresh the iframe notice for the sidebar currently on screen. */
|
|
504
|
+
const updateIframeNotice = (shownSession) => {
|
|
505
|
+
const guests = shownSession === undefined ? [] : findGuests(shownSession)
|
|
506
|
+
const hasWebview = guests.some((element) => !isIframeGuest(element))
|
|
507
|
+
const iframe = hasWebview ? undefined : guests.find((element) => isIframeGuest(element))
|
|
508
|
+
if (iframe === undefined) {
|
|
509
|
+
iframeNoticeDismissed = false
|
|
510
|
+
hideIframeNotice()
|
|
511
|
+
} else {
|
|
512
|
+
showIframeNotice()
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const stopWorker = (sessionId) => {
|
|
517
|
+
const worker = workers.get(sessionId)
|
|
518
|
+
if (worker === undefined) return
|
|
519
|
+
worker.stopped = true
|
|
520
|
+
if (worker.timer !== undefined) clearTimeout(worker.timer)
|
|
521
|
+
workers.delete(sessionId)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* One request loop for one conversation.
|
|
526
|
+
*
|
|
527
|
+
* The panel is bound to `sessionId` for its whole life. It never reads the
|
|
528
|
+
* chat currently on screen, so switching chats cannot steal or reject a
|
|
529
|
+
* command that belongs to this conversation.
|
|
530
|
+
*/
|
|
531
|
+
const tickFor = async (sessionId, worker) => {
|
|
532
|
+
for (const element of findGuests(sessionId)) {
|
|
533
|
+
if (!known.has(element)) track(element)
|
|
534
|
+
}
|
|
535
|
+
for (const element of [...known.keys()]) {
|
|
536
|
+
if (!element.isConnected) known.delete(element)
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
let command
|
|
347
540
|
try {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
//
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
for (const element of [...known.keys()]) {
|
|
357
|
-
if (!element.isConnected) known.delete(element)
|
|
358
|
-
}
|
|
541
|
+
command = await bridge.poll(worker.panelId, sessionId)
|
|
542
|
+
} catch {
|
|
543
|
+
// A failed long poll (host restarting, page hidden) backs off once.
|
|
544
|
+
return true
|
|
545
|
+
}
|
|
546
|
+
if (command === null || command === undefined) return false
|
|
359
547
|
|
|
360
|
-
|
|
361
|
-
|
|
548
|
+
if (command.sessionId !== undefined && command.sessionId !== sessionId) {
|
|
549
|
+
await bridge.settle(worker.panelId, command.id, {
|
|
550
|
+
ok: false,
|
|
551
|
+
error: `这条命令属于对话 ${command.sessionId},当前面板绑定的是 ${sessionId};已拒绝,避免操作到错误页面。`,
|
|
552
|
+
})
|
|
553
|
+
return false
|
|
554
|
+
}
|
|
555
|
+
const target = sessionId
|
|
362
556
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
if (command.sessionId !== undefined && shown !== undefined && command.sessionId !== shown) {
|
|
369
|
-
await bridge.settle(panelId, command.id, {
|
|
557
|
+
// Explicit opener: create the browser tab in this conversation, even
|
|
558
|
+
// while a different conversation is on screen.
|
|
559
|
+
if (command.method === 'openPanel') {
|
|
560
|
+
if (!(await canAutoOpen())) {
|
|
561
|
+
await bridge.settle(worker.panelId, command.id, {
|
|
370
562
|
ok: false,
|
|
371
|
-
error:
|
|
563
|
+
error:
|
|
564
|
+
'当前对话的右侧栏还没有网页,且插件设置里关闭了「自动打开面板」;请先手动打开「浏览器」标签页后重试',
|
|
372
565
|
})
|
|
373
|
-
return
|
|
566
|
+
return false
|
|
374
567
|
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
'当前对话的右侧栏还没有网页,且插件设置里关闭了「自动打开面板」;请先手动打开「浏览器」标签页后重试',
|
|
391
|
-
})
|
|
392
|
-
return
|
|
393
|
-
}
|
|
394
|
-
if (openPanel === undefined) {
|
|
395
|
-
await bridge.settle(panelId, command.id, {
|
|
396
|
-
ok: false,
|
|
397
|
-
error: '右侧栏还没有打开任何网页,并且当前环境无法自动打开浏览器',
|
|
398
|
-
})
|
|
399
|
-
return
|
|
400
|
-
}
|
|
401
|
-
try {
|
|
402
|
-
await openPanel(
|
|
403
|
-
command.method === 'navigate' ? command.params?.url : undefined,
|
|
404
|
-
// Open in the chat that asked, not the chat now on screen.
|
|
405
|
-
target,
|
|
406
|
-
)
|
|
407
|
-
} catch (failure) {
|
|
408
|
-
await bridge.settle(panelId, command.id, {
|
|
409
|
-
ok: false,
|
|
410
|
-
error: `无法自动打开侧边栏浏览器:${String(failure?.message ?? failure)}`,
|
|
411
|
-
})
|
|
412
|
-
return
|
|
413
|
-
}
|
|
414
|
-
element = await waitForGuest(8000, target)
|
|
415
|
-
if (element === undefined) {
|
|
416
|
-
await bridge.settle(panelId, command.id, {
|
|
417
|
-
ok: false,
|
|
418
|
-
error: '侧边栏浏览器已打开,但页面还没准备好,请稍后重试',
|
|
419
|
-
})
|
|
420
|
-
return
|
|
421
|
-
}
|
|
568
|
+
if (openPanel === undefined) {
|
|
569
|
+
await bridge.settle(worker.panelId, command.id, {
|
|
570
|
+
ok: false,
|
|
571
|
+
error: '右侧栏还没有打开任何网页,并且当前环境无法自动打开浏览器',
|
|
572
|
+
})
|
|
573
|
+
return false
|
|
574
|
+
}
|
|
575
|
+
try {
|
|
576
|
+
await openPanel(command.params?.url, target)
|
|
577
|
+
} catch (failure) {
|
|
578
|
+
await bridge.settle(worker.panelId, command.id, {
|
|
579
|
+
ok: false,
|
|
580
|
+
error: `无法自动打开侧边栏浏览器:${String(failure?.message ?? failure)}`,
|
|
581
|
+
})
|
|
582
|
+
return false
|
|
422
583
|
}
|
|
584
|
+
const opened = await waitForGuest(8000, target)
|
|
585
|
+
await bridge.settle(worker.panelId, command.id, {
|
|
586
|
+
ok: true,
|
|
587
|
+
value: { opened: true, hasPage: opened !== undefined },
|
|
588
|
+
})
|
|
589
|
+
return false
|
|
590
|
+
}
|
|
423
591
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
592
|
+
let element = targetGuest(target)
|
|
593
|
+
if (element === undefined) {
|
|
594
|
+
// `state`/`tabs` are status probes; they must not create tabs.
|
|
595
|
+
if (command.method === 'state') {
|
|
596
|
+
await bridge.settle(worker.panelId, command.id, { ok: true, value: null })
|
|
597
|
+
return false
|
|
598
|
+
}
|
|
599
|
+
if (command.method === 'tabs') {
|
|
600
|
+
await bridge.settle(worker.panelId, command.id, { ok: true, value: { tabs: [] } })
|
|
601
|
+
return false
|
|
602
|
+
}
|
|
603
|
+
if (!(await canAutoOpen())) {
|
|
604
|
+
await bridge.settle(worker.panelId, command.id, {
|
|
428
605
|
ok: false,
|
|
429
|
-
error:
|
|
606
|
+
error:
|
|
607
|
+
'当前对话的右侧栏还没有网页,且插件设置里关闭了「自动打开面板」;请先手动打开「浏览器」标签页后重试',
|
|
430
608
|
})
|
|
431
|
-
return
|
|
609
|
+
return false
|
|
432
610
|
}
|
|
433
|
-
if (
|
|
434
|
-
await bridge.settle(panelId, command.id, {
|
|
435
|
-
|
|
611
|
+
if (openPanel === undefined) {
|
|
612
|
+
await bridge.settle(worker.panelId, command.id, {
|
|
613
|
+
ok: false,
|
|
614
|
+
error: '右侧栏还没有打开任何网页,并且当前环境无法自动打开浏览器',
|
|
615
|
+
})
|
|
616
|
+
return false
|
|
436
617
|
}
|
|
437
|
-
|
|
438
618
|
try {
|
|
439
|
-
|
|
440
|
-
await bridge.settle(panelId, command.id, { ok: true, value: normalizeValue(value) })
|
|
619
|
+
await openPanel(command.method === 'navigate' ? command.params?.url : undefined, target)
|
|
441
620
|
} catch (failure) {
|
|
442
|
-
await bridge.settle(panelId, command.id, {
|
|
621
|
+
await bridge.settle(worker.panelId, command.id, {
|
|
622
|
+
ok: false,
|
|
623
|
+
error: `无法自动打开侧边栏浏览器:${String(failure?.message ?? failure)}`,
|
|
624
|
+
})
|
|
625
|
+
return false
|
|
626
|
+
}
|
|
627
|
+
element = await waitForGuest(8000, target)
|
|
628
|
+
if (element === undefined) {
|
|
629
|
+
await bridge.settle(worker.panelId, command.id, {
|
|
443
630
|
ok: false,
|
|
444
|
-
error:
|
|
631
|
+
error: '侧边栏浏览器已打开,但页面还没准备好,请稍后重试',
|
|
445
632
|
})
|
|
633
|
+
return false
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const state = known.get(element)
|
|
638
|
+
if (state !== undefined && state.failure !== undefined) {
|
|
639
|
+
// Say what actually went wrong instead of "still loading".
|
|
640
|
+
await bridge.settle(worker.panelId, command.id, {
|
|
641
|
+
ok: false,
|
|
642
|
+
error: `页面加载失败:${state.failure}`,
|
|
643
|
+
})
|
|
644
|
+
return false
|
|
645
|
+
}
|
|
646
|
+
if (state === undefined || !state.ready) {
|
|
647
|
+
await bridge.settle(worker.panelId, command.id, {
|
|
648
|
+
ok: false,
|
|
649
|
+
error: '页面还在加载,请稍后重试',
|
|
650
|
+
})
|
|
651
|
+
return false
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
try {
|
|
655
|
+
const value = await dispatch(element, command, target)
|
|
656
|
+
await bridge.settle(worker.panelId, command.id, {
|
|
657
|
+
ok: true,
|
|
658
|
+
value: normalizeValue(value),
|
|
659
|
+
})
|
|
660
|
+
} catch (failure) {
|
|
661
|
+
await bridge.settle(worker.panelId, command.id, {
|
|
662
|
+
ok: false,
|
|
663
|
+
error: String(failure?.message ?? failure),
|
|
664
|
+
})
|
|
665
|
+
}
|
|
666
|
+
return false
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const startWorker = (sessionId) => {
|
|
670
|
+
const worker = {
|
|
671
|
+
panelId: `${basePanelId}-${sessionId}`,
|
|
672
|
+
timer: undefined,
|
|
673
|
+
lastSeen: Date.now(),
|
|
674
|
+
stopped: false,
|
|
675
|
+
}
|
|
676
|
+
const loop = async () => {
|
|
677
|
+
if (stopped || worker.stopped) return
|
|
678
|
+
let retry = false
|
|
679
|
+
try {
|
|
680
|
+
retry = await tickFor(sessionId, worker)
|
|
681
|
+
} catch {
|
|
682
|
+
retry = true
|
|
683
|
+
}
|
|
684
|
+
if (stopped || worker.stopped) return
|
|
685
|
+
worker.timer = setTimeout(() => {
|
|
686
|
+
void loop()
|
|
687
|
+
}, retry ? POLL_RETRY_MS : 0)
|
|
688
|
+
}
|
|
689
|
+
void loop()
|
|
690
|
+
return worker
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Keep one worker per mounted conversation.
|
|
695
|
+
*
|
|
696
|
+
* A conversation whose sidebar is hidden is still mounted by DSH; its
|
|
697
|
+
* worker keeps polling, which is what lets a command finish there while the
|
|
698
|
+
* user is reading another chat.
|
|
699
|
+
*/
|
|
700
|
+
const syncWorkers = () => {
|
|
701
|
+
if (stopped) return
|
|
702
|
+
const visible = currentSession()
|
|
703
|
+
const ids = new Set()
|
|
704
|
+
if (visible !== undefined) ids.add(visible)
|
|
705
|
+
for (const view of sessionViews(scope())) {
|
|
706
|
+
const id = viewSessionId(view)
|
|
707
|
+
if (id !== undefined) ids.add(id)
|
|
708
|
+
}
|
|
709
|
+
// A conversation with no browser tab has no sidebar to discover. Include
|
|
710
|
+
// every live session so a cold-start navigate can create its tab even
|
|
711
|
+
// while another conversation is on screen.
|
|
712
|
+
try {
|
|
713
|
+
for (const session of listSessions?.() ?? []) {
|
|
714
|
+
const id = typeof session === 'string' ? session : session?.id
|
|
715
|
+
if (typeof id === 'string' && id !== '') ids.add(id)
|
|
446
716
|
}
|
|
447
717
|
} catch {
|
|
448
|
-
//
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
718
|
+
// Optional service; the DOM/current session still cover the common case.
|
|
719
|
+
}
|
|
720
|
+
for (const id of ids) {
|
|
721
|
+
let worker = workers.get(id)
|
|
722
|
+
if (worker === undefined) {
|
|
723
|
+
worker = startWorker(id)
|
|
724
|
+
workers.set(id, worker)
|
|
725
|
+
}
|
|
726
|
+
worker.lastSeen = Date.now()
|
|
727
|
+
}
|
|
728
|
+
// A closed chat leaves the DOM immediately but its worker may still hold
|
|
729
|
+
// a queued command for a few seconds; keep it through that grace period.
|
|
730
|
+
for (const [id, worker] of [...workers]) {
|
|
731
|
+
if (!ids.has(id) && Date.now() - worker.lastSeen > 30000) stopWorker(id)
|
|
452
732
|
}
|
|
733
|
+
updateIframeNotice(visible)
|
|
453
734
|
}
|
|
454
735
|
|
|
455
736
|
return {
|
|
@@ -465,25 +746,16 @@
|
|
|
465
746
|
}
|
|
466
747
|
}
|
|
467
748
|
if (stopped) return
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
const loop = async () => {
|
|
471
|
-
if (stopped) return
|
|
472
|
-
try {
|
|
473
|
-
await tick()
|
|
474
|
-
} finally {
|
|
475
|
-
if (stopped) return
|
|
476
|
-
const wait = tickFailed === true ? POLL_RETRY_MS : 0
|
|
477
|
-
tickFailed = false
|
|
478
|
-
timer = setTimeout(() => { void loop() }, wait)
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
void loop()
|
|
749
|
+
syncWorkers()
|
|
750
|
+
discoveryTimer = setInterval(syncWorkers, 500)
|
|
482
751
|
},
|
|
483
752
|
stop() {
|
|
484
753
|
stopped = true
|
|
485
|
-
|
|
754
|
+
if (discoveryTimer !== undefined) clearInterval(discoveryTimer)
|
|
755
|
+
for (const id of [...workers.keys()]) stopWorker(id)
|
|
486
756
|
known.clear()
|
|
757
|
+
iframeNoticeDismissed = false
|
|
758
|
+
hideIframeNotice()
|
|
487
759
|
},
|
|
488
760
|
}
|
|
489
761
|
}
|
|
@@ -537,10 +809,20 @@
|
|
|
537
809
|
* @returns {Element | undefined}
|
|
538
810
|
*/
|
|
539
811
|
function foregroundSessionView(documentRef) {
|
|
540
|
-
|
|
812
|
+
const views = sessionViews(documentRef).filter((view) => {
|
|
541
813
|
if (typeof view.hasAttribute === 'function' && view.hasAttribute('hidden')) return false
|
|
542
|
-
|
|
814
|
+
if (typeof view.closest === 'function' && view.closest('[hidden]') !== null) return false
|
|
815
|
+
// Background sessions may be hidden with CSS rather than the `hidden`
|
|
816
|
+
// attribute, and a portaled panel can keep a non-zero box there. A view
|
|
817
|
+
// that is not laid out is not the one the human is looking at.
|
|
818
|
+
return isLaidOut(view)
|
|
543
819
|
})
|
|
820
|
+
const active = documentRef?.activeElement
|
|
821
|
+
const focused =
|
|
822
|
+
active === undefined || active === null
|
|
823
|
+
? undefined
|
|
824
|
+
: views.find((view) => typeof view.contains === 'function' && view.contains(active))
|
|
825
|
+
return focused ?? views[0]
|
|
544
826
|
}
|
|
545
827
|
|
|
546
828
|
/**
|
|
@@ -809,6 +1091,13 @@
|
|
|
809
1091
|
// itself, inside the requesting conversation.
|
|
810
1092
|
openPanel: (url, sessionId) => openSidebarBrowser(ctx, sessionId, url),
|
|
811
1093
|
currentSessionId: () => currentSessionId(ctx),
|
|
1094
|
+
listSessions: () => {
|
|
1095
|
+
try {
|
|
1096
|
+
return ctx.get?.('sessions')?.list?.() ?? []
|
|
1097
|
+
} catch {
|
|
1098
|
+
return []
|
|
1099
|
+
}
|
|
1100
|
+
},
|
|
812
1101
|
})
|
|
813
1102
|
void driver.start()
|
|
814
1103
|
return () => driver.stop()
|