refactor(stage-ui): advanced tool rendering, error handling, and ui improvements

This commit is contained in:
Neko Ayaka
2026-04-29 06:05:49 +08:00
parent 2d099e336b
commit 09b0390aba
15 changed files with 730 additions and 174 deletions
@@ -1,4 +1,5 @@
<script setup lang="ts">
import type { ChatToolCallRendererRegistry } from '@proj-airi/stage-ui/components'
import type { ChatHistoryItem } from '@proj-airi/stage-ui/types/chat'
import { errorMessageFrom } from '@moeru/std'
@@ -17,6 +18,8 @@ import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import JournalToolCallBlock from './chat-tool-renderers/journal-tool-call-block.vue'
import { useChatSyncStore } from '../stores/chat-sync'
const router = useRouter()
@@ -44,6 +47,10 @@ const TRAILING_NEWLINES_REGEX = /[\r\n]+$/
const SEND_MODES = ['enter', 'ctrl-enter', 'double-enter'] as const
type SendMode = (typeof SEND_MODES)[number]
const sendMode = useLocalStorage<SendMode>('ui/chat/settings/send-mode', 'enter')
const toolCallRenderers = {
image_journal: JournalToolCallBlock,
text_journal: JournalToolCallBlock,
} satisfies ChatToolCallRendererRegistry
const sendModeLabels = computed<Record<SendMode, string>>(() => ({
'enter': t('stage.send-mode.enter'),
'ctrl-enter': t('stage.send-mode.ctrl-enter'),
@@ -211,6 +218,7 @@ async function handleRetryMessage(index: number) {
:messages="historyMessages"
:sending="sending"
:streaming-message="streamingMessage"
:tool-call-renderers="toolCallRenderers"
@delete-message="handleDeleteMessage($event.index)"
@retry-message="handleRetryMessage($event.index)"
/>
@@ -0,0 +1,223 @@
<script setup lang="ts">
import { createToolResultError, MarkdownRenderer, normalizeToolResultText } from '@proj-airi/stage-ui/components'
import { useJournalPreviewStore } from '@proj-airi/stage-ui/stores/journal-preview'
import { Collapsible, ContainerError } from '@proj-airi/ui'
import { computed } from 'vue'
const props = defineProps<{
toolName: string
args: string
state?: 'executing' | 'done' | 'error'
result?: unknown
}>()
interface TextJournalArgs {
action?: string
title?: string
content?: string
}
interface ImageJournalArgs {
action?: string
prompt?: string
title?: string
mode?: 'inline' | 'widget' | 'bg'
}
interface ImageJournalResult {
imageUrl?: string
}
const { openImagePreview } = useJournalPreviewStore()
function parseObject<T extends object>(value: unknown): T | null {
if (typeof value === 'string') {
try {
return JSON.parse(value) as T
}
catch {
return null
}
}
if (value && typeof value === 'object')
return value as T
return null
}
const parsedArgs = computed(() => parseObject<TextJournalArgs & ImageJournalArgs>(props.args))
const isTextJournalCreate = computed(() => {
return props.toolName === 'text_journal'
&& parsedArgs.value?.action === 'create'
&& !!parsedArgs.value?.content?.trim()
})
const isImageJournalCreate = computed(() => {
return props.toolName === 'image_journal'
&& parsedArgs.value?.action === 'create'
&& !!parsedArgs.value?.prompt?.trim()
})
const textJournalMarkdown = computed(() => {
if (!isTextJournalCreate.value)
return ''
const title = parsedArgs.value?.title?.trim() || 'Journal Entry'
const content = parsedArgs.value?.content?.trim() || ''
return `# ${title}\n\n${content}`
})
const imageJournalMarkdown = computed(() => {
if (!isImageJournalCreate.value)
return ''
const title = parsedArgs.value?.title?.trim() || 'Untitled Image'
const prompt = parsedArgs.value?.prompt?.trim() || ''
const mode = parsedArgs.value?.mode || 'inline'
let footer = ''
if (mode === 'bg')
footer = '\n\n> **Scene Shift**: Setting this as the active background...'
else if (mode === 'widget')
footer = '\n\n> **Canvas Created**: Spawning an artistry widget for you...'
else
footer = '\n\n> **Sharing**: Sending a quick sketch to our chat history...'
return `### ${title}\n\n*${prompt}*${footer}`
})
const imageJournalResult = computed(() => {
if (props.toolName !== 'image_journal' || !props.result)
return null
return parseObject<ImageJournalResult>(props.result)
})
const resultText = computed(() => normalizeToolResultText(props.result))
const resultError = computed(() => props.state === 'error' ? createToolResultError(props.result) : undefined)
const formattedArgs = computed(() => {
try {
const parsed = JSON.parse(props.args)
return JSON.stringify(parsed, null, 2).trim()
}
catch {
return props.args
}
})
const imageMode = computed(() => parsedArgs.value?.mode || 'inline')
const imageStatusLabel = computed(() => imageMode.value === 'bg' ? 'Updating Scene' : 'Generating image')
const imageStatusIconClass = computed(() => imageMode.value === 'bg' ? 'i-solar:gallery-wide-bold-duotone text-emerald-500' : 'i-solar:camera-bold-duotone text-violet-500')
const imageStatusBadgeClass = computed(() => {
return imageMode.value === 'bg'
? 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300'
: 'bg-violet-500/12 text-violet-700 dark:text-violet-300'
})
function openGeneratedImagePreview() {
openImagePreview({
title: parsedArgs.value?.title || 'Generated Image',
url: imageJournalResult.value?.imageUrl ?? null,
})
}
</script>
<template>
<Collapsible
:class="[
'bg-primary-100/40 dark:bg-primary-900/60 rounded-lg px-2 pb-2 pt-2',
'flex flex-col gap-2 items-start',
]"
>
<template #trigger="{ visible, setVisible }">
<button
:class="[
'w-full text-start',
]"
@click="setVisible(!visible)"
>
<div
v-if="state === 'executing'"
i-eos-icons:loading class="mr-1 inline-block translate-y-0.5 op-50"
/>
<div
v-else-if="state === 'error'"
i-ph:warning-circle-duotone class="mr-1 inline-block translate-y-0.5 text-red-500"
/>
<div
v-else-if="state === 'done'"
i-ph:check-circle-duotone class="mr-1 inline-block translate-y-0.5 text-emerald-500"
/>
<div
v-else
i-solar:sledgehammer-bold-duotone class="mr-1 inline-block translate-y-1 op-50"
/>
<code>{{ toolName }}</code>
<span v-if="state === 'error' && resultText" class="ml-2 text-xs text-red-500 op-80">
(failed)
</span>
</button>
</template>
<div
:class="[
'rounded-md p-2 w-full',
'bg-neutral-100/80 text-sm text-neutral-800 dark:bg-neutral-900/80 dark:text-neutral-200',
]"
>
<template v-if="resultError">
<ContainerError
:error="resultError"
:include-stack="false"
:show-feedback-button="false"
height-preset="auto"
/>
<div
:class="[
'mt-2 whitespace-pre-wrap break-words font-mono',
]"
>
{{ formattedArgs }}
</div>
</template>
<template v-else-if="isTextJournalCreate">
<div class="mb-2 flex items-center gap-2">
<div class="i-solar:notebook-bookmark-bold-duotone text-base text-emerald-500" />
<div class="rounded-full bg-emerald-500/12 px-2.5 py-1 text-xs text-emerald-700 dark:text-emerald-300">
Saved to long-term memory
</div>
</div>
<MarkdownRenderer :content="textJournalMarkdown" />
</template>
<template v-else-if="isImageJournalCreate">
<div class="mb-2 flex items-center gap-2">
<div :class="[imageStatusIconClass, 'text-base']" />
<div
:class="[
'rounded-full px-2.5 py-1 text-xs',
imageStatusBadgeClass,
]"
>
{{ imageStatusLabel }}
</div>
</div>
<MarkdownRenderer :content="imageJournalMarkdown" />
<div
v-if="imageJournalResult?.imageUrl"
class="mt-4 overflow-hidden border border-primary-500/20 rounded-xl shadow-lg"
>
<img
:src="imageJournalResult.imageUrl"
class="w-full cursor-pointer object-contain transition-all active:scale-[0.98] hover:ring-2 hover:ring-primary-500/50"
@click="openGeneratedImagePreview"
>
</div>
</template>
<div v-else class="whitespace-pre-wrap break-words font-mono">
{{ formattedArgs }}
</div>
</div>
</Collapsible>
</template>
@@ -7,7 +7,7 @@ import { useMarkdown } from '../../composables/markdown'
interface Props {
content: string
class?: string
class?: string | string[]
}
const props = defineProps<Props>()
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { ChatAssistantMessage, ChatHistoryItem, ChatSlices, ChatSlicesText } from '../../../../types/chat'
import type { ChatAssistantMessage, ChatHistoryItem, ChatSlices, ChatSlicesText, ChatSlicesToolCallResult } from '../../../../types/chat'
import type { ChatToolCallRendererRegistry } from './tool-call-renderer'
import { isStageCapacitor, isStageWeb } from '@proj-airi/stage-shared'
import { computed } from 'vue'
@@ -10,15 +11,18 @@ import ChatToolCallBlock from './tool-call-block.vue'
import { MarkdownRenderer } from '../../../markdown'
import { getChatHistoryItemCopyText } from '../utils'
import { ChatActionMenu } from './action-menu'
import { createToolCallResultLookup, resolveToolCallBlockState } from './tool-call-results'
const props = withDefaults(defineProps<{
message: ChatAssistantMessage
label: string
showPlaceholder?: boolean
variant?: 'desktop' | 'mobile'
toolCallRenderers?: ChatToolCallRendererRegistry
}>(), {
showPlaceholder: false,
variant: 'desktop',
toolCallRenderers: () => ({}),
})
const emit = defineEmits<{
@@ -44,6 +48,30 @@ const resolvedSlices = computed<ChatSlices[]>(() => {
return []
})
const toolResultById = computed(() => {
return createToolCallResultLookup(resolvedSlices.value, props.message.tool_results)
})
function getToolCallResult(slice: ChatSlices): ChatSlicesToolCallResult | undefined {
if (slice.type !== 'tool-call') {
return undefined
}
return toolResultById.value.get(slice.toolCall.toolCallId)
}
function getToolCallState(slice: ChatSlices): 'executing' | 'done' | 'error' {
return resolveToolCallBlockState(getToolCallResult(slice))
}
function getToolCallRenderer(slice: ChatSlices) {
if (slice.type !== 'tool-call') {
return ChatToolCallBlock
}
return props.toolCallRenderers[slice.toolCall.toolName] ?? ChatToolCallBlock
}
const showLoader = computed(() => props.showPlaceholder && resolvedSlices.value.length === 0)
const containerClass = computed(() => props.variant === 'mobile' ? 'mr-0' : 'mr-12')
const boxClasses = computed(() => [
@@ -64,22 +92,29 @@ const copyText = computed(() => getChatHistoryItemCopyText(props.message as Chat
<div
:ref="setMeasuredElement"
flex="~ col" shadow="sm primary-200/50 dark:none"
min-w-20 rounded-xl h="unset <sm:fit"
min-w-20 gap-2 rounded-xl h="unset <sm:fit"
:class="[
boxClasses,
(isStageWeb() || isStageCapacitor()) && props.variant === 'mobile' ? 'select-none sm:select-auto' : '',
]"
>
<div>
<span text-sm text="black/60 dark:white/65" font-normal class="inline <sm:hidden">{{ label }}</span>
<ChatResponsePart
v-if="message.categorization"
:message="message"
:variant="variant"
/>
<div class="<sm:hidden">
<span text-sm text="black/60 dark:white/65" font-normal>{{ label }}</span>
</div>
<div v-if="resolvedSlices.length > 0" class="break-words" text="primary-700 dark:primary-100">
<div v-if="resolvedSlices.length > 0" class="flex flex-col gap-2 break-words" text="primary-700 dark:primary-100">
<template v-for="(slice, sliceIndex) in resolvedSlices" :key="sliceIndex">
<ChatToolCallBlock
<component
:is="getToolCallRenderer(slice)"
v-if="slice.type === 'tool-call'"
:tool-name="slice.toolCall.toolName"
:args="slice.toolCall.args"
class="mb-2"
:state="getToolCallState(slice)"
:result="getToolCallResult(slice)?.result"
/>
<template v-else-if="slice.type === 'tool-call-result'" />
<template v-else-if="slice.type === 'text'">
@@ -88,12 +123,6 @@ const copyText = computed(() => getChatHistoryItemCopyText(props.message as Chat
</template>
</div>
<div v-else-if="showLoader" i-eos-icons:three-dots-loading />
<ChatResponsePart
v-if="message.categorization"
:message="message"
:variant="variant"
/>
</div>
</template>
</ChatActionMenu>
@@ -6,6 +6,96 @@ import { computed, ref } from 'vue'
import ChatScrollVisualizer from '../composables/use-element-scroll-visualize.vue'
import ChatHistory from './history.vue'
const comprehensiveMessages = ref<ChatHistoryItem[]>([
{
role: 'user',
content: 'Plan a compact release update. Check the task list, inspect the weather for launch timing, and explain what changed.',
},
{
role: 'assistant',
content: '',
slices: [
{
type: 'text',
text: 'I will collect the operational context first, then summarize the release status in a short update.',
},
{
type: 'tool-call',
toolCall: {
toolName: 'fetch_tasks',
args: JSON.stringify({ project: 'stage-ui', status: ['todo', 'in_progress'], limit: 4 }),
toolCallId: 'normal-fetch-tasks',
toolCallType: 'function',
},
},
{
type: 'tool-call',
toolCall: {
toolName: 'weather',
args: JSON.stringify({ location: 'Tokyo', date: 'today' }),
toolCallId: 'normal-weather',
toolCallType: 'function',
},
},
{
type: 'tool-call-result',
id: 'normal-weather',
result: 'Tokyo is clear with light wind. No weather risk for the launch window.',
},
{
type: 'tool-call',
toolCall: {
toolName: 'deploy_preview',
args: JSON.stringify({ branch: 'release/chat-history-states', environment: 'preview' }),
toolCallId: 'normal-deploy-preview',
toolCallType: 'function',
},
},
{
type: 'tool-call-result',
id: 'normal-deploy-preview',
isError: true,
result: 'Preview deployment failed: missing VITE_PUBLIC_STAGE_API_URL.',
},
{
type: 'text',
text: [
'Here is the release update:',
'',
'- Task scan is still running, so I will keep that status visible.',
'- Weather check succeeded; the launch window has no external timing concern.',
'- Preview deploy failed because one environment variable is missing.',
].join('\n'),
},
{
type: 'text',
text: 'Next step: add `VITE_PUBLIC_STAGE_API_URL` to the preview environment, then rerun the deploy.',
},
],
tool_results: [],
categorization: {
speech: 'Here is the release update. Task scan succeeded, weather is clear, and preview deploy needs one missing environment variable.',
reasoning: [
'The user asked for a compact release update, so I should keep the final answer concise.',
'',
'I need to distinguish successful tool output from the failed preview deployment. The deploy error is actionable because it names the missing environment variable.',
].join('\n'),
},
},
{
role: 'assistant',
content: [
{ type: 'text', text: 'This assistant message has no `slices`, so the renderer falls back to the text part from array content.' },
],
slices: [],
tool_results: [],
},
{
role: 'error',
content: 'Provider stream aborted after the tool results were rendered. Retry from the previous user message if the final answer is incomplete.',
},
])
const markdownMessages = ref<ChatHistoryItem[]>([
{
role: 'user',
@@ -93,6 +183,65 @@ const toolHeavyMessages = computed<ChatHistoryItem[]>(() => [
},
])
const hybridToolFailureMessages = ref<ChatHistoryItem[]>([
{
role: 'user',
content: 'Start a chess game for me, then recover if the setup fails.',
},
{
role: 'assistant',
content: '',
slices: [
{
type: 'tool-call',
toolCall: {
toolName: 'play_chess',
args: JSON.stringify({ mode: 'focus' }),
toolCallId: 'hybrid-play-chess-focus',
toolCallType: 'function',
},
},
{
type: 'text',
text: 'I found the chess widget and brought it forward. I will try to bootstrap a fresh L1 match now.',
},
{
type: 'tool-call',
toolCall: {
toolName: 'play_chess',
args: JSON.stringify({
difficultyLevel: 'L1',
difficultyMode: 'manual',
fen: null,
mode: 'new',
notes: 'L1 game started; assistant plays White and will move first.',
opening: null,
pgn: null,
side: 'white',
}),
toolCallId: 'hybrid-play-chess-new-failed',
toolCallType: 'function',
},
},
{
type: 'text',
text: 'That setup attempt failed because focus mode rejected mutation inputs. I will keep the board open and retry with a clean new-game payload next.',
},
],
tool_results: [
{
id: 'hybrid-play-chess-focus',
result: 'Focused the existing chess gamelet.',
},
{
id: 'hybrid-play-chess-new-failed',
isError: true,
result: 'Focus mode does not accept game-state mutation inputs.',
},
],
},
])
const errorMessages = ref<ChatHistoryItem[]>([
{
role: 'user',
@@ -134,6 +283,15 @@ const streamingMessage = ref<ChatAssistantMessage>({
<ThemeColorsHueControl />
</template>
<Variant
id="all-normal-message-parts"
title="All Normal Message Parts"
>
<div class="font-cute">
<ChatHistory :messages="comprehensiveMessages" />
</div>
</Variant>
<Variant
id="with-tools-desktop"
title="With Tools"
@@ -182,6 +340,15 @@ const streamingMessage = ref<ChatAssistantMessage>({
</div>
</Variant>
<Variant
id="hybrid-tool-failure"
title="Hybrid Tool Failure"
>
<div class="font-cute">
<ChatHistory :messages="hybridToolFailureMessages" />
</div>
</Variant>
<Variant
id="streaming"
title="Streaming"
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { ChatAssistantMessage, ChatHistoryItem, ContextMessage } from '../../../../types/chat'
import type { ChatToolCallRendererRegistry } from './tool-call-renderer'
import { computed, provide, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -21,9 +22,11 @@ const props = withDefaults(defineProps<{
errorLabel?: string
retryLabel?: string
variant?: 'desktop' | 'mobile'
toolCallRenderers?: ChatToolCallRendererRegistry
}>(), {
sending: false,
variant: 'desktop',
toolCallRenderers: () => ({}),
})
const emit = defineEmits<{
@@ -125,6 +128,7 @@ function emitRetryMessage(message: ChatHistoryItem, index: number) {
:label="labels.assistant"
:show-placeholder="shouldShowPlaceholder(message) && showStreamingPlaceholder"
:variant="variant"
:tool-call-renderers="toolCallRenderers"
@copy="emitCopyMessage(message, index)"
@delete="emitDeleteMessage(message, index)"
/>
@@ -1,9 +1,8 @@
<script setup lang="ts">
import type { ChatAssistantMessage } from '../../../../types/chat'
import { Collapsible } from '@proj-airi/ui'
import { Truncatable } from '@proj-airi/ui'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { MarkdownRenderer } from '../../../markdown'
@@ -12,9 +11,8 @@ const props = defineProps<{
variant?: 'desktop' | 'mobile'
}>()
const { t } = useI18n()
const hasReasoning = computed(() => !!props.message.categorization?.reasoning?.trim())
const reasoningContent = computed(() => props.message.categorization?.reasoning?.trim() ?? '')
const hasReasoning = computed(() => reasoningContent.value.length > 0)
const containerClasses = computed(() => [
'mt-2',
@@ -24,33 +22,12 @@ const containerClasses = computed(() => [
<template>
<div v-if="hasReasoning" :class="containerClasses" flex="~ col" gap-1>
<Collapsible :default="false">
<template #trigger="slotProps">
<button
class="w-full flex items-center justify-between rounded-lg bg-neutral-100/50 px-2 py-1 text-xs text-neutral-600 outline-none transition-all duration-200 dark:bg-neutral-800/50 hover:bg-neutral-200/50 dark:text-neutral-400 dark:hover:bg-neutral-700/50"
@click="slotProps.setVisible(!slotProps.visible)"
>
<div flex="~ items-center" gap-1.5>
<div i-solar:lightbulb-bolt-bold-duotone size-3.5 text-amber-500 dark:text-amber-400 />
<span font-medium>{{ t('stage.chat.reasoning') }}</span>
</div>
<div
i-solar:alt-arrow-down-linear
size-3
transition="transform duration-200"
:class="{ 'rotate-180': slotProps.visible }"
/>
</button>
</template>
<div
class="mt-1 border border-neutral-200 rounded-md bg-neutral-50/80 px-2 py-1.5 dark:border-neutral-700 dark:bg-neutral-900/80"
>
<MarkdownRenderer
:content="message.categorization?.reasoning ?? ''"
class="break-words"
text="xs neutral-700 dark:neutral-300"
/>
</div>
</Collapsible>
<Truncatable :line-clamp="1">
<MarkdownRenderer
:content="reasoningContent"
:class="['break-words']"
text="sm neutral-700/50 dark:neutral-300/50"
/>
</Truncatable>
</div>
</template>
@@ -1,92 +1,17 @@
<script setup lang="ts">
import { MarkdownRenderer } from '@proj-airi/stage-ui/components'
import { useJournalPreviewStore } from '@proj-airi/stage-ui/stores/journal-preview'
import { Collapsible } from '@proj-airi/ui'
import { Collapsible, ContainerError } from '@proj-airi/ui'
import { computed } from 'vue'
import { createToolResultError } from './tool-call-display'
const props = defineProps<{
toolName: string
args: string
state?: 'executing' | 'done' | 'error'
result?: any
result?: unknown
}>()
const journalPreviewStore = useJournalPreviewStore()
const { openImagePreview } = journalPreviewStore
interface TextJournalArgs {
action?: string
title?: string
content?: string
}
interface ImageJournalArgs {
action?: string
prompt?: string
title?: string
mode?: 'inline' | 'widget' | 'bg'
}
const parsedArgs = computed<TextJournalArgs | null>(() => {
try {
return JSON.parse(props.args) as TextJournalArgs
}
catch {
return null
}
})
const isTextJournalCreate = computed(() => {
return props.toolName === 'text_journal'
&& parsedArgs.value?.action === 'create'
&& !!parsedArgs.value?.content?.trim()
})
const isImageJournalCreate = computed(() => {
return props.toolName === 'image_journal'
&& parsedArgs.value?.action === 'create'
&& !!(parsedArgs.value as ImageJournalArgs)?.prompt?.trim()
})
const textJournalMarkdown = computed(() => {
if (!isTextJournalCreate.value)
return ''
const title = parsedArgs.value?.title?.trim() || 'Journal Entry'
const content = parsedArgs.value?.content?.trim() || ''
return `# ${title}\n\n${content}`
})
const imageJournalMarkdown = computed(() => {
if (!isImageJournalCreate.value)
return ''
const args = parsedArgs.value as ImageJournalArgs
const title = args?.title?.trim() || 'Untitled Image'
const prompt = args?.prompt?.trim() || ''
const mode = args?.mode || 'inline'
let footer = ''
if (mode === 'bg')
footer = '\n\n> **Scene Shift**: Setting this as the active background...'
else if (mode === 'widget')
footer = '\n\n> **Canvas Created**: Spawning an artistry widget for you...'
else
footer = '\n\n> **Sharing**: Sending a quick sketch to our chat history...'
return `### ${title}\n\n*${prompt}*${footer}`
})
const imageJournalResult = computed(() => {
if (props.toolName !== 'image_journal' || !props.result)
return null
try {
return typeof props.result === 'string' ? JSON.parse(props.result) : props.result
}
catch {
return null
}
})
const resultError = computed(() => props.state === 'error' ? createToolResultError(props.result) : undefined)
const formattedArgs = computed(() => {
try {
@@ -102,7 +27,7 @@ const formattedArgs = computed(() => {
<template>
<Collapsible
:class="[
'bg-primary-100/40 dark:bg-primary-900/60 rounded-lg px-2 pb-2 pt-2',
'bg-primary-100/40 dark:bg-primary-900/60 rounded-lg px-1 pb-1 pt-1',
'flex flex-col gap-2 items-start',
]"
>
@@ -110,29 +35,27 @@ const formattedArgs = computed(() => {
<button
:class="[
'w-full text-start',
'inline-flex items-center',
]"
@click="setVisible(!visible)"
>
<div
v-if="state === 'executing'"
i-eos-icons:loading class="mr-1 inline-block translate-y-0.5 op-50"
i-eos-icons:loading class="mr-1 inline-block op-50"
/>
<div
v-else-if="state === 'error'"
i-ph:warning-circle-duotone class="mr-1 inline-block translate-y-0.5 text-red-500"
i-solar:danger-circle-bold-duotone class="mr-1 inline-block text-red-500"
/>
<div
v-else-if="state === 'done'"
i-ph:check-circle-duotone class="mr-1 inline-block translate-y-0.5 text-emerald-500"
i-solar:check-circle-bold-duotone class="mr-1 inline-block text-emerald-500"
/>
<div
v-else
i-solar:sledgehammer-bold-duotone class="mr-1 inline-block translate-y-1 op-50"
/>
<code>{{ toolName }}</code>
<span v-if="state === 'error' && result" class="ml-2 text-xs text-red-500 op-80">
({{ result }})
</span>
<code class="text-xs">{{ toolName }}</code>
</button>
</template>
<div
@@ -141,38 +64,19 @@ const formattedArgs = computed(() => {
'bg-neutral-100/80 text-sm text-neutral-800 dark:bg-neutral-900/80 dark:text-neutral-200',
]"
>
<template v-if="isTextJournalCreate">
<div class="mb-2 flex items-center gap-2">
<div class="i-solar:notebook-bookmark-bold-duotone text-base text-emerald-500" />
<div class="rounded-full bg-emerald-500/12 px-2.5 py-1 text-xs text-emerald-700 dark:text-emerald-300">
Saved to long-term memory
</div>
</div>
<MarkdownRenderer :content="textJournalMarkdown" />
</template>
<template v-else-if="isImageJournalCreate">
<div class="mb-2 flex items-center gap-2">
<div :class="[(parsedArgs as ImageJournalArgs)?.mode === 'bg' ? 'i-solar:gallery-wide-bold-duotone text-emerald-500' : 'i-solar:camera-bold-duotone text-violet-500']" class="text-base" />
<div
class="rounded-full px-2.5 py-1 text-xs"
:class="[
(parsedArgs as ImageJournalArgs)?.mode === 'bg'
? 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300'
: 'bg-violet-500/12 text-violet-700 dark:text-violet-300',
]"
>
{{ (parsedArgs as ImageJournalArgs)?.mode === 'bg' ? 'Updating Scene' : 'Generating image' }}
</div>
</div>
<MarkdownRenderer :content="imageJournalMarkdown" />
<!-- Result Rendering (for inline mode) -->
<div v-if="imageJournalResult?.imageUrl" class="mt-4 overflow-hidden border border-primary-500/20 rounded-xl shadow-lg">
<img
:src="imageJournalResult.imageUrl"
class="w-full cursor-pointer object-contain transition-all active:scale-[0.98] hover:ring-2 hover:ring-primary-500/50"
@click="openImagePreview({ title: (parsedArgs as ImageJournalArgs)?.title || 'Generated Image', url: imageJournalResult.imageUrl })"
>
<template v-if="resultError">
<ContainerError
:error="resultError"
:include-stack="false"
:show-feedback-button="false"
height-preset="auto"
/>
<div
:class="[
'mt-2 whitespace-pre-wrap break-words font-mono',
]"
>
{{ formattedArgs }}
</div>
</template>
<div v-else class="whitespace-pre-wrap break-words font-mono">
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { createToolResultError, normalizeToolResultText } from './tool-call-display'
describe('tool call display helpers', () => {
/**
* @example
* expect(normalizeToolResultText({ ok: true })).toContain('"ok": true')
*/
it('normalizes structured tool results into copyable text', () => {
const text = normalizeToolResultText({
ok: true,
mode: 'focus',
})
expect(text).toContain('"ok": true')
expect(text).toContain('"mode": "focus"')
})
/**
* @example
* expect(createToolResultError('Tool failed')?.message).toBe('Tool failed')
*/
it('creates an Error wrapper for failed tool results', () => {
const error = createToolResultError('Tool call error for "play_chess": Focus mode does not accept game-state mutation inputs.')
expect(error).toBeInstanceOf(Error)
expect(error?.message).toContain('Focus mode does not accept game-state mutation inputs.')
})
})
@@ -0,0 +1,48 @@
/**
* Normalizes a tool result into readable text for compact chat UI.
*
* Before:
* - { ok: true, mode: "focus" }
* - "Tool call error for \"play_chess\": failed"
*
* After:
* - "{\n \"ok\": true,\n \"mode\": \"focus\"\n}"
* - "Tool call error for \"play_chess\": failed"
*/
export function normalizeToolResultText(result: unknown): string {
if (result == null) {
return ''
}
if (typeof result === 'string') {
return result.trim()
}
try {
return JSON.stringify(result, null, 2).trim()
}
catch {
return String(result).trim()
}
}
/**
* Creates a displayable `Error` from a failed tool result.
*
* Use when:
* - A tool call block needs to reuse the shared copyable error panel
*
* Expects:
* - Tool failures may arrive as strings or structured values
*
* Returns:
* - `Error` when there is readable text, otherwise `undefined`
*/
export function createToolResultError(result: unknown): Error | undefined {
const message = normalizeToolResultText(result)
if (!message) {
return undefined
}
return new Error(message)
}
@@ -0,0 +1,27 @@
import type { Component } from 'vue'
/**
* Props passed to a tool-call renderer component.
*
* Use when:
* - A runtime registers a custom renderer for a tool name
* - A generic chat surface needs to forward tool-call render data without owning runtime state
*
* Expects:
* - `args` is the raw serialized tool-call argument payload
* - `result` is the latest matching tool-call result when available
*
* Returns:
* - A prop contract that custom runtime renderers can implement
*/
export interface ChatToolCallRendererProps {
toolName: string
args: string
state?: 'executing' | 'done' | 'error'
result?: unknown
}
/**
* Maps tool names to custom renderer components.
*/
export type ChatToolCallRendererRegistry = Partial<Record<string, Component>>
@@ -0,0 +1,84 @@
import type { ChatAssistantMessage } from '../../../../types/chat'
import { describe, expect, it } from 'vitest'
import { createToolCallResultLookup, resolveToolCallBlockState } from './tool-call-results'
describe('tool call result lookup', () => {
/**
* @example
* expect(resolveToolCallBlockState(undefined)).toBe('executing')
*/
it('marks a tool call without a result as executing', () => {
expect(resolveToolCallBlockState(undefined)).toBe('executing')
})
/**
* @example
* expect(resolveToolCallBlockState(result)).toBe('done')
*/
it('marks a successful tool result as done', () => {
const message: ChatAssistantMessage = {
role: 'assistant',
content: '',
slices: [
{
type: 'tool-call',
toolCall: {
toolCallId: 'call-weather',
toolCallType: 'function',
toolName: 'weather',
args: JSON.stringify({ location: 'Tokyo' }),
},
},
{
type: 'tool-call-result',
id: 'call-weather',
result: 'Tokyo is clear with light wind.',
},
],
tool_results: [],
}
const lookup = createToolCallResultLookup(message.slices, message.tool_results)
const result = lookup.get('call-weather')
expect(result?.result).toBe('Tokyo is clear with light wind.')
expect(resolveToolCallBlockState(result)).toBe('done')
})
/**
* @example
* expect(resolveToolCallBlockState(result)).toBe('error')
*/
it('pairs a failed tool result with its tool call id', () => {
const message: ChatAssistantMessage = {
role: 'assistant',
content: '',
slices: [
{
type: 'tool-call',
toolCall: {
toolCallId: 'call-play-chess',
toolCallType: 'function',
toolName: 'play_chess',
args: JSON.stringify({ mode: 'new', side: 'white' }),
},
},
],
tool_results: [
{
id: 'call-play-chess',
isError: true,
result: 'Focus mode does not accept game-state mutation inputs.',
},
],
}
const lookup = createToolCallResultLookup(message.slices, message.tool_results)
const result = lookup.get('call-play-chess')
expect(result?.result).toBe('Focus mode does not accept game-state mutation inputs.')
expect(resolveToolCallBlockState(result)).toBe('error')
})
})
@@ -0,0 +1,57 @@
import type { ChatAssistantMessage, ChatSlices, ChatSlicesToolCallResult } from '../../../../types/chat'
/**
* Creates a lookup from tool-call id to its latest result slice.
*
* Use when:
* - Rendering assistant messages with separate `tool-call` and `tool-call-result` data
* - Streaming messages store tool results on `tool_results` instead of inline slices
*
* Expects:
* - Tool call ids use `toolCall.toolCallId`
* - Tool result ids use `id`
*
* Returns:
* - A map keyed by tool call id, preferring inline result slices over stored results
*/
export function createToolCallResultLookup(
slices: ChatSlices[],
toolResults: ChatAssistantMessage['tool_results'] = [],
): Map<string, ChatSlicesToolCallResult> {
const resultMap = new Map<string, ChatSlicesToolCallResult>()
for (const result of toolResults) {
resultMap.set(result.id, {
type: 'tool-call-result',
...result,
})
}
for (const slice of slices) {
if (slice.type === 'tool-call-result') {
resultMap.set(slice.id, slice)
}
}
return resultMap
}
/**
* Resolves the visual state for a tool call block from its result.
*
* Use when:
* - Tool call UI needs to show success or failure without replacing the assistant message
*
* Expects:
* - Missing result means the call is still running
*
* Returns:
* - `executing` for missing results, `error` for failed results, or `done` for successful results
*/
export function resolveToolCallBlockState(result: ChatSlicesToolCallResult | undefined): 'executing' | 'done' | 'error' {
if (!result) {
return 'executing'
}
return result.isError ? 'error' : 'done'
}
@@ -2,5 +2,7 @@ export { ChatActionMenu } from './components/action-menu'
export { default as ChatAssistantItem } from './components/assistant-item.vue'
export { default as ChatErrorItem } from './components/error-item.vue'
export { default as ChatHistory } from './components/history.vue'
export { createToolResultError, normalizeToolResultText } from './components/tool-call-display'
export type { ChatToolCallRendererProps, ChatToolCallRendererRegistry } from './components/tool-call-renderer'
export { default as ChatUserItem } from './components/user-item.vue'
export { default as JournalPreviewModal } from './JournalPreviewModal.vue'
@@ -157,11 +157,10 @@ async function copyContent() {
<template>
<div
:class="[
'relative w-full rounded-xl border-2 border-red-200/70 bg-red-50/60 backdrop-blur-md p-1',
'dark:border-red-900/50 dark:bg-red-950/25',
'relative w-full rounded-lg bg-red-50/60 dark:bg-red-950/25 backdrop-blur-md p-1',
]"
>
<div :class="['absolute right-4 -translate-x-full top-2 z-10']">
<div :class="['absolute right-2 -translate-x-full top-2 z-10']">
<Button
v-if="showCopyButton"
size="sm"
@@ -172,9 +171,7 @@ async function copyContent() {
:aria-label="copied ? copiedButtonLabel : copyButtonLabel"
@click="copyContent"
/>
</div>
<div :class="['absolute right-2 top-2 z-10']">
<Button
v-if="showFeedbackButton"
size="sm"
@@ -191,13 +188,12 @@ async function copyContent() {
type="auto"
:class="[
'relative w-full overflow-hidden rounded-xl',
'bg-neutral-50/80 dark:bg-neutral-950/80',
...heightPresetClasses[heightPreset],
]"
>
<ScrollAreaViewport :class="['h-full w-full']">
<div :class="['flex flex-col gap-2 p-3 text-xs']">
<div v-if="resolvedErrorName || resolvedMessage" :class="['font-mono font-semibold text-red-700 leading-relaxed dark:text-red-300']">
<div v-if="resolvedErrorName || resolvedMessage" :class="['font-mono text-red-700 leading-relaxed dark:text-red-300']">
{{ resolvedErrorName || 'Error' }}
<span v-if="resolvedMessage">
: {{ resolvedMessage }}