1460 lines
50 KiB
TypeScript
1460 lines
50 KiB
TypeScript
import { randomBytes } from "node:crypto";
|
||
import {
|
||
AUTH_SESSION_TTL_MS,
|
||
aiRoleLabel,
|
||
aiProviderLabel,
|
||
appendProjectMessage,
|
||
completeMasterAgentTask,
|
||
getProjectAttachment,
|
||
getAttachmentStorageConfig,
|
||
getProjectAgentControls,
|
||
getLatestDeviceImportDraft,
|
||
getRuntimeAiAccountById,
|
||
getMasterAgentRuntimeAccount,
|
||
getMasterAgentTask,
|
||
previewDeviceImportResolution,
|
||
queueMasterAgentTask,
|
||
readState,
|
||
isDispatchableThreadProject,
|
||
updateAttachmentAnalysisResult,
|
||
updateAiAccountHealth,
|
||
} from "@/lib/boss-data";
|
||
import type { DispatchPlanTarget, Project, ProjectAgentControls, ReasoningEffort } from "@/lib/boss-data";
|
||
import { canInlineAttachmentText, extractAttachmentTextExcerpt } from "@/lib/boss-attachments";
|
||
import { readAliyunOssObjectBuffer } from "@/lib/boss-storage-aliyun-oss";
|
||
import { readServerFileAttachmentBuffer } from "@/lib/boss-storage-server-file";
|
||
|
||
type MasterAgentReplyState = "queued" | "running" | "completed";
|
||
const OPENAI_MASTER_AGENT_DEVICE_ID = "master-agent-openai";
|
||
|
||
type QueuedMasterAgentReplyEnvelope = {
|
||
ok: true;
|
||
accountId: string;
|
||
taskId: string;
|
||
masterReplyState: MasterAgentReplyState;
|
||
task: {
|
||
taskId: string;
|
||
taskType: "conversation_reply";
|
||
status: MasterAgentReplyState;
|
||
};
|
||
};
|
||
|
||
function buildAgentControlsDigest(agentControls?: ProjectAgentControls | null) {
|
||
if (!agentControls) {
|
||
return "当前对话覆盖:无";
|
||
}
|
||
|
||
return [
|
||
"当前对话覆盖:",
|
||
`model=${agentControls.modelOverride ?? "默认"}`,
|
||
`reasoning=${agentControls.reasoningEffortOverride ?? "默认"}`,
|
||
].join(" ");
|
||
}
|
||
|
||
function buildMasterAgentInstructions() {
|
||
return [
|
||
"你是 Boss 控制台的主 Agent。",
|
||
"你要基于当前运行时状态给出中文回复,要求直接、可执行、便于继续联调。",
|
||
"优先关注线程上下文预算、must_finish_before_compaction、最新 APP 日志、设备在线状态和 OTA 状态。",
|
||
"如果信息不足,就明确说缺什么;不要编造设备状态或执行结果。",
|
||
"如果用户要继续开发,默认给出下一步实现/验证动作,而不是泛泛安慰。",
|
||
"保持回答简洁,通常 3-6 句即可。",
|
||
].join("\n");
|
||
}
|
||
|
||
function buildThreadConversationReplyPrompt(project: Project, requestText: string) {
|
||
return [
|
||
"你正在代表某个 Codex 线程回复 Boss 控制台里的单线程会话。",
|
||
"你不是主 Agent,不要使用“主 Agent”口吻,不要写总结,不要解释调度过程。",
|
||
"请直接像该线程本人一样,用中文回复用户当前这条消息。",
|
||
"如果信息不足,要明确说缺什么;不要假装已经执行过设备操作。",
|
||
"输出要求:只输出线程要回给用户的正文,不要输出 JSON、代码块或额外前缀。",
|
||
`threadProjectId: ${project.id}`,
|
||
`threadTitle: ${project.threadMeta.threadDisplayName}`,
|
||
`folderName: ${project.threadMeta.folderName}`,
|
||
`deviceIds: ${project.deviceIds.join(",")}`,
|
||
`requestText: ${requestText}`,
|
||
].join("\n");
|
||
}
|
||
|
||
function buildRuntimeDigest(
|
||
state: Awaited<ReturnType<typeof readState>>,
|
||
requestText: string,
|
||
currentSessionExpiresAt?: string,
|
||
agentControls?: ProjectAgentControls | null,
|
||
) {
|
||
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,
|
||
buildAgentControlsDigest(agentControls),
|
||
]
|
||
.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();
|
||
const lowered = trimmed.toLowerCase();
|
||
if (lowered.includes("network is unreachable") || lowered.includes("enetunreach")) {
|
||
return "服务器当前无法访问 api.openai.com,请先恢复服务器出网,或先切回 Master Codex Node。";
|
||
}
|
||
if (lowered.includes("fetch failed") || lowered.includes("connect timeout") || lowered.includes("timed out")) {
|
||
return "服务器当前无法连接 OpenAI API,请检查出网、代理或防火墙配置。";
|
||
}
|
||
if (!trimmed) return "主 Agent 当前调用模型失败。";
|
||
if (trimmed.length <= 240) return trimmed;
|
||
return `${trimmed.slice(0, 237)}...`;
|
||
}
|
||
|
||
function normalizeOpenAiFetchFailure(error: unknown) {
|
||
if (error instanceof Error) {
|
||
const causeCode =
|
||
typeof (error as Error & { cause?: { code?: string } }).cause?.code === "string"
|
||
? (error as Error & { cause?: { code?: string } }).cause?.code
|
||
: "";
|
||
const causeMessage =
|
||
(error as Error & { cause?: { message?: string } }).cause?.message?.trim() || "";
|
||
return normalizeOpenAiError([error.message, causeCode, causeMessage].filter(Boolean).join(" "));
|
||
}
|
||
return normalizeOpenAiError(String(error));
|
||
}
|
||
|
||
function fallbackAiRolePriority(role: "primary" | "backup" | "api_fallback") {
|
||
switch (role) {
|
||
case "primary":
|
||
return 0;
|
||
case "backup":
|
||
return 1;
|
||
case "api_fallback":
|
||
return 2;
|
||
default:
|
||
return 9;
|
||
}
|
||
}
|
||
|
||
async function findFallbackOpenAiAccount(excludedAccountId?: string) {
|
||
const state = await readState();
|
||
return [...state.aiAccounts]
|
||
.filter(
|
||
(account) =>
|
||
account.accountId !== excludedAccountId &&
|
||
account.enabled &&
|
||
account.provider === "openai_api" &&
|
||
Boolean(account.apiKey?.trim()),
|
||
)
|
||
.sort((left, right) => {
|
||
const roleDelta = fallbackAiRolePriority(left.role) - fallbackAiRolePriority(right.role);
|
||
if (roleDelta !== 0) return roleDelta;
|
||
return (right.updatedAt ?? "").localeCompare(left.updatedAt ?? "");
|
||
})[0];
|
||
}
|
||
|
||
async function replyViaOpenAiAccount(params: {
|
||
account: Awaited<ReturnType<typeof findFallbackOpenAiAccount>>;
|
||
requestText: string;
|
||
currentSessionExpiresAt?: string;
|
||
senderLabel: string;
|
||
agentControls?: ProjectAgentControls | null;
|
||
}) {
|
||
if (!params.account?.apiKey?.trim()) {
|
||
throw new Error("OPENAI_ACCOUNT_NOT_CONFIGURED");
|
||
}
|
||
|
||
const generated = await generateOpenAiReply({
|
||
apiKey: params.account.apiKey,
|
||
model: params.agentControls?.modelOverride || params.account.model || "gpt-5.4",
|
||
reasoningEffort: params.agentControls?.reasoningEffortOverride || "medium",
|
||
requestText: params.requestText,
|
||
currentSessionExpiresAt: params.currentSessionExpiresAt,
|
||
agentControls: params.agentControls,
|
||
});
|
||
|
||
await appendMasterAgentSystemReply(generated.content, params.senderLabel);
|
||
await updateAiAccountHealth({
|
||
accountId: params.account.accountId,
|
||
status: "ready",
|
||
lastValidatedAt: new Date().toISOString(),
|
||
lastUsedAt: new Date().toISOString(),
|
||
});
|
||
|
||
return {
|
||
ok: true as const,
|
||
accountId: params.account.accountId,
|
||
requestId: generated.requestId,
|
||
};
|
||
}
|
||
|
||
async function generateOpenAiReply(params: {
|
||
apiKey: string;
|
||
model: string;
|
||
reasoningEffort: ReasoningEffort;
|
||
requestText: string;
|
||
currentSessionExpiresAt?: string;
|
||
agentControls?: ProjectAgentControls | null;
|
||
}) {
|
||
const state = await readState();
|
||
let response: Response;
|
||
try {
|
||
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: params.reasoningEffort },
|
||
instructions: buildMasterAgentInstructions(),
|
||
input: buildRuntimeDigest(
|
||
state,
|
||
params.requestText,
|
||
params.currentSessionExpiresAt,
|
||
params.agentControls,
|
||
),
|
||
}),
|
||
signal: AbortSignal.timeout(45_000),
|
||
});
|
||
} catch (error) {
|
||
throw new Error(normalizeOpenAiFetchFailure(error));
|
||
}
|
||
|
||
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,
|
||
};
|
||
}
|
||
|
||
function buildMasterOpenAiReplyPrompt(
|
||
state: Awaited<ReturnType<typeof readState>>,
|
||
requestText: string,
|
||
currentSessionExpiresAt?: string,
|
||
agentControls?: ProjectAgentControls | null,
|
||
) {
|
||
return [
|
||
buildMasterAgentInstructions(),
|
||
"",
|
||
buildRuntimeDigest(state, requestText, currentSessionExpiresAt, agentControls),
|
||
].join("\n");
|
||
}
|
||
|
||
async function queueAndStartOpenAiMasterAgentReply(params: {
|
||
taskId: string;
|
||
deviceId: string;
|
||
requestText: string;
|
||
currentSessionExpiresAt?: string;
|
||
apiKey: string;
|
||
model: string;
|
||
reasoningEffort: ReasoningEffort;
|
||
agentControls?: ProjectAgentControls | null;
|
||
}) {
|
||
const timer = setTimeout(() => {
|
||
void (async () => {
|
||
const task = await getMasterAgentTask(params.taskId);
|
||
if (!task || task.status !== "queued") {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const generated = await generateOpenAiReply({
|
||
apiKey: params.apiKey,
|
||
model: params.model,
|
||
reasoningEffort: params.reasoningEffort,
|
||
requestText: params.requestText,
|
||
currentSessionExpiresAt: params.currentSessionExpiresAt,
|
||
agentControls: params.agentControls,
|
||
});
|
||
|
||
await completeMasterAgentTask({
|
||
taskId: params.taskId,
|
||
deviceId: params.deviceId,
|
||
status: "completed",
|
||
replyBody: generated.content,
|
||
requestId: generated.requestId,
|
||
});
|
||
} catch (error) {
|
||
await completeMasterAgentTask({
|
||
taskId: params.taskId,
|
||
deviceId: params.deviceId,
|
||
status: "failed",
|
||
errorMessage: error instanceof Error ? error.message : "主 Agent 当前调用模型失败。",
|
||
});
|
||
}
|
||
})();
|
||
}, 0);
|
||
timer.unref?.();
|
||
}
|
||
|
||
async function enqueueOpenAiMasterAgentReply(params: {
|
||
accountId: string;
|
||
accountLabel: string;
|
||
requestMessageId?: string;
|
||
requestText: string;
|
||
requestedBy: string;
|
||
requestedByAccount: string;
|
||
currentSessionExpiresAt?: string;
|
||
apiKey: string;
|
||
model: string;
|
||
reasoningEffort: ReasoningEffort;
|
||
agentControls?: ProjectAgentControls | null;
|
||
}) {
|
||
const state = await readState();
|
||
const task = await queueMasterAgentTask({
|
||
requestMessageId: params.requestMessageId ?? "master-agent-manual",
|
||
requestText: params.requestText,
|
||
executionPrompt: buildMasterOpenAiReplyPrompt(
|
||
state,
|
||
params.requestText,
|
||
params.currentSessionExpiresAt,
|
||
params.agentControls,
|
||
),
|
||
requestedBy: params.requestedBy,
|
||
requestedByAccount: params.requestedByAccount,
|
||
deviceId: OPENAI_MASTER_AGENT_DEVICE_ID,
|
||
accountId: params.accountId,
|
||
accountLabel: params.accountLabel,
|
||
});
|
||
void queueAndStartOpenAiMasterAgentReply({
|
||
taskId: task.taskId,
|
||
deviceId: OPENAI_MASTER_AGENT_DEVICE_ID,
|
||
requestText: params.requestText,
|
||
currentSessionExpiresAt: params.currentSessionExpiresAt,
|
||
apiKey: params.apiKey,
|
||
model: params.model,
|
||
reasoningEffort: params.reasoningEffort,
|
||
agentControls: params.agentControls,
|
||
});
|
||
|
||
const queuedReply: QueuedMasterAgentReplyEnvelope = {
|
||
ok: true as const,
|
||
accountId: params.accountId,
|
||
taskId: task.taskId,
|
||
masterReplyState: "queued" as const,
|
||
task: {
|
||
taskId: task.taskId,
|
||
taskType: "conversation_reply" as const,
|
||
status: "queued" as const,
|
||
},
|
||
};
|
||
return queuedReply;
|
||
}
|
||
|
||
export async function probeOpenAiApiAccount(params: {
|
||
apiKey: string;
|
||
model?: string;
|
||
}) {
|
||
const apiKey = params.apiKey.trim();
|
||
if (!apiKey) {
|
||
throw new Error("当前账号还没有可用的 OpenAI API Key。");
|
||
}
|
||
|
||
const model = params.model?.trim() || "gpt-5.4";
|
||
let response: Response;
|
||
try {
|
||
response = await fetch("https://api.openai.com/v1/responses", {
|
||
method: "POST",
|
||
headers: {
|
||
Authorization: `Bearer ${apiKey}`,
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
model,
|
||
reasoning: { effort: "low" },
|
||
instructions: "你正在执行 OpenAI API 连接自检。请只回复“连接正常”。",
|
||
input: "请只回复“连接正常”。",
|
||
}),
|
||
signal: AbortSignal.timeout(15_000),
|
||
});
|
||
} catch (error) {
|
||
throw new Error(normalizeOpenAiFetchFailure(error));
|
||
}
|
||
|
||
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) || "连接正常。";
|
||
return {
|
||
ok: true as const,
|
||
message: content,
|
||
requestId,
|
||
model,
|
||
};
|
||
}
|
||
|
||
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,
|
||
agentControls?: ProjectAgentControls | null,
|
||
) {
|
||
return [
|
||
"你是 Boss 控制台的主 Agent,运行在用户自己的 Master Codex Node 上。",
|
||
"请结合下面的运行时状态和用户消息,直接给出中文回复。",
|
||
"如果你认为需要继续在当前仓库里推进实现、排障或验证,可以直接说明你下一步会做什么;如果必须先做交接或收尾,也要明确说出原因。",
|
||
"保持简洁,优先给出结论、动作、验证点。",
|
||
buildAgentControlsDigest(agentControls),
|
||
"",
|
||
buildRuntimeDigest(state, requestText, currentSessionExpiresAt, agentControls),
|
||
].join("\n");
|
||
}
|
||
|
||
function summarizeDispatchRequest(requestText: string) {
|
||
const compact = requestText.trim().replace(/\s+/g, " ");
|
||
if (!compact) {
|
||
return "用户发来新的群聊协作请求";
|
||
}
|
||
if (compact.length <= 36) {
|
||
return compact;
|
||
}
|
||
return `${compact.slice(0, 33)}...`;
|
||
}
|
||
|
||
function collectGroupDispatchTargets(
|
||
state: Awaited<ReturnType<typeof readState>>,
|
||
project: Project,
|
||
requestText: string,
|
||
): DispatchPlanTarget[] {
|
||
const members =
|
||
project.groupMembers.length > 0
|
||
? project.groupMembers
|
||
: project.deviceIds.map((deviceId) => ({
|
||
projectId: project.id,
|
||
deviceId,
|
||
threadId: project.threadMeta.threadId,
|
||
threadDisplayName: project.threadMeta.threadDisplayName,
|
||
folderName: project.threadMeta.folderName,
|
||
}));
|
||
|
||
return members
|
||
.map((member) => {
|
||
const candidate = state.projects.find((projectCandidate) => projectCandidate.id === member.projectId);
|
||
if (!candidate) {
|
||
throw new Error("DISPATCH_TARGET_PROJECT_NOT_FOUND");
|
||
}
|
||
return candidate;
|
||
})
|
||
.filter((candidate) => isDispatchableThreadProject(candidate))
|
||
.map((candidate) => ({
|
||
deviceId: candidate.deviceIds[0] ?? candidate.id,
|
||
projectId: candidate.id,
|
||
threadId: candidate.threadMeta.threadId,
|
||
threadDisplayName: candidate.threadMeta.threadDisplayName,
|
||
folderName: candidate.threadMeta.folderName,
|
||
codexFolderRef: candidate.threadMeta.codexFolderRef,
|
||
codexThreadRef: candidate.threadMeta.codexThreadRef,
|
||
reason: `群聊消息“${summarizeDispatchRequest(requestText)}”需要该线程补充状态或执行建议。`,
|
||
}))
|
||
.filter((target, index, array) => {
|
||
const signature = `${target.projectId}::${target.deviceId}::${target.threadId}`;
|
||
return array.findIndex((item) => `${item.projectId}::${item.deviceId}::${item.threadId}` === signature) === index;
|
||
});
|
||
}
|
||
|
||
function summarizeGroupDispatchPlan(requestText: string, targets: DispatchPlanTarget[]) {
|
||
const targetLabels = targets.map((target) => target.threadDisplayName).filter(Boolean);
|
||
return `主 Agent 建议先按线程分发这条群聊消息:${summarizeDispatchRequest(requestText)}${targetLabels.length > 0 ? `。建议目标:${targetLabels.join("、")}` : ""}`;
|
||
}
|
||
|
||
function buildGroupDispatchPlanPrompt(project: Project, requestText: string) {
|
||
const memberDigest = (project.groupMembers.length > 0
|
||
? project.groupMembers
|
||
: project.deviceIds.map((deviceId) => ({
|
||
projectId: project.id,
|
||
deviceId,
|
||
threadId: project.threadMeta.threadId,
|
||
threadDisplayName: project.threadMeta.threadDisplayName,
|
||
folderName: project.threadMeta.folderName,
|
||
}))
|
||
)
|
||
.map(
|
||
(member) =>
|
||
`${member.projectId} / ${member.threadDisplayName} / ${member.folderName} / device=${member.deviceId}`,
|
||
)
|
||
.join("\n");
|
||
|
||
return [
|
||
"你正在处理 Boss 控制台的群聊分发建议任务。",
|
||
"目标不是直接回复用户,而是为这条群聊消息推荐后续需要分发到哪些线程。",
|
||
"当前服务端会优先使用已有群成员和线程映射做 recommendation workflow。",
|
||
`groupProjectId: ${project.id}`,
|
||
`groupProjectName: ${project.name}`,
|
||
`requestText: ${requestText}`,
|
||
"groupMembers:",
|
||
memberDigest || "无",
|
||
].join("\n");
|
||
}
|
||
|
||
type GroupDispatchRecommendationResult =
|
||
| {
|
||
ok: true;
|
||
taskId: string;
|
||
status: "completed";
|
||
dispatchPlan: NonNullable<
|
||
Awaited<ReturnType<typeof completeMasterAgentTask>>
|
||
>["dispatchPlan"] | null;
|
||
}
|
||
| {
|
||
ok: false;
|
||
taskId: string;
|
||
status: "failed";
|
||
dispatchPlan: null;
|
||
error: string;
|
||
};
|
||
|
||
async function resolveGroupDispatchPlanTask(taskId: string): Promise<GroupDispatchRecommendationResult> {
|
||
const task = await getMasterAgentTask(taskId);
|
||
if (!task) {
|
||
throw new Error("MASTER_AGENT_TASK_NOT_FOUND");
|
||
}
|
||
if (task.taskType !== "group_dispatch_plan") {
|
||
throw new Error("MASTER_AGENT_TASK_TYPE_INVALID");
|
||
}
|
||
|
||
try {
|
||
const state = await readState();
|
||
const project = state.projects.find((item) => item.id === task.projectId);
|
||
if (!project) {
|
||
throw new Error("PROJECT_NOT_FOUND");
|
||
}
|
||
if (!project.isGroup) {
|
||
throw new Error("PROJECT_NOT_GROUP_CHAT");
|
||
}
|
||
|
||
const targets = collectGroupDispatchTargets(state, project, task.requestText);
|
||
if (targets.length === 0) {
|
||
throw new Error("GROUP_DISPATCH_TARGETS_REQUIRED");
|
||
}
|
||
|
||
const completedTask = await completeMasterAgentTask({
|
||
taskId: task.taskId,
|
||
deviceId: task.deviceId,
|
||
status: "completed",
|
||
dispatchPlan: {
|
||
summary: summarizeGroupDispatchPlan(task.requestText, targets),
|
||
targets,
|
||
},
|
||
});
|
||
|
||
return {
|
||
ok: true as const,
|
||
taskId: task.taskId,
|
||
status: "completed" as const,
|
||
dispatchPlan: completedTask.dispatchPlan ?? null,
|
||
};
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : "GROUP_DISPATCH_PLAN_FAILED";
|
||
await completeMasterAgentTask({
|
||
taskId: task.taskId,
|
||
deviceId: task.deviceId,
|
||
status: "failed",
|
||
errorMessage: message,
|
||
});
|
||
return {
|
||
ok: false as const,
|
||
taskId: task.taskId,
|
||
status: "failed" as const,
|
||
dispatchPlan: null,
|
||
error: message,
|
||
};
|
||
}
|
||
}
|
||
|
||
export async function queueGroupDispatchPlan(params: {
|
||
groupProjectId: string;
|
||
requestMessageId: string;
|
||
requestText: string;
|
||
requestedBy: string;
|
||
}): Promise<GroupDispatchRecommendationResult> {
|
||
const state = await readState();
|
||
const project = state.projects.find((item) => item.id === params.groupProjectId);
|
||
if (!project) {
|
||
throw new Error("PROJECT_NOT_FOUND");
|
||
}
|
||
if (!project.isGroup) {
|
||
throw new Error("PROJECT_NOT_GROUP_CHAT");
|
||
}
|
||
|
||
const task = await queueMasterAgentTask({
|
||
projectId: project.id,
|
||
taskType: "group_dispatch_plan",
|
||
requestMessageId: params.requestMessageId,
|
||
requestText: params.requestText,
|
||
executionPrompt: buildGroupDispatchPlanPrompt(project, params.requestText),
|
||
requestedBy: params.requestedBy,
|
||
requestedByAccount: params.requestedBy,
|
||
deviceId: state.user.boundDeviceId || "mac-studio",
|
||
});
|
||
|
||
return resolveGroupDispatchPlanTask(task.taskId);
|
||
}
|
||
|
||
export async function queueThreadConversationReplyTask(params: {
|
||
projectId: string;
|
||
requestMessageId: string;
|
||
requestText: string;
|
||
requestedBy: string;
|
||
requestedByAccount: string;
|
||
}) {
|
||
const state = await readState();
|
||
const project = state.projects.find((item) => item.id === params.projectId);
|
||
if (!project) {
|
||
throw new Error("PROJECT_NOT_FOUND");
|
||
}
|
||
if (project.isGroup) {
|
||
throw new Error("PROJECT_NOT_SINGLE_THREAD");
|
||
}
|
||
if (project.id === "master-agent") {
|
||
throw new Error("PROJECT_NOT_THREAD_CONVERSATION");
|
||
}
|
||
|
||
const deviceId = project.deviceIds[0] || state.user.boundDeviceId || "mac-studio";
|
||
return queueMasterAgentTask({
|
||
projectId: project.id,
|
||
taskType: "conversation_reply",
|
||
requestMessageId: params.requestMessageId,
|
||
requestText: params.requestText,
|
||
executionPrompt: buildThreadConversationReplyPrompt(project, params.requestText),
|
||
requestedBy: params.requestedBy,
|
||
requestedByAccount: params.requestedByAccount,
|
||
deviceId,
|
||
targetProjectId: project.id,
|
||
targetThreadId: project.threadMeta.threadId,
|
||
targetThreadDisplayName: project.threadMeta.threadDisplayName,
|
||
targetCodexThreadRef: project.threadMeta.codexThreadRef,
|
||
targetCodexFolderRef: project.threadMeta.codexFolderRef,
|
||
});
|
||
}
|
||
|
||
function buildDeviceImportResolutionPrompt(params: {
|
||
deviceName: string;
|
||
deviceId: string;
|
||
draftId: string;
|
||
selectedCandidates: Array<{
|
||
candidateId: string;
|
||
threadDisplayName: string;
|
||
folderName: string;
|
||
lastActiveAt: string;
|
||
}>;
|
||
existingProjects: string[];
|
||
}) {
|
||
return [
|
||
"你正在处理 Boss 控制台的设备导入决议任务。",
|
||
"请根据候选线程和现有会话,给出导入建议。",
|
||
"输出必须是 JSON,对象结构如下:",
|
||
'{ "summary": "一句中文摘要", "items": [{ "candidateId": "...", "action": "create_thread_conversation|attach_existing|skip", "targetProjectId": "可选", "reason": "中文原因" }] }',
|
||
"要求:",
|
||
"1. 每个 candidateId 最多出现一次。",
|
||
"2. 如果 action=attach_existing,尽量给出 targetProjectId。",
|
||
"3. 如果信息不足,也必须给出 reason,不要输出额外解释文本。",
|
||
"",
|
||
`deviceName: ${params.deviceName}`,
|
||
`deviceId: ${params.deviceId}`,
|
||
`draftId: ${params.draftId}`,
|
||
"selectedCandidates:",
|
||
params.selectedCandidates
|
||
.map(
|
||
(candidate) =>
|
||
`${candidate.candidateId} / ${candidate.threadDisplayName} / ${candidate.folderName} / ${candidate.lastActiveAt}`,
|
||
)
|
||
.join("\n") || "无",
|
||
"",
|
||
"existingProjects:",
|
||
params.existingProjects.join("\n") || "无",
|
||
].join("\n");
|
||
}
|
||
|
||
type DeviceImportResolutionTaskResult =
|
||
| {
|
||
ok: true;
|
||
taskId: string;
|
||
status: "completed";
|
||
draft: NonNullable<Awaited<ReturnType<typeof getLatestDeviceImportDraft>>["draft"]>;
|
||
resolution: NonNullable<Awaited<ReturnType<typeof getLatestDeviceImportDraft>>["resolution"]>;
|
||
}
|
||
| {
|
||
ok: false;
|
||
taskId: string;
|
||
status: "failed";
|
||
draft: Awaited<ReturnType<typeof getLatestDeviceImportDraft>>["draft"];
|
||
resolution: Awaited<ReturnType<typeof getLatestDeviceImportDraft>>["resolution"];
|
||
error: string;
|
||
};
|
||
|
||
async function resolveDeviceImportResolutionTask(taskId: string): Promise<DeviceImportResolutionTaskResult> {
|
||
const task = await getMasterAgentTask(taskId);
|
||
if (!task) {
|
||
throw new Error("MASTER_AGENT_TASK_NOT_FOUND");
|
||
}
|
||
if (task.taskType !== "device_import_resolution" || !task.deviceImportDraftId) {
|
||
throw new Error("MASTER_AGENT_TASK_TYPE_INVALID");
|
||
}
|
||
|
||
const draftRecord = await readState();
|
||
const draft = draftRecord.deviceImportDrafts.find((item) => item.draftId === task.deviceImportDraftId);
|
||
if (!draft) {
|
||
throw new Error("DEVICE_IMPORT_DRAFT_NOT_FOUND");
|
||
}
|
||
|
||
try {
|
||
const proposal = await previewDeviceImportResolution({ deviceId: draft.deviceId });
|
||
await completeMasterAgentTask({
|
||
taskId: task.taskId,
|
||
deviceId: task.deviceId,
|
||
status: "completed",
|
||
replyBody: JSON.stringify(
|
||
{
|
||
summary: proposal.summary,
|
||
items: proposal.items.map((item) => ({
|
||
candidateId: item.candidateId,
|
||
action: item.action,
|
||
targetProjectId: item.targetProjectId,
|
||
reason: item.reason,
|
||
})),
|
||
},
|
||
null,
|
||
2,
|
||
),
|
||
});
|
||
|
||
const latest = await getLatestDeviceImportDraft(draft.deviceId);
|
||
return {
|
||
ok: true as const,
|
||
taskId: task.taskId,
|
||
status: "completed" as const,
|
||
draft: latest.draft!,
|
||
resolution: latest.resolution!,
|
||
};
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : "DEVICE_IMPORT_RESOLUTION_FAILED";
|
||
await completeMasterAgentTask({
|
||
taskId: task.taskId,
|
||
deviceId: task.deviceId,
|
||
status: "failed",
|
||
errorMessage: message,
|
||
});
|
||
const latest = await getLatestDeviceImportDraft(draft.deviceId);
|
||
return {
|
||
ok: false as const,
|
||
taskId: task.taskId,
|
||
status: "failed" as const,
|
||
draft: latest.draft,
|
||
resolution: latest.resolution,
|
||
error: message,
|
||
};
|
||
}
|
||
}
|
||
|
||
export async function queueDeviceImportResolutionTask(params: {
|
||
deviceId: string;
|
||
reviewedBy: string;
|
||
}) {
|
||
const state = await readState();
|
||
const draft = state.deviceImportDrafts.find((item) => item.deviceId === params.deviceId);
|
||
if (!draft) {
|
||
throw new Error("DEVICE_IMPORT_DRAFT_NOT_FOUND");
|
||
}
|
||
if (draft.selectedCandidateIds.length === 0) {
|
||
throw new Error("DEVICE_IMPORT_SELECTION_REQUIRED");
|
||
}
|
||
const device = state.devices.find((item) => item.id === params.deviceId);
|
||
if (!device) {
|
||
throw new Error("DEVICE_NOT_FOUND");
|
||
}
|
||
|
||
const selectedCandidates = draft.candidates.filter((candidate) =>
|
||
draft.selectedCandidateIds.includes(candidate.candidateId),
|
||
);
|
||
const task = await queueMasterAgentTask({
|
||
projectId: "master-agent",
|
||
taskType: "device_import_resolution",
|
||
requestMessageId: draft.draftId,
|
||
requestText: `请审核设备 ${device.name} 的线程导入建议`,
|
||
executionPrompt: buildDeviceImportResolutionPrompt({
|
||
deviceName: device.name,
|
||
deviceId: device.id,
|
||
draftId: draft.draftId,
|
||
selectedCandidates: selectedCandidates.map((candidate) => ({
|
||
candidateId: candidate.candidateId,
|
||
threadDisplayName: candidate.threadDisplayName,
|
||
folderName: candidate.folderName,
|
||
lastActiveAt: candidate.lastActiveAt,
|
||
})),
|
||
existingProjects: state.projects
|
||
.filter((project) => !project.isGroup)
|
||
.map(
|
||
(project) =>
|
||
`${project.id} / ${project.threadMeta.threadDisplayName} / ${project.threadMeta.folderName} / devices=${project.deviceIds.join(",")}`,
|
||
),
|
||
}),
|
||
requestedBy: params.reviewedBy,
|
||
requestedByAccount: params.reviewedBy,
|
||
deviceId: state.user.boundDeviceId || "mac-studio",
|
||
deviceImportDraftId: draft.draftId,
|
||
});
|
||
|
||
return resolveDeviceImportResolutionTask(task.taskId);
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
function resolveBossPublicBaseUrl() {
|
||
const configured = process.env.BOSS_PUBLIC_BASE_URL?.trim();
|
||
return configured && /^https?:\/\//i.test(configured) ? configured.replace(/\/+$/, "") : "https://boss.hyzq.net";
|
||
}
|
||
|
||
async function buildAttachmentAnalysisContext(params: {
|
||
attachment: NonNullable<Awaited<ReturnType<typeof getProjectAttachment>>>["attachment"];
|
||
}) {
|
||
const attachment = params.attachment;
|
||
let excerpt = "";
|
||
try {
|
||
if (canInlineAttachmentText(attachment)) {
|
||
let buffer: Buffer | Uint8Array = Buffer.alloc(0);
|
||
if (attachment.storageBackend === "server_file") {
|
||
buffer = await readServerFileAttachmentBuffer(attachment.storagePath);
|
||
} else if (attachment.storageBackend === "aliyun_oss") {
|
||
if (attachment.storageSnapshot?.provider === "aliyun_oss") {
|
||
buffer = await readAliyunOssObjectBuffer(
|
||
{
|
||
enabled: true,
|
||
accessKeyId: attachment.storageSnapshot.accessKeyId,
|
||
accessKeySecretEncrypted: attachment.storageSnapshot.accessKeySecretEncrypted,
|
||
bucket: attachment.storageSnapshot.bucket,
|
||
endpoint: attachment.storageSnapshot.endpoint,
|
||
region: attachment.storageSnapshot.region,
|
||
prefix: attachment.storageSnapshot.prefix,
|
||
},
|
||
attachment.storagePath,
|
||
);
|
||
} else {
|
||
const currentConfig = await getAttachmentStorageConfig(attachment.uploadedBy);
|
||
if (
|
||
currentConfig.mode === "oss" &&
|
||
currentConfig.ossProvider === "aliyun_oss" &&
|
||
currentConfig.aliyunOss
|
||
) {
|
||
buffer = await readAliyunOssObjectBuffer(currentConfig.aliyunOss, attachment.storagePath);
|
||
}
|
||
}
|
||
}
|
||
excerpt = extractAttachmentTextExcerpt(buffer);
|
||
}
|
||
} catch {
|
||
excerpt = "";
|
||
}
|
||
return {
|
||
textExcerpt: excerpt,
|
||
};
|
||
}
|
||
|
||
function buildAttachmentAnalysisPrompt(params: {
|
||
projectId: string;
|
||
projectName: string;
|
||
attachment: NonNullable<Awaited<ReturnType<typeof getProjectAttachment>>>["attachment"];
|
||
messageBody: string;
|
||
requestedBy: string;
|
||
requestedByAccount: string;
|
||
attachmentDownloadUrl: string;
|
||
attachmentTextExcerpt?: string;
|
||
}) {
|
||
const attachment = params.attachment;
|
||
return [
|
||
"你是 Boss 控制台的附件分析主 Agent。",
|
||
"请根据下面的附件元数据、可下载地址,以及你能实际读取到的附件内容进行分析。",
|
||
"如果需要读取原始文件,请优先使用 curl、python 或系统工具下载并检查该附件。",
|
||
"如果你无法直接读取原始内容,不要假装已经看过内容,必须明确说明限制,并只基于你实际拿到的内容给出判断。",
|
||
"输出要求:",
|
||
"1. 一句话结论",
|
||
"2. 内容摘要或可见特征",
|
||
"3. 风险或异常",
|
||
"4. 建议动作",
|
||
"",
|
||
`projectId: ${params.projectId}`,
|
||
`projectName: ${params.projectName}`,
|
||
`requestedBy: ${params.requestedBy}`,
|
||
`requestedByAccount: ${params.requestedByAccount}`,
|
||
`attachmentId: ${attachment.attachmentId}`,
|
||
`fileName: ${attachment.fileName}`,
|
||
`mimeType: ${attachment.mimeType}`,
|
||
`fileSizeBytes: ${attachment.fileSizeBytes}`,
|
||
`attachmentKind: ${attachment.attachmentKind}`,
|
||
`storageBackend: ${attachment.storageBackend}`,
|
||
`storagePath: ${attachment.storagePath}`,
|
||
`previewAvailable: ${attachment.previewAvailable ? "yes" : "no"}`,
|
||
`uploadedAt: ${attachment.uploadedAt}`,
|
||
`uploadedBy: ${attachment.uploadedBy}`,
|
||
`analysisState: ${attachment.analysisState}`,
|
||
`downloadUrl: ${params.attachmentDownloadUrl}`,
|
||
"",
|
||
"原始消息:",
|
||
params.messageBody || "无",
|
||
"",
|
||
"如果附件可以直接解析文本,请优先基于文本内容进行判断。",
|
||
"文本摘录:",
|
||
params.attachmentTextExcerpt || "无可直接内嵌的文本摘录,请按需下载原文件后自行读取。",
|
||
].join("\n");
|
||
}
|
||
|
||
export async function queueAttachmentAnalysisTask(params: {
|
||
projectId: string;
|
||
attachmentId: string;
|
||
requestMessageId: string;
|
||
requestedBy: string;
|
||
requestedByAccount: string;
|
||
markProcessing?: boolean;
|
||
publicBaseUrl?: string;
|
||
}) {
|
||
const record = await getProjectAttachment(params.projectId, params.attachmentId);
|
||
if (!record) {
|
||
throw new Error("ATTACHMENT_NOT_FOUND");
|
||
}
|
||
|
||
const state = await readState();
|
||
const taskId = `mastertask-${randomBytes(4).toString("hex")}`;
|
||
const attachmentDownloadToken = randomBytes(12).toString("hex");
|
||
const attachmentDownloadExpiresAt = new Date(Date.now() + 30 * 60_000).toISOString();
|
||
const attachmentDownloadUrl =
|
||
`${params.publicBaseUrl?.trim() || resolveBossPublicBaseUrl()}/api/v1/attachments/${record.attachment.attachmentId}/download` +
|
||
`?taskId=${taskId}&token=${attachmentDownloadToken}`;
|
||
const attachmentContext = await buildAttachmentAnalysisContext({
|
||
attachment: record.attachment,
|
||
});
|
||
const task = await queueMasterAgentTask({
|
||
taskId,
|
||
projectId: record.project.id,
|
||
taskType: "attachment_analysis",
|
||
requestMessageId: params.requestMessageId,
|
||
requestText: `分析附件《${record.attachment.fileName}》`,
|
||
executionPrompt: buildAttachmentAnalysisPrompt({
|
||
projectId: record.project.id,
|
||
projectName: record.project.name,
|
||
attachment: record.attachment,
|
||
messageBody: record.message.body,
|
||
requestedBy: params.requestedBy,
|
||
requestedByAccount: params.requestedByAccount,
|
||
attachmentDownloadUrl,
|
||
attachmentTextExcerpt: attachmentContext.textExcerpt,
|
||
}),
|
||
requestedBy: params.requestedBy,
|
||
requestedByAccount: params.requestedByAccount,
|
||
deviceId: state.user.boundDeviceId || "mac-studio",
|
||
attachmentId: record.attachment.attachmentId,
|
||
attachmentFileName: record.attachment.fileName,
|
||
attachmentDownloadToken,
|
||
attachmentDownloadExpiresAt,
|
||
attachmentDownloadUrl,
|
||
attachmentTextExcerpt: attachmentContext.textExcerpt,
|
||
});
|
||
|
||
if (params.markProcessing) {
|
||
await updateAttachmentAnalysisResult({
|
||
projectId: params.projectId,
|
||
attachmentId: params.attachmentId,
|
||
status: "processing",
|
||
});
|
||
}
|
||
|
||
return task;
|
||
}
|
||
|
||
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") {
|
||
const state = await readState();
|
||
const nodeId = account.nodeId?.trim() || state.user.boundDeviceId || "";
|
||
const boundDevice = state.devices.find((device) => device.id === nodeId);
|
||
const boundNodeLabel =
|
||
account.nodeLabel?.trim() ||
|
||
boundDevice?.name ||
|
||
state.user.boundCodexNodeLabel ||
|
||
state.user.boundDeviceId ||
|
||
"绑定设备";
|
||
|
||
if (!nodeId) {
|
||
await updateAiAccountHealth({
|
||
accountId: account.accountId,
|
||
status: "needs_login",
|
||
lastError: "MASTER_CODEX_NODE_NOT_CONFIGURED",
|
||
lastValidatedAt: new Date().toISOString(),
|
||
});
|
||
return {
|
||
ok: false as const,
|
||
status: "needs_login" as const,
|
||
message: `主 GPT 不在手机里直接登录。请先在绑定设备(例如 ${boundNodeLabel})上的 Codex / ChatGPT Plus 会话里登录,并填写正确的节点 ID,再回来校验连接。`,
|
||
};
|
||
}
|
||
|
||
if (!boundDevice || boundDevice.status !== "online") {
|
||
await updateAiAccountHealth({
|
||
accountId: account.accountId,
|
||
status: "degraded",
|
||
lastError: !boundDevice ? "MASTER_CODEX_NODE_DEVICE_NOT_FOUND" : "MASTER_CODEX_NODE_DEVICE_OFFLINE",
|
||
lastValidatedAt: new Date().toISOString(),
|
||
});
|
||
return {
|
||
ok: false as const,
|
||
status: "degraded" as const,
|
||
message: `主 GPT 不在手机里直接登录。当前绑定设备 ${boundNodeLabel}${boundDevice ? " 不在线" : " 未找到"},主 Agent 暂时无法通过该节点对话。请先在这台设备上登录 Codex / ChatGPT Plus,并确保 local-agent 在线。`,
|
||
};
|
||
}
|
||
|
||
await updateAiAccountHealth({
|
||
accountId: account.accountId,
|
||
status: "ready",
|
||
lastError: undefined,
|
||
lastValidatedAt: new Date().toISOString(),
|
||
lastUsedAt: boundDevice.lastSeenAt || new Date().toISOString(),
|
||
});
|
||
return {
|
||
ok: true as const,
|
||
status: "ready" as const,
|
||
message: `主 GPT 不在手机里直接登录。当前已通过绑定设备 ${boundNodeLabel} 接好 Master Codex Node,主 Agent 会把任务转交给这台设备上的 Codex / ChatGPT Plus 会话。`,
|
||
};
|
||
}
|
||
|
||
if (account.provider !== "openai_api" || !account.apiKey?.trim()) {
|
||
return {
|
||
ok: false as const,
|
||
status: "needs_api_key",
|
||
message: "当前账号还没有可用的 OpenAI API Key。",
|
||
};
|
||
}
|
||
|
||
const generated = await probeOpenAiApiAccount({
|
||
apiKey: account.apiKey,
|
||
model: account.model || "gpt-5.4",
|
||
});
|
||
|
||
await updateAiAccountHealth({
|
||
accountId: account.accountId,
|
||
status: "ready",
|
||
lastValidatedAt: new Date().toISOString(),
|
||
lastUsedAt: new Date().toISOString(),
|
||
});
|
||
|
||
return {
|
||
ok: true as const,
|
||
status: "ready",
|
||
message: generated.message,
|
||
requestId: generated.requestId,
|
||
};
|
||
}
|
||
|
||
export async function replyToMasterAgentUserMessage(params: {
|
||
requestMessageId?: string;
|
||
requestText: string;
|
||
requestedBy: string;
|
||
requestedByAccount: string;
|
||
currentSessionExpiresAt?: string;
|
||
mode?: "wait" | "enqueue";
|
||
}) {
|
||
const runtime = await getMasterAgentRuntimeAccount();
|
||
const agentControls = await getProjectAgentControls("master-agent");
|
||
|
||
if (!runtime?.account) {
|
||
await appendMasterAgentSystemReply(
|
||
"我已经收到你的消息,但当前没有可用的主控 AI 账号。请到“我的 > AI 账号”至少配置一个可用的 OpenAI API 账号,再继续对话。",
|
||
);
|
||
return { ok: false as const, reason: "NO_AI_ACCOUNT" };
|
||
}
|
||
|
||
if (params.mode === "enqueue") {
|
||
if (runtime.account.provider === "master_codex_node") {
|
||
const state = await readState();
|
||
const deviceId = runtime.account.nodeId || state.user.boundDeviceId || "mac-studio";
|
||
const boundDevice = state.devices.find((device) => device.id === deviceId);
|
||
const boundNodeLabel =
|
||
runtime.account.nodeLabel?.trim() ||
|
||
boundDevice?.name ||
|
||
state.user.boundCodexNodeLabel ||
|
||
deviceId;
|
||
|
||
if (!boundDevice || boundDevice.status !== "online") {
|
||
await updateAiAccountHealth({
|
||
accountId: runtime.account.accountId,
|
||
status: "degraded",
|
||
lastError: !boundDevice ? "MASTER_CODEX_NODE_DEVICE_NOT_FOUND" : "MASTER_CODEX_NODE_DEVICE_OFFLINE",
|
||
lastValidatedAt: new Date().toISOString(),
|
||
});
|
||
|
||
const fallbackAccount = await findFallbackOpenAiAccount(runtime.account.accountId);
|
||
if (fallbackAccount?.apiKey?.trim()) {
|
||
return enqueueOpenAiMasterAgentReply({
|
||
accountId: fallbackAccount.accountId,
|
||
accountLabel: fallbackAccount.label || aiRoleLabel(fallbackAccount.role),
|
||
requestMessageId: params.requestMessageId,
|
||
requestText: params.requestText,
|
||
requestedBy: params.requestedBy,
|
||
requestedByAccount: params.requestedByAccount,
|
||
currentSessionExpiresAt: params.currentSessionExpiresAt,
|
||
apiKey: fallbackAccount.apiKey,
|
||
model: agentControls?.modelOverride || fallbackAccount.model || "gpt-5.4",
|
||
reasoningEffort: agentControls?.reasoningEffortOverride || "medium",
|
||
agentControls,
|
||
});
|
||
}
|
||
|
||
await appendMasterAgentSystemReply(
|
||
`主 GPT 不在手机里直接登录。当前绑定设备 ${boundNodeLabel}${boundDevice ? " 不在线" : " 未找到"},主 Agent 暂时无法通过这台设备对话。请先在该设备上登录 Codex / ChatGPT Plus,并确保 local-agent 在线后再重试。`,
|
||
`主 Agent · ${runtime.summary.roleLabel}`,
|
||
);
|
||
return { ok: false as const, reason: "MASTER_NODE_OFFLINE" };
|
||
}
|
||
|
||
const task = await queueMasterAgentTask({
|
||
requestMessageId: params.requestMessageId ?? "master-agent-manual",
|
||
requestText: params.requestText,
|
||
executionPrompt: buildMasterCodexNodePrompt(
|
||
state,
|
||
params.requestText,
|
||
params.currentSessionExpiresAt,
|
||
agentControls,
|
||
),
|
||
requestedBy: params.requestedBy,
|
||
requestedByAccount: params.requestedByAccount,
|
||
deviceId,
|
||
accountId: runtime.account.accountId,
|
||
accountLabel: runtime.account.label || runtime.summary.roleLabel,
|
||
});
|
||
|
||
const queuedReply: QueuedMasterAgentReplyEnvelope = {
|
||
ok: true as const,
|
||
accountId: runtime.account.accountId,
|
||
taskId: task.taskId,
|
||
masterReplyState: "queued" as const,
|
||
task: {
|
||
taskId: task.taskId,
|
||
taskType: "conversation_reply" as const,
|
||
status: "queued" as const,
|
||
},
|
||
};
|
||
return queuedReply;
|
||
}
|
||
|
||
if (runtime.account.provider === "openai_api" && runtime.account.apiKey?.trim()) {
|
||
return enqueueOpenAiMasterAgentReply({
|
||
accountId: runtime.account.accountId,
|
||
accountLabel: runtime.account.label || runtime.summary.roleLabel,
|
||
requestMessageId: params.requestMessageId,
|
||
requestText: params.requestText,
|
||
requestedBy: params.requestedBy,
|
||
requestedByAccount: params.requestedByAccount,
|
||
currentSessionExpiresAt: params.currentSessionExpiresAt,
|
||
apiKey: runtime.account.apiKey,
|
||
model: agentControls?.modelOverride || runtime.account.model || "gpt-5.4",
|
||
reasoningEffort: agentControls?.reasoningEffortOverride || "medium",
|
||
agentControls,
|
||
});
|
||
}
|
||
}
|
||
|
||
if (runtime.account.provider === "master_codex_node") {
|
||
const state = await readState();
|
||
const deviceId = runtime.account.nodeId || state.user.boundDeviceId || "mac-studio";
|
||
const boundDevice = state.devices.find((device) => device.id === deviceId);
|
||
const boundNodeLabel =
|
||
runtime.account.nodeLabel?.trim() ||
|
||
boundDevice?.name ||
|
||
state.user.boundCodexNodeLabel ||
|
||
deviceId;
|
||
|
||
if (!boundDevice || boundDevice.status !== "online") {
|
||
await updateAiAccountHealth({
|
||
accountId: runtime.account.accountId,
|
||
status: "degraded",
|
||
lastError: !boundDevice ? "MASTER_CODEX_NODE_DEVICE_NOT_FOUND" : "MASTER_CODEX_NODE_DEVICE_OFFLINE",
|
||
lastValidatedAt: new Date().toISOString(),
|
||
});
|
||
const fallbackAccount = await findFallbackOpenAiAccount(runtime.account.accountId);
|
||
if (fallbackAccount) {
|
||
try {
|
||
return await replyViaOpenAiAccount({
|
||
account: fallbackAccount,
|
||
requestText: params.requestText,
|
||
currentSessionExpiresAt: params.currentSessionExpiresAt,
|
||
senderLabel: `主 Agent · ${fallbackAccount.label || aiRoleLabel(fallbackAccount.role)}`,
|
||
agentControls,
|
||
});
|
||
} catch {
|
||
// Fall through to the original offline guidance when the fallback API account cannot respond.
|
||
}
|
||
}
|
||
await appendMasterAgentSystemReply(
|
||
`主 GPT 不在手机里直接登录。当前绑定设备 ${boundNodeLabel}${boundDevice ? " 不在线" : " 未找到"},主 Agent 暂时无法通过这台设备对话。请先在该设备上登录 Codex / ChatGPT Plus,并确保 local-agent 在线后再重试。`,
|
||
`主 Agent · ${runtime.summary.roleLabel}`,
|
||
);
|
||
return { ok: false as const, reason: "MASTER_NODE_OFFLINE" };
|
||
}
|
||
|
||
const task = await queueMasterAgentTask({
|
||
requestMessageId: params.requestMessageId ?? "master-agent-manual",
|
||
requestText: params.requestText,
|
||
executionPrompt: buildMasterCodexNodePrompt(
|
||
state,
|
||
params.requestText,
|
||
params.currentSessionExpiresAt,
|
||
agentControls,
|
||
),
|
||
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") {
|
||
const fallbackAccount = await findFallbackOpenAiAccount(runtime.account.accountId);
|
||
if (fallbackAccount) {
|
||
try {
|
||
return await replyViaOpenAiAccount({
|
||
account: fallbackAccount,
|
||
requestText: params.requestText,
|
||
currentSessionExpiresAt: params.currentSessionExpiresAt,
|
||
senderLabel: `主 Agent · ${fallbackAccount.label || aiRoleLabel(fallbackAccount.role)}`,
|
||
agentControls,
|
||
});
|
||
} catch {
|
||
// Preserve the original execution failure below if the fallback account also fails.
|
||
}
|
||
}
|
||
return {
|
||
ok: false as const,
|
||
reason: "MASTER_NODE_EXEC_FAILED",
|
||
taskId: task.taskId,
|
||
message: completedTask.errorMessage,
|
||
};
|
||
}
|
||
|
||
await appendMasterAgentSystemReply(
|
||
[
|
||
`当前主控身份是 ${runtime.summary.roleLabel},任务已经转交到 ${boundNodeLabel} 的 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: agentControls?.modelOverride || runtime.account.model || "gpt-5.4",
|
||
reasoningEffort: agentControls?.reasoningEffortOverride || "medium",
|
||
requestText: params.requestText,
|
||
currentSessionExpiresAt: params.currentSessionExpiresAt,
|
||
agentControls,
|
||
});
|
||
|
||
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 };
|
||
}
|
||
}
|