From 974cfde851109534c0af40e5738646ccd8b4db8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=A0=E7=9A=84=E5=A7=93=E5=90=8D?= <你的邮箱@example.com> Date: Sat, 1 Aug 2026 15:29:07 +0800 Subject: [PATCH] feat: make generated file links actionable --- app/globals.css | 26 +++++++++++++++ components/ChatWindow.tsx | 2 +- components/FileIcons.tsx | 8 ++++- components/MarkdownBody.test.mjs | 30 ++++++++++++++--- components/MarkdownBody.tsx | 44 ++++++++++++++----------- components/MessageView.tsx | 19 +++++------ lib/file-links.test.mjs | 16 ++++++++++ lib/file-links.ts | 12 ++++--- lib/markdown.ts | 55 +++++++++++++++++++++++++++++++- 9 files changed, 172 insertions(+), 40 deletions(-) diff --git a/app/globals.css b/app/globals.css index 1c0e917bd..4ef8dce20 100644 --- a/app/globals.css +++ b/app/globals.css @@ -163,6 +163,32 @@ pre, code { text-decoration-color: color-mix(in srgb, var(--accent) 45%, transparent); } .markdown-body a:hover { text-decoration-color: var(--accent); } +.markdown-body .markdown-file-link { + display: inline-flex; + align-items: center; + gap: 5px; + max-width: 100%; + padding: 1px 2px; + color: var(--accent); + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: 3px; + text-decoration-color: color-mix(in srgb, var(--accent) 52%, transparent); + vertical-align: middle; +} +.markdown-body .markdown-file-link:hover { + color: var(--accent); + text-decoration-color: var(--accent); +} +.markdown-body .markdown-file-link-icon { + display: inline-flex; + align-items: center; + flex: 0 0 auto; +} +.markdown-body .markdown-file-link > span:last-child { + min-width: 0; + overflow-wrap: anywhere; +} .markdown-body img { display: block; max-width: 100%; diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx index 794a34cf3..f4c3ed360 100644 --- a/components/ChatWindow.tsx +++ b/components/ChatWindow.tsx @@ -692,7 +692,7 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate ); })()} {streamState.isStreaming && streamState.streamingMessage && ( - + )} {agentRunning && !streamState.streamingMessage && agentPhase && ( diff --git a/components/FileIcons.tsx b/components/FileIcons.tsx index 6ce7a9a64..22e54b9a7 100644 --- a/components/FileIcons.tsx +++ b/components/FileIcons.tsx @@ -179,6 +179,9 @@ export function LockFileIcon({ size = 14 }: IconProps) { ); } +export function SpreadsheetFileIcon({ size = 14 }: IconProps) { + return ; +} export function DocFileIcon({ size = 14 }: IconProps) { return ; } @@ -242,7 +245,10 @@ export function getFileIcon(name: string, size = 14): React.ReactNode { case "tf": case "hcl": return ; case "docx": return ; - case "pdf": return ; + case "xls": + case "xlsx": + case "xlsm": return ; + case "pdf": return ; case "lock": return ; default: return ; } diff --git a/components/MarkdownBody.test.mjs b/components/MarkdownBody.test.mjs index aa2736a5f..d7929d930 100644 --- a/components/MarkdownBody.test.mjs +++ b/components/MarkdownBody.test.mjs @@ -11,11 +11,12 @@ const jiti = createJiti(import.meta.url, { const { MarkdownBody } = await jiti.import("./MarkdownBody.tsx"); const { normalizeDisplayMath } = await jiti.import("../lib/markdown.ts"); -function renderMarkdown(markdown) { +function renderMarkdown(markdown, extraProps = {}) { return renderToStaticMarkup( React.createElement(MarkdownBody, { cwd: "/home/me/project", onOpenFile() {}, + ...extraProps, }, markdown), ); } @@ -30,13 +31,34 @@ test("opens non-file markdown links in a safe new tab", () => { assert.doesNotMatch(html, /\snode=/); }); -test("keeps local file markdown links in the app", () => { - const html = renderMarkdown("[file](components/MarkdownBody.tsx)"); +test("renders local file markdown links as downloadable file chips", () => { + const html = renderMarkdown("[file](components/MarkdownBody.tsx)", { + sessionId: "11111111-1111-1111-1111-111111111111", + }); - assert.match(html, /file<\/a>/); + assert.match(html, /class="markdown-file-link"/); + assert.match(html, /href="\/api\/files\/home\/me\/project\/components\/MarkdownBody\.tsx\?type=download&sessionId=11111111-1111-1111-1111-111111111111"/); + assert.match(html, /download="MarkdownBody\.tsx"/); + assert.match(html, / { + const html = renderMarkdown("[source](MarkdownBody.tsx:42)"); + + assert.match(html, /class="markdown-file-link"/); + assert.match(html, /download="MarkdownBody\.tsx"/); +}); + +test("renders Windows backslash file links as downloadable file chips", () => { + const html = renderMarkdown(String.raw`[下载](D:\\桌面文件\\易智瑞\\数据731\\结果.xlsx)`, { + sessionId: "11111111-1111-1111-1111-111111111111", + }); + + assert.match(html, /class="markdown-file-link"/); + assert.match(html, /download="结果\.xlsx"/); +}); + test("renders LaTeX parenthesis delimiters as inline math", () => { const html = renderMarkdown(String.raw`射线为 \(r_c = K^{-1}p\)。`); diff --git a/components/MarkdownBody.tsx b/components/MarkdownBody.tsx index cc6bd6822..98b68a019 100644 --- a/components/MarkdownBody.tsx +++ b/components/MarkdownBody.tsx @@ -1,21 +1,29 @@ "use client"; -import { useMemo, type MouseEvent } from "react"; +import { useMemo } from "react"; import ReactMarkdown, { type Components } from "react-markdown"; import { resolveLocalFileHref } from "@/lib/file-links"; -import { encodeFilePathForApi } from "@/lib/file-paths"; +import { encodeFilePathForApi, getFileName } from "@/lib/file-paths"; import { markdownRehypePlugins, markdownRemarkPlugins, normalizeDisplayMath } from "@/lib/markdown"; import { MermaidBlock, CodeBlock } from "./MermaidBlock"; +import { getFileIcon } from "./FileIcons"; interface MarkdownBodyProps { children: string; className?: string; isStreaming?: boolean; cwd?: string; + sessionId?: string; onOpenFile?: (filePath: string) => void; } -export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile }: MarkdownBodyProps) { +function getFileDownloadHref(filePath: string, sessionId?: string): string { + const params = new URLSearchParams({ type: "download" }); + if (sessionId) params.set("sessionId", sessionId); + return `/api/files/${encodeFilePathForApi(filePath)}?${params.toString()}`; +} + +export function MarkdownBody({ children, className, isStreaming, cwd, sessionId, onOpenFile }: MarkdownBodyProps) { const normalizedMarkdown = useMemo(() => normalizeDisplayMath(children), [children]); // Stable renderer identities keep stateful blocks mounted across message hover updates. const components = useMemo(() => ({ @@ -44,27 +52,25 @@ export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile a({ href, children, ...props }) { // `node` is react-markdown metadata, not a DOM attribute. delete props.node; - const filePath = onOpenFile ? resolveLocalFileHref(href, cwd) : null; - const openFile = onOpenFile; - if (!filePath || !openFile) { + const filePath = (onOpenFile || sessionId) ? resolveLocalFileHref(href, cwd) : null; + if (filePath) { + const fileName = getFileName(filePath); return ( - - {children} + + + {children} ); } - const handleClick = (event: MouseEvent) => { - if (event.defaultPrevented || event.button !== 0) return; - if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; - const target = event.currentTarget.getAttribute("target"); - if (target && target !== "_self") return; - event.preventDefault(); - openFile(filePath); - }; - return ( - + {children} ); @@ -86,7 +92,7 @@ export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile ); }, - }), [cwd, isStreaming, onOpenFile]); + }), [cwd, isStreaming, onOpenFile, sessionId]); return (
diff --git a/components/MessageView.tsx b/components/MessageView.tsx index d73bee37f..8cb78759d 100644 --- a/components/MessageView.tsx +++ b/components/MessageView.tsx @@ -100,7 +100,7 @@ function haveSameRelevantToolResults( export const MessageView = memo(function MessageView({ message, isStreaming, toolResults, modelNames, cwd, onOpenFile, entryId, onFork, forking, onNavigate, prevAssistantEntryId, onEditContent, showTimestamp, prevTimestamp, sessionId }: Props) { if (message.role === "user") { - return ; + return ; } if (message.role === "assistant") { return ; @@ -113,7 +113,7 @@ export const MessageView = memo(function MessageView({ message, isStreaming, too if ((message as CustomMessage).customType === "compaction") { return ; } - return ; + return ; } if (message.role === "bashExecution") { return ; @@ -137,9 +137,10 @@ export const MessageView = memo(function MessageView({ message, isStreaming, too && prev.sessionId === next.sessionId; }); -function UserMessageView({ message, cwd, onOpenFile, entryId, onFork, forking, onNavigate, prevAssistantEntryId, onEditContent }: { +function UserMessageView({ message, cwd, sessionId, onOpenFile, entryId, onFork, forking, onNavigate, prevAssistantEntryId, onEditContent }: { message: UserMessage; cwd?: string; + sessionId?: string; onOpenFile?: (filePath: string) => void; entryId?: string; onFork?: (entryId: string) => void; @@ -222,7 +223,7 @@ function UserMessageView({ message, cwd, onOpenFile, entryId, onFork, forking, o })}
)} - {content && {content}} + {content && {content}} @@ -605,7 +606,7 @@ function AssistantMessageView({ function BlockView({ block, toolResults, isStreaming, streamingDuration, toolCallDurations, cwd, onOpenFile, sessionId, entryId, blockIndex }: { block: AssistantContentBlock; toolResults?: Map; isStreaming?: boolean; streamingDuration?: number; toolCallDurations?: Map; cwd?: string; onOpenFile?: (filePath: string) => void; sessionId?: string; entryId?: string; blockIndex: number }) { if (block.type === "text") { - return ; + return ; } if (block.type === "thinking") { return ; @@ -619,8 +620,8 @@ function BlockView({ block, toolResults, isStreaming, streamingDuration, toolCal return null; } -function TextBlock({ block, isStreaming, cwd, onOpenFile }: { block: TextContent; isStreaming?: boolean; cwd?: string; onOpenFile?: (filePath: string) => void }) { - return {block.text}; +function TextBlock({ block, isStreaming, cwd, onOpenFile, sessionId }: { block: TextContent; isStreaming?: boolean; cwd?: string; onOpenFile?: (filePath: string) => void; sessionId?: string }) { + return {block.text}; } function ThinkingBlock({ block, duration, sessionId, entryId, blockIndex }: { @@ -1163,7 +1164,7 @@ function CompactionFileList({ title, files }: { title: string; files: string[] } ); } -function CustomMessageView({ message, cwd, onOpenFile }: { message: CustomMessage; cwd?: string; onOpenFile?: (filePath: string) => void }) { +function CustomMessageView({ message, cwd, sessionId, onOpenFile }: { message: CustomMessage; cwd?: string; sessionId?: string; onOpenFile?: (filePath: string) => void }) { const { t } = useI18n(); const isHiddenDisplay = message.display === false; const [contentExpanded, setContentExpanded] = useState(!isHiddenDisplay); @@ -1232,7 +1233,7 @@ function CustomMessageView({ message, cwd, onOpenFile }: { message: CustomMessag })} )} - {text ? {text} : {t("i18n.noMessage")}} + {text ? {text} : {t("i18n.noMessage")}} ) : (