* Chat Message Component
* Renders user / assistant / system / toolresult messages
* with markdown and images. Tool steps render in ExecutionGraphCard;
* streaming runs may show a compact ToolStatusBar. Thinking output is
* surfaced via ExecutionGraphCard, not inside message bubbles.
*/
import { useState, useCallback, useEffect, memo } from 'react';
import { Sparkles, Copy, Check, Wrench, FileText, Film, Music, FileArchive, File, X, FolderOpen, ZoomIn, Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import { createPortal } from 'react-dom';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { invokeIpc, statFile } from '@/lib/api-client';
import type { RawMessage, AttachedFileMeta } from '@/stores/chat';
import { extractText, extractImages, extractToolUse, formatTimestamp } from './message-utils';
interface ChatMessageProps {
message: RawMessage;
textOverride?: string;
suppressToolCards?: boolean;
suppressProcessAttachments?: boolean;
* When true, hides the assistant text bubble (and any thinking block that
* would be shown above it). Used when the message's text is being folded
* into an ExecutionGraphCard as a narration step, to prevent the same text
* from appearing both inside the graph and as an orphan bubble in the chat
* stream.
*/
suppressAssistantText?: boolean;
isStreaming?: boolean;
streamingTools?: Array<{
id?: string;
toolCallId?: string;
name: string;
status: 'running' | 'completed' | 'error';
durationMs?: number;
summary?: string;
}>;
* Optional callback invoked when a non-image file card is clicked.
* When provided, the file opens in the in-app preview panel instead of
* the system default editor.
*/
onOpenFile?: (file: AttachedFileMeta) => void;
}
interface ExtractedImage { url?: string; data?: string; mimeType: string; }
const DIRECTORY_MIME_TYPE = 'application/x-directory';
function isChatPreviewDocument(file: AttachedFileMeta): boolean {
const name = file.fileName.toLowerCase();
const mime = file.mimeType.toLowerCase();
return (
mime === 'application/pdf'
|| mime === 'application/vnd.ms-excel'
|| mime === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|| name.endsWith('.pdf')
|| name.endsWith('.xls')
|| name.endsWith('.xlsx')
);
}
function isDirectoryAttachment(file: AttachedFileMeta): boolean {
return file.mimeType === DIRECTORY_MIME_TYPE;
}
function isSkillFileAttachment(file: AttachedFileMeta): boolean {
const path = file.filePath ?? '';
return (
/(?:^|[\\/])\.openclaw[\\/]skills[\\/][^\\/]+[\\/].+\.[A-Za-z0-9]+$/i.test(path)
|| /(?:^|[\\/])skills[\\/][^\\/]+[\\/]SKILL\.md$/i.test(path)
);
}
function isHtmlOrMarkdownPreview(file: AttachedFileMeta): boolean {
const name = file.fileName.toLowerCase();
const mime = file.mimeType.toLowerCase();
return (
mime === 'text/html'
|| mime === 'text/markdown'
|| name.endsWith('.html')
|| name.endsWith('.htm')
|| name.endsWith('.md')
|| name.endsWith('.markdown')
);
}
function isUserFacingAttachmentWhenFolded(file: AttachedFileMeta): boolean {
if (file.mimeType.startsWith('image/')) return true;
if (isDirectoryAttachment(file)) return true;
if (isSkillFileAttachment(file)) return true;
if (isChatPreviewDocument(file)) return true;
if (file.source === 'message-ref' && isHtmlOrMarkdownPreview(file)) return true;
return false;
}
function validationKindForAttachment(file: AttachedFileMeta): 'file' | 'dir' | null {
if (!file.filePath) return null;
if (file.source !== 'message-ref' && file.source !== 'tool-result') return null;
if (file.fileSize > 0 || file.preview) return null;
return isDirectoryAttachment(file) ? 'dir' : 'file';
}
function previewMimeFromPath(filePath: string): string | null {
const lower = filePath.toLowerCase();
if (lower.endsWith('.md') || lower.endsWith('.markdown')) return 'text/markdown';
if (lower.endsWith('.html') || lower.endsWith('.htm')) return 'text/html';
if (lower.endsWith('.pdf')) return 'application/pdf';
if (lower.endsWith('.xls')) return 'application/vnd.ms-excel';
if (lower.endsWith('.xlsx')) return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
return null;
}
function fileNameFromPath(filePath: string): string {
return filePath.split(/[\\/]/).pop() || 'file';
}
function trimPathTerminators(filePath: string): string {
return filePath.replace(/[,。;;,.!?]+$/u, '');
}
function extractPreviewDocumentPaths(text: string): AttachedFileMeta[] {
if (!text) return [];
const refs: AttachedFileMeta[] = [];
const seen = new Set<string>();
const pushRef = (filePath: string, mimeType: string) => {
const normalizedPath = trimPathTerminators(filePath);
if (!normalizedPath || seen.has(normalizedPath)) return;
seen.add(normalizedPath);
refs.push({
fileName: fileNameFromPath(normalizedPath),
mimeType,
fileSize: 0,
preview: null,
filePath: normalizedPath,
source: 'message-ref',
});
};
const exts = 'html?|md|markdown|pdf|xlsx?|HTML?|MD|MARKDOWN|PDF|XLSX?';
const taggedRegex = new RegExp(`(?:^|[\\s(\\[{>])(?:MEDIA|media):((?:\\/|~\\/)[^\\s\\n"'()\\[\\],<>]*?\\.(?:${exts}))`, 'g');
const unixRegex = new RegExp('(?<![\\w./:])((?:\\/|~\\/)[^\\s\\n"\'`()\\[\\],<>]*?\\.(?:' + exts + '))', 'g');
const skillPathBoundary = '(?=$|\\s|[\\x5b\\x5d"\'`(),<>,。;;,.!?])';
const skillPathPart = '[^\\\\/\\s\\n"\'`()\\x5b\\x5d,<>]+';
const skillPathTail = '[^\\s\\n"\'`()\\x5b\\x5d,<>]*?';
const skillDirRegex = new RegExp(
`(?<![\\w./:])((?:~[\\\\/]\\.openclaw[\\\\/]skills[\\\\/]${skillPathPart})|(?:(?:\\/|[A-Za-z]:\\\\)${skillPathTail}[\\\\/]\\.openclaw[\\\\/]skills[\\\\/]${skillPathPart}))${skillPathBoundary}`,
'gi',
);
const skillMarkdownRegex = new RegExp(
`(?<![\\w./:])((?:~[\\\\/]\\.openclaw[\\\\/]skills[\\\\/]${skillPathTail}\\.md)|(?:(?:\\/|[A-Za-z]:\\\\)${skillPathTail}[\\\\/]\\.openclaw[\\\\/]skills[\\\\/]${skillPathTail}\\.md))${skillPathBoundary}`,
'gi',
);
let workingText = text;
let taggedMatch: RegExpExecArray | null;
while ((taggedMatch = taggedRegex.exec(text)) !== null) {
const filePath = taggedMatch[1];
const mimeType = previewMimeFromPath(filePath);
if (mimeType) pushRef(filePath, mimeType);
const start = taggedMatch.index;
const end = start + taggedMatch[0].length;
workingText = workingText.slice(0, start) + ' '.repeat(end - start) + workingText.slice(end);
}
for (const regex of [unixRegex, skillMarkdownRegex, skillDirRegex]) {
let match: RegExpExecArray | null;
while ((match = regex.exec(workingText)) !== null) {
const filePath = match[1];
const mimeType = regex === skillDirRegex ? DIRECTORY_MIME_TYPE : previewMimeFromPath(filePath);
if (mimeType) pushRef(filePath, mimeType);
}
}
return refs;
}
* Normalize LaTeX delimiters so `remark-math` can detect them.
*
* Many LLMs emit LaTeX using `\(` / `\)` for inline math and `\[` / `\]`
* for block math (OpenAI style), which are NOT recognized by remark-math.
* remark-math only parses `$...$` and `$$...$$`.
*
* We convert the backslash-paren/bracket forms to dollar-sign forms so the
* math is rendered regardless of which convention the model uses.
*
* Transformations are skipped inside fenced/inline code spans to avoid
* clobbering code samples that legitimately contain `\(` etc.
*/
function normalizeLatexDelimiters(input: string): string {
if (!input || (input.indexOf('\\(') === -1 && input.indexOf('\\[') === -1)) {
return input;
}
const parts = input.split(/(```[\s\S]*?```|`[^`\n]*`)/g);
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (!part) continue;
if (part.startsWith('```') || part.startsWith('`')) continue;
let next = part.replace(/\\\[([\s\S]+?)\\\]/g, (_m, body: string) => `\n$$\n${body.trim()}\n$$\n`);
next = next.replace(/\\\(([\s\S]+?)\\\)/g, (_m, body: string) => `$${body}$`);
parts[i] = next;
}
return parts.join('');
}
function imageSrc(img: ExtractedImage): string | null {
if (img.url) return img.url;
if (img.data) return `data:${img.mimeType};base64,${img.data}`;
return null;
}
export const ChatMessage = memo(function ChatMessage({
message,
textOverride,
suppressToolCards = false,
suppressProcessAttachments = false,
suppressAssistantText = false,
isStreaming = false,
streamingTools = [],
onOpenFile,
}: ChatMessageProps) {
const isUser = message.role === 'user';
const role = typeof message.role === 'string' ? message.role.toLowerCase() : '';
const isToolResult = role === 'toolresult' || role === 'tool_result';
const text = textOverride ?? extractText(message);
const hideAssistantText = suppressAssistantText && !isUser;
const hasText = !hideAssistantText && text.trim().length > 0;
const images = extractImages(message);
const tools = extractToolUse(message);
const visibleTools = suppressToolCards ? [] : tools;
const [validatedPaths, setValidatedPaths] = useState<Record<string, boolean>>({});
const rawAttachedFiles = message._attachedFiles || [];
const textPreviewFiles = isUser ? [] : extractPreviewDocumentPaths(text);
const rawAttachedPaths = new Set(rawAttachedFiles.map((file) => file.filePath).filter(Boolean));
const derivedAttachedFiles = [
...rawAttachedFiles,
...textPreviewFiles.filter((file) => !file.filePath || !rawAttachedPaths.has(file.filePath)),
];
const validationTargets = derivedAttachedFiles
.map((file) => {
const kind = validationKindForAttachment(file);
return kind && file.filePath ? { filePath: file.filePath, kind } : null;
})
.filter((target): target is { filePath: string; kind: 'file' | 'dir' } => !!target);
const validationKey = validationTargets
.map((target) => `${target.kind}:${target.filePath}`)
.sort()
.join('\n');
useEffect(() => {
if (!validationKey) return;
const pendingTargets = validationTargets.filter((target) => validatedPaths[target.filePath] === undefined);
if (pendingTargets.length === 0) return;
let cancelled = false;
void Promise.all(
pendingTargets.map(async (target) => {
try {
const stat = await statFile(target.filePath);
return {
filePath: target.filePath,
exists: !!stat.ok && (target.kind === 'dir' ? !!stat.isDir : !!stat.isFile),
};
} catch {
return { filePath: target.filePath, exists: false };
}
}),
).then((results) => {
if (cancelled) return;
setValidatedPaths((current) => {
const next = { ...current };
for (const result of results) next[result.filePath] = result.exists;
return next;
});
});
return () => {
cancelled = true;
};
}, [validationKey, validationTargets, validatedPaths]);
const existingDerivedAttachedFiles = derivedAttachedFiles.filter((file) => {
const kind = validationKindForAttachment(file);
if (!kind || !file.filePath) return true;
return validatedPaths[file.filePath] === true;
});
const filteredProcessAttachments = derivedAttachedFiles.filter((file) => {
if (file.source !== 'tool-result' && file.source !== 'message-ref') return true;
return isUserFacingAttachmentWhenFolded(file);
});
const processVisibleAttachments = filteredProcessAttachments.filter((file) => {
const kind = validationKindForAttachment(file);
if (!kind || !file.filePath) return true;
return validatedPaths[file.filePath] === true;
});
const attachedFiles = suppressProcessAttachments && (hasText || images.length > 0 || visibleTools.length > 0)
? processVisibleAttachments
: existingDerivedAttachedFiles;
const [lightboxImg, setLightboxImg] = useState<{ src: string; fileName: string; filePath?: string; base64?: string; mimeType?: string } | null>(null);
if (isToolResult) return null;
const hasStreamingToolStatus = isStreaming && streamingTools.length > 0;
if (!hasText && images.length === 0 && visibleTools.length === 0 && attachedFiles.length === 0 && !hasStreamingToolStatus) return null;
return (
<div
className={cn(
'flex gap-3 group',
isUser ? 'flex-row-reverse' : 'flex-row',
)}
>
{/* Avatar */}
{!isUser && (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full mt-1 bg-black/5 dark:bg-white/5 text-foreground">
<Sparkles className="h-4 w-4" />
</div>
)}
{/* Content */}
<div
className={cn(
'flex flex-col w-full min-w-0 max-w-[80%] space-y-2',
isUser ? 'items-end' : 'items-start',
)}
>
{isStreaming && !isUser && streamingTools.length > 0 && (
<ToolStatusBar tools={streamingTools} />
)}
{/* Images — rendered ABOVE text bubble for user messages */}
{/* Images from content blocks (Gateway session data / channel push photos) */}
{isUser && images.length > 0 && (
<div className="flex flex-wrap gap-2">
{images.map((img, i) => {
const src = imageSrc(img);
if (!src) return null;
return (
<ImageThumbnail
key={`content-${i}`}
src={src}
fileName="image"
base64={img.data}
mimeType={img.mimeType}
onPreview={() => setLightboxImg({ src, fileName: 'image', base64: img.data, mimeType: img.mimeType })}
/>
);
})}
</div>
)}
{/* File attachments — images above text for user, file cards below */}
{isUser && attachedFiles.length > 0 && (
<div className="flex flex-wrap gap-2">
{attachedFiles.map((file, i) => {
const isImage = file.mimeType.startsWith('image/');
// Skip image attachments if we already have images from content blocks
if (isImage && images.length > 0) return null;
if (isImage) {
return file.preview ? (
<ImageThumbnail
key={`local-${i}`}
src={file.preview}
fileName={file.fileName}
filePath={file.filePath}
mimeType={file.mimeType}
onPreview={() => setLightboxImg({ src: file.preview!, fileName: file.fileName, filePath: file.filePath, mimeType: file.mimeType })}
/>
) : (
<div
key={`local-${i}`}
className="w-36 h-36 rounded-xl border border-black/10 dark:border-white/10 bg-black/5 dark:bg-white/5 flex items-center justify-center text-muted-foreground"
>
<File className="h-8 w-8" />
</div>
);
}
// Non-image files → file card
return <FileCard key={`local-${i}`} file={file} onOpen={onOpenFile} />;
})}
</div>
)}
{/* Main text */}
{hasText && (
isUser ? (
<UserMessageBubble text={text} />
) : (
<AssistantMarkdown text={text} isStreaming={isStreaming} />
)
)}
{/* Images from content blocks — assistant messages (below text) */}
{!isUser && images.length > 0 && (
<div className="flex flex-wrap gap-2">
{images.map((img, i) => {
const src = imageSrc(img);
if (!src) return null;
return (
<ImagePreviewCard
key={`content-${i}`}
src={src}
fileName="image"
base64={img.data}
mimeType={img.mimeType}
onPreview={() => setLightboxImg({ src, fileName: 'image', base64: img.data, mimeType: img.mimeType })}
/>
);
})}
</div>
)}
{/* File attachments — assistant messages (below text) */}
{!isUser && attachedFiles.length > 0 && (
<div className="flex flex-wrap gap-2">
{attachedFiles.map((file, i) => {
const isImage = file.mimeType.startsWith('image/');
if (isImage && images.length > 0) return null;
if (isImage && file.preview) {
return (
<ImagePreviewCard
key={`local-${i}`}
src={file.preview}
fileName={file.fileName}
filePath={file.filePath}
mimeType={file.mimeType}
onPreview={() => setLightboxImg({ src: file.preview!, fileName: file.fileName, filePath: file.filePath, mimeType: file.mimeType })}
/>
);
}
if (isImage && !file.preview) {
return (
<div key={`local-${i}`} className="w-36 h-36 rounded-xl border border-black/10 dark:border-white/10 bg-black/5 dark:bg-white/5 flex items-center justify-center text-muted-foreground">
<File className="h-8 w-8" />
</div>
);
}
return <FileCard key={`local-${i}`} file={file} onOpen={onOpenFile} />;
})}
</div>
)}
{/* Hover row for user messages — timestamp only */}
{isUser && message.timestamp && (
<span className="text-xs text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity duration-200 select-none">
{formatTimestamp(message.timestamp)}
</span>
)}
{/* Hover row for assistant messages — only when there is real text content */}
{!isUser && hasText && (
<AssistantHoverBar text={text} timestamp={message.timestamp} />
)}
</div>
{/* Image lightbox portal */}
{lightboxImg && (
<ImageLightbox
src={lightboxImg.src}
fileName={lightboxImg.fileName}
filePath={lightboxImg.filePath}
base64={lightboxImg.base64}
mimeType={lightboxImg.mimeType}
onClose={() => setLightboxImg(null)}
/>
)}
</div>
);
});
function formatDuration(durationMs?: number): string | null {
if (!durationMs || !Number.isFinite(durationMs)) return null;
if (durationMs < 1000) return `${Math.round(durationMs)}ms`;
return `${(durationMs / 1000).toFixed(1)}s`;
}
function ToolStatusBar({
tools,
}: {
tools: Array<{
id?: string;
toolCallId?: string;
name: string;
status: 'running' | 'completed' | 'error';
durationMs?: number;
summary?: string;
}>;
}) {
return (
<div className="w-full space-y-1">
{tools.map((tool) => {
const duration = formatDuration(tool.durationMs);
const isRunning = tool.status === 'running';
const isError = tool.status === 'error';
return (
<div
key={tool.toolCallId || tool.id || tool.name}
className={cn(
'flex items-center gap-2 rounded-lg border px-3 py-2 text-xs transition-colors',
isRunning && 'border-primary/30 bg-primary/5 text-foreground',
!isRunning && !isError && 'border-border/50 bg-muted/20 text-muted-foreground',
isError && 'border-destructive/30 bg-destructive/5 text-destructive',
)}
>
{isRunning && <Loader2 className="h-3.5 w-3.5 animate-spin text-primary shrink-0" />}
{!isRunning && !isError && <CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />}
{isError && <AlertCircle className="h-3.5 w-3.5 text-destructive shrink-0" />}
<Wrench className="h-3 w-3 shrink-0 opacity-60" />
<span className="font-mono text-xs font-medium">{tool.name}</span>
{duration && <span className="text-tiny opacity-60">{tool.summary ? `(${duration})` : duration}</span>}
{tool.summary && (
<span className="truncate text-tiny opacity-70">{tool.summary}</span>
)}
</div>
);
})}
</div>
);
}
function AssistantHoverBar({ text, timestamp }: { text: string; timestamp?: number }) {
const [copied, setCopied] = useState(false);
const copyContent = useCallback(() => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [text]);
return (
<div className="flex items-center justify-between w-full opacity-0 group-hover:opacity-100 transition-opacity duration-200 select-none px-1">
<span className="text-xs text-muted-foreground">
{timestamp ? formatTimestamp(timestamp) : ''}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={copyContent}
>
{copied ? <Check className="h-3 w-3 text-green-500" /> : <Copy className="h-3 w-3" />}
</Button>
</div>
);
}
function UserMessageBubble({
text,
}: {
text: string;
}) {
return (
<div className="relative rounded-2xl px-4 py-3 bg-brand text-white shadow-sm">
<p className="whitespace-pre-wrap break-words text-sm">{text}</p>
</div>
);
}
function AssistantMarkdown({
text,
isStreaming,
}: {
text: string;
isStreaming: boolean;
}) {
return (
<div className="prose prose-sm dark:prose-invert w-full max-w-none break-words text-foreground">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[[rehypeKatex, { strict: false, throwOnError: false, output: 'html' }]]}
components={{
code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '');
const isInline = !match && !className;
if (isInline) {
return (
<code className="bg-muted px-1.5 py-0.5 rounded text-sm font-mono break-words break-all" {...props}>
{children}
</code>
);
}
return (
<pre className="bg-muted rounded-lg p-4 overflow-x-auto">
<code className={cn('text-sm font-mono', className)} {...props}>
{children}
</code>
</pre>
);
},
a({ href, children }) {
return (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-words break-all">
{children}
</a>
);
},
}}
>
{normalizeLatexDelimiters(text)}
</ReactMarkdown>
{isStreaming && (
<span className="inline-block w-2 h-4 bg-foreground/50 animate-pulse ml-0.5" />
)}
</div>
);
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
function FileIcon({ mimeType, className }: { mimeType: string; className?: string }) {
if (mimeType === DIRECTORY_MIME_TYPE) return <FolderOpen className={className} />;
if (mimeType.startsWith('video/')) return <Film className={className} />;
if (mimeType.startsWith('audio/')) return <Music className={className} />;
if (mimeType.startsWith('text/') || mimeType === 'application/json' || mimeType === 'application/xml') return <FileText className={className} />;
if (mimeType.includes('zip') || mimeType.includes('compressed') || mimeType.includes('archive') || mimeType.includes('tar') || mimeType.includes('rar') || mimeType.includes('7z')) return <FileArchive className={className} />;
if (mimeType === 'application/pdf') return <FileText className={className} />;
return <File className={className} />;
}
function FileCard({ file, onOpen }: { file: AttachedFileMeta; onOpen?: (file: AttachedFileMeta) => void }) {
const handleOpen = useCallback(() => {
if (!file.filePath) return;
if (onOpen) {
onOpen(file);
} else {
invokeIpc('shell:openPath', file.filePath);
}
}, [file, onOpen]);
return (
<div
className={cn(
"flex items-center gap-3 rounded-xl border border-black/10 dark:border-white/10 px-3 py-2.5 bg-black/5 dark:bg-white/5 max-w-[220px]",
file.filePath && "cursor-pointer hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
)}
onClick={handleOpen}
title={file.filePath ? "Open file" : undefined}
>
<FileIcon mimeType={file.mimeType} className="h-5 w-5 shrink-0 text-muted-foreground" />
<div className="min-w-0 overflow-hidden">
<p className="text-xs font-medium truncate">{file.fileName}</p>
<p className="text-2xs text-muted-foreground">
{file.mimeType === DIRECTORY_MIME_TYPE ? '文件夹' : file.fileSize > 0 ? formatFileSize(file.fileSize) : 'File'}
</p>
</div>
</div>
);
}
function ImageThumbnail({
src,
fileName,
filePath,
base64,
mimeType,
onPreview,
}: {
src: string;
fileName: string;
filePath?: string;
base64?: string;
mimeType?: string;
onPreview: () => void;
}) {
void filePath; void base64; void mimeType;
return (
<div
className="relative w-36 h-36 rounded-xl border overflow-hidden border-black/10 dark:border-white/10 bg-black/5 dark:bg-white/5 group/img cursor-zoom-in"
onClick={onPreview}
>
<img src={src} alt={fileName} className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-black/0 group-hover/img:bg-black/25 transition-colors flex items-center justify-center">
<ZoomIn className="h-6 w-6 text-white opacity-0 group-hover/img:opacity-100 transition-opacity drop-shadow" />
</div>
</div>
);
}
function ImagePreviewCard({
src,
fileName,
filePath,
base64,
mimeType,
onPreview,
}: {
src: string;
fileName: string;
filePath?: string;
base64?: string;
mimeType?: string;
onPreview: () => void;
}) {
void filePath; void base64; void mimeType;
return (
<div
className="relative max-w-xs rounded-xl border overflow-hidden border-black/10 dark:border-white/10 bg-black/5 dark:bg-white/5 group/img cursor-zoom-in"
onClick={onPreview}
>
<img src={src} alt={fileName} className="block w-full" />
<div className="absolute inset-0 bg-black/0 group-hover/img:bg-black/20 transition-colors flex items-center justify-center">
<ZoomIn className="h-6 w-6 text-white opacity-0 group-hover/img:opacity-100 transition-opacity drop-shadow" />
</div>
</div>
);
}
function ImageLightbox({
src,
fileName,
filePath,
base64,
mimeType,
onClose,
}: {
src: string;
fileName: string;
filePath?: string;
base64?: string;
mimeType?: string;
onClose: () => void;
}) {
void src; void base64; void mimeType; void fileName;
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [onClose]);
const handleShowInFolder = useCallback(() => {
if (filePath) {
invokeIpc('shell:showItemInFolder', filePath);
}
}, [filePath]);
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
onClick={onClose}
>
{/* Image + buttons stacked */}
<div
className="flex flex-col items-center gap-3"
onClick={(e) => e.stopPropagation()}
>
<img
src={src}
alt={fileName}
className="max-w-[90vw] max-h-[85vh] rounded-lg shadow-2xl object-contain"
/>
{/* Action buttons below image */}
<div className="flex items-center gap-2">
{filePath && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 bg-white/10 hover:bg-white/20 text-white"
onClick={handleShowInFolder}
title="在文件夹中显示"
>
<FolderOpen className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 bg-white/10 hover:bg-white/20 text-white"
onClick={onClose}
title="关闭"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</div>,
document.body,
);
}