From 2eb0c6bf7a901c41765976660d95561419784420 Mon Sep 17 00:00:00 2001 From: ViperEkura <3081035982@qq.com> Date: Wed, 15 Apr 2026 11:03:27 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E7=AE=80=E5=8C=96=E9=83=A8=E5=88=86?= =?UTF-8?q?=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dashboard/src/components/EmptyState.vue | 79 --- dashboard/src/components/ErrorMessage.vue | 103 ---- dashboard/src/index.js | 6 +- dashboard/src/router/index.js | 8 +- dashboard/src/style.css | 39 ++ dashboard/src/utils/useConversations.js | 261 +++++++++ dashboard/src/utils/useFormatters.js | 112 ++-- dashboard/src/utils/useUtils.js | 121 +--- .../src/views/ConversationDetailView.vue | 526 ------------------ ...ersationsView.vue => ConversationView.vue} | 379 ++++--------- dashboard/src/views/HomeView.vue | 7 +- dashboard/src/views/SettingsView.vue | 8 +- dashboard/src/views/ToolsView.vue | 7 +- luxx/routes/messages.py | 3 +- luxx/routes/tools.py | 1 - 15 files changed, 452 insertions(+), 1208 deletions(-) delete mode 100644 dashboard/src/components/EmptyState.vue delete mode 100644 dashboard/src/components/ErrorMessage.vue create mode 100644 dashboard/src/utils/useConversations.js delete mode 100644 dashboard/src/views/ConversationDetailView.vue rename dashboard/src/views/{ConversationsView.vue => ConversationView.vue} (68%) diff --git a/dashboard/src/components/EmptyState.vue b/dashboard/src/components/EmptyState.vue deleted file mode 100644 index 84421e9..0000000 --- a/dashboard/src/components/EmptyState.vue +++ /dev/null @@ -1,79 +0,0 @@ - - - - - diff --git a/dashboard/src/components/ErrorMessage.vue b/dashboard/src/components/ErrorMessage.vue deleted file mode 100644 index 7c92623..0000000 --- a/dashboard/src/components/ErrorMessage.vue +++ /dev/null @@ -1,103 +0,0 @@ - - - - - diff --git a/dashboard/src/index.js b/dashboard/src/index.js index d4d18e0..eed2331 100644 --- a/dashboard/src/index.js +++ b/dashboard/src/index.js @@ -1,3 +1,5 @@ // 导出组件 -export { default as ErrorMessage } from './components/ErrorMessage.vue' -export { default as EmptyState } from './components/EmptyState.vue' +export { default as AppHeader } from './components/AppHeader.vue' +export { default as ProcessBlock } from './components/ProcessBlock.vue' +export { default as MessageBubble } from './components/MessageBubble.vue' +export { default as MessageNav } from './components/MessageNav.vue' diff --git a/dashboard/src/router/index.js b/dashboard/src/router/index.js index 7ea70c5..9e41d99 100644 --- a/dashboard/src/router/index.js +++ b/dashboard/src/router/index.js @@ -23,13 +23,7 @@ const routes = [ { path: '/conversations', name: 'Conversations', - component: () => import('../views/ConversationsView.vue'), - meta: { requiresAuth: true } - }, - { - path: '/conversations/:id', - name: 'ConversationDetail', - component: () => import('../views/ConversationDetailView.vue'), + component: () => import('../views/ConversationView.vue'), meta: { requiresAuth: true } }, { diff --git a/dashboard/src/style.css b/dashboard/src/style.css index e83f74d..f0df474 100644 --- a/dashboard/src/style.css +++ b/dashboard/src/style.css @@ -601,3 +601,42 @@ select[aria-expanded="true"] { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; } + +/* ============ Switch Toggle ============ */ +.switch { + position: relative; + display: inline-block; + width: 40px; + height: 22px; + flex-shrink: 0; +} +.switch input { + opacity: 0; + width: 0; + height: 0; +} +.slider { + position: absolute; + cursor: pointer; + inset: 0; + background-color: #ccc; + transition: 0.3s; + border-radius: 22px; +} +.slider:before { + position: absolute; + content: ""; + height: 16px; + width: 16px; + left: 3px; + bottom: 3px; + background-color: white; + transition: 0.3s; + border-radius: 50%; +} +input:checked + .slider { + background-color: var(--accent-primary); +} +input:checked + .slider:before { + transform: translateX(18px); +} diff --git a/dashboard/src/utils/useConversations.js b/dashboard/src/utils/useConversations.js new file mode 100644 index 0000000..9dcf410 --- /dev/null +++ b/dashboard/src/utils/useConversations.js @@ -0,0 +1,261 @@ +import { ref, computed, watch } from 'vue' +import { conversationsAPI, messagesAPI, toolsAPI, providersAPI } from './api.js' +import { streamManager } from './streamManager.js' +import { useStreamStore } from './streamStore.js' + +// 对话管理 Composable +export function useConversations() { + const streamStore = useStreamStore() + + // 状态 + 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 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 currentStreamState = computed(() => { + return streamStore.getStreamState(selectedId.value) + }) + + const sending = computed(() => { + const state = streamStore.getStreamState(selectedId.value) + return state && state.status === 'streaming' + }) + + // 检查指定会话是否有活跃流 + const hasActiveStream = (convId) => { + return streamStore.hasActiveStream(convId) + } + + // 加载启用的工具列表 + 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 fetchData = async () => { + loading.value = true + error.value = '' + try { + const [convRes, provRes, toolsRes] = await Promise.allSettled([ + conversationsAPI.list({ page: page.value, page_size: pageSize }), + providersAPI.list(), + toolsAPI.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 || [] + } + if (toolsRes.status === 'fulfilled' && toolsRes.value.success) { + enabledTools.value = (toolsRes.value.data?.tools || []).map(t => t.function?.name || t.name) + } + } 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) + setupStreamWatch() + } + + // 获取会话消息 + 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 sendMessage = async (content) => { + if (!content.trim() || !selectedConv.value || sending.value) return + + const trimmedContent = content.trim() + + // 添加用户消息到列表 + const userMsgId = 'user-' + Date.now() + const userMsg = { + id: userMsgId, + role: 'user', + content: trimmedContent, + created_at: new Date().toISOString() + } + convMessages.value.push(userMsg) + + // 如果还没有标题或标题为默认标题,使用第一条消息作为标题 + const currentTitle = selectedConv.value?.title + const isDefaultTitle = !currentTitle || currentTitle === 'New Conversation' || currentTitle.trim() === '' + if (isDefaultTitle) { + const title = trimmedContent.slice(0, 30) + (trimmedContent.length > 30 ? '...' : '') + selectedConv.value.title = title + // 更新列表中的标题 + const conv = list.value.find(c => c.id === selectedConv.value.id) + if (conv) conv.title = title + // 调用 API 保存 + await conversationsAPI.update(selectedConv.value.id, { title }) + } + + // 使用 StreamManager 发送流式请求 + await streamManager.startStream( + selectedConv.value.id, + { + conversation_id: selectedConv.value.id, + content: trimmedContent, + enabled_tools: enabledTools.value + }, + userMsgId + ) + } + + // 创建会话 + const createConv = async (form) => { + const res = await conversationsAPI.create(form) + if (res.success && res.data?.id) { + await fetchData() + const newConv = list.value.find(c => c.id === res.data.id) + if (newConv) { + await selectConv(newConv) + } + return res.data + } + throw new Error(res.message) + } + + // 删除会话 + const deleteConv = async (c) => { + if (hasActiveStream(c.id)) { + streamManager.cancelStream(c.id) + } + await conversationsAPI.delete(c.id) + if (selectedId.value === c.id) { + selectedId.value = null + selectedConv.value = null + } + await fetchData() + } + + // 更新会话标题 + const updateConvTitle = async (c, newTitle) => { + c.title = newTitle + // 更新列表中的标题 + const conv = list.value.find(item => item.id === c.id) + if (conv) conv.title = newTitle + // 调用 API 保存 + await conversationsAPI.update(c.id, { title: newTitle }) + } + + // 设置流状态监听 + let unwatchStream = null + const setupStreamWatch = () => { + if (unwatchStream) { + unwatchStream() + } + + unwatchStream = watch( + () => streamStore.getStreamState(selectedId.value), + (state) => { + if (!state) return + + if (state.status === 'done') { + const completedMessage = { + id: state.id, + role: 'assistant', + process_steps: state.process_steps, + token_count: state.token_count, + usage: state.usage, + created_at: new Date().toISOString() + } + convMessages.value.push(completedMessage) + streamStore.clearStream(selectedId.value) + } else if (state.status === 'error') { + console.error('Stream error:', state.error) + streamStore.clearStream(selectedId.value) + } + }, + { deep: true } + ) + } + + // 初始化 + const init = async () => { + await fetchData() + } + + // 清理 + const cleanup = () => { + if (unwatchStream) { + unwatchStream() + } + } + + return { + // 状态 + list, + providers, + page, + totalPages, + loading, + error, + selectedId, + selectedConv, + convMessages, + loadingMessages, + sending, + currentStreamState, + + // 方法 + hasActiveStream, + fetchData, + selectConv, + fetchConvMessages, + sendMessage, + createConv, + deleteConv, + updateConvTitle, + loadEnabledTools, + init, + cleanup + } +} diff --git a/dashboard/src/utils/useFormatters.js b/dashboard/src/utils/useFormatters.js index 2c88552..851d6c7 100644 --- a/dashboard/src/utils/useFormatters.js +++ b/dashboard/src/utils/useFormatters.js @@ -2,51 +2,27 @@ * 格式化工具函数 */ +// 相对时间映射 +const RELATIVE = (diff) => { + if (diff < 60000) return '刚刚' + if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前` + if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前` + return `${Math.floor(diff / 86400000)} 天前` +} + /** - * 格式化日期 + * 格式化日期 (MMDDHHMM) * @param {string|Date} date - 日期 * @param {string} format - 格式 ('short', 'long', 'relative') */ -export function formatDate(date, format = 'short') { +export const formatDate = (date, format = 'short') => { if (!date) return '未知时间' - const d = new Date(date) if (isNaN(d.getTime())) return '无效日期' + if (format === 'relative') return RELATIVE(new Date() - d) - const now = new Date() - const diff = now - d - - // 相对时间 - if (format === 'relative') { - if (diff < 60000) return '刚刚' - if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前` - if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前` - if (diff < 604800000) return `${Math.floor(diff / 86400000)} 天前` - } - - // 短格式 - if (format === 'short') { - const year = d.getFullYear() - const month = String(d.getMonth() + 1).padStart(2, '0') - const day = String(d.getDate()).padStart(2, '0') - const hours = String(d.getHours()).padStart(2, '0') - const minutes = String(d.getMinutes()).padStart(2, '0') - - if (year === now.getFullYear()) { - return `${month}-${day} ${hours}:${minutes}` - } - return `${year}-${month}-${day}` - } - - // 长格式 - const year = d.getFullYear() - const month = d.getMonth() + 1 - const day = d.getDate() - const hours = String(d.getHours()).padStart(2, '0') - const minutes = String(d.getMinutes()).padStart(2, '0') - const seconds = String(d.getSeconds()).padStart(2, '0') - - return `${year}年${month}月${day}日 ${hours}:${minutes}:${seconds}` + const pad = (n) => String(n).padStart(2, '0') + return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}` } /** @@ -54,67 +30,41 @@ export function formatDate(date, format = 'short') { * @param {number} num - 数字 * @param {Object} options - 配置 */ -export function formatNumber(num, options = {}) { - const { - decimals = 0, - thousands = true, - suffix = '' - } = options - +export const formatNumber = (num, options = {}) => { if (typeof num !== 'number') return num - - const fixed = num.toFixed(decimals) - - if (thousands) { - const parts = fixed.split('.') - parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',') - return parts.join('.') + suffix - } - - return fixed + suffix + const { decimals = 0, thousands = true, suffix = '' } = options + return new Intl.NumberFormat('zh-CN', { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + useGrouping: thousands + }).format(num) + suffix } /** * 截断文本 - * @param {string} text - 文本 - * @param {number} maxLength - 最大长度 - * @param {string} suffix - 后缀 */ -export function truncate(text, maxLength = 50, suffix = '...') { - if (!text) return '' - if (text.length <= maxLength) return text - return text.slice(0, maxLength - suffix.length) + suffix -} +export const truncate = (text, maxLength = 50, suffix = '...') => + !text || text.length <= maxLength ? text : text.slice(0, maxLength - suffix.length) + suffix /** * 格式化文件大小 - * @param {number} bytes - 字节数 */ -export function formatFileSize(bytes) { - if (bytes === 0) return '0 B' - - const k = 1024 - const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] +export const formatFileSize = (bytes) => { + if (!bytes) return '0 B' + const k = 1024, sizes = ['B', 'KB', 'MB', 'GB', 'TB'] const i = Math.floor(Math.log(bytes) / Math.log(k)) - - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] + return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}` } /** * 首字母大写 - * @param {string} str - 字符串 */ -export function capitalize(str) { - if (!str) return '' - return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase() -} +export const capitalize = (str) => str ? str.replace(/^./, c => c.toUpperCase()) : '' /** * 格式化令牌数 - * @param {number} tokens - 令牌数 */ -export function formatTokens(tokens) { - if (tokens < 1000) return tokens.toString() - if (tokens < 1000000) return (tokens / 1000).toFixed(1) + 'K' - return (tokens / 1000000).toFixed(1) + 'M' -} +export const formatTokens = (tokens) => + tokens >= 1e6 ? `${(tokens / 1e6).toFixed(1)}M` + : tokens >= 1e3 ? `${(tokens / 1e3).toFixed(1)}K` + : String(tokens) diff --git a/dashboard/src/utils/useUtils.js b/dashboard/src/utils/useUtils.js index be727f5..b02629c 100644 --- a/dashboard/src/utils/useUtils.js +++ b/dashboard/src/utils/useUtils.js @@ -3,16 +3,9 @@ * @param {Function} func - 要执行的函数 * @param {number} wait - 等待时间(毫秒) */ -export function debounce(func, wait = 300) { - let timeout - return function executedFunction(...args) { - const later = () => { - clearTimeout(timeout) - func(...args) - } - clearTimeout(timeout) - timeout = setTimeout(later, wait) - } +export const debounce = (fn, wait = 300) => { + let t + return (...args) => (clearTimeout(t), t = setTimeout(() => fn(...args), wait)) } /** @@ -20,101 +13,46 @@ export function debounce(func, wait = 300) { * @param {Function} func - 要执行的函数 * @param {number} limit - 时间限制(毫秒) */ -export function throttle(func, limit = 300) { - let inThrottle - return function executedFunction(...args) { - if (!inThrottle) { - func(...args) - inThrottle = true - setTimeout(() => inThrottle = false, limit) - } - } +export const throttle = (fn, limit = 300) => { + let t + return (...args) => !t && (fn(...args), t = setTimeout(() => t = null, limit)) } /** - * 深拷贝 + * 深拷贝(使用原生 API) * @param {any} obj - 要拷贝的对象 */ -export function deepClone(obj) { - if (obj === null || typeof obj !== 'object') return obj - if (obj instanceof Date) return new Date(obj) - if (obj instanceof Array) return obj.map(item => deepClone(item)) - if (obj instanceof Object) { - const copy = {} - for (const key in obj) { - if (obj.hasOwnProperty(key)) { - copy[key] = deepClone(obj[key]) - } - } - return copy - } -} +export const deepClone = (obj) => structuredClone(obj) /** * 生成随机 Id * @param {number} length - 长度 */ -export function generateId(length = 8) { - const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' - let result = '' - for (let i = 0; i < length; i++) { - result += chars.charAt(Math.floor(Math.random() * chars.length)) - } - return result -} +export const generateId = (length = 8) => + Math.random().toString(36).slice(2, 2 + length) /** * 本地存储工具 */ export const storage = { - get(key, defaultValue = null) { + get: (key, defaultValue = null) => { try { const item = localStorage.getItem(key) return item ? JSON.parse(item) : defaultValue - } catch { - return defaultValue - } + } catch { return defaultValue } }, - - set(key, value) { - try { - localStorage.setItem(key, JSON.stringify(value)) - return true - } catch { - return false - } - }, - - remove(key) { - try { - localStorage.removeItem(key) - return true - } catch { - return false - } - }, - - clear() { - try { - localStorage.clear() - return true - } catch { - return false - } - } + set: (key, value) => { try { localStorage.setItem(key, JSON.stringify(value)); return true } catch { return false } }, + remove: (key) => { try { localStorage.removeItem(key); return true } catch { return false } }, + clear: () => { try { localStorage.clear(); return true } catch { return false } } } /** * 检测设备类型 */ -export function getDeviceType() { +export const getDeviceType = () => { const ua = navigator.userAgent - if (/(tablet|ipad|playbook|silk)|(android(?!.*mobi))/i.test(ua)) { - return 'tablet' - } - if (/Mobile|Android|iP(hone|od)|IEMobile|BlackBerry|Kindle|Silk-Accelerated|(hpw|web)OS|Opera M(obi|ini)/.test(ua)) { - return 'mobile' - } + if (/(tablet|ipad|playbook|silk)|(android(?!.*mobi))/i.test(ua)) return 'tablet' + if (/Mobile|Android|iP(hone|od)|IEMobile|BlackBerry|Kindle|Silk-Accelerated|(hpw|web)OS|Opera M(obi|ini)/.test(ua)) return 'mobile' return 'desktop' } @@ -122,25 +60,4 @@ export function getDeviceType() { * 复制到剪贴板 * @param {string} text - 要复制的文本 */ -export async function copyToClipboard(text) { - try { - await navigator.clipboard.writeText(text) - return true - } catch { - // 降级方案 - const textarea = document.createElement('textarea') - textarea.value = text - textarea.style.position = 'fixed' - textarea.style.opacity = '0' - document.body.appendChild(textarea) - textarea.select() - try { - document.execCommand('copy') - document.body.removeChild(textarea) - return true - } catch { - document.body.removeChild(textarea) - return false - } - } -} +export const copyToClipboard = (text) => navigator.clipboard.writeText(text).then(() => true).catch(() => false) diff --git a/dashboard/src/views/ConversationDetailView.vue b/dashboard/src/views/ConversationDetailView.vue deleted file mode 100644 index 696c399..0000000 --- a/dashboard/src/views/ConversationDetailView.vue +++ /dev/null @@ -1,526 +0,0 @@ - - - - - diff --git a/dashboard/src/views/ConversationsView.vue b/dashboard/src/views/ConversationView.vue similarity index 68% rename from dashboard/src/views/ConversationsView.vue rename to dashboard/src/views/ConversationView.vue index ffdb8db..4dd6df7 100644 --- a/dashboard/src/views/ConversationsView.vue +++ b/dashboard/src/views/ConversationView.vue @@ -35,7 +35,7 @@ - @@ -137,7 +137,7 @@ @@ -149,7 +149,7 @@
@@ -166,93 +166,39 @@ @@ -620,7 +427,7 @@ onUnmounted(() => { .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); } +.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; } diff --git a/dashboard/src/views/HomeView.vue b/dashboard/src/views/HomeView.vue index 592dc4b..dd4b55d 100644 --- a/dashboard/src/views/HomeView.vue +++ b/dashboard/src/views/HomeView.vue @@ -30,15 +30,10 @@