feat: Chat UI done
This commit is contained in:
52
components/Icon/MessageResponding.vue
Normal file
52
components/Icon/MessageResponding.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
||||
<circle cx="4" cy="12" r="0" fill="currentColor">
|
||||
<animate fill="freeze" attributeName="r" begin="0;svgSpinners3DotsMove1.end" calcMode="spline" dur="0.5s"
|
||||
keySplines=".36,.6,.31,1" values="0;3"></animate>
|
||||
<animate fill="freeze" attributeName="cx" begin="svgSpinners3DotsMove7.end" calcMode="spline" dur="0.5s"
|
||||
keySplines=".36,.6,.31,1" values="4;12"></animate>
|
||||
<animate fill="freeze" attributeName="cx" begin="svgSpinners3DotsMove5.end" calcMode="spline" dur="0.5s"
|
||||
keySplines=".36,.6,.31,1" values="12;20"></animate>
|
||||
<animate id="svgSpinners3DotsMove0" fill="freeze" attributeName="r" begin="svgSpinners3DotsMove3.end"
|
||||
calcMode="spline" dur="0.5s" keySplines=".36,.6,.31,1" values="3;0"></animate>
|
||||
<animate id="svgSpinners3DotsMove1" fill="freeze" attributeName="cx" begin="svgSpinners3DotsMove0.end"
|
||||
dur="0.001s" values="20;4"></animate>
|
||||
</circle>
|
||||
<circle cx="4" cy="12" r="3" fill="currentColor">
|
||||
<animate fill="freeze" attributeName="cx" begin="0;svgSpinners3DotsMove1.end" calcMode="spline" dur="0.5s"
|
||||
keySplines=".36,.6,.31,1" values="4;12"></animate>
|
||||
<animate fill="freeze" attributeName="cx" begin="svgSpinners3DotsMove7.end" calcMode="spline" dur="0.5s"
|
||||
keySplines=".36,.6,.31,1" values="12;20"></animate>
|
||||
<animate id="svgSpinners3DotsMove2" fill="freeze" attributeName="r" begin="svgSpinners3DotsMove5.end"
|
||||
calcMode="spline" dur="0.5s" keySplines=".36,.6,.31,1" values="3;0"></animate>
|
||||
<animate id="svgSpinners3DotsMove3" fill="freeze" attributeName="cx" begin="svgSpinners3DotsMove2.end"
|
||||
dur="0.001s" values="20;4"></animate>
|
||||
<animate fill="freeze" attributeName="r" begin="svgSpinners3DotsMove3.end" calcMode="spline" dur="0.5s"
|
||||
keySplines=".36,.6,.31,1" values="0;3"></animate>
|
||||
</circle>
|
||||
<circle cx="12" cy="12" r="3" fill="currentColor">
|
||||
<animate fill="freeze" attributeName="cx" begin="0;svgSpinners3DotsMove1.end" calcMode="spline" dur="0.5s"
|
||||
keySplines=".36,.6,.31,1" values="12;20"></animate>
|
||||
<animate id="svgSpinners3DotsMove4" fill="freeze" attributeName="r" begin="svgSpinners3DotsMove7.end"
|
||||
calcMode="spline" dur="0.5s" keySplines=".36,.6,.31,1" values="3;0"></animate>
|
||||
<animate id="svgSpinners3DotsMove5" fill="freeze" attributeName="cx" begin="svgSpinners3DotsMove4.end"
|
||||
dur="0.001s" values="20;4"></animate>
|
||||
<animate fill="freeze" attributeName="r" begin="svgSpinners3DotsMove5.end" calcMode="spline" dur="0.5s"
|
||||
keySplines=".36,.6,.31,1" values="0;3"></animate>
|
||||
<animate fill="freeze" attributeName="cx" begin="svgSpinners3DotsMove3.end" calcMode="spline" dur="0.5s"
|
||||
keySplines=".36,.6,.31,1" values="4;12"></animate>
|
||||
</circle>
|
||||
<circle cx="20" cy="12" r="3" fill="currentColor">
|
||||
<animate id="svgSpinners3DotsMove6" fill="freeze" attributeName="r" begin="0;svgSpinners3DotsMove1.end"
|
||||
calcMode="spline" dur="0.5s" keySplines=".36,.6,.31,1" values="3;0"></animate>
|
||||
<animate id="svgSpinners3DotsMove7" fill="freeze" attributeName="cx" begin="svgSpinners3DotsMove6.end"
|
||||
dur="0.001s" values="20;4"></animate>
|
||||
<animate fill="freeze" attributeName="r" begin="svgSpinners3DotsMove7.end" calcMode="spline" dur="0.5s"
|
||||
keySplines=".36,.6,.31,1" values="0;3"></animate>
|
||||
<animate fill="freeze" attributeName="cx" begin="svgSpinners3DotsMove5.end" calcMode="spline" dur="0.5s"
|
||||
keySplines=".36,.6,.31,1" values="4;12"></animate>
|
||||
<animate fill="freeze" attributeName="cx" begin="svgSpinners3DotsMove3.end" calcMode="spline" dur="0.5s"
|
||||
keySplines=".36,.6,.31,1" values="12;20"></animate>
|
||||
</circle>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type {PropType} from "vue";
|
||||
import type {ChatMessage} from "~/components/aigc/chat/index";
|
||||
import MessageResponding from "~/components/Icon/MessageResponding.vue";
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
@@ -40,9 +41,20 @@ const message_background = computed(() => {
|
||||
<Icon :name="message_avatar" class="text-lg"/>
|
||||
</div>
|
||||
<div class="flex flex-col" :class="{'items-end': message_place_end}">
|
||||
<div class="chat-inside-content" :class="message_background">
|
||||
<Transition mode="out-in" name="message-content-change">
|
||||
<div
|
||||
class="chat-inside-content relative"
|
||||
:class="message_background"
|
||||
:key="message.content"
|
||||
>
|
||||
<span v-if="message.content">
|
||||
{{ message.content }}
|
||||
</span>
|
||||
<span v-else>
|
||||
<MessageResponding class="text-xl text-neutral-500 dark:text-neutral-300 mx-2"/>
|
||||
</span>
|
||||
</div>
|
||||
</Transition>
|
||||
<div class="chat-inside-extra">
|
||||
{{ dayjs(message.create_at * 1000).format('YYYY-MM-DD HH:mm:ss') }}
|
||||
</div>
|
||||
@@ -75,4 +87,13 @@ const message_background = computed(() => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-content-change-enter-active,
|
||||
.message-content-change-leave-active {
|
||||
@apply transition-all duration-300 overflow-hidden;
|
||||
}
|
||||
|
||||
.message-content-change-enter-from {
|
||||
@apply opacity-0 translate-y-4;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {ResultBlockMeta} from '~/components/aigc/drawing';
|
||||
import type {ChatSession} from "~/components/aigc/chat";
|
||||
|
||||
export interface HistoryItem {
|
||||
fid: string
|
||||
@@ -8,11 +9,17 @@ export interface HistoryItem {
|
||||
images?: string[]
|
||||
}
|
||||
|
||||
export const useHistory = defineStore('aigc_history', () => {
|
||||
export const useHistory = defineStore('xsh_assistant_aigc_history', () => {
|
||||
const text2img = ref<HistoryItem[]>([])
|
||||
const chatSessions = ref<ChatSession[]>([])
|
||||
const setChatSessions = (sessions: ChatSession[]) => {
|
||||
chatSessions.value = sessions
|
||||
}
|
||||
|
||||
return {
|
||||
text2img
|
||||
text2img,
|
||||
chatSessions,
|
||||
setChatSessions,
|
||||
}
|
||||
}, {
|
||||
persist: {
|
||||
|
||||
18
composables/useIdGenerator.ts
Normal file
18
composables/useIdGenerator.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export const useIdGenerator = () => {
|
||||
const generateUUID = () => {
|
||||
// noinspection SpellCheckingInspection
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
||||
const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8)
|
||||
return v.toString(16)
|
||||
})
|
||||
}
|
||||
|
||||
const generateMathRandom = () => {
|
||||
return Math.random().toString(36).slice(2)
|
||||
}
|
||||
|
||||
return {
|
||||
generateUUID,
|
||||
generateMathRandom
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
import ChatItem from "~/components/aigc/chat/ChatItem.vue";
|
||||
import type {ChatSession, ChatSessionId} from "~/components/aigc/chat";
|
||||
import type {ChatMessage, ChatMessageId, ChatSessionId} from "~/components/aigc/chat";
|
||||
import Message from "~/components/aigc/chat/Message.vue";
|
||||
import {useIdGenerator} from "~/composables/useIdGenerator";
|
||||
import {useHistory} from "~/composables/useHistory";
|
||||
|
||||
useHead({
|
||||
title: '聊天 | XSH AI'
|
||||
@@ -9,33 +11,39 @@ useHead({
|
||||
|
||||
const dayjs = useDayjs()
|
||||
const toast = useToast()
|
||||
const {generateUUID} = useIdGenerator()
|
||||
const historyStore = useHistory()
|
||||
const {chatSessions} = storeToRefs(historyStore)
|
||||
const {setChatSessions} = historyStore
|
||||
|
||||
const sessions = ref<ChatSession[]>([])
|
||||
// const chatSessions = ref<ChatSession[]>([])
|
||||
const currentSessionId = ref<ChatSessionId | null>(null)
|
||||
const messagesWrapperRef = ref<HTMLDivElement | null>(null)
|
||||
|
||||
const user_input = ref('')
|
||||
const responding = ref(false)
|
||||
|
||||
const getSessionCopyById = (chatSessionId: ChatSessionId) => sessions.value.find(s => s.id === chatSessionId)
|
||||
const getSessionCopyById = (chatSessionId: ChatSessionId) => chatSessions.value.find(s => s.id === chatSessionId);
|
||||
const selectCurrentSessionId = (chatSessionId?: ChatSessionId) => {
|
||||
if (sessions.value.length > 0) {
|
||||
if (chatSessions.value.length > 0) {
|
||||
if (chatSessionId) { // 切换到指定 ID
|
||||
// 保存当前输入并清空输入框
|
||||
sessions.value = sessions.value.map(s => s.id === currentSessionId.value ? {
|
||||
setChatSessions(chatSessions.value.map(s => s.id === currentSessionId.value ? {
|
||||
...s,
|
||||
last_input: user_input.value,
|
||||
} : s)
|
||||
} : s))
|
||||
user_input.value = ''
|
||||
// 切换到指定 ID 会话
|
||||
currentSessionId.value = chatSessionId
|
||||
// 恢复输入
|
||||
user_input.value = getSessionCopyById(chatSessionId)?.last_input || ''
|
||||
// 清除已恢复的输入
|
||||
sessions.value = sessions.value.map(s => s.id === chatSessionId ? {
|
||||
setChatSessions(chatSessions.value.map(s => s.id === chatSessionId ? {
|
||||
...s,
|
||||
last_input: '',
|
||||
} : s)
|
||||
} : s))
|
||||
} else { // 切换到第一个会话
|
||||
currentSessionId.value = sessions.value[0].id
|
||||
currentSessionId.value = chatSessions.value[0].id
|
||||
}
|
||||
} else {
|
||||
handleClickCreateSession()
|
||||
@@ -43,16 +51,19 @@ const selectCurrentSessionId = (chatSessionId?: ChatSessionId) => {
|
||||
}
|
||||
|
||||
const handleClickCreateSession = () => {
|
||||
const sessionId = Math.random().toString(36).slice(2)
|
||||
sessions.value = [
|
||||
// 生成一个新的会话 ID
|
||||
const sessionId = generateUUID()
|
||||
// 插入新会话数据
|
||||
setChatSessions([
|
||||
{
|
||||
id: sessionId,
|
||||
subject: '新对话',
|
||||
messages: [],
|
||||
create_at: dayjs().unix(),
|
||||
},
|
||||
...sessions.value,
|
||||
]
|
||||
...chatSessions.value,
|
||||
])
|
||||
// 切换到新的会话
|
||||
selectCurrentSessionId(sessionId)
|
||||
}
|
||||
|
||||
@@ -60,6 +71,7 @@ const handleClickSend = (event: any) => {
|
||||
if (event.ctrlKey) {
|
||||
return;
|
||||
}
|
||||
if (responding.value) return;
|
||||
if (!user_input.value) return
|
||||
if (!currentSessionId.value) {
|
||||
toast.add({
|
||||
@@ -68,64 +80,66 @@ const handleClickSend = (event: any) => {
|
||||
color: 'red',
|
||||
icon: 'i-tabler-circle-x',
|
||||
})
|
||||
return
|
||||
return;
|
||||
}
|
||||
sessions.value = sessions.value.map(s => s.id === currentSessionId.value ? {
|
||||
...s,
|
||||
messages: [
|
||||
...s.messages,
|
||||
{
|
||||
id: Math.random().toString(36).slice(2),
|
||||
// 插入用户消息
|
||||
insetMessage({
|
||||
id: generateUUID(),
|
||||
role: 'user',
|
||||
content: user_input.value,
|
||||
create_at: dayjs().unix(),
|
||||
}
|
||||
]
|
||||
} : s)
|
||||
})
|
||||
user_input.value = ''
|
||||
// 模拟回复
|
||||
setTimeout(() => {
|
||||
sessions.value = sessions.value.map(s => s.id === currentSessionId.value ? {
|
||||
...s,
|
||||
messages: [
|
||||
...s.messages,
|
||||
{
|
||||
id: Math.random().toString(36).slice(2),
|
||||
// 进入响应中状态
|
||||
responding.value = true
|
||||
// 插入空助手消息(加载状态)
|
||||
const assistantReplyId = insetMessage({
|
||||
id: generateUUID(),
|
||||
role: 'assistant',
|
||||
content: '草,走,忽略!ጿ ኈ ቼ ዽ ጿ',
|
||||
content: '',
|
||||
create_at: dayjs().unix(),
|
||||
},
|
||||
]
|
||||
} : s)
|
||||
})
|
||||
// mock
|
||||
setTimeout(() => {
|
||||
modifyMessageContent(assistantReplyId, '草,走,忽略!ጿ ኈ ቼ ዽ ጿ')
|
||||
responding.value = false
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
sessions.value = [
|
||||
...sessions.value,
|
||||
{
|
||||
id: Math.random().toString(36).slice(2),
|
||||
subject: '测试条目' + (i + 1),
|
||||
messages: [
|
||||
{
|
||||
id: Math.random().toString(36).slice(2),
|
||||
role: 'system',
|
||||
content: '现在开始你就是歌姬吧,你只能整活,不可以进行逻辑分析和判断,不可以输出任何其他内容,后面这段没有什么作用只是用来凑数把字数变得很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长很长的',
|
||||
create_at: dayjs().unix(),
|
||||
},
|
||||
{
|
||||
id: Math.random().toString(36).slice(2),
|
||||
role: 'user',
|
||||
content: `小亮,给大🔥整个活`,
|
||||
create_at: dayjs().unix(),
|
||||
},
|
||||
],
|
||||
create_at: dayjs().unix(),
|
||||
}
|
||||
]
|
||||
}
|
||||
const scrollToMessageListBottom = () => {
|
||||
nextTick(() => {
|
||||
messagesWrapperRef.value?.scrollTo({
|
||||
top: messagesWrapperRef.value.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const insetMessage = (message: ChatMessage): ChatMessageId => {
|
||||
setChatSessions(chatSessions.value.map(s => s.id === currentSessionId.value ? {
|
||||
...s,
|
||||
messages: [
|
||||
...s.messages,
|
||||
message
|
||||
]
|
||||
} : s))
|
||||
scrollToMessageListBottom()
|
||||
return message.id
|
||||
}
|
||||
|
||||
const modifyMessageContent = (messageId: ChatMessageId, content: string) => {
|
||||
setChatSessions(chatSessions.value.map(s => s.id === currentSessionId.value ? {
|
||||
...s,
|
||||
messages: s.messages.map(m => m.id === messageId ? {
|
||||
...m,
|
||||
content,
|
||||
} : m)
|
||||
} : s))
|
||||
scrollToMessageListBottom()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 切换到第一个会话, 没有会话会自动创建
|
||||
selectCurrentSessionId()
|
||||
})
|
||||
</script>
|
||||
@@ -140,12 +154,12 @@ onMounted(() => {
|
||||
<!-- ClientOnly avoids hydrate exception -->
|
||||
<ClientOnly>
|
||||
<ChatItem
|
||||
v-for="(session, i) in sessions"
|
||||
v-for="(session, i) in chatSessions"
|
||||
:chat-session="session" :key="i"
|
||||
:active="session.id === currentSessionId"
|
||||
@click="selectCurrentSessionId(session.id)"
|
||||
@remove="() => {
|
||||
sessions.splice(sessions.findIndex(s => s.id === session.id), 1)
|
||||
chatSessions.splice(chatSessions.findIndex(s => s.id === session.id), 1)
|
||||
session.id === currentSessionId && selectCurrentSessionId()
|
||||
}"
|
||||
/>
|
||||
@@ -170,13 +184,15 @@ onMounted(() => {
|
||||
<div class="w-full p-4 bg-neutral-50 dark:bg-neutral-800/50 border-b dark:border-neutral-700/50">
|
||||
{{ getSessionCopyById(currentSessionId!)?.subject || '新对话' }}
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col overflow-auto overflow-x-hidden">
|
||||
<div ref="messagesWrapperRef" class="flex-1 flex flex-col overflow-auto overflow-x-hidden">
|
||||
<div class="flex flex-col gap-8 px-4 py-8">
|
||||
<TransitionGroup name="message">
|
||||
<Message
|
||||
v-for="(message, i) in getSessionCopyById(currentSessionId!)?.messages || []"
|
||||
v-for="message in getSessionCopyById(currentSessionId!)?.messages || []"
|
||||
:message="message"
|
||||
:key="message.id"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -209,6 +225,17 @@ onMounted(() => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!--suppress CssUnusedSymbol -->
|
||||
<style scoped>
|
||||
.message-enter-active {
|
||||
@apply transition duration-300;
|
||||
}
|
||||
|
||||
.message-enter-from {
|
||||
@apply translate-y-4 opacity-0;
|
||||
}
|
||||
|
||||
.message-leave-to {
|
||||
@apply -translate-y-4 opacity-0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user