dsh-live-browser 0.0.0-stage → 0.4.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/LICENSE +21 -0
- package/README.en.md +298 -0
- package/README.md +311 -2
- package/client.js +835 -0
- package/cordis.patch.yml +3 -0
- package/lib/agent-script.js +723 -0
- package/lib/bridge.js +336 -0
- package/lib/config.js +84 -0
- package/lib/index.js +98 -0
- package/lib/output.js +12 -0
- package/lib/rpc.js +398 -0
- package/lib/schema.js +52 -0
- package/lib/skills.js +60 -0
- package/lib/tools.js +756 -0
- package/package.json +72 -4
package/client.js
ADDED
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-live-browser client half.
|
|
3
|
+
*
|
|
4
|
+
* The right-sidebar browser you already see is DSH's own
|
|
5
|
+
* `@deepseek-ai/dsh-client-ui-sidebar-browser`: it owns the tab strip, the
|
|
6
|
+
* address bar, the guest <webview> and the browser session. This plugin adds the
|
|
7
|
+
* one thing that package cannot do — let the agent drive the page.
|
|
8
|
+
*
|
|
9
|
+
* How it attaches: the official plugin tags every guest with
|
|
10
|
+
* `data-sidebar-browser-frame="webview"`, so this bundle finds those elements in
|
|
11
|
+
* the live DOM, injects a command surface into each document, and polls the host
|
|
12
|
+
* for work. No DSH file is modified, so app upgrades cannot break it.
|
|
13
|
+
*
|
|
14
|
+
* Two command families, handled differently:
|
|
15
|
+
*
|
|
16
|
+
* - Page commands (click/type/snapshot/...) run *inside* the guest through the
|
|
17
|
+
* injected script.
|
|
18
|
+
* - Browser commands (navigate/back/forward/reload) drive the guest element
|
|
19
|
+
* itself, exactly the way the official plugin's toolbar does.
|
|
20
|
+
*
|
|
21
|
+
* Wrapped in an IIFE: DSH batches several plugin bundles into one combo
|
|
22
|
+
* <script>, so a top-level `const` here would collide with a sibling bundle.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
;(function () {
|
|
26
|
+
'use strict'
|
|
27
|
+
|
|
28
|
+
/** Live guests are found by this attribute, owned by the official plugin. */
|
|
29
|
+
const GUEST_SELECTOR = 'webview[data-sidebar-browser-frame="webview"]'
|
|
30
|
+
|
|
31
|
+
/** One per conversation: DSH keeps a sidebar mounted for each chat it visited. */
|
|
32
|
+
const SESSION_SELECTOR = '[data-sidebar-right-session]'
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* How long one poll request may be held open by the host.
|
|
36
|
+
*
|
|
37
|
+
* The old design woke the panel every 50 ms: every command paid an average
|
|
38
|
+
* 25 ms of pure hand-off latency and an idle panel generated 20 HTTP requests
|
|
39
|
+
* per second. A long poll removes both — the host answers the moment a
|
|
40
|
+
* command is queued. The value stays below the browser's request patience.
|
|
41
|
+
*/
|
|
42
|
+
const POLL_WAIT_MS = 25000
|
|
43
|
+
|
|
44
|
+
/** Only used to retry after a failed poll, never on the happy path. */
|
|
45
|
+
const POLL_RETRY_MS = 250
|
|
46
|
+
|
|
47
|
+
/** Browser-level commands and the guest method that performs each. */
|
|
48
|
+
const GUEST_METHODS = { back: 'goBack', forward: 'goForward', reload: 'reload' }
|
|
49
|
+
|
|
50
|
+
/** Normalize a typed or model-supplied address. */
|
|
51
|
+
function normalizeAddress(raw) {
|
|
52
|
+
const text = String(raw ?? '').trim()
|
|
53
|
+
if (text === '') return undefined
|
|
54
|
+
// localhost / bare IP first: "localhost:5173" otherwise matches the scheme
|
|
55
|
+
// pattern and turns into a nonsense URL.
|
|
56
|
+
if (/^localhost(:\d+)?(\/|$)/i.test(text)) return `http://${text}`
|
|
57
|
+
if (/^\d{1,3}(\.\d{1,3}){3}(:\d+)?(\/|$)/.test(text)) return `http://${text}`
|
|
58
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(text)) return text
|
|
59
|
+
return `https://${text}`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Make a page-produced value safe to send back as JSON. */
|
|
63
|
+
function normalizeValue(value) {
|
|
64
|
+
if (value === undefined) return null
|
|
65
|
+
if (value === null || typeof value !== 'object') return value
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(JSON.stringify(value))
|
|
68
|
+
} catch {
|
|
69
|
+
return String(value)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The bridge to the host half: ask for work, post results. */
|
|
74
|
+
function createRuntime() {
|
|
75
|
+
const channel = '/dsh-live-browser'
|
|
76
|
+
const call = async (endpoint, payload = {}) => {
|
|
77
|
+
const rpcId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
|
78
|
+
const response = await fetch(`${channel}/${endpoint}`, {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: { 'content-type': 'application/json' },
|
|
81
|
+
body: JSON.stringify({ type: 'client-request', rpcId, method: endpoint, payload }),
|
|
82
|
+
})
|
|
83
|
+
if (!response.ok) throw new Error(`浏览器桥接不可用:HTTP ${response.status}`)
|
|
84
|
+
const body = await response.json()
|
|
85
|
+
const result = body?.result
|
|
86
|
+
if (result?.ok === false) throw new Error(String(result?.error?.message ?? '浏览器操作失败'))
|
|
87
|
+
return result?.value
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
// The panel announces which conversation it is rendering so the host can
|
|
91
|
+
// hand it only that conversation's commands.
|
|
92
|
+
poll: (panelId, sessionId) => call('poll', { panelId, sessionId, waitMs: POLL_WAIT_MS }),
|
|
93
|
+
settle: (panelId, id, outcome) => call('settle', { panelId, id, outcome }),
|
|
94
|
+
/** The injected script is served by the host half, so it always matches. */
|
|
95
|
+
source: () => call('source', {}).then((value) => String(value?.source ?? '')),
|
|
96
|
+
/** Behaviour flags the host owns; read right before an auto-open. */
|
|
97
|
+
config: () => call('config', {}),
|
|
98
|
+
/** Tab management is a host concern: only it can drive the sidebar. */
|
|
99
|
+
tabCommand: (action, params) => call('tabCommand', { action, params }),
|
|
100
|
+
/** Read a local file the agent wants to attach to a file input. */
|
|
101
|
+
readUpload: (path) => call('readUpload', { path }),
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Drive whichever sidebar-browser guests exist.
|
|
107
|
+
*
|
|
108
|
+
* @param {{ bridge: ReturnType<typeof createRuntime>, loadSource: () => Promise<string>, openPanel?: (url?: string, sessionId?: string) => Promise<object>, documentRef?: Document, currentSessionId?: () => string | undefined }} options
|
|
109
|
+
*/
|
|
110
|
+
function createDriver({ bridge, loadSource, openPanel, documentRef, currentSessionId }) {
|
|
111
|
+
const panelId = `panel-${Math.random().toString(36).slice(2, 10)}`
|
|
112
|
+
/** @type {Map<Element, {ready: boolean, installed: boolean, url: string, failure?: string}>} */
|
|
113
|
+
const known = new Map()
|
|
114
|
+
let source = ''
|
|
115
|
+
let stopped = false
|
|
116
|
+
let busy = false
|
|
117
|
+
let timer
|
|
118
|
+
/** Set when the last poll round threw, so the loop backs off once. */
|
|
119
|
+
let tickFailed = false
|
|
120
|
+
|
|
121
|
+
const scope = () => documentRef ?? (typeof document === 'undefined' ? undefined : document)
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The sidebar the human is looking at.
|
|
125
|
+
*
|
|
126
|
+
* Re-read on every tick rather than cached: switching chats swaps which
|
|
127
|
+
* session view is shown without this component remounting, and a stale
|
|
128
|
+
* element would let one chat's command run against another chat's page.
|
|
129
|
+
*/
|
|
130
|
+
const view = () => foregroundSessionView(scope())
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The conversation this panel is showing right now.
|
|
134
|
+
*
|
|
135
|
+
* The id and the guests are read from that same view, so the pair can never
|
|
136
|
+
* disagree: whatever gets reported belongs to the sidebar the agent will act
|
|
137
|
+
* on. Without a mounted sidebar the shell's current conversation is the only
|
|
138
|
+
* source left, and it is exactly where a cold start has to open the tab.
|
|
139
|
+
*/
|
|
140
|
+
const sessionId = () => {
|
|
141
|
+
const shown = view()
|
|
142
|
+
if (shown !== undefined) return viewSessionId(shown)
|
|
143
|
+
return currentSessionId?.()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The sidebar of one conversation, or the one on screen when none is named.
|
|
148
|
+
*
|
|
149
|
+
* A command names its conversation; the guest has to come from that same
|
|
150
|
+
* chat's sidebar even when the user has switched to another one meanwhile.
|
|
151
|
+
*/
|
|
152
|
+
const viewFor = (target) => {
|
|
153
|
+
if (target === undefined) return view()
|
|
154
|
+
return sessionViews(scope()).find((candidate) => viewSessionId(candidate) === target)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const findGuests = (target) => {
|
|
158
|
+
const shown = viewFor(target)
|
|
159
|
+
return shown === undefined ? [] : [...shown.querySelectorAll(GUEST_SELECTOR)]
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Whether the settings still allow opening the sidebar browser by ourselves.
|
|
164
|
+
*
|
|
165
|
+
* Read per attempt rather than cached: the setting lives on the host and can
|
|
166
|
+
* change without reloading the page. A failed read keeps the documented
|
|
167
|
+
* default (allowed), so a transient host hiccup does not disable the feature.
|
|
168
|
+
*/
|
|
169
|
+
const canAutoOpen = async () => {
|
|
170
|
+
try {
|
|
171
|
+
return (await bridge.config())?.autoOpenPanel !== false
|
|
172
|
+
} catch {
|
|
173
|
+
return true
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The guest the agent should act on: the one holding focus, else the last
|
|
179
|
+
* one that is actually laid out.
|
|
180
|
+
*
|
|
181
|
+
* Every visited tab keeps its body mounted (`keepMounted`), and DSH hides the
|
|
182
|
+
* inactive ones with the `hidden` attribute. Document order therefore says
|
|
183
|
+
* nothing about what the human sees: the last mounted guest is often a tab
|
|
184
|
+
* that is not on screen.
|
|
185
|
+
*/
|
|
186
|
+
const targetGuest = (target) => {
|
|
187
|
+
const guests = findGuests(target)
|
|
188
|
+
if (guests.length === 0) return undefined
|
|
189
|
+
const doc = scope()
|
|
190
|
+
const focused = guests.find((element) => element === doc?.activeElement)
|
|
191
|
+
if (focused !== undefined) return focused
|
|
192
|
+
const onScreen = guests.filter(isLaidOut)
|
|
193
|
+
if (onScreen.length > 0) return onScreen[onScreen.length - 1]
|
|
194
|
+
return guests[guests.length - 1]
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Wait for a usable guest to appear, tracking anything new on the way.
|
|
199
|
+
*
|
|
200
|
+
* @param {number} timeoutMs how long to wait
|
|
201
|
+
* @param {string} [target] conversation whose sidebar to search
|
|
202
|
+
* @returns {Promise<Element | undefined>} a ready guest, or undefined
|
|
203
|
+
*/
|
|
204
|
+
const waitForGuest = async (timeoutMs, target) => {
|
|
205
|
+
const deadline = Date.now() + timeoutMs
|
|
206
|
+
while (Date.now() < deadline) {
|
|
207
|
+
for (const candidate of findGuests(target)) {
|
|
208
|
+
if (!known.has(candidate)) track(candidate)
|
|
209
|
+
}
|
|
210
|
+
const ready = findGuests(target).find((candidate) => known.get(candidate)?.ready === true)
|
|
211
|
+
if (ready !== undefined) return ready
|
|
212
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
213
|
+
}
|
|
214
|
+
return undefined
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Track a guest element and notice each new document it loads. */
|
|
218
|
+
const track = (element) => {
|
|
219
|
+
const state = { ready: false, installed: false, url: '', failure: undefined }
|
|
220
|
+
known.set(element, state)
|
|
221
|
+
const refresh = () => {
|
|
222
|
+
try {
|
|
223
|
+
state.url = element.getURL()
|
|
224
|
+
state.ready = state.url !== ''
|
|
225
|
+
} catch {
|
|
226
|
+
/* the element is between documents */
|
|
227
|
+
}
|
|
228
|
+
// A new document means the injected surface is gone.
|
|
229
|
+
state.installed = false
|
|
230
|
+
state.failure = undefined
|
|
231
|
+
}
|
|
232
|
+
element.addEventListener('dom-ready', refresh)
|
|
233
|
+
element.addEventListener('did-navigate', refresh)
|
|
234
|
+
element.addEventListener('did-navigate-in-page', refresh)
|
|
235
|
+
element.addEventListener('did-start-navigation', () => { state.installed = false })
|
|
236
|
+
// A page that never loads otherwise reports "still loading, retry later"
|
|
237
|
+
// forever: the guest emits this instead of ever becoming ready.
|
|
238
|
+
element.addEventListener('did-fail-load', (event) => {
|
|
239
|
+
// ERR_ABORTED (-3) is what every ordinary redirect produces, so it is not
|
|
240
|
+
// a failure worth reporting.
|
|
241
|
+
if (event?.errorCode === -3) return
|
|
242
|
+
state.failure = String(event?.errorDescription ?? `加载失败(${event?.errorCode ?? '?'})`)
|
|
243
|
+
state.ready = false
|
|
244
|
+
state.installed = false
|
|
245
|
+
})
|
|
246
|
+
try {
|
|
247
|
+
if (typeof element.getURL === 'function') refresh()
|
|
248
|
+
} catch {
|
|
249
|
+
/* not attached yet */
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Inject the command surface, skipping the work when it is already there.
|
|
255
|
+
*
|
|
256
|
+
* The bundle is ~29 KB of source. After a same-document navigation the
|
|
257
|
+
* global survives, so running a one-line probe first avoids re-sending and
|
|
258
|
+
* re-evaluating the whole script on every SPA route change.
|
|
259
|
+
*/
|
|
260
|
+
const ensureInstalled = async (element) => {
|
|
261
|
+
const state = known.get(element)
|
|
262
|
+
if (state === undefined) return false
|
|
263
|
+
if (state.installed) return true
|
|
264
|
+
if (source === '') return false
|
|
265
|
+
let present = false
|
|
266
|
+
try {
|
|
267
|
+
present = await element.executeJavaScript(
|
|
268
|
+
"typeof window.__dshBrowserAgent === 'object' && typeof window.__dshBrowserAgent.state === 'function'",
|
|
269
|
+
false,
|
|
270
|
+
)
|
|
271
|
+
} catch {
|
|
272
|
+
present = false
|
|
273
|
+
}
|
|
274
|
+
if (present !== true) await element.executeJavaScript(source, false)
|
|
275
|
+
state.installed = true
|
|
276
|
+
return true
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const describeGuest = (element) => {
|
|
280
|
+
const state = known.get(element)
|
|
281
|
+
let url = state?.url ?? ''
|
|
282
|
+
let title = ''
|
|
283
|
+
let canGoBack = false
|
|
284
|
+
let canGoForward = false
|
|
285
|
+
let loading = false
|
|
286
|
+
try {
|
|
287
|
+
url = element.getURL() || url
|
|
288
|
+
title = element.getTitle()
|
|
289
|
+
canGoBack = element.canGoBack()
|
|
290
|
+
canGoForward = element.canGoForward()
|
|
291
|
+
loading = element.isLoading()
|
|
292
|
+
} catch {
|
|
293
|
+
/* the element is between documents */
|
|
294
|
+
}
|
|
295
|
+
return { id: url, url, title, canGoBack, canGoForward, loading }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Perform one command on the chosen guest. */
|
|
299
|
+
const dispatch = async (element, command, target) => {
|
|
300
|
+
const method = String(command.method)
|
|
301
|
+
|
|
302
|
+
// Tab management goes through the host: the sidebar owns the tab strip, and
|
|
303
|
+
// a guest element cannot create or close its siblings.
|
|
304
|
+
if (method === 'tabNew' || method === 'tabClose' || method === 'tabActivate') {
|
|
305
|
+
return bridge.tabCommand(method, command.params ?? {})
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Browser-level commands drive the element, like the toolbar does.
|
|
309
|
+
if (method in GUEST_METHODS) {
|
|
310
|
+
element[GUEST_METHODS[method]]()
|
|
311
|
+
return describeGuest(element)
|
|
312
|
+
}
|
|
313
|
+
if (method === 'navigate') {
|
|
314
|
+
const url = normalizeAddress(command.params?.url)
|
|
315
|
+
if (url === undefined) throw new Error('地址为空')
|
|
316
|
+
await element.loadURL(url)
|
|
317
|
+
return describeGuest(element)
|
|
318
|
+
}
|
|
319
|
+
if (method === 'tabs') {
|
|
320
|
+
return { tabs: findGuests(target).map(describeGuest) }
|
|
321
|
+
}
|
|
322
|
+
if (method === 'state') {
|
|
323
|
+
const guest = describeGuest(element ?? targetGuest(target))
|
|
324
|
+
// Page state adds the viewport and scroll offset.
|
|
325
|
+
if (await ensureInstalled(element)) {
|
|
326
|
+
const page = await element.executeJavaScript(
|
|
327
|
+
`window.__dshBrowserAgent.state()`,
|
|
328
|
+
true,
|
|
329
|
+
)
|
|
330
|
+
return { ...guest, ...normalizeValue(page) }
|
|
331
|
+
}
|
|
332
|
+
return guest
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Everything else runs inside the page.
|
|
336
|
+
if (!(await ensureInstalled(element))) {
|
|
337
|
+
throw new Error('无法在页面里注入操作脚本(页面可能禁止脚本执行)')
|
|
338
|
+
}
|
|
339
|
+
const params = { ...(command.params ?? {}) }
|
|
340
|
+
const expression = `(async () => { ${source}; return await window.__dshBrowserAgent[${JSON.stringify(method)}](${JSON.stringify(params)}); })()`
|
|
341
|
+
return element.executeJavaScript(expression, true)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const tick = async () => {
|
|
345
|
+
if (stopped || busy) return
|
|
346
|
+
busy = true
|
|
347
|
+
try {
|
|
348
|
+
// Keep the guest map in step with the DOM. Every mounted conversation's
|
|
349
|
+
// sidebar counts, not just the visible one: a command can name a chat the
|
|
350
|
+
// user has just switched away from, and its guest still has to be tracked.
|
|
351
|
+
for (const shown of sessionViews(scope())) {
|
|
352
|
+
for (const element of shown.querySelectorAll(GUEST_SELECTOR)) {
|
|
353
|
+
if (!known.has(element)) track(element)
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
for (const element of [...known.keys()]) {
|
|
357
|
+
if (!element.isConnected) known.delete(element)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const command = await bridge.poll(panelId, sessionId())
|
|
361
|
+
if (command === null || command === undefined) return
|
|
362
|
+
|
|
363
|
+
// The conversation on screen *now*. The poll above may have been parked
|
|
364
|
+
// for up to 25 s, so the panel can still be registered against the chat
|
|
365
|
+
// the user was in when it started; a command handed over in that window
|
|
366
|
+
// would otherwise run against a page that is no longer visible.
|
|
367
|
+
const shown = sessionId()
|
|
368
|
+
if (command.sessionId !== undefined && shown !== undefined && command.sessionId !== shown) {
|
|
369
|
+
await bridge.settle(panelId, command.id, {
|
|
370
|
+
ok: false,
|
|
371
|
+
error: `这条命令属于另一个对话(${command.sessionId}),当前显示的是 ${shown};为避免操作到看不见的页面,已拒绝。请切回发起命令的对话后重试。`,
|
|
372
|
+
})
|
|
373
|
+
return
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// The sidebar that belongs to the command's own conversation. A command
|
|
377
|
+
// without one comes from an older caller and belongs to whatever is shown.
|
|
378
|
+
const target = command.sessionId ?? shown
|
|
379
|
+
|
|
380
|
+
// No page on screen: this is the cold-start case. Open the sidebar
|
|
381
|
+
// browser ourselves (it is the only place the page can render), give the
|
|
382
|
+
// guest a moment to mount, then run the command against it. The user
|
|
383
|
+
// never has to open a browser by hand, and nothing leaves DSH.
|
|
384
|
+
let element = targetGuest(target)
|
|
385
|
+
if (element === undefined) {
|
|
386
|
+
if (!(await canAutoOpen())) {
|
|
387
|
+
await bridge.settle(panelId, command.id, {
|
|
388
|
+
ok: false,
|
|
389
|
+
error:
|
|
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
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const state = known.get(element)
|
|
425
|
+
if (state !== undefined && state.failure !== undefined) {
|
|
426
|
+
// Say what actually went wrong instead of "still loading".
|
|
427
|
+
await bridge.settle(panelId, command.id, {
|
|
428
|
+
ok: false,
|
|
429
|
+
error: `页面加载失败:${state.failure}`,
|
|
430
|
+
})
|
|
431
|
+
return
|
|
432
|
+
}
|
|
433
|
+
if (state === undefined || !state.ready) {
|
|
434
|
+
await bridge.settle(panelId, command.id, { ok: false, error: '页面还在加载,请稍后重试' })
|
|
435
|
+
return
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
try {
|
|
439
|
+
const value = await dispatch(element, command, target)
|
|
440
|
+
await bridge.settle(panelId, command.id, { ok: true, value: normalizeValue(value) })
|
|
441
|
+
} catch (failure) {
|
|
442
|
+
await bridge.settle(panelId, command.id, {
|
|
443
|
+
ok: false,
|
|
444
|
+
error: String(failure?.message ?? failure),
|
|
445
|
+
})
|
|
446
|
+
}
|
|
447
|
+
} catch {
|
|
448
|
+
// Back off once so a persistently broken endpoint cannot spin.
|
|
449
|
+
tickFailed = true
|
|
450
|
+
} finally {
|
|
451
|
+
busy = false
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return {
|
|
456
|
+
async start() {
|
|
457
|
+
// The host may be restarting while the page reloads; keep asking for the
|
|
458
|
+
// injected script instead of leaving the panel dead until a manual reload.
|
|
459
|
+
while (!stopped) {
|
|
460
|
+
try {
|
|
461
|
+
source = await loadSource()
|
|
462
|
+
break
|
|
463
|
+
} catch {
|
|
464
|
+
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
if (stopped) return
|
|
468
|
+
// Long poll: one request stays open until there is work. The loop only
|
|
469
|
+
// sleeps after a failure, so a healthy idle panel makes no requests.
|
|
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()
|
|
482
|
+
},
|
|
483
|
+
stop() {
|
|
484
|
+
stopped = true
|
|
485
|
+
clearTimeout(timer)
|
|
486
|
+
known.clear()
|
|
487
|
+
},
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Every sidebar currently in the document.
|
|
493
|
+
*
|
|
494
|
+
* DSH marks each conversation's sidebar wrapper with
|
|
495
|
+
* `data-sidebar-right-session` — the same attribute the shipped sidebar uses to
|
|
496
|
+
* validate its own elements. It keeps one sidebar per conversation: the selected
|
|
497
|
+
* one plus every background chat that still holds `keepMounted` content, and
|
|
498
|
+
* the browser tab is one of those. They are ordered by session id, not by what
|
|
499
|
+
* is on screen.
|
|
500
|
+
*
|
|
501
|
+
* Never throws: this runs on every poll tick, and an unexpected DOM shape must
|
|
502
|
+
* degrade to "no sidebar" rather than break the loop.
|
|
503
|
+
*
|
|
504
|
+
* @param {Document | undefined} documentRef
|
|
505
|
+
* @returns {Element[]}
|
|
506
|
+
*/
|
|
507
|
+
function sessionViews(documentRef) {
|
|
508
|
+
if (documentRef === undefined || documentRef === null) return []
|
|
509
|
+
try {
|
|
510
|
+
if (typeof documentRef.querySelectorAll !== 'function') return []
|
|
511
|
+
return [...documentRef.querySelectorAll(SESSION_SELECTOR)]
|
|
512
|
+
} catch {
|
|
513
|
+
return []
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Which conversation one sidebar belongs to.
|
|
519
|
+
*
|
|
520
|
+
* @param {Element | undefined} view
|
|
521
|
+
* @returns {string | undefined}
|
|
522
|
+
*/
|
|
523
|
+
function viewSessionId(view) {
|
|
524
|
+
const id = view?.dataset?.sidebarRightSession
|
|
525
|
+
return typeof id === 'string' && id !== '' ? id : undefined
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* The sidebar the human is looking at.
|
|
530
|
+
*
|
|
531
|
+
* DSH keeps the background conversations' sidebars mounted so their pages stay
|
|
532
|
+
* alive, and marks those `hidden`. Reading the first
|
|
533
|
+
* `[data-sidebar-right-session]` instead picked the alphabetically first
|
|
534
|
+
* session, which is why the agent could drive a page from another chat.
|
|
535
|
+
*
|
|
536
|
+
* @param {Document | undefined} documentRef
|
|
537
|
+
* @returns {Element | undefined}
|
|
538
|
+
*/
|
|
539
|
+
function foregroundSessionView(documentRef) {
|
|
540
|
+
return sessionViews(documentRef).find((view) => {
|
|
541
|
+
if (typeof view.hasAttribute === 'function' && view.hasAttribute('hidden')) return false
|
|
542
|
+
return typeof view.closest !== 'function' || view.closest('[hidden]') === null
|
|
543
|
+
})
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Whether a guest has a box on screen.
|
|
548
|
+
*
|
|
549
|
+
* Inactive tabs stay mounted but hidden, so their guests keep existing while the
|
|
550
|
+
* human cannot see them. A laid-out box is the only reliable signal.
|
|
551
|
+
*
|
|
552
|
+
* @param {Element} element
|
|
553
|
+
*/
|
|
554
|
+
function isLaidOut(element) {
|
|
555
|
+
try {
|
|
556
|
+
if (typeof element.closest === 'function' && element.closest('[hidden]') !== null) return false
|
|
557
|
+
const rect = element.getBoundingClientRect()
|
|
558
|
+
return rect.width > 1 && rect.height > 1
|
|
559
|
+
} catch {
|
|
560
|
+
return false
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Ask the sidebar to show the browser tab in one specific conversation.
|
|
566
|
+
*
|
|
567
|
+
* `openTab` targets whichever Session is on screen; `openTabIn` targets the
|
|
568
|
+
* Session that asked. Using the latter is what keeps a browser in the chat
|
|
569
|
+
* that requested it when the user switches chats mid-request.
|
|
570
|
+
*
|
|
571
|
+
* @param {object} ctx cordis client context
|
|
572
|
+
* @param {string | undefined} sessionId conversation that asked
|
|
573
|
+
* @param {string} [url] address to open in the new tab
|
|
574
|
+
*/
|
|
575
|
+
async function openSidebarBrowser(ctx, sessionId, url) {
|
|
576
|
+
const service = ctx.reflect?.get?.('sidebarRight') ?? ctx.get?.('sidebarRight')
|
|
577
|
+
if (service === undefined || service === null) {
|
|
578
|
+
throw new Error('拿不到侧边栏服务:这个 DSH 版本可能没有内置浏览器')
|
|
579
|
+
}
|
|
580
|
+
const options = url === undefined ? {} : { params: { url } }
|
|
581
|
+
const canTarget = typeof service.openTabIn === 'function'
|
|
582
|
+
if (canTarget && sessionId !== undefined) {
|
|
583
|
+
service.openTabIn(sessionId, 'browser', options)
|
|
584
|
+
return { opened: true, sessionId }
|
|
585
|
+
}
|
|
586
|
+
if (typeof service.openTab === 'function') {
|
|
587
|
+
service.openTab('browser', options)
|
|
588
|
+
return { opened: true, sessionId: undefined }
|
|
589
|
+
}
|
|
590
|
+
throw new Error('侧边栏服务没有 openTab,无法自动打开浏览器')
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* The conversation the shell is showing.
|
|
595
|
+
*
|
|
596
|
+
* Only needed while no sidebar is mounted: the tab a cold start opens has to
|
|
597
|
+
* land in the chat on screen, and the DOM has no marker to read yet.
|
|
598
|
+
*
|
|
599
|
+
* @param {object} ctx cordis client context
|
|
600
|
+
* @returns {string | undefined}
|
|
601
|
+
*/
|
|
602
|
+
function currentSessionId(ctx) {
|
|
603
|
+
try {
|
|
604
|
+
const current = ctx.get?.('uiSession')?.adapter?.current
|
|
605
|
+
const key = current?.getSnapshot?.()?.key
|
|
606
|
+
return typeof key === 'string' && key !== '' ? key : undefined
|
|
607
|
+
} catch {
|
|
608
|
+
return undefined
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** Escape a value for an attribute selector (tab ids are plain, but be safe). */
|
|
613
|
+
function attributeValue(value) {
|
|
614
|
+
return String(value).replace(/["\\]/g, '\\$&')
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Ids of the tabs in the strip that holds `tabId`, in the order the human sees.
|
|
619
|
+
*
|
|
620
|
+
* The menu is opened from a chip in the tab strip; reading that strip is the
|
|
621
|
+
* only way to know what "to the right" means, because the sidebar keeps its
|
|
622
|
+
* tab order internal. Returns [] when the tab is not in a strip (floating pane).
|
|
623
|
+
*
|
|
624
|
+
* @param {Document | undefined} documentRef
|
|
625
|
+
* @param {string} tabId
|
|
626
|
+
* @returns {string[]}
|
|
627
|
+
*/
|
|
628
|
+
function tabStripIds(documentRef, tabId) {
|
|
629
|
+
if (documentRef === undefined || documentRef === null || tabId === undefined) return []
|
|
630
|
+
try {
|
|
631
|
+
if (typeof documentRef.querySelector !== 'function') return []
|
|
632
|
+
const chip = documentRef.querySelector(`[data-dockkit-tab="${attributeValue(tabId)}"]`)
|
|
633
|
+
const strip = chip?.closest?.('[data-dockkit-strip-tabs]')
|
|
634
|
+
if (strip === undefined || strip === null) return []
|
|
635
|
+
return [...strip.querySelectorAll('[data-dockkit-tab]')]
|
|
636
|
+
.map((element) => element.dataset?.dockkitTab)
|
|
637
|
+
.filter((id) => typeof id === 'string' && id !== '')
|
|
638
|
+
} catch {
|
|
639
|
+
return []
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Which tabs each menu action closes, for the tab the menu was opened on.
|
|
645
|
+
*
|
|
646
|
+
* "To the right" excludes the tab itself, which is the convention every editor
|
|
647
|
+
* follows; "all" includes it, and the sidebar refuses to close the last guide
|
|
648
|
+
* tab on its own.
|
|
649
|
+
*
|
|
650
|
+
* @param {string[]} ids tab ids in strip order
|
|
651
|
+
* @param {string} currentId
|
|
652
|
+
* @returns {{others: string[], all: string[], right: string[]}}
|
|
653
|
+
*/
|
|
654
|
+
function closingPlan(ids, currentId) {
|
|
655
|
+
const index = ids.indexOf(currentId)
|
|
656
|
+
return {
|
|
657
|
+
others: ids.filter((id) => id !== currentId),
|
|
658
|
+
all: [...ids],
|
|
659
|
+
right: index === -1 ? [] : ids.slice(index + 1),
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** Close one set of tabs through the sidebar controller, then hide the menu. */
|
|
664
|
+
function closeTabs(ctx, ids, dismiss) {
|
|
665
|
+
const service = ctx.reflect?.get?.('sidebarRight') ?? ctx.get?.('sidebarRight')
|
|
666
|
+
if (service === undefined || service === null || typeof service.close !== 'function') return
|
|
667
|
+
for (const id of ids) {
|
|
668
|
+
try {
|
|
669
|
+
service.close(id)
|
|
670
|
+
} catch {
|
|
671
|
+
/* the tab may already be gone; closing the rest still makes sense */
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
if (typeof dismiss === 'function') dismiss()
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** Styles that make our entries indistinguishable from the sidebar's own. */
|
|
678
|
+
const TAB_MENU_STYLE = `
|
|
679
|
+
.dsh-live-browser-menu-group{display:contents}
|
|
680
|
+
.dsh-live-browser-menu-item{padding:4px 7px;color:var(--dsw-alias-label-primary);font-size:calc(var(--dsh-content-font-size-secondary, 13px) - 1px);text-align:left;background:transparent;border:none;border-radius:var(--dsw-radius-md);cursor:pointer}
|
|
681
|
+
.dsh-live-browser-menu-item:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
|
|
682
|
+
.dsh-live-browser-menu-item:disabled{opacity:.45;cursor:default}
|
|
683
|
+
`
|
|
684
|
+
|
|
685
|
+
/** Marker on our group, so the two entry points never double up. */
|
|
686
|
+
const TAB_MENU_MARK = 'data-dsh-live-browser-menu'
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Add the three close actions to the tab menu that just opened.
|
|
690
|
+
*
|
|
691
|
+
* The shipped sidebar exposes a React slot for extra menu entries, but that
|
|
692
|
+
* registration has to win a race against the sidebar declaring the slot, and
|
|
693
|
+
* when it loses it silently does nothing. The menu itself is a stable portal
|
|
694
|
+
* (`[data-dockkit-tab-menu]`), so extending it here works on every build while
|
|
695
|
+
* still leaving DSH unpatched.
|
|
696
|
+
*
|
|
697
|
+
* @param {object} ctx cordis client context
|
|
698
|
+
* @param {Element | undefined} menu the open tab menu
|
|
699
|
+
* @param {string | undefined} tabId the tab whose menu this is
|
|
700
|
+
*/
|
|
701
|
+
function decorateTabMenu(ctx, menu, tabId) {
|
|
702
|
+
if (menu === undefined || menu === null || tabId === undefined) return
|
|
703
|
+
if (typeof menu.querySelector !== 'function') return
|
|
704
|
+
if (menu.querySelector(`[${TAB_MENU_MARK}]`) !== null) return
|
|
705
|
+
|
|
706
|
+
const doc = menu.ownerDocument ?? document
|
|
707
|
+
const plan = closingPlan(tabStripIds(doc, tabId), tabId)
|
|
708
|
+
const group = doc.createElement('div')
|
|
709
|
+
group.className = 'dsh-live-browser-menu-group'
|
|
710
|
+
group.setAttribute(TAB_MENU_MARK, '')
|
|
711
|
+
|
|
712
|
+
const entries = [
|
|
713
|
+
['关闭其他标签页', plan.others],
|
|
714
|
+
['关闭全部标签页', plan.all],
|
|
715
|
+
['关闭右侧所有标签页', plan.right],
|
|
716
|
+
]
|
|
717
|
+
for (const [label, targets] of entries) {
|
|
718
|
+
const button = doc.createElement('button')
|
|
719
|
+
button.type = 'button'
|
|
720
|
+
button.setAttribute('role', 'menuitem')
|
|
721
|
+
button.className = 'dsh-live-browser-menu-item'
|
|
722
|
+
button.textContent = label
|
|
723
|
+
button.disabled = targets.length === 0
|
|
724
|
+
button.addEventListener('click', (event) => {
|
|
725
|
+
event.stopPropagation()
|
|
726
|
+
if (targets.length === 0) return
|
|
727
|
+
closeTabs(ctx, targets, () => dismissTabMenu(menu))
|
|
728
|
+
})
|
|
729
|
+
group.appendChild(button)
|
|
730
|
+
}
|
|
731
|
+
menu.appendChild(group)
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** Close the menu the way an outside click does; its React state follows. */
|
|
735
|
+
function dismissTabMenu(menu) {
|
|
736
|
+
const doc = menu.ownerDocument ?? document
|
|
737
|
+
const view = doc.defaultView
|
|
738
|
+
try {
|
|
739
|
+
const Pointer = view?.PointerEvent ?? PointerEvent
|
|
740
|
+
doc.dispatchEvent(new Pointer('pointerdown', { bubbles: true }))
|
|
741
|
+
} catch {
|
|
742
|
+
try {
|
|
743
|
+
const Keyboard = view?.KeyboardEvent ?? KeyboardEvent
|
|
744
|
+
doc.dispatchEvent(new Keyboard('keydown', { key: 'Escape', bubbles: true }))
|
|
745
|
+
} catch {
|
|
746
|
+
/* no DOM events: the menu will close on the next outside click */
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Watch for tab context menus and decorate them.
|
|
753
|
+
*
|
|
754
|
+
* The menu is created in the same event as the right-click, so the tab id has
|
|
755
|
+
* to be captured there; a zero-delay timer is enough time for React to commit
|
|
756
|
+
* the portal.
|
|
757
|
+
*/
|
|
758
|
+
function installTabMenu(ctx) {
|
|
759
|
+
if (typeof document === 'undefined' || document === null) return undefined
|
|
760
|
+
// Tests and non-DOM builds hand us a document without a head; the browser
|
|
761
|
+
// tools must keep working there.
|
|
762
|
+
if (typeof document.addEventListener !== 'function' || document.head === undefined || document.head === null) {
|
|
763
|
+
return undefined
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
let style
|
|
767
|
+
try {
|
|
768
|
+
style = document.createElement('style')
|
|
769
|
+
style.dataset.plugin = 'dsh-live-browser:tab-menu'
|
|
770
|
+
style.textContent = TAB_MENU_STYLE
|
|
771
|
+
document.head.appendChild(style)
|
|
772
|
+
} catch {
|
|
773
|
+
style = undefined
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
let pending
|
|
777
|
+
const onContextMenu = (event) => {
|
|
778
|
+
const target = event.target instanceof Element ? event.target.closest('[data-dockkit-tab]') : null
|
|
779
|
+
const tabId = target?.dataset?.dockkitTab
|
|
780
|
+
if (tabId === undefined) return
|
|
781
|
+
pending = tabId
|
|
782
|
+
setTimeout(() => {
|
|
783
|
+
if (pending !== tabId) return
|
|
784
|
+
pending = undefined
|
|
785
|
+
decorateTabMenu(ctx, document.querySelector('[data-dockkit-tab-menu]'), tabId)
|
|
786
|
+
}, 0)
|
|
787
|
+
}
|
|
788
|
+
document.addEventListener('contextmenu', onContextMenu, true)
|
|
789
|
+
|
|
790
|
+
return () => {
|
|
791
|
+
document.removeEventListener('contextmenu', onContextMenu, true)
|
|
792
|
+
style?.remove()
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** Cordis plugin entry for the client half. */
|
|
797
|
+
function apply(ctx) {
|
|
798
|
+
const bridge = createRuntime()
|
|
799
|
+
|
|
800
|
+
ctx.effect(() => installTabMenu(ctx), 'dsh-live-browser: tab menu')
|
|
801
|
+
|
|
802
|
+
// The loop is page-scoped, not component-scoped: the sidebar may unmount and
|
|
803
|
+
// remount tabs without the page ever reloading.
|
|
804
|
+
ctx.effect(() => {
|
|
805
|
+
const driver = createDriver({
|
|
806
|
+
bridge,
|
|
807
|
+
loadSource: () => bridge.source(),
|
|
808
|
+
// Auto-open: a tool call from a cold start must bring the browser up by
|
|
809
|
+
// itself, inside the requesting conversation.
|
|
810
|
+
openPanel: (url, sessionId) => openSidebarBrowser(ctx, sessionId, url),
|
|
811
|
+
currentSessionId: () => currentSessionId(ctx),
|
|
812
|
+
})
|
|
813
|
+
void driver.start()
|
|
814
|
+
return () => driver.stop()
|
|
815
|
+
}, 'dsh-live-browser: agent loop')
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
if (typeof window !== 'undefined' && typeof window.__ModuleLoader__ === 'object') {
|
|
819
|
+
window.__ModuleLoader__.load({
|
|
820
|
+
id: 'dsh-live-browser',
|
|
821
|
+
factory: () => ({ apply }),
|
|
822
|
+
})
|
|
823
|
+
} else if (typeof module !== 'undefined' && module.exports) {
|
|
824
|
+
module.exports = {
|
|
825
|
+
normalizeAddress,
|
|
826
|
+
normalizeValue,
|
|
827
|
+
GUEST_SELECTOR,
|
|
828
|
+
GUEST_METHODS,
|
|
829
|
+
tabStripIds,
|
|
830
|
+
closingPlan,
|
|
831
|
+
closeTabs,
|
|
832
|
+
decorateTabMenu,
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
})()
|