mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-07 14:12:40 +08:00
feat: support attached images for all providers
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
180
src/components/chat/view/subcomponents/ChatMessageImages.tsx
Normal file
180
src/components/chat/view/subcomponents/ChatMessageImages.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user