401 lines
12 KiB
TypeScript
401 lines
12 KiB
TypeScript
"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>
|
||
);
|
||
}
|