import type { BrowserSessionView } from "@umt/contracts"; import { LoaderCircle, RotateCw } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { useBrowserReconnect } from "~/api/browser.ts"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { Button } from "~/components/ui/button.tsx"; import { ApiRequestError, errorMessage } from "~/lib/api.ts"; // BR-3 浏览器服务(camofox)重启后的自动重连:截图模式与桌面模式共用。 // 收到"标签页 / 会话不在了"(404)、"已被回收"(409)或自动重连失败(503),或会话详情标了 needsReconnect 时, // 自动调用一次 POST /browser/sessions/{id}/reconnect;成功后继续轮询,失败显示原因与"重新连接"按钮,不再反复刷错误。 /** 这个错误是否说明浏览器服务重启过(上游标签页 / 会话不在了、自动重连失败) */ export function isBrowserGone(err: unknown): boolean { if (!(err instanceof ApiRequestError)) return false; if (err.code === "not_found" || err.code === "browser_reconnect_failed") return true; return err.code === "conflict" && /回收/.test(err.message); } export type ReconnectPhase = { phase: "idle" } | { phase: "reconnecting" } | { phase: "failed"; reason: string }; /** * gone = 当前视图收到了"浏览器不在了"类错误。每次出问题只自动重连一次(问题消失后复位); * 失败后等人点"重新连接"(retry)。onReconnected:重连成功(桌面模式据此重建 Selkies 连接)。 */ export function useAutoReconnect( siteId: string, session: BrowserSessionView | null, gone: boolean, onReconnected?: () => void, ) { const mutation = useBrowserReconnect(siteId); const [state, setState] = useState({ phase: "idle" }); const tried = useRef(null); const logged = useRef(false); const done = useRef(onReconnected); done.current = onReconnected; const id = session && session.controller !== "closed" ? session.id : null; const trigger = !!id && (gone || session?.needsReconnect === true); const { mutate } = mutation; const run = useCallback(() => { if (!id) return; setState({ phase: "reconnecting" }); mutate(id, { onSuccess: () => { setState({ phase: "idle" }); done.current?.(); }, onError: (e) => setState({ phase: "failed", reason: errorMessage(e, "浏览器服务不可用") }), }); }, [id, mutate]); useEffect(() => { if (!trigger) { // 问题消失(截图恢复 / 会话不再需要重连):下次再出问题时还能自动重连一次 if (tried.current === id) tried.current = null; return; } if (tried.current === id) return; tried.current = id; if (!logged.current) { logged.current = true; console.debug("[browser] 浏览器服务可能重启过,自动重新连接一次", id); } run(); }, [trigger, id, run]); return { state, retry: run, busy: state.phase === "reconnecting" }; } /** 画面区域:重连中 / 重连失败(原因 + 重新连接按钮) */ export function ReconnectPanel({ state, error, onRetry, }: { state: ReconnectPhase; /** idle 但仍拿到"浏览器不在了"类错误时显示它的消息 */ error?: unknown; onRetry: () => void; }) { if (state.phase === "reconnecting") return (

浏览器已重启,正在重新连接…

重新注入账号登录态,并回到原来的页面。

); const reason = state.phase === "failed" ? state.reason : errorMessage(error, "浏览器服务不可用"); return (

浏览器连接已断开

{reason}

); } /** 人工接管中浏览器重连过:页面已重新加载(表单输入、滚动位置都没了),提示核对;点"知道了"收起 */ export function ReloadNotice({ session }: { session: BrowserSessionView }) { const r = session.reconnect; const at = r?.status === "ok" && r.humanNotice && session.controller === "human" ? r.at : null; const [dismissed, setDismissed] = useState(null); if (!at || dismissed === at) return null; return (
浏览器重启过,页面已重新加载,表单输入和滚动位置已丢失
); }