feat: add attachment analysis access links

This commit is contained in:
kris
2026-03-29 17:06:54 +08:00
parent 18dc7c6120
commit 88ab2d011a
7 changed files with 169 additions and 10 deletions

View File

@@ -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,

View File

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

View File

@@ -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<MessageAttachment, "attachmentKind" | "mimeType">) {
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...[已截断]`;
}

View File

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

View File

@@ -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<Awaited<ReturnType<typeof getProjectAttachment>>>["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) {

View File

@@ -86,6 +86,29 @@ export async function getAliyunOssSignedDownloadUrl(
});
}
export async function readAliyunOssObjectBuffer(
config: AliyunOssConfig,
objectKey: string,
): Promise<Buffer<ArrayBufferLike>> {
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);

View File

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