Implement attachment analysis task flow

This commit is contained in:
kris
2026-03-29 16:21:05 +08:00
parent 8273340f7f
commit 9e4b64ba9e
5 changed files with 453 additions and 5 deletions

View File

@@ -0,0 +1,141 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createRequire } from "node:module";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const runtimeDir = await fs.mkdtemp(path.join(os.tmpdir(), "boss-attachment-analysis-"));
const stateFile = path.join(runtimeDir, "data", "boss-state.json");
const require = createRequire(import.meta.url);
process.env.BOSS_RUNTIME_ROOT = runtimeDir;
process.env.BOSS_STATE_FILE = stateFile;
process.env.BOSS_AUTH_AUTO_LOGIN = "0";
const { NextRequest } = require("next/server");
const authLoginRoute = require(path.join(rootDir, ".next/standalone/.next/server/app/api/auth/login/route.js"));
const attachmentsRoute = require(
path.join(rootDir, ".next/standalone/.next/server/app/api/v1/projects/[projectId]/attachments/route.js"),
);
const analyzeRoute = require(
path.join(
rootDir,
".next/standalone/.next/server/app/api/v1/projects/[projectId]/attachments/[attachmentId]/analyze/route.js",
),
);
const loginHandler = authLoginRoute.routeModule.userland.POST;
const uploadHandler = attachmentsRoute.routeModule.userland.POST;
const analyzeHandler = analyzeRoute.routeModule.userland.POST;
async function invokeRoute(handler, url, init = {}, context) {
const request = new NextRequest(url, {
method: init.method ?? "GET",
headers: init.headers,
body: init.body,
});
return handler(request, context);
}
function parseCookieValue(setCookieHeader, cookieName) {
assert.ok(setCookieHeader, "set-cookie header is missing");
const match = setCookieHeader.match(new RegExp(`${cookieName}=([^;]+)`));
assert.ok(match, `${cookieName} cookie is missing`);
return match[1];
}
async function loginAsAdmin() {
const response = await invokeRoute(
loginHandler,
"http://localhost/api/auth/login",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
account: "17600003315",
password: "boss123456",
method: "password",
}),
},
);
assert.equal(response.status, 200, "login should succeed");
const payload = await response.json();
assert.equal(payload.ok, true, "login payload should be ok");
const cookie = parseCookieValue(response.headers.get("set-cookie"), "boss_session");
return { cookie, payload };
}
async function uploadAttachment(cookie, projectId, fileName, type, bytes) {
const form = new FormData();
form.set("file", new File([bytes], fileName, { type }));
const response = await invokeRoute(
uploadHandler,
`http://localhost/api/v1/projects/${projectId}/attachments`,
{
method: "POST",
headers: { cookie: `boss_session=${cookie}` },
body: form,
},
{ params: Promise.resolve({ projectId }) },
);
assert.equal(response.status, 200, `upload ${fileName} should succeed`);
return response.json();
}
const { cookie } = await loginAsAdmin();
const textUpload = await uploadAttachment(
cookie,
"master-agent",
"analysis-note.txt",
"text/plain",
Buffer.from("text attachment for automatic analysis"),
);
assert.equal(textUpload.attachment.analysisState, "queued_auto", "text attachment should queue automatically");
assert.ok(textUpload.analysisTask, "queued auto attachment should create a master agent task");
assert.equal(textUpload.analysisTask.taskType, "attachment_analysis", "queued task type should be attachment_analysis");
assert.equal(
textUpload.analysisTask.attachmentFileName,
"analysis-note.txt",
"queued task should carry attachment file name",
);
const manualUpload = await uploadAttachment(
cookie,
"master-agent",
"manual-binary.bin",
"application/octet-stream",
Buffer.from([0, 1, 2, 3]),
);
assert.equal(manualUpload.attachment.analysisState, "ready_manual", "binary attachment should be manually analyzable");
const analyzeResponse = await invokeRoute(
analyzeHandler,
`http://localhost/api/v1/projects/master-agent/attachments/${manualUpload.attachment.attachmentId}/analyze`,
{
method: "POST",
headers: { cookie: `boss_session=${cookie}` },
},
{
params: Promise.resolve({
projectId: "master-agent",
attachmentId: manualUpload.attachment.attachmentId,
}),
},
);
assert.equal(analyzeResponse.status, 200, "manual analyze should succeed");
const analyzePayload = await analyzeResponse.json();
assert.ok(analyzePayload.taskId, "manual analyze should return a taskId");
assert.ok(analyzePayload.task, "manual analyze should return a task payload");
assert.equal(analyzePayload.task.taskType, "attachment_analysis", "manual analyze task should be attachment_analysis");
assert.equal(
analyzePayload.task.attachmentId,
manualUpload.attachment.attachmentId,
"manual task should link the attachment",
);
console.log("attachment analysis validation passed");

View File

@@ -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 });
}
}

View File

@@ -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`,
});
}

View File

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

View File

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