Files
makelore/src/pages/Chat/ChatMessage.tsx
inman 80e8386fa6
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
feat: update Makelore modules and conversations
2026-07-31 10:08:41 +08:00

949 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Chat Message Component
* Renders user / assistant / system / toolresult messages
* with markdown, images, and tool cards. Thinking output is
* surfaced via ExecutionGraphCard, not inside message bubbles.
*/
import { useState, useCallback, useEffect, memo } from 'react';
import { Sparkles, Copy, Check, ChevronDown, ChevronRight, Wrench, FileText, Music, FileArchive, File, X, FolderOpen, ZoomIn, Loader2, CheckCircle2, AlertCircle, Clock3 } 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 '@/types/chat';
import { extractText, extractImages, extractToolUse, formatTimestamp } from './message-utils';
import { getToolVisibilityId } from './chat-transcript';
interface ChatMessageProps {
message: RawMessage;
messageKey?: string;
showTimestamps?: boolean;
expandedToolIds?: ReadonlySet<string>;
onToolExpandedChange?: (toolId: string, expanded: boolean) => void;
assistantAvatarSrc?: string;
assistantAvatarAlt?: string;
/**
* Keep the assistant column aligned without painting a second avatar when
* the message belongs to an execution trace that already owns the avatar.
*/
assistantAvatarMode?: 'visible' | 'reserved' | 'reserved-trace';
/** Reduce the top whitespace when the reply follows its own execution trace. */
compactAssistantReply?: boolean;
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';
const EMPTY_TOOL_IDS: ReadonlySet<string> = new Set<string>();
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 validationKindForAttachment(file: AttachedFileMeta): 'file' | 'dir' | null {
if (!file.filePath) return null;
// User-selected uploads and already enriched attachments are trusted enough
// for immediate display. Regex-derived message refs start at size 0/null and
// are validated through main-process stat before becoming clickable cards.
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 attachmentIdentity(file: AttachedFileMeta): string {
return file.filePath?.trim()
|| file.runtimeUrl?.trim()
|| `${file.mimeType}:${file.fileName}:${file.fileSize}`;
}
function normalizeRenderableAttachments(files: AttachedFileMeta[]): AttachedFileMeta[] {
const normalized: AttachedFileMeta[] = [];
const indexes = new Map<string, number>();
for (const file of files) {
const key = attachmentIdentity(file);
const existingIndex = indexes.get(key);
if (existingIndex === undefined) {
indexes.set(key, normalized.length);
normalized.push(file);
continue;
}
const existing = normalized[existingIndex];
if (!existing.preview && file.preview) {
normalized[existingIndex] = file;
}
}
// Images need actual preview bytes/URLs to be meaningful in the transcript.
// A metadata-only image record otherwise becomes a misleading blank file tile.
return normalized.filter((file) => !file.mimeType.startsWith('image/') || Boolean(file.preview));
}
function previewMimeFromPath(filePath: string): string | null {
const lower = filePath.toLowerCase();
if (lower.endsWith('.md') || lower.endsWith('.markdown')) return 'text/markdown';
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',
});
};
// Deliberately narrow this render-layer fallback to user-facing artifacts.
const exts = 'pdf|xlsx?|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./:])((?:~[\\\\/]\\.niancode[\\\\/]skills[\\\\/]${skillPathPart})|(?:(?:\\/|[A-Za-z]:\\\\)${skillPathTail}[\\\\/]\\.niancode[\\\\/]skills[\\\\/]${skillPathPart}))${skillPathBoundary}`,
'gi',
);
const skillMarkdownRegex = new RegExp(
`(?<![\\w./:])((?:~[\\\\/]\\.niancode[\\\\/]skills[\\\\/]${skillPathTail}\\.md)|(?:(?:\\/|[A-Za-z]:\\\\)${skillPathTail}[\\\\/]\\.niancode[\\\\/]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('');
}
/** Resolve an ExtractedImage to a displayable src string, or null if not possible. */
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,
messageKey,
showTimestamps = true,
expandedToolIds = EMPTY_TOOL_IDS,
onToolExpandedChange,
assistantAvatarSrc,
assistantAvatarAlt = '',
assistantAvatarMode = 'visible',
compactAssistantReply = false,
textOverride,
suppressToolCards = false,
suppressProcessAttachments = false,
suppressAssistantText = false,
isStreaming = false,
streamingTools = [],
onOpenFile,
}: ChatMessageProps) {
const isUser = message.role === 'user';
const deliveryStatus = isUser ? message._deliveryStatus : undefined;
const role = typeof message.role === 'string' ? message.role.toLowerCase() : '';
const isToolResult = role === 'toolresult' || role === 'tool_result';
const shouldRenderAssistantAvatar = !isUser && assistantAvatarMode === 'visible';
const shouldReserveAssistantAvatar = !isUser
&& (assistantAvatarMode === 'reserved' || assistantAvatarMode === 'reserved-trace');
const shouldAlignTraceContent = !isUser && assistantAvatarMode === 'reserved-trace';
const text = textOverride ?? extractText(message);
// When text is folded into an ExecutionGraphCard, treat the message as
// having no text for rendering purposes. Keeping this behind a flag (vs
// blanking `text` outright) lets future hover affordances still read the
// original content without surfacing the bubble.
const hideAssistantText = suppressAssistantText && !isUser;
const hasText = !hideAssistantText && text.trim().length > 0;
const images = extractImages(message);
const tools = extractToolUse(message);
const visibleTools = suppressToolCards ? [] : tools;
const stableMessageKey = messageKey?.trim()
? messageKey
: (message.id?.trim() ? message.id : undefined);
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;
// Runtime-produced user-facing artifacts (images, PDFs, spreadsheets,
// skill directories, ...) must remain visible in the reply bubble even
// when generic process attachments are folded into the execution graph.
// The graph card itself does not render `_attachedFiles`, so dropping
// images here would leave the user with no way to see them at all.
if (file.mimeType.startsWith('image/')) return true;
return isChatPreviewDocument(file) || isDirectoryAttachment(file);
});
// When a message is attachment-only, keep those attachments visible even if
// process attachments are generally suppressed for this run segment —
// otherwise the reply disappears entirely.
const processVisibleAttachments = filteredProcessAttachments.filter((file) => {
const kind = validationKindForAttachment(file);
if (!kind || !file.filePath) return true;
return validatedPaths[file.filePath] === true;
});
const attachedFiles = normalizeRenderableAttachments(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);
// Never render tool result messages in chat UI
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 group',
isUser ? 'flex-row-reverse gap-0' : 'flex-row gap-3',
)}
>
{/* Avatar */}
{(shouldRenderAssistantAvatar || shouldReserveAssistantAvatar) && (
<div
data-testid={shouldRenderAssistantAvatar ? 'chat-message-avatar-assistant' : 'chat-message-avatar-reserved'}
aria-hidden={shouldReserveAssistantAvatar || undefined}
className={cn(
'mt-1 h-8 w-8 shrink-0',
shouldRenderAssistantAvatar
&& 'flex items-center justify-center overflow-hidden rounded-md border border-foreground/15 bg-surface-tertiary text-foreground shadow-soft',
)}
>
{shouldRenderAssistantAvatar && (
assistantAvatarSrc ? (
<img src={assistantAvatarSrc} alt={assistantAvatarAlt} className="h-full w-full object-cover [image-rendering:pixelated]" />
) : (
<Sparkles className="h-4 w-4" />
)
)}
</div>
)}
{/* Content */}
<div
className={cn(
'flex w-full min-w-0 flex-col space-y-2',
isUser ? 'max-w-[78%] items-end' : 'max-w-none flex-1 items-start',
shouldAlignTraceContent && 'pl-9',
)}
>
{isStreaming && !isUser && streamingTools.length > 0 && (
<ToolStatusBar tools={streamingTools} />
)}
{/* Tool use cards */}
{visibleTools.length > 0 && (
<div className="space-y-1">
{visibleTools.map((tool, i) => (
<ToolCard
key={getToolVisibilityId(stableMessageKey, tool.id, i)}
id={getToolVisibilityId(stableMessageKey, tool.id, i)}
name={tool.name}
input={tool.input}
expanded={expandedToolIds.has(
getToolVisibilityId(stableMessageKey, tool.id, i),
)}
onExpandedChange={onToolExpandedChange}
/>
))}
</div>
)}
{/* Images — rendered ABOVE text bubble for user messages */}
{/* Images from content blocks */}
{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 && file.preview) {
return (
<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 })}
/>
);
}
// Non-image files → file card
return <FileCard key={`local-${i}`} file={file} onOpen={onOpenFile} />;
})}
</div>
)}
{/* Main text bubble */}
{hasText && (
<MessageBubble
text={text}
isUser={isUser}
isStreaming={isStreaming}
deliveryStatus={deliveryStatus}
compactAssistantReply={compactAssistantReply}
/>
)}
{/* 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 })}
/>
);
}
return <FileCard key={`local-${i}`} file={file} onOpen={onOpenFile} />;
})}
</div>
)}
{/* Hover row for user messages — timestamp only */}
{isUser && showTimestamps && 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={showTimestamps ? message.timestamp : undefined}
/>
)}
</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>
);
}
// ── Assistant hover bar (timestamp + copy, shown on group hover) ─
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>
);
}
// ── Message Bubble ──────────────────────────────────────────────
function MessageBubble({
text,
isUser,
isStreaming,
deliveryStatus,
compactAssistantReply,
}: {
text: string;
isUser: boolean;
isStreaming: boolean;
deliveryStatus?: RawMessage['_deliveryStatus'];
compactAssistantReply: boolean;
}) {
const deliveryLabel = deliveryStatus === 'queued'
? 'Queued'
: deliveryStatus === 'sending'
? 'Sending'
: null;
return (
<div
data-testid={isUser ? 'chat-message-bubble-user' : 'chat-message-bubble-assistant'}
className={cn(
'relative rounded-2xl border border-foreground/15 px-4 pb-3 shadow-soft',
!isUser && 'w-full',
isUser || !compactAssistantReply ? 'pt-3' : 'pt-1',
isUser
? 'chat-user-message-surface bg-brand-soft text-foreground'
: 'chat-assistant-message-surface bg-background text-foreground',
)}
>
{isUser ? (
<>
{deliveryLabel && (
<div
className="mb-2 flex items-center justify-end gap-1.5 text-[11px] font-medium leading-none text-foreground/70"
data-testid={`message-delivery-${deliveryStatus}`}
>
{deliveryStatus === 'sending' ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Clock3 className="h-3 w-3" />
)}
<span>{deliveryLabel}</span>
</div>
)}
<p className="whitespace-pre-wrap break-words break-all text-sm">{text}</p>
</>
) : (
<div className="prose prose-sm max-w-none break-words break-all 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-background/50 px-1.5 py-0.5 rounded text-sm font-mono break-words break-all" {...props}>
{children}
</code>
);
}
return (
<pre className="bg-background/50 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>
)}
</div>
);
}
// ── File Card (for user-uploaded non-image files) ───────────────
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('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-border/70 px-3 py-2.5 bg-surface-subtle max-w-[220px]",
file.filePath && "cursor-pointer hover:bg-surface-tertiary 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 ? 'Folder' : file.fileSize > 0 ? formatFileSize(file.fileSize) : 'File'}
</p>
</div>
</div>
);
}
// ── Image Thumbnail (user bubble — square crop with zoom hint) ──
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-border/70 bg-surface-subtle 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>
);
}
// ── Image Preview Card (assistant bubble — natural size with overlay actions) ──
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-border/70 bg-surface-subtle 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>
);
}
// ── Image Lightbox ───────────────────────────────────────────────
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-foreground/65 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,
);
}
// ── Tool Card ───────────────────────────────────────────────────
interface ToolCardProps {
id: string;
name: string;
input: unknown;
expanded: boolean;
onExpandedChange?: (id: string, expanded: boolean) => void;
}
function ToolCard({
id,
name,
input,
expanded,
onExpandedChange,
}: ToolCardProps) {
return (
<div className="rounded-xl border border-border/70 bg-surface-subtle text-sm">
<button
type="button"
aria-expanded={expanded}
aria-label={`工具 ${name}`}
className="flex items-center gap-2 w-full px-3 py-1.5 text-muted-foreground hover:text-foreground transition-colors"
onClick={() => onExpandedChange?.(id, !expanded)}
>
<CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />
<Wrench className="h-3 w-3 shrink-0 opacity-60" />
<span className="font-mono text-xs">{name}</span>
{expanded
? <ChevronDown className="h-3 w-3 ml-auto" />
: <ChevronRight className="h-3 w-3 ml-auto" />}
</button>
{expanded && input != null && (
<pre className="px-3 pb-2 text-xs text-muted-foreground overflow-x-auto">
{typeof input === 'string' ? input : JSON.stringify(input, null, 2)}
</pre>
)}
</div>
);
}