diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..da26ded --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + 智队中枢 - Chat + + + + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..50580d3 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,29 @@ +{ + "name": "pit-chat-ui", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "typecheck": "vue-tsc --noEmit" + }, + "dependencies": { + "vue": "^3.4.0", + "vue-router": "^4.2.0", + "pinia": "^2.1.0", + "socket.io-client": "^4.7.0", + "axios": "^1.6.0", + "markdown-it": "^14.0.0", + "highlight.js": "^11.9.0", + "@highlightjs/vue-plugin": "^2.1.0", + "date-fns": "^3.0.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "@vue/tsconfig": "^0.5.0", + "typescript": "^5.3.0", + "vite": "^5.0.0", + "vue-tsc": "^1.8.0" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..c4a4a24 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,17 @@ + + + + + diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts new file mode 100644 index 0000000..b6dbfa2 --- /dev/null +++ b/frontend/src/api/index.ts @@ -0,0 +1,79 @@ +import axios from 'axios' + +const api = axios.create({ + baseURL: '/api', + timeout: 30000, + headers: { + 'Content-Type': 'application/json' + } +}) + +// Request interceptor - add auth token +api.interceptors.request.use( + (config) => { + const token = localStorage.getItem('access_token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config + }, + (error) => Promise.reject(error) +) + +// Response interceptor - handle errors +api.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + localStorage.removeItem('access_token') + window.location.href = '/login' + } + return Promise.reject(error) + } +) + +// Auth API +export const authApi = { + login: (username: string, password: string) => + api.post('/auth/login', { username, password }), + register: (username: string, email: string, password: string) => + api.post('/auth/register', { username, email, password }), + refresh: () => api.post('/auth/refresh'), + me: () => api.get('/auth/me') +} + +// Bot API +export const botApi = { + list: (params?: { owner_only?: boolean }) => + api.get('/bots', { params }), + get: (id: string) => api.get(`/bots/${id}`), + create: (data: any) => api.post('/bots', data), + update: (id: string, data: any) => api.put(`/bots/${id}`, data), + delete: (id: string) => api.delete(`/bots/${id}`), + bind: (id: string, agentId: string) => + api.post(`/bots/${id}/bind`, { agent_id: agentId }), + unbind: (id: string) => api.post(`/bots/${id}/unbind`), + status: (id: string) => api.get(`/bots/${id}/status`), + stats: (id: string) => api.get(`/bots/${id}/stats`) +} + +// Chat API +export const chatApi = { + sessions: { + list: (params?: { status?: string; bot_id?: string; limit?: number; offset?: number }) => + api.get('/chat/sessions', { params }), + get: (id: string) => api.get(`/chat/sessions/${id}`), + create: (data: { bot_id: string; title?: string }) => + api.post('/chat/sessions', data), + close: (id: string) => api.delete(`/chat/sessions/${id}`), + messages: { + list: (sessionId: string, params?: { limit?: number; before?: string }) => + api.get(`/chat/sessions/${sessionId}/messages`, { params }), + send: (sessionId: string, data: { content: string; reply_to?: string }) => + api.post(`/chat/sessions/${sessionId}/messages`, data) + }, + markRead: (id: string) => api.put(`/chat/sessions/${id}/read`) + } +} + +export default api diff --git a/frontend/src/components/chat/BotSelector.vue b/frontend/src/components/chat/BotSelector.vue new file mode 100644 index 0000000..2d6a834 --- /dev/null +++ b/frontend/src/components/chat/BotSelector.vue @@ -0,0 +1,167 @@ + + + + + diff --git a/frontend/src/components/chat/ChatSidebar.vue b/frontend/src/components/chat/ChatSidebar.vue new file mode 100644 index 0000000..657c9be --- /dev/null +++ b/frontend/src/components/chat/ChatSidebar.vue @@ -0,0 +1,174 @@ + + + + + diff --git a/frontend/src/components/chat/ChatWindow.vue b/frontend/src/components/chat/ChatWindow.vue new file mode 100644 index 0000000..9e58e2a --- /dev/null +++ b/frontend/src/components/chat/ChatWindow.vue @@ -0,0 +1,209 @@ + + + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..e33b0cf --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,12 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import App from './App.vue' +import router from './router' +import './style.css' + +const app = createApp(App) + +app.use(createPinia()) +app.use(router) + +app.mount('#app') diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..5a67bf6 --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1,40 @@ +import { createRouter, createWebHistory } from 'vue-router' +import type { RouteRecordRaw } from 'vue-router' + +const routes: RouteRecordRaw[] = [ + { + path: '/', + name: 'home', + component: () => import('@/views/HomeView.vue') + }, + { + path: '/chat/:sessionId?', + name: 'chat', + component: () => import('@/views/ChatView.vue') + }, + { + path: '/login', + name: 'login', + component: () => import('@/views/LoginView.vue') + } +] + +const router = createRouter({ + history: createWebHistory(), + routes +}) + +// Navigation guard +router.beforeEach((to, from, next) => { + const token = localStorage.getItem('access_token') + + if (to.name !== 'login' && !token) { + next({ name: 'login' }) + } else if (to.name === 'login' && token) { + next({ name: 'home' }) + } else { + next() + } +}) + +export default router diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts new file mode 100644 index 0000000..a42f4fd --- /dev/null +++ b/frontend/src/stores/auth.ts @@ -0,0 +1,70 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { authApi } from '@/api' + +interface User { + id: string + username: string + email: string + nickname?: string + role: string +} + +export const useAuthStore = defineStore('auth', () => { + const user = ref(null) + const token = ref(localStorage.getItem('access_token')) + const loading = ref(false) + + const isLoggedIn = computed(() => !!token.value) + const isAdmin = computed(() => user.value?.role === 'admin') + + async function login(username: string, password: string) { + loading.value = true + try { + const response = await authApi.login(username, password) + const { access_token } = response.data + token.value = access_token + localStorage.setItem('access_token', access_token) + await fetchUser() + return true + } catch (error) { + console.error('Login failed:', error) + return false + } finally { + loading.value = false + } + } + + async function fetchUser() { + if (!token.value) return + try { + const response = await authApi.me() + user.value = response.data.user + } catch (error) { + console.error('Fetch user failed:', error) + logout() + } + } + + function logout() { + token.value = null + user.value = null + localStorage.removeItem('access_token') + } + + // Initialize + if (token.value) { + fetchUser() + } + + return { + user, + token, + loading, + isLoggedIn, + isAdmin, + login, + logout, + fetchUser + } +}) diff --git a/frontend/src/stores/chat.ts b/frontend/src/stores/chat.ts new file mode 100644 index 0000000..312ef6e --- /dev/null +++ b/frontend/src/stores/chat.ts @@ -0,0 +1,212 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { io, Socket } from 'socket.io-client' +import { chatApi, botApi } from '@/api' + +export interface Bot { + id: string + name: string + display_name?: string + avatar?: string + description?: string + status: string +} + +export interface Message { + id: string + session_id: string + sender_type: string + sender_id: string + sender_name?: string + bot_id?: string + content: string + content_type: string + reply_to?: string + status: string + created_at: string +} + +export interface ChatSession { + id: string + user_id: string + bot_id?: string + title: string + status: string + message_count: number + unread_count: number + created_at: string + last_active_at?: string + bot?: Bot + last_message?: { + id: string + content: string + sender_type: string + created_at: string + } +} + +export const useChatStore = defineStore('chat', () => { + const socket = ref(null) + const connected = ref(false) + + const bots = ref([]) + const sessions = ref([]) + const currentSession = ref(null) + const messages = ref([]) + const loading = ref(false) + + const hasActiveSession = computed(() => !!currentSession.value) + + // Socket event handlers + function setupSocket() { + if (socket.value) return + + const token = localStorage.getItem('access_token') + socket.value = io({ + auth: { token }, + transports: ['websocket'] + }) + + socket.value.on('connect', () => { + connected.value = true + console.log('Socket connected') + }) + + socket.value.on('disconnect', () => { + connected.value = false + console.log('Socket disconnected') + }) + + socket.value.on('chat.message', (data: Message) => { + if (currentSession.value?.id === data.session_id) { + messages.value.push(data) + } + }) + + socket.value.on('chat.typing', (data: any) => { + // Handle typing indicator + }) + + socket.value.on('chat_error', (data: any) => { + console.error('Chat error:', data) + }) + } + + function disconnectSocket() { + if (socket.value) { + socket.value.disconnect() + socket.value = null + connected.value = false + } + } + + // Bot actions + async function fetchBots() { + try { + const response = await botApi.list() + bots.value = response.data.bots + } catch (error) { + console.error('Fetch bots failed:', error) + } + } + + // Session actions + async function fetchSessions() { + loading.value = true + try { + const response = await chatApi.sessions.list({ limit: 50 }) + sessions.value = response.data.sessions + } catch (error) { + console.error('Fetch sessions failed:', error) + } finally { + loading.value = false + } + } + + async function createSession(botId: string, title?: string) { + try { + const response = await chatApi.sessions.create({ bot_id: botId, title }) + const session = response.data.session + sessions.value.unshift(session) + return session + } catch (error) { + console.error('Create session failed:', error) + throw error + } + } + + async function joinSession(sessionId: string) { + try { + // Fetch session details + const sessionResponse = await chatApi.sessions.get(sessionId) + currentSession.value = sessionResponse.data.session + + // Fetch messages + const messagesResponse = await chatApi.sessions.messages.list(sessionId) + messages.value = messagesResponse.data.messages + + // Join socket room + if (socket.value) { + socket.value.emit('chat.send.join', { session_id: sessionId }) + } + + return currentSession.value + } catch (error) { + console.error('Join session failed:', error) + throw error + } + } + + async function leaveSession() { + if (socket.value && currentSession.value) { + socket.value.emit('chat.send.leave', { session_id: currentSession.value.id }) + } + currentSession.value = null + messages.value = [] + } + + // Message actions + async function sendMessage(content: string, replyTo?: string) { + if (!currentSession.value) return + + try { + const response = await chatApi.sessions.messages.send(currentSession.value.id, { + content, + reply_to: replyTo + }) + return response.data.message + } catch (error) { + console.error('Send message failed:', error) + throw error + } + } + + function sendTyping(isTyping: boolean) { + if (socket.value && currentSession.value) { + socket.value.emit('chat.send.typing', { + session_id: currentSession.value.id, + is_typing: isTyping + }) + } + } + + return { + socket, + connected, + bots, + sessions, + currentSession, + messages, + loading, + hasActiveSession, + setupSocket, + disconnectSocket, + fetchBots, + fetchSessions, + createSession, + joinSession, + leaveSession, + sendMessage, + sendTyping + } +}) diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..2e80c48 --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,143 @@ +/* Base styles */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + /* Colors */ + --primary-color: #6366f1; + --primary-light: #818cf8; + --primary-dark: #4f46e5; + --secondary-color: #ec4899; + --success-color: #10b981; + --warning-color: #f59e0b; + --error-color: #ef4444; + + /* Background */ + --bg-primary: #ffffff; + --bg-secondary: #f3f4f6; + --bg-tertiary: #e5e7eb; + + /* Text */ + --text-primary: #111827; + --text-secondary: #6b7280; + --text-tertiary: #9ca3af; + + /* Border */ + --border-color: #e5e7eb; + --border-radius: 8px; + --border-radius-lg: 12px; + + /* Spacing */ + --spacing-xs: 4px; + --spacing-sm: 8px; + --spacing-md: 16px; + --spacing-lg: 24px; + --spacing-xl: 32px; + + /* Shadow */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + + /* Font */ + --font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --font-size-xs: 12px; + --font-size-sm: 14px; + --font-size-md: 16px; + --font-size-lg: 18px; + --font-size-xl: 20px; +} + +/* Dark mode */ +@media (prefers-color-scheme: dark) { + :root { + --bg-primary: #0f172a; + --bg-secondary: #1e293b; + --bg-tertiary: #334155; + + --text-primary: #f1f5f9; + --text-secondary: #94a3b8; + --text-tertiary: #64748b; + + --border-color: #334155; + } +} + +body { + font-family: var(--font-family); + font-size: var(--font-size-md); + color: var(--text-primary); + background-color: var(--bg-primary); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Scrollbar */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--text-tertiary); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-secondary); +} + +/* Utility classes */ +.flex { + display: flex; +} + +.flex-col { + display: flex; + flex-direction: column; +} + +.items-center { + align-items: center; +} + +.justify-center { + justify-content: center; +} + +.justify-between { + justify-content: space-between; +} + +.gap-sm { + gap: var(--spacing-sm); +} + +.gap-md { + gap: var(--spacing-md); +} + +.gap-lg { + gap: var(--spacing-lg); +} + +.text-secondary { + color: var(--text-secondary); +} + +.text-tertiary { + color: var(--text-tertiary); +} + +.truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/frontend/src/views/ChatView.vue b/frontend/src/views/ChatView.vue new file mode 100644 index 0000000..2b14658 --- /dev/null +++ b/frontend/src/views/ChatView.vue @@ -0,0 +1,222 @@ + + +