176 lines
5.8 KiB
Vue
176 lines
5.8 KiB
Vue
<template>
|
|
<div class="chat-view">
|
|
<div class="chat-container">
|
|
<div class="messages" ref="messagesContainer">
|
|
<div v-if="loading" class="loading">加载中...</div>
|
|
<div v-else-if="!messages.length" class="empty">
|
|
<p>开始对话吧!</p>
|
|
</div>
|
|
<div v-for="msg in messages" :key="msg.id" :class="['message', msg.role]">
|
|
<div class="message-avatar">{{ msg.role === 'user' ? 'U' : 'A' }}</div>
|
|
<div class="message-content">
|
|
<div class="message-text">{{ msg.content }}</div>
|
|
<div class="message-time">{{ formatTime(msg.created_at) }}</div>
|
|
</div>
|
|
</div>
|
|
<div v-if="streaming" class="message assistant streaming">
|
|
<div class="message-avatar">A</div>
|
|
<div class="message-content">
|
|
<div class="message-text">{{ streamContent }}<span class="cursor">▋</span></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="input-area">
|
|
<textarea
|
|
v-model="inputMessage"
|
|
@keydown.enter.exact.prevent="sendMessage"
|
|
placeholder="输入消息..."
|
|
rows="1"
|
|
></textarea>
|
|
<button @click="sendMessage" :disabled="!inputMessage.trim() || sending" class="send-btn">
|
|
{{ sending ? '发送中...' : '发送' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted, nextTick } from 'vue'
|
|
import { useRoute } from 'vue-router'
|
|
import { conversationsAPI, messagesAPI } from '../services/api.js'
|
|
|
|
const route = useRoute()
|
|
const messages = ref([])
|
|
const inputMessage = ref('')
|
|
const loading = ref(true)
|
|
const sending = ref(false)
|
|
const streaming = ref(false)
|
|
const streamContent = ref('')
|
|
const messagesContainer = ref(null)
|
|
const conversationId = ref(route.params.id)
|
|
|
|
const loadMessages = async () => {
|
|
loading.value = true
|
|
try {
|
|
const res = await messagesAPI.list(conversationId.value)
|
|
if (res.success) {
|
|
messages.value = res.data.messages || []
|
|
}
|
|
} catch (e) {
|
|
console.error(e)
|
|
} finally {
|
|
loading.value = false
|
|
scrollToBottom()
|
|
}
|
|
}
|
|
|
|
const sendMessage = async () => {
|
|
if (!inputMessage.value.trim() || sending.value) return
|
|
|
|
const content = inputMessage.value.trim()
|
|
inputMessage.value = ''
|
|
sending.value = true
|
|
|
|
// 添加用户消息
|
|
messages.value.push({
|
|
id: Date.now(),
|
|
role: 'user',
|
|
content: content,
|
|
created_at: new Date().toISOString()
|
|
})
|
|
scrollToBottom()
|
|
|
|
try {
|
|
streaming.value = true
|
|
streamContent.value = ''
|
|
|
|
const response = await messagesAPI.sendStream({
|
|
conversation_id: conversationId.value,
|
|
content: content,
|
|
tools_enabled: true
|
|
})
|
|
|
|
const reader = response.body.getReader()
|
|
const decoder = new TextDecoder()
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
|
|
const chunk = decoder.decode(value)
|
|
const lines = chunk.split('\n')
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith('data: ')) {
|
|
const data = line.slice(6)
|
|
if (data === '[DONE]') continue
|
|
|
|
try {
|
|
const parsed = JSON.parse(data)
|
|
if (parsed.type === 'text') {
|
|
streamContent.value += parsed.content
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 添加助手消息
|
|
if (streamContent.value) {
|
|
messages.value.push({
|
|
id: Date.now() + 1,
|
|
role: 'assistant',
|
|
content: streamContent.value,
|
|
created_at: new Date().toISOString()
|
|
})
|
|
}
|
|
} catch (e) {
|
|
console.error('发送失败:', e)
|
|
alert('发送失败: ' + e.message)
|
|
} finally {
|
|
sending.value = false
|
|
streaming.value = false
|
|
scrollToBottom()
|
|
}
|
|
}
|
|
|
|
const scrollToBottom = () => {
|
|
nextTick(() => {
|
|
if (messagesContainer.value) {
|
|
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
|
}
|
|
})
|
|
}
|
|
|
|
const formatTime = (time) => {
|
|
if (!time) return ''
|
|
return new Date(time).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
|
}
|
|
|
|
onMounted(loadMessages)
|
|
</script>
|
|
|
|
<style scoped>
|
|
.chat-view { height: calc(100vh - 70px); display: flex; flex-direction: column; }
|
|
.chat-container { flex: 1; display: flex; flex-direction: column; max-width: 900px; margin: 0 auto; width: 100%; }
|
|
.messages { flex: 1; overflow-y: auto; padding: 1rem; }
|
|
.loading, .empty { text-align: center; padding: 4rem; color: var(--text); }
|
|
.message { display: flex; gap: 1rem; margin-bottom: 1.5rem; }
|
|
.message.user { flex-direction: row-reverse; }
|
|
.message-avatar { width: 40px; height: 40px; background: var(--code-bg); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1.2rem; flex-shrink: 0; }
|
|
.message.user .message-avatar { background: var(--accent-bg); }
|
|
.message-content { max-width: 70%; }
|
|
.message-text { padding: 1rem; background: var(--code-bg); border-radius: 12px; line-height: 1.6; white-space: pre-wrap; }
|
|
.message.user .message-text { background: var(--accent); color: white; }
|
|
.message-time { font-size: 0.75rem; color: var(--text); margin-top: 0.25rem; }
|
|
.cursor { animation: blink 1s infinite; }
|
|
@keyframes blink { 0%, 50% { opacity: 1; } 51%, 100% { opacity: 0; } }
|
|
.input-area { display: flex; gap: 0.75rem; padding: 1rem; border-top: 1px solid var(--border); }
|
|
.input-area textarea { flex: 1; padding: 0.875rem 1rem; border: 1px solid var(--border); border-radius: 12px; resize: none; font-size: 1rem; background: var(--bg); color: var(--text); }
|
|
.input-area textarea:focus { outline: none; border-color: var(--accent); }
|
|
.send-btn { padding: 0.875rem 1.5rem; background: var(--accent); color: white; border: none; border-radius: 12px; font-size: 1rem; cursor: pointer; white-space: nowrap; }
|
|
.send-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
|
</style>
|