feat(webui): inline workspace images/links in chat via thread-aware file urls

This commit is contained in:
m4
2026-08-11 15:07:53 +08:00
parent 84e4d298b3
commit e32dd49095
4 changed files with 139 additions and 14 deletions
+6
View File
@@ -52,6 +52,8 @@ interface ChatMessageProps {
autoApprove?: boolean;
/** Live intermediate steps per task tool-call id (sub-agent activity). */
subAgentSteps?: Record<string, SubAgentStep[]>;
/** Current conversation thread; enables inline workspace images. */
threadId?: string | null;
}
export const ChatMessage = React.memo<ChatMessageProps>(
@@ -71,6 +73,7 @@ export const ChatMessage = React.memo<ChatMessageProps>(
onEditMessage,
autoApprove,
subAgentSteps,
threadId,
}) => {
const isUser = message.type === "human";
const messageContent = extractStringFromMessageContent(message);
@@ -285,6 +288,7 @@ export const ChatMessage = React.memo<ChatMessageProps>(
<StreamingMarkdown
content={messageContent}
isStreaming={!!isStreaming}
threadId={threadId}
/>
) : null}
</div>
@@ -422,6 +426,7 @@ export const ChatMessage = React.memo<ChatMessageProps>(
<div className="mb-4">
<MarkdownContent
content={extractSubAgentContent(subAgent.input)}
threadId={threadId}
/>
</div>
{(subAgentSteps?.[subAgent.id]?.length ?? 0) > 0 && (
@@ -444,6 +449,7 @@ export const ChatMessage = React.memo<ChatMessageProps>(
</h4>
<MarkdownContent
content={extractSubAgentContent(subAgent.output)}
threadId={threadId}
/>
</>
)}
+3 -1
View File
@@ -9,10 +9,11 @@ interface StreamingMarkdownProps {
content: string;
isStreaming: boolean;
className?: string;
threadId?: string | null;
}
export const StreamingMarkdown = React.memo<StreamingMarkdownProps>(
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<StreamingMarkdownProps>(
content={typed.text}
isStreaming={isStreaming || typed.isTyping}
className={cn(className, typed.isTyping && "typewriter-caret")}
threadId={threadId}
/>
);
}
+39
View File
@@ -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();
});
});
+91 -13
View File
@@ -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<string>([
"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/<scope>/files/<rel>`) to the canonical
* workspace-relative form (`<rel>`). 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 {