diff --git a/apps/web/app/components/browser/desktop-view.tsx b/apps/web/app/components/browser/desktop-view.tsx new file mode 100644 index 00000000..a7b6040f --- /dev/null +++ b/apps/web/app/components/browser/desktop-view.tsx @@ -0,0 +1,209 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import type { SiteListItem } from "@umt/contracts"; +import { Hand, Image as ImageIcon, LoaderCircle, Monitor, Power, RotateCw, Undo2 } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { useBrowserControl, useCloseBrowserSession, useSiteBrowserSession } from "~/api/browser.ts"; +import { LoadingState } from "~/components/console/empty-state.tsx"; +import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { apiData, errorMessage } from "~/lib/api.ts"; +import { qk } from "~/lib/query-keys.ts"; +import { CONTROLLER_LABEL, holderName, waitingText } from "./coords.ts"; +import { SiteOriginView } from "./site-origin-view.tsx"; + +// 原站视图桌面模式(docs/design/webtop-remote-browser.md §5.7):iframe 嵌浏览器桌面(Selkies), +// 画面就是 Agent 正在驱动的同一个浏览器窗口。键鼠权限由服务端按人工租约决定:未接管只能看,接管后可直接点和输入。 +// 桌面不可用(旧镜像 / 未配置)或当前不是安全上下文(HTTP)时回退截图模式,并说明原因。 + +interface DesktopStatus { + available: boolean; + reason: string | null; + role: "controller" | "viewer"; + leaseExpiresAt: string | null; + path: string; + requiresSecureContext: true; +} + +function useDesktopStatus(sessionId: string | null) { + return useQuery({ + queryKey: qk.sub("browser", "desktop", sessionId ?? ""), + queryFn: ({ signal }) => + apiData("/browser/desktop/{id}/_status", { pathParams: { id: sessionId ?? "" }, signal }), + enabled: !!sessionId, + refetchInterval: 10_000, + retry: false, + }); +} + +const CONTROLLER_TONE: Record = { + automation: "neutral", + human: "success", + waiting: "warning", + closed: "neutral", +}; + +type Mode = "auto" | "screenshot"; + +/** 原站视图 Tab:能用桌面模式就用,否则截图模式 */ +export function SiteOriginPanel({ site }: { site: SiteListItem }) { + const session = useSiteBrowserSession(site.id); + const s = session.data ?? null; + const status = useDesktopStatus(s && s.controller !== "closed" ? s.id : null); + const [mode, setMode] = useState("auto"); + const [secure, setSecure] = useState(true); + useEffect(() => setSecure(window.isSecureContext), []); + + const desktop = status.data; + const canDesktop = !!desktop?.available && secure; + if (s && status.isPending && s.controller !== "closed") return ; + if (!s || !canDesktop || mode === "screenshot") { + let note: string | null = null; + if (s && desktop && !desktop.available && desktop.reason) note = `桌面模式不可用:${desktop.reason}。`; + else if (s && desktop?.available && !secure) note = "桌面模式需要通过 HTTPS 访问控制台,当前使用截图模式。"; + return ( +
+ {note || (canDesktop && mode === "screenshot") ? ( +
+ {note ? {note} : null} + {canDesktop ? ( + + ) : null} +
+ ) : null} + +
+ ); + } + return setMode("screenshot")} />; +} + +function DesktopView({ + site, + status, + onScreenshotMode, +}: { + site: SiteListItem; + status: DesktopStatus; + onScreenshotMode: () => void; +}) { + const session = useSiteBrowserSession(site.id); + const s = session.data ?? null; + const control = useBrowserControl(site.id); + const close = useCloseBrowserSession(site.id); + const qc = useQueryClient(); + const refreshRole = () => void qc.invalidateQueries({ queryKey: qk.sub("browser", "desktop") }); + const [reloadKey, setReloadKey] = useState(0); + const [loaded, setLoaded] = useState(false); + + // 控制权变化(接管 / 交还 / 过期)后重连,Selkies 按新口令认证;服务端也会主动断开旧连接 + const role = status.role; + const prevRole = useRef(role); + useEffect(() => { + if (prevRole.current !== role) { + prevRole.current = role; + setLoaded(false); + setReloadKey((k) => k + 1); + } + }, [role]); + + if (!s) return ; + const human = s.controller === "human"; + const mine = role === "controller"; + const busy = control.acquire.isPending || control.release.isPending; + const waiting = waitingText(s.waitingReason); + const leaseHolder = s.lease ? holderName(s.lease.holder) : null; + const conn: { tone: StatusTone; text: string } = loaded + ? mine + ? { tone: "success", text: "已连接,可操作" } + : { tone: "success", text: "已连接,只读" } + : { tone: "neutral", text: "连接中" }; + + return ( +
+
+ {conn.text} + + 当前控制:{CONTROLLER_LABEL[s.controller]} + {human && leaseHolder ? `(${leaseHolder})` : ""} + + {waiting ? {waiting} : null} +
+ {human && mine ? ( + + ) : ( + + )} + + + +
+
+ +

+ {mine + ? "直接在画面里点击和输入。中文、日文用本机输入法输入;复制粘贴用 Ctrl+C / Ctrl+V。" + : "只读观看,Agent 可能正在操作。需要自己点或输入时先接管。"} +

+ +
+