feat: complete chat routing and openai onboarding
This commit is contained in:
75
src/app/api/v1/accounts/onboard/master-node/route.ts
Normal file
75
src/app/api/v1/accounts/onboard/master-node/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { readState, saveAiAccount } from "@/lib/boss-data";
|
||||
import { validateAiAccountConnection } from "@/lib/boss-master-agent";
|
||||
|
||||
function chooseMasterPrimaryAccountId(state: Awaited<ReturnType<typeof readState>>) {
|
||||
return (
|
||||
state.aiAccounts.find((item) => item.provider === "master_codex_node" && item.role === "primary")
|
||||
?.accountId || "master-codex-primary"
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
if (session.role !== "highest_admin") {
|
||||
return NextResponse.json({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
label?: string;
|
||||
displayName?: string;
|
||||
accountIdentifier?: string;
|
||||
nodeId?: string;
|
||||
nodeLabel?: string;
|
||||
model?: string;
|
||||
setActive?: boolean;
|
||||
};
|
||||
|
||||
if (!body.displayName?.trim()) {
|
||||
return NextResponse.json({ ok: false, message: "显示名称不能为空。" }, { status: 400 });
|
||||
}
|
||||
if (!body.nodeId?.trim()) {
|
||||
return NextResponse.json({ ok: false, message: "请先填写节点 ID。" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const state = await readState();
|
||||
const accountId = chooseMasterPrimaryAccountId(state);
|
||||
const account = await saveAiAccount({
|
||||
accountId,
|
||||
label: body.label?.trim() || "主 GPT",
|
||||
role: "primary",
|
||||
provider: "master_codex_node",
|
||||
displayName: body.displayName.trim(),
|
||||
accountIdentifier: body.accountIdentifier?.trim() || undefined,
|
||||
nodeId: body.nodeId.trim(),
|
||||
nodeLabel: body.nodeLabel?.trim() || undefined,
|
||||
model: body.model?.trim() || "gpt-5.4",
|
||||
enabled: true,
|
||||
setActive: body.setActive !== false,
|
||||
loginStatusNote: "节点绑定信息已保存,请在绑定设备上的 Codex / ChatGPT Plus 会话里完成登录。",
|
||||
});
|
||||
|
||||
const validation = await validateAiAccountConnection(account.accountId);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
accountId: account.accountId,
|
||||
account,
|
||||
validation,
|
||||
message:
|
||||
body.setActive === false
|
||||
? "Master Codex Node 绑定信息已保存。"
|
||||
: "Master Codex Node 已绑定,并设为当前主控。",
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: error instanceof Error ? error.message : "MASTER_NODE_ONBOARD_FAILED" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
82
src/app/api/v1/accounts/onboard/openai-api/route.ts
Normal file
82
src/app/api/v1/accounts/onboard/openai-api/route.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { getMasterIdentitySummaryFromState, readState, saveAiAccount, updateAiAccountHealth } from "@/lib/boss-data";
|
||||
import { probeOpenAiApiAccount } from "@/lib/boss-master-agent";
|
||||
|
||||
function chooseOpenAiPrimaryAccountId(state: Awaited<ReturnType<typeof readState>>) {
|
||||
return (
|
||||
state.aiAccounts.find((item) => item.provider === "openai_api" && item.role === "primary")?.accountId ||
|
||||
"openai-api-primary"
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
if (session.role !== "highest_admin") {
|
||||
return NextResponse.json({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
label?: string;
|
||||
displayName?: string;
|
||||
accountIdentifier?: string;
|
||||
model?: string;
|
||||
apiKey?: string;
|
||||
};
|
||||
|
||||
if (!body.displayName?.trim()) {
|
||||
return NextResponse.json({ ok: false, message: "显示名称不能为空。" }, { status: 400 });
|
||||
}
|
||||
if (!body.apiKey?.trim()) {
|
||||
return NextResponse.json({ ok: false, message: "请先填写 OpenAI API Key。" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const probe = await probeOpenAiApiAccount({
|
||||
apiKey: body.apiKey,
|
||||
model: body.model,
|
||||
});
|
||||
|
||||
const state = await readState();
|
||||
const accountId = chooseOpenAiPrimaryAccountId(state);
|
||||
const account = await saveAiAccount({
|
||||
accountId,
|
||||
label: body.label?.trim() || "主 GPT",
|
||||
role: "primary",
|
||||
provider: "openai_api",
|
||||
displayName: body.displayName.trim(),
|
||||
accountIdentifier: body.accountIdentifier?.trim() || undefined,
|
||||
model: probe.model,
|
||||
apiKey: body.apiKey.trim(),
|
||||
enabled: true,
|
||||
setActive: true,
|
||||
loginStatusNote: "已在手机端登录 OpenAI 平台账号,可直接作为当前主控。",
|
||||
});
|
||||
|
||||
await updateAiAccountHealth({
|
||||
accountId: account.accountId,
|
||||
status: "ready",
|
||||
lastError: undefined,
|
||||
lastValidatedAt: new Date().toISOString(),
|
||||
lastUsedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const nextState = await readState();
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
accountId: account.accountId,
|
||||
account,
|
||||
activeIdentity: getMasterIdentitySummaryFromState(nextState),
|
||||
requestId: probe.requestId,
|
||||
message: "OpenAI 平台账号已登录,并设为当前主控。",
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: error instanceof Error ? error.message : "OPENAI_ONBOARD_FAILED" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,21 @@ import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { appendProjectMessage, readState } from "@/lib/boss-data";
|
||||
import {
|
||||
queueGroupDispatchPlan,
|
||||
queueThreadConversationReplyTask,
|
||||
replyToMasterAgentUserMessage,
|
||||
} from "@/lib/boss-master-agent";
|
||||
|
||||
function dispatchFailureNotice(error?: string) {
|
||||
switch (error) {
|
||||
case "GROUP_DISPATCH_TARGETS_REQUIRED":
|
||||
return "当前群聊里还没有可下发的真实线程,请先在群资料里重新添加线程后再试。";
|
||||
case "DISPATCH_TARGET_PROJECT_NOT_FOUND":
|
||||
return "当前群聊里有失效的线程引用,请重新整理群成员后再试。";
|
||||
default:
|
||||
return error ? `主 Agent 暂时无法生成推荐线程:${error}` : "主 Agent 暂时无法生成推荐线程,请稍后重试。";
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ projectId: string }> },
|
||||
@@ -37,8 +49,22 @@ export async function POST(
|
||||
}
|
||||
| null = null;
|
||||
let masterReply:
|
||||
| { ok: boolean; reason?: string; message?: string; accountId?: string; requestId?: string }
|
||||
| {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
message?: string;
|
||||
accountId?: string;
|
||||
requestId?: string;
|
||||
taskId?: string;
|
||||
}
|
||||
| undefined;
|
||||
let task:
|
||||
| {
|
||||
taskId: string;
|
||||
taskType: "conversation_reply";
|
||||
status: "queued" | "completed";
|
||||
}
|
||||
| null = null;
|
||||
|
||||
const state = await readState();
|
||||
const project = state.projects.find((item) => item.id === projectId);
|
||||
@@ -58,13 +84,42 @@ export async function POST(
|
||||
});
|
||||
dispatchRecommendation = recommendation;
|
||||
dispatchPlan = recommendation.dispatchPlan;
|
||||
if (!recommendation.ok) {
|
||||
await appendProjectMessage({
|
||||
projectId,
|
||||
sender: "master",
|
||||
senderLabel: "主 Agent",
|
||||
body: dispatchFailureNotice(recommendation.error),
|
||||
kind: "system_notice",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
dispatchRecommendation = {
|
||||
ok: false,
|
||||
status: "failed",
|
||||
error: error instanceof Error ? error.message : "GROUP_DISPATCH_PLAN_FAILED",
|
||||
};
|
||||
await appendProjectMessage({
|
||||
projectId,
|
||||
sender: "master",
|
||||
senderLabel: "主 Agent",
|
||||
body: dispatchFailureNotice(dispatchRecommendation.error),
|
||||
kind: "system_notice",
|
||||
});
|
||||
}
|
||||
} else if (project && projectId !== "master-agent" && !project.isGroup && message.body.trim().length > 0) {
|
||||
const queuedTask = await queueThreadConversationReplyTask({
|
||||
projectId,
|
||||
requestMessageId: message.id,
|
||||
requestText: message.body,
|
||||
requestedBy: session.displayName || session.account,
|
||||
requestedByAccount: session.account,
|
||||
});
|
||||
task = {
|
||||
taskId: queuedTask.taskId,
|
||||
taskType: "conversation_reply",
|
||||
status: "queued",
|
||||
};
|
||||
} else {
|
||||
dispatchRecommendation = {
|
||||
ok: false,
|
||||
@@ -80,6 +135,13 @@ export async function POST(
|
||||
requestedByAccount: session.account,
|
||||
currentSessionExpiresAt: session.expiresAt,
|
||||
});
|
||||
if (masterReply?.ok && masterReply.taskId) {
|
||||
task = {
|
||||
taskId: masterReply.taskId,
|
||||
taskType: "conversation_reply",
|
||||
status: masterReply.requestId ? "completed" : "queued",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const nextState = shouldCreateDispatchPlan ? await readState() : state;
|
||||
@@ -103,6 +165,7 @@ export async function POST(
|
||||
ok: true,
|
||||
message,
|
||||
masterReply,
|
||||
task,
|
||||
dispatchPlan,
|
||||
dispatchRecommendation,
|
||||
collaborationGate,
|
||||
|
||||
@@ -314,12 +314,13 @@ export function AiAccountsClient({
|
||||
nodeId: draft.nodeId.trim(),
|
||||
nodeLabel: draft.nodeLabel.trim(),
|
||||
model: draft.model.trim() || "gpt-5.4",
|
||||
setActive: true,
|
||||
}),
|
||||
});
|
||||
const result = (await response.json()) as { ok: boolean; message?: string };
|
||||
setBusyKey(null);
|
||||
if (result.ok) {
|
||||
setMessage(result.message || "Master Codex Node 已绑定。");
|
||||
setMessage(result.message || "Master Codex Node 已绑定,并设为当前主控。");
|
||||
setMasterNodeOnboardDraft(defaultMasterNodeOnboardDraft());
|
||||
closeOnboarding();
|
||||
router.refresh();
|
||||
|
||||
@@ -327,6 +327,15 @@ export interface DeviceImportCandidate {
|
||||
suggestedImport: boolean;
|
||||
}
|
||||
|
||||
export function isDispatchableThreadProject(project: Project) {
|
||||
return (
|
||||
project.id !== "master-agent" &&
|
||||
!project.isGroup &&
|
||||
Boolean(project.threadMeta.codexThreadRef?.trim()) &&
|
||||
project.deviceIds.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export interface DeviceImportDraft {
|
||||
draftId: string;
|
||||
deviceId: string;
|
||||
@@ -521,6 +530,8 @@ export interface MasterAgentTask {
|
||||
targetProjectId?: string;
|
||||
targetThreadId?: string;
|
||||
targetThreadDisplayName?: string;
|
||||
targetCodexThreadRef?: string;
|
||||
targetCodexFolderRef?: string;
|
||||
deviceImportDraftId?: string;
|
||||
status: MasterAgentTaskStatus;
|
||||
requestedAt: string;
|
||||
@@ -3812,7 +3823,10 @@ export async function saveAiAccount(payload: {
|
||||
const existing = payload.accountId
|
||||
? state.aiAccounts.find((item) => item.accountId === payload.accountId)
|
||||
: null;
|
||||
const accountId = existing?.accountId ?? `ai-${slugify(`${payload.label}-${payload.displayName}`)}`;
|
||||
const accountId =
|
||||
existing?.accountId ??
|
||||
payload.accountId?.trim() ??
|
||||
`ai-${slugify(`${payload.label}-${payload.displayName}`)}`;
|
||||
const next: AiAccount = normalizeAiAccount({
|
||||
accountId,
|
||||
label: payload.label.trim() || aiRoleLabel(payload.role),
|
||||
@@ -3989,6 +4003,8 @@ export async function queueMasterAgentTask(payload: {
|
||||
targetProjectId?: string;
|
||||
targetThreadId?: string;
|
||||
targetThreadDisplayName?: string;
|
||||
targetCodexThreadRef?: string;
|
||||
targetCodexFolderRef?: string;
|
||||
}) {
|
||||
const task = await mutateState((state) => {
|
||||
const task: MasterAgentTask = {
|
||||
@@ -4014,6 +4030,8 @@ export async function queueMasterAgentTask(payload: {
|
||||
targetProjectId: payload.targetProjectId,
|
||||
targetThreadId: payload.targetThreadId,
|
||||
targetThreadDisplayName: payload.targetThreadDisplayName,
|
||||
targetCodexThreadRef: payload.targetCodexThreadRef,
|
||||
targetCodexFolderRef: payload.targetCodexFolderRef,
|
||||
status: "queued",
|
||||
requestedAt: nowIso(),
|
||||
};
|
||||
@@ -4281,6 +4299,8 @@ function ensureDispatchExecutionTaskInState(
|
||||
existing.targetProjectId = existing.targetProjectId ?? execution.targetProjectId;
|
||||
existing.targetThreadId = existing.targetThreadId ?? execution.targetThreadId;
|
||||
existing.targetThreadDisplayName = existing.targetThreadDisplayName ?? target.threadDisplayName;
|
||||
existing.targetCodexThreadRef = existing.targetCodexThreadRef ?? target.codexThreadRef;
|
||||
existing.targetCodexFolderRef = existing.targetCodexFolderRef ?? target.codexFolderRef;
|
||||
existing.executionPrompt =
|
||||
existing.executionPrompt ||
|
||||
buildDispatchExecutionPrompt({
|
||||
@@ -4311,6 +4331,8 @@ function ensureDispatchExecutionTaskInState(
|
||||
targetProjectId: execution.targetProjectId,
|
||||
targetThreadId: execution.targetThreadId,
|
||||
targetThreadDisplayName: target.threadDisplayName,
|
||||
targetCodexThreadRef: target.codexThreadRef,
|
||||
targetCodexFolderRef: target.codexFolderRef,
|
||||
status: "queued",
|
||||
requestedAt: nowIso(),
|
||||
};
|
||||
@@ -4790,17 +4812,48 @@ export async function completeMasterAgentTask(payload: {
|
||||
masterSummary: payload.replyBody?.trim(),
|
||||
});
|
||||
} else if (!attachmentProjectId && payload.status === "completed" && task.replyBody) {
|
||||
pushProjectLedgerMessage(state, task.projectId, {
|
||||
sender: "master",
|
||||
senderLabel: task.accountLabel ? `主 Agent · ${task.accountLabel}` : "主 Agent",
|
||||
body: task.replyBody,
|
||||
kind: "text",
|
||||
});
|
||||
const isThreadConversationReply =
|
||||
task.taskType === "conversation_reply" &&
|
||||
task.projectId !== "master-agent" &&
|
||||
Boolean(task.targetProjectId && task.targetThreadId);
|
||||
if (isThreadConversationReply) {
|
||||
const threadProject = state.projects.find(
|
||||
(item) => item.id === (task.targetProjectId ?? task.projectId),
|
||||
);
|
||||
const device = state.devices.find((item) => item.id === payload.deviceId);
|
||||
pushProjectLedgerMessage(state, threadProject?.id ?? task.projectId, {
|
||||
sender: "device",
|
||||
senderLabel:
|
||||
task.targetThreadDisplayName?.trim() ||
|
||||
threadProject?.threadMeta.threadDisplayName ||
|
||||
device?.name ||
|
||||
"线程",
|
||||
body: task.replyBody,
|
||||
kind: "text",
|
||||
});
|
||||
} else {
|
||||
pushProjectLedgerMessage(state, task.projectId, {
|
||||
sender: "master",
|
||||
senderLabel: task.accountLabel ? `主 Agent · ${task.accountLabel}` : "主 Agent",
|
||||
body: task.replyBody,
|
||||
kind: "text",
|
||||
});
|
||||
}
|
||||
} else if (!attachmentProjectId && payload.status === "failed") {
|
||||
const isThreadConversationReply =
|
||||
task.taskType === "conversation_reply" &&
|
||||
task.projectId !== "master-agent" &&
|
||||
Boolean(task.targetProjectId && task.targetThreadId);
|
||||
pushProjectLedgerMessage(state, task.projectId, {
|
||||
sender: "ops",
|
||||
senderLabel: task.accountLabel ? `主 Agent Relay · ${task.accountLabel}` : "主 Agent Relay",
|
||||
body: `Master Codex Node 执行失败:${task.errorMessage ?? "UNKNOWN_ERROR"}`,
|
||||
senderLabel: isThreadConversationReply
|
||||
? "线程执行失败"
|
||||
: task.accountLabel
|
||||
? `主 Agent Relay · ${task.accountLabel}`
|
||||
: "主 Agent Relay",
|
||||
body: isThreadConversationReply
|
||||
? `${task.targetThreadDisplayName ?? "当前线程"} 执行失败:${task.errorMessage ?? "UNKNOWN_ERROR"}`
|
||||
: `Master Codex Node 执行失败:${task.errorMessage ?? "UNKNOWN_ERROR"}`,
|
||||
kind: "text",
|
||||
});
|
||||
}
|
||||
@@ -6233,6 +6286,9 @@ function createGroupChatFromProjectIds(
|
||||
if (!memberProject) {
|
||||
throw new Error("GROUP_CHAT_MEMBER_NOT_FOUND");
|
||||
}
|
||||
if (!isDispatchableThreadProject(memberProject)) {
|
||||
throw new Error("GROUP_CHAT_MEMBER_NOT_THREAD");
|
||||
}
|
||||
memberProjects.push(memberProject);
|
||||
}
|
||||
if (memberProjects.length < 2) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import {
|
||||
AUTH_SESSION_TTL_MS,
|
||||
aiRoleLabel,
|
||||
aiProviderLabel,
|
||||
appendProjectMessage,
|
||||
completeMasterAgentTask,
|
||||
@@ -13,10 +14,11 @@ import {
|
||||
previewDeviceImportResolution,
|
||||
queueMasterAgentTask,
|
||||
readState,
|
||||
isDispatchableThreadProject,
|
||||
updateAttachmentAnalysisResult,
|
||||
updateAiAccountHealth,
|
||||
} from "@/lib/boss-data";
|
||||
import type { DispatchPlanTarget, GroupConversationMember, Project } from "@/lib/boss-data";
|
||||
import type { DispatchPlanTarget, Project } 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";
|
||||
@@ -32,6 +34,21 @@ function buildMasterAgentInstructions() {
|
||||
].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,
|
||||
@@ -133,11 +150,93 @@ function extractResponseText(payload: unknown): string {
|
||||
|
||||
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;
|
||||
}) {
|
||||
if (!params.account?.apiKey?.trim()) {
|
||||
throw new Error("OPENAI_ACCOUNT_NOT_CONFIGURED");
|
||||
}
|
||||
|
||||
const generated = await generateOpenAiReply({
|
||||
apiKey: params.account.apiKey,
|
||||
model: params.account.model || "gpt-5.4",
|
||||
requestText: params.requestText,
|
||||
currentSessionExpiresAt: params.currentSessionExpiresAt,
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -145,20 +244,25 @@ async function generateOpenAiReply(params: {
|
||||
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),
|
||||
});
|
||||
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: "medium" },
|
||||
instructions: buildMasterAgentInstructions(),
|
||||
input: buildRuntimeDigest(state, params.requestText, params.currentSessionExpiresAt),
|
||||
}),
|
||||
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
|
||||
@@ -192,6 +296,62 @@ async function generateOpenAiReply(params: {
|
||||
};
|
||||
}
|
||||
|
||||
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",
|
||||
@@ -229,15 +389,11 @@ function summarizeDispatchRequest(requestText: string) {
|
||||
}
|
||||
|
||||
function collectGroupDispatchTargets(
|
||||
state: Awaited<ReturnType<typeof readState>>,
|
||||
project: Project,
|
||||
requestText: string,
|
||||
): DispatchPlanTarget[] {
|
||||
const members: Array<
|
||||
Pick<
|
||||
GroupConversationMember,
|
||||
"deviceId" | "projectId" | "threadId" | "threadDisplayName" | "folderName"
|
||||
>
|
||||
> =
|
||||
const members =
|
||||
project.groupMembers.length > 0
|
||||
? project.groupMembers
|
||||
: project.deviceIds.map((deviceId) => ({
|
||||
@@ -249,20 +405,27 @@ function collectGroupDispatchTargets(
|
||||
}));
|
||||
|
||||
return members
|
||||
.map((member) => ({
|
||||
deviceId: member.deviceId,
|
||||
projectId: member.projectId,
|
||||
threadId: member.threadId,
|
||||
threadDisplayName: member.threadDisplayName,
|
||||
folderName: member.folderName,
|
||||
.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
|
||||
);
|
||||
return array.findIndex((item) => `${item.projectId}::${item.deviceId}::${item.threadId}` === signature) === index;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -336,7 +499,7 @@ async function resolveGroupDispatchPlanTask(taskId: string): Promise<GroupDispat
|
||||
throw new Error("PROJECT_NOT_GROUP_CHAT");
|
||||
}
|
||||
|
||||
const targets = collectGroupDispatchTargets(project, task.requestText);
|
||||
const targets = collectGroupDispatchTargets(state, project, task.requestText);
|
||||
if (targets.length === 0) {
|
||||
throw new Error("GROUP_DISPATCH_TARGETS_REQUIRED");
|
||||
}
|
||||
@@ -404,6 +567,43 @@ export async function queueGroupDispatchPlan(params: {
|
||||
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;
|
||||
@@ -813,10 +1013,9 @@ export async function validateAiAccountConnection(accountId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
const generated = await generateOpenAiReply({
|
||||
const generated = await probeOpenAiApiAccount({
|
||||
apiKey: account.apiKey,
|
||||
model: account.model || "gpt-5.4",
|
||||
requestText: "请只回复“连接正常”。",
|
||||
});
|
||||
|
||||
await updateAiAccountHealth({
|
||||
@@ -829,7 +1028,7 @@ export async function validateAiAccountConnection(accountId: string) {
|
||||
return {
|
||||
ok: true as const,
|
||||
status: "ready",
|
||||
message: generated.content,
|
||||
message: generated.message,
|
||||
requestId: generated.requestId,
|
||||
};
|
||||
}
|
||||
@@ -867,6 +1066,19 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
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)}`,
|
||||
});
|
||||
} 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}`,
|
||||
@@ -898,6 +1110,19 @@ export async function replyToMasterAgentUserMessage(params: {
|
||||
};
|
||||
}
|
||||
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)}`,
|
||||
});
|
||||
} catch {
|
||||
// Preserve the original execution failure below if the fallback account also fails.
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: false as const,
|
||||
reason: "MASTER_NODE_EXEC_FAILED",
|
||||
|
||||
Reference in New Issue
Block a user