diff --git a/src/app/components/ChatMessage.tsx b/src/app/components/ChatMessage.tsx index ed140d1..9de8b6f 100644 --- a/src/app/components/ChatMessage.tsx +++ b/src/app/components/ChatMessage.tsx @@ -52,6 +52,8 @@ interface ChatMessageProps { autoApprove?: boolean; /** Live intermediate steps per task tool-call id (sub-agent activity). */ subAgentSteps?: Record; + /** Current conversation thread; enables inline workspace images. */ + threadId?: string | null; } export const ChatMessage = React.memo( @@ -71,6 +73,7 @@ export const ChatMessage = React.memo( onEditMessage, autoApprove, subAgentSteps, + threadId, }) => { const isUser = message.type === "human"; const messageContent = extractStringFromMessageContent(message); @@ -285,6 +288,7 @@ export const ChatMessage = React.memo( ) : null} @@ -422,6 +426,7 @@ export const ChatMessage = React.memo(
{(subAgentSteps?.[subAgent.id]?.length ?? 0) > 0 && ( @@ -444,6 +449,7 @@ export const ChatMessage = React.memo( )} diff --git a/src/app/components/StreamingMarkdown.tsx b/src/app/components/StreamingMarkdown.tsx index a3bdb1a..6ce4df1 100644 --- a/src/app/components/StreamingMarkdown.tsx +++ b/src/app/components/StreamingMarkdown.tsx @@ -9,10 +9,11 @@ interface StreamingMarkdownProps { content: string; isStreaming: boolean; className?: string; + threadId?: string | null; } export const StreamingMarkdown = React.memo( - function StreamingMarkdown({ content, isStreaming, className }) { + function StreamingMarkdown({ content, isStreaming, className, threadId }) { const typed = useTypewriterText(content, isStreaming); return ( @@ -20,6 +21,7 @@ export const StreamingMarkdown = React.memo( content={typed.text} isStreaming={isStreaming || typed.isTyping} className={cn(className, typed.isTyping && "typewriter-caret")} + threadId={threadId} /> ); } diff --git a/src/lib/fileLink.test.ts b/src/lib/fileLink.test.ts new file mode 100644 index 0000000..8f487ad --- /dev/null +++ b/src/lib/fileLink.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { detectFileLink } from "./fileLink"; + +describe("detectFileLink", () => { + it("links workspace text files", () => { + expect(detectFileLink("DVL_QP-SFG_严格审稿意见.md")).toEqual({ + kind: "workspace", + display: "DVL_QP-SFG_严格审稿意见.md", + path: "DVL_QP-SFG_严格审稿意见.md", + }); + }); + + it("links Office documents the file dialog can preview or download", () => { + for (const name of [ + "DVL_QP-SFG_论文修改方向.docx", + "./修改方向.docx", + "outputs/表格.xlsx", + "汇报.pptx", + "notes.doc", + "data.xls", + ]) { + const link = detectFileLink(name); + expect(link, name).not.toBeNull(); + expect(link?.kind).toBe("workspace"); + } + }); + + it("links common archive/ebook deliverables as workspace files", () => { + for (const name of ["report.zip", "backup.tar.gz", "book.epub"]) { + expect(detectFileLink(name)?.kind, name).toBe("workspace"); + } + }); + + it("still rejects scheme URLs and extension-less strings", () => { + expect(detectFileLink("https://example.com/x.docx")).toBeNull(); + expect(detectFileLink("sandbox:/mnt/data/x.docx")).toBeNull(); + expect(detectFileLink("README")).toBeNull(); + }); +}); diff --git a/src/lib/fileLink.ts b/src/lib/fileLink.ts index 310f2a0..6262ee5 100644 --- a/src/lib/fileLink.ts +++ b/src/lib/fileLink.ts @@ -1,9 +1,12 @@ -// Detect file paths the agent emits as inline code and turn them into -// click-to-open links in chat. Intentionally conservative: matches only -// strings that BOTH contain `/` AND end with a known file extension, so -// inline code like `.md`, `field_name`, or a JSON key doesn't get -// underlined. Bare prose paths are out of scope (too noisy without the -// backtick signal). +// Detect file paths the agent emits and turn them into click-to-open links +// in chat. The canonical workspace-reference grammar (shared with the +// backend's file tools and shell path rewriter) is a workspace-relative +// path — `artifacts/x.png`, bare `x.png`, or the `/`-prefixed virtual form +// `/artifacts/x.png`. This matcher is intentionally conservative in one +// respect: it requires a recognised file extension, so inline code like +// `.md`, `field_name`, or a JSON key doesn't get underlined. Bare prose +// paths are out of scope (too noisy without the backtick signal — the +// rehypePathLinks prose scanner additionally requires a `/`). // // Dispatch flows through a custom DOM event so MarkdownContent doesn't need // a context dependency — ChatInterface (or any parent) attaches the @@ -38,6 +41,35 @@ const KNOWN_EXTS = new Set([ "wav", "mp4", "mov", + // Office documents the file dialog previews (word/spreadsheet) or offers + // as a download — without these, agent-cited deliverables render as raw + // page-relative links that 404. + "doc", + "docx", + "docm", + "dotx", + "dotm", + "xls", + "xlsx", + "xlsm", + "xlsb", + "xltx", + "xltm", + "ods", + "ppt", + "pptx", + "pptm", + "odp", + "odt", + "epub", + // Archives and academic sources the dialog serves as binary/text downloads. + "zip", + "rar", + "7z", + "tar", + "gz", + "tex", + "bib", ]); export type FileLinkKind = "workspace" | "memory"; @@ -52,21 +84,38 @@ export interface FileLink { path: string; } +/** + * Reduce a host-absolute path under a conversation files dir + * (`/…/.evoscientist/conversations//files/`) to the canonical + * workspace-relative form (``). Shell flows expose real host paths to + * the agent (cwd, `pwd`), and it sometimes cites them verbatim in replies. + * Anything else returns unchanged. + */ +export function stripConversationHostPrefix(text: string): string { + const MARKER = "/.evoscientist/conversations/"; + const idx = text.indexOf(MARKER); + if (idx === -1) return text; + const filesIdx = text.indexOf("/files/", idx + MARKER.length); + if (filesIdx === -1) return text; + return text.slice(filesIdx + "/files/".length); +} + /** * Classify an inline-code string as a file link, or return null if it doesn't * look like one. Whitespace, scheme URLs (http:, mailto:), and strings without - * both a `/` and a recognised extension are rejected. + * a recognised extension are rejected. Both `dir/name.ext` and bare + * `name.ext` forms are accepted (the shared workspace-reference grammar); + * memory paths require the `/memories/` prefix. Host-absolute paths under a + * conversation files dir are first reduced to workspace-relative form. */ export function detectFileLink(text: string): FileLink | null { - const s = text.trim(); + const s = stripConversationHostPrefix(text.trim()); if (!s || /\s/.test(s)) return null; if (/^[a-z][a-z0-9+.-]*:/i.test(s)) return null; - if (!s.includes("/")) return null; - const dot = s.lastIndexOf("."); - if (dot <= s.lastIndexOf("/")) return null; - const ext = s.slice(dot + 1).toLowerCase(); - if (!KNOWN_EXTS.has(ext)) return null; if (s.startsWith("/memories/")) { + const dot = s.lastIndexOf("."); + if (dot <= s.lastIndexOf("/")) return null; + if (!KNOWN_EXTS.has(s.slice(dot + 1).toLowerCase())) return null; // The memory API resolves paths relative to the memory root, so strip the // virtual `/memories/` prefix the agent emits. Display keeps the prefix // since that's the form the user reads in chat. @@ -76,6 +125,13 @@ export function detectFileLink(text: string): FileLink | null { path: s.replace(/^\/memories\/+/, ""), }; } + // A bare filename (`x.png`) has no slash; a relative one (`a/x.png`) does. + // Both are workspace-relative. Reject dotfiles (`.md`) and trailing dots. + const stem = s.split("/").pop() ?? s; + const dot = stem.lastIndexOf("."); + if (dot <= 0 || dot === stem.length - 1) return null; + const ext = stem.slice(dot + 1).toLowerCase(); + if (!KNOWN_EXTS.has(ext)) return null; // Workspace API resolves relative to the workspace root. Strip both a // leading `/` (the absolute form the agent sometimes emits) and a leading // `./` (the explicit-relative form, e.g. `./attention.pdf`) so the server's @@ -86,6 +142,28 @@ export function detectFileLink(text: string): FileLink | null { export const FILE_LINK_EVENT = "evosci:open-file"; +/** URL of the workspace file-serving endpoint for a conversation. */ +export function workspaceFileUrl(path: string, threadId: string): string { + const qs = new URLSearchParams({ path, threadId }); + return `/api/workspace/file?${qs.toString()}`; +} + +/** + * Rewrite a markdown image source so workspace-relative paths + * (`artifacts/x.png`, `/artifacts/x.png`) are served through the workspace + * file API of the current conversation. Absolute URLs, data:/blob: sources, + * memory paths, and anything without a thread return unchanged. + */ +export function resolveChatImageSrc( + src: string | undefined, + threadId: string | null | undefined +): string | undefined { + if (!src || !threadId) return src; + const link = detectFileLink(src); + if (!link || link.kind !== "workspace") return src; + return workspaceFileUrl(link.path, threadId); +} + export type FileLinkEventDetail = FileLink; export function dispatchFileLink(detail: FileLink): void {