openpencil/src/components/chat/ChatMessage.vue
Danila Poyarkov 4342fd3262
perf(chat): release completed streaming parser history (#553)
- Keep only the active assistant response in streaming Markdown mode.\n- Remount completed responses in static mode to release incremental AST caches.\n- Preserve final Markdown, code rendering, and theme behavior.
2026-08-18 17:43:49 +03:00

167 lines
6.1 KiB
Vue

<script setup lang="ts">
import { computed } from 'vue'
import { isTextUIPart, isToolUIPart, getToolName } from 'ai'
import { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from 'reka-ui'
import { Markdown } from 'vue-stream-markdown'
import { useI18n, vTestId } from '@open-pencil/vue'
import 'vue-stream-markdown/index.css'
import {
imageAttachmentsForMessage,
visibleUserMessageText
} from '@/app/ai/attachment/image/presentation'
import { resolvedAppTheme } from '@/app/shell/theme'
import ImageAttachment from '@/components/chat/attachment/image/ImageAttachment.vue'
import type { UIDataTypes, UIMessage, UIMessagePart, UITools } from 'ai'
const { message, streaming = false } = defineProps<{
message: UIMessage
streaming?: boolean
}>()
const { dialogs } = useI18n()
const isDark = computed(() => resolvedAppTheme.value === 'dark')
const markdownMode = computed(() => (streaming ? 'streaming' : 'static'))
const imageAttachments = imageAttachmentsForMessage(message.id)
type ToolPart = Extract<UIMessagePart<UIDataTypes, UITools>, { toolCallId: string }>
function toolDisplayName(part: ToolPart): string {
return getToolName(part)
.replace(/^mcp__[^_]+__/, '')
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase())
}
function hasErrorOutput(part: ToolPart): boolean {
return (
part.state === 'output-available' &&
typeof part.output === 'object' &&
part.output !== null &&
'error' in part.output
)
}
function toolState(part: ToolPart): 'pending' | 'done' | 'error' {
if (part.state === 'output-error' || hasErrorOutput(part)) return 'error'
if (part.state === 'output-available') return 'done'
return 'pending'
}
function partKey(part: UIMessagePart<UIDataTypes, UITools>, index: number): string {
if ('toolCallId' in part) return part.toolCallId
return `part-${index}`
}
</script>
<template>
<div
v-test-id="`chat-message-${message.role}`"
:class="message.role === 'user' ? 'flex justify-end' : ''"
>
<div
class="min-w-0 space-y-2 select-text"
:class="message.role === 'user' ? 'max-w-[85%]' : ''"
>
<template v-if="message.role === 'assistant'">
<template v-for="(part, i) in message.parts" :key="partKey(part, i)">
<!-- Tool call -->
<div v-if="isToolUIPart(part)" class="rounded-lg border border-border bg-canvas p-2">
<CollapsibleRoot>
<CollapsibleTrigger
class="flex w-full items-center gap-2 rounded px-1 py-0.5 hover:bg-hover"
>
<div
class="flex size-4 items-center justify-center rounded-full"
:class="{
'bg-accent/20 text-accent': toolState(part) === 'pending',
'bg-green-500/20 text-green-400': toolState(part) === 'done',
'bg-red-500/20 text-red-400': toolState(part) === 'error'
}"
>
<icon-lucide-loader-circle
v-if="toolState(part) === 'pending'"
class="size-3 animate-spin"
/>
<icon-lucide-check v-else-if="toolState(part) === 'done'" class="size-3" />
<icon-lucide-triangle-alert v-else class="size-3" />
</div>
<span class="text-[11px] text-surface">
{{ toolDisplayName(part) }}
</span>
<span class="text-[10px] text-muted">
{{
toolState(part) === 'pending'
? dialogs.toolRunning
: toolState(part) === 'done'
? dialogs.toolFinished
: dialogs.toolError
}}
</span>
<icon-lucide-chevron-down
v-if="toolState(part) !== 'pending'"
class="ml-auto size-3 text-muted transition-transform [[data-state=open]>&]:rotate-180"
/>
</CollapsibleTrigger>
<CollapsibleContent
v-if="toolState(part) !== 'pending'"
class="data-[state=closed]:collapsible-up data-[state=open]:collapsible-down overflow-hidden text-[10px]"
>
<pre class="mt-1 overflow-x-auto rounded bg-input p-2 text-muted">{{
part.state === 'output-error' && part.errorText
? part.errorText
: hasErrorOutput(part)
? (part.output as { error: string }).error
: JSON.stringify(part.output, null, 2)
}}</pre>
</CollapsibleContent>
</CollapsibleRoot>
</div>
<!-- Text -->
<div
v-else-if="isTextUIPart(part) && part.text"
data-test-id="chat-text-bubble"
class="rounded-xl rounded-tl-md bg-hover px-3 py-2 text-xs leading-relaxed text-surface"
>
<Markdown
:key="markdownMode"
:content="part.text"
:is-dark="isDark"
:mermaid="false"
:mode="markdownMode"
:data-chat-markdown-mode="markdownMode"
class="chat-markdown [&_[data-stream-markdown=code]]:!bg-input"
/>
</div>
</template>
</template>
<!-- User message -->
<template v-else-if="message.role === 'user'">
<div v-if="imageAttachments.length" class="flex flex-wrap justify-end gap-1.5">
<ImageAttachment
v-for="attachment in imageAttachments"
:key="attachment.id"
:attachment="attachment"
/>
</div>
<div
data-test-id="chat-text-bubble"
class="rounded-xl rounded-br-md bg-accent px-3 py-2 text-xs leading-relaxed whitespace-pre-wrap text-white"
>
{{
visibleUserMessageText(
message.id,
message.parts
.filter(isTextUIPart)
.map((p) => p.text)
.join('')
)
}}
</div>
</template>
</div>
</div>
</template>