feat: add attachment analysis access links
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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...[已截断]`;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user