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