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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -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('&amp;&lt;&gt;&quot;&#39;')
})
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('&lt;/title&gt;&lt;img src=x onerror=alert(document.origin)&gt;')
})
it('escapes ampersands first so entities are not double-broken', () => {
expect(escapeHtml('a & b < c')).toBe('a &amp; b &lt; 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> = {
'&': '&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
)
}
)