From 9613c3c15441483d211cd68a804d8e10ce72f6f9 Mon Sep 17 00:00:00 2001 From: kris Date: Sat, 28 Mar 2026 07:20:49 +0800 Subject: [PATCH] feat: add structured message forwarding payloads --- .../v1/projects/[projectId]/forwards/route.ts | 69 ++++- src/lib/boss-data.ts | 242 ++++++++++++++++-- 2 files changed, 272 insertions(+), 39 deletions(-) diff --git a/src/app/api/v1/projects/[projectId]/forwards/route.ts b/src/app/api/v1/projects/[projectId]/forwards/route.ts index f9f908a..3853f82 100644 --- a/src/app/api/v1/projects/[projectId]/forwards/route.ts +++ b/src/app/api/v1/projects/[projectId]/forwards/route.ts @@ -2,6 +2,18 @@ import { NextRequest, NextResponse } from "next/server"; import { requireRequestSession } from "@/lib/boss-auth"; import { forwardProjectMessage } from "@/lib/boss-data"; +type ForwardBody = + | { + mode?: "single"; + targetProjectId?: string; + sourceMessageId?: string; + } + | { + mode?: "bundle"; + targetProjectId?: string; + sourceMessageIds?: string[]; + }; + export async function POST( request: NextRequest, context: { params: Promise<{ projectId: string }> }, @@ -11,25 +23,60 @@ export async function POST( return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); } const { projectId } = await context.params; - const body = (await request.json()) as { - targetProjectId?: string; - note?: string; - }; + const body = (await request.json()) as ForwardBody; - if (!body.targetProjectId || !body.note) { + const mode = body.mode ?? "single"; + const targetProjectId = body.targetProjectId; + const sourceMessageId: string | undefined = + "sourceMessageId" in body ? body.sourceMessageId : undefined; + const sourceMessageIds: string[] = + "sourceMessageIds" in body && Array.isArray(body.sourceMessageIds) + ? body.sourceMessageIds + : []; + + if (!targetProjectId) { return NextResponse.json( - { ok: false, message: "缺少 targetProjectId 或 note" }, + { ok: false, message: "缺少 targetProjectId" }, { status: 400 }, ); } + if (mode === "bundle") { + if (sourceMessageIds.length <= 1) { + return NextResponse.json( + { ok: false, message: "bundle 转发至少需要 2 条 sourceMessageIds" }, + { status: 400 }, + ); + } + } else if (!sourceMessageId) { + return NextResponse.json( + { ok: false, message: "single 转发缺少 sourceMessageId" }, + { status: 400 }, + ); + } try { - const message = await forwardProjectMessage({ - sourceProjectId: projectId, - targetProjectId: body.targetProjectId, - note: body.note, + const result = + mode === "bundle" + ? await forwardProjectMessage({ + sourceProjectId: projectId, + mode: "bundle", + targetProjectId, + sourceMessageIds, + requestedBy: session.account, + }) + : await forwardProjectMessage({ + sourceProjectId: projectId, + mode: "single", + targetProjectId, + sourceMessageId: sourceMessageId ?? "", + requestedBy: session.account, + }); + return NextResponse.json({ + ok: true, + message: result.message ?? null, + approvalRequired: Boolean(result.approvalRequired), + approvalReason: result.approvalReason ?? null, }); - return NextResponse.json({ ok: true, message }); } 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 d91f9c8..f3c4f06 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -11,12 +11,45 @@ export type DeviceStatus = "online" | "abnormal" | "offline"; export type DeviceSource = "production" | "demo"; export type GoalState = "pending" | "completed"; export type MessageSender = "master" | "device" | "user" | "ops" | "audit"; +// Forwarding uses a structured contract so the route can distinguish +// single-message forwarding, bundle forwarding, and the legacy notice shape. export type MessageKind = | "text" | "voice_intent" | "image_intent" | "video_intent" - | "forward_notice"; + | "forward_notice" + | "forward_single" + | "forward_bundle"; + +export interface ForwardSource { + sourceProjectId: string; + sourceProjectName: string; + sourceThreadId?: string; + sourceThreadTitle?: string; + sourceMessageId: string; + forwardedBy: string; + forwardedAt: string; +} + +export interface ForwardBundleItem { + messageId: string; + senderLabel: string; + body: string; + kind: string; + sentAt: string; +} + +export interface ForwardBundlePayload { + sourceProjectId: string; + sourceProjectName: string; + sourceThreadId?: string; + sourceThreadTitle?: string; + itemCount: number; + startedAt: string; + endedAt: string; + items: ForwardBundleItem[]; +} export type ContextBudgetLevel = "safe" | "watch" | "urgent" | "critical"; export type ThreadState = | "idle" @@ -113,6 +146,8 @@ export interface Message { body: string; sentAt: string; kind?: MessageKind; + forwardSource?: ForwardSource; + forwardBundle?: ForwardBundlePayload; } export interface GoalItem { @@ -1807,6 +1842,8 @@ function normalizeMessage(raw: Partial): Message { body: raw.body ?? "", sentAt: raw.sentAt ?? nowIso(), kind: raw.kind ?? "text", + forwardSource: raw.forwardSource, + forwardBundle: raw.forwardBundle, }; } @@ -4250,49 +4287,198 @@ export async function appendProjectMessage(payload: { return message; } -export async function forwardProjectMessage(payload: { - sourceProjectId: string; - targetProjectId: string; - note: string; +function findProjectMessage(project: Project, messageId: string) { + return project.messages.find((message) => message.id === messageId) ?? null; +} + +function requiresForwardApproval(source: Project, target: Project) { + return source.collaborationMode === "approval_required" && target.id !== "master-agent"; +} + +function buildForwardSingleMessage(input: { + source: Project; + target: Project; + message: Message; + requestedBy: string; }) { + const sentAt = nowIso(); + const body = `转发自《${input.source.name}》到《${input.target.name}》:${input.message.body}`; + return { + id: randomToken("forward"), + sender: "user" as const, + senderLabel: "你", + body, + sentAt, + kind: "forward_single" as const, + forwardSource: { + sourceProjectId: input.source.id, + sourceProjectName: input.source.name, + sourceThreadId: input.source.threadMeta?.threadId, + sourceThreadTitle: input.source.threadMeta?.threadDisplayName, + sourceMessageId: input.message.id, + forwardedBy: input.requestedBy, + forwardedAt: sentAt, + }, + } satisfies Message; +} + +function buildForwardBundleMessage(input: { + source: Project; + target: Project; + messages: Message[]; + requestedBy: string; +}) { + const sentAt = nowIso(); + const startedAt = input.messages[0]?.sentAt ?? sentAt; + const endedAt = input.messages[input.messages.length - 1]?.sentAt ?? sentAt; + const body = `转发自《${input.source.name}》到《${input.target.name}》:${input.messages.length} 条消息,最后一条:${ + input.messages[input.messages.length - 1]?.body ?? "" + }`; + return { + id: randomToken("forward"), + sender: "user" as const, + senderLabel: "你", + body, + sentAt, + kind: "forward_bundle" as const, + forwardBundle: { + sourceProjectId: input.source.id, + sourceProjectName: input.source.name, + sourceThreadId: input.source.threadMeta?.threadId, + sourceThreadTitle: input.source.threadMeta?.threadDisplayName, + itemCount: input.messages.length, + startedAt, + endedAt, + items: input.messages.map((message) => ({ + messageId: message.id, + senderLabel: message.senderLabel, + body: message.body, + kind: message.kind ?? "text", + sentAt: message.sentAt, + })), + }, + } satisfies Message; +} + +export async function forwardProjectMessage( + payload: + | { + sourceProjectId: string; + mode: "single"; + targetProjectId: string; + sourceMessageId: string; + requestedBy: string; + } + | { + sourceProjectId: string; + mode: "bundle"; + targetProjectId: string; + sourceMessageIds: string[]; + requestedBy: string; + }, +) { + const state = await readState(); + const source = state.projects.find((item) => item.id === payload.sourceProjectId); + const target = state.projects.find((item) => item.id === payload.targetProjectId); + if (!source || !target) throw new Error("PROJECT_NOT_FOUND"); + if (requiresForwardApproval(source, target)) { + return { + approvalRequired: true, + approvalReason: "NON_DEVELOPMENT_THREAD_FORWARD", + } as const; + } + + if (payload.mode === "single") { + const sourceMessage = findProjectMessage(source, payload.sourceMessageId); + if (!sourceMessage) throw new Error("MESSAGE_NOT_FOUND"); + + const message = await mutateState((state) => { + const sourceProject = state.projects.find((item) => item.id === payload.sourceProjectId); + const targetProject = state.projects.find((item) => item.id === payload.targetProjectId); + if (!sourceProject || !targetProject) throw new Error("PROJECT_NOT_FOUND"); + const sourceLedgerMessage = findProjectMessage(sourceProject, payload.sourceMessageId); + if (!sourceLedgerMessage) throw new Error("MESSAGE_NOT_FOUND"); + + const message = buildForwardSingleMessage({ + source: sourceProject, + target: targetProject, + message: sourceLedgerMessage, + requestedBy: payload.requestedBy, + }); + + targetProject.messages.push(message); + targetProject.unreadCount += 1; + targetProject.lastMessageAt = message.sentAt; + targetProject.preview = message.body; + + sourceProject.messages.push({ + id: randomToken("forward-log"), + sender: "master", + senderLabel: "主 Agent", + body: `已转发到《${targetProject.name}》。`, + sentAt: message.sentAt, + kind: "forward_notice", + }); + sourceProject.lastMessageAt = message.sentAt; + return message; + }); + + publishBossEvent("project.messages.updated", { projectId: payload.sourceProjectId }); + publishBossEvent("project.messages.updated", { projectId: payload.targetProjectId }); + publishBossEvent("conversation.updated", { projectId: payload.sourceProjectId }); + publishBossEvent("conversation.updated", { projectId: payload.targetProjectId }); + return { message }; + } + + const sourceMessageIds = payload.mode === "bundle" ? payload.sourceMessageIds : []; + const sourceMessages = sourceMessageIds + .map((messageId) => findProjectMessage(source, messageId)) + .filter((message): message is Message => Boolean(message)); + if (sourceMessages.length <= 1 || sourceMessages.length !== sourceMessageIds.length) { + throw new Error("MESSAGE_NOT_FOUND"); + } + const message = await mutateState((state) => { - const source = state.projects.find((item) => item.id === payload.sourceProjectId); - const target = state.projects.find((item) => item.id === payload.targetProjectId); - if (!source || !target) throw new Error("PROJECT_NOT_FOUND"); - if (!payload.note.trim()) throw new Error("FORWARD_NOTE_REQUIRED"); + const sourceProject = state.projects.find((item) => item.id === payload.sourceProjectId); + const targetProject = state.projects.find((item) => item.id === payload.targetProjectId); + if (!sourceProject || !targetProject) throw new Error("PROJECT_NOT_FOUND"); - const sentAt = nowIso(); - const forwardBody = `转发自《${source.name}》:${payload.note.trim()}`; - const message: Message = { - id: randomToken("forward"), - sender: "user", - senderLabel: "你", - body: forwardBody, - sentAt, - kind: "forward_notice", - }; + const bundleMessages = sourceMessageIds + .map((messageId) => findProjectMessage(sourceProject, messageId)) + .filter((item): item is Message => Boolean(item)); + if (bundleMessages.length <= 1 || bundleMessages.length !== sourceMessageIds.length) { + throw new Error("MESSAGE_NOT_FOUND"); + } - target.messages.push(message); - target.unreadCount += 1; - target.lastMessageAt = sentAt; - target.preview = forwardBody; + const message = buildForwardBundleMessage({ + source: sourceProject, + target: targetProject, + messages: bundleMessages, + requestedBy: payload.requestedBy, + }); - source.messages.push({ + targetProject.messages.push(message); + targetProject.unreadCount += 1; + targetProject.lastMessageAt = message.sentAt; + targetProject.preview = message.body; + + sourceProject.messages.push({ id: randomToken("forward-log"), sender: "master", senderLabel: "主 Agent", - body: `已把消息转发到《${target.name}》。`, - sentAt, + body: `已转发到《${targetProject.name}》。`, + sentAt: message.sentAt, kind: "forward_notice", }); - source.lastMessageAt = sentAt; + sourceProject.lastMessageAt = message.sentAt; return message; }); + publishBossEvent("project.messages.updated", { projectId: payload.sourceProjectId }); publishBossEvent("project.messages.updated", { projectId: payload.targetProjectId }); publishBossEvent("conversation.updated", { projectId: payload.sourceProjectId }); publishBossEvent("conversation.updated", { projectId: payload.targetProjectId }); - return message; + return { message }; } export async function updateUserSettings(settings: Partial) {