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,627 @@
"use client";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import clsx from "clsx";
import type {
AiAccountRole,
AiAccountSummary,
AiAccountSwitchRecord,
AiProvider,
MasterIdentitySummary,
} from "@/lib/boss-data";
import { formatTimestampLabel } from "@/lib/boss-projections";
type AccountDraft = {
label: string;
role: AiAccountRole;
provider: AiProvider;
displayName: string;
accountIdentifier: string;
nodeId: string;
nodeLabel: string;
model: string;
apiKey: string;
enabled: boolean;
loginStatusNote: string;
};
function roleOptions() {
return [
{ value: "primary", label: "主 GPT" },
{ value: "backup", label: "备用 GPT" },
{ value: "api_fallback", label: "API 容灾" },
] as const;
}
function providerOptions() {
return [
{ value: "openai_api", label: "OpenAI API" },
{ value: "master_codex_node", label: "Master Codex Node / ChatGPT Plus 节点" },
] as const;
}
function emptyDraft(): AccountDraft {
return {
label: "API 容灾",
role: "api_fallback",
provider: "openai_api",
displayName: "",
accountIdentifier: "",
nodeId: "",
nodeLabel: "",
model: "gpt-5.4",
apiKey: "",
enabled: true,
loginStatusNote: "",
};
}
function draftFromAccount(account: AiAccountSummary): AccountDraft {
return {
label: account.label,
role: account.role,
provider: account.provider,
displayName: account.displayName,
accountIdentifier: account.accountIdentifier || "",
nodeId: account.nodeId || "",
nodeLabel: account.nodeLabel || "",
model: account.model || "gpt-5.4",
apiKey: "",
enabled: account.enabled,
loginStatusNote: account.loginStatusNote || "",
};
}
function statusClasses(role: MasterIdentitySummary["role"]) {
switch (role) {
case "primary":
return "bg-[#EEF5FF] text-[#2457C5]";
case "backup":
return "bg-[#FFF5E8] text-[#B54708]";
case "api_fallback":
return "bg-[#F3F4F6] text-[#4B5563]";
default:
return "bg-[#F3F4F6] text-[#4B5563]";
}
}
function roleBadgeClasses(role: AiAccountRole) {
switch (role) {
case "primary":
return "bg-[#EEF5FF] text-[#2457C5]";
case "backup":
return "bg-[#FFF5E8] text-[#B54708]";
case "api_fallback":
return "bg-[#F3F4F6] text-[#4B5563]";
default:
return "bg-[#F3F4F6] text-[#4B5563]";
}
}
function AccountField({
label,
value,
onChange,
placeholder,
type = "text",
}: {
label: string;
value: string;
onChange: (value: string) => void;
placeholder: string;
type?: "text" | "password";
}) {
return (
<label className="space-y-1">
<div className="text-[12px] text-[#8C8C8C]">{label}</div>
<input
type={type}
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
className="w-full rounded-xl border border-[#E5E5EA] bg-[#F7F8FA] px-3 py-2 text-[13px] text-[#111111] outline-none"
/>
</label>
);
}
export function AiAccountsClient({
accounts,
activeIdentity,
switchHistory,
canManage,
}: {
accounts: AiAccountSummary[];
activeIdentity: MasterIdentitySummary;
switchHistory: AiAccountSwitchRecord[];
canManage: boolean;
}) {
const router = useRouter();
const [drafts, setDrafts] = useState<Record<string, AccountDraft>>({});
const [newDraft, setNewDraft] = useState<AccountDraft>(emptyDraft());
const [busyKey, setBusyKey] = useState<string | null>(null);
const [message, setMessage] = useState("");
const accountDrafts = useMemo(
() =>
Object.fromEntries(
accounts.map((account) => [account.accountId, drafts[account.accountId] ?? draftFromAccount(account)]),
),
[accounts, drafts],
);
function updateDraft(accountId: string, updater: (draft: AccountDraft) => AccountDraft) {
setDrafts((current) => ({
...current,
[accountId]: updater(current[accountId] ?? draftFromAccount(accounts.find((item) => item.accountId === accountId)!)),
}));
}
async function saveAccount(accountId?: string) {
const isNew = !accountId;
const draft = isNew ? newDraft : accountDrafts[accountId];
if (!draft.displayName.trim()) {
setMessage("AI 账号名称不能为空。");
return;
}
setBusyKey(`${isNew ? "create" : "save"}:${accountId ?? "new"}`);
const response = await fetch(isNew ? "/api/v1/accounts" : `/api/v1/accounts/${accountId}`, {
method: isNew ? "POST" : "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(draft),
});
const result = (await response.json()) as { ok: boolean; message?: string };
setBusyKey(null);
setMessage(
result.ok
? isNew
? "AI 账号已创建。"
: "AI 账号已更新。"
: result.message || "AI 账号保存失败。",
);
if (result.ok) {
if (isNew) {
setNewDraft(emptyDraft());
}
router.refresh();
}
}
async function activateAccount(accountId: string) {
setBusyKey(`activate:${accountId}`);
const response = await fetch(`/api/v1/accounts/${accountId}/activate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason: "在手机端手动切换主控身份" }),
});
const result = (await response.json()) as { ok: boolean; message?: string };
setBusyKey(null);
setMessage(result.ok ? "主控身份已切换。" : result.message || "切换失败。");
if (result.ok) {
router.refresh();
}
}
async function validateAccount(accountId: string) {
setBusyKey(`validate:${accountId}`);
const response = await fetch(`/api/v1/accounts/${accountId}/validate`, {
method: "POST",
});
const result = (await response.json()) as { ok: boolean; message?: string };
setBusyKey(null);
setMessage(result.ok ? result.message || "连接测试通过。" : result.message || "连接测试失败。");
router.refresh();
}
async function removeAccount(accountId: string) {
setBusyKey(`delete:${accountId}`);
const response = await fetch(`/api/v1/accounts/${accountId}`, {
method: "DELETE",
});
const result = (await response.json()) as { ok: boolean; message?: string };
setBusyKey(null);
setMessage(result.ok ? "AI 账号已删除。" : result.message || "删除失败。");
if (result.ok) {
router.refresh();
}
}
return (
<div className="space-y-4 px-[18px] pb-6">
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[16px] font-semibold text-[#111111]"></div>
<div className="mt-2 text-[13px] leading-6 text-[#57606A]">
{activeIdentity.displayName}
{activeIdentity.nodeLabel ? ` · ${activeIdentity.nodeLabel}` : ""}
</div>
<div className="mt-1 text-[12px] text-[#8C8C8C]">
{activeIdentity.providerLabel}
{activeIdentity.model ? ` · ${activeIdentity.model}` : ""}
{activeIdentity.lastSwitchedAt
? ` · 最近切换 ${formatTimestampLabel(activeIdentity.lastSwitchedAt)}`
: ""}
</div>
</div>
<div
className={clsx(
"rounded-full px-3 py-1 text-[12px] font-semibold",
statusClasses(activeIdentity.role),
)}
>
{activeIdentity.roleLabel}
</div>
</div>
<div className="mt-3 rounded-2xl bg-[#F7F8FA] px-3 py-3 text-[12px] leading-6 text-[#57606A]">
{activeIdentity.statusLabel}
<br />
{activeIdentity.note}
</div>
</div>
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
<div className="text-[16px] font-semibold text-[#111111]"></div>
<div className="mt-2 text-[12px] leading-6 text-[#57606A]">
GPT / GPT API 线 Master
Codex Node ChatGPT Plus / Codex
<span className="font-medium text-[#111111]">
Boss Web -&gt; task queue -&gt; local-agent -&gt; codex exec -&gt;
</span>
OpenAI API
</div>
<div className="mt-3 rounded-2xl bg-[#F7F8FA] px-3 py-3 text-[12px] leading-6 text-[#57606A]">
GPT / GPT
<span className="font-medium text-[#111111]"> Master Codex Node </span>
Codex / ChatGPT APP 退
</div>
</div>
{accounts.map((account) => {
const draft = accountDrafts[account.accountId];
const locked = account.isEnvironmentFallback || !canManage;
return (
<div key={account.accountId} className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<div className="text-[16px] font-semibold text-[#111111]">{account.label}</div>
<span
className={clsx(
"rounded-full px-2 py-1 text-[11px] font-semibold",
roleBadgeClasses(account.role),
)}
>
{account.roleLabel}
</span>
{account.isEnvironmentFallback ? (
<span className="rounded-full bg-[#EAF7F0] px-2 py-1 text-[11px] font-semibold text-[#215B39]">
</span>
) : null}
</div>
<div className="mt-1 text-[12px] text-[#8C8C8C]">
{account.providerLabel}
{account.nodeLabel ? ` · ${account.nodeLabel}` : ""}
{account.model ? ` · ${account.model}` : ""}
</div>
</div>
{account.isActive ? (
<span className="rounded-full bg-[#111111] px-3 py-1 text-[11px] font-semibold text-white">
</span>
) : null}
</div>
<div className="mt-3 grid grid-cols-2 gap-3">
<AccountField
label="标签"
value={draft.label}
onChange={(value) => updateDraft(account.accountId, (current) => ({ ...current, label: value }))}
placeholder="例如:主 GPT"
/>
<AccountField
label="AI 名称"
value={draft.displayName}
onChange={(value) =>
updateDraft(account.accountId, (current) => ({ ...current, displayName: value }))
}
placeholder="例如OpenAI 主控"
/>
<label className="space-y-1">
<div className="text-[12px] text-[#8C8C8C]"></div>
<select
value={draft.role}
onChange={(event) =>
updateDraft(account.accountId, (current) => ({
...current,
role: event.target.value as AiAccountRole,
}))
}
disabled={locked}
className="w-full rounded-xl border border-[#E5E5EA] bg-[#F7F8FA] px-3 py-2 text-[13px] text-[#111111] outline-none"
>
{roleOptions().map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="space-y-1">
<div className="text-[12px] text-[#8C8C8C]"></div>
<select
value={draft.provider}
onChange={(event) =>
updateDraft(account.accountId, (current) => ({
...current,
provider: event.target.value as AiProvider,
}))
}
disabled={locked}
className="w-full rounded-xl border border-[#E5E5EA] bg-[#F7F8FA] px-3 py-2 text-[13px] text-[#111111] outline-none"
>
{providerOptions().map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<AccountField
label="账号标识"
value={draft.accountIdentifier}
onChange={(value) =>
updateDraft(account.accountId, (current) => ({ ...current, accountIdentifier: value }))
}
placeholder="手机号 / 邮箱 / 账号别名"
/>
<AccountField
label="节点标签"
value={draft.nodeLabel}
onChange={(value) =>
updateDraft(account.accountId, (current) => ({ ...current, nodeLabel: value }))
}
placeholder="例如Mac Studio 主控节点"
/>
<AccountField
label="节点 ID"
value={draft.nodeId}
onChange={(value) => updateDraft(account.accountId, (current) => ({ ...current, nodeId: value }))}
placeholder="例如mac-studio"
/>
<AccountField
label="模型"
value={draft.model}
onChange={(value) => updateDraft(account.accountId, (current) => ({ ...current, model: value }))}
placeholder="例如gpt-5.4"
/>
{draft.provider === "openai_api" ? (
<div className="col-span-2">
<AccountField
label={`API Key${account.apiKeyMasked ? `(已配置 ${account.apiKeyMasked}` : ""}`}
value={draft.apiKey}
onChange={(value) =>
updateDraft(account.accountId, (current) => ({ ...current, apiKey: value }))
}
placeholder={account.apiKeyConfigured ? "留空则保持现有 Key" : "输入 OpenAI API Key"}
type="password"
/>
</div>
) : null}
</div>
<label className="mt-3 block space-y-1">
<div className="text-[12px] text-[#8C8C8C]"></div>
<textarea
value={draft.loginStatusNote}
onChange={(event) =>
updateDraft(account.accountId, (current) => ({
...current,
loginStatusNote: event.target.value,
}))
}
disabled={locked}
rows={3}
className="w-full rounded-2xl border border-[#E5E5EA] bg-[#F7F8FA] px-3 py-3 text-[13px] leading-6 text-[#111111] outline-none"
/>
</label>
<div className="mt-3 rounded-2xl bg-[#F7F8FA] px-3 py-3 text-[12px] leading-6 text-[#57606A]">
{account.statusLabel}
{account.lastValidatedAt ? ` · 校验 ${formatTimestampLabel(account.lastValidatedAt)}` : ""}
{account.lastUsedAt ? ` · 最近使用 ${formatTimestampLabel(account.lastUsedAt)}` : ""}
{account.lastError ? <><br />{account.lastError}</> : null}
</div>
<div className="mt-4 flex flex-wrap gap-2">
<button
type="button"
onClick={() => void validateAccount(account.accountId)}
disabled={busyKey === `validate:${account.accountId}`}
className="rounded-full border border-[#D9D9D9] px-3 py-2 text-[12px] font-semibold text-[#57606A]"
>
{busyKey === `validate:${account.accountId}` ? "测试中" : "测试连接"}
</button>
<button
type="button"
onClick={() => void activateAccount(account.accountId)}
disabled={locked || busyKey === `activate:${account.accountId}`}
className="rounded-full border border-[#D9D9D9] px-3 py-2 text-[12px] font-semibold text-[#57606A]"
>
{busyKey === `activate:${account.accountId}` ? "切换中" : "设为当前主控"}
</button>
<button
type="button"
onClick={() => void saveAccount(account.accountId)}
disabled={locked || busyKey === `save:${account.accountId}`}
className="rounded-full bg-[#07C160] px-4 py-2 text-[12px] font-semibold text-white"
>
{busyKey === `save:${account.accountId}` ? "保存中" : "保存"}
</button>
{!account.isEnvironmentFallback ? (
<button
type="button"
onClick={() => void removeAccount(account.accountId)}
disabled={!canManage || busyKey === `delete:${account.accountId}`}
className="rounded-full border border-[#FFD2D2] px-3 py-2 text-[12px] font-semibold text-[#D92D20]"
>
{busyKey === `delete:${account.accountId}` ? "删除中" : "删除"}
</button>
) : null}
</div>
</div>
);
})}
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
<div className="text-[16px] font-semibold text-[#111111]"> AI </div>
<div className="mt-3 grid grid-cols-2 gap-3">
<AccountField
label="标签"
value={newDraft.label}
onChange={(value) => setNewDraft((current) => ({ ...current, label: value }))}
placeholder="例如API 容灾"
/>
<AccountField
label="AI 名称"
value={newDraft.displayName}
onChange={(value) => setNewDraft((current) => ({ ...current, displayName: value }))}
placeholder="例如OpenAI 生产主控"
/>
<label className="space-y-1">
<div className="text-[12px] text-[#8C8C8C]"></div>
<select
value={newDraft.role}
onChange={(event) =>
setNewDraft((current) => ({ ...current, role: event.target.value as AiAccountRole }))
}
className="w-full rounded-xl border border-[#E5E5EA] bg-[#F7F8FA] px-3 py-2 text-[13px] text-[#111111] outline-none"
>
{roleOptions().map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="space-y-1">
<div className="text-[12px] text-[#8C8C8C]"></div>
<select
value={newDraft.provider}
onChange={(event) =>
setNewDraft((current) => ({ ...current, provider: event.target.value as AiProvider }))
}
className="w-full rounded-xl border border-[#E5E5EA] bg-[#F7F8FA] px-3 py-2 text-[13px] text-[#111111] outline-none"
>
{providerOptions().map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<AccountField
label="账号标识"
value={newDraft.accountIdentifier}
onChange={(value) => setNewDraft((current) => ({ ...current, accountIdentifier: value }))}
placeholder="手机号 / 邮箱 / 账号别名"
/>
<AccountField
label="节点标签"
value={newDraft.nodeLabel}
onChange={(value) => setNewDraft((current) => ({ ...current, nodeLabel: value }))}
placeholder="例如:备用 Mac mini"
/>
<AccountField
label="节点 ID"
value={newDraft.nodeId}
onChange={(value) => setNewDraft((current) => ({ ...current, nodeId: value }))}
placeholder="例如mac-mini-02"
/>
<AccountField
label="模型"
value={newDraft.model}
onChange={(value) => setNewDraft((current) => ({ ...current, model: value }))}
placeholder="例如gpt-5.4"
/>
{newDraft.provider === "openai_api" ? (
<div className="col-span-2">
<AccountField
label="API Key"
value={newDraft.apiKey}
onChange={(value) => setNewDraft((current) => ({ ...current, apiKey: value }))}
placeholder="输入 OpenAI API Key"
type="password"
/>
</div>
) : null}
</div>
<label className="mt-3 block space-y-1">
<div className="text-[12px] text-[#8C8C8C]"></div>
<textarea
value={newDraft.loginStatusNote}
onChange={(event) =>
setNewDraft((current) => ({ ...current, loginStatusNote: event.target.value }))
}
rows={3}
className="w-full rounded-2xl border border-[#E5E5EA] bg-[#F7F8FA] px-3 py-3 text-[13px] leading-6 text-[#111111] outline-none"
placeholder="例如:该节点已单独登录 ChatGPT等待后续接入远程执行器。"
/>
</label>
<div className="mt-4 flex flex-wrap items-center gap-3">
<label className="flex items-center gap-2 text-[13px] text-[#57606A]">
<input
type="checkbox"
checked={newDraft.enabled}
onChange={(event) =>
setNewDraft((current) => ({ ...current, enabled: event.target.checked }))
}
/>
</label>
<button
type="button"
onClick={() => void saveAccount()}
disabled={!canManage || busyKey === "create:new"}
className="rounded-full bg-[#111111] px-4 py-2 text-[12px] font-semibold text-white"
>
{busyKey === "create:new" ? "创建中" : "新增账号"}
</button>
</div>
</div>
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
<div className="text-[16px] font-semibold text-[#111111]"></div>
<div className="mt-3 space-y-3">
{switchHistory.length ? (
switchHistory.slice(0, 6).map((item) => (
<div key={item.switchId} className="rounded-2xl bg-[#F7F8FA] px-3 py-3 text-[12px] leading-6 text-[#57606A]">
{item.fromLabel ? `${item.fromLabel} -> ` : ""}
<span className="font-semibold text-[#111111]">{item.toLabel}</span>
<br />
{item.reason}
<br />
{formatTimestampLabel(item.switchedAt)}
</div>
))
) : (
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-3 text-[12px] leading-6 text-[#57606A]">
</div>
)}
</div>
</div>
{message ? (
<div className="rounded-2xl bg-[#EAF7F0] px-4 py-3 text-[13px] leading-6 text-[#215B39]">
{message}
</div>
) : null}
</div>
);
}

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>
);
}

1876
src/components/app-ui.tsx Normal file

File diff suppressed because it is too large Load Diff