@spzxg/cli-ui 0.6.2 → 0.6.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/CHANGELOG.md +49 -0
- package/README.md +165 -19
- package/dist/cli-ui-embed-BAqonuoD.js +45952 -0
- package/dist/{cli-ui-index-BY5i3wId.js → cli-ui-index-b8wF7wrN.js} +2 -2
- package/dist/{cli-ui-jszip.min-m01fexHW.js → cli-ui-jszip.min-B0uLrCHg.js} +2 -2
- package/dist/{cli-ui-jszip.min-BZsUwI-s.js → cli-ui-jszip.min-ggP8OokP.js} +1 -1
- package/dist/{cli-ui-xlsx-CpWicJLT.js → cli-ui-xlsx-DEDdHrJJ.js} +1 -1
- package/dist/cli-ui.css +32 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +17 -10
- package/src/board/BrowserPane.tsx +346 -115
- package/src/board/ChangesPane.tsx +2 -2
- package/src/board/CliBoard.tsx +79 -50
- package/src/board/FileViewer.tsx +54 -31
- package/src/board/FilesPane.tsx +32 -15
- package/src/board/PanelControls.tsx +28 -0
- package/src/board/blocks.tsx +5 -5
- package/src/board/context.ts +65 -0
- package/src/board/fileOps.ts +26 -3
- package/src/board/fileWatch.ts +94 -0
- package/src/board/folderDownload.ts +30 -14
- package/src/board/frameQuote.ts +2 -2
- package/src/board/panels.ts +33 -0
- package/src/board/textLoad.ts +9 -7
- package/src/board/workspace.ts +53 -14
- package/src/chat/CliChat.tsx +93 -40
- package/src/chat/Composer.tsx +44 -42
- package/src/chat/RunControls.tsx +99 -0
- package/src/chat/configuration.ts +15 -0
- package/src/chat/draft.ts +41 -0
- package/src/chat/export.ts +2 -2
- package/src/components.tsx +13 -0
- package/src/core/blocks.ts +17 -6
- package/src/core/callbacks.ts +8 -0
- package/src/core/client.ts +103 -19
- package/src/core/conversation.ts +4 -3
- package/src/core/id.ts +15 -0
- package/src/core/identity.ts +33 -0
- package/src/core/provider.tsx +31 -14
- package/src/core/store.ts +151 -53
- package/src/core/types.ts +64 -9
- package/src/elements.tsx +135 -29
- package/src/embed.ts +6 -8
- package/src/index.ts +24 -17
- package/src/layout/CliSplit.tsx +1 -1
- package/src/layout/split.ts +67 -30
- package/src/msglist/CliMsgList.tsx +19 -13
- package/src/operator/branchFlow.ts +35 -0
- package/src/operator/index.ts +9 -0
- package/src/operator/liveStream.ts +1 -0
- package/src/operator/node/AgentConnect.tsx +624 -0
- package/src/operator/node/AppearancePanel.tsx +35 -0
- package/src/operator/node/BranchHistory.tsx +67 -0
- package/src/operator/node/BranchNavigator.tsx +36 -0
- package/src/operator/node/BrowserPane.tsx +128 -0
- package/src/operator/node/BrowserPopup.tsx +22 -0
- package/src/operator/node/Chat.tsx +517 -0
- package/src/operator/node/CommandPalette.tsx +213 -0
- package/src/operator/node/Composer.tsx +287 -0
- package/src/operator/node/InsightsView.tsx +831 -0
- package/src/operator/node/Layout.tsx +190 -0
- package/src/operator/node/Markdown.tsx +61 -0
- package/src/operator/node/Modal.tsx +179 -0
- package/src/operator/node/NeedsYouMenu.tsx +54 -0
- package/src/operator/node/OnboardingWizard.tsx +250 -0
- package/src/operator/node/Orchestrator.tsx +634 -0
- package/src/operator/node/ProjectLanding.tsx +86 -0
- package/src/operator/node/ProjectsColumn.tsx +137 -0
- package/src/operator/node/ProviderAuth.tsx +561 -0
- package/src/operator/node/RunControls.tsx +2 -0
- package/src/operator/node/RunSwitch.tsx +113 -0
- package/src/operator/node/Services.tsx +245 -0
- package/src/operator/node/SessionRail.tsx +260 -0
- package/src/operator/node/SessionView.tsx +288 -0
- package/src/operator/node/SettingsView.tsx +690 -0
- package/src/operator/node/TaskBoard.tsx +352 -0
- package/src/operator/node/TaskChanges.tsx +530 -0
- package/src/operator/node/TaskHero.tsx +50 -0
- package/src/operator/node/TasksColumn.tsx +200 -0
- package/src/operator/node/Terminal.tsx +143 -0
- package/src/operator/node/Transcript.tsx +468 -0
- package/src/operator/node/ViewFile.tsx +79 -0
- package/src/operator/node/Welcome.tsx +101 -0
- package/src/operator/node/WorktreePrune.tsx +333 -0
- package/src/operator/node/agents.ts +58 -0
- package/src/operator/node/api.ts +40 -0
- package/src/operator/node/boardBackend.ts +82 -0
- package/src/operator/node/boardEvents.ts +22 -0
- package/src/operator/node/boardInteraction.ts +15 -0
- package/src/operator/node/boardState.ts +57 -0
- package/src/operator/node/boardWorkspace.ts +12 -0
- package/src/operator/node/branchFlow.ts +1 -0
- package/src/operator/node/composerDraft.ts +52 -0
- package/src/operator/node/fileLinks.ts +81 -0
- package/src/operator/node/format.ts +290 -0
- package/src/operator/node/github.tsx +234 -0
- package/src/operator/node/icons.tsx +88 -0
- package/src/operator/node/liveStream.ts +270 -0
- package/src/operator/node/modals.tsx +720 -0
- package/src/operator/node/navHistory.ts +70 -0
- package/src/operator/node/persist.ts +26 -0
- package/src/operator/node/protocol/agents/optIn.ts +9 -0
- package/src/operator/node/protocol/agents/types.ts +394 -0
- package/src/operator/node/protocol/approvalFailure.ts +77 -0
- package/src/operator/node/protocol/authFailure.ts +66 -0
- package/src/operator/node/protocol/customModels.ts +23 -0
- package/src/operator/node/protocol/events.ts +13 -0
- package/src/operator/node/protocol/features.ts +70 -0
- package/src/operator/node/protocol/internalUsage.ts +1 -0
- package/src/operator/node/protocol/promptLimits.ts +52 -0
- package/src/operator/node/protocol/storageRetention.ts +13 -0
- package/src/operator/node/protocol/types.ts +332 -0
- package/src/operator/node/protocol/usageLimit.ts +72 -0
- package/src/operator/node/shared.tsx +89 -0
- package/src/operator/node/transcriptModel.ts +129 -0
- package/src/operator/node/types.ts +296 -0
- package/src/operator/node/useGlobalEvents.ts +97 -0
- package/src/operator/node/useOrchestrator.ts +696 -0
- package/src/operator/node/usePrefs.ts +114 -0
- package/src/operator/node/useRecaps.ts +91 -0
- package/src/operator/node/useTaskStream.ts +280 -0
- package/src/operator.base.css +1806 -0
- package/src/operator.css +194 -16
- package/src/styles.css +49 -1
- package/src/ui/Markdown.tsx +7 -2
- package/src/ui/Popover.tsx +82 -0
- package/src/ui/TextBlock.tsx +3 -3
- package/src/ui/Transcript.tsx +17 -10
- package/src/ui/fileLinks.ts +6 -0
- package/src/ui/format.ts +1 -1
- package/src/ui/shared.tsx +1 -1
- package/src/ui/transcriptModel.ts +2 -2
- package/dist/cli-ui-embed-6IJ_jRVh.js +0 -41223
|
@@ -9,14 +9,21 @@
|
|
|
9
9
|
// · 按住拖动带着按键(选中文字、拖滑块);滚轮合并发送;
|
|
10
10
|
// · 网页的 alert / confirm / prompt 画面里看不到,这里单独弹出来处理。
|
|
11
11
|
// Operator 的「在节点屏幕上显示」「弹出窗口」「登录状态」是节点本机的功能,这里不提供。
|
|
12
|
-
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState, type MutableRefObject } from "react";
|
|
13
|
-
import { CenterError, isMissingRoute, type BoardBackend } from "../core/client
|
|
14
|
-
import { jitter } from "../core/store
|
|
15
|
-
import type { BrowserFramesState, BrowserMessage, BrowserStatus } from "../core/types
|
|
16
|
-
import { Icon } from "../ui/icons
|
|
12
|
+
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState, type MutableRefObject, type ReactNode } from "react";
|
|
13
|
+
import { CenterError, isMissingRoute, type BoardBackend } from "../core/client";
|
|
14
|
+
import { jitter } from "../core/store";
|
|
15
|
+
import type { BrowserFramesState, BrowserMessage, BrowserStatus } from "../core/types";
|
|
16
|
+
import { Icon } from "../ui/icons";
|
|
17
17
|
|
|
18
18
|
interface FrameMeta { deviceWidth: number; deviceHeight: number }
|
|
19
19
|
type Dialog = Extract<BrowserMessage, { type: "dialog" }>;
|
|
20
|
+
type Tabs = Extract<BrowserMessage, { type: "tabs" }>;
|
|
21
|
+
type Target = { targetId: string; targetEpoch: number };
|
|
22
|
+
function messageTarget(message: BrowserMessage): Target | null {
|
|
23
|
+
return typeof message.targetId === "string" && message.targetId.length > 0 && Number.isSafeInteger(message.targetEpoch) && message.targetEpoch! >= 0
|
|
24
|
+
? { targetId: message.targetId, targetEpoch: message.targetEpoch! } : null;
|
|
25
|
+
}
|
|
26
|
+
const sameTarget = (a: Target | null, b: Target | null) => !!a && !!b && a.targetId === b.targetId && a.targetEpoch === b.targetEpoch;
|
|
20
27
|
|
|
21
28
|
const CAPABILITY_LABEL: Record<string, string> = { config: "任务配置", manual: "手动打开过", ai: "AI 自行启用" };
|
|
22
29
|
const SPECIAL_KEYS: Record<string, number> = {
|
|
@@ -90,6 +97,7 @@ interface BrowserFeed {
|
|
|
90
97
|
/** 发输入用的 viewer id;ready = 输入能送到节点。 */
|
|
91
98
|
viewer: string | null;
|
|
92
99
|
ready: boolean;
|
|
100
|
+
generation: number;
|
|
93
101
|
frames: BrowserFramesState;
|
|
94
102
|
paused: boolean;
|
|
95
103
|
reconnect: () => void;
|
|
@@ -97,7 +105,7 @@ interface BrowserFeed {
|
|
|
97
105
|
* 最近的页面、弹窗、画面:中枢在连上时就推过来,那时画面区(要等状态是「已打开」才挂上)可能还没挂好,
|
|
98
106
|
* 挂上时从这里补上——否则地址栏一直是空的,要等下一次换页。
|
|
99
107
|
*/
|
|
100
|
-
latest: MutableRefObject<{ page?: BrowserMessage; dialog?: BrowserMessage | null; frame?: BrowserMessage }>;
|
|
108
|
+
latest: MutableRefObject<{ generation: number; protocol: boolean; tabs?: Tabs; page?: BrowserMessage; dialog?: BrowserMessage | null; frame?: BrowserMessage }>;
|
|
101
109
|
}
|
|
102
110
|
|
|
103
111
|
/**
|
|
@@ -112,15 +120,44 @@ function useBrowserFeed(backend: BoardBackend, jobId: string, sink: MutableRefOb
|
|
|
112
120
|
const [ended, setEnded] = useState<string | null>(null);
|
|
113
121
|
const [viewer, setViewer] = useState<string | null>(null);
|
|
114
122
|
const [ready, setReady] = useState(false);
|
|
123
|
+
const [generation, setGeneration] = useState(0);
|
|
115
124
|
const [frames, setFrames] = useState<BrowserFramesState>({ state: "idle" });
|
|
116
125
|
const [retry, setRetry] = useState(0);
|
|
117
126
|
const reconnect = useCallback(() => { setEnded(null); setRetry((n) => n + 1); }, []);
|
|
118
|
-
const latest = useRef<BrowserFeed["latest"]["current"]>({});
|
|
127
|
+
const latest = useRef<BrowserFeed["latest"]["current"]>({ generation: 0, protocol: false });
|
|
128
|
+
const beginViewer = useCallback(() => {
|
|
129
|
+
const previous = latest.current;
|
|
130
|
+
// Epochs belong to one stream. A recovered viewer may start at epoch 1 again.
|
|
131
|
+
// Legacy servers retain their dialog while reconnecting, as before.
|
|
132
|
+
const next = previous.generation + 1;
|
|
133
|
+
latest.current = previous.protocol ? { generation: next, protocol: true } : { ...previous, generation: next };
|
|
134
|
+
setGeneration(next);
|
|
135
|
+
}, []);
|
|
119
136
|
const deliver = useCallback((m: BrowserMessage) => {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
137
|
+
const cache = latest.current;
|
|
138
|
+
const target = messageTarget(m);
|
|
139
|
+
const current = cache.page ? messageTarget(cache.page) : null;
|
|
140
|
+
if (target) cache.protocol = true;
|
|
141
|
+
if (m.type === "tabs") {
|
|
142
|
+
cache.protocol = true; cache.tabs = m;
|
|
143
|
+
if (m.activeId === null) { delete cache.page; delete cache.frame; cache.dialog = null; }
|
|
144
|
+
}
|
|
145
|
+
if (m.type === "page") {
|
|
146
|
+
if (cache.protocol && !target) return;
|
|
147
|
+
if (target) {
|
|
148
|
+
cache.protocol = true;
|
|
149
|
+
if (current && (target.targetEpoch < current.targetEpoch || (target.targetEpoch === current.targetEpoch && target.targetId !== current.targetId))) return;
|
|
150
|
+
if (!sameTarget(current, target)) { delete cache.frame; cache.dialog = null; }
|
|
151
|
+
}
|
|
152
|
+
cache.page = m;
|
|
153
|
+
}
|
|
154
|
+
// A frame can arrive late or precede its page announcement. Neither is safe
|
|
155
|
+
// to display or use for coordinate input in the current page.
|
|
156
|
+
else if (cache.protocol && m.type !== "tabs" && m.type !== "error" && (!target || !sameTarget(current, target))) return;
|
|
157
|
+
else if (target && current && !sameTarget(current, target)) return;
|
|
158
|
+
if (m.type === "dialog") cache.dialog = m;
|
|
159
|
+
else if (m.type === "dialog_closed") cache.dialog = null;
|
|
160
|
+
else if (m.type === "frame") cache.frame = m;
|
|
124
161
|
sink.current?.(m);
|
|
125
162
|
}, [sink]);
|
|
126
163
|
|
|
@@ -133,32 +170,45 @@ function useBrowserFeed(backend: BoardBackend, jobId: string, sink: MutableRefOb
|
|
|
133
170
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
134
171
|
let attempt = 0;
|
|
135
172
|
const connect = () => {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
173
|
+
if (!alive) return;
|
|
174
|
+
let finished = false;
|
|
175
|
+
let seenViewer = false;
|
|
176
|
+
let stopThis: (() => void) | null = null;
|
|
177
|
+
const retire = () => {
|
|
178
|
+
if (!alive || finished) return false;
|
|
179
|
+
finished = true;
|
|
180
|
+
stopThis?.();
|
|
181
|
+
setViewer(null); setReady(false);
|
|
182
|
+
return true;
|
|
183
|
+
};
|
|
184
|
+
stopThis = backend.browserLive!(jobId, {
|
|
185
|
+
onViewer: (id, isReady) => { if (!alive || finished) return; if (!seenViewer) { seenViewer = true; beginViewer(); } attempt = 0; setViewer(id); setReady(isReady); },
|
|
186
|
+
onStatus: (s) => { if (!alive || finished) return; attempt = 0; setStreamError(null); setStatus(s); },
|
|
187
|
+
onMessage: (message) => { if (alive && !finished) deliver(message); },
|
|
188
|
+
onFrames: (state) => { if (alive && !finished) setFrames(state); },
|
|
142
189
|
onEnd: (end) => {
|
|
143
|
-
if (end.reason
|
|
190
|
+
if (end.reason !== "refused" && end.reason !== "client_closed") return;
|
|
191
|
+
if (!retire()) return;
|
|
192
|
+
setFrames({ state: "idle" });
|
|
193
|
+
if (end.reason === "refused") setEnded(end.detail || "执行节点拒绝了浏览器连接");
|
|
144
194
|
},
|
|
145
195
|
onClose: (err) => {
|
|
146
|
-
|
|
147
|
-
setReady(false);
|
|
148
|
-
if (!alive || final) return;
|
|
196
|
+
if (!retire()) return;
|
|
149
197
|
if (isMissingRoute(err)) { setMode("legacy"); return; }
|
|
150
198
|
// 对话不存在:重连也没用。
|
|
151
|
-
if (err && err.status
|
|
199
|
+
if (err && [400,404,405,410,501].includes(err.status)) { setEnded(err.message); return; }
|
|
152
200
|
if (err) setStreamError(err.message);
|
|
153
201
|
// 409 还没派发:隔几秒再问;401 / 403 多半是宿主会话过期,30 秒后再试(续上会话就自己恢复);
|
|
154
202
|
// 其余(断线、中枢重启、人太多)按退避重连。
|
|
155
203
|
timer = setTimeout(connect, nextTry(err, attempt++));
|
|
156
204
|
},
|
|
157
205
|
});
|
|
206
|
+
stop = stopThis;
|
|
207
|
+
if (finished) stopThis();
|
|
158
208
|
};
|
|
159
209
|
connect();
|
|
160
210
|
return () => { alive = false; stop?.(); if (timer) clearTimeout(timer); };
|
|
161
|
-
}, [backend, jobId, mode, visible, retry, deliver]);
|
|
211
|
+
}, [backend, jobId, mode, visible, retry, deliver, beginViewer]);
|
|
162
212
|
|
|
163
213
|
// ── 旧版:状态一条(页面在后台时也断开,切回来再连:人多时少占中枢和节点的连接) ──
|
|
164
214
|
useEffect(() => {
|
|
@@ -168,15 +218,23 @@ function useBrowserFeed(backend: BoardBackend, jobId: string, sink: MutableRefOb
|
|
|
168
218
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
169
219
|
let attempt = 0;
|
|
170
220
|
const connect = () => {
|
|
171
|
-
|
|
172
|
-
|
|
221
|
+
if (!alive) return;
|
|
222
|
+
let finished = false;
|
|
223
|
+
let stopThis: (() => void) | null = null;
|
|
224
|
+
stopThis = backend.browserEvents(jobId, {
|
|
225
|
+
onStatus: (s) => { if (!alive || finished) return; attempt = 0; setStreamError(null); setStatus(s); },
|
|
226
|
+
onError: (error) => { if (alive && !finished) setStreamError(error.message); },
|
|
173
227
|
onClose: (err) => {
|
|
174
|
-
if (!alive) return;
|
|
175
|
-
|
|
228
|
+
if (!alive || finished) return;
|
|
229
|
+
finished = true;
|
|
230
|
+
stopThis?.();
|
|
231
|
+
if (err && [400,404,405,410,501].includes(err.status)) { setEnded(err.message); return; }
|
|
176
232
|
if (err) setStreamError(err.message);
|
|
177
233
|
timer = setTimeout(connect, nextTry(err, attempt++));
|
|
178
234
|
},
|
|
179
235
|
});
|
|
236
|
+
stop = stopThis;
|
|
237
|
+
if (finished) stopThis();
|
|
180
238
|
};
|
|
181
239
|
connect();
|
|
182
240
|
return () => { alive = false; stop?.(); if (timer) clearTimeout(timer); };
|
|
@@ -190,34 +248,51 @@ function useBrowserFeed(backend: BoardBackend, jobId: string, sink: MutableRefOb
|
|
|
190
248
|
let stop: (() => void) | null = null;
|
|
191
249
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
192
250
|
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
251
|
const connect = () => {
|
|
252
|
+
if (!alive) return;
|
|
253
|
+
let finished = false;
|
|
254
|
+
let seenViewer = false;
|
|
255
|
+
let stopThis: (() => void) | null = null;
|
|
256
|
+
const again = (detail?: string) => {
|
|
257
|
+
// A stream can emit an end event and then close; retire it only once.
|
|
258
|
+
if (!alive || finished) return;
|
|
259
|
+
finished = true;
|
|
260
|
+
stopThis?.();
|
|
261
|
+
setViewer(null);
|
|
262
|
+
setReady(false);
|
|
263
|
+
setFrames({ state: "retrying", ...(detail ? { detail } : {}) });
|
|
264
|
+
timer = setTimeout(connect, retryDelay(attempt++));
|
|
265
|
+
};
|
|
201
266
|
setFrames({ state: attempt ? "retrying" : "connecting" });
|
|
202
|
-
|
|
203
|
-
onViewer: (id) => { attempt = 0; setViewer(id); setReady(true); setFrames({ state: "live" }); },
|
|
204
|
-
onMessage: deliver,
|
|
205
|
-
onEnd: (end) => {
|
|
206
|
-
|
|
267
|
+
stopThis = backend.browserFrames(jobId, {
|
|
268
|
+
onViewer: (id) => { if (!alive || finished) return; if (!seenViewer) { seenViewer = true; beginViewer(); } attempt = 0; setViewer(id); setReady(true); setFrames({ state: "live" }); },
|
|
269
|
+
onMessage: (message) => { if (alive && !finished) deliver(message); },
|
|
270
|
+
onEnd: (end) => {
|
|
271
|
+
if (end.reason !== "client_closed") { again(end.reason === "refused" ? end.detail : undefined); return; }
|
|
272
|
+
if (!alive || finished) return;
|
|
273
|
+
finished = true;
|
|
274
|
+
stopThis?.();
|
|
275
|
+
setViewer(null); setReady(false); setFrames({ state: "idle" });
|
|
276
|
+
},
|
|
277
|
+
// Clean EOF also invalidates the old viewer and needs a reconnect.
|
|
278
|
+
onClose: (err) => again(err?.message),
|
|
207
279
|
});
|
|
280
|
+
stop = stopThis;
|
|
281
|
+
// Custom transports may close synchronously before returning cleanup.
|
|
282
|
+
if (finished) stopThis();
|
|
208
283
|
};
|
|
209
284
|
connect();
|
|
210
285
|
return () => { alive = false; stop?.(); if (timer) clearTimeout(timer); };
|
|
211
|
-
}, [backend, jobId, legacyRunning, retry, deliver]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
286
|
+
}, [backend, jobId, legacyRunning, retry, deliver, beginViewer]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
212
287
|
|
|
213
288
|
// 浏览器关了:上一个页面的地址、弹窗、画面都作废,重新打开时不先闪一下旧画面。
|
|
214
289
|
const running = status?.state === "running";
|
|
215
|
-
useEffect(() => { if (!running) latest.current = {}; }, [running]);
|
|
290
|
+
useEffect(() => { if (status && !running) latest.current = { generation: latest.current.generation, protocol: false }; }, [running, status]);
|
|
216
291
|
|
|
217
|
-
return { status, setStatus, streamError, ended, viewer, ready, frames, paused: !visible, reconnect, latest };
|
|
292
|
+
return { status, setStatus, streamError, ended, viewer, ready, generation, frames, paused: !visible, reconnect, latest };
|
|
218
293
|
}
|
|
219
294
|
|
|
220
|
-
interface ScreenHandle { draw(base64: string, meta: FrameMeta | null): void }
|
|
295
|
+
interface ScreenHandle { draw(base64: string, meta: FrameMeta | null): void; clear(): void }
|
|
221
296
|
|
|
222
297
|
/** base64 → 字节。 */
|
|
223
298
|
function decodeBase64(data: string): Uint8Array {
|
|
@@ -234,27 +309,38 @@ function decodeBase64(data: string): Uint8Array {
|
|
|
234
309
|
* · 输入用指针事件:鼠标照原样;触屏点一下 = 点击,拖动 = 滚动;接管时滚轮 / 触摸只作用于画面,不滚动宿主页面。
|
|
235
310
|
* · 画面按比例缩放,适应容器宽高(窄的侧栏、弹窗、整页都可以)。
|
|
236
311
|
*/
|
|
237
|
-
const Screen = forwardRef<ScreenHandle, { human: boolean; hidden: boolean; label: string; send: (msg: Record<string, unknown>) => void; onFocusKeys: () => void }>(
|
|
238
|
-
function Screen({ human, hidden, label, send, onFocusKeys }, ref) {
|
|
312
|
+
const Screen = forwardRef<ScreenHandle, { human: boolean; inputEpoch: number; hidden: boolean; label: string; send: (msg: Record<string, unknown>) => void; onFocusKeys: () => void }>(
|
|
313
|
+
function Screen({ human, inputEpoch, hidden, label, send, onFocusKeys }, ref) {
|
|
239
314
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
240
315
|
const imgRef = useRef<HTMLImageElement | null>(null);
|
|
241
316
|
const meta = useRef<FrameMeta | null>(null);
|
|
242
317
|
const seq = useRef({ next: 0, drawn: 0 });
|
|
318
|
+
const imageGeneration = useRef(0);
|
|
243
319
|
const [fallback] = useState(() => typeof createImageBitmap !== "function");
|
|
244
|
-
const [
|
|
320
|
+
const [fallbackFrame, setFallbackFrame] = useState<{src:string;meta:FrameMeta|null} | null>(null);
|
|
245
321
|
|
|
246
322
|
useImperativeHandle(ref, () => ({
|
|
323
|
+
clear() {
|
|
324
|
+
imageGeneration.current++;
|
|
325
|
+
seq.current = { next: 0, drawn: 0 };
|
|
326
|
+
meta.current = null;
|
|
327
|
+
setFallbackFrame(null);
|
|
328
|
+
const canvas = canvasRef.current;
|
|
329
|
+
// Resetting dimensions also clears its pixels without requiring a 2d context.
|
|
330
|
+
if (canvas) canvas.width = canvas.width;
|
|
331
|
+
},
|
|
247
332
|
draw(data, frameMeta) {
|
|
248
|
-
meta.current = frameMeta;
|
|
249
333
|
const type = data.startsWith("iVBOR") ? "image/png" : "image/jpeg";
|
|
250
|
-
if (fallback) {
|
|
334
|
+
if (fallback) { setFallbackFrame({src:`data:${type};base64,${data}`,meta:frameMeta}); return; }
|
|
251
335
|
const n = ++seq.current.next;
|
|
336
|
+
const generation = imageGeneration.current;
|
|
252
337
|
let bytes: Uint8Array;
|
|
253
338
|
try { bytes = decodeBase64(data); } catch { return; }
|
|
254
339
|
createImageBitmap(new Blob([bytes as BlobPart], { type })).then((bitmap) => {
|
|
255
340
|
const canvas = canvasRef.current;
|
|
256
|
-
if (!canvas || n < seq.current.drawn) { bitmap.close(); return; }
|
|
341
|
+
if (!canvas || generation !== imageGeneration.current || n < seq.current.drawn) { bitmap.close(); return; }
|
|
257
342
|
seq.current.drawn = n;
|
|
343
|
+
meta.current = frameMeta;
|
|
258
344
|
if (canvas.width !== bitmap.width || canvas.height !== bitmap.height) { canvas.width = bitmap.width; canvas.height = bitmap.height; }
|
|
259
345
|
canvas.getContext("2d")?.drawImage(bitmap, 0, 0);
|
|
260
346
|
bitmap.close();
|
|
@@ -262,11 +348,18 @@ const Screen = forwardRef<ScreenHandle, { human: boolean; hidden: boolean; label
|
|
|
262
348
|
},
|
|
263
349
|
}), [fallback]);
|
|
264
350
|
|
|
351
|
+
// Identical image bytes may accompany updated CSS viewport metadata and
|
|
352
|
+
// need not trigger another load event in the fallback image element.
|
|
353
|
+
useEffect(()=>{const img=imgRef.current;if(fallbackFrame&&img?.complete&&img.naturalWidth>0)meta.current=fallbackFrame.meta;},[fallbackFrame]);
|
|
354
|
+
|
|
265
355
|
const el = () => (fallback ? imgRef.current : canvasRef.current);
|
|
266
356
|
/** 屏幕坐标 → 页面 CSS 像素(按帧自带的页面尺寸换算)。 */
|
|
267
357
|
const point = useCallback((clientX: number, clientY: number) => {
|
|
268
358
|
const node = el();
|
|
269
359
|
if (!node) return null;
|
|
360
|
+
// Network arrival is not an interactive image: never map a click onto
|
|
361
|
+
// the blank initial canvas or an image that has not decoded yet.
|
|
362
|
+
if (fallback ? !(node instanceof HTMLImageElement && node.complete && node.naturalWidth > 0) : seq.current.drawn === 0) return null;
|
|
270
363
|
const r = node.getBoundingClientRect();
|
|
271
364
|
if (!r.width || !r.height) return null;
|
|
272
365
|
const natural = node instanceof HTMLCanvasElement ? { w: node.width, h: node.height } : { w: node.naturalWidth, h: node.naturalHeight };
|
|
@@ -290,13 +383,13 @@ const Screen = forwardRef<ScreenHandle, { human: boolean; hidden: boolean; label
|
|
|
290
383
|
};
|
|
291
384
|
|
|
292
385
|
const onPointerDown = (e: React.PointerEvent) => {
|
|
293
|
-
if (!human) return;
|
|
386
|
+
if (!human || !point(e.clientX,e.clientY)) return;
|
|
294
387
|
e.preventDefault();
|
|
295
|
-
onFocusKeys();
|
|
296
388
|
if (e.pointerType === "touch") {
|
|
297
389
|
touch.current = { id: e.pointerId, x: e.clientX, y: e.clientY, lastX: e.clientX, lastY: e.clientY, moved: false };
|
|
298
390
|
return;
|
|
299
391
|
}
|
|
392
|
+
onFocusKeys();
|
|
300
393
|
(e.currentTarget as Element).setPointerCapture?.(e.pointerId);
|
|
301
394
|
pressed.current = MOUSE_BUTTONS[e.button] ?? "left";
|
|
302
395
|
mouse("mousePressed", e, { button: pressed.current, clickCount: e.detail || 1 });
|
|
@@ -326,29 +419,35 @@ const Screen = forwardRef<ScreenHandle, { human: boolean; hidden: boolean; label
|
|
|
326
419
|
const t = touch.current;
|
|
327
420
|
if (e.pointerType === "touch" && t && t.id === e.pointerId) {
|
|
328
421
|
touch.current = null;
|
|
329
|
-
if (!t.moved) {
|
|
422
|
+
if (!t.moved && point(e.clientX, e.clientY)) {
|
|
423
|
+
// Send before focusing: the mobile keyboard can resize/reposition the frame.
|
|
330
424
|
// 触屏点一下 = 鼠标点击
|
|
331
425
|
mouse("mousePressed", e, { button: "left", clickCount: 1 });
|
|
332
426
|
mouse("mouseReleased", e, { button: "left", clickCount: 1 });
|
|
427
|
+
onFocusKeys();
|
|
333
428
|
}
|
|
334
429
|
return;
|
|
335
430
|
}
|
|
431
|
+
if (e.pointerType === "touch" || !pressed.current) return;
|
|
336
432
|
pressed.current = null;
|
|
337
433
|
mouse("mouseReleased", e, { button: MOUSE_BUTTONS[e.button] ?? "left", clickCount: e.detail || 1 });
|
|
338
434
|
};
|
|
339
435
|
|
|
340
|
-
//
|
|
341
|
-
useEffect(()
|
|
342
|
-
|
|
436
|
+
// A new control/viewer epoch cannot inherit a drag from the previous one.
|
|
437
|
+
useEffect(()=>{touch.current=null;pressed.current=null;lastPoint.current=null;},[human,inputEpoch]);
|
|
438
|
+
const release = useCallback(() => {
|
|
439
|
+
touch.current=null;
|
|
343
440
|
if (!pressed.current || !lastPoint.current) return;
|
|
344
441
|
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 });
|
|
442
|
+
if (p && humanRef.current) send({ type: "mouse", event: "mouseReleased", ...p, button: pressed.current, clickCount: 1, modifiers: 0 });
|
|
346
443
|
pressed.current = null;
|
|
347
|
-
|
|
444
|
+
},[point,send]);
|
|
445
|
+
// Cancellation/window blur must release the remote pointer as well.
|
|
446
|
+
useEffect(() => {
|
|
348
447
|
window.addEventListener("blur", release);
|
|
349
448
|
document.addEventListener("visibilitychange", release);
|
|
350
449
|
return () => { window.removeEventListener("blur", release); document.removeEventListener("visibilitychange", release); };
|
|
351
|
-
}, [
|
|
450
|
+
}, [release]);
|
|
352
451
|
|
|
353
452
|
// 滚轮:React 的 onWheel 是被动监听,拦不住宿主页面滚动,所以用原生监听(passive: false)。
|
|
354
453
|
useEffect(() => {
|
|
@@ -369,13 +468,13 @@ const Screen = forwardRef<ScreenHandle, { human: boolean; hidden: boolean; label
|
|
|
369
468
|
className: `bp-frame ${human ? "live" : ""}`,
|
|
370
469
|
style: { display: hidden ? "none" : undefined, touchAction: human ? "none" : "auto" } as React.CSSProperties,
|
|
371
470
|
onPointerDown, onPointerMove, onPointerUp,
|
|
372
|
-
onPointerCancel:
|
|
471
|
+
onPointerCancel: release,
|
|
373
472
|
onContextMenu: (e: React.MouseEvent) => { if (human) e.preventDefault(); },
|
|
374
473
|
role: "img" as const,
|
|
375
474
|
"aria-label": label,
|
|
376
475
|
};
|
|
377
476
|
return fallback
|
|
378
|
-
? (
|
|
477
|
+
? (fallbackFrame ? <img ref={imgRef} src={fallbackFrame.src} onLoad={()=>{meta.current=fallbackFrame.meta;}} alt={label} draggable={false} {...common} /> : null)
|
|
379
478
|
: <canvas ref={canvasRef} {...common} />;
|
|
380
479
|
},
|
|
381
480
|
);
|
|
@@ -388,14 +487,23 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
|
|
|
388
487
|
const screenRef = useRef<ScreenHandle | null>(null);
|
|
389
488
|
const keysRef = useRef<HTMLTextAreaElement | null>(null);
|
|
390
489
|
const [hasFrame, setHasFrame] = useState(false);
|
|
490
|
+
const [tabs, setTabs] = useState<Tabs | null>(null);
|
|
491
|
+
const [switching, setSwitching] = useState<string | null>(null);
|
|
492
|
+
const targetRef = useRef<Target | null>(null);
|
|
493
|
+
const frameReady = useRef(false);
|
|
494
|
+
const pendingTarget = useRef<string | null>(null);
|
|
495
|
+
const pageGeneration = useRef(feed.latest.current.generation);
|
|
391
496
|
const [page, setPage] = useState<{ url: string; title: string }>({ url: "", title: "" });
|
|
392
497
|
const [addr, setAddr] = useState("");
|
|
393
498
|
const [editing, setEditing] = useState(false);
|
|
394
499
|
const [notice, setNotice] = useState<string | null>(null);
|
|
395
500
|
const [dialog, setDialog] = useState<Dialog | null>(null);
|
|
396
501
|
const [promptText, setPromptText] = useState("");
|
|
502
|
+
const [dialogSending, setDialogSending] = useState(false);
|
|
503
|
+
const pendingDialog = useRef<Record<string, unknown> | null>(null);
|
|
397
504
|
// 在网页里复制出来的文字:除了写进剪贴板,还可以「引用到对话」。
|
|
398
|
-
const [copied, setCopied] = useState<string | null>(null);
|
|
505
|
+
const [copied, setCopied] = useState<{text:string;page:{url:string;title:string}} | null>(null);
|
|
506
|
+
const clipboardSequence=useRef(0);
|
|
399
507
|
// 画面没有响应:接管时发了操作,之后 UNRESPONSIVE_MS 一帧都没来。
|
|
400
508
|
const [stuck, setStuck] = useState(false);
|
|
401
509
|
const lastInputAt = useRef(0);
|
|
@@ -408,32 +516,6 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
|
|
|
408
516
|
}, []);
|
|
409
517
|
useEffect(() => () => { if (noticeTimer.current) clearTimeout(noticeTimer.current); }, []);
|
|
410
518
|
|
|
411
|
-
// 连接上来的消息:画面直接画,其余更新界面。挂上时先补上挂上之前到的页面 / 弹窗 / 最后一帧。
|
|
412
|
-
const latest = feed.latest;
|
|
413
|
-
useEffect(() => {
|
|
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
|
-
}
|
|
428
|
-
};
|
|
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]);
|
|
436
|
-
|
|
437
519
|
useEffect(() => { if (!editing) setAddr(page.url); }, [page.url, editing]);
|
|
438
520
|
const onPageRef = useRef(onPage);
|
|
439
521
|
onPageRef.current = onPage;
|
|
@@ -451,16 +533,35 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
|
|
|
451
533
|
|
|
452
534
|
// 输入按顺序发:一个请求在路上时攒着,回来后一次发完(鼠标移动只留最后一个,连续的滚轮合并)。
|
|
453
535
|
const viewerRef = useRef<string | null>(null);
|
|
454
|
-
viewerRef.current = feed.ready ? feed.viewer : null;
|
|
455
536
|
const queue = useRef<Array<Record<string, unknown>>>([]);
|
|
456
|
-
const
|
|
537
|
+
const controlRef = useRef(human); controlRef.current=human;
|
|
538
|
+
const mountedRef = useRef(true);
|
|
539
|
+
useEffect(()=>{mountedRef.current=true;return()=>{mountedRef.current=false;queue.current=[];};},[]);
|
|
540
|
+
useEffect(() => { if (!feed.ready || !human) { pendingDialog.current = null; setDialogSending(false); } }, [feed.ready, feed.viewer, human]);
|
|
541
|
+
const inflight = useRef<{ viewer:string; epoch:number } | null>(null);
|
|
542
|
+
const inputEpoch = useRef(0);
|
|
543
|
+
const previousControl = useRef(human);
|
|
544
|
+
const nextViewer=feed.ready?feed.viewer:null;
|
|
545
|
+
if(viewerRef.current!==nextViewer||previousControl.current!==human){
|
|
546
|
+
inputEpoch.current++;queue.current=[];inflight.current=null;
|
|
547
|
+
}
|
|
548
|
+
previousControl.current=human;
|
|
549
|
+
viewerRef.current=nextViewer;
|
|
457
550
|
const reconnect = feed.reconnect;
|
|
458
551
|
const flush = useCallback(() => {
|
|
459
552
|
const viewer = viewerRef.current;
|
|
460
|
-
if (inflight.current || !viewer || !queue.current.length) return;
|
|
553
|
+
if (!mountedRef.current || !controlRef.current || inflight.current || !viewer || !queue.current.length) return;
|
|
461
554
|
const batch = queue.current.splice(0, 50);
|
|
462
|
-
|
|
555
|
+
const token={viewer,epoch:inputEpoch.current};
|
|
556
|
+
inflight.current = token;
|
|
463
557
|
backend.browserInput(jobId, viewer, batch).catch((error: unknown) => {
|
|
558
|
+
if(!mountedRef.current||inflight.current!==token||viewerRef.current!==viewer||!controlRef.current)return;
|
|
559
|
+
if (pendingDialog.current && batch.includes(pendingDialog.current)) { pendingDialog.current = null; setDialogSending(false); }
|
|
560
|
+
if (batch.some((message) => message.type === "select_tab")) {
|
|
561
|
+
flash("页面切换未确认,正在重新连接…", 5000);
|
|
562
|
+
reconnect();
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
464
565
|
// 画面连接已失效(中枢重启、连接断开):丢掉这批输入并重连,避免用户以为点了没反应。
|
|
465
566
|
if (error instanceof CenterError && (error.status === 404 || error.status === 409)) {
|
|
466
567
|
queue.current = [];
|
|
@@ -474,13 +575,19 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
|
|
|
474
575
|
const why = error instanceof CenterError && error.status === 429 ? "操作太快,任务中心暂时没接收" : "网络不稳";
|
|
475
576
|
flash(`${why}:刚才的操作可能没有送达,请看画面确认后再操作`, 5000);
|
|
476
577
|
}
|
|
477
|
-
}).finally(() => { inflight.current =
|
|
578
|
+
}).finally(() => { if(inflight.current!==token)return;inflight.current = null; flush(); });
|
|
478
579
|
}, [backend, jobId, reconnect, flash]);
|
|
479
580
|
const send = useCallback((msg: Record<string, unknown>) => {
|
|
581
|
+
if(!mountedRef.current||!controlRef.current)return false;
|
|
480
582
|
// 画面连接还没就绪(刚接管、正在重连):不攒着等连上再补发——过几秒才落下的点击比没点更糟。
|
|
481
583
|
if (!viewerRef.current) {
|
|
482
584
|
if (msg.type !== "mouse" || msg.event === "mousePressed") flash("画面连接还没就绪,刚才的操作没有送达,请稍后再操作");
|
|
483
|
-
return;
|
|
585
|
+
return false;
|
|
586
|
+
}
|
|
587
|
+
if (feed.latest.current.protocol) {
|
|
588
|
+
if (!targetRef.current || (!frameReady.current && msg.type !== "dialog") || pendingTarget.current || pageGeneration.current !== feed.latest.current.generation) return false;
|
|
589
|
+
// Preserve command identity (dialog acknowledgement tracks the queued object).
|
|
590
|
+
Object.assign(msg, targetRef.current);
|
|
484
591
|
}
|
|
485
592
|
lastInputAt.current = Date.now();
|
|
486
593
|
const q = queue.current;
|
|
@@ -496,7 +603,102 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
|
|
|
496
603
|
: msg;
|
|
497
604
|
} else q.push(msg);
|
|
498
605
|
flush();
|
|
499
|
-
|
|
606
|
+
return true;
|
|
607
|
+
}, [flush, flash, feed.latest]);
|
|
608
|
+
|
|
609
|
+
const clearPage = useCallback(() => {
|
|
610
|
+
inputEpoch.current++;
|
|
611
|
+
queue.current = [];
|
|
612
|
+
inflight.current = null;
|
|
613
|
+
frameReady.current = false;
|
|
614
|
+
screenRef.current?.clear();
|
|
615
|
+
setHasFrame(false);
|
|
616
|
+
setPage({ url: "", title: "" });
|
|
617
|
+
setAddr(""); setEditing(false);
|
|
618
|
+
if (keysRef.current) keysRef.current.value = "";
|
|
619
|
+
pendingDialog.current = null; setDialogSending(false); setDialog(null); setPromptText("");
|
|
620
|
+
clipboardSequence.current++; setCopied(null); setNotice(null);
|
|
621
|
+
lastInputAt.current = 0; lastFrameAt.current = 0; setStuck(false);
|
|
622
|
+
}, []);
|
|
623
|
+
const selectTab = (id: string) => {
|
|
624
|
+
const current = targetRef.current;
|
|
625
|
+
if (!human || !feed.ready || !viewerRef.current || tabs?.canSelect !== true || !current || pendingTarget.current || id === current.targetId || pageGeneration.current !== feed.latest.current.generation) return;
|
|
626
|
+
if (!tabs.tabs.some((tab) => tab.id === id)) return;
|
|
627
|
+
clearPage();
|
|
628
|
+
pendingTarget.current = id; setSwitching(id);
|
|
629
|
+
queue.current.push({ type: "select_tab", targetId: id, expectedTargetId: current.targetId, expectedTargetEpoch: current.targetEpoch });
|
|
630
|
+
lastInputAt.current = Date.now();
|
|
631
|
+
flush();
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
useEffect(() => {
|
|
635
|
+
if (pageGeneration.current === feed.generation || !feed.latest.current.protocol) return;
|
|
636
|
+
pageGeneration.current = feed.generation;
|
|
637
|
+
targetRef.current = null; pendingTarget.current = null; setSwitching(null); setTabs(null);
|
|
638
|
+
clearPage();
|
|
639
|
+
}, [feed.generation, feed.latest, clearPage]);
|
|
640
|
+
useEffect(() => {
|
|
641
|
+
if (!switching) return;
|
|
642
|
+
const timer = setTimeout(() => {
|
|
643
|
+
if (!pendingTarget.current) return;
|
|
644
|
+
flash("切换页面尚未确认,正在重新连接…", 5000);
|
|
645
|
+
reconnect();
|
|
646
|
+
}, UNRESPONSIVE_MS);
|
|
647
|
+
return () => clearTimeout(timer);
|
|
648
|
+
}, [switching, flash, reconnect]);
|
|
649
|
+
|
|
650
|
+
// 连接上来的消息:画面直接画,其余更新界面。挂上时先补上挂上之前到的页面 / 弹窗 / 最后一帧。
|
|
651
|
+
const latest = feed.latest;
|
|
652
|
+
useEffect(() => {
|
|
653
|
+
const handle = (m: BrowserMessage) => {
|
|
654
|
+
if (pageGeneration.current !== latest.current.generation) {
|
|
655
|
+
pageGeneration.current = latest.current.generation;
|
|
656
|
+
if (latest.current.protocol) {
|
|
657
|
+
targetRef.current = null; pendingTarget.current = null; setSwitching(null); setTabs(null);
|
|
658
|
+
clearPage();
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
const target = messageTarget(m);
|
|
662
|
+
// Until the page announcement confirms a switch, events from the previous
|
|
663
|
+
// page may still be in transit (notably clipboard results and JS dialogs).
|
|
664
|
+
if (pendingTarget.current && m.type !== "page" && m.type !== "tabs" && m.type !== "error") return;
|
|
665
|
+
if (m.type === "tabs") {
|
|
666
|
+
setTabs(m);
|
|
667
|
+
if (m.activeId === null) { clearPage(); targetRef.current = null; pendingTarget.current = null; setSwitching(null); }
|
|
668
|
+
} else if (m.type === "page") {
|
|
669
|
+
// The old page may still announce its title while a switch is in flight.
|
|
670
|
+
if (pendingTarget.current && sameTarget(targetRef.current, target)) return;
|
|
671
|
+
if (target && !sameTarget(targetRef.current, target)) {
|
|
672
|
+
clearPage(); targetRef.current = target;
|
|
673
|
+
pendingTarget.current = null; setSwitching(null);
|
|
674
|
+
}
|
|
675
|
+
setPage({ url: m.url ?? "", title: m.title ?? "" });
|
|
676
|
+
}
|
|
677
|
+
if (m.type === "frame" && m.data) {
|
|
678
|
+
if (latest.current.protocol && (pendingTarget.current || !sameTarget(targetRef.current, target))) return;
|
|
679
|
+
screenRef.current?.draw(m.data, m.metadata ?? null);
|
|
680
|
+
frameReady.current = true;
|
|
681
|
+
lastFrameAt.current = Date.now();
|
|
682
|
+
setStuck(false);
|
|
683
|
+
setHasFrame(true);
|
|
684
|
+
} else if (m.type === "error" && m.message) { if(pendingDialog.current){pendingDialog.current=null;setDialogSending(false);} if (pendingTarget.current) reconnect(); flash(m.message); }
|
|
685
|
+
else if (m.type === "dialog") { pendingDialog.current = null; setDialogSending(false); setDialog(m); setPromptText(m.defaultPrompt ?? ""); }
|
|
686
|
+
else if (m.type === "dialog_closed") { pendingDialog.current = null; setDialogSending(false); setDialog(null); }
|
|
687
|
+
else if (m.type === "clipboard") {
|
|
688
|
+
const source=latest.current.page;
|
|
689
|
+
const sequence=++clipboardSequence.current;
|
|
690
|
+
setCopied({text:m.text,page:{url:source?.type==='page'?source.url??'':'',title:source?.type==='page'?source.title??'':''}});
|
|
691
|
+
void writeClipboard(m.text).then((ok) => {if(sequence===clipboardSequence.current)flash(ok ? "已复制到你的剪贴板" : "没能写入剪贴板(浏览器不允许),可以在网页里选中后再试", 6000);});
|
|
692
|
+
}
|
|
693
|
+
};
|
|
694
|
+
const { tabs: lastTabs, page: lastPage, dialog: lastDialog, frame: lastFrame } = latest.current;
|
|
695
|
+
if (lastTabs) handle(lastTabs);
|
|
696
|
+
if (lastPage) handle(lastPage);
|
|
697
|
+
if (lastDialog) handle(lastDialog);
|
|
698
|
+
if (lastFrame) handle(lastFrame);
|
|
699
|
+
sink.current = handle;
|
|
700
|
+
return () => { sink.current = null; clipboardSequence.current++; };
|
|
701
|
+
}, [sink, flash, latest, clearPage, reconnect]);
|
|
500
702
|
|
|
501
703
|
// 键盘:
|
|
502
704
|
// · 普通字符(不带 Ctrl / ⌘)作为真正的按键发出(带 text)——网页的 keydown、快捷键、输入即搜索都能收到;
|
|
@@ -525,30 +727,43 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
|
|
|
525
727
|
};
|
|
526
728
|
const nav = (msg: Record<string, unknown>) => { if (human) send(msg); };
|
|
527
729
|
const answer = (accept: boolean) => {
|
|
528
|
-
if (!dialog) return;
|
|
529
|
-
|
|
530
|
-
|
|
730
|
+
if (!dialog || !human || !feed.ready || pendingDialog.current) return;
|
|
731
|
+
const command = { type: "dialog", accept, ...(dialog.dialogType === "prompt" && accept ? { promptText } : {}) };
|
|
732
|
+
if (send(command)) { pendingDialog.current = command; setDialogSending(true); }
|
|
733
|
+
// Keep the dialog until the remote browser confirms dialog_closed. Failed
|
|
734
|
+
// or disconnected input must remain visible and available to retry.
|
|
531
735
|
};
|
|
532
736
|
|
|
533
737
|
const { frames, paused } = feed;
|
|
534
738
|
const retrying = frames.state === "retrying";
|
|
739
|
+
const interactive = human && (!latest.current.protocol || (feed.ready && !!targetRef.current && hasFrame && !switching && pageGeneration.current === latest.current.generation));
|
|
740
|
+
const activeTab = switching ?? targetRef.current?.targetId ?? tabs?.activeId;
|
|
741
|
+
const listedTabs = tabs?.tabs.filter((tab, index, all) => typeof tab.id === "string" && !!tab.id && all.findIndex((candidate) => candidate.id === tab.id) === index) ?? [];
|
|
535
742
|
return (
|
|
536
743
|
<div className="bp-viewer">
|
|
744
|
+
{listedTabs.length > 0 && <nav className="bp-tabs" aria-label="任务浏览器页面">
|
|
745
|
+
{listedTabs.map((tab) => <button key={tab.id} className={`bp-tab${activeTab === tab.id ? " active" : ""}`} aria-pressed={activeTab === tab.id}
|
|
746
|
+
title={`${tab.title || "未命名页面"}${tab.url ? `\n${tab.url}` : ""}${!human ? "\n接管浏览器后可切换" : ""}`}
|
|
747
|
+
disabled={!human || !feed.ready || tabs?.canSelect !== true || !!switching || !targetRef.current}
|
|
748
|
+
onClick={() => selectTab(tab.id)}>
|
|
749
|
+
<span>{tab.title || tab.url || "未命名页面"}</span>{switching === tab.id && <span className="bp-tab-wait" aria-label="正在切换">…</span>}
|
|
750
|
+
</button>)}
|
|
751
|
+
</nav>}
|
|
537
752
|
<div className="prev-chrome bp-chrome">
|
|
538
|
-
<button className="bp-nav" disabled={!
|
|
539
|
-
<button className="bp-nav" disabled={!
|
|
540
|
-
<button className="bp-nav" disabled={!
|
|
753
|
+
<button className="bp-nav" disabled={!interactive} onClick={() => nav({ type: "back" })} title="后退">‹</button>
|
|
754
|
+
<button className="bp-nav" disabled={!interactive} onClick={() => nav({ type: "forward" })} title="前进">›</button>
|
|
755
|
+
<button className="bp-nav" disabled={!interactive} onClick={() => nav({ type: "reload" })} title="刷新">↻</button>
|
|
541
756
|
<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(); }}>
|
|
542
|
-
<input className="prev-addr bp-addr" value={addr} readOnly={!
|
|
757
|
+
<input className="prev-addr bp-addr" value={addr} readOnly={!interactive} title={page.title || page.url}
|
|
543
758
|
onFocus={() => setEditing(true)} onBlur={() => setEditing(false)} onChange={(e) => setAddr(e.target.value)} />
|
|
544
759
|
</form>
|
|
545
760
|
</div>
|
|
546
|
-
<div className={`bp-screen ${
|
|
761
|
+
<div className={`bp-screen ${interactive ? "live" : ""}`}>
|
|
547
762
|
{/* 画面一直挂着(收到第一帧前隐藏),解码在内存里做,不受宿主页面 CSP 的 img-src 限制。 */}
|
|
548
|
-
<Screen ref={screenRef} human={
|
|
763
|
+
<Screen ref={screenRef} human={interactive} inputEpoch={inputEpoch.current} hidden={!hasFrame} label={page.title || "浏览器画面"} send={send} onFocusKeys={() => keysRef.current?.focus({ preventScroll: true })} />
|
|
549
764
|
{!hasFrame && (
|
|
550
765
|
<div className="bp-empty">
|
|
551
|
-
{paused ? "页面不可见,已暂停画面" : retrying ? `画面连接中断,正在重连…${frames.detail ? `(${frames.detail})` : ""}` : "正在连接画面…"}
|
|
766
|
+
{switching ? "正在切换页面…" : paused ? "页面不可见,已暂停画面" : retrying ? `画面连接中断,正在重连…${frames.detail ? `(${frames.detail})` : ""}` : "正在连接画面…"}
|
|
552
767
|
{retrying && <><br /><button className="btn btn-line btn-sm" style={{ marginTop: 8 }} onClick={reconnect}>立即重试</button></>}
|
|
553
768
|
</div>
|
|
554
769
|
)}
|
|
@@ -557,19 +772,21 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
|
|
|
557
772
|
<div className="bp-dialog-box">
|
|
558
773
|
<div className="bp-dialog-t">网页{dialog.dialogType === "confirm" ? "请求确认" : dialog.dialogType === "prompt" ? "请求输入" : dialog.dialogType === "beforeunload" ? "询问是否离开" : "提示"}</div>
|
|
559
774
|
<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); }} />}
|
|
775
|
+
{dialog.dialogType === "prompt" && <input className="bp-dialog-in" value={promptText} disabled={!human || !feed.ready || dialogSending} onChange={(e) => setPromptText(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") answer(true); }} />}
|
|
561
776
|
{!human && <div className="bp-dialog-hint">接管浏览器后才能处理这个弹窗。</div>}
|
|
777
|
+
{human && !feed.ready && <div className="bp-dialog-hint">画面连接恢复后可继续处理这个弹窗。</div>}
|
|
778
|
+
{dialogSending && <div className="bp-dialog-hint" role="status">正在等待网页确认…</div>}
|
|
562
779
|
<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>
|
|
780
|
+
{dialog.dialogType !== "alert" && <button className="btn btn-line btn-sm" disabled={!human || !feed.ready || dialogSending} onClick={() => answer(false)}>取消</button>}
|
|
781
|
+
<button className="btn btn-accent btn-sm" disabled={!human || !feed.ready || dialogSending} onClick={() => answer(true)}>确定</button>
|
|
565
782
|
</div>
|
|
566
783
|
</div>
|
|
567
784
|
</div>
|
|
568
785
|
)}
|
|
569
786
|
{notice && (
|
|
570
787
|
<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>
|
|
788
|
+
{copied?.text && onQuoteText && /剪贴板/.test(notice) && (
|
|
789
|
+
<button className="bp-toast-btn" onClick={() => { onQuoteText(copied.text, copied.page); setCopied(null); setNotice(null); }}>引用到对话</button>
|
|
573
790
|
)}
|
|
574
791
|
</div>
|
|
575
792
|
)}
|
|
@@ -584,7 +801,7 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
|
|
|
584
801
|
ref={keysRef}
|
|
585
802
|
className="bp-keys"
|
|
586
803
|
aria-label="向浏览器输入"
|
|
587
|
-
disabled={!
|
|
804
|
+
disabled={!interactive}
|
|
588
805
|
onKeyDown={onKeyDown}
|
|
589
806
|
onInput={(e) => { if (!(e.nativeEvent as InputEvent).isComposing) flushText(e.currentTarget); }}
|
|
590
807
|
onCompositionEnd={(e) => flushText(e.currentTarget)}
|
|
@@ -593,15 +810,27 @@ function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
|
|
|
593
810
|
);
|
|
594
811
|
}
|
|
595
812
|
|
|
596
|
-
|
|
813
|
+
type BrowserPaneProps = {
|
|
597
814
|
backend: BoardBackend; jobId: string; readOnly?: boolean;
|
|
815
|
+
extension?: (status: BrowserStatus, update: (status:BrowserStatus)=>void) => ReactNode;
|
|
816
|
+
footer?: ReactNode;
|
|
817
|
+
className?: string;
|
|
598
818
|
/** 给了就显示「引用到对话」:把浏览器当前页面(标题 + 地址)带进下一条消息。 */
|
|
599
819
|
onQuote?: (page: { url: string; title: string }) => void;
|
|
600
820
|
/** 给了就能把在网页里复制(⌘C / Ctrl+C)出来的文字引用到对话。 */
|
|
601
821
|
onQuoteText?: (text: string, page: { url: string; title: string }) => void;
|
|
602
822
|
/** 当前页面变了(null = 浏览器没开 / 不在看)。 */
|
|
603
823
|
onPage?: (page: { url: string; title: string } | null) => void;
|
|
604
|
-
}
|
|
824
|
+
};
|
|
825
|
+
const browserBackendIds = new WeakMap<BoardBackend,number>();
|
|
826
|
+
let nextBrowserBackendId=1;
|
|
827
|
+
/** The low-level public component is safe to reuse across task/backend identity changes too. */
|
|
828
|
+
export function BrowserPane(props:BrowserPaneProps) {
|
|
829
|
+
let identity=browserBackendIds.get(props.backend);
|
|
830
|
+
if(identity===undefined){identity=nextBrowserBackendId++;browserBackendIds.set(props.backend,identity);}
|
|
831
|
+
return <BrowserPaneInner key={`${identity}:${props.jobId}`} {...props}/>;
|
|
832
|
+
}
|
|
833
|
+
function BrowserPaneInner({ backend, jobId, readOnly, onQuote, onQuoteText, onPage, extension, footer, className }:BrowserPaneProps) {
|
|
605
834
|
const sink = useRef<((m: BrowserMessage) => void) | null>(null);
|
|
606
835
|
const feed = useBrowserFeed(backend, jobId, sink);
|
|
607
836
|
const { status, setStatus, streamError, ended } = feed;
|
|
@@ -649,7 +878,7 @@ export function BrowserPane({ backend, jobId, readOnly, onQuote, onQuoteText, on
|
|
|
649
878
|
return (
|
|
650
879
|
<>
|
|
651
880
|
{big && zoomable && <div className="cli-ui bp-backdrop" onClick={() => setBig(false)} />}
|
|
652
|
-
<div className={`rail-pad bp ${big && zoomable ? "bp-big" : ""}`}>
|
|
881
|
+
<div className={`rail-pad bp ${big && zoomable ? "bp-big" : ""} ${className??""}`}>
|
|
653
882
|
<div className="bp-head">
|
|
654
883
|
<span className="rail-eyebrow">浏览器 · ego lite</span>
|
|
655
884
|
<span className={`bp-badge ${running ? (human ? "human" : "ai") : ""}`}>
|
|
@@ -674,7 +903,7 @@ export function BrowserPane({ backend, jobId, readOnly, onQuote, onQuoteText, on
|
|
|
674
903
|
|
|
675
904
|
{running ? (
|
|
676
905
|
<>
|
|
677
|
-
<Viewer backend={backend} jobId={jobId} human={human && !readOnly} feed={feed} sink={sink} onPage={onPage} onQuoteText={onQuoteText} />
|
|
906
|
+
<Viewer backend={backend} jobId={jobId} human={human && !readOnly} feed={feed} sink={sink} onPage={onPage} onQuoteText={onQuoteText?(text,page)=>{setBig(false);onQuoteText(text,page);}:undefined} />
|
|
678
907
|
{!readOnly && (
|
|
679
908
|
<div className="prev-actions bp-actions">
|
|
680
909
|
{human
|
|
@@ -684,7 +913,7 @@ export function BrowserPane({ backend, jobId, readOnly, onQuote, onQuoteText, on
|
|
|
684
913
|
{onQuote && (
|
|
685
914
|
<button className="btn btn-ghost btn-sm" title="把当前页面的标题和地址带进下一条消息(在网页里复制文字后,也可以把复制的文字引用过去)"
|
|
686
915
|
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 ?? "" }); }}>
|
|
916
|
+
onClick={() => { const p = feed.latest.current.page; if (p?.type === "page" && p.url) { setBig(false); onQuote({ url: p.url, title: p.title ?? "" }); } }}>
|
|
688
917
|
{Icon.board()} 引用到对话
|
|
689
918
|
</button>
|
|
690
919
|
)}
|
|
@@ -710,6 +939,8 @@ export function BrowserPane({ backend, jobId, readOnly, onQuote, onQuoteText, on
|
|
|
710
939
|
</div>
|
|
711
940
|
</div>
|
|
712
941
|
)}
|
|
942
|
+
{extension?.(status,setStatus)}
|
|
943
|
+
{footer}
|
|
713
944
|
{shownError && <div className="bp-error">{shownError}</div>}
|
|
714
945
|
{shownError && ended && <button className="btn btn-line btn-sm" onClick={feed.reconnect}>重试</button>}
|
|
715
946
|
</div>
|