feat: ship native boss android console

This commit is contained in:
kris
2026-03-26 23:16:56 +08:00
parent 90e904814d
commit 90cb6b7ff1
261 changed files with 40051 additions and 135 deletions

View File

@@ -0,0 +1,371 @@
"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,
}: {
events: BossEventName[];
}) {
const router = useRouter();
useEffect(() => {
const source = new EventSource("/api/v1/events");
const refresh = () => router.refresh();
const listeners = [
"conversation.context_indicator.updated",
"project.context_risk.updated",
...events,
];
for (const event of listeners) {
source.addEventListener(event, refresh);
}
return () => {
for (const event of listeners) {
source.removeEventListener(event, refresh);
}
source.close();
};
}, [events, 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>
);
}