feat: harden enterprise control plane
This commit is contained in:
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