feat: ship native boss android console
This commit is contained in:
627
src/components/ai-accounts-client.tsx
Normal file
627
src/components/ai-accounts-client.tsx
Normal 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 -> task queue -> local-agent -> codex exec -> 回写项目账本
|
||||
</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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
1876
src/components/app-ui.tsx
Normal file
1876
src/components/app-ui.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user