Luxx/dashboard/src/views/ConversationDetailView.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>