chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button, cn, Download, Loader } from '@sim/emcn'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { sleep } from '@sim/utils/helpers'
|
||||
import { Music } from 'lucide-react'
|
||||
import { DefaultFileIcon, getDocumentIcon } from '@/components/icons/document-icons'
|
||||
import { isSafeHttpUrl } from '@/lib/core/utils/urls'
|
||||
import type { ChatFile } from '@/app/(interfaces)/chat/components/message/message'
|
||||
|
||||
const logger = createLogger('ChatFileDownload')
|
||||
|
||||
interface ChatFileDownloadProps {
|
||||
file: ChatFile
|
||||
}
|
||||
|
||||
interface ChatFileDownloadAllProps {
|
||||
files: ChatFile[]
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
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]}`
|
||||
}
|
||||
|
||||
function isAudioFile(mimeType: string, filename: string): boolean {
|
||||
const audioMimeTypes = [
|
||||
'audio/mpeg',
|
||||
'audio/wav',
|
||||
'audio/mp3',
|
||||
'audio/ogg',
|
||||
'audio/webm',
|
||||
'audio/aac',
|
||||
'audio/flac',
|
||||
]
|
||||
const audioExtensions = ['mp3', 'wav', 'ogg', 'webm', 'aac', 'flac', 'm4a']
|
||||
const extension = filename.split('.').pop()?.toLowerCase()
|
||||
|
||||
return (
|
||||
audioMimeTypes.some((t) => mimeType.includes(t)) ||
|
||||
(extension ? audioExtensions.includes(extension) : false)
|
||||
)
|
||||
}
|
||||
|
||||
function isImageFile(mimeType: string): boolean {
|
||||
return mimeType.startsWith('image/')
|
||||
}
|
||||
|
||||
function getFileUrl(file: ChatFile): string {
|
||||
return `/api/files/serve/${encodeURIComponent(file.key)}?context=${file.context || 'execution'}`
|
||||
}
|
||||
|
||||
async function triggerDownload(url: string, filename: string): Promise<void> {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch file: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
|
||||
const link = document.createElement('a')
|
||||
link.href = blobUrl
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
logger.info(`Downloaded: ${filename}`)
|
||||
}
|
||||
|
||||
export function ChatFileDownload({ file }: ChatFileDownloadProps) {
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (isDownloading) return
|
||||
|
||||
setIsDownloading(true)
|
||||
|
||||
try {
|
||||
logger.info(`Initiating download for file: ${file.name}`)
|
||||
const url = getFileUrl(file)
|
||||
await triggerDownload(url, file.name)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to download file ${file.name}:`, error)
|
||||
if (file.url && isSafeHttpUrl(file.url)) {
|
||||
window.open(file.url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
} finally {
|
||||
setIsDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const renderIcon = () => {
|
||||
if (isAudioFile(file.type, file.name)) {
|
||||
return <Music className='size-4 text-purple-500' />
|
||||
}
|
||||
if (isImageFile(file.type)) {
|
||||
const ImageIcon = DefaultFileIcon
|
||||
return <ImageIcon className='size-5' />
|
||||
}
|
||||
const DocumentIcon = getDocumentIcon(file.type, file.name)
|
||||
return <DocumentIcon className='size-5' />
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={handleDownload}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
disabled={isDownloading}
|
||||
className='flex h-auto w-[200px] items-center gap-2 rounded-lg px-3 py-2'
|
||||
>
|
||||
<div className='flex size-8 flex-shrink-0 items-center justify-center'>{renderIcon()}</div>
|
||||
<div className='min-w-0 flex-1 text-left'>
|
||||
<div className='w-[100px] truncate text-xs'>{file.name}</div>
|
||||
<div className='text-[var(--text-muted)] text-micro'>{formatFileSize(file.size)}</div>
|
||||
</div>
|
||||
<div className='flex-shrink-0'>
|
||||
{isDownloading ? (
|
||||
<Loader className='size-3.5' animate />
|
||||
) : (
|
||||
<Download
|
||||
className={cn('size-3.5 transition-opacity', isHovered ? 'opacity-100' : 'opacity-0')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChatFileDownloadAll({ files }: ChatFileDownloadAllProps) {
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
|
||||
if (!files || files.length === 0) return null
|
||||
|
||||
const handleDownloadAll = async () => {
|
||||
if (isDownloading) return
|
||||
|
||||
setIsDownloading(true)
|
||||
|
||||
try {
|
||||
logger.info(`Initiating download for ${files.length} files`)
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
try {
|
||||
const url = getFileUrl(file)
|
||||
await triggerDownload(url, file.name)
|
||||
logger.info(`Downloaded file ${i + 1}/${files.length}: ${file.name}`)
|
||||
|
||||
if (i < files.length - 1) {
|
||||
await sleep(150)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to download file ${file.name}:`, error)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant='ghost-secondary'
|
||||
onClick={handleDownloadAll}
|
||||
disabled={isDownloading}
|
||||
className='p-0'
|
||||
>
|
||||
{isDownloading ? (
|
||||
<Loader className='size-3' animate />
|
||||
) : (
|
||||
<Download className='size-3' strokeWidth={2} />
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import React, { type HTMLAttributes, memo, type ReactNode } from 'react'
|
||||
import { Streamdown } from 'streamdown'
|
||||
import 'streamdown/styles.css'
|
||||
import { CopyCodeButton, Tooltip } from '@sim/emcn'
|
||||
import { extractTextContent } from '@/lib/core/utils/react-node-text'
|
||||
|
||||
function LinkWithPreview({ href, children }: { href: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Tooltip.Root delayDuration={300}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<a
|
||||
href={href}
|
||||
className='text-[var(--text-primary)] underline decoration-dashed underline-offset-4'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top' align='center' sideOffset={5} className='max-w-sm'>
|
||||
<span className='truncate font-medium text-xs'>{href}</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const COMPONENTS = {
|
||||
p: ({ children }: React.HTMLAttributes<HTMLParagraphElement>) => (
|
||||
<p className='mb-1 font-sans text-[var(--text-primary)] text-base leading-relaxed last:mb-0'>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
|
||||
h1: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h1 className='mt-10 mb-5 font-sans font-semibold text-2xl text-[var(--text-primary)]'>
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h2 className='mt-8 mb-4 font-sans font-semibold text-[var(--text-primary)] text-xl'>
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h3 className='mt-7 mb-3 font-sans font-semibold text-[var(--text-primary)] text-lg'>
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
h4: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h4 className='mt-5 mb-2 font-sans font-semibold text-[var(--text-primary)] text-base'>
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
|
||||
ul: ({ children }: React.HTMLAttributes<HTMLUListElement>) => (
|
||||
<ul
|
||||
className='mt-1 mb-1 space-y-1 pl-6 font-sans text-[var(--text-primary)]'
|
||||
style={{ listStyleType: 'disc' }}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }: React.HTMLAttributes<HTMLOListElement>) => (
|
||||
<ol
|
||||
className='mt-1 mb-1 space-y-1 pl-6 font-sans text-[var(--text-primary)]'
|
||||
style={{ listStyleType: 'decimal' }}
|
||||
>
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }: React.LiHTMLAttributes<HTMLLIElement>) => (
|
||||
<li className='font-sans text-[var(--text-primary)]' style={{ display: 'list-item' }}>
|
||||
{children}
|
||||
</li>
|
||||
),
|
||||
|
||||
pre: ({ children }: HTMLAttributes<HTMLPreElement>) => {
|
||||
let codeProps: HTMLAttributes<HTMLElement> = {}
|
||||
let codeContent: ReactNode = children
|
||||
|
||||
if (
|
||||
React.isValidElement<{ className?: string; children?: ReactNode }>(children) &&
|
||||
children.type === 'code'
|
||||
) {
|
||||
const childElement = children as React.ReactElement<{
|
||||
className?: string
|
||||
children?: ReactNode
|
||||
}>
|
||||
codeProps = { className: childElement.props.className }
|
||||
codeContent = childElement.props.children
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='my-6 overflow-hidden rounded-lg border border-[var(--divider)] text-sm'>
|
||||
<div className='flex items-center justify-between border-[var(--divider)] border-b bg-[var(--surface-4)] px-4 py-1.5'>
|
||||
<span className='font-sans text-[var(--text-tertiary)] text-xs'>
|
||||
{codeProps.className?.replace('language-', '') || 'code'}
|
||||
</span>
|
||||
<CopyCodeButton
|
||||
code={extractTextContent(codeContent)}
|
||||
className='text-[var(--text-tertiary)] hover-hover:bg-[var(--surface-5)] hover-hover:text-[var(--text-secondary)]'
|
||||
/>
|
||||
</div>
|
||||
<pre className='overflow-x-auto bg-[var(--surface-5)] p-4 font-mono text-[var(--text-primary)]'>
|
||||
{codeContent}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
||||
inlineCode: ({ children }: { children?: React.ReactNode }) => (
|
||||
<code className='rounded bg-[var(--surface-5)] px-1 py-0.5 font-mono text-[var(--text-primary)] text-inherit'>
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
|
||||
blockquote: ({ children }: React.HTMLAttributes<HTMLQuoteElement>) => (
|
||||
<blockquote className='my-4 break-words border-[var(--divider)] border-l-2 pl-4 font-sans text-[var(--text-primary)] italic [&>p:first-child]:mt-0 [&>p:last-child]:mb-0 [&>p]:my-2'>
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
|
||||
hr: () => <hr className='my-8 border-[var(--divider)] border-t' />,
|
||||
|
||||
a: ({ href, children, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
|
||||
<LinkWithPreview href={href || '#'} {...props}>
|
||||
{children}
|
||||
</LinkWithPreview>
|
||||
),
|
||||
|
||||
table: ({ children }: React.TableHTMLAttributes<HTMLTableElement>) => (
|
||||
<div className='my-4 w-full overflow-x-auto'>
|
||||
<table className='min-w-full table-auto border border-[var(--border)] font-sans text-sm'>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }: React.HTMLAttributes<HTMLTableSectionElement>) => (
|
||||
<thead className='bg-[var(--surface-3)] text-left'>{children}</thead>
|
||||
),
|
||||
tbody: ({ children }: React.HTMLAttributes<HTMLTableSectionElement>) => (
|
||||
<tbody className='divide-y divide-[var(--divider)] bg-[var(--surface-2)]'>{children}</tbody>
|
||||
),
|
||||
tr: ({ children }: React.HTMLAttributes<HTMLTableRowElement>) => (
|
||||
<tr className='border-[var(--divider)] border-b transition-colors hover:bg-[var(--surface-hover)]'>
|
||||
{children}
|
||||
</tr>
|
||||
),
|
||||
th: ({ children }: React.ThHTMLAttributes<HTMLTableCellElement>) => (
|
||||
<th className='border-[var(--border)] border-r px-4 py-2 font-medium text-[var(--text-secondary)] last:border-r-0'>
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }: React.TdHTMLAttributes<HTMLTableCellElement>) => (
|
||||
<td className='break-words border-[var(--border)] border-r px-4 py-2 text-[var(--text-body)] last:border-r-0'>
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
|
||||
img: ({ src, alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement>) => (
|
||||
<img src={src} alt={alt || 'Image'} className='my-3 h-auto max-w-full rounded-md' {...props} />
|
||||
),
|
||||
}
|
||||
|
||||
const MarkdownRenderer = memo(function MarkdownRenderer({ content }: { content: string }) {
|
||||
return (
|
||||
<div className='space-y-4 break-words font-sans text-[var(--text-primary)] text-base leading-relaxed'>
|
||||
<Streamdown mode='static' components={COMPONENTS}>
|
||||
{content.trim()}
|
||||
</Streamdown>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default MarkdownRenderer
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@sim/emcn', () => ({
|
||||
Duplicate: () => null,
|
||||
Tooltip: {},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/(interfaces)/chat/components/message/components/file-download', () => ({
|
||||
ChatFileDownload: () => null,
|
||||
ChatFileDownloadAll: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/(interfaces)/chat/components/message/components/markdown-renderer', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
import { escapeHtml } from '@/app/(interfaces)/chat/components/message/message'
|
||||
|
||||
describe('escapeHtml', () => {
|
||||
it('escapes all five HTML-significant characters', () => {
|
||||
expect(escapeHtml('&<>"\'')).toBe('&<>"'')
|
||||
})
|
||||
|
||||
it('neutralizes a markup-breakout filename payload', () => {
|
||||
const payload = '</title><img src=x onerror=alert(document.origin)>'
|
||||
const escaped = escapeHtml(payload)
|
||||
expect(escaped).not.toContain('<img')
|
||||
expect(escaped).not.toContain('</title>')
|
||||
expect(escaped).toBe('</title><img src=x onerror=alert(document.origin)>')
|
||||
})
|
||||
|
||||
it('escapes ampersands first so entities are not double-broken', () => {
|
||||
expect(escapeHtml('a & b < c')).toBe('a & b < c')
|
||||
})
|
||||
|
||||
it('leaves safe strings untouched', () => {
|
||||
expect(escapeHtml('report-2026.pdf')).toBe('report-2026.pdf')
|
||||
expect(escapeHtml('')).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,282 @@
|
||||
'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> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
} 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
|
||||
)
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user