feat: support attached images for all providers

This commit is contained in:
Haileyesus
2026-07-03 15:42:29 +03:00
parent 3ade1a1105
commit a253a2bda4
33 changed files with 1467 additions and 321 deletions

View File

@@ -642,7 +642,7 @@ export function useChatComposerState({
});
try {
const response = await authenticatedFetch(`/api/projects/${selectedProject.projectId}/upload-images`, {
const response = await authenticatedFetch('/api/assets/images', {
method: 'POST',
headers: {},
body: formData,

View File

@@ -51,7 +51,8 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
switch (msg.kind) {
case 'text': {
const content = msg.content || '';
if (!content.trim()) continue;
const images = Array.isArray(msg.images) && msg.images.length > 0 ? msg.images : undefined;
if (!content.trim() && !images) continue;
if (msg.role === 'user') {
// Parse task notifications
@@ -71,6 +72,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes
type: 'user',
content: unescapeWithMathProtection(decodeHtmlEntities(content)),
timestamp: msg.timestamp,
images,
...sharedMetadata,
});
}

View File

@@ -83,6 +83,9 @@ function chatMessageToNormalized(
kind: 'text',
role: msg.type === 'user' ? 'user' : 'assistant',
content: msg.content || '',
// Keep attachment references on the local echo so the user bubble shows
// its images immediately, before the server-backed copy replaces it.
images: Array.isArray(msg.images) && msg.images.length > 0 ? msg.images : undefined,
} as NormalizedMessage;
}

View File

@@ -10,8 +10,12 @@ export type Provider = LLMProvider;
export type PermissionMode = 'default' | 'acceptEdits' | 'auto' | 'bypassPermissions' | 'plan';
export interface ChatImage {
data: string;
name: string;
/** Inline data URL (Claude history stores attachments as base64). */
data?: string;
/** Project-relative path under `.cloudcli/assets` served via the files API. */
path?: string;
name?: string;
mimeType?: string;
}
export interface ToolResult {

View File

@@ -0,0 +1,180 @@
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import { authenticatedFetch } from '../../../../utils/api';
import type { ChatImage } from '../../types/types';
type ChatMessageImagesProps = {
images: ChatImage[];
projectId?: string | null;
};
/**
* Resolves one chat image to a displayable src. Inline data URLs are used
* directly; path-based attachments are fetched as blobs (a bare <img src>
* cannot carry the auth header) — first from the global assets route
* (`~/.cloudcli/assets`), then from the project files route as a fallback for
* sessions recorded before attachments moved to the global store.
*/
function useChatImageSrc(image: ChatImage, projectId?: string | null): { src: string | null; failed: boolean } {
const [src, setSrc] = useState<string | null>(image.data || null);
const [failed, setFailed] = useState(false);
useEffect(() => {
if (image.data) {
setSrc(image.data);
setFailed(false);
return;
}
const imagePath = image.path;
if (!imagePath) {
setSrc(null);
setFailed(true);
return;
}
const filename = imagePath.split(/[\\/]/).pop() || '';
const candidateUrls = [
`/api/assets/images/${encodeURIComponent(filename)}`,
...(projectId
? [`/api/projects/${projectId}/files/content?path=${encodeURIComponent(imagePath)}`]
: []),
];
let objectUrl: string | null = null;
const controller = new AbortController();
const load = async () => {
setFailed(false);
for (const url of candidateUrls) {
try {
const response = await authenticatedFetch(url, { signal: controller.signal });
if (!response.ok) {
continue;
}
const blob = await response.blob();
objectUrl = URL.createObjectURL(blob);
setSrc(objectUrl);
return;
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return;
}
}
}
setSrc(null);
setFailed(true);
};
void load();
return () => {
controller.abort();
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [image.data, image.path, projectId]);
return { src, failed };
}
/**
* Fullscreen image overlay in the claude.ai style: dark backdrop, centered
* image, closes on backdrop click, close button, or Escape.
*/
function ImageLightbox({ src, alt, onClose }: { src: string; alt: string; onClose: () => void }) {
useEffect(() => {
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key === 'Escape') {
event.stopPropagation();
onClose();
}
};
document.addEventListener('keydown', handleKeyDown, true);
return () => document.removeEventListener('keydown', handleKeyDown, true);
}, [onClose]);
return createPortal(
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label={alt}
>
<button
type="button"
onClick={onClose}
aria-label="Close image preview"
className="absolute right-4 top-4 rounded-full bg-white/10 p-2 text-white transition-colors hover:bg-white/20"
>
<X className="h-5 w-5" />
</button>
<img
src={src}
alt={alt}
onClick={(event) => event.stopPropagation()}
className="max-h-[90vh] max-w-[92vw] rounded-lg object-contain shadow-2xl"
/>
</div>,
document.body,
);
}
function ChatMessageImage({ image, projectId }: { image: ChatImage; projectId?: string | null }) {
const { src, failed } = useChatImageSrc(image, projectId);
const [expanded, setExpanded] = useState(false);
const alt = image.name || 'Attached image';
if (failed) {
return (
<div className="flex h-28 w-28 items-center justify-center rounded-xl border border-border/50 bg-muted px-2 text-center text-[10px] text-muted-foreground">
{alt}
</div>
);
}
if (!src) {
return <div className="h-28 w-28 animate-pulse rounded-xl border border-border/50 bg-muted" />;
}
return (
<>
<button
type="button"
onClick={() => setExpanded(true)}
aria-label={`Expand ${alt}`}
className="block overflow-hidden rounded-xl border border-border/50 shadow-sm focus:outline-none focus:ring-2 focus:ring-primary/60"
>
<img
src={src}
alt={alt}
className="h-28 w-28 cursor-zoom-in object-cover transition-transform duration-200 hover:scale-105"
/>
</button>
{expanded && <ImageLightbox src={src} alt={alt} onClose={() => setExpanded(false)} />}
</>
);
}
/**
* Image attachments for a user turn, rendered claude.ai-style: standalone
* rounded square cards shown above the message bubble. Each thumbnail
* expands to a fullscreen lightbox on click.
*/
export default function ChatMessageImages({ images, projectId }: ChatMessageImagesProps) {
if (!images || images.length === 0) {
return null;
}
return (
<div className="flex flex-wrap justify-end gap-2">
{images.map((image, index) => (
<ChatMessageImage key={image.path || image.name || index} image={image} projectId={projectId} />
))}
</div>
);
}

View File

@@ -18,14 +18,16 @@ const ImageAttachment = ({ file, onRemove, uploadProgress, error }: ImageAttachm
return (
<div className="group relative">
<img src={preview} alt={file.name} className="h-20 w-20 rounded object-cover" />
<div className="overflow-hidden rounded-xl border border-border/50 shadow-sm">
<img src={preview} alt={file.name} className="h-20 w-20 object-cover" />
</div>
{uploadProgress !== undefined && uploadProgress < 100 && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
<div className="absolute inset-0 flex items-center justify-center rounded-xl bg-black/50">
<div className="text-xs text-white">{uploadProgress}%</div>
</div>
)}
{error && (
<div className="absolute inset-0 flex items-center justify-center bg-red-500/50">
<div className="absolute inset-0 flex items-center justify-center rounded-xl bg-red-500/50">
<svg className="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
@@ -34,7 +36,7 @@ const ImageAttachment = ({ file, onRemove, uploadProgress, error }: ImageAttachm
<button
type="button"
onClick={onRemove}
className="absolute -right-2 -top-2 rounded-full bg-red-500 p-1 text-white opacity-100 transition-opacity focus:opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
className="absolute -right-1.5 -top-1.5 rounded-full border border-border/40 bg-background/90 p-1 text-foreground shadow-sm backdrop-blur transition-opacity hover:bg-background focus:opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
aria-label="Remove image"
>
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@@ -13,6 +13,7 @@ import type { Project } from '../../../../types/app';
import { ToolRenderer, shouldHideToolResult } from '../../tools';
import { Reasoning, ReasoningTrigger, ReasoningContent } from '../../../../shared/view/ui';
import ChatMessageImages from './ChatMessageImages';
import { Markdown } from './Markdown';
import MessageCopyControl from './MessageCopyControl';
import MessageSpeakControl from './MessageSpeakControl';
@@ -84,31 +85,28 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, s
className={`chat-message ${message.type} ${isGrouped ? 'grouped' : ''} ${message.type === 'user' ? 'flex justify-end px-3 sm:px-0' : 'px-3 sm:px-0'}`}
>
{message.type === 'user' ? (
/* User message bubble on the right */
/* User turn on the right: claude.ai-style attachment cards above the bubble */
<div className="flex w-full items-end space-x-0 sm:w-auto sm:max-w-[85%] sm:space-x-3 md:max-w-md lg:max-w-lg xl:max-w-xl">
<div className="group flex-1 rounded-2xl rounded-br-md bg-blue-600 px-3 py-2 text-white shadow-sm sm:flex-initial sm:px-4">
<div dir="auto" className="whitespace-pre-wrap break-words font-serif text-sm">
{message.content}
</div>
<div className="flex min-w-0 flex-1 flex-col items-end gap-2 sm:flex-initial">
{message.images && message.images.length > 0 && (
<div className="mt-2 grid grid-cols-2 gap-2">
{message.images.map((img, idx) => (
<img
key={img.name || idx}
src={img.data}
alt={img.name}
className="h-auto max-w-full cursor-pointer rounded-lg transition-opacity hover:opacity-90"
onClick={() => window.open(img.data, '_blank')}
/>
))}
<ChatMessageImages
images={message.images}
projectId={selectedProject?.projectId}
/>
)}
{(userCopyContent.trim().length > 0 || !message.images?.length) && (
<div className="group max-w-full rounded-2xl rounded-br-md bg-blue-600 px-3 py-2 text-white shadow-sm sm:px-4">
<div dir="auto" className="whitespace-pre-wrap break-words font-serif text-sm">
{message.content}
</div>
<div className="mt-1 flex items-center justify-end gap-1 text-xs text-blue-100">
{shouldShowUserCopyControl && (
<MessageCopyControl content={userCopyContent} messageType="user" />
)}
<span>{formattedTime}</span>
</div>
</div>
)}
<div className="mt-1 flex items-center justify-end gap-1 text-xs text-blue-100">
{shouldShowUserCopyControl && (
<MessageCopyControl content={userCopyContent} messageType="user" />
)}
<span>{formattedTime}</span>
</div>
</div>
{!isGrouped && (
<div className="hidden h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-sm text-white sm:flex">

View File

@@ -202,7 +202,7 @@ export const PromptInputSubmit = React.forwardRef<HTMLButtonElement, PromptInput
type={isActive ? 'button' : 'submit'}
variant="default"
size="icon"
className={cn('h-8 w-8 rounded-lg', className)}
className={cn('h-8 w-8 shrink-0 rounded-lg', className)}
{...props}
>
{children ?? (isActive ? (

View File

@@ -60,7 +60,7 @@ export interface NormalizedMessage {
isLocalCommand?: boolean;
isLocalCommandStdout?: boolean;
isCompactSummary?: boolean;
images?: string[];
images?: Array<{ path?: string; data?: string; name?: string }>;
toolName?: string;
toolInput?: unknown;
toolId?: string;