- 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.
167 lines
6.1 KiB
Vue
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>
|