dsh-browser-playwright-codex 0.6.6 → 0.7.1

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/lib/client.js ADDED
@@ -0,0 +1,604 @@
1
+ /**
2
+ * dsh-browser-toggle - Client half.
3
+ *
4
+ * Settings → Plugins card for the browser plugin. Manual __ModuleLoader__
5
+ * shim, same shape as the other dsh-* panels: zero build step, inline
6
+ * React.createElement. Talks to the host /api/browser-playwright/* routes via
7
+ * fetch (GET polling, POST with the x-dsh-plugin CSRF header).
8
+ *
9
+ * Two user settings, both effective without a restart:
10
+ * 1. availability — on/off, rendered with the same Switch the other panels use.
11
+ * 2. 页面识别方式 — a choice between two ways of reading a page. Multi-choice
12
+ * settings use radio rows here (same pattern as dsh-search-switch's
13
+ * 搜索提供方 card), not a switch: it reads as "pick one", not "turn on".
14
+ *
15
+ * 排版跟官方设置页同一套:--dsw-* 设计令牌 + client-ui-primitives 原子件,
16
+ * 不再自带 font-family、不再写死颜色 / 字号(浅色深色自动跟随)。
17
+ */
18
+ window.__ModuleLoader__.load({
19
+ id: 'dsh-browser-playwright-codex',
20
+ factory: (require) => {
21
+ const module = { exports: {} };
22
+ const exports = module.exports;
23
+ const React = require('react');
24
+ const h = React.createElement;
25
+ const { useEffect, useState, useRef } = React;
26
+
27
+ // ---- 官方排版套件:与设置页其它面板同一套令牌 / 原子件 ----
28
+ // 1) 不再覆盖 font-family:继承外壳的 --dsw-font-family
29
+ // (-apple-system / Segoe UI / PingFang SC / Microsoft YaHei ...)
30
+ // 2) 颜色一律走 --dsw-alias-*,浅色 / 深色主题自动跟随
31
+ // 3) 按钮 / 圆点 / 开关 / 标签直接用官方 @deepseek-ai/dsh-client-ui-primitives
32
+ // (静态基座模块,third-party client bundle 可直接 require;取不到时退回同令牌样式)
33
+ const UI = (() => {
34
+ try { return require('@deepseek-ai/dsh-client-ui-primitives') || {} } catch (e) { return {} }
35
+ })()
36
+
37
+ const T = {
38
+ primary: 'var(--dsw-alias-label-primary)',
39
+ secondary: 'var(--dsw-alias-label-secondary)',
40
+ tertiary: 'var(--dsw-alias-label-tertiary)',
41
+ border: 'var(--dsw-alias-border-l2)',
42
+ borderL3: 'var(--dsw-alias-border-l3)',
43
+ layer2: 'var(--dsw-alias-bg-layer-2)',
44
+ success: 'var(--dsw-alias-state-success-primary)',
45
+ warn: 'var(--dsw-alias-state-warn-primary)',
46
+ error: 'var(--dsw-alias-state-error-primary)',
47
+ }
48
+
49
+ const S = {
50
+ page: { display: 'flex', flexDirection: 'column', width: '100%' },
51
+ title: { margin: '0 0 6px', fontSize: 16, fontWeight: 500, lineHeight: '24px', color: T.primary },
52
+ intro: { margin: '0 0 16px', fontSize: 13, lineHeight: '20px', color: T.tertiary },
53
+ list: { display: 'flex', flexDirection: 'column', gap: 8 },
54
+ row: { display: 'flex', alignItems: 'center', gap: 12, padding: '12px 14px', border: '0.5px solid ' + T.border, borderRadius: 12 },
55
+ rowTitle: { fontSize: 14, fontWeight: 500, lineHeight: '22px', color: T.primary },
56
+ rowDesc: { fontSize: 12, lineHeight: '18px', color: T.secondary },
57
+ meta: { fontSize: 11, lineHeight: '16px', color: T.tertiary, marginTop: 2 },
58
+ group: { margin: '18px 0 8px', fontSize: 13, fontWeight: 600, lineHeight: '20px', color: T.primary },
59
+ groupCount: { fontWeight: 400, color: T.tertiary },
60
+ footer: { marginTop: 16, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' },
61
+ tip: { fontSize: 12, lineHeight: '18px', color: T.tertiary },
62
+ note: { margin: '6px 0 0', fontSize: 11, lineHeight: '16px', color: T.tertiary },
63
+ msg: { marginTop: 10, fontSize: 12, lineHeight: '18px', minHeight: 18, color: T.error },
64
+ }
65
+
66
+ function Btn({ danger, ...props }) {
67
+ const style = danger ? { color: T.error } : undefined
68
+ // 设置页自己的动作按钮是 outline + sm(实测「打开配置文件」就是这一套)——
69
+ // ghost 在这页会显得轻一档,跟旁边的官方按钮不是一路。
70
+ if (UI.Button) return h(UI.Button, { variant: 'outline', size: 'sm', ...props, style })
71
+ return h('button', {
72
+ type: 'button', ...props,
73
+ style: {
74
+ height: 28, padding: '0 10px', border: '0.5px solid ' + T.borderL3, borderRadius: 14,
75
+ background: 'transparent', font: 'inherit', fontSize: 12, lineHeight: '18px',
76
+ color: danger ? T.error : T.primary, cursor: props.disabled ? 'default' : 'pointer',
77
+ opacity: props.disabled ? 0.4 : 1,
78
+ },
79
+ })
80
+ }
81
+
82
+ function Dot({ state }) {
83
+ if (UI.StateDot) return h(UI.StateDot, { state })
84
+ const color = { done: T.success, warning: T.warn, error: T.error }[state] || T.tertiary
85
+ return h('span', { style: { width: 8, height: 8, borderRadius: '50%', flex: 'none', background: color } })
86
+ }
87
+
88
+ function Toggle({ checked, onChange, label, disabled }) {
89
+ if (UI.Switch) return h(UI.Switch, { checked, onChange, label, disabled })
90
+ return h('button', {
91
+ type: 'button', role: 'switch', 'aria-checked': checked, 'aria-label': label, disabled,
92
+ onClick: () => onChange(!checked),
93
+ style: { width: 36, height: 20, flex: 'none', padding: 2, border: 'none', borderRadius: 10, background: checked ? T.primary : T.borderL3, cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.5 : 1 },
94
+ }, h('span', { style: { display: 'block', width: 16, height: 16, borderRadius: '50%', background: 'var(--dsw-alias-label-primary-foreground)', transform: checked ? 'translateX(16px)' : 'none', transition: 'transform 120ms ease' } }))
95
+ }
96
+
97
+ /** 官方 Tag:像「已启用 / 已禁用」这类状态小标记(以前是行内一段灰字)。 */
98
+ function Tag(props) {
99
+ if (UI.Tag) return h(UI.Tag, { tone: props.tone || 'outline' }, props.children)
100
+ return h('span', { style: { fontSize: 11, lineHeight: '16px', color: T.tertiary, marginRight: 6 } }, props.children)
101
+ }
102
+
103
+ /** 官方 DisclosureRow:长说明默认折叠(首屏只留结论)。 */
104
+ function Notes(props) {
105
+ const [open, setOpen] = useState(false)
106
+ if (UI.DisclosureRow) {
107
+ return h(UI.DisclosureRow, {
108
+ title: props.title,
109
+ open: open,
110
+ expandable: true,
111
+ expandOnRowClick: true,
112
+ onToggle: () => setOpen(!open),
113
+ }, open ? props.children : null)
114
+ }
115
+ return h('div', null, props.children)
116
+ }
117
+
118
+ // 单选行:与 dsh-search-switch 的 RadioRow 同一套(role=radio / 选中描边加粗 /
119
+ // 圆点标记 / 键盘可操作),所以两张卡片放在一起看不出是两个人写的。
120
+ function RadioRow({ p, selected, disabled, onPick }) {
121
+ return h('div', {
122
+ role: 'radio',
123
+ 'aria-checked': selected,
124
+ 'aria-label': p.label,
125
+ tabIndex: disabled ? -1 : 0,
126
+ onClick: () => { if (!disabled) onPick(p.id) },
127
+ onKeyDown: (e) => {
128
+ if (disabled) return
129
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onPick(p.id) }
130
+ },
131
+ style: {
132
+ display: 'flex', alignItems: 'flex-start', gap: 12, padding: '12px 14px',
133
+ border: (selected ? '1px' : '0.5px') + ' solid ' + (selected ? T.primary : T.border),
134
+ borderRadius: 12,
135
+ cursor: disabled ? 'default' : 'pointer',
136
+ opacity: disabled && !selected ? 0.6 : 1,
137
+ },
138
+ }, [
139
+ h('span', {
140
+ key: 'mark',
141
+ style: {
142
+ width: 16, height: 16, flex: 'none', marginTop: 2, borderRadius: '50%',
143
+ border: '1.5px solid ' + (selected ? T.primary : T.borderL3),
144
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
145
+ },
146
+ }, selected ? h('span', { style: { width: 8, height: 8, borderRadius: '50%', background: T.primary } }) : null),
147
+ h('div', { key: 'text', style: { flex: 1, minWidth: 0 } }, [
148
+ h('div', { key: 'label', style: S.rowTitle }, p.label),
149
+ p.hint ? h('div', { key: 'hint', style: S.rowDesc }, p.hint) : null,
150
+ ]),
151
+ selected ? h(Dot, { key: 'dot', state: 'done' }) : null,
152
+ ])
153
+ }
154
+
155
+ const API = '/api/browser-playwright';
156
+ const HEADER = 'browser-toggle';
157
+
158
+ // 两种"看网页"的方式。写成用户能懂的话,不出现 legacy / aria / 引擎 这些词。
159
+ const ENGINE_OPTIONS = [
160
+ {
161
+ id: 'legacy',
162
+ label: '兼容模式',
163
+ hint: '插件自己去找页面上的按钮和输入框。用得最久、测得最多,遇到奇怪的网站最不容易出问题。',
164
+ },
165
+ {
166
+ id: 'aria',
167
+ label: '智能模式',
168
+ hint: '改用浏览器官方的"页面说明书"(和读屏软件看的是同一份)。菜单、弹窗、动态加载多的网站通常更准;万一读不出来,它会自动退回兼容模式。',
169
+ },
170
+ ]
171
+
172
+ // 两个"强力工具":默认关闭,用户想要才打开。写的都是人话,不出现 evaluate/cdp。
173
+ const GATE_ROWS = [
174
+ {
175
+ key: 'allowEvaluate',
176
+ label: '允许在页面里执行任意 JavaScript',
177
+ hint: '打开后 AI 能在任意网页里运行脚本:能读走页面上的任何内容,也能用你的登录状态去点、去填、去提交。真正需要时才打开。',
178
+ },
179
+ {
180
+ key: 'diagnostics',
181
+ label: '诊断工具:控制台消息与网络请求',
182
+ hint: '给排障用:能读出页面控制台的报错和最近的网络请求(URL、状态、失败原因)。平时用不到;遇到"点了没反应"或"背后的请求失败了"再打开。',
183
+ },
184
+ {
185
+ key: 'allowCdp',
186
+ label: '允许原始 CDP 调试协议',
187
+ hint: '给排查疑难页面用:只放行"查看类"和"模拟输入类"命令,读取 Cookie/存储、改网络、动安全设置这类命令一律被拦住。',
188
+ },
189
+ ]
190
+
191
+ async function api(path, method, body) {
192
+ // The header goes on reads too: the host now requires it for GET as well, so a GET
193
+ // without it is a 403 (2026-10-16 the CSRF check moved from POST-only to every route).
194
+ const opts = { method, headers: { 'x-dsh-plugin': HEADER } };
195
+ if (method === 'POST') {
196
+ opts.headers['Content-Type'] = 'application/json';
197
+ if (body !== undefined)
198
+ opts.body = JSON.stringify(body);
199
+ }
200
+ else {
201
+ opts.headers['Cache-Control'] = 'no-store';
202
+ }
203
+ const res = await fetch(API + path, opts);
204
+ return res.json();
205
+ }
206
+
207
+ // Which domains the user chose not to see. A view preference only: it lives in this
208
+ // browser's localStorage, never in the deployment's files and never on the host.
209
+ const HIDDEN_KEY = 'dsh-browser-toggle.hidden-logins';
210
+ function readHiddenDomains() {
211
+ try {
212
+ const raw = window.localStorage.getItem(HIDDEN_KEY);
213
+ const parsed = raw === null ? [] : JSON.parse(raw);
214
+ return Array.isArray(parsed) ? parsed.filter((x) => typeof x === 'string') : [];
215
+ }
216
+ catch {
217
+ return [];
218
+ }
219
+ }
220
+ function writeHiddenDomains(list) {
221
+ try {
222
+ window.localStorage.setItem(HIDDEN_KEY, JSON.stringify(list));
223
+ }
224
+ catch {
225
+ // private mode / quota: hiding a row is cosmetic, losing the preference is fine
226
+ }
227
+ }
228
+ function BrowserPanel() {
229
+ const [enabled, setEnabled] = useState(null);
230
+ const [engine, setEngine] = useState(null);
231
+ const [engineUserChosen, setEngineUserChosen] = useState(false);
232
+ const [gates, setGates] = useState({ allowEvaluate: null, allowCdp: null, diagnostics: null });
233
+ const [busy, setBusy] = useState(false);
234
+ const [msg, setMsg] = useState('');
235
+ const [logins, setLogins] = useState(null);
236
+ const [showAll, setShowAll] = useState(false);
237
+ const [openGroups, setOpenGroups] = useState([]);
238
+ const [hiddenDomains, setHiddenDomains] = useState(readHiddenDomains());
239
+ const [loginsOpen, setLoginsOpen] = useState(false);
240
+ const mountedRef = useRef(true);
241
+
242
+ const refresh = async () => {
243
+ try {
244
+ const s = await api('/state', 'GET');
245
+ if (!mountedRef.current) return;
246
+ if (typeof s.enabled === 'boolean')
247
+ setEnabled(s.enabled);
248
+ if (typeof s.engine === 'string')
249
+ setEngine(s.engine);
250
+ setEngineUserChosen(s.engineUserChosen === true);
251
+ setGates({
252
+ allowEvaluate: typeof s.allowEvaluate === 'boolean' ? s.allowEvaluate : null,
253
+ allowCdp: typeof s.allowCdp === 'boolean' ? s.allowCdp : null,
254
+ diagnostics: typeof s.diagnostics === 'boolean' ? s.diagnostics : null,
255
+ });
256
+ }
257
+ catch (error) {
258
+ if (mountedRef.current)
259
+ setMsg('状态获取失败:' + (error?.message ?? String(error)));
260
+ }
261
+ };
262
+
263
+ const refreshLogins = async () => {
264
+ try {
265
+ const r = await api('/logins', 'GET');
266
+ if (mountedRef.current)
267
+ setLogins(r && typeof r === 'object' ? (r.logins ?? null) : null);
268
+ }
269
+ catch {
270
+ if (mountedRef.current)
271
+ setLogins(null);
272
+ }
273
+ };
274
+
275
+ useEffect(() => {
276
+ mountedRef.current = true;
277
+ refresh();
278
+ refreshLogins();
279
+ const timer = setInterval(refresh, 5000);
280
+ // Cookies change far more slowly than switches; polling this less often keeps the
281
+ // settings page from doing pointless work.
282
+ const loginsTimer = setInterval(refreshLogins, 15000);
283
+ return () => {
284
+ mountedRef.current = false;
285
+ clearInterval(timer);
286
+ clearInterval(loginsTimer);
287
+ };
288
+ }, []);
289
+
290
+ const applyEnabled = async (next) => {
291
+ if (enabled === null || busy || next === enabled)
292
+ return;
293
+ setBusy(true);
294
+ setMsg('');
295
+ try {
296
+ const r = await api('/toggle', 'POST', { enabled: next });
297
+ if (typeof r.enabled === 'boolean')
298
+ setEnabled(r.enabled);
299
+ setMsg(r.changed
300
+ ? (next ? '已启用:AI 可以替你操作网页。' : '已禁用:AI 不再操作网页,已打开的窗口会自动关闭(登录状态保留)。')
301
+ : '状态未变化');
302
+ }
303
+ catch (error) {
304
+ setMsg('切换失败:' + (error?.message ?? String(error)));
305
+ }
306
+ finally {
307
+ setBusy(false);
308
+ }
309
+ };
310
+
311
+ const chooseEngine = async (next) => {
312
+ if (engine === null || busy || next === engine)
313
+ return;
314
+ setBusy(true);
315
+ setMsg('');
316
+ try {
317
+ const r = await api('/engine', 'POST', { engine: next });
318
+ if (typeof r.engine === 'string')
319
+ setEngine(r.engine);
320
+ const picked = ENGINE_OPTIONS.find((o) => o.id === r.engine) || {};
321
+ setEngineUserChosen(true);
322
+ setMsg(r.changed
323
+ ? '已切到「' + (picked.label || r.engine) + '」,下一步操作就生效,不用重启。'
324
+ : '状态未变化');
325
+ }
326
+ catch (error) {
327
+ setMsg('切换失败:' + (error?.message ?? String(error)));
328
+ }
329
+ finally {
330
+ setBusy(false);
331
+ }
332
+ };
333
+
334
+ const applyGates = async (key, next) => {
335
+ if (gates[key] === null || busy || next === gates[key])
336
+ return;
337
+ setBusy(true);
338
+ setMsg('');
339
+ // Send both: the host writes one managed block for the pair.
340
+ const payload = {
341
+ allowEvaluate: gates.allowEvaluate === true,
342
+ allowCdp: gates.allowCdp === true,
343
+ diagnostics: gates.diagnostics === true,
344
+ };
345
+ payload[key] = next;
346
+ try {
347
+ const r = await api('/gates', 'POST', payload);
348
+ if (r && r.error)
349
+ throw new Error(r.error.message || r.error.code || 'unknown');
350
+ setGates({
351
+ allowEvaluate: r.allowEvaluate === true,
352
+ allowCdp: r.allowCdp === true,
353
+ diagnostics: r.diagnostics === true,
354
+ });
355
+ setMsg(r.changed
356
+ ? (next
357
+ ? '已打开:' + ((GATE_ROWS.find((row) => row.key === key) || {}).label || key) + '。工具会在热重载后出现(约 1-2 秒);若一直没出现,重启一次 DSH。'
358
+ : '已关闭。工具会在热重载后消失;若还在,重启一次 DSH。')
359
+ : '状态未变化');
360
+ }
361
+ catch (error) {
362
+ setMsg('切换失败:' + (error?.message ?? String(error)));
363
+ }
364
+ finally {
365
+ setBusy(false);
366
+ }
367
+ };
368
+
369
+ const state = enabled === null ? 'ongoing' : (enabled ? 'done' : 'idle');
370
+ const gateState = (key) => (gates[key] === null ? 'ongoing' : (gates[key] ? 'done' : 'idle'));
371
+ const gatesOn = GATE_ROWS.filter((row) => gates[row.key] === true).length;
372
+ const statusLabel = enabled === null ? '读取中…' : (enabled ? '已启用' : '已禁用');
373
+ const current = ENGINE_OPTIONS.find((o) => o.id === engine) || null;
374
+
375
+ return h('div', { style: S.page }, [
376
+ h('h2', { key: 'title', style: S.title }, '浏览器'),
377
+ h('p', { key: 'intro', style: S.intro },
378
+ '让 AI 用你的登录状态替你上网办事(查后台、填表、抓列表)。这里的改动立刻生效,不需要重启。'),
379
+ h('div', { key: 'row', style: S.row }, [
380
+ h(Dot, { key: 'dot', state }),
381
+ h('div', { key: 'text', style: { flex: 1, minWidth: 0 } }, [
382
+ h('div', { key: 'label', style: S.rowTitle }, '允许 AI 操作网页'),
383
+ h('div', { key: 'desc', style: S.rowDesc }, [
384
+ h(Tag, { key: 'status' }, statusLabel),
385
+ ' 关闭后已打开的窗口会自动关闭,登录状态会保留。',
386
+ ]),
387
+ ]),
388
+ h(Toggle, {
389
+ key: 'toggle',
390
+ checked: enabled === true,
391
+ onChange: applyEnabled,
392
+ disabled: enabled === null || busy,
393
+ label: '允许 AI 操作网页',
394
+ }),
395
+ ]),
396
+ h('div', { key: 'group', style: S.group }, '页面识别方式'),
397
+ h(Notes, { key: 'groupNotes', title: '说明' },
398
+ h('p', { style: S.note },
399
+ '决定它怎么"看"一个网页:页面上有哪些能点的、能填的。两种都能正常用,选哪个看你常去的网站。')),
400
+ h('div', { key: 'engines', style: { ...S.list, marginTop: 8 } }, ENGINE_OPTIONS.map((o) =>
401
+ h(RadioRow, {
402
+ key: o.id,
403
+ p: o,
404
+ selected: engine === o.id,
405
+ disabled: engine === null || busy,
406
+ onPick: chooseEngine,
407
+ }))),
408
+ h('div', { key: 'footer', style: S.footer }, [
409
+ h(Btn, { key: 'refresh', onClick: refresh, disabled: busy }, '刷新状态'),
410
+ current
411
+ ? h('span', { key: 'cur', style: S.tip },
412
+ '当前:' + current.label + (engineUserChosen ? '(你选的)' : '(默认)'))
413
+ : null,
414
+ ]),
415
+ h('div', { key: 'gatesGroup', style: S.group }, '强力工具(默认关闭)'),
416
+ h(Notes, { key: 'gatesNotes', title: '说明' },
417
+ h('p', { style: S.note },
418
+ '这两个开关控制的是"能力",不是效果微调:打开"任意 JavaScript"就等于把页面里的东西都交给 AI。'
419
+ + '插件包里默认关闭 —— 因为包是发给每个安装者的,默认打开等于替所有人做了这个决定;'
420
+ + '这里打开的是你自己机器上的选择,记在你自己机器的配置层里。')),
421
+ h('div', { key: 'gates', style: { ...S.list, marginTop: 8 } }, GATE_ROWS.map((row) =>
422
+ h('div', { key: row.key, style: S.row }, [
423
+ h(Dot, { key: 'dot', state: gateState(row.key) }),
424
+ h('div', { key: 'text', style: { flex: 1, minWidth: 0 } }, [
425
+ h('div', { key: 'label', style: S.rowTitle }, row.label),
426
+ h('div', { key: 'desc', style: S.rowDesc }, row.hint),
427
+ ]),
428
+ h(Toggle, {
429
+ key: 'toggle',
430
+ checked: gates[row.key] === true,
431
+ onChange: (next) => applyGates(row.key, next),
432
+ disabled: gates[row.key] === null || busy,
433
+ label: row.label,
434
+ }),
435
+ ]))),
436
+ h('div', { key: 'gatesFooter', style: S.footer },
437
+ gates.allowEvaluate === null && gates.allowCdp === null
438
+ ? h('span', { key: 'unknown', style: S.tip }, '当前:读取不到(这个 profile 里的插件版本还没上报闸门状态)')
439
+ : h('span', { key: 'sum', style: S.tip }, '当前:' + (gatesOn === 0 ? '都关着' : gatesOn + ' 个开着'))),
440
+ h('div', {
441
+ key: 'loginsGroup',
442
+ style: { ...S.group, cursor: 'pointer', userSelect: 'none' },
443
+ onClick: () => setLoginsOpen((open) => !open),
444
+ }, '已登录的站点(只给你自己看)'
445
+ + (logins === null || logins.sites === undefined
446
+ ? ''
447
+ : ' · ' + logins.sites.length + ' 个域 · ' + logins.sites.reduce((acc, site) => acc + site.cookies, 0) + ' 个 cookie')
448
+ + (loginsOpen ? ' ▾ 点击收起' : ' ▸ 点击展开')),
449
+ loginsOpen ? h(Notes, { key: 'loginsNotes', title: '说明' },
450
+ h('p', { style: S.note },
451
+ '只列**这台浏览器真的访问过**的域(你在 DSH 里打开过的页面),第三方统计/广告域已排除 ——'
452
+ + '别人的 cookie 属于你当时浏览的那个站,不代表你登录了它。判据是 OWASP 的会话特征:'
453
+ + 'httpOnly 且(会话级 或 值像令牌),名字不作为依据。'
454
+ + '每一行的说明来自网页自己的标题,或一份离线对照表(认不出的就直接显示域名,不编)。'
455
+ + '这里不显示 cookie 的名字和值;名单只存在于本机内存、不写磁盘;也没有任何工具能让 AI 看到它。')) : null,
456
+ loginsOpen === false
457
+ ? null
458
+ : logins === null
459
+ ? h('div', { key: 'loginsUnknown', style: S.tip },
460
+ '读取不到:这个 profile 里的插件版本还没上报,或还没打开过浏览器。')
461
+ : (() => {
462
+ const pool = showAll ? logins.sites : logins.sites.filter((s) => s.likely);
463
+ const visible = pool.filter((s) => hiddenDomains.indexOf(s.domain) < 0);
464
+ const hiddenCount = pool.length - visible.length;
465
+ // 同一家公司的域折叠成一行:真实名单里京东系二十来个域各自占一行,小半屏都是它。
466
+ const byGroup = new Map();
467
+ const items = [];
468
+ for (const site of visible) {
469
+ const key = site.group ? site.group.key : null;
470
+ if (key === null) {
471
+ items.push({ kind: 'single', site });
472
+ continue;
473
+ }
474
+ let group = byGroup.get(key);
475
+ if (group === undefined) {
476
+ group = { kind: 'group', key, label: site.group.label, sites: [] };
477
+ byGroup.set(key, group);
478
+ items.push(group);
479
+ }
480
+ group.sites.push(site);
481
+ }
482
+ // 组里只剩一个域就没有折叠的意义,退回单行。
483
+ for (let i = items.length - 1; i >= 0; i -= 1) {
484
+ if (items[i].kind === 'group' && items[i].sites.length === 1)
485
+ items[i] = { kind: 'single', site: items[i].sites[0] };
486
+ }
487
+ const sum = (list, pick) => list.reduce((acc, s) => acc + pick(s), 0);
488
+ const rowFor = (site, indented) => h('div', {
489
+ key: site.domain,
490
+ style: indented ? { ...S.row, paddingLeft: 18 } : S.row,
491
+ }, [
492
+ h('div', { key: 'text', style: { flex: 1, minWidth: 0 } }, [
493
+ h('div', { key: 'label', style: S.rowTitle }, [
494
+ site.likely ? h(Tag, { key: 'tag' }, '推测登录') : null,
495
+ // 说明优先:光看域名认不出来是这个功能最初的痛点。
496
+ site.alias || site.title || site.domain,
497
+ ]),
498
+ h('div', { key: 'desc', style: S.rowDesc },
499
+ (site.alias ? site.domain + ' ' : '')
500
+ + (site.title && site.title !== site.alias ? '「' + site.title + '」 ' : '')
501
+ + site.cookies + ' 个 cookie(httpOnly ' + site.httpOnly
502
+ + ' / 会话 ' + site.sessionLike
503
+ + ' / 值像令牌 ' + site.tokenLike + ')'
504
+ + (site.signals.length > 0 ? ' ' + site.signals.join(' · ') : '')),
505
+ ]),
506
+ h(Btn, {
507
+ key: 'hide',
508
+ onClick: () => {
509
+ const next = hiddenDomains.concat([site.domain]);
510
+ setHiddenDomains(next);
511
+ writeHiddenDomains(next);
512
+ },
513
+ }, '隐藏'),
514
+ ]);
515
+ const renderItem = (item) => {
516
+ if (item.kind === 'single') return rowFor(item.site, false);
517
+ const open = openGroups.indexOf(item.key) >= 0;
518
+ const anyLikely = item.sites.some((s) => s.likely);
519
+ return h('div', { key: 'group-' + item.key }, [
520
+ h('div', { key: 'head', style: S.row }, [
521
+ h('div', { key: 'text', style: { flex: 1, minWidth: 0 } }, [
522
+ h('div', { key: 'label', style: S.rowTitle }, [
523
+ anyLikely ? h(Tag, { key: 'tag' }, '推测登录') : null,
524
+ item.label + '(' + item.sites.length + ' 个域)',
525
+ ]),
526
+ h('div', { key: 'desc', style: S.rowDesc },
527
+ sum(item.sites, (s) => s.cookies) + ' 个 cookie httpOnly '
528
+ + sum(item.sites, (s) => s.httpOnly) + ' 会话 '
529
+ + sum(item.sites, (s) => s.sessionLike) + ' 值像令牌 '
530
+ + sum(item.sites, (s) => s.tokenLike)),
531
+ ]),
532
+ h(Btn, {
533
+ key: 'toggle',
534
+ onClick: () => {
535
+ setOpenGroups(open
536
+ ? openGroups.filter((k) => k !== item.key)
537
+ : openGroups.concat([item.key]));
538
+ },
539
+ }, open ? '收起' : '展开'),
540
+ ]),
541
+ open
542
+ ? h('div', { key: 'members' }, item.sites.map((s) => rowFor(s, true)))
543
+ : null,
544
+ ]);
545
+ };
546
+ return h('div', { key: 'loginsBody' }, [
547
+ h('div', { key: 'list', style: { ...S.list, marginTop: 8 } },
548
+ visible.length === 0
549
+ ? h('div', { key: 'none', style: S.tip },
550
+ hiddenCount > 0
551
+ ? '这一页都被你隐藏了。'
552
+ : '还没发现登录过的站点(用这个浏览器登录过网站后,这里会出现)。')
553
+ : items.map(renderItem)),
554
+ h('div', { key: 'loginsFooter', style: S.footer }, [
555
+ h('span', { key: 'src', style: S.tip },
556
+ (logins.source === 'live' ? '实时(浏览器开着)' : '上次快照(浏览器当前没开)')
557
+ + ' ' + new Date(logins.capturedAt).toLocaleTimeString()
558
+ + ' 你在 DSH 里访问过 ' + logins.total + ' 个域,其中 '
559
+ + logins.likelyTotal + ' 个像登录态'
560
+ + (logins.thirdPartyTotal > 0
561
+ ? ';另有 ' + logins.thirdPartyTotal + ' 个第三方域(统计/广告)已排除'
562
+ : '')
563
+ + (logins.attribution === 'unfiltered'
564
+ ? '(这次没拿到浏览器打开的页面列表,未做第一方过滤:下面可能有第三方域)'
565
+ : '')),
566
+ h(Btn, {
567
+ key: 'all',
568
+ onClick: () => setShowAll(!showAll),
569
+ }, showAll
570
+ ? '只看像登录态的 ' + logins.likelyTotal + ' 个'
571
+ : '显示其余 ' + (logins.total - logins.likelyTotal) + ' 个'),
572
+
573
+ hiddenCount > 0
574
+ ? h(Btn, {
575
+ key: 'reset',
576
+ onClick: () => {
577
+ setHiddenDomains([]);
578
+ writeHiddenDomains([]);
579
+ },
580
+ }, '恢复显示 ' + hiddenCount + ' 个被隐藏的')
581
+ : null,
582
+ ]),
583
+ ]);
584
+ })(),
585
+ msg ? h('div', { key: 'msg', style: S.msg }, msg) : null,
586
+ ]);
587
+ }
588
+
589
+ function apply(ctx) {
590
+ ctx.effect(() =>
591
+ ctx.slots.inject('settings.section', () =>
592
+ ctx.slots.register(
593
+ { name: 'settings.section', id: 'browser-toggle', order: 106, label: () => '浏览器' },
594
+ BrowserPanel,
595
+ ),
596
+ ),
597
+ );
598
+ }
599
+
600
+ exports.apply = apply;
601
+ exports.inject = ['slots'];
602
+ return module.exports;
603
+ },
604
+ });
package/lib/contract.d.ts CHANGED
@@ -16,13 +16,13 @@ export type ToolSuffix = (typeof TOOL_SUFFIXES)[number];
16
16
  * Suffixes registered unconditionally. They are always in the system prompt,
17
17
  * so this list is also the resident schema cost of the plugin.
18
18
  */
19
- export declare const ALWAYS_ON_SUFFIXES: readonly ["navigate", "snapshot", "click", "dialog", "fill", "press", "scroll", "back", "forward", "wait", "tabs", "switch_tab", "open_tab", "close_tab", "screenshot", "console_messages", "network_requests", "close"];
19
+ export declare const ALWAYS_ON_SUFFIXES: readonly ["navigate", "snapshot", "click", "dialog", "fill", "press", "scroll", "back", "forward", "wait", "tabs", "switch_tab", "open_tab", "close_tab", "screenshot", "close"];
20
20
  /**
21
21
  * Suffixes registered only when their capability is enabled (or configured):
22
22
  * a tool the deployment disabled can only error, so shipping its schema would
23
23
  * spend prompt tokens on a capability that cannot run.
24
24
  */
25
- export declare const GATED_SUFFIXES: readonly ["extract", "evaluate", "cdp"];
25
+ export declare const GATED_SUFFIXES: readonly ["extract", "evaluate", "cdp", "console_messages", "network_requests"];
26
26
  /** Which gated capabilities are live for one configuration. */
27
27
  export interface ToolSurfaceConfig {
28
28
  /** allowEvaluate: registers browser_evaluate when true. */
@@ -31,6 +31,8 @@ export interface ToolSurfaceConfig {
31
31
  allowCdp: boolean;
32
32
  /** extract.provider and extract.model both set: registers browser_extract. */
33
33
  extractConfigured: boolean;
34
+ /** Diagnostics switch: registers browser_console_messages and browser_network_requests. */
35
+ diagnostics: boolean;
34
36
  /** Force every gated tool to register even when its capability is off. */
35
37
  registerDisabledTools: boolean;
36
38
  }
@@ -62,6 +64,7 @@ export declare const ERROR_CODES: {
62
64
  readonly PARAM_MISSING: "PARAM_MISSING";
63
65
  readonly ELEMENT_NOT_ACTIONABLE: "ELEMENT_NOT_ACTIONABLE";
64
66
  readonly VALUE_NOT_SET: "VALUE_NOT_SET";
67
+ readonly CREDENTIAL_FIELD: "CREDENTIAL_FIELD";
65
68
  readonly CLICK_TARGET_NOT_FOUND: "CLICK_TARGET_NOT_FOUND";
66
69
  readonly HOVER_TARGET_NOT_FOUND: "HOVER_TARGET_NOT_FOUND";
67
70
  readonly DIALOG_PENDING: "DIALOG_PENDING";