dsh-coding-sidebar 1.0.4 → 1.0.6
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-editor.js +191 -173
- package/lib/client-registry.js +640 -306
- package/lib/client-terminal.js +192 -174
- package/lib/client.js +637 -303
- package/lib/index.js +9 -0
- package/lib/types/client/LiveView.d.ts +1 -0
- package/lib/types/client/api.d.ts +8 -0
- package/lib/types/client/locales.d.ts +6 -0
- package/lib/types/wire.d.ts +1 -1
- package/package.json +1 -1
- package/src/client/BrowserView.tsx +46 -4
- package/src/client/LiveView.tsx +229 -0
- package/src/client/api.ts +3 -0
- package/src/client/locales.ts +12 -0
- package/src/client/sidebar.module.css +32 -0
- package/src/index.ts +10 -0
- package/src/wire.ts +1 -0
package/lib/index.js
CHANGED
|
@@ -3971,6 +3971,15 @@ function buildApi(ctx, ptyManager, agentPtyRegistry, resolved, terminalShell, ge
|
|
|
3971
3971
|
throw new SidebarError("settings-rejected", error instanceof Error ? error.message : String(error), 400);
|
|
3972
3972
|
}
|
|
3973
3973
|
},
|
|
3974
|
+
"cdp.targets": async () => {
|
|
3975
|
+
const res = await fetch("http://127.0.0.1:9223/json/list", { signal: AbortSignal.timeout(3e3) });
|
|
3976
|
+
if (!res.ok) throw new SidebarError("cdp-down", `browser host unreachable (${res.status})`, 502);
|
|
3977
|
+
return { targets: (await res.json()).filter((t) => t.type === "page").map((t) => ({
|
|
3978
|
+
id: t.id,
|
|
3979
|
+
url: t.url,
|
|
3980
|
+
title: t.title
|
|
3981
|
+
})) };
|
|
3982
|
+
},
|
|
3974
3983
|
"browser.probe": async (payload) => {
|
|
3975
3984
|
const raw = requireString(payload, "url");
|
|
3976
3985
|
let parsed;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function LiveView(): React.JSX.Element;
|
|
@@ -253,6 +253,14 @@ export declare const api: {
|
|
|
253
253
|
/** Probe a URL's response headers (the sidebar browser's embeddability
|
|
254
254
|
* check; see the host's browser.probe route). */
|
|
255
255
|
browserProbe: (url: string, signal?: AbortSignal) => Promise<BrowserProbeResult>;
|
|
256
|
+
/** Agent 浏览器宿主的 page target 列表(host 代理 CDP /json/list)。 */
|
|
257
|
+
cdpTargets: (signal?: AbortSignal) => Promise<{
|
|
258
|
+
targets: Array<{
|
|
259
|
+
id: string;
|
|
260
|
+
url: string;
|
|
261
|
+
title: string;
|
|
262
|
+
}>;
|
|
263
|
+
}>;
|
|
256
264
|
/** External open for the file tree's "open with" menu: reveal a path in
|
|
257
265
|
* the OS file manager, or hand a custom-scheme URL (vscode://, cursor://,
|
|
258
266
|
* zed://, custom editors) to its registered handler. The host launches
|
|
@@ -244,6 +244,12 @@ export declare const zh: {
|
|
|
244
244
|
browserBack: string;
|
|
245
245
|
browserForward: string;
|
|
246
246
|
browserStart: string;
|
|
247
|
+
browserLive: string;
|
|
248
|
+
browserLiveFree: string;
|
|
249
|
+
browserLiveConnecting: string;
|
|
250
|
+
browserLiveDown: string;
|
|
251
|
+
browserLiveTargetNone: string;
|
|
252
|
+
browserLiveFollowLatest: string;
|
|
247
253
|
browserBlockedScheme: string;
|
|
248
254
|
browserBlockedLoopback: string;
|
|
249
255
|
browserInvalid: string;
|
package/lib/types/wire.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { SidebarHttpRequest, SidebarHttpResponse } from './context-types.ts';
|
|
8
8
|
/** Machine-readable error codes of the sidebar API. */
|
|
9
|
-
export type SidebarErrorCode = 'bad-request' | 'not-found' | 'forbidden' | 'method-error' | 'too-large' | 'fs-error' | 'git-error' | 'pty-error' | 'pty-deps-missing' | 'job-error' | 'sidechat-error' | 'subagents-unavailable' | 'settings-rejected' | 'settings-conflict' | 'internal';
|
|
9
|
+
export type SidebarErrorCode = 'bad-request' | 'not-found' | 'forbidden' | 'method-error' | 'too-large' | 'fs-error' | 'git-error' | 'pty-error' | 'pty-deps-missing' | 'job-error' | 'cdp-down' | 'sidechat-error' | 'subagents-unavailable' | 'settings-rejected' | 'settings-conflict' | 'internal';
|
|
10
10
|
/** One API failure with its wire code and HTTP status. */
|
|
11
11
|
export declare class SidebarError extends Error {
|
|
12
12
|
readonly code: SidebarErrorCode;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-coding-sidebar",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes the betterSidebar service for other plugins to register sidebar tabs and file viewers. KCoder-maintained fork of DSH-better-sidebar 0.17.2 (bottom panel removed, upstream-decoupled release line).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* address-bar navigations (in-frame link clicks are cross-origin and
|
|
17
17
|
* invisible — a documented limitation).
|
|
18
18
|
*/
|
|
19
|
-
import { useEffect, useState } from 'react'
|
|
19
|
+
import { useEffect, useRef, useState } from 'react'
|
|
20
20
|
import {
|
|
21
21
|
IconChevronLeftOutline14,
|
|
22
22
|
IconChevronRightOutline14,
|
|
@@ -24,12 +24,13 @@ import {
|
|
|
24
24
|
IconRefreshOutline14,
|
|
25
25
|
IconWarningOutline16,
|
|
26
26
|
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
27
|
-
import { VscLinkExternal } from 'react-icons/vsc'
|
|
27
|
+
import { VscLinkExternal, VscRemoteExplorer } from 'react-icons/vsc'
|
|
28
28
|
import { api } from './api.ts'
|
|
29
29
|
import { embeddabilityOf, isAllowedLoopbackUrl, normalizeBrowserUrl } from './browser.ts'
|
|
30
30
|
import { patchTab } from './state.ts'
|
|
31
31
|
import { SandboxStatusBar } from './SandboxStatusBar.tsx'
|
|
32
32
|
import { t } from './locales.ts'
|
|
33
|
+
import { LiveView } from './LiveView.tsx'
|
|
33
34
|
import type { TabComponentProps } from './service.ts'
|
|
34
35
|
import css from './sidebar.module.css'
|
|
35
36
|
|
|
@@ -80,7 +81,7 @@ export function iframeSandboxFor(url: string | undefined, allowedLoopback: strin
|
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
export function BrowserView(props: TabComponentProps) {
|
|
83
|
-
const { store, tab } = props
|
|
84
|
+
const { store, tab, ctx } = props
|
|
84
85
|
// The current address (initialized from the persisted tab.path so a
|
|
85
86
|
// reload restores the visited page).
|
|
86
87
|
const [url, setUrl] = useState<string | undefined>(tab.path)
|
|
@@ -101,6 +102,9 @@ export function BrowserView(props: TabComponentProps) {
|
|
|
101
102
|
const [embedBlocked, setEmbedBlocked] = useState<string | null>(null)
|
|
102
103
|
/** The user asked to load the refused site anyway (keeps the plain iframe). */
|
|
103
104
|
const [forceEmbed, setForceEmbed] = useState(false)
|
|
105
|
+
/** Agent 实况模式(CDP screencast):开启后本 tab 只显示 agent 无头
|
|
106
|
+
* 浏览器的实况画面,地址栏/iframe 暂停。会话态开关,不持久化。 */
|
|
107
|
+
const [live, setLive] = useState(false)
|
|
104
108
|
|
|
105
109
|
// Probe every navigation (address bar, history, restored path): when the
|
|
106
110
|
// target forbids embedding, show the reason + open-in-browser instead of
|
|
@@ -117,6 +121,32 @@ export function BrowserView(props: TabComponentProps) {
|
|
|
117
121
|
return () => { cancelled = true }
|
|
118
122
|
}, [url])
|
|
119
123
|
|
|
124
|
+
// Agent 实况自动切换(2026-09-12):非实况态轮询浏览器宿主的 page
|
|
125
|
+
// target——出现 agent 页面(非 about: 空页)即自动开实况并把浏览器 tab
|
|
126
|
+
// 顶到前台。一次性:首次自动切换后不再抢控制权(用户手动关掉实况
|
|
127
|
+
// 也不会被再次强制打开)。
|
|
128
|
+
const autoRaised = useRef(false)
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
if (live || autoRaised.current) return
|
|
131
|
+
const timer = setInterval(async () => {
|
|
132
|
+
if (autoRaised.current) return
|
|
133
|
+
try {
|
|
134
|
+
const { targets: list } = await api.cdpTargets()
|
|
135
|
+
const agentPages = list.filter((t) => t.url !== '' && !t.url.startsWith('about:'))
|
|
136
|
+
const had = autoRaised.current
|
|
137
|
+
if (agentPages.length > 0 && !had) {
|
|
138
|
+
autoRaised.current = true
|
|
139
|
+
setLive(true)
|
|
140
|
+
try {
|
|
141
|
+
const better = (ctx as unknown as { betterSidebar?: { openTab?: (r: { type: string }) => unknown } }).betterSidebar
|
|
142
|
+
better?.openTab?.({ type: 'browser' })
|
|
143
|
+
} catch { /* 顶起失败:实况仍已在 tab 内容里生效 */ }
|
|
144
|
+
}
|
|
145
|
+
} catch { /* 宿主未起:静默等下次轮询 */ }
|
|
146
|
+
}, 3000)
|
|
147
|
+
return () => { clearInterval(timer) }
|
|
148
|
+
}, [live, ctx])
|
|
149
|
+
|
|
120
150
|
const persist = (nextUrl: string): void => {
|
|
121
151
|
let host = nextUrl
|
|
122
152
|
try { host = new URL(nextUrl).hostname } catch { /* keep the URL as title */ }
|
|
@@ -212,6 +242,16 @@ export function BrowserView(props: TabComponentProps) {
|
|
|
212
242
|
>
|
|
213
243
|
<IconLinkOutline14 />
|
|
214
244
|
</button>
|
|
245
|
+
<button
|
|
246
|
+
type="button"
|
|
247
|
+
className={css.iconButton}
|
|
248
|
+
aria-label={t('browserLive')}
|
|
249
|
+
title={t('browserLive')}
|
|
250
|
+
aria-pressed={live}
|
|
251
|
+
onClick={() => { setLive(value => !value) }}
|
|
252
|
+
>
|
|
253
|
+
<VscRemoteExplorer size={15} />
|
|
254
|
+
</button>
|
|
215
255
|
<button
|
|
216
256
|
type="button"
|
|
217
257
|
className={css.iconButton}
|
|
@@ -233,7 +273,9 @@ export function BrowserView(props: TabComponentProps) {
|
|
|
233
273
|
onUnlock={() => { setLocalUnlock(true) }}
|
|
234
274
|
onRestore={() => { setLocalUnlock(false) }}
|
|
235
275
|
/>
|
|
236
|
-
{
|
|
276
|
+
{live ? (
|
|
277
|
+
<LiveView />
|
|
278
|
+
) : url === undefined ? (
|
|
237
279
|
<div className={css.browserStart}>{t('browserStart')}</div>
|
|
238
280
|
) : embedBlocked !== null && !forceEmbed ? (
|
|
239
281
|
<BrowserEmbedBlocked
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent 浏览器实况(CDP screencast + 输入回传,2026-09-12 方案 2)。
|
|
3
|
+
*
|
|
4
|
+
* 数据面:KCoder 桌面端 browser-host 维护一个无头 Chromium(固定 CDP
|
|
5
|
+
* 转发地址 127.0.0.1:9223,与 playwright MCP `--cdp-endpoint` 共用)。
|
|
6
|
+
* 本组件作为第二个 CDP 客户端:
|
|
7
|
+
* - target 列表经宿主代理 `/sidebar/api`(CDP HTTP 无 CORS 头);
|
|
8
|
+
* - screencast/输入走 renderer 直连 `ws://127.0.0.1:9223/devtools/page/<id>`
|
|
9
|
+
* (WebSocket 不受 CORS 约束,宿主未设 CSP)。
|
|
10
|
+
*
|
|
11
|
+
* 连接模型:`attachedId`(用户显式选择)优先,否则自动跟随「最新创建的
|
|
12
|
+
* page target」;连接按 effectiveId 建立与重建,列表仅在内容变化时更新
|
|
13
|
+
* state(JSON 比对),避免轮询引起重连抖动。
|
|
14
|
+
*
|
|
15
|
+
* 交互:screencast 帧绘到 canvas;指针/滚轮按 canvas→viewport 比例回传
|
|
16
|
+
* (Input.dispatchMouseEvent);可打印字符经 Input.insertText,控制键走
|
|
17
|
+
* rawKeyDown/keyUp。视口统一 1280×800(Emulation.setDeviceMetricsOverride),
|
|
18
|
+
* canvas 等比缩放显示。
|
|
19
|
+
*/
|
|
20
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
21
|
+
import { api } from './api.ts'
|
|
22
|
+
import { t } from './locales.ts'
|
|
23
|
+
|
|
24
|
+
/** 与 KCoder desktop/main/browser-host.ts 的 BROWSER_HOST_PORT 一致。 */
|
|
25
|
+
const CDP_WS = 'ws://127.0.0.1:9223'
|
|
26
|
+
/** 实况视口(CDP 侧强制,帧与输入坐标以此为基准)。 */
|
|
27
|
+
const VIEW_W = 1280
|
|
28
|
+
const VIEW_H = 800
|
|
29
|
+
|
|
30
|
+
type Target = { id: string; url: string; title: string }
|
|
31
|
+
type Status = 'connecting' | 'live' | 'down' | 'idle'
|
|
32
|
+
|
|
33
|
+
export function LiveView(): React.JSX.Element {
|
|
34
|
+
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
|
35
|
+
const wsRef = useRef<WebSocket | null>(null)
|
|
36
|
+
const [status, setStatus] = useState<Status>('connecting')
|
|
37
|
+
const [targets, setTargets] = useState<Target[]>([])
|
|
38
|
+
const [hostDown, setHostDown] = useState(false)
|
|
39
|
+
/** 用户显式钉住的 target(null = 自动跟随最新)。 */
|
|
40
|
+
const [pinnedId, setPinnedId] = useState<string | null>(null)
|
|
41
|
+
/** 连接代数:手动重连/换 target 时 +1。 */
|
|
42
|
+
const [epoch, setEpoch] = useState(0)
|
|
43
|
+
|
|
44
|
+
/* target 列表轮询(3s):内容变化才更新 state,避免无谓重连 */
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
let alive = true
|
|
47
|
+
const poll = async (): Promise<void> => {
|
|
48
|
+
try {
|
|
49
|
+
const { targets: list } = await api.cdpTargets()
|
|
50
|
+
if (!alive) return
|
|
51
|
+
setHostDown(false)
|
|
52
|
+
setTargets((prev) => (JSON.stringify(prev) === JSON.stringify(list) ? prev : list))
|
|
53
|
+
} catch {
|
|
54
|
+
if (alive) setHostDown(true)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
void poll()
|
|
58
|
+
const timer = setInterval(() => { void poll() }, 3000)
|
|
59
|
+
return () => { alive = false; clearInterval(timer) }
|
|
60
|
+
}, [])
|
|
61
|
+
|
|
62
|
+
/** 实际附加的 target:id 钉住且存活 → 它;否则自动跟随最新页。 */
|
|
63
|
+
const effectiveId = useMemo<string | null>(() => {
|
|
64
|
+
if (pinnedId !== null && targets.some((tg) => tg.id === pinnedId)) return pinnedId
|
|
65
|
+
return targets.length > 0 ? targets[targets.length - 1]!.id : null
|
|
66
|
+
}, [pinnedId, targets])
|
|
67
|
+
|
|
68
|
+
/* 连接与 screencast(按 effectiveId 建立,变化即重连) */
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (effectiveId === null) { setStatus('idle'); return }
|
|
71
|
+
let cancelled = false
|
|
72
|
+
let socket: WebSocket | null = null
|
|
73
|
+
let msgId = 0
|
|
74
|
+
|
|
75
|
+
setStatus('connecting')
|
|
76
|
+
socket = new WebSocket(`${CDP_WS}/devtools/page/${effectiveId}`)
|
|
77
|
+
wsRef.current = socket
|
|
78
|
+
|
|
79
|
+
const send = (method: string, params?: Record<string, unknown>): void => {
|
|
80
|
+
if (socket !== null && socket.readyState === WebSocket.OPEN) {
|
|
81
|
+
socket.send(JSON.stringify({ id: ++msgId, method, params: params ?? {} }))
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const draw = (data: string): void => {
|
|
86
|
+
const el = canvasRef.current
|
|
87
|
+
if (el === null || cancelled) return
|
|
88
|
+
const img = new Image()
|
|
89
|
+
img.onload = () => {
|
|
90
|
+
if (cancelled) return
|
|
91
|
+
el.width = img.naturalWidth
|
|
92
|
+
el.height = img.naturalHeight
|
|
93
|
+
el.getContext('2d')?.drawImage(img, 0, 0)
|
|
94
|
+
}
|
|
95
|
+
img.src = `data:image/jpeg;base64,${data}`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
socket.onopen = () => {
|
|
99
|
+
if (cancelled) return
|
|
100
|
+
setStatus('live')
|
|
101
|
+
send('Page.enable')
|
|
102
|
+
send('Runtime.enable')
|
|
103
|
+
send('Emulation.setDeviceMetricsOverride', {
|
|
104
|
+
width: VIEW_W, height: VIEW_H, deviceScaleFactor: 1, mobile: false,
|
|
105
|
+
})
|
|
106
|
+
send('Page.startScreencast', {
|
|
107
|
+
format: 'jpeg', quality: 62, maxWidth: VIEW_W, maxHeight: VIEW_H, everyNthFrame: 1,
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
socket.onmessage = (event) => {
|
|
111
|
+
const msg = JSON.parse(event.data as string) as {
|
|
112
|
+
id?: number
|
|
113
|
+
method?: string
|
|
114
|
+
params?: { data?: string; screencastFrameId?: string }
|
|
115
|
+
}
|
|
116
|
+
if (msg.method === 'Page.screencastFrame') {
|
|
117
|
+
const p = msg.params ?? {}
|
|
118
|
+
if (typeof p.data === 'string') draw(p.data)
|
|
119
|
+
if (typeof p.screencastFrameId === 'string') {
|
|
120
|
+
send('Page.screencastFrameAck', { screencastFrameId: p.screencastFrameId })
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
socket.onclose = () => { if (!cancelled) setStatus('connecting') }
|
|
125
|
+
socket.onerror = () => { if (!cancelled) setStatus('down') }
|
|
126
|
+
|
|
127
|
+
return () => {
|
|
128
|
+
cancelled = true
|
|
129
|
+
socket?.close()
|
|
130
|
+
wsRef.current = null
|
|
131
|
+
}
|
|
132
|
+
}, [effectiveId])
|
|
133
|
+
|
|
134
|
+
/* 输入回传:坐标按 canvas 显示比例换算到 1280×800 视口 */
|
|
135
|
+
const sendInput = (method: string, params: Record<string, unknown>): void => {
|
|
136
|
+
wsRef.current?.send(JSON.stringify({ id: Math.floor(Math.random() * 1e9), method, params }))
|
|
137
|
+
}
|
|
138
|
+
const scaleOf = (el: HTMLCanvasElement): number => {
|
|
139
|
+
const shown = el.getBoundingClientRect().width
|
|
140
|
+
return shown > 0 ? VIEW_W / shown : 1
|
|
141
|
+
}
|
|
142
|
+
const posOf = (e: { clientX: number; clientY: number; currentTarget: HTMLCanvasElement }): { x: number; y: number } => {
|
|
143
|
+
const rect = e.currentTarget.getBoundingClientRect()
|
|
144
|
+
const k = scaleOf(e.currentTarget)
|
|
145
|
+
return { x: (e.clientX - rect.left) * k, y: (e.clientY - rect.top) * k }
|
|
146
|
+
}
|
|
147
|
+
const posOfWheel = (e: React.WheelEvent<HTMLCanvasElement>): { x: number; y: number } => {
|
|
148
|
+
const rect = e.currentTarget.getBoundingClientRect()
|
|
149
|
+
const k = scaleOf(e.currentTarget)
|
|
150
|
+
return { x: (e.clientX - rect.left) * k, y: (e.clientY - rect.top) * k }
|
|
151
|
+
}
|
|
152
|
+
const onPointerDown = (e: React.PointerEvent<HTMLCanvasElement>): void => {
|
|
153
|
+
const p = posOf(e)
|
|
154
|
+
sendInput('Input.dispatchMouseEvent', { type: 'mousePressed', x: p.x, y: p.y, button: 'left', clickCount: 1 })
|
|
155
|
+
}
|
|
156
|
+
const onPointerUp = (e: React.PointerEvent<HTMLCanvasElement>): void => {
|
|
157
|
+
const p = posOf(e)
|
|
158
|
+
sendInput('Input.dispatchMouseEvent', { type: 'mouseReleased', x: p.x, y: p.y, button: 'left', clickCount: 1 })
|
|
159
|
+
}
|
|
160
|
+
const onPointerMove = (e: React.PointerEvent<HTMLCanvasElement>): void => {
|
|
161
|
+
if (e.buttons === 0) return
|
|
162
|
+
const p = posOf(e)
|
|
163
|
+
sendInput('Input.dispatchMouseEvent', { type: 'mouseMoved', x: p.x, y: p.y, button: 'left' })
|
|
164
|
+
}
|
|
165
|
+
const onWheel = (e: React.WheelEvent<HTMLCanvasElement>): void => {
|
|
166
|
+
const p = posOf(e)
|
|
167
|
+
sendInput('Input.dispatchMouseEvent', { type: 'mouseWheel', x: p.x, y: p.y, deltaX: e.deltaX, deltaY: e.deltaY })
|
|
168
|
+
}
|
|
169
|
+
const onKeyDown = (e: React.KeyboardEvent<HTMLCanvasElement>): void => {
|
|
170
|
+
const vk: Record<string, number> = {
|
|
171
|
+
Enter: 13, Backspace: 8, Tab: 9, Escape: 27,
|
|
172
|
+
ArrowUp: 38, ArrowDown: 40, ArrowLeft: 37, ArrowRight: 39,
|
|
173
|
+
}
|
|
174
|
+
const code = vk[e.key]
|
|
175
|
+
if (code !== undefined) {
|
|
176
|
+
sendInput('Input.dispatchKeyEvent', { type: 'rawKeyDown', windowsVirtualKeyCode: code, key: e.key })
|
|
177
|
+
sendInput('Input.dispatchKeyEvent', { type: 'keyUp', windowsVirtualKeyCode: code, key: e.key })
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
if (e.key.length === 1) sendInput('Input.insertText', { text: e.key })
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const statusText = hostDown || status === 'down'
|
|
184
|
+
? t('browserLiveDown')
|
|
185
|
+
: status === 'live'
|
|
186
|
+
? (targets.find((tg) => tg.id === effectiveId)?.url ?? t('browserLiveTargetNone'))
|
|
187
|
+
: effectiveId === null
|
|
188
|
+
? t('browserLiveTargetNone')
|
|
189
|
+
: t('browserLiveConnecting')
|
|
190
|
+
|
|
191
|
+
return (
|
|
192
|
+
<div className="browserLive">
|
|
193
|
+
<div className="browserLiveStatus">
|
|
194
|
+
<button type="button" onClick={() => { setEpoch(e => e + 1) }}>{t('refresh')}</button>
|
|
195
|
+
<select
|
|
196
|
+
className="browserLiveTarget"
|
|
197
|
+
value={effectiveId ?? ''}
|
|
198
|
+
onChange={(e) => { setPinnedId(e.target.value === '' ? null : e.target.value) }}
|
|
199
|
+
>
|
|
200
|
+
<option value="">{t('browserLiveFollowLatest')}</option>
|
|
201
|
+
{targets.map((tg) => (
|
|
202
|
+
<option key={tg.id} value={tg.id}>{tg.title || tg.url}</option>
|
|
203
|
+
))}
|
|
204
|
+
</select>
|
|
205
|
+
<span>{statusText}</span>
|
|
206
|
+
</div>
|
|
207
|
+
{effectiveId !== null && status !== 'idle'
|
|
208
|
+
? (
|
|
209
|
+
<canvas
|
|
210
|
+
ref={canvasRef}
|
|
211
|
+
className="browserLiveCanvas"
|
|
212
|
+
width={VIEW_W}
|
|
213
|
+
height={VIEW_H}
|
|
214
|
+
tabIndex={0}
|
|
215
|
+
onPointerDown={onPointerDown}
|
|
216
|
+
onPointerUp={onPointerUp}
|
|
217
|
+
onPointerMove={onPointerMove}
|
|
218
|
+
onWheel={onWheel}
|
|
219
|
+
onKeyDown={onKeyDown}
|
|
220
|
+
/>
|
|
221
|
+
)
|
|
222
|
+
: (
|
|
223
|
+
<div className="browserStart">
|
|
224
|
+
{hostDown ? t('browserLiveDown') : t('browserLiveTargetNone')}
|
|
225
|
+
</div>
|
|
226
|
+
)}
|
|
227
|
+
</div>
|
|
228
|
+
)
|
|
229
|
+
}
|
package/src/client/api.ts
CHANGED
|
@@ -316,6 +316,9 @@ export const api = {
|
|
|
316
316
|
* check; see the host's browser.probe route). */
|
|
317
317
|
browserProbe: (url: string, signal?: AbortSignal) =>
|
|
318
318
|
call<BrowserProbeResult>('browser.probe', { url }, signal),
|
|
319
|
+
/** Agent 浏览器宿主的 page target 列表(host 代理 CDP /json/list)。 */
|
|
320
|
+
cdpTargets: (signal?: AbortSignal) =>
|
|
321
|
+
call<{ targets: Array<{ id: string; url: string; title: string }> }>('cdp.targets', {}, signal),
|
|
319
322
|
/** External open for the file tree's "open with" menu: reveal a path in
|
|
320
323
|
* the OS file manager, or hand a custom-scheme URL (vscode://, cursor://,
|
|
321
324
|
* zed://, custom editors) to its registered handler. The host launches
|
package/src/client/locales.ts
CHANGED
|
@@ -245,6 +245,12 @@ export const zh = {
|
|
|
245
245
|
browserBack: '后退',
|
|
246
246
|
browserForward: '前进',
|
|
247
247
|
browserStart: '输入网址开始浏览(沙箱模式)',
|
|
248
|
+
browserLive: 'Agent 实况',
|
|
249
|
+
browserLiveFree: '自由浏览',
|
|
250
|
+
browserLiveConnecting: '连接 agent 浏览器宿主…',
|
|
251
|
+
browserLiveDown: '宿主不可达:确认 KCoder 已启动(端口 9223)',
|
|
252
|
+
browserLiveTargetNone: '暂无打开的页面——agent 发起浏览后此处实时显示',
|
|
253
|
+
browserLiveFollowLatest: '跟随最新页面',
|
|
248
254
|
browserBlockedScheme: '已阻止:仅支持 http/https 链接',
|
|
249
255
|
browserBlockedLoopback: '已阻止:不允许在浏览器中访问本机或内部地址',
|
|
250
256
|
browserInvalid: '无效的网址',
|
|
@@ -575,6 +581,12 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
575
581
|
browserBack: 'Back',
|
|
576
582
|
browserForward: 'Forward',
|
|
577
583
|
browserStart: 'Enter a URL to start browsing (sandbox mode)',
|
|
584
|
+
browserLive: 'Agent live',
|
|
585
|
+
browserLiveFree: 'Free browse',
|
|
586
|
+
browserLiveConnecting: 'connecting to the agent browser host…',
|
|
587
|
+
browserLiveDown: 'host unreachable: make sure KCoder is running (port 9223)',
|
|
588
|
+
browserLiveTargetNone: 'No open pages yet — they appear here live once the agent browses',
|
|
589
|
+
browserLiveFollowLatest: 'Follow latest',
|
|
578
590
|
browserBlockedScheme: 'Blocked: only http/https URLs are allowed',
|
|
579
591
|
browserBlockedLoopback: 'Blocked: local and internal addresses cannot be browsed here',
|
|
580
592
|
browserInvalid: 'Invalid URL',
|
|
@@ -2362,3 +2362,35 @@ body[data-dsh-sidebar-dragging] .panel {
|
|
|
2362
2362
|
0%, 60% { background: var(--dsw-alias-interactive-bg-hover); }
|
|
2363
2363
|
100% { background: transparent; }
|
|
2364
2364
|
}
|
|
2365
|
+
|
|
2366
|
+
/* ═══ Agent 浏览器实况(CDP screencast,2026-09-12 方案 2)═══ */
|
|
2367
|
+
.browserLive {
|
|
2368
|
+
position: absolute;
|
|
2369
|
+
inset: 0;
|
|
2370
|
+
display: flex;
|
|
2371
|
+
flex-direction: column;
|
|
2372
|
+
gap: 6px;
|
|
2373
|
+
padding: 8px 10px;
|
|
2374
|
+
overflow: hidden;
|
|
2375
|
+
}
|
|
2376
|
+
.browserLiveStatus {
|
|
2377
|
+
display: flex;
|
|
2378
|
+
align-items: center;
|
|
2379
|
+
gap: 8px;
|
|
2380
|
+
font-size: 12px;
|
|
2381
|
+
opacity: 0.75;
|
|
2382
|
+
}
|
|
2383
|
+
.browserLiveCanvas {
|
|
2384
|
+
flex: 1;
|
|
2385
|
+
min-height: 0;
|
|
2386
|
+
width: 100%;
|
|
2387
|
+
object-fit: contain;
|
|
2388
|
+
border: 1px solid var(--dsw-alias-border-l, rgba(128, 128, 128, 0.25));
|
|
2389
|
+
border-radius: 8px;
|
|
2390
|
+
background: #fff;
|
|
2391
|
+
cursor: crosshair;
|
|
2392
|
+
}
|
|
2393
|
+
.browserLiveTarget {
|
|
2394
|
+
max-width: 260px;
|
|
2395
|
+
font-size: 12px;
|
|
2396
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -517,6 +517,16 @@ function buildApi(
|
|
|
517
517
|
// probe is display-only (headers back to the caller), restricted to
|
|
518
518
|
// http(s) non-loopback URLs with a hard timeout, and gated by the same
|
|
519
519
|
// trust fence as every other route — a cross-site page cannot reach it.
|
|
520
|
+
'cdp.targets': async () => {
|
|
521
|
+
// Agent 浏览器宿主(KCoder 桌面端维护,固定 127.0.0.1:9223)的
|
|
522
|
+
// page target 列表。host 侧代理:CDP 端点无 CORS 头,renderer 直连
|
|
523
|
+
// /json/list 会被拦,WS(screencast/输入)则由 renderer 直连。
|
|
524
|
+
// 端口/地址是编译期常量,无用户输入,无 SSRF 面。
|
|
525
|
+
const res = await fetch('http://127.0.0.1:9223/json/list', { signal: AbortSignal.timeout(3000) })
|
|
526
|
+
if (!res.ok) throw new SidebarError('cdp-down', `browser host unreachable (${res.status})`, 502)
|
|
527
|
+
const list = (await res.json()) as Array<{ id: string; type: string; title: string; url: string }>
|
|
528
|
+
return { targets: list.filter((t) => t.type === 'page').map((t) => ({ id: t.id, url: t.url, title: t.title })) }
|
|
529
|
+
},
|
|
520
530
|
'browser.probe': async (payload) => {
|
|
521
531
|
const raw = requireString(payload, 'url')
|
|
522
532
|
let parsed: URL
|