refactor(stage-ui): advanced tool rendering, error handling, and ui improvements
This commit is contained in:
@@ -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)"
|
||||
/>
|
||||
|
||||
+223
@@ -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 }}
|
||||
|
||||
Reference in New Issue
Block a user