feat: add group repair and dispatch rejection flows

This commit is contained in:
kris
2026-03-31 03:56:28 +08:00
parent 9c02ebb574
commit 4336dc22a7
21 changed files with 832 additions and 83 deletions

View File

@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from "next/server";
import { requireRequestSession } from "@/lib/boss-auth";
import { rejectDispatchPlan } from "@/lib/boss-data";
export async function POST(
request: NextRequest,
context: { params: Promise<{ projectId: string; planId: string }> },
) {
const session = await requireRequestSession(request);
if (!session) {
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
}
const { projectId, planId } = await context.params;
try {
const result = await rejectDispatchPlan({
groupProjectId: projectId,
planId,
rejectedBy: session.account,
});
return NextResponse.json({
ok: true,
plan: result.plan,
notice: result.notice,
});
} catch (error) {
return NextResponse.json(
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
{ status: 400 },
);
}
}

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { requireRequestSession } from "@/lib/boss-auth";
import { readState } from "@/lib/boss-data";
import { isDispatchableThreadProject, readState, replaceGroupChatMembers } from "@/lib/boss-data";
type ConversationParticipant = {
projectId: string;
@@ -10,6 +10,9 @@ type ConversationParticipant = {
folderName: string;
avatar: string;
isSourceProject: boolean;
status: "active" | "missing_project" | "invalid_target";
statusLabel?: string;
canOpenProject: boolean;
};
function getFallbackAvatar(label: string) {
@@ -26,6 +29,8 @@ function buildParticipant(
folderName: string,
avatar?: string,
isSourceProject = false,
status: ConversationParticipant["status"] = "active",
canOpenProject = true,
): ConversationParticipant {
return {
projectId,
@@ -35,6 +40,81 @@ function buildParticipant(
folderName,
avatar: avatar?.trim() || getFallbackAvatar(threadDisplayName),
isSourceProject,
status,
statusLabel:
status === "missing_project"
? "引用已失效"
: status === "invalid_target"
? "不是可下发线程"
: undefined,
canOpenProject,
};
}
function buildParticipantsPayload(
state: Awaited<ReturnType<typeof readState>>,
projectId: string,
) {
const project = state.projects.find((item) => item.id === projectId);
if (!project) {
return null;
}
const participants = project.isGroup
? project.groupMembers.map((member) => {
const candidateProject = state.projects.find((item) => item.id === member.projectId);
const device = state.devices.find((item) => item.id === member.deviceId);
const status: ConversationParticipant["status"] = !candidateProject
? "missing_project"
: isDispatchableThreadProject(candidateProject)
? "active"
: "invalid_target";
return buildParticipant(
member.projectId,
member.deviceId,
member.threadId,
member.threadDisplayName,
member.folderName,
device?.avatar,
member.projectId === project.id,
status,
Boolean(candidateProject),
);
})
: [
buildParticipant(
project.id,
project.deviceIds[0] ?? project.id,
project.threadMeta.threadId,
project.threadMeta.threadDisplayName,
project.threadMeta.folderName,
state.devices.find((item) => item.id === project.deviceIds[0])?.avatar,
true,
),
];
const validParticipantCount = participants.filter((item) => item.status === "active").length;
const invalidParticipantCount = participants.length - validParticipantCount;
const repairRequired =
project.isGroup && (invalidParticipantCount > 0 || validParticipantCount < 2);
const repairReason = !repairRequired
? undefined
: validParticipantCount === 0
? "当前群聊里还没有可下发的真实线程,请重新添加线程。"
: invalidParticipantCount > 0
? "当前群聊里有失效或不可下发的线程引用,请重新整理群成员。"
: "当前群聊至少需要 2 个真实线程成员。";
return {
ok: true,
projectId: project.id,
isGroup: project.isGroup,
threadMeta: project.threadMeta,
participants,
repairRequired,
repairReason,
validParticipantCount,
invalidParticipantCount,
};
}
@@ -49,54 +129,47 @@ export async function GET(
const { projectId } = await context.params;
const state = await readState();
const project = state.projects.find((item) => item.id === projectId);
if (!project) {
const payload = buildParticipantsPayload(state, projectId);
if (!payload) {
return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 });
}
const participants = project.isGroup
? (project.groupMembers.length > 0
? project.groupMembers.map((member) => {
const device = state.devices.find((item) => item.id === member.deviceId);
return buildParticipant(
member.projectId,
member.deviceId,
member.threadId,
member.threadDisplayName,
member.folderName,
device?.avatar,
member.projectId === project.id,
);
})
: [
buildParticipant(
project.id,
project.deviceIds[0] ?? project.id,
project.threadMeta.threadId,
project.threadMeta.threadDisplayName,
project.threadMeta.folderName,
state.devices.find((item) => item.id === project.deviceIds[0])?.avatar,
true,
),
])
: [
buildParticipant(
project.id,
project.deviceIds[0] ?? project.id,
project.threadMeta.threadId,
project.threadMeta.threadDisplayName,
project.threadMeta.folderName,
state.devices.find((item) => item.id === project.deviceIds[0])?.avatar,
true,
),
];
return NextResponse.json({
ok: true,
projectId: project.id,
isGroup: project.isGroup,
threadMeta: project.threadMeta,
participants,
});
return NextResponse.json(payload);
}
export async function POST(
request: NextRequest,
context: { params: Promise<{ projectId: string }> },
) {
const session = await requireRequestSession(request);
if (!session) {
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
}
const body = (await request.json().catch(() => ({}))) as {
memberProjectIds?: string[];
};
const { projectId } = await context.params;
try {
await replaceGroupChatMembers({
projectId,
memberProjectIds: Array.isArray(body.memberProjectIds)
? body.memberProjectIds.filter(
(item): item is string => typeof item === "string" && item.trim().length > 0,
)
: [],
requestedBy: session.account,
});
const nextState = await readState();
const payload = buildParticipantsPayload(nextState, projectId);
if (!payload) {
return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 });
}
return NextResponse.json(payload);
} catch (error) {
return NextResponse.json(
{ ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" },
{ status: 400 },
);
}
}

View File

@@ -4175,6 +4175,50 @@ export async function confirmDispatchPlan(input: {
});
}
export async function rejectDispatchPlan(input: {
groupProjectId: string;
planId: string;
rejectedBy: string;
}) {
const result = await mutateState((state) => {
const groupProjectId = input.groupProjectId.trim();
if (!groupProjectId) throw new Error("PROJECT_NOT_FOUND");
const groupProject = state.projects.find((item) => item.id === groupProjectId);
if (!groupProject) throw new Error("PROJECT_NOT_FOUND");
if (!groupProject.isGroup) throw new Error("PROJECT_NOT_GROUP_CHAT");
requireDispatchActorSession(state, input.rejectedBy);
const plan = state.dispatchPlans.find((item) => item.planId === input.planId);
if (!plan) throw new Error("DISPATCH_PLAN_NOT_FOUND");
if (plan.groupProjectId !== groupProjectId) {
throw new Error("DISPATCH_PLAN_PROJECT_MISMATCH");
}
if (plan.status === "dispatched") {
throw new Error("DISPATCH_PLAN_ALREADY_DISPATCHED");
}
if (plan.status !== "rejected") {
plan.status = "rejected";
}
groupProject.approvalState = "rejected";
const notice =
pushProjectLedgerMessage(state, groupProjectId, {
sender: "master",
senderLabel: "主 Agent",
body: "已拒绝主 Agent 推荐,本次不会下发到任何线程。",
kind: "system_notice",
}) ?? null;
return {
plan: { ...plan },
notice: notice ? { ...notice } : null,
};
});
publishBossEvent("project.messages.updated", { projectId: input.groupProjectId });
publishBossEvent("conversation.updated", { projectId: input.groupProjectId });
return result;
}
export async function createDispatchExecutionsFromPlan(input: {
planId: string;
confirmedBy: string;
@@ -6266,17 +6310,13 @@ export async function createIndependentGroupChat(input: {
return project;
}
function createGroupChatFromProjectIds(
function resolveGroupChatThreadProjects(
state: BossState,
input: {
requestedProjectIds: string[];
createdBy: string;
defaultRiskLevel?: Project["riskLevel"];
},
requestedProjectIds: string[],
) {
const memberProjects: Project[] = [];
const seenProjectIds = new Set<string>();
for (const projectId of input.requestedProjectIds) {
for (const projectId of requestedProjectIds) {
if (!projectId || seenProjectIds.has(projectId)) {
continue;
}
@@ -6291,6 +6331,77 @@ function createGroupChatFromProjectIds(
}
memberProjects.push(memberProject);
}
return memberProjects;
}
export async function replaceGroupChatMembers(input: {
projectId: string;
memberProjectIds: string[];
requestedBy: string;
}) {
const result = await mutateState((state) => {
const groupProject = state.projects.find((item) => item.id === input.projectId);
if (!groupProject) {
throw new Error("PROJECT_NOT_FOUND");
}
if (!groupProject.isGroup) {
throw new Error("PROJECT_NOT_GROUP_CHAT");
}
const memberProjects = resolveGroupChatThreadProjects(state, input.memberProjectIds);
if (memberProjects.length < 2) {
throw new Error("GROUP_CHAT_REQUIRES_AT_LEAST_TWO_THREADS");
}
const now = nowIso();
groupProject.groupMembers = memberProjects.map((memberProject) => ({
projectId: memberProject.id,
deviceId: memberProject.deviceIds[0] ?? memberProject.id,
threadId: memberProject.threadMeta.threadId,
threadDisplayName: memberProject.threadMeta.threadDisplayName,
folderName: memberProject.threadMeta.folderName,
}));
groupProject.deviceIds = dedupeStrings(groupProject.groupMembers.map((member) => member.deviceId));
groupProject.threadMeta.activityIconCount = Math.max(1, groupProject.groupMembers.length);
groupProject.threadMeta.folderName = "群聊";
groupProject.threadMeta.updatedAt = now;
groupProject.updatedAt = now;
groupProject.lastMessageAt = now;
groupProject.approvalState = "not_required";
const memberLabel = memberProjects
.map((project) => project.threadMeta.threadDisplayName || project.name)
.join("、");
pushProjectLedgerMessage(state, groupProject.id, {
sender: "master",
senderLabel: "主 Agent",
body: `已更新群成员:${memberLabel}`,
kind: "system_notice",
sentAt: now,
});
return {
project: { ...groupProject },
groupMembers: groupProject.groupMembers.map((member) => ({ ...member })),
};
});
publishBossEvent("project.messages.updated", { projectId: input.projectId });
publishBossEvent("conversation.updated", {
projectId: input.projectId,
note: `group members updated by ${input.requestedBy}`,
});
return result;
}
function createGroupChatFromProjectIds(
state: BossState,
input: {
requestedProjectIds: string[];
createdBy: string;
defaultRiskLevel?: Project["riskLevel"];
},
) {
const memberProjects = resolveGroupChatThreadProjects(state, input.requestedProjectIds);
if (memberProjects.length < 2) {
throw new Error("GROUP_CHAT_REQUIRES_AT_LEAST_TWO_THREADS");
}