fix(webui): validate inferred file paths before preview (#4935)

This commit is contained in:
chengyongru
2026-07-15 10:45:40 +08:00
committed by GitHub
parent aa70aa48f9
commit 5ed28a6744
10 changed files with 461 additions and 51 deletions
@@ -0,0 +1,25 @@
import { createContext, useContext, type ReactNode } from "react";
export type FilePreviewAvailabilityResolver = (path: string) => Promise<boolean>;
const FilePreviewAvailabilityContext = createContext<
FilePreviewAvailabilityResolver | undefined
>(undefined);
export function FilePreviewAvailabilityProvider({
children,
resolve,
}: {
children: ReactNode;
resolve?: FilePreviewAvailabilityResolver;
}) {
return (
<FilePreviewAvailabilityContext.Provider value={resolve}>
{children}
</FilePreviewAvailabilityContext.Provider>
);
}
export function useFilePreviewAvailabilityResolver() {
return useContext(FilePreviewAvailabilityContext);
}
+56 -1
View File
@@ -1,7 +1,9 @@
import {
Children,
isValidElement,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import type { Components, Options as ReactMarkdownOptions } from "react-markdown";
@@ -14,6 +16,10 @@ import remarkMath from "remark-math";
import { AttachmentTile } from "@/components/AttachmentTile";
import { CodeBlock } from "@/components/CodeBlock";
import {
useFilePreviewAvailabilityResolver,
type FilePreviewAvailabilityResolver,
} from "@/components/FilePreviewAvailabilityContext";
import {
FileReferenceChip,
isFilePatternReference,
@@ -50,6 +56,50 @@ type InlineLinkPreview = {
title: string;
};
type AvailabilityResult = {
available: boolean;
path: string;
resolve: FilePreviewAvailabilityResolver;
};
function InferredFileReferenceChip({
path,
onOpen,
}: {
path: string;
onOpen?: (path: string) => void;
}) {
const resolve = useFilePreviewAvailabilityResolver();
const [result, setResult] = useState<AvailabilityResult | null>(null);
useEffect(() => {
if (!resolve || !onOpen) return;
let cancelled = false;
resolve(path)
.then((available) => {
if (!cancelled) setResult({ available, path, resolve });
})
.catch(() => {
if (!cancelled) setResult({ available: false, path, resolve });
});
return () => {
cancelled = true;
};
}, [onOpen, path, resolve]);
const resolvedAvailable = !resolve || (
result?.resolve === resolve
&& result.path === path
&& result.available
);
return (
<FileReferenceChip
path={path}
onOpen={onOpen && resolvedAvailable ? onOpen : undefined}
/>
);
}
const SAFE_INLINE_HTML_TAGS = new Set(["mark", "sub", "sup"]);
function extensionOf(value: string): string {
@@ -402,7 +452,12 @@ export default function MarkdownTextRenderer({
}
const raw = String(kids).replace(/\n$/, "");
if (isLikelyFilePath(raw)) {
return <FileReferenceChip path={raw} onOpen={onOpenFilePreview} />;
return (
<InferredFileReferenceChip
path={raw}
onOpen={onOpenFilePreview}
/>
);
}
/** Plain fenced ``` blocks (no language) & wide one-liners: block monospace, not inline pill. */
const widePlainBlock = raw.includes("\n") || raw.length > 120;
+76 -21
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
import type { PointerEvent as ReactPointerEvent } from "react";
import { useTranslation } from "react-i18next";
import { FilePreviewAvailabilityProvider } from "@/components/FilePreviewAvailabilityContext";
import { FilePreviewPanel } from "@/components/FilePreviewPanel";
import { PromptNavigator } from "@/components/thread/PromptNavigator";
import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover";
@@ -12,6 +13,8 @@ import { ThreadViewport, type ThreadViewportHandle } from "@/components/thread/T
import { useNanobotStream, type SendAttachment, type SendOptions } from "@/hooks/useNanobotStream";
import { useSessionHistory } from "@/hooks/useSessions";
import {
ApiError,
fetchFilePreviewAvailability,
fetchInstalledCliApps,
fetchMcpPresets,
fetchSettings,
@@ -109,6 +112,12 @@ const FILE_PREVIEW_MAX_WIDTH = 860;
const FILE_PREVIEW_MIN_MAIN_WIDTH = 420;
const FILE_PREVIEW_CLOSE_ANIMATION_MS = 320;
type FilePreviewAvailabilityCacheEntry = {
available?: boolean;
promise: Promise<boolean>;
revision: number;
};
function clampFilePreviewWidth(width: number, maxWidth: number): number {
return Math.min(Math.max(width, FILE_PREVIEW_MIN_WIDTH), maxWidth);
}
@@ -397,6 +406,48 @@ export function ThreadShell({
}, []);
const displayMessages = useMemo(() => projectWebuiThreadMessages(messages), [messages]);
const filePreviewAvailabilityCache = useMemo(
() => new Map<string, FilePreviewAvailabilityCacheEntry>(),
[historyKey, token],
);
const filePreviewAvailabilityRevision = displayMessages.length;
const resolveFilePreviewAvailability = useCallback((path: string) => {
if (!historyKey) return Promise.resolve(false);
const cached = filePreviewAvailabilityCache.get(path);
if (
cached
&& (cached.available !== false || cached.revision === filePreviewAvailabilityRevision)
) {
return cached.promise;
}
const pending = fetchFilePreviewAvailability(token, historyKey, path).catch(
(error: unknown) => {
if (error instanceof ApiError) {
if (error.status === 404 && /API route not found/i.test(error.message)) {
return true;
}
if ([400, 403, 404, 415].includes(error.status)) return false;
}
return false;
},
);
const entry: FilePreviewAvailabilityCacheEntry = {
promise: pending,
revision: filePreviewAvailabilityRevision,
};
filePreviewAvailabilityCache.set(path, entry);
void pending.then((available) => {
if (filePreviewAvailabilityCache.get(path) === entry) {
entry.available = available;
}
});
return pending;
}, [
filePreviewAvailabilityCache,
filePreviewAvailabilityRevision,
historyKey,
token,
]);
const showHeroComposer = messages.length === 0 && !loading;
const wasShowingHeroComposerRef = useRef(showHeroComposer);
@@ -830,27 +881,31 @@ export function ThreadShell({
sessionInfoAction={sessionInfoAction}
/>
) : null}
<ThreadViewport
ref={viewportRef}
messages={displayMessages}
isStreaming={isStreaming}
emptyState={emptyState}
composer={composer}
scrollToBottomSignal={scrollToBottomSignal}
scrollToLatestUserPromptSignal={scrollToLatestUserPromptSignal}
conversationKey={historyKey}
showScrollToBottomButton={!!session}
cliApps={cliApps}
mcpPresets={mcpPresets}
slashCommands={slashCommands}
forkBoundaryMessageCount={forkBoundaryMessageCount}
hasMoreBefore={hasMoreBefore}
loadingOlder={loadingOlder}
userMessageOffset={userMessageOffset}
onLoadOlder={loadOlder}
onOpenFilePreview={historyKey ? handleOpenFilePreview : undefined}
onForkFromMessage={onForkChat ? handleForkFromMessage : undefined}
/>
<FilePreviewAvailabilityProvider
resolve={historyKey ? resolveFilePreviewAvailability : undefined}
>
<ThreadViewport
ref={viewportRef}
messages={displayMessages}
isStreaming={isStreaming}
emptyState={emptyState}
composer={composer}
scrollToBottomSignal={scrollToBottomSignal}
scrollToLatestUserPromptSignal={scrollToLatestUserPromptSignal}
conversationKey={historyKey}
showScrollToBottomButton={!!session}
cliApps={cliApps}
mcpPresets={mcpPresets}
slashCommands={slashCommands}
forkBoundaryMessageCount={forkBoundaryMessageCount}
hasMoreBefore={hasMoreBefore}
loadingOlder={loadingOlder}
userMessageOffset={userMessageOffset}
onLoadOlder={loadOlder}
onOpenFilePreview={historyKey ? handleOpenFilePreview : undefined}
onForkFromMessage={onForkChat ? handleForkFromMessage : undefined}
/>
</FilePreviewAvailabilityProvider>
</div>
{filePreviewPath && historyKey ? (
<FilePreviewPanel
+18
View File
@@ -200,6 +200,24 @@ export async function fetchFilePreview(
);
}
export async function fetchFilePreviewAvailability(
token: string,
key: string,
path: string,
base: string = "",
): Promise<boolean> {
const query = new URLSearchParams();
query.set("path", path);
query.set("probe", "1");
const payload = await request<{ available?: boolean }>(
`${base}/api/sessions/${encodeURIComponent(key)}/file-preview?${query}`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
return payload.available !== false;
}
export async function fetchSessionAutomations(
token: string,
key: string,
+27
View File
@@ -5,6 +5,7 @@ import {
createModelConfiguration,
deleteSession,
fetchFilePreview,
fetchFilePreviewAvailability,
fetchAutomations,
fetchApiService,
fetchCliApps,
@@ -102,6 +103,32 @@ describe("webui API helpers", () => {
);
});
it("probes file preview availability without requesting contents", async () => {
await expect(
fetchFilePreviewAvailability("tok", "websocket:chat-1", "notes/ready.md"),
).resolves.toBe(true);
expect(fetch).toHaveBeenCalledWith(
"/api/sessions/websocket%3Achat-1/file-preview?path=notes%2Fready.md&probe=1",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
credentials: "same-origin",
}),
);
});
it("returns false when a file preview probe is unavailable", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ available: false }),
} as Response);
await expect(
fetchFilePreviewAvailability("tok", "websocket:chat-1", "notes/missing.md"),
).resolves.toBe(false);
});
it("percent-encodes websocket keys when fetching session automations", async () => {
await fetchSessionAutomations("tok", "websocket:chat-1");
@@ -1,6 +1,7 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { FilePreviewAvailabilityProvider } from "@/components/FilePreviewAvailabilityContext";
import MarkdownTextRenderer from "@/components/MarkdownTextRenderer";
describe("MarkdownTextRenderer", () => {
@@ -34,6 +35,75 @@ describe("MarkdownTextRenderer", () => {
);
});
it("keeps unavailable inferred inline file paths non-interactive", async () => {
const onOpenFilePreview = vi.fn();
const resolve = vi.fn().mockResolvedValue(false);
render(
<FilePreviewAvailabilityProvider resolve={resolve}>
<MarkdownTextRenderer onOpenFilePreview={onOpenFilePreview}>
{"Future file: `notes/missing.md`"}
</MarkdownTextRenderer>
</FilePreviewAvailabilityProvider>,
);
const reference = screen.getByTestId("inline-file-path");
expect(reference).toHaveTextContent("missing.md");
await waitFor(() => expect(resolve).toHaveBeenCalledWith("notes/missing.md"));
expect(reference).not.toHaveAttribute("role");
expect(reference).not.toHaveAttribute("tabindex");
fireEvent.click(reference);
expect(onOpenFilePreview).not.toHaveBeenCalled();
});
it("keeps inferred inline file paths non-interactive when availability lookup fails", async () => {
const onOpenFilePreview = vi.fn();
let rejectAvailability!: (reason?: unknown) => void;
const resolve = vi.fn(() => new Promise<boolean>((_resolve, reject) => {
rejectAvailability = reject;
}));
render(
<FilePreviewAvailabilityProvider resolve={resolve}>
<MarkdownTextRenderer onOpenFilePreview={onOpenFilePreview}>
{"Unreadable file: `notes/locked.md`"}
</MarkdownTextRenderer>
</FilePreviewAvailabilityProvider>,
);
const reference = screen.getByTestId("inline-file-path");
await waitFor(() => expect(resolve).toHaveBeenCalledWith("notes/locked.md"));
await act(async () => {
rejectAvailability(new Error("probe failed"));
await Promise.resolve();
});
expect(reference).not.toHaveAttribute("role");
expect(reference).not.toHaveAttribute("tabindex");
fireEvent.click(reference);
expect(onOpenFilePreview).not.toHaveBeenCalled();
});
it("makes available inferred inline file paths previewable", async () => {
const onOpenFilePreview = vi.fn();
const resolve = vi.fn().mockResolvedValue(true);
render(
<FilePreviewAvailabilityProvider resolve={resolve}>
<MarkdownTextRenderer onOpenFilePreview={onOpenFilePreview}>
{"Existing file: `notes/ready.md`"}
</MarkdownTextRenderer>
</FilePreviewAvailabilityProvider>,
);
const reference = screen.getByTestId("inline-file-path");
await waitFor(() => expect(reference).toHaveAttribute("role", "button"));
expect(reference).toHaveAttribute("tabindex", "0");
fireEvent.click(reference);
expect(onOpenFilePreview).toHaveBeenCalledWith("notes/ready.md");
});
it("does not treat non-file hrefs as previews just because the label looks like a file", () => {
const onOpenFilePreview = vi.fn();
render(
+53
View File
@@ -231,6 +231,59 @@ describe("ThreadShell", () => {
);
});
it("keeps inferred file paths non-interactive when the availability probe fails", async () => {
const client = makeClient();
let resolveProbe!: (value: Response) => void;
const probe = new Promise<Response>((resolve) => {
resolveProbe = resolve;
});
const fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("websocket%3Apreview-error/webui-thread")) {
return Promise.resolve(httpJson(transcriptFromSimpleMessages([
{ role: "assistant", content: "Unreadable file: `prompts/dream.md`" },
])));
}
if (url.includes("websocket%3Apreview-error/file-preview?")) return probe;
return Promise.resolve({
ok: false,
status: 404,
json: async () => ({}),
});
});
vi.stubGlobal("fetch", fetchMock);
render(wrap(
client,
<ThreadShell
session={session("preview-error")}
title="Preview error"
onToggleSidebar={() => {}}
/>,
));
const reference = await screen.findByTestId("inline-file-path");
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining("file-preview?path=prompts%2Fdream.md&probe=1"),
expect.anything(),
));
await act(async () => {
resolveProbe({
ok: false,
status: 500,
text: async () => "failed to read file",
json: async () => ({}),
} as Response);
await probe;
await Promise.resolve();
});
expect(reference).not.toHaveAttribute("role");
expect(reference).not.toHaveAttribute("tabindex");
fireEvent.click(reference);
expect(screen.queryByText("failed to read file")).not.toBeInTheDocument();
});
it("does not navigate away when clicking the chat title", async () => {
const client = makeClient();
const onGoHome = vi.fn();