'use client' import type { UIMessage } from 'ai' import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' import { Conversation, ConversationContent, ConversationScrollButton } from '@/components/ai-elements/conversation' import { Message, MessageContent, MessageResponse } from '@/components/ai-elements/message' import { Reasoning, ReasoningContent, ReasoningTrigger } from '@/components/ai-elements/reasoning' import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput, type ToolPart } from '@/components/ai-elements/tool' import { AppIcon } from '@/components/ui/icons' interface AssistantChatModalProps { open: boolean title: string subtitle: string closeLabel: string userLabel: string assistantLabel: string reasoningTitle: string reasoningExpandLabel: string reasoningCollapseLabel: string emptyAssistantMessage?: string inputPlaceholder: string sendLabel: string pendingLabel: string messages: UIMessage[] input: string pending: boolean completed?: boolean completedTitle?: string completedMessage?: string errorMessage?: string onClose: () => void onInputChange: (value: string) => void onSend: () => void } interface ParsedMessageContent { lines: string[] reasoningLines: string[] } type ParsedToolPart = | { partType: 'dynamic-tool' state: ToolPart['state'] toolName: string input: unknown output: unknown errorText?: string } | { partType: Exclude state: ToolPart['state'] input: unknown output: unknown errorText?: string } interface RenderableMessage { id: string role: UIMessage['role'] lines: string[] reasoningLines: string[] tools: ParsedToolPart[] } interface MessageCacheEntry { signature: string rendered: RenderableMessage } function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value) } function readTrimmedString(value: unknown): string { return typeof value === 'string' ? value.trim() : '' } function joinClassNames(...values: Array): string { return values.filter((value): value is string => Boolean(value)).join(' ') } function isToolState(value: string): value is ToolPart['state'] { return value === 'approval-requested' || value === 'approval-responded' || value === 'input-streaming' || value === 'input-available' || value === 'output-available' || value === 'output-error' || value === 'output-denied' } function isToolPartType(value: string): value is ToolPart['type'] { if (value === 'dynamic-tool') return true return value.startsWith('tool-') } function splitThinkTaggedContent(input: string): { text: string; reasoning: string } { const thinkTagPattern = /<(think|thinking)\b[^>]*>([\s\S]*?)<\/\1>/gi const reasoningParts: string[] = [] let hadTag = false const stripped = input.replace(thinkTagPattern, (_fullMatch, _tagName: string, inner: string) => { hadTag = true const trimmedInner = inner.trim() if (trimmedInner) reasoningParts.push(trimmedInner) return '' }) let visibleText = stripped const openTagMatch = visibleText.match(/<(think|thinking)\b[^>]*>/i) if (openTagMatch && typeof openTagMatch.index === 'number') { hadTag = true const start = openTagMatch.index const openTag = openTagMatch[0] const tail = visibleText .slice(start + openTag.length) .replace(/<\/(think|thinking)\s*>/gi, '') .trim() if (tail) reasoningParts.push(tail) visibleText = visibleText.slice(0, start) } if (!hadTag) { return { text: input.trim(), reasoning: '', } } return { text: visibleText.trim(), reasoning: reasoningParts.join('\n\n').trim(), } } function parseToolPart(part: unknown): ParsedToolPart | null { if (!isRecord(part)) return null const rawType = readTrimmedString(part.type) if (!isToolPartType(rawType)) return null const rawState = readTrimmedString(part.state) if (!isToolState(rawState)) return null const toolName = readTrimmedString(part.toolName) const output = part.output const input = part.input const errorText = readTrimmedString(part.errorText) || undefined if (rawType === 'dynamic-tool') { if (!toolName) return null return { partType: rawType, state: rawState, toolName, input, output, ...(errorText ? { errorText } : {}), } } return { partType: rawType, state: rawState, input, output, ...(errorText ? { errorText } : {}), } } function buildMessageSignature(message: UIMessage): string { const parts: string[] = [] for (const part of message.parts) { if (!isRecord(part)) { parts.push('x') continue } const partRecord = part as Record const type = readTrimmedString(partRecord.type) const state = readTrimmedString(partRecord.state) const toolName = readTrimmedString(partRecord.toolName) const text = typeof partRecord.text === 'string' ? partRecord.text : '' const errorText = typeof partRecord.errorText === 'string' ? partRecord.errorText : '' let outputMarker = '' if (isRecord(partRecord.output)) { outputMarker = `${readTrimmedString(partRecord.output.status)}:${readTrimmedString(partRecord.output.message)}` } parts.push(`${type}:${state}:${toolName}:${text}:${errorText}:${outputMarker}`) } return `${message.role}|${parts.join('|')}` } export function extractMessageContent(message: UIMessage): ParsedMessageContent { const lines: string[] = [] const reasoningLines: string[] = [] for (const part of message.parts) { if (!isRecord(part)) continue const partRecord = part as Record const partType = readTrimmedString(partRecord.type) const text = typeof partRecord.text === 'string' ? partRecord.text.trim() : '' if (partType === 'text' && text) { const parsed = splitThinkTaggedContent(text) if (parsed.reasoning) reasoningLines.push(parsed.reasoning) if (parsed.text) lines.push(parsed.text) continue } if (partType === 'reasoning' && text) { const parsed = splitThinkTaggedContent(text) if (parsed.reasoning) reasoningLines.push(parsed.reasoning) else if (parsed.text) reasoningLines.push(parsed.text) continue } const isSaveToolPart = partType === 'tool-saveModelTemplate' || partType === 'tool-saveModelTemplates' if (!isSaveToolPart) continue const state = readTrimmedString(partRecord.state) if (state === 'output-error') { const errorText = readTrimmedString(partRecord.errorText) if (errorText) lines.push(errorText) continue } if (state !== 'output-available') continue const output = partRecord.output if (!isRecord(output)) continue const messageText = readTrimmedString(output.message) if (messageText) lines.push(messageText) const issues = output.issues if (Array.isArray(issues)) { for (const issue of issues) { if (!isRecord(issue)) continue const field = readTrimmedString(issue.field) const issueMessage = readTrimmedString(issue.message) if (!field && !issueMessage) continue lines.push(`${field || 'issue'}: ${issueMessage || 'invalid'}`) } } } return { lines, reasoningLines, } } function buildRenderableMessage(message: UIMessage): RenderableMessage { const base = extractMessageContent(message) const tools = message.parts .map((part) => parseToolPart(part)) .filter((part): part is ParsedToolPart => part !== null) return { id: message.id, role: message.role, lines: base.lines, reasoningLines: base.reasoningLines, tools, } } function onEnterSubmit(event: KeyboardEvent, submit: () => void) { if (event.key !== 'Enter') return if (event.shiftKey || event.nativeEvent.isComposing) return event.preventDefault() submit() } export function AssistantChatModal({ open, title, subtitle, closeLabel, userLabel, assistantLabel, reasoningTitle, reasoningExpandLabel, reasoningCollapseLabel, emptyAssistantMessage, inputPlaceholder, sendLabel, pendingLabel, messages, input, pending, completed = false, completedTitle, completedMessage, errorMessage, onClose, onInputChange, onSend, }: AssistantChatModalProps) { const [expandedReasoningByMessageId, setExpandedReasoningByMessageId] = useState>({}) const messageCacheRef = useRef(new Map()) useEffect(() => { if (!open) setExpandedReasoningByMessageId({}) }, [open]) const visibleMessages = useMemo(() => { const nextCache = new Map() const list: RenderableMessage[] = [] for (const message of messages) { const signature = buildMessageSignature(message) const cached = messageCacheRef.current.get(message.id) const rendered = cached && cached.signature === signature ? cached.rendered : buildRenderableMessage(message) nextCache.set(message.id, { signature, rendered }) if (rendered.lines.length > 0 || rendered.reasoningLines.length > 0 || rendered.tools.length > 0) { list.push(rendered) } } messageCacheRef.current = nextCache return list }, [messages]) const lastAssistantMessageId = useMemo(() => { for (let index = visibleMessages.length - 1; index >= 0; index -= 1) { const message = visibleMessages[index] if (message?.role === 'assistant') return message.id } return null }, [visibleMessages]) const shouldShowEmptyAssistantMessage = visibleMessages.length === 0 && typeof emptyAssistantMessage === 'string' && emptyAssistantMessage.trim().length > 0 if (!open) return null const setReasoningExpanded = (messageId: string, openState: boolean): void => { setExpandedReasoningByMessageId((previous) => ({ ...previous, [messageId]: openState, })) } return (
event.stopPropagation()} >

{title}

{subtitle}

{completed ? (
{completedTitle || assistantLabel}
{completedMessage && (
{completedMessage}
)}
{closeLabel}
) : ( {shouldShowEmptyAssistantMessage && (
{assistantLabel}
{emptyAssistantMessage}
)} {visibleMessages.map((message) => { const isAssistant = message.role === 'assistant' const isStreamingAssistantMessage = pending && isAssistant && message.id === lastAssistantMessageId return (
{isAssistant ? assistantLabel : userLabel}
{isAssistant && message.reasoningLines.length > 0 && ( setReasoningExpanded(message.id, nextOpenState)} className="mb-2 rounded-xl border border-[var(--glass-stroke-base)] bg-[var(--glass-bg-soft)] p-2" > {reasoningTitle} {expandedReasoningByMessageId[message.id] ? reasoningCollapseLabel : reasoningExpandLabel} {message.reasoningLines.join('\n\n')} )} {message.lines.map((line, index) => ( {line} ))} {message.tools.map((tool, index) => ( {tool.partType === 'dynamic-tool' ? : } {tool.input !== undefined && } ))}
) })} {pending && !completed && (
{assistantLabel}
{pendingLabel}
)} {errorMessage && (
{errorMessage}
)}
)}
{completed ? ( ) : ( <> onInputChange(event.target.value)} onKeyDown={(event) => onEnterSubmit(event, onSend)} placeholder={inputPlaceholder} className="glass-input-base flex-1 px-3 py-2 text-sm" disabled={pending} /> )}
) }