feat: ship native boss android console
This commit is contained in:
371
src/components/app-runtime.tsx
Normal file
371
src/components/app-runtime.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user