refactor(stage-*): unified chat history & chat item

This commit is contained in:
Neko Ayaka
2025-12-08 01:03:59 +08:00
parent ed3919ce76
commit a1aad5d020
18 changed files with 540 additions and 495 deletions
@@ -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>
+4
View File
@@ -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'