@spzxg/cli-ui 0.3.1 → 0.6.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.
@@ -1,13 +1,22 @@
1
1
  // 任务浏览器:移植自 Operator 的 BrowserPane(状态、实时画面、接管 / 交还、打开 / 关闭)。
2
- // 全部经 center 转发:状态和画面走 SSE,鼠标键盘走 POST;只有面板挂着且页面可见时才拉画面。
2
+ // 全部经 center 转发:一条实时连接(GET /browser/live)里有状态、画面、弹窗和复制出的文字,鼠标键盘走 POST。
3
+ // 旧版中枢没有这条连接时退回两条(/browser/events 状态 + /browser/frames 画面)。页面切到后台就断开,切回来再连
4
+ // (同一域名的连接数有限,后台页面不该占着)。
3
5
  // 输入只在用户接管(control = human)时发送,节点那边也只在接管时接受。
6
+ // 让远端操作和本机一样:
7
+ // · 打字是真正的按键事件(网页的 keydown / 快捷键 / 输入即搜索都能收到);输入法、粘贴走文字插入;
8
+ // · 编辑快捷键(全选 / 复制 / 剪切 / 撤销)按节点的系统换成那边的按键,复制出来的文字写进你自己的剪贴板;
9
+ // · 按住拖动带着按键(选中文字、拖滑块);滚轮合并发送;
10
+ // · 网页的 alert / confirm / prompt 画面里看不到,这里单独弹出来处理。
4
11
  // Operator 的「在节点屏幕上显示」「弹出窗口」「登录状态」是节点本机的功能,这里不提供。
5
- import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
6
- import { CenterError, type BoardBackend } from "../core/client.js";
7
- import type { BrowserStatus } from "../core/types.js";
12
+ import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState, type MutableRefObject } from "react";
13
+ import { CenterError, isMissingRoute, type BoardBackend } from "../core/client.js";
14
+ import { jitter } from "../core/store.js";
15
+ import type { BrowserFramesState, BrowserMessage, BrowserStatus } from "../core/types.js";
8
16
  import { Icon } from "../ui/icons.js";
9
17
 
10
18
  interface FrameMeta { deviceWidth: number; deviceHeight: number }
19
+ type Dialog = Extract<BrowserMessage, { type: "dialog" }>;
11
20
 
12
21
  const CAPABILITY_LABEL: Record<string, string> = { config: "任务配置", manual: "手动打开过", ai: "AI 自行启用" };
13
22
  const SPECIAL_KEYS: Record<string, number> = {
@@ -16,6 +25,27 @@ const SPECIAL_KEYS: Record<string, number> = {
16
25
  };
17
26
  const MOUSE_BUTTONS = ["left", "middle", "right"] as const;
18
27
  const RETRY_MS = [1_000, 3_000, 8_000, 15_000];
28
+ const retryDelay = (attempt: number) => jitter(RETRY_MS[Math.min(attempt, RETRY_MS.length - 1)]);
29
+ /** 看的人满了(中枢回 429):别人退出才有位置,隔久一点再试。 */
30
+ const FULL_RETRY_MS = 20_000;
31
+ /** 连接失败后多久再试:还没派发不超过 3 秒;登录过期 30 秒;人满 20 秒;其余按退避(都带随机浮动,免得大家同一秒一起重连)。 */
32
+ const nextTry = (err: { status?: number } | null | undefined, attempt: number) =>
33
+ err?.status === 409 ? Math.round(3_000 * (0.7 + Math.random() * 0.3)) : err && (err.status === 401 || err.status === 403) ? jitter(30_000) : err?.status === 429 ? jitter(FULL_RETRY_MS) : retryDelay(attempt);
34
+
35
+ /** 接管时发了操作、这么久画面一点没变:提示「画面没有响应」,给重连。 */
36
+ const UNRESPONSIVE_MS = 8_000;
37
+ /** 输入队列最多攒这么多(请求卡住时不无限堆积;先丢鼠标移动)。 */
38
+ const QUEUE_MAX = 200;
39
+
40
+ /** 地址栏:没写协议的补 https://(example.com、localhost:3000);看起来不像地址的原样交给节点。 */
41
+ export function normalizeUrl(input: string): string {
42
+ const v = input.trim();
43
+ if (!v) return "";
44
+ if (/^[a-z][a-z0-9+.-]*:/i.test(v) && !/^[^/]+:\d+(\/|$)/.test(v)) return v;
45
+ if (/^(localhost|\d{1,3}(\.\d{1,3}){3})(:\d+)?(\/|$)/i.test(v)) return `http://${v}`;
46
+ if (!/\s/.test(v) && /\.[a-z]{2,}(:\d+)?(\/|$|\?|#)/i.test(v)) return `https://${v}`;
47
+ return v;
48
+ }
19
49
 
20
50
  function modifiers(e: { altKey: boolean; ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }): number {
21
51
  return (e.altKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.metaKey ? 4 : 0) | (e.shiftKey ? 8 : 0);
@@ -31,32 +61,160 @@ function usePageVisible(): boolean {
31
61
  return visible;
32
62
  }
33
63
 
34
- /** 浏览器状态:SSE 推送,断了按退避重连。 */
35
- function useBrowserStatus(backend: BoardBackend, jobId: string): [BrowserStatus | null, (s: BrowserStatus) => void, string | null] {
64
+ /** 写进本机剪贴板:优先 Clipboard API,不行(没有授权、不是安全来源)退回 execCommand。 */
65
+ async function writeClipboard(text: string): Promise<boolean> {
66
+ try {
67
+ if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return true; }
68
+ } catch { /* 退回下面 */ }
69
+ try {
70
+ const area = document.createElement("textarea");
71
+ area.value = text;
72
+ area.style.cssText = "position:fixed;left:0;top:0;opacity:0;";
73
+ document.body.appendChild(area);
74
+ area.select();
75
+ const ok = document.execCommand("copy");
76
+ area.remove();
77
+ return ok;
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+
83
+ interface BrowserFeed {
84
+ status: BrowserStatus | null;
85
+ setStatus: (s: BrowserStatus) => void;
86
+ /** 连接出了问题(正在重连)。 */
87
+ streamError: string | null;
88
+ /** 不会自动重连了(节点拒绝、没有权限):显示原因和「重试」。 */
89
+ ended: string | null;
90
+ /** 发输入用的 viewer id;ready = 输入能送到节点。 */
91
+ viewer: string | null;
92
+ ready: boolean;
93
+ frames: BrowserFramesState;
94
+ paused: boolean;
95
+ reconnect: () => void;
96
+ /**
97
+ * 最近的页面、弹窗、画面:中枢在连上时就推过来,那时画面区(要等状态是「已打开」才挂上)可能还没挂好,
98
+ * 挂上时从这里补上——否则地址栏一直是空的,要等下一次换页。
99
+ */
100
+ latest: MutableRefObject<{ page?: BrowserMessage; dialog?: BrowserMessage | null; frame?: BrowserMessage }>;
101
+ }
102
+
103
+ /**
104
+ * 浏览器的数据:状态、画面连接、viewer。新版中枢一条连接(browserLive),旧版两条。
105
+ * 画面和其他消息交给 sink(画面直接画到 canvas,不经 React 状态)。
106
+ */
107
+ function useBrowserFeed(backend: BoardBackend, jobId: string, sink: MutableRefObject<((m: BrowserMessage) => void) | null>): BrowserFeed {
108
+ const visible = usePageVisible();
109
+ const [mode, setMode] = useState<"live" | "legacy">(backend.browserLive ? "live" : "legacy");
36
110
  const [status, setStatus] = useState<BrowserStatus | null>(null);
37
- const [error, setError] = useState<string | null>(null);
111
+ const [streamError, setStreamError] = useState<string | null>(null);
112
+ const [ended, setEnded] = useState<string | null>(null);
113
+ const [viewer, setViewer] = useState<string | null>(null);
114
+ const [ready, setReady] = useState(false);
115
+ const [frames, setFrames] = useState<BrowserFramesState>({ state: "idle" });
116
+ const [retry, setRetry] = useState(0);
117
+ const reconnect = useCallback(() => { setEnded(null); setRetry((n) => n + 1); }, []);
118
+ const latest = useRef<BrowserFeed["latest"]["current"]>({});
119
+ const deliver = useCallback((m: BrowserMessage) => {
120
+ if (m.type === "page") latest.current.page = m;
121
+ else if (m.type === "dialog") latest.current.dialog = m;
122
+ else if (m.type === "dialog_closed") latest.current.dialog = null;
123
+ else if (m.type === "frame") latest.current.frame = m;
124
+ sink.current?.(m);
125
+ }, [sink]);
126
+
127
+ // ── 新版:一条连接 ──
38
128
  useEffect(() => {
39
- setStatus(null);
129
+ if (mode !== "live") return;
130
+ if (!visible) { setViewer(null); setReady(false); setFrames({ state: "idle" }); return; }
131
+ let alive = true;
40
132
  let stop: (() => void) | null = null;
41
133
  let timer: ReturnType<typeof setTimeout> | undefined;
42
134
  let attempt = 0;
135
+ const connect = () => {
136
+ let final = false;
137
+ stop = backend.browserLive!(jobId, {
138
+ onViewer: (id, isReady) => { attempt = 0; setViewer(id); setReady(isReady); },
139
+ onStatus: (s) => { attempt = 0; setStreamError(null); setStatus(s); },
140
+ onMessage: deliver,
141
+ onFrames: setFrames,
142
+ onEnd: (end) => {
143
+ if (end.reason === "refused") { final = true; setEnded(end.detail || "执行节点拒绝了浏览器连接"); }
144
+ },
145
+ onClose: (err) => {
146
+ setViewer(null);
147
+ setReady(false);
148
+ if (!alive || final) return;
149
+ if (isMissingRoute(err)) { setMode("legacy"); return; }
150
+ // 对话不存在:重连也没用。
151
+ if (err && err.status === 404) { setEnded(err.message); return; }
152
+ if (err) setStreamError(err.message);
153
+ // 409 还没派发:隔几秒再问;401 / 403 多半是宿主会话过期,30 秒后再试(续上会话就自己恢复);
154
+ // 其余(断线、中枢重启、人太多)按退避重连。
155
+ timer = setTimeout(connect, nextTry(err, attempt++));
156
+ },
157
+ });
158
+ };
159
+ connect();
160
+ return () => { alive = false; stop?.(); if (timer) clearTimeout(timer); };
161
+ }, [backend, jobId, mode, visible, retry, deliver]);
162
+
163
+ // ── 旧版:状态一条(页面在后台时也断开,切回来再连:人多时少占中枢和节点的连接) ──
164
+ useEffect(() => {
165
+ if (mode !== "legacy" || !visible) return;
43
166
  let alive = true;
167
+ let stop: (() => void) | null = null;
168
+ let timer: ReturnType<typeof setTimeout> | undefined;
169
+ let attempt = 0;
44
170
  const connect = () => {
45
171
  stop = backend.browserEvents(jobId, {
46
- onStatus: (s) => { attempt = 0; setError(null); setStatus(s); },
172
+ onStatus: (s) => { attempt = 0; setStreamError(null); setStatus(s); },
47
173
  onClose: (err) => {
48
174
  if (!alive) return;
49
- // 404 / 409:节点没开浏览器功能或还没派发,不再重试(CliBoard 会在派发后重新挂载)。
50
- if (err && (err.status === 404 || err.status === 409 || err.status === 401)) { setError(err.message); return; }
51
- if (err) setError(err.message);
52
- timer = setTimeout(connect, RETRY_MS[Math.min(attempt++, RETRY_MS.length - 1)]);
175
+ if (err && err.status === 404) { setEnded(err.message); return; }
176
+ if (err) setStreamError(err.message);
177
+ timer = setTimeout(connect, nextTry(err, attempt++));
53
178
  },
54
179
  });
55
180
  };
56
181
  connect();
57
182
  return () => { alive = false; stop?.(); if (timer) clearTimeout(timer); };
58
- }, [backend, jobId]);
59
- return [status, setStatus, error];
183
+ }, [backend, jobId, mode, visible, retry]);
184
+
185
+ // ── 旧版:画面一条(浏览器开着、页面可见时) ──
186
+ const legacyRunning = mode === "legacy" && visible && status?.state === "running";
187
+ useEffect(() => {
188
+ if (!legacyRunning) { if (mode === "legacy") { setViewer(null); setReady(false); setFrames({ state: "idle" }); } return; }
189
+ let alive = true;
190
+ let stop: (() => void) | null = null;
191
+ let timer: ReturnType<typeof setTimeout> | undefined;
192
+ let attempt = 0;
193
+ const again = (detail?: string) => {
194
+ setViewer(null);
195
+ setReady(false);
196
+ if (!alive) return;
197
+ setFrames({ state: "retrying", ...(detail ? { detail } : {}) });
198
+ timer = setTimeout(connect, retryDelay(attempt++));
199
+ };
200
+ const connect = () => {
201
+ setFrames({ state: attempt ? "retrying" : "connecting" });
202
+ stop = backend.browserFrames(jobId, {
203
+ onViewer: (id) => { attempt = 0; setViewer(id); setReady(true); setFrames({ state: "live" }); },
204
+ onMessage: deliver,
205
+ onEnd: (end) => { if (end.reason !== "client_closed") again(end.reason === "refused" ? end.detail : undefined); },
206
+ onClose: (err) => { if (err) again(err.message); },
207
+ });
208
+ };
209
+ connect();
210
+ return () => { alive = false; stop?.(); if (timer) clearTimeout(timer); };
211
+ }, [backend, jobId, legacyRunning, retry, deliver]); // eslint-disable-line react-hooks/exhaustive-deps
212
+
213
+ // 浏览器关了:上一个页面的地址、弹窗、画面都作废,重新打开时不先闪一下旧画面。
214
+ const running = status?.state === "running";
215
+ useEffect(() => { if (!running) latest.current = {}; }, [running]);
216
+
217
+ return { status, setStatus, streamError, ended, viewer, ready, frames, paused: !visible, reconnect, latest };
60
218
  }
61
219
 
62
220
  interface ScreenHandle { draw(base64: string, meta: FrameMeta | null): void }
@@ -121,8 +279,12 @@ const Screen = forwardRef<ScreenHandle, { human: boolean; hidden: boolean; label
121
279
  humanRef.current = human;
122
280
  const touch = useRef<{ id: number; x: number; y: number; lastY: number; lastX: number; moved: boolean } | null>(null);
123
281
  const lastMove = useRef(0);
282
+ // 按住的鼠标键:按住拖动时移动事件要带上它,网页里选中文字、拖滑块、拖拽才生效(CDP 按 button 判断是不是在拖)。
283
+ const pressed = useRef<(typeof MOUSE_BUTTONS)[number] | null>(null);
284
+ const lastPoint = useRef<{ clientX: number; clientY: number } | null>(null);
124
285
 
125
286
  const mouse = (event: string, e: { clientX: number; clientY: number; altKey: boolean; ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }, extra: Record<string, unknown> = {}) => {
287
+ lastPoint.current = { clientX: e.clientX, clientY: e.clientY };
126
288
  const p = point(e.clientX, e.clientY);
127
289
  if (p) send({ type: "mouse", event, ...p, modifiers: modifiers(e), ...extra });
128
290
  };
@@ -136,7 +298,8 @@ const Screen = forwardRef<ScreenHandle, { human: boolean; hidden: boolean; label
136
298
  return;
137
299
  }
138
300
  (e.currentTarget as Element).setPointerCapture?.(e.pointerId);
139
- mouse("mousePressed", e, { button: MOUSE_BUTTONS[e.button] ?? "left", clickCount: e.detail || 1 });
301
+ pressed.current = MOUSE_BUTTONS[e.button] ?? "left";
302
+ mouse("mousePressed", e, { button: pressed.current, clickCount: e.detail || 1 });
140
303
  };
141
304
  const onPointerMove = (e: React.PointerEvent) => {
142
305
  if (!human) return;
@@ -156,7 +319,7 @@ const Screen = forwardRef<ScreenHandle, { human: boolean; hidden: boolean; label
156
319
  }
157
320
  if (Date.now() - lastMove.current < 50) return;
158
321
  lastMove.current = Date.now();
159
- mouse("mouseMoved", e);
322
+ mouse("mouseMoved", e, pressed.current ? { button: pressed.current } : {});
160
323
  };
161
324
  const onPointerUp = (e: React.PointerEvent) => {
162
325
  if (!human) return;
@@ -170,9 +333,23 @@ const Screen = forwardRef<ScreenHandle, { human: boolean; hidden: boolean; label
170
333
  }
171
334
  return;
172
335
  }
336
+ pressed.current = null;
173
337
  mouse("mouseReleased", e, { button: MOUSE_BUTTONS[e.button] ?? "left", clickCount: e.detail || 1 });
174
338
  };
175
339
 
340
+ // 按住鼠标时切走窗口 / 标签页:松手事件收不到,远端会一直「按着」(一直在拖、选中)。补发一次松开。
341
+ useEffect(() => {
342
+ const release = () => {
343
+ if (!pressed.current || !lastPoint.current) return;
344
+ const p = point(lastPoint.current.clientX, lastPoint.current.clientY);
345
+ if (p) send({ type: "mouse", event: "mouseReleased", ...p, button: pressed.current, clickCount: 1, modifiers: 0 });
346
+ pressed.current = null;
347
+ };
348
+ window.addEventListener("blur", release);
349
+ document.addEventListener("visibilitychange", release);
350
+ return () => { window.removeEventListener("blur", release); document.removeEventListener("visibilitychange", release); };
351
+ }, [point, send]);
352
+
176
353
  // 滚轮:React 的 onWheel 是被动监听,拦不住宿主页面滚动,所以用原生监听(passive: false)。
177
354
  useEffect(() => {
178
355
  const node: HTMLElement | null = el();
@@ -180,7 +357,9 @@ const Screen = forwardRef<ScreenHandle, { human: boolean; hidden: boolean; label
180
357
  const onWheel = (e: WheelEvent) => {
181
358
  if (!humanRef.current) return;
182
359
  e.preventDefault();
183
- mouse("mouseWheel", e, { deltaX: e.deltaX, deltaY: e.deltaY });
360
+ // deltaMode 1 / 2(按行 / 按页滚动的鼠标)换成像素。
361
+ const k = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 800 : 1;
362
+ mouse("mouseWheel", e, { deltaX: e.deltaX * k, deltaY: e.deltaY * k });
184
363
  };
185
364
  node.addEventListener("wheel", onWheel, { passive: false });
186
365
  return () => node.removeEventListener("wheel", onWheel);
@@ -190,7 +369,7 @@ const Screen = forwardRef<ScreenHandle, { human: boolean; hidden: boolean; label
190
369
  className: `bp-frame ${human ? "live" : ""}`,
191
370
  style: { display: hidden ? "none" : undefined, touchAction: human ? "none" : "auto" } as React.CSSProperties,
192
371
  onPointerDown, onPointerMove, onPointerUp,
193
- onPointerCancel: () => { touch.current = null; },
372
+ onPointerCancel: () => { touch.current = null; pressed.current = null; },
194
373
  onContextMenu: (e: React.MouseEvent) => { if (human) e.preventDefault(); },
195
374
  role: "img" as const,
196
375
  "aria-label": label,
@@ -201,9 +380,11 @@ const Screen = forwardRef<ScreenHandle, { human: boolean; hidden: boolean; label
201
380
  },
202
381
  );
203
382
 
204
- function Viewer({ backend, jobId, human }: { backend: BoardBackend; jobId: string; human: boolean }) {
205
- const visible = usePageVisible();
206
- const viewerRef = useRef<string | null>(null);
383
+ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
384
+ backend: BoardBackend; jobId: string; human: boolean; feed: BrowserFeed; sink: MutableRefObject<((m: BrowserMessage) => void) | null>;
385
+ onPage?: (page: { url: string; title: string } | null) => void;
386
+ onQuoteText?: (text: string, page: { url: string; title: string }) => void;
387
+ }) {
207
388
  const screenRef = useRef<ScreenHandle | null>(null);
208
389
  const keysRef = useRef<HTMLTextAreaElement | null>(null);
209
390
  const [hasFrame, setHasFrame] = useState(false);
@@ -211,91 +392,131 @@ function Viewer({ backend, jobId, human }: { backend: BoardBackend; jobId: strin
211
392
  const [addr, setAddr] = useState("");
212
393
  const [editing, setEditing] = useState(false);
213
394
  const [notice, setNotice] = useState<string | null>(null);
214
- const [ended, setEnded] = useState<string | null>(null);
215
- const [retry, setRetry] = useState(0);
216
- const [reconnecting, setReconnecting] = useState(false);
217
- // 连续失败次数:按退避重连,收到画面后清零。
218
- const failures = useRef(0);
219
- const reconnect = useCallback((delay?: number) => {
220
- const wait = delay ?? RETRY_MS[Math.min(failures.current++, RETRY_MS.length - 1)];
221
- setReconnecting(true);
222
- return setTimeout(() => setRetry((n) => n + 1), wait);
395
+ const [dialog, setDialog] = useState<Dialog | null>(null);
396
+ const [promptText, setPromptText] = useState("");
397
+ // 在网页里复制出来的文字:除了写进剪贴板,还可以「引用到对话」。
398
+ const [copied, setCopied] = useState<string | null>(null);
399
+ // 画面没有响应:接管时发了操作,之后 UNRESPONSIVE_MS 一帧都没来。
400
+ const [stuck, setStuck] = useState(false);
401
+ const lastInputAt = useRef(0);
402
+ const lastFrameAt = useRef(0);
403
+ const noticeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
404
+ const flash = useCallback((text: string, ms = 3000) => {
405
+ setNotice(text);
406
+ if (noticeTimer.current) clearTimeout(noticeTimer.current);
407
+ noticeTimer.current = setTimeout(() => setNotice(null), ms);
223
408
  }, []);
409
+ useEffect(() => () => { if (noticeTimer.current) clearTimeout(noticeTimer.current); }, []);
224
410
 
411
+ // 连接上来的消息:画面直接画,其余更新界面。挂上时先补上挂上之前到的页面 / 弹窗 / 最后一帧。
412
+ const latest = feed.latest;
225
413
  useEffect(() => {
226
- if (!visible) return;
227
- setEnded(null);
228
- let clear: ReturnType<typeof setTimeout> | undefined;
229
- let again: ReturnType<typeof setTimeout> | undefined;
230
- const flash = (text: string) => {
231
- setNotice(text);
232
- if (clear) clearTimeout(clear);
233
- clear = setTimeout(() => setNotice(null), 3000);
414
+ const handle = (m: BrowserMessage) => {
415
+ if (m.type === "frame" && m.data) {
416
+ screenRef.current?.draw(m.data, m.metadata ?? null);
417
+ lastFrameAt.current = Date.now();
418
+ setStuck(false);
419
+ setHasFrame(true);
420
+ } else if (m.type === "page") setPage({ url: m.url ?? "", title: m.title ?? "" });
421
+ else if (m.type === "error" && m.message) flash(m.message);
422
+ else if (m.type === "dialog") { setDialog(m); setPromptText(m.defaultPrompt ?? ""); }
423
+ else if (m.type === "dialog_closed") setDialog(null);
424
+ else if (m.type === "clipboard") {
425
+ setCopied(m.text);
426
+ void writeClipboard(m.text).then((ok) => flash(ok ? "已复制到你的剪贴板" : "没能写入剪贴板(浏览器不允许),可以在网页里选中后再试", 6000));
427
+ }
234
428
  };
235
- const stop = backend.browserFrames(jobId, {
236
- onViewer: (id) => { viewerRef.current = id; setReconnecting(false); },
237
- onMessage: (m) => {
238
- if (m.type === "frame" && m.data) {
239
- failures.current = 0;
240
- screenRef.current?.draw(m.data, m.metadata ?? null);
241
- setHasFrame(true);
242
- } else if (m.type === "page") setPage({ url: m.url ?? "", title: m.title ?? "" });
243
- else if (m.type === "error" && m.message) flash(m.message);
244
- },
245
- onEnd: (end) => {
246
- viewerRef.current = null;
247
- // 节点明确拒绝:不自动重试(多半是节点版本 / 功能开关),给「重试」按钮。其余(超时、节点断开)按退避重连。
248
- if (end.reason === "refused") setEnded(end.detail || "执行节点拒绝了画面连接");
249
- else if (end.reason !== "client_closed") again = reconnect();
250
- },
251
- onClose: (err) => {
252
- viewerRef.current = null;
253
- if (!err) return; // 正常结束:onEnd 已经处理
254
- if (err.status === 429 || err.status === 404 || err.status === 401 || err.status === 409) setEnded(err.message);
255
- else again = reconnect();
256
- },
257
- });
258
- return () => { stop(); viewerRef.current = null; if (clear) clearTimeout(clear); if (again) clearTimeout(again); };
259
- }, [backend, jobId, visible, retry, reconnect]);
429
+ const { page: lastPage, dialog: lastDialog, frame: lastFrame } = latest.current;
430
+ if (lastPage) handle(lastPage);
431
+ if (lastDialog) handle(lastDialog);
432
+ if (lastFrame) handle(lastFrame);
433
+ sink.current = handle;
434
+ return () => { sink.current = null; };
435
+ }, [sink, flash, latest]);
260
436
 
261
437
  useEffect(() => { if (!editing) setAddr(page.url); }, [page.url, editing]);
438
+ const onPageRef = useRef(onPage);
439
+ onPageRef.current = onPage;
440
+ useEffect(() => { onPageRef.current?.(page.url ? page : null); }, [page]);
441
+ useEffect(() => () => onPageRef.current?.(null), []);
442
+ // 接管中发了操作却一直没有新画面:多半是画面连接卡住或网络很慢,说出来并给「重连」。
443
+ useEffect(() => {
444
+ if (!human) { setStuck(false); return; }
445
+ const t = setInterval(() => {
446
+ const waiting = lastInputAt.current > lastFrameAt.current && Date.now() - lastInputAt.current > UNRESPONSIVE_MS;
447
+ setStuck(waiting);
448
+ }, 1_000);
449
+ return () => clearInterval(t);
450
+ }, [human]);
262
451
 
263
- // 输入按顺序发:一个请求在路上时攒着,回来后一次发完(鼠标移动只留最后一个)。
452
+ // 输入按顺序发:一个请求在路上时攒着,回来后一次发完(鼠标移动只留最后一个,连续的滚轮合并)。
453
+ const viewerRef = useRef<string | null>(null);
454
+ viewerRef.current = feed.ready ? feed.viewer : null;
264
455
  const queue = useRef<Array<Record<string, unknown>>>([]);
265
456
  const inflight = useRef(false);
457
+ const reconnect = feed.reconnect;
266
458
  const flush = useCallback(() => {
267
459
  const viewer = viewerRef.current;
268
460
  if (inflight.current || !viewer || !queue.current.length) return;
269
461
  const batch = queue.current.splice(0, 50);
270
462
  inflight.current = true;
271
463
  backend.browserInput(jobId, viewer, batch).catch((error: unknown) => {
272
- // 画面连接已失效(中枢重启、连接断开):丢掉这批输入并重连画面,避免用户以为点了没反应。
464
+ // 画面连接已失效(中枢重启、连接断开):丢掉这批输入并重连,避免用户以为点了没反应。
273
465
  if (error instanceof CenterError && (error.status === 404 || error.status === 409)) {
274
- viewerRef.current = null;
275
466
  queue.current = [];
276
- setNotice("画面连接已断开,正在重连…(刚才的操作没有送达,请重新操作)");
277
- setTimeout(() => setNotice(null), 4000);
278
- reconnect(300);
467
+ flash("画面连接已断开,正在重连…(刚才的操作没有送达,请重新操作)", 4000);
468
+ reconnect();
469
+ return;
470
+ }
471
+ // 其余(网络抖动、超时、中枢忙):不自动重发——点击、回车重复送达比没送达更糟。说清楚,让用户看画面决定要不要再操作。
472
+ const important = batch.some((m) => m.type !== "mouse" || m.event === "mousePressed");
473
+ if (important) {
474
+ const why = error instanceof CenterError && error.status === 429 ? "操作太快,任务中心暂时没接收" : "网络不稳";
475
+ flash(`${why}:刚才的操作可能没有送达,请看画面确认后再操作`, 5000);
279
476
  }
280
477
  }).finally(() => { inflight.current = false; flush(); });
281
- }, [backend, jobId, reconnect]);
478
+ }, [backend, jobId, reconnect, flash]);
282
479
  const send = useCallback((msg: Record<string, unknown>) => {
480
+ // 画面连接还没就绪(刚接管、正在重连):不攒着等连上再补发——过几秒才落下的点击比没点更糟。
481
+ if (!viewerRef.current) {
482
+ if (msg.type !== "mouse" || msg.event === "mousePressed") flash("画面连接还没就绪,刚才的操作没有送达,请稍后再操作");
483
+ return;
484
+ }
485
+ lastInputAt.current = Date.now();
283
486
  const q = queue.current;
487
+ // 请求卡住时别无限攒:超了先丢鼠标移动,还超就丢最早的。
488
+ if (q.length >= QUEUE_MAX) {
489
+ const kept = q.filter((m) => !(m.type === "mouse" && m.event === "mouseMoved"));
490
+ q.splice(0, q.length, ...kept.slice(-(QUEUE_MAX - 1)));
491
+ }
284
492
  const last = q[q.length - 1];
285
- if (msg.type === "mouse" && msg.event === "mouseMoved" && last?.type === "mouse" && last.event === "mouseMoved") q[q.length - 1] = msg;
286
- else q.push(msg);
493
+ if (msg.type === "mouse" && last?.type === "mouse" && msg.event === last.event && (msg.event === "mouseMoved" || (msg.event === "mouseWheel" && msg.x === last.x && msg.y === last.y))) {
494
+ q[q.length - 1] = msg.event === "mouseWheel"
495
+ ? { ...msg, deltaX: Number(last.deltaX) + Number(msg.deltaX), deltaY: Number(last.deltaY) + Number(msg.deltaY) }
496
+ : msg;
497
+ } else q.push(msg);
287
498
  flush();
288
- }, [flush]);
499
+ }, [flush, flash]);
289
500
 
501
+ // 键盘:
502
+ // · 普通字符(不带 Ctrl / ⌘)作为真正的按键发出(带 text)——网页的 keydown、快捷键、输入即搜索都能收到;
503
+ // · 功能键(回车、退格、方向……)照发;
504
+ // · Ctrl / ⌘ 组合键照发(节点按自己的系统换成那边的快捷键;复制 / 剪切出来的文字写进你的剪贴板);
505
+ // ⌘V / Ctrl+V 例外:让本机的粘贴进到接收框,再作为文字插入;
506
+ // · 输入法的组字结果、粘贴走文字插入(compositionend / input)。
290
507
  const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
291
- if (e.nativeEvent.isComposing) return;
292
- const code = SPECIAL_KEYS[e.key];
508
+ if (e.nativeEvent.isComposing || e.keyCode === 229) return;
293
509
  const mods = modifiers(e);
294
- const shortcut = (mods & 6) !== 0 && e.key.toLowerCase() !== "v";
295
- if (code === undefined && !shortcut) return;
510
+ const shortcut = (mods & 6) !== 0;
511
+ if (shortcut && e.key.toLowerCase() === "v") return;
512
+ const special = SPECIAL_KEYS[e.key];
513
+ const printable = e.key.length === 1;
514
+ if (special === undefined && !printable && !shortcut) return;
296
515
  e.preventDefault();
297
- const base = { key: e.key, code: e.code, keyCode: code ?? e.keyCode, modifiers: mods };
298
- send({ type: "key", event: "keyDown", ...base, ...(e.key === "Enter" ? { text: "\r" } : {}) });
516
+ const keyCode = special ?? (e.keyCode || (printable ? e.key.toUpperCase().charCodeAt(0) : 0));
517
+ const base = { key: e.key, code: e.code, keyCode, modifiers: mods };
518
+ const text = e.key === "Enter" ? "\r" : printable && !shortcut ? e.key : undefined;
519
+ send({ type: "key", event: "keyDown", ...base, ...(text ? { text } : {}) });
299
520
  send({ type: "key", event: "keyUp", ...base });
300
521
  };
301
522
  const flushText = (el: HTMLTextAreaElement) => {
@@ -303,29 +524,61 @@ function Viewer({ backend, jobId, human }: { backend: BoardBackend; jobId: strin
303
524
  el.value = "";
304
525
  };
305
526
  const nav = (msg: Record<string, unknown>) => { if (human) send(msg); };
527
+ const answer = (accept: boolean) => {
528
+ if (!dialog) return;
529
+ send({ type: "dialog", accept, ...(dialog.dialogType === "prompt" && accept ? { promptText } : {}) });
530
+ setDialog(null);
531
+ };
306
532
 
533
+ const { frames, paused } = feed;
534
+ const retrying = frames.state === "retrying";
307
535
  return (
308
536
  <div className="bp-viewer">
309
537
  <div className="prev-chrome bp-chrome">
310
538
  <button className="bp-nav" disabled={!human} onClick={() => nav({ type: "back" })} title="后退">‹</button>
311
539
  <button className="bp-nav" disabled={!human} onClick={() => nav({ type: "forward" })} title="前进">›</button>
312
540
  <button className="bp-nav" disabled={!human} onClick={() => nav({ type: "reload" })} title="刷新">↻</button>
313
- <form className="bp-addr-form" onSubmit={(e) => { e.preventDefault(); nav({ type: "navigate", url: addr }); setEditing(false); }}>
541
+ <form className="bp-addr-form" onSubmit={(e) => { e.preventDefault(); const url = normalizeUrl(addr); if (url) nav({ type: "navigate", url }); setEditing(false); (e.currentTarget.querySelector("input") as HTMLInputElement | null)?.blur(); }}>
314
542
  <input className="prev-addr bp-addr" value={addr} readOnly={!human} title={page.title || page.url}
315
543
  onFocus={() => setEditing(true)} onBlur={() => setEditing(false)} onChange={(e) => setAddr(e.target.value)} />
316
544
  </form>
317
545
  </div>
318
546
  <div className={`bp-screen ${human ? "live" : ""}`}>
319
- {ended ? (
320
- <div className="bp-empty">看不到实时画面:{ended}<br /><button className="btn btn-line btn-sm" style={{ marginTop: 8 }} onClick={() => { failures.current = 0; setRetry((n) => n + 1); }}>重试</button></div>
321
- ) : null}
322
547
  {/* 画面一直挂着(收到第一帧前隐藏),解码在内存里做,不受宿主页面 CSP 的 img-src 限制。 */}
323
- <Screen ref={screenRef} human={human && !ended} hidden={!hasFrame || !!ended} label={page.title || "浏览器画面"} send={send} onFocusKeys={() => keysRef.current?.focus({ preventScroll: true })} />
324
- {!ended && !hasFrame && (
325
- <div className="bp-empty">{!visible ? "页面不可见,已暂停画面" : reconnecting ? "画面连接中断,正在重连…" : "正在连接画面…"}</div>
548
+ <Screen ref={screenRef} human={human} hidden={!hasFrame} label={page.title || "浏览器画面"} send={send} onFocusKeys={() => keysRef.current?.focus({ preventScroll: true })} />
549
+ {!hasFrame && (
550
+ <div className="bp-empty">
551
+ {paused ? "页面不可见,已暂停画面" : retrying ? `画面连接中断,正在重连…${frames.detail ? `(${frames.detail})` : ""}` : "正在连接画面…"}
552
+ {retrying && <><br /><button className="btn btn-line btn-sm" style={{ marginTop: 8 }} onClick={reconnect}>立即重试</button></>}
553
+ </div>
326
554
  )}
327
- {notice && <div className="bp-toast">{notice}</div>}
328
- {hasFrame && reconnecting && !notice && <div className="bp-toast">画面连接中断,正在重连…</div>}
555
+ {dialog && (
556
+ <div className="bp-dialog" role="dialog" aria-label="网页弹窗">
557
+ <div className="bp-dialog-box">
558
+ <div className="bp-dialog-t">网页{dialog.dialogType === "confirm" ? "请求确认" : dialog.dialogType === "prompt" ? "请求输入" : dialog.dialogType === "beforeunload" ? "询问是否离开" : "提示"}</div>
559
+ <div className="bp-dialog-m">{dialog.message || "(没有内容)"}</div>
560
+ {dialog.dialogType === "prompt" && <input className="bp-dialog-in" value={promptText} disabled={!human} onChange={(e) => setPromptText(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") answer(true); }} />}
561
+ {!human && <div className="bp-dialog-hint">接管浏览器后才能处理这个弹窗。</div>}
562
+ <div className="bp-dialog-a">
563
+ {dialog.dialogType !== "alert" && <button className="btn btn-line btn-sm" disabled={!human} onClick={() => answer(false)}>取消</button>}
564
+ <button className="btn btn-accent btn-sm" disabled={!human} onClick={() => answer(true)}>确定</button>
565
+ </div>
566
+ </div>
567
+ </div>
568
+ )}
569
+ {notice && (
570
+ <div className="bp-toast">{notice}
571
+ {copied && onQuoteText && /剪贴板/.test(notice) && (
572
+ <button className="bp-toast-btn" onClick={() => { onQuoteText(copied, page); setCopied(null); setNotice(null); }}>引用到对话</button>
573
+ )}
574
+ </div>
575
+ )}
576
+ {stuck && !notice && (
577
+ <div className="bp-toast">画面 {Math.round(UNRESPONSIVE_MS / 1000)} 秒没有变化:网络较慢,或画面连接卡住了
578
+ <button className="bp-toast-btn" onClick={() => { setStuck(false); lastInputAt.current = 0; reconnect(); }}>重连画面</button>
579
+ </div>
580
+ )}
581
+ {hasFrame && !notice && (paused ? <div className="bp-toast">页面不可见,已暂停画面</div> : retrying ? <div className="bp-toast">画面连接中断,正在重连…</div> : null)}
329
582
  </div>
330
583
  <textarea
331
584
  ref={keysRef}
@@ -340,33 +593,71 @@ function Viewer({ backend, jobId, human }: { backend: BoardBackend; jobId: strin
340
593
  );
341
594
  }
342
595
 
343
- export function BrowserPane({ backend, jobId, readOnly }: { backend: BoardBackend; jobId: string; readOnly?: boolean }) {
344
- const [status, setStatus, streamError] = useBrowserStatus(backend, jobId);
596
+ export function BrowserPane({ backend, jobId, readOnly, onQuote, onQuoteText, onPage }: {
597
+ backend: BoardBackend; jobId: string; readOnly?: boolean;
598
+ /** 给了就显示「引用到对话」:把浏览器当前页面(标题 + 地址)带进下一条消息。 */
599
+ onQuote?: (page: { url: string; title: string }) => void;
600
+ /** 给了就能把在网页里复制(⌘C / Ctrl+C)出来的文字引用到对话。 */
601
+ onQuoteText?: (text: string, page: { url: string; title: string }) => void;
602
+ /** 当前页面变了(null = 浏览器没开 / 不在看)。 */
603
+ onPage?: (page: { url: string; title: string } | null) => void;
604
+ }) {
605
+ const sink = useRef<((m: BrowserMessage) => void) | null>(null);
606
+ const feed = useBrowserFeed(backend, jobId, sink);
607
+ const { status, setStatus, streamError, ended } = feed;
345
608
  const [busy, setBusy] = useState<string | null>(null);
346
609
  const [error, setError] = useState<string | null>(null);
347
610
 
611
+ // 放大:看板 / 弹窗里画面太小,接管后登录、填验证码看不清。接管时自动放大,交还、关闭时还原;也可以随时手动放大。
612
+ const [big, setBig] = useState(false);
613
+ useEffect(() => {
614
+ if (!big) return;
615
+ const prev = document.body.style.overflow;
616
+ document.body.style.overflow = "hidden";
617
+ return () => { document.body.style.overflow = prev; };
618
+ }, [big]);
348
619
  const act = async (action: "open" | "close" | "takeover" | "handback") => {
349
620
  setBusy(action);
350
621
  setError(null);
351
- try { setStatus(await backend.browserAction(jobId, action)); }
622
+ if (action === "handback" || action === "close") setBig(false);
623
+ try {
624
+ setStatus(await backend.browserAction(jobId, action));
625
+ if (action === "takeover") setBig(true);
626
+ }
352
627
  catch (e) { setError(e instanceof Error ? e.message : String(e)); }
353
628
  finally { setBusy(null); }
354
629
  };
355
630
 
356
- if (!status) return <div className="rail-pad"><div className="prev-note">{streamError ? `读取浏览器状态失败:${streamError}` : "读取浏览器状态…"}</div></div>;
631
+ if (!status) {
632
+ return (
633
+ <div className="rail-pad">
634
+ <div className="prev-note">
635
+ {ended ? `看不到浏览器:${ended}` : streamError ? `读取浏览器状态失败:${streamError}(正在重试)` : "读取浏览器状态…"}
636
+ {ended && <><br /><button className="btn btn-line btn-sm" style={{ marginTop: 8 }} onClick={feed.reconnect}>重试</button></>}
637
+ </div>
638
+ </div>
639
+ );
640
+ }
357
641
  const running = status.state === "running";
358
642
  const human = running && status.control === "human";
359
643
  const kept = human && status.handoff?.by === "kept";
360
- const shownError = error ?? (status.state === "none" ? status.error : null);
644
+ // 状态连接断了(还在重连)时,界面上的状态可能已经过时:写出来,免得用户对着旧状态操作。
645
+ const shownError = error ?? (status.state === "none" ? status.error : null)
646
+ ?? (ended ? `浏览器连接已停止:${ended}` : streamError ? `浏览器状态连接中断,正在重连…(${streamError})` : null);
361
647
 
648
+ const zoomable = running;
362
649
  return (
363
- <div className="rail-pad bp">
650
+ <>
651
+ {big && zoomable && <div className="cli-ui bp-backdrop" onClick={() => setBig(false)} />}
652
+ <div className={`rail-pad bp ${big && zoomable ? "bp-big" : ""}`}>
364
653
  <div className="bp-head">
365
654
  <span className="rail-eyebrow">浏览器 · ego lite</span>
366
655
  <span className={`bp-badge ${running ? (human ? "human" : "ai") : ""}`}>
367
656
  {status.state === "opening" ? "正在打开" : status.state === "closing" ? "正在关闭" : !running ? "未打开" : kept ? "结果页保留中" : human ? "你在操作" : "AI 控制中"}
368
657
  </span>
369
658
  {running && status.viewers > 0 && <span className="bp-meta">{status.viewers} 人在看</span>}
659
+ <span style={{ flex: 1 }} />
660
+ {zoomable && <button className="btn btn-ghost btn-sm bp-zoom" onClick={() => setBig((b) => !b)} title={big ? "还原到看板里" : "放大画面(登录、填验证码时更清楚)"}>{big ? "还原" : "⤢ 放大"}</button>}
370
661
  </div>
371
662
 
372
663
  {kept && <div className="bp-kept">AI 已完成,把结果页留在这里供你查看。看完点「关闭浏览器」;任务之后再用浏览器时会自动收回,没人看 10 分钟后也会自动关闭。</div>}
@@ -383,13 +674,20 @@ export function BrowserPane({ backend, jobId, readOnly }: { backend: BoardBacken
383
674
 
384
675
  {running ? (
385
676
  <>
386
- <Viewer backend={backend} jobId={jobId} human={human && !readOnly} />
677
+ <Viewer backend={backend} jobId={jobId} human={human && !readOnly} feed={feed} sink={sink} onPage={onPage} onQuoteText={onQuoteText} />
387
678
  {!readOnly && (
388
679
  <div className="prev-actions bp-actions">
389
680
  {human
390
681
  ? <button className="btn btn-accent" disabled={!!busy} onClick={() => act("handback")}>{Icon.check()} 交还给 AI</button>
391
682
  : <button className="btn btn-line" disabled={!!busy} onClick={() => act("takeover")}>{Icon.edit()} 接管</button>}
392
683
  <span style={{ flex: 1 }} />
684
+ {onQuote && (
685
+ <button className="btn btn-ghost btn-sm" title="把当前页面的标题和地址带进下一条消息(在网页里复制文字后,也可以把复制的文字引用过去)"
686
+ disabled={!feed.latest.current.page}
687
+ onClick={() => { const p = feed.latest.current.page; if (p?.type === "page" && p.url) onQuote({ url: p.url, title: p.title ?? "" }); }}>
688
+ {Icon.board()} 引用到对话
689
+ </button>
690
+ )}
393
691
  <button className="btn btn-ghost btn-sm" disabled={!!busy} onClick={() => act("close")}>{Icon.x()} 关闭浏览器</button>
394
692
  </div>
395
693
  )}
@@ -413,6 +711,8 @@ export function BrowserPane({ backend, jobId, readOnly }: { backend: BoardBacken
413
711
  </div>
414
712
  )}
415
713
  {shownError && <div className="bp-error">{shownError}</div>}
714
+ {shownError && ended && <button className="btn btn-line btn-sm" onClick={feed.reconnect}>重试</button>}
416
715
  </div>
716
+ </>
417
717
  );
418
718
  }