diff --git a/frontend/src/api/conversation.ts b/frontend/src/api/conversation.ts index e31cf77..6815826 100644 --- a/frontend/src/api/conversation.ts +++ b/frontend/src/api/conversation.ts @@ -1,5 +1,12 @@ import api from './index' +export interface MessageAttachment { + id: string + name: string + type: string + size: number +} + export interface Message { id: string role: 'user' | 'assistant' @@ -7,6 +14,7 @@ export interface Message { model?: string tokens_used?: number created_at: string + attachments?: MessageAttachment[] } export interface Conversation { diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 0518102..1dbe877 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -1,27 +1,81 @@ import axios from 'axios' const api = axios.create({ - baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000', + baseURL: import.meta.env.VITE_API_URL || 'http://localhost:9527', timeout: 30000, }) +function createRequestId() { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { + return crypto.randomUUID() + } + return `req-${Date.now()}-${Math.random().toString(16).slice(2)}` +} + +function isDev() { + return Boolean(import.meta.env.DEV) +} + +function debugLog(stage: string, payload: Record) { + if (!isDev()) return + console.debug(`[api:${stage}]`, payload) +} + // 请求拦截器:添加 Token api.interceptors.request.use((config) => { const token = localStorage.getItem('access_token') + const requestId = createRequestId() + config.headers = config.headers || {} + config.headers['X-Request-ID'] = requestId if (token) { config.headers.Authorization = `Bearer ${token}` } + ;(config as typeof config & { metadata?: { startedAt: number; requestId: string } }).metadata = { + startedAt: Date.now(), + requestId, + } + debugLog('request', { + requestId, + method: config.method, + url: config.url, + params: config.params, + data: config.data, + }) return config }) // 响应拦截器:处理错误 api.interceptors.response.use( - (response) => response, + (response) => { + const metadata = (response.config as typeof response.config & { metadata?: { startedAt: number; requestId: string } }).metadata + debugLog('response', { + requestId: response.headers['x-request-id'] || metadata?.requestId, + method: response.config.method, + url: response.config.url, + status: response.status, + durationMs: metadata ? Date.now() - metadata.startedAt : undefined, + data: response.data, + }) + return response + }, async (error) => { + const metadata = (error.config as typeof error.config & { metadata?: { startedAt: number; requestId: string } })?.metadata + const requestId = error.response?.headers?.['x-request-id'] || metadata?.requestId if (error.response?.status === 401) { localStorage.removeItem('access_token') window.location.href = '/login' } + debugLog('error', { + requestId, + method: error.config?.method, + url: error.config?.url, + status: error.response?.status, + durationMs: metadata ? Date.now() - metadata.startedAt : undefined, + detail: error.response?.data, + }) + if (requestId) { + error.requestId = requestId + } return Promise.reject(error) } ) diff --git a/frontend/src/app/navigation/nav.ts b/frontend/src/app/navigation/nav.ts new file mode 100644 index 0000000..fe08c01 --- /dev/null +++ b/frontend/src/app/navigation/nav.ts @@ -0,0 +1,34 @@ +import { + Activity, + BookOpen, + Bot, + CheckSquare, + LayoutGrid, + MessageCircle, + MessageSquare, + Network, + Settings, + Star, + Terminal, + type LucideIcon, +} from 'lucide-vue-next' + +export interface NavItem { + name: string + path: string + icon: LucideIcon +} + +export const navItems: NavItem[] = [ + { name: '沟通系统', path: '/chat', icon: MessageCircle }, + { name: '智能链路', path: '/agents', icon: Bot }, + { name: '技能中心', path: '/skills', icon: Star }, + { name: '资料中枢', path: '/knowledge', icon: BookOpen }, + { name: '知识大脑', path: '/graph', icon: Network }, + { name: '任务矩阵', path: '/kanban', icon: LayoutGrid }, + { name: '任务调度', path: '/todo', icon: CheckSquare }, + { name: '信息交易所', path: '/forum', icon: MessageSquare }, + { name: '运行状态', path: '/stats', icon: Activity }, + { name: '运行日志', path: '/logs', icon: Terminal }, + { name: '系统设置', path: '/settings', icon: Settings }, +] diff --git a/frontend/src/app/router/index.ts b/frontend/src/app/router/index.ts new file mode 100644 index 0000000..c996369 --- /dev/null +++ b/frontend/src/app/router/index.ts @@ -0,0 +1,21 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '@/stores/auth' +import { routes } from './routes' + +const router = createRouter({ + history: createWebHistory(), + routes, +}) + +router.beforeEach((to, _from, next) => { + const auth = useAuthStore() + if (to.meta.requiresAuth && !auth.isAuthenticated) { + next('/login') + } else if (to.meta.guest && auth.isAuthenticated) { + next('/chat') + } else { + next() + } +}) + +export default router diff --git a/frontend/src/app/router/routes.ts b/frontend/src/app/router/routes.ts new file mode 100644 index 0000000..8fb4bbd --- /dev/null +++ b/frontend/src/app/router/routes.ts @@ -0,0 +1,80 @@ +import type { RouteRecordRaw } from 'vue-router' + +const appChildren: RouteRecordRaw[] = [ + { + path: '', + redirect: '/chat', + }, + { + path: 'chat', + name: 'chat', + component: () => import('@/pages/chat/index.vue'), + }, + { + path: 'knowledge', + name: 'knowledge', + component: () => import('@/pages/knowledge/index.vue'), + }, + { + path: 'graph', + name: 'graph', + component: () => import('@/pages/graph/index.vue'), + }, + { + path: 'kanban', + name: 'kanban', + component: () => import('@/pages/kanban/index.vue'), + }, + { + path: 'forum', + name: 'forum', + component: () => import('@/pages/forum/index.vue'), + }, + { + path: 'agents', + name: 'agents', + component: () => import('@/pages/agents/index.vue'), + }, + { + path: 'stats', + name: 'stats', + component: () => import('@/pages/stats/index.vue'), + }, + { + path: 'skills', + name: 'skills', + component: () => import('@/pages/skills/index.vue'), + }, + { + path: 'todo', + name: 'todo', + component: () => import('@/pages/todo/index.vue'), + }, + { + path: 'settings', + name: 'settings', + component: () => import('@/pages/settings/index.vue'), + }, + { + path: 'logs', + name: 'logs', + component: () => import('@/pages/logs/index.vue'), + }, +] + +export const routes: RouteRecordRaw[] = [ + { + path: '/login', + name: 'login', + component: () => import('@/pages/login/index.vue'), + meta: { guest: true }, + }, + { + path: '/', + component: () => import('@/pages/app/layout.vue'), + meta: { requiresAuth: true }, + children: appChildren, + }, +] + +export { appChildren } diff --git a/frontend/src/components/SidebarNav.vue b/frontend/src/components/SidebarNav.vue index 036f3dc..ea1854a 100644 --- a/frontend/src/components/SidebarNav.vue +++ b/frontend/src/components/SidebarNav.vue @@ -1,26 +1,13 @@ diff --git a/frontend/src/views/LayoutView.vue b/frontend/src/pages/app/layout.vue similarity index 100% rename from frontend/src/views/LayoutView.vue rename to frontend/src/pages/app/layout.vue diff --git a/frontend/src/pages/chat/composables/useChatView.ts b/frontend/src/pages/chat/composables/useChatView.ts new file mode 100644 index 0000000..e4a12e9 --- /dev/null +++ b/frontend/src/pages/chat/composables/useChatView.ts @@ -0,0 +1,210 @@ +import { nextTick, onMounted, ref } from 'vue' +import { useConversationStore } from '@/stores/conversation' +import { conversationApi, type Message } from '@/api/conversation' +import { documentApi } from '@/api/document' + +export interface SelectedFile { + id: string + name: string + type: string + size: number +} + +interface MessageWithAttachments extends Message { + attachments?: SelectedFile[] +} + +export function useChatView() { + const store = useConversationStore() + const inputMessage = ref('') + const isSending = ref(false) + const chatContainer = ref() + const inputRef = ref() + const isTyping = ref(false) + const fileInputRef = ref() + const showEmojiPicker = ref(false) + const selectedFiles = ref([]) + + async function sendMessage() { + if (!inputMessage.value.trim() || isSending.value) return + + isSending.value = true + isTyping.value = true + const text = inputMessage.value.trim() + const attachments = [...selectedFiles.value] + inputMessage.value = '' + + store.addMessage({ + id: `temp-${Date.now()}`, + role: 'user', + content: text, + created_at: new Date().toISOString(), + attachments, + } as MessageWithAttachments) + + await nextTick() + scrollToBottom() + + try { + const response = await conversationApi.chat(text, store.currentConversationId || undefined, attachments.map((file) => file.id)) + selectedFiles.value = [] + isTyping.value = false + store.addMessage({ + id: response.data.message_id, + role: 'assistant', + content: response.data.content, + model: response.data.agent_name, + created_at: new Date().toISOString(), + }) + if (!store.currentConversationId) { + store.setCurrentConversation(response.data.conversation_id) + await loadConversations() + } + } catch (error) { + isTyping.value = false + console.error('发送失败:', error) + store.addMessage({ + id: `err-${Date.now()}`, + role: 'assistant', + content: '抱歉,连接失败。请检查服务状态。', + created_at: new Date().toISOString(), + }) + } + + isSending.value = false + await nextTick() + scrollToBottom() + } + + async function loadConversations() { + try { + const response = await conversationApi.list() + store.setConversations(response.data) + } catch (error) { + console.error('加载对话列表失败:', error) + } + } + + async function selectConversation(id: string) { + store.setCurrentConversation(id) + store.setMessages([]) + try { + const response = await conversationApi.getMessages(id) + store.setMessages(response.data) + await nextTick() + scrollToBottom() + } catch (error) { + console.error('加载消息失败:', error) + } + } + + function newConversation() { + store.setCurrentConversation('') + store.setMessages([]) + inputRef.value?.focus() + } + + async function deleteConversation(id: string, event: Event) { + event.stopPropagation() + try { + await conversationApi.delete(id) + store.removeConversation(id) + if (store.currentConversationId === id) { + store.setCurrentConversation('') + store.setMessages([]) + } + } catch (error) { + console.error('删除失败:', error) + } + } + + function scrollToBottom() { + if (chatContainer.value) { + chatContainer.value.scrollTop = chatContainer.value.scrollHeight + } + } + + function formatTime(dateStr: string) { + const date = new Date(dateStr) + return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) + } + + function formatConvDate(dateStr: string) { + const date = new Date(dateStr) + const now = new Date() + const diff = now.getTime() - date.getTime() + if (diff < 60000) return '刚刚' + if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前` + if (diff < 86400000) return '今天' + return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }) + } + + function autoResize(event: Event) { + const element = event.target as HTMLTextAreaElement + element.style.height = 'auto' + element.style.height = `${Math.min(element.scrollHeight, 120)}px` + } + + async function handleFileSelect(event: Event) { + const input = event.target as HTMLInputElement + if (!input.files?.length) return + + for (const file of input.files) { + if (file.size > 10 * 1024 * 1024) { + alert(`文件 ${file.name} 超过10MB限制`) + continue + } + try { + const response = await documentApi.upload(file) + selectedFiles.value.push({ + id: response.data.id, + name: file.name, + type: file.type, + size: file.size, + }) + } catch (error) { + console.error('上传失败:', error) + alert(`文件 ${file.name} 上传失败`) + } + } + if (fileInputRef.value) { + fileInputRef.value.value = '' + } + } + + function insertEmoji(emoji: string) { + inputMessage.value += emoji + showEmojiPicker.value = false + } + + function openFilePicker() { + fileInputRef.value?.click() + } + + onMounted(() => { + loadConversations() + inputRef.value?.focus() + }) + + return { + store, + inputMessage, + isSending, + chatContainer, + inputRef, + isTyping, + fileInputRef, + showEmojiPicker, + selectedFiles, + sendMessage, + selectConversation, + newConversation, + deleteConversation, + formatTime, + formatConvDate, + autoResize, + handleFileSelect, + insertEmoji, + openFilePicker, + } +} diff --git a/frontend/src/views/ChatView.vue b/frontend/src/pages/chat/index.vue similarity index 79% rename from frontend/src/views/ChatView.vue rename to frontend/src/pages/chat/index.vue index 67e18a3..dc8d002 100644 --- a/frontend/src/views/ChatView.vue +++ b/frontend/src/pages/chat/index.vue @@ -1,180 +1,29 @@