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\)/); +});