Files
boss/src/components/app-ui.tsx

2369 lines
78 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useState } from "react";
import clsx from "clsx";
import { sendAppLog } from "@/components/app-runtime";
import { DeviceImportDraftManager } from "@/components/device-import-draft-manager";
import {
clearNativeSessionSnapshot,
currentAppLocation,
isNativeBossApp,
persistNativeSessionSnapshot,
popAppHistoryEntry,
resolveAppBackAction,
} from "@/lib/boss-app-client";
import { getMasterAgentChatMenuItems } from "@/lib/master-agent-chat-menu";
import {
extractApprovedTargetProjectIds,
summarizeDispatchPlan,
} from "@/lib/dispatch-plan-ui";
import type {
Device,
DeviceEnrollment,
GoalItem,
MasterIdentitySummary,
Message,
OtaUpdate,
OtaUpdateLog,
OpsRepairTicket,
OpsRepairVerification,
ProjectOrchestrationBackendState,
OrchestrationBackendId,
ThreadContextSnapshot,
UserProfile,
UserSettings,
} from "@/lib/boss-data";
import type { ConversationItem } from "@/lib/boss-projections";
import { formatTimestampLabel } from "@/lib/boss-projections";
function formatClock(value: string) {
return formatTimestampLabel(value);
}
function formatBytes(value?: number) {
if (!value || value <= 0) return "未知大小";
if (value >= 1024 * 1024) return `${(value / 1024 / 1024).toFixed(2)} MB`;
if (value >= 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${value} B`;
}
function boundDeviceIdFromDom() {
return document.body.dataset.boundDeviceId || "mac-studio";
}
async function waitForLoginSessionReady(nativeClient: boolean) {
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch("/api/auth/session", {
cache: "no-store",
headers: nativeClient ? { "x-boss-native-app": "1" } : undefined,
}).catch(() => null);
if (response?.ok) {
return true;
}
await new Promise((resolve) => window.setTimeout(resolve, 120));
}
return false;
}
function navigateToConversations(router: ReturnType<typeof useRouter>) {
router.replace("/conversations", { scroll: false });
router.refresh();
window.setTimeout(() => {
if (window.location.pathname !== "/conversations") {
window.location.replace("/conversations");
}
}, 180);
}
export function AppShell({
children,
bottomNav = true,
}: {
children: React.ReactNode;
bottomNav?: boolean;
}) {
return (
<main className="min-h-[100dvh] md:px-6 md:py-6">
<div
className={clsx(
"flex w-full flex-col bg-[rgba(247,249,245,0.90)] backdrop-blur-[18px]",
bottomNav ? "min-h-[100dvh] overflow-hidden md:h-[calc(100dvh-48px)]" : "min-h-[100dvh]",
"md:mx-auto md:max-w-[430px] md:overflow-hidden md:rounded-[32px] md:border md:border-white/70 md:shadow-[0_20px_48px_rgba(22,31,44,0.14)]",
)}
>
<div
className={clsx(
"flex min-h-0 flex-1 flex-col",
bottomNav ? "overflow-y-auto overscroll-y-contain boss-scrollbar" : "",
)}
>
{children}
</div>
{bottomNav ? <BottomNav /> : null}
</div>
</main>
);
}
export function StatusBar() {
return <div className="shrink-0" style={{ height: "max(12px, env(safe-area-inset-top))" }} />;
}
export function PageNav({
title,
backHref,
rightLabel,
rightHref,
rightNode,
}: {
title: string;
backHref?: string;
rightLabel?: string;
rightHref?: string;
rightNode?: React.ReactNode;
}) {
const router = useRouter();
async function handleBack() {
const currentPath = currentAppLocation();
const action = resolveAppBackAction(currentPath, backHref);
if (action.mode === "history") {
popAppHistoryEntry(currentPath);
router.back();
return;
}
if (action.mode === "replace") {
popAppHistoryEntry(currentPath);
router.replace(action.target, { scroll: false });
}
}
return (
<div className="flex items-center justify-between px-[18px] py-2">
{backHref ? (
<button type="button" onClick={() => void handleBack()} className="text-[15px] text-[#57606A]">
{"< 返回"}
</button>
) : (
<span className="w-12" />
)}
<h1 className="text-[17px] font-semibold text-[#111111]">{title}</h1>
{rightNode ? (
rightNode
) : rightLabel ? (
<Link href={rightHref ?? "#"} className="text-[15px] font-semibold text-[#07C160]">
{rightLabel}
</Link>
) : (
<span className="w-12" />
)}
</div>
);
}
export function BottomNav() {
const pathname = usePathname();
const items = [
{ href: "/conversations", label: "会话", icon: "◌" },
{ href: "/devices", label: "设备", icon: "◫" },
{ href: "/me", label: "我的", icon: "◍" },
];
return (
<div className="mt-auto shrink-0 border-t border-[#E5E5EA] bg-[rgba(255,255,255,0.94)] backdrop-blur-xl">
<div
className="flex items-center justify-between px-[18px] pt-[8px]"
style={{ minHeight: "64px", paddingBottom: "max(12px, env(safe-area-inset-bottom))" }}
>
{items.map((item) => {
const active = pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className="flex w-full flex-col items-center gap-1 text-xs"
>
<span className={clsx("text-lg", active ? "text-[#07C160]" : "text-[#9A9A9A]")}>
{item.icon}
</span>
<span className={clsx(active ? "text-[#07C160]" : "text-[#9A9A9A]")}>
{item.label}
</span>
</Link>
);
})}
</div>
</div>
);
}
export function HeaderTitle({
title,
extra,
}: {
title: string;
extra?: React.ReactNode;
}) {
return (
<div className="flex items-center justify-between px-[18px] py-2">
<h1 className="text-[22px] font-semibold text-[#111111]">{title}</h1>
{extra}
</div>
);
}
export function LogoutButton({
label = "退出登录",
compact = false,
}: {
label?: string;
compact?: boolean;
}) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState("");
async function handleLogout() {
setLoading(true);
const response = await fetch("/api/auth/logout", {
method: "POST",
});
const result = (await response.json()) as { ok: boolean; message?: string };
setLoading(false);
setMessage(result.message ?? (result.ok ? "已退出登录。" : "退出失败。"));
if (result.ok) {
await clearNativeSessionSnapshot();
router.push("/auth/login");
router.refresh();
}
}
if (compact) {
return (
<button
type="button"
onClick={() => void handleLogout()}
disabled={loading}
className="text-[15px] font-semibold text-[#07C160]"
>
{loading ? "退出中" : label}
</button>
);
}
return (
<div 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-medium text-[#111111]">{label}</div>
<div className="mt-1 text-[12px] text-[#8C8C8C]">
</div>
</div>
<button
type="button"
onClick={() => void handleLogout()}
disabled={loading}
className="rounded-full bg-[#111111] px-4 py-2 text-[13px] font-semibold text-white"
>
{loading ? "退出中" : "立即退出"}
</button>
</div>
{message ? (
<div className="mt-3 rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] text-[#57606A]">
{message}
</div>
) : null}
</div>
);
}
export function AvatarStack({
primary,
secondary,
overflowCount,
}: {
primary: string;
secondary?: string;
overflowCount?: number;
}) {
if (!secondary) {
return (
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#DFF4E8] text-lg font-semibold text-[#215B39]">
{primary}
</div>
);
}
return (
<div className="relative h-12 w-12">
<div className="absolute left-0 top-1 flex h-8 w-8 items-center justify-center rounded-xl bg-[#DFF4E8] text-sm font-semibold text-[#215B39]">
{primary}
</div>
<div className="absolute bottom-0 right-0 flex h-8 w-8 items-center justify-center rounded-xl border-2 border-white bg-[#EAF0FF] text-sm font-semibold text-[#3B5CCC]">
{overflowCount ? `+${overflowCount}` : secondary}
</div>
</div>
);
}
export function ContextRing({
value,
label,
level,
}: {
value: number;
label: string;
level?: "safe" | "watch" | "urgent" | "critical";
}) {
const color =
level === "critical"
? "#FF4D4F"
: level === "urgent"
? "#FF8A00"
: level === "watch"
? "#1C7ED6"
: "#07C160";
const background = `conic-gradient(${color} ${value * 3.6}deg, #D9D9D9 0deg)`;
return (
<div className="flex items-center gap-2 text-[11px] text-[#8C8C8C]">
<span className="relative block h-[14px] w-[14px] rounded-full" style={{ background }}>
<span className="absolute inset-[2px] rounded-full bg-white" />
</span>
<span>{label}</span>
</div>
);
}
function riskBadgeColor(level: ConversationItem["riskLevel"]) {
switch (level) {
case "high":
return "bg-[#FFF1F0] text-[#CF1322]";
case "medium":
return "bg-[#FFF7E6] text-[#D46B08]";
default:
return "bg-[#F6FFED] text-[#389E0D]";
}
}
function conversationActionsPath(projectId: string) {
return `/api/v1/conversations/${projectId}/actions`;
}
function ConversationActionButtons({
conversation,
}: {
conversation: ConversationItem;
}) {
const router = useRouter();
const [loading, setLoading] = useState<"toggle_pin" | "mark_read" | null>(null);
if (conversation.conversationType === "folder_archive") {
return <div className="min-h-[24px]" />;
}
async function runAction(action: "toggle_pin" | "mark_read") {
setLoading(action);
await fetch(conversationActionsPath(conversation.projectId), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action }),
});
setLoading(null);
router.refresh();
}
return (
<div className="flex items-center gap-2">
{conversation.projectId !== "master-agent" ? (
<button
type="button"
onClick={() => void runAction("toggle_pin")}
disabled={loading === "toggle_pin"}
className="rounded-full border border-[#D9D9D9] px-3 py-1 text-[11px] text-[#57606A]"
>
{conversation.manualPinned ? "取消置顶" : "置顶"}
</button>
) : null}
{conversation.unreadCount > 0 ? (
<button
type="button"
onClick={() => void runAction("mark_read")}
disabled={loading === "mark_read"}
className="rounded-full border border-[#E5E5EA] px-3 py-1 text-[11px] text-[#57606A]"
>
</button>
) : null}
</div>
);
}
export function ConversationList({
conversations,
}: {
conversations: ConversationItem[];
}) {
return (
<div className="flex flex-col gap-1 px-[18px] pb-5">
{conversations.map((conversation) => (
<div
key={conversation.conversationId}
className="rounded-2xl px-1 py-3 transition hover:bg-white/70"
>
<div className="mb-2 flex justify-end">
<ConversationActionButtons conversation={conversation} />
</div>
<Link
href={
conversation.conversationType === "folder_archive" && conversation.folderKey
? `/conversations/folders/${encodeURIComponent(conversation.folderKey)}`
: `/conversations/${conversation.projectId}`
}
className="flex items-start gap-3"
>
<AvatarStack
primary={conversation.avatar.primary}
secondary={conversation.avatar.secondary}
overflowCount={conversation.avatar.overflowCount}
/>
<div className="min-w-0 flex-1 border-b border-[#EFEFF4] pb-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="truncate text-[17px] font-medium text-[#111111]">
{conversation.conversationType === "folder_archive"
? conversation.threadTitle
: conversation.projectTitle}
</div>
{conversation.conversationType === "folder_archive" ? (
<span className="rounded-full bg-[#F4F5F7] px-2 py-0.5 text-[10px] font-semibold text-[#57606A]">
{conversation.threadCount ?? 0} 线
</span>
) : (
<span
className={clsx(
"rounded-full px-2 py-0.5 text-[10px] font-semibold",
riskBadgeColor(conversation.riskLevel),
)}
>
{conversation.riskLevel === "high"
? "高风险"
: conversation.riskLevel === "medium"
? "关注"
: "稳定"}
</span>
)}
{conversation.unreadCount > 0 ? (
<span className="rounded-full bg-[#FF4D4F] px-2 py-0.5 text-[10px] font-semibold text-white">
{conversation.unreadCount}
</span>
) : null}
</div>
<div className="mt-1 text-[13px] text-[#8C8C8C]">
{conversation.conversationType === "folder_archive"
? conversation.folderLabel
: conversation.deviceNamesPreview.join(" / ")}
</div>
<div className="mt-1 truncate text-[14px] text-[#57606A]">
{conversation.preview}
</div>
</div>
<div className="flex min-w-[98px] flex-col items-end gap-1">
<div className="min-h-[18px] text-[11px] text-[#07C160]">
{conversation.projectId === "master-agent"
? "置顶"
: conversation.manualPinned
? "置顶"
: ""}
</div>
<div className="text-[12px] text-[#8C8C8C]">
{conversation.latestReplyLabel}
</div>
{conversation.contextBudgetIndicator.visible &&
conversation.contextBudgetIndicator.percent !== undefined ? (
<ContextRing
value={conversation.contextBudgetIndicator.percent}
label={`${conversation.contextBudgetIndicator.percent}%`}
level={conversation.contextBudgetIndicator.level}
/>
) : (
<div className="min-h-[16px] text-[11px] text-[#8C8C8C]">
{conversation.activeDeviceCount > 1
? `${conversation.activeDeviceCount} 台协作`
: ""}
</div>
)}
</div>
</div>
</div>
</Link>
</div>
))}
</div>
);
}
export function DeviceList({ devices }: { devices: Device[] }) {
const router = useRouter();
return (
<div className="flex flex-col gap-3 px-[18px] pb-5">
{devices.map((device) => (
<button
key={device.id}
type="button"
onClick={() => router.push(`/devices?device=${device.id}`)}
className="flex w-full items-start gap-3 rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4 text-left"
>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#DFF4E8] text-lg font-semibold text-[#215B39]">
{device.avatar}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<div className="text-[16px] font-medium text-[#111111]">{device.name}</div>
<span
className={clsx(
"h-2.5 w-2.5 rounded-full",
device.status === "online"
? "bg-[#07C160]"
: device.status === "abnormal"
? "bg-[#FF4D4F]"
: "bg-[#B8B8B8]",
)}
/>
</div>
<div className="mt-1 text-[13px] text-[#8C8C8C]">{device.account}</div>
<div className="mt-2 text-[13px] text-[#57606A]">
{device.projects.join(" · ")}
</div>
<div className="mt-2 flex flex-wrap gap-4 text-[12px] text-[#57606A]">
<span>5h{device.quota5h}%</span>
<span>7d{device.quota7d}%</span>
<span>{formatClock(device.lastSeenAt)}</span>
</div>
</div>
</button>
))}
</div>
);
}
export function DeviceEditorCard({
device,
relatedThreads,
activeEnrollment,
}: {
device: Device;
relatedThreads: ThreadContextSnapshot[];
activeEnrollment?: DeviceEnrollment;
}) {
const router = useRouter();
const [name, setName] = useState(device.name);
const [avatar, setAvatar] = useState(device.avatar);
const [account, setAccount] = useState(device.account);
const [status, setStatus] = useState<Device["status"]>(device.status);
const [endpoint, setEndpoint] = useState(device.endpoint ?? "");
const [note, setNote] = useState(device.note ?? "");
const [projects, setProjects] = useState(device.projects.join(", "));
const [message, setMessage] = useState("");
async function save() {
const response = await fetch(`/api/v1/devices/${device.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
avatar,
account,
status,
endpoint,
note,
projects: projects
.split(",")
.map((item) => item.trim())
.filter(Boolean),
}),
});
const result = (await response.json()) as { ok: boolean; message?: string };
setMessage(result.ok ? "设备信息已更新。" : result.message ?? "更新失败");
if (result.ok) router.refresh();
}
return (
<div className="space-y-3 rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
<div className="flex items-center justify-between">
<div className="text-[16px] font-semibold text-[#111111]"></div>
<div className="text-[12px] text-[#8C8C8C]">ID: {device.id}</div>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="设备名称" value={name} onChange={setName} />
<Field label="头像缩写" value={avatar} onChange={setAvatar} />
</div>
<Field label="账号" value={account} onChange={setAccount} />
<Field label="Endpoint" value={endpoint} onChange={setEndpoint} />
<Field label="备注" value={note} onChange={setNote} />
<Field label="项目列表(逗号分隔)" value={projects} onChange={setProjects} />
<div className="space-y-1">
<div className="text-[12px] text-[#8C8C8C]"></div>
<div className="flex gap-2">
{(["online", "abnormal", "offline"] as const).map((item) => (
<button
key={item}
type="button"
onClick={() => setStatus(item)}
className={clsx(
"rounded-full px-3 py-2 text-[12px] font-semibold",
status === item ? "bg-[#07C160] text-white" : "bg-[#F5F5F7] text-[#57606A]",
)}
>
{item}
</button>
))}
</div>
</div>
<button
type="button"
onClick={() => void save()}
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white"
>
</button>
{message ? (
<div className="rounded-2xl bg-[#EAF7F0] px-4 py-3 text-[12px] leading-6 text-[#215B39]">
{message}
</div>
) : null}
{activeEnrollment ? (
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
Pairing code: <span className="font-semibold text-[#111111]">{activeEnrollment.pairingCode}</span>
<br />
Token: <span className="font-semibold text-[#111111]">{activeEnrollment.token}</span>
<br />
{formatClock(activeEnrollment.expiresAt)}
</div>
) : null}
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
线{relatedThreads.length}
<br />
{relatedThreads.map((thread) => `${thread.title} (${thread.contextBudgetRemainingPct}%)`).join("") ||
"当前无活跃线程。"}
</div>
</div>
);
}
export function ProfileHero({ user }: { user: UserProfile }) {
return (
<div className="rounded-2xl bg-white px-4 py-4">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-[#DFF4E8] text-xl font-semibold text-[#215B39]">
{user.avatar}
</div>
<div>
<div className="text-[18px] font-semibold">{user.name}</div>
<div className="mt-1 text-[13px] text-[#8C8C8C]">{user.accountType}</div>
<div className="mt-1 text-[12px] text-[#57606A]">{user.account}</div>
<div className="mt-1 text-[12px] text-[#57606A]">
{user.boundCodexNodeLabel ?? "未绑定"}
</div>
<div className="mt-1 text-[12px] text-[#57606A]">{user.qrCodeValue}</div>
</div>
</div>
<div className="space-y-2 text-right">
<div className="rounded-full bg-[#FFF5E8] px-3 py-1 text-[12px] font-semibold text-[#B54708]">
{user.roleLabel}
</div>
<div className="rounded-xl border border-[#E5E5EA] px-3 py-2 text-lg text-[#57606A]"></div>
</div>
</div>
</div>
);
}
export function MenuRow({
href,
title,
description,
badge,
}: {
href: string;
title: string;
description: string;
badge?: React.ReactNode;
}) {
return (
<Link
href={href}
className="flex items-center justify-between rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4"
>
<div>
<div className="text-[16px] font-medium text-[#111111]">{title}</div>
<div className="mt-1 text-[12px] text-[#8C8C8C]">{description}</div>
</div>
<div className="flex items-center gap-2 text-[#8C8C8C]">
{badge}
<span>&gt;</span>
</div>
</Link>
);
}
function kindLabel(kind?: Message["kind"]) {
switch (kind) {
case "voice_intent":
return "语音";
case "image_intent":
return "图片";
case "video_intent":
return "视频";
case "forward_notice":
return "转发";
default:
return null;
}
}
export function ChatBubble({ message }: { message: Message }) {
const mine = message.sender === "user";
const green = message.sender === "master";
const tag = kindLabel(message.kind);
return (
<div className={clsx("flex", mine ? "justify-end" : "justify-start")}>
<div className="max-w-[82%]">
<div className="mb-1 px-1 text-[11px] text-[#8C8C8C]">
{message.senderLabel} · {formatClock(message.sentAt)}
</div>
<div
className={clsx(
"rounded-[18px] px-4 py-3 text-[15px] leading-6",
mine
? "bg-[#07C160] text-white"
: green
? "bg-[#EAF7F0] text-[#215B39]"
: "bg-white text-[#111111]",
)}
>
{tag ? (
<div className="mb-2 text-[11px] font-semibold opacity-80">{tag}</div>
) : null}
{message.body}
</div>
</div>
</div>
);
}
export function ProjectHeaderActions({ projectId }: { projectId: string }) {
return (
<div className="grid grid-cols-3 gap-3">
<Link
href={`/conversations/${projectId}/goals`}
className="flex h-11 items-center justify-center rounded-2xl bg-[#EAF7F0] text-[14px] font-semibold text-[#215B39]"
>
</Link>
<Link
href={`/conversations/${projectId}/versions`}
className="flex h-11 items-center justify-center rounded-2xl bg-white text-[14px] font-semibold text-[#111111]"
>
</Link>
<Link
href={`/conversations/${projectId}/forward`}
className="flex h-11 items-center justify-center rounded-2xl bg-white text-[14px] font-semibold text-[#111111]"
>
</Link>
</div>
);
}
function orchestrationBackendChoiceLabel(choice: ProjectOrchestrationBackendState["availableChoices"][number]) {
return choice.backendId === "boss-native-orchestrator"
? "Boss Native Orchestrator"
: "OMX Team Runtime";
}
function normalizeOrchestrationReasonLabel(value: string) {
const trimmed = value.trim();
if (trimmed.endsWith("。") || trimmed.endsWith(".")) {
return trimmed.slice(0, -1);
}
return trimmed;
}
function orchestrationBackendAvailabilityCopy(
state: ProjectOrchestrationBackendState,
fallbackActive: boolean,
) {
if (state.omxAvailability.selectable) {
return {
badge: "正常",
summary: "OMX Team Runtime 当前可用,当前可切换到该后端。",
};
}
return {
badge: fallbackActive ? "已回退" : "OMX 受限",
summary: fallbackActive
? `${normalizeOrchestrationReasonLabel(state.omxAvailability.reasonLabel)},当前已自动回退到 Boss Native Orchestrator。`
: `${normalizeOrchestrationReasonLabel(state.omxAvailability.reasonLabel)},切换后会自动回退到 Boss Native Orchestrator。`,
};
}
export function ProjectOrchestrationBackendCard({
projectId,
initialState,
}: {
projectId: string;
initialState: ProjectOrchestrationBackendState;
}) {
const router = useRouter();
const [state, setState] = useState(initialState);
const [savingBackendId, setSavingBackendId] = useState<OrchestrationBackendId | null>(null);
const [message, setMessage] = useState("");
const fallbackActive = state.requestedBackendId !== state.currentBackendId;
const availabilityCopy = orchestrationBackendAvailabilityCopy(state, fallbackActive);
async function saveBackend(requestedBackendId: OrchestrationBackendId) {
setSavingBackendId(requestedBackendId);
const response = await fetch(`/api/v1/projects/${projectId}/orchestration-backend`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ requestedBackendId }),
});
const result = (await response.json()) as {
ok: boolean;
message?: string;
currentBackendId?: OrchestrationBackendId;
currentBackendLabel?: string;
requestedBackendId?: OrchestrationBackendId;
requestedBackendLabel?: string;
availableChoices?: ProjectOrchestrationBackendState["availableChoices"];
omxAvailability?: ProjectOrchestrationBackendState["omxAvailability"];
};
setSavingBackendId(null);
if (
!result.ok ||
!result.currentBackendId ||
!result.currentBackendLabel ||
!result.requestedBackendId ||
!result.requestedBackendLabel ||
!result.availableChoices ||
!result.omxAvailability
) {
setMessage(result.message ?? "保存失败");
return;
}
setState({
projectId,
currentBackendId: result.currentBackendId,
currentBackendLabel: result.currentBackendLabel,
requestedBackendId: result.requestedBackendId,
requestedBackendLabel: result.requestedBackendLabel,
availableChoices: result.availableChoices,
omxAvailability: result.omxAvailability,
});
setMessage(
requestedBackendId === "omx-team"
? "已切换到 OMX Team Runtime。"
: "已切换回 Boss Native Orchestrator。",
);
router.refresh();
}
return (
<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-[14px] font-semibold text-[#111111]"></div>
<div className="mt-1 text-[12px] leading-5 text-[#57606A]">
{state.currentBackendLabel}
<br />
{state.requestedBackendLabel}
</div>
</div>
<div
className={clsx(
"rounded-full px-3 py-1 text-[11px] font-semibold",
fallbackActive || !state.omxAvailability.selectable
? "bg-[#FFF7E6] text-[#D46B08]"
: "bg-[#EAF7F0] text-[#215B39]",
)}
>
{availabilityCopy.badge}
</div>
</div>
<div className="mt-3 grid gap-2">
{state.availableChoices.map((choice) => {
const active = choice.current;
const selectable = choice.selectable && savingBackendId !== choice.backendId;
return (
<button
key={choice.backendId}
type="button"
onClick={() => void saveBackend(choice.backendId)}
disabled={!selectable}
className={clsx(
"flex items-center justify-between rounded-2xl border px-4 py-3 text-left",
active ? "border-[#07C160] bg-[#F5FFF8]" : "border-[#E5E5EA] bg-[#F7F8FA]",
!choice.selectable ? "opacity-70" : "",
)}
>
<div>
<div className="text-[14px] font-semibold text-[#111111]">
{orchestrationBackendChoiceLabel(choice)}
</div>
<div className="mt-1 text-[12px] leading-5 text-[#57606A]">{choice.label}</div>
</div>
<div className="text-right text-[11px] text-[#8C8C8C]">
<div>{active ? "当前" : "切换"}</div>
{!choice.selectable ? <div></div> : null}
</div>
</button>
);
})}
</div>
<div
className={clsx(
"mt-3 rounded-2xl px-4 py-3 text-[12px] leading-6",
state.omxAvailability.selectable
? "bg-[#EAF7F0] text-[#215B39]"
: "bg-[#FFF7E6] text-[#8D5D00]",
)}
>
{availabilityCopy.summary}
</div>
{message ? (
<div className="mt-3 rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] text-[#57606A]">
{message}
</div>
) : null}
</div>
);
}
function masterIdentityPillClasses(role: MasterIdentitySummary["role"]) {
switch (role) {
case "primary":
return "border-[#BBD6FF] bg-[#EEF5FF] text-[#2457C5]";
case "backup":
return "border-[#FFD9B8] bg-[#FFF5E8] text-[#B54708]";
case "api_fallback":
return "border-[#D8DDE6] bg-[#F3F4F6] text-[#4B5563]";
default:
return "border-[#D8DDE6] bg-[#F3F4F6] text-[#4B5563]";
}
}
export function MasterIdentityPill({ identity }: { identity: MasterIdentitySummary }) {
return (
<Link
href="/me/ai-accounts"
className={clsx(
"rounded-full border px-3 py-1 text-right",
masterIdentityPillClasses(identity.role),
)}
>
<div className="text-[11px] font-semibold leading-4">{identity.roleLabel}</div>
<div className="text-[10px] leading-4 opacity-80">
{(identity.nodeLabel || identity.displayName).slice(0, 18)}
</div>
</Link>
);
}
export function MasterAgentChatMenu({ projectId }: { projectId: string }) {
const router = useRouter();
const items = getMasterAgentChatMenuItems(projectId);
if (items.length === 0) {
return null;
}
return (
<details className="relative">
<summary
className="flex h-9 w-9 list-none items-center justify-center rounded-full border border-[#E5E5EA] bg-white text-[18px] font-semibold leading-none text-[#57606A] shadow-sm marker:content-none"
aria-label="更多"
>
</summary>
<div className="absolute right-0 top-11 z-20 min-w-[116px] rounded-2xl border border-[#E5E5EA] bg-white p-2 shadow-[0_12px_28px_rgba(15,23,42,0.14)]">
{items.map((item) =>
item.href ? (
<Link
key={item.key}
href={item.href}
className="flex rounded-xl px-3 py-2 text-[13px] text-[#111111] hover:bg-[#F7F8FA]"
>
{item.label}
</Link>
) : (
<button
key={item.key}
type="button"
onClick={() => {
if (item.action === "refresh") {
router.refresh();
}
}}
className="flex w-full rounded-xl px-3 py-2 text-left text-[13px] text-[#111111] hover:bg-[#F7F8FA]"
>
{item.label}
</button>
),
)}
</div>
</details>
);
}
type PendingDispatchPlanState = {
planId: string;
summary?: string;
targets: Array<{ projectId: string; threadDisplayName: string }>;
};
export function ChatComposer({
projectId,
initialPendingDispatchPlan,
initialRejectedDispatchPlan,
dispatchPlanRecoveryHint,
}: {
projectId: string;
initialPendingDispatchPlan?: PendingDispatchPlanState | null;
initialRejectedDispatchPlan?: PendingDispatchPlanState | null;
dispatchPlanRecoveryHint?: string | null;
}) {
const router = useRouter();
const [value, setValue] = useState("");
const [message, setMessage] = useState("");
const [messageTone, setMessageTone] = useState<"success" | "error">("success");
const [loading, setLoading] = useState(false);
const [localPendingDispatchPlan, setLocalPendingDispatchPlan] =
useState<PendingDispatchPlanState | null>(null);
const [localRejectedDispatchPlan, setLocalRejectedDispatchPlan] =
useState<PendingDispatchPlanState | null>(null);
const [dismissedPendingPlanId, setDismissedPendingPlanId] = useState<string | null>(null);
const pendingDispatchPlan =
localPendingDispatchPlan ??
(initialPendingDispatchPlan && initialPendingDispatchPlan.planId !== dismissedPendingPlanId
? initialPendingDispatchPlan
: null);
const rejectedDispatchPlan =
pendingDispatchPlan ? null : localRejectedDispatchPlan ?? initialRejectedDispatchPlan ?? null;
async function confirmDispatchPlan() {
if (!pendingDispatchPlan) return;
setLoading(true);
const response = await fetch(
`/api/v1/projects/${projectId}/dispatch-plans/${pendingDispatchPlan.planId}/confirm`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
approvedTargetProjectIds: extractApprovedTargetProjectIds(pendingDispatchPlan),
}),
},
);
const result = (await response.json()) as {
ok: boolean;
executions?: Array<unknown>;
message?: string;
};
setLoading(false);
if (!result.ok) {
setMessageTone("error");
setMessage(result.message ?? "确认下发失败,请重试。");
return;
}
const executionCount = result.executions?.length ?? extractApprovedTargetProjectIds(pendingDispatchPlan).length;
setLocalPendingDispatchPlan(null);
setLocalRejectedDispatchPlan(null);
setDismissedPendingPlanId(pendingDispatchPlan.planId);
setMessageTone("success");
setMessage(`已确认下发到 ${executionCount} 个线程。`);
router.refresh();
}
async function retryDispatchPlan() {
if (!rejectedDispatchPlan) return;
setLoading(true);
const response = await fetch(
`/api/v1/projects/${projectId}/dispatch-plans/${rejectedDispatchPlan.planId}/retry`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
},
);
const result = (await response.json()) as {
ok: boolean;
dispatchPlan?: {
planId: string;
summary?: string;
targets?: Array<{ projectId: string; threadDisplayName: string }>;
} | null;
collaborationGate?: {
requiresMasterAgentApproval?: boolean;
};
message?: string;
};
setLoading(false);
if (!result.ok) {
setMessageTone("error");
setMessage(result.message ?? "重新生成推荐失败,请稍后重试。");
return;
}
setLocalRejectedDispatchPlan(null);
setLocalPendingDispatchPlan(
result.dispatchPlan
? {
planId: result.dispatchPlan.planId,
summary: result.dispatchPlan.summary,
targets: result.dispatchPlan.targets ?? [],
}
: null,
);
setDismissedPendingPlanId(null);
setMessageTone("success");
setMessage(
result.collaborationGate?.requiresMasterAgentApproval
? "主 Agent 已重新生成推荐,等待你确认下发。"
: "主 Agent 已重新生成推荐。",
);
router.refresh();
}
async function rejectDispatchPlan() {
if (!pendingDispatchPlan) return;
setLoading(true);
const response = await fetch(
`/api/v1/projects/${projectId}/dispatch-plans/${pendingDispatchPlan.planId}/reject`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
},
);
const result = (await response.json()) as {
ok: boolean;
plan?: {
planId: string;
summary?: string;
targets?: Array<{ projectId: string; threadDisplayName: string }>;
} | null;
message?: string;
};
setLoading(false);
if (!result.ok) {
setMessageTone("error");
setMessage(result.message ?? "拒绝失败,请稍后重试。");
return;
}
setLocalPendingDispatchPlan(null);
setLocalRejectedDispatchPlan(
result.plan
? {
planId: result.plan.planId,
summary: result.plan.summary,
targets: result.plan.targets ?? pendingDispatchPlan.targets,
}
: pendingDispatchPlan,
);
setDismissedPendingPlanId(pendingDispatchPlan.planId);
setMessageTone("success");
setMessage("已拒绝主 Agent 推荐。");
router.refresh();
}
async function send(kind: "text" | "voice_intent" | "image_intent" | "video_intent") {
setLoading(true);
const response = await fetch(`/api/v1/projects/${projectId}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: kind === "text" ? value : undefined, kind }),
});
const result = (await response.json()) as {
ok: boolean;
message?: { body: string };
dispatchPlan?: {
planId: string;
summary?: string;
targets?: Array<{ projectId: string; threadDisplayName: string }>;
} | null;
collaborationGate?: {
requiresMasterAgentApproval?: boolean;
};
messageText?: string;
};
setLoading(false);
if (result.ok) {
void sendAppLog({
deviceId: boundDeviceIdFromDom(),
projectId,
level: "info",
category: "chat.message_sent",
message:
kind === "text" ? `已发送文本消息:${value.trim() || "空文本"}` : `已发送 ${kind} 意图消息。`,
mirrorToMaster: false,
});
setValue("");
if (result.dispatchPlan) {
setLocalPendingDispatchPlan({
planId: result.dispatchPlan.planId,
summary: result.dispatchPlan.summary,
targets: result.dispatchPlan.targets ?? [],
});
setLocalRejectedDispatchPlan(null);
setDismissedPendingPlanId(null);
setMessage(
result.collaborationGate?.requiresMasterAgentApproval
? "消息已发送,等待你批准主 Agent 下发。"
: "消息已发送,主 Agent 已给出推荐线程。",
);
setMessageTone("success");
} else {
setLocalPendingDispatchPlan(null);
setMessage("");
}
router.refresh();
return;
}
void sendAppLog({
deviceId: boundDeviceIdFromDom(),
projectId,
level: "error",
category: "chat.message_failed",
message: `发送 ${kind} 消息失败。`,
detail: "Boss 会话消息接口返回失败。",
mirrorToMaster: true,
});
setMessageTone("error");
setMessage("消息发送失败,请重试。");
}
return (
<div
className="mt-auto border-t border-[#E5E5EA] bg-white px-[18px] pt-3"
style={{ paddingBottom: "max(12px, env(safe-area-inset-bottom))" }}
>
<div className="flex items-center gap-2">
<button
type="button"
disabled={loading}
onClick={() => void send("voice_intent")}
className="text-xl text-[#57606A]"
>
🎙
</button>
<input
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder="向主 Agent 或项目发送消息"
className="flex-1 rounded-full bg-[#F5F5F7] px-4 py-3 text-[14px] text-[#111111] outline-none"
/>
<button
type="button"
disabled={loading}
onClick={() => void send("text")}
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white"
>
</button>
</div>
<div className="mt-3 flex items-center gap-4 text-[12px] text-[#8C8C8C]">
<button type="button" onClick={() => void send("image_intent")}>
</button>
<button type="button" onClick={() => void send("video_intent")}>
</button>
<Link href={`/conversations/${projectId}/forward`}></Link>
</div>
{message ? (
<div
className={clsx(
"mt-3 rounded-2xl px-4 py-3 text-[12px]",
messageTone === "success"
? "bg-[#EAF7F0] text-[#215B39]"
: "bg-[#FFF1F0] text-[#CF1322]",
)}
>
{message}
</div>
) : null}
{dispatchPlanRecoveryHint ? (
<div className="mt-3 rounded-2xl border border-[#E5E5EA] bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
{dispatchPlanRecoveryHint}
</div>
) : null}
{pendingDispatchPlan ? (
<div className="mt-3 rounded-2xl border border-[#E5E5EA] bg-[#F7F8FA] px-4 py-4 text-[12px] leading-6 text-[#57606A]">
<div className="text-[14px] font-semibold text-[#111111]"> Agent </div>
<div className="mt-2 whitespace-pre-line">{summarizeDispatchPlan(pendingDispatchPlan)}</div>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
disabled={loading}
onClick={() => void confirmDispatchPlan()}
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white disabled:bg-[#B7E6C9]"
>
</button>
<button
type="button"
disabled={loading}
onClick={() => void rejectDispatchPlan()}
className="rounded-full border border-[#F0B5B5] px-4 py-2 text-[13px] font-semibold text-[#CF1322]"
>
</button>
<button
type="button"
disabled={loading}
onClick={() => {
setLocalPendingDispatchPlan(null);
setDismissedPendingPlanId(pendingDispatchPlan.planId);
}}
className="rounded-full border border-[#D9D9D9] px-4 py-2 text-[13px] font-semibold text-[#57606A]"
>
</button>
</div>
</div>
) : null}
{rejectedDispatchPlan ? (
<div className="mt-3 rounded-2xl border border-[#F3D19C] bg-[#FFF7E6] px-4 py-4 text-[12px] leading-6 text-[#8D5D00]">
<div className="text-[14px] font-semibold text-[#111111]"></div>
<div className="mt-2 whitespace-pre-line">{summarizeDispatchPlan(rejectedDispatchPlan)}</div>
<div className="mt-2">
{dispatchPlanRecoveryHint ??
"如果还想继续当前协作,可以直接重新生成新的推荐,不用把整条需求重新打一遍。"}
</div>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
disabled={loading}
onClick={() => void retryDispatchPlan()}
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white disabled:bg-[#B7E6C9]"
>
</button>
</div>
</div>
) : null}
</div>
);
}
export function GoalChecklist({
projectId,
goals,
}: {
projectId: string;
goals: GoalItem[];
}) {
const router = useRouter();
const [workingId, setWorkingId] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const [newGoal, setNewGoal] = useState("");
async function handleToggle(goalId: string) {
setWorkingId(goalId);
await fetch(`/api/projects/${projectId}/goals/${goalId}/toggle`, { method: "POST" });
setWorkingId(null);
router.refresh();
}
async function handleSave(goalId: string) {
await fetch(`/api/projects/${projectId}/goals/update`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ goalId, text: draft, action: "update" }),
});
setEditingId(null);
setDraft("");
router.refresh();
}
async function handleCreate() {
if (!newGoal.trim()) return;
await fetch(`/api/projects/${projectId}/goals/update`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: newGoal, action: "create" }),
});
setNewGoal("");
router.refresh();
}
return (
<div className="flex flex-col gap-3">
<div className="rounded-2xl border border-dashed border-[#B7EAC7] bg-[#F5FFF8] px-4 py-4">
<div className="text-[13px] font-semibold text-[#215B39]"></div>
<textarea
value={newGoal}
onChange={(event) => setNewGoal(event.target.value)}
placeholder="写入新的项目目标,主 Agent 会据此调整优先级。"
className="mt-3 min-h-24 w-full rounded-xl border border-[#DFF4E8] bg-white p-3 text-[14px] leading-6 outline-none"
/>
<button
type="button"
onClick={() => void handleCreate()}
className="mt-3 rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white"
>
</button>
</div>
{goals.map((goal) => {
const completed = goal.state === "completed";
const editing = editingId === goal.id;
return (
<div key={goal.id} className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
<div className="flex items-start gap-3">
<button
type="button"
onClick={() => void handleToggle(goal.id)}
disabled={workingId === goal.id}
className={clsx(
"mt-0.5 flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full border-2",
completed
? "border-[#07C160] bg-[#07C160] text-white"
: "border-[#07C160] bg-white text-transparent",
)}
>
</button>
<div className="min-w-0 flex-1">
{editing ? (
<div className="space-y-2">
<textarea
value={draft}
onChange={(event) => setDraft(event.target.value)}
className="min-h-24 w-full rounded-xl border border-[#E5E5EA] bg-[#F9F9FA] p-3 text-[14px] leading-6 outline-none"
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => void handleSave(goal.id)}
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white"
>
</button>
<button
type="button"
onClick={() => {
setEditingId(null);
setDraft("");
}}
className="rounded-full border border-[#E5E5EA] px-4 py-2 text-[13px] text-[#57606A]"
>
</button>
</div>
</div>
) : (
<>
<div
className={clsx(
"text-[15px] leading-6",
completed ? "text-[#8C8C8C] line-through" : "text-[#111111]",
)}
>
{goal.text}
</div>
<div className="mt-2 text-[12px] text-[#8C8C8C]">
{goal.note}
{goal.completedAt ? ` · ${formatClock(goal.completedAt)}` : ""}
</div>
</>
)}
</div>
{!editing ? (
<button
type="button"
onClick={() => {
setEditingId(goal.id);
setDraft(goal.text);
}}
className="text-[13px] font-semibold text-[#07C160]"
>
</button>
) : null}
</div>
</div>
);
})}
</div>
);
}
export function AuthForm({
title,
description,
mode,
}: {
title: string;
description: string;
mode: "login" | "register" | "forgot-password";
}) {
const router = useRouter();
const [account, setAccount] = useState("");
const [loginMethod, setLoginMethod] = useState<"password" | "code">("password");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [code, setCode] = useState("");
const [message, setMessage] = useState("");
const [submitting, setSubmitting] = useState(false);
async function sendCode() {
if (!account.trim()) {
setMessage("请先输入账号,再发送验证码。");
return;
}
const response = await fetch("/api/auth/send-code", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ account, purpose: mode }),
});
const result = (await response.json()) as { message: string };
setMessage(result.message);
}
async function submit() {
setSubmitting(true);
const route =
mode === "login"
? "/api/auth/login"
: mode === "register"
? "/api/auth/register"
: "/api/auth/forgot-password";
const nativeClient = await isNativeBossApp();
try {
const response = await fetch(route, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(nativeClient ? { "x-boss-native-app": "1" } : {}),
},
body: JSON.stringify({
account,
password,
confirmPassword,
code,
method: mode === "login" ? loginMethod : undefined,
}),
});
const result = (await response.json()) as {
ok: boolean;
message: string;
account?: string;
displayName?: string;
sessionExpiresAt?: string;
restoreToken?: string;
};
setMessage(result.message);
if (result.ok && mode === "login") {
if (
nativeClient &&
result.restoreToken &&
result.account &&
result.displayName &&
result.sessionExpiresAt
) {
try {
await persistNativeSessionSnapshot({
restoreToken: result.restoreToken,
account: result.account,
displayName: result.displayName,
expiresAt: result.sessionExpiresAt,
lastSyncedAt: new Date().toISOString(),
});
} catch (error) {
void sendAppLog({
deviceId: boundDeviceIdFromDom(),
level: "warn",
category: "auth.native_restore_snapshot_failed",
message: "登录成功,但原生 restore token 快照写入失败。",
detail: error instanceof Error ? error.message : String(error),
mirrorToMaster: false,
});
}
}
await waitForLoginSessionReady(nativeClient);
navigateToConversations(router);
return;
}
if (result.ok && mode === "register") {
router.push("/auth/login");
}
} catch (error) {
setMessage("登录链路发生异常,请重试。");
void sendAppLog({
deviceId: boundDeviceIdFromDom(),
level: "error",
category: "auth.submit_failed",
message: "登录或注册提交失败。",
detail: error instanceof Error ? error.message : String(error),
mirrorToMaster: true,
});
} finally {
setSubmitting(false);
}
}
return (
<div className="space-y-4 px-[18px] pb-8">
<div className="space-y-2 px-0 py-2">
<div className="flex h-16 w-16 items-center justify-center rounded-[18px] bg-[#07C160] text-[30px] font-semibold text-white">
C
</div>
<h1 className="pt-2 text-[34px] font-semibold tracking-[-0.02em] text-[#111111]">
{title}
</h1>
<p className="text-[14px] leading-6 text-[#8C8C8C]">{description}</p>
</div>
<div className="overflow-hidden rounded-2xl border border-[#E5E5EA] bg-white">
{mode === "login" ? (
<>
<div className="grid grid-cols-2 gap-2 bg-[#F7F8FA] px-4 py-3">
<button
type="button"
onClick={() => {
setLoginMethod("password");
setMessage("");
}}
className={clsx(
"rounded-xl px-3 py-2 text-[13px] font-semibold",
loginMethod === "password" ? "bg-[#07C160] text-white" : "bg-white text-[#57606A]",
)}
>
</button>
<button
type="button"
onClick={() => {
setLoginMethod("code");
setMessage("");
}}
className={clsx(
"rounded-xl px-3 py-2 text-[13px] font-semibold",
loginMethod === "code" ? "bg-[#07C160] text-white" : "bg-white text-[#57606A]",
)}
>
</button>
</div>
<div className="h-px w-full bg-[#E5E5EA]" />
</>
) : null}
<AuthField label="账号" value={account} onChange={setAccount} placeholder="输入手机号 / 邮箱" />
{mode === "login" && loginMethod === "code" ? (
<>
<AuthCodeField label="登录验证码" value={code} onChange={setCode} onSend={sendCode} />
<div className="border-t border-[#E5E5EA] px-4 py-3 text-[12px] leading-5 text-[#57606A]">
<span className="font-semibold text-[#111111]">000000</span>{" "}
</div>
</>
) : null}
{mode !== "login" ? (
<AuthCodeField
label={mode === "register" ? "注册验证码" : "重置验证码"}
value={code}
onChange={setCode}
onSend={sendCode}
/>
) : null}
{(mode !== "login" || loginMethod === "password") ? (
<AuthField
label={mode === "forgot-password" ? "新密码" : mode === "register" ? "登录密码" : "密码"}
value={password}
onChange={setPassword}
placeholder={mode === "register" ? "设置 8-20 位密码" : "输入密码"}
type="password"
/>
) : null}
{(mode === "register" || mode === "forgot-password") && (
<AuthField
label={mode === "register" ? "确认密码" : "确认新密码"}
value={confirmPassword}
onChange={setConfirmPassword}
placeholder="再次输入密码"
type="password"
/>
)}
</div>
<button
type="button"
onClick={() => void submit()}
disabled={submitting}
className="flex h-[52px] w-full items-center justify-center rounded-[14px] bg-[#07C160] text-[17px] font-semibold text-white"
>
{submitting ? "处理中..." : mode === "login" ? "登录" : mode === "register" ? "注册并继续" : "确认重置"}
</button>
{message ? (
<div className="rounded-2xl bg-[#EAF7F0] px-4 py-3 text-[13px] leading-6 text-[#215B39]">
{message}
</div>
) : null}
<div className="flex items-center justify-between text-[14px]">
{mode === "login" ? (
<>
<Link href="/auth/register" className="text-[#07C160]">
</Link>
<Link href="/auth/forgot-password" className="text-[#57606A]">
</Link>
</>
) : mode === "register" ? (
<Link href="/auth/login" className="mx-auto text-[#07C160]">
</Link>
) : (
<Link href="/auth/login" className="mx-auto text-[#57606A]">
</Link>
)}
</div>
</div>
);
}
function AuthField({
label,
value,
onChange,
placeholder,
type = "text",
}: {
label: string;
value: string;
onChange: (value: string) => void;
placeholder: string;
type?: "text" | "password";
}) {
return (
<>
<div className="space-y-1 px-4 py-3">
<div className="text-[12px] text-[#8C8C8C]">{label}</div>
<input
type={type}
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
className="w-full border-0 bg-transparent p-0 text-[16px] text-[#111111] outline-none"
/>
</div>
<div className="h-px w-full bg-[#E5E5EA]" />
</>
);
}
function AuthCodeField({
label,
value,
onChange,
onSend,
}: {
label: string;
value: string;
onChange: (value: string) => void;
onSend: () => void;
}) {
return (
<>
<div className="flex items-center gap-3 px-4 py-3">
<div className="min-w-0 flex-1 space-y-1">
<div className="text-[12px] text-[#8C8C8C]">{label}</div>
<input
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder="输入 6 位验证码"
inputMode="numeric"
className="w-full border-0 bg-transparent p-0 text-[16px] text-[#111111] outline-none"
/>
</div>
<button
type="button"
onClick={onSend}
className="rounded-xl border border-[#07C160] bg-[#F5FFF8] px-3 py-2 text-[13px] font-semibold text-[#07C160]"
>
</button>
</div>
<div className="h-px w-full bg-[#E5E5EA]" />
</>
);
}
function Field({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (value: string) => void;
}) {
return (
<label className="block space-y-1">
<span className="text-[12px] text-[#8C8C8C]">{label}</span>
<input
value={value}
onChange={(event) => onChange(event.target.value)}
className="w-full rounded-xl border border-[#E5E5EA] bg-[#F9F9FA] px-3 py-3 text-[14px] outline-none"
/>
</label>
);
}
export function DeviceEnrollmentBuilder() {
const [name, setName] = useState("Mac Mini");
const [avatar, setAvatar] = useState("M");
const [account, setAccount] = useState("17600003315");
const [projects, setProjects] = useState("Boss 移动控制台");
const [endpoint, setEndpoint] = useState("mac://new-device.local");
const [note, setNote] = useState("新设备待绑定");
const [result, setResult] = useState<{
enrollment?: DeviceEnrollment;
device?: Device;
message?: string;
}>({});
async function submit() {
const response = await fetch("/api/v1/devices/enrollments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
avatar,
account,
endpoint,
note,
projects: projects
.split(",")
.map((item) => item.trim())
.filter(Boolean),
}),
});
const data = (await response.json()) as {
ok: boolean;
enrollment?: DeviceEnrollment;
device?: Device;
message?: string;
};
setResult(data);
}
const configSnippet = result.enrollment
? JSON.stringify(
{
bindHost: "127.0.0.1",
port: 4317,
heartbeatIntervalMs: 60000,
controlPlaneUrl: "http://127.0.0.1:3000",
deviceId: result.device?.id,
pairingCode: result.enrollment.pairingCode,
token: result.enrollment.token,
name,
avatar,
account,
status: "online",
quota5h: 100,
quota7d: 100,
projects: projects
.split(",")
.map((item) => item.trim())
.filter(Boolean),
endpoint,
},
null,
2,
)
: "";
return (
<div className="space-y-3 rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
<div className="text-[16px] font-semibold text-[#111111]">稿</div>
<Field label="设备名称" value={name} onChange={setName} />
<div className="grid grid-cols-2 gap-3">
<Field label="头像缩写" value={avatar} onChange={setAvatar} />
<Field label="账号" value={account} onChange={setAccount} />
</div>
<Field label="项目列表(逗号分隔)" value={projects} onChange={setProjects} />
<Field label="Endpoint" value={endpoint} onChange={setEndpoint} />
<Field label="备注" value={note} onChange={setNote} />
<button
type="button"
onClick={() => void submit()}
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white"
>
pairing code / token
</button>
{result.message ? (
<div className="rounded-2xl bg-[#FFF1F0] px-4 py-3 text-[12px] text-[#CF1322]">
{result.message}
</div>
) : null}
{result.enrollment ? (
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
Pairing code: <span className="font-semibold text-[#111111]">{result.enrollment.pairingCode}</span>
<br />
Token: <span className="font-semibold text-[#111111]">{result.enrollment.token}</span>
<br />
ID: {result.device?.id}
<br />
{formatClock(result.enrollment.expiresAt)}
</div>
) : null}
{configSnippet ? (
<pre className="overflow-x-auto rounded-2xl bg-[#111111] p-4 text-[11px] leading-6 text-[#E8E8E8]">
{configSnippet}
</pre>
) : null}
{result.device?.id ? (
<DeviceImportDraftManager deviceId={result.device.id} deviceName={result.device.name} />
) : null}
</div>
);
}
export function OtaActionButton({ canApply = true }: { canApply?: boolean }) {
const router = useRouter();
const [message, setMessage] = useState("");
async function run() {
if (!canApply) {
setMessage("当前账号没有 OTA 执行权限。");
void sendAppLog({
deviceId: boundDeviceIdFromDom(),
level: "warn",
category: "ota.apply_blocked",
message: "当前账号没有 OTA 执行权限。",
mirrorToMaster: true,
});
return;
}
const response = await fetch("/api/v1/user/ota", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "apply" }),
});
const result = (await response.json()) as { ok: boolean; version?: string; message?: string };
setMessage(result.ok ? `已升级到 ${result.version}` : result.message ?? "OTA 失败");
void sendAppLog({
deviceId: boundDeviceIdFromDom(),
level: result.ok ? "info" : "error",
category: result.ok ? "ota.apply_succeeded" : "ota.apply_failed",
message: result.ok ? `已执行 OTA 升级到 ${result.version}` : result.message ?? "OTA 失败",
mirrorToMaster: true,
});
if (result.ok) router.refresh();
}
return (
<>
<button
type="button"
onClick={() => void run()}
disabled={!canApply}
className={clsx(
"rounded-full px-4 py-2 text-[13px] font-semibold",
canApply ? "bg-[#07C160] text-white" : "bg-[#E5E5EA] text-[#8C8C8C]",
)}
>
OTA
</button>
{message ? (
<div className="mt-3 rounded-2xl bg-[#EAF7F0] px-4 py-3 text-[12px] text-[#215B39]">
{message}
</div>
) : null}
</>
);
}
export function OtaCenterCard({
currentVersion,
availableRelease,
logs,
boundCodexNodeLabel,
roleLabel,
canApply,
compact = false,
}: {
currentVersion: string;
availableRelease?: OtaUpdate | null;
logs: OtaUpdateLog[];
boundCodexNodeLabel?: string;
roleLabel: string;
canApply: boolean;
compact?: boolean;
}) {
const router = useRouter();
const [message, setMessage] = useState("");
async function checkUpdate() {
const response = await fetch("/api/v1/user/ota", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "check" }),
});
const result = (await response.json()) as { ok: boolean; message?: string };
setMessage(result.message ?? (result.ok ? "检查完成。" : "检查失败"));
void sendAppLog({
deviceId: boundDeviceIdFromDom(),
level: result.ok ? "info" : "error",
category: result.ok ? "ota.check_succeeded" : "ota.check_failed",
message: result.message ?? (result.ok ? "检查完成。" : "检查失败"),
mirrorToMaster: !result.ok,
});
if (result.ok) router.refresh();
}
return (
<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]"> / OTA</div>
<div className="mt-1 text-[13px] leading-6 text-[#57606A]">
{currentVersion}
<br />
{roleLabel}
<br />
{boundCodexNodeLabel ?? "未绑定"}
</div>
</div>
{availableRelease ? (
<span className="rounded-full bg-[#FFF1F0] px-3 py-1 text-[12px] font-semibold text-[#CF1322]">
OTA
</span>
) : null}
</div>
<div className="mt-4 rounded-2xl bg-[#F7F8FA] px-4 py-4">
<div className="text-[14px] font-semibold text-[#111111]">
{availableRelease ? `可用版本 ${availableRelease.version}` : "当前没有待升级版本"}
</div>
<div className="mt-2 text-[13px] leading-6 text-[#57606A]">
{availableRelease ? (
<>
{availableRelease.currentVersion}
<br />
{availableRelease.targetScope}
<br />
{(availableRelease.summary ?? []).join("")}
{availableRelease.packageFileName ? (
<>
<br />
{availableRelease.packageFileName}
{availableRelease.packageSizeBytes ? ` · ${formatBytes(availableRelease.packageSizeBytes)}` : ""}
</>
) : null}
{availableRelease.assetUpdatedAt ? (
<>
<br />
{formatClock(availableRelease.assetUpdatedAt)}
</>
) : null}
</>
) : (
"点击“检查更新”可重新拉取 OTA 状态。"
)}
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<button
type="button"
onClick={() => void checkUpdate()}
className="rounded-full border border-[#07C160] bg-[#F5FFF8] px-4 py-2 text-[13px] font-semibold text-[#07C160]"
>
</button>
{availableRelease?.downloadUrl ? (
<a
href={availableRelease.downloadUrl}
target="_blank"
rel="noreferrer"
className="rounded-full border border-[#111111] bg-white px-4 py-2 text-[13px] font-semibold text-[#111111]"
>
APK
</a>
) : null}
{availableRelease ? <OtaActionButton canApply={canApply} /> : null}
</div>
{message ? (
<div className="mt-3 rounded-2xl bg-[#EAF7F0] px-4 py-3 text-[12px] leading-6 text-[#215B39]">
{message}
</div>
) : null}
{availableRelease?.packageSha256 ? (
<div className="mt-3 rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
SHA256{availableRelease.packageSha256}
</div>
) : null}
{!compact ? (
<div className="mt-4 rounded-2xl bg-[#F7F8FA] px-4 py-4">
<div className="text-[14px] font-semibold text-[#111111]">OTA </div>
<div className="mt-2 space-y-3 text-[12px] leading-6 text-[#57606A]">
{logs.length ? (
logs.slice(0, 4).map((log) => (
<div key={log.logId}>
<div className="font-semibold text-[#111111]">
{log.version} · {log.status}
</div>
<div>
{formatClock(log.triggeredAt)} · {log.note}
</div>
</div>
))
) : (
<div> OTA </div>
)}
</div>
</div>
) : null}
</div>
);
}
export function SettingsForm({ settings }: { settings: UserSettings }) {
const router = useRouter();
const [draft, setDraft] = useState(settings);
const [message, setMessage] = useState("");
async function save() {
const response = await fetch("/api/v1/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(draft),
});
const result = (await response.json()) as { ok: boolean; message?: string };
setMessage(result.ok ? "设置已保存。" : result.message ?? "保存失败");
if (result.ok) router.refresh();
}
return (
<div className="space-y-3 rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
<SwitchRow
label="启用实时刷新提示"
value={draft.liveUpdates}
onChange={(value) => setDraft((current) => ({ ...current, liveUpdates: value }))}
/>
<SwitchRow
label="显示风险徽标"
value={draft.showRiskBadges}
onChange={(value) => setDraft((current) => ({ ...current, showRiskBadges: value }))}
/>
<SwitchRow
label="危险操作前二次确认"
value={draft.confirmDangerousActions}
onChange={(value) =>
setDraft((current) => ({ ...current, confirmDangerousActions: value }))
}
/>
<div className="space-y-1">
<div className="text-[12px] text-[#8C8C8C]"></div>
<div className="flex gap-2">
{(["conversations", "devices", "me"] as const).map((item) => (
<button
key={item}
type="button"
onClick={() => setDraft((current) => ({ ...current, preferredEntryPoint: item }))}
className={clsx(
"rounded-full px-3 py-2 text-[12px] font-semibold",
draft.preferredEntryPoint === item
? "bg-[#07C160] text-white"
: "bg-[#F5F5F7] text-[#57606A]",
)}
>
{item}
</button>
))}
</div>
</div>
<button
type="button"
onClick={() => void save()}
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white"
>
</button>
{message ? (
<div className="rounded-2xl bg-[#EAF7F0] px-4 py-3 text-[12px] text-[#215B39]">
{message}
</div>
) : null}
</div>
);
}
function SwitchRow({
label,
value,
onChange,
}: {
label: string;
value: boolean;
onChange: (value: boolean) => void;
}) {
return (
<button
type="button"
onClick={() => onChange(!value)}
className="flex w-full items-center justify-between rounded-2xl bg-[#F7F8FA] px-4 py-3"
>
<span className="text-[14px] text-[#111111]">{label}</span>
<span
className={clsx(
"rounded-full px-3 py-1 text-[12px] font-semibold",
value ? "bg-[#07C160] text-white" : "bg-white text-[#57606A]",
)}
>
{value ? "开" : "关"}
</span>
</button>
);
}
export function RepairTicketActions({
ticket,
verification,
}: {
ticket: OpsRepairTicket;
verification?: OpsRepairVerification;
}) {
const router = useRouter();
const [message, setMessage] = useState("");
async function run(action: "approve" | "verify") {
const response = await fetch(`/api/v1/ops/repair-tickets/${ticket.ticketId}/${action}`, {
method: "POST",
});
const result = (await response.json()) as { ok: boolean; message?: string };
setMessage(result.ok ? "工单状态已更新。" : result.message ?? "操作失败");
if (result.ok) router.refresh();
}
return (
<div className="mt-3 flex flex-wrap items-center gap-2">
{ticket.approvalStatus !== "approved" ? (
<button
type="button"
onClick={() => void run("approve")}
className="rounded-full bg-[#07C160] px-3 py-2 text-[12px] font-semibold text-white"
>
Agent
</button>
) : null}
{ticket.executionStatus !== "verified" ? (
<button
type="button"
onClick={() => void run("verify")}
className="rounded-full border border-[#07C160] px-3 py-2 text-[12px] font-semibold text-[#07C160]"
>
</button>
) : null}
{verification ? (
<span className="rounded-full bg-[#F5F5F7] px-3 py-2 text-[12px] text-[#57606A]">
{verification.status}
</span>
) : null}
{message ? (
<span className="text-[12px] text-[#57606A]">{message}</span>
) : null}
</div>
);
}
export function ForwardComposer({
sourceProjectId,
targets,
}: {
sourceProjectId: string;
targets: Array<{ id: string; name: string }>;
}) {
const router = useRouter();
const [targetProjectId, setTargetProjectId] = useState(targets[0]?.id ?? "");
const [note, setNote] = useState("");
const [message, setMessage] = useState("");
async function submit() {
const response = await fetch(`/api/v1/projects/${sourceProjectId}/forwards`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetProjectId, note }),
});
const result = (await response.json()) as { ok: boolean; message?: string };
if (result.ok) {
router.push(`/conversations/${targetProjectId}`);
router.refresh();
return;
}
setMessage(result.message ?? "转发失败");
}
return (
<div className="space-y-3 rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
<div className="text-[16px] font-semibold text-[#111111]"></div>
<label className="block space-y-1">
<span className="text-[12px] text-[#8C8C8C]"></span>
<select
value={targetProjectId}
onChange={(event) => setTargetProjectId(event.target.value)}
className="w-full rounded-xl border border-[#E5E5EA] bg-[#F9F9FA] px-3 py-3 text-[14px] outline-none"
>
{targets.map((target) => (
<option key={target.id} value={target.id}>
{target.name}
</option>
))}
</select>
</label>
<label className="block space-y-1">
<span className="text-[12px] text-[#8C8C8C]"></span>
<textarea
value={note}
onChange={(event) => setNote(event.target.value)}
className="min-h-28 w-full rounded-xl border border-[#E5E5EA] bg-[#F9F9FA] p-3 text-[14px] leading-6 outline-none"
/>
</label>
<button
type="button"
onClick={() => void submit()}
className="rounded-full bg-[#07C160] px-4 py-2 text-[13px] font-semibold text-white"
>
</button>
{message ? (
<div className="rounded-2xl bg-[#FFF1F0] px-4 py-3 text-[12px] text-[#CF1322]">
{message}
</div>
) : null}
</div>
);
}