diff --git a/src/app/api/v1/projects/[projectId]/messages/route.ts b/src/app/api/v1/projects/[projectId]/messages/route.ts index 9f85dbc..24b9ca1 100644 --- a/src/app/api/v1/projects/[projectId]/messages/route.ts +++ b/src/app/api/v1/projects/[projectId]/messages/route.ts @@ -1,7 +1,10 @@ import { NextRequest, NextResponse } from "next/server"; import { requireRequestSession } from "@/lib/boss-auth"; import { appendProjectMessage, readState } from "@/lib/boss-data"; -import { replyToMasterAgentUserMessage } from "@/lib/boss-master-agent"; +import { + queueGroupDispatchPlan, + replyToMasterAgentUserMessage, +} from "@/lib/boss-master-agent"; export async function POST( request: NextRequest, @@ -24,10 +27,28 @@ export async function POST( body: body.body, kind: body.kind ?? "text", }); + let dispatchPlan = null; let masterReply: | { ok: boolean; reason?: string; message?: string; accountId?: string; requestId?: string } | undefined; + const state = await readState(); + const project = state.projects.find((item) => item.id === projectId); + const shouldCreateDispatchPlan = + project?.isGroup && + project.id !== "master-agent" && + (body.kind ?? "text") === "text" && + message.body.trim().length > 0; + + if (shouldCreateDispatchPlan) { + dispatchPlan = await queueGroupDispatchPlan({ + groupProjectId: projectId, + requestMessageId: message.id, + requestText: message.body, + requestedBy: session.account, + }); + } + if (projectId === "master-agent" && (body.kind ?? "text") === "text" && message.body.trim()) { masterReply = await replyToMasterAgentUserMessage({ requestMessageId: message.id, @@ -38,15 +59,15 @@ export async function POST( }); } - const state = await readState(); - const project = state.projects.find((item) => item.id === projectId); - const collaborationGate = project + const nextState = shouldCreateDispatchPlan ? await readState() : state; + const nextProject = nextState.projects.find((item) => item.id === projectId); + const collaborationGate = nextProject ? { - isGroup: project.isGroup, - collaborationMode: project.collaborationMode, + isGroup: nextProject.isGroup, + collaborationMode: nextProject.collaborationMode, requiresMasterAgentApproval: - project.isGroup && project.collaborationMode === "approval_required", - approvalState: project.approvalState, + nextProject.isGroup && nextProject.collaborationMode === "approval_required", + approvalState: nextProject.approvalState, } : { isGroup: false, @@ -55,7 +76,7 @@ export async function POST( approvalState: "not_required" as const, }; - return NextResponse.json({ ok: true, message, masterReply, collaborationGate }); + return NextResponse.json({ ok: true, message, masterReply, dispatchPlan, collaborationGate }); } catch (error) { return NextResponse.json( { ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" }, diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts index 76554bd..fc9ae07 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -130,7 +130,10 @@ export type AiProvider = "master_codex_node" | "openai_api"; export type AiAccountRole = "primary" | "backup" | "api_fallback"; export type AiAccountStatus = "ready" | "needs_login" | "needs_api_key" | "degraded" | "disabled"; export type MasterAgentTaskStatus = "queued" | "running" | "completed" | "failed"; -export type MasterAgentTaskType = "conversation_reply" | "attachment_analysis"; +export type MasterAgentTaskType = + | "conversation_reply" + | "attachment_analysis" + | "group_dispatch_plan"; export type DispatchPlanStatus = | "pending_user_confirmation" | "approved" diff --git a/src/lib/boss-master-agent.ts b/src/lib/boss-master-agent.ts index d529ad9..08bb4a8 100644 --- a/src/lib/boss-master-agent.ts +++ b/src/lib/boss-master-agent.ts @@ -3,6 +3,7 @@ import { AUTH_SESSION_TTL_MS, aiProviderLabel, appendProjectMessage, + createDispatchPlan, getProjectAttachment, getAttachmentStorageConfig, getRuntimeAiAccountById, @@ -213,6 +214,70 @@ function buildMasterCodexNodePrompt( ].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)}...`; +} + +export async function queueGroupDispatchPlan(params: { + groupProjectId: string; + requestMessageId: string; + requestText: string; + requestedBy: string; +}) { + 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 memberTargets = (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) => ({ + deviceId: member.deviceId, + projectId: member.projectId, + threadId: member.threadId, + threadDisplayName: member.threadDisplayName, + folderName: member.folderName, + reason: `群聊消息“${summarizeDispatchRequest(params.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; + }); + + if (memberTargets.length === 0) { + throw new Error("GROUP_DISPATCH_TARGETS_REQUIRED"); + } + + const targetLabels = memberTargets.map((target) => target.threadDisplayName).filter(Boolean); + const summary = `主 Agent 建议先按线程分发这条群聊消息:${summarizeDispatchRequest(params.requestText)}${targetLabels.length > 0 ? `。建议目标:${targetLabels.join("、")}` : ""}`; + + return createDispatchPlan({ + groupProjectId: project.id, + requestMessageId: params.requestMessageId, + requestedBy: params.requestedBy, + summary, + targets: memberTargets, + }); +} + async function waitForMasterAgentTaskCompletion(taskId: string, timeoutMs = 55_000) { const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { diff --git a/tests/group-message-dispatch-plan.test.ts b/tests/group-message-dispatch-plan.test.ts new file mode 100644 index 0000000..bbb8d95 --- /dev/null +++ b/tests/group-message-dispatch-plan.test.ts @@ -0,0 +1,178 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, rm } from "node:fs/promises"; +import { NextRequest } from "next/server"; + +let runtimeRoot = ""; +let POST: (typeof import("../src/app/api/v1/projects/[projectId]/messages/route"))["POST"]; +let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"]; +let createProjectGroupChat: (typeof import("../src/lib/boss-data"))["createProjectGroupChat"]; +let readState: (typeof import("../src/lib/boss-data"))["readState"]; +let writeState: (typeof import("../src/lib/boss-data"))["writeState"]; +let AUTH_SESSION_COOKIE: string; + +async function setup() { + if (runtimeRoot) { + return; + } + + runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-task3-")); + process.env.BOSS_RUNTIME_ROOT = runtimeRoot; + process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json"); + + const [{ POST: routePost }, data, auth] = await Promise.all([ + import("../src/app/api/v1/projects/[projectId]/messages/route.ts"), + import("../src/lib/boss-data.ts"), + import("../src/lib/boss-auth.ts"), + ]); + + POST = routePost; + createAuthSession = data.createAuthSession; + createProjectGroupChat = data.createProjectGroupChat; + readState = data.readState; + writeState = data.writeState; + AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE; +} + +test.after(async () => { + if (runtimeRoot) { + await rm(runtimeRoot, { recursive: true, force: true }); + } +}); + +async function createAuthedRequest(projectId: string, body: { body: string; kind?: string }) { + const session = await createAuthSession({ + account: "17600003315", + role: "highest_admin", + displayName: "Boss 超级管理员", + loginMethod: "password", + }); + + return new NextRequest(`http://127.0.0.1:3000/api/v1/projects/${projectId}/messages`, { + method: "POST", + headers: { + "content-type": "application/json", + cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`, + }, + body: JSON.stringify(body), + }); +} + +async function ensureTwoSingleThreadProjects() { + const state = await readState(); + const singles = state.projects.filter((project) => project.id !== "master-agent" && !project.isGroup); + if (singles.length >= 2) { + return singles; + } + + assert.ok(singles[0], "expected at least one seeded single-thread project"); + const seed = singles[0]; + const clonedProject = { + ...seed, + id: "boss-console-clone", + name: "Boss 移动控制台副线程", + deviceIds: ["win-gpu-01"], + updatedAt: "2026-03-30T10:00:00+08:00", + lastMessageAt: "2026-03-30T10:00:00+08:00", + preview: "副线程等待主 Agent 汇总阻塞点。", + threadMeta: { + ...seed.threadMeta, + projectId: "boss-console-clone", + threadId: "thread-boss-ui-clone", + threadDisplayName: "南区试产线回归", + folderName: "阻塞梳理", + updatedAt: "2026-03-30T10:00:00+08:00", + codexThreadRef: "thread-boss-ui-clone", + codexFolderRef: "boss-console-clone", + }, + groupMembers: [], + messages: [ + { + id: "msg-boss-console-clone", + sender: "device" as const, + senderLabel: "Win GPU / Codex", + body: "这里还在等待视觉链路复核。", + sentAt: "2026-03-30T10:00:00+08:00", + kind: "text" as const, + }, + ], + goals: [], + versions: [], + }; + + await writeState({ + ...state, + projects: [...state.projects, clonedProject], + }); + + const nextState = await readState(); + return nextState.projects.filter((project) => project.id !== "master-agent" && !project.isGroup); +} + +test("POST /api/v1/projects/[projectId]/messages returns a dispatch plan for group text messages", async () => { + await setup(); + const memberProjects = await ensureTwoSingleThreadProjects(); + assert.ok(memberProjects.length >= 2, "expected seeded single-thread projects"); + + const groupProject = await createProjectGroupChat({ + sourceProjectId: memberProjects[0].id, + memberProjectIds: [memberProjects[1].id], + createdBy: "17600003315", + }); + + const response = await POST(await createAuthedRequest(groupProject.id, { body: "请大家汇总今天的阻塞点" }), { + params: Promise.resolve({ projectId: groupProject.id }), + }); + assert.equal(response.status, 200); + + const payload = (await response.json()) as { + ok: boolean; + message: { id: string; body: string }; + dispatchPlan: null | { + groupProjectId: string; + requestMessageId: string; + status: string; + targets: Array<{ projectId: string }>; + summary: string; + }; + collaborationGate: { isGroup: boolean }; + }; + + assert.equal(payload.ok, true); + assert.equal(payload.message.body, "请大家汇总今天的阻塞点"); + assert.ok(payload.dispatchPlan, "expected dispatch plan in response"); + assert.equal(payload.dispatchPlan?.groupProjectId, groupProject.id); + assert.equal(payload.dispatchPlan?.requestMessageId, payload.message.id); + assert.equal(payload.dispatchPlan?.status, "pending_user_confirmation"); + assert.equal(payload.dispatchPlan?.targets.length, groupProject.groupMembers.length); + assert.match(payload.dispatchPlan?.summary ?? "", /阻塞点/); + assert.equal(payload.collaborationGate.isGroup, true); +}); + +test("POST /api/v1/projects/[projectId]/messages keeps dispatchPlan null for single-thread projects", async () => { + await setup(); + const state = await readState(); + const singleProject = state.projects.find( + (project) => project.id !== "master-agent" && !project.isGroup, + ); + assert.ok(singleProject, "expected a seeded single-thread project"); + + const response = await POST(await createAuthedRequest(singleProject.id, { body: "单线程消息" }), { + params: Promise.resolve({ projectId: singleProject.id }), + }); + assert.equal(response.status, 200); + + const payload = (await response.json()) as { + ok: boolean; + message: { body: string }; + dispatchPlan: null; + collaborationGate: { isGroup: boolean }; + }; + + assert.equal(payload.ok, true); + assert.equal(payload.message.body, "单线程消息"); + assert.equal(payload.dispatchPlan, null); + assert.equal(payload.collaborationGate.isGroup, false); +});