Luxx/dashboard/src/views/ConversationsView.vue

742 lines
17 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="page-container conversations">
<div class="conv-layout">
<!-- 左侧会话列表 -->
<aside class="conv-sidebar">
<div v-if="loading" class="loading"><div class="spinner-small"></div>加载中...</div>
<div v-else-if="error" class="error-msg">{{ error }} <button @click="fetchData">重试</button></div>
<div v-else-if="!list.length" class="empty-sidebar">暂无会话</div>
<div v-else class="conv-list">
<div
v-for="c in list"
:key="c.id"
class="conv-item"
:class="{ active: selectedId === c.id }"
@click="selectConv(c)"
>
<div class="conv-item-header">
<span class="conv-item-title">{{ c.title || c.first_message || '未命名会话' }}</span>
<span class="conv-item-time">{{ formatDate(c.updated_at) }}</span>
</div>
<div class="conv-item-meta">
<span class="conv-item-model">{{ c.model || '-' }}</span>
<div class="conv-item-actions" @click.stop>
<button @click="editTitle(c)" class="btn-icon" title="重命名">✏️</button>
<button @click="deleteConv(c)" class="btn-icon btn-delete-icon" title="删除">🗑️</button>
</div>
</div>
</div>
</div>
<div v-if="totalPages > 1" class="sidebar-pagination">
<button @click="page--; fetchData()" :disabled="page === 1" class="btn-page"></button>
<span>{{ page }} / {{ totalPages }}</span>
<button @click="page++; fetchData()" :disabled="page >= totalPages" class="btn-page"></button>
</div>
</aside>
<!-- 右侧内容区 - 对话界面 -->
<main class="conv-content">
<div v-if="!selectedConv" class="empty-content">
<div class="empty-icon">💬</div>
<p>选择一个会话查看</p>
</div>
<div v-else class="chat-view-container">
<div class="chat-messages" ref="messagesContainer">
<div v-if="loadingMessages" class="loading-messages">
<div class="spinner-small"></div>
<span>加载中...</span>
</div>
<div v-else-if="convMessages.length">
<div v-for="msg in convMessages" :key="msg.id" class="chat-message" :class="msg.role">
<div class="message-avatar">{{ msg.role === 'user' ? '👤' : '🤖' }}</div>
<div class="message-content">
<!-- 工具调用步骤显示(包含思考和文本内容) -->
<ProcessBlock
v-if="msg.process_steps && msg.process_steps.length"
:process-steps="msg.process_steps"
/>
<!-- 或仅显示消息内容 -->
<div v-else class="message-text" v-html="renderMsgContent(msg)"></div>
</div>
</div>
</div>
<div v-else class="chat-empty">
<p>暂无消息,开始对话吧</p>
</div>
</div>
<div class="chat-input-area">
<input
v-model="newMessage"
@keyup.enter="sendMessage"
type="text"
placeholder="输入消息..."
class="chat-input"
:disabled="sending"
/>
<button @click="sendMessage" class="btn-send" :disabled="sending || !newMessage.trim()">
{{ sending ? '发送中...' : '发送' }}
</button>
</div>
</div>
</main>
</div>
<!-- 新建会话弹窗 -->
<div v-if="showModal" class="modal-overlay" @click.self="showModal = false">
<div class="modal">
<h2>新建会话</h2>
<div class="form-group"><label>标题</label><input v-model="form.title" placeholder="会话标题(可选)" /></div>
<div class="form-group">
<label>Provider</label>
<select v-model="form.provider_id" @change="onProviderChange">
<option :value="null" disabled>选择 Provider</option>
<option v-for="p in providers" :key="p.id" :value="p.id">
{{ p.name }} ({{ p.provider_type }})
</option>
</select>
</div>
<div class="form-group">
<label>模型</label>
<input v-model="form.model" placeholder="如: gpt-4, deepseek-chat" />
</div>
<div class="modal-actions">
<button @click="showModal = false" class="btn-secondary">取消</button>
<button @click="createConv" :disabled="creating || !form.provider_id" class="btn-primary">{{ creating ? '创建中...' : '创建' }}</button>
</div>
</div>
</div>
<!-- 重命名弹窗 -->
<div v-if="editConv" class="modal-overlay" @click.self="editConv = null">
<div class="modal">
<h2>重命名会话</h2>
<div class="form-group"><label>标题</label><input v-model="editConv.title" /></div>
<div class="modal-actions">
<button @click="editConv = null" class="btn-secondary">取消</button>
<button @click="saveTitle" class="btn-primary">保存</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { conversationsAPI, providersAPI, messagesAPI } from '../utils/api.js'
import { renderMarkdown } from '../utils/markdown.js'
import ProcessBlock from '../components/ProcessBlock.vue'
const router = useRouter()
const list = ref([])
const providers = ref([])
const page = ref(1)
const pageSize = 20
const total = ref(0)
const loading = ref(true)
const error = ref('')
const showModal = ref(false)
const creating = ref(false)
const form = ref({ title: '', provider_id: null, model: '' })
const selectedId = ref(null)
const selectedConv = ref(null)
const convMessages = ref([])
const loadingMessages = ref(false)
const totalPages = computed(() => Math.ceil(total.value / pageSize))
const onProviderChange = () => {
const p = providers.value.find(p => p.id === form.value.provider_id)
if (p) form.value.model = p.default_model || ''
}
const fetchData = async () => {
loading.value = true
error.value = ''
try {
const [convRes, provRes] = await Promise.allSettled([
conversationsAPI.list({ page: page.value, page_size: pageSize }),
providersAPI.list()
])
if (convRes.status === 'fulfilled' && convRes.value.success) {
list.value = convRes.value.data?.items || []
total.value = convRes.value.data?.total || 0
// 默认选中最后一个会话
if (list.value.length > 0 && !selectedId.value) {
selectConv(list.value[0])
}
}
if (provRes.status === 'fulfilled' && provRes.value.success) {
providers.value = provRes.value.data?.providers || []
}
} catch (e) { error.value = e.message }
finally { loading.value = false }
}
const selectConv = async (c) => {
selectedId.value = c.id
selectedConv.value = c
await fetchConvMessages(c.id)
}
const fetchConvMessages = async (convId) => {
loadingMessages.value = true
convMessages.value = []
try {
const res = await messagesAPI.list(convId)
if (res.success) {
convMessages.value = res.data?.messages || []
}
} catch (e) {
console.error('获取消息失败:', e)
} finally {
loadingMessages.value = false
}
}
const newMessage = ref('')
const sending = ref(false)
const messagesContainer = ref(null)
watch(convMessages, () => {
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
})
}, { deep: true })
// 渲染消息内容Markdown
const renderMsgContent = (msg) => {
const content = msg.content || msg.text || ''
if (!content) return '-'
return renderMarkdown(content)
}
const sendMessage = async () => {
if (!newMessage.value.trim() || !selectedConv.value || sending.value) return
const content = newMessage.value.trim()
newMessage.value = ''
sending.value = true
// 添加用户消息到列表
const userMsg = {
id: 'temp-' + Date.now(),
role: 'user',
content: content,
created_at: new Date().toISOString()
}
convMessages.value.push(userMsg)
try {
await new Promise((resolve, reject) => {
messagesAPI.sendStream({
conversation_id: selectedConv.value.id,
content: content
}, {
onProcessStep: (step) => {
// 处理过程步骤
},
onDone: async (data) => {
// 刷新消息列表
await fetchConvMessages(selectedConv.value.id)
resolve()
},
onError: (error) => {
reject(new Error(error))
}
})
})
} catch (e) {
alert('发送失败: ' + e.message)
} finally {
sending.value = false
}
}
const createConv = async () => {
creating.value = true
try {
const res = await conversationsAPI.create(form.value)
if (res.success && res.data?.id) {
showModal.value = false
form.value = { title: '', provider_id: null, model: '' }
router.push(`/conversations/${res.data.id}`)
}
} catch (e) { alert('创建失败: ' + e.message) }
finally { creating.value = false }
}
const deleteConv = async (c) => {
if (!confirm(`删除「${c.title || '未命名会话'}」?`)) return
await conversationsAPI.delete(c.id)
if (selectedId.value === c.id) {
selectedId.value = null
selectedConv.value = null
}
fetchData()
}
const editConv = ref(null)
const editTitle = (c) => {
editConv.value = { ...c }
}
const saveTitle = async () => {
if (!editConv.value) return
await conversationsAPI.update(editConv.value.id, { title: editConv.value.title })
editConv.value = null
fetchData()
}
const formatDate = (dateStr) => {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
onMounted(fetchData)
</script>
<style scoped>
/* 布局 */
.page-container {
padding: 0 !important;
overflow: hidden;
}
.conv-layout {
display: flex;
gap: 1rem;
height: calc(100vh - 80px);
min-height: 400px;
}
/* 左侧边栏 */
.conv-sidebar {
width: 220px;
min-width: 180px;
background: var(--bg-primary);
border: 1px solid var(--border-light);
border-radius: 12px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.conv-list {
flex: 1;
overflow-y: auto;
}
/* 会话列表项 */
.conv-item {
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--border-light);
cursor: pointer;
transition: all 0.15s ease;
}
.conv-item:hover {
background: var(--bg-hover);
}
.conv-item.active {
background: var(--accent-primary-light);
border-left: 3px solid var(--accent-primary);
}
.conv-item-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.5rem;
margin-bottom: 0.4rem;
}
.conv-item-title {
font-size: 0.9rem;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
flex: 1;
}
.conv-item-time {
font-size: 0.7rem;
color: var(--text-tertiary);
flex-shrink: 0;
}
.conv-item-meta {
display: flex;
justify-content: space-between;
align-items: center;
}
.conv-item-model {
font-size: 0.75rem;
color: var(--text-secondary);
}
.conv-item-actions {
display: flex;
gap: 0.25rem;
opacity: 1;
}
.btn-icon {
padding: 0.2rem;
background: transparent;
border: none;
cursor: pointer;
font-size: 0.75rem;
opacity: 0.6;
transition: opacity 0.15s;
}
.btn-icon:hover {
opacity: 1;
}
.btn-delete-icon {
color: var(--danger-color);
}
/* 侧边栏分页 */
.sidebar-pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
border-top: 1px solid var(--border-light);
font-size: 0.8rem;
color: var(--text-secondary);
}
.btn-page {
padding: 0.25rem 0.5rem;
background: var(--bg-secondary);
border: 1px solid var(--border-light);
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
}
.btn-page:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* 右侧内容区 */
.conv-content {
flex: 1;
background: var(--bg-primary);
border: 1px solid var(--border-light);
border-radius: 12px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.empty-content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--text-secondary);
}
.empty-icon {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.empty-content p {
font-size: 0.9rem;
}
/* 聊天视图容器 */
.chat-view-container {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 0.75rem;
}
.chat-message {
display: flex;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.chat-message.user {
flex-direction: row-reverse;
}
.message-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--bg-secondary);
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
flex-shrink: 0;
}
.message-content {
max-width: 70%;
}
.message-text {
padding: 0.65rem 0.9rem;
border-radius: 12px;
font-size: 0.9rem;
line-height: 1.5;
background: var(--bg-secondary);
color: var(--text-primary);
word-break: break-word;
}
.chat-message.user .message-text {
background: var(--accent-primary);
color: white;
}
.chat-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
font-size: 0.85rem;
}
.loading-messages {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
color: var(--text-secondary);
font-size: 0.85rem;
}
.loading-messages .spinner-small {
margin-bottom: 0.5rem;
}
/* Markdown 内容样式 */
.message-text {
line-height: 1.6;
}
.message-text :deep(pre) {
background: var(--bg-code);
border-radius: 8px;
padding: 0.75rem;
overflow-x: auto;
margin: 0.5rem 0;
}
.message-text :deep(code) {
background: var(--bg-code);
padding: 0.15rem 0.35rem;
border-radius: 4px;
font-size: 0.85em;
}
.message-text :deep(pre code) {
background: none;
padding: 0;
}
.message-text :deep(p) {
margin: 0.5rem 0;
}
.message-text :deep(p:first-child) {
margin-top: 0;
}
.message-text :deep(p:last-child) {
margin-bottom: 0;
}
.chat-input-area {
padding: 1rem;
border-top: 1px solid var(--border-light);
display: flex;
gap: 0.75rem;
}
.chat-input {
flex: 1;
padding: 0.65rem 0.9rem;
border: 1px solid var(--border-input);
border-radius: 8px;
background: var(--bg-input);
color: var(--text-primary);
font-size: 0.9rem;
}
.chat-input:focus {
outline: none;
border-color: var(--accent-primary);
}
.btn-send {
padding: 0.5rem 1rem;
background: var(--accent-primary);
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 0.85rem;
}
.btn-send:hover {
background: var(--accent-primary-hover);
}
/* 按钮样式 */
.btn-primary {
padding: 0.5rem 1rem;
background: var(--accent-primary);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.btn-primary:hover {
background: var(--accent-primary-hover);
}
.btn-secondary {
padding: 0.5rem 1rem;
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-light);
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.btn-secondary:hover {
background: var(--bg-hover);
}
/* 加载和空状态 */
.loading, .empty-sidebar {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
font-size: 0.85rem;
}
.error-msg {
text-align: center;
padding: 1rem;
color: var(--danger-color);
background: var(--danger-bg);
border-radius: 8px;
margin: 1rem;
font-size: 0.85rem;
}
.spinner-small {
width: 24px;
height: 24px;
border: 3px solid var(--border-light);
border-top-color: var(--accent-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 0.5rem;
}
/* 模态框 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: var(--bg-primary);
border-radius: 16px;
padding: 1.25rem;
width: 100%;
max-width: 400px;
}
.modal h2 {
margin: 0 0 1.25rem;
font-size: 1.1rem;
color: var(--text-primary);
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
font-size: 0.9rem;
color: var(--text-primary);
}
.form-group input,
.form-group select {
width: 100%;
padding: 0.65rem;
border: 1px solid var(--border-input);
border-radius: 8px;
background: var(--bg-input);
box-sizing: border-box;
font-size: 0.9rem;
color: var(--text-primary);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1.25rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>