Skip to content
Open
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
26 changes: 26 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
Expand Down
2 changes: 1 addition & 1 deletion components/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -692,7 +692,7 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate
);
})()}
{streamState.isStreaming && streamState.streamingMessage && (
<MessageView message={streamState.streamingMessage as AgentMessage} isStreaming modelNames={modelNames} cwd={messageCwd} onOpenFile={onOpenFile} />
<MessageView message={streamState.streamingMessage as AgentMessage} isStreaming modelNames={modelNames} cwd={messageCwd} onOpenFile={onOpenFile} sessionId={session?.id ?? sessionIdRef.current ?? undefined} />
)}

{agentRunning && !streamState.streamingMessage && agentPhase && (
Expand Down
8 changes: 7 additions & 1 deletion components/FileIcons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,9 @@ export function LockFileIcon({ size = 14 }: IconProps) {
</svg>
);
}
export function SpreadsheetFileIcon({ size = 14 }: IconProps) {
return <LabelFileIcon label="XLS" size={size} />;
}
export function DocFileIcon({ size = 14 }: IconProps) {
return <LabelFileIcon label="DOC" size={size} />;
}
Expand Down Expand Up @@ -242,7 +245,10 @@ export function getFileIcon(name: string, size = 14): React.ReactNode {
case "tf":
case "hcl": return <TerraformIcon size={size} />;
case "docx": return <DocFileIcon size={size} />;
case "pdf": return <PdfFileIcon size={size} />;
case "xls":
case "xlsx":
case "xlsm": return <SpreadsheetFileIcon size={size} />;
case "pdf": return <PdfFileIcon size={size} />;
case "lock": return <LockFileIcon size={size} />;
default: return <GenericFileIcon size={size} />;
}
Expand Down
30 changes: 26 additions & 4 deletions components/MarkdownBody.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
}
Expand All @@ -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, /<a href="components\/MarkdownBody\.tsx">file<\/a>/);
assert.match(html, /class="markdown-file-link"/);
assert.match(html, /href="\/api\/files\/home\/me\/project\/components\/MarkdownBody\.tsx\?type=download&amp;sessionId=11111111-1111-1111-1111-111111111111"/);
assert.match(html, /download="MarkdownBody\.tsx"/);
assert.match(html, /<svg/);
assert.doesNotMatch(html, /target=|rel=|\snode=/);
});

test("recognizes bare relative file links with line numbers", () => {
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\)。`);

Expand Down
44 changes: 25 additions & 19 deletions components/MarkdownBody.tsx
Original file line number Diff line number Diff line change
@@ -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<Components>(() => ({
Expand Down Expand Up @@ -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 (
<a href={href} {...props} target="_blank" rel="noopener noreferrer">
{children}
<a
href={getFileDownloadHref(filePath, sessionId)}
download={fileName}
{...props}
className="markdown-file-link"
title={filePath}
>
<span className="markdown-file-link-icon" aria-hidden="true">{getFileIcon(fileName, 16)}</span>
<span>{children}</span>
</a>
);
}

const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
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 (
<a href={href} {...props} onClick={handleClick}>
<a href={href} {...props} target="_blank" rel="noopener noreferrer">
{children}
</a>
);
Expand All @@ -86,7 +92,7 @@ export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile
</div>
);
},
}), [cwd, isStreaming, onOpenFile]);
}), [cwd, isStreaming, onOpenFile, sessionId]);

return (
<div className={["markdown-body", className].filter(Boolean).join(" ")}>
Expand Down
19 changes: 10 additions & 9 deletions components/MessageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <UserMessageView message={message as UserMessage} cwd={cwd} onOpenFile={onOpenFile} entryId={entryId} onFork={onFork} forking={forking} onNavigate={onNavigate} prevAssistantEntryId={prevAssistantEntryId} onEditContent={onEditContent} />;
return <UserMessageView message={message as UserMessage} cwd={cwd} sessionId={sessionId} onOpenFile={onOpenFile} entryId={entryId} onFork={onFork} forking={forking} onNavigate={onNavigate} prevAssistantEntryId={prevAssistantEntryId} onEditContent={onEditContent} />;
}
if (message.role === "assistant") {
return <AssistantMessageView message={message as AssistantMessage} isStreaming={isStreaming} toolResults={toolResults} modelNames={modelNames} cwd={cwd} onOpenFile={onOpenFile} showTimestamp={showTimestamp} prevTimestamp={prevTimestamp} sessionId={sessionId} entryId={entryId} />;
Expand All @@ -113,7 +113,7 @@ export const MessageView = memo(function MessageView({ message, isStreaming, too
if ((message as CustomMessage).customType === "compaction") {
return <CompactionMessageView message={message as CustomMessage} />;
}
return <CustomMessageView message={message as CustomMessage} cwd={cwd} onOpenFile={onOpenFile} />;
return <CustomMessageView message={message as CustomMessage} cwd={cwd} sessionId={sessionId} onOpenFile={onOpenFile} />;
}
if (message.role === "bashExecution") {
return <BashExecutionView message={message as BashExecutionMessage} sessionId={sessionId} />;
Expand All @@ -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;
Expand Down Expand Up @@ -222,7 +223,7 @@ function UserMessageView({ message, cwd, onOpenFile, entryId, onFork, forking, o
})}
</div>
)}
{content && <MarkdownBody className="markdown-user-message" cwd={cwd} onOpenFile={onOpenFile}>{content}</MarkdownBody>}
{content && <MarkdownBody className="markdown-user-message" cwd={cwd} sessionId={sessionId} onOpenFile={onOpenFile}>{content}</MarkdownBody>}
</div>

</div>
Expand Down Expand Up @@ -605,7 +606,7 @@ function AssistantMessageView({

function BlockView({ block, toolResults, isStreaming, streamingDuration, toolCallDurations, cwd, onOpenFile, sessionId, entryId, blockIndex }: { block: AssistantContentBlock; toolResults?: Map<string, ToolResultMessage>; isStreaming?: boolean; streamingDuration?: number; toolCallDurations?: Map<string, number>; cwd?: string; onOpenFile?: (filePath: string) => void; sessionId?: string; entryId?: string; blockIndex: number }) {
if (block.type === "text") {
return <TextBlock block={block as TextContent} isStreaming={isStreaming} cwd={cwd} onOpenFile={onOpenFile} />;
return <TextBlock block={block as TextContent} isStreaming={isStreaming} cwd={cwd} onOpenFile={onOpenFile} sessionId={sessionId} />;
}
if (block.type === "thinking") {
return <ThinkingBlock block={block as ThinkingContent} duration={streamingDuration} sessionId={sessionId} entryId={entryId} blockIndex={blockIndex} />;
Expand All @@ -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 <MarkdownBody isStreaming={isStreaming} cwd={cwd} onOpenFile={onOpenFile}>{block.text}</MarkdownBody>;
function TextBlock({ block, isStreaming, cwd, onOpenFile, sessionId }: { block: TextContent; isStreaming?: boolean; cwd?: string; onOpenFile?: (filePath: string) => void; sessionId?: string }) {
return <MarkdownBody isStreaming={isStreaming} cwd={cwd} sessionId={sessionId} onOpenFile={onOpenFile}>{block.text}</MarkdownBody>;
}

function ThinkingBlock({ block, duration, sessionId, entryId, blockIndex }: {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1232,7 +1233,7 @@ function CustomMessageView({ message, cwd, onOpenFile }: { message: CustomMessag
})}
</div>
)}
{text ? <MarkdownBody className="markdown-custom-message" cwd={cwd} onOpenFile={onOpenFile}>{text}</MarkdownBody> : <span style={{ color: "var(--text-dim)", fontSize: 12 }}>{t("i18n.noMessage")}</span>}
{text ? <MarkdownBody className="markdown-custom-message" cwd={cwd} sessionId={sessionId} onOpenFile={onOpenFile}>{text}</MarkdownBody> : <span style={{ color: "var(--text-dim)", fontSize: 12 }}>{t("i18n.noMessage")}</span>}
</div>
) : (
<button
Expand Down
16 changes: 16 additions & 0 deletions lib/file-links.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,22 @@ test("resolves Windows file URLs without a synthetic leading slash", async () =>
);
});

test("resolves Windows paths and Chinese filenames", async () => {
const { resolveLocalFileHref } = await loadSubject();
const cwd = "D:/桌面文件/易智瑞";
const filePath = "D:/桌面文件/易智瑞/数据731/转移矩阵与转入转出比例分析.xlsx";

assert.equal(resolveLocalFileHref(filePath, cwd), filePath);
assert.equal(
resolveLocalFileHref("数据731/转移矩阵与转入转出比例分析.xlsx", cwd),
filePath,
);
assert.equal(
resolveLocalFileHref("file:///D:/桌面文件/易智瑞/数据731/转移矩阵与转入转出比例分析.xlsx", cwd),
filePath,
);
});

test("resolves UNC file URLs and backslash UNC paths", async () => {
const { resolveLocalFileHref } = await loadSubject();

Expand Down
12 changes: 7 additions & 5 deletions lib/file-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,11 @@ function isPathInside(candidate: string, root: string): boolean {
}

function looksLikeRelativeFileHref(href: string): boolean {
if (href.startsWith("#") || href.startsWith("?")) return false;
if (href.startsWith("./") || href.startsWith("../")) return true;
if (href.includes("/")) return true;
return /(^|\/)\.?[^/]+\.[^/.]+$/.test(href);
const pathWithoutLine = stripLineSuffix(href);
if (pathWithoutLine.startsWith("#") || pathWithoutLine.startsWith("?")) return false;
if (pathWithoutLine.startsWith("./") || pathWithoutLine.startsWith("../")) return true;
if (pathWithoutLine.includes("/")) return true;
return /(^|\/)\.?[^/]+\.[^/.]+$/.test(pathWithoutLine);
}

function fileUrlToPath(href: string): string | null {
Expand Down Expand Up @@ -90,10 +91,11 @@ export function resolveLocalFileHref(
const isBackslashUncPath = decodedHref.startsWith("\\\\");
const normalizedHref = normalizeFilePathSlashes(decodedHref);
const lowerHref = normalizedHref.toLowerCase();
const isBareRelativeLineHref = /^[^/?#\s]+:\d+(?::\d+)?$/.test(normalizedHref);

if (lowerHref.startsWith("/api/") || lowerHref.startsWith("/_next/")) return null;
if (!isBackslashUncPath && normalizedHref.startsWith("//")) return null;
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/i.test(normalizedHref) && !lowerHref.startsWith("file:") && !/^[a-zA-Z]:\//.test(normalizedHref)) {
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/i.test(normalizedHref) && !lowerHref.startsWith("file:") && !/^[a-zA-Z]:\//.test(normalizedHref) && !isBareRelativeLineHref) {
return null;
}

Expand Down
Loading