Files
boss/src/components/app-runtime.tsx

401 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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<string, (event: Event) => 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 (
<div className="space-y-4">
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4 text-[13px] leading-6 text-[#57606A]">
Skill
</div>
{groups.map((group) => (
<div key={group.device.id} className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-[16px] font-semibold text-[#111111]">{group.device.name}</div>
<div className="mt-1 text-[12px] text-[#8C8C8C]">
{group.device.account} · {group.skills.length} Skill
</div>
</div>
<span
className={clsx(
"rounded-full px-3 py-1 text-[12px] font-semibold",
group.device.id === boundDeviceId ? "bg-[#EAF7F0] text-[#215B39]" : "bg-[#F5F5F7] text-[#57606A]",
)}
>
{group.device.id === boundDeviceId ? "当前绑定设备" : "已同步设备"}
</span>
</div>
<div className="mt-4 space-y-3">
{group.skills.map((skill) => (
<div key={skill.skillId} className="rounded-2xl bg-[#F7F8FA] px-4 py-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[14px] font-semibold text-[#111111]">{skill.name}</div>
<div className="mt-1 text-[12px] text-[#8C8C8C]">{skill.path}</div>
</div>
<button
type="button"
onClick={() => void copyInvocation(skill)}
className="rounded-full bg-[#07C160] px-3 py-2 text-[12px] font-semibold text-white"
>
</button>
</div>
<div className="mt-2 text-[13px] leading-6 text-[#57606A]">{skill.description}</div>
<div className="mt-2 text-[12px] text-[#8C8C8C]">{skill.invocation}</div>
</div>
))}
</div>
</div>
))}
{message ? (
<div className="rounded-2xl bg-[#EAF7F0] px-4 py-3 text-[12px] text-[#215B39]">{message}</div>
) : null}
{!groups.length ? (
<div className="rounded-2xl bg-[#FFF7E6] px-4 py-4 text-[13px] leading-6 text-[#D46B08]">
Skill local-agent 线 `~/.codex/skills`
</div>
) : null}
</div>
);
}