feat: ship native boss android console
This commit is contained in:
152
src/lib/boss-app-client.ts
Normal file
152
src/lib/boss-app-client.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
const APP_HISTORY_KEY = "boss.app.history.v1";
|
||||
const NATIVE_SESSION_KEY = "boss.native.session.v1";
|
||||
const MAX_HISTORY_ENTRIES = 48;
|
||||
|
||||
export type NativeSessionSnapshot = {
|
||||
restoreToken: string;
|
||||
account: string;
|
||||
displayName: string;
|
||||
expiresAt: string;
|
||||
lastSyncedAt: string;
|
||||
};
|
||||
|
||||
export type AppBackAction =
|
||||
| { mode: "history" }
|
||||
| { mode: "replace"; target: string }
|
||||
| { mode: "noop" };
|
||||
|
||||
function isBrowser() {
|
||||
return typeof window !== "undefined";
|
||||
}
|
||||
|
||||
function readJsonStorage<T>(key: string, storage: Storage): T | null {
|
||||
try {
|
||||
const raw = storage.getItem(key);
|
||||
return raw ? (JSON.parse(raw) as T) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonStorage(key: string, value: unknown, storage: Storage) {
|
||||
try {
|
||||
storage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
// Ignore storage write failures in constrained webviews.
|
||||
}
|
||||
}
|
||||
|
||||
function basePathFor(input: string) {
|
||||
return input.split("#")[0]?.split("?")[0] || "/";
|
||||
}
|
||||
|
||||
function fallbackForPath(input: string) {
|
||||
const pathname = basePathFor(input);
|
||||
if (pathname === "/auth/login") return null;
|
||||
if (pathname.startsWith("/auth/")) return "/auth/login";
|
||||
if (pathname === "/conversations") return null;
|
||||
if (pathname.startsWith("/conversations/")) return "/conversations";
|
||||
if (pathname === "/devices") {
|
||||
return input.includes("?") ? "/devices" : "/conversations";
|
||||
}
|
||||
if (pathname.startsWith("/devices/")) return "/devices";
|
||||
if (pathname === "/me") return "/conversations";
|
||||
if (pathname.startsWith("/me/")) return "/me";
|
||||
return "/conversations";
|
||||
}
|
||||
|
||||
export function currentAppLocation() {
|
||||
if (!isBrowser()) return "/";
|
||||
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
}
|
||||
|
||||
export function pushAppHistoryEntry(path: string) {
|
||||
if (!isBrowser()) return;
|
||||
const current = readJsonStorage<string[]>(APP_HISTORY_KEY, window.sessionStorage) ?? [];
|
||||
const normalized = path || "/";
|
||||
const next = current.filter((entry) => entry !== normalized);
|
||||
next.push(normalized);
|
||||
writeJsonStorage(APP_HISTORY_KEY, next.slice(-MAX_HISTORY_ENTRIES), window.sessionStorage);
|
||||
}
|
||||
|
||||
export function popAppHistoryEntry(expectedPath?: string) {
|
||||
if (!isBrowser()) return;
|
||||
const current = readJsonStorage<string[]>(APP_HISTORY_KEY, window.sessionStorage) ?? [];
|
||||
if (!current.length) return;
|
||||
if (expectedPath && current[current.length - 1] !== expectedPath) {
|
||||
writeJsonStorage(
|
||||
APP_HISTORY_KEY,
|
||||
current.filter((entry) => entry !== expectedPath),
|
||||
window.sessionStorage,
|
||||
);
|
||||
return;
|
||||
}
|
||||
current.pop();
|
||||
writeJsonStorage(APP_HISTORY_KEY, current, window.sessionStorage);
|
||||
}
|
||||
|
||||
export function resolveAppBackAction(currentPath: string, explicitFallback?: string): AppBackAction {
|
||||
if (!isBrowser()) {
|
||||
return explicitFallback ? { mode: "replace", target: explicitFallback } : { mode: "noop" };
|
||||
}
|
||||
|
||||
const history = readJsonStorage<string[]>(APP_HISTORY_KEY, window.sessionStorage) ?? [];
|
||||
const previous = [...history].reverse().find((entry) => entry !== currentPath);
|
||||
if (previous) {
|
||||
return { mode: "history" };
|
||||
}
|
||||
|
||||
const fallback = explicitFallback ?? fallbackForPath(currentPath);
|
||||
if (fallback && fallback !== currentPath) {
|
||||
return { mode: "replace", target: fallback };
|
||||
}
|
||||
|
||||
return { mode: "noop" };
|
||||
}
|
||||
|
||||
export async function isNativeBossApp() {
|
||||
if (!isBrowser()) return false;
|
||||
const { Capacitor } = await import("@capacitor/core");
|
||||
return Capacitor.getPlatform() !== "web";
|
||||
}
|
||||
|
||||
async function preferencesApi() {
|
||||
if (!(await isNativeBossApp())) return null;
|
||||
const { Preferences } = await import("@capacitor/preferences");
|
||||
return Preferences;
|
||||
}
|
||||
|
||||
export async function persistNativeSessionSnapshot(snapshot: NativeSessionSnapshot) {
|
||||
const serialized = JSON.stringify(snapshot);
|
||||
const preferences = await preferencesApi();
|
||||
if (preferences) {
|
||||
await preferences.set({ key: NATIVE_SESSION_KEY, value: serialized });
|
||||
return;
|
||||
}
|
||||
if (isBrowser()) {
|
||||
window.localStorage.setItem(NATIVE_SESSION_KEY, serialized);
|
||||
}
|
||||
}
|
||||
|
||||
export async function readNativeSessionSnapshot() {
|
||||
const preferences = await preferencesApi();
|
||||
if (preferences) {
|
||||
const result = await preferences.get({ key: NATIVE_SESSION_KEY });
|
||||
return result.value ? (JSON.parse(result.value) as NativeSessionSnapshot) : null;
|
||||
}
|
||||
if (!isBrowser()) return null;
|
||||
return readJsonStorage<NativeSessionSnapshot>(NATIVE_SESSION_KEY, window.localStorage);
|
||||
}
|
||||
|
||||
export async function clearNativeSessionSnapshot() {
|
||||
const preferences = await preferencesApi();
|
||||
if (preferences) {
|
||||
await preferences.remove({ key: NATIVE_SESSION_KEY });
|
||||
return;
|
||||
}
|
||||
if (isBrowser()) {
|
||||
window.localStorage.removeItem(NATIVE_SESSION_KEY);
|
||||
}
|
||||
}
|
||||
73
src/lib/boss-auth.ts
Normal file
73
src/lib/boss-auth.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { cookies } 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";
|
||||
|
||||
function shouldUseSecureCookie(request?: NextRequest) {
|
||||
const forwardedProto = request?.headers.get("x-forwarded-proto");
|
||||
return request?.nextUrl.protocol === "https:" || forwardedProto === "https";
|
||||
}
|
||||
|
||||
export async function getCurrentPageSession() {
|
||||
const token = (await cookies()).get(AUTH_SESSION_COOKIE)?.value;
|
||||
return getAuthSession(token);
|
||||
}
|
||||
|
||||
export async function requirePageSession() {
|
||||
const session = await getCurrentPageSession();
|
||||
if (!session) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function redirectIfAuthenticated() {
|
||||
const session = await getCurrentPageSession();
|
||||
if (session) {
|
||||
redirect("/conversations");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRequestSession(request: NextRequest) {
|
||||
return getAuthSession(request.cookies.get(AUTH_SESSION_COOKIE)?.value);
|
||||
}
|
||||
|
||||
export async function requireRequestSession(request: NextRequest) {
|
||||
const session = await getRequestSession(request);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
export function setAuthSessionCookie(
|
||||
response: NextResponse,
|
||||
sessionToken: string,
|
||||
request?: NextRequest,
|
||||
) {
|
||||
response.cookies.set({
|
||||
name: AUTH_SESSION_COOKIE,
|
||||
value: sessionToken,
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: shouldUseSecureCookie(request),
|
||||
path: "/",
|
||||
maxAge: 30 * 24 * 60 * 60,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
export function clearAuthSessionCookie(response: NextResponse, request?: NextRequest) {
|
||||
response.cookies.set({
|
||||
name: AUTH_SESSION_COOKIE,
|
||||
value: "",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: shouldUseSecureCookie(request),
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
4102
src/lib/boss-data.ts
Normal file
4102
src/lib/boss-data.ts
Normal file
File diff suppressed because it is too large
Load Diff
34
src/lib/boss-device-auth.ts
Normal file
34
src/lib/boss-device-auth.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { getDevice, verifyDeviceToken } from "@/lib/boss-data";
|
||||
|
||||
export async function authorizeDeviceWriteRequest(
|
||||
request: NextRequest,
|
||||
deviceId: string,
|
||||
) {
|
||||
const device = await getDevice(deviceId);
|
||||
const session = await requireRequestSession(request);
|
||||
|
||||
if (device && session && (session.role === "highest_admin" || device.account === session.account)) {
|
||||
return {
|
||||
ok: true as const,
|
||||
device,
|
||||
principal: "session" as const,
|
||||
};
|
||||
}
|
||||
|
||||
const deviceToken = request.headers.get("x-boss-device-token") ?? undefined;
|
||||
if (deviceToken && (await verifyDeviceToken(deviceId, deviceToken))) {
|
||||
return {
|
||||
ok: true as const,
|
||||
device,
|
||||
principal: "device_token" as const,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false as const,
|
||||
device,
|
||||
principal: null,
|
||||
};
|
||||
}
|
||||
38
src/lib/boss-events.ts
Normal file
38
src/lib/boss-events.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
export type BossEventName =
|
||||
| "conversation.updated"
|
||||
| "project.messages.updated"
|
||||
| "project.context_risk.updated"
|
||||
| "app.logs.updated"
|
||||
| "master_agent.task.updated"
|
||||
| "devices.updated"
|
||||
| "devices.skills.updated"
|
||||
| "ota.updated";
|
||||
|
||||
export interface BossEventPayload {
|
||||
at: string;
|
||||
projectId?: string;
|
||||
deviceId?: string;
|
||||
taskId?: string;
|
||||
status?: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
type BossEventListener = (event: BossEventName, payload: BossEventPayload) => void;
|
||||
|
||||
const eventBus = new EventEmitter();
|
||||
|
||||
export function publishBossEvent(event: BossEventName, payload: Omit<BossEventPayload, "at"> = {}) {
|
||||
eventBus.emit("boss-event", event, {
|
||||
at: new Date().toISOString(),
|
||||
...payload,
|
||||
} satisfies BossEventPayload);
|
||||
}
|
||||
|
||||
export function subscribeBossEvents(listener: BossEventListener) {
|
||||
eventBus.on("boss-event", listener);
|
||||
return () => {
|
||||
eventBus.off("boss-event", listener);
|
||||
};
|
||||
}
|
||||
171
src/lib/boss-mail.ts
Normal file
171
src/lib/boss-mail.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
export type VerificationDeliveryMode = "fixed" | "email";
|
||||
export type VerificationPurpose = "login" | "register" | "forgot-password";
|
||||
|
||||
export interface VerificationDeliveryResult {
|
||||
delivered: boolean;
|
||||
mode: VerificationDeliveryMode;
|
||||
message: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
function purposeLabel(purpose: VerificationPurpose) {
|
||||
switch (purpose) {
|
||||
case "login":
|
||||
return "登录";
|
||||
case "register":
|
||||
return "注册";
|
||||
case "forgot-password":
|
||||
return "重置密码";
|
||||
default:
|
||||
return "验证";
|
||||
}
|
||||
}
|
||||
|
||||
export function getVerificationDeliveryMode(): VerificationDeliveryMode {
|
||||
return process.env.BOSS_AUTH_VERIFICATION_MODE === "email" ? "email" : "fixed";
|
||||
}
|
||||
|
||||
export function getFixedVerificationCode() {
|
||||
return process.env.BOSS_AUTH_FIXED_CODE?.trim() || "000000";
|
||||
}
|
||||
|
||||
export function getVerificationDeliverySummary(code: string) {
|
||||
if (getVerificationDeliveryMode() === "email") {
|
||||
return "验证码邮件已发送,请检查邮箱。";
|
||||
}
|
||||
return `邮件验证码尚未切到真实投递,当前固定验证码为 ${code}。`;
|
||||
}
|
||||
|
||||
function isLikelyEmailAccount(account: string) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(account);
|
||||
}
|
||||
|
||||
function buildVerificationMessage({
|
||||
account,
|
||||
recipient,
|
||||
purpose,
|
||||
code,
|
||||
}: {
|
||||
account: string;
|
||||
recipient: string;
|
||||
purpose: VerificationPurpose;
|
||||
code: string;
|
||||
}) {
|
||||
const domain = process.env.BOSS_MAIL_DOMAIN?.trim() || "boss.hyzq.net";
|
||||
const fromAddress = process.env.BOSS_MAIL_FROM_ADDRESS?.trim() || `verify@${domain}`;
|
||||
const fromName = process.env.BOSS_MAIL_FROM_NAME?.trim() || "Boss Verify";
|
||||
const subject = `Boss ${purposeLabel(purpose)}验证码`;
|
||||
const messageId = `<${Date.now()}.${randomBytes(4).toString("hex")}@${domain}>`;
|
||||
const body = [
|
||||
`你好,${account}`,
|
||||
"",
|
||||
recipient !== account ? `当前验证码投递到绑定邮箱:${recipient}` : undefined,
|
||||
`你本次的 Boss ${purposeLabel(purpose)}验证码是:${code}`,
|
||||
"验证码 5 分钟内有效。",
|
||||
"",
|
||||
"如果这不是你本人发起的操作,请忽略本邮件。",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
const lines = [
|
||||
`From: ${fromName} <${fromAddress}>`,
|
||||
`To: ${recipient}`,
|
||||
`Subject: ${subject}`,
|
||||
`Date: ${new Date().toUTCString()}`,
|
||||
`Message-ID: ${messageId}`,
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/plain; charset=UTF-8",
|
||||
"Content-Transfer-Encoding: 8bit",
|
||||
"",
|
||||
body,
|
||||
"",
|
||||
];
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function sendMail(message: string) {
|
||||
const sendmailPath = process.env.BOSS_SENDMAIL_PATH?.trim() || "/usr/sbin/sendmail";
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(sendmailPath, ["-t", "-i"], { stdio: ["pipe", "ignore", "pipe"] });
|
||||
let stderr = "";
|
||||
|
||||
child.on("error", (error) => {
|
||||
reject(error);
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString("utf8");
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(stderr.trim() || `SENDMAIL_EXIT_${code ?? "UNKNOWN"}`));
|
||||
});
|
||||
|
||||
child.stdin.end(message);
|
||||
});
|
||||
}
|
||||
|
||||
export async function deliverVerificationCode({
|
||||
account,
|
||||
recipient,
|
||||
purpose,
|
||||
code,
|
||||
}: {
|
||||
account: string;
|
||||
recipient?: string | null;
|
||||
purpose: VerificationPurpose;
|
||||
code: string;
|
||||
}): Promise<VerificationDeliveryResult> {
|
||||
const mode = getVerificationDeliveryMode();
|
||||
if (mode === "fixed") {
|
||||
return {
|
||||
delivered: true,
|
||||
mode,
|
||||
status: 200,
|
||||
message: getVerificationDeliverySummary(code),
|
||||
};
|
||||
}
|
||||
|
||||
const finalRecipient = recipient?.trim() || account;
|
||||
if (!isLikelyEmailAccount(finalRecipient)) {
|
||||
return {
|
||||
delivered: false,
|
||||
mode,
|
||||
status: 400,
|
||||
message: "当前邮件验证码模式需要可接收验证码的邮箱账号或已绑定验证邮箱。",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await sendMail(
|
||||
buildVerificationMessage({
|
||||
account,
|
||||
recipient: finalRecipient,
|
||||
purpose,
|
||||
code,
|
||||
}),
|
||||
);
|
||||
return {
|
||||
delivered: true,
|
||||
mode,
|
||||
status: 200,
|
||||
message: getVerificationDeliverySummary(code),
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "EMAIL_DELIVERY_FAILED";
|
||||
return {
|
||||
delivered: false,
|
||||
mode,
|
||||
status: 502,
|
||||
message: `验证码邮件发送失败:${message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
390
src/lib/boss-master-agent.ts
Normal file
390
src/lib/boss-master-agent.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
import {
|
||||
AUTH_SESSION_TTL_MS,
|
||||
aiProviderLabel,
|
||||
appendProjectMessage,
|
||||
getRuntimeAiAccountById,
|
||||
getMasterAgentRuntimeAccount,
|
||||
getMasterAgentTask,
|
||||
queueMasterAgentTask,
|
||||
readState,
|
||||
updateAiAccountHealth,
|
||||
} from "@/lib/boss-data";
|
||||
|
||||
function buildMasterAgentInstructions() {
|
||||
return [
|
||||
"你是 Boss 控制台的主 Agent。",
|
||||
"你要基于当前运行时状态给出中文回复,要求直接、可执行、便于继续联调。",
|
||||
"优先关注线程上下文预算、must_finish_before_compaction、最新 APP 日志、设备在线状态和 OTA 状态。",
|
||||
"如果信息不足,就明确说缺什么;不要编造设备状态或执行结果。",
|
||||
"如果用户要继续开发,默认给出下一步实现/验证动作,而不是泛泛安慰。",
|
||||
"保持回答简洁,通常 3-6 句即可。",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildRuntimeDigest(
|
||||
state: Awaited<ReturnType<typeof readState>>,
|
||||
requestText: string,
|
||||
currentSessionExpiresAt?: string,
|
||||
) {
|
||||
const recentMessages = state.projects
|
||||
.find((project) => project.id === "master-agent")
|
||||
?.messages.slice(-6)
|
||||
.map((message) => `${message.senderLabel}:${message.body}`)
|
||||
.join("\n");
|
||||
|
||||
const recentLogs = state.appLogs
|
||||
.slice(0, 5)
|
||||
.map((log) => `${log.createdAt} ${log.deviceId} ${log.category} ${log.message}`)
|
||||
.join("\n");
|
||||
|
||||
const riskyThreads = state.threadContextSnapshots
|
||||
.slice()
|
||||
.sort((a, b) => a.contextBudgetRemainingPct - b.contextBudgetRemainingPct)
|
||||
.slice(0, 4)
|
||||
.map(
|
||||
(snapshot) =>
|
||||
`${snapshot.projectId} / ${snapshot.title} / ${snapshot.contextBudgetLevel} / ${snapshot.contextBudgetRemainingPct}% / must_finish=${snapshot.mustFinishBeforeCompaction ? "yes" : "no"} / ${snapshot.summary}`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
const devices = state.devices
|
||||
.map(
|
||||
(device) =>
|
||||
`${device.name}(${device.id}) 状态=${device.status} 账号=${device.account} 5h=${device.quota5h} 7d=${device.quota7d}`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
const ota = state.otaUpdates
|
||||
.filter((update) => update.status === "available")
|
||||
.map((update) => `${update.version} -> ${update.targetScope}`)
|
||||
.join("\n");
|
||||
|
||||
const authSummary = [
|
||||
`登录会话策略:成功登录后默认保持 ${Math.round(AUTH_SESSION_TTL_MS / 24 / 60 / 60_000)} 天。`,
|
||||
"Cookie Max-Age:2592000 秒。",
|
||||
currentSessionExpiresAt ? `当前请求会话到期时间:${currentSessionExpiresAt}` : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
return [
|
||||
`当前时间:${new Date().toISOString()}`,
|
||||
`用户消息:${requestText}`,
|
||||
"",
|
||||
"最近主 Agent 对话:",
|
||||
recentMessages || "无",
|
||||
"",
|
||||
"最新 APP 日志:",
|
||||
recentLogs || "无",
|
||||
"",
|
||||
"高风险线程:",
|
||||
riskyThreads || "无",
|
||||
"",
|
||||
"在线设备:",
|
||||
devices || "无",
|
||||
"",
|
||||
"认证状态:",
|
||||
authSummary,
|
||||
"",
|
||||
"可用 OTA:",
|
||||
ota || "无",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function extractResponseText(payload: unknown): string {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const response = payload as {
|
||||
output_text?: string;
|
||||
output?: Array<{
|
||||
content?: Array<{ type?: string; text?: string; content?: string }>;
|
||||
}>;
|
||||
};
|
||||
|
||||
if (typeof response.output_text === "string" && response.output_text.trim()) {
|
||||
return response.output_text.trim();
|
||||
}
|
||||
|
||||
const chunks =
|
||||
response.output
|
||||
?.flatMap((item) => item.content ?? [])
|
||||
.map((item) => {
|
||||
if (typeof item.text === "string") return item.text;
|
||||
if (typeof item.content === "string") return item.content;
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean) ?? [];
|
||||
|
||||
return chunks.join("\n").trim();
|
||||
}
|
||||
|
||||
function normalizeOpenAiError(message: string) {
|
||||
const trimmed = message.trim();
|
||||
if (!trimmed) return "主 Agent 当前调用模型失败。";
|
||||
if (trimmed.length <= 240) return trimmed;
|
||||
return `${trimmed.slice(0, 237)}...`;
|
||||
}
|
||||
|
||||
async function generateOpenAiReply(params: {
|
||||
apiKey: string;
|
||||
model: string;
|
||||
requestText: string;
|
||||
currentSessionExpiresAt?: string;
|
||||
}) {
|
||||
const state = await readState();
|
||||
const response = await fetch("https://api.openai.com/v1/responses", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: params.model,
|
||||
reasoning: { effort: "medium" },
|
||||
instructions: buildMasterAgentInstructions(),
|
||||
input: buildRuntimeDigest(state, params.requestText, params.currentSessionExpiresAt),
|
||||
}),
|
||||
signal: AbortSignal.timeout(45_000),
|
||||
});
|
||||
|
||||
const requestId = response.headers.get("x-request-id") ?? undefined;
|
||||
const payload = (await response.json().catch(() => null)) as
|
||||
| { error?: { message?: string } }
|
||||
| null;
|
||||
|
||||
if (!response.ok) {
|
||||
const apiError =
|
||||
payload && typeof payload === "object" && "error" in payload
|
||||
? payload.error?.message
|
||||
: undefined;
|
||||
throw new Error(
|
||||
normalizeOpenAiError(
|
||||
`${apiError ?? `OpenAI API ${response.status}`}${requestId ? ` (request_id=${requestId})` : ""}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const content = extractResponseText(payload);
|
||||
if (!content) {
|
||||
throw new Error(
|
||||
normalizeOpenAiError(
|
||||
`模型已返回成功状态,但没有可用文本输出${requestId ? ` (request_id=${requestId})` : ""}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
content,
|
||||
requestId,
|
||||
};
|
||||
}
|
||||
|
||||
async function appendMasterAgentSystemReply(body: string, senderLabel = "主 Agent") {
|
||||
return appendProjectMessage({
|
||||
projectId: "master-agent",
|
||||
sender: "master",
|
||||
senderLabel,
|
||||
body,
|
||||
kind: "text",
|
||||
});
|
||||
}
|
||||
|
||||
function buildMasterCodexNodePrompt(
|
||||
state: Awaited<ReturnType<typeof readState>>,
|
||||
requestText: string,
|
||||
currentSessionExpiresAt?: string,
|
||||
) {
|
||||
return [
|
||||
"你是 Boss 控制台的主 Agent,运行在用户自己的 Master Codex Node 上。",
|
||||
"请结合下面的运行时状态和用户消息,直接给出中文回复。",
|
||||
"如果你认为需要继续在当前仓库里推进实现、排障或验证,可以直接说明你下一步会做什么;如果必须先做交接或收尾,也要明确说出原因。",
|
||||
"保持简洁,优先给出结论、动作、验证点。",
|
||||
"",
|
||||
buildRuntimeDigest(state, requestText, currentSessionExpiresAt),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function waitForMasterAgentTaskCompletion(taskId: string, timeoutMs = 55_000) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const task = await getMasterAgentTask(taskId);
|
||||
if (task?.status === "completed" || task?.status === "failed") {
|
||||
return task;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_500));
|
||||
}
|
||||
return getMasterAgentTask(taskId);
|
||||
}
|
||||
|
||||
export async function validateAiAccountConnection(accountId: string) {
|
||||
const account = await getRuntimeAiAccountById(accountId);
|
||||
if (!account) {
|
||||
throw new Error("AI_ACCOUNT_NOT_FOUND");
|
||||
}
|
||||
|
||||
if (account.provider === "master_codex_node") {
|
||||
return {
|
||||
ok: Boolean(account.nodeId?.trim()) as boolean,
|
||||
status: account.nodeId?.trim() ? "ready" : "needs_login",
|
||||
message:
|
||||
account.nodeId?.trim()
|
||||
? "Master Codex Node 已配置。主 Agent 会通过 local-agent relay 把任务转交给该节点上的 Codex。"
|
||||
: "请先填写 Master Codex Node 的节点 ID,再让 local-agent 认领主 Agent 任务。",
|
||||
};
|
||||
}
|
||||
|
||||
if (account.provider !== "openai_api" || !account.apiKey?.trim()) {
|
||||
return {
|
||||
ok: false as const,
|
||||
status: "needs_api_key",
|
||||
message: "当前账号还没有可用的 OpenAI API Key。",
|
||||
};
|
||||
}
|
||||
|
||||
const generated = await generateOpenAiReply({
|
||||
apiKey: account.apiKey,
|
||||
model: account.model || "gpt-5.4",
|
||||
requestText: "请只回复“连接正常”。",
|
||||
});
|
||||
|
||||
await updateAiAccountHealth({
|
||||
accountId: account.accountId,
|
||||
status: "ready",
|
||||
lastValidatedAt: new Date().toISOString(),
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
status: "ready",
|
||||
message: generated.content,
|
||||
requestId: generated.requestId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function replyToMasterAgentUserMessage(params: {
|
||||
requestMessageId?: string;
|
||||
requestText: string;
|
||||
requestedBy: string;
|
||||
requestedByAccount: string;
|
||||
currentSessionExpiresAt?: string;
|
||||
}) {
|
||||
const runtime = await getMasterAgentRuntimeAccount();
|
||||
|
||||
if (!runtime?.account) {
|
||||
await appendMasterAgentSystemReply(
|
||||
"我已经收到你的消息,但当前没有可用的主控 AI 账号。请到“我的 > AI 账号”至少配置一个可用的 OpenAI API 账号,再继续对话。",
|
||||
);
|
||||
return { ok: false as const, reason: "NO_AI_ACCOUNT" };
|
||||
}
|
||||
|
||||
if (runtime.account.provider === "master_codex_node") {
|
||||
const state = await readState();
|
||||
const deviceId = runtime.account.nodeId || state.user.boundDeviceId || "mac-studio";
|
||||
const task = await queueMasterAgentTask({
|
||||
requestMessageId: params.requestMessageId ?? "master-agent-manual",
|
||||
requestText: params.requestText,
|
||||
executionPrompt: buildMasterCodexNodePrompt(
|
||||
state,
|
||||
params.requestText,
|
||||
params.currentSessionExpiresAt,
|
||||
),
|
||||
requestedBy: params.requestedBy,
|
||||
requestedByAccount: params.requestedByAccount,
|
||||
deviceId,
|
||||
accountId: runtime.account.accountId,
|
||||
accountLabel: runtime.summary.roleLabel,
|
||||
});
|
||||
const completedTask = await waitForMasterAgentTaskCompletion(task.taskId);
|
||||
if (completedTask?.status === "completed") {
|
||||
return {
|
||||
ok: true as const,
|
||||
accountId: runtime.account.accountId,
|
||||
taskId: task.taskId,
|
||||
requestId: completedTask.requestId,
|
||||
};
|
||||
}
|
||||
if (completedTask?.status === "failed") {
|
||||
return {
|
||||
ok: false as const,
|
||||
reason: "MASTER_NODE_EXEC_FAILED",
|
||||
taskId: task.taskId,
|
||||
message: completedTask.errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
await appendMasterAgentSystemReply(
|
||||
[
|
||||
`当前主控身份是 ${runtime.summary.roleLabel},任务已经转交到 ${runtime.account.nodeLabel ?? deviceId} 的 Master Codex Node。`,
|
||||
"如果本机 Codex 节点在线,回复会在稍后自动回写到当前会话。",
|
||||
].join(""),
|
||||
`主 Agent · ${runtime.summary.roleLabel}`,
|
||||
);
|
||||
return { ok: true as const, accountId: runtime.account.accountId, taskId: task.taskId };
|
||||
}
|
||||
|
||||
if (runtime.account.provider !== "openai_api" || !runtime.account.apiKey?.trim()) {
|
||||
await appendMasterAgentSystemReply(
|
||||
[
|
||||
`当前主控身份是 ${runtime.summary.roleLabel},来源 ${aiProviderLabel(runtime.account.provider)}。`,
|
||||
"当前账号既没有接入 Master Codex Node 执行器,也没有可用的 OpenAI API Key。",
|
||||
"请到“我的 > AI 账号”补一个可用的 OpenAI API 账号,或者把当前节点接回 Master Codex Node relay。",
|
||||
].join(""),
|
||||
`主 Agent · ${runtime.summary.roleLabel}`,
|
||||
);
|
||||
return { ok: false as const, reason: "MASTER_NODE_NOT_CONNECTED" };
|
||||
}
|
||||
|
||||
try {
|
||||
const generated = await generateOpenAiReply({
|
||||
apiKey: runtime.account.apiKey,
|
||||
model: runtime.account.model || "gpt-5.4",
|
||||
requestText: params.requestText,
|
||||
currentSessionExpiresAt: params.currentSessionExpiresAt,
|
||||
});
|
||||
|
||||
await appendMasterAgentSystemReply(
|
||||
generated.content,
|
||||
`主 Agent · ${runtime.summary.roleLabel}`,
|
||||
);
|
||||
|
||||
if (!runtime.isEnvironmentFallback) {
|
||||
await updateAiAccountHealth({
|
||||
accountId: runtime.account.accountId,
|
||||
status: "ready",
|
||||
lastValidatedAt: new Date().toISOString(),
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
activate: !runtime.account.isActive,
|
||||
switchReason: runtime.account.isActive
|
||||
? runtime.account.switchReason
|
||||
: `主 Agent 回复时自动切换到 ${runtime.account.label}`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
accountId: runtime.account.accountId,
|
||||
requestId: generated.requestId,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "主 Agent 当前调用模型失败。";
|
||||
if (!runtime.isEnvironmentFallback) {
|
||||
await updateAiAccountHealth({
|
||||
accountId: runtime.account.accountId,
|
||||
status: "degraded",
|
||||
lastError: message,
|
||||
lastValidatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
await appendMasterAgentSystemReply(
|
||||
[
|
||||
`我已经收到你的消息,但当前 AI 账号调用失败:${message}。`,
|
||||
"请到“我的 > AI 账号”检查 API Key、模型名或切换到其他 AI 账号后重试。",
|
||||
].join(""),
|
||||
`主 Agent · ${runtime.summary.roleLabel}`,
|
||||
);
|
||||
return { ok: false as const, reason: "MODEL_CALL_FAILED", message };
|
||||
}
|
||||
}
|
||||
92
src/lib/boss-ota.ts
Normal file
92
src/lib/boss-ota.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export interface PublishedOtaAsset {
|
||||
absolutePath: string;
|
||||
fileName: string;
|
||||
sizeBytes: number;
|
||||
sha256: string;
|
||||
updatedAt: string;
|
||||
downloadUrl: string;
|
||||
}
|
||||
|
||||
const OTA_PACKAGE_FILE_NAME = "boss-android-latest.apk";
|
||||
const OTA_META_FILE_NAME = "boss-android-latest.json";
|
||||
const OTA_DOWNLOAD_URL = "/api/v1/user/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 otaPackagePath() {
|
||||
return path.join(otaPublicDir(), OTA_PACKAGE_FILE_NAME);
|
||||
}
|
||||
|
||||
function otaMetaPath() {
|
||||
return path.join(otaPublicDir(), OTA_META_FILE_NAME);
|
||||
}
|
||||
|
||||
export async function getPublishedOtaAsset(): Promise<PublishedOtaAsset | null> {
|
||||
const apkPath = otaPackagePath();
|
||||
if (!existsSync(apkPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metaPath = otaMetaPath();
|
||||
if (existsSync(metaPath)) {
|
||||
try {
|
||||
const meta = JSON.parse(await fs.readFile(metaPath, "utf8")) as Partial<PublishedOtaAsset> & {
|
||||
urlPath?: string;
|
||||
};
|
||||
return {
|
||||
absolutePath: apkPath,
|
||||
fileName: meta.fileName ?? OTA_PACKAGE_FILE_NAME,
|
||||
sizeBytes: meta.sizeBytes ?? (await fs.stat(apkPath)).size,
|
||||
sha256: meta.sha256 ?? createHash("sha256").update(await fs.readFile(apkPath)).digest("hex"),
|
||||
updatedAt: meta.updatedAt ?? (await fs.stat(apkPath)).mtime.toISOString(),
|
||||
downloadUrl: meta.downloadUrl ?? meta.urlPath ?? OTA_DOWNLOAD_URL,
|
||||
};
|
||||
} catch {
|
||||
// Fall through to live stat/hash.
|
||||
}
|
||||
}
|
||||
|
||||
const stat = await fs.stat(apkPath);
|
||||
const content = await fs.readFile(apkPath);
|
||||
return {
|
||||
absolutePath: apkPath,
|
||||
fileName: OTA_PACKAGE_FILE_NAME,
|
||||
sizeBytes: stat.size,
|
||||
sha256: createHash("sha256").update(content).digest("hex"),
|
||||
updatedAt: stat.mtime.toISOString(),
|
||||
downloadUrl: OTA_DOWNLOAD_URL,
|
||||
};
|
||||
}
|
||||
480
src/lib/boss-projections.ts
Normal file
480
src/lib/boss-projections.ts
Normal file
@@ -0,0 +1,480 @@
|
||||
import type {
|
||||
AiAccountRole,
|
||||
AiProvider,
|
||||
AiAccountStatus,
|
||||
AppLogEntry,
|
||||
AuditTaskRequest,
|
||||
AuditTaskResult,
|
||||
BossState,
|
||||
Capability,
|
||||
ContextBudgetLevel,
|
||||
Device,
|
||||
DeviceEnrollment,
|
||||
DeviceSkill,
|
||||
MasterIdentitySummary,
|
||||
OpsFault,
|
||||
OpsRepairTicket,
|
||||
OpsRepairVerification,
|
||||
Project,
|
||||
RiskLevel,
|
||||
ThreadContextAlert,
|
||||
ThreadContextSnapshot,
|
||||
ThreadHandoffPackage,
|
||||
} from "@/lib/boss-data";
|
||||
|
||||
export interface ContextIndicator {
|
||||
visible: boolean;
|
||||
style: "ring_percent";
|
||||
percent?: number;
|
||||
level?: ContextBudgetLevel;
|
||||
}
|
||||
|
||||
export interface ConversationItem {
|
||||
conversationId: string;
|
||||
conversationType: "master_agent" | "single_device" | "group";
|
||||
projectId: string;
|
||||
projectTitle: string;
|
||||
preview: string;
|
||||
manualPinned: boolean;
|
||||
latestReplyAt: string;
|
||||
latestReplyLabel: string;
|
||||
unreadCount: number;
|
||||
riskLevel: RiskLevel;
|
||||
activeDeviceCount: number;
|
||||
deviceNamesPreview: string[];
|
||||
avatar: {
|
||||
primary: string;
|
||||
secondary?: string;
|
||||
overflowCount?: number;
|
||||
};
|
||||
contextBudgetIndicator: ContextIndicator;
|
||||
contextBudgetSourceNodeId?: string;
|
||||
contextBudgetUpdatedAt?: string;
|
||||
mustFinishBeforeCompaction: boolean;
|
||||
}
|
||||
|
||||
export interface ThreadContextView {
|
||||
snapshot: ThreadContextSnapshot;
|
||||
handoffPackage?: ThreadHandoffPackage;
|
||||
alerts: ThreadContextAlert[];
|
||||
}
|
||||
|
||||
export interface ProjectDetailView {
|
||||
project: Project;
|
||||
devices: Device[];
|
||||
masterIdentity?: MasterIdentitySummary;
|
||||
activeThreadContexts: ThreadContextView[];
|
||||
nextCompactionRiskThreadId?: string;
|
||||
threadsRequiringHandoff: ThreadContextView[];
|
||||
masterContextStrategySummary: string;
|
||||
recentAppLogs: AppLogEntry[];
|
||||
openFaults: OpsFault[];
|
||||
relatedAuditResults: AuditTaskResult[];
|
||||
}
|
||||
|
||||
export interface ThreadContextDetailView {
|
||||
snapshot: ThreadContextSnapshot;
|
||||
handoffPackage?: ThreadHandoffPackage;
|
||||
alerts: ThreadContextAlert[];
|
||||
currentChecklist: string[];
|
||||
masterActions: string[];
|
||||
}
|
||||
|
||||
export interface DeviceWorkspaceView {
|
||||
selectedDevice?: Device;
|
||||
relatedThreads: ThreadContextSnapshot[];
|
||||
activeEnrollment?: DeviceEnrollment;
|
||||
}
|
||||
|
||||
export interface OpsSummaryView {
|
||||
mode: "active" | "idle";
|
||||
faults: OpsFault[];
|
||||
tickets: Array<
|
||||
OpsRepairTicket & {
|
||||
verification?: OpsRepairVerification;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
export interface AuditSummaryView {
|
||||
pendingRequests: AuditTaskRequest[];
|
||||
latestResults: AuditTaskResult[];
|
||||
capabilities: Capability[];
|
||||
}
|
||||
|
||||
export interface SkillInventoryDeviceGroup {
|
||||
device: Device;
|
||||
skills: DeviceSkill[];
|
||||
}
|
||||
|
||||
export interface SkillInventoryView {
|
||||
boundDeviceId?: string;
|
||||
groups: SkillInventoryDeviceGroup[];
|
||||
}
|
||||
|
||||
const levelPriority: Record<ContextBudgetLevel, number> = {
|
||||
critical: 0,
|
||||
urgent: 1,
|
||||
watch: 2,
|
||||
safe: 3,
|
||||
};
|
||||
|
||||
const aiRolePriority: Record<AiAccountRole, number> = {
|
||||
primary: 0,
|
||||
backup: 1,
|
||||
api_fallback: 2,
|
||||
};
|
||||
|
||||
const shanghaiFormatter = new Intl.DateTimeFormat("zh-CN", {
|
||||
timeZone: "Asia/Shanghai",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
const shanghaiDayFormatter = new Intl.DateTimeFormat("zh-CN", {
|
||||
timeZone: "Asia/Shanghai",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
export function formatTimestampLabel(value?: string, fallback = "刚刚") {
|
||||
if (!value) return fallback;
|
||||
if (!value.includes("T")) return value;
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
|
||||
const diff = Date.now() - date.getTime();
|
||||
if (Math.abs(diff) < 60_000) return "刚刚";
|
||||
if (diff >= 0 && diff < 24 * 60 * 60_000) {
|
||||
return shanghaiFormatter.format(date);
|
||||
}
|
||||
return shanghaiDayFormatter.format(date);
|
||||
}
|
||||
|
||||
function compareSnapshots(a: ThreadContextSnapshot, b: ThreadContextSnapshot) {
|
||||
if (a.mustFinishBeforeCompaction !== b.mustFinishBeforeCompaction) {
|
||||
return a.mustFinishBeforeCompaction ? -1 : 1;
|
||||
}
|
||||
if (levelPriority[a.contextBudgetLevel] !== levelPriority[b.contextBudgetLevel]) {
|
||||
return levelPriority[a.contextBudgetLevel] - levelPriority[b.contextBudgetLevel];
|
||||
}
|
||||
return (a.compactionExpectedAt ?? "").localeCompare(b.compactionExpectedAt ?? "");
|
||||
}
|
||||
|
||||
function projectType(project: Project): ConversationItem["conversationType"] {
|
||||
if (project.id === "master-agent") return "master_agent";
|
||||
return project.isGroup ? "group" : "single_device";
|
||||
}
|
||||
|
||||
function aiRoleLabel(role: AiAccountRole) {
|
||||
switch (role) {
|
||||
case "primary":
|
||||
return "主 GPT";
|
||||
case "backup":
|
||||
return "备用 GPT";
|
||||
case "api_fallback":
|
||||
return "API 容灾";
|
||||
default:
|
||||
return role;
|
||||
}
|
||||
}
|
||||
|
||||
function aiProviderLabel(provider: AiProvider) {
|
||||
switch (provider) {
|
||||
case "master_codex_node":
|
||||
return "Master Codex Node";
|
||||
case "openai_api":
|
||||
return "OpenAI API";
|
||||
default:
|
||||
return provider;
|
||||
}
|
||||
}
|
||||
|
||||
function aiStatusLabel(status: AiAccountStatus) {
|
||||
switch (status) {
|
||||
case "ready":
|
||||
return "可用";
|
||||
case "needs_login":
|
||||
return "待登录";
|
||||
case "needs_api_key":
|
||||
return "待配置 Key";
|
||||
case "degraded":
|
||||
return "异常";
|
||||
case "disabled":
|
||||
return "已停用";
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
function canGenerateAiAccount(account: BossState["aiAccounts"][number]) {
|
||||
if (!account.enabled) return false;
|
||||
if (account.provider === "master_codex_node") {
|
||||
return Boolean(account.nodeId?.trim());
|
||||
}
|
||||
return Boolean(account.apiKey?.trim());
|
||||
}
|
||||
|
||||
function getProjectMasterIdentity(state: BossState): MasterIdentitySummary {
|
||||
const accounts = [...state.aiAccounts].sort((a, b) => {
|
||||
if (a.isActive !== b.isActive) return a.isActive ? -1 : 1;
|
||||
if (aiRolePriority[a.role] !== aiRolePriority[b.role]) {
|
||||
return aiRolePriority[a.role] - aiRolePriority[b.role];
|
||||
}
|
||||
return (b.updatedAt ?? "").localeCompare(a.updatedAt ?? "");
|
||||
});
|
||||
const account = accounts.find((item) => item.isActive) ?? accounts.find(canGenerateAiAccount) ?? accounts[0];
|
||||
|
||||
if (!account) {
|
||||
return {
|
||||
label: "API 容灾",
|
||||
role: "api_fallback",
|
||||
roleLabel: aiRoleLabel("api_fallback"),
|
||||
provider: "openai_api",
|
||||
providerLabel: aiProviderLabel("openai_api"),
|
||||
displayName: "未配置 AI 账号",
|
||||
status: "needs_api_key",
|
||||
statusLabel: aiStatusLabel("needs_api_key"),
|
||||
canGenerate: false,
|
||||
note: "请到“我的 > AI 账号”补齐主控 AI 账号。",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
accountId: account.accountId,
|
||||
label: account.label,
|
||||
role: account.role,
|
||||
roleLabel: aiRoleLabel(account.role),
|
||||
provider: account.provider,
|
||||
providerLabel: aiProviderLabel(account.provider),
|
||||
displayName: account.displayName,
|
||||
nodeLabel: account.nodeLabel,
|
||||
model: account.model,
|
||||
status: account.status,
|
||||
statusLabel: aiStatusLabel(account.status),
|
||||
canGenerate: canGenerateAiAccount(account),
|
||||
switchReason: account.switchReason,
|
||||
lastSwitchedAt: account.lastSwitchedAt,
|
||||
note: account.loginStatusNote,
|
||||
};
|
||||
}
|
||||
|
||||
function threadViewsForProject(state: BossState, projectId: string) {
|
||||
return state.threadContextSnapshots
|
||||
.filter((snapshot) => snapshot.projectId === projectId)
|
||||
.sort(compareSnapshots)
|
||||
.map((snapshot) => ({
|
||||
snapshot,
|
||||
handoffPackage: state.threadHandoffPackages.find(
|
||||
(item) => item.fromThreadId === snapshot.threadId && item.packageStatus !== "expired",
|
||||
),
|
||||
alerts: state.threadContextAlerts.filter((alert) => alert.threadId === snapshot.threadId),
|
||||
}));
|
||||
}
|
||||
|
||||
export function getConversationItems(state: BossState): ConversationItem[] {
|
||||
const conversations = state.projects.map((project) => {
|
||||
const devices = state.devices.filter((device) => project.deviceIds.includes(device.id));
|
||||
const threadViews = threadViewsForProject(state, project.id);
|
||||
const topThread = threadViews[0]?.snapshot;
|
||||
|
||||
return {
|
||||
conversationId: `conv-${project.id}`,
|
||||
conversationType: projectType(project),
|
||||
projectId: project.id,
|
||||
projectTitle: project.name,
|
||||
preview: project.preview,
|
||||
manualPinned: Boolean(project.pinned && !project.systemPinned),
|
||||
latestReplyAt: project.lastMessageAt,
|
||||
latestReplyLabel: formatTimestampLabel(project.lastMessageAt),
|
||||
unreadCount: project.unreadCount,
|
||||
riskLevel: project.riskLevel,
|
||||
activeDeviceCount: devices.length,
|
||||
deviceNamesPreview: devices.map((device) => device.name),
|
||||
avatar: {
|
||||
primary: devices[0]?.avatar ?? "A",
|
||||
secondary: project.isGroup ? devices[1]?.avatar : undefined,
|
||||
overflowCount: Math.max(0, devices.length - 2) || undefined,
|
||||
},
|
||||
contextBudgetIndicator: {
|
||||
visible: !project.isGroup && Boolean(topThread),
|
||||
style: "ring_percent",
|
||||
percent: !project.isGroup ? topThread?.contextBudgetRemainingPct : undefined,
|
||||
level: !project.isGroup ? topThread?.contextBudgetLevel : undefined,
|
||||
},
|
||||
contextBudgetSourceNodeId: !project.isGroup ? topThread?.nodeId : undefined,
|
||||
contextBudgetUpdatedAt: !project.isGroup ? topThread?.capturedAt : undefined,
|
||||
mustFinishBeforeCompaction: Boolean(topThread?.mustFinishBeforeCompaction),
|
||||
} satisfies ConversationItem;
|
||||
});
|
||||
|
||||
return conversations.sort((a, b) => {
|
||||
if (a.projectId === "master-agent") return -1;
|
||||
if (b.projectId === "master-agent") return 1;
|
||||
if (a.manualPinned !== b.manualPinned) return a.manualPinned ? -1 : 1;
|
||||
return b.latestReplyAt.localeCompare(a.latestReplyAt);
|
||||
});
|
||||
}
|
||||
|
||||
export function getProjectDetailView(state: BossState, projectId: string): ProjectDetailView | null {
|
||||
const project = state.projects.find((item) => item.id === projectId);
|
||||
if (!project) return null;
|
||||
|
||||
const activeThreadContexts = threadViewsForProject(state, projectId);
|
||||
const threadsRequiringHandoff = activeThreadContexts.filter(
|
||||
(item) =>
|
||||
item.snapshot.mustFinishBeforeCompaction ||
|
||||
item.snapshot.contextBudgetLevel === "urgent" ||
|
||||
item.snapshot.contextBudgetLevel === "critical",
|
||||
);
|
||||
|
||||
const openFaults = state.opsFaults.filter(
|
||||
(fault) => fault.projectId === projectId && fault.status !== "resolved",
|
||||
);
|
||||
const relatedAuditResults = state.auditResults.filter((result) =>
|
||||
state.auditRequests.some(
|
||||
(request) => request.auditRequestId === result.auditRequestId && request.projectId === projectId,
|
||||
),
|
||||
);
|
||||
|
||||
const topRisk = threadsRequiringHandoff[0]?.snapshot ?? activeThreadContexts[0]?.snapshot;
|
||||
const masterContextStrategySummary = topRisk
|
||||
? `${topRisk.title} 需要优先处理,当前 ${topRisk.contextBudgetLevel} ${topRisk.contextBudgetRemainingPct}%${topRisk.mustFinishBeforeCompaction ? ",必须先固化 patch / 测试 / 证据" : ""}。`
|
||||
: "当前没有高风险线程,主 Agent 可以继续按正常优先级调度。";
|
||||
const projectDeviceIds = new Set(project.deviceIds);
|
||||
const recentAppLogs = [...state.appLogs]
|
||||
.filter((log) =>
|
||||
projectId === "master-agent"
|
||||
? true
|
||||
: log.projectId === projectId || projectDeviceIds.has(log.deviceId),
|
||||
)
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
|
||||
.slice(0, 6);
|
||||
|
||||
return {
|
||||
project,
|
||||
devices: state.devices.filter((device) => project.deviceIds.includes(device.id)),
|
||||
masterIdentity: projectId === "master-agent" ? getProjectMasterIdentity(state) : undefined,
|
||||
activeThreadContexts,
|
||||
nextCompactionRiskThreadId: topRisk?.threadId,
|
||||
threadsRequiringHandoff,
|
||||
masterContextStrategySummary,
|
||||
recentAppLogs,
|
||||
openFaults,
|
||||
relatedAuditResults,
|
||||
};
|
||||
}
|
||||
|
||||
export function getThreadContextDetailView(
|
||||
state: BossState,
|
||||
threadId: string,
|
||||
): ThreadContextDetailView | null {
|
||||
const snapshot = state.threadContextSnapshots.find((item) => item.threadId === threadId);
|
||||
if (!snapshot) return null;
|
||||
const handoffPackage = state.threadHandoffPackages.find(
|
||||
(item) => item.fromThreadId === threadId && item.packageStatus !== "expired",
|
||||
);
|
||||
const alerts = state.threadContextAlerts.filter((item) => item.threadId === threadId);
|
||||
const masterActions = Array.from(
|
||||
new Set(alerts.flatMap((alert) => alert.masterActions)),
|
||||
);
|
||||
|
||||
return {
|
||||
snapshot,
|
||||
handoffPackage,
|
||||
alerts,
|
||||
currentChecklist: snapshot.checklist,
|
||||
masterActions,
|
||||
};
|
||||
}
|
||||
|
||||
export function getDeviceWorkspaceView(
|
||||
state: BossState,
|
||||
deviceId?: string,
|
||||
): DeviceWorkspaceView {
|
||||
if (!deviceId) {
|
||||
return {
|
||||
relatedThreads: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
selectedDevice: state.devices.find((item) => item.id === deviceId),
|
||||
relatedThreads: state.threadContextSnapshots.filter((item) => item.nodeId === deviceId),
|
||||
activeEnrollment: state.deviceEnrollments.find((item) => item.deviceId === deviceId),
|
||||
};
|
||||
}
|
||||
|
||||
export function getOpsSummaryView(state: BossState): OpsSummaryView {
|
||||
const tickets = state.opsRepairTickets.map((ticket) => ({
|
||||
...ticket,
|
||||
verification: state.opsRepairVerifications.find((item) => item.ticketId === ticket.ticketId),
|
||||
}));
|
||||
const mode =
|
||||
state.opsFaults.some((fault) => fault.status !== "resolved") ||
|
||||
state.threadContextSnapshots.some(
|
||||
(snapshot) =>
|
||||
snapshot.contextBudgetLevel === "urgent" || snapshot.contextBudgetLevel === "critical",
|
||||
)
|
||||
? "active"
|
||||
: "idle";
|
||||
|
||||
return {
|
||||
mode,
|
||||
faults: [...state.opsFaults].sort((a, b) => b.lastSeenAt.localeCompare(a.lastSeenAt)),
|
||||
tickets: tickets.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)),
|
||||
};
|
||||
}
|
||||
|
||||
export function getAuditSummaryView(state: BossState): AuditSummaryView {
|
||||
const completedIds = new Set(state.auditResults.map((result) => result.auditRequestId));
|
||||
return {
|
||||
pendingRequests: state.auditRequests.filter(
|
||||
(request) => !completedIds.has(request.auditRequestId),
|
||||
),
|
||||
latestResults: [...state.auditResults].sort((a, b) =>
|
||||
b.completedAt.localeCompare(a.completedAt),
|
||||
),
|
||||
capabilities: state.capabilities,
|
||||
};
|
||||
}
|
||||
|
||||
export function getSkillInventoryView(state: BossState): SkillInventoryView {
|
||||
return getSkillInventoryViewForAccount(
|
||||
state,
|
||||
state.user.account,
|
||||
state.user.boundDeviceId,
|
||||
);
|
||||
}
|
||||
|
||||
export function getSkillInventoryViewForAccount(
|
||||
state: BossState,
|
||||
account: string,
|
||||
boundDeviceId?: string,
|
||||
): SkillInventoryView {
|
||||
const devices = state.devices
|
||||
.filter(
|
||||
(device) =>
|
||||
device.account === account || device.id === boundDeviceId,
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (a.id === boundDeviceId) return -1;
|
||||
if (b.id === boundDeviceId) return 1;
|
||||
return b.lastSeenAt.localeCompare(a.lastSeenAt);
|
||||
});
|
||||
|
||||
return {
|
||||
boundDeviceId,
|
||||
groups: devices
|
||||
.map((device) => ({
|
||||
device,
|
||||
skills: state.deviceSkills
|
||||
.filter((skill) => skill.deviceId === device.id)
|
||||
.sort((a, b) => a.name.localeCompare(b.name, "zh-CN")),
|
||||
}))
|
||||
.filter((group) => group.skills.length > 0),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user