986 lines
24 KiB
Vue
986 lines
24 KiB
Vue
<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: calc(100vh - 80px) !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>
|