528 lines
19 KiB
TypeScript
528 lines
19 KiB
TypeScript
'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<ToolPart['type'], 'dynamic-tool'>
|
|
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<string, unknown> {
|
|
return !!value && typeof value === 'object' && !Array.isArray(value)
|
|
}
|
|
|
|
function readTrimmedString(value: unknown): string {
|
|
return typeof value === 'string' ? value.trim() : ''
|
|
}
|
|
|
|
function joinClassNames(...values: Array<string | undefined>): 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<string, unknown>
|
|
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<string, unknown>
|
|
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<HTMLInputElement>, 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<Record<string, boolean>>({})
|
|
const messageCacheRef = useRef(new Map<string, MessageCacheEntry>())
|
|
|
|
useEffect(() => {
|
|
if (!open) setExpandedReasoningByMessageId({})
|
|
}, [open])
|
|
|
|
const visibleMessages = useMemo(() => {
|
|
const nextCache = new Map<string, MessageCacheEntry>()
|
|
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 (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center glass-overlay px-4"
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
className="glass-surface-modal w-full max-w-3xl overflow-hidden rounded-2xl"
|
|
onClick={(event) => event.stopPropagation()}
|
|
>
|
|
<div className="flex items-center justify-between border-b border-[var(--glass-stroke-base)] px-4 py-3">
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-[var(--glass-text-primary)]">{title}</h3>
|
|
<p className="text-xs text-[var(--glass-text-secondary)]">{subtitle}</p>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="glass-icon-btn-sm"
|
|
title={closeLabel}
|
|
>
|
|
<AppIcon name="close" className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="h-[420px] overflow-hidden bg-[var(--glass-bg-soft)]">
|
|
{completed ? (
|
|
<div className="flex h-full items-center justify-center px-4 py-4">
|
|
<div className="w-full max-w-md rounded-2xl border border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface)] px-6 py-7 text-center shadow-sm">
|
|
<div className="relative mx-auto mb-4 flex h-20 w-20 items-center justify-center">
|
|
<div className="absolute h-20 w-20 rounded-full bg-emerald-500/20 animate-ping" />
|
|
<div className="relative z-10 flex h-20 w-20 items-center justify-center rounded-full border border-emerald-400/60 bg-emerald-500/15">
|
|
<AppIcon name="check" className="h-10 w-10 text-emerald-500" />
|
|
</div>
|
|
</div>
|
|
<div className="text-base font-semibold text-[var(--glass-text-primary)]">
|
|
{completedTitle || assistantLabel}
|
|
</div>
|
|
{completedMessage && (
|
|
<div className="mt-2 whitespace-pre-wrap text-sm leading-relaxed text-[var(--glass-text-secondary)]">
|
|
{completedMessage}
|
|
</div>
|
|
)}
|
|
<div className="mt-4 text-xs text-[var(--glass-text-tertiary)]">
|
|
{closeLabel}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<Conversation className="h-full">
|
|
<ConversationContent className="h-full space-y-3 p-4">
|
|
{shouldShowEmptyAssistantMessage && (
|
|
<Message from="assistant">
|
|
<MessageContent className="max-w-[84%] rounded-2xl rounded-bl-md border border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface)] px-3 py-2">
|
|
<div className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-[var(--glass-text-tertiary)]">
|
|
{assistantLabel}
|
|
</div>
|
|
<MessageResponse className="whitespace-pre-wrap break-words leading-relaxed">
|
|
{emptyAssistantMessage}
|
|
</MessageResponse>
|
|
</MessageContent>
|
|
</Message>
|
|
)}
|
|
|
|
{visibleMessages.map((message) => {
|
|
const isAssistant = message.role === 'assistant'
|
|
const isStreamingAssistantMessage = pending && isAssistant && message.id === lastAssistantMessageId
|
|
return (
|
|
<Message key={message.id} from={message.role}>
|
|
<MessageContent
|
|
className={isAssistant
|
|
? 'max-w-[84%] rounded-2xl rounded-bl-md border border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface)] px-3 py-2'
|
|
: 'max-w-[84%] rounded-2xl rounded-br-md bg-[var(--brand-primary)]/15 px-3 py-2'}
|
|
>
|
|
<div className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-[var(--glass-text-tertiary)]">
|
|
{isAssistant ? assistantLabel : userLabel}
|
|
</div>
|
|
|
|
{isAssistant && message.reasoningLines.length > 0 && (
|
|
<Reasoning
|
|
open={Boolean(expandedReasoningByMessageId[message.id])}
|
|
onOpenChange={(nextOpenState) => setReasoningExpanded(message.id, nextOpenState)}
|
|
className="mb-2 rounded-xl border border-[var(--glass-stroke-base)] bg-[var(--glass-bg-soft)] p-2"
|
|
>
|
|
<ReasoningTrigger className="text-xs text-[var(--glass-text-secondary)]">
|
|
<span className="mr-2">{reasoningTitle}</span>
|
|
<span className="text-[11px] text-[var(--glass-text-tertiary)]">
|
|
{expandedReasoningByMessageId[message.id] ? reasoningCollapseLabel : reasoningExpandLabel}
|
|
</span>
|
|
</ReasoningTrigger>
|
|
<ReasoningContent className="space-y-1 border-t border-[var(--glass-stroke-base)] pt-2 text-xs text-[var(--glass-text-secondary)]">
|
|
{message.reasoningLines.join('\n\n')}
|
|
</ReasoningContent>
|
|
</Reasoning>
|
|
)}
|
|
|
|
{message.lines.map((line, index) => (
|
|
<MessageResponse
|
|
key={`${message.id}-line-${index}`}
|
|
className={joinClassNames(
|
|
'whitespace-pre-wrap break-words leading-relaxed',
|
|
isStreamingAssistantMessage ? 'assistant-streaming-response' : undefined,
|
|
)}
|
|
>
|
|
{line}
|
|
</MessageResponse>
|
|
))}
|
|
|
|
{message.tools.map((tool, index) => (
|
|
<Tool
|
|
key={`${message.id}-tool-${index}`}
|
|
defaultOpen={tool.state !== 'output-available'}
|
|
className="mt-2 border-[var(--glass-stroke-base)] bg-[var(--glass-bg-soft)]"
|
|
>
|
|
{tool.partType === 'dynamic-tool'
|
|
? <ToolHeader type={tool.partType} state={tool.state} toolName={tool.toolName} />
|
|
: <ToolHeader type={tool.partType} state={tool.state} />}
|
|
<ToolContent>
|
|
{tool.input !== undefined && <ToolInput input={tool.input as ToolPart['input']} />}
|
|
<ToolOutput
|
|
output={tool.output as ToolPart['output']}
|
|
errorText={tool.errorText as ToolPart['errorText']}
|
|
/>
|
|
</ToolContent>
|
|
</Tool>
|
|
))}
|
|
</MessageContent>
|
|
</Message>
|
|
)
|
|
})}
|
|
|
|
{pending && !completed && (
|
|
<Message from="assistant">
|
|
<MessageContent className="max-w-[84%] rounded-2xl rounded-bl-md border border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface)] px-3 py-2">
|
|
<div className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-[var(--glass-text-tertiary)]">
|
|
{assistantLabel}
|
|
</div>
|
|
<MessageResponse>{pendingLabel}</MessageResponse>
|
|
</MessageContent>
|
|
</Message>
|
|
)}
|
|
|
|
{errorMessage && (
|
|
<div className="rounded-xl border border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface)] px-3 py-2 text-xs text-[var(--glass-text-secondary)]">
|
|
{errorMessage}
|
|
</div>
|
|
)}
|
|
</ConversationContent>
|
|
<ConversationScrollButton />
|
|
</Conversation>
|
|
)}
|
|
</div>
|
|
|
|
<div className="border-t border-[var(--glass-stroke-base)] px-4 py-3">
|
|
<div className="flex items-center gap-2">
|
|
{completed ? (
|
|
<button
|
|
onClick={onClose}
|
|
className="glass-btn-base glass-btn-primary ml-auto px-3 py-2 text-sm font-medium"
|
|
>
|
|
{closeLabel}
|
|
</button>
|
|
) : (
|
|
<>
|
|
<input
|
|
type="text"
|
|
value={input}
|
|
onChange={(event) => 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}
|
|
/>
|
|
<button
|
|
onClick={onSend}
|
|
disabled={pending}
|
|
className="glass-btn-base glass-btn-primary px-3 py-2 text-sm font-medium disabled:opacity-60"
|
|
>
|
|
{pending ? pendingLabel : sendLabel}
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|