"use client"; import { useEffect, useRef, useState } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import clsx from "clsx"; import type { BossEventName } from "@/lib/boss-events"; import type { SkillInventoryDeviceGroup } from "@/lib/boss-projections"; import { clearNativeSessionSnapshot, currentAppLocation, isNativeBossApp, persistNativeSessionSnapshot, popAppHistoryEntry, pushAppHistoryEntry, readNativeSessionSnapshot, resolveAppBackAction, } from "@/lib/boss-app-client"; export async function sendAppLog(payload: { deviceId: string; projectId?: string; level: "info" | "warn" | "error"; category: string; message: string; detail?: string; mirrorToMaster?: boolean; }) { try { await fetch("/api/v1/app-logs", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), keepalive: true, }); } catch { // Ignore client-side log transport errors. } } type AuthSessionPayload = { account: string; displayName: string; expiresAt: string; restoreToken?: string; }; function currentProjectId(pathname: string) { const match = pathname.match(/^\/conversations\/([^/]+)/); return match?.[1]; } function basePathname(pathname: string) { return pathname.split("?")[0]?.split("#")[0] || "/"; } async function fetchNativeAwareSession() { const response = await fetch("/api/auth/session", { cache: "no-store", headers: { "x-boss-native-app": "1" }, }); if (!response.ok) return null; const result = (await response.json()) as { ok: boolean; session?: AuthSessionPayload }; return result.ok ? (result.session ?? null) : null; } async function persistSessionFromPayload(session?: AuthSessionPayload | null) { if (!session?.restoreToken) return; await persistNativeSessionSnapshot({ restoreToken: session.restoreToken, account: session.account, displayName: session.displayName, expiresAt: session.expiresAt, lastSyncedAt: new Date().toISOString(), }); } export function AppLogBridge({ deviceId, }: { deviceId?: string; }) { const pathname = usePathname(); useEffect(() => { if (!deviceId) return; void sendAppLog({ deviceId, level: "info", category: "app.lifecycle.ready", message: "APP 客户端已连接到日志桥。", mirrorToMaster: false, }); }, [deviceId]); useEffect(() => { if (!deviceId) return; void sendAppLog({ deviceId, projectId: currentProjectId(pathname), level: "info", category: "navigation.route_changed", message: pathname, mirrorToMaster: false, }); }, [deviceId, pathname]); useEffect(() => { if (!deviceId) return; const onError = (event: ErrorEvent) => { void sendAppLog({ deviceId, projectId: currentProjectId(pathname), level: "error", category: "runtime.error", message: event.message || "未知前端错误", detail: event.filename ? `${event.filename}:${event.lineno}` : undefined, mirrorToMaster: true, }); }; const onRejection = (event: PromiseRejectionEvent) => { void sendAppLog({ deviceId, projectId: currentProjectId(pathname), level: "error", category: "runtime.unhandled_rejection", message: "捕获到未处理 Promise 异常。", detail: String(event.reason ?? "unknown"), mirrorToMaster: true, }); }; window.addEventListener("error", onError); window.addEventListener("unhandledrejection", onRejection); return () => { window.removeEventListener("error", onError); window.removeEventListener("unhandledrejection", onRejection); }; }, [deviceId, pathname]); return null; } export function NativeAppBridge() { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); const restoreInFlightRef = useRef(false); const currentPath = searchParams.toString() ? `${pathname}?${searchParams.toString()}` : pathname; useEffect(() => { pushAppHistoryEntry(currentPath); }, [currentPath]); useEffect(() => { let cancelled = false; async function reconcileSession() { const isNative = await isNativeBossApp(); if (!isNative || restoreInFlightRef.current) { return; } const activeSession = await fetchNativeAwareSession().catch(() => null); if (cancelled) return; if (activeSession?.restoreToken) { await persistSessionFromPayload(activeSession); return; } const stored = await readNativeSessionSnapshot(); if (cancelled || !stored?.restoreToken) return; if (Date.parse(stored.expiresAt) <= Date.now()) { await clearNativeSessionSnapshot(); return; } restoreInFlightRef.current = true; try { const response = await fetch("/api/auth/restore", { method: "POST", headers: { "Content-Type": "application/json", "x-boss-native-app": "1" }, body: JSON.stringify({ restoreToken: stored.restoreToken }), }); if (!response.ok) { await clearNativeSessionSnapshot(); return; } const result = (await response.json()) as { ok: boolean; session?: AuthSessionPayload }; if (!result.ok || !result.session?.restoreToken) { await clearNativeSessionSnapshot(); return; } await persistSessionFromPayload(result.session); if (basePathname(pathname).startsWith("/auth")) { popAppHistoryEntry(currentPath); router.replace("/conversations", { scroll: false }); return; } router.refresh(); } finally { restoreInFlightRef.current = false; } } void reconcileSession(); return () => { cancelled = true; }; }, [currentPath, pathname, router]); useEffect(() => { let remove: (() => void) | undefined; let disposed = false; async function bindBackHandler() { if (!(await isNativeBossApp())) return; const { App } = await import("@capacitor/app"); if (disposed) return; const listener = await App.addListener("backButton", () => { const location = currentAppLocation(); const action = resolveAppBackAction(location); if (action.mode === "history") { popAppHistoryEntry(location); router.back(); return; } if (action.mode === "replace") { popAppHistoryEntry(location); router.replace(action.target, { scroll: false }); } }); remove = () => { void listener.remove(); }; } void bindBackHandler(); return () => { disposed = true; remove?.(); }; }, [router, currentPath]); return null; } export function RealtimeRefresh({ events, projectId, }: { events: BossEventName[]; projectId?: string; }) { const router = useRouter(); useEffect(() => { const source = new EventSource("/api/v1/events"); const listeners = Array.from(new Set([ "conversation.context_indicator.updated", "project.context_risk.updated", ...events, ])); const shouldRefresh = (event: Event) => { if (!projectId || !("data" in event) || typeof event.data !== "string" || !event.data.trim()) { return true; } try { const payload = JSON.parse(event.data) as { projectId?: string }; if (!payload || typeof payload !== "object" || Array.isArray(payload)) { return true; } if (typeof payload.projectId !== "string" || !payload.projectId.trim()) { return true; } return payload.projectId === projectId; } catch { return true; } }; const listenerMap = new Map void>(); for (const event of listeners) { const refresh = (nextEvent: Event) => { if (!shouldRefresh(nextEvent)) { return; } router.refresh(); }; listenerMap.set(event, refresh); source.addEventListener(event, refresh); } return () => { for (const event of listeners) { const refresh = listenerMap.get(event); if (refresh) { source.removeEventListener(event, refresh); } } source.close(); }; }, [events, projectId, router]); return null; } export function SkillInventoryPanel({ groups, boundDeviceId, }: { groups: SkillInventoryDeviceGroup[]; boundDeviceId?: string; }) { const [message, setMessage] = useState(""); async function copyInvocation(skill: SkillInventoryDeviceGroup["skills"][number]) { try { await navigator.clipboard.writeText(skill.invocation); setMessage(`已复制 ${skill.name} 的调用语句。`); } catch { setMessage(`复制 ${skill.name} 失败,请手动复制。`); return; } if (boundDeviceId) { void sendAppLog({ deviceId: boundDeviceId, level: "info", category: "skill.copy_invocation", message: `已复制 Skill:${skill.name}`, detail: skill.invocation, mirrorToMaster: true, }); } } return (
技能按绑定设备分类展示。每台电脑只显示自己已同步上来的 Skill,点击即可一键复制调用语句。
{groups.map((group) => (
{group.device.name}
{group.device.account} · {group.skills.length} 个 Skill
{group.device.id === boundDeviceId ? "当前绑定设备" : "已同步设备"}
{group.skills.map((skill) => (
{skill.name}
{skill.path}
{skill.description}
调用语句:{skill.invocation}
))}
))} {message ? (
{message}
) : null} {!groups.length ? (
当前还没有同步到任何 Skill。请先保证本机 local-agent 在线,并已扫描到 `~/.codex/skills`。
) : null}
); }