style(stage-web): improve mobile chat ui

This commit is contained in:
Neko Ayaka
2025-07-07 02:10:57 +08:00
parent 4841fa95be
commit d4a1bfc4f6
8 changed files with 71 additions and 33 deletions
@@ -21,6 +21,7 @@ const { activeProvider, activeModel } = storeToRefs(useConsciousnessStore())
// const { selectedAudioDevice, isAudioInputOn, selectedAudioDeviceId } = storeToRefs(useSettings())
const { isAudioInputOn, selectedAudioDeviceId, themeColorsHueDynamic } = storeToRefs(useSettings())
const { send, onAfterSend } = useChatStore()
const { messages } = storeToRefs(useChatStore())
const { t } = useI18n()
async function handleSend() {
@@ -28,13 +29,22 @@ async function handleSend() {
return
}
const providerConfig = providersStore.getProviderConfig(activeProvider.value)
try {
const providerConfig = providersStore.getProviderConfig(activeProvider.value)
await send(messageInput.value, {
chatProvider: providersStore.getProviderInstance(activeProvider.value) as ChatProvider,
model: activeModel.value,
providerConfig,
})
await send(messageInput.value, {
chatProvider: providersStore.getProviderInstance(activeProvider.value) as ChatProvider,
model: activeModel.value,
providerConfig,
})
}
catch (error) {
messages.value.pop()
messages.value.push({
role: 'error',
content: (error as Error).message,
})
}
}
const { destroy, start } = useMicVAD(selectedAudioDeviceId, {
@@ -85,16 +95,16 @@ onMounted(() => {
<template>
<div fixed bottom-0 w-full flex gap-1 max-w="[calc(100dvw-1rem)]">
<MobileChatHistory absolute left-0 top-0 transform="translate-y-[-100%]" w-full />
<MobileChatHistory absolute left-0 top-0 transform="translate-y-[-100%]" h="80dvh" w-full />
<div flex flex-1>
<BasicTextarea
v-model="messageInput"
:placeholder="t('stage.message')"
border="solid 2 primary-200 dark:primary-400/20"
text="primary-300 hover:primary-500 dark:primary-300/50 dark:hover:primary-500 placeholder:primary-300 placeholder:hover:primary-500 placeholder:dark:primary-300/50 placeholder:dark:hover:primary-500"
bg="primary-50 dark:primary-400/20"
max-h="[10lh]" min-h="[1lh]"
w-full resize-none overflow-y-scroll rounded-xl p-2 font-medium outline-none
border="solid 2 primary-50 dark:primary-950/10"
text="primary-500 hover:primary-600 dark:primary-100 dark:hover:primary-200 placeholder:primary-400 placeholder:hover:primary-500 placeholder:dark:primary-300 placeholder:dark:hover:primary-400"
bg="primary-50/50 dark:primary-950/70"
max-h="[10lh]" min-h="[2lh]"
w-full resize-none overflow-y-scroll rounded-xl p-3 font-medium outline-none backdrop-blur-md
transition="all duration-250 ease-in-out placeholder:all placeholder:duration-250 placeholder:ease-in-out"
:class="{ 'transition-colors-none placeholder:transition-colors-none': themeColorsHueDynamic }"
@submit="handleSend"
@@ -8,11 +8,11 @@ import { useI18n } from 'vue-i18n'
const chatHistoryRef = ref<HTMLDivElement>()
const { t } = useI18n()
const { messages, sending } = storeToRefs(useChatStore())
const bounding = useElementBounding(chatHistoryRef, { immediate: true, windowScroll: true, windowResize: true })
const { y: chatHistoryContainerY } = useScroll(chatHistoryRef)
const { t } = useI18n()
const { process } = useMarkdown()
const { onBeforeMessageComposed, onTokenLiteral } = useChatStore()
@@ -36,21 +36,39 @@ onTokenLiteral(async () => {
<template>
<div py="1" flex="~ col" rounded="lg" overflow-hidden>
<div flex-1 /> <!-- spacer -->
<div ref="chatHistoryRef" v-auto-animate h-full w-full max-h="30vh" flex="~ col" overflow-scroll class="chat-history">
<div ref="chatHistoryRef" v-auto-animate h-full w-full max-h="40%" flex="~ col" overflow-scroll class="chat-history">
<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-200/20 dark:none"
min-w-20 rounded-lg px-3 py-2
h="unset <sm:fit"
bg="violet-500 dark:violet-800"
backdrop-blur-sm
>
<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="message.content" class="markdown-content" text="base <sm:xs" v-html="process(message.content as string)" />
<div v-else i-eos-icons:three-dots-loading />
</div>
</div>
<div v-if="message.role === 'assistant'" flex mr="12">
<div
flex="~ col"
border="4 solid primary-200/50 dark:primary-500/50"
shadow="md primary-200/50 dark:none"
min-w-20 rounded-lg px-2 py-1 backdrop-blur-sm
shadow="md primary-200/20 dark:none"
min-w-20 rounded-lg px-3 py-2
h="unset <sm:fit"
bg="<md:primary-500/25"
bg="primary-100 dark:primary-800"
backdrop-blur-md
>
<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>
<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>
<div v-if="message.content" class="markdown-content" text="base <sm:xs" v-html="process(message.content as string)" />
<div v-else i-eos-icons:three-dots-loading />
@@ -59,11 +77,11 @@ onTokenLiteral(async () => {
<div v-else-if="message.role === 'user'" flex="~">
<div
flex="~ col"
border="4 solid cyan-200/50 dark:cyan-500/50"
shadow="md cyan-200/50 dark:none"
shadow="md cyan-200/20 dark:none"
px="2"
h="unset <sm:fit" min-w-20 rounded-lg px-2 py-1 backdrop-blur-sm
bg="<md:cyan-500/25"
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>
@@ -78,8 +96,14 @@ onTokenLiteral(async () => {
</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), rgba(0,0,0,1));
--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%;
@@ -32,7 +32,7 @@ watch(live2dModelRef, (model) => {
function handleModelLoaded() {
if (live2dModelRef.value) {
live2dModelRef.value.setMotion(motion.value)
live2dModelRef.value?.setMotion(motion.value)
motionGroupsList.value = live2dModelRef.value.listMotionGroups()
}
}
+5 -6
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { WidgetStage } from '@proj-airi/stage-ui/components/scenes'
import { useDark, useMouse } from '@vueuse/core'
import { breakpointsTailwind, useBreakpoints, useDark, useMouse } from '@vueuse/core'
import { ref } from 'vue'
import Cross from '../components/Backgrounds/Cross.vue'
@@ -17,6 +17,8 @@ function handleSettingsOpen(open: boolean) {
}
const positionCursor = useMouse()
const breakpoints = useBreakpoints(breakpointsTailwind)
const isMobile = breakpoints.smaller('md')
</script>
<template>
@@ -28,10 +30,7 @@ const positionCursor = useMouse()
>
<div relative flex="~ col" z-2 h-100dvh w-100vw of-hidden>
<!-- header -->
<div
class="px-0 py-1 hidden sm:block md:px-3 md:py-3"
w-full gap-2
>
<div class="px-0 py-1 md:px-3 md:py-3" w-full gap-2>
<Header class="flex" />
</div>
<!-- page -->
@@ -43,7 +42,7 @@ const positionCursor = useMouse()
x: positionCursor.x.value,
y: positionCursor.y.value,
}"
x-offset="-10%"
:x-offset="isMobile ? undefined : '-10%'"
/>
<InteractiveArea class="flex <md:hidden" absolute h="85dvh" right-4 flex-1 max-w="500px" min-w="30%" />
<MobileInteractiveArea class="hidden <md:block" mx2 mb2 @settings-open="handleSettingsOpen" />
+1
View File
@@ -97,6 +97,7 @@
"unified": "^11.0.5",
"unist-builder": "^4.0.0",
"unspeech": "^0.1.7",
"vaul-vue": "^0.4.1",
"vue-i18n": "^11.1.9",
"vue-router": "^4.5.1",
"vue-tsc": "^2.2.12",
@@ -230,7 +230,8 @@ async function initLive2DPixiStage() {
}
async function setMotion(motionName: string, index?: number) {
await model.value!.motion(motionName, index, MotionPriority.FORCE)
// TODO: motion? Not every Live2D model has motion, we do need to help users to set motion
await model.value?.motion(motionName, index, MotionPriority.FORCE)
}
const handleResize = useDebounceFn(() => {
+3
View File
@@ -1168,6 +1168,9 @@ importers:
unspeech:
specifier: ^0.1.7
version: 0.1.7
vaul-vue:
specifier: ^0.4.1
version: 0.4.1(reka-ui@2.3.2(typescript@5.8.3)(vue@3.5.17(typescript@5.8.3)))(vue@3.5.17(typescript@5.8.3))
vue-i18n:
specifier: ^11.1.9
version: 11.1.9(vue@3.5.17(typescript@5.8.3))