feat: add avatar chat and knowledge workflow
This commit is contained in:
154
digital-avatar-app/src/views/AvatarChat.vue
Normal file
154
digital-avatar-app/src/views/AvatarChat.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user