feat: add structured message forwarding payloads

This commit is contained in:
kris
2026-03-28 07:20:49 +08:00
parent 227d270505
commit 9613c3c154
2 changed files with 272 additions and 39 deletions

View File

@@ -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" },

View File

@@ -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>): 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<UserSettings>) {