Files
simstudioai--sim/apps/sim/app/(interfaces)/chat/components/message/message.tsx
T
wehub-resource-sync d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

283 lines
12 KiB
TypeScript

'use client'
import { memo, useState } from 'react'
import { Button, cn, Duplicate, Tooltip } from '@sim/emcn'
import { Check, File as FileIcon, FileText, Image as ImageIcon } from 'lucide-react'
import {
ChatFileDownload,
ChatFileDownloadAll,
} from '@/app/(interfaces)/chat/components/message/components/file-download'
import MarkdownRenderer from '@/app/(interfaces)/chat/components/message/components/markdown-renderer'
export interface ChatAttachment {
id: string
name: string
type: string
dataUrl: string
size?: number
}
export interface ChatFile {
id: string
name: string
url: string
key: string
size: number
type: string
context?: string
}
export interface ChatMessage {
id: string
content: string | Record<string, unknown>
type: 'user' | 'assistant'
timestamp: Date
isInitialMessage?: boolean
isStreaming?: boolean
attachments?: ChatAttachment[]
files?: ChatFile[]
}
const HTML_ESCAPES: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
} as const
/**
* Escapes HTML entities so untrusted strings are safe to interpolate into markup.
*/
export function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (c) => HTML_ESCAPES[c] || c)
}
/**
* Opens an image attachment preview in a new tab via a blob URL,
* escaping the user-controlled filename and data URL to prevent XSS.
*/
function openAttachmentPreview(name: string, dataUrl: string): void {
const safeName = escapeHtml(name)
const safeUrl = escapeHtml(dataUrl)
const html = `
<!DOCTYPE html>
<html>
<head>
<title>${safeName}</title>
<style>
body { margin: 0; display: flex; justify-content: center; align-items: center; min-height: 100vh; background: #000; }
img { max-width: 100%; max-height: 100vh; object-fit: contain; }
</style>
</head>
<body>
<img src="${safeUrl}" alt="${safeName}" />
</body>
</html>
`
const blob = new Blob([html], { type: 'text/html' })
const blobUrl = URL.createObjectURL(blob)
window.open(blobUrl, '_blank', 'noopener,noreferrer')
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000)
}
export const ClientChatMessage = memo(
function ClientChatMessage({ message }: { message: ChatMessage }) {
const [isCopied, setIsCopied] = useState(false)
const isJsonObject = typeof message.content === 'object' && message.content !== null
// Since tool calls are now handled via SSE events and stored in message.toolCalls,
// we can use the content directly without parsing
const cleanTextContent = message.content
const content =
message.type === 'user' ? (
<div className='px-4 py-5' data-message-id={message.id}>
<div className='mx-auto max-w-3xl'>
{/* File attachments displayed above the message */}
{message.attachments && message.attachments.length > 0 && (
<div className='mb-2 flex justify-end'>
<div className='flex flex-wrap gap-2'>
{message.attachments.map((attachment) => {
const isImage = attachment.type.startsWith('image/')
const getFileIcon = (type: string) => {
if (type.includes('pdf'))
return <FileText className='size-5 text-[var(--text-muted)] md:size-6' />
if (type.startsWith('image/'))
return <ImageIcon className='size-5 text-[var(--text-muted)] md:size-6' />
if (type.includes('text') || type.includes('json'))
return <FileText className='size-5 text-[var(--text-muted)] md:size-6' />
return <FileIcon className='size-5 text-[var(--text-muted)] md:size-6' />
}
const formatFileSize = (bytes?: number) => {
if (!bytes || bytes === 0) return ''
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${Math.round((bytes / k ** i) * 10) / 10} ${sizes[i]}`
}
const isInteractive =
!!attachment.dataUrl?.trim() && attachment.dataUrl.startsWith('data:')
const handleOpenPreview = () => {
const validDataUrl = attachment.dataUrl?.trim()
if (!validDataUrl?.startsWith('data:')) return
openAttachmentPreview(attachment.name, validDataUrl)
}
return (
<div
key={attachment.id}
role={isInteractive ? 'button' : undefined}
aria-disabled={!isInteractive}
tabIndex={isInteractive ? 0 : undefined}
className={cn(
'relative overflow-hidden rounded-2xl border border-[var(--border-1)] bg-[var(--surface-2)]',
isInteractive && 'cursor-pointer',
isImage
? 'size-16 md:size-20'
: 'flex h-16 min-w-[140px] max-w-[220px] items-center gap-2 px-3 md:h-20 md:min-w-[160px] md:max-w-[240px]'
)}
onClick={(e) => {
if (!isInteractive) return
e.preventDefault()
e.stopPropagation()
handleOpenPreview()
}}
onKeyDown={(e) => {
if (!isInteractive) return
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
handleOpenPreview()
}
}}
>
{isImage &&
attachment.dataUrl?.trim() &&
attachment.dataUrl.startsWith('data:') ? (
<img
src={attachment.dataUrl}
alt={attachment.name}
className='size-full object-cover'
/>
) : (
<>
<div className='flex size-10 flex-shrink-0 items-center justify-center rounded bg-[var(--surface-3)] md:size-12'>
{getFileIcon(attachment.type)}
</div>
<div className='min-w-0 flex-1'>
<div className='truncate font-medium text-[var(--text-primary)] text-xs md:text-sm'>
{attachment.name}
</div>
{attachment.size && (
<div className='text-[var(--text-muted)] text-micro md:text-xs'>
{formatFileSize(attachment.size)}
</div>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)}
{/* Only render message bubble if there's actual text content (not just file count message) */}
{message.content && !String(message.content).startsWith('Sent') && (
<div className='flex justify-end'>
<div className='max-w-[80%] rounded-3xl bg-[var(--surface-3)] px-4 py-3'>
<div className='whitespace-pre-wrap break-words text-[var(--text-primary)] text-base leading-relaxed'>
{isJsonObject ? (
<pre>{JSON.stringify(message.content, null, 2)}</pre>
) : (
<span>{message.content as string}</span>
)}
</div>
</div>
</div>
)}
</div>
</div>
) : (
<div className='px-4 pt-5 pb-2' data-message-id={message.id}>
<div className='mx-auto max-w-3xl'>
<div className='flex flex-col space-y-3'>
{/* Direct content rendering - tool calls are now handled via SSE events */}
<div>
<div className='break-words text-base'>
{isJsonObject ? (
<pre className='text-[var(--text-primary)]'>
{JSON.stringify(cleanTextContent, null, 2)}
</pre>
) : (
<MarkdownRenderer content={cleanTextContent as string} />
)}
</div>
</div>
{message.files && message.files.length > 0 && (
<div className='flex flex-wrap gap-2'>
{message.files.map((file) => (
<ChatFileDownload key={file.id} file={file} />
))}
</div>
)}
{message.type === 'assistant' && !isJsonObject && !message.isInitialMessage && (
<div className='flex items-center justify-start space-x-2'>
{/* Copy Button - Only show when not streaming */}
{!message.isStreaming && (
<Tooltip.Root delayDuration={300}>
<Tooltip.Trigger asChild>
<Button
variant='ghost-secondary'
className='p-0'
onClick={() => {
const contentToCopy =
typeof cleanTextContent === 'string'
? cleanTextContent
: JSON.stringify(cleanTextContent, null, 2)
navigator.clipboard.writeText(contentToCopy)
setIsCopied(true)
setTimeout(() => setIsCopied(false), 2000)
}}
>
{isCopied ? (
<Check className='size-3' strokeWidth={2} />
) : (
<Duplicate className='size-3' />
)}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top' align='center' sideOffset={5}>
{isCopied ? 'Copied!' : 'Copy to clipboard'}
</Tooltip.Content>
</Tooltip.Root>
)}
{/* Download All Button - Only show when there are files */}
{!message.isStreaming && message.files && (
<ChatFileDownloadAll files={message.files} />
)}
</div>
)}
</div>
</div>
</div>
)
return <Tooltip.Provider>{content}</Tooltip.Provider>
},
(prevProps, nextProps) => {
return (
prevProps.message.id === nextProps.message.id &&
prevProps.message.content === nextProps.message.content &&
prevProps.message.isStreaming === nextProps.message.isStreaming &&
prevProps.message.isInitialMessage === nextProps.message.isInitialMessage &&
prevProps.message.files?.length === nextProps.message.files?.length
)
}
)