Luxx/dashboard/src/views/ConversationView.vue

437 lines
19 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 v-if="hasActiveStream(c.id)" class="streaming-indicator" title="正在生成中"></span>
</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 || currentStreamState">
<!-- 历史消息 -->
<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>
<!-- 流式消息 - 使用 store 中的状态 -->
<div v-if="currentStreamState" class="chat-message assistant streaming">
<div class="message-avatar">🤖</div>
<div class="message-content">
<ProcessBlock
:process-steps="currentStreamState.process_steps"
:streaming="currentStreamState.status === 'streaming'"
/>
</div>
</div>
</div>
<div v-else class="chat-empty">
<p>暂无消息,开始对话吧</p>
</div>
</div>
<div class="chat-input-area">
<input
v-model="newMessage"
@keyup.enter="handleSend"
type="text"
placeholder="输入消息..."
class="chat-input"
:disabled="sending"
/>
<button @click="handleSend" class="btn-send" :disabled="sending || !newMessage.trim()" title="发送">
<svg v-if="!sending" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
</svg>
<span v-else>...</span>
</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="handleCreate" :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="handleSaveTitle" 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 { useConversations } from '../utils/useConversations.js'
import { renderMarkdown } from '../utils/markdown.js'
import ProcessBlock from '../components/ProcessBlock.vue'
import MessageNav from '../components/MessageNav.vue'
const {
list,
providers,
page,
totalPages,
loading,
error,
selectedId,
selectedConv,
convMessages,
loadingMessages,
sending,
currentStreamState,
hasActiveStream,
fetchData,
selectConv,
sendMessage,
createConv,
deleteConv,
updateConvTitle,
init,
cleanup
} = useConversations()
const showModal = ref(false)
const creating = ref(false)
const form = ref({ title: '', provider_id: null, model: '' })
const newMessage = ref('')
const messagesContainer = ref(null)
const activeMessageId = ref(null)
let scrollObserver = null
const observedElements = new Set()
const editConv = ref(null)
// 渲染消息内容Markdown
const renderMsgContent = (msg) => {
const content = msg.content || msg.text || ''
if (!content) return '-'
return renderMarkdown(content)
}
// 处理发送消息
const handleSend = async () => {
if (!newMessage.value.trim()) return
await sendMessage(newMessage.value)
newMessage.value = ''
scrollToBottom()
}
// 处理创建会话
const handleCreate = async () => {
creating.value = true
try {
await createConv(form.value)
showModal.value = false
form.value = { title: '', provider_id: null, model: '' }
} catch (e) {
alert('创建失败: ' + e.message)
}
finally {
creating.value = false
}
}
// 处理删除会话
const deleteConvAction = async (c) => {
if (!confirm(`删除「${c.title || '未命名会话'}」?`)) return
try {
await deleteConv(c)
} catch (e) {
alert('删除失败: ' + e.message)
}
}
// 处理重命名
const editTitle = (c) => {
editConv.value = { ...c }
}
const handleSaveTitle = async () => {
if (!editConv.value) return
try {
await updateConvTitle(editConv.value, editConv.value.title)
editConv.value = null
} catch (e) {
alert('保存失败: ' + e.message)
}
}
const formatDate = (dateStr) => {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
const onProviderChange = () => {
const p = providers.value.find(p => p.id === form.value.provider_id)
if (p) form.value.model = p.default_model || ''
}
// 自动滚动到底部
const scrollToBottom = () => {
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
})
}
watch(convMessages, () => {
scrollToBottom()
}, { deep: true })
watch(() => currentStreamState.value?.process_steps?.length, () => {
scrollToBottom()
})
// 导航到指定消息通过ID
const scrollToMessageById = (msgId) => {
nextTick(() => {
if (!messagesContainer.value) return
const el = messagesContainer.value.querySelector(`[data-msg-id="${msgId}"]`)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
activeMessageId.value = msgId
}
})
}
onMounted(() => {
init()
})
onUnmounted(() => {
scrollObserver?.disconnect()
cleanup()
})
</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); display: flex; align-items: center; gap: 4px; }
.streaming-indicator {
width: 6px;
height: 6px;
background: #4ade80;
border-radius: 50%;
animation: pulse 1.5s ease-in-out infinite;
}
.conv-item-actions { display: flex; gap: 0.25rem; opacity: 1; }
.btn-icon {
background: transparent;
border: none;
cursor: pointer;
padding: 4px;
border-radius: 4px;
color: var(--text-secondary);
transition: all 0.15s;
display: flex;
align-items: center;
justify-content: center;
}
.btn-icon:hover {
background: var(--bg-hover);
color: var(--accent-primary);
}
.btn-icon svg {
width: 14px;
height: 14px;
}
.btn-delete-icon { color: var(--danger-color); }
.btn-delete-icon:hover { 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 { width: 40px; height: 40px; background: var(--accent-primary); color: white; border: none; border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s ease; }
.btn-send:hover:not(:disabled) { background: var(--accent-primary-hover); transform: translateY(-1px); box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3); }
.btn-send:disabled { opacity: 0.5; cursor: not-allowed; }
/* 按钮样式 */
.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; }
@keyframes spin { to { transform: rotate(360deg); } }
/* 模态框 */
.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 { width: 100%; padding: 0.65rem; border: 1px solid var(--border-input); border-radius: 8px; background: var(--bg-input); box-sizing: border-box; color: var(--text-primary); font-size: 0.9rem; }
.modal-actions { display: flex; justify-content: flex-end; gap: 0.75rem; margin-top: 1.25rem; }
</style>