From 4afc781717b738ed126435c24dc058cf342399eb Mon Sep 17 00:00:00 2001 From: aaron <> Date: Sat, 31 May 2025 22:36:49 +0800 Subject: [PATCH] update --- docker-compose.yml | 2 +- src/views/ChatAgentView.vue | 1061 ++++++++++++++++++++++++++++++++--- 2 files changed, 979 insertions(+), 84 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index a898e70..b218374 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,7 @@ services: build: context: . dockerfile: Dockerfile - image: tradus-web:1.3.30 + image: tradus-web:1.3.31 container_name: tradus-web ports: - '6000:80' diff --git a/src/views/ChatAgentView.vue b/src/views/ChatAgentView.vue index f992d49..b12e374 100644 --- a/src/views/ChatAgentView.vue +++ b/src/views/ChatAgentView.vue @@ -9,6 +9,9 @@ onMounted(() => { breaks: true, gfm: true, // GitHub风格Markdown,支持表格等扩展语法 }) + + // 初始化对话列表 + fetchConversations() }) // 消息类型定义 @@ -20,16 +23,135 @@ interface Message { isStreaming?: boolean } +// 对话类型定义 +interface Conversation { + id: string + name: string +} + +// 历史消息类型定义 +interface HistoryMessage { + id: string + conversation_id: string + query: string + answer: string + created_at: number +} + // 状态管理 const messageInput = ref('') const messages = ref([]) const isLoading = ref(false) const chatContainer = ref(null) +// 对话相关状态 +const conversations = ref([]) +const selectedConversationId = ref('') +const isLoadingConversations = ref(false) +const isLoadingHistory = ref(false) +const isDropdownOpen = ref(false) + +// 自定义弹框状态 +const showDeleteDialog = ref(false) +const showRenameDialog = ref(false) +const conversationToDelete = ref(null) +const conversationToRename = ref(null) +const newConversationName = ref('') +const renameInputRef = ref(null) + // 根据环境选择API基础URL const apiBaseUrl = import.meta.env.MODE === 'development' ? 'http://127.0.0.1:8000' : 'https://api.ibtc.work' +// 获取对话列表 +const fetchConversations = async () => { + try { + isLoadingConversations.value = true + const response = await http.get(`${apiBaseUrl}/analysis/conversations`) + if (response.ok) { + conversations.value = await response.json() + + // 如果有历史会话且当前没有选中任何会话,默认选择第一个会话 + if (conversations.value.length > 0 && !selectedConversationId.value) { + selectedConversationId.value = conversations.value[0].id + await fetchConversationMessages(conversations.value[0].id) + } + } + } catch (error) { + console.error('获取对话列表失败:', error) + } finally { + isLoadingConversations.value = false + } +} + +// 获取对话历史消息 +const fetchConversationMessages = async (conversationId: string) => { + try { + isLoadingHistory.value = true + const response = await http.get( + `${apiBaseUrl}/analysis/conversation_messages/${conversationId}`, + ) + if (response.ok) { + const historyMessages: HistoryMessage[] = await response.json() + + // 将历史消息转换为Message格式 + const convertedMessages: Message[] = [] + historyMessages.forEach((msg) => { + // 添加用户消息 + convertedMessages.push({ + id: `${msg.id}_query`, + type: 'user', + content: msg.query, + timestamp: new Date(msg.created_at * 1000), + }) + + // 添加助手回复 + convertedMessages.push({ + id: `${msg.id}_answer`, + type: 'assistant', + content: msg.answer, + timestamp: new Date(msg.created_at * 1000), + }) + }) + + messages.value = convertedMessages + await scrollToBottom() + } + } catch (error) { + console.error('获取对话历史失败:', error) + } finally { + isLoadingHistory.value = false + } +} + +// 处理对话选择变化 +const selectConversationFromDropdown = async (conversationId: string) => { + selectedConversationId.value = conversationId + await fetchConversationMessages(conversationId) + closeConversationDropdown() +} + +// 选择新对话 +const selectNewConversation = () => { + selectedConversationId.value = '' + messages.value = [] + closeConversationDropdown() +} + +// 关闭下拉会话列表 +const closeConversationDropdown = () => { + isDropdownOpen.value = false +} + +// 获取当前对话名称 +const getCurrentConversationName = () => { + if (!selectedConversationId.value) { + return '新对话' + } + const conversation = conversations.value.find((c) => c.id === selectedConversationId.value) + return conversation ? conversation.name : '未命名对话' +} + // 滚动到底部 const scrollToBottom = async () => { await nextTick() @@ -82,6 +204,7 @@ const sendMessage = async () => { try { const response = await http.post(`${apiBaseUrl}/analysis/chat-messages`, { message: message, + ...(selectedConversationId.value && { conversation_id: selectedConversationId.value }), }) console.log('响应状态:', response.status, response.ok) // 调试日志 @@ -260,11 +383,222 @@ const sendExampleMessage = async (message: string) => { messageInput.value = message await sendMessage() } + +// 切换下拉会话列表 +const toggleConversationDropdown = () => { + isDropdownOpen.value = !isDropdownOpen.value +} + +// 重命名会话 +const startRenaming = (conversation: Conversation) => { + conversationToRename.value = conversation + newConversationName.value = conversation.name + showRenameDialog.value = true + // 下一个tick后聚焦输入框 + nextTick(() => { + renameInputRef.value?.focus() + renameInputRef.value?.select() + }) +} + +// 取消重命名 +const cancelRename = () => { + showRenameDialog.value = false + conversationToRename.value = null + newConversationName.value = '' +} + +// 确认重命名 +const confirmRename = () => { + if (!conversationToRename.value || !newConversationName.value.trim()) return + + const trimmedName = newConversationName.value.trim() + if (trimmedName === conversationToRename.value.name) { + cancelRename() + return + } + + renameConversation(conversationToRename.value.id, trimmedName) + cancelRename() +} + +// 重命名会话API调用 +const renameConversation = async (conversationId: string, newName: string) => { + try { + const response = await http.post( + `${apiBaseUrl}/analysis/conversations/${conversationId}/name`, + { + name: newName, + }, + ) + if (response.ok) { + // 更新本地会话列表 + const conversationIndex = conversations.value.findIndex((c) => c.id === conversationId) + if (conversationIndex !== -1) { + conversations.value[conversationIndex].name = newName + } + } else { + console.error('重命名会话失败') + alert('重命名失败,请稍后重试') + } + } catch (error) { + console.error('重命名会话失败:', error) + alert('重命名失败,请稍后重试') + } +} + +// 删除会话 +const deleteConversation = async (conversationId: string) => { + const conversation = conversations.value.find((c) => c.id === conversationId) + if (!conversation) return + + conversationToDelete.value = conversation + showDeleteDialog.value = true +} + +// 取消删除 +const cancelDelete = () => { + showDeleteDialog.value = false + conversationToDelete.value = null +} + +// 确认删除 +const confirmDelete = async () => { + if (!conversationToDelete.value) return + + const conversationId = conversationToDelete.value.id + + try { + // 使用createAuthenticatedFetch发送DELETE请求 + const { createAuthenticatedFetch } = await import('../services/api') + const authenticatedFetch = createAuthenticatedFetch() + const response = await authenticatedFetch( + `${apiBaseUrl}/analysis/conversations/${conversationId}`, + { + method: 'DELETE', + }, + ) + + if (response.ok) { + // 从本地会话列表中移除 + conversations.value = conversations.value.filter((c) => c.id !== conversationId) + + // 如果删除的是当前选中的会话,切换到新对话或第一个会话 + if (selectedConversationId.value === conversationId) { + if (conversations.value.length > 0) { + selectedConversationId.value = conversations.value[0].id + await fetchConversationMessages(conversations.value[0].id) + } else { + selectedConversationId.value = '' + messages.value = [] + } + } + } else { + console.error('删除会话失败') + alert('删除失败,请稍后重试') + } + } catch (error) { + console.error('删除会话失败:', error) + alert('删除失败,请稍后重试') + } + + cancelDelete() +} @@ -440,6 +837,186 @@ const sendExampleMessage = async (message: string) => { position: relative; } +.conversation-header { + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + height: 5.2rem; + border-bottom: 1px solid var(--color-border); + background-color: var(--color-bg-primary); + position: relative; +} + +.conversation-title-container { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; +} + +.conversation-title { + margin: 0; + font-size: 1.1rem; + font-weight: 600; + color: var(--color-text-primary); +} + +.dropdown-icon { + width: 18px; + height: 18px; + color: var(--color-text-secondary); + transition: transform 0.3s ease; +} + +.dropdown-icon.active { + transform: rotate(180deg); +} + +.conversation-dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + max-width: 500px; + margin: 0 auto; + background-color: var(--color-bg-primary); + border: 1px solid var(--color-border); + border-top: none; + border-radius: 0 0 var(--border-radius) var(--border-radius); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + z-index: 120; +} + +.dropdown-content { + padding: 1rem; +} + +.dropdown-item { + width: 100%; + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem; + margin-bottom: 0.75rem; + border: 1px solid transparent; + border-radius: var(--border-radius); + background-color: transparent; + color: var(--color-text-secondary); + cursor: pointer; + transition: all 0.2s ease; +} + +.dropdown-item:hover:not(:disabled) { + border-color: var(--color-accent); + background-color: rgba(51, 85, 255, 0.05); + color: var(--color-accent); +} + +.dropdown-item:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.dropdown-item svg { + width: 16px; + height: 16px; +} + +.conversation-info { + flex: 1; + cursor: pointer; +} + +.conversation-name { + font-size: 0.9rem; + font-weight: 500; + color: var(--color-text-primary); + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dropdown-item.active .conversation-name { + color: var(--color-accent); +} + +.conversation-actions { + display: flex; + gap: 0.25rem; + opacity: 0; + transition: opacity 0.2s ease; +} + +.dropdown-item:hover .conversation-actions { + opacity: 1; +} + +.conversation-actions .action-btn { + padding: 0.25rem; + border: none; + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; + border-radius: 4px; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; +} + +.conversation-actions .action-btn:hover { + background-color: rgba(0, 0, 0, 0.1); + color: var(--color-text-primary); +} + +.conversation-actions .action-btn svg { + width: 14px; + height: 14px; +} + +.conversation-actions .delete-btn:hover { + background-color: rgba(239, 68, 68, 0.1); + color: #ef4444; +} + +.dropdown-loading { + text-align: center; + padding: 1rem; + color: var(--color-text-secondary); + font-size: 0.85rem; +} + +/* 下拉框动画 */ +.dropdown-enter-active { + transition: all 0.3s ease-out; +} + +.dropdown-leave-active { + transition: all 0.2s ease-in; +} + +.dropdown-enter-from { + opacity: 0; + transform: translateY(-10px); +} + +.dropdown-enter-to { + opacity: 1; + transform: translateY(0); +} + +.dropdown-leave-from { + opacity: 1; + transform: translateY(0); +} + +.dropdown-leave-to { + opacity: 0; + transform: translateY(-10px); +} + .chat-messages { flex: 1; overflow-y: auto; @@ -459,7 +1036,7 @@ const sendExampleMessage = async (message: string) => { .welcome-content { max-width: 500px; - padding: 0 0.75rem; + padding: 0 1rem; } .welcome-icon { @@ -859,6 +1436,70 @@ const sendExampleMessage = async (message: string) => { padding-bottom: 80px; /* 为固定输入框留出空间 */ } + .conversation-header { + padding: 0.75rem 1rem; + } + + .conversation-title-container { + gap: 0.25rem; + } + + .conversation-title { + font-size: 1rem; + } + + .dropdown-icon { + width: 16px; + height: 16px; + } + + /* 移动端让会话操作按钮始终可见 */ + .conversation-actions { + opacity: 1; + } + + .conversation-actions .action-btn { + padding: 0.5rem; + } + + .conversation-actions .action-btn svg { + width: 16px; + height: 16px; + } + + .welcome-content { + padding: 0 1rem; + } +} + +@media (max-width: 480px) { + .chat-agent-view { + height: 100vh; + height: 100dvh; + min-height: -webkit-fill-available; + } + + .chat-messages { + padding: 1.5rem 0.75rem 80px 0.75rem; /* 为固定输入框留出空间 */ + } + + .conversation-header { + padding: 0.6rem 0.75rem; + } + + .conversation-title-container { + gap: 0.25rem; + } + + .conversation-title { + font-size: 1rem; + } + + .dropdown-icon { + width: 16px; + height: 16px; + } + .welcome-content { padding: 0 1rem; } @@ -972,102 +1613,171 @@ const sendExampleMessage = async (message: string) => { } } -@media (max-width: 480px) { - .chat-agent-view { - height: 100vh; - height: 100dvh; - min-height: -webkit-fill-available; - } +.sidebar { + position: fixed; + top: 0; + right: -350px; + width: 350px; + height: 100vh; + background-color: var(--color-bg-secondary); + border-left: 1px solid var(--color-border); + z-index: 120; + transition: right 0.3s ease; + display: flex; + flex-direction: column; +} - .chat-messages { - padding: 4.5rem 0.75rem 80px 0.75rem; /* 为固定输入框留出空间 */ - } +.sidebar.open { + right: 0; +} - .welcome-content { - padding: 0 0.75rem; - } +.sidebar-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--color-border); + background-color: var(--color-bg-primary); +} - .example-grid { - grid-template-columns: 1fr; - max-width: 350px; - gap: 0.6rem; - padding: 0 0.5rem; - } +.sidebar-header h3 { + margin: 0; + font-size: 1.1rem; + font-weight: 600; + color: var(--color-text-primary); +} - .example-question { - padding: 0.7rem 1rem; - font-size: 0.8rem; - min-height: 45px; - } +.close-sidebar-btn { + width: 32px; + height: 32px; + border: none; + border-radius: 6px; + background-color: transparent; + color: var(--color-text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; +} - .example-icon { - font-size: 0.9rem; - } +.close-sidebar-btn:hover { + background-color: var(--color-bg-secondary); + color: var(--color-text-primary); +} - .chat-input { - padding: 0.5rem 0.75rem; - background-color: var(--color-bg-primary); - position: fixed; - bottom: 0; - left: 0; - right: 0; - z-index: 100; - border-top: 1px solid var(--color-border); - } +.close-sidebar-btn svg { + width: 16px; + height: 16px; +} - .input-container { - gap: 0.5rem; - padding: 0.25rem; - border-radius: 20px; - align-items: center; - display: flex; - flex-direction: row; - margin: 0 auto; - max-width: 1000px; - } +.sidebar-content { + flex: 1; + padding: 1rem; + overflow-y: auto; +} - .message-input { - height: 34px !important; - padding: 0 0.6rem; - font-size: 0.9rem; - border-radius: 16px; - flex: 1; - min-width: 0; - box-sizing: border-box; - resize: none; - overflow: hidden; - line-height: 34px; - } +.new-conversation-item { + width: 100%; + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem; + margin-bottom: 1rem; + border: 1px dashed var(--color-border); + border-radius: var(--border-radius); + background-color: transparent; + color: var(--color-text-secondary); + cursor: pointer; + transition: all 0.2s ease; + font-size: 0.9rem; +} - .message-body { - padding: 0.75rem; - } +.new-conversation-item:hover:not(:disabled) { + border-color: var(--color-accent); + background-color: rgba(51, 85, 255, 0.05); + color: var(--color-accent); +} - .send-button { - width: 34px; - height: 34px; - border-radius: 17px; - flex-shrink: 0; - } +.new-conversation-item:disabled { + opacity: 0.6; + cursor: not-allowed; +} - .send-button svg { - width: 15px; - height: 15px; - } +.new-conversation-item svg { + width: 16px; + height: 16px; +} - .message-avatar { - width: 38px; - height: 38px; - font-size: 1rem; - } +.conversation-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} - .user-avatar { - font-size: 0.9rem; - } +.conversation-item { + width: 100%; + display: flex; + align-items: center; + padding: 0.75rem 1rem; + border: 1px solid transparent; + border-radius: var(--border-radius); + background-color: var(--color-bg-primary); + cursor: pointer; + transition: all 0.2s ease; + text-align: left; +} - .assistant-avatar { - font-size: 0.9rem; - } +.conversation-item:hover:not(:disabled) { + border-color: var(--color-border); + background-color: var(--color-bg-secondary); +} + +.conversation-item.active { + border-color: var(--color-accent); + background-color: rgba(51, 85, 255, 0.1); +} + +.conversation-item:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.conversation-info { + flex: 1; +} + +.conversation-name { + font-size: 0.9rem; + font-weight: 500; + color: var(--color-text-primary); + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.conversation-item.active .conversation-name { + color: var(--color-accent); +} + +.loading-conversations, +.loading-history { + text-align: center; + padding: 1rem; + color: var(--color-text-secondary); + font-size: 0.85rem; +} + +.sidebar-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.3); + z-index: 110; + backdrop-filter: blur(2px); } @@ -1245,4 +1955,189 @@ const sendExampleMessage = async (message: string) => { border-left-color: rgba(255, 255, 255, 0.3); color: rgba(255, 255, 255, 0.9); } + +/* 自定义弹框样式 */ +.dialog-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + backdrop-filter: blur(4px); +} + +.dialog-content { + background-color: var(--color-bg-primary); + border-radius: 12px; + min-width: 400px; + max-width: 500px; + width: 90%; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2); + border: 1px solid var(--color-border); + overflow: hidden; +} + +.dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.5rem; + border-bottom: 1px solid var(--color-border); + background-color: var(--color-bg-secondary); +} + +.dialog-header h3 { + margin: 0; + font-size: 1.1rem; + font-weight: 600; + color: var(--color-text-primary); +} + +.dialog-close { + width: 32px; + height: 32px; + border: none; + border-radius: 6px; + background-color: transparent; + color: var(--color-text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; +} + +.dialog-close:hover { + background-color: rgba(0, 0, 0, 0.1); + color: var(--color-text-primary); +} + +.dialog-close svg { + width: 16px; + height: 16px; +} + +.dialog-body { + padding: 1.5rem; +} + +.dialog-body p { + margin: 0 0 0.5rem 0; + color: var(--color-text-primary); + line-height: 1.5; +} + +.warning-text { + color: var(--color-text-secondary); + font-size: 0.9rem; +} + +.input-label { + display: block; + margin-bottom: 0.5rem; + font-size: 0.9rem; + font-weight: 500; + color: var(--color-text-primary); +} + +.rename-input { + width: 100%; + padding: 0.75rem 1rem; + border: 1px solid var(--color-border); + border-radius: var(--border-radius); + background-color: var(--color-bg-secondary); + color: var(--color-text-primary); + font-size: 0.95rem; + transition: all 0.2s ease; + box-sizing: border-box; +} + +.rename-input:focus { + outline: none; + border-color: var(--color-accent); + background-color: var(--color-bg-primary); +} + +.dialog-footer { + display: flex; + gap: 0.75rem; + padding: 1.5rem; + border-top: 1px solid var(--color-border); + background-color: var(--color-bg-secondary); + justify-content: flex-end; +} + +.btn { + padding: 0.75rem 1.5rem; + border: none; + border-radius: var(--border-radius); + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + min-width: 80px; +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn-primary { + background-color: var(--color-accent); + color: white; +} + +.btn-primary:hover:not(:disabled) { + background-color: var(--color-accent-hover); +} + +.btn-secondary { + background-color: transparent; + color: var(--color-text-secondary); + border: 1px solid var(--color-border); +} + +.btn-secondary:hover:not(:disabled) { + background-color: var(--color-bg-primary); + color: var(--color-text-primary); + border-color: var(--color-text-secondary); +} + +.btn-danger { + background-color: #ef4444; + color: white; +} + +.btn-danger:hover:not(:disabled) { + background-color: #dc2626; +} + +/* 移动端弹框适配 */ +@media (max-width: 480px) { + .dialog-content { + min-width: auto; + width: 95%; + margin: 1rem; + } + + .dialog-header, + .dialog-body, + .dialog-footer { + padding: 1rem; + } + + .dialog-footer { + flex-direction: column-reverse; + } + + .btn { + width: 100%; + } +}