949 lines
35 KiB
TypeScript
949 lines
35 KiB
TypeScript
/**
|
||
* 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>
|
||
);
|
||
}
|