From 9e4b64ba9e36321e55f788b3ac976e50f4a32063 Mon Sep 17 00:00:00 2001 From: kris Date: Sun, 29 Mar 2026 16:21:05 +0800 Subject: [PATCH] Implement attachment analysis task flow --- scripts/validate-attachment-analysis.mjs | 141 +++++++++++++++ .../[attachmentId]/analyze/route.ts | 56 ++++++ .../projects/[projectId]/attachments/route.ts | 13 ++ src/lib/boss-data.ts | 161 +++++++++++++++++- src/lib/boss-master-agent.ts | 87 ++++++++++ 5 files changed, 453 insertions(+), 5 deletions(-) create mode 100644 scripts/validate-attachment-analysis.mjs create mode 100644 src/app/api/v1/projects/[projectId]/attachments/[attachmentId]/analyze/route.ts diff --git a/scripts/validate-attachment-analysis.mjs b/scripts/validate-attachment-analysis.mjs new file mode 100644 index 0000000..0491270 --- /dev/null +++ b/scripts/validate-attachment-analysis.mjs @@ -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"); diff --git a/src/app/api/v1/projects/[projectId]/attachments/[attachmentId]/analyze/route.ts b/src/app/api/v1/projects/[projectId]/attachments/[attachmentId]/analyze/route.ts new file mode 100644 index 0000000..94d8451 --- /dev/null +++ b/src/app/api/v1/projects/[projectId]/attachments/[attachmentId]/analyze/route.ts @@ -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 }); + } +} diff --git a/src/app/api/v1/projects/[projectId]/attachments/route.ts b/src/app/api/v1/projects/[projectId]/attachments/route.ts index c9089b8..f04a98e 100644 --- a/src/app/api/v1/projects/[projectId]/attachments/route.ts +++ b/src/app/api/v1/projects/[projectId]/attachments/route.ts @@ -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`, }); } diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts index aa3b089..77e08b1 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -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 | 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 | 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; + 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"; } diff --git a/src/lib/boss-master-agent.ts b/src/lib/boss-master-agent.ts index 905b844..5dc2e09 100644 --- a/src/lib/boss-master-agent.ts +++ b/src/lib/boss-master-agent.ts @@ -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>>["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) {