@spzxg/cli-ui 0.1.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spzxg/cli-ui",
3
- "version": "0.1.0",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "description": "任务中心(cli-task-center)对话任务的前端积木:cli-msglist 消息对话列表、cli-chat 对话,数据全部来自 center 服务。",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,418 @@
1
+ // 任务浏览器:移植自 Operator 的 BrowserPane(状态、实时画面、接管 / 交还、打开 / 关闭)。
2
+ // 全部经 center 转发:状态和画面走 SSE,鼠标键盘走 POST;只有面板挂着且页面可见时才拉画面。
3
+ // 输入只在用户接管(control = human)时发送,节点那边也只在接管时接受。
4
+ // Operator 的「在节点屏幕上显示」「弹出窗口」「登录状态」是节点本机的功能,这里不提供。
5
+ import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
6
+ import { CenterError, type BoardBackend } from "../core/client.js";
7
+ import type { BrowserStatus } from "../core/types.js";
8
+ import { Icon } from "../ui/icons.js";
9
+
10
+ interface FrameMeta { deviceWidth: number; deviceHeight: number }
11
+
12
+ const CAPABILITY_LABEL: Record<string, string> = { config: "任务配置", manual: "手动打开过", ai: "AI 自行启用" };
13
+ const SPECIAL_KEYS: Record<string, number> = {
14
+ Enter: 13, Backspace: 8, Tab: 9, Escape: 27, Delete: 46, ArrowLeft: 37, ArrowUp: 38, ArrowRight: 39, ArrowDown: 40,
15
+ Home: 36, End: 35, PageUp: 33, PageDown: 34,
16
+ };
17
+ const MOUSE_BUTTONS = ["left", "middle", "right"] as const;
18
+ const RETRY_MS = [1_000, 3_000, 8_000, 15_000];
19
+
20
+ function modifiers(e: { altKey: boolean; ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }): number {
21
+ return (e.altKey ? 1 : 0) | (e.ctrlKey ? 2 : 0) | (e.metaKey ? 4 : 0) | (e.shiftKey ? 8 : 0);
22
+ }
23
+
24
+ function usePageVisible(): boolean {
25
+ const [visible, setVisible] = useState(() => typeof document === "undefined" || document.visibilityState === "visible");
26
+ useEffect(() => {
27
+ const on = () => setVisible(document.visibilityState === "visible");
28
+ document.addEventListener("visibilitychange", on);
29
+ return () => document.removeEventListener("visibilitychange", on);
30
+ }, []);
31
+ return visible;
32
+ }
33
+
34
+ /** 浏览器状态:SSE 推送,断了按退避重连。 */
35
+ function useBrowserStatus(backend: BoardBackend, jobId: string): [BrowserStatus | null, (s: BrowserStatus) => void, string | null] {
36
+ const [status, setStatus] = useState<BrowserStatus | null>(null);
37
+ const [error, setError] = useState<string | null>(null);
38
+ useEffect(() => {
39
+ setStatus(null);
40
+ let stop: (() => void) | null = null;
41
+ let timer: ReturnType<typeof setTimeout> | undefined;
42
+ let attempt = 0;
43
+ let alive = true;
44
+ const connect = () => {
45
+ stop = backend.browserEvents(jobId, {
46
+ onStatus: (s) => { attempt = 0; setError(null); setStatus(s); },
47
+ onClose: (err) => {
48
+ if (!alive) return;
49
+ // 404 / 409:节点没开浏览器功能或还没派发,不再重试(CliBoard 会在派发后重新挂载)。
50
+ if (err && (err.status === 404 || err.status === 409 || err.status === 401)) { setError(err.message); return; }
51
+ if (err) setError(err.message);
52
+ timer = setTimeout(connect, RETRY_MS[Math.min(attempt++, RETRY_MS.length - 1)]);
53
+ },
54
+ });
55
+ };
56
+ connect();
57
+ return () => { alive = false; stop?.(); if (timer) clearTimeout(timer); };
58
+ }, [backend, jobId]);
59
+ return [status, setStatus, error];
60
+ }
61
+
62
+ interface ScreenHandle { draw(base64: string, meta: FrameMeta | null): void }
63
+
64
+ /** base64 → 字节。 */
65
+ function decodeBase64(data: string): Uint8Array {
66
+ const bin = atob(data);
67
+ const out = new Uint8Array(bin.length);
68
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
69
+ return out;
70
+ }
71
+
72
+ /**
73
+ * 实时画面:画在 canvas 上。
74
+ * · 帧在内存里解码(createImageBitmap),不走 data: / blob: 地址,宿主页面的 CSP 没放行 img-src data: 也能显示;
75
+ * 解码是异步的,按序号丢掉过期帧,画面不会倒退。没有 createImageBitmap 的环境退回 <img>。
76
+ * · 输入用指针事件:鼠标照原样;触屏点一下 = 点击,拖动 = 滚动;接管时滚轮 / 触摸只作用于画面,不滚动宿主页面。
77
+ * · 画面按比例缩放,适应容器宽高(窄的侧栏、弹窗、整页都可以)。
78
+ */
79
+ const Screen = forwardRef<ScreenHandle, { human: boolean; hidden: boolean; label: string; send: (msg: Record<string, unknown>) => void; onFocusKeys: () => void }>(
80
+ function Screen({ human, hidden, label, send, onFocusKeys }, ref) {
81
+ const canvasRef = useRef<HTMLCanvasElement | null>(null);
82
+ const imgRef = useRef<HTMLImageElement | null>(null);
83
+ const meta = useRef<FrameMeta | null>(null);
84
+ const seq = useRef({ next: 0, drawn: 0 });
85
+ const [fallback] = useState(() => typeof createImageBitmap !== "function");
86
+ const [fallbackSrc, setFallbackSrc] = useState<string | null>(null);
87
+
88
+ useImperativeHandle(ref, () => ({
89
+ draw(data, frameMeta) {
90
+ meta.current = frameMeta;
91
+ const type = data.startsWith("iVBOR") ? "image/png" : "image/jpeg";
92
+ if (fallback) { setFallbackSrc(`data:${type};base64,${data}`); return; }
93
+ const n = ++seq.current.next;
94
+ let bytes: Uint8Array;
95
+ try { bytes = decodeBase64(data); } catch { return; }
96
+ createImageBitmap(new Blob([bytes as BlobPart], { type })).then((bitmap) => {
97
+ const canvas = canvasRef.current;
98
+ if (!canvas || n < seq.current.drawn) { bitmap.close(); return; }
99
+ seq.current.drawn = n;
100
+ if (canvas.width !== bitmap.width || canvas.height !== bitmap.height) { canvas.width = bitmap.width; canvas.height = bitmap.height; }
101
+ canvas.getContext("2d")?.drawImage(bitmap, 0, 0);
102
+ bitmap.close();
103
+ }, () => undefined);
104
+ },
105
+ }), [fallback]);
106
+
107
+ const el = () => (fallback ? imgRef.current : canvasRef.current);
108
+ /** 屏幕坐标 → 页面 CSS 像素(按帧自带的页面尺寸换算)。 */
109
+ const point = useCallback((clientX: number, clientY: number) => {
110
+ const node = el();
111
+ if (!node) return null;
112
+ const r = node.getBoundingClientRect();
113
+ if (!r.width || !r.height) return null;
114
+ const natural = node instanceof HTMLCanvasElement ? { w: node.width, h: node.height } : { w: node.naturalWidth, h: node.naturalHeight };
115
+ const w = meta.current?.deviceWidth ?? natural.w;
116
+ const h = meta.current?.deviceHeight ?? natural.h;
117
+ return { x: Math.round(((clientX - r.left) / r.width) * w), y: Math.round(((clientY - r.top) / r.height) * h) };
118
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
119
+
120
+ const humanRef = useRef(human);
121
+ humanRef.current = human;
122
+ const touch = useRef<{ id: number; x: number; y: number; lastY: number; lastX: number; moved: boolean } | null>(null);
123
+ const lastMove = useRef(0);
124
+
125
+ const mouse = (event: string, e: { clientX: number; clientY: number; altKey: boolean; ctrlKey: boolean; metaKey: boolean; shiftKey: boolean }, extra: Record<string, unknown> = {}) => {
126
+ const p = point(e.clientX, e.clientY);
127
+ if (p) send({ type: "mouse", event, ...p, modifiers: modifiers(e), ...extra });
128
+ };
129
+
130
+ const onPointerDown = (e: React.PointerEvent) => {
131
+ if (!human) return;
132
+ e.preventDefault();
133
+ onFocusKeys();
134
+ if (e.pointerType === "touch") {
135
+ touch.current = { id: e.pointerId, x: e.clientX, y: e.clientY, lastX: e.clientX, lastY: e.clientY, moved: false };
136
+ return;
137
+ }
138
+ (e.currentTarget as Element).setPointerCapture?.(e.pointerId);
139
+ mouse("mousePressed", e, { button: MOUSE_BUTTONS[e.button] ?? "left", clickCount: e.detail || 1 });
140
+ };
141
+ const onPointerMove = (e: React.PointerEvent) => {
142
+ if (!human) return;
143
+ const t = touch.current;
144
+ if (e.pointerType === "touch" && t && t.id === e.pointerId) {
145
+ // 触屏拖动 → 滚动页面(在按下的位置发滚轮)。
146
+ const dx = t.lastX - e.clientX;
147
+ const dy = t.lastY - e.clientY;
148
+ if (Math.abs(e.clientX - t.x) + Math.abs(e.clientY - t.y) > 8) t.moved = true;
149
+ if (t.moved && (Math.abs(dx) >= 4 || Math.abs(dy) >= 4)) {
150
+ const p = point(t.x, t.y);
151
+ if (p) send({ type: "mouse", event: "mouseWheel", ...p, deltaX: dx * 2, deltaY: dy * 2, modifiers: 0 });
152
+ t.lastX = e.clientX;
153
+ t.lastY = e.clientY;
154
+ }
155
+ return;
156
+ }
157
+ if (Date.now() - lastMove.current < 50) return;
158
+ lastMove.current = Date.now();
159
+ mouse("mouseMoved", e);
160
+ };
161
+ const onPointerUp = (e: React.PointerEvent) => {
162
+ if (!human) return;
163
+ const t = touch.current;
164
+ if (e.pointerType === "touch" && t && t.id === e.pointerId) {
165
+ touch.current = null;
166
+ if (!t.moved) {
167
+ // 触屏点一下 = 鼠标点击
168
+ mouse("mousePressed", e, { button: "left", clickCount: 1 });
169
+ mouse("mouseReleased", e, { button: "left", clickCount: 1 });
170
+ }
171
+ return;
172
+ }
173
+ mouse("mouseReleased", e, { button: MOUSE_BUTTONS[e.button] ?? "left", clickCount: e.detail || 1 });
174
+ };
175
+
176
+ // 滚轮:React 的 onWheel 是被动监听,拦不住宿主页面滚动,所以用原生监听(passive: false)。
177
+ useEffect(() => {
178
+ const node: HTMLElement | null = el();
179
+ if (!node) return;
180
+ const onWheel = (e: WheelEvent) => {
181
+ if (!humanRef.current) return;
182
+ e.preventDefault();
183
+ mouse("mouseWheel", e, { deltaX: e.deltaX, deltaY: e.deltaY });
184
+ };
185
+ node.addEventListener("wheel", onWheel, { passive: false });
186
+ return () => node.removeEventListener("wheel", onWheel);
187
+ }); // 每次渲染重绑:fallback 时节点会变
188
+
189
+ const common = {
190
+ className: `bp-frame ${human ? "live" : ""}`,
191
+ style: { display: hidden ? "none" : undefined, touchAction: human ? "none" : "auto" } as React.CSSProperties,
192
+ onPointerDown, onPointerMove, onPointerUp,
193
+ onPointerCancel: () => { touch.current = null; },
194
+ onContextMenu: (e: React.MouseEvent) => { if (human) e.preventDefault(); },
195
+ role: "img" as const,
196
+ "aria-label": label,
197
+ };
198
+ return fallback
199
+ ? (fallbackSrc ? <img ref={imgRef} src={fallbackSrc} alt={label} draggable={false} {...common} /> : null)
200
+ : <canvas ref={canvasRef} {...common} />;
201
+ },
202
+ );
203
+
204
+ function Viewer({ backend, jobId, human }: { backend: BoardBackend; jobId: string; human: boolean }) {
205
+ const visible = usePageVisible();
206
+ const viewerRef = useRef<string | null>(null);
207
+ const screenRef = useRef<ScreenHandle | null>(null);
208
+ const keysRef = useRef<HTMLTextAreaElement | null>(null);
209
+ const [hasFrame, setHasFrame] = useState(false);
210
+ const [page, setPage] = useState<{ url: string; title: string }>({ url: "", title: "" });
211
+ const [addr, setAddr] = useState("");
212
+ const [editing, setEditing] = useState(false);
213
+ const [notice, setNotice] = useState<string | null>(null);
214
+ const [ended, setEnded] = useState<string | null>(null);
215
+ const [retry, setRetry] = useState(0);
216
+ const [reconnecting, setReconnecting] = useState(false);
217
+ // 连续失败次数:按退避重连,收到画面后清零。
218
+ const failures = useRef(0);
219
+ const reconnect = useCallback((delay?: number) => {
220
+ const wait = delay ?? RETRY_MS[Math.min(failures.current++, RETRY_MS.length - 1)];
221
+ setReconnecting(true);
222
+ return setTimeout(() => setRetry((n) => n + 1), wait);
223
+ }, []);
224
+
225
+ useEffect(() => {
226
+ if (!visible) return;
227
+ setEnded(null);
228
+ let clear: ReturnType<typeof setTimeout> | undefined;
229
+ let again: ReturnType<typeof setTimeout> | undefined;
230
+ const flash = (text: string) => {
231
+ setNotice(text);
232
+ if (clear) clearTimeout(clear);
233
+ clear = setTimeout(() => setNotice(null), 3000);
234
+ };
235
+ const stop = backend.browserFrames(jobId, {
236
+ onViewer: (id) => { viewerRef.current = id; setReconnecting(false); },
237
+ onMessage: (m) => {
238
+ if (m.type === "frame" && m.data) {
239
+ failures.current = 0;
240
+ screenRef.current?.draw(m.data, m.metadata ?? null);
241
+ setHasFrame(true);
242
+ } else if (m.type === "page") setPage({ url: m.url ?? "", title: m.title ?? "" });
243
+ else if (m.type === "error" && m.message) flash(m.message);
244
+ },
245
+ onEnd: (end) => {
246
+ viewerRef.current = null;
247
+ // 节点明确拒绝:不自动重试(多半是节点版本 / 功能开关),给「重试」按钮。其余(超时、节点断开)按退避重连。
248
+ if (end.reason === "refused") setEnded(end.detail || "执行节点拒绝了画面连接");
249
+ else if (end.reason !== "client_closed") again = reconnect();
250
+ },
251
+ onClose: (err) => {
252
+ viewerRef.current = null;
253
+ if (!err) return; // 正常结束:onEnd 已经处理
254
+ if (err.status === 429 || err.status === 404 || err.status === 401 || err.status === 409) setEnded(err.message);
255
+ else again = reconnect();
256
+ },
257
+ });
258
+ return () => { stop(); viewerRef.current = null; if (clear) clearTimeout(clear); if (again) clearTimeout(again); };
259
+ }, [backend, jobId, visible, retry, reconnect]);
260
+
261
+ useEffect(() => { if (!editing) setAddr(page.url); }, [page.url, editing]);
262
+
263
+ // 输入按顺序发:一个请求在路上时攒着,回来后一次发完(鼠标移动只留最后一个)。
264
+ const queue = useRef<Array<Record<string, unknown>>>([]);
265
+ const inflight = useRef(false);
266
+ const flush = useCallback(() => {
267
+ const viewer = viewerRef.current;
268
+ if (inflight.current || !viewer || !queue.current.length) return;
269
+ const batch = queue.current.splice(0, 50);
270
+ inflight.current = true;
271
+ backend.browserInput(jobId, viewer, batch).catch((error: unknown) => {
272
+ // 画面连接已失效(中枢重启、连接断开):丢掉这批输入并重连画面,避免用户以为点了没反应。
273
+ if (error instanceof CenterError && (error.status === 404 || error.status === 409)) {
274
+ viewerRef.current = null;
275
+ queue.current = [];
276
+ setNotice("画面连接已断开,正在重连…(刚才的操作没有送达,请重新操作)");
277
+ setTimeout(() => setNotice(null), 4000);
278
+ reconnect(300);
279
+ }
280
+ }).finally(() => { inflight.current = false; flush(); });
281
+ }, [backend, jobId, reconnect]);
282
+ const send = useCallback((msg: Record<string, unknown>) => {
283
+ const q = queue.current;
284
+ const last = q[q.length - 1];
285
+ if (msg.type === "mouse" && msg.event === "mouseMoved" && last?.type === "mouse" && last.event === "mouseMoved") q[q.length - 1] = msg;
286
+ else q.push(msg);
287
+ flush();
288
+ }, [flush]);
289
+
290
+ const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
291
+ if (e.nativeEvent.isComposing) return;
292
+ const code = SPECIAL_KEYS[e.key];
293
+ const mods = modifiers(e);
294
+ const shortcut = (mods & 6) !== 0 && e.key.toLowerCase() !== "v";
295
+ if (code === undefined && !shortcut) return;
296
+ e.preventDefault();
297
+ const base = { key: e.key, code: e.code, keyCode: code ?? e.keyCode, modifiers: mods };
298
+ send({ type: "key", event: "keyDown", ...base, ...(e.key === "Enter" ? { text: "\r" } : {}) });
299
+ send({ type: "key", event: "keyUp", ...base });
300
+ };
301
+ const flushText = (el: HTMLTextAreaElement) => {
302
+ if (el.value) send({ type: "text", text: el.value });
303
+ el.value = "";
304
+ };
305
+ const nav = (msg: Record<string, unknown>) => { if (human) send(msg); };
306
+
307
+ return (
308
+ <div className="bp-viewer">
309
+ <div className="prev-chrome bp-chrome">
310
+ <button className="bp-nav" disabled={!human} onClick={() => nav({ type: "back" })} title="后退">‹</button>
311
+ <button className="bp-nav" disabled={!human} onClick={() => nav({ type: "forward" })} title="前进">›</button>
312
+ <button className="bp-nav" disabled={!human} onClick={() => nav({ type: "reload" })} title="刷新">↻</button>
313
+ <form className="bp-addr-form" onSubmit={(e) => { e.preventDefault(); nav({ type: "navigate", url: addr }); setEditing(false); }}>
314
+ <input className="prev-addr bp-addr" value={addr} readOnly={!human} title={page.title || page.url}
315
+ onFocus={() => setEditing(true)} onBlur={() => setEditing(false)} onChange={(e) => setAddr(e.target.value)} />
316
+ </form>
317
+ </div>
318
+ <div className={`bp-screen ${human ? "live" : ""}`}>
319
+ {ended ? (
320
+ <div className="bp-empty">看不到实时画面:{ended}<br /><button className="btn btn-line btn-sm" style={{ marginTop: 8 }} onClick={() => { failures.current = 0; setRetry((n) => n + 1); }}>重试</button></div>
321
+ ) : null}
322
+ {/* 画面一直挂着(收到第一帧前隐藏),解码在内存里做,不受宿主页面 CSP 的 img-src 限制。 */}
323
+ <Screen ref={screenRef} human={human && !ended} hidden={!hasFrame || !!ended} label={page.title || "浏览器画面"} send={send} onFocusKeys={() => keysRef.current?.focus({ preventScroll: true })} />
324
+ {!ended && !hasFrame && (
325
+ <div className="bp-empty">{!visible ? "页面不可见,已暂停画面" : reconnecting ? "画面连接中断,正在重连…" : "正在连接画面…"}</div>
326
+ )}
327
+ {notice && <div className="bp-toast">{notice}</div>}
328
+ {hasFrame && reconnecting && !notice && <div className="bp-toast">画面连接中断,正在重连…</div>}
329
+ </div>
330
+ <textarea
331
+ ref={keysRef}
332
+ className="bp-keys"
333
+ aria-label="向浏览器输入"
334
+ disabled={!human}
335
+ onKeyDown={onKeyDown}
336
+ onInput={(e) => { if (!(e.nativeEvent as InputEvent).isComposing) flushText(e.currentTarget); }}
337
+ onCompositionEnd={(e) => flushText(e.currentTarget)}
338
+ />
339
+ </div>
340
+ );
341
+ }
342
+
343
+ export function BrowserPane({ backend, jobId, readOnly }: { backend: BoardBackend; jobId: string; readOnly?: boolean }) {
344
+ const [status, setStatus, streamError] = useBrowserStatus(backend, jobId);
345
+ const [busy, setBusy] = useState<string | null>(null);
346
+ const [error, setError] = useState<string | null>(null);
347
+
348
+ const act = async (action: "open" | "close" | "takeover" | "handback") => {
349
+ setBusy(action);
350
+ setError(null);
351
+ try { setStatus(await backend.browserAction(jobId, action)); }
352
+ catch (e) { setError(e instanceof Error ? e.message : String(e)); }
353
+ finally { setBusy(null); }
354
+ };
355
+
356
+ if (!status) return <div className="rail-pad"><div className="prev-note">{streamError ? `读取浏览器状态失败:${streamError}` : "读取浏览器状态…"}</div></div>;
357
+ const running = status.state === "running";
358
+ const human = running && status.control === "human";
359
+ const kept = human && status.handoff?.by === "kept";
360
+ const shownError = error ?? (status.state === "none" ? status.error : null);
361
+
362
+ return (
363
+ <div className="rail-pad bp">
364
+ <div className="bp-head">
365
+ <span className="rail-eyebrow">浏览器 · ego lite</span>
366
+ <span className={`bp-badge ${running ? (human ? "human" : "ai") : ""}`}>
367
+ {status.state === "opening" ? "正在打开" : status.state === "closing" ? "正在关闭" : !running ? "未打开" : kept ? "结果页保留中" : human ? "你在操作" : "AI 控制中"}
368
+ </span>
369
+ {running && status.viewers > 0 && <span className="bp-meta">{status.viewers} 人在看</span>}
370
+ </div>
371
+
372
+ {kept && <div className="bp-kept">AI 已完成,把结果页留在这里供你查看。看完点「关闭浏览器」;任务之后再用浏览器时会自动收回,没人看 10 分钟后也会自动关闭。</div>}
373
+ {human && !kept && status.handoff && (
374
+ <div className="bp-handoff">
375
+ {status.handoff.by === "ai"
376
+ ? <>🖐 AI 请求你操作:<b>{status.handoff.instruction || "完成浏览器里的这一步"}</b></>
377
+ : status.handoff.by === "browser"
378
+ ? <>🖐 浏览器需要你处理(AI 交出了控制权,或页面弹出了权限请求)</>
379
+ : <>你已接管浏览器,AI 的浏览器命令会暂停</>}
380
+ <div className="bp-handoff-sub">完成后点「交还给 AI」。密码只会发到网页,不会进入对话记录。</div>
381
+ </div>
382
+ )}
383
+
384
+ {running ? (
385
+ <>
386
+ <Viewer backend={backend} jobId={jobId} human={human && !readOnly} />
387
+ {!readOnly && (
388
+ <div className="prev-actions bp-actions">
389
+ {human
390
+ ? <button className="btn btn-accent" disabled={!!busy} onClick={() => act("handback")}>{Icon.check()} 交还给 AI</button>
391
+ : <button className="btn btn-line" disabled={!!busy} onClick={() => act("takeover")}>{Icon.edit()} 接管</button>}
392
+ <span style={{ flex: 1 }} />
393
+ <button className="btn btn-ghost btn-sm" disabled={!!busy} onClick={() => act("close")}>{Icon.x()} 关闭浏览器</button>
394
+ </div>
395
+ )}
396
+ </>
397
+ ) : (
398
+ <div className="bp-idle">
399
+ <p>
400
+ {status.capability
401
+ ? `这个任务已开启浏览器能力(${CAPABILITY_LABEL[status.capability] ?? status.capability}):AI 第一次用到时会自动打开,轮次结束自动关闭。`
402
+ : "这个任务还没有开启浏览器能力。手动打开一次就会开启,之后 AI 也能使用这个浏览器(例如你先在这里登录好网站)。"}
403
+ </p>
404
+ {!readOnly && (
405
+ <button className="btn btn-accent" disabled={status.state !== "none" || !!busy} onClick={() => act("open")}>
406
+ {Icon.external()} {status.state === "opening" || busy === "open" ? "正在打开…" : "打开浏览器"}
407
+ </button>
408
+ )}
409
+ <div className="prev-note">
410
+ 节点浏览器:{status.instance.running ? `运行中(${status.instance.spaces} 个任务在用)` : "未运行(首次打开需要几秒启动)"}
411
+ <br />登录状态保存在执行节点的 ego lite 里,所有任务共享:登录一次即可。
412
+ </div>
413
+ </div>
414
+ )}
415
+ {shownError && <div className="bp-error">{shownError}</div>}
416
+ </div>
417
+ );
418
+ }
@@ -0,0 +1,142 @@
1
+ // 改动:移植自 Operator 的 TaskChanges(只读部分:分支、统计、文件列表、逐文件 diff)。
2
+ // 合并 / 解决冲突 / 建 PR 是 Operator 里的操作,经中枢的对话任务不提供。
3
+ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import type { BoardBackend } from "../core/client.js";
5
+ import type { DiffFile, DiffResult } from "../core/types.js";
6
+
7
+ const STATUS_LABEL: Record<string, string> = { A: "新增", M: "修改", D: "删除", R: "重命名", "?": "未跟踪" };
8
+ const COLLAPSE_LINES = 400;
9
+
10
+ // 去掉 diff --git / index / --- / +++ 文件头,只留 hunk 行(与 Operator 一致)。
11
+ function hunkLines(patch: string): string[] {
12
+ const lines = patch.split("\n");
13
+ const i = lines.findIndex((l) => l.startsWith("@@"));
14
+ return i >= 0 ? lines.slice(i) : [];
15
+ }
16
+ function lineClass(line: string): string {
17
+ if (line.startsWith("@@")) return "hunk";
18
+ if (line.startsWith("+") && !line.startsWith("+++")) return "add";
19
+ if (line.startsWith("-") && !line.startsWith("---")) return "del";
20
+ return "";
21
+ }
22
+
23
+ /** 只给了一整段 unified diff 的节点:按文件切开。 */
24
+ export function splitUnified(diff: string): DiffFile[] {
25
+ const files: DiffFile[] = [];
26
+ for (const chunk of diff.split(/^(?=diff --git )/m)) {
27
+ if (!chunk.startsWith("diff --git ")) continue;
28
+ const head = /^diff --git a\/(.+?) b\/(.+)$/m.exec(chunk);
29
+ const path = head?.[2] ?? "?";
30
+ const status = /^new file mode/m.test(chunk) ? "A" : /^deleted file mode/m.test(chunk) ? "D" : /^rename from/m.test(chunk) ? "R" : "M";
31
+ let additions = 0;
32
+ let deletions = 0;
33
+ for (const l of hunkLines(chunk)) {
34
+ if (l.startsWith("+") && !l.startsWith("+++")) additions++;
35
+ else if (l.startsWith("-") && !l.startsWith("---")) deletions++;
36
+ }
37
+ files.push({ path, status, additions, deletions, binary: /^Binary files /m.test(chunk), patch: chunk });
38
+ }
39
+ return files;
40
+ }
41
+
42
+ const FileDiff = memo(function FileDiff({ file: f, userToggled, onToggle, refs, onOpen }: {
43
+ file: DiffFile; userToggled: boolean; onToggle: (path: string) => void;
44
+ refs: { current: Record<string, HTMLDivElement | null> }; onOpen?: (path: string) => void;
45
+ }) {
46
+ const lines = useMemo(() => (f.binary ? [] : hunkLines(f.patch)), [f]);
47
+ const big = lines.length > COLLAPSE_LINES;
48
+ const isCollapsed = userToggled ? !big : big;
49
+ const est = Math.round(34 + (isCollapsed ? 38 : Math.max(1, lines.length) * 18.6));
50
+ return (
51
+ <div className="tc-file" style={{ containIntrinsicSize: `auto ${est}px` }} ref={(el) => { refs.current[f.path] = el; }}>
52
+ <button className="tc-fhead" onClick={() => onToggle(f.path)}>
53
+ <span className={`tc-chev ${isCollapsed ? "" : "open"}`}>▸</span>
54
+ <span className={`tc-st s-${f.status === "?" ? "new" : f.status}`}>{f.status}</span>
55
+ <span className="tc-fpath">{f.path}</span>
56
+ {onOpen && f.status !== "D" && (
57
+ <span className="cli-tc-open" role="button" tabIndex={0} title="打开文件" onClick={(e) => { e.stopPropagation(); onOpen(f.path); }} onKeyDown={(e) => { if (e.key === "Enter") { e.stopPropagation(); onOpen(f.path); } }}>打开</span>
58
+ )}
59
+ <span className="tc-cnt"><b className="add">+{f.additions}</b> <b className="del">−{f.deletions}</b></span>
60
+ </button>
61
+ {isCollapsed ? (
62
+ big && <button className="tc-bigdiff" onClick={() => onToggle(f.path)}>大型 diff({lines.length.toLocaleString()} 行)—— 点击展开</button>
63
+ ) : (
64
+ <div className="tc-hunks">
65
+ {f.binary ? <div className="tc-empty">二进制文件 —— 不予显示</div>
66
+ : lines.length === 0 ? <div className="tc-empty">没有文本改动(仅权限或重命名)。</div>
67
+ : lines.map((ln, i) => <div key={i} className={`dl ${lineClass(ln)}`}>{ln || " "}</div>)}
68
+ {f.truncated && <div className="tc-empty">… 文件 diff 已截断</div>}
69
+ </div>
70
+ )}
71
+ </div>
72
+ );
73
+ });
74
+
75
+ export function ChangesPane({ backend, jobId, refreshKey, onOpen }: { backend: BoardBackend; jobId: string; refreshKey: number; onOpen?: (path: string) => void }) {
76
+ const [data, setData] = useState<DiffResult | null>(null);
77
+ const [error, setError] = useState<string | null>(null);
78
+ const [loading, setLoading] = useState(false);
79
+ const [manual, setManual] = useState(0);
80
+ const [toggled, setToggled] = useState<Set<string>>(new Set());
81
+ const [active, setActive] = useState<string | null>(null);
82
+ const secRefs = useRef<Record<string, HTMLDivElement | null>>({});
83
+ const scrollRef = useRef<HTMLDivElement | null>(null);
84
+
85
+ useEffect(() => {
86
+ let alive = true;
87
+ setLoading(true);
88
+ backend.diff(jobId).then((d) => { if (alive) { setData(d); setError(null); setLoading(false); } },
89
+ (e: unknown) => { if (alive) { setError(e instanceof Error ? e.message : String(e)); setLoading(false); } });
90
+ return () => { alive = false; };
91
+ }, [backend, jobId, refreshKey, manual]);
92
+
93
+ const toggle = useCallback((path: string) => setToggled((s) => { const n = new Set(s); if (n.has(path)) n.delete(path); else n.add(path); return n; }), []);
94
+ const jump = (path: string) => {
95
+ setActive(path);
96
+ const el = secRefs.current[path];
97
+ const box = scrollRef.current;
98
+ if (el && box) box.scrollTo({ top: el.offsetTop - box.offsetTop, behavior: "smooth" });
99
+ };
100
+
101
+ const files = useMemo(() => data?.files ?? (data?.diff || data?.patch ? splitUnified(data.diff ?? data.patch ?? "") : []), [data]);
102
+
103
+ if (error && !data) return <div className="tc-note">读取改动失败:{error} <button className="tc-btn" onClick={() => setManual((n) => n + 1)}>重试</button></div>;
104
+ if (!data) return <div className="tc-note">读取改动…</div>;
105
+ if (data.error) return <div className="tc-note">{data.error} <button className="tc-btn" onClick={() => setManual((n) => n + 1)}>重试</button></div>;
106
+ const unified = !data.files && files.length > 0;
107
+ if (!unified && data.isolated === false) return <div className="tc-note">{data.reason || "该任务没有独立分支,改动直接写在工作区里(见「文件」)。"}</div>;
108
+ const totalAdd = files.reduce((n, f) => n + f.additions, 0);
109
+ const totalDel = files.reduce((n, f) => n + f.deletions, 0);
110
+ const nothing = files.length === 0;
111
+
112
+ return (
113
+ <div className="tc-root">
114
+ <div className="tc-bar">
115
+ {data.branch && <code className="tc-branch">{data.branch}</code>}
116
+ {data.baseLabel && <span className="tc-arrow">→ {data.baseLabel}</span>}
117
+ {!nothing && <span className="tc-stat"><b className="add">+{totalAdd}</b> <b className="del">−{totalDel}</b></span>}
118
+ {data.isDirty && <span className="tc-dirty">● 未提交</span>}
119
+ {(data.ahead ?? 0) > 0 && <span className="tc-ahead">领先 {data.ahead} 个提交</span>}
120
+ <span className="tc-spacer" />
121
+ <button className="tc-btn" onClick={() => setManual((n) => n + 1)} disabled={loading}>{loading ? "…" : "刷新"}</button>
122
+ </div>
123
+ {nothing ? (
124
+ <div className="tc-note">该分支上还没有改动。</div>
125
+ ) : (
126
+ <div className="tc-scroll" ref={scrollRef}>
127
+ <div className="tc-list">
128
+ <div className="tc-list-h">{files.length} 个文件有改动</div>
129
+ {files.map((f) => (
130
+ <button key={f.path} className={`tc-frow ${active === f.path ? "on" : ""}`} onClick={() => jump(f.path)} title={f.path}>
131
+ <span className={`tc-st s-${f.status === "?" ? "new" : f.status}`} title={STATUS_LABEL[f.status] || f.status}>{f.status}</span>
132
+ <span className="tc-fpath">{f.path}</span>
133
+ {f.binary ? <span className="tc-bin">bin</span> : <span className="tc-cnt"><b className="add">+{f.additions}</b> <b className="del">−{f.deletions}</b></span>}
134
+ </button>
135
+ ))}
136
+ </div>
137
+ {files.map((f) => <FileDiff key={f.path} file={f} userToggled={toggled.has(f.path)} onToggle={toggle} refs={secRefs} onOpen={onOpen} />)}
138
+ </div>
139
+ )}
140
+ </div>
141
+ );
142
+ }