@xgspz/cli-ui 0.0.0-stage → 0.6.15
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 +165 -0
- package/README.md +517 -2
- package/dist/cli-ui-embed-vIXVNs6k.js +50295 -0
- package/dist/cli-ui-index-R24x28OX.js +11209 -0
- package/dist/cli-ui-jszip.min-DUghzByz.js +2324 -0
- package/dist/cli-ui-jszip.min-DfH6rdQD.js +26 -0
- package/dist/cli-ui-xlsx-CP0lJhff.js +93 -0
- package/dist/cli-ui.css +32 -0
- package/dist/cli-ui.js +17 -0
- package/package.json +60 -4
- package/src/board/BrowserPane.tsx +969 -0
- package/src/board/ChangesPane.tsx +185 -0
- package/src/board/CliBoard.tsx +484 -0
- package/src/board/FileViewer.tsx +721 -0
- package/src/board/FilesPane.tsx +262 -0
- package/src/board/HtmlReadingPreview.tsx +52 -0
- package/src/board/MarkdownPreview.tsx +134 -0
- package/src/board/PanelControls.tsx +40 -0
- package/src/board/blocks.tsx +97 -0
- package/src/board/context.ts +65 -0
- package/src/board/diffLines.ts +46 -0
- package/src/board/fileKind.ts +250 -0
- package/src/board/fileOps.ts +85 -0
- package/src/board/fileScope.ts +32 -0
- package/src/board/fileSync.ts +351 -0
- package/src/board/fileSyncCache.ts +250 -0
- package/src/board/fileSyncProcessor.ts +72 -0
- package/src/board/fileSyncQueue.ts +56 -0
- package/src/board/fileSyncTransport.ts +85 -0
- package/src/board/fileWatch.ts +211 -0
- package/src/board/folderDownload.ts +211 -0
- package/src/board/frameQuote.ts +52 -0
- package/src/board/htmlReading.ts +148 -0
- package/src/board/panels.ts +33 -0
- package/src/board/textLoad.ts +76 -0
- package/src/board/useBoardUpload.tsx +51 -0
- package/src/board/useBoardVisibility.ts +35 -0
- package/src/board/workspace.ts +200 -0
- package/src/board/zip.ts +85 -0
- package/src/chat/CliChat.tsx +960 -0
- package/src/chat/Composer.tsx +239 -0
- package/src/chat/GroupChat.tsx +113 -0
- package/src/chat/RunControls.tsx +196 -0
- package/src/chat/configuration.ts +15 -0
- package/src/chat/draft.ts +49 -0
- package/src/chat/export.ts +34 -0
- package/src/chat/queueEdit.ts +27 -0
- package/src/chat/useExecutionConfig.ts +93 -0
- package/src/components.tsx +13 -0
- package/src/core/blocks.ts +205 -0
- package/src/core/callbacks.ts +8 -0
- package/src/core/client.ts +822 -0
- package/src/core/config.ts +252 -0
- package/src/core/conversation.ts +581 -0
- package/src/core/documentUpload.ts +196 -0
- package/src/core/execution.ts +24 -0
- package/src/core/id.ts +15 -0
- package/src/core/identity.ts +21 -0
- package/src/core/provider.tsx +141 -0
- package/src/core/sha256.ts +21 -0
- package/src/core/sse.ts +51 -0
- package/src/core/store.ts +1109 -0
- package/src/core/subagents.ts +78 -0
- package/src/core/textDecode.ts +24 -0
- package/src/core/types.ts +507 -0
- package/src/core/xlsx.ts +142 -0
- package/src/elements.tsx +376 -0
- package/src/embed.ts +56 -0
- package/src/index.ts +56 -0
- package/src/layout/CliSplit.tsx +53 -0
- package/src/layout/split.ts +251 -0
- package/src/msglist/CliMsgList.tsx +422 -0
- 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 +619 -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 +538 -0
- package/src/operator/node/CommandPalette.tsx +213 -0
- package/src/operator/node/Composer.tsx +316 -0
- package/src/operator/node/InsightsView.tsx +831 -0
- package/src/operator/node/Layout.tsx +192 -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 +248 -0
- package/src/operator/node/Orchestrator.tsx +644 -0
- package/src/operator/node/ProjectLanding.tsx +86 -0
- package/src/operator/node/ProjectsColumn.tsx +137 -0
- package/src/operator/node/ProviderAuth.tsx +559 -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 +299 -0
- package/src/operator/node/SessionView.tsx +292 -0
- package/src/operator/node/SettingsView.tsx +690 -0
- package/src/operator/node/TaskBoard.tsx +352 -0
- package/src/operator/node/TaskChanges.tsx +526 -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 +485 -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 +57 -0
- package/src/operator/node/boardBackend.ts +63 -0
- package/src/operator/node/boardEvents.ts +40 -0
- package/src/operator/node/boardInteraction.ts +15 -0
- package/src/operator/node/boardState.ts +74 -0
- package/src/operator/node/boardWorkspace.ts +15 -0
- package/src/operator/node/boundedPoll.ts +47 -0
- package/src/operator/node/branchFlow.ts +1 -0
- package/src/operator/node/composerDraft.ts +78 -0
- package/src/operator/node/fileLinks.ts +81 -0
- package/src/operator/node/format.ts +292 -0
- package/src/operator/node/github.tsx +232 -0
- package/src/operator/node/icons.tsx +88 -0
- package/src/operator/node/liveStream.ts +323 -0
- package/src/operator/node/modals.tsx +766 -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 +335 -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 +305 -0
- package/src/operator/node/useGlobalEvents.ts +97 -0
- package/src/operator/node/useOrchestrator.ts +697 -0
- package/src/operator/node/usePrefs.ts +114 -0
- package/src/operator/node/useRecaps.ts +102 -0
- package/src/operator/node/useTaskStream.ts +286 -0
- package/src/operator.base.css +1952 -0
- package/src/operator.css +1952 -0
- package/src/styles.css +579 -0
- package/src/ui/ErrorBoundary.tsx +38 -0
- package/src/ui/Markdown.tsx +60 -0
- package/src/ui/Popover.tsx +95 -0
- package/src/ui/QueuedMessages.tsx +110 -0
- package/src/ui/ReferenceCard.tsx +28 -0
- package/src/ui/SubagentChat.tsx +233 -0
- package/src/ui/TextBlock.tsx +51 -0
- package/src/ui/Transcript.tsx +435 -0
- package/src/ui/UploadProgress.tsx +46 -0
- package/src/ui/WorkspaceDrawer.tsx +64 -0
- package/src/ui/WorkspaceTabs.tsx +60 -0
- package/src/ui/fileLinks.ts +52 -0
- package/src/ui/format.ts +103 -0
- package/src/ui/icons.tsx +92 -0
- package/src/ui/reference.ts +40 -0
- package/src/ui/shared.tsx +38 -0
- package/src/ui/transcriptModel.ts +151 -0
- package/src/ui/useChatScrollbar.ts +29 -0
- package/src/ui/useComposerAutosize.ts +54 -0
|
@@ -0,0 +1,969 @@
|
|
|
1
|
+
// 任务浏览器:移植自 Operator 的 BrowserPane(状态、实时画面、接管 / 交还、打开 / 关闭)。
|
|
2
|
+
// 全部经 center 转发:一条实时连接(GET /browser/live)里有状态、画面、弹窗和复制出的文字,鼠标键盘走 POST。
|
|
3
|
+
// 旧版中枢没有这条连接时退回两条(/browser/events 状态 + /browser/frames 画面)。页面切到后台就断开,切回来再连
|
|
4
|
+
// (同一域名的连接数有限,后台页面不该占着)。
|
|
5
|
+
// 输入只在用户接管(control = human)时发送,节点那边也只在接管时接受。
|
|
6
|
+
// 让远端操作和本机一样:
|
|
7
|
+
// · 打字是真正的按键事件(网页的 keydown / 快捷键 / 输入即搜索都能收到);输入法、粘贴走文字插入;
|
|
8
|
+
// · 编辑快捷键(全选 / 复制 / 剪切 / 撤销)按节点的系统换成那边的按键,复制出来的文字写进你自己的剪贴板;
|
|
9
|
+
// · 按住拖动带着按键(选中文字、拖滑块);滚轮合并发送;
|
|
10
|
+
// · 网页的 alert / confirm / prompt 画面里看不到,这里单独弹出来处理。
|
|
11
|
+
// Operator 的「在节点屏幕上显示」「弹出窗口」「登录状态」是节点本机的功能,这里不提供。
|
|
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
|
+
|
|
18
|
+
interface FrameMeta { deviceWidth: number; deviceHeight: number }
|
|
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;
|
|
27
|
+
|
|
28
|
+
const CAPABILITY_LABEL: Record<string, string> = { config: "任务配置", manual: "手动打开过", ai: "AI 自行启用" };
|
|
29
|
+
const SPECIAL_KEYS: Record<string, number> = {
|
|
30
|
+
Enter: 13, Backspace: 8, Tab: 9, Escape: 27, Delete: 46, ArrowLeft: 37, ArrowUp: 38, ArrowRight: 39, ArrowDown: 40,
|
|
31
|
+
Home: 36, End: 35, PageUp: 33, PageDown: 34,
|
|
32
|
+
};
|
|
33
|
+
const MOUSE_BUTTONS = ["left", "middle", "right"] as const;
|
|
34
|
+
const RETRY_MS = [1_000, 3_000, 8_000, 15_000];
|
|
35
|
+
const retryDelay = (attempt: number) => jitter(RETRY_MS[Math.min(attempt, RETRY_MS.length - 1)]);
|
|
36
|
+
/** 看的人满了(中枢回 429):别人退出才有位置,隔久一点再试。 */
|
|
37
|
+
const FULL_RETRY_MS = 20_000;
|
|
38
|
+
/** 连接失败后多久再试:还没派发不超过 3 秒;登录过期 30 秒;人满 20 秒;其余按退避(都带随机浮动,免得大家同一秒一起重连)。 */
|
|
39
|
+
const nextTry = (err: { status?: number } | null | undefined, attempt: number) =>
|
|
40
|
+
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);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 连续失败这么多次就停下,不再定时重试(约 1.5 分钟;人满约 2.5 分钟):显示「重试」,
|
|
44
|
+
* 网络恢复、页面回到前台、窗口重新获得焦点时自动再连。
|
|
45
|
+
*/
|
|
46
|
+
const MAX_TRIES = 8;
|
|
47
|
+
const GAVE_UP = "连接多次失败,已停止自动重试;网络恢复、回到页面或点「重试」时再连";
|
|
48
|
+
|
|
49
|
+
/** 接管时发了操作、这么久画面一点没变:提示「画面没有响应」,给重连。 */
|
|
50
|
+
const UNRESPONSIVE_MS = 8_000;
|
|
51
|
+
/** 输入队列最多攒这么多(请求卡住时不无限堆积;先丢鼠标移动)。 */
|
|
52
|
+
const QUEUE_MAX = 200;
|
|
53
|
+
|
|
54
|
+
/** 地址栏:没写协议的补 https://(example.com、localhost:3000);看起来不像地址的原样交给节点。 */
|
|
55
|
+
export function normalizeUrl(input: string): string {
|
|
56
|
+
const v = input.trim();
|
|
57
|
+
if (!v) return "";
|
|
58
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(v) && !/^[^/]+:\d+(\/|$)/.test(v)) return v;
|
|
59
|
+
if (/^(localhost|\d{1,3}(\.\d{1,3}){3})(:\d+)?(\/|$)/i.test(v)) return `http://${v}`;
|
|
60
|
+
if (!/\s/.test(v) && /\.[a-z]{2,}(:\d+)?(\/|$|\?|#)/i.test(v)) return `https://${v}`;
|
|
61
|
+
return v;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function modifiers(e: { altKey: boolean; ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }): number {
|
|
65
|
+
return (e.altKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.metaKey ? 4 : 0) | (e.shiftKey ? 8 : 0);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function usePageVisible(): boolean {
|
|
69
|
+
const [visible, setVisible] = useState(() => typeof document === "undefined" || document.visibilityState === "visible");
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const on = () => setVisible(document.visibilityState === "visible");
|
|
72
|
+
document.addEventListener("visibilitychange", on);
|
|
73
|
+
return () => document.removeEventListener("visibilitychange", on);
|
|
74
|
+
}, []);
|
|
75
|
+
return visible;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 写进本机剪贴板:优先 Clipboard API,不行(没有授权、不是安全来源)退回 execCommand。 */
|
|
79
|
+
async function writeClipboard(text: string): Promise<boolean> {
|
|
80
|
+
try {
|
|
81
|
+
if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return true; }
|
|
82
|
+
} catch { /* 退回下面 */ }
|
|
83
|
+
try {
|
|
84
|
+
const area = document.createElement("textarea");
|
|
85
|
+
area.value = text;
|
|
86
|
+
area.style.cssText = "position:fixed;left:0;top:0;opacity:0;";
|
|
87
|
+
document.body.appendChild(area);
|
|
88
|
+
area.select();
|
|
89
|
+
const ok = document.execCommand("copy");
|
|
90
|
+
area.remove();
|
|
91
|
+
return ok;
|
|
92
|
+
} catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface BrowserFeed {
|
|
98
|
+
status: BrowserStatus | null;
|
|
99
|
+
setStatus: (s: BrowserStatus) => void;
|
|
100
|
+
/** 连接出了问题(正在重连)。 */
|
|
101
|
+
streamError: string | null;
|
|
102
|
+
/** 不会自动重连了(节点拒绝、没有权限):显示原因和「重试」。 */
|
|
103
|
+
ended: string | null;
|
|
104
|
+
/** 发输入用的 viewer id;ready = 输入能送到节点。 */
|
|
105
|
+
viewer: string | null;
|
|
106
|
+
ready: boolean;
|
|
107
|
+
generation: number;
|
|
108
|
+
frames: BrowserFramesState;
|
|
109
|
+
paused: boolean;
|
|
110
|
+
reconnect: () => void;
|
|
111
|
+
/**
|
|
112
|
+
* 最近的页面、弹窗、画面:中枢在连上时就推过来,那时画面区(要等状态是「已打开」才挂上)可能还没挂好,
|
|
113
|
+
* 挂上时从这里补上——否则地址栏一直是空的,要等下一次换页。
|
|
114
|
+
*/
|
|
115
|
+
latest: MutableRefObject<{ generation: number; protocol: boolean; tabs?: Tabs; page?: BrowserMessage; dialog?: BrowserMessage | null; frame?: BrowserMessage }>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 浏览器的数据:状态、画面连接、viewer。新版中枢一条连接(browserLive),旧版两条。
|
|
120
|
+
* 画面和其他消息交给 sink(画面直接画到 canvas,不经 React 状态)。
|
|
121
|
+
*/
|
|
122
|
+
function useBrowserFeed(backend: BoardBackend, jobId: string, sink: MutableRefObject<((m: BrowserMessage) => void) | null>): BrowserFeed {
|
|
123
|
+
const visible = usePageVisible();
|
|
124
|
+
const [mode, setMode] = useState<"live" | "legacy">(backend.browserLive ? "live" : "legacy");
|
|
125
|
+
const [status, setStatus] = useState<BrowserStatus | null>(null);
|
|
126
|
+
const [streamError, setStreamError] = useState<string | null>(null);
|
|
127
|
+
const [ended, setEnded] = useState<string | null>(null);
|
|
128
|
+
const [viewer, setViewer] = useState<string | null>(null);
|
|
129
|
+
const [ready, setReady] = useState(false);
|
|
130
|
+
const [generation, setGeneration] = useState(0);
|
|
131
|
+
const [frames, setFrames] = useState<BrowserFramesState>({ state: "idle" });
|
|
132
|
+
const [retry, setRetry] = useState(0);
|
|
133
|
+
// 重试用完后停下:等网络恢复 / 回到页面 / 获得焦点再连(不定时重试)。
|
|
134
|
+
const gaveUp = useRef(false);
|
|
135
|
+
const reconnect = useCallback(() => { gaveUp.current = false; setEnded(null); setRetry((n) => n + 1); }, []);
|
|
136
|
+
const giveUp = useCallback(() => { gaveUp.current = true; setEnded(GAVE_UP); }, []);
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
const wake = () => { if (gaveUp.current && document.visibilityState !== "hidden") reconnect(); };
|
|
139
|
+
window.addEventListener("online", wake);
|
|
140
|
+
window.addEventListener("focus", wake);
|
|
141
|
+
document.addEventListener("visibilitychange", wake);
|
|
142
|
+
return () => { window.removeEventListener("online", wake); window.removeEventListener("focus", wake); document.removeEventListener("visibilitychange", wake); };
|
|
143
|
+
}, [reconnect]);
|
|
144
|
+
const latest = useRef<BrowserFeed["latest"]["current"]>({ generation: 0, protocol: false });
|
|
145
|
+
const beginViewer = useCallback(() => {
|
|
146
|
+
const previous = latest.current;
|
|
147
|
+
// Epochs belong to one stream. A recovered viewer may start at epoch 1 again.
|
|
148
|
+
// Legacy servers retain their dialog while reconnecting, as before.
|
|
149
|
+
const next = previous.generation + 1;
|
|
150
|
+
latest.current = previous.protocol ? { generation: next, protocol: true } : { ...previous, generation: next };
|
|
151
|
+
setGeneration(next);
|
|
152
|
+
}, []);
|
|
153
|
+
const deliver = useCallback((m: BrowserMessage) => {
|
|
154
|
+
const cache = latest.current;
|
|
155
|
+
const target = messageTarget(m);
|
|
156
|
+
const current = cache.page ? messageTarget(cache.page) : null;
|
|
157
|
+
if (target) cache.protocol = true;
|
|
158
|
+
if (m.type === "tabs") {
|
|
159
|
+
cache.protocol = true; cache.tabs = m;
|
|
160
|
+
if (m.activeId === null) { delete cache.page; delete cache.frame; cache.dialog = null; }
|
|
161
|
+
}
|
|
162
|
+
if (m.type === "page") {
|
|
163
|
+
if (cache.protocol && !target) return;
|
|
164
|
+
if (target) {
|
|
165
|
+
cache.protocol = true;
|
|
166
|
+
if (current && (target.targetEpoch < current.targetEpoch || (target.targetEpoch === current.targetEpoch && target.targetId !== current.targetId))) return;
|
|
167
|
+
if (!sameTarget(current, target)) { delete cache.frame; cache.dialog = null; }
|
|
168
|
+
}
|
|
169
|
+
cache.page = m;
|
|
170
|
+
}
|
|
171
|
+
// A frame can arrive late or precede its page announcement. Neither is safe
|
|
172
|
+
// to display or use for coordinate input in the current page.
|
|
173
|
+
else if (cache.protocol && m.type !== "tabs" && m.type !== "error" && (!target || !sameTarget(current, target))) return;
|
|
174
|
+
else if (target && current && !sameTarget(current, target)) return;
|
|
175
|
+
if (m.type === "dialog") cache.dialog = m;
|
|
176
|
+
else if (m.type === "dialog_closed") cache.dialog = null;
|
|
177
|
+
else if (m.type === "frame") cache.frame = m;
|
|
178
|
+
sink.current?.(m);
|
|
179
|
+
}, [sink]);
|
|
180
|
+
|
|
181
|
+
// ── 新版:一条连接 ──
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
if (mode !== "live") return;
|
|
184
|
+
if (!visible) { setViewer(null); setReady(false); setFrames({ state: "idle" }); return; }
|
|
185
|
+
let alive = true;
|
|
186
|
+
let stop: (() => void) | null = null;
|
|
187
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
188
|
+
let attempt = 0;
|
|
189
|
+
const connect = () => {
|
|
190
|
+
if (!alive) return;
|
|
191
|
+
let finished = false;
|
|
192
|
+
let seenViewer = false;
|
|
193
|
+
let stopThis: (() => void) | null = null;
|
|
194
|
+
const retire = () => {
|
|
195
|
+
if (!alive || finished) return false;
|
|
196
|
+
finished = true;
|
|
197
|
+
stopThis?.();
|
|
198
|
+
setViewer(null); setReady(false);
|
|
199
|
+
return true;
|
|
200
|
+
};
|
|
201
|
+
stopThis = backend.browserLive!(jobId, {
|
|
202
|
+
onViewer: (id, isReady) => { if (!alive || finished) return; if (!seenViewer) { seenViewer = true; beginViewer(); } attempt = 0; setViewer(id); setReady(isReady); },
|
|
203
|
+
onStatus: (s) => { if (!alive || finished) return; attempt = 0; setStreamError(null); setStatus(s); },
|
|
204
|
+
onMessage: (message) => { if (alive && !finished) deliver(message); },
|
|
205
|
+
onFrames: (state) => { if (alive && !finished) setFrames(state); },
|
|
206
|
+
onEnd: (end) => {
|
|
207
|
+
if (end.reason !== "refused" && end.reason !== "client_closed") return;
|
|
208
|
+
if (!retire()) return;
|
|
209
|
+
setFrames({ state: "idle" });
|
|
210
|
+
if (end.reason === "refused") setEnded(end.detail || "执行节点拒绝了浏览器连接");
|
|
211
|
+
},
|
|
212
|
+
onClose: (err) => {
|
|
213
|
+
if (!retire()) return;
|
|
214
|
+
if (isMissingRoute(err)) { setMode("legacy"); return; }
|
|
215
|
+
// 对话不存在:重连也没用。
|
|
216
|
+
if (err && [400,404,405,410,501].includes(err.status)) { setEnded(err.message); return; }
|
|
217
|
+
if (err) setStreamError(err.message);
|
|
218
|
+
// 409 还没派发:隔几秒再问;401 / 403 多半是宿主会话过期,30 秒后再试(续上会话就自己恢复);
|
|
219
|
+
// 其余(断线、中枢重启、人太多)按退避重连。
|
|
220
|
+
if (attempt >= MAX_TRIES) { giveUp(); return; }
|
|
221
|
+
timer = setTimeout(connect, nextTry(err, attempt++));
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
stop = stopThis;
|
|
225
|
+
if (finished) stopThis();
|
|
226
|
+
};
|
|
227
|
+
connect();
|
|
228
|
+
return () => { alive = false; stop?.(); if (timer) clearTimeout(timer); };
|
|
229
|
+
}, [backend, jobId, mode, visible, retry, deliver, beginViewer, giveUp]);
|
|
230
|
+
|
|
231
|
+
// ── 旧版:状态一条(页面在后台时也断开,切回来再连:人多时少占中枢和节点的连接) ──
|
|
232
|
+
useEffect(() => {
|
|
233
|
+
if (mode !== "legacy" || !visible) return;
|
|
234
|
+
let alive = true;
|
|
235
|
+
let stop: (() => void) | null = null;
|
|
236
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
237
|
+
let attempt = 0;
|
|
238
|
+
const connect = () => {
|
|
239
|
+
if (!alive) return;
|
|
240
|
+
let finished = false;
|
|
241
|
+
let stopThis: (() => void) | null = null;
|
|
242
|
+
stopThis = backend.browserEvents(jobId, {
|
|
243
|
+
onStatus: (s) => { if (!alive || finished) return; attempt = 0; setStreamError(null); setStatus(s); },
|
|
244
|
+
onError: (error) => { if (alive && !finished) setStreamError(error.message); },
|
|
245
|
+
onClose: (err) => {
|
|
246
|
+
if (!alive || finished) return;
|
|
247
|
+
finished = true;
|
|
248
|
+
stopThis?.();
|
|
249
|
+
if (err && [400,404,405,410,501].includes(err.status)) { setEnded(err.message); return; }
|
|
250
|
+
if (err) setStreamError(err.message);
|
|
251
|
+
if (attempt >= MAX_TRIES) { giveUp(); return; }
|
|
252
|
+
timer = setTimeout(connect, nextTry(err, attempt++));
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
stop = stopThis;
|
|
256
|
+
if (finished) stopThis();
|
|
257
|
+
};
|
|
258
|
+
connect();
|
|
259
|
+
return () => { alive = false; stop?.(); if (timer) clearTimeout(timer); };
|
|
260
|
+
}, [backend, jobId, mode, visible, retry, giveUp]);
|
|
261
|
+
|
|
262
|
+
// ── 旧版:画面一条(浏览器开着、页面可见时) ──
|
|
263
|
+
const legacyRunning = mode === "legacy" && visible && status?.state === "running";
|
|
264
|
+
useEffect(() => {
|
|
265
|
+
if (!legacyRunning) { if (mode === "legacy") { setViewer(null); setReady(false); setFrames({ state: "idle" }); } return; }
|
|
266
|
+
let alive = true;
|
|
267
|
+
let stop: (() => void) | null = null;
|
|
268
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
269
|
+
let attempt = 0;
|
|
270
|
+
const connect = () => {
|
|
271
|
+
if (!alive) return;
|
|
272
|
+
let finished = false;
|
|
273
|
+
let seenViewer = false;
|
|
274
|
+
let stopThis: (() => void) | null = null;
|
|
275
|
+
const again = (detail?: string) => {
|
|
276
|
+
// A stream can emit an end event and then close; retire it only once.
|
|
277
|
+
if (!alive || finished) return;
|
|
278
|
+
finished = true;
|
|
279
|
+
stopThis?.();
|
|
280
|
+
setViewer(null);
|
|
281
|
+
setReady(false);
|
|
282
|
+
if (attempt >= MAX_TRIES) { setFrames({ state: "idle" }); giveUp(); return; }
|
|
283
|
+
setFrames({ state: "retrying", ...(detail ? { detail } : {}) });
|
|
284
|
+
timer = setTimeout(connect, retryDelay(attempt++));
|
|
285
|
+
};
|
|
286
|
+
setFrames({ state: attempt ? "retrying" : "connecting" });
|
|
287
|
+
stopThis = backend.browserFrames(jobId, {
|
|
288
|
+
onViewer: (id) => { if (!alive || finished) return; if (!seenViewer) { seenViewer = true; beginViewer(); } attempt = 0; setViewer(id); setReady(true); setFrames({ state: "live" }); },
|
|
289
|
+
onMessage: (message) => { if (alive && !finished) deliver(message); },
|
|
290
|
+
onEnd: (end) => {
|
|
291
|
+
if (end.reason !== "client_closed") { again(end.reason === "refused" ? end.detail : undefined); return; }
|
|
292
|
+
if (!alive || finished) return;
|
|
293
|
+
finished = true;
|
|
294
|
+
stopThis?.();
|
|
295
|
+
setViewer(null); setReady(false); setFrames({ state: "idle" });
|
|
296
|
+
},
|
|
297
|
+
// Clean EOF also invalidates the old viewer and needs a reconnect.
|
|
298
|
+
onClose: (err) => again(err?.message),
|
|
299
|
+
});
|
|
300
|
+
stop = stopThis;
|
|
301
|
+
// Custom transports may close synchronously before returning cleanup.
|
|
302
|
+
if (finished) stopThis();
|
|
303
|
+
};
|
|
304
|
+
connect();
|
|
305
|
+
return () => { alive = false; stop?.(); if (timer) clearTimeout(timer); };
|
|
306
|
+
}, [backend, jobId, legacyRunning, retry, deliver, beginViewer, giveUp]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
307
|
+
|
|
308
|
+
// 浏览器关了:上一个页面的地址、弹窗、画面都作废,重新打开时不先闪一下旧画面。
|
|
309
|
+
const running = status?.state === "running";
|
|
310
|
+
useEffect(() => { if (status && !running) latest.current = { generation: latest.current.generation, protocol: false }; }, [running, status]);
|
|
311
|
+
|
|
312
|
+
return { status, setStatus, streamError, ended, viewer, ready, generation, frames, paused: !visible, reconnect, latest };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
interface ScreenHandle { draw(base64: string, meta: FrameMeta | null): void; clear(): void }
|
|
316
|
+
|
|
317
|
+
/** base64 → 字节。 */
|
|
318
|
+
function decodeBase64(data: string): Uint8Array {
|
|
319
|
+
const bin = atob(data);
|
|
320
|
+
const out = new Uint8Array(bin.length);
|
|
321
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
322
|
+
return out;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* 实时画面:画在 canvas 上。
|
|
327
|
+
* · 帧在内存里解码(createImageBitmap),不走 data: / blob: 地址,宿主页面的 CSP 没放行 img-src data: 也能显示;
|
|
328
|
+
* 解码是异步的,按序号丢掉过期帧,画面不会倒退。没有 createImageBitmap 的环境退回 <img>。
|
|
329
|
+
* · 输入用指针事件:鼠标照原样;触屏点一下 = 点击,拖动 = 滚动;接管时滚轮 / 触摸只作用于画面,不滚动宿主页面。
|
|
330
|
+
* · 画面按比例缩放,适应容器宽高(窄的侧栏、弹窗、整页都可以)。
|
|
331
|
+
*/
|
|
332
|
+
const Screen = forwardRef<ScreenHandle, { human: boolean; inputEpoch: number; hidden: boolean; label: string; send: (msg: Record<string, unknown>) => void; onFocusKeys: () => void }>(
|
|
333
|
+
function Screen({ human, inputEpoch, hidden, label, send, onFocusKeys }, ref) {
|
|
334
|
+
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
335
|
+
const imgRef = useRef<HTMLImageElement | null>(null);
|
|
336
|
+
const meta = useRef<FrameMeta | null>(null);
|
|
337
|
+
const seq = useRef({ next: 0, drawn: 0 });
|
|
338
|
+
const imageGeneration = useRef(0);
|
|
339
|
+
const [fallback] = useState(() => typeof createImageBitmap !== "function");
|
|
340
|
+
const [fallbackFrame, setFallbackFrame] = useState<{src:string;meta:FrameMeta|null} | null>(null);
|
|
341
|
+
|
|
342
|
+
useImperativeHandle(ref, () => ({
|
|
343
|
+
clear() {
|
|
344
|
+
imageGeneration.current++;
|
|
345
|
+
seq.current = { next: 0, drawn: 0 };
|
|
346
|
+
meta.current = null;
|
|
347
|
+
setFallbackFrame(null);
|
|
348
|
+
const canvas = canvasRef.current;
|
|
349
|
+
// Resetting dimensions also clears its pixels without requiring a 2d context.
|
|
350
|
+
if (canvas) canvas.width = canvas.width;
|
|
351
|
+
},
|
|
352
|
+
draw(data, frameMeta) {
|
|
353
|
+
const type = data.startsWith("iVBOR") ? "image/png" : "image/jpeg";
|
|
354
|
+
if (fallback) { setFallbackFrame({src:`data:${type};base64,${data}`,meta:frameMeta}); return; }
|
|
355
|
+
const n = ++seq.current.next;
|
|
356
|
+
const generation = imageGeneration.current;
|
|
357
|
+
let bytes: Uint8Array;
|
|
358
|
+
try { bytes = decodeBase64(data); } catch { return; }
|
|
359
|
+
createImageBitmap(new Blob([bytes as BlobPart], { type })).then((bitmap) => {
|
|
360
|
+
const canvas = canvasRef.current;
|
|
361
|
+
if (!canvas || generation !== imageGeneration.current || n < seq.current.drawn) { bitmap.close(); return; }
|
|
362
|
+
seq.current.drawn = n;
|
|
363
|
+
meta.current = frameMeta;
|
|
364
|
+
if (canvas.width !== bitmap.width || canvas.height !== bitmap.height) { canvas.width = bitmap.width; canvas.height = bitmap.height; }
|
|
365
|
+
canvas.getContext("2d")?.drawImage(bitmap, 0, 0);
|
|
366
|
+
bitmap.close();
|
|
367
|
+
}, () => undefined);
|
|
368
|
+
},
|
|
369
|
+
}), [fallback]);
|
|
370
|
+
|
|
371
|
+
// Identical image bytes may accompany updated CSS viewport metadata and
|
|
372
|
+
// need not trigger another load event in the fallback image element.
|
|
373
|
+
useEffect(()=>{const img=imgRef.current;if(fallbackFrame&&img?.complete&&img.naturalWidth>0)meta.current=fallbackFrame.meta;},[fallbackFrame]);
|
|
374
|
+
|
|
375
|
+
const el = () => (fallback ? imgRef.current : canvasRef.current);
|
|
376
|
+
/** 屏幕坐标 → 页面 CSS 像素(按帧自带的页面尺寸换算)。 */
|
|
377
|
+
const point = useCallback((clientX: number, clientY: number) => {
|
|
378
|
+
const node = el();
|
|
379
|
+
if (!node) return null;
|
|
380
|
+
// Network arrival is not an interactive image: never map a click onto
|
|
381
|
+
// the blank initial canvas or an image that has not decoded yet.
|
|
382
|
+
if (fallback ? !(node instanceof HTMLImageElement && node.complete && node.naturalWidth > 0) : seq.current.drawn === 0) return null;
|
|
383
|
+
const r = node.getBoundingClientRect();
|
|
384
|
+
if (!r.width || !r.height) return null;
|
|
385
|
+
const natural = node instanceof HTMLCanvasElement ? { w: node.width, h: node.height } : { w: node.naturalWidth, h: node.naturalHeight };
|
|
386
|
+
const w = meta.current?.deviceWidth ?? natural.w;
|
|
387
|
+
const h = meta.current?.deviceHeight ?? natural.h;
|
|
388
|
+
return { x: Math.round(((clientX - r.left) / r.width) * w), y: Math.round(((clientY - r.top) / r.height) * h) };
|
|
389
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
390
|
+
|
|
391
|
+
const humanRef = useRef(human);
|
|
392
|
+
humanRef.current = human;
|
|
393
|
+
const touch = useRef<{ id: number; x: number; y: number; lastY: number; lastX: number; moved: boolean } | null>(null);
|
|
394
|
+
const lastMove = useRef(0);
|
|
395
|
+
// 按住的鼠标键:按住拖动时移动事件要带上它,网页里选中文字、拖滑块、拖拽才生效(CDP 按 button 判断是不是在拖)。
|
|
396
|
+
const pressed = useRef<(typeof MOUSE_BUTTONS)[number] | null>(null);
|
|
397
|
+
const lastPoint = useRef<{ clientX: number; clientY: number } | null>(null);
|
|
398
|
+
|
|
399
|
+
const mouse = (event: string, e: { clientX: number; clientY: number; altKey: boolean; ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }, extra: Record<string, unknown> = {}) => {
|
|
400
|
+
lastPoint.current = { clientX: e.clientX, clientY: e.clientY };
|
|
401
|
+
const p = point(e.clientX, e.clientY);
|
|
402
|
+
if (p) send({ type: "mouse", event, ...p, modifiers: modifiers(e), ...extra });
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
const onPointerDown = (e: React.PointerEvent) => {
|
|
406
|
+
if (!human || !point(e.clientX,e.clientY)) return;
|
|
407
|
+
e.preventDefault();
|
|
408
|
+
if (e.pointerType === "touch") {
|
|
409
|
+
touch.current = { id: e.pointerId, x: e.clientX, y: e.clientY, lastX: e.clientX, lastY: e.clientY, moved: false };
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
onFocusKeys();
|
|
413
|
+
(e.currentTarget as Element).setPointerCapture?.(e.pointerId);
|
|
414
|
+
pressed.current = MOUSE_BUTTONS[e.button] ?? "left";
|
|
415
|
+
mouse("mousePressed", e, { button: pressed.current, clickCount: e.detail || 1 });
|
|
416
|
+
};
|
|
417
|
+
const onPointerMove = (e: React.PointerEvent) => {
|
|
418
|
+
if (!human) return;
|
|
419
|
+
const t = touch.current;
|
|
420
|
+
if (e.pointerType === "touch" && t && t.id === e.pointerId) {
|
|
421
|
+
// 触屏拖动 → 滚动页面(在按下的位置发滚轮)。
|
|
422
|
+
const dx = t.lastX - e.clientX;
|
|
423
|
+
const dy = t.lastY - e.clientY;
|
|
424
|
+
if (Math.abs(e.clientX - t.x) + Math.abs(e.clientY - t.y) > 8) t.moved = true;
|
|
425
|
+
if (t.moved && (Math.abs(dx) >= 4 || Math.abs(dy) >= 4)) {
|
|
426
|
+
const p = point(t.x, t.y);
|
|
427
|
+
if (p) send({ type: "mouse", event: "mouseWheel", ...p, deltaX: dx * 2, deltaY: dy * 2, modifiers: 0 });
|
|
428
|
+
t.lastX = e.clientX;
|
|
429
|
+
t.lastY = e.clientY;
|
|
430
|
+
}
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (Date.now() - lastMove.current < 50) return;
|
|
434
|
+
lastMove.current = Date.now();
|
|
435
|
+
mouse("mouseMoved", e, pressed.current ? { button: pressed.current } : {});
|
|
436
|
+
};
|
|
437
|
+
const onPointerUp = (e: React.PointerEvent) => {
|
|
438
|
+
if (!human) return;
|
|
439
|
+
const t = touch.current;
|
|
440
|
+
if (e.pointerType === "touch" && t && t.id === e.pointerId) {
|
|
441
|
+
touch.current = null;
|
|
442
|
+
if (!t.moved && point(e.clientX, e.clientY)) {
|
|
443
|
+
// Send before focusing: the mobile keyboard can resize/reposition the frame.
|
|
444
|
+
// 触屏点一下 = 鼠标点击
|
|
445
|
+
mouse("mousePressed", e, { button: "left", clickCount: 1 });
|
|
446
|
+
mouse("mouseReleased", e, { button: "left", clickCount: 1 });
|
|
447
|
+
onFocusKeys();
|
|
448
|
+
}
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
if (e.pointerType === "touch" || !pressed.current) return;
|
|
452
|
+
pressed.current = null;
|
|
453
|
+
mouse("mouseReleased", e, { button: MOUSE_BUTTONS[e.button] ?? "left", clickCount: e.detail || 1 });
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
// A new control/viewer epoch cannot inherit a drag from the previous one.
|
|
457
|
+
useEffect(()=>{touch.current=null;pressed.current=null;lastPoint.current=null;},[human,inputEpoch]);
|
|
458
|
+
const release = useCallback(() => {
|
|
459
|
+
touch.current=null;
|
|
460
|
+
if (!pressed.current || !lastPoint.current) return;
|
|
461
|
+
const p = point(lastPoint.current.clientX, lastPoint.current.clientY);
|
|
462
|
+
if (p && humanRef.current) send({ type: "mouse", event: "mouseReleased", ...p, button: pressed.current, clickCount: 1, modifiers: 0 });
|
|
463
|
+
pressed.current = null;
|
|
464
|
+
},[point,send]);
|
|
465
|
+
// Cancellation/window blur must release the remote pointer as well.
|
|
466
|
+
useEffect(() => {
|
|
467
|
+
window.addEventListener("blur", release);
|
|
468
|
+
document.addEventListener("visibilitychange", release);
|
|
469
|
+
return () => { window.removeEventListener("blur", release); document.removeEventListener("visibilitychange", release); };
|
|
470
|
+
}, [release]);
|
|
471
|
+
|
|
472
|
+
// 滚轮:React 的 onWheel 是被动监听,拦不住宿主页面滚动,所以用原生监听(passive: false)。
|
|
473
|
+
useEffect(() => {
|
|
474
|
+
const node: HTMLElement | null = el();
|
|
475
|
+
if (!node) return;
|
|
476
|
+
const onWheel = (e: WheelEvent) => {
|
|
477
|
+
if (!humanRef.current) return;
|
|
478
|
+
e.preventDefault();
|
|
479
|
+
// deltaMode 1 / 2(按行 / 按页滚动的鼠标)换成像素。
|
|
480
|
+
const k = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 800 : 1;
|
|
481
|
+
mouse("mouseWheel", e, { deltaX: e.deltaX * k, deltaY: e.deltaY * k });
|
|
482
|
+
};
|
|
483
|
+
node.addEventListener("wheel", onWheel, { passive: false });
|
|
484
|
+
return () => node.removeEventListener("wheel", onWheel);
|
|
485
|
+
}); // 每次渲染重绑:fallback 时节点会变
|
|
486
|
+
|
|
487
|
+
const common = {
|
|
488
|
+
className: `bp-frame ${human ? "live" : ""}`,
|
|
489
|
+
style: { display: hidden ? "none" : undefined, touchAction: human ? "none" : "auto" } as React.CSSProperties,
|
|
490
|
+
onPointerDown, onPointerMove, onPointerUp,
|
|
491
|
+
onPointerCancel: release,
|
|
492
|
+
onContextMenu: (e: React.MouseEvent) => { if (human) e.preventDefault(); },
|
|
493
|
+
role: "img" as const,
|
|
494
|
+
"aria-label": label,
|
|
495
|
+
};
|
|
496
|
+
return fallback
|
|
497
|
+
? (fallbackFrame ? <img ref={imgRef} src={fallbackFrame.src} onLoad={()=>{meta.current=fallbackFrame.meta;}} alt={label} draggable={false} {...common} /> : null)
|
|
498
|
+
: <canvas ref={canvasRef} {...common} />;
|
|
499
|
+
},
|
|
500
|
+
);
|
|
501
|
+
|
|
502
|
+
function Viewer({ backend, jobId, human, feed, sink, onPage, onQuoteText }: {
|
|
503
|
+
backend: BoardBackend; jobId: string; human: boolean; feed: BrowserFeed; sink: MutableRefObject<((m: BrowserMessage) => void) | null>;
|
|
504
|
+
onPage?: (page: { url: string; title: string } | null) => void;
|
|
505
|
+
onQuoteText?: (text: string, page: { url: string; title: string }) => void;
|
|
506
|
+
}) {
|
|
507
|
+
const screenRef = useRef<ScreenHandle | null>(null);
|
|
508
|
+
const keysRef = useRef<HTMLTextAreaElement | null>(null);
|
|
509
|
+
const [hasFrame, setHasFrame] = useState(false);
|
|
510
|
+
const [tabs, setTabs] = useState<Tabs | null>(null);
|
|
511
|
+
const [switching, setSwitching] = useState<string | null>(null);
|
|
512
|
+
const targetRef = useRef<Target | null>(null);
|
|
513
|
+
const frameReady = useRef(false);
|
|
514
|
+
const pendingTarget = useRef<string | null>(null);
|
|
515
|
+
const pageGeneration = useRef(feed.latest.current.generation);
|
|
516
|
+
const [page, setPage] = useState<{ url: string; title: string }>({ url: "", title: "" });
|
|
517
|
+
const [addr, setAddr] = useState("");
|
|
518
|
+
const [editing, setEditing] = useState(false);
|
|
519
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
520
|
+
const [dialog, setDialog] = useState<Dialog | null>(null);
|
|
521
|
+
const [promptText, setPromptText] = useState("");
|
|
522
|
+
const [dialogSending, setDialogSending] = useState(false);
|
|
523
|
+
const pendingDialog = useRef<Record<string, unknown> | null>(null);
|
|
524
|
+
// 在网页里复制出来的文字:除了写进剪贴板,还可以「引用到对话」。
|
|
525
|
+
const [copied, setCopied] = useState<{text:string;page:{url:string;title:string}} | null>(null);
|
|
526
|
+
const clipboardSequence=useRef(0);
|
|
527
|
+
// 画面没有响应:接管时发了操作,之后 UNRESPONSIVE_MS 一帧都没来。
|
|
528
|
+
const [stuck, setStuck] = useState(false);
|
|
529
|
+
const lastInputAt = useRef(0);
|
|
530
|
+
const lastFrameAt = useRef(0);
|
|
531
|
+
const noticeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
532
|
+
const flash = useCallback((text: string, ms = 3000) => {
|
|
533
|
+
setNotice(text);
|
|
534
|
+
if (noticeTimer.current) clearTimeout(noticeTimer.current);
|
|
535
|
+
noticeTimer.current = setTimeout(() => setNotice(null), ms);
|
|
536
|
+
}, []);
|
|
537
|
+
useEffect(() => () => { if (noticeTimer.current) clearTimeout(noticeTimer.current); }, []);
|
|
538
|
+
|
|
539
|
+
useEffect(() => { if (!editing) setAddr(page.url); }, [page.url, editing]);
|
|
540
|
+
const onPageRef = useRef(onPage);
|
|
541
|
+
onPageRef.current = onPage;
|
|
542
|
+
useEffect(() => { onPageRef.current?.(page.url ? page : null); }, [page]);
|
|
543
|
+
useEffect(() => () => onPageRef.current?.(null), []);
|
|
544
|
+
// 接管中发了操作却一直没有新画面:多半是画面连接卡住或网络很慢,说出来并给「重连」。
|
|
545
|
+
useEffect(() => {
|
|
546
|
+
if (!human) { setStuck(false); return; }
|
|
547
|
+
const t = setInterval(() => {
|
|
548
|
+
const waiting = lastInputAt.current > lastFrameAt.current && Date.now() - lastInputAt.current > UNRESPONSIVE_MS;
|
|
549
|
+
setStuck(waiting);
|
|
550
|
+
}, 1_000);
|
|
551
|
+
return () => clearInterval(t);
|
|
552
|
+
}, [human]);
|
|
553
|
+
|
|
554
|
+
// 输入按顺序发:一个请求在路上时攒着,回来后一次发完(鼠标移动只留最后一个,连续的滚轮合并)。
|
|
555
|
+
const viewerRef = useRef<string | null>(null);
|
|
556
|
+
const queue = useRef<Array<Record<string, unknown>>>([]);
|
|
557
|
+
const controlRef = useRef(human); controlRef.current=human;
|
|
558
|
+
const mountedRef = useRef(true);
|
|
559
|
+
useEffect(()=>{mountedRef.current=true;return()=>{mountedRef.current=false;queue.current=[];};},[]);
|
|
560
|
+
useEffect(() => { if (!feed.ready || !human) { pendingDialog.current = null; setDialogSending(false); } }, [feed.ready, feed.viewer, human]);
|
|
561
|
+
const inflight = useRef<{ viewer:string; epoch:number } | null>(null);
|
|
562
|
+
const inputEpoch = useRef(0);
|
|
563
|
+
const previousControl = useRef(human);
|
|
564
|
+
const nextViewer=feed.ready?feed.viewer:null;
|
|
565
|
+
if(viewerRef.current!==nextViewer||previousControl.current!==human){
|
|
566
|
+
inputEpoch.current++;queue.current=[];inflight.current=null;
|
|
567
|
+
}
|
|
568
|
+
previousControl.current=human;
|
|
569
|
+
viewerRef.current=nextViewer;
|
|
570
|
+
const reconnect = feed.reconnect;
|
|
571
|
+
const flush = useCallback(() => {
|
|
572
|
+
const viewer = viewerRef.current;
|
|
573
|
+
if (!mountedRef.current || !controlRef.current || inflight.current || !viewer || !queue.current.length) return;
|
|
574
|
+
const batch = queue.current.splice(0, 50);
|
|
575
|
+
const token={viewer,epoch:inputEpoch.current};
|
|
576
|
+
inflight.current = token;
|
|
577
|
+
backend.browserInput(jobId, viewer, batch).catch((error: unknown) => {
|
|
578
|
+
if(!mountedRef.current||inflight.current!==token||viewerRef.current!==viewer||!controlRef.current)return;
|
|
579
|
+
if (pendingDialog.current && batch.includes(pendingDialog.current)) { pendingDialog.current = null; setDialogSending(false); }
|
|
580
|
+
if (batch.some((message) => message.type === "select_tab")) {
|
|
581
|
+
flash("页面切换未确认,正在重新连接…", 5000);
|
|
582
|
+
reconnect();
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
// 画面连接已失效(中枢重启、连接断开):丢掉这批输入并重连,避免用户以为点了没反应。
|
|
586
|
+
if (error instanceof CenterError && (error.status === 404 || error.status === 409)) {
|
|
587
|
+
queue.current = [];
|
|
588
|
+
flash("画面连接已断开,正在重连…(刚才的操作没有送达,请重新操作)", 4000);
|
|
589
|
+
reconnect();
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
// 其余(网络抖动、超时、中枢忙):不自动重发——点击、回车重复送达比没送达更糟。说清楚,让用户看画面决定要不要再操作。
|
|
593
|
+
const important = batch.some((m) => m.type !== "mouse" || m.event === "mousePressed");
|
|
594
|
+
if (important) {
|
|
595
|
+
const why = error instanceof CenterError && error.status === 429 ? "操作太快,任务中心暂时没接收" : "网络不稳";
|
|
596
|
+
flash(`${why}:刚才的操作可能没有送达,请看画面确认后再操作`, 5000);
|
|
597
|
+
}
|
|
598
|
+
}).finally(() => { if(inflight.current!==token)return;inflight.current = null; flush(); });
|
|
599
|
+
}, [backend, jobId, reconnect, flash]);
|
|
600
|
+
const send = useCallback((msg: Record<string, unknown>) => {
|
|
601
|
+
if(!mountedRef.current||!controlRef.current)return false;
|
|
602
|
+
// 画面连接还没就绪(刚接管、正在重连):不攒着等连上再补发——过几秒才落下的点击比没点更糟。
|
|
603
|
+
if (!viewerRef.current) {
|
|
604
|
+
if (msg.type !== "mouse" || msg.event === "mousePressed") flash("画面连接还没就绪,刚才的操作没有送达,请稍后再操作");
|
|
605
|
+
return false;
|
|
606
|
+
}
|
|
607
|
+
if (feed.latest.current.protocol) {
|
|
608
|
+
if (!targetRef.current || (!frameReady.current && msg.type !== "dialog") || pendingTarget.current || pageGeneration.current !== feed.latest.current.generation) return false;
|
|
609
|
+
// Preserve command identity (dialog acknowledgement tracks the queued object).
|
|
610
|
+
Object.assign(msg, targetRef.current);
|
|
611
|
+
}
|
|
612
|
+
lastInputAt.current = Date.now();
|
|
613
|
+
const q = queue.current;
|
|
614
|
+
// 请求卡住时别无限攒:超了先丢鼠标移动,还超就丢最早的。
|
|
615
|
+
if (q.length >= QUEUE_MAX) {
|
|
616
|
+
const kept = q.filter((m) => !(m.type === "mouse" && m.event === "mouseMoved"));
|
|
617
|
+
q.splice(0, q.length, ...kept.slice(-(QUEUE_MAX - 1)));
|
|
618
|
+
}
|
|
619
|
+
const last = q[q.length - 1];
|
|
620
|
+
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))) {
|
|
621
|
+
q[q.length - 1] = msg.event === "mouseWheel"
|
|
622
|
+
? { ...msg, deltaX: Number(last.deltaX) + Number(msg.deltaX), deltaY: Number(last.deltaY) + Number(msg.deltaY) }
|
|
623
|
+
: msg;
|
|
624
|
+
} else q.push(msg);
|
|
625
|
+
flush();
|
|
626
|
+
return true;
|
|
627
|
+
}, [flush, flash, feed.latest]);
|
|
628
|
+
|
|
629
|
+
const clearPage = useCallback(() => {
|
|
630
|
+
inputEpoch.current++;
|
|
631
|
+
queue.current = [];
|
|
632
|
+
inflight.current = null;
|
|
633
|
+
frameReady.current = false;
|
|
634
|
+
screenRef.current?.clear();
|
|
635
|
+
setHasFrame(false);
|
|
636
|
+
setPage({ url: "", title: "" });
|
|
637
|
+
setAddr(""); setEditing(false);
|
|
638
|
+
if (keysRef.current) keysRef.current.value = "";
|
|
639
|
+
pendingDialog.current = null; setDialogSending(false); setDialog(null); setPromptText("");
|
|
640
|
+
clipboardSequence.current++; setCopied(null); setNotice(null);
|
|
641
|
+
lastInputAt.current = 0; lastFrameAt.current = 0; setStuck(false);
|
|
642
|
+
}, []);
|
|
643
|
+
const selectTab = (id: string) => {
|
|
644
|
+
const current = targetRef.current;
|
|
645
|
+
if (!human || !feed.ready || !viewerRef.current || tabs?.canSelect !== true || !current || pendingTarget.current || id === current.targetId || pageGeneration.current !== feed.latest.current.generation) return;
|
|
646
|
+
if (!tabs.tabs.some((tab) => tab.id === id)) return;
|
|
647
|
+
clearPage();
|
|
648
|
+
pendingTarget.current = id; setSwitching(id);
|
|
649
|
+
queue.current.push({ type: "select_tab", targetId: id, expectedTargetId: current.targetId, expectedTargetEpoch: current.targetEpoch });
|
|
650
|
+
lastInputAt.current = Date.now();
|
|
651
|
+
flush();
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
useEffect(() => {
|
|
655
|
+
if (pageGeneration.current === feed.generation || !feed.latest.current.protocol) return;
|
|
656
|
+
pageGeneration.current = feed.generation;
|
|
657
|
+
targetRef.current = null; pendingTarget.current = null; setSwitching(null); setTabs(null);
|
|
658
|
+
clearPage();
|
|
659
|
+
}, [feed.generation, feed.latest, clearPage]);
|
|
660
|
+
useEffect(() => {
|
|
661
|
+
if (!switching) return;
|
|
662
|
+
const timer = setTimeout(() => {
|
|
663
|
+
if (!pendingTarget.current) return;
|
|
664
|
+
flash("切换页面尚未确认,正在重新连接…", 5000);
|
|
665
|
+
reconnect();
|
|
666
|
+
}, UNRESPONSIVE_MS);
|
|
667
|
+
return () => clearTimeout(timer);
|
|
668
|
+
}, [switching, flash, reconnect]);
|
|
669
|
+
|
|
670
|
+
// 连接上来的消息:画面直接画,其余更新界面。挂上时先补上挂上之前到的页面 / 弹窗 / 最后一帧。
|
|
671
|
+
const latest = feed.latest;
|
|
672
|
+
useEffect(() => {
|
|
673
|
+
const handle = (m: BrowserMessage) => {
|
|
674
|
+
if (pageGeneration.current !== latest.current.generation) {
|
|
675
|
+
pageGeneration.current = latest.current.generation;
|
|
676
|
+
if (latest.current.protocol) {
|
|
677
|
+
targetRef.current = null; pendingTarget.current = null; setSwitching(null); setTabs(null);
|
|
678
|
+
clearPage();
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
const target = messageTarget(m);
|
|
682
|
+
// Until the page announcement confirms a switch, events from the previous
|
|
683
|
+
// page may still be in transit (notably clipboard results and JS dialogs).
|
|
684
|
+
if (pendingTarget.current && m.type !== "page" && m.type !== "tabs" && m.type !== "error") return;
|
|
685
|
+
if (m.type === "tabs") {
|
|
686
|
+
setTabs(m);
|
|
687
|
+
if (m.activeId === null) { clearPage(); targetRef.current = null; pendingTarget.current = null; setSwitching(null); }
|
|
688
|
+
} else if (m.type === "page") {
|
|
689
|
+
// The old page may still announce its title while a switch is in flight.
|
|
690
|
+
if (pendingTarget.current && sameTarget(targetRef.current, target)) return;
|
|
691
|
+
if (target && !sameTarget(targetRef.current, target)) {
|
|
692
|
+
clearPage(); targetRef.current = target;
|
|
693
|
+
pendingTarget.current = null; setSwitching(null);
|
|
694
|
+
}
|
|
695
|
+
setPage({ url: m.url ?? "", title: m.title ?? "" });
|
|
696
|
+
}
|
|
697
|
+
if (m.type === "frame" && m.data) {
|
|
698
|
+
if (latest.current.protocol && (pendingTarget.current || !sameTarget(targetRef.current, target))) return;
|
|
699
|
+
screenRef.current?.draw(m.data, m.metadata ?? null);
|
|
700
|
+
frameReady.current = true;
|
|
701
|
+
lastFrameAt.current = Date.now();
|
|
702
|
+
setStuck(false);
|
|
703
|
+
setHasFrame(true);
|
|
704
|
+
} else if (m.type === "error" && m.message) { if(pendingDialog.current){pendingDialog.current=null;setDialogSending(false);} if (pendingTarget.current) reconnect(); flash(m.message); }
|
|
705
|
+
else if (m.type === "dialog") { pendingDialog.current = null; setDialogSending(false); setDialog(m); setPromptText(m.defaultPrompt ?? ""); }
|
|
706
|
+
else if (m.type === "dialog_closed") { pendingDialog.current = null; setDialogSending(false); setDialog(null); }
|
|
707
|
+
else if (m.type === "clipboard") {
|
|
708
|
+
const source=latest.current.page;
|
|
709
|
+
const sequence=++clipboardSequence.current;
|
|
710
|
+
setCopied({text:m.text,page:{url:source?.type==='page'?source.url??'':'',title:source?.type==='page'?source.title??'':''}});
|
|
711
|
+
void writeClipboard(m.text).then((ok) => {if(sequence===clipboardSequence.current)flash(ok ? "已复制到你的剪贴板" : "没能写入剪贴板(浏览器不允许),可以在网页里选中后再试", 6000);});
|
|
712
|
+
}
|
|
713
|
+
};
|
|
714
|
+
const { tabs: lastTabs, page: lastPage, dialog: lastDialog, frame: lastFrame } = latest.current;
|
|
715
|
+
if (lastTabs) handle(lastTabs);
|
|
716
|
+
if (lastPage) handle(lastPage);
|
|
717
|
+
if (lastDialog) handle(lastDialog);
|
|
718
|
+
if (lastFrame) handle(lastFrame);
|
|
719
|
+
sink.current = handle;
|
|
720
|
+
return () => { sink.current = null; clipboardSequence.current++; };
|
|
721
|
+
}, [sink, flash, latest, clearPage, reconnect]);
|
|
722
|
+
|
|
723
|
+
// 键盘:
|
|
724
|
+
// · 普通字符(不带 Ctrl / ⌘)作为真正的按键发出(带 text)——网页的 keydown、快捷键、输入即搜索都能收到;
|
|
725
|
+
// · 功能键(回车、退格、方向……)照发;
|
|
726
|
+
// · Ctrl / ⌘ 组合键照发(节点按自己的系统换成那边的快捷键;复制 / 剪切出来的文字写进你的剪贴板);
|
|
727
|
+
// ⌘V / Ctrl+V 例外:让本机的粘贴进到接收框,再作为文字插入;
|
|
728
|
+
// · 输入法的组字结果、粘贴走文字插入(compositionend / input)。
|
|
729
|
+
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
730
|
+
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
|
|
731
|
+
const mods = modifiers(e);
|
|
732
|
+
const shortcut = (mods & 6) !== 0;
|
|
733
|
+
if (shortcut && e.key.toLowerCase() === "v") return;
|
|
734
|
+
const special = SPECIAL_KEYS[e.key];
|
|
735
|
+
const printable = e.key.length === 1;
|
|
736
|
+
if (special === undefined && !printable && !shortcut) return;
|
|
737
|
+
e.preventDefault();
|
|
738
|
+
const keyCode = special ?? (e.keyCode || (printable ? e.key.toUpperCase().charCodeAt(0) : 0));
|
|
739
|
+
const base = { key: e.key, code: e.code, keyCode, modifiers: mods };
|
|
740
|
+
const text = e.key === "Enter" ? "\r" : printable && !shortcut ? e.key : undefined;
|
|
741
|
+
send({ type: "key", event: "keyDown", ...base, ...(text ? { text } : {}) });
|
|
742
|
+
send({ type: "key", event: "keyUp", ...base });
|
|
743
|
+
};
|
|
744
|
+
const flushText = (el: HTMLTextAreaElement) => {
|
|
745
|
+
if (el.value) send({ type: "text", text: el.value });
|
|
746
|
+
el.value = "";
|
|
747
|
+
};
|
|
748
|
+
const nav = (msg: Record<string, unknown>) => { if (human) send(msg); };
|
|
749
|
+
const answer = (accept: boolean) => {
|
|
750
|
+
if (!dialog || !human || !feed.ready || pendingDialog.current) return;
|
|
751
|
+
const command = { type: "dialog", accept, ...(dialog.dialogType === "prompt" && accept ? { promptText } : {}) };
|
|
752
|
+
if (send(command)) { pendingDialog.current = command; setDialogSending(true); }
|
|
753
|
+
// Keep the dialog until the remote browser confirms dialog_closed. Failed
|
|
754
|
+
// or disconnected input must remain visible and available to retry.
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
const { frames, paused } = feed;
|
|
758
|
+
const retrying = frames.state === "retrying";
|
|
759
|
+
const interactive = human && (!latest.current.protocol || (feed.ready && !!targetRef.current && hasFrame && !switching && pageGeneration.current === latest.current.generation));
|
|
760
|
+
const activeTab = switching ?? targetRef.current?.targetId ?? tabs?.activeId;
|
|
761
|
+
const listedTabs = tabs?.tabs.filter((tab, index, all) => typeof tab.id === "string" && !!tab.id && all.findIndex((candidate) => candidate.id === tab.id) === index) ?? [];
|
|
762
|
+
return (
|
|
763
|
+
<div className="bp-viewer">
|
|
764
|
+
{listedTabs.length > 0 && <nav className="bp-tabs" aria-label="任务浏览器页面">
|
|
765
|
+
{listedTabs.map((tab) => <button key={tab.id} className={`bp-tab${activeTab === tab.id ? " active" : ""}`} aria-pressed={activeTab === tab.id}
|
|
766
|
+
title={`${tab.title || "未命名页面"}${tab.url ? `\n${tab.url}` : ""}${!human ? "\n接管浏览器后可切换" : ""}`}
|
|
767
|
+
disabled={!human || !feed.ready || tabs?.canSelect !== true || !!switching || !targetRef.current}
|
|
768
|
+
onClick={() => selectTab(tab.id)}>
|
|
769
|
+
<span>{tab.title || tab.url || "未命名页面"}</span>{switching === tab.id && <span className="bp-tab-wait" aria-label="正在切换">…</span>}
|
|
770
|
+
</button>)}
|
|
771
|
+
</nav>}
|
|
772
|
+
<div className="prev-chrome bp-chrome">
|
|
773
|
+
<button className="bp-nav" disabled={!interactive} onClick={() => nav({ type: "back" })} title="后退">‹</button>
|
|
774
|
+
<button className="bp-nav" disabled={!interactive} onClick={() => nav({ type: "forward" })} title="前进">›</button>
|
|
775
|
+
<button className="bp-nav" disabled={!interactive} onClick={() => nav({ type: "reload" })} title="刷新">↻</button>
|
|
776
|
+
<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(); }}>
|
|
777
|
+
<input className="prev-addr bp-addr" value={addr} readOnly={!interactive} title={page.title || page.url}
|
|
778
|
+
onFocus={() => setEditing(true)} onBlur={() => setEditing(false)} onChange={(e) => setAddr(e.target.value)} />
|
|
779
|
+
</form>
|
|
780
|
+
</div>
|
|
781
|
+
<div className={`bp-screen ${interactive ? "live" : ""}`}>
|
|
782
|
+
{/* 画面一直挂着(收到第一帧前隐藏),解码在内存里做,不受宿主页面 CSP 的 img-src 限制。 */}
|
|
783
|
+
<Screen ref={screenRef} human={interactive} inputEpoch={inputEpoch.current} hidden={!hasFrame} label={page.title || "浏览器画面"} send={send} onFocusKeys={() => keysRef.current?.focus({ preventScroll: true })} />
|
|
784
|
+
{!hasFrame && (
|
|
785
|
+
<div className="bp-empty">
|
|
786
|
+
{switching ? "正在切换页面…" : paused ? "页面不可见,已暂停画面" : retrying ? `画面连接中断,正在重连…${frames.detail ? `(${frames.detail})` : ""}` : "正在连接画面…"}
|
|
787
|
+
{retrying && <><br /><button className="btn btn-line btn-sm" style={{ marginTop: 8 }} onClick={reconnect}>立即重试</button></>}
|
|
788
|
+
</div>
|
|
789
|
+
)}
|
|
790
|
+
{dialog && (
|
|
791
|
+
<div className="bp-dialog" role="dialog" aria-label="网页弹窗">
|
|
792
|
+
<div className="bp-dialog-box">
|
|
793
|
+
<div className="bp-dialog-t">网页{dialog.dialogType === "confirm" ? "请求确认" : dialog.dialogType === "prompt" ? "请求输入" : dialog.dialogType === "beforeunload" ? "询问是否离开" : "提示"}</div>
|
|
794
|
+
<div className="bp-dialog-m">{dialog.message || "(没有内容)"}</div>
|
|
795
|
+
{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); }} />}
|
|
796
|
+
{!human && <div className="bp-dialog-hint">接管浏览器后才能处理这个弹窗。</div>}
|
|
797
|
+
{human && !feed.ready && <div className="bp-dialog-hint">画面连接恢复后可继续处理这个弹窗。</div>}
|
|
798
|
+
{dialogSending && <div className="bp-dialog-hint" role="status">正在等待网页确认…</div>}
|
|
799
|
+
<div className="bp-dialog-a">
|
|
800
|
+
{dialog.dialogType !== "alert" && <button className="btn btn-line btn-sm" disabled={!human || !feed.ready || dialogSending} onClick={() => answer(false)}>取消</button>}
|
|
801
|
+
<button className="btn btn-accent btn-sm" disabled={!human || !feed.ready || dialogSending} onClick={() => answer(true)}>确定</button>
|
|
802
|
+
</div>
|
|
803
|
+
</div>
|
|
804
|
+
</div>
|
|
805
|
+
)}
|
|
806
|
+
{notice && (
|
|
807
|
+
<div className="bp-toast">{notice}
|
|
808
|
+
{copied?.text && onQuoteText && /剪贴板/.test(notice) && (
|
|
809
|
+
<button className="bp-toast-btn" onClick={() => { onQuoteText(copied.text, copied.page); setCopied(null); setNotice(null); }}>引用到对话</button>
|
|
810
|
+
)}
|
|
811
|
+
</div>
|
|
812
|
+
)}
|
|
813
|
+
{stuck && !notice && (
|
|
814
|
+
<div className="bp-toast">画面 {Math.round(UNRESPONSIVE_MS / 1000)} 秒没有变化:网络较慢,或画面连接卡住了
|
|
815
|
+
<button className="bp-toast-btn" onClick={() => { setStuck(false); lastInputAt.current = 0; reconnect(); }}>重连画面</button>
|
|
816
|
+
</div>
|
|
817
|
+
)}
|
|
818
|
+
{hasFrame && !notice && (paused ? <div className="bp-toast">页面不可见,已暂停画面</div> : retrying ? <div className="bp-toast">画面连接中断,正在重连…</div> : null)}
|
|
819
|
+
</div>
|
|
820
|
+
<textarea
|
|
821
|
+
ref={keysRef}
|
|
822
|
+
className="bp-keys"
|
|
823
|
+
aria-label="向浏览器输入"
|
|
824
|
+
disabled={!interactive}
|
|
825
|
+
onKeyDown={onKeyDown}
|
|
826
|
+
onInput={(e) => { if (!(e.nativeEvent as InputEvent).isComposing) flushText(e.currentTarget); }}
|
|
827
|
+
onCompositionEnd={(e) => flushText(e.currentTarget)}
|
|
828
|
+
/>
|
|
829
|
+
</div>
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
type BrowserPaneProps = {
|
|
834
|
+
backend: BoardBackend; jobId: string; readOnly?: boolean;
|
|
835
|
+
extension?: (status: BrowserStatus, update: (status:BrowserStatus)=>void) => ReactNode;
|
|
836
|
+
footer?: ReactNode;
|
|
837
|
+
className?: string;
|
|
838
|
+
/** 给了就显示「引用到对话」:把浏览器当前页面(标题 + 地址)带进下一条消息。 */
|
|
839
|
+
onQuote?: (page: { url: string; title: string }) => void;
|
|
840
|
+
/** 给了就能把在网页里复制(⌘C / Ctrl+C)出来的文字引用到对话。 */
|
|
841
|
+
onQuoteText?: (text: string, page: { url: string; title: string }) => void;
|
|
842
|
+
/** 当前页面变了(null = 浏览器没开 / 不在看)。 */
|
|
843
|
+
onPage?: (page: { url: string; title: string } | null) => void;
|
|
844
|
+
};
|
|
845
|
+
const browserBackendIds = new WeakMap<BoardBackend,number>();
|
|
846
|
+
let nextBrowserBackendId=1;
|
|
847
|
+
/** The low-level public component is safe to reuse across task/backend identity changes too. */
|
|
848
|
+
export function BrowserPane(props:BrowserPaneProps) {
|
|
849
|
+
let identity=browserBackendIds.get(props.backend);
|
|
850
|
+
if(identity===undefined){identity=nextBrowserBackendId++;browserBackendIds.set(props.backend,identity);}
|
|
851
|
+
return <BrowserPaneInner key={`${identity}:${props.jobId}`} {...props}/>;
|
|
852
|
+
}
|
|
853
|
+
function BrowserPaneInner({ backend, jobId, readOnly, onQuote, onQuoteText, onPage, extension, footer, className }:BrowserPaneProps) {
|
|
854
|
+
const sink = useRef<((m: BrowserMessage) => void) | null>(null);
|
|
855
|
+
const feed = useBrowserFeed(backend, jobId, sink);
|
|
856
|
+
const { status, setStatus, streamError, ended } = feed;
|
|
857
|
+
const [busy, setBusy] = useState<string | null>(null);
|
|
858
|
+
const [error, setError] = useState<string | null>(null);
|
|
859
|
+
|
|
860
|
+
// 放大:看板 / 弹窗里画面太小,接管后登录、填验证码看不清。接管时自动放大,交还、关闭时还原;也可以随时手动放大。
|
|
861
|
+
const [big, setBig] = useState(false);
|
|
862
|
+
useEffect(() => {
|
|
863
|
+
if (!big) return;
|
|
864
|
+
const prev = document.body.style.overflow;
|
|
865
|
+
document.body.style.overflow = "hidden";
|
|
866
|
+
return () => { document.body.style.overflow = prev; };
|
|
867
|
+
}, [big]);
|
|
868
|
+
const act = async (action: "open" | "close" | "takeover" | "handback") => {
|
|
869
|
+
setBusy(action);
|
|
870
|
+
setError(null);
|
|
871
|
+
if (action === "handback" || action === "close") setBig(false);
|
|
872
|
+
try {
|
|
873
|
+
setStatus(await backend.browserAction(jobId, action));
|
|
874
|
+
if (action === "takeover") setBig(true);
|
|
875
|
+
}
|
|
876
|
+
catch (e) { setError(e instanceof Error ? e.message : String(e)); }
|
|
877
|
+
finally { setBusy(null); }
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
if (!status) {
|
|
881
|
+
return (
|
|
882
|
+
<div className="rail-pad">
|
|
883
|
+
<div className="prev-note">
|
|
884
|
+
{ended ? `看不到浏览器:${ended}` : streamError ? `读取浏览器状态失败:${streamError}(正在重试)` : "读取浏览器状态…"}
|
|
885
|
+
{ended && <><br /><button className="btn btn-line btn-sm" style={{ marginTop: 8 }} onClick={feed.reconnect}>重试</button></>}
|
|
886
|
+
</div>
|
|
887
|
+
</div>
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
const running = status.state === "running";
|
|
891
|
+
const human = running && status.control === "human";
|
|
892
|
+
const kept = human && status.handoff?.by === "kept";
|
|
893
|
+
// 状态连接断了(还在重连)时,界面上的状态可能已经过时:写出来,免得用户对着旧状态操作。
|
|
894
|
+
const shownError = error ?? (status.state === "none" ? status.error : null)
|
|
895
|
+
?? (ended ? `浏览器连接已停止:${ended}` : streamError ? `浏览器状态连接中断,正在重连…(${streamError})` : null);
|
|
896
|
+
|
|
897
|
+
const zoomable = running;
|
|
898
|
+
return (
|
|
899
|
+
<>
|
|
900
|
+
{big && zoomable && <div className="cli-ui bp-backdrop" onClick={() => setBig(false)} />}
|
|
901
|
+
<div className={`rail-pad bp ${big && zoomable ? "bp-big" : ""} ${className??""}`}>
|
|
902
|
+
<div className="bp-head">
|
|
903
|
+
<span className="rail-eyebrow">浏览器 · ego lite</span>
|
|
904
|
+
<span className={`bp-badge ${running ? (human ? "human" : "ai") : ""}`}>
|
|
905
|
+
{status.state === "opening" ? "正在打开" : status.state === "closing" ? "正在关闭" : !running ? "未打开" : kept ? "结果页保留中" : human ? "你在操作" : "AI 控制中"}
|
|
906
|
+
</span>
|
|
907
|
+
{running && status.viewers > 0 && <span className="bp-meta">{status.viewers} 人在看</span>}
|
|
908
|
+
<span style={{ flex: 1 }} />
|
|
909
|
+
{zoomable && <button className="btn btn-ghost btn-sm bp-zoom" onClick={() => setBig((b) => !b)} title={big ? "还原到看板里" : "放大画面(登录、填验证码时更清楚)"}>{big ? "还原" : "⤢ 放大"}</button>}
|
|
910
|
+
</div>
|
|
911
|
+
|
|
912
|
+
{kept && <div className="bp-kept">AI 已完成,把结果页留在这里供你查看。看完点「关闭浏览器」;任务之后再用浏览器时会自动收回,没人看 10 分钟后也会自动关闭。</div>}
|
|
913
|
+
{human && !kept && status.handoff && (
|
|
914
|
+
<div className="bp-handoff">
|
|
915
|
+
{status.handoff.by === "ai"
|
|
916
|
+
? <>🖐 AI 请求你操作:<b>{status.handoff.instruction || "完成浏览器里的这一步"}</b></>
|
|
917
|
+
: status.handoff.by === "browser"
|
|
918
|
+
? <>🖐 浏览器需要你处理(AI 交出了控制权,或页面弹出了权限请求)</>
|
|
919
|
+
: <>你已接管浏览器,AI 的浏览器命令会暂停</>}
|
|
920
|
+
<div className="bp-handoff-sub">完成后点「交还给 AI」。密码只会发到网页,不会进入对话记录。</div>
|
|
921
|
+
</div>
|
|
922
|
+
)}
|
|
923
|
+
|
|
924
|
+
{running ? (
|
|
925
|
+
<>
|
|
926
|
+
<Viewer backend={backend} jobId={jobId} human={human && !readOnly} feed={feed} sink={sink} onPage={onPage} onQuoteText={onQuoteText?(text,page)=>{setBig(false);onQuoteText(text,page);}:undefined} />
|
|
927
|
+
{!readOnly && (
|
|
928
|
+
<div className="prev-actions bp-actions">
|
|
929
|
+
{human
|
|
930
|
+
? <button className="btn btn-accent" disabled={!!busy} onClick={() => act("handback")}>{Icon.check()} 交还给 AI</button>
|
|
931
|
+
: <button className="btn btn-line" disabled={!!busy} onClick={() => act("takeover")}>{Icon.edit()} 接管</button>}
|
|
932
|
+
<span style={{ flex: 1 }} />
|
|
933
|
+
{onQuote && (
|
|
934
|
+
<button className="btn btn-ghost btn-sm" title="把当前页面的标题和地址带进下一条消息(在网页里复制文字后,也可以把复制的文字引用过去)"
|
|
935
|
+
disabled={!feed.latest.current.page}
|
|
936
|
+
onClick={() => { const p = feed.latest.current.page; if (p?.type === "page" && p.url) { setBig(false); onQuote({ url: p.url, title: p.title ?? "" }); } }}>
|
|
937
|
+
{Icon.board()} 引用到对话
|
|
938
|
+
</button>
|
|
939
|
+
)}
|
|
940
|
+
<button className="btn btn-ghost btn-sm" disabled={!!busy} onClick={() => act("close")}>{Icon.x()} 关闭浏览器</button>
|
|
941
|
+
</div>
|
|
942
|
+
)}
|
|
943
|
+
</>
|
|
944
|
+
) : (
|
|
945
|
+
<div className="bp-idle">
|
|
946
|
+
<p>
|
|
947
|
+
{status.capability
|
|
948
|
+
? `这个任务已开启浏览器能力(${CAPABILITY_LABEL[status.capability] ?? status.capability}):AI 第一次用到时会自动打开,轮次结束自动关闭。`
|
|
949
|
+
: "这个任务还没有开启浏览器能力。手动打开一次就会开启,之后 AI 也能使用这个浏览器(例如你先在这里登录好网站)。"}
|
|
950
|
+
</p>
|
|
951
|
+
{!readOnly && (
|
|
952
|
+
<button className="btn btn-accent" disabled={status.state !== "none" || !!busy} onClick={() => act("open")}>
|
|
953
|
+
{Icon.external()} {status.state === "opening" || busy === "open" ? "正在打开…" : "打开浏览器"}
|
|
954
|
+
</button>
|
|
955
|
+
)}
|
|
956
|
+
<div className="prev-note">
|
|
957
|
+
节点浏览器:{status.instance.running ? `运行中(${status.instance.spaces} 个任务在用)` : "未运行(首次打开需要几秒启动)"}
|
|
958
|
+
<br />登录状态保存在执行节点的 ego lite 里,所有任务共享:登录一次即可。
|
|
959
|
+
</div>
|
|
960
|
+
</div>
|
|
961
|
+
)}
|
|
962
|
+
{extension?.(status,setStatus)}
|
|
963
|
+
{footer}
|
|
964
|
+
{shownError && <div className="bp-error">{shownError}</div>}
|
|
965
|
+
{shownError && ended && <button className="btn btn-line btn-sm" onClick={feed.reconnect}>重试</button>}
|
|
966
|
+
</div>
|
|
967
|
+
</>
|
|
968
|
+
);
|
|
969
|
+
}
|