jan/web-app/src/containers/ToolCallBlock.tsx
2025-09-06 21:48:09 +07:00

239 lines
6.9 KiB
TypeScript

import { ChevronDown, ChevronUp, Loader } from 'lucide-react'
import { cn } from '@/lib/utils'
import { create } from 'zustand'
import { RenderMarkdown } from '@/containers/RenderMarkdown'
import { useMemo, useState } from 'react'
import { twMerge } from 'tailwind-merge'
import { useTranslation } from '@/i18n/react-i18next-compat'
import ImageModal from '@/containers/dialogs/ImageModal'
interface Props {
result: string
name: string
args: object
id: number
loading: boolean
}
type ToolCallBlockState = {
collapseState: { [id: number]: boolean }
setCollapseState: (id: number, expanded: boolean) => void
}
const useToolCallBlockStore = create<ToolCallBlockState>((set) => ({
collapseState: {},
setCollapseState: (id, expanded) =>
set((state) => ({
collapseState: {
...state.collapseState,
[id]: expanded,
},
})),
}))
// Types for MCP response content
interface MCPContentItem {
type: string
data?: string
text?: string
mimeType?: string
}
interface MCPResponse {
content?: MCPContentItem[]
}
// Utility function to create data URL from base64 and mimeType
const createDataUrl = (base64Data: string, mimeType: string): string => {
// Handle case where base64 data might already include data URL prefix
if (base64Data.startsWith('data:')) {
return base64Data
}
return `data:${mimeType};base64,${base64Data}`
}
// Parse MCP response and extract content items
const parseMCPResponse = (result: string) => {
try {
const parsed: MCPResponse = JSON.parse(result)
const content = parsed.content || []
return {
parsedResult: parsed,
contentItems: content,
hasStructuredContent: content.length > 0,
parseError: false,
}
} catch {
// Fallback: JSON parsing failed, treat as plain text
return {
parsedResult: result,
contentItems: [],
hasStructuredContent: false,
parseError: true,
}
}
}
// Component to render individual content items based on type
const ContentItemRenderer = ({
item,
index,
onImageClick,
}: {
item: MCPContentItem
index: number
onImageClick?: (imageUrl: string, alt: string) => void
}) => {
if (item.type === 'image' && item.data && item.mimeType) {
const imageUrl = createDataUrl(item.data, item.mimeType)
return (
<div key={index} className="my-3">
<img
src={imageUrl}
alt={`Result image ${index + 1}`}
className="max-w-full max-h-64 object-contain rounded-md border border-main-view-fg/10 cursor-pointer hover:opacity-80 transition-opacity"
onError={(e) => {
// Hide broken images
e.currentTarget.style.display = 'none'
}}
onClick={() => onImageClick?.(imageUrl, `Result image ${index + 1}`)}
/>
</div>
)
}
// For any other types, render as JSON
return (
<div key={index} className="mt-3">
<RenderMarkdown
content={'```json\n' + JSON.stringify(item, null, 2) + '\n```'}
/>
</div>
)
}
const ToolCallBlock = ({ id, name, result, loading, args }: Props) => {
const { collapseState, setCollapseState } = useToolCallBlockStore()
const { t } = useTranslation()
const isExpanded = collapseState[id] ?? (loading ? true : false)
const [modalImage, setModalImage] = useState<{
url: string
alt: string
} | null>(null)
const handleClick = () => {
const newExpandedState = !isExpanded
setCollapseState(id, newExpandedState)
}
const handleImageClick = (imageUrl: string, alt: string) => {
setModalImage({ url: imageUrl, alt })
}
const closeModal = () => {
setModalImage(null)
}
// Parse the MCP response and extract content items
const { parsedResult, contentItems, hasStructuredContent } = useMemo(() => {
return parseMCPResponse(result)
}, [result])
return (
<div
className="mx-auto w-full cursor-pointer break-words"
data-tool-call-block={id}
>
<div className="rounded-lg bg-main-view-fg/4 border border-dashed border-main-view-fg/10">
<div className="flex items-center gap-3 p-2" onClick={handleClick}>
{loading && (
<div className="w-4 h-4">
<Loader className="size-4 animate-spin text-main-view-fg/60" />
</div>
)}
<button className="flex items-center gap-2 focus:outline-none">
{!loading && (
<>
{isExpanded ? (
<>
<div className="ml-1 w-4 h-4">
<ChevronUp className="h-4 w-4" />
</div>
</>
) : (
<div className="ml-1 w-4 h-4">
<ChevronDown className="h-4 w-4" />
</div>
)}
</>
)}
<span className="font-medium text-main-view-fg/80">
<span className="font-medium text-main-view-fg mr-2">{name}</span>
<span
className={twMerge(
'text-xs bg-main-view-fg/4 rounded-sm p-1',
loading ? 'text-main-view-fg/40' : 'text-accent'
)}
>
{loading ? t('common:callingTool') : t('common:completed')}{' '}
</span>
</span>
</button>
</div>
<div
className={cn(
'h-fit w-full overflow-auto transition-all duration-300 px-2',
isExpanded ? '' : 'max-h-0 overflow-hidden'
)}
>
<div className="mt-2 text-main-view-fg/60 overflow-hidden">
{args && Object.keys(args).length > 3 && (
<>
<p className="mb-3">Arguments:</p>
<RenderMarkdown
isWrapping={true}
content={'```json\n' + args + '\n```'}
/>
</>
)}
{result && (
<>
<p>Output:</p>
{hasStructuredContent ? (
/* Render each content item individually based on its type */
<div className="space-y-2">
{contentItems.map((item, index) => (
<ContentItemRenderer
key={index}
item={item}
index={index}
onImageClick={handleImageClick}
/>
))}
</div>
) : (
/* Fallback: render as JSON for valid JSON but unstructured responses */
<RenderMarkdown
content={
'```json\n' +
JSON.stringify(parsedResult, null, 2) +
'\n```'
}
/>
)}
</>
)}
</div>
</div>
</div>
<ImageModal image={modalImage} onClose={closeModal} />
</div>
)
}
export default ToolCallBlock