Implement attachment analysis task flow
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { canSessionAccessAttachmentProject } from "@/lib/boss-attachment-access";
|
||||
import { getProjectAttachment, readState } from "@/lib/boss-data";
|
||||
import { queueAttachmentAnalysisTask } from "@/lib/boss-master-agent";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ projectId: string; attachmentId: string }> },
|
||||
) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { projectId, attachmentId } = await context.params;
|
||||
const record = await getProjectAttachment(projectId, attachmentId);
|
||||
if (!record) {
|
||||
return NextResponse.json({ ok: false, message: "ATTACHMENT_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
|
||||
const state = await readState();
|
||||
if (!canSessionAccessAttachmentProject(state, session, record.project)) {
|
||||
return NextResponse.json({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (record.attachment.analysisState !== "ready_manual" && record.attachment.analysisState !== "failed") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
message: "ATTACHMENT_NOT_READY_FOR_MANUAL_ANALYSIS",
|
||||
analysisState: record.attachment.analysisState,
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const task = await queueAttachmentAnalysisTask({
|
||||
projectId,
|
||||
attachmentId,
|
||||
requestMessageId: record.message.id,
|
||||
requestedBy: session.displayName || "你",
|
||||
requestedByAccount: session.account,
|
||||
markProcessing: true,
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, taskId: task.taskId, task });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "UNKNOWN_ERROR";
|
||||
const status = message === "ATTACHMENT_NOT_FOUND" ? 404 : 500;
|
||||
return NextResponse.json({ ok: false, message }, { status });
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
readState,
|
||||
type MessageAttachment,
|
||||
} from "@/lib/boss-data";
|
||||
import { queueAttachmentAnalysisTask } from "@/lib/boss-master-agent";
|
||||
import { detectAttachmentKind, resolveAttachmentAnalysisState } from "@/lib/boss-attachments";
|
||||
import { getAttachmentStorageProvider } from "@/lib/boss-storage";
|
||||
|
||||
@@ -81,10 +82,22 @@ export async function POST(
|
||||
attachment,
|
||||
});
|
||||
|
||||
let analysisTask = null;
|
||||
if (attachment.analysisState === "queued_auto") {
|
||||
analysisTask = await queueAttachmentAnalysisTask({
|
||||
projectId,
|
||||
attachmentId,
|
||||
requestMessageId: message.id,
|
||||
requestedBy: session.displayName || "你",
|
||||
requestedByAccount: session.account,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
attachment,
|
||||
message,
|
||||
analysisTask,
|
||||
downloadUrl: `/api/v1/attachments/${attachmentId}/download`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -121,6 +121,7 @@ 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 interface UserSettings {
|
||||
liveUpdates: boolean;
|
||||
@@ -393,6 +394,7 @@ export interface MasterIdentitySummary {
|
||||
export interface MasterAgentTask {
|
||||
taskId: string;
|
||||
projectId: string;
|
||||
taskType: MasterAgentTaskType;
|
||||
requestMessageId: string;
|
||||
requestText: string;
|
||||
executionPrompt: string;
|
||||
@@ -401,6 +403,8 @@ export interface MasterAgentTask {
|
||||
deviceId: string;
|
||||
accountId?: string;
|
||||
accountLabel?: string;
|
||||
attachmentId?: string;
|
||||
attachmentFileName?: string;
|
||||
status: MasterAgentTaskStatus;
|
||||
requestedAt: string;
|
||||
claimedAt?: string;
|
||||
@@ -2104,6 +2108,7 @@ function normalizeState(raw: Partial<BossState> | undefined): BossState {
|
||||
masterAgentTasks: ensureArray(raw.masterAgentTasks, base.masterAgentTasks).map((task) => ({
|
||||
taskId: task.taskId ?? randomToken("mastertask"),
|
||||
projectId: task.projectId ?? "master-agent",
|
||||
taskType: task.taskType ?? "conversation_reply",
|
||||
requestMessageId: task.requestMessageId ?? "",
|
||||
requestText: task.requestText ?? "",
|
||||
executionPrompt: task.executionPrompt ?? task.requestText ?? "",
|
||||
@@ -2112,6 +2117,8 @@ function normalizeState(raw: Partial<BossState> | undefined): BossState {
|
||||
deviceId: task.deviceId ?? PRIMARY_CODEX_NODE_ID,
|
||||
accountId: task.accountId,
|
||||
accountLabel: task.accountLabel,
|
||||
attachmentId: task.attachmentId,
|
||||
attachmentFileName: task.attachmentFileName,
|
||||
status: task.status ?? "queued",
|
||||
requestedAt: task.requestedAt ?? nowIso(),
|
||||
claimedAt: task.claimedAt,
|
||||
@@ -3429,6 +3436,8 @@ export async function getMasterAgentRuntimeAccount() {
|
||||
}
|
||||
|
||||
export async function queueMasterAgentTask(payload: {
|
||||
projectId?: string;
|
||||
taskType?: MasterAgentTaskType;
|
||||
requestMessageId: string;
|
||||
requestText: string;
|
||||
executionPrompt: string;
|
||||
@@ -3437,11 +3446,14 @@ export async function queueMasterAgentTask(payload: {
|
||||
deviceId: string;
|
||||
accountId?: string;
|
||||
accountLabel?: string;
|
||||
attachmentId?: string;
|
||||
attachmentFileName?: string;
|
||||
}) {
|
||||
const task = await mutateState((state) => {
|
||||
const task: MasterAgentTask = {
|
||||
taskId: randomToken("mastertask"),
|
||||
projectId: "master-agent",
|
||||
projectId: payload.projectId ?? "master-agent",
|
||||
taskType: payload.taskType ?? "conversation_reply",
|
||||
requestMessageId: payload.requestMessageId,
|
||||
requestText: payload.requestText,
|
||||
executionPrompt: payload.executionPrompt,
|
||||
@@ -3450,6 +3462,8 @@ export async function queueMasterAgentTask(payload: {
|
||||
deviceId: payload.deviceId,
|
||||
accountId: payload.accountId,
|
||||
accountLabel: payload.accountLabel,
|
||||
attachmentId: payload.attachmentId,
|
||||
attachmentFileName: payload.attachmentFileName,
|
||||
status: "queued",
|
||||
requestedAt: nowIso(),
|
||||
};
|
||||
@@ -3470,6 +3484,7 @@ export async function getMasterAgentTask(taskId: string) {
|
||||
}
|
||||
|
||||
export async function claimNextMasterAgentTask(deviceId: string) {
|
||||
let attachmentProjectId: string | undefined;
|
||||
const task = await mutateState((state) => {
|
||||
const next = state.masterAgentTasks.find(
|
||||
(item) => item.deviceId === deviceId && item.status === "queued",
|
||||
@@ -3477,6 +3492,16 @@ export async function claimNextMasterAgentTask(deviceId: string) {
|
||||
if (!next) return null;
|
||||
next.status = "running";
|
||||
next.claimedAt = nowIso();
|
||||
if (next.taskType === "attachment_analysis" && next.attachmentId) {
|
||||
const project = state.projects.find((item) => item.id === next.projectId);
|
||||
const match = project ? findProjectAttachment(project, next.attachmentId) : null;
|
||||
if (match) {
|
||||
match.attachment.analysisState = "processing";
|
||||
match.attachment.analysisSummary = undefined;
|
||||
match.attachment.analysisCardId = undefined;
|
||||
attachmentProjectId = next.projectId;
|
||||
}
|
||||
}
|
||||
return { ...next };
|
||||
});
|
||||
if (task) {
|
||||
@@ -3485,6 +3510,10 @@ export async function claimNextMasterAgentTask(deviceId: string) {
|
||||
deviceId: task.deviceId,
|
||||
status: task.status,
|
||||
});
|
||||
if (attachmentProjectId) {
|
||||
publishBossEvent("project.messages.updated", { projectId: attachmentProjectId });
|
||||
publishBossEvent("conversation.updated", { projectId: attachmentProjectId });
|
||||
}
|
||||
}
|
||||
return task;
|
||||
}
|
||||
@@ -3531,15 +3560,56 @@ export async function completeMasterAgentTask(payload: {
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.status === "completed" && task.replyBody) {
|
||||
pushProjectLedgerMessage(state, "master-agent", {
|
||||
let attachmentProjectId: string | undefined;
|
||||
if (task.taskType === "attachment_analysis" && task.attachmentId) {
|
||||
const project = state.projects.find((item) => item.id === task.projectId);
|
||||
const match = project ? findProjectAttachment(project, task.attachmentId) : null;
|
||||
if (match) {
|
||||
attachmentProjectId = project?.id;
|
||||
if (payload.status === "completed") {
|
||||
const summary = summarizeAttachmentAnalysis(task.replyBody ?? "");
|
||||
match.attachment.analysisState = "completed";
|
||||
match.attachment.analysisSummary = summary;
|
||||
pushProjectLedgerMessage(state, task.projectId, {
|
||||
sender: "master",
|
||||
senderLabel: task.accountLabel ? `主 Agent · ${task.accountLabel}` : "主 Agent",
|
||||
body: summary,
|
||||
kind: "text",
|
||||
});
|
||||
if (task.replyBody) {
|
||||
const card = pushProjectLedgerMessage(state, task.projectId, {
|
||||
sender: "master",
|
||||
senderLabel: task.accountLabel ? `主 Agent · ${task.accountLabel}` : "主 Agent",
|
||||
body: task.replyBody,
|
||||
kind: "analysis_card",
|
||||
});
|
||||
match.attachment.analysisCardId = card?.id;
|
||||
} else {
|
||||
match.attachment.analysisCardId = undefined;
|
||||
}
|
||||
} else if (payload.status === "failed") {
|
||||
match.attachment.analysisState = "failed";
|
||||
match.attachment.analysisSummary = task.errorMessage ?? "附件分析失败,请稍后重试。";
|
||||
match.attachment.analysisCardId = undefined;
|
||||
pushProjectLedgerMessage(state, task.projectId, {
|
||||
sender: "ops",
|
||||
senderLabel: task.accountLabel ? `主 Agent Relay · ${task.accountLabel}` : "主 Agent Relay",
|
||||
body: `附件分析失败:${task.errorMessage ?? "UNKNOWN_ERROR"}`,
|
||||
kind: "text",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
});
|
||||
} else if (payload.status === "failed") {
|
||||
pushProjectLedgerMessage(state, "master-agent", {
|
||||
} else if (!attachmentProjectId && payload.status === "failed") {
|
||||
pushProjectLedgerMessage(state, task.projectId, {
|
||||
sender: "ops",
|
||||
senderLabel: task.accountLabel ? `主 Agent Relay · ${task.accountLabel}` : "主 Agent Relay",
|
||||
body: `Master Codex Node 执行失败:${task.errorMessage ?? "UNKNOWN_ERROR"}`,
|
||||
@@ -4482,6 +4552,23 @@ export function findProjectAttachment(
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getProjectAttachment(projectId: string, attachmentId: string) {
|
||||
const state = await readState();
|
||||
const project = state.projects.find((item) => item.id === projectId);
|
||||
if (!project) {
|
||||
return null;
|
||||
}
|
||||
const match = findProjectAttachment(project, attachmentId);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
project,
|
||||
message: match.message,
|
||||
attachment: match.attachment,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAttachmentById(attachmentId: string) {
|
||||
const state = await readState();
|
||||
for (const project of state.projects) {
|
||||
@@ -4497,6 +4584,70 @@ export async function getAttachmentById(attachmentId: string) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function summarizeAttachmentAnalysis(body: string) {
|
||||
const compact = body.replace(/\s+/g, " ").trim();
|
||||
if (!compact) {
|
||||
return "附件分析已完成。";
|
||||
}
|
||||
return compact.length <= 120 ? compact : `${compact.slice(0, 117)}...`;
|
||||
}
|
||||
|
||||
export async function updateAttachmentAnalysisResult(payload: {
|
||||
projectId: string;
|
||||
attachmentId: string;
|
||||
status: Exclude<AttachmentAnalysisState, "not_applicable" | "queued_auto" | "ready_manual">;
|
||||
summary?: string;
|
||||
cardBody?: string;
|
||||
}) {
|
||||
return mutateState((state) => {
|
||||
const project = state.projects.find((item) => item.id === payload.projectId);
|
||||
if (!project) {
|
||||
throw new Error("PROJECT_NOT_FOUND");
|
||||
}
|
||||
const match = findProjectAttachment(project, payload.attachmentId);
|
||||
if (!match) {
|
||||
throw new Error("ATTACHMENT_NOT_FOUND");
|
||||
}
|
||||
|
||||
match.attachment.analysisState = payload.status;
|
||||
match.attachment.analysisSummary =
|
||||
payload.status === "completed"
|
||||
? payload.summary?.trim() || summarizeAttachmentAnalysis(payload.cardBody ?? "")
|
||||
: payload.summary;
|
||||
match.attachment.analysisCardId = undefined;
|
||||
|
||||
if (payload.status === "completed" && payload.cardBody?.trim()) {
|
||||
const summary = payload.summary?.trim() || summarizeAttachmentAnalysis(payload.cardBody);
|
||||
pushProjectLedgerMessage(state, payload.projectId, {
|
||||
sender: "master",
|
||||
senderLabel: "主 Agent",
|
||||
body: summary,
|
||||
kind: "text",
|
||||
});
|
||||
const card = pushProjectLedgerMessage(state, payload.projectId, {
|
||||
sender: "master",
|
||||
senderLabel: "主 Agent",
|
||||
body: payload.cardBody.trim(),
|
||||
kind: "analysis_card",
|
||||
});
|
||||
match.attachment.analysisCardId = card?.id;
|
||||
match.attachment.analysisSummary = summary;
|
||||
}
|
||||
|
||||
return {
|
||||
projectId: payload.projectId,
|
||||
attachmentId: payload.attachmentId,
|
||||
analysisState: match.attachment.analysisState,
|
||||
analysisSummary: match.attachment.analysisSummary,
|
||||
analysisCardId: match.attachment.analysisCardId,
|
||||
};
|
||||
}).then((result) => {
|
||||
publishBossEvent("project.messages.updated", { projectId: result.projectId });
|
||||
publishBossEvent("conversation.updated", { projectId: result.projectId });
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
function requiresForwardApproval(source: Project, target: Project) {
|
||||
return source.collaborationMode === "approval_required" && target.id !== "master-agent";
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@ import {
|
||||
AUTH_SESSION_TTL_MS,
|
||||
aiProviderLabel,
|
||||
appendProjectMessage,
|
||||
getProjectAttachment,
|
||||
getRuntimeAiAccountById,
|
||||
getMasterAgentRuntimeAccount,
|
||||
getMasterAgentTask,
|
||||
queueMasterAgentTask,
|
||||
readState,
|
||||
updateAttachmentAnalysisResult,
|
||||
updateAiAccountHealth,
|
||||
} from "@/lib/boss-data";
|
||||
|
||||
@@ -218,6 +220,91 @@ async function waitForMasterAgentTaskCompletion(taskId: string, timeoutMs = 55_0
|
||||
return getMasterAgentTask(taskId);
|
||||
}
|
||||
|
||||
function buildAttachmentAnalysisPrompt(params: {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
attachment: NonNullable<Awaited<ReturnType<typeof getProjectAttachment>>>["attachment"];
|
||||
messageBody: string;
|
||||
requestedBy: string;
|
||||
requestedByAccount: string;
|
||||
}) {
|
||||
const attachment = params.attachment;
|
||||
return [
|
||||
"你是 Boss 控制台的附件分析主 Agent。",
|
||||
"请只根据下面的附件元数据和你能实际读取到的附件内容进行分析。",
|
||||
"如果你无法直接读取原始内容,不要假装已经看过内容,必须明确说明限制,并只基于元数据给出判断。",
|
||||
"输出要求:",
|
||||
"1. 一句话结论",
|
||||
"2. 内容摘要或可见特征",
|
||||
"3. 风险或异常",
|
||||
"4. 建议动作",
|
||||
"",
|
||||
`projectId: ${params.projectId}`,
|
||||
`projectName: ${params.projectName}`,
|
||||
`requestedBy: ${params.requestedBy}`,
|
||||
`requestedByAccount: ${params.requestedByAccount}`,
|
||||
`attachmentId: ${attachment.attachmentId}`,
|
||||
`fileName: ${attachment.fileName}`,
|
||||
`mimeType: ${attachment.mimeType}`,
|
||||
`fileSizeBytes: ${attachment.fileSizeBytes}`,
|
||||
`attachmentKind: ${attachment.attachmentKind}`,
|
||||
`storageBackend: ${attachment.storageBackend}`,
|
||||
`storagePath: ${attachment.storagePath}`,
|
||||
`previewAvailable: ${attachment.previewAvailable ? "yes" : "no"}`,
|
||||
`uploadedAt: ${attachment.uploadedAt}`,
|
||||
`uploadedBy: ${attachment.uploadedBy}`,
|
||||
`analysisState: ${attachment.analysisState}`,
|
||||
"",
|
||||
"原始消息:",
|
||||
params.messageBody || "无",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export async function queueAttachmentAnalysisTask(params: {
|
||||
projectId: string;
|
||||
attachmentId: string;
|
||||
requestMessageId: string;
|
||||
requestedBy: string;
|
||||
requestedByAccount: string;
|
||||
markProcessing?: boolean;
|
||||
}) {
|
||||
const record = await getProjectAttachment(params.projectId, params.attachmentId);
|
||||
if (!record) {
|
||||
throw new Error("ATTACHMENT_NOT_FOUND");
|
||||
}
|
||||
|
||||
const state = await readState();
|
||||
const task = await queueMasterAgentTask({
|
||||
projectId: record.project.id,
|
||||
taskType: "attachment_analysis",
|
||||
requestMessageId: params.requestMessageId,
|
||||
requestText: `分析附件《${record.attachment.fileName}》`,
|
||||
executionPrompt: buildAttachmentAnalysisPrompt({
|
||||
projectId: record.project.id,
|
||||
projectName: record.project.name,
|
||||
attachment: record.attachment,
|
||||
messageBody: record.message.body,
|
||||
requestedBy: params.requestedBy,
|
||||
requestedByAccount: params.requestedByAccount,
|
||||
}),
|
||||
requestedBy: params.requestedBy,
|
||||
requestedByAccount: params.requestedByAccount,
|
||||
deviceId: state.user.boundDeviceId || "mac-studio",
|
||||
attachmentId: record.attachment.attachmentId,
|
||||
attachmentFileName: record.attachment.fileName,
|
||||
});
|
||||
|
||||
if (params.markProcessing) {
|
||||
await updateAttachmentAnalysisResult({
|
||||
projectId: params.projectId,
|
||||
attachmentId: params.attachmentId,
|
||||
status: "processing",
|
||||
});
|
||||
}
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
export async function validateAiAccountConnection(accountId: string) {
|
||||
const account = await getRuntimeAiAccountById(accountId);
|
||||
if (!account) {
|
||||
|
||||
Reference in New Issue
Block a user