fix(web): agent check default model fallback; ensure browser session before desktop iframe (FIX-5)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
6aaf8c0704
commit
9244315768
4 files changed
+63
-22
No files matched your search
@@ -132,22 +132,29 @@ function DesktopView({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// INT-3(VER-3b):桌面 iframe 直连 Selkies,不经过截图 / 操作接口,不会触发服务端的被动重连 →
|
// INT-3(VER-3b):桌面 iframe 直连 Selkies,不经过截图 / 操作接口,不会触发服务端的被动重连 →
|
||||||
// 打开桌面时先调一次幂等的 reconnect(上游上下文还在时服务端什么都不做),失败再走自动重连
|
// 打开桌面时先调一次幂等的 reconnect(上游上下文还在时服务端什么都不做),失败再走自动重连。
|
||||||
|
// FIX-5(VER-4):先等这次确认结束再连桌面画面,不再先连上黑屏、等 5 秒黑屏检测才重连
|
||||||
const ensure = useBrowserReconnect(site.id);
|
const ensure = useBrowserReconnect(site.id);
|
||||||
const { mutate: ensureMutate } = ensure;
|
const { mutate: ensureMutate } = ensure;
|
||||||
const sid = s && s.controller !== "closed" ? s.id : null;
|
const sid = s && s.controller !== "closed" ? s.id : null;
|
||||||
const ensured = useRef<string | null>(null);
|
const ensured = useRef<string | null>(null);
|
||||||
|
const [ensuredSid, setEnsuredSid] = useState<string | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sid || ensured.current === sid) return;
|
if (!sid || ensured.current === sid) return;
|
||||||
ensured.current = sid;
|
ensured.current = sid;
|
||||||
ensureMutate(sid, { onError: () => setStuck(true) });
|
ensureMutate(sid, {
|
||||||
|
onError: () => setStuck(true),
|
||||||
|
onSettled: () => setEnsuredSid(sid),
|
||||||
|
});
|
||||||
}, [sid, ensureMutate]);
|
}, [sid, ensureMutate]);
|
||||||
|
/** 会话已确认(或已关闭):可以连桌面画面 */
|
||||||
|
const ready = !sid || ensuredSid === sid;
|
||||||
|
|
||||||
// 画面加载失败或黑屏超过 5 秒:每个会话自动重连一次(之后靠「重新连接」按钮)
|
// 画面加载失败或黑屏超过 5 秒:每个会话自动重连一次(之后靠「重新连接」按钮)
|
||||||
const blankTried = useRef<string | null>(null);
|
const blankTried = useRef<string | null>(null);
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reloadKey 变化 = 新一轮加载,重新计时
|
// biome-ignore lint/correctness/useExhaustiveDependencies: reloadKey 变化 = 新一轮加载,重新计时
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sid || blankTried.current === sid) return;
|
if (!sid || !ready || blankTried.current === sid) return;
|
||||||
const t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
if (loaded && !desktopBlank(frame.current)) return;
|
if (loaded && !desktopBlank(frame.current)) return;
|
||||||
blankTried.current = sid;
|
blankTried.current = sid;
|
||||||
@@ -155,7 +162,7 @@ function DesktopView({
|
|||||||
setStuck(true);
|
setStuck(true);
|
||||||
}, BLANK_TIMEOUT_MS);
|
}, BLANK_TIMEOUT_MS);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [sid, loaded, reloadKey]);
|
}, [sid, ready, loaded, reloadKey]);
|
||||||
// 别处(Agent 操作、截图)触发的重连成功后也重建桌面连接
|
// 别处(Agent 操作、截图)触发的重连成功后也重建桌面连接
|
||||||
const reconnectedAt = s?.reconnect?.status === "ok" ? s.reconnect.at : null;
|
const reconnectedAt = s?.reconnect?.status === "ok" ? s.reconnect.at : null;
|
||||||
const prevReconnect = useRef(reconnectedAt);
|
const prevReconnect = useRef(reconnectedAt);
|
||||||
@@ -187,8 +194,9 @@ function DesktopView({
|
|||||||
const waiting = waitingText(s.waitingReason);
|
const waiting = waitingText(s.waitingReason);
|
||||||
const cred = credentialBadge(s.credential);
|
const cred = credentialBadge(s.credential);
|
||||||
const leaseHolder = s.lease ? holderName(s.lease.holder) : null;
|
const leaseHolder = s.lease ? holderName(s.lease.holder) : null;
|
||||||
const conn: { tone: StatusTone; text: string } =
|
const conn: { tone: StatusTone; text: string } = !ready
|
||||||
rc.state.phase === "reconnecting"
|
? { tone: "neutral", text: "正在确认浏览器会话" }
|
||||||
|
: rc.state.phase === "reconnecting"
|
||||||
? { tone: "warning", text: "正在重新连接" }
|
? { tone: "warning", text: "正在重新连接" }
|
||||||
: rc.state.phase === "failed"
|
: rc.state.phase === "failed"
|
||||||
? { tone: "destructive", text: "连接中断" }
|
? { tone: "destructive", text: "连接中断" }
|
||||||
@@ -278,15 +286,27 @@ function DesktopView({
|
|||||||
<ReconnectPanel state={rc.state} onRetry={rc.retry} />
|
<ReconnectPanel state={rc.state} onRetry={rc.retry} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<iframe
|
{ready ? (
|
||||||
key={reloadKey}
|
<iframe
|
||||||
ref={frame}
|
key={reloadKey}
|
||||||
title="浏览器桌面"
|
ref={frame}
|
||||||
src={status.path}
|
title="浏览器桌面"
|
||||||
onLoad={() => setLoaded(true)}
|
src={status.path}
|
||||||
allow="clipboard-read; clipboard-write; fullscreen; keyboard-map"
|
onLoad={() => setLoaded(true)}
|
||||||
className="block aspect-[1280/900] w-full border-0 bg-scrim"
|
allow="clipboard-read; clipboard-write; fullscreen; keyboard-map"
|
||||||
/>
|
className="block aspect-[1280/900] w-full border-0 bg-scrim"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className="grid aspect-[1280/900] w-full place-items-center bg-scrim text-caption text-muted-foreground"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||||
|
正在确认浏览器会话…
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,7 +31,12 @@ const plainSummary = (t: string) =>
|
|||||||
const capTitle = (id: string) => CAPABILITY_CATALOG[id as KnownCapabilityId]?.title ?? id;
|
const capTitle = (id: string) => CAPABILITY_CATALOG[id as KnownCapabilityId]?.title ?? id;
|
||||||
|
|
||||||
/** 默认上游(巡检没有单独的上游设置,跟随 Agent 设置里的默认上游) */
|
/** 默认上游(巡检没有单独的上游设置,跟随 Agent 设置里的默认上游) */
|
||||||
export type DefaultUpstream = { name: string; model: string | null } | null;
|
export type DefaultUpstream = {
|
||||||
|
name: string;
|
||||||
|
/** 上游的默认模型;上游没填时是模型目录的第一个(modelFromCatalog) */
|
||||||
|
model: string | null;
|
||||||
|
modelFromCatalog?: boolean;
|
||||||
|
} | null;
|
||||||
|
|
||||||
const modelsTitle = (models: { provider: string | null; model: string; count: number }[]) =>
|
const modelsTitle = (models: { provider: string | null; model: string; count: number }[]) =>
|
||||||
models.map((m) => `${m.provider ? `${m.provider} · ` : ""}${m.model}:${m.count} 次`).join("\n");
|
models.map((m) => `${m.provider ? `${m.provider} · ` : ""}${m.model}:${m.count} 次`).join("\n");
|
||||||
@@ -152,9 +157,16 @@ export function AgentCheckCard({
|
|||||||
) : null}
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
) : upstream ? (
|
) : upstream ? (
|
||||||
<span className="break-words">
|
<span
|
||||||
默认上游 {upstream.name}
|
className="break-words"
|
||||||
{upstream.model ? ` · ${upstream.model}` : ""}
|
title={
|
||||||
|
upstream.modelFromCatalog
|
||||||
|
? "上游没有指定默认模型,运行时使用模型目录里的第一个"
|
||||||
|
: "还没有运行记录,按默认上游显示"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
默认上游 {upstream.name} · {upstream.model ?? "模型未指定"}
|
||||||
|
{upstream.modelFromCatalog ? <span className="text-muted-foreground">(目录首个)</span> : null}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground">未配置 Agent 上游</span>
|
<span className="text-muted-foreground">未配置 Agent 上游</span>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { SCHEDULE_CHANGED_EVENT, type ScheduledJob } from "@umt/contracts";
|
|||||||
import { AlertTriangle, Bot, CalendarClock, CircleCheck, Coins, LoaderCircle, Plus, XCircle } from "lucide-react";
|
import { AlertTriangle, Bot, CalendarClock, CircleCheck, Coins, LoaderCircle, Plus, XCircle } from "lucide-react";
|
||||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||||
import { useLocation } from "react-router";
|
import { useLocation } from "react-router";
|
||||||
import { usePiUpstreams } from "~/api/pi.ts";
|
import { usePiUpstreamModels, usePiUpstreams } from "~/api/pi.ts";
|
||||||
import { useScheduleBoard } from "~/api/schedules.ts";
|
import { useScheduleBoard } from "~/api/schedules.ts";
|
||||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||||
import { StatTile } from "~/components/console/stat-tile.tsx";
|
import { StatTile } from "~/components/console/stat-tile.tsx";
|
||||||
@@ -96,7 +96,16 @@ export function PlanBoard({ highlightJob }: { highlightJob?: string }) {
|
|||||||
const autoJobs = items.filter((j) => j.kind === "task");
|
const autoJobs = items.filter((j) => j.kind === "task");
|
||||||
const checks = items.filter((j) => j.kind === "agent_check");
|
const checks = items.filter((j) => j.kind === "agent_check");
|
||||||
const def = upstreams.data?.items.find((u) => u.isDefault);
|
const def = upstreams.data?.items.find((u) => u.isDefault);
|
||||||
const upstream: DefaultUpstream = def ? { name: def.name, model: def.defaultModel ?? null } : null;
|
// 上游没填默认模型时,运行时用模型目录的第一个(packages/agent pi/upstream.ts pickModel),这里同样取目录首个
|
||||||
|
const catalog = usePiUpstreamModels(def && !def.defaultModel ? def.id : null);
|
||||||
|
const catalogModel = catalog.data?.models[0]?.id ?? null;
|
||||||
|
const upstream: DefaultUpstream = def
|
||||||
|
? {
|
||||||
|
name: def.name,
|
||||||
|
model: def.defaultModel ?? catalogModel,
|
||||||
|
modelFromCatalog: !def.defaultModel && !!catalogModel,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
const s = planStats(items);
|
const s = planStats(items);
|
||||||
const val = (n: number) => (board.isPending ? "…" : board.isError ? "—" : n);
|
const val = (n: number) => (board.isPending ? "…" : board.isError ? "—" : n);
|
||||||
|
|
||||||
|
|||||||
@@ -268,7 +268,7 @@ pixelflux + labwc Wayland 桌面 + Selkies 2.0 Web 客户端)+ camofox 1.17.0 **
|
|||||||
| 初始化 | `init-umt-desktop` | 由 `CAMOFOX_ACCESS_KEY` 派生 Selkies 控制 / 只读口令;覆盖 labwc autostart;把 `/root/.camofox` 交给 `abc` |
|
| 初始化 | `init-umt-desktop` | 由 `CAMOFOX_ACCESS_KEY` 派生 Selkies 控制 / 只读口令;覆盖 labwc autostart;把 `/root/.camofox` 交给 `abc` |
|
||||||
| 反代 | `apps/server/src/modules/desktop` | `/api/v1/browser/desktop/{sessionId}/*` → 容器 `:3000`(静态客户端 + `api/websockets`);只接受控制台会话 |
|
| 反代 | `apps/server/src/modules/desktop` | `/api/v1/browser/desktop/{sessionId}/*` → 容器 `:3000`(静态客户端 + `api/websockets`);只接受控制台会话 |
|
||||||
| 前端 | `apps/web/app/components/browser/desktop-view.tsx` | 原站视图 Tab:桌面可用且是安全上下文 → 桌面模式;否则截图模式并说明原因;可手动切换 |
|
| 前端 | `apps/web/app/components/browser/desktop-view.tsx` | 原站视图 Tab:桌面可用且是安全上下文 → 桌面模式;否则截图模式并说明原因;可手动切换 |
|
||||||
| 自愈(INT-3) | 同上 | 打开桌面先调一次幂等 `POST /browser/sessions/{id}/reconnect`(iframe 直连 Selkies 不触发被动重连);画面 5 秒未加载或视频流未出帧 → 每个会话自动重连一次并重建桌面连接,之后靠「重新连接」按钮 |
|
| 自愈(INT-3 / FIX-5) | 同上 | 打开桌面先调一次幂等 `POST /browser/sessions/{id}/reconnect`(iframe 直连 Selkies 不触发被动重连),等它返回后才加载桌面 iframe(期间显示「正在确认浏览器会话…」,FIX-5 起不再先连上黑屏);画面 5 秒未加载或视频流未出帧 → 每个会话自动重连一次并重建桌面连接,之后靠「重新连接」按钮 |
|
||||||
| HTTPS | compose `caddy`(profile `tls`)+ `docker/browser-desktop/Caddyfile` | 可选;已有反代终结 TLS 时不用 |
|
| HTTPS | compose `caddy`(profile `tls`)+ `docker/browser-desktop/Caddyfile` | 可选;已有反代终结 TLS 时不用 |
|
||||||
|
|
||||||
窗口:无标题栏、最大化铺满 1280×900(`BROWSER_DESKTOP_WIDTH/HEIGHT`),`HARDEN_DESKTOP` 去掉终端 / sudo / xdg-open,
|
窗口:无标题栏、最大化铺满 1280×900(`BROWSER_DESKTOP_WIDTH/HEIGHT`),`HARDEN_DESKTOP` 去掉终端 / sudo / xdg-open,
|
||||||
|
|||||||
Reference in new issue
Block a user