diff --git a/app/chat-sessions/page.tsx b/app/chat-sessions/page.tsx index 61cb8f2..88bda9b 100644 --- a/app/chat-sessions/page.tsx +++ b/app/chat-sessions/page.tsx @@ -58,6 +58,7 @@ import { MarkdownRenderer } from "@/components/MarkdownRenderer"; import { FilePreviewDialog } from "@/components/FilePreviewDialog"; import { getFileUrl } from "@/lib/ossUpload"; import { + attachmentPreviewUrl, sessionAttachmentMime, type SessionAttachment, } from "@/lib/sessionAttachments"; @@ -873,7 +874,7 @@ export default function ChatSessionsPage() { filename: attachment.filename, originalname: attachment.filename, mimetype: sessionAttachmentMime(attachment), - sourceUrl: getFileUrl(attachment.objectKey), + sourceUrl: attachmentPreviewUrl(getFileUrl(attachment.objectKey)), }) } > diff --git a/lib/sessionAttachments.ts b/lib/sessionAttachments.ts index cccb915..36f5152 100644 --- a/lib/sessionAttachments.ts +++ b/lib/sessionAttachments.ts @@ -56,12 +56,27 @@ export function normalizeSessionAttachments(raw: unknown): SessionAttachment[] { return out; } -/** 预览对话框要 MIME;上传时浏览器可能给空串,用扩展名补。 */ +/** content_type 有时是给气泡看的标签("PDF"),不是 MIME。 */ +function isMimeType(value: string): boolean { + return /^[\w.+-]+\/[\w.+-]+$/.test(value); +} + +/** + * 对象存储的签名响应带 Content-Disposition: attachment。 + * iframe 直接跟着 302 走会下载,预览要改走同域 inline 流。 + */ +export function attachmentPreviewUrl(url: string): string { + if (!url.startsWith("/api/oss/")) return url; + return `${url}${url.includes("?") ? "&" : "?"}inline=1`; +} + +/** 预览对话框要 MIME;标签、空串、octet-stream 都按扩展名补。 */ export function sessionAttachmentMime(attachment: SessionAttachment): string { - if (attachment.contentType && attachment.contentType !== "application/octet-stream") { - return attachment.contentType; + const stored = (attachment.contentType || "").split(";")[0].trim(); + if (stored && stored !== "application/octet-stream" && isMimeType(stored)) { + return stored; } const name = attachment.filename.toLowerCase(); const ext = Object.keys(MIME_BY_EXT).find((suffix) => name.endsWith(suffix)); - return (ext && MIME_BY_EXT[ext]) || attachment.contentType || "application/octet-stream"; + return (ext && MIME_BY_EXT[ext]) || stored || "application/octet-stream"; } diff --git a/pages/api/oss/[...key].ts b/pages/api/oss/[...key].ts index 69a9892..ea764b0 100644 --- a/pages/api/oss/[...key].ts +++ b/pages/api/oss/[...key].ts @@ -1,3 +1,4 @@ +import { Readable } from "node:stream"; import type { NextApiRequest, NextApiResponse } from "next"; import { getUserIdFromRequest } from "@/lib/auth"; import { ossClient } from "@/lib/ossClient"; @@ -26,8 +27,25 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) } try { - const { url } = await ossClient.sign({ objectKey }); + // 签名 URL 上的 Content-Disposition 是 attachment。预览 iframe 跟着 302 + // 会触发下载,所以 inline=1 时由我们把字节流回去并改成 inline。 + if (req.query.inline === "1") { + const { url } = await ossClient.sign({ objectKey, expiresIn: 600 }); + const upstream = await fetch(url); + if (!upstream.ok || !upstream.body) { + return res.status(502).json({ error: "Failed to fetch file" }); + } + const contentType = (upstream.headers.get("content-type") || "application/octet-stream") + .split(";")[0] + .trim(); + res.setHeader("Content-Type", contentType || "application/octet-stream"); + res.setHeader("Content-Disposition", "inline"); + res.setHeader("Cache-Control", "private, max-age=600"); + Readable.fromWeb(upstream.body).pipe(res); + return; + } + const { url } = await ossClient.sign({ objectKey }); return res.redirect(302, url); } catch (error: any) { console.error("[OSS Download] Error:", { diff --git a/test/sessionAttachments.test.ts b/test/sessionAttachments.test.ts index 0d20aad..22ebcd9 100644 --- a/test/sessionAttachments.test.ts +++ b/test/sessionAttachments.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import { test } from "node:test"; import { + attachmentPreviewUrl, normalizeSessionAttachments, sessionAttachmentMime, } from "../lib/sessionAttachments.ts"; @@ -47,6 +48,25 @@ test("空值和非法输入都当成没有附件", () => { assert.deepEqual(normalizeSessionAttachments({}), []); }); +test("会话里记下的是展示标签时,预览仍按扩展名识别 PDF", () => { + assert.equal( + sessionAttachmentMime({ + filename: "双中心线试验圆角.pdf", + objectKey: "attachments/202609/双中心线试验圆角_1f7749.pdf", + contentType: "PDF", + }), + "application/pdf" + ); +}); + +test("会话附件预览走 inline,避免对象存储的 attachment 头触发下载", () => { + assert.equal( + attachmentPreviewUrl("/api/oss/attachments/202609/a.pdf"), + "/api/oss/attachments/202609/a.pdf?inline=1" + ); + assert.equal(attachmentPreviewUrl("https://example.com/a.pdf"), "https://example.com/a.pdf"); +}); + test("预览用 MIME:有有效类型就用,否则按扩展名补", () => { assert.equal( sessionAttachmentMime({