Files
huihuiSquare/digital-avatar-app/src/views/AvatarChat.vue
2026-07-23 17:21:49 +08:00

155 lines
8.2 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="chat-page">
<header class="chat-header">
<button class="back-btn" @click="router.back()"></button>
<div class="avatar-heading">
<div class="avatar-mark">{{ avatar?.emoji || '🤖' }}</div>
<div>
<h1>{{ avatar?.displayName || avatar?.name || '数字分身' }}</h1>
<span class="online-state"> 随时可以和我聊聊</span>
</div>
</div>
<button class="settings-btn" title="编辑分身" @click="router.push(`/avatar/edit/${avatarId}`)"></button>
</header>
<main ref="messageList" class="message-list">
<div v-if="!messages.length" class="welcome-card">
<div class="welcome-icon"></div>
<h2>你好我是{{ avatar?.displayName || '你的数字分身' }}</h2>
<p>我会优先参考标准问答和知识库再结合自己的理解回答你</p>
<div class="starter-list">
<button v-for="starter in starters" :key="starter" @click="sendMessage(starter)">{{ starter }}</button>
</div>
</div>
<article v-for="(message, index) in messages" :key="`${message.role}-${index}`" class="message-row" :class="message.role">
<div v-if="message.role === 'assistant'" class="message-avatar">{{ avatar?.emoji || '🤖' }}</div>
<div class="message-column">
<div class="message-bubble">{{ message.content }}</div>
<div v-if="message.source || message.references?.length" class="message-source">
{{ sourceLabel(message.source) }}
<span v-if="message.references?.length"> · {{ message.references.map((item) => item.filename).filter(Boolean).join('、') }}</span>
</div>
</div>
</article>
<div v-if="sending" class="message-row assistant">
<div class="message-avatar">{{ avatar?.emoji || '🤖' }}</div>
<div class="message-bubble typing"><i></i><i></i><i></i></div>
</div>
<p v-if="errorMessage" class="chat-error">{{ errorMessage }} <button @click="retryLast">重试</button></p>
</main>
<form class="composer" @submit.prevent="sendMessage(inputText)">
<textarea v-model="inputText" rows="1" :disabled="sending" placeholder="输入你想聊的内容…" @keydown.enter.exact.prevent="sendMessage(inputText)"></textarea>
<button class="send-btn" type="submit" :disabled="sending || !inputText.trim()">发送</button>
</form>
</div>
</template>
<script setup lang="ts">
import { nextTick, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getAvatarDetail, sendAvatarChat, type ChatMessage } from '@/api'
import { useAvatarStore } from '@/store/avatar'
type DisplayMessage = ChatMessage & {
source?: 'qa' | 'knowledge' | 'qwen'
references?: Array<{ filename?: string }>
}
const route = useRoute()
const router = useRouter()
const store = useAvatarStore()
const avatarId = String(route.params.id || '')
const avatar = ref<any>(null)
const messages = ref<DisplayMessage[]>([])
const inputText = ref('')
const sending = ref(false)
const errorMessage = ref('')
const lastQuestion = ref('')
const messageList = ref<HTMLElement | null>(null)
const starters = ['介绍一下你自己', '你能帮我做什么?', '请根据我的知识库回答一个问题']
const sourceLabel = (source?: DisplayMessage['source']) => ({
qa: '标准问答对',
knowledge: '参考文件知识库',
qwen: 'Qwen 智能回答'
}[source || ''] || '')
const scrollToBottom = async () => {
await nextTick()
if (messageList.value) messageList.value.scrollTop = messageList.value.scrollHeight
}
const loadAvatar = async () => {
avatar.value = store.avatars.find((item) => String(item.id) === avatarId)
if (!avatar.value) avatar.value = await getAvatarDetail(avatarId)
}
const sendMessage = async (value: string) => {
const question = value.trim()
if (!question || sending.value) return
lastQuestion.value = question
inputText.value = ''
errorMessage.value = ''
messages.value.push({ role: 'user', content: question })
sending.value = true
await scrollToBottom()
try {
const response = await sendAvatarChat(avatarId, {
message: question,
history: messages.value.slice(-10).map(({ role, content }) => ({ role, content }))
})
messages.value.push({ role: 'assistant', content: response.answer, source: response.source, references: response.references })
await scrollToBottom()
} catch (error: any) {
errorMessage.value = error?.message || '暂时无法回答,请稍后重试'
} finally {
sending.value = false
}
}
const retryLast = () => {
if (!lastQuestion.value || sending.value) return
const last = messages.value[messages.value.length - 1]
if (last?.role === 'user') messages.value.pop()
sendMessage(lastQuestion.value)
}
onMounted(loadAvatar)
</script>
<style scoped>
.chat-page { min-height: 100dvh; display: flex; flex-direction: column; background: #FFF8F1; color: #3B2417; }
.chat-header { flex: 0 0 auto; display: flex; align-items: center; gap: 12px; padding: 14px 18px; color: white; background: linear-gradient(135deg, #F97316, #FB923C); box-shadow: 0 5px 18px rgba(249, 115, 22, .2); }
.back-btn, .settings-btn { border: 0; background: transparent; color: white; cursor: pointer; font-size: 25px; padding: 2px 6px; }
.settings-btn { font-size: 20px; margin-left: auto; }
.avatar-heading { display: flex; align-items: center; gap: 10px; }
.avatar-mark { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 13px; background: rgba(255,255,255,.24); font-size: 23px; }
.avatar-heading h1 { margin: 0; font-size: 17px; }
.online-state { display: block; margin-top: 3px; font-size: 11px; opacity: .86; }
.message-list { flex: 1 1 auto; width: min(760px, 100%); box-sizing: border-box; margin: 0 auto; padding: 24px 18px 120px; overflow-y: auto; }
.welcome-card { padding: 28px 20px; text-align: center; background: rgba(255,255,255,.72); border: 1px solid #FFE1C2; border-radius: 22px; box-shadow: 0 10px 28px rgba(181, 99, 35, .08); }
.welcome-icon { color: #F97316; font-size: 30px; }
.welcome-card h2 { margin: 9px 0 8px; font-size: 20px; }
.welcome-card p { margin: 0 auto 20px; max-width: 420px; color: #8B6B58; line-height: 1.6; font-size: 14px; }
.starter-list { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; }
.starter-list button { border: 1px solid #FFD1A8; color: #C15F18; background: #FFF4E8; border-radius: 20px; padding: 8px 12px; cursor: pointer; }
.message-row { display: flex; gap: 9px; margin: 18px 0; align-items: flex-end; }
.message-row.user { justify-content: flex-end; }
.message-avatar { flex: 0 0 auto; width: 30px; height: 30px; display: grid; place-items: center; border-radius: 10px; background: #FFE4C7; }
.message-column { max-width: min(78%, 560px); }
.message-bubble { padding: 12px 14px; white-space: pre-wrap; line-height: 1.6; font-size: 15px; border-radius: 16px 16px 16px 4px; background: white; box-shadow: 0 3px 12px rgba(96, 52, 21, .07); }
.user .message-bubble { color: white; border-radius: 16px 16px 4px 16px; background: #F97316; }
.message-source { margin: 5px 4px 0; font-size: 11px; color: #A77A5B; }
.typing { display: flex; gap: 4px; padding: 14px 16px; }
.typing i { width: 5px; height: 5px; border-radius: 50%; background: #F97316; animation: blink 1s infinite alternate; }
.typing i:nth-child(2) { animation-delay: .2s; }.typing i:nth-child(3) { animation-delay: .4s; }
@keyframes blink { from { opacity: .25; } to { opacity: 1; } }
.chat-error { margin: 4px auto; color: #B42318; font-size: 13px; }.chat-error button { border: 0; background: none; color: #C15F18; cursor: pointer; text-decoration: underline; }
.composer { position: fixed; left: 0; right: 0; bottom: 0; display: flex; gap: 10px; padding: 12px max(18px, calc((100vw - 760px) / 2 + 18px)); background: rgba(255,255,255,.92); border-top: 1px solid #F4DCC7; backdrop-filter: blur(12px); }
.composer textarea { flex: 1; resize: none; min-height: 22px; max-height: 100px; padding: 11px 13px; border: 1px solid #EED8C5; border-radius: 13px; font: inherit; color: #3B2417; outline: none; }.composer textarea:focus { border-color: #F97316; }
.send-btn { align-self: flex-end; padding: 11px 18px; border: 0; border-radius: 12px; color: white; background: #F97316; cursor: pointer; }.send-btn:disabled { opacity: .45; cursor: not-allowed; }
</style>