Luxx/dashboard/src/views/ConversationsView.vue

986 lines
24 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 class="sidebar-header">
<button @click="showModal = true" class="btn-new-conv">+ 新建会话</button>
</div>
<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="重命名">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</button>
<button @click="deleteConv(c)" class="btn-icon btn-delete-icon" title="删除">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
</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 || streamingMessage">
<!-- 历史消息 -->
<div v-for="msg in convMessages" :key="msg.id" class="chat-message" :class="msg.role" :data-msg-id="msg.id">
<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 v-if="streamingMessage" class="chat-message assistant streaming">
<div class="message-avatar">🤖</div>
<div class="message-content">
<ProcessBlock
:process-steps="streamingMessage.process_steps"
:streaming="true"
/>
</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>
<!-- 消息导航栏 -->
<MessageNav
v-if="selectedConv && convMessages.length > 0"
:messages="convMessages"
:active-id="activeMessageId"
@scroll-to="scrollToMessageById"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { conversationsAPI, providersAPI, messagesAPI, toolsAPI } from '../utils/api.js'
import { renderMarkdown } from '../utils/markdown.js'
import ProcessBlock from '../components/ProcessBlock.vue'
import MessageNav from '../components/MessageNav.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 enabledTools = ref([]) // 启用的工具名称列表
const totalPages = computed(() => Math.ceil(total.value / pageSize))
// 加载启用的工具列表
const loadEnabledTools = async () => {
try {
const res = await toolsAPI.list()
if (res.success) {
const tools = res.data?.tools || []
enabledTools.value = tools.map(t => t.function?.name || t.name)
}
} catch (e) {
console.error('Failed to load tools:', e)
}
}
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 newMessage = ref('')
const sending = ref(false)
const messagesContainer = ref(null)
const streamingMessage = ref(null)
const activeMessageId = ref(null)
let scrollObserver = null
const observedElements = new Set()
// 初始化 IntersectionObserver 来跟踪可见消息
const initScrollObserver = () => {
if (!messagesContainer.value) return
scrollObserver?.disconnect()
scrollObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
activeMessageId.value = entry.target.dataset.msgId || null
}
}
},
{ root: messagesContainer.value, threshold: 0.5 }
)
// 观察已有的消息元素
nextTick(() => {
if (!messagesContainer.value) return
const wrappers = messagesContainer.value.querySelectorAll('[data-msg-id]')
wrappers.forEach(el => {
if (!observedElements.has(el)) {
scrollObserver.observe(el)
observedElements.add(el)
}
})
})
}
// 观察新添加的消息元素
watch(() => convMessages.value.length, () => {
nextTick(() => {
if (!scrollObserver || !messagesContainer.value) return
const wrappers = messagesContainer.value.querySelectorAll('[data-msg-id]')
wrappers.forEach(el => {
if (!observedElements.has(el)) {
scrollObserver.observe(el)
observedElements.add(el)
}
})
})
})
const fetchConvMessages = async (convId) => {
loadingMessages.value = true
convMessages.value = []
// 重置观察的元素集合
observedElements.clear()
try {
const res = await messagesAPI.list(convId)
if (res.success) {
convMessages.value = res.data?.messages || []
}
} catch (e) {
console.error('获取消息失败:', e)
} finally {
loadingMessages.value = false
// 加载完成后初始化 observer
nextTick(() => initScrollObserver())
}
}
// 导航到指定消息
const scrollToMessage = (index) => {
if (!messagesContainer.value) return
const items = messagesContainer.value.querySelectorAll('.chat-message')
if (items[index]) {
items[index].scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
// 导航到指定消息通过ID
const scrollToMessageById = (msgId) => {
nextTick(() => {
if (!messagesContainer.value) return
// 使用 data-msg-id 直接定位消息元素
const el = messagesContainer.value.querySelector(`[data-msg-id="${msgId}"]`)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
activeMessageId.value = msgId
}
})
}
watch(convMessages, () => {
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
})
}, { deep: true })
watch(() => streamingMessage.value?.process_steps?.length, () => {
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
})
})
// 渲染消息内容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)
// 初始化流式消息
streamingMessage.value = {
id: Date.now() + 1,
role: 'assistant',
process_steps: [],
content: '',
created_at: new Date().toISOString()
}
try {
await new Promise((resolve, reject) => {
messagesAPI.sendStream({
conversation_id: selectedConv.value.id,
content: content,
enabled_tools: enabledTools.value // 传递启用的工具名称列表
}, {
onProcessStep: (step) => {
if (!streamingMessage.value) return
// 按 id 更新或追加步骤
const idx = streamingMessage.value.process_steps.findIndex(s => s.id === step.id)
if (idx >= 0) {
streamingMessage.value.process_steps[idx] = step
} else {
streamingMessage.value.process_steps.push(step)
}
},
onDone: async (data) => {
// 将流式消息添加到列表
if (streamingMessage.value) {
convMessages.value.push({
...streamingMessage.value,
created_at: new Date().toISOString()
})
streamingMessage.value = null
}
resolve()
},
onError: (error) => {
if (streamingMessage.value) {
streamingMessage.value.process_steps.push({
id: 'error-' + Date.now(),
index: streamingMessage.value.process_steps.length,
type: 'error',
content: error
})
}
reject(new Error(error))
}
})
})
} catch (e) {
// 错误已处理
} 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: '' }
// 刷新列表
await fetchData()
// 选中新创建的会话
const newConv = list.value.find(c => c.id === res.data.id)
if (newConv) {
selectConv(newConv)
}
}
} 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()
loadEnabledTools()
})
onUnmounted(() => {
scrollObserver?.disconnect()
})
</script>
<style scoped>
/* 布局 */
.page-container {
padding: 0 !important;
overflow: hidden;
height: 100% !important;
min-height: 0;
display: flex;
flex-direction: column;
}
.page-container.conversations {
height: 100% !important;
}
.conv-layout {
display: flex;
gap: 1rem;
height: 100%;
min-height: 0;
flex: 1;
}
/* 左侧边栏 */
.conv-sidebar {
width: 20%;
min-width: 160px;
max-width: 280px;
background: var(--bg-primary);
border: 1px solid var(--border-light);
border-radius: 12px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-header {
padding: 0.75rem;
border-bottom: 1px solid var(--border-light);
}
.btn-new-conv {
width: 100%;
padding: 0.5rem;
background: var(--accent-primary);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
transition: all 0.2s;
}
.btn-new-conv:hover {
background: var(--accent-primary-hover);
}
.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.8rem;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
flex: 1;
}
.conv-item-time {
font-size: 0.65rem;
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.7rem;
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;
min-height: 0;
}
.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-header {
display: flex;
justify-content: flex-end;
padding: 8px;
border-bottom: 1px solid var(--border-light);
}
.btn-nav-toggle {
background: none;
border: none;
font-size: 18px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: background 0.15s;
}
.btn-nav-toggle:hover {
background: var(--bg-hover);
}
.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;
}
.chat-message.streaming {
opacity: 0.9;
}
.chat-message.streaming .message-avatar {
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.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: 80%;
width: 80%;
}
.chat-message.user .message-content {
max-width: 80%;
width: auto;
}
.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>