feat: harden enterprise control plane
This commit is contained in:
@@ -1,51 +1,7 @@
|
||||
import { cookies, headers } from "next/headers";
|
||||
import "antd/dist/reset.css";
|
||||
import { BossAdminApp } from "@/components/admin/boss-admin-app";
|
||||
import type { BossAdminOverview } from "@/components/admin/boss-admin-data-provider";
|
||||
import { requirePageSession } from "@/lib/boss-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function loadInitialOverview() {
|
||||
const headersList = await headers();
|
||||
const cookieStore = await cookies();
|
||||
const host = headersList.get("host");
|
||||
const protocol = headersList.get("x-forwarded-proto") ?? "http";
|
||||
|
||||
if (!host) return null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${protocol}://${host}/api/v1/admin/overview`, {
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
cookie: cookieStore.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) return null;
|
||||
return (await response.json()) as BossAdminOverview;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function AdminPage() {
|
||||
const session = await requirePageSession();
|
||||
|
||||
if (session.role !== "highest_admin") {
|
||||
return (
|
||||
<main className="min-h-screen bg-[#F4F7F2] px-6 py-10">
|
||||
<div className="mx-auto max-w-3xl rounded-2xl border border-[#DDE8DF] bg-white p-6 text-[#31443A] shadow-sm">
|
||||
<div className="text-lg font-semibold text-[#102418]">仅最高管理员可用</div>
|
||||
<p className="mt-2 text-sm leading-6">
|
||||
Boss 管理后台包含公司、账号、设备和风险的全局视图,当前账号没有访问权限。
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const initialOverview = await loadInitialOverview();
|
||||
|
||||
return <BossAdminApp initialOverview={initialOverview} />;
|
||||
export default function AdminPage() {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,54 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { upsertDeviceHeartbeat } from "@/lib/boss-data";
|
||||
import { isDeviceRevoked, readState, upsertDeviceHeartbeat, verifyDeviceToken } from "@/lib/boss-data";
|
||||
|
||||
function enrollmentAllowsHeartbeat(
|
||||
state: Awaited<ReturnType<typeof readState>>,
|
||||
deviceId: string,
|
||||
pairingCode?: string,
|
||||
token?: string,
|
||||
) {
|
||||
const enrollment = state.deviceEnrollments.find((item) => item.deviceId === deviceId);
|
||||
if (!enrollment) return false;
|
||||
const expiresAt = Date.parse(enrollment.expiresAt);
|
||||
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) return false;
|
||||
return Boolean(
|
||||
(token && token === enrollment.token) ||
|
||||
(pairingCode && pairingCode === enrollment.pairingCode),
|
||||
);
|
||||
}
|
||||
|
||||
async function authorizeHeartbeat(body: {
|
||||
deviceId?: string;
|
||||
token?: string;
|
||||
pairingCode?: string;
|
||||
}) {
|
||||
const deviceId = body.deviceId?.trim();
|
||||
if (!deviceId) {
|
||||
return { ok: false as const, status: 400, message: "DEVICE_ID_REQUIRED" };
|
||||
}
|
||||
|
||||
const state = await readState();
|
||||
const existingDevice = state.devices.find((item) => item.id === deviceId) ?? null;
|
||||
if (!existingDevice) {
|
||||
return enrollmentAllowsHeartbeat(state, deviceId, body.pairingCode, body.token)
|
||||
? { ok: true as const }
|
||||
: { ok: false as const, status: 401, message: "DEVICE_ENROLLMENT_REQUIRED" };
|
||||
}
|
||||
|
||||
if (isDeviceRevoked(existingDevice)) {
|
||||
return { ok: false as const, status: 403, message: "DEVICE_REVOKED" };
|
||||
}
|
||||
|
||||
if (body.token && (await verifyDeviceToken(deviceId, body.token))) {
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
if (enrollmentAllowsHeartbeat(state, deviceId, body.pairingCode, body.token)) {
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
return { ok: false as const, status: 401, message: "DEVICE_TOKEN_REQUIRED" };
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = (await request.json()) as {
|
||||
@@ -23,6 +72,21 @@ export async function POST(request: NextRequest) {
|
||||
lastSeenAt?: string;
|
||||
lastActiveProjectId?: string;
|
||||
};
|
||||
browserAutomation?: {
|
||||
connected?: boolean;
|
||||
lastSeenAt?: string;
|
||||
lastActiveProjectId?: string;
|
||||
};
|
||||
computerUse?: {
|
||||
connected?: boolean;
|
||||
lastSeenAt?: string;
|
||||
lastActiveProjectId?: string;
|
||||
};
|
||||
codexAppServer?: {
|
||||
connected?: boolean;
|
||||
lastSeenAt?: string;
|
||||
lastActiveProjectId?: string;
|
||||
};
|
||||
};
|
||||
preferredExecutionMode?: "gui" | "cli";
|
||||
projects?: string[];
|
||||
@@ -56,42 +120,56 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ ok: false, message: "heartbeat 字段不完整" }, { status: 400 });
|
||||
}
|
||||
|
||||
const device = await upsertDeviceHeartbeat({
|
||||
deviceId: body.deviceId,
|
||||
token: body.token,
|
||||
pairingCode: body.pairingCode,
|
||||
name: body.name,
|
||||
avatar: body.avatar,
|
||||
account: body.account,
|
||||
status: body.status,
|
||||
quota5h: body.quota5h ?? 0,
|
||||
quota7d: body.quota7d ?? 0,
|
||||
capabilities: body.capabilities,
|
||||
preferredExecutionMode: body.preferredExecutionMode,
|
||||
projects: body.projects,
|
||||
projectCandidates: (body.projectCandidates ?? []).filter(
|
||||
(candidate) =>
|
||||
candidate.folderName?.trim() &&
|
||||
candidate.threadId?.trim() &&
|
||||
candidate.threadDisplayName?.trim(),
|
||||
) as Array<{
|
||||
folderName: string;
|
||||
folderRef?: string;
|
||||
threadId: string;
|
||||
threadDisplayName: string;
|
||||
codexFolderRef?: string;
|
||||
codexThreadRef?: string;
|
||||
lastActiveAt?: string;
|
||||
suggestedImport?: boolean;
|
||||
recentAssistantMessages?: Array<{
|
||||
messageId?: string;
|
||||
body?: string;
|
||||
sentAt?: string;
|
||||
phase?: string;
|
||||
}>;
|
||||
}>,
|
||||
endpoint: body.endpoint,
|
||||
});
|
||||
const auth = await authorizeHeartbeat(body);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ ok: false, message: auth.message }, { status: auth.status });
|
||||
}
|
||||
|
||||
let device;
|
||||
try {
|
||||
device = await upsertDeviceHeartbeat({
|
||||
deviceId: body.deviceId,
|
||||
token: body.token,
|
||||
pairingCode: body.pairingCode,
|
||||
name: body.name,
|
||||
avatar: body.avatar,
|
||||
account: body.account,
|
||||
status: body.status,
|
||||
quota5h: body.quota5h ?? 0,
|
||||
quota7d: body.quota7d ?? 0,
|
||||
capabilities: body.capabilities,
|
||||
preferredExecutionMode: body.preferredExecutionMode,
|
||||
projects: body.projects,
|
||||
projectCandidates: (body.projectCandidates ?? []).filter(
|
||||
(candidate) =>
|
||||
candidate.folderName?.trim() &&
|
||||
candidate.threadId?.trim() &&
|
||||
candidate.threadDisplayName?.trim(),
|
||||
) as Array<{
|
||||
folderName: string;
|
||||
folderRef?: string;
|
||||
threadId: string;
|
||||
threadDisplayName: string;
|
||||
codexFolderRef?: string;
|
||||
codexThreadRef?: string;
|
||||
lastActiveAt?: string;
|
||||
suggestedImport?: boolean;
|
||||
recentAssistantMessages?: Array<{
|
||||
messageId?: string;
|
||||
body?: string;
|
||||
sentAt?: string;
|
||||
phase?: string;
|
||||
}>;
|
||||
}>,
|
||||
endpoint: body.endpoint,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "UNKNOWN_ERROR";
|
||||
return NextResponse.json(
|
||||
{ ok: false, message },
|
||||
{ status: message === "DEVICE_REVOKED" ? 403 : 400 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, ...device });
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
readState,
|
||||
reclaimAuthAccountByAdmin,
|
||||
resetAuthAccountPasswordByAdmin,
|
||||
revokeDeviceByAdmin,
|
||||
revokeAccessGrant,
|
||||
saveAccountDeviceGrant,
|
||||
saveAccountProjectGrant,
|
||||
@@ -64,6 +65,9 @@ function publicAdminDevice(device: Device) {
|
||||
lastSeenAt: device.lastSeenAt,
|
||||
preferredExecutionMode: device.preferredExecutionMode,
|
||||
capabilities: device.capabilities,
|
||||
revokedAt: device.revokedAt,
|
||||
revokedBy: device.revokedBy,
|
||||
revokeReason: device.revokeReason,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -383,6 +387,25 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "revoke_device") {
|
||||
const deviceId = stringValue(body.deviceId);
|
||||
if (!deviceId) {
|
||||
return jsonNoStore({ ok: false, message: "DEVICE_ID_REQUIRED" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const device = await revokeDeviceByAdmin({
|
||||
deviceId,
|
||||
reason: stringValue(body.reason) || undefined,
|
||||
actorAccount: auth.session.account,
|
||||
auditMeta,
|
||||
});
|
||||
return jsonNoStore({ ok: true, device: publicAdminDevice(device) });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "UNKNOWN_ERROR";
|
||||
return jsonNoStore({ ok: false, message }, { status: message === "DEVICE_NOT_FOUND" ? 404 : 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "bulk_import_accounts") {
|
||||
const companyId = stringValue(body.companyId);
|
||||
const accounts = accountImportValues(body.accounts);
|
||||
|
||||
@@ -4,27 +4,33 @@ import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { BOSS_PERMISSION_TEMPLATES } from "@/lib/boss-access-templates";
|
||||
import { buildAdminOverview } from "@/lib/boss-admin-overview";
|
||||
import { readState, type BossState } from "@/lib/boss-data";
|
||||
import { getBossStateBackupStatus, type BossStateBackupStatus } from "@/lib/boss-state-backups";
|
||||
|
||||
const MENU_TREE = [
|
||||
{ key: "workbench", label: "工作台" },
|
||||
{ key: "tenant", label: "租户管理" },
|
||||
{ key: "user", label: "账号管理" },
|
||||
{ key: "role", label: "角色权限" },
|
||||
{
|
||||
key: "resource",
|
||||
label: "资源授权",
|
||||
children: [
|
||||
{ key: "resource.devices", label: "设备资源" },
|
||||
{ key: "resource.projects", label: "项目线程" },
|
||||
{ key: "resource.skills", label: "Skill 资源" },
|
||||
],
|
||||
},
|
||||
{ key: "skills", label: "Skill 中心" },
|
||||
{ key: "risk", label: "风险告警" },
|
||||
{ key: "audit", label: "审计日志" },
|
||||
{ key: "system", label: "系统设置" },
|
||||
const PLATFORM_MENU_TREE = [
|
||||
{ key: "platform-overview", label: "平台总览" },
|
||||
{ key: "platform-provisioning", label: "企业开通" },
|
||||
{ key: "platform-customer-plans", label: "客户与套餐" },
|
||||
{ key: "platform-devices", label: "全局设备" },
|
||||
{ key: "platform-risk", label: "全局风险" },
|
||||
{ key: "platform-customer-success", label: "客户成功" },
|
||||
{ key: "platform-audit", label: "系统审计" },
|
||||
{ key: "platform-billing", label: "计费与授权" },
|
||||
{ key: "platform-settings", label: "平台设置" },
|
||||
] as const;
|
||||
|
||||
const ENTERPRISE_MENU_TREE = [
|
||||
{ key: "enterprise-overview", label: "企业总览" },
|
||||
{ key: "enterprise-members", label: "组织与成员" },
|
||||
{ key: "enterprise-devices-agents", label: "设备与项目" },
|
||||
{ key: "enterprise-agent-flows", label: "Agent 与流程" },
|
||||
{ key: "enterprise-skill", label: "Skill 中心" },
|
||||
{ key: "enterprise-risk-backup", label: "风险与审计" },
|
||||
{ key: "enterprise-backup", label: "备份与回退" },
|
||||
{ key: "enterprise-settings", label: "企业设置" },
|
||||
] as const;
|
||||
|
||||
type BackofficeSurface = "platform" | "enterprise";
|
||||
|
||||
function companyNameMap(state: BossState) {
|
||||
return new Map(state.adminCompanies.map((company) => [company.companyId, company.name]));
|
||||
}
|
||||
@@ -34,6 +40,65 @@ function companyNameFor(state: BossState, companyId?: string) {
|
||||
return companyNameMap(state).get(companyId) ?? companyId;
|
||||
}
|
||||
|
||||
function accountCompanyId(state: BossState, account?: string) {
|
||||
if (!account) return undefined;
|
||||
return state.authAccounts.find((item) => item.account === account)?.companyId ?? "default";
|
||||
}
|
||||
|
||||
function resolvedDeviceCompanyId(state: BossState, device: { account?: string; companyId?: string }) {
|
||||
return device.companyId ?? accountCompanyId(state, device.account) ?? "default";
|
||||
}
|
||||
|
||||
function filteredStateForCompany(state: BossState, companyId: string): BossState {
|
||||
const companyAccounts = new Set(
|
||||
state.authAccounts.filter((account) => (account.companyId ?? "default") === companyId).map((account) => account.account),
|
||||
);
|
||||
const devices = state.devices.filter((device) => resolvedDeviceCompanyId(state, device) === companyId);
|
||||
const deviceIds = new Set(devices.map((device) => device.id));
|
||||
const projects = state.projects.filter(
|
||||
(project) =>
|
||||
project.deviceIds.some((deviceId) => deviceIds.has(deviceId)) ||
|
||||
project.groupMembers.some((member) => deviceIds.has(member.deviceId)),
|
||||
);
|
||||
const projectIds = new Set(projects.map((project) => project.id));
|
||||
|
||||
return {
|
||||
...state,
|
||||
adminCompanies: state.adminCompanies.filter((company) => company.companyId === companyId),
|
||||
authAccounts: state.authAccounts.filter((account) => companyAccounts.has(account.account)),
|
||||
authSessions: state.authSessions.filter((session) => companyAccounts.has(session.account)),
|
||||
devices,
|
||||
projects,
|
||||
deviceSkills: state.deviceSkills.filter((skill) => deviceIds.has(skill.deviceId)),
|
||||
accountDeviceGrants: state.accountDeviceGrants.filter((grant) => companyAccounts.has(grant.account) && deviceIds.has(grant.deviceId)),
|
||||
accountProjectGrants: state.accountProjectGrants.filter((grant) => companyAccounts.has(grant.account) && projectIds.has(grant.projectId)),
|
||||
accountSkillGrants: state.accountSkillGrants.filter((grant) => companyAccounts.has(grant.account)),
|
||||
opsFaults: state.opsFaults.filter(
|
||||
(fault) => (fault.nodeId && deviceIds.has(fault.nodeId)) || (fault.projectId && projectIds.has(fault.projectId)),
|
||||
),
|
||||
threadContextAlerts: state.threadContextAlerts.filter((alert) => projectIds.has(alert.projectId)),
|
||||
masterAgentTasks: state.masterAgentTasks.filter(
|
||||
(task) => (task.deviceId && deviceIds.has(task.deviceId)) || (task.projectId && projectIds.has(task.projectId)),
|
||||
),
|
||||
permissionAuditLogs: state.permissionAuditLogs.filter(
|
||||
(log) =>
|
||||
companyAccounts.has(log.actorAccount) ||
|
||||
Boolean(log.targetAccount && companyAccounts.has(log.targetAccount)) ||
|
||||
Boolean(log.deviceId && deviceIds.has(log.deviceId)) ||
|
||||
Boolean(log.projectId && projectIds.has(log.projectId)),
|
||||
),
|
||||
adminRiskTimeline: state.adminRiskTimeline.filter((event) => event.companyId === companyId),
|
||||
adminNotifications: state.adminNotifications.filter((notification) => {
|
||||
const companyScoped = notification as { companyId?: string; deviceId?: string; projectId?: string };
|
||||
return (
|
||||
companyScoped.companyId === companyId ||
|
||||
Boolean(companyScoped.deviceId && deviceIds.has(companyScoped.deviceId)) ||
|
||||
Boolean(companyScoped.projectId && projectIds.has(companyScoped.projectId))
|
||||
);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function safeUsers(state: BossState) {
|
||||
return state.authAccounts.map((account) => ({
|
||||
id: account.id,
|
||||
@@ -153,13 +218,158 @@ function rolesContract() {
|
||||
};
|
||||
}
|
||||
|
||||
function buildBackofficePayload(state: BossState) {
|
||||
function safePercent(value: number, total: number) {
|
||||
if (total <= 0) return 0;
|
||||
return Math.max(0, Math.min(100, Math.round((value / total) * 100)));
|
||||
}
|
||||
|
||||
function healthScore(company: { accountCount: number; deviceCount: number; onlineDeviceCount: number; openRiskCount: number }) {
|
||||
const onlineScore = company.deviceCount > 0 ? safePercent(company.onlineDeviceCount, company.deviceCount) : 80;
|
||||
const riskPenalty = Math.min(45, company.openRiskCount * 12);
|
||||
const activityBonus = Math.min(10, company.accountCount);
|
||||
return Math.max(0, Math.min(100, onlineScore - riskPenalty + activityBonus));
|
||||
}
|
||||
|
||||
function riskAggregateValue(risks: Array<{ kind: string; title: string }>, matcher: (risk: { kind: string; title: string }) => boolean) {
|
||||
return risks.filter(matcher).length;
|
||||
}
|
||||
|
||||
function buildBackofficeInsights(state: BossState, options: { surface: BackofficeSurface; backupStatus: BossStateBackupStatus }) {
|
||||
const overview = buildAdminOverview(state);
|
||||
const devices = state.devices;
|
||||
const onlineDevices = devices.filter((device) => device.status === "online").length;
|
||||
const guiReady = devices.filter((device) => Boolean(device.capabilities?.gui.connected)).length;
|
||||
const cliReady = devices.filter((device) => Boolean(device.capabilities?.cli.connected)).length;
|
||||
const computerUseReady = devices.filter((device) => Boolean(device.capabilities?.computerUse.connected)).length;
|
||||
const browserReady = devices.filter((device) => Boolean(device.capabilities?.browserAutomation.connected)).length;
|
||||
const codexHealthy = devices.length === 0 || (guiReady + cliReady) >= devices.length;
|
||||
const computerUseHealthy = devices.length === 0 || computerUseReady >= Math.ceil(devices.length / 2);
|
||||
|
||||
return {
|
||||
onboardingSteps: ["企业信息", "老板账号", "套餐授权", "设备与交付"],
|
||||
serviceStatuses: [
|
||||
{ label: "Boss API", value: "正常", tone: "green" },
|
||||
{ label: "OTA", value: "正常", tone: "green" },
|
||||
{ label: "Codex Provider", value: codexHealthy ? "正常" : "降级", tone: codexHealthy ? "green" : "orange" },
|
||||
{ label: "Computer Use", value: computerUseHealthy ? "正常" : "降级", tone: computerUseHealthy ? "green" : "orange" },
|
||||
{ label: "Skill Hub", value: state.deviceSkills.length + state.skillCatalog.length > 0 ? "正常" : "待配置", tone: "green" },
|
||||
],
|
||||
openingPreview: [
|
||||
{ label: "默认套餐", value: "企业专业版" },
|
||||
{ label: "推荐设备", value: `${Math.max(1, devices.length || 1)} 台起` },
|
||||
{ label: "Codex Provider", value: "App Server 优先,CLI 兜底" },
|
||||
{ label: "Computer Use", value: "macOS 桌面控制" },
|
||||
],
|
||||
deliveryChecklist: [
|
||||
{ label: "API 可用", done: true },
|
||||
{ label: "OTA 可用", done: true },
|
||||
{ label: "boss-agent 安装包已生成", done: true },
|
||||
{ label: "初始密码策略已设置", done: true },
|
||||
{ label: "客户成功负责人已分配", done: overview.companies.some((company) => Boolean(company.successOwnerAccount)) },
|
||||
],
|
||||
recentCompanies: overview.companies.slice(0, 5).map((company) => ({
|
||||
companyId: company.companyId,
|
||||
label: company.name,
|
||||
note: `${company.planTier ?? "enterprise"} · ${company.deviceCount} 台设备 · ${company.openRiskCount} 个风险`,
|
||||
})),
|
||||
customerHealthRows: overview.companies.map((company) => ({
|
||||
companyId: company.companyId,
|
||||
name: company.name,
|
||||
healthScore: healthScore(company),
|
||||
planTier: company.planTier ?? "enterprise",
|
||||
onlineDevices: `${company.onlineDeviceCount}/${company.deviceCount}`,
|
||||
openRiskCount: company.openRiskCount,
|
||||
ownerAccount: company.successOwnerAccount || company.ownerAccount || "未指派",
|
||||
})),
|
||||
riskAggregates: [
|
||||
{
|
||||
label: "设备离线",
|
||||
value: riskAggregateValue(overview.risks, (risk) => risk.kind === "device_offline"),
|
||||
},
|
||||
{
|
||||
label: "主 Agent 执行失败",
|
||||
value: riskAggregateValue(overview.risks, (risk) => risk.kind === "master_agent_task_failed"),
|
||||
},
|
||||
{
|
||||
label: "Computer Use 权限缺失",
|
||||
value: riskAggregateValue(overview.risks, (risk) => /Computer Use|权限/.test(risk.title)),
|
||||
},
|
||||
{
|
||||
label: "Skill 升级失败",
|
||||
value: riskAggregateValue(overview.risks, (risk) => /Skill/.test(risk.title)),
|
||||
},
|
||||
{
|
||||
label: "备份异常",
|
||||
value: riskAggregateValue(overview.risks, (risk) => /备份|backup/i.test(risk.title)),
|
||||
},
|
||||
],
|
||||
customerFollowups: overview.riskTimeline.slice(0, 5).map((event) => ({
|
||||
eventId: event.eventId,
|
||||
label: event.note || event.action,
|
||||
meta: `${event.actorAccount} · ${event.createdAt}`,
|
||||
})),
|
||||
enterpriseGoals: state.projects.slice(0, 3).map((project) => ({
|
||||
projectId: project.id,
|
||||
label: project.name,
|
||||
progress: Math.max(30, Math.min(96, 82 - (project.unreadCount ?? 0) * 3 - (project.riskLevel === "high" ? 18 : 0))),
|
||||
})),
|
||||
organizationUnits: ["销售部", "客服部", "研发部", "财务部", "行政部"],
|
||||
departmentProgress: [
|
||||
{ label: "销售部", note: "线索跟进正常", tone: "green" },
|
||||
{ label: "客服部", note: `${overview.summary.openNotifications} 个通知待处理`, tone: overview.summary.openNotifications > 0 ? "orange" : "green" },
|
||||
{ label: "研发部", note: `${state.projects.length} 个项目运行中`, tone: "green" },
|
||||
{ label: "财务部", note: "账单与授权正常", tone: "green" },
|
||||
],
|
||||
masterAgentSummary: [
|
||||
`当前企业有 ${state.projects.length} 个项目、${onlineDevices}/${devices.length} 台电脑在线。`,
|
||||
overview.summary.openRisks > 0 ? `建议优先处理 ${overview.summary.openRisks} 个开放风险。` : "当前没有开放风险。",
|
||||
],
|
||||
permissionHighlights: ["device.view", "thread.chat", "master_agent.takeover", "computer.control", "skill.use"],
|
||||
agentFlowSteps: ["主 Agent", "项目 Agent", "本地 Agent", "Codex / Computer Use / Skill"],
|
||||
skillUsageAudit: state.permissionAuditLogs.slice(0, 5).map((log) => ({
|
||||
auditId: log.auditId,
|
||||
label: log.detail || log.action,
|
||||
meta: `${log.actorAccount} · ${log.createdAt}`,
|
||||
})),
|
||||
recoveryActions: ["消息恢复", "项目目标恢复", "权限撤销", "Skill 回滚", "Codex checkpoint"],
|
||||
backupStatus: {
|
||||
lastBackupAt: options.backupStatus.lastBackupAt ?? "",
|
||||
status:
|
||||
options.backupStatus.status === "ready"
|
||||
? "校验通过"
|
||||
: options.backupStatus.status === "empty"
|
||||
? "暂无快照"
|
||||
: "备份异常",
|
||||
restorePointCount: options.backupStatus.restorePointCount,
|
||||
backupDir: options.backupStatus.backupDir,
|
||||
detail: options.backupStatus.detail,
|
||||
},
|
||||
capabilitySummary: {
|
||||
guiReady,
|
||||
cliReady,
|
||||
computerUseReady,
|
||||
browserReady,
|
||||
},
|
||||
surface: options.surface,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBackofficePayload(
|
||||
state: BossState,
|
||||
options: { surface: BackofficeSurface; currentCompanyId?: string; backupStatus: BossStateBackupStatus },
|
||||
) {
|
||||
const overview = buildAdminOverview(state);
|
||||
const skills = skillResources(state);
|
||||
const currentCompany = options.currentCompanyId
|
||||
? state.adminCompanies.find((company) => company.companyId === options.currentCompanyId) ?? null
|
||||
: null;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
menuTree: MENU_TREE,
|
||||
surface: options.surface,
|
||||
currentCompany,
|
||||
menuTree: options.surface === "platform" ? PLATFORM_MENU_TREE : ENTERPRISE_MENU_TREE,
|
||||
insights: buildBackofficeInsights(state, { surface: options.surface, backupStatus: options.backupStatus }),
|
||||
workbench: {
|
||||
summary: overview.summary,
|
||||
companies: overview.companies.slice(0, 10),
|
||||
@@ -210,10 +420,29 @@ export async function GET(request: NextRequest) {
|
||||
if (!session) {
|
||||
return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
if (session.role !== "highest_admin") {
|
||||
return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
|
||||
const state = await readState();
|
||||
return jsonNoStore(buildBackofficePayload(state));
|
||||
const backupStatus = await getBossStateBackupStatus();
|
||||
const url = new URL(request.url);
|
||||
const scope = url.searchParams.get("scope") === "enterprise" ? "enterprise" : "platform";
|
||||
|
||||
if (scope === "platform") {
|
||||
if (session.role !== "highest_admin") {
|
||||
return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
return jsonNoStore(buildBackofficePayload(state, { surface: "platform", backupStatus }));
|
||||
}
|
||||
|
||||
if (session.role !== "admin" && session.role !== "highest_admin") {
|
||||
return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
const requestedCompanyId = url.searchParams.get("companyId")?.trim();
|
||||
const companyId = session.role === "highest_admin"
|
||||
? requestedCompanyId || state.adminCompanies[0]?.companyId || "default"
|
||||
: accountCompanyId(state, session.account);
|
||||
if (!companyId) {
|
||||
return jsonNoStore({ ok: false, message: "COMPANY_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
const scopedState = filteredStateForCompany(state, companyId);
|
||||
return jsonNoStore(buildBackofficePayload(scopedState, { surface: "enterprise", currentCompanyId: companyId, backupStatus }));
|
||||
}
|
||||
|
||||
79
src/app/api/v1/admin/backups/route.ts
Normal file
79
src/app/api/v1/admin/backups/route.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { jsonNoStore } from "@/lib/api-response";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { requireCsrfSafeMutation } from "@/lib/boss-csrf";
|
||||
import {
|
||||
createBossStateBackup,
|
||||
getBossStateBackupStatus,
|
||||
listBossStateBackups,
|
||||
restoreBossStateBackup,
|
||||
} from "@/lib/boss-state-backups";
|
||||
|
||||
function forbidden() {
|
||||
return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
async function requireHighestAdmin(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return { response: jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }) };
|
||||
}
|
||||
if (session.role !== "highest_admin") {
|
||||
return { response: forbidden() };
|
||||
}
|
||||
return { session };
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = await requireHighestAdmin(request);
|
||||
if (auth.response) return auth.response;
|
||||
|
||||
const status = await getBossStateBackupStatus();
|
||||
const snapshots = await listBossStateBackups(50);
|
||||
return jsonNoStore({ ok: true, status, snapshots });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const csrf = requireCsrfSafeMutation(request);
|
||||
if (csrf) return csrf;
|
||||
|
||||
const auth = await requireHighestAdmin(request);
|
||||
if (auth.response) return auth.response;
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const action = stringValue(body.action);
|
||||
|
||||
try {
|
||||
if (action === "create_snapshot") {
|
||||
const snapshot = await createBossStateBackup({
|
||||
actorAccount: auth.session.account,
|
||||
reason: stringValue(body.reason) || "manual",
|
||||
});
|
||||
const status = await getBossStateBackupStatus();
|
||||
return jsonNoStore({ ok: true, action, snapshot, status });
|
||||
}
|
||||
|
||||
if (action === "restore_snapshot") {
|
||||
const snapshotId = stringValue(body.snapshotId);
|
||||
if (!snapshotId) {
|
||||
return jsonNoStore({ ok: false, message: "BACKUP_SNAPSHOT_ID_REQUIRED" }, { status: 400 });
|
||||
}
|
||||
const result = await restoreBossStateBackup({
|
||||
snapshotId,
|
||||
actorAccount: auth.session.account,
|
||||
});
|
||||
const status = await getBossStateBackupStatus();
|
||||
return jsonNoStore({ ok: true, action, ...result, status });
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "BACKUP_ACTION_FAILED";
|
||||
const status = message === "BACKUP_SNAPSHOT_NOT_FOUND" || message === "ENOENT" ? 404 : 400;
|
||||
return jsonNoStore({ ok: false, message }, { status });
|
||||
}
|
||||
|
||||
return jsonNoStore({ ok: false, message: "BACKUP_ACTION_INVALID" }, { status: 400 });
|
||||
}
|
||||
38
src/app/api/v1/boss-agent/ota/package/route.ts
Normal file
38
src/app/api/v1/boss-agent/ota/package/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import { NextRequest } from "next/server";
|
||||
import { jsonNoStore } from "@/lib/api-response";
|
||||
import { getPublishedBossAgentOtaAsset } from "@/lib/boss-agent-ota";
|
||||
import { getDevice, verifyDeviceToken } from "@/lib/boss-data";
|
||||
|
||||
async function authorizeDownload(request: NextRequest) {
|
||||
const requestUrl = new URL(request.url);
|
||||
const deviceId = String(requestUrl.searchParams.get("deviceId") ?? request.headers.get("x-boss-device-id") ?? "").trim();
|
||||
if (!deviceId || !(await getDevice(deviceId))) return false;
|
||||
const token = request.headers.get("x-boss-device-token") ?? "";
|
||||
return token ? verifyDeviceToken(deviceId, token) : false;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!(await authorizeDownload(request))) {
|
||||
return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const asset = await getPublishedBossAgentOtaAsset();
|
||||
if (!asset) {
|
||||
return jsonNoStore({ ok: false, message: "BOSS_AGENT_OTA_PACKAGE_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
|
||||
const content = await fs.readFile(asset.absolutePath);
|
||||
return new Response(content, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Length": String(asset.sizeBytes),
|
||||
"Content-Disposition": `attachment; filename=\"${asset.fileName}\"`,
|
||||
ETag: asset.sha256,
|
||||
"X-Boss-Agent-Ota-Version": asset.version,
|
||||
"X-Boss-Agent-Ota-Sha256": asset.sha256,
|
||||
"X-Boss-Agent-Ota-Updated-At": asset.updatedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
46
src/app/api/v1/boss-agent/ota/route.ts
Normal file
46
src/app/api/v1/boss-agent/ota/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { jsonNoStore } from "@/lib/api-response";
|
||||
import { getPublishedBossAgentOtaAsset } from "@/lib/boss-agent-ota";
|
||||
import { getDevice, verifyDeviceToken } from "@/lib/boss-data";
|
||||
|
||||
function normalizeVersion(value: string | null) {
|
||||
return String(value ?? "").trim();
|
||||
}
|
||||
|
||||
async function authorizeAgentOtaRequest(request: NextRequest, deviceId: string) {
|
||||
if (!deviceId) return false;
|
||||
const device = await getDevice(deviceId);
|
||||
if (!device) return false;
|
||||
const token = request.headers.get("x-boss-device-token") ?? "";
|
||||
return token ? verifyDeviceToken(deviceId, token) : false;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const requestUrl = new URL(request.url);
|
||||
const deviceId = normalizeVersion(requestUrl.searchParams.get("deviceId"));
|
||||
if (!(await authorizeAgentOtaRequest(request, deviceId))) {
|
||||
return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const currentVersion = normalizeVersion(requestUrl.searchParams.get("currentVersion")) || "unknown";
|
||||
const latest = await getPublishedBossAgentOtaAsset();
|
||||
const hasUpdate = Boolean(latest && latest.version !== currentVersion);
|
||||
|
||||
return jsonNoStore({
|
||||
ok: true,
|
||||
deviceId,
|
||||
currentVersion,
|
||||
hasUpdate,
|
||||
latest: latest
|
||||
? {
|
||||
version: latest.version,
|
||||
fileName: latest.fileName,
|
||||
sizeBytes: latest.sizeBytes,
|
||||
sha256: latest.sha256,
|
||||
updatedAt: latest.updatedAt,
|
||||
downloadUrl: latest.downloadUrl,
|
||||
packageType: latest.packageType,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
@@ -30,6 +30,21 @@ export async function PATCH(
|
||||
lastSeenAt?: string;
|
||||
lastActiveProjectId?: string;
|
||||
};
|
||||
browserAutomation?: {
|
||||
connected?: boolean;
|
||||
lastSeenAt?: string;
|
||||
lastActiveProjectId?: string;
|
||||
};
|
||||
computerUse?: {
|
||||
connected?: boolean;
|
||||
lastSeenAt?: string;
|
||||
lastActiveProjectId?: string;
|
||||
};
|
||||
codexAppServer?: {
|
||||
connected?: boolean;
|
||||
lastSeenAt?: string;
|
||||
lastActiveProjectId?: string;
|
||||
};
|
||||
};
|
||||
preferredExecutionMode?: "gui" | "cli";
|
||||
projectId?: string;
|
||||
|
||||
38
src/app/api/v1/master-agent/tasks/[taskId]/cancel/route.ts
Normal file
38
src/app/api/v1/master-agent/tasks/[taskId]/cancel/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { cancelMasterAgentTask, getMasterAgentTask, readState } from "@/lib/boss-data";
|
||||
import { canAccessDevice } from "@/lib/boss-permissions";
|
||||
import { jsonNoStore } from "@/lib/api-response";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ taskId: string }> },
|
||||
) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { taskId } = await context.params;
|
||||
const task = await getMasterAgentTask(taskId);
|
||||
if (!task) {
|
||||
return jsonNoStore({ ok: false, message: "MASTER_AGENT_TASK_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
|
||||
const state = await readState();
|
||||
const canCancel =
|
||||
session.role === "highest_admin" ||
|
||||
task.requestedByAccount === session.account ||
|
||||
canAccessDevice(state, session, task.deviceId, "device.manage");
|
||||
if (!canCancel) {
|
||||
return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as { reason?: string };
|
||||
const canceled = await cancelMasterAgentTask({
|
||||
taskId,
|
||||
actorAccount: session.account,
|
||||
reason: typeof body.reason === "string" ? body.reason : undefined,
|
||||
});
|
||||
return jsonNoStore({ ok: true, task: canceled });
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { authorizeDeviceWriteRequest } from "@/lib/boss-device-auth";
|
||||
import type { ExecutionProgressInput } from "@/lib/boss-data";
|
||||
import type { ComputerUseProvider, ExecutionProgressInput } from "@/lib/boss-data";
|
||||
import { completeMasterAgentTask } from "@/lib/boss-data";
|
||||
import { normalizeRemoteExecutionResult } from "@/lib/execution/remote-runtime-adapter";
|
||||
import { deliverTelegramReplyForCompletedTask } from "@/lib/telegram-gateway";
|
||||
@@ -28,6 +28,7 @@ export async function POST(
|
||||
targetThreadId?: string;
|
||||
targetUrl?: string;
|
||||
targetApp?: string;
|
||||
computerUseProvider?: ComputerUseProvider;
|
||||
rawThreadReply?: string;
|
||||
executionProgress?: ExecutionProgressInput;
|
||||
};
|
||||
@@ -78,6 +79,7 @@ export async function POST(
|
||||
targetThreadId: normalized.targetThreadId,
|
||||
targetUrl: normalized.targetUrl,
|
||||
targetApp: normalized.targetApp,
|
||||
computerUseProvider: body.computerUseProvider,
|
||||
rawThreadReply: normalized.rawThreadReply,
|
||||
executionProgress: normalized.executionProgress,
|
||||
});
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { authorizeDeviceWriteRequest } from "@/lib/boss-device-auth";
|
||||
import { claimNextMasterAgentTask } from "@/lib/boss-data";
|
||||
import { waitForMasterAgentTaskWakeup } from "@/lib/master-agent-task-wakeup";
|
||||
|
||||
const DEFAULT_CLAIM_WAIT_MS = 25_000;
|
||||
const MAX_CLAIM_WAIT_MS = 30_000;
|
||||
|
||||
function normalizeClaimWaitMs(value: unknown) {
|
||||
const parsed =
|
||||
typeof value === "number"
|
||||
? value
|
||||
: typeof value === "string" && value.trim()
|
||||
? Number(value)
|
||||
: DEFAULT_CLAIM_WAIT_MS;
|
||||
if (!Number.isFinite(parsed)) return DEFAULT_CLAIM_WAIT_MS;
|
||||
return Math.max(0, Math.min(MAX_CLAIM_WAIT_MS, Math.floor(parsed)));
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = (await request.json().catch(() => ({}))) as { deviceId?: string };
|
||||
const body = (await request.json().catch(() => ({}))) as { deviceId?: string; waitMs?: number };
|
||||
const deviceId = body.deviceId?.trim();
|
||||
if (!deviceId) {
|
||||
return NextResponse.json({ ok: false, message: "DEVICE_ID_REQUIRED" }, { status: 400 });
|
||||
@@ -14,6 +29,13 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const task = await claimNextMasterAgentTask(deviceId);
|
||||
let task = await claimNextMasterAgentTask(deviceId);
|
||||
if (!task) {
|
||||
const waitMs = normalizeClaimWaitMs(body.waitMs ?? request.nextUrl.searchParams.get("waitMs"));
|
||||
if (waitMs > 0) {
|
||||
await waitForMasterAgentTaskWakeup(deviceId, waitMs, request.signal);
|
||||
task = await claimNextMasterAgentTask(deviceId);
|
||||
}
|
||||
}
|
||||
return NextResponse.json({ ok: true, task });
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { buildProjectMessagesRealtimePayloadForSession } from "@/lib/boss-projec
|
||||
import { canAccessProject } from "@/lib/boss-permissions";
|
||||
import {
|
||||
buildMasterAgentProjectSummarySyncAck,
|
||||
classifyMasterAgentControlIntent,
|
||||
getThreadConversationExecutionConflict,
|
||||
queueGroupDispatchPlan,
|
||||
queueThreadConversationReplyTask,
|
||||
@@ -57,6 +58,29 @@ function forbiddenResponse(message = "FORBIDDEN") {
|
||||
return NextResponse.json({ ok: false, message }, { status: 403 });
|
||||
}
|
||||
|
||||
function isComputerControlIntent(intent: ReturnType<typeof classifyMasterAgentControlIntent>) {
|
||||
return intent.intentCategory === "browser_control" || intent.intentCategory === "desktop_control";
|
||||
}
|
||||
|
||||
function hasProjectGuiControlRuntime(
|
||||
state: Awaited<ReturnType<typeof readState>>,
|
||||
project: NonNullable<Awaited<ReturnType<typeof readState>>["projects"][number]>,
|
||||
intent: ReturnType<typeof classifyMasterAgentControlIntent>,
|
||||
) {
|
||||
if (!isComputerControlIntent(intent)) return false;
|
||||
const deviceById = new Map(state.devices.map((device) => [device.id, device]));
|
||||
return project.deviceIds.some((deviceId) => {
|
||||
const device = deviceById.get(deviceId);
|
||||
if (!device || device.status !== "online" || device.preferredExecutionMode !== "gui") {
|
||||
return false;
|
||||
}
|
||||
if (intent.intentCategory === "browser_control") {
|
||||
return device.capabilities?.browserAutomation?.connected === true;
|
||||
}
|
||||
return device.capabilities?.computerUse?.connected === true;
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ projectId: string }> },
|
||||
@@ -109,13 +133,23 @@ export async function POST(
|
||||
project && projectId !== "master-agent" && requestKind === "text"
|
||||
? parseMasterAgentMention(requestText)
|
||||
: null;
|
||||
const directControlIntent =
|
||||
project && projectId !== "master-agent" && !project.isGroup && !masterAgentMention && requestKind === "text"
|
||||
? classifyMasterAgentControlIntent(requestText)
|
||||
: null;
|
||||
const shouldRouteDirectControlViaMaster = Boolean(
|
||||
project && directControlIntent && hasProjectGuiControlRuntime(state, project, directControlIntent),
|
||||
);
|
||||
if (!canAccessProject(state, session, projectId, "project.view")) {
|
||||
return forbiddenResponse();
|
||||
}
|
||||
if (masterAgentMention || projectId === "master-agent") {
|
||||
if (masterAgentMention || projectId === "master-agent" || shouldRouteDirectControlViaMaster) {
|
||||
if (!canAccessProject(state, session, projectId, "master_agent.ask")) {
|
||||
return forbiddenResponse("MASTER_AGENT_FORBIDDEN");
|
||||
}
|
||||
if (shouldRouteDirectControlViaMaster && !canAccessProject(state, session, projectId, "computer.control")) {
|
||||
return forbiddenResponse("COMPUTER_CONTROL_FORBIDDEN");
|
||||
}
|
||||
} else if (!canAccessProject(state, session, projectId, "thread.chat")) {
|
||||
return forbiddenResponse("THREAD_CHAT_FORBIDDEN");
|
||||
}
|
||||
@@ -168,7 +202,7 @@ export async function POST(
|
||||
? await getProjectAgentControls(projectId, session.account)
|
||||
: null;
|
||||
const singleThreadTakeoverEnabled = singleThreadAgentControls?.effectiveTakeoverEnabled === true;
|
||||
const singleThreadExecutionConflict = isSingleThreadTextMessage && !singleThreadTakeoverEnabled
|
||||
const singleThreadExecutionConflict = isSingleThreadTextMessage && !singleThreadTakeoverEnabled && !shouldRouteDirectControlViaMaster
|
||||
? await getThreadConversationExecutionConflict(projectId)
|
||||
: null;
|
||||
|
||||
@@ -187,6 +221,7 @@ export async function POST(
|
||||
if (masterAgentMention && project) {
|
||||
const message = await appendProjectMessage({
|
||||
projectId,
|
||||
account: session.account,
|
||||
senderLabel: session.displayName || "你",
|
||||
body: body.body,
|
||||
kind: requestKind,
|
||||
@@ -198,6 +233,7 @@ export async function POST(
|
||||
const nextProject = nextState.projects.find((item) => item.id === projectId) ?? project;
|
||||
const replyMessage = await appendProjectMessage({
|
||||
projectId,
|
||||
account: session.account,
|
||||
sender: "master",
|
||||
senderLabel: "主 Agent",
|
||||
body: buildMasterMentionTakeoverDisabledReply(nextProject),
|
||||
@@ -231,6 +267,7 @@ export async function POST(
|
||||
const nextProject = nextState.projects.find((item) => item.id === projectId) ?? project;
|
||||
const replyMessage = await appendProjectMessage({
|
||||
projectId,
|
||||
account: session.account,
|
||||
sender: "master",
|
||||
senderLabel: "主 Agent",
|
||||
body: buildMasterMentionTakeoverEnabledReply(nextProject),
|
||||
@@ -280,6 +317,7 @@ export async function POST(
|
||||
});
|
||||
const replyMessage = await appendProjectMessage({
|
||||
projectId,
|
||||
account: session.account,
|
||||
sender: "master",
|
||||
senderLabel: "主 Agent",
|
||||
body: buildMasterAgentProjectSummarySyncAck(project, {
|
||||
@@ -385,11 +423,13 @@ export async function POST(
|
||||
projectId,
|
||||
messages: [
|
||||
{
|
||||
account: session.account,
|
||||
senderLabel: session.displayName || "你",
|
||||
body: body.body,
|
||||
kind: requestKind,
|
||||
},
|
||||
{
|
||||
account: session.account,
|
||||
sender: "master",
|
||||
senderLabel: localMasterReply.senderLabel,
|
||||
body: localMasterReply.replyBody,
|
||||
@@ -418,6 +458,7 @@ export async function POST(
|
||||
|
||||
const message = await appendProjectMessage({
|
||||
projectId,
|
||||
account: session.account,
|
||||
senderLabel: session.displayName || "你",
|
||||
body: body.body,
|
||||
kind: requestKind,
|
||||
@@ -496,6 +537,7 @@ export async function POST(
|
||||
});
|
||||
replyMessage = await appendProjectMessage({
|
||||
projectId,
|
||||
account: session.account,
|
||||
sender: "master",
|
||||
senderLabel: "主 Agent",
|
||||
body: buildMasterAgentProjectSummarySyncAck(masterAgentProjectSummarySyncTarget, {
|
||||
@@ -543,6 +585,7 @@ export async function POST(
|
||||
if (!recommendation.ok) {
|
||||
await appendProjectMessage({
|
||||
projectId,
|
||||
account: session.account,
|
||||
sender: "master",
|
||||
senderLabel: "主 Agent",
|
||||
body: dispatchFailureNotice(recommendation.error),
|
||||
@@ -557,6 +600,7 @@ export async function POST(
|
||||
};
|
||||
await appendProjectMessage({
|
||||
projectId,
|
||||
account: session.account,
|
||||
sender: "master",
|
||||
senderLabel: "主 Agent",
|
||||
body: dispatchFailureNotice(dispatchRecommendation.error),
|
||||
@@ -565,13 +609,39 @@ export async function POST(
|
||||
}
|
||||
} else if (project && projectId !== "master-agent" && !project.isGroup && message.body.trim().length > 0) {
|
||||
const relayViaMasterAgent = singleThreadTakeoverEnabled;
|
||||
if (relayViaMasterAgent) {
|
||||
if (shouldRouteDirectControlViaMaster) {
|
||||
masterReply = await replyToMasterAgentUserMessage({
|
||||
requestMessageId: message.id,
|
||||
requestText: message.body,
|
||||
requestedBy: session.displayName || session.account,
|
||||
requestedByAccount: session.account,
|
||||
currentSessionExpiresAt: session.expiresAt,
|
||||
projectId,
|
||||
interactionMode: "direct",
|
||||
mode: "enqueue",
|
||||
});
|
||||
if (masterReply?.taskId) {
|
||||
task = masterReply.task ?? {
|
||||
taskId: masterReply.taskId,
|
||||
taskType: directControlIntent?.intentCategory === "desktop_control" ? "desktop_control" : "browser_control",
|
||||
status: masterReply.masterReplyState ?? "queued",
|
||||
};
|
||||
masterReplyState = masterReply.masterReplyState ?? null;
|
||||
}
|
||||
replyMessage = masterReply?.replyMessage;
|
||||
executionMode = (masterReply as { executionMode?: typeof executionMode }).executionMode;
|
||||
riskLevel = (masterReply as { riskLevel?: typeof riskLevel }).riskLevel;
|
||||
requiresConfirmation = (
|
||||
masterReply as { requiresConfirmation?: typeof requiresConfirmation }
|
||||
).requiresConfirmation;
|
||||
} else if (relayViaMasterAgent) {
|
||||
if (shouldDisableCurrentProjectTakeoverFromMasterMention(message.body)) {
|
||||
await updateProjectAgentControls(projectId, { takeoverEnabled: false }, session.account);
|
||||
const nextState = await readState();
|
||||
const nextProject = nextState.projects.find((item) => item.id === projectId) ?? project;
|
||||
replyMessage = await appendProjectMessage({
|
||||
projectId,
|
||||
account: session.account,
|
||||
sender: "master",
|
||||
senderLabel: "主 Agent",
|
||||
body: buildMasterMentionTakeoverDisabledReply(nextProject),
|
||||
@@ -605,6 +675,7 @@ export async function POST(
|
||||
});
|
||||
replyMessage = await appendProjectMessage({
|
||||
projectId,
|
||||
account: session.account,
|
||||
sender: "master",
|
||||
senderLabel: "主 Agent",
|
||||
body: buildMasterAgentProjectSummarySyncAck(project, {
|
||||
@@ -706,7 +777,7 @@ export async function POST(
|
||||
status: "queued",
|
||||
};
|
||||
}
|
||||
replyPresenter = relayViaMasterAgent ? "master" : "thread";
|
||||
replyPresenter = shouldRouteDirectControlViaMaster || relayViaMasterAgent ? "master" : "thread";
|
||||
} else {
|
||||
dispatchRecommendation = {
|
||||
ok: false,
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
import { AppShell, AuthForm, PageNav, StatusBar } from "@/components/app-ui";
|
||||
import { EnterpriseAdminLoginShell } from "@/components/enterprise-admin-login-shell";
|
||||
import { redirectIfAuthenticated } from "@/lib/boss-auth";
|
||||
|
||||
export default async function LoginPage() {
|
||||
await redirectIfAuthenticated();
|
||||
return (
|
||||
<AppShell bottomNav={false}>
|
||||
<StatusBar />
|
||||
<PageNav title="登录" rightLabel="帮助" rightHref="/auth/help" />
|
||||
<AuthForm
|
||||
mode="login"
|
||||
title="登录 Codex 协同"
|
||||
description="使用企业账号密码或验证码登录。"
|
||||
/>
|
||||
</AppShell>
|
||||
);
|
||||
return <EnterpriseAdminLoginShell />;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export default async function Home() {
|
||||
const host = headersList.get("host")?.split(":")[0];
|
||||
const session = await getCurrentPageSession();
|
||||
if (host === PLATFORM_ADMIN_HOST) {
|
||||
redirect(session ? "/admin" : "/auth/login");
|
||||
redirect(session ? "/admin-web/index.html" : "/auth/login");
|
||||
}
|
||||
redirect(session ? "/conversations" : "/auth/login");
|
||||
}
|
||||
|
||||
@@ -1,721 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Refine } from "@refinedev/core";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
ConfigProvider,
|
||||
Empty,
|
||||
Input,
|
||||
Statistic,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
theme,
|
||||
message,
|
||||
} from "antd";
|
||||
import zhCN from "antd/locale/zh_CN";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { AdminAccessPanel } from "@/components/admin/admin-access-panel";
|
||||
import { AdminSkillLifecyclePanel } from "@/components/admin/admin-skill-lifecycle-panel";
|
||||
import {
|
||||
type BossAdminOverview,
|
||||
createBossAdminDataProvider,
|
||||
} from "@/components/admin/boss-admin-data-provider";
|
||||
|
||||
type AdminRow = Record<string, unknown>;
|
||||
|
||||
type BossAdminAppProps = {
|
||||
initialOverview?: BossAdminOverview | null;
|
||||
};
|
||||
|
||||
type AdminSection = "dashboard" | "customers" | "permissions" | "governance";
|
||||
type RiskAction = "ack" | "resolve" | "create_repair_ticket" | "assign_owner" | "set_sla";
|
||||
|
||||
const resources = [
|
||||
{ name: "companies", list: "/admin#companies", meta: { label: "公司" } },
|
||||
{ name: "accounts", list: "/admin#accounts", meta: { label: "账号" } },
|
||||
{ name: "devices", list: "/admin#devices", meta: { label: "设备" } },
|
||||
{ name: "risks", list: "/admin#risks", meta: { label: "风险" } },
|
||||
{ name: "notifications", list: "/admin#notifications", meta: { label: "通知" } },
|
||||
{ name: "auditLogs", list: "/admin#auditLogs", meta: { label: "审计日志" } },
|
||||
];
|
||||
|
||||
const adminShell = "min-h-screen bg-[#F3F5F2] p-5 text-[#101814]";
|
||||
const adminChrome =
|
||||
"mx-auto grid min-h-[calc(100vh-40px)] max-w-[1680px] grid-cols-[248px_minmax(0,1fr)] overflow-hidden rounded-[30px] border border-[#E0E6E1] bg-white shadow-[0_32px_100px_rgba(22,37,28,0.10)]";
|
||||
const adminSidebar = "border-r border-[#E3E8E4] bg-[#FBFCFB] px-4 py-5";
|
||||
const adminHeader = "flex min-h-[86px] items-center border-b border-[#E3E8E4] bg-white px-7";
|
||||
const adminCardClass = "boss-admin-card border-[#E3E8E4] shadow-[0_14px_42px_rgba(20,35,25,0.045)]";
|
||||
const adminDense = "boss-admin-dense";
|
||||
|
||||
const navItems: Array<{
|
||||
key: AdminSection;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
marker: string;
|
||||
}> = [
|
||||
{ key: "dashboard", title: "平台运营驾驶舱", subtitle: "全局健康与待处理事项", marker: "D" },
|
||||
{ key: "customers", title: "客户与账号", subtitle: "公司、老板账号与子账号", marker: "C" },
|
||||
{ key: "permissions", title: "授权工作台", subtitle: "设备、项目与 Skill 权限", marker: "P" },
|
||||
{ key: "governance", title: "风险与治理", subtitle: "风险、SLA、Skill", marker: "R" },
|
||||
];
|
||||
|
||||
function text(value: unknown, fallback = "-") {
|
||||
if (value === null || value === undefined || value === "") return fallback;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function numberValue(value: unknown) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function rowId(row: AdminRow, index?: number) {
|
||||
return text(row.id ?? row.companyId ?? row.account ?? row.deviceId ?? row.riskId ?? row.auditId, String(index ?? 0));
|
||||
}
|
||||
|
||||
function statusTag(value: unknown) {
|
||||
const status = text(value, "unknown");
|
||||
const color =
|
||||
status === "online" || status === "active" || status === "healthy" || status === "completed"
|
||||
? "green"
|
||||
: status === "offline" || status === "disabled"
|
||||
? "default"
|
||||
: status === "failed" || status === "critical"
|
||||
? "red"
|
||||
: "orange";
|
||||
return <Tag color={color}>{status}</Tag>;
|
||||
}
|
||||
|
||||
function severityTag(value: unknown) {
|
||||
const severity = text(value, "info");
|
||||
const color = severity === "critical" || severity === "high" ? "red" : severity === "warning" || severity === "medium" ? "orange" : "blue";
|
||||
return <Tag color={color}>{severity}</Tag>;
|
||||
}
|
||||
|
||||
function riskTarget(row: AdminRow) {
|
||||
return text(row.target ?? row.deviceId ?? row.projectId ?? row.account ?? row.companyId);
|
||||
}
|
||||
|
||||
function sectionTitle(section: AdminSection) {
|
||||
return navItems.find((item) => item.key === section)?.title ?? "平台运营驾驶舱";
|
||||
}
|
||||
|
||||
function currentSubtitle(section: AdminSection) {
|
||||
return navItems.find((item) => item.key === section)?.subtitle ?? "全局健康与待处理事项";
|
||||
}
|
||||
|
||||
function customerHealthTone(company: AdminRow) {
|
||||
const riskCount = numberValue(company.openRiskCount);
|
||||
const deviceCount = numberValue(company.deviceCount);
|
||||
const onlineCount = numberValue(company.onlineDeviceCount);
|
||||
if (riskCount >= 3) return { label: "需介入", color: "red" };
|
||||
if (deviceCount > 0 && onlineCount === 0) return { label: "离线", color: "orange" };
|
||||
if (riskCount > 0) return { label: "观察", color: "gold" };
|
||||
return { label: "健康", color: "green" };
|
||||
}
|
||||
|
||||
const riskColumns: ColumnsType<AdminRow> = [
|
||||
{ title: "风险", dataIndex: "title", render: (_, row) => text(row.title ?? row.name ?? row.kind) },
|
||||
{ title: "级别", dataIndex: "severity", width: 104, render: severityTag },
|
||||
{ title: "对象", dataIndex: "target", width: 170, render: (_, row) => riskTarget(row) },
|
||||
{ title: "负责人", dataIndex: "ownerAccount", width: 150, render: (_, row) => text(row.ownerAccount, "未指派") },
|
||||
{ title: "SLA", dataIndex: "slaDueAt", width: 180, render: (_, row) => text(row.slaDueAt, "未设置") },
|
||||
{ title: "状态", dataIndex: "status", width: 104, render: statusTag },
|
||||
];
|
||||
|
||||
const deviceColumns: ColumnsType<AdminRow> = [
|
||||
{ title: "设备", dataIndex: "name", render: (_, row) => text(row.name ?? row.deviceName ?? row.deviceId ?? row.id) },
|
||||
{ title: "状态", dataIndex: "status", width: 105, render: (_, row) => statusTag(row.status ?? row.onlineStatus) },
|
||||
{ title: "GUI", dataIndex: "codexGuiOnline", width: 86, render: (_, row) => statusTag(row.codexGuiOnline ? "online" : "offline") },
|
||||
{ title: "CLI", dataIndex: "codexCliOnline", width: 86, render: (_, row) => statusTag(row.codexCliOnline ? "online" : "offline") },
|
||||
{ title: "风险", dataIndex: "openRiskCount", width: 86, render: numberValue },
|
||||
{ title: "最近心跳", dataIndex: "lastSeenAt", width: 210, render: (_, row) => text(row.lastSeenAt ?? row.updatedAt) },
|
||||
];
|
||||
|
||||
const companyColumns: ColumnsType<AdminRow> = [
|
||||
{ title: "公司", dataIndex: "name", render: (_, row) => text(row.name ?? row.companyName ?? row.companyId) },
|
||||
{ title: "健康", dataIndex: "health", width: 100, render: (_, row) => {
|
||||
const tone = customerHealthTone(row);
|
||||
return <Tag color={tone.color}>{tone.label}</Tag>;
|
||||
} },
|
||||
{ title: "账号", dataIndex: "accountCount", width: 86, render: numberValue },
|
||||
{ title: "在线设备", dataIndex: "onlineDeviceCount", width: 112, render: (_, row) => `${numberValue(row.onlineDeviceCount)}/${numberValue(row.deviceCount)}` },
|
||||
{ title: "开放风险", dataIndex: "openRiskCount", width: 104, render: numberValue },
|
||||
{ title: "客户成功", dataIndex: "successOwnerAccount", width: 150, render: (_, row) => text(row.successOwnerAccount, "未指派") },
|
||||
];
|
||||
|
||||
const accountColumns: ColumnsType<AdminRow> = [
|
||||
{ title: "账号", dataIndex: "account", render: (_, row) => text(row.account ?? row.phone ?? row.id) },
|
||||
{ title: "角色", dataIndex: "role", width: 130, render: statusTag },
|
||||
{ title: "公司", dataIndex: "companyName", render: (_, row) => text(row.companyName ?? row.companyId) },
|
||||
{ title: "状态", dataIndex: "status", width: 118, render: statusTag },
|
||||
{ title: "最近登录", dataIndex: "lastLoginAt", width: 210, render: (_, row) => text(row.lastLoginAt, "暂无") },
|
||||
];
|
||||
|
||||
const notificationColumns: ColumnsType<AdminRow> = [
|
||||
{ title: "通知", dataIndex: "title", render: (_, row) => text(row.title ?? row.kind) },
|
||||
{ title: "级别", dataIndex: "severity", width: 110, render: severityTag },
|
||||
{ title: "公司", dataIndex: "companyId", width: 150, render: (_, row) => text(row.companyId) },
|
||||
{ title: "风险", dataIndex: "riskId", width: 220, render: (_, row) => text(row.riskId) },
|
||||
{ title: "时间", dataIndex: "createdAt", width: 190, render: (_, row) => text(row.createdAt) },
|
||||
];
|
||||
|
||||
async function loadOverview() {
|
||||
const response = await fetch("/api/v1/admin/overview", {
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`后台总览读取失败:${response.status}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as BossAdminOverview;
|
||||
}
|
||||
|
||||
function MetricCard({
|
||||
title,
|
||||
value,
|
||||
tone = "default",
|
||||
hint,
|
||||
}: {
|
||||
title: string;
|
||||
value: number;
|
||||
tone?: "default" | "green" | "red" | "orange";
|
||||
hint?: string;
|
||||
}) {
|
||||
const valueColor = tone === "green" ? "#07A85A" : tone === "red" ? "#E23D3D" : tone === "orange" ? "#D97706" : "#101814";
|
||||
return (
|
||||
<Card className={adminCardClass}>
|
||||
<Statistic title={title} value={value} valueStyle={{ color: valueColor, fontWeight: 800 }} />
|
||||
{hint ? <div className="mt-2 text-xs text-[#758078]">{hint}</div> : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelTitle({ title, subtitle, extra }: { title: string; subtitle?: string; extra?: ReactNode }) {
|
||||
return (
|
||||
<div className="mb-4 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-[18px] font-black tracking-[-0.02em] text-[#101814]">{title}</div>
|
||||
{subtitle ? <div className="mt-1 text-sm text-[#68746D]">{subtitle}</div> : null}
|
||||
</div>
|
||||
{extra ? <div className="shrink-0">{extra}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyBlock({ textValue }: { textValue: string }) {
|
||||
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={textValue} />;
|
||||
}
|
||||
|
||||
type RiskActionsProps = {
|
||||
selectedRisk?: AdminRow;
|
||||
actionBusy: string;
|
||||
onSubmit: (risk: AdminRow, action: RiskAction, extraBody?: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
function RiskActionPanel({ selectedRisk, actionBusy, onSubmit }: RiskActionsProps) {
|
||||
const [ownerAccount, setOwnerAccount] = useState("");
|
||||
const [slaDueAt, setSlaDueAt] = useState("");
|
||||
const riskId = selectedRisk ? text(selectedRisk.riskId ?? selectedRisk.id, "") : "";
|
||||
const kind = selectedRisk ? text(selectedRisk.kind, "") : "";
|
||||
const canAckResolve = kind === "ops_fault" || kind === "thread_context_alert";
|
||||
const canCreateTicket = kind === "ops_fault";
|
||||
|
||||
if (!selectedRisk) {
|
||||
return (
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle title="处理面板" subtitle="选择左侧风险后,在这里指派负责人、设置 SLA 或创建修复工单。" />
|
||||
<EmptyBlock textValue="暂无选中风险" />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle title="处理面板" subtitle="所有动作都会写入风险时间线和权限审计。" />
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-[#E3E8E4] bg-[#F8FAF8] p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{severityTag(selectedRisk.severity)}
|
||||
<span className="font-bold text-[#101814]">{text(selectedRisk.title ?? selectedRisk.kind)}</span>
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-[#5F6B64]">{text(selectedRisk.detail ?? selectedRisk.summary, "暂无详情")}</div>
|
||||
<div className="mt-3 text-xs text-[#7B857E]">对象:{riskTarget(selectedRisk)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-bold text-[#68746D]">负责人账号</div>
|
||||
<div className="flex gap-2">
|
||||
<Input value={ownerAccount} onChange={(event) => setOwnerAccount(event.target.value)} placeholder="例如 ops@company.com" />
|
||||
<Button
|
||||
disabled={!canAckResolve || !ownerAccount.trim()}
|
||||
loading={actionBusy === `${riskId}:assign_owner`}
|
||||
onClick={() => onSubmit(selectedRisk, "assign_owner", { ownerAccount: ownerAccount.trim() })}
|
||||
>
|
||||
指派
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-bold text-[#68746D]">SLA 截止时间</div>
|
||||
<div className="flex gap-2">
|
||||
<Input value={slaDueAt} onChange={(event) => setSlaDueAt(event.target.value)} placeholder="2026-04-30T18:00:00+08:00" />
|
||||
<Button
|
||||
disabled={!canAckResolve || !slaDueAt.trim()}
|
||||
loading={actionBusy === `${riskId}:set_sla`}
|
||||
onClick={() => onSubmit(selectedRisk, "set_sla", { slaDueAt: slaDueAt.trim() })}
|
||||
>
|
||||
设置 SLA
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Button disabled={!canAckResolve} loading={actionBusy === `${riskId}:ack`} onClick={() => onSubmit(selectedRisk, "ack")}>
|
||||
确认
|
||||
</Button>
|
||||
<Button disabled={!canAckResolve} loading={actionBusy === `${riskId}:resolve`} onClick={() => onSubmit(selectedRisk, "resolve")}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!canCreateTicket}
|
||||
loading={actionBusy === `${riskId}:create_repair_ticket`}
|
||||
onClick={() => onSubmit(selectedRisk, "create_repair_ticket")}
|
||||
>
|
||||
工单
|
||||
</Button>
|
||||
</div>
|
||||
{!canAckResolve ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="该风险类型当前只读"
|
||||
description="当前动作接口暂不支持该风险类型,后台保留展示但不会假装处置成功。"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardView({
|
||||
stats,
|
||||
companies,
|
||||
devices,
|
||||
risks,
|
||||
notifications,
|
||||
timeline,
|
||||
onOpenRisk,
|
||||
}: {
|
||||
stats: AdminRow;
|
||||
companies: AdminRow[];
|
||||
devices: AdminRow[];
|
||||
risks: AdminRow[];
|
||||
notifications: AdminRow[];
|
||||
timeline: AdminRow[];
|
||||
onOpenRisk: () => void;
|
||||
}) {
|
||||
const topRisks = risks.slice(0, 5);
|
||||
const topCompanies = companies.slice().sort((left, right) => numberValue(right.openRiskCount) - numberValue(left.openRiskCount)).slice(0, 6);
|
||||
return (
|
||||
<div className={`${adminDense} space-y-5`}>
|
||||
<section>
|
||||
<PanelTitle title="今日待处理" subtitle="先看需要平台侧介入的客户、设备和主 Agent 风险。" />
|
||||
<div className="grid gap-4 lg:grid-cols-5">
|
||||
<MetricCard title="客户公司" value={numberValue(stats.companies ?? companies.length)} hint="当前纳入平台管理的公司" />
|
||||
<MetricCard title="账号" value={numberValue(stats.accounts)} hint="含最高管理员与客户账号" />
|
||||
<MetricCard title="在线设备" value={numberValue(stats.onlineDevices)} tone="green" hint={`总设备 ${numberValue(stats.devices ?? devices.length)}`} />
|
||||
<MetricCard title="开放风险" value={numberValue(stats.openRisks ?? risks.length)} tone="red" hint={`关键 ${numberValue(stats.criticalRisks)}`} />
|
||||
<MetricCard title="风险通知" value={numberValue(stats.openNotifications ?? notifications.length)} tone="orange" hint="SLA 与主动通知" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-[0.95fr_1.05fr]">
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle title="客户健康排行" subtitle="优先跟进开放风险最多或设备离线的客户。" />
|
||||
<div className="space-y-3">
|
||||
{topCompanies.length > 0 ? topCompanies.map((company) => {
|
||||
const tone = customerHealthTone(company);
|
||||
return (
|
||||
<div key={rowId(company)} className="flex items-center justify-between rounded-2xl border border-[#E3E8E4] bg-[#FBFCFB] px-4 py-3">
|
||||
<div>
|
||||
<div className="font-bold text-[#101814]">{text(company.name ?? company.companyName ?? company.companyId)}</div>
|
||||
<div className="mt-1 text-xs text-[#68746D]">
|
||||
账号 {numberValue(company.accountCount)} · 设备 {numberValue(company.onlineDeviceCount)}/{numberValue(company.deviceCount)} · 客户成功 {text(company.successOwnerAccount, "未指派")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag color={tone.color}>{tone.label}</Tag>
|
||||
<span className="text-sm font-bold text-[#E23D3D]">{numberValue(company.openRiskCount)} 风险</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}) : <EmptyBlock textValue="暂无客户数据" />}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle
|
||||
title="关键风险队列"
|
||||
subtitle="只展示最该处理的前几条,完整队列在风险与治理里。"
|
||||
extra={<Button onClick={onOpenRisk}>进入战情室</Button>}
|
||||
/>
|
||||
{topRisks.length > 0 ? (
|
||||
<Table rowKey={rowId} columns={riskColumns} dataSource={topRisks} pagination={false} size="small" />
|
||||
) : (
|
||||
<EmptyBlock textValue="暂无开放高优风险" />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-[1.05fr_0.95fr]">
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle title="节点健康" subtitle="集中查看客户电脑、Codex GUI/CLI 与最近心跳。" />
|
||||
<Table rowKey={rowId} columns={deviceColumns} dataSource={devices.slice(0, 8)} pagination={false} size="small" />
|
||||
</Card>
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle title="最近事件" subtitle="风险通知和处置时间线,避免平台侧漏跟进。" />
|
||||
<div className="space-y-3">
|
||||
{[...notifications, ...timeline].slice(0, 7).map((event, index) => (
|
||||
<div key={rowId(event, index)} className="rounded-2xl border border-[#E3E8E4] bg-[#FBFCFB] px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="font-bold text-[#101814]">{text(event.title ?? event.action ?? event.kind, "事件")}</div>
|
||||
{severityTag(event.severity ?? "info")}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-[#68746D]">{text(event.createdAt ?? event.updatedAt ?? event.time, "暂无时间")}</div>
|
||||
</div>
|
||||
))}
|
||||
{notifications.length === 0 && timeline.length === 0 ? <EmptyBlock textValue="暂无风险事件" /> : null}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomersView({ companies, accounts, devices }: { companies: AdminRow[]; accounts: AdminRow[]; devices: AdminRow[] }) {
|
||||
return (
|
||||
<div className={`${adminDense} space-y-5`}>
|
||||
<div className="grid gap-5 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle title="客户与账号" subtitle="先看客户公司,再进入账号、设备和权限配置。" />
|
||||
<Table rowKey={rowId} columns={companyColumns} dataSource={companies} pagination={{ pageSize: 8 }} size="small" />
|
||||
</Card>
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle title="客户开通任务流" subtitle="把公司、老板账号、设备和 Skill 权限串成一个交付动作。" />
|
||||
<div className="space-y-3">
|
||||
{["创建客户公司", "开通老板账号", "绑定客户电脑", "分配项目与 Skill 权限"].map((item, index) => (
|
||||
<div key={item} className="flex items-center gap-3 rounded-2xl border border-[#E3E8E4] bg-[#FBFCFB] px-4 py-3">
|
||||
<span className="grid size-8 place-items-center rounded-full bg-[#101814] text-xs font-black text-white">{index + 1}</span>
|
||||
<div>
|
||||
<div className="font-bold text-[#101814]">{item}</div>
|
||||
<div className="text-xs text-[#68746D]">当前仍复用下方授权工作台写入接口,先保证链路稳定。</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="grid gap-5 lg:grid-cols-[1fr_1fr]">
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle title="账号列表" subtitle="查看角色、状态、公司和最近登录。" />
|
||||
<Table rowKey={rowId} columns={accountColumns} dataSource={accounts} pagination={{ pageSize: 8 }} size="small" />
|
||||
</Card>
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle title="客户设备" subtitle="确认设备归属和在线状态。" />
|
||||
<Table rowKey={rowId} columns={deviceColumns} dataSource={devices} pagination={{ pageSize: 8 }} size="small" />
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionsView() {
|
||||
return (
|
||||
<div className={`${adminDense} space-y-5`}>
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle
|
||||
title="授权工作台"
|
||||
subtitle="按账号分配设备、项目与 Skill 权限;高危动作保留二次确认和审计。"
|
||||
/>
|
||||
<AdminAccessPanel className={adminDense} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GovernanceView({
|
||||
risks,
|
||||
notifications,
|
||||
selectedRisk,
|
||||
setSelectedRisk,
|
||||
actionBusy,
|
||||
submitRiskAction,
|
||||
}: {
|
||||
risks: AdminRow[];
|
||||
notifications: AdminRow[];
|
||||
selectedRisk?: AdminRow;
|
||||
setSelectedRisk: (risk?: AdminRow) => void;
|
||||
actionBusy: string;
|
||||
submitRiskAction: (risk: AdminRow, action: RiskAction, extraBody?: Record<string, unknown>) => void;
|
||||
}) {
|
||||
return (
|
||||
<Tabs
|
||||
className="boss-admin-governance-tabs"
|
||||
items={[
|
||||
{
|
||||
key: "risk",
|
||||
label: "风险战情室",
|
||||
children: (
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_420px]">
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle title="风险与治理" subtitle="按严重程度、客户影响、负责人和 SLA 推进处置。" />
|
||||
<Table
|
||||
rowKey={rowId}
|
||||
columns={riskColumns}
|
||||
dataSource={risks}
|
||||
pagination={{ pageSize: 8 }}
|
||||
size="small"
|
||||
onRow={(risk) => ({
|
||||
onClick: () => setSelectedRisk(risk),
|
||||
className: rowId(risk) === rowId(selectedRisk ?? {}) ? "cursor-pointer bg-[#F1FAF4]" : "cursor-pointer",
|
||||
})}
|
||||
/>
|
||||
</Card>
|
||||
<div className="space-y-5">
|
||||
<RiskActionPanel
|
||||
key={selectedRisk ? rowId(selectedRisk) : "empty-risk"}
|
||||
selectedRisk={selectedRisk}
|
||||
actionBusy={actionBusy}
|
||||
onSubmit={submitRiskAction}
|
||||
/>
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle title="风险通知" subtitle="SLA 扫描和主动通知生成的待跟进事项。" />
|
||||
<Table rowKey={rowId} columns={notificationColumns} dataSource={notifications} pagination={{ pageSize: 5 }} size="small" />
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "skills",
|
||||
label: "Skill 生命周期",
|
||||
children: (
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle title="Skill 生命周期" subtitle="安装、更新、卸载、回滚和版本锁定统一排队,设备端按安全策略执行。" />
|
||||
<AdminSkillLifecyclePanel className={adminDense} />
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function BossAdminApp({ initialOverview = null }: BossAdminAppProps) {
|
||||
const [overview, setOverview] = useState<BossAdminOverview | null>(initialOverview);
|
||||
const [error, setError] = useState("");
|
||||
const [actionBusy, setActionBusy] = useState("");
|
||||
const [activeSection, setActiveSection] = useState<AdminSection>("dashboard");
|
||||
const [selectedRiskId, setSelectedRiskId] = useState("");
|
||||
const [messageApi, messageContext] = message.useMessage();
|
||||
|
||||
useEffect(() => {
|
||||
if (overview) return;
|
||||
|
||||
let active = true;
|
||||
loadOverview()
|
||||
.then((nextOverview) => {
|
||||
if (active) setOverview(nextOverview);
|
||||
})
|
||||
.catch((nextError: Error) => {
|
||||
if (active) setError(nextError.message);
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [overview]);
|
||||
|
||||
const stats = overview?.summary ?? overview?.stats ?? {};
|
||||
const companies = overview?.companies ?? [];
|
||||
const accounts = overview?.accounts ?? [];
|
||||
const devices = overview?.devices ?? [];
|
||||
const risks = overview?.risks ?? [];
|
||||
const notifications = overview?.notifications ?? [];
|
||||
const timeline = Array.isArray((overview as { riskTimeline?: AdminRow[] } | null)?.riskTimeline)
|
||||
? ((overview as { riskTimeline?: AdminRow[] }).riskTimeline ?? [])
|
||||
: [];
|
||||
const selectedRisk = risks.find((risk) => rowId(risk) === selectedRiskId) ?? risks[0];
|
||||
|
||||
async function refreshOverview() {
|
||||
setOverview(await loadOverview());
|
||||
}
|
||||
|
||||
async function submitRiskAction(risk: AdminRow, action: RiskAction, extraBody: Record<string, unknown> = {}) {
|
||||
const riskId = text(risk.riskId ?? risk.id, "");
|
||||
if (!riskId) return;
|
||||
setActionBusy(`${riskId}:${action}`);
|
||||
setError("");
|
||||
try {
|
||||
const response = await fetch("/api/v1/admin/risks/actions", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ riskId, action, ...extraBody }),
|
||||
});
|
||||
const payload = (await response.json().catch(() => null)) as { ok?: boolean; message?: string } | null;
|
||||
if (!response.ok || payload?.ok === false) {
|
||||
throw new Error(payload?.message || `风险动作失败:${response.status}`);
|
||||
}
|
||||
messageApi.success(
|
||||
action === "ack"
|
||||
? "已确认风险"
|
||||
: action === "resolve"
|
||||
? "已关闭风险"
|
||||
: action === "assign_owner"
|
||||
? "已指派负责人"
|
||||
: action === "set_sla"
|
||||
? "已设置 SLA"
|
||||
: "已创建修复工单",
|
||||
);
|
||||
await refreshOverview();
|
||||
} catch (nextError) {
|
||||
const messageText = nextError instanceof Error ? nextError.message : "风险动作失败";
|
||||
setError(messageText);
|
||||
messageApi.error(messageText);
|
||||
} finally {
|
||||
setActionBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
function renderActiveSection() {
|
||||
if (activeSection === "customers") {
|
||||
return <CustomersView companies={companies} accounts={accounts} devices={devices} />;
|
||||
}
|
||||
if (activeSection === "permissions") {
|
||||
return <PermissionsView />;
|
||||
}
|
||||
if (activeSection === "governance") {
|
||||
return (
|
||||
<GovernanceView
|
||||
risks={risks}
|
||||
notifications={notifications}
|
||||
selectedRisk={selectedRisk}
|
||||
setSelectedRisk={(risk) => setSelectedRiskId(risk ? rowId(risk) : "")}
|
||||
actionBusy={actionBusy}
|
||||
submitRiskAction={(risk, action, extraBody) => void submitRiskAction(risk, action, extraBody)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DashboardView
|
||||
stats={stats}
|
||||
companies={companies}
|
||||
devices={devices}
|
||||
risks={risks}
|
||||
notifications={notifications}
|
||||
timeline={timeline}
|
||||
onOpenRisk={() => setActiveSection("governance")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
theme={{
|
||||
algorithm: theme.defaultAlgorithm,
|
||||
token: {
|
||||
colorPrimary: "#07C160",
|
||||
borderRadius: 14,
|
||||
fontFamily: '"PingFang SC", "Microsoft YaHei", sans-serif',
|
||||
colorBgLayout: "#F3F5F2",
|
||||
colorBorderSecondary: "#E3E8E4",
|
||||
},
|
||||
components: {
|
||||
Button: {
|
||||
controlHeight: 34,
|
||||
borderRadius: 10,
|
||||
},
|
||||
Card: {
|
||||
headerFontSize: 16,
|
||||
},
|
||||
Table: {
|
||||
headerBg: "#F7F8F7",
|
||||
cellPaddingBlock: 9,
|
||||
cellPaddingInline: 10,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Refine dataProvider={createBossAdminDataProvider(initialOverview ?? undefined)} resources={resources}>
|
||||
<main className={adminShell}>
|
||||
{messageContext}
|
||||
<div className={adminChrome}>
|
||||
<aside className={adminSidebar}>
|
||||
<div className="mb-7 px-2">
|
||||
<div className="text-[24px] font-black tracking-[-0.04em] text-[#101814]">Boss</div>
|
||||
<div className="mt-1 text-xs font-semibold text-[#7A857D]">To B 总后台</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{navItems.map((item) => {
|
||||
const active = activeSection === item.key;
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => setActiveSection(item.key)}
|
||||
className={[
|
||||
"flex w-full items-center gap-3 rounded-2xl px-3 py-3 text-left transition",
|
||||
active ? "bg-[#EAF8EF] text-[#075F31] shadow-[inset_0_0_0_1px_rgba(7,193,96,0.18)]" : "text-[#46524B] hover:bg-[#F2F5F2]",
|
||||
].join(" ")}
|
||||
>
|
||||
<span className={active ? "grid size-9 place-items-center rounded-xl bg-[#07C160] text-xs font-black text-white" : "grid size-9 place-items-center rounded-xl bg-white text-xs font-black text-[#7A857D] ring-1 ring-[#E3E8E4]"}>
|
||||
{item.marker}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-black">{item.title}</span>
|
||||
<span className="mt-0.5 block truncate text-xs opacity-70">{item.subtitle}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-8 rounded-2xl border border-[#E3E8E4] bg-white p-4">
|
||||
<div className="text-xs font-bold text-[#7A857D]">当前身份</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="grid size-8 place-items-center rounded-full bg-[#07C160] text-xs font-black text-white">k</span>
|
||||
<span className="text-sm font-bold text-[#101814]">highest_admin</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<section className="min-w-0 bg-[#F8FAF8]">
|
||||
<header className={adminHeader}>
|
||||
<div>
|
||||
<div className="text-[26px] font-black tracking-[-0.04em] text-[#101814]">{sectionTitle(activeSection)}</div>
|
||||
<div className="mt-1 text-sm text-[#68746D]">{currentSubtitle(activeSection)}</div>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<Button onClick={() => void refreshOverview()}>刷新</Button>
|
||||
<div className="rounded-full border border-[#E3E8E4] bg-white px-4 py-2 text-sm font-semibold text-[#4B5750]">
|
||||
平台最高管理员
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="p-7">
|
||||
{error ? <Alert className="mb-5" type="warning" showIcon message="后台数据暂不可用" description={error} /> : null}
|
||||
{renderActiveSection()}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</Refine>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import type {
|
||||
BaseRecord,
|
||||
CreateResponse,
|
||||
DataProvider,
|
||||
DeleteOneResponse,
|
||||
DeleteOneParams,
|
||||
GetListResponse,
|
||||
GetListParams,
|
||||
GetOneResponse,
|
||||
GetOneParams,
|
||||
CreateParams,
|
||||
UpdateParams,
|
||||
UpdateResponse,
|
||||
} from "@refinedev/core";
|
||||
|
||||
export type BossAdminSeverity = "critical" | "high" | "medium" | "low" | "info";
|
||||
|
||||
export type BossAdminOverview = {
|
||||
ok?: boolean;
|
||||
summary?: {
|
||||
companies?: number;
|
||||
accounts?: number;
|
||||
devices?: number;
|
||||
onlineDevices?: number;
|
||||
openRisks?: number;
|
||||
openNotifications?: number;
|
||||
criticalRisks?: number;
|
||||
};
|
||||
stats?: {
|
||||
companies?: number;
|
||||
accounts?: number;
|
||||
devices?: number;
|
||||
onlineDevices?: number;
|
||||
openRisks?: number;
|
||||
openNotifications?: number;
|
||||
criticalRisks?: number;
|
||||
};
|
||||
companies?: Array<Record<string, unknown>>;
|
||||
accounts?: Array<Record<string, unknown>>;
|
||||
devices?: Array<Record<string, unknown>>;
|
||||
risks?: Array<Record<string, unknown>>;
|
||||
notifications?: Array<Record<string, unknown>>;
|
||||
auditLogs?: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
const resourceKeys = new Set(["companies", "accounts", "devices", "risks", "notifications", "auditLogs"]);
|
||||
|
||||
async function fetchOverview() {
|
||||
const response = await fetch("/api/v1/admin/overview", {
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load admin overview: ${response.status}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as BossAdminOverview;
|
||||
}
|
||||
|
||||
function listFromOverview(overview: BossAdminOverview | undefined, resource: string) {
|
||||
if (!resourceKeys.has(resource)) return [];
|
||||
const value = overview?.[resource as keyof BossAdminOverview];
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function recordId(item: Record<string, unknown>, index: number) {
|
||||
return String(item.id ?? item.companyId ?? item.account ?? item.deviceId ?? item.riskId ?? item.auditId ?? index);
|
||||
}
|
||||
|
||||
export function createBossAdminDataProvider(initialOverview?: BossAdminOverview): DataProvider {
|
||||
let overviewCache = initialOverview;
|
||||
|
||||
return {
|
||||
getList: async <TData extends BaseRecord = BaseRecord>({ resource }: GetListParams) => {
|
||||
if (!overviewCache) {
|
||||
overviewCache = await fetchOverview();
|
||||
}
|
||||
|
||||
const data = listFromOverview(overviewCache, resource).map((item, index) => ({
|
||||
id: recordId(item, index),
|
||||
...item,
|
||||
})) as TData[];
|
||||
|
||||
return {
|
||||
data,
|
||||
total: data.length,
|
||||
} satisfies GetListResponse<TData>;
|
||||
},
|
||||
getOne: async <TData extends BaseRecord = BaseRecord>({ resource, id }: GetOneParams) => {
|
||||
if (!overviewCache) {
|
||||
overviewCache = await fetchOverview();
|
||||
}
|
||||
|
||||
const item = listFromOverview(overviewCache, resource).find((entry, index) => {
|
||||
return recordId(entry, index) === String(id);
|
||||
});
|
||||
|
||||
return {
|
||||
data: {
|
||||
id,
|
||||
...(item ?? {}),
|
||||
} as TData,
|
||||
} satisfies GetOneResponse<TData>;
|
||||
},
|
||||
create: async <TData extends BaseRecord = BaseRecord, TVariables = unknown>({
|
||||
variables,
|
||||
}: CreateParams<TVariables>) =>
|
||||
({ data: variables as unknown as TData }) satisfies CreateResponse<TData>,
|
||||
update: async <TData extends BaseRecord = BaseRecord, TVariables = unknown>({
|
||||
id,
|
||||
variables,
|
||||
}: UpdateParams<TVariables>) =>
|
||||
({ data: { id, ...(variables as BaseRecord) } as TData }) satisfies UpdateResponse<TData>,
|
||||
deleteOne: async <TData extends BaseRecord = BaseRecord, TVariables = unknown>({
|
||||
id,
|
||||
}: DeleteOneParams<TVariables>) =>
|
||||
({ data: { id } as TData }) satisfies DeleteOneResponse<TData>,
|
||||
getApiUrl: () => "/api/v1/admin/overview",
|
||||
};
|
||||
}
|
||||
@@ -123,7 +123,9 @@ async function waitForLoginSessionReady(nativeClient: boolean) {
|
||||
}
|
||||
|
||||
function resolvePostLoginPath() {
|
||||
return window.location.hostname === "admin.boss.hyzq.net" ? "/admin" : "/conversations";
|
||||
return window.location.hostname === "admin.boss.hyzq.net"
|
||||
? "/"
|
||||
: "/conversations";
|
||||
}
|
||||
|
||||
function navigateAfterLogin(router: ReturnType<typeof useRouter>) {
|
||||
|
||||
248
src/components/enterprise-admin-login-shell.tsx
Normal file
248
src/components/enterprise-admin-login-shell.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
isNativeBossApp,
|
||||
persistNativeSessionSnapshot,
|
||||
} from "@/lib/boss-app-client";
|
||||
|
||||
type LoginResult = {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
account?: string;
|
||||
displayName?: string;
|
||||
sessionExpiresAt?: string;
|
||||
restoreToken?: string;
|
||||
};
|
||||
|
||||
function resolvePostLoginPath() {
|
||||
return window.location.hostname === "admin.boss.hyzq.net"
|
||||
? "/"
|
||||
: "/conversations";
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function EnterpriseAdminLoginShell() {
|
||||
const router = useRouter();
|
||||
const [account, setAccount] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [remember, setRemember] = useState(true);
|
||||
const [message, setMessage] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function submit() {
|
||||
if (!account.trim() || !password) {
|
||||
setMessage("请填写账号和密码。");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setMessage("");
|
||||
const nativeClient = await isNativeBossApp();
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(nativeClient ? { "x-boss-native-app": "1" } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
account,
|
||||
password,
|
||||
method: "password",
|
||||
}),
|
||||
});
|
||||
|
||||
const result = (await response.json()) as LoginResult;
|
||||
if (!result.ok) {
|
||||
setMessage(result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
nativeClient &&
|
||||
result.restoreToken &&
|
||||
result.account &&
|
||||
result.displayName &&
|
||||
result.sessionExpiresAt
|
||||
) {
|
||||
await persistNativeSessionSnapshot({
|
||||
restoreToken: result.restoreToken,
|
||||
account: result.account,
|
||||
displayName: result.displayName,
|
||||
expiresAt: result.sessionExpiresAt,
|
||||
lastSyncedAt: new Date().toISOString(),
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
|
||||
await waitForLoginSessionReady(nativeClient);
|
||||
const targetPath = resolvePostLoginPath();
|
||||
router.replace(targetPath, { scroll: false });
|
||||
router.refresh();
|
||||
window.setTimeout(() => {
|
||||
if (window.location.pathname !== targetPath) {
|
||||
window.location.replace(targetPath);
|
||||
}
|
||||
}, 180);
|
||||
} catch {
|
||||
setMessage("登录链路发生异常,请重试。");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-[100dvh] bg-[#eef7f1] px-5 py-6 text-[#102418] md:px-10 md:py-10">
|
||||
<div className="mx-auto flex min-h-[calc(100dvh-48px)] max-w-6xl overflow-hidden rounded-[34px] border border-white/80 bg-white shadow-[0_24px_80px_rgba(16,36,24,0.12)]">
|
||||
<section className="relative hidden flex-1 flex-col justify-between overflow-hidden bg-[#e9f8f0] px-12 py-12 lg:flex">
|
||||
<div className="absolute -left-20 top-12 h-72 w-72 rounded-full bg-[#c8f5dd] blur-3xl" />
|
||||
<div className="absolute -bottom-24 right-4 h-80 w-80 rounded-full bg-white/70 blur-2xl" />
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-[#12c66a] text-[22px] font-black text-white shadow-[0_16px_36px_rgba(18,198,106,0.28)]">
|
||||
B
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[25px] font-black tracking-[-0.04em]">
|
||||
Boss 企业管理后台
|
||||
</div>
|
||||
<div className="mt-1 text-[14px] font-medium text-[#66746c]">
|
||||
统一管理企业账号、电脑节点、Skill 与风险
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 max-w-xl">
|
||||
<div className="inline-flex rounded-full border border-[#bfead1] bg-white/72 px-4 py-2 text-[13px] font-semibold text-[#0b8f4a]">
|
||||
平台级权限 · 企业账号 · 设备治理 · 风险审计
|
||||
</div>
|
||||
<h1 className="mt-7 text-[48px] font-black leading-[1.08] tracking-[-0.06em] text-[#102418]">
|
||||
企业级电脑与 Agent 统一治理入口
|
||||
</h1>
|
||||
<p className="mt-5 text-[17px] leading-8 text-[#607269]">
|
||||
面向 To B 交付场景,集中完成企业开通、账号授权、设备接入、Skill 分发与风险处置。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 grid max-w-xl grid-cols-3 gap-3">
|
||||
{["企业开通", "设备授权", "风险治理"].map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
className="rounded-2xl border border-white/80 bg-white/72 px-4 py-4 text-[15px] font-bold text-[#17372a] shadow-sm"
|
||||
>
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative rounded-3xl border border-white/80 bg-white/70 p-5 text-[13px] leading-6 text-[#617168]">
|
||||
仅限授权管理员访问。所有登录行为会进入审计链路,用于企业安全、客户成功和异常追踪。
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex w-full items-center justify-center px-5 py-8 md:px-10 lg:w-[470px]">
|
||||
<div className="w-full max-w-[390px]">
|
||||
<div className="mb-9 lg:hidden">
|
||||
<div className="flex h-13 w-13 items-center justify-center rounded-2xl bg-[#12c66a] text-[20px] font-black text-white">
|
||||
B
|
||||
</div>
|
||||
<h1 className="mt-5 text-[30px] font-black tracking-[-0.04em]">
|
||||
Boss 企业管理后台
|
||||
</h1>
|
||||
<p className="mt-2 text-[14px] leading-6 text-[#66746c]">
|
||||
统一管理企业账号、电脑节点、Skill 与风险
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[28px] border border-[#dfe9e3] bg-white p-6 shadow-[0_18px_48px_rgba(16,36,24,0.08)] md:p-8">
|
||||
<div>
|
||||
<div className="text-[28px] font-black tracking-[-0.04em]">
|
||||
登录企业后台
|
||||
</div>
|
||||
<p className="mt-2 text-[14px] leading-6 text-[#66746c]">
|
||||
仅限授权管理员访问
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="mt-8 space-y-5"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void submit();
|
||||
}}
|
||||
>
|
||||
<label className="block">
|
||||
<span className="text-[13px] font-bold text-[#42554a]">账号</span>
|
||||
<input
|
||||
value={account}
|
||||
onChange={(event) => setAccount(event.target.value)}
|
||||
placeholder="输入管理员账号"
|
||||
autoComplete="username"
|
||||
className="mt-2 h-13 w-full rounded-2xl border border-[#dfe9e3] bg-[#f8fbf9] px-4 text-[16px] text-[#102418] outline-none transition focus:border-[#12c66a] focus:bg-white focus:ring-4 focus:ring-[#12c66a]/10"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-[13px] font-bold text-[#42554a]">密码</span>
|
||||
<input
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="输入登录密码"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
className="mt-2 h-13 w-full rounded-2xl border border-[#dfe9e3] bg-[#f8fbf9] px-4 text-[16px] text-[#102418] outline-none transition focus:border-[#12c66a] focus:bg-white focus:ring-4 focus:ring-[#12c66a]/10"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label className="flex cursor-pointer items-center gap-2 text-[13px] font-semibold text-[#526258]">
|
||||
<input
|
||||
checked={remember}
|
||||
onChange={(event) => setRemember(event.target.checked)}
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-[#12c66a]"
|
||||
/>
|
||||
记住登录状态
|
||||
</label>
|
||||
<span className="text-[12px] text-[#8b9990]">HTTPS 安全会话</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="flex h-13 w-full items-center justify-center rounded-2xl bg-[#12c66a] text-[16px] font-black text-white shadow-[0_16px_32px_rgba(18,198,106,0.22)] transition hover:bg-[#0fb85f] disabled:cursor-not-allowed disabled:bg-[#9adfba]"
|
||||
>
|
||||
{submitting ? "登录中..." : "登录"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{message ? (
|
||||
<div className="mt-5 rounded-2xl border border-[#bfead1] bg-[#eefaf3] px-4 py-3 text-[13px] leading-6 text-[#1c6b3e]">
|
||||
{message}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-2xl border border-[#e0e9e4] bg-white/74 px-4 py-4 text-[12px] leading-6 text-[#6f7d75]">
|
||||
登录代表你正在访问企业级管理后台。请确认账号权限来自企业或平台管理员授权。
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
97
src/lib/boss-agent-ota.ts
Normal file
97
src/lib/boss-agent-ota.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export interface PublishedBossAgentOtaAsset {
|
||||
absolutePath: string;
|
||||
version: string;
|
||||
fileName: string;
|
||||
sizeBytes: number;
|
||||
sha256: string;
|
||||
updatedAt: string;
|
||||
downloadUrl: string;
|
||||
packageType: "boss_agent_macos";
|
||||
}
|
||||
|
||||
const BOSS_AGENT_OTA_PACKAGE_FILE_NAME = "boss-agent-mac-latest.zip";
|
||||
const BOSS_AGENT_OTA_META_FILE_NAME = "boss-agent-mac-latest.json";
|
||||
const BOSS_AGENT_OTA_DOWNLOAD_URL = "/api/v1/boss-agent/ota/package";
|
||||
|
||||
function detectRuntimeRoot(startDir: string) {
|
||||
let current = startDir;
|
||||
while (true) {
|
||||
if (existsSync(path.join(current, "package.json")) && existsSync(path.join(current, "src", "app"))) {
|
||||
return current;
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) {
|
||||
return startDir;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRuntimeRoot() {
|
||||
if (process.env.BOSS_RUNTIME_ROOT?.trim()) {
|
||||
return path.resolve(process.env.BOSS_RUNTIME_ROOT);
|
||||
}
|
||||
if (process.env.BOSS_STATE_FILE?.trim()) {
|
||||
return path.dirname(path.dirname(path.resolve(process.env.BOSS_STATE_FILE)));
|
||||
}
|
||||
return detectRuntimeRoot(/* turbopackIgnore: true */ process.cwd());
|
||||
}
|
||||
|
||||
const runtimeRoot = resolveRuntimeRoot();
|
||||
|
||||
function otaPublicDir() {
|
||||
return path.join(runtimeRoot, "public", "downloads");
|
||||
}
|
||||
|
||||
function packagePath() {
|
||||
return path.join(otaPublicDir(), BOSS_AGENT_OTA_PACKAGE_FILE_NAME);
|
||||
}
|
||||
|
||||
function metaPath() {
|
||||
return path.join(otaPublicDir(), BOSS_AGENT_OTA_META_FILE_NAME);
|
||||
}
|
||||
|
||||
function inferVersionFromFile(filePath: string, updatedAt: string) {
|
||||
const versionMatch = path.basename(filePath).match(/boss-agent-mac-runtime-([0-9A-Za-z._-]+)\.zip/i);
|
||||
if (versionMatch?.[1]) return versionMatch[1];
|
||||
return updatedAt.replace(/[-:TZ.]/g, "").slice(0, 14) || "latest";
|
||||
}
|
||||
|
||||
export async function getPublishedBossAgentOtaAsset(): Promise<PublishedBossAgentOtaAsset | null> {
|
||||
const archivePath = packagePath();
|
||||
if (!existsSync(archivePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stat = await fs.stat(archivePath);
|
||||
const content = await fs.readFile(archivePath);
|
||||
const fallbackUpdatedAt = stat.mtime.toISOString();
|
||||
const fallbackSha256 = createHash("sha256").update(content).digest("hex");
|
||||
let meta: Partial<PublishedBossAgentOtaAsset> & { urlPath?: string } = {};
|
||||
|
||||
if (existsSync(metaPath())) {
|
||||
try {
|
||||
meta = JSON.parse(await fs.readFile(metaPath(), "utf8")) as Partial<PublishedBossAgentOtaAsset> & {
|
||||
urlPath?: string;
|
||||
};
|
||||
} catch {
|
||||
meta = {};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
absolutePath: archivePath,
|
||||
version: meta.version ?? inferVersionFromFile(meta.fileName ?? archivePath, meta.updatedAt ?? fallbackUpdatedAt),
|
||||
fileName: meta.fileName ?? BOSS_AGENT_OTA_PACKAGE_FILE_NAME,
|
||||
sizeBytes: meta.sizeBytes ?? stat.size,
|
||||
sha256: meta.sha256 ?? fallbackSha256,
|
||||
updatedAt: meta.updatedAt ?? fallbackUpdatedAt,
|
||||
downloadUrl: meta.downloadUrl ?? meta.urlPath ?? BOSS_AGENT_OTA_DOWNLOAD_URL,
|
||||
packageType: "boss_agent_macos",
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { NextRequest, NextResponse } from "next/server";
|
||||
import { getAuthSession } from "@/lib/boss-data";
|
||||
|
||||
export const AUTH_SESSION_COOKIE = "boss_session";
|
||||
const PLATFORM_ADMIN_HOST = "admin.boss.hyzq.net";
|
||||
|
||||
async function currentHost() {
|
||||
return (await headers()).get("host")?.split(":")[0] ?? "";
|
||||
}
|
||||
|
||||
function shouldUseSecureCookie(request?: NextRequest) {
|
||||
const forwardedProto = request?.headers.get("x-forwarded-proto");
|
||||
@@ -26,6 +31,9 @@ export async function requirePageSession() {
|
||||
export async function redirectIfAuthenticated() {
|
||||
const session = await getCurrentPageSession();
|
||||
if (session) {
|
||||
if ((await currentHost()) === PLATFORM_ADMIN_HOST) {
|
||||
redirect("/");
|
||||
}
|
||||
redirect("/conversations");
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { getDevice, readState, verifyDeviceToken } from "@/lib/boss-data";
|
||||
import { getDevice, isDeviceRevoked, readState, verifyDeviceToken } from "@/lib/boss-data";
|
||||
import { canAccessDevice } from "@/lib/boss-permissions";
|
||||
|
||||
export async function authorizeDeviceWriteRequest(
|
||||
@@ -8,6 +8,13 @@ export async function authorizeDeviceWriteRequest(
|
||||
deviceId: string,
|
||||
) {
|
||||
const device = await getDevice(deviceId);
|
||||
if (isDeviceRevoked(device)) {
|
||||
return {
|
||||
ok: false as const,
|
||||
device,
|
||||
principal: null,
|
||||
};
|
||||
}
|
||||
const session = await requireRequestSession(request);
|
||||
|
||||
if (device && session) {
|
||||
|
||||
@@ -26,8 +26,6 @@ import type {
|
||||
AiProvider,
|
||||
AuthRole,
|
||||
BossPermission,
|
||||
ComputerControlIntentCategory,
|
||||
ComputerControlRiskLevel,
|
||||
DispatchPlanTarget,
|
||||
ExternalReplyTarget,
|
||||
Project,
|
||||
@@ -61,6 +59,9 @@ import {
|
||||
getUserMasterPromptView,
|
||||
listUserMasterMemoriesView,
|
||||
} from "@/lib/boss-projections";
|
||||
import {
|
||||
classifyMasterAgentControlIntent,
|
||||
} from "@/lib/master-agent-intent-router";
|
||||
|
||||
type MasterAgentReplyState = "queued" | "running" | "completed";
|
||||
const OPENAI_MASTER_AGENT_DEVICE_ID = "master-agent-openai";
|
||||
@@ -222,99 +223,8 @@ export function buildAuthorizedMasterAgentPromptForTest(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export interface MasterAgentControlIntentClassification {
|
||||
intentCategory: ComputerControlIntentCategory;
|
||||
executionMode: "discussion" | "thread" | "development" | "browser" | "desktop";
|
||||
riskLevel: ComputerControlRiskLevel;
|
||||
}
|
||||
|
||||
function includesAny(text: string, keywords: string[]) {
|
||||
return keywords.some((keyword) => text.includes(keyword));
|
||||
}
|
||||
|
||||
export function classifyMasterAgentControlIntent(
|
||||
requestText: string,
|
||||
): MasterAgentControlIntentClassification {
|
||||
const normalized = requestText.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return {
|
||||
intentCategory: "discussion_only",
|
||||
executionMode: "discussion",
|
||||
riskLevel: "low",
|
||||
};
|
||||
}
|
||||
|
||||
const browserSignals = [
|
||||
"chrome",
|
||||
"浏览器",
|
||||
"网页",
|
||||
"网站",
|
||||
"表单",
|
||||
"登录网站",
|
||||
"打开网站",
|
||||
"打开后台",
|
||||
"打开页面",
|
||||
"提交表单",
|
||||
];
|
||||
const desktopSignals = [
|
||||
"桌面",
|
||||
"系统设置",
|
||||
"finder",
|
||||
"微信",
|
||||
"飞书",
|
||||
"telegram",
|
||||
"打开应用",
|
||||
"打开软件",
|
||||
"app",
|
||||
"应用",
|
||||
"窗口",
|
||||
];
|
||||
const developmentSignals = [
|
||||
"开发",
|
||||
"改代码",
|
||||
"修复",
|
||||
"跑测试",
|
||||
"联调",
|
||||
"实现",
|
||||
"提交",
|
||||
"构建",
|
||||
"回归测试",
|
||||
"debug",
|
||||
"编译",
|
||||
];
|
||||
|
||||
if (includesAny(normalized, browserSignals)) {
|
||||
return {
|
||||
intentCategory: "browser_control",
|
||||
executionMode: "browser",
|
||||
riskLevel: "medium",
|
||||
};
|
||||
}
|
||||
|
||||
if (includesAny(normalized, desktopSignals)) {
|
||||
return {
|
||||
intentCategory: "desktop_control",
|
||||
executionMode: "desktop",
|
||||
riskLevel: "medium",
|
||||
};
|
||||
}
|
||||
|
||||
if (includesAny(normalized, developmentSignals)) {
|
||||
return {
|
||||
intentCategory: "project_development",
|
||||
executionMode: "development",
|
||||
riskLevel: "medium",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
intentCategory: "discussion_only",
|
||||
executionMode: "discussion",
|
||||
riskLevel: "low",
|
||||
};
|
||||
}
|
||||
|
||||
export const classifyMasterAgentControlIntentForTesting = classifyMasterAgentControlIntent;
|
||||
export { classifyMasterAgentControlIntent };
|
||||
|
||||
type ControlTargetDeviceInput = {
|
||||
replyProjectId: string;
|
||||
@@ -2078,6 +1988,7 @@ async function resolveMasterNodeExecutionCandidate(params: {
|
||||
async function replyViaOpenAiAccount(params: {
|
||||
account: AiAccount;
|
||||
requestText: string;
|
||||
requestedByAccount: string;
|
||||
projectId?: string;
|
||||
currentSessionExpiresAt?: string;
|
||||
senderLabel: string;
|
||||
@@ -2116,6 +2027,7 @@ async function replyViaOpenAiAccount(params: {
|
||||
generated.content,
|
||||
params.senderLabel,
|
||||
params.projectId,
|
||||
params.requestedByAccount,
|
||||
);
|
||||
await updateAiAccountHealth({
|
||||
accountId: params.account.accountId,
|
||||
@@ -2668,9 +2580,15 @@ export async function probeOpenAiApiAccount(params: { apiKey: string; model?: st
|
||||
});
|
||||
}
|
||||
|
||||
async function appendMasterAgentSystemReply(body: string, senderLabel = "主 Agent", projectId = "master-agent") {
|
||||
async function appendMasterAgentSystemReply(
|
||||
body: string,
|
||||
senderLabel = "主 Agent",
|
||||
projectId = "master-agent",
|
||||
account?: string,
|
||||
) {
|
||||
return appendProjectMessage({
|
||||
projectId,
|
||||
account,
|
||||
sender: "master",
|
||||
senderLabel,
|
||||
body,
|
||||
@@ -2736,7 +2654,12 @@ async function replyViaClawBackend(params: {
|
||||
});
|
||||
|
||||
if (result.status === "completed") {
|
||||
await appendMasterAgentSystemReply(result.output, "主 Agent · Claw Runtime", params.projectId);
|
||||
await appendMasterAgentSystemReply(
|
||||
result.output,
|
||||
"主 Agent · Claw Runtime",
|
||||
params.projectId,
|
||||
params.requestedByAccount,
|
||||
);
|
||||
return {
|
||||
ok: true as const,
|
||||
accountId: CLAW_BACKEND_ID,
|
||||
@@ -3672,6 +3595,7 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
"我已经收到你的消息,但当前没有可用的主控 AI 账号。请到“我的 > AI 账号”至少配置一个可用的 API 链路,或接回 Master Codex Node 后,再继续对话。",
|
||||
"主 Agent",
|
||||
replyProjectId,
|
||||
params.requestedByAccount,
|
||||
);
|
||||
return { ok: false as const, reason: "NO_AI_ACCOUNT" };
|
||||
}
|
||||
@@ -3700,6 +3624,7 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
"这个会话还没有授权给当前账号,主 Agent 不能读取或接管它。请让超级管理员先分配项目权限。",
|
||||
"主 Agent",
|
||||
replyProjectId,
|
||||
params.requestedByAccount,
|
||||
);
|
||||
return { ok: false as const, reason: "FORBIDDEN" };
|
||||
}
|
||||
@@ -3810,6 +3735,7 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
localFastReply.replyBody,
|
||||
localFastReply.senderLabel,
|
||||
replyProjectId,
|
||||
params.requestedByAccount,
|
||||
);
|
||||
return {
|
||||
replyMessage,
|
||||
@@ -3846,6 +3772,8 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
controlIntent.intentCategory === "browser_control"
|
||||
? "browser-automation-runtime"
|
||||
: "computer-use-runtime",
|
||||
controlPlatform: controlIntent.platform,
|
||||
computerUseProvider: controlIntent.recommendedProvider,
|
||||
riskLevel: controlIntent.riskLevel,
|
||||
confirmationPolicy: controlIntent.riskLevel === "high" ? "strong_confirm" : "light_confirm",
|
||||
requiresUserConfirmation: false,
|
||||
@@ -3879,6 +3807,7 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
].join(""),
|
||||
`主 Agent · ${runtime.summary.roleLabel}`,
|
||||
replyProjectId,
|
||||
params.requestedByAccount,
|
||||
);
|
||||
return { ok: false as const, reason: "MASTER_NODE_NOT_CONNECTED" };
|
||||
}
|
||||
@@ -3902,6 +3831,7 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
`主 GPT 不在手机里直接登录。当前绑定设备 ${boundNodeLabel}${boundDevice ? " 不在线" : " 未找到"},主 Agent 暂时无法通过这台设备对话。请先在该设备上登录 Codex / ChatGPT Plus,并确保 local-agent 在线后再重试。`,
|
||||
`主 Agent · ${selectedMasterAccount.label || runtime.summary.roleLabel}`,
|
||||
replyProjectId,
|
||||
params.requestedByAccount,
|
||||
);
|
||||
return { ok: false as const, reason: "MASTER_NODE_OFFLINE" };
|
||||
}
|
||||
@@ -3943,6 +3873,8 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
...masterTaskAuthorization(["master_agent.ask", "computer.control"]),
|
||||
intentCategory: controlIntent.intentCategory,
|
||||
runtimeKind,
|
||||
controlPlatform: controlIntent.platform,
|
||||
computerUseProvider: controlIntent.recommendedProvider,
|
||||
riskLevel: controlIntent.riskLevel,
|
||||
confirmationPolicy: controlIntent.riskLevel === "high" ? "strong_confirm" : "light_confirm",
|
||||
requiresUserConfirmation: false,
|
||||
@@ -4130,6 +4062,7 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
`我已经收到你的消息,但 Claw Runtime 当前执行失败:${clawReply.message}。请检查 Claw 可执行入口,或先切回其他主控后再试。`,
|
||||
"主 Agent · Claw Runtime",
|
||||
replyProjectId,
|
||||
params.requestedByAccount,
|
||||
);
|
||||
return clawReply;
|
||||
}
|
||||
@@ -4146,6 +4079,7 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
const reply = await replyViaOpenAiAccount({
|
||||
account: candidate.account,
|
||||
requestText: params.requestText,
|
||||
requestedByAccount: params.requestedByAccount,
|
||||
projectId: replyProjectId,
|
||||
currentSessionExpiresAt: params.currentSessionExpiresAt,
|
||||
senderLabel: `主 Agent · ${candidate.model}`,
|
||||
@@ -4187,6 +4121,7 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
].join(""),
|
||||
`主 Agent · ${lastFailedAccount?.label || runtime.summary.roleLabel}`,
|
||||
replyProjectId,
|
||||
params.requestedByAccount,
|
||||
);
|
||||
return { ok: false as const, reason: "MODEL_CALL_FAILED", message: lastApiFailureMessage };
|
||||
}
|
||||
@@ -4200,6 +4135,7 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
].join(""),
|
||||
`主 Agent · ${runtime.summary.roleLabel}`,
|
||||
replyProjectId,
|
||||
params.requestedByAccount,
|
||||
);
|
||||
return { ok: false as const, reason: "MASTER_NODE_NOT_CONNECTED" };
|
||||
}
|
||||
|
||||
@@ -20,6 +20,19 @@ function permissionSetIncludes(permissions: BossPermission[], required: BossPerm
|
||||
return permissions.includes(required);
|
||||
}
|
||||
|
||||
function accountHasActiveProjectPermission(
|
||||
state: BossState,
|
||||
account: string,
|
||||
permission: BossPermission,
|
||||
) {
|
||||
return state.accountProjectGrants.some(
|
||||
(grant) =>
|
||||
grant.account === account &&
|
||||
!isExpired(grant.expiresAt) &&
|
||||
permissionSetIncludes(grant.permissions, permission),
|
||||
);
|
||||
}
|
||||
|
||||
function projectUsesDevice(project: Project, deviceId: string) {
|
||||
if (project.deviceIds.includes(deviceId)) return true;
|
||||
return project.groupMembers.some((member) => member.deviceId === deviceId);
|
||||
@@ -92,6 +105,15 @@ export function canAccessProject(
|
||||
if (isHighestAdmin(session)) return true;
|
||||
const project = state.projects.find((item) => item.id === projectId);
|
||||
if (!project) return false;
|
||||
|
||||
if (
|
||||
projectId === "master-agent" &&
|
||||
(permission === "project.view" || permission === "master_agent.ask") &&
|
||||
accountHasActiveProjectPermission(state, session.account, "master_agent.ask")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!tenantAllowsCompanies(state, session, projectCompanyIds(state, project))) return false;
|
||||
|
||||
const directProjectGrant = state.accountProjectGrants.some(
|
||||
|
||||
@@ -862,13 +862,18 @@ function stateForSession(state: BossState, session: PermissionSession): BossStat
|
||||
deviceIds: project.deviceIds.filter((deviceId) => visibleDeviceIds.has(deviceId)),
|
||||
groupMembers: project.groupMembers.filter((member) => visibleDeviceIds.has(member.deviceId)),
|
||||
}));
|
||||
const visibleProjectIds = new Set(visibleProjects.map((project) => project.id));
|
||||
const scopedVisibleProjects = visibleProjects.map((project) =>
|
||||
project.id === "master-agent" && session.role !== "highest_admin"
|
||||
? projectWithAccountScopedMasterMessages(project, session.account)
|
||||
: project,
|
||||
);
|
||||
const visibleProjectIds = new Set(scopedVisibleProjects.map((project) => project.id));
|
||||
const canSeeThreadOnDevice = (projectId: string, deviceId: string) =>
|
||||
visibleProjectIds.has(projectId) && visibleDeviceIds.has(deviceId);
|
||||
return {
|
||||
...state,
|
||||
devices: visibleDevices,
|
||||
projects: visibleProjects,
|
||||
projects: scopedVisibleProjects,
|
||||
deviceSkills: state.deviceSkills.filter((skill) =>
|
||||
visibleDeviceIds.has(skill.deviceId) &&
|
||||
(session.role === "highest_admin" ||
|
||||
@@ -921,6 +926,20 @@ function stateForSession(state: BossState, session: PermissionSession): BossStat
|
||||
};
|
||||
}
|
||||
|
||||
function projectWithAccountScopedMasterMessages(project: Project, account: string): Project {
|
||||
const messages = project.messages.filter((message) => message.account === account);
|
||||
const latestMessage = [...messages].sort(
|
||||
(left, right) => Date.parse(right.sentAt) - Date.parse(left.sentAt),
|
||||
)[0];
|
||||
return {
|
||||
...project,
|
||||
messages,
|
||||
preview: latestMessage?.body ?? "",
|
||||
lastMessageAt: latestMessage?.sentAt ?? project.updatedAt,
|
||||
unreadCount: messages.filter((message) => message.sender !== "user").length,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAuthorizedStateSnapshot(
|
||||
state: BossState,
|
||||
session: Pick<AuthSession, "account" | "role" | "displayName">,
|
||||
@@ -1161,9 +1180,13 @@ export function buildProjectMessagesRealtimePayloadForSession(
|
||||
if (!project) {
|
||||
return null;
|
||||
}
|
||||
const scopedProject =
|
||||
project.id === "master-agent" && session.role !== "highest_admin"
|
||||
? projectWithAccountScopedMasterMessages(cloneProjectWithDisplayTitles(project), session.account)
|
||||
: cloneProjectWithDisplayTitles(project);
|
||||
return {
|
||||
ok: true,
|
||||
project: cloneProjectWithDisplayTitles(project),
|
||||
project: scopedProject,
|
||||
devices: filterProjectDevicesForSession(state, session, project),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
AuthAccount,
|
||||
BossState,
|
||||
Device,
|
||||
AppLogEntry,
|
||||
OpsFault,
|
||||
OpsSeverity,
|
||||
ThreadContextAlert,
|
||||
@@ -71,6 +72,77 @@ function buildBody(summary: string, slaDueAt: string) {
|
||||
return `${summary || "风险已超过 SLA,需要平台协助跟进"};SLA 截止 ${slaDueAt}`;
|
||||
}
|
||||
|
||||
function safeIdSegment(value: string) {
|
||||
return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unknown";
|
||||
}
|
||||
|
||||
function faultIdFor(kind: string, deviceId: string, suffix = "") {
|
||||
return ["fault", safeIdSegment(kind), safeIdSegment(deviceId), safeIdSegment(suffix)].filter(Boolean).join("-");
|
||||
}
|
||||
|
||||
function latestLogsByDevice(logs: AppLogEntry[], category: string) {
|
||||
const byDevice = new Map<string, AppLogEntry>();
|
||||
for (const log of logs) {
|
||||
if (log.category !== category) continue;
|
||||
const existing = byDevice.get(log.deviceId);
|
||||
if (!existing || log.createdAt.localeCompare(existing.createdAt) > 0) {
|
||||
byDevice.set(log.deviceId, log);
|
||||
}
|
||||
}
|
||||
return byDevice;
|
||||
}
|
||||
|
||||
export function buildOperationalRiskFaultDrafts(
|
||||
state: BossState,
|
||||
now: Date = new Date(),
|
||||
): OpsFault[] {
|
||||
const createdAt = now.toISOString();
|
||||
const drafts: OpsFault[] = [];
|
||||
const otaFailureLogs = latestLogsByDevice(state.appLogs, "local_agent.boss_agent_ota_failed");
|
||||
|
||||
for (const device of state.devices) {
|
||||
if (device.status === "online" && device.capabilities?.computerUse?.connected === false) {
|
||||
drafts.push({
|
||||
faultId: faultIdFor("computer-use-unavailable", device.id),
|
||||
faultKey: "BOSS.COMPUTER_USE.UNAVAILABLE",
|
||||
severity: "warning",
|
||||
status: "opened",
|
||||
nodeId: device.id,
|
||||
serviceName: "computer-use",
|
||||
traceId: `capability:${device.id}:computerUse`,
|
||||
runbookId: "runbook-computer-use-permission",
|
||||
firstSeenAt: createdAt,
|
||||
lastSeenAt: device.capabilities.computerUse.lastSeenAt ?? device.lastSeenAt ?? createdAt,
|
||||
summary: `${device.name} 已在线,但 Computer Use 能力不可用,远程桌面控制会降级或失败。`,
|
||||
suggestedNextAction: "检查 boss-agent 本机权限、Codex Computer Use、CUA fallback 和本机 runtime 配置。",
|
||||
autoRepairable: false,
|
||||
});
|
||||
}
|
||||
|
||||
const otaLog = otaFailureLogs.get(device.id);
|
||||
if (otaLog) {
|
||||
drafts.push({
|
||||
faultId: faultIdFor("boss-agent-ota-failed", device.id),
|
||||
faultKey: "BOSS_AGENT.OTA.FAILED",
|
||||
severity: "warning",
|
||||
status: "opened",
|
||||
nodeId: device.id,
|
||||
serviceName: "boss-agent-ota",
|
||||
projectId: otaLog.projectId,
|
||||
traceId: otaLog.logId,
|
||||
runbookId: "runbook-boss-agent-ota",
|
||||
firstSeenAt: otaLog.createdAt,
|
||||
lastSeenAt: otaLog.createdAt,
|
||||
summary: otaLog.message || "boss-agent OTA 更新失败",
|
||||
suggestedNextAction: otaLog.detail || "检查 OTA 包 sha256、下载链路、安装脚本和设备绑定配置。",
|
||||
autoRepairable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return drafts;
|
||||
}
|
||||
|
||||
export function buildRiskSlaNotificationDrafts(
|
||||
state: BossState,
|
||||
now: Date = new Date(),
|
||||
|
||||
219
src/lib/boss-state-backups.ts
Normal file
219
src/lib/boss-state-backups.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { readState, writeState, type BossState } from "@/lib/boss-data";
|
||||
|
||||
export interface BossStateBackupSnapshot {
|
||||
snapshotId: string;
|
||||
fileName: string;
|
||||
absolutePath: string;
|
||||
bytes: number;
|
||||
sha256: string;
|
||||
createdAt: string;
|
||||
actorAccount?: string;
|
||||
reason?: string;
|
||||
schemaVersion?: number;
|
||||
}
|
||||
|
||||
export interface BossStateBackupStatus {
|
||||
mode: "file";
|
||||
backupDir: string;
|
||||
stateFile: string;
|
||||
restorePointCount: number;
|
||||
lastBackupAt?: string;
|
||||
status: "ready" | "empty" | "error";
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
function stateFilePath() {
|
||||
const configuredStateFile = process.env.BOSS_STATE_FILE?.trim();
|
||||
if (configuredStateFile) {
|
||||
return path.resolve(configuredStateFile);
|
||||
}
|
||||
const runtimeRoot = process.env.BOSS_RUNTIME_ROOT?.trim();
|
||||
if (runtimeRoot) {
|
||||
return path.resolve(runtimeRoot, "data", "boss-state.json");
|
||||
}
|
||||
return path.join(process.cwd(), "data", "boss-state.json");
|
||||
}
|
||||
|
||||
function backupDirPath() {
|
||||
const configuredBackupDir = process.env.BOSS_STATE_BACKUP_DIR?.trim();
|
||||
if (configuredBackupDir) {
|
||||
return path.resolve(configuredBackupDir);
|
||||
}
|
||||
return path.join(path.dirname(stateFilePath()), "backups");
|
||||
}
|
||||
|
||||
function timestampSegment() {
|
||||
return new Date().toISOString().replace(/[:.]/g, "-");
|
||||
}
|
||||
|
||||
function snapshotIdFor(createdAtSegment: string, text: string) {
|
||||
const digest = createHash("sha256")
|
||||
.update(text)
|
||||
.update(randomBytes(6))
|
||||
.digest("hex")
|
||||
.slice(0, 12);
|
||||
return `state-snapshot-${createdAtSegment}-${digest}`;
|
||||
}
|
||||
|
||||
function snapshotPath(snapshotId: string) {
|
||||
if (!/^state-snapshot-[0-9TZ-]+-[a-f0-9]{12}$/.test(snapshotId)) {
|
||||
throw new Error("BACKUP_SNAPSHOT_ID_INVALID");
|
||||
}
|
||||
return path.join(backupDirPath(), `${snapshotId}.json`);
|
||||
}
|
||||
|
||||
async function readStateText() {
|
||||
const state = await readState();
|
||||
return `${JSON.stringify(state, null, 2)}\n`;
|
||||
}
|
||||
|
||||
function parseStateText(text: string, source: string) {
|
||||
const parsed = JSON.parse(text) as BossState;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error(`BACKUP_STATE_INVALID:${source}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function writeMeta(snapshotId: string, meta: Pick<BossStateBackupSnapshot, "actorAccount" | "reason" | "createdAt" | "sha256" | "bytes" | "schemaVersion">) {
|
||||
await fs.writeFile(path.join(backupDirPath(), `${snapshotId}.meta.json`), `${JSON.stringify(meta, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
async function readMeta(snapshotId: string) {
|
||||
const metaPath = path.join(backupDirPath(), `${snapshotId}.meta.json`);
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(metaPath, "utf8")) as Partial<BossStateBackupSnapshot>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function createBossStateBackup(input: {
|
||||
actorAccount: string;
|
||||
reason?: string;
|
||||
prefix?: string;
|
||||
}): Promise<BossStateBackupSnapshot> {
|
||||
const text = await readStateText();
|
||||
const parsed = parseStateText(text, stateFilePath());
|
||||
const createdAtSegment = timestampSegment();
|
||||
const snapshotId = snapshotIdFor(createdAtSegment, text);
|
||||
const dir = backupDirPath();
|
||||
const absolutePath = path.join(dir, `${snapshotId}.json`);
|
||||
const sha256 = createHash("sha256").update(text).digest("hex");
|
||||
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(absolutePath, text.endsWith("\n") ? text : `${text}\n`, "utf8");
|
||||
const stat = await fs.stat(absolutePath);
|
||||
const createdAt = new Date().toISOString();
|
||||
await writeMeta(snapshotId, {
|
||||
actorAccount: input.actorAccount,
|
||||
reason: input.reason,
|
||||
createdAt,
|
||||
sha256,
|
||||
bytes: stat.size,
|
||||
schemaVersion: parsed.schemaVersion,
|
||||
});
|
||||
|
||||
return {
|
||||
snapshotId,
|
||||
fileName: `${snapshotId}.json`,
|
||||
absolutePath,
|
||||
bytes: stat.size,
|
||||
sha256,
|
||||
createdAt,
|
||||
actorAccount: input.actorAccount,
|
||||
reason: input.reason,
|
||||
schemaVersion: parsed.schemaVersion,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listBossStateBackups(limit = 20): Promise<BossStateBackupSnapshot[]> {
|
||||
const dir = backupDirPath();
|
||||
const entries = await fs.readdir(dir).catch(() => []);
|
||||
const snapshots = await Promise.all(
|
||||
entries
|
||||
.filter((fileName) => /^state-snapshot-.*\.json$/.test(fileName) && !fileName.endsWith(".meta.json"))
|
||||
.map(async (fileName) => {
|
||||
const absolutePath = path.join(dir, fileName);
|
||||
const text = await fs.readFile(absolutePath, "utf8");
|
||||
const stat = await fs.stat(absolutePath);
|
||||
const snapshotId = fileName.replace(/\.json$/, "");
|
||||
const meta = await readMeta(snapshotId);
|
||||
let schemaVersion: number | undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(text) as Partial<BossState>;
|
||||
schemaVersion = typeof parsed.schemaVersion === "number" ? parsed.schemaVersion : undefined;
|
||||
} catch {
|
||||
schemaVersion = undefined;
|
||||
}
|
||||
return {
|
||||
snapshotId,
|
||||
fileName,
|
||||
absolutePath,
|
||||
bytes: typeof meta.bytes === "number" ? meta.bytes : stat.size,
|
||||
sha256: typeof meta.sha256 === "string" ? meta.sha256 : createHash("sha256").update(text).digest("hex"),
|
||||
createdAt: typeof meta.createdAt === "string" ? meta.createdAt : stat.mtime.toISOString(),
|
||||
actorAccount: typeof meta.actorAccount === "string" ? meta.actorAccount : undefined,
|
||||
reason: typeof meta.reason === "string" ? meta.reason : undefined,
|
||||
schemaVersion: typeof meta.schemaVersion === "number" ? meta.schemaVersion : schemaVersion,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return snapshots
|
||||
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))
|
||||
.slice(0, Math.max(1, Math.min(100, limit)));
|
||||
}
|
||||
|
||||
export async function getBossStateBackupStatus(): Promise<BossStateBackupStatus> {
|
||||
try {
|
||||
const snapshots = await listBossStateBackups(100);
|
||||
return {
|
||||
mode: "file",
|
||||
backupDir: backupDirPath(),
|
||||
stateFile: stateFilePath(),
|
||||
restorePointCount: snapshots.length,
|
||||
lastBackupAt: snapshots[0]?.createdAt,
|
||||
status: snapshots.length > 0 ? "ready" : "empty",
|
||||
detail: snapshots.length > 0 ? `最近快照:${snapshots[0]?.snapshotId}` : "暂无可用快照",
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
mode: "file",
|
||||
backupDir: backupDirPath(),
|
||||
stateFile: stateFilePath(),
|
||||
restorePointCount: 0,
|
||||
status: "error",
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreBossStateBackup(input: {
|
||||
snapshotId: string;
|
||||
actorAccount: string;
|
||||
}): Promise<{
|
||||
restored: BossStateBackupSnapshot;
|
||||
preRestoreSnapshot: BossStateBackupSnapshot;
|
||||
}> {
|
||||
const absolutePath = snapshotPath(input.snapshotId);
|
||||
const text = await fs.readFile(absolutePath, "utf8");
|
||||
const parsed = parseStateText(text, absolutePath);
|
||||
const restored = (await listBossStateBackups(100)).find((snapshot) => snapshot.snapshotId === input.snapshotId);
|
||||
if (!restored) {
|
||||
throw new Error("BACKUP_SNAPSHOT_NOT_FOUND");
|
||||
}
|
||||
|
||||
const preRestoreSnapshot = await createBossStateBackup({
|
||||
actorAccount: input.actorAccount,
|
||||
reason: `pre-restore:${input.snapshotId}`,
|
||||
});
|
||||
await writeState(parsed);
|
||||
|
||||
return {
|
||||
restored,
|
||||
preRestoreSnapshot,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Client as PgClient } from "pg";
|
||||
@@ -67,10 +67,120 @@ function createFileStateStore(paths: BossStateStorePaths): BossStateStore {
|
||||
await fs.writeFile(tempFile, text, "utf8");
|
||||
await fs.rename(tempFile, paths.dataFile);
|
||||
await fs.writeFile(paths.backupFile, text, "utf8");
|
||||
await writeAutomaticStateSnapshot(paths, text);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let lastAutomaticSnapshotAt = 0;
|
||||
|
||||
function boolEnv(name: string, fallback: boolean) {
|
||||
const value = process.env[name]?.trim();
|
||||
if (value === undefined || value === "") return fallback;
|
||||
return value !== "0" && value.toLowerCase() !== "false";
|
||||
}
|
||||
|
||||
function numberEnv(name: string, fallback: number) {
|
||||
const value = Number(process.env[name]);
|
||||
return Number.isFinite(value) && value >= 0 ? value : fallback;
|
||||
}
|
||||
|
||||
function autoBackupDir(paths: BossStateStorePaths) {
|
||||
const configured = process.env.BOSS_STATE_BACKUP_DIR?.trim();
|
||||
return configured ? path.resolve(configured) : path.join(path.dirname(paths.dataFile), "backups");
|
||||
}
|
||||
|
||||
function automaticSnapshotId(text: string, now: Date) {
|
||||
const timestamp = now.toISOString().replace(/[:.]/g, "-");
|
||||
const digest = createHash("sha256")
|
||||
.update(text)
|
||||
.update(String(now.getTime()))
|
||||
.update(randomBytes(6))
|
||||
.digest("hex")
|
||||
.slice(0, 12);
|
||||
return `state-snapshot-${timestamp}-${digest}`;
|
||||
}
|
||||
|
||||
async function pruneAutomaticSnapshots(dir: string, keep: number) {
|
||||
if (keep <= 0) return;
|
||||
const entries = await fs.readdir(dir).catch(() => []);
|
||||
const autos = await Promise.all(
|
||||
entries
|
||||
.filter((fileName) => /^state-snapshot-.*\.meta\.json$/.test(fileName))
|
||||
.map(async (fileName) => {
|
||||
const metaPath = path.join(dir, fileName);
|
||||
try {
|
||||
const meta = JSON.parse(await fs.readFile(metaPath, "utf8")) as { reason?: string; createdAt?: string };
|
||||
if (meta.reason !== "auto:writeState") return null;
|
||||
return {
|
||||
snapshotId: fileName.replace(/\.meta\.json$/, ""),
|
||||
createdAt: meta.createdAt || "",
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
const stale = autos
|
||||
.filter((entry): entry is { snapshotId: string; createdAt: string } => Boolean(entry))
|
||||
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))
|
||||
.slice(keep);
|
||||
|
||||
await Promise.all(
|
||||
stale.flatMap((entry) => [
|
||||
fs.rm(path.join(dir, `${entry.snapshotId}.json`), { force: true }),
|
||||
fs.rm(path.join(dir, `${entry.snapshotId}.meta.json`), { force: true }),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
async function writeAutomaticStateSnapshot(paths: BossStateStorePaths, text: string) {
|
||||
if (!boolEnv("BOSS_STATE_AUTO_BACKUP_ENABLED", true)) return;
|
||||
|
||||
const intervalMs = numberEnv("BOSS_STATE_AUTO_BACKUP_INTERVAL_MS", 60 * 60 * 1000);
|
||||
const nowMs = Date.now();
|
||||
if (lastAutomaticSnapshotAt > 0 && nowMs - lastAutomaticSnapshotAt < intervalMs) return;
|
||||
|
||||
const run = async () => {
|
||||
const now = new Date(nowMs);
|
||||
const dir = autoBackupDir(paths);
|
||||
const snapshotId = automaticSnapshotId(text, now);
|
||||
const filePath = path.join(dir, `${snapshotId}.json`);
|
||||
const metaPath = path.join(dir, `${snapshotId}.meta.json`);
|
||||
const normalizedText = text.endsWith("\n") ? text : `${text}\n`;
|
||||
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(filePath, normalizedText, "utf8");
|
||||
const stat = await fs.stat(filePath);
|
||||
await fs.writeFile(
|
||||
metaPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
actorAccount: "system",
|
||||
reason: "auto:writeState",
|
||||
createdAt: now.toISOString(),
|
||||
sha256: createHash("sha256").update(normalizedText).digest("hex"),
|
||||
bytes: stat.size,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
lastAutomaticSnapshotAt = nowMs;
|
||||
await pruneAutomaticSnapshots(dir, numberEnv("BOSS_STATE_AUTO_BACKUP_KEEP", 200));
|
||||
};
|
||||
|
||||
try {
|
||||
await run();
|
||||
} catch (error) {
|
||||
if (boolEnv("BOSS_STATE_AUTO_BACKUP_STRICT", false)) {
|
||||
throw error;
|
||||
}
|
||||
console.warn(`boss-state auto backup failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function postgresClient() {
|
||||
const connectionString = process.env.BOSS_DATABASE_URL?.trim();
|
||||
if (!connectionString) {
|
||||
|
||||
285
src/lib/master-agent-intent-router.ts
Normal file
285
src/lib/master-agent-intent-router.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
import type {
|
||||
ComputerControlIntentCategory,
|
||||
ComputerControlPlatform,
|
||||
ComputerControlRiskLevel,
|
||||
ComputerUseProvider,
|
||||
} from "@/lib/boss-data";
|
||||
|
||||
export type MasterAgentExecutionMode = "discussion" | "thread" | "development" | "browser" | "desktop";
|
||||
export type MasterAgentIntentRoutingSource = "fast_path" | "semantic_heuristic";
|
||||
export type MacComputerUseProvider = ComputerUseProvider;
|
||||
|
||||
export interface MasterAgentControlIntentClassification {
|
||||
intentCategory: ComputerControlIntentCategory;
|
||||
executionMode: MasterAgentExecutionMode;
|
||||
riskLevel: ComputerControlRiskLevel;
|
||||
source?: MasterAgentIntentRoutingSource;
|
||||
confidence?: number;
|
||||
platform?: ComputerControlPlatform;
|
||||
recommendedProvider?: MacComputerUseProvider;
|
||||
}
|
||||
|
||||
function normalizeIntentText(value: string) {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function includesAny(text: string, keywords: string[]) {
|
||||
return keywords.some((keyword) => text.includes(keyword.toLowerCase()));
|
||||
}
|
||||
|
||||
function buildResult(
|
||||
intentCategory: ComputerControlIntentCategory,
|
||||
executionMode: MasterAgentExecutionMode,
|
||||
riskLevel: ComputerControlRiskLevel,
|
||||
options: {
|
||||
source?: MasterAgentIntentRoutingSource;
|
||||
confidence?: number;
|
||||
recommendedProvider?: MacComputerUseProvider;
|
||||
} = {},
|
||||
): MasterAgentControlIntentClassification {
|
||||
const platform =
|
||||
executionMode === "browser" || executionMode === "desktop" ? ("macos" as const) : undefined;
|
||||
return {
|
||||
intentCategory,
|
||||
executionMode,
|
||||
riskLevel,
|
||||
source: options.source ?? "semantic_heuristic",
|
||||
confidence: options.confidence,
|
||||
platform,
|
||||
recommendedProvider: options.recommendedProvider,
|
||||
};
|
||||
}
|
||||
|
||||
const DISCUSSION_SIGNALS = [
|
||||
"讨论",
|
||||
"分析",
|
||||
"评估",
|
||||
"方案",
|
||||
"怎么",
|
||||
"如何",
|
||||
"为什么",
|
||||
"是什么",
|
||||
"能否",
|
||||
"可不可以",
|
||||
"有没有",
|
||||
"设计",
|
||||
"建议",
|
||||
"解释",
|
||||
"总结",
|
||||
"规划",
|
||||
];
|
||||
|
||||
const ACTION_SIGNALS = [
|
||||
"帮我",
|
||||
"你去",
|
||||
"打开",
|
||||
"点开",
|
||||
"点击",
|
||||
"输入",
|
||||
"填写",
|
||||
"发送",
|
||||
"搜索",
|
||||
"搜一下",
|
||||
"搜",
|
||||
"查一下",
|
||||
"找一下",
|
||||
"播放",
|
||||
"切到",
|
||||
"进入",
|
||||
"访问",
|
||||
"登录",
|
||||
"操作",
|
||||
"控制",
|
||||
"运行",
|
||||
"启动",
|
||||
"关闭",
|
||||
"关掉",
|
||||
"整理",
|
||||
"复制",
|
||||
"移动",
|
||||
"删除",
|
||||
"上传",
|
||||
"下载",
|
||||
"安装",
|
||||
"卸载",
|
||||
];
|
||||
|
||||
const BROWSER_SIGNALS = [
|
||||
"chrome",
|
||||
"safari",
|
||||
"youtube",
|
||||
"油管",
|
||||
"google",
|
||||
"百度",
|
||||
"浏览器",
|
||||
"网页",
|
||||
"网站",
|
||||
"url",
|
||||
"http://",
|
||||
"https://",
|
||||
"搜索页面",
|
||||
"页面里面搜索",
|
||||
"页面里搜索",
|
||||
"表单",
|
||||
"登录网站",
|
||||
"打开网站",
|
||||
"打开后台",
|
||||
"打开页面",
|
||||
"提交表单",
|
||||
];
|
||||
|
||||
const WEB_RESEARCH_SIGNALS = [
|
||||
"搜一下",
|
||||
"搜索",
|
||||
"查一下",
|
||||
"查找",
|
||||
"检索",
|
||||
"找资料",
|
||||
"资料",
|
||||
"调研",
|
||||
];
|
||||
|
||||
const MAC_DESKTOP_SIGNALS = [
|
||||
"mac",
|
||||
"电脑",
|
||||
"本机",
|
||||
"桌面",
|
||||
"系统设置",
|
||||
"finder",
|
||||
"访达",
|
||||
"微信",
|
||||
"飞书",
|
||||
"telegram",
|
||||
"qq",
|
||||
"应用",
|
||||
"软件",
|
||||
"窗口",
|
||||
"文件",
|
||||
"下载目录",
|
||||
"安装包",
|
||||
];
|
||||
|
||||
const DEVELOPMENT_ACTION_SIGNALS = [
|
||||
"继续开发",
|
||||
"直接开发",
|
||||
"开始开发",
|
||||
"改代码",
|
||||
"修复",
|
||||
"跑测试",
|
||||
"联调",
|
||||
"实现",
|
||||
"提交代码",
|
||||
"构建",
|
||||
"回归测试",
|
||||
"debug",
|
||||
"编译",
|
||||
"打包",
|
||||
"部署",
|
||||
];
|
||||
|
||||
const HIGH_RISK_ACTION_SIGNALS = [
|
||||
"删除",
|
||||
"卸载",
|
||||
"发送",
|
||||
"发消息",
|
||||
"发邮件",
|
||||
"提交表单",
|
||||
"提交订单",
|
||||
"发布",
|
||||
"付款",
|
||||
"购买",
|
||||
"转账",
|
||||
"授权",
|
||||
"改密码",
|
||||
];
|
||||
|
||||
function hasActionIntent(text: string) {
|
||||
return includesAny(text, ACTION_SIGNALS);
|
||||
}
|
||||
|
||||
function isDiscussionOnly(text: string) {
|
||||
if (!includesAny(text, DISCUSSION_SIGNALS)) return false;
|
||||
if (text.includes("讨论") || text.includes("分析") || text.includes("评估") || text.includes("方案")) {
|
||||
return !includesAny(text, ["你去", "帮我打开", "帮我搜索", "帮我查", "帮我找", "帮我点击"]);
|
||||
}
|
||||
if (text.includes("怎么开发") || text.includes("如何开发") || text.includes("怎么实现") || text.includes("如何实现")) {
|
||||
return !includesAny(text, ["继续", "直接", "开始", "马上", "修复", "改代码", "跑测试"]);
|
||||
}
|
||||
return !hasActionIntent(text);
|
||||
}
|
||||
|
||||
function resolveRiskLevel(text: string): ComputerControlRiskLevel {
|
||||
return includesAny(text, HIGH_RISK_ACTION_SIGNALS) ? "high" : "medium";
|
||||
}
|
||||
|
||||
function isMacDesktopAction(text: string) {
|
||||
if (!hasActionIntent(text)) return false;
|
||||
if (includesAny(text, ["打开应用", "打开软件", "系统设置", "finder", "访达"])) return true;
|
||||
return includesAny(text, MAC_DESKTOP_SIGNALS) &&
|
||||
includesAny(text, ["打开", "找一下", "查找", "切到", "点击", "输入", "操作", "控制", "整理", "复制", "移动", "删除"]);
|
||||
}
|
||||
|
||||
function isBrowserAction(text: string) {
|
||||
if (includesAny(text, ["youtube", "油管"]) && includesAny(text, ["打开", "搜索", "搜", "播放", "找"])) {
|
||||
return true;
|
||||
}
|
||||
if (includesAny(text, BROWSER_SIGNALS) && hasActionIntent(text)) {
|
||||
return true;
|
||||
}
|
||||
if (includesAny(text, ["用电脑", "在电脑", "这台电脑", "这台 mac", "这台mac"]) && includesAny(text, WEB_RESEARCH_SIGNALS)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isDevelopmentAction(text: string) {
|
||||
if (isDiscussionOnly(text)) return false;
|
||||
return includesAny(text, DEVELOPMENT_ACTION_SIGNALS);
|
||||
}
|
||||
|
||||
export function classifyMasterAgentControlIntent(
|
||||
requestText: string,
|
||||
): MasterAgentControlIntentClassification {
|
||||
const text = normalizeIntentText(requestText);
|
||||
if (!text) {
|
||||
return buildResult("discussion_only", "discussion", "low", {
|
||||
source: "fast_path",
|
||||
confidence: 1,
|
||||
});
|
||||
}
|
||||
|
||||
if (isDiscussionOnly(text)) {
|
||||
return buildResult("discussion_only", "discussion", "low", {
|
||||
source: "fast_path",
|
||||
confidence: 0.92,
|
||||
});
|
||||
}
|
||||
|
||||
if (isDevelopmentAction(text)) {
|
||||
return buildResult("project_development", "development", "medium", {
|
||||
source: "semantic_heuristic",
|
||||
confidence: 0.86,
|
||||
});
|
||||
}
|
||||
|
||||
if (isBrowserAction(text)) {
|
||||
return buildResult("browser_control", "browser", resolveRiskLevel(text), {
|
||||
source: "semantic_heuristic",
|
||||
confidence: 0.88,
|
||||
recommendedProvider: "openai-computer-use",
|
||||
});
|
||||
}
|
||||
|
||||
if (isMacDesktopAction(text)) {
|
||||
return buildResult("desktop_control", "desktop", resolveRiskLevel(text), {
|
||||
source: "semantic_heuristic",
|
||||
confidence: 0.87,
|
||||
recommendedProvider: "codex-computer-use",
|
||||
});
|
||||
}
|
||||
|
||||
return buildResult("discussion_only", "discussion", "low", {
|
||||
source: "semantic_heuristic",
|
||||
confidence: 0.72,
|
||||
});
|
||||
}
|
||||
35
src/lib/master-agent-task-wakeup.ts
Normal file
35
src/lib/master-agent-task-wakeup.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { subscribeBossEvents } from "@/lib/boss-events";
|
||||
|
||||
export function waitForMasterAgentTaskWakeup(
|
||||
deviceId: string,
|
||||
waitMs: number,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const normalizedWaitMs = Math.max(0, Math.floor(waitMs));
|
||||
if (!deviceId || normalizedWaitMs <= 0 || signal?.aborted) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
let settled = false;
|
||||
|
||||
const finish = (woke: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
unsubscribe();
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve(woke);
|
||||
};
|
||||
const onAbort = () => finish(false);
|
||||
|
||||
const unsubscribe = subscribeBossEvents((event, payload) => {
|
||||
if (event !== "master_agent.task.updated") return;
|
||||
if (payload.deviceId !== deviceId) return;
|
||||
if (payload.status !== "queued") return;
|
||||
finish(true);
|
||||
});
|
||||
const timer = setTimeout(() => finish(false), normalizedWaitMs);
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user