Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions app/chat-sessions/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,19 @@ import {
FileText,
FileType,
FileSpreadsheet,
Paperclip,
X,
Download,
} from "lucide-react";
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";
Expand Down Expand Up @@ -107,6 +113,7 @@ interface SessionDetail {
segmentsIds?: number[];
segmentSimilarities?: number[];
usage?: TurnUsage;
attachments?: SessionAttachment[];
}

/** 本会话显式调用过的一个 skill。只有 execute_skill / load_skill 两条
Expand Down Expand Up @@ -849,6 +856,36 @@ export default function ChatSessionsPage() {
</div>
<div className="p-3 bg-muted rounded-md">
<MarkdownRenderer content={detail.question} />
{detail.attachments && detail.attachments.length > 0 && (
<div className="mt-3 pt-3 border-t border-border">
<div className="text-xs text-muted-foreground mb-2 flex items-center gap-1">
<Paperclip className="w-3 h-3" />
{t("attachments")}
</div>
<div className="flex flex-wrap gap-2">
{detail.attachments.map((attachment) => (
<button
key={attachment.objectKey}
type="button"
className="flex items-center gap-2 text-sm bg-background border border-border rounded-md px-2 py-1 hover:bg-accent transition-colors"
onClick={() =>
setPreviewFile({
filename: attachment.filename,
originalname: attachment.filename,
mimetype: sessionAttachmentMime(attachment),
sourceUrl: getFileUrl(attachment.objectKey),
})
}
>
{getFileIcon(sessionAttachmentMime(attachment))}
<span className="truncate max-w-[16rem]">
{attachment.filename}
</span>
</button>
))}
</div>
</div>
)}
</div>
</div>
{detail.answer && (
Expand Down
15 changes: 15 additions & 0 deletions app/chat/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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();
Expand Down Expand Up @@ -437,6 +451,7 @@ export default function ChatPage() {
historyMessages.push({
role: "user",
content: detail.question || "",
attachments: toHistoryAttachments(detail.attachments),
});

let formattedReference: any;
Expand Down
67 changes: 67 additions & 0 deletions lib/sessionAttachments.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
".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<string, unknown>;
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";
}
1 change: 1 addition & 0 deletions messages/en/chatSessions.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"similarity": "Similarity",
"feedbackContent": "Feedback",
"question": "Question",
"attachments": "Attachments",
"answer": "Answer",
"hours": "hours",
"minutes": "minutes",
Expand Down
1 change: 1 addition & 0 deletions messages/zh-CN/chatSessions.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"similarity": "相似度",
"feedbackContent": "反馈内容",
"question": "问题",
"attachments": "附件",
"answer": "回答",
"hours": "小时",
"minutes": "分钟",
Expand Down
5 changes: 4 additions & 1 deletion pages/api/chat/sessions/[id]/details.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
88 changes: 88 additions & 0 deletions test/sessionAttachments.test.ts
Original file line number Diff line number Diff line change
@@ -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\)/);
});
Loading