diff --git a/scripts/validate-attachment-analysis.mjs b/scripts/validate-attachment-analysis.mjs index af2bcea..ee55c48 100644 --- a/scripts/validate-attachment-analysis.mjs +++ b/scripts/validate-attachment-analysis.mjs @@ -57,6 +57,7 @@ async function startStandaloneServer(appRoot, runtimeDir, port) { ...process.env, PORT: String(port), HOSTNAME: "127.0.0.1", + BOSS_PUBLIC_BASE_URL: baseUrl, BOSS_RUNTIME_ROOT: runtimeDir, BOSS_STATE_FILE: path.join(runtimeDir, "data", "boss-state.json"), BOSS_AUTH_AUTO_LOGIN: "0", @@ -197,6 +198,16 @@ try { "analysis-note.txt", "queued task should carry attachment file name", ); + assert.ok(textUpload.analysisTask.attachmentDownloadUrl, "queued task should expose attachment download url"); + const promptDownloadUrlMatch = textUpload.analysisTask.executionPrompt.match(/downloadUrl:\s+(http[^\s]+)/); + assert.ok(promptDownloadUrlMatch, "execution prompt should include attachment download url"); + const unauthDownloadResponse = await fetch(textUpload.analysisTask.attachmentDownloadUrl); + assert.equal(unauthDownloadResponse.status, 200, "attachment download url should be readable with task token"); + assert.equal( + await unauthDownloadResponse.text(), + "text attachment for automatic analysis", + "downloaded attachment content should match the uploaded text", + ); const manualUpload = await uploadAttachment( currentServer.baseUrl, diff --git a/src/app/api/v1/attachments/[attachmentId]/download/route.ts b/src/app/api/v1/attachments/[attachmentId]/download/route.ts index b0291bd..aaba6e1 100644 --- a/src/app/api/v1/attachments/[attachmentId]/download/route.ts +++ b/src/app/api/v1/attachments/[attachmentId]/download/route.ts @@ -4,30 +4,52 @@ import { Readable } from "node:stream"; import { NextRequest, NextResponse } from "next/server"; import { requireRequestSession } from "@/lib/boss-auth"; import { canSessionAccessAttachmentProject } from "@/lib/boss-attachment-access"; -import { getAttachmentById, getAttachmentStorageConfig, readState } from "@/lib/boss-data"; +import { getAttachmentById, getAttachmentStorageConfig, getMasterAgentTask, readState } from "@/lib/boss-data"; import { buildAttachmentDownloadHeaders } from "@/lib/boss-attachments"; import { getAliyunOssSignedDownloadUrl } from "@/lib/boss-storage-aliyun-oss"; import { resolveServerFileAttachmentAbsolutePath } from "@/lib/boss-storage-server-file"; export const runtime = "nodejs"; +async function hasTaskTokenAccess(request: NextRequest, attachmentId: string) { + const taskId = request.nextUrl.searchParams.get("taskId")?.trim(); + const token = request.nextUrl.searchParams.get("token")?.trim(); + if (!taskId || !token) { + return false; + } + const task = await getMasterAgentTask(taskId); + if (!task || task.taskType !== "attachment_analysis") { + return false; + } + if (task.attachmentId !== attachmentId || task.attachmentDownloadToken !== token) { + return false; + } + if (!task.attachmentDownloadExpiresAt) { + return false; + } + return Date.parse(task.attachmentDownloadExpiresAt) > Date.now(); +} + export async function GET( request: NextRequest, context: { params: Promise<{ attachmentId: string }> }, ) { + const { attachmentId } = await context.params; const session = await requireRequestSession(request); - if (!session) { + const taskTokenAccess = session ? false : await hasTaskTokenAccess(request, attachmentId); + if (!session && !taskTokenAccess) { return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); } - const { attachmentId } = await context.params; const record = await getAttachmentById(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 (session) { + const state = await readState(); + if (!canSessionAccessAttachmentProject(state, session, record.project)) { + return NextResponse.json({ ok: false, message: "FORBIDDEN" }, { status: 403 }); + } } if (record.attachment.storageBackend === "aliyun_oss") { diff --git a/src/lib/boss-attachments.ts b/src/lib/boss-attachments.ts index 90c8b08..37cc71d 100644 --- a/src/lib/boss-attachments.ts +++ b/src/lib/boss-attachments.ts @@ -6,6 +6,7 @@ import type { } from "@/lib/boss-data"; const LARGE_ATTACHMENT_THRESHOLD_BYTES = 20 * 1024 * 1024; +const MAX_ATTACHMENT_TEXT_EXCERPT_CHARS = 12_000; function extensionOf(fileName: string) { return path.extname(fileName).toLowerCase(); @@ -74,3 +75,24 @@ export function buildAttachmentDownloadHeaders(attachment: MessageAttachment) { "X-Content-Type-Options": "nosniff", }; } + +export function canExtractAttachmentText(attachment: Pick) { + return ( + attachment.attachmentKind === "text" || + (attachment.mimeType || "").toLowerCase().startsWith("text/") + ); +} + +export function extractAttachmentTextExcerpt(buffer: Buffer | Uint8Array) { + const normalized = Buffer.from(buffer) + .toString("utf8") + .replace(/\u0000/g, "") + .trim(); + if (!normalized) { + return ""; + } + if (normalized.length <= MAX_ATTACHMENT_TEXT_EXCERPT_CHARS) { + return normalized; + } + return `${normalized.slice(0, MAX_ATTACHMENT_TEXT_EXCERPT_CHARS)}\n...[已截断]`; +} diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts index 49d8047..f67d13a 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -405,6 +405,10 @@ export interface MasterAgentTask { accountLabel?: string; attachmentId?: string; attachmentFileName?: string; + attachmentDownloadToken?: string; + attachmentDownloadExpiresAt?: string; + attachmentDownloadUrl?: string; + attachmentTextExcerpt?: string; status: MasterAgentTaskStatus; requestedAt: string; claimedAt?: string; @@ -2119,6 +2123,10 @@ function normalizeState(raw: Partial | undefined): BossState { accountLabel: task.accountLabel, attachmentId: task.attachmentId, attachmentFileName: task.attachmentFileName, + attachmentDownloadToken: task.attachmentDownloadToken, + attachmentDownloadExpiresAt: task.attachmentDownloadExpiresAt, + attachmentDownloadUrl: task.attachmentDownloadUrl, + attachmentTextExcerpt: task.attachmentTextExcerpt, status: task.status ?? "queued", requestedAt: task.requestedAt ?? nowIso(), claimedAt: task.claimedAt, @@ -3436,6 +3444,7 @@ export async function getMasterAgentRuntimeAccount() { } export async function queueMasterAgentTask(payload: { + taskId?: string; projectId?: string; taskType?: MasterAgentTaskType; requestMessageId: string; @@ -3448,10 +3457,14 @@ export async function queueMasterAgentTask(payload: { accountLabel?: string; attachmentId?: string; attachmentFileName?: string; + attachmentDownloadToken?: string; + attachmentDownloadExpiresAt?: string; + attachmentDownloadUrl?: string; + attachmentTextExcerpt?: string; }) { const task = await mutateState((state) => { const task: MasterAgentTask = { - taskId: randomToken("mastertask"), + taskId: payload.taskId ?? randomToken("mastertask"), projectId: payload.projectId ?? "master-agent", taskType: payload.taskType ?? "conversation_reply", requestMessageId: payload.requestMessageId, @@ -3464,6 +3477,10 @@ export async function queueMasterAgentTask(payload: { accountLabel: payload.accountLabel, attachmentId: payload.attachmentId, attachmentFileName: payload.attachmentFileName, + attachmentDownloadToken: payload.attachmentDownloadToken, + attachmentDownloadExpiresAt: payload.attachmentDownloadExpiresAt, + attachmentDownloadUrl: payload.attachmentDownloadUrl, + attachmentTextExcerpt: payload.attachmentTextExcerpt, status: "queued", requestedAt: nowIso(), }; diff --git a/src/lib/boss-master-agent.ts b/src/lib/boss-master-agent.ts index 5dc2e09..e4cc3ea 100644 --- a/src/lib/boss-master-agent.ts +++ b/src/lib/boss-master-agent.ts @@ -1,8 +1,10 @@ +import { randomBytes } from "node:crypto"; import { AUTH_SESSION_TTL_MS, aiProviderLabel, appendProjectMessage, getProjectAttachment, + getAttachmentStorageConfig, getRuntimeAiAccountById, getMasterAgentRuntimeAccount, getMasterAgentTask, @@ -11,6 +13,9 @@ import { updateAttachmentAnalysisResult, updateAiAccountHealth, } from "@/lib/boss-data"; +import { canExtractAttachmentText, extractAttachmentTextExcerpt } from "@/lib/boss-attachments"; +import { readAliyunOssObjectBuffer } from "@/lib/boss-storage-aliyun-oss"; +import { readServerFileAttachmentBuffer } from "@/lib/boss-storage-server-file"; function buildMasterAgentInstructions() { return [ @@ -220,6 +225,37 @@ async function waitForMasterAgentTaskCompletion(taskId: string, timeoutMs = 55_0 return getMasterAgentTask(taskId); } +function resolveBossPublicBaseUrl() { + const configured = process.env.BOSS_PUBLIC_BASE_URL?.trim(); + return configured && /^https?:\/\//i.test(configured) ? configured.replace(/\/+$/, "") : "https://boss.hyzq.net"; +} + +async function buildAttachmentAnalysisContext(params: { + attachment: NonNullable>>["attachment"]; +}) { + const attachment = params.attachment; + let excerpt = ""; + try { + if (canExtractAttachmentText(attachment)) { + let buffer: Buffer | Uint8Array = Buffer.alloc(0); + if (attachment.storageBackend === "server_file") { + buffer = await readServerFileAttachmentBuffer(attachment.storagePath); + } else if (attachment.storageBackend === "aliyun_oss") { + const config = await getAttachmentStorageConfig(attachment.uploadedBy); + if (config.mode === "oss" && config.ossProvider === "aliyun_oss" && config.aliyunOss) { + buffer = await readAliyunOssObjectBuffer(config.aliyunOss, attachment.storagePath); + } + } + excerpt = extractAttachmentTextExcerpt(buffer); + } + } catch { + excerpt = ""; + } + return { + textExcerpt: excerpt, + }; +} + function buildAttachmentAnalysisPrompt(params: { projectId: string; projectName: string; @@ -227,12 +263,15 @@ function buildAttachmentAnalysisPrompt(params: { messageBody: string; requestedBy: string; requestedByAccount: string; + attachmentDownloadUrl: string; + attachmentTextExcerpt?: string; }) { const attachment = params.attachment; return [ "你是 Boss 控制台的附件分析主 Agent。", - "请只根据下面的附件元数据和你能实际读取到的附件内容进行分析。", - "如果你无法直接读取原始内容,不要假装已经看过内容,必须明确说明限制,并只基于元数据给出判断。", + "请根据下面的附件元数据、可下载地址,以及你能实际读取到的附件内容进行分析。", + "如果需要读取原始文件,请优先使用 curl、python 或系统工具下载并检查该附件。", + "如果你无法直接读取原始内容,不要假装已经看过内容,必须明确说明限制,并只基于你实际拿到的内容给出判断。", "输出要求:", "1. 一句话结论", "2. 内容摘要或可见特征", @@ -254,9 +293,14 @@ function buildAttachmentAnalysisPrompt(params: { `uploadedAt: ${attachment.uploadedAt}`, `uploadedBy: ${attachment.uploadedBy}`, `analysisState: ${attachment.analysisState}`, + `downloadUrl: ${params.attachmentDownloadUrl}`, "", "原始消息:", params.messageBody || "无", + "", + "如果附件可以直接解析文本,请优先基于文本内容进行判断。", + "文本摘录:", + params.attachmentTextExcerpt || "无可直接内嵌的文本摘录,请按需下载原文件后自行读取。", ].join("\n"); } @@ -274,7 +318,17 @@ export async function queueAttachmentAnalysisTask(params: { } const state = await readState(); + const taskId = `mastertask-${randomBytes(4).toString("hex")}`; + const attachmentDownloadToken = randomBytes(12).toString("hex"); + const attachmentDownloadExpiresAt = new Date(Date.now() + 30 * 60_000).toISOString(); + const attachmentDownloadUrl = + `${resolveBossPublicBaseUrl()}/api/v1/attachments/${record.attachment.attachmentId}/download` + + `?taskId=${taskId}&token=${attachmentDownloadToken}`; + const attachmentContext = await buildAttachmentAnalysisContext({ + attachment: record.attachment, + }); const task = await queueMasterAgentTask({ + taskId, projectId: record.project.id, taskType: "attachment_analysis", requestMessageId: params.requestMessageId, @@ -286,12 +340,18 @@ export async function queueAttachmentAnalysisTask(params: { messageBody: record.message.body, requestedBy: params.requestedBy, requestedByAccount: params.requestedByAccount, + attachmentDownloadUrl, + attachmentTextExcerpt: attachmentContext.textExcerpt, }), requestedBy: params.requestedBy, requestedByAccount: params.requestedByAccount, deviceId: state.user.boundDeviceId || "mac-studio", attachmentId: record.attachment.attachmentId, attachmentFileName: record.attachment.fileName, + attachmentDownloadToken, + attachmentDownloadExpiresAt, + attachmentDownloadUrl, + attachmentTextExcerpt: attachmentContext.textExcerpt, }); if (params.markProcessing) { diff --git a/src/lib/boss-storage-aliyun-oss.ts b/src/lib/boss-storage-aliyun-oss.ts index c14d381..3657a23 100644 --- a/src/lib/boss-storage-aliyun-oss.ts +++ b/src/lib/boss-storage-aliyun-oss.ts @@ -86,6 +86,29 @@ export async function getAliyunOssSignedDownloadUrl( }); } +export async function readAliyunOssObjectBuffer( + config: AliyunOssConfig, + objectKey: string, +): Promise> { + const client = await createAliyunOssClient(config); + const response = await client.get(objectKey); + const content = response.content; + if (Buffer.isBuffer(content)) { + return content; + } + if (typeof content === "string") { + return Buffer.from(content); + } + if (content instanceof ArrayBuffer) { + return Buffer.from(content); + } + if (content && typeof (content as ArrayBufferView).byteLength === "number") { + const view = content as ArrayBufferView; + return Buffer.from(view.buffer, view.byteOffset, view.byteLength); + } + return Buffer.alloc(0); +} + export async function validateAliyunOssConfig(config: AliyunOssConfig) { const client = await createAliyunOssClient(config); await client.getBucketInfo(config.bucket); diff --git a/src/lib/boss-storage-server-file.ts b/src/lib/boss-storage-server-file.ts index 33d76a2..d5d51cb 100644 --- a/src/lib/boss-storage-server-file.ts +++ b/src/lib/boss-storage-server-file.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import type { StoreAttachmentParams, StoredAttachmentRecord } from "@/lib/boss-storage"; import { sanitizeFileName } from "@/lib/boss-attachments"; @@ -80,3 +80,7 @@ export async function storeServerFileAttachment( export function resolveServerFileAttachmentAbsolutePath(storagePath: string) { return resolvePathWithinUploadsRoot(storagePath); } + +export async function readServerFileAttachmentBuffer(storagePath: string) { + return readFile(resolveServerFileAttachmentAbsolutePath(storagePath)); +}