@spzxg/cli-ui 0.4.0 → 0.6.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/README.md +84 -10
- package/dist/cli-ui-embed-Dk2QGHxn.js +41146 -0
- package/dist/cli-ui-index-BFpRZXn4.js +11209 -0
- package/dist/cli-ui-jszip.min-B9tqMA-e.js +2324 -0
- package/dist/cli-ui-jszip.min-DIavJzUG.js +26 -0
- package/dist/cli-ui-xlsx-BKHhqcoa.js +93 -0
- package/dist/cli-ui.css +1 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +3 -1
- package/src/board/BrowserPane.tsx +396 -96
- package/src/board/ChangesPane.tsx +13 -4
- package/src/board/CliBoard.tsx +219 -37
- package/src/board/FileViewer.tsx +387 -73
- package/src/board/FilesPane.tsx +51 -4
- package/src/board/blocks.tsx +3 -2
- package/src/board/fileKind.ts +31 -0
- package/src/board/fileOps.ts +55 -0
- package/src/board/folderDownload.ts +161 -0
- package/src/board/frameQuote.ts +50 -0
- package/src/board/textLoad.ts +99 -0
- package/src/board/workspace.ts +129 -0
- package/src/board/zip.ts +85 -0
- package/src/chat/CliChat.tsx +122 -23
- package/src/chat/Composer.tsx +4 -4
- package/src/core/blocks.ts +69 -11
- package/src/core/client.ts +285 -41
- package/src/core/config.ts +18 -5
- package/src/core/conversation.ts +17 -7
- package/src/core/store.ts +340 -71
- package/src/core/textDecode.ts +24 -0
- package/src/core/types.ts +21 -6
- package/src/core/xlsx.ts +142 -0
- package/src/elements.tsx +106 -11
- package/src/index.ts +7 -2
- package/src/layout/CliSplit.tsx +53 -0
- package/src/layout/split.ts +214 -0
- package/src/msglist/CliMsgList.tsx +50 -22
- package/src/styles.css +101 -0
- package/src/ui/ErrorBoundary.tsx +38 -0
- package/src/ui/TextBlock.tsx +1 -1
- package/src/ui/Transcript.tsx +10 -5
- package/src/ui/transcriptModel.ts +66 -9
- package/dist/cli-ui-embed-D7xtcvGG.js +0 -37967
- package/dist/cli-ui-index-O0krpZBg.js +0 -13516
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
// 任务浏览器:移植自 Operator 的 BrowserPane(状态、实时画面、接管 / 交还、打开 / 关闭)。
|
|
2
|
-
// 全部经 center
|
|
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
|
|
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
|
-
/**
|
|
35
|
-
function
|
|
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 [
|
|
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
|
-
|
|
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;
|
|
172
|
+
onStatus: (s) => { attempt = 0; setStreamError(null); setStatus(s); },
|
|
47
173
|
onClose: (err) => {
|
|
48
174
|
if (!alive) return;
|
|
49
|
-
|
|
50
|
-
if (err
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
205
|
-
|
|
206
|
-
|
|
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 [
|
|
215
|
-
const [
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
if (
|
|
233
|
-
|
|
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
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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" &&
|
|
286
|
-
|
|
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
|
|
295
|
-
if (
|
|
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
|
|
298
|
-
|
|
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
|
|
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
|
|
324
|
-
{!
|
|
325
|
-
<div className="bp-empty">
|
|
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
|
-
{
|
|
328
|
-
|
|
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
|
|
344
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|