Add inline image previews in assistant messages
This commit is contained in:
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
## 0.1.54 - 2026-04-12
|
## 0.1.54 - 2026-04-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Inline image previews in agent messages — screenshots and images generated by agents render directly in the conversation instead of showing as raw markdown links.
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
- Paseo tools are no longer injected into agents by default — opt in from Settings when you need agent-to-agent orchestration.
|
- Paseo tools are no longer injected into agents by default — opt in from Settings when you need agent-to-agent orchestration.
|
||||||
- Agent provider and mode are now resolved server-side, so CLI commands like `paseo run` use consistent defaults without client-side lookups.
|
- Agent provider and mode are now resolved server-side, so CLI commands like `paseo run` use consistent defaults without client-side lookups.
|
||||||
|
|||||||
@@ -331,6 +331,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
timestamp={item.timestamp.getTime()}
|
timestamp={item.timestamp.getTime()}
|
||||||
onInlinePathPress={handleInlinePathPress}
|
onInlinePathPress={handleInlinePathPress}
|
||||||
workspaceRoot={workspaceRoot}
|
workspaceRoot={workspaceRoot}
|
||||||
|
serverId={serverId}
|
||||||
|
client={client}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "thought": {
|
case "thought": {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
|
import {
|
||||||
|
clearAssistantImageMetadataCache,
|
||||||
|
setAssistantImageMetadata,
|
||||||
|
} from "@/utils/assistant-image-metadata";
|
||||||
import {
|
import {
|
||||||
DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS,
|
DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS,
|
||||||
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
|
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
|
||||||
@@ -128,6 +132,25 @@ describe("estimateStreamItemHeight", () => {
|
|||||||
|
|
||||||
expect(estimateStreamItemHeight(item)).toBe(220);
|
expect(estimateStreamItemHeight(item)).toBe(220);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses cached assistant image metadata when available", () => {
|
||||||
|
clearAssistantImageMetadataCache();
|
||||||
|
setAssistantImageMetadata(
|
||||||
|
{
|
||||||
|
source: "https://example.com/tall.png",
|
||||||
|
},
|
||||||
|
{ width: 800, height: 1600 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const item: StreamItem = {
|
||||||
|
kind: "assistant_message",
|
||||||
|
id: "a-image",
|
||||||
|
text: "Look at this\n\n",
|
||||||
|
timestamp: createTimestamp(2),
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(estimateStreamItemHeight(item)).toBeGreaterThan(220);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("web virtualization test overrides", () => {
|
describe("web virtualization test overrides", () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
|
import { estimateAssistantMessageHeightFromCache } from "@/utils/assistant-image-metadata";
|
||||||
|
|
||||||
export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100;
|
export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100;
|
||||||
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = 50;
|
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = 50;
|
||||||
@@ -45,7 +46,7 @@ export function estimateStreamItemHeight(item: StreamItem): number {
|
|||||||
case "user_message":
|
case "user_message":
|
||||||
return item.images && item.images.length > 0 ? 220 : 96;
|
return item.images && item.images.length > 0 ? 220 : 96;
|
||||||
case "assistant_message":
|
case "assistant_message":
|
||||||
return 220;
|
return estimateAssistantMessageHeightFromCache(item.text) ?? 220;
|
||||||
case "tool_call":
|
case "tool_call":
|
||||||
return 136;
|
return 136;
|
||||||
case "thought":
|
case "thought":
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import type { ReactNode, ComponentType } from "react";
|
import type { ReactNode, ComponentType } from "react";
|
||||||
import Markdown, { MarkdownIt, type RenderRules } from "react-native-markdown-display";
|
import Markdown, { MarkdownIt, type RenderRules } from "react-native-markdown-display";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import MaskedView from "@react-native-masked-view/masked-view";
|
import MaskedView from "@react-native-masked-view/masked-view";
|
||||||
import {
|
import {
|
||||||
Circle,
|
Circle,
|
||||||
@@ -74,11 +75,17 @@ import { getMarkdownListMarker } from "@/utils/markdown-list";
|
|||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
import { markScrollInvestigationEvent } from "@/utils/scroll-jank-investigation";
|
import { markScrollInvestigationEvent } from "@/utils/scroll-jank-investigation";
|
||||||
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
||||||
|
import {
|
||||||
|
getAssistantImageMetadata,
|
||||||
|
setAssistantImageMetadata,
|
||||||
|
} from "@/utils/assistant-image-metadata";
|
||||||
|
import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
|
||||||
export type { InlinePathTarget } from "@/utils/inline-path";
|
export type { InlinePathTarget } from "@/utils/inline-path";
|
||||||
import { PlanCard } from "./plan-card";
|
import { PlanCard } from "./plan-card";
|
||||||
import { useToolCallSheet } from "./tool-call-sheet";
|
import { useToolCallSheet } from "./tool-call-sheet";
|
||||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||||
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
|
|
||||||
interface UserMessageProps {
|
interface UserMessageProps {
|
||||||
message: string;
|
message: string;
|
||||||
@@ -401,6 +408,8 @@ interface AssistantMessageProps {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
onInlinePathPress?: (target: InlinePathTarget) => void;
|
onInlinePathPress?: (target: InlinePathTarget) => void;
|
||||||
workspaceRoot?: string;
|
workspaceRoot?: string;
|
||||||
|
serverId?: string;
|
||||||
|
client?: DaemonClient | null;
|
||||||
disableOuterSpacing?: boolean;
|
disableOuterSpacing?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,8 +436,215 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({
|
|||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
userSelect: Platform.OS === "web" ? "text" : "auto",
|
userSelect: Platform.OS === "web" ? "text" : "auto",
|
||||||
},
|
},
|
||||||
|
imageFrame: {
|
||||||
|
width: "100%",
|
||||||
|
minHeight: 160,
|
||||||
|
marginHorizontal: -theme.spacing[1],
|
||||||
|
},
|
||||||
|
imageSurface: {
|
||||||
|
width: "100%",
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
},
|
||||||
|
imageState: {
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
paddingVertical: theme.spacing[6],
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
imageErrorText: {
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const ASSISTANT_IMAGE_MIN_HEIGHT = 160;
|
||||||
|
|
||||||
|
const AssistantMarkdownResolvedImage = memo(function AssistantMarkdownResolvedImage({
|
||||||
|
uri,
|
||||||
|
alt,
|
||||||
|
containerStyle,
|
||||||
|
source,
|
||||||
|
workspaceRoot,
|
||||||
|
serverId,
|
||||||
|
}: {
|
||||||
|
uri: string;
|
||||||
|
alt?: string;
|
||||||
|
containerStyle?: StyleProp<ViewStyle>;
|
||||||
|
source: string;
|
||||||
|
workspaceRoot?: string;
|
||||||
|
serverId?: string;
|
||||||
|
}) {
|
||||||
|
const cachedMetadata = useMemo(
|
||||||
|
() => getAssistantImageMetadata({ source, workspaceRoot, serverId }),
|
||||||
|
[serverId, source, workspaceRoot],
|
||||||
|
);
|
||||||
|
const [aspectRatio, setAspectRatio] = useState<number | null>(
|
||||||
|
cachedMetadata?.aspectRatio ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (cachedMetadata) {
|
||||||
|
setAspectRatio(cachedMetadata.aspectRatio);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAspectRatio(null);
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
Image.getSize(
|
||||||
|
uri,
|
||||||
|
(width, height) => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (width > 0 && height > 0) {
|
||||||
|
const metadata = setAssistantImageMetadata(
|
||||||
|
{ source, workspaceRoot, serverId },
|
||||||
|
{ width, height },
|
||||||
|
);
|
||||||
|
setAspectRatio(metadata?.aspectRatio ?? width / height);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAspectRatio(null);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [cachedMetadata, serverId, source, uri, workspaceRoot]);
|
||||||
|
|
||||||
|
const surfaceStyle = useMemo<StyleProp<ViewStyle>>(
|
||||||
|
() => [
|
||||||
|
assistantMessageStylesheet.imageSurface,
|
||||||
|
aspectRatio ? { aspectRatio } : { minHeight: ASSISTANT_IMAGE_MIN_HEIGHT },
|
||||||
|
],
|
||||||
|
[aspectRatio],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[assistantMessageStylesheet.imageFrame, containerStyle]}>
|
||||||
|
<View style={surfaceStyle}>
|
||||||
|
<Image
|
||||||
|
source={{ uri }}
|
||||||
|
style={assistantMessageStylesheet.image}
|
||||||
|
resizeMode="contain"
|
||||||
|
accessibilityLabel={alt}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function AssistantMarkdownImage({
|
||||||
|
source,
|
||||||
|
alt,
|
||||||
|
hasLeadingContent,
|
||||||
|
client,
|
||||||
|
workspaceRoot,
|
||||||
|
serverId,
|
||||||
|
}: {
|
||||||
|
source: string;
|
||||||
|
alt?: string;
|
||||||
|
hasLeadingContent: boolean;
|
||||||
|
client?: DaemonClient | null;
|
||||||
|
workspaceRoot?: string;
|
||||||
|
serverId?: string;
|
||||||
|
}) {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const resolution = useMemo(
|
||||||
|
() => resolveAssistantImageSource({ source, workspaceRoot }),
|
||||||
|
[source, workspaceRoot],
|
||||||
|
);
|
||||||
|
const containerStyle = useMemo<StyleProp<ViewStyle>>(
|
||||||
|
() => ({
|
||||||
|
marginTop: hasLeadingContent ? theme.spacing[4] : 0,
|
||||||
|
marginBottom: 0,
|
||||||
|
}),
|
||||||
|
[hasLeadingContent, theme],
|
||||||
|
);
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"assistantMarkdownImage",
|
||||||
|
serverId ?? "unknown-server",
|
||||||
|
resolution?.kind === "file_rpc" ? resolution.cwd : null,
|
||||||
|
resolution?.kind === "file_rpc" ? resolution.path : null,
|
||||||
|
],
|
||||||
|
enabled: Boolean(client && resolution?.kind === "file_rpc"),
|
||||||
|
staleTime: 30_000,
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!client || !resolution || resolution.kind !== "file_rpc") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await client.exploreFileSystem(resolution.cwd, resolution.path, "file");
|
||||||
|
if (payload.error) {
|
||||||
|
throw new Error(payload.error);
|
||||||
|
}
|
||||||
|
if (!payload.file || payload.file.kind !== "image" || !payload.file.content) {
|
||||||
|
throw new Error("Image preview unavailable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return `data:${payload.file.mimeType ?? "image/png"};base64,${payload.file.content}`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const directUri = resolution?.kind === "direct" ? resolution.uri : null;
|
||||||
|
const resolvedUri = directUri ?? query.data ?? null;
|
||||||
|
|
||||||
|
if (resolvedUri) {
|
||||||
|
return (
|
||||||
|
<AssistantMarkdownResolvedImage
|
||||||
|
uri={resolvedUri}
|
||||||
|
alt={alt}
|
||||||
|
containerStyle={containerStyle}
|
||||||
|
source={source}
|
||||||
|
workspaceRoot={workspaceRoot}
|
||||||
|
serverId={serverId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.isLoading) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
assistantMessageStylesheet.imageFrame,
|
||||||
|
containerStyle,
|
||||||
|
assistantMessageStylesheet.imageState,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<ActivityIndicator size="small" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
assistantMessageStylesheet.imageFrame,
|
||||||
|
containerStyle,
|
||||||
|
assistantMessageStylesheet.imageState,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={assistantMessageStylesheet.imageErrorText}>
|
||||||
|
{query.error instanceof Error ? query.error.message : "Unable to load image preview."}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function MarkdownLink({
|
function MarkdownLink({
|
||||||
href,
|
href,
|
||||||
style,
|
style,
|
||||||
@@ -741,6 +957,8 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
timestamp,
|
timestamp,
|
||||||
onInlinePathPress,
|
onInlinePathPress,
|
||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
|
serverId,
|
||||||
|
client,
|
||||||
disableOuterSpacing,
|
disableOuterSpacing,
|
||||||
}: AssistantMessageProps) {
|
}: AssistantMessageProps) {
|
||||||
const { theme, rt } = useUnistyles();
|
const { theme, rt } = useUnistyles();
|
||||||
@@ -777,7 +995,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
[onInlinePathPress, workspaceRoot],
|
[onInlinePathPress, workspaceRoot],
|
||||||
);
|
);
|
||||||
|
|
||||||
const markdownRules = useMemo(() => {
|
const markdownRules = useMemo<RenderRules>(() => {
|
||||||
return {
|
return {
|
||||||
text: (
|
text: (
|
||||||
node: any,
|
node: any,
|
||||||
@@ -892,14 +1110,11 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
paragraph: (node: any, children: ReactNode[], parent: any, styles: any) => {
|
paragraph: (node: any, children: ReactNode[], _parent: any, styles: any) => (
|
||||||
const isLastChild = parent[0]?.children?.at(-1)?.key === node.key;
|
<View key={node.key} style={[styles.paragraph, { marginBottom: 0 }]}>
|
||||||
return (
|
{children}
|
||||||
<View key={node.key} style={[styles.paragraph, isLastChild && { marginBottom: 0 }]}>
|
</View>
|
||||||
{children}
|
),
|
||||||
</View>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
link: (node: any, children: ReactNode[], _parent: any, styles: any) => (
|
link: (node: any, children: ReactNode[], _parent: any, styles: any) => (
|
||||||
<MarkdownLink
|
<MarkdownLink
|
||||||
key={node.key}
|
key={node.key}
|
||||||
@@ -916,8 +1131,30 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
)}
|
)}
|
||||||
</MarkdownLink>
|
</MarkdownLink>
|
||||||
),
|
),
|
||||||
|
image: (node: any, _children: ReactNode[], parent: any, styles: any) => {
|
||||||
|
const paragraphNode = Array.isArray(parent)
|
||||||
|
? parent.find((ancestor: any) => ancestor?.type === "paragraph")
|
||||||
|
: null;
|
||||||
|
const paragraphChildren = Array.isArray(paragraphNode?.children)
|
||||||
|
? paragraphNode.children
|
||||||
|
: [];
|
||||||
|
const imageIndex = paragraphChildren.findIndex((child: any) => child?.key === node.key);
|
||||||
|
const hasLeadingContent = imageIndex > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AssistantMarkdownImage
|
||||||
|
key={node.key}
|
||||||
|
source={String(node.attributes?.src ?? "")}
|
||||||
|
alt={typeof node.attributes?.alt === "string" ? node.attributes.alt : undefined}
|
||||||
|
hasLeadingContent={hasLeadingContent}
|
||||||
|
client={client}
|
||||||
|
workspaceRoot={workspaceRoot}
|
||||||
|
serverId={serverId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}, [handleLinkPress, markdownParser, onInlinePathPress]);
|
}, [client, handleLinkPress, markdownParser, onInlinePathPress, serverId, workspaceRoot]);
|
||||||
|
|
||||||
const blocks = useMemo(() => splitMarkdownBlocks(message), [message]);
|
const blocks = useMemo(() => splitMarkdownBlocks(message), [message]);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
clearAssistantImageMetadataCache,
|
||||||
|
estimateAssistantMessageHeightFromCache,
|
||||||
|
extractAssistantImageSources,
|
||||||
|
getAssistantImageMetadata,
|
||||||
|
setAssistantImageMetadata,
|
||||||
|
} from "./assistant-image-metadata";
|
||||||
|
|
||||||
|
describe("assistant image metadata", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearAssistantImageMetadataCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts markdown image sources", () => {
|
||||||
|
expect(
|
||||||
|
extractAssistantImageSources(
|
||||||
|
'Before\n\n\n\n',
|
||||||
|
),
|
||||||
|
).toEqual(["/tmp/paseo.png", "https://example.com/test.png"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reuses cached metadata across canonical and raw source keys", () => {
|
||||||
|
setAssistantImageMetadata(
|
||||||
|
{
|
||||||
|
source: "/tmp/paseo-codex-screenshot.png",
|
||||||
|
workspaceRoot: "/Users/moboudra/dev/paseo",
|
||||||
|
serverId: "server-1",
|
||||||
|
},
|
||||||
|
{ width: 1200, height: 800 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getAssistantImageMetadata({
|
||||||
|
source: "/tmp/paseo-codex-screenshot.png",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
width: 1200,
|
||||||
|
height: 800,
|
||||||
|
aspectRatio: 1.5,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("estimates assistant message height from cached image metadata", () => {
|
||||||
|
setAssistantImageMetadata(
|
||||||
|
{
|
||||||
|
source: "https://example.com/landscape.png",
|
||||||
|
},
|
||||||
|
{ width: 1200, height: 800 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
estimateAssistantMessageHeightFromCache(
|
||||||
|
"Here is the screenshot\n\n",
|
||||||
|
),
|
||||||
|
).toBeGreaterThan(220);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||||
|
import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
|
||||||
|
|
||||||
|
export interface AssistantImageMetadata {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
aspectRatio: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assistantImageMetadataCache = new Map<string, AssistantImageMetadata>();
|
||||||
|
const assistantImageParseCache = new Map<string, { sources: string[]; hasNonImageText: boolean }>();
|
||||||
|
const ASSISTANT_IMAGE_METADATA_CACHE_LIMIT = 500;
|
||||||
|
const ASSISTANT_IMAGE_PARSE_CACHE_LIMIT = 500;
|
||||||
|
|
||||||
|
const MARKDOWN_IMAGE_PATTERN = /!\[[^\]]*]\((<[^>]+>|[^)\n]+)\)/g;
|
||||||
|
const ASSISTANT_IMAGE_ESTIMATE_WIDTH = MAX_CONTENT_WIDTH - 8;
|
||||||
|
const ASSISTANT_IMAGE_MIN_HEIGHT = 160;
|
||||||
|
const ASSISTANT_IMAGE_BLOCK_GAP = 24;
|
||||||
|
const ASSISTANT_MESSAGE_BASE_HEIGHT = 96;
|
||||||
|
const ASSISTANT_MESSAGE_MIN_HEIGHT = 220;
|
||||||
|
const ASSISTANT_MESSAGE_IMAGE_ONLY_BASE_HEIGHT = 40;
|
||||||
|
|
||||||
|
function touchCacheEntry<K, V>(cache: Map<K, V>, key: K, value: V, limit: number): void {
|
||||||
|
cache.delete(key);
|
||||||
|
cache.set(key, value);
|
||||||
|
if (cache.size <= limit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const oldestKey = cache.keys().next().value;
|
||||||
|
if (oldestKey !== undefined) {
|
||||||
|
cache.delete(oldestKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAssistantImageSourceToken(value: string): string | null {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.startsWith("<") && trimmed.endsWith(">")) {
|
||||||
|
const inner = trimmed.slice(1, -1).trim();
|
||||||
|
return inner || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleMatch = /^(.*?)(?:\s+(['"]).*?\2)?$/.exec(trimmed);
|
||||||
|
const source = titleMatch?.[1]?.trim() ?? trimmed;
|
||||||
|
return source || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSourceAliasKey(source: string): string {
|
||||||
|
return `source:${source}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createResolutionKey(input: {
|
||||||
|
source: string;
|
||||||
|
workspaceRoot?: string;
|
||||||
|
serverId?: string;
|
||||||
|
}): string | null {
|
||||||
|
const resolution = resolveAssistantImageSource({
|
||||||
|
source: input.source,
|
||||||
|
workspaceRoot: input.workspaceRoot,
|
||||||
|
});
|
||||||
|
if (!resolution) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (resolution.kind === "direct") {
|
||||||
|
return `direct:${resolution.uri}`;
|
||||||
|
}
|
||||||
|
return `file:${input.serverId ?? "unknown-server"}:${resolution.cwd}:${resolution.path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAssistantImageMetadataKeys(input: {
|
||||||
|
source: string;
|
||||||
|
workspaceRoot?: string;
|
||||||
|
serverId?: string;
|
||||||
|
}): string[] {
|
||||||
|
const source = input.source.trim();
|
||||||
|
if (!source) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = [createSourceAliasKey(source)];
|
||||||
|
const resolutionKey = createResolutionKey(input);
|
||||||
|
if (resolutionKey) {
|
||||||
|
keys.unshift(resolutionKey);
|
||||||
|
}
|
||||||
|
return [...new Set(keys)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAssistantImageMetadata(input: {
|
||||||
|
source: string;
|
||||||
|
workspaceRoot?: string;
|
||||||
|
serverId?: string;
|
||||||
|
}): AssistantImageMetadata | null {
|
||||||
|
for (const key of getAssistantImageMetadataKeys(input)) {
|
||||||
|
const metadata = assistantImageMetadataCache.get(key);
|
||||||
|
if (metadata) {
|
||||||
|
touchCacheEntry(
|
||||||
|
assistantImageMetadataCache,
|
||||||
|
key,
|
||||||
|
metadata,
|
||||||
|
ASSISTANT_IMAGE_METADATA_CACHE_LIMIT,
|
||||||
|
);
|
||||||
|
return metadata;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAssistantImageMetadata(
|
||||||
|
input: {
|
||||||
|
source: string;
|
||||||
|
workspaceRoot?: string;
|
||||||
|
serverId?: string;
|
||||||
|
},
|
||||||
|
dimensions: { width: number; height: number },
|
||||||
|
): AssistantImageMetadata | null {
|
||||||
|
const { width, height } = dimensions;
|
||||||
|
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata: AssistantImageMetadata = {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
aspectRatio: width / height,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const key of getAssistantImageMetadataKeys(input)) {
|
||||||
|
touchCacheEntry(
|
||||||
|
assistantImageMetadataCache,
|
||||||
|
key,
|
||||||
|
metadata,
|
||||||
|
ASSISTANT_IMAGE_METADATA_CACHE_LIMIT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractAssistantImageSources(markdown: string): string[] {
|
||||||
|
const cachedParse = assistantImageParseCache.get(markdown);
|
||||||
|
if (cachedParse) {
|
||||||
|
touchCacheEntry(
|
||||||
|
assistantImageParseCache,
|
||||||
|
markdown,
|
||||||
|
cachedParse,
|
||||||
|
ASSISTANT_IMAGE_PARSE_CACHE_LIMIT,
|
||||||
|
);
|
||||||
|
return cachedParse.sources;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sources: string[] = [];
|
||||||
|
for (const match of markdown.matchAll(MARKDOWN_IMAGE_PATTERN)) {
|
||||||
|
const normalized = normalizeAssistantImageSourceToken(match[1] ?? "");
|
||||||
|
if (normalized) {
|
||||||
|
sources.push(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const hasNonImageText = markdown.replace(MARKDOWN_IMAGE_PATTERN, "").trim().length > 0;
|
||||||
|
touchCacheEntry(
|
||||||
|
assistantImageParseCache,
|
||||||
|
markdown,
|
||||||
|
{ sources, hasNonImageText },
|
||||||
|
ASSISTANT_IMAGE_PARSE_CACHE_LIMIT,
|
||||||
|
);
|
||||||
|
return sources;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function estimateAssistantMessageHeightFromCache(markdown: string): number | null {
|
||||||
|
const cachedParse = assistantImageParseCache.get(markdown);
|
||||||
|
const parsed =
|
||||||
|
cachedParse ??
|
||||||
|
(() => {
|
||||||
|
const sources = extractAssistantImageSources(markdown);
|
||||||
|
const nextParsed = assistantImageParseCache.get(markdown);
|
||||||
|
return nextParsed ?? { sources, hasNonImageText: true };
|
||||||
|
})();
|
||||||
|
if (parsed.sources.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const knownHeights = parsed.sources
|
||||||
|
.map((source) => getAssistantImageMetadata({ source }))
|
||||||
|
.filter((metadata): metadata is AssistantImageMetadata => metadata !== null)
|
||||||
|
.map((metadata) =>
|
||||||
|
Math.max(
|
||||||
|
ASSISTANT_IMAGE_MIN_HEIGHT,
|
||||||
|
Math.round(ASSISTANT_IMAGE_ESTIMATE_WIDTH / metadata.aspectRatio),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (knownHeights.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseHeight = parsed.hasNonImageText
|
||||||
|
? ASSISTANT_MESSAGE_BASE_HEIGHT
|
||||||
|
: ASSISTANT_MESSAGE_IMAGE_ONLY_BASE_HEIGHT;
|
||||||
|
|
||||||
|
const estimatedHeight =
|
||||||
|
baseHeight +
|
||||||
|
knownHeights.reduce((sum, height) => sum + height, 0) +
|
||||||
|
ASSISTANT_IMAGE_BLOCK_GAP * knownHeights.length;
|
||||||
|
|
||||||
|
return Math.max(ASSISTANT_MESSAGE_MIN_HEIGHT, estimatedHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAssistantImageMetadataCache(): void {
|
||||||
|
assistantImageMetadataCache.clear();
|
||||||
|
assistantImageParseCache.clear();
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { resolveAssistantImageSource } from "./assistant-image-source";
|
||||||
|
|
||||||
|
describe("resolveAssistantImageSource", () => {
|
||||||
|
it("passes through direct image URIs", () => {
|
||||||
|
expect(resolveAssistantImageSource({ source: "https://example.com/image.png" })).toEqual({
|
||||||
|
kind: "direct",
|
||||||
|
uri: "https://example.com/image.png",
|
||||||
|
});
|
||||||
|
expect(resolveAssistantImageSource({ source: "data:image/png;base64,abc" })).toEqual({
|
||||||
|
kind: "direct",
|
||||||
|
uri: "data:image/png;base64,abc",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the workspace root for relative paths", () => {
|
||||||
|
expect(
|
||||||
|
resolveAssistantImageSource({
|
||||||
|
source: "screenshots/output.png",
|
||||||
|
workspaceRoot: "/Users/test/project",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
kind: "file_rpc",
|
||||||
|
cwd: "/Users/test/project",
|
||||||
|
path: "screenshots/output.png",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the workspace root for absolute paths inside the workspace", () => {
|
||||||
|
expect(
|
||||||
|
resolveAssistantImageSource({
|
||||||
|
source: "/Users/test/project/screenshots/output.png",
|
||||||
|
workspaceRoot: "/Users/test/project",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
kind: "file_rpc",
|
||||||
|
cwd: "/Users/test/project",
|
||||||
|
path: "/Users/test/project/screenshots/output.png",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to filesystem root for absolute paths outside the workspace", () => {
|
||||||
|
expect(
|
||||||
|
resolveAssistantImageSource({
|
||||||
|
source: "/tmp/paseo-codex-screenshot.png",
|
||||||
|
workspaceRoot: "/Users/test/project",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
kind: "file_rpc",
|
||||||
|
cwd: "/",
|
||||||
|
path: "/tmp/paseo-codex-screenshot.png",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes file URIs into file RPC requests", () => {
|
||||||
|
expect(
|
||||||
|
resolveAssistantImageSource({
|
||||||
|
source: "file:///tmp/paseo-codex-screenshot.png",
|
||||||
|
workspaceRoot: "/Users/test/project",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
kind: "file_rpc",
|
||||||
|
cwd: "/",
|
||||||
|
path: "/tmp/paseo-codex-screenshot.png",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the drive root for Windows absolute paths", () => {
|
||||||
|
expect(
|
||||||
|
resolveAssistantImageSource({
|
||||||
|
source: "C:/Users/test/Desktop/screenshot.png",
|
||||||
|
workspaceRoot: "D:/repo",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
kind: "file_rpc",
|
||||||
|
cwd: "C:/",
|
||||||
|
path: "C:/Users/test/Desktop/screenshot.png",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { fileUriToPath } from "@/attachments/utils";
|
||||||
|
import { isAbsolutePath } from "@/utils/path";
|
||||||
|
|
||||||
|
export type AssistantImageSourceResolution =
|
||||||
|
| { kind: "direct"; uri: string }
|
||||||
|
| { kind: "file_rpc"; cwd: string; path: string };
|
||||||
|
|
||||||
|
function trimTrailingSeparators(value: string): string {
|
||||||
|
if (value === "/" || /^[A-Za-z]:[\\/]?$/.test(value)) {
|
||||||
|
return value.replace(/\\/g, "/");
|
||||||
|
}
|
||||||
|
return value.replace(/[\\/]+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeForPathComparison(value: string): string {
|
||||||
|
const normalized = trimTrailingSeparators(value.replace(/\\/g, "/"));
|
||||||
|
if (/^[A-Za-z]:\//.test(normalized)) {
|
||||||
|
return `${normalized.slice(0, 1).toUpperCase()}${normalized.slice(1)}`;
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPathWithinRoot(candidatePath: string, rootPath: string): boolean {
|
||||||
|
const candidate = normalizeForPathComparison(candidatePath);
|
||||||
|
const root = normalizeForPathComparison(rootPath);
|
||||||
|
if (!candidate || !root) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (root === "/") {
|
||||||
|
return candidate.startsWith("/");
|
||||||
|
}
|
||||||
|
if (candidate === root) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return candidate.startsWith(`${root}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveFallbackRootFromAbsolutePath(value: string): string | null {
|
||||||
|
if (value.startsWith("/")) {
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
const driveMatch = /^([A-Za-z]:)[\\/]/.exec(value);
|
||||||
|
if (driveMatch?.[1]) {
|
||||||
|
return `${driveMatch[1]}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uncMatch = /^(\\\\[^\\]+\\[^\\]+)/.exec(value);
|
||||||
|
if (uncMatch?.[1]) {
|
||||||
|
return uncMatch[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAssistantImageSource(input: {
|
||||||
|
source: string;
|
||||||
|
workspaceRoot?: string;
|
||||||
|
}): AssistantImageSourceResolution | null {
|
||||||
|
const source = input.source.trim();
|
||||||
|
if (!source) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^(https?:|data:|blob:)/i.test(source)) {
|
||||||
|
return { kind: "direct", uri: source };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourcePath = source.startsWith("file://") ? fileUriToPath(source) : source;
|
||||||
|
if (!sourcePath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAbsolutePath(sourcePath)) {
|
||||||
|
const workspaceRoot = input.workspaceRoot?.trim();
|
||||||
|
if (!workspaceRoot || !isAbsolutePath(workspaceRoot)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: "file_rpc",
|
||||||
|
cwd: workspaceRoot,
|
||||||
|
path: sourcePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceRoot = input.workspaceRoot?.trim();
|
||||||
|
if (
|
||||||
|
workspaceRoot &&
|
||||||
|
isAbsolutePath(workspaceRoot) &&
|
||||||
|
isPathWithinRoot(sourcePath, workspaceRoot)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
kind: "file_rpc",
|
||||||
|
cwd: workspaceRoot,
|
||||||
|
path: sourcePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackRoot = deriveFallbackRootFromAbsolutePath(sourcePath);
|
||||||
|
if (!fallbackRoot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "file_rpc",
|
||||||
|
cwd: fallbackRoot,
|
||||||
|
path: sourcePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user