refactor(stage-*): unified chat history & chat item
This commit is contained in:
@@ -1,207 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { Collapsable, MarkdownRenderer } from '@proj-airi/stage-ui/components'
|
||||
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
|
||||
import { useBroadcastChannel } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const chatHistoryRef = ref<HTMLDivElement>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { messages, sending, streamingMessage } = storeToRefs(useChatStore())
|
||||
|
||||
const { onBeforeMessageComposed, onTokenLiteral } = useChatStore()
|
||||
|
||||
// Presentation channel: show assistant text only when corresponding TTS segment starts
|
||||
type PresentEvent
|
||||
= | { type: 'assistant-reset' }
|
||||
| { type: 'assistant-append', text: string }
|
||||
const { data: presentEvent } = useBroadcastChannel<PresentEvent, PresentEvent>({ name: 'airi-chat-present' })
|
||||
const presentSlices = ref<string[]>([])
|
||||
|
||||
watch(presentEvent, (ev) => {
|
||||
if (!ev)
|
||||
return
|
||||
if (ev.type === 'assistant-reset') {
|
||||
presentSlices.value = []
|
||||
}
|
||||
else if (ev.type === 'assistant-append') {
|
||||
presentSlices.value.push(ev.text)
|
||||
}
|
||||
})
|
||||
|
||||
function scrollToBottom() {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (!chatHistoryRef.value)
|
||||
return
|
||||
|
||||
chatHistoryRef.value.scrollTop = chatHistoryRef.value.scrollHeight
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
onBeforeMessageComposed(async () => {
|
||||
// Scroll down to the new sent message
|
||||
await scrollToBottom()
|
||||
})
|
||||
|
||||
onTokenLiteral(async () => {
|
||||
// Scroll down to the new responding message
|
||||
await scrollToBottom()
|
||||
})
|
||||
|
||||
watch(sending, () => {
|
||||
scrollToBottom()
|
||||
}, { flush: 'post' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="chatHistoryRef" v-auto-animate flex="~ col" relative h-full w-full overflow-y-auto rounded-xl px="<sm:2" py="<sm:2">
|
||||
<div v-for="(message, index) in messages" :key="index" mb-2>
|
||||
<div v-if="message.role === 'error'" flex mr="12">
|
||||
<div
|
||||
flex="~ col" shadow="sm violet-200/50 dark:none"
|
||||
min-w-20 rounded-xl px-3 py-3 h="unset <sm:fit"
|
||||
class="bg-violet-100/80 dark:bg-violet-950/80"
|
||||
>
|
||||
<div flex="~ row" gap-2>
|
||||
<div flex-1 class="inline <sm:hidden">
|
||||
<span text-sm text="black/60 dark:white/65" font-normal>{{ t('stage.chat.message.character-name.core-system') }}</span>
|
||||
</div>
|
||||
<div i-solar:danger-triangle-bold-duotone text-violet-500 />
|
||||
</div>
|
||||
<div v-if="sending && index === messages.length - 1" i-eos-icons:three-dots-loading />
|
||||
<MarkdownRenderer
|
||||
v-else
|
||||
:content="message.content as string"
|
||||
class="break-words text-violet-500 dark:text-violet-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="message.role === 'assistant'" flex mr="12">
|
||||
<div
|
||||
flex="~ col" shadow="sm primary-200/50 dark:none"
|
||||
min-w-20 rounded-xl px-3 py-3 h="unset <sm:fit"
|
||||
class="bg-primary-50/80 dark:bg-primary-950/80"
|
||||
>
|
||||
<div>
|
||||
<span text-sm text="black/60 dark:white/65" font-normal class="inline <sm:hidden">{{ t('stage.chat.message.character-name.airi') }}</span>
|
||||
</div>
|
||||
<div v-if="message.content" class="break-words" text="primary-700 dark:primary-100">
|
||||
<div v-for="(slice, sliceIndex) in message.slices" :key="sliceIndex">
|
||||
<div v-if="slice.type === 'tool-call'" mb-2>
|
||||
<Collapsable
|
||||
: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 i-solar:sledgehammer-bold-duotone class="mr-1 inline-block translate-y-1 op-50" /><code>{{ slice.toolCall.toolName }}</code>
|
||||
</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',
|
||||
]"
|
||||
>
|
||||
<div class="whitespace-pre-wrap break-words font-mono">
|
||||
{{ JSON.stringify(JSON.parse(slice.toolCall.args), null, 2).trim() }}
|
||||
</div>
|
||||
</div>
|
||||
</Collapsable>
|
||||
</div>
|
||||
<div v-else-if="slice.type === 'tool-call-result'" /> <!-- this line should be unreachable -->
|
||||
<MarkdownRenderer
|
||||
v-else
|
||||
:content="slice.text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="index === messages.length - 1 && !message.content" i-eos-icons:three-dots-loading />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="message.role === 'user'" flex="~ row-reverse" ml="12">
|
||||
<div
|
||||
flex="~ col" shadow="sm neutral-200/50 dark:none" px="2" h="unset <sm:fit"
|
||||
min-w-20 rounded-xl px-3 py-3
|
||||
class="bg-neutral-100/80 dark:bg-neutral-800/80"
|
||||
>
|
||||
<div>
|
||||
<span text-sm text="black/60 dark:white/65" font-normal class="inline <sm:hidden">{{ t('stage.chat.message.character-name.you') }}</span>
|
||||
</div>
|
||||
<MarkdownRenderer
|
||||
v-if="message.content"
|
||||
:content="message.content as string"
|
||||
class="break-words"
|
||||
/>
|
||||
<div v-else />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="sending" flex mr="12">
|
||||
<div
|
||||
flex="~ col" shadow="sm primary-200/50 dark:none"
|
||||
min-w-20 rounded-xl px-3 py-3 h="unset <sm:fit"
|
||||
class="bg-primary-50/80 dark:bg-primary-950/80"
|
||||
>
|
||||
<div>
|
||||
<span text-sm text="black/60 dark:white/65" font-normal class="inline <sm:hidden">{{ t('stage.chat.message.character-name.airi') }}</span>
|
||||
</div>
|
||||
<div v-if="presentSlices.length > 0 || streamingMessage.content" class="break-words" text="primary-700 dark:primary-100">
|
||||
<!-- Prefer presentation slices if available; fallback to normal streaming -->
|
||||
<template v-if="presentSlices.length > 0">
|
||||
<div v-for="(text, idx) in presentSlices" :key="`present-${idx}`">
|
||||
<MarkdownRenderer :content="text" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-for="(slice, sliceIndex) in streamingMessage.slices" :key="sliceIndex">
|
||||
<div v-if="slice.type === 'tool-call'" mb-2>
|
||||
<Collapsable
|
||||
: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 i-solar:sledgehammer-bold-duotone class="mr-1 inline-block translate-y-1 op-50" /><code>{{ slice.toolCall.toolName }}</code>
|
||||
</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',
|
||||
]"
|
||||
>
|
||||
<div class="whitespace-pre-wrap break-words font-mono">
|
||||
{{ JSON.stringify(JSON.parse(slice.toolCall.args), null, 2).trim() }}
|
||||
</div>
|
||||
</div>
|
||||
</Collapsable>
|
||||
</div>
|
||||
<div v-else-if="slice.type === 'tool-call-result'" />
|
||||
<MarkdownRenderer v-else :content="slice.text" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else i-eos-icons:three-dots-loading />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChatProvider } from '@xsai-ext/shared-providers'
|
||||
|
||||
import { ChatHistory } from '@proj-airi/stage-ui/components/scenarios/chat'
|
||||
import { useMicVAD } from '@proj-airi/stage-ui/composables'
|
||||
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
|
||||
import { useConsciousnessStore } from '@proj-airi/stage-ui/stores/modules/consciousness'
|
||||
@@ -11,8 +12,6 @@ import { storeToRefs } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import TamagotchiChatHistory from './ChatHistory.vue'
|
||||
|
||||
import { widgetsTools } from '../stores/tools/builtin/widgets'
|
||||
|
||||
const messageInput = ref('')
|
||||
@@ -21,8 +20,9 @@ const attachments = ref<{ type: 'image', data: string, mimeType: string, url: st
|
||||
|
||||
const { askPermission } = useSettingsAudioDevice()
|
||||
const { enabled, selectedAudioInput } = storeToRefs(useSettingsAudioDevice())
|
||||
const { send, onAfterMessageComposed, discoverToolsCompatibility, cleanupMessages } = useChatStore()
|
||||
const { messages } = storeToRefs(useChatStore())
|
||||
const chatStore = useChatStore()
|
||||
const { send, onAfterMessageComposed, discoverToolsCompatibility, cleanupMessages } = chatStore
|
||||
const { messages, sending, streamingMessage } = storeToRefs(chatStore)
|
||||
const { t } = useI18n()
|
||||
const providersStore = useProvidersStore()
|
||||
const { activeModel, activeProvider } = storeToRefs(useConsciousnessStore())
|
||||
@@ -147,7 +147,11 @@ onAfterMessageComposed(async () => {
|
||||
<template>
|
||||
<div h-full w-full flex="~ col gap-1">
|
||||
<div w-full flex-1 overflow-hidden>
|
||||
<TamagotchiChatHistory />
|
||||
<ChatHistory
|
||||
:messages="messages"
|
||||
:sending="sending"
|
||||
:streaming-message="streamingMessage"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="attachments.length > 0" class="flex flex-wrap gap-2 border-t border-primary-100 p-2">
|
||||
<div v-for="(attachment, index) in attachments" :key="index" class="relative">
|
||||
@@ -173,7 +177,7 @@ onAfterMessageComposed(async () => {
|
||||
<BasicTextarea
|
||||
v-model="messageInput"
|
||||
:placeholder="t('stage.message')"
|
||||
text="primary-600 dark:primary-100 placeholder:primary-500"
|
||||
text="primary-600 dark:primary-100 placeholder:primary-500 dark:placeholder:primary-200"
|
||||
border="solid 2 primary-200/20 dark:primary-400/20"
|
||||
bg="primary-100/50 dark:primary-900/70"
|
||||
max-h="[10lh]" min-h="[1lh]"
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { ChatHistory } from '@proj-airi/stage-ui/components/scenarios/chat'
|
||||
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
|
||||
import { useDeferredMount } from '@proj-airi/ui'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ChatActionButtons from '../Widgets/ChatActionButtons.vue'
|
||||
import ChatArea from '../Widgets/ChatArea.vue'
|
||||
import ChatContainer from '../Widgets/ChatContainer.vue'
|
||||
import ChatHistory from '../Widgets/ChatHistory.vue'
|
||||
|
||||
const { isReady } = useDeferredMount()
|
||||
const { messages, sending, streamingMessage } = storeToRefs(useChatStore())
|
||||
|
||||
const isLoading = ref(true)
|
||||
</script>
|
||||
@@ -23,8 +26,16 @@ const isLoading = ref(true)
|
||||
>
|
||||
<div h-full w="1/3" origin-left bg-primary-500 class="animate-scan" />
|
||||
</div>
|
||||
<div w="full" max-h="<md:[60%]" py="<sm:2" flex="~ col" rounded="lg" relative h-full flex-1 overflow-hidden py-4>
|
||||
<ChatHistory v-if="isReady" h-full @vue:mounted="isLoading = false" />
|
||||
<div w="full" max-h="<md:[60%]" py="<sm:2" flex="~ col" rounded="lg" relative h-full flex-1 overflow-hidden px="2 <md:0" py-4>
|
||||
<ChatHistory
|
||||
v-if="isReady"
|
||||
:messages="messages"
|
||||
:sending="sending"
|
||||
:streaming-message="streamingMessage"
|
||||
h-full
|
||||
variant="desktop"
|
||||
@vue:mounted="isLoading = false"
|
||||
/>
|
||||
</div>
|
||||
<ChatArea />
|
||||
</ChatContainer>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { ChatProvider } from '@xsai-ext/shared-providers'
|
||||
|
||||
import { HearingConfigDialog } from '@proj-airi/stage-ui/components'
|
||||
import { ChatHistory } from '@proj-airi/stage-ui/components/scenarios/chat'
|
||||
import { useAudioAnalyzer } from '@proj-airi/stage-ui/composables'
|
||||
import { useAudioContext } from '@proj-airi/stage-ui/stores/audio'
|
||||
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
|
||||
@@ -16,13 +17,13 @@ import { useI18n } from 'vue-i18n'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import IndicatorMicVolume from '../Widgets/IndicatorMicVolume.vue'
|
||||
import MobileChatHistory from '../Widgets/MobileChatHistory.vue'
|
||||
import ActionAbout from './InteractiveArea/Actions/About.vue'
|
||||
import ActionViewControls from './InteractiveArea/Actions/ViewControls.vue'
|
||||
import ViewControlInputs from './ViewControls/Inputs.vue'
|
||||
|
||||
const { isDark, toggleDark } = useTheme()
|
||||
const hearingDialogOpen = ref(false)
|
||||
const { messages, sending, streamingMessage } = storeToRefs(useChatStore())
|
||||
|
||||
const viewControlsActiveMode = ref<'x' | 'y' | 'z' | 'scale'>('scale')
|
||||
const viewControlsInputsRef = useTemplateRef<InstanceType<typeof ViewControlInputs>>('viewControlsInputs')
|
||||
@@ -39,7 +40,6 @@ const { themeColorsHueDynamic, stageViewControlsEnabled } = storeToRefs(useSetti
|
||||
const settingsAudioDevice = useSettingsAudioDevice()
|
||||
const { enabled, selectedAudioInput, stream, audioInputs } = storeToRefs(settingsAudioDevice)
|
||||
const { send, onAfterMessageComposed, discoverToolsCompatibility, cleanupMessages } = useChatStore()
|
||||
const { messages } = storeToRefs(useChatStore())
|
||||
const { t } = useI18n()
|
||||
const { audioContext } = useAudioContext()
|
||||
const { startAnalyzer, stopAnalyzer, volumeLevel } = useAudioAnalyzer()
|
||||
@@ -133,7 +133,21 @@ onMounted(() => {
|
||||
<div fixed bottom-0 w-full flex flex-col>
|
||||
<KeepAlive>
|
||||
<Transition name="fade">
|
||||
<MobileChatHistory v-if="!stageViewControlsEnabled" max-w="[calc(100%-3.5rem)]" w-full self-start pl-3 />
|
||||
<ChatHistory
|
||||
v-if="!stageViewControlsEnabled"
|
||||
variant="mobile"
|
||||
:messages="messages"
|
||||
:sending="sending"
|
||||
:streaming-message="streamingMessage"
|
||||
max-w="[calc(100%-3.5rem)]"
|
||||
w-full
|
||||
self-start
|
||||
pl-3
|
||||
class="chat-history"
|
||||
:class="[
|
||||
'relative z-20',
|
||||
]"
|
||||
/>
|
||||
</Transition>
|
||||
</KeepAlive>
|
||||
<div relative w-full self-end>
|
||||
@@ -220,3 +234,37 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@keyframes scan {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(400%);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-scan {
|
||||
animation: scan 2s infinite linear;
|
||||
}
|
||||
|
||||
/*
|
||||
DO NOT ATTEMPT TO USE backdrop-filter TOGETHER WITH mask-image.
|
||||
|
||||
html - Why doesn't blur backdrop-filter work together with mask-image? - Stack Overflow
|
||||
https://stackoverflow.com/questions/72780266/why-doesnt-blur-backdrop-filter-work-together-with-mask-image
|
||||
*/
|
||||
.chat-history {
|
||||
--gradient: linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 20%);
|
||||
-webkit-mask-image: var(--gradient);
|
||||
mask-image: var(--gradient);
|
||||
-webkit-mask-size: 100% 100%;
|
||||
mask-size: 100% 100%;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
-webkit-mask-position: bottom;
|
||||
mask-position: bottom;
|
||||
max-height: 35dvh;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -116,7 +116,7 @@ onUnmounted(() => {
|
||||
<BasicTextarea
|
||||
v-model="messageInput"
|
||||
:placeholder="t('stage.message')"
|
||||
text="primary-500 hover:primary-600 dark:primary-300/50 dark:hover:primary-500 placeholder:primary-400 placeholder:hover:primary-500 placeholder:dark:primary-300/50 placeholder:dark:hover:primary-500"
|
||||
text="primary-600 dark:primary-100 placeholder:primary-500 dark:placeholder:primary-200"
|
||||
bg="transparent"
|
||||
min-h="[100px]" max-h="[300px]" w-full
|
||||
rounded-t-xl p-4 font-medium
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { MarkdownRenderer } from '@proj-airi/stage-ui/components'
|
||||
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const chatHistoryRef = ref<HTMLDivElement>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { messages, sending, streamingMessage } = storeToRefs(useChatStore())
|
||||
|
||||
const { onBeforeMessageComposed, onTokenLiteral } = useChatStore()
|
||||
|
||||
function scrollToBottom() {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (!chatHistoryRef.value)
|
||||
return
|
||||
|
||||
chatHistoryRef.value.scrollTop = chatHistoryRef.value.scrollHeight
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
onBeforeMessageComposed(async () => {
|
||||
// Scroll down to the new sent message
|
||||
await scrollToBottom()
|
||||
})
|
||||
|
||||
onTokenLiteral(async () => {
|
||||
// Scroll down to the new responding message
|
||||
await scrollToBottom()
|
||||
})
|
||||
|
||||
watch(sending, () => {
|
||||
scrollToBottom()
|
||||
}, { flush: 'post' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div overflow-hidden>
|
||||
<div flex-1 /> <!-- spacer -->
|
||||
<div ref="chatHistoryRef" v-auto-animate px="<sm:2" flex="~ col" h-full w-full overflow-scroll px-4>
|
||||
<div flex-1 /> <!-- spacer -->
|
||||
<div v-for="(message, index) in messages" :key="index" mb-2>
|
||||
<div v-if="message.role === 'error'" flex mr="12">
|
||||
<div
|
||||
flex="~ col" shadow="md violet-900/50 dark:none"
|
||||
min-w-20 rounded-lg px-2 py-1 h="unset <sm:fit"
|
||||
class="bg-violet-50/80 <md:bg-violet-500/25 dark:bg-violet-900/80"
|
||||
>
|
||||
<div flex="~ row" gap-2>
|
||||
<div flex-1>
|
||||
<span text-xs text="violet-400/90 dark:violet-600/90" font-normal class="inline <sm:hidden">{{ t('stage.chat.message.character-name.core-system') }}</span>
|
||||
</div>
|
||||
<div i-solar:danger-triangle-bold-duotone text-violet-500 />
|
||||
</div>
|
||||
<div v-if="sending && index === messages.length - 1" i-eos-icons:three-dots-loading />
|
||||
<MarkdownRenderer
|
||||
v-else
|
||||
:content="message.content as string"
|
||||
class="break-words text-violet-500"
|
||||
text="base <sm:xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="message.role === 'assistant'" flex mr="12">
|
||||
<div
|
||||
flex="~ col" shadow="sm primary-200/50 dark:none" min-w-20
|
||||
rounded-lg px-2 py-1 h="unset <sm:fit"
|
||||
class="bg-primary-50/80 <md:bg-primary-500/25 dark:bg-primary-900/80"
|
||||
>
|
||||
<div>
|
||||
<span text-xs text="primary-400/90 dark:primary-600/90" font-normal class="inline <sm:hidden">{{ t('stage.chat.message.character-name.airi') }}</span>
|
||||
</div>
|
||||
<div v-if="message.content" class="break-words" text="primary-700 dark:primary-200">
|
||||
<div v-for="(slice, sliceIndex) in message.slices" :key="sliceIndex">
|
||||
<div v-if="slice.type === 'tool-call'">
|
||||
<div
|
||||
p="1" border="1 solid primary-200" rounded-lg m="y-1" bg="primary-100"
|
||||
>
|
||||
Called: <code>{{ slice.toolCall.toolName }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="slice.type === 'tool-call-result'" /> <!-- this line should be unreachable -->
|
||||
<MarkdownRenderer
|
||||
v-else
|
||||
:content="slice.text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="index === messages.length - 1 && !message.content" i-eos-icons:three-dots-loading />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="message.role === 'user'" flex="~ row-reverse" ml="12">
|
||||
<div
|
||||
flex="~ col" shadow="sm cyan-200/50 dark:none" px="2"
|
||||
h="unset <sm:fit" min-w-20 rounded-lg px-2 py-1
|
||||
class="bg-cyan-50/80 <md:bg-cyan-500/25 dark:bg-cyan-900/80"
|
||||
>
|
||||
<div>
|
||||
<span text-xs text="cyan-400/90 dark:cyan-600/90" font-normal class="inline <sm:hidden">{{ t('stage.chat.message.character-name.you') }}</span>
|
||||
</div>
|
||||
<MarkdownRenderer
|
||||
v-if="message.content"
|
||||
:content="message.content as string"
|
||||
class="break-words"
|
||||
text="base <sm:xs"
|
||||
/>
|
||||
<div v-else />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="sending" flex mr="12">
|
||||
<div
|
||||
flex="~ col" shadow="sm primary-200/50 dark:none" min-w-20
|
||||
rounded-lg px-2 py-1 h="unset <sm:fit"
|
||||
class="bg-primary-50/80 <md:bg-primary-500/25 dark:bg-primary-900/80"
|
||||
>
|
||||
<div>
|
||||
<span text-xs text="primary-400/90 dark:primary-600/90" font-normal class="inline <sm:hidden">{{ t('stage.chat.message.character-name.airi') }}</span>
|
||||
</div>
|
||||
<div v-if="streamingMessage.content" class="break-words" text="primary-700 dark:primary-200">
|
||||
<div v-for="(slice, sliceIndex) in streamingMessage.slices" :key="sliceIndex">
|
||||
<div v-if="slice.type === 'tool-call'">
|
||||
<div
|
||||
p="1" border="1 solid primary-200" rounded-lg m="y-1" bg="primary-100"
|
||||
>
|
||||
Called: <code>{{ slice.toolCall.toolName }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="slice.type === 'tool-call-result'" /> <!-- this line should be unreachable -->
|
||||
<MarkdownRenderer
|
||||
v-else
|
||||
:content="slice.text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else i-eos-icons:three-dots-loading />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,129 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { MarkdownRenderer } from '@proj-airi/stage-ui/components'
|
||||
import { useChatStore } from '@proj-airi/stage-ui/stores/chat'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const chatHistoryRef = ref<HTMLDivElement>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { messages } = storeToRefs(useChatStore())
|
||||
|
||||
const { onBeforeMessageComposed, onTokenLiteral } = useChatStore()
|
||||
|
||||
onBeforeMessageComposed(async () => {
|
||||
// Scroll down to the new sent message
|
||||
nextTick().then(() => {
|
||||
if (!chatHistoryRef.value)
|
||||
return
|
||||
|
||||
chatHistoryRef.value.scrollTop = chatHistoryRef.value.scrollHeight
|
||||
})
|
||||
})
|
||||
|
||||
onTokenLiteral(async () => {
|
||||
// Scroll down to the new responding message
|
||||
nextTick().then(() => {
|
||||
if (!chatHistoryRef.value)
|
||||
return
|
||||
|
||||
chatHistoryRef.value.scrollTop = chatHistoryRef.value.scrollHeight
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex="~ col" rounded="lg" overflow-hidden>
|
||||
<div ref="chatHistoryRef" v-auto-animate max-h="35dvh" z-5 flex="~ col" h-full w-full overflow-scroll class="chat-history">
|
||||
<div v-for="(message, index) in messages" :key="index" mb-2>
|
||||
<div v-if="message.role === 'error'" flex mr="12">
|
||||
<div
|
||||
flex="~ col"
|
||||
shadow="sm violet-200/50 dark:none"
|
||||
min-w-20 rounded-lg px-3 py-2
|
||||
h="unset <sm:fit"
|
||||
bg="violet-100 dark:violet-800"
|
||||
backdrop-blur-sm
|
||||
>
|
||||
<div flex="~ row" items-center justify-between gap-2>
|
||||
<div>
|
||||
<span text-xs text="violet-400/90 dark:violet-600/90" font-normal>{{ t('stage.chat.message.character-name.core-system') }}</span>
|
||||
</div>
|
||||
<div i-solar:danger-triangle-bold-duotone text-violet-500 />
|
||||
</div>
|
||||
<MarkdownRenderer
|
||||
v-if="message.content"
|
||||
:content="message.content as string"
|
||||
class="break-words"
|
||||
text="base <sm:xs"
|
||||
/>
|
||||
<div v-else i-eos-icons:three-dots-loading />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="message.role === 'assistant'" flex mr="12">
|
||||
<div
|
||||
flex="~ col"
|
||||
shadow="sm primary-200/50 dark:none"
|
||||
min-w-20 rounded-lg px-3 py-2
|
||||
h="unset <sm:fit"
|
||||
backdrop-blur-md
|
||||
class="bg-primary-50 dark:bg-primary-900"
|
||||
>
|
||||
<div>
|
||||
<span text="primary-400/90 dark:primary-600/90" text-xs font-normal class="inline <sm:hidden">{{ t('stage.chat.message.character-name.airi') }}</span>
|
||||
</div>
|
||||
<MarkdownRenderer
|
||||
v-if="message.content"
|
||||
:content="message.content as string"
|
||||
class="break-words"
|
||||
text="base <sm:xs"
|
||||
/>
|
||||
<div v-else i-eos-icons:three-dots-loading />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="message.role === 'user'" flex="~">
|
||||
<div
|
||||
flex="~ col"
|
||||
shadow="sm cyan-200/50 dark:none"
|
||||
px="2"
|
||||
h="unset <sm:fit" min-w-20 rounded-lg px-3 py-2
|
||||
bg="white dark:neutral-800"
|
||||
backdrop-blur-md
|
||||
>
|
||||
<div>
|
||||
<span text-xs text="cyan-400/90 dark:cyan-600/90" font-normal class="inline <sm:hidden">{{ t('stage.chat.message.character-name.you') }}</span>
|
||||
</div>
|
||||
<MarkdownRenderer
|
||||
v-if="message.content"
|
||||
:content="message.content as string"
|
||||
class="break-words"
|
||||
text="base <sm:xs"
|
||||
/>
|
||||
<div v-else />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/*
|
||||
DO NOT ATTEMPT TO USE backdrop-filter TOGETHER WITH mask-image.
|
||||
|
||||
html - Why doesn't blur backdrop-filter work together with mask-image? - Stack Overflow
|
||||
https://stackoverflow.com/questions/72780266/why-doesnt-blur-backdrop-filter-work-together-with-mask-image
|
||||
*/
|
||||
.chat-history {
|
||||
--gradient: linear-gradient(to bottom, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 20%);
|
||||
-webkit-mask-image: var(--gradient);
|
||||
mask-image: var(--gradient);
|
||||
-webkit-mask-size: 100% 100%;
|
||||
mask-size: 100% 100%;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
-webkit-mask-position: bottom;
|
||||
mask-position: bottom;
|
||||
}
|
||||
</style>
|
||||
@@ -112,6 +112,10 @@ export default defineConfig({
|
||||
id: 'widgets',
|
||||
title: 'Widgets',
|
||||
},
|
||||
{
|
||||
id: 'chat',
|
||||
title: 'Chat',
|
||||
},
|
||||
{
|
||||
id: 'gadgets',
|
||||
title: 'Gadgets',
|
||||
|
||||
@@ -28,7 +28,7 @@ function handleItemClick({ globalIndex }: { globalIndex: number }) {
|
||||
<template>
|
||||
<Story
|
||||
title="RippleGrid"
|
||||
group="layout"
|
||||
group="common"
|
||||
:layout="{ type: 'grid', width: '100%' }"
|
||||
>
|
||||
<Variant
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChatAssistantMessage, ChatSlices, ChatSlicesText } from '../../../types/chat'
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
import MarkdownRenderer from '../../markdown/MarkdownRenderer.vue'
|
||||
import ChatToolCallBlock from './ChatToolCallBlock.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
message: ChatAssistantMessage
|
||||
label: string
|
||||
showPlaceholder?: boolean
|
||||
variant?: 'desktop' | 'mobile'
|
||||
}>(), {
|
||||
showPlaceholder: false,
|
||||
variant: 'desktop',
|
||||
})
|
||||
|
||||
const resolvedSlices = computed<ChatSlices[]>(() => {
|
||||
if (props.message.slices?.length) {
|
||||
return props.message.slices
|
||||
}
|
||||
|
||||
if (typeof props.message.content === 'string' && props.message.content.trim()) {
|
||||
return [{ type: 'text', text: props.message.content } satisfies ChatSlicesText]
|
||||
}
|
||||
|
||||
if (Array.isArray(props.message.content)) {
|
||||
const textPart = props.message.content.find(part => 'type' in part && part.type === 'text') as { text?: string } | undefined
|
||||
if (textPart?.text)
|
||||
return [{ type: 'text', text: textPart.text } satisfies ChatSlicesText]
|
||||
}
|
||||
|
||||
return []
|
||||
})
|
||||
|
||||
const showLoader = computed(() => props.showPlaceholder && resolvedSlices.value.length === 0)
|
||||
const containerClass = computed(() => props.variant === 'mobile' ? 'mr-0' : 'mr-12')
|
||||
const boxClasses = computed(() => [
|
||||
props.variant === 'mobile' ? 'px-2 py-2 text-sm bg-primary-50/90 dark:bg-primary-950/90' : 'px-3 py-3 bg-primary-50/80 dark:bg-primary-950/80',
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex :class="containerClass">
|
||||
<div
|
||||
flex="~ col" shadow="sm primary-200/50 dark:none"
|
||||
min-w-20 rounded-xl h="unset <sm:fit"
|
||||
:class="boxClasses"
|
||||
>
|
||||
<div>
|
||||
<span text-sm text="black/60 dark:white/65" font-normal class="inline <sm:hidden">{{ label }}</span>
|
||||
</div>
|
||||
<div v-if="resolvedSlices.length > 0" class="break-words" text="primary-700 dark:primary-100">
|
||||
<template v-for="(slice, sliceIndex) in resolvedSlices" :key="sliceIndex">
|
||||
<ChatToolCallBlock
|
||||
v-if="slice.type === 'tool-call'"
|
||||
:tool-name="slice.toolCall.toolName"
|
||||
:args="slice.toolCall.args"
|
||||
class="mb-2"
|
||||
/>
|
||||
<template v-else-if="slice.type === 'tool-call-result'" />
|
||||
<template v-else-if="slice.type === 'text'">
|
||||
<MarkdownRenderer :content="slice.text" />
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else-if="showLoader" i-eos-icons:three-dots-loading />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChatErrorMessage } from './types'
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
import MarkdownRenderer from '../../markdown/MarkdownRenderer.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
message: ChatErrorMessage
|
||||
label: string
|
||||
showPlaceholder?: boolean
|
||||
variant?: 'desktop' | 'mobile'
|
||||
}>(), {
|
||||
showPlaceholder: false,
|
||||
variant: 'desktop',
|
||||
})
|
||||
|
||||
const boxClasses = computed(() => [
|
||||
props.variant === 'mobile' ? 'px-2 py-2 text-sm' : 'px-3 py-3',
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex :class="variant === 'mobile' ? 'mr-0' : 'mr-12'">
|
||||
<div
|
||||
flex="~ col" shadow="sm violet-200/50 dark:none"
|
||||
min-w-20 rounded-xl h="unset <sm:fit"
|
||||
class="bg-violet-100/80 dark:bg-violet-950/80"
|
||||
:class="boxClasses"
|
||||
>
|
||||
<div flex="~ row" gap-2>
|
||||
<div flex-1 class="inline <sm:hidden">
|
||||
<span text-sm text="black/60 dark:white/65" font-normal>{{ label }}</span>
|
||||
</div>
|
||||
<div i-solar:danger-triangle-bold-duotone text-violet-500 />
|
||||
</div>
|
||||
<div v-if="showPlaceholder" i-eos-icons:three-dots-loading />
|
||||
<MarkdownRenderer
|
||||
v-else
|
||||
:content="message.content"
|
||||
class="break-words text-violet-500 dark:text-violet-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChatAssistantMessage } from '../../../types/chat'
|
||||
import type { ChatHistoryMessage } from './types'
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import ChatHistory from './ChatHistory.vue'
|
||||
|
||||
const markdownMessages = ref<ChatHistoryMessage[]>([
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Hey AIRI, can you summarize today\'s tasks?',
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
slices: [
|
||||
{ type: 'text', text: 'Absolutely! Here is a **quick recap** with bullet points:\n\n- Finish UI polish\n- Ship the API client\n- Record the demo' },
|
||||
],
|
||||
tool_results: [],
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
slices: [
|
||||
{ type: 'tool-call', toolCall: { toolName: 'fetch_tasks', args: JSON.stringify({ limit: 5 }), toolCallId: '1', toolCallType: 'function' } },
|
||||
{ type: 'text', text: 'Let me pull the latest tasks from the tracker.' },
|
||||
],
|
||||
tool_results: [],
|
||||
},
|
||||
])
|
||||
|
||||
const toolHeavyMessages = computed<ChatHistoryMessage[]>(() => [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Grab the weather for Tokyo and Osaka.',
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
slices: [
|
||||
{ type: 'tool-call', toolCall: { toolName: 'weather', args: JSON.stringify({ location: 'Tokyo' }), toolCallId: '2', toolCallType: 'function' } },
|
||||
{ type: 'tool-call', toolCall: { toolName: 'weather', args: JSON.stringify({ location: 'Osaka' }), toolCallId: '3', toolCallType: 'function' } },
|
||||
{ type: 'text', text: 'I will fetch both cities, one sec.' },
|
||||
],
|
||||
tool_results: [],
|
||||
},
|
||||
])
|
||||
|
||||
const errorMessages = ref<ChatHistoryMessage[]>([
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Push the deployment now.',
|
||||
},
|
||||
{
|
||||
role: 'error',
|
||||
content: 'Deployment failed: upstream gateway timed out. Please try again in a minute.',
|
||||
},
|
||||
])
|
||||
|
||||
const streamingMessage = ref<ChatAssistantMessage>({
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
slices: [
|
||||
{ type: 'text', text: 'Working on it...' },
|
||||
],
|
||||
tool_results: [],
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Chat / History"
|
||||
group="chat"
|
||||
>
|
||||
<template #controls>
|
||||
<ThemeColorsHueControl />
|
||||
</template>
|
||||
|
||||
<Variant
|
||||
id="with-tools-desktop"
|
||||
title="With Tools"
|
||||
>
|
||||
<ChatHistory :messages="markdownMessages" />
|
||||
</Variant>
|
||||
|
||||
<Variant
|
||||
id="with-tools-mobile"
|
||||
title="With Tools (Mobile)"
|
||||
>
|
||||
<ChatHistory
|
||||
:messages="markdownMessages"
|
||||
variant="mobile"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant
|
||||
id="multiple-tools"
|
||||
title="Multiple Tools"
|
||||
>
|
||||
<ChatHistory :messages="toolHeavyMessages" />
|
||||
</Variant>
|
||||
|
||||
<Variant
|
||||
id="streaming"
|
||||
title="Streaming"
|
||||
>
|
||||
<ChatHistory
|
||||
:messages="[]"
|
||||
:sending="true"
|
||||
:streaming-message="streamingMessage"
|
||||
variant="mobile"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant
|
||||
id="error"
|
||||
title="Error"
|
||||
>
|
||||
<ChatHistory
|
||||
:messages="errorMessages"
|
||||
/>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChatAssistantMessage } from '../../../types/chat'
|
||||
import type { ChatHistoryMessage } from './types'
|
||||
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ChatAssistantItem from './ChatAssistantItem.vue'
|
||||
import ChatErrorItem from './ChatErrorItem.vue'
|
||||
import ChatUserItem from './ChatUserItem.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
messages: ChatHistoryMessage[]
|
||||
streamingMessage?: ChatAssistantMessage
|
||||
sending?: boolean
|
||||
assistantLabel?: string
|
||||
userLabel?: string
|
||||
errorLabel?: string
|
||||
variant?: 'desktop' | 'mobile'
|
||||
}>(), {
|
||||
sending: false,
|
||||
variant: 'desktop',
|
||||
})
|
||||
|
||||
const chatHistoryRef = ref<HTMLDivElement>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const labels = computed(() => ({
|
||||
assistant: props.assistantLabel ?? t('stage.chat.message.character-name.airi'),
|
||||
user: props.userLabel ?? t('stage.chat.message.character-name.you'),
|
||||
error: props.errorLabel ?? t('stage.chat.message.character-name.core-system'),
|
||||
}))
|
||||
|
||||
function scrollToBottom() {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (!chatHistoryRef.value)
|
||||
return
|
||||
|
||||
chatHistoryRef.value.scrollTop = chatHistoryRef.value.scrollHeight
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
watch(() => props.messages, () => scrollToBottom(), { deep: true, flush: 'post' })
|
||||
watch(() => props.streamingMessage, () => scrollToBottom(), { deep: true, flush: 'post' })
|
||||
watch(() => props.sending, () => scrollToBottom(), { flush: 'post' })
|
||||
onMounted(() => scrollToBottom())
|
||||
|
||||
const streaming = computed<ChatAssistantMessage>(() => props.streamingMessage ?? { role: 'assistant', content: '', slices: [], tool_results: [] })
|
||||
const showStreamingPlaceholder = computed(() => (streaming.value.slices?.length ?? 0) === 0 && !streaming.value.content)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="chatHistoryRef" v-auto-animate flex="~ col" relative h-full w-full overflow-y-auto rounded-xl px="<sm:2" py="<sm:2" :class="variant === 'mobile' ? 'gap-1' : 'gap-2'">
|
||||
<template v-for="(message, index) in messages" :key="index">
|
||||
<div v-if="message.role === 'error'">
|
||||
<ChatErrorItem
|
||||
:message="message"
|
||||
:label="labels.error"
|
||||
:show-placeholder="sending && index === messages.length - 1"
|
||||
:variant="variant"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="message.role === 'assistant'">
|
||||
<ChatAssistantItem
|
||||
:message="message"
|
||||
:label="labels.assistant"
|
||||
:variant="variant"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="message.role === 'user'">
|
||||
<ChatUserItem
|
||||
:message="message"
|
||||
:label="labels.user"
|
||||
:variant="variant"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="sending">
|
||||
<ChatAssistantItem
|
||||
:message="streaming"
|
||||
:label="labels.assistant"
|
||||
:show-placeholder="showStreamingPlaceholder"
|
||||
:variant="variant"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Collapsable from '../../misc/Collapsable.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
toolName: string
|
||||
args: string
|
||||
}>()
|
||||
|
||||
const formattedArgs = computed(() => {
|
||||
try {
|
||||
const parsed = JSON.parse(props.args)
|
||||
return JSON.stringify(parsed, null, 2).trim()
|
||||
}
|
||||
catch {
|
||||
return props.args
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Collapsable
|
||||
: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 i-solar:sledgehammer-bold-duotone class="mr-1 inline-block translate-y-1 op-50" />
|
||||
<code>{{ toolName }}</code>
|
||||
</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',
|
||||
]"
|
||||
>
|
||||
<div class="whitespace-pre-wrap break-words font-mono">
|
||||
{{ formattedArgs }}
|
||||
</div>
|
||||
</div>
|
||||
</Collapsable>
|
||||
</template>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChatMessage } from '../../../types/chat'
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
import MarkdownRenderer from '../../markdown/MarkdownRenderer.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
message: Extract<ChatMessage, { role: 'user' }>
|
||||
label: string
|
||||
variant?: 'desktop' | 'mobile'
|
||||
}>(), {
|
||||
variant: 'desktop',
|
||||
})
|
||||
|
||||
const content = computed(() => {
|
||||
const raw = props.message.content
|
||||
if (typeof raw === 'string')
|
||||
return raw
|
||||
|
||||
if (Array.isArray(raw)) {
|
||||
const textPart = raw.find(part => 'type' in part && part.type === 'text') as { text?: string } | undefined
|
||||
if (textPart?.text)
|
||||
return textPart.text
|
||||
|
||||
return raw.map(entry => JSON.stringify(entry)).join('\n')
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
|
||||
const containerClasses = computed(() => [
|
||||
'flex',
|
||||
props.variant === 'mobile' ? 'ml-0 flex-row' : 'ml-12 flex-row-reverse',
|
||||
])
|
||||
|
||||
const boxClasses = computed(() => [
|
||||
props.variant === 'mobile' ? 'px-2 py-2 text-sm bg-neutral-100/90 dark:bg-neutral-800/90' : 'px-3 py-3 bg-neutral-100/80 dark:bg-neutral-800/80',
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="message.role === 'user'" :class="containerClasses">
|
||||
<div
|
||||
flex="~ col" shadow="sm neutral-200/50 dark:none"
|
||||
min-w-20 rounded-xl h="unset <sm:fit"
|
||||
:class="boxClasses"
|
||||
>
|
||||
<div>
|
||||
<span text-sm text="black/60 dark:white/65" font-normal class="inline <sm:hidden">{{ label }}</span>
|
||||
</div>
|
||||
<MarkdownRenderer
|
||||
:content="content as string"
|
||||
class="break-words"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
export { default as ChatAssistantItem } from './ChatAssistantItem.vue'
|
||||
export { default as ChatErrorItem } from './ChatErrorItem.vue'
|
||||
export { default as ChatHistory } from './ChatHistory.vue'
|
||||
export { default as ChatUserItem } from './ChatUserItem.vue'
|
||||
|
||||
export type { ChatErrorMessage, ChatHistoryMessage } from './types'
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ChatAssistantMessage, ChatMessage, ChatSlices } from '../../../types/chat'
|
||||
|
||||
export interface ChatErrorMessage {
|
||||
role: 'error'
|
||||
content: string
|
||||
}
|
||||
|
||||
export type ChatHistoryMessage = (ChatMessage | ChatErrorMessage) & {
|
||||
slices?: ChatSlices[]
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './dialogs'
|
||||
export * from './chat'
|
||||
export * from './providers'
|
||||
export * from './settings'
|
||||
export * from './toasters'
|
||||
|
||||
Reference in New Issue
Block a user