155 lines
8.2 KiB
Vue
155 lines
8.2 KiB
Vue
<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>
|