From 83b14ff1a52717031f45f9ba8eeac9b8bb9a6e71 Mon Sep 17 00:00:00 2001 From: Jesse <15653378+squarezw@user.noreply.gitee.com> Date: Thu, 24 Sep 2026 16:43:31 +0800 Subject: [PATCH 1/2] feat(chat-sessions): show user upload attachments in session details Session history only stored question and answer, so admins could not see the files a user attached. Read the persisted metadata and render it under the question, including when reopening a chat. Co-authored-by: Cursor --- app/chat-sessions/page.tsx | 37 +++++++++++ app/chat/page.tsx | 15 +++++ lib/sessionAttachments.ts | 67 +++++++++++++++++++ messages/en/chatSessions.json | 1 + messages/zh-CN/chatSessions.json | 1 + pages/api/chat/sessions/[id]/details.ts | 5 +- test/sessionAttachments.test.ts | 88 +++++++++++++++++++++++++ 7 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 lib/sessionAttachments.ts create mode 100644 test/sessionAttachments.test.ts diff --git a/app/chat-sessions/page.tsx b/app/chat-sessions/page.tsx index 647af90..61cb8f2 100644 --- a/app/chat-sessions/page.tsx +++ b/app/chat-sessions/page.tsx @@ -48,6 +48,7 @@ import { FileText, FileType, FileSpreadsheet, + Paperclip, X, Download, } from "lucide-react"; @@ -55,6 +56,11 @@ import { format, formatDistanceToNow } from "date-fns"; import { zhCN, enUS } from "date-fns/locale"; import { MarkdownRenderer } from "@/components/MarkdownRenderer"; import { FilePreviewDialog } from "@/components/FilePreviewDialog"; +import { getFileUrl } from "@/lib/ossUpload"; +import { + sessionAttachmentMime, + type SessionAttachment, +} from "@/lib/sessionAttachments"; import axios from "@/lib/axios"; import { toast } from "sonner"; import { useTranslations, useLocale } from "next-intl"; @@ -107,6 +113,7 @@ interface SessionDetail { segmentsIds?: number[]; segmentSimilarities?: number[]; usage?: TurnUsage; + attachments?: SessionAttachment[]; } /** 本会话显式调用过的一个 skill。只有 execute_skill / load_skill 两条 @@ -849,6 +856,36 @@ export default function ChatSessionsPage() {
+ {detail.attachments && detail.attachments.length > 0 && ( +
+
+ + {t("attachments")} +
+
+ {detail.attachments.map((attachment) => ( + + ))} +
+
+ )}
{detail.answer && ( diff --git a/app/chat/page.tsx b/app/chat/page.tsx index 572438a..2938635 100644 --- a/app/chat/page.tsx +++ b/app/chat/page.tsx @@ -24,6 +24,8 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { Attachment } from "./hooks/useFileAttachments"; import type { TurnUsage } from "@/types/token-usage"; import { getFileDownloadUrl } from "@/lib/fileApi"; +import { getFileUrl } from "@/lib/ossUpload"; +import type { SessionAttachment } from "@/lib/sessionAttachments"; import { downloadChatLink, attachmentPreviewResource, @@ -43,6 +45,18 @@ interface Message { attachments?: Attachment[]; } +function toHistoryAttachments(raw: SessionAttachment[] | undefined): Attachment[] | undefined { + if (!raw || raw.length === 0) return undefined; + return raw.map((attachment) => ({ + filename: attachment.filename, + type: attachment.contentType || "File", + content: "", + url: getFileUrl(attachment.objectKey), + objectKey: attachment.objectKey, + size: attachment.size, + })); +} + export default function ChatPage() { const t = useTranslations("chat"); const { askStream, sendFeedback, setChatId, chatId } = useChatSession(); @@ -437,6 +451,7 @@ export default function ChatPage() { historyMessages.push({ role: "user", content: detail.question || "", + attachments: toHistoryAttachments(detail.attachments), }); let formattedReference: any; diff --git a/lib/sessionAttachments.ts b/lib/sessionAttachments.ts new file mode 100644 index 0000000..cccb915 --- /dev/null +++ b/lib/sessionAttachments.ts @@ -0,0 +1,67 @@ +/** + * 会话轮次里记下的用户上传附件。 + * + * 库里是 snake_case JSON(后端 normalize_turn_attachments 的形状)。 + * 详情接口和聊天历史共用这一层,避免一边读 object_key、一边读 objectKey。 + */ + +export interface SessionAttachment { + filename: string; + objectKey: string; + contentType?: string; + size?: number; +} + +const MIME_BY_EXT: Record = { + ".pdf": "application/pdf", + ".doc": "application/msword", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".xls": "application/vnd.ms-excel", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".csv": "text/csv", + ".txt": "text/plain", + ".md": "text/markdown", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".bmp": "image/bmp", + ".tif": "image/tiff", + ".tiff": "image/tiff", + ".webp": "image/webp", + ".svg": "image/svg+xml", + ".dxf": "application/dxf", + ".dwg": "application/dwg", + ".ai": "application/postscript", +}; + +export function normalizeSessionAttachments(raw: unknown): SessionAttachment[] { + if (!Array.isArray(raw)) return []; + const out: SessionAttachment[] = []; + for (const item of raw) { + if (!item || typeof item !== "object") continue; + const row = item as Record; + const objectKey = String(row.object_key ?? row.objectKey ?? "").trim(); + const filename = String(row.filename ?? "").trim(); + if (!objectKey || !filename) continue; + const contentType = String(row.content_type ?? row.contentType ?? "").trim(); + const size = row.size; + out.push({ + filename, + objectKey, + ...(contentType ? { contentType } : {}), + ...(typeof size === "number" ? { size } : {}), + }); + } + return out; +} + +/** 预览对话框要 MIME;上传时浏览器可能给空串,用扩展名补。 */ +export function sessionAttachmentMime(attachment: SessionAttachment): string { + if (attachment.contentType && attachment.contentType !== "application/octet-stream") { + return attachment.contentType; + } + 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"; +} diff --git a/messages/en/chatSessions.json b/messages/en/chatSessions.json index 8c6bc96..f6d7dcb 100644 --- a/messages/en/chatSessions.json +++ b/messages/en/chatSessions.json @@ -61,6 +61,7 @@ "similarity": "Similarity", "feedbackContent": "Feedback", "question": "Question", + "attachments": "Attachments", "answer": "Answer", "hours": "hours", "minutes": "minutes", diff --git a/messages/zh-CN/chatSessions.json b/messages/zh-CN/chatSessions.json index fab7356..245dff0 100644 --- a/messages/zh-CN/chatSessions.json +++ b/messages/zh-CN/chatSessions.json @@ -61,6 +61,7 @@ "similarity": "相似度", "feedbackContent": "反馈内容", "question": "问题", + "attachments": "附件", "answer": "回答", "hours": "小时", "minutes": "分钟", diff --git a/pages/api/chat/sessions/[id]/details.ts b/pages/api/chat/sessions/[id]/details.ts index 56b66f9..34612ab 100644 --- a/pages/api/chat/sessions/[id]/details.ts +++ b/pages/api/chat/sessions/[id]/details.ts @@ -2,6 +2,7 @@ import { NextApiRequest, NextApiResponse } from "next"; import pool from "@/lib/db"; import { getUserIdFromRequest } from "@/lib/auth"; import { buildVisibilityScope, canViewOwner } from "@/lib/visibilityScope"; +import { normalizeSessionAttachments } from "@/lib/sessionAttachments"; export default async function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method !== "GET") { @@ -130,7 +131,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) WHERE ct.chat_session_detail_id = chat_session_detail.id AND ct.tx_type = 'consume' LIMIT 1) AS credits, cache_read_tokens, - cache_write_tokens + cache_write_tokens, + attachments FROM chat_session_detail WHERE session_id = $1 ORDER BY submitted_at ASC @@ -205,6 +207,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) sessionId: detail.session_id, question: detail.question, answer: detail.answer, + attachments: normalizeSessionAttachments(detail.attachments), submittedAt: detail.submitted_at, answeredAt: detail.answered_at, durationMs: detail.duration_ms, diff --git a/test/sessionAttachments.test.ts b/test/sessionAttachments.test.ts new file mode 100644 index 0000000..0d20aad --- /dev/null +++ b/test/sessionAttachments.test.ts @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { test } from "node:test"; +import { + normalizeSessionAttachments, + sessionAttachmentMime, +} from "../lib/sessionAttachments.ts"; + +const REPO = path.resolve(import.meta.dirname, ".."); +const read = (rel: string) => fs.readFileSync(path.join(REPO, rel), "utf8"); + +test("只保留能取回文件的附件", () => { + assert.deepEqual( + normalizeSessionAttachments([ + { + object_key: "attachments/202609/a.pdf", + filename: "图纸.pdf", + content_type: "application/pdf", + size: 12, + }, + { object_key: "", filename: "取不回的.pdf" }, + { filename: "没有 key.png" }, + null, + ]), + [ + { + filename: "图纸.pdf", + objectKey: "attachments/202609/a.pdf", + contentType: "application/pdf", + size: 12, + }, + ] + ); +}); + +test("也认 camelCase,避免前后端各写一套字段名", () => { + assert.deepEqual( + normalizeSessionAttachments([{ objectKey: "attachments/x.dxf", filename: "图.dxf" }]), + [{ filename: "图.dxf", objectKey: "attachments/x.dxf" }] + ); +}); + +test("空值和非法输入都当成没有附件", () => { + assert.deepEqual(normalizeSessionAttachments(null), []); + assert.deepEqual(normalizeSessionAttachments(undefined), []); + assert.deepEqual(normalizeSessionAttachments({}), []); +}); + +test("预览用 MIME:有有效类型就用,否则按扩展名补", () => { + assert.equal( + sessionAttachmentMime({ + filename: "a.pdf", + objectKey: "k", + contentType: "application/pdf", + }), + "application/pdf" + ); + assert.equal(sessionAttachmentMime({ filename: "图.dxf", objectKey: "k" }), "application/dxf"); + assert.equal( + sessionAttachmentMime({ + filename: "图.dxf", + objectKey: "k", + contentType: "application/octet-stream", + }), + "application/dxf" + ); +}); + +const DETAILS_API = read("pages/api/chat/sessions/[id]/details.ts"); +const ADMIN_PAGE = read("app/chat-sessions/page.tsx"); +const CHAT_PAGE = read("app/chat/page.tsx"); + +test("详情接口查出 attachments 并规范化后返回", () => { + const select = DETAILS_API.match(/SELECT[\s\S]*?FROM chat_session_detail/); + assert.ok(select, "找不到 chat_session_detail 的 SELECT"); + assert.ok(select[0].includes("attachments"), "SELECT 漏了 attachments"); + assert.match(DETAILS_API, /normalizeSessionAttachments\(detail\.attachments\)/); +}); + +test("会话详情在问题下面渲染附件", () => { + assert.match(ADMIN_PAGE, /detail\.attachments/); + assert.match(ADMIN_PAGE, /t\("attachments"\)/); +}); + +test("加载历史会话时把附件挂回用户消息", () => { + assert.match(CHAT_PAGE, /attachments: toHistoryAttachments\(detail\.attachments\)/); +}); From f6d0e99038b12e7162bc121a2a11082dca9a1c51 Mon Sep 17 00:00:00 2001 From: Jesse <15653378+squarezw@user.noreply.gitee.com> Date: Thu, 24 Sep 2026 18:00:05 +0800 Subject: [PATCH 2/2] fix(chat-sessions): preview PDF attachments stored as display labels Session rows keep the bubble label "PDF" instead of a MIME type, and the signed object URL forces a download. Recognize the label from the extension and stream the file inline. Co-authored-by: Cursor --- app/chat-sessions/page.tsx | 3 ++- lib/sessionAttachments.ts | 23 +++++++++++++++++++---- pages/api/oss/[...key].ts | 20 +++++++++++++++++++- test/sessionAttachments.test.ts | 20 ++++++++++++++++++++ 4 files changed, 60 insertions(+), 6 deletions(-) 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({