From a1deeecedeb71be8e063eaa6796a4df1b9f0bdc0 Mon Sep 17 00:00:00 2001 From: chiguyong Date: Wed, 10 Jun 2026 01:06:48 +0800 Subject: [PATCH] feat(phase5): implement Vue3 portal foundation with chat interface and routing (U13a) - Add Portal API routes: chat, stream, capabilities, conversations, WebSocket - Add ConversationStore for in-memory conversation management - Add CAPABILITY_CATEGORIES mapping for 8 capability types - Create Vue3 SPA with TypeScript, Pinia, Vue Router, Ant Design Vue - Implement ChatView with message bubbles, input, sidebar, WebSocket support - Add side navigation skeleton for all 8 capability sections - Add placeholder views for workflow, knowledge, skills, terminal, etc. - 31 backend tests passing --- src/agentkit/server/app.py | 3 +- src/agentkit/server/frontend/env.d.ts | 7 + src/agentkit/server/frontend/index.html | 13 + src/agentkit/server/frontend/package.json | 24 + src/agentkit/server/frontend/src/App.vue | 33 + .../server/frontend/src/api/client.ts | 83 +++ src/agentkit/server/frontend/src/api/types.ts | 76 +++ .../src/components/chat/ChatInput.vue | 83 +++ .../src/components/chat/ChatMessage.vue | 138 ++++ .../src/components/chat/ChatSidebar.vue | 137 ++++ .../src/components/layout/AppLayout.vue | 26 + .../src/components/layout/SideNav.vue | 136 ++++ src/agentkit/server/frontend/src/main.ts | 14 + .../server/frontend/src/router/index.ts | 68 ++ .../frontend/src/stores/capabilities.ts | 53 ++ .../server/frontend/src/stores/chat.ts | 315 ++++++++++ .../server/frontend/src/views/ChatView.vue | 185 ++++++ .../frontend/src/views/ComputerUseView.vue | 28 + .../frontend/src/views/EvolutionView.vue | 28 + .../frontend/src/views/KnowledgeBaseView.vue | 28 + .../frontend/src/views/SettingsView.vue | 28 + .../server/frontend/src/views/SkillsView.vue | 28 + .../frontend/src/views/TerminalView.vue | 28 + .../frontend/src/views/WorkflowView.vue | 28 + src/agentkit/server/frontend/tsconfig.json | 28 + .../server/frontend/tsconfig.node.json | 20 + src/agentkit/server/frontend/vite.config.ts | 24 + src/agentkit/server/routes/__init__.py | 4 +- src/agentkit/server/routes/portal.py | 590 ++++++++++++++++++ tests/unit/server/test_portal_routes.py | 421 +++++++++++++ 30 files changed, 2674 insertions(+), 3 deletions(-) create mode 100644 src/agentkit/server/frontend/env.d.ts create mode 100644 src/agentkit/server/frontend/index.html create mode 100644 src/agentkit/server/frontend/package.json create mode 100644 src/agentkit/server/frontend/src/App.vue create mode 100644 src/agentkit/server/frontend/src/api/client.ts create mode 100644 src/agentkit/server/frontend/src/api/types.ts create mode 100644 src/agentkit/server/frontend/src/components/chat/ChatInput.vue create mode 100644 src/agentkit/server/frontend/src/components/chat/ChatMessage.vue create mode 100644 src/agentkit/server/frontend/src/components/chat/ChatSidebar.vue create mode 100644 src/agentkit/server/frontend/src/components/layout/AppLayout.vue create mode 100644 src/agentkit/server/frontend/src/components/layout/SideNav.vue create mode 100644 src/agentkit/server/frontend/src/main.ts create mode 100644 src/agentkit/server/frontend/src/router/index.ts create mode 100644 src/agentkit/server/frontend/src/stores/capabilities.ts create mode 100644 src/agentkit/server/frontend/src/stores/chat.ts create mode 100644 src/agentkit/server/frontend/src/views/ChatView.vue create mode 100644 src/agentkit/server/frontend/src/views/ComputerUseView.vue create mode 100644 src/agentkit/server/frontend/src/views/EvolutionView.vue create mode 100644 src/agentkit/server/frontend/src/views/KnowledgeBaseView.vue create mode 100644 src/agentkit/server/frontend/src/views/SettingsView.vue create mode 100644 src/agentkit/server/frontend/src/views/SkillsView.vue create mode 100644 src/agentkit/server/frontend/src/views/TerminalView.vue create mode 100644 src/agentkit/server/frontend/src/views/WorkflowView.vue create mode 100644 src/agentkit/server/frontend/tsconfig.json create mode 100644 src/agentkit/server/frontend/tsconfig.node.json create mode 100644 src/agentkit/server/frontend/vite.config.ts create mode 100644 src/agentkit/server/routes/portal.py create mode 100644 tests/unit/server/test_portal_routes.py diff --git a/src/agentkit/server/app.py b/src/agentkit/server/app.py index 65d4650..7ab9998 100644 --- a/src/agentkit/server/app.py +++ b/src/agentkit/server/app.py @@ -21,7 +21,7 @@ from agentkit.skills.base import Skill, SkillConfig from agentkit.skills.registry import SkillRegistry from agentkit.tools.registry import ToolRegistry from agentkit.server.config import ServerConfig -from agentkit.server.routes import agents, tasks, skills, llm, health, metrics, ws, evolution, memory +from agentkit.server.routes import agents, tasks, skills, llm, health, metrics, ws, evolution, memory, portal from agentkit.server.middleware import APIKeyAuthMiddleware, RateLimitMiddleware from agentkit.server.task_store import create_task_store from agentkit.server.runner import BackgroundRunner @@ -426,5 +426,6 @@ def create_app( app.include_router(ws.router, prefix="/api/v1") app.include_router(evolution.router, prefix="/api/v1") app.include_router(memory.router, prefix="/api/v1") + app.include_router(portal.router, prefix="/api/v1") return app diff --git a/src/agentkit/server/frontend/env.d.ts b/src/agentkit/server/frontend/env.d.ts new file mode 100644 index 0000000..65c7311 --- /dev/null +++ b/src/agentkit/server/frontend/env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent + export default component +} diff --git a/src/agentkit/server/frontend/index.html b/src/agentkit/server/frontend/index.html new file mode 100644 index 0000000..508256e --- /dev/null +++ b/src/agentkit/server/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Fischer AgentKit + + +
+ + + diff --git a/src/agentkit/server/frontend/package.json b/src/agentkit/server/frontend/package.json new file mode 100644 index 0000000..cfc5054 --- /dev/null +++ b/src/agentkit/server/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "agentkit-portal", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.5.0", + "vue-router": "^4.4.0", + "pinia": "^2.2.0", + "ant-design-vue": "^4.2.0", + "@ant-design/icons-vue": "^7.0.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.1.0", + "typescript": "^5.6.0", + "vite": "^5.4.0", + "vue-tsc": "^2.1.0" + } +} diff --git a/src/agentkit/server/frontend/src/App.vue b/src/agentkit/server/frontend/src/App.vue new file mode 100644 index 0000000..5ea2222 --- /dev/null +++ b/src/agentkit/server/frontend/src/App.vue @@ -0,0 +1,33 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/api/client.ts b/src/agentkit/server/frontend/src/api/client.ts new file mode 100644 index 0000000..8f66911 --- /dev/null +++ b/src/agentkit/server/frontend/src/api/client.ts @@ -0,0 +1,83 @@ +import type { + IChatRequest, + IChatResponse, + ICapabilitiesResponse, + IConversation, + IApiError, +} from './types' + +const API_BASE = '/api/v1/portal' + +class ApiClient { + private baseUrl: string + + constructor(baseUrl: string = API_BASE) { + this.baseUrl = baseUrl + } + + private async request( + path: string, + options: RequestInit = {} + ): Promise { + const url = `${this.baseUrl}${path}` + const headers: HeadersInit = { + 'Content-Type': 'application/json', + ...options.headers, + } + + const response = await fetch(url, { + ...options, + headers, + }) + + if (!response.ok) { + const error: IApiError = { + status: response.status, + message: response.statusText, + } + try { + const body = await response.json() + error.detail = body.detail ?? body.message + error.message = error.detail ?? error.message + } catch { + // response body is not JSON, use status text + } + throw error + } + + return response.json() as Promise + } + + /** Send a chat message via REST API */ + async chat(request: IChatRequest): Promise { + return this.request('/chat', { + method: 'POST', + body: JSON.stringify(request), + }) + } + + /** Get all available capabilities */ + async getCapabilities(): Promise { + return this.request('/capabilities') + } + + /** Get conversation list */ + async getConversations(): Promise { + return this.request('/conversations') + } + + /** Get a single conversation by ID */ + async getConversation(id: string): Promise { + return this.request(`/conversations/${id}`) + } + + /** Create a WebSocket connection for real-time chat */ + createWebSocket(): WebSocket { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + const host = window.location.host + return new WebSocket(`${protocol}//${host}${this.baseUrl}/ws`) + } +} + +export const apiClient = new ApiClient() +export { ApiClient } diff --git a/src/agentkit/server/frontend/src/api/types.ts b/src/agentkit/server/frontend/src/api/types.ts new file mode 100644 index 0000000..2a150fd --- /dev/null +++ b/src/agentkit/server/frontend/src/api/types.ts @@ -0,0 +1,76 @@ +/** Chat request payload */ +export interface IChatRequest { + message: string + conversation_id?: string + sources?: string[] + skill_name?: string +} + +/** Chat response from API */ +export interface IChatResponse { + conversation_id: string + message: string + matched_skill?: string + routing_method?: string + confidence?: number + task_id?: string + status: 'completed' | 'pending' +} + +/** Single chat message */ +export interface IChatMessage { + id: string + role: 'user' | 'assistant' + content: string + timestamp: string + matched_skill?: string + routing_method?: string + confidence?: number + task_id?: string + status?: 'completed' | 'pending' +} + +/** Conversation with messages */ +export interface IConversation { + id: string + title: string + messages: IChatMessage[] + created_at: string + updated_at: string +} + +/** Capability info */ +export interface ICapabilityInfo { + name: string + display_name: string + description: string + icon: string + enabled: boolean + skill_count: number +} + +/** Capabilities response */ +export interface ICapabilitiesResponse { + capabilities: ICapabilityInfo[] +} + +/** WebSocket message types */ +export type WsClientMessage = { + type: 'chat' + message: string + sources?: string[] + conversation_id?: string +} + +export type WsServerMessage = + | { type: 'routing'; skill: string; confidence: number; method: string } + | { type: 'step'; step: string; detail?: string } + | { type: 'result'; message: string; conversation_id: string } + | { type: 'error'; message: string; code?: string } + +/** API error */ +export interface IApiError { + status: number + message: string + detail?: string +} diff --git a/src/agentkit/server/frontend/src/components/chat/ChatInput.vue b/src/agentkit/server/frontend/src/components/chat/ChatInput.vue new file mode 100644 index 0000000..0f6b5a8 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/chat/ChatInput.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/chat/ChatMessage.vue b/src/agentkit/server/frontend/src/components/chat/ChatMessage.vue new file mode 100644 index 0000000..bb551bd --- /dev/null +++ b/src/agentkit/server/frontend/src/components/chat/ChatMessage.vue @@ -0,0 +1,138 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/chat/ChatSidebar.vue b/src/agentkit/server/frontend/src/components/chat/ChatSidebar.vue new file mode 100644 index 0000000..030b5ef --- /dev/null +++ b/src/agentkit/server/frontend/src/components/chat/ChatSidebar.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/layout/AppLayout.vue b/src/agentkit/server/frontend/src/components/layout/AppLayout.vue new file mode 100644 index 0000000..1b6dbf2 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/layout/AppLayout.vue @@ -0,0 +1,26 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/components/layout/SideNav.vue b/src/agentkit/server/frontend/src/components/layout/SideNav.vue new file mode 100644 index 0000000..ffa4888 --- /dev/null +++ b/src/agentkit/server/frontend/src/components/layout/SideNav.vue @@ -0,0 +1,136 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/main.ts b/src/agentkit/server/frontend/src/main.ts new file mode 100644 index 0000000..5a9e101 --- /dev/null +++ b/src/agentkit/server/frontend/src/main.ts @@ -0,0 +1,14 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import Antd from 'ant-design-vue' +import 'ant-design-vue/dist/reset.css' +import App from './App.vue' +import router from './router' + +const app = createApp(App) + +app.use(createPinia()) +app.use(router) +app.use(Antd) + +app.mount('#app') diff --git a/src/agentkit/server/frontend/src/router/index.ts b/src/agentkit/server/frontend/src/router/index.ts new file mode 100644 index 0000000..f1e0597 --- /dev/null +++ b/src/agentkit/server/frontend/src/router/index.ts @@ -0,0 +1,68 @@ +import { createRouter, createWebHistory } from 'vue-router' +import type { RouteRecordRaw } from 'vue-router' + +const routes: RouteRecordRaw[] = [ + { + path: '/', + name: 'chat', + component: () => import('@/views/ChatView.vue'), + meta: { title: '智能对话' }, + }, + { + path: '/workflow', + name: 'workflow', + component: () => import('@/views/WorkflowView.vue'), + meta: { title: '工作流' }, + }, + { + path: '/knowledge', + name: 'knowledge', + component: () => import('@/views/KnowledgeBaseView.vue'), + meta: { title: '知识库' }, + }, + { + path: '/skills', + name: 'skills', + component: () => import('@/views/SkillsView.vue'), + meta: { title: '技能' }, + }, + { + path: '/terminal', + name: 'terminal', + component: () => import('@/views/TerminalView.vue'), + meta: { title: '终端' }, + }, + { + path: '/computer-use', + name: 'computer-use', + component: () => import('@/views/ComputerUseView.vue'), + meta: { title: 'Computer Use' }, + }, + { + path: '/evolution', + name: 'evolution', + component: () => import('@/views/EvolutionView.vue'), + meta: { title: '自进化' }, + }, + { + path: '/settings', + name: 'settings', + component: () => import('@/views/SettingsView.vue'), + meta: { title: '设置' }, + }, +] + +const router = createRouter({ + history: createWebHistory(), + routes, +}) + +router.beforeEach((to, _from, next) => { + const title = to.meta.title as string | undefined + if (title) { + document.title = `${title} - Fischer AgentKit` + } + next() +}) + +export default router diff --git a/src/agentkit/server/frontend/src/stores/capabilities.ts b/src/agentkit/server/frontend/src/stores/capabilities.ts new file mode 100644 index 0000000..30fbed7 --- /dev/null +++ b/src/agentkit/server/frontend/src/stores/capabilities.ts @@ -0,0 +1,53 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { apiClient } from '@/api/client' +import type { ICapabilityInfo } from '@/api/types' + +export const useCapabilitiesStore = defineStore('capabilities', () => { + // --- State --- + const capabilities = ref([]) + const isLoading = ref(false) + const error = ref(null) + + // --- Getters --- + const enabledCapabilities = computed(() => { + return capabilities.value.filter((c) => c.enabled) + }) + + const capabilityNames = computed(() => { + return capabilities.value.map((c) => c.name) + }) + + function getByName(name: string): ICapabilityInfo | undefined { + return capabilities.value.find((c) => c.name === name) + } + + // --- Actions --- + async function fetchCapabilities(): Promise { + isLoading.value = true + error.value = null + + try { + const response = await apiClient.getCapabilities() + capabilities.value = response.capabilities + } catch (err) { + error.value = err instanceof Error ? err.message : '获取能力列表失败' + console.error('Failed to fetch capabilities:', err) + } finally { + isLoading.value = false + } + } + + return { + // State + capabilities, + isLoading, + error, + // Getters + enabledCapabilities, + capabilityNames, + getByName, + // Actions + fetchCapabilities, + } +}) diff --git a/src/agentkit/server/frontend/src/stores/chat.ts b/src/agentkit/server/frontend/src/stores/chat.ts new file mode 100644 index 0000000..32ad3df --- /dev/null +++ b/src/agentkit/server/frontend/src/stores/chat.ts @@ -0,0 +1,315 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { apiClient } from '@/api/client' +import type { + IChatMessage, + IConversation, + IChatRequest, + WsClientMessage, + WsServerMessage, +} from '@/api/types' + +function generateId(): string { + return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}` +} + +export const useChatStore = defineStore('chat', () => { + // --- State --- + const conversations = ref([]) + const currentConversationId = ref(null) + const isLoading = ref(false) + const isWsConnected = ref(false) + const ws = ref(null) + const streamingSteps = ref([]) + + // --- Getters --- + const currentConversation = computed(() => { + return conversations.value.find((c) => c.id === currentConversationId.value) + }) + + const currentMessages = computed(() => { + return currentConversation.value?.messages ?? [] + }) + + // --- Actions --- + + /** Load all conversations from the server */ + async function loadConversations(): Promise { + try { + const data = await apiClient.getConversations() + conversations.value = data + } catch (error) { + console.error('Failed to load conversations:', error) + } + } + + /** Select a conversation by ID */ + function selectConversation(id: string): void { + currentConversationId.value = id + streamingSteps.value = [] + } + + /** Create a new empty conversation */ + function createConversation(): void { + const newConversation: IConversation = { + id: generateId(), + title: '新对话', + messages: [], + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + } + conversations.value.unshift(newConversation) + currentConversationId.value = newConversation.id + streamingSteps.value = [] + } + + /** Send a message using REST API (fallback) */ + async function sendMessage(message: string, sources?: string[]): Promise { + if (!currentConversationId.value) { + createConversation() + } + + const conversationId = currentConversationId.value as string + + // Add user message + const userMessage: IChatMessage = { + id: generateId(), + role: 'user', + content: message, + timestamp: new Date().toISOString(), + } + appendMessage(conversationId, userMessage) + + // Add placeholder assistant message + const assistantMessage: IChatMessage = { + id: generateId(), + role: 'assistant', + content: '', + timestamp: new Date().toISOString(), + status: 'pending', + } + appendMessage(conversationId, assistantMessage) + + isLoading.value = true + + try { + const request: IChatRequest = { + message, + conversation_id: conversationId, + sources, + } + const response = await apiClient.chat(request) + + // Update assistant message with response + updateMessage(conversationId, assistantMessage.id, { + content: response.message, + matched_skill: response.matched_skill, + routing_method: response.routing_method, + confidence: response.confidence, + task_id: response.task_id, + status: response.status, + }) + + // Update conversation title from first message + const conv = conversations.value.find((c) => c.id === conversationId) + if (conv && conv.messages.length <= 2) { + conv.title = message.length > 20 ? `${message.substring(0, 20)}...` : message + } + } catch (error) { + updateMessage(conversationId, assistantMessage.id, { + content: `请求失败: ${error instanceof Error ? error.message : '未知错误'}`, + status: 'completed', + }) + } finally { + isLoading.value = false + } + } + + /** Send a message via WebSocket for streaming */ + function sendWsMessage(message: string, sources?: string[]): void { + if (!currentConversationId.value) { + createConversation() + } + + const conversationId = currentConversationId.value as string + + // Add user message + const userMessage: IChatMessage = { + id: generateId(), + role: 'user', + content: message, + timestamp: new Date().toISOString(), + } + appendMessage(conversationId, userMessage) + + // Add placeholder assistant message + const assistantMessage: IChatMessage = { + id: generateId(), + role: 'assistant', + content: '', + timestamp: new Date().toISOString(), + status: 'pending', + } + appendMessage(conversationId, assistantMessage) + + streamingSteps.value = [] + + const wsMessage: WsClientMessage = { + type: 'chat', + message, + sources, + conversation_id: conversationId, + } + + if (ws.value && ws.value.readyState === WebSocket.OPEN) { + ws.value.send(JSON.stringify(wsMessage)) + } else { + // Fallback to REST + sendMessage(message, sources) + } + } + + /** Connect to WebSocket for real-time streaming */ + function connectWebSocket(): void { + if (ws.value && ws.value.readyState === WebSocket.OPEN) { + return + } + + const socket = apiClient.createWebSocket() + + socket.onopen = () => { + isWsConnected.value = true + console.log('WebSocket connected') + } + + socket.onmessage = (event: MessageEvent) => { + try { + const data = JSON.parse(event.data as string) as WsServerMessage + handleWsMessage(data) + } catch (error) { + console.error('Failed to parse WebSocket message:', error) + } + } + + socket.onclose = () => { + isWsConnected.value = false + console.log('WebSocket disconnected') + // Auto reconnect after 3 seconds + setTimeout(() => { + if (!ws.value || ws.value.readyState === WebSocket.CLOSED) { + connectWebSocket() + } + }, 3000) + } + + socket.onerror = (error) => { + console.error('WebSocket error:', error) + isWsConnected.value = false + } + + ws.value = socket + } + + /** Disconnect WebSocket */ + function disconnectWebSocket(): void { + if (ws.value) { + ws.value.close() + ws.value = null + isWsConnected.value = false + } + } + + // --- Internal helpers --- + + function handleWsMessage(data: WsServerMessage): void { + const conversationId = currentConversationId.value + if (!conversationId) return + + const conv = conversations.value.find((c) => c.id === conversationId) + if (!conv) return + + const lastAssistantMsg = [...conv.messages] + .reverse() + .find((m) => m.role === 'assistant') + + switch (data.type) { + case 'routing': + if (lastAssistantMsg) { + updateMessage(conversationId, lastAssistantMsg.id, { + matched_skill: data.skill, + confidence: data.confidence, + routing_method: data.method, + }) + } + streamingSteps.value.push(`路由至: ${data.skill} (置信度: ${(data.confidence * 100).toFixed(1)}%)`) + break + + case 'step': + streamingSteps.value.push(data.step) + break + + case 'result': + if (lastAssistantMsg) { + updateMessage(conversationId, lastAssistantMsg.id, { + content: data.message, + status: 'completed', + }) + } + isLoading.value = false + streamingSteps.value = [] + break + + case 'error': + if (lastAssistantMsg) { + updateMessage(conversationId, lastAssistantMsg.id, { + content: `错误: ${data.message}`, + status: 'completed', + }) + } + isLoading.value = false + streamingSteps.value = [] + break + } + } + + function appendMessage(conversationId: string, message: IChatMessage): void { + const conv = conversations.value.find((c) => c.id === conversationId) + if (conv) { + conv.messages.push(message) + conv.updated_at = new Date().toISOString() + } + } + + function updateMessage( + conversationId: string, + messageId: string, + updates: Partial + ): void { + const conv = conversations.value.find((c) => c.id === conversationId) + if (!conv) return + const msg = conv.messages.find((m) => m.id === messageId) + if (msg) { + Object.assign(msg, updates) + } + } + + return { + // State + conversations, + currentConversationId, + isLoading, + isWsConnected, + streamingSteps, + // Getters + currentConversation, + currentMessages, + // Actions + loadConversations, + selectConversation, + createConversation, + sendMessage, + sendWsMessage, + connectWebSocket, + disconnectWebSocket, + } +}) diff --git a/src/agentkit/server/frontend/src/views/ChatView.vue b/src/agentkit/server/frontend/src/views/ChatView.vue new file mode 100644 index 0000000..11e34c4 --- /dev/null +++ b/src/agentkit/server/frontend/src/views/ChatView.vue @@ -0,0 +1,185 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/views/ComputerUseView.vue b/src/agentkit/server/frontend/src/views/ComputerUseView.vue new file mode 100644 index 0000000..b857de9 --- /dev/null +++ b/src/agentkit/server/frontend/src/views/ComputerUseView.vue @@ -0,0 +1,28 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/views/EvolutionView.vue b/src/agentkit/server/frontend/src/views/EvolutionView.vue new file mode 100644 index 0000000..61af5f9 --- /dev/null +++ b/src/agentkit/server/frontend/src/views/EvolutionView.vue @@ -0,0 +1,28 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/views/KnowledgeBaseView.vue b/src/agentkit/server/frontend/src/views/KnowledgeBaseView.vue new file mode 100644 index 0000000..7e95bfb --- /dev/null +++ b/src/agentkit/server/frontend/src/views/KnowledgeBaseView.vue @@ -0,0 +1,28 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/views/SettingsView.vue b/src/agentkit/server/frontend/src/views/SettingsView.vue new file mode 100644 index 0000000..71d1b99 --- /dev/null +++ b/src/agentkit/server/frontend/src/views/SettingsView.vue @@ -0,0 +1,28 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/views/SkillsView.vue b/src/agentkit/server/frontend/src/views/SkillsView.vue new file mode 100644 index 0000000..9798242 --- /dev/null +++ b/src/agentkit/server/frontend/src/views/SkillsView.vue @@ -0,0 +1,28 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/views/TerminalView.vue b/src/agentkit/server/frontend/src/views/TerminalView.vue new file mode 100644 index 0000000..e5a030d --- /dev/null +++ b/src/agentkit/server/frontend/src/views/TerminalView.vue @@ -0,0 +1,28 @@ + + + + + diff --git a/src/agentkit/server/frontend/src/views/WorkflowView.vue b/src/agentkit/server/frontend/src/views/WorkflowView.vue new file mode 100644 index 0000000..03a2b0f --- /dev/null +++ b/src/agentkit/server/frontend/src/views/WorkflowView.vue @@ -0,0 +1,28 @@ + + + + + diff --git a/src/agentkit/server/frontend/tsconfig.json b/src/agentkit/server/frontend/tsconfig.json new file mode 100644 index 0000000..1e5ec8c --- /dev/null +++ b/src/agentkit/server/frontend/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "preserve", + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "env.d.ts"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/src/agentkit/server/frontend/tsconfig.node.json b/src/agentkit/server/frontend/tsconfig.node.json new file mode 100644 index 0000000..b3fc13e --- /dev/null +++ b/src/agentkit/server/frontend/tsconfig.node.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/src/agentkit/server/frontend/vite.config.ts b/src/agentkit/server/frontend/vite.config.ts new file mode 100644 index 0000000..28c2cf0 --- /dev/null +++ b/src/agentkit/server/frontend/vite.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { resolve } from 'path' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + }, + }, + build: { + outDir: '../static', + emptyOutDir: true, + }, + server: { + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + }, + }, + }, +}) diff --git a/src/agentkit/server/routes/__init__.py b/src/agentkit/server/routes/__init__.py index 46c1768..9337477 100644 --- a/src/agentkit/server/routes/__init__.py +++ b/src/agentkit/server/routes/__init__.py @@ -1,5 +1,5 @@ """Server route modules""" -from agentkit.server.routes import agents, tasks, skills, llm, health, metrics, ws, evolution, memory +from agentkit.server.routes import agents, tasks, skills, llm, health, metrics, ws, evolution, memory, portal -__all__ = ["agents", "tasks", "skills", "llm", "health", "metrics", "ws", "evolution", "memory"] +__all__ = ["agents", "tasks", "skills", "llm", "health", "metrics", "ws", "evolution", "memory", "portal"] diff --git a/src/agentkit/server/routes/portal.py b/src/agentkit/server/routes/portal.py new file mode 100644 index 0000000..46762f3 --- /dev/null +++ b/src/agentkit/server/routes/portal.py @@ -0,0 +1,590 @@ +"""Portal API routes - unified chat interface with intent routing""" + +from __future__ import annotations + +import asyncio +import json +import logging +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any + +from fastapi import APIRouter, HTTPException, Request, WebSocket, WebSocketDisconnect +from pydantic import BaseModel + +from agentkit.core.protocol import TaskMessage +from agentkit.core.react import ReActEngine +from agentkit.router.intent import IntentRouter + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["portal"]) + + +# --------------------------------------------------------------------------- +# In-memory Conversation Store +# --------------------------------------------------------------------------- + + +@dataclass +class ChatMessage: + role: str # "user" or "assistant" + content: str + timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + metadata: dict = field(default_factory=dict) + + +@dataclass +class Conversation: + id: str + messages: list[ChatMessage] = field(default_factory=list) + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + +class ConversationStore: + def __init__(self, max_conversations: int = 1000): + self._conversations: dict[str, Conversation] = {} + self._max = max_conversations + + def get_or_create(self, conversation_id: str | None = None) -> Conversation: + if conversation_id and conversation_id in self._conversations: + conv = self._conversations[conversation_id] + conv.updated_at = datetime.now(timezone.utc) + return conv + + cid = conversation_id or str(uuid.uuid4()) + conv = Conversation(id=cid) + self._conversations[cid] = conv + # Evict oldest if over limit + if len(self._conversations) > self._max: + oldest_id = min(self._conversations, key=lambda k: self._conversations[k].updated_at) + del self._conversations[oldest_id] + return conv + + def add_message( + self, conversation_id: str, role: str, content: str, metadata: dict | None = None + ) -> ChatMessage: + conv = self._conversations.get(conversation_id) + if conv is None: + raise KeyError(f"Conversation '{conversation_id}' not found") + msg = ChatMessage(role=role, content=content, metadata=metadata or {}) + conv.messages.append(msg) + conv.updated_at = datetime.now(timezone.utc) + return msg + + def get_history(self, conversation_id: str, limit: int = 50) -> list[ChatMessage]: + conv = self._conversations.get(conversation_id) + if conv is None: + return [] + return conv.messages[-limit:] + + def list_conversations(self, limit: int = 20) -> list[Conversation]: + sorted_convs = sorted( + self._conversations.values(), key=lambda c: c.updated_at, reverse=True + ) + return sorted_convs[:limit] + + +# Module-level singleton +_conversation_store = ConversationStore() + + +# --------------------------------------------------------------------------- +# Capability mapping +# --------------------------------------------------------------------------- + +CAPABILITY_CATEGORIES: dict[str, dict[str, str]] = { + "chat": { + "display_name": "智能对话", + "description": "自然语言交互,自动路由到对应能力", + "icon": "MessageOutlined", + }, + "workflow": { + "display_name": "工作流编排", + "description": "可视化拖拽编排工作流", + "icon": "ApartmentOutlined", + }, + "knowledge": { + "display_name": "知识库", + "description": "文档摄取、语义检索、多源RAG", + "icon": "BookOutlined", + }, + "skills": { + "display_name": "技能管理", + "description": "浏览和管理已注册的技能", + "icon": "AppstoreOutlined", + }, + "terminal": { + "display_name": "智能终端", + "description": "交互式终端会话和命令执行", + "icon": "CodeOutlined", + }, + "computer_use": { + "display_name": "Computer Use", + "description": "UI自动化操作和截屏识别", + "icon": "DesktopOutlined", + }, + "evolution": { + "display_name": "自进化", + "description": "经验积累、避坑预警、路径优化", + "icon": "RiseOutlined", + }, + "settings": { + "display_name": "系统设置", + "description": "配置LLM、技能、知识库连接", + "icon": "SettingOutlined", + }, +} + + +# --------------------------------------------------------------------------- +# Request / Response models +# --------------------------------------------------------------------------- + + +class ChatRequest(BaseModel): + message: str + conversation_id: str | None = None + sources: list[str] | None = None + skill_name: str | None = None + + +class ChatResponse(BaseModel): + conversation_id: str + message: str + matched_skill: str | None = None + routing_method: str | None = None + confidence: float | None = None + task_id: str | None = None + status: str = "completed" + + +class CapabilityInfo(BaseModel): + name: str + display_name: str + description: str + icon: str + enabled: bool + skill_count: int + + +class CapabilitiesResponse(BaseModel): + capabilities: list[CapabilityInfo] + + +# --------------------------------------------------------------------------- +# Helper: resolve agent + skill for a chat request +# --------------------------------------------------------------------------- + + +async def _resolve_for_chat( + request: ChatRequest, req: Request +) -> tuple[Any, Any, str | None, str | None, float | None]: + """Resolve agent and skill for a chat request. + + Returns (agent, skill, matched_skill_name, routing_method, confidence). + """ + pool = req.app.state.agent_pool + skill_registry = req.app.state.skill_registry + intent_router: IntentRouter = req.app.state.intent_router + + matched_skill_name: str | None = None + routing_method: str | None = None + confidence: float | None = None + + if request.skill_name: + # Use specified skill directly + try: + skill = skill_registry.get(request.skill_name) + except Exception: + raise HTTPException( + status_code=404, + detail=f"Skill '{request.skill_name}' not found", + ) + matched_skill_name = request.skill_name + routing_method = "direct" + confidence = 1.0 + agent = pool.get_agent(request.skill_name) + if agent is None: + agent = await pool.create_agent_from_skill(request.skill_name) + return agent, skill, matched_skill_name, routing_method, confidence + + # Use IntentRouter + all_skills = skill_registry.list_skills() + if not all_skills: + raise HTTPException( + status_code=400, + detail="No skills available. Please register skills first.", + ) + try: + routing_result = await intent_router.route( + {"query": request.message, "sources": request.sources}, all_skills + ) + matched_skill_name = routing_result.matched_skill + routing_method = routing_result.method + confidence = routing_result.confidence + skill = skill_registry.get(matched_skill_name) + agent = pool.get_agent(matched_skill_name) + if agent is None: + agent = await pool.create_agent_from_skill(matched_skill_name) + except (ValueError, RuntimeError) as e: + raise HTTPException(status_code=400, detail=str(e)) + + return agent, skill, matched_skill_name, routing_method, confidence + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + + +@router.post("/portal/chat", response_model=ChatResponse) +async def chat(request: ChatRequest, req: Request): + """Send a chat message and get a response with intent routing.""" + agent, skill, matched_skill, routing_method, confidence = await _resolve_for_chat( + request, req + ) + + # Create or reuse conversation + conv = _conversation_store.get_or_create(request.conversation_id) + _conversation_store.add_message(conv.id, "user", request.message) + + # Build task and execute + task = TaskMessage( + task_id=str(uuid.uuid4()), + agent_name=agent.name, + task_type=agent.agent_type, + priority=0, + input_data={"query": request.message, "sources": request.sources}, + callback_url=None, + created_at=datetime.now(timezone.utc), + ) + + task_result = await agent.execute(task) + + # Extract response text + if task_result.output_data: + if isinstance(task_result.output_data, dict): + response_text = task_result.output_data.get("result") or task_result.output_data.get( + "output" + ) or json.dumps(task_result.output_data, ensure_ascii=False) + else: + response_text = str(task_result.output_data) + elif task_result.error_message: + response_text = task_result.error_message + else: + response_text = "" + + _conversation_store.add_message(conv.id, "assistant", response_text) + + return ChatResponse( + conversation_id=conv.id, + message=response_text, + matched_skill=matched_skill, + routing_method=routing_method, + confidence=confidence, + task_id=task.task_id, + status="completed", + ) + + +@router.post("/portal/chat/stream") +async def chat_stream(request: ChatRequest, req: Request): + """Stream chat responses via SSE.""" + from sse_starlette.sse import EventSourceResponse + + agent, skill, matched_skill, routing_method, confidence = await _resolve_for_chat( + request, req + ) + + # Create or reuse conversation + conv = _conversation_store.get_or_create(request.conversation_id) + _conversation_store.add_message(conv.id, "user", request.message) + + async def event_generator(): + react_config = agent.get_react_config() + react_engine = ReActEngine( + llm_gateway=req.app.state.llm_gateway, + max_steps=react_config["max_steps"], + ) + + messages = [{"role": "user", "content": request.message}] + tools = agent.get_tools() + model = agent.get_model() + system_prompt = agent.get_system_prompt() + timeout_seconds = react_config["timeout_seconds"] + + # Send routing info as first event + yield { + "event": "routing", + "data": json.dumps( + { + "skill": matched_skill, + "method": routing_method, + "confidence": confidence, + } + ), + } + + collected_output: list[str] = [] + try: + async for event in react_engine.execute_stream( + messages=messages, + tools=tools, + model=model, + agent_name=agent.name, + system_prompt=system_prompt, + timeout_seconds=timeout_seconds, + ): + if event.event_type == "final_answer": + collected_output.append( + event.data.get("output", "") + ) + yield { + "event": event.event_type, + "data": json.dumps( + { + "step": event.step, + "data": event.data, + "timestamp": event.timestamp, + } + ), + } + except Exception as e: + yield { + "event": "error", + "data": json.dumps({"error": str(e)}), + } + return + + # Save assistant response to conversation + response_text = "".join(collected_output) if collected_output else "" + if response_text: + _conversation_store.add_message(conv.id, "assistant", response_text) + + return EventSourceResponse(event_generator()) + + +@router.get("/portal/capabilities", response_model=CapabilitiesResponse) +async def get_capabilities(req: Request): + """List all available capabilities with their status.""" + skill_registry = req.app.state.skill_registry + all_skills = skill_registry.list_skills() + + # Build a map of capability tag -> skill count + cap_skill_counts: dict[str, int] = {} + for skill in all_skills: + for cap in skill.capabilities: + cap_skill_counts[cap.tag] = cap_skill_counts.get(cap.tag, 0) + 1 + # Also count the skill itself toward "skills" category + cap_skill_counts["skills"] = cap_skill_counts.get("skills", 0) + 1 + + capabilities: list[CapabilityInfo] = [] + for cat_name, cat_info in CAPABILITY_CATEGORIES.items(): + skill_count = cap_skill_counts.get(cat_name, 0) + capabilities.append( + CapabilityInfo( + name=cat_name, + display_name=cat_info["display_name"], + description=cat_info["description"], + icon=cat_info["icon"], + enabled=True, + skill_count=skill_count, + ) + ) + + return CapabilitiesResponse(capabilities=capabilities) + + +@router.get("/portal/conversations") +async def list_conversations(limit: int = 20): + """List recent conversations.""" + convs = _conversation_store.list_conversations(limit=limit) + return [ + { + "id": c.id, + "created_at": c.created_at.isoformat(), + "updated_at": c.updated_at.isoformat(), + "message_count": len(c.messages), + } + for c in convs + ] + + +@router.get("/portal/conversations/{conversation_id}") +async def get_conversation(conversation_id: str, limit: int = 50): + """Get conversation history.""" + history = _conversation_store.get_history(conversation_id, limit=limit) + if not history and conversation_id not in _conversation_store._conversations: + raise HTTPException(status_code=404, detail=f"Conversation '{conversation_id}' not found") + return [ + { + "role": m.role, + "content": m.content, + "timestamp": m.timestamp.isoformat(), + "metadata": m.metadata, + } + for m in history + ] + + +@router.websocket("/portal/ws") +async def portal_websocket(websocket: WebSocket): + """Real-time chat WebSocket endpoint.""" + # Authentication + configured_api_key: str | None = None + if hasattr(websocket.app.state, "server_config") and websocket.app.state.server_config: + configured_api_key = websocket.app.state.server_config.api_key + if configured_api_key is None and hasattr(websocket.app.state, "api_key"): + configured_api_key = websocket.app.state.api_key + + # Check api_key query param + if configured_api_key: + provided = websocket.query_params.get("api_key") + if provided != configured_api_key: + await websocket.accept() + await websocket.send_json( + {"type": "error", "data": {"message": "Invalid or missing api_key"}} + ) + await websocket.close(code=4001, reason="Invalid or missing api_key") + return + + await websocket.accept() + + # Create conversation + conv = _conversation_store.get_or_create() + await websocket.send_json({"type": "connected", "conversation_id": conv.id}) + + try: + while True: + try: + raw = await asyncio.wait_for(websocket.receive_text(), timeout=120.0) + except asyncio.TimeoutError: + await websocket.close(code=1000, reason="Heartbeat timeout") + return + + try: + msg = json.loads(raw) + except json.JSONDecodeError: + continue + + msg_type = msg.get("type") + + if msg_type == "cancel": + await websocket.send_json( + {"type": "result", "data": {"status": "cancelled"}} + ) + return + + if msg_type != "chat": + continue + + message_text = msg.get("message", "") + sources = msg.get("sources") + + if not message_text: + continue + + # Save user message + _conversation_store.add_message(conv.id, "user", message_text) + + # Resolve skill via IntentRouter + pool = websocket.app.state.agent_pool + skill_registry = websocket.app.state.skill_registry + intent_router: IntentRouter = websocket.app.state.intent_router + + all_skills = skill_registry.list_skills() + if not all_skills: + await websocket.send_json( + { + "type": "error", + "data": {"message": "No skills available"}, + } + ) + continue + + try: + routing_result = await intent_router.route( + {"query": message_text, "sources": sources}, all_skills + ) + await websocket.send_json( + { + "type": "routing", + "skill": routing_result.matched_skill, + "method": routing_result.method, + "confidence": routing_result.confidence, + } + ) + + skill = skill_registry.get(routing_result.matched_skill) + agent = pool.get_agent(routing_result.matched_skill) + if agent is None: + agent = await pool.create_agent_from_skill(routing_result.matched_skill) + except (ValueError, RuntimeError) as e: + await websocket.send_json( + {"type": "error", "data": {"message": str(e)}} + ) + continue + + # Execute via ReAct stream + react_config = agent.get_react_config() + react_engine = ReActEngine( + llm_gateway=websocket.app.state.llm_gateway, + max_steps=react_config["max_steps"], + ) + + messages = [{"role": "user", "content": message_text}] + tools = agent.get_tools() + model = agent.get_model() + system_prompt = agent.get_system_prompt() + timeout_seconds = react_config["timeout_seconds"] + + collected_output: list[str] = [] + try: + async for event in react_engine.execute_stream( + messages=messages, + tools=tools, + model=model, + agent_name=agent.name, + system_prompt=system_prompt, + timeout_seconds=timeout_seconds, + ): + if event.event_type == "final_answer": + collected_output.append(event.data.get("output", "")) + await websocket.send_json( + { + "type": "step", + "data": { + "event_type": event.event_type, + "step": event.step, + "data": event.data, + "timestamp": event.timestamp, + }, + } + ) + except Exception as e: + await websocket.send_json( + {"type": "error", "data": {"message": str(e)}} + ) + continue + + response_text = "".join(collected_output) if collected_output else "" + if response_text: + _conversation_store.add_message(conv.id, "assistant", response_text) + + await websocket.send_json( + {"type": "result", "data": {"message": response_text}} + ) + + except WebSocketDisconnect: + logger.debug(f"Portal WebSocket disconnected for conversation {conv.id}") + except Exception as e: + logger.error(f"Portal WebSocket error: {e}") + try: + await websocket.send_json( + {"type": "error", "data": {"message": str(e)}} + ) + except Exception: + pass diff --git a/tests/unit/server/test_portal_routes.py b/tests/unit/server/test_portal_routes.py new file mode 100644 index 0000000..57e79b6 --- /dev/null +++ b/tests/unit/server/test_portal_routes.py @@ -0,0 +1,421 @@ +"""Tests for Portal API routes""" + +from __future__ import annotations + +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from agentkit.llm.gateway import LLMGateway +from agentkit.llm.protocol import LLMResponse, TokenUsage +from agentkit.server.app import create_app +from agentkit.server.routes.portal import ( + CAPABILITY_CATEGORIES, + ChatMessage, + ConversationStore, +) +from agentkit.skills.base import Skill, SkillConfig +from agentkit.skills.registry import SkillRegistry +from agentkit.tools.registry import ToolRegistry + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_llm_gateway(): + gateway = LLMGateway() + mock_provider = AsyncMock() + mock_provider.chat.return_value = LLMResponse( + content='{"skill": "chat_skill", "confidence": 0.9}', + model="test-model", + usage=TokenUsage(prompt_tokens=10, completion_tokens=20), + ) + gateway.register_provider("test", mock_provider) + return gateway + + +@pytest.fixture +def skill_registry(): + return SkillRegistry() + + +@pytest.fixture +def tool_registry(): + return ToolRegistry() + + +@pytest.fixture +def app(mock_llm_gateway, skill_registry, tool_registry): + return create_app( + llm_gateway=mock_llm_gateway, + skill_registry=skill_registry, + tool_registry=tool_registry, + ) + + +@pytest.fixture +def client(app): + return TestClient(app) + + +def _register_skill(registry: SkillRegistry, name: str = "chat_skill", **kwargs): + """Helper to register a skill with sensible defaults.""" + config = SkillConfig( + name=name, + agent_type="chat_type", + task_mode="llm_generate", + prompt={"identity": "Chat Skill", "instructions": "Handle chat"}, + intent={"keywords": ["chat", "hello"], "description": "A chat skill"}, + **kwargs, + ) + skill = Skill(config=config) + registry.register(skill) + return skill + + +# --------------------------------------------------------------------------- +# ConversationStore unit tests +# --------------------------------------------------------------------------- + + +class TestConversationStore: + def test_get_or_create_new(self): + store = ConversationStore() + conv = store.get_or_create() + assert conv.id is not None + assert conv.messages == [] + + def test_get_or_create_with_id(self): + store = ConversationStore() + conv = store.get_or_create("test-id-123") + assert conv.id == "test-id-123" + + def test_get_or_create_reuse(self): + store = ConversationStore() + conv1 = store.get_or_create("reuse-id") + store.add_message("reuse-id", "user", "hello") + conv2 = store.get_or_create("reuse-id") + assert conv2.id == "reuse-id" + assert len(conv2.messages) == 1 + + def test_add_message(self): + store = ConversationStore() + conv = store.get_or_create("msg-id") + msg = store.add_message("msg-id", "user", "hello") + assert msg.role == "user" + assert msg.content == "hello" + assert len(conv.messages) == 1 + + def test_add_message_not_found(self): + store = ConversationStore() + with pytest.raises(KeyError): + store.add_message("nonexistent", "user", "hello") + + def test_get_history(self): + store = ConversationStore() + store.get_or_create("hist-id") + store.add_message("hist-id", "user", "msg1") + store.add_message("hist-id", "assistant", "msg2") + history = store.get_history("hist-id") + assert len(history) == 2 + assert history[0].role == "user" + assert history[1].role == "assistant" + + def test_get_history_limit(self): + store = ConversationStore() + store.get_or_create("limit-id") + for i in range(10): + store.add_message("limit-id", "user", f"msg{i}") + history = store.get_history("limit-id", limit=3) + assert len(history) == 3 + assert history[0].content == "msg7" + + def test_get_history_nonexistent(self): + store = ConversationStore() + history = store.get_history("no-such-id") + assert history == [] + + def test_list_conversations(self): + store = ConversationStore() + store.get_or_create("conv-a") + store.get_or_create("conv-b") + convs = store.list_conversations() + assert len(convs) == 2 + + def test_list_conversations_limit(self): + store = ConversationStore() + for i in range(5): + store.get_or_create(f"conv-{i}") + convs = store.list_conversations(limit=2) + assert len(convs) == 2 + + def test_max_conversations_eviction(self): + store = ConversationStore(max_conversations=3) + for i in range(5): + store.get_or_create(f"evict-{i}") + assert len(store._conversations) <= 3 + + +# --------------------------------------------------------------------------- +# POST /portal/chat +# --------------------------------------------------------------------------- + + +class TestPortalChat: + def test_chat_with_skill_name(self, client, skill_registry): + _register_skill(skill_registry, "chat_skill") + + response = client.post( + "/api/v1/portal/chat", + json={"message": "hello", "skill_name": "chat_skill"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["conversation_id"] is not None + assert data["matched_skill"] == "chat_skill" + assert data["routing_method"] == "direct" + assert data["confidence"] == 1.0 + assert data["status"] == "completed" + + def test_chat_with_intent_routing(self, client, skill_registry): + _register_skill(skill_registry, "chat_skill") + + response = client.post( + "/api/v1/portal/chat", + json={"message": "hello chat"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["matched_skill"] is not None + assert data["routing_method"] is not None + assert data["conversation_id"] is not None + + def test_chat_no_skills_available(self, client): + response = client.post( + "/api/v1/portal/chat", + json={"message": "hello"}, + ) + assert response.status_code == 400 + assert "No skills available" in response.json()["detail"] + + def test_chat_skill_not_found(self, client): + response = client.post( + "/api/v1/portal/chat", + json={"message": "hello", "skill_name": "nonexistent_skill"}, + ) + assert response.status_code == 404 + + def test_chat_with_conversation_id(self, client, skill_registry): + _register_skill(skill_registry, "chat_skill") + + response1 = client.post( + "/api/v1/portal/chat", + json={"message": "hello", "skill_name": "chat_skill"}, + ) + conv_id = response1.json()["conversation_id"] + + response2 = client.post( + "/api/v1/portal/chat", + json={"message": "follow up", "skill_name": "chat_skill", "conversation_id": conv_id}, + ) + assert response2.status_code == 200 + assert response2.json()["conversation_id"] == conv_id + + def test_chat_with_sources(self, client, skill_registry): + _register_skill(skill_registry, "chat_skill") + + response = client.post( + "/api/v1/portal/chat", + json={ + "message": "search docs", + "skill_name": "chat_skill", + "sources": ["wiki", "docs"], + }, + ) + assert response.status_code == 200 + + +# --------------------------------------------------------------------------- +# GET /portal/capabilities +# --------------------------------------------------------------------------- + + +class TestPortalCapabilities: + def test_capabilities_returns_list(self, client): + response = client.get("/api/v1/portal/capabilities") + assert response.status_code == 200 + data = response.json() + assert "capabilities" in data + caps = data["capabilities"] + assert len(caps) == len(CAPABILITY_CATEGORIES) + + def test_capabilities_structure(self, client): + response = client.get("/api/v1/portal/capabilities") + caps = response.json()["capabilities"] + for cap in caps: + assert "name" in cap + assert "display_name" in cap + assert "description" in cap + assert "icon" in cap + assert "enabled" in cap + assert "skill_count" in cap + + def test_capabilities_with_skills(self, client, skill_registry): + _register_skill(skill_registry, "chat_skill", capabilities=["chat"]) + + response = client.get("/api/v1/portal/capabilities") + caps = response.json()["capabilities"] + chat_cap = next(c for c in caps if c["name"] == "chat") + assert chat_cap["skill_count"] >= 1 + + def test_capabilities_skills_category_always_increments(self, client, skill_registry): + _register_skill(skill_registry, "some_skill") + + response = client.get("/api/v1/portal/capabilities") + caps = response.json()["capabilities"] + skills_cap = next(c for c in caps if c["name"] == "skills") + assert skills_cap["skill_count"] >= 1 + + +# --------------------------------------------------------------------------- +# GET /portal/conversations +# --------------------------------------------------------------------------- + + +class TestPortalConversations: + def test_list_conversations_empty(self, client): + response = client.get("/api/v1/portal/conversations") + assert response.status_code == 200 + assert isinstance(response.json(), list) + + def test_list_conversations_after_chat(self, client, skill_registry): + _register_skill(skill_registry, "chat_skill") + client.post( + "/api/v1/portal/chat", + json={"message": "hello", "skill_name": "chat_skill"}, + ) + + response = client.get("/api/v1/portal/conversations") + assert response.status_code == 200 + data = response.json() + assert len(data) >= 1 + assert "id" in data[0] + assert "message_count" in data[0] + + def test_list_conversations_limit(self, client, skill_registry): + _register_skill(skill_registry, "chat_skill") + for i in range(3): + client.post( + "/api/v1/portal/chat", + json={"message": f"msg{i}", "skill_name": "chat_skill"}, + ) + + response = client.get("/api/v1/portal/conversations?limit=2") + assert response.status_code == 200 + assert len(response.json()) <= 2 + + +# --------------------------------------------------------------------------- +# GET /portal/conversations/{id} +# --------------------------------------------------------------------------- + + +class TestPortalConversationHistory: + def test_get_conversation_history(self, client, skill_registry): + _register_skill(skill_registry, "chat_skill") + chat_resp = client.post( + "/api/v1/portal/chat", + json={"message": "hello", "skill_name": "chat_skill"}, + ) + conv_id = chat_resp.json()["conversation_id"] + + response = client.get(f"/api/v1/portal/conversations/{conv_id}") + assert response.status_code == 200 + data = response.json() + assert len(data) >= 1 + assert data[0]["role"] in ("user", "assistant") + assert "content" in data[0] + assert "timestamp" in data[0] + + def test_get_conversation_not_found(self, client): + response = client.get("/api/v1/portal/conversations/nonexistent-id") + assert response.status_code == 404 + + def test_get_conversation_history_limit(self, client, skill_registry): + _register_skill(skill_registry, "chat_skill") + chat_resp = client.post( + "/api/v1/portal/chat", + json={"message": "hello", "skill_name": "chat_skill"}, + ) + conv_id = chat_resp.json()["conversation_id"] + + response = client.get(f"/api/v1/portal/conversations/{conv_id}?limit=1") + assert response.status_code == 200 + assert len(response.json()) <= 1 + + +# --------------------------------------------------------------------------- +# WebSocket /portal/ws +# --------------------------------------------------------------------------- + + +class TestPortalWebSocket: + def test_ws_connect(self, client): + with client.websocket_connect("/api/v1/portal/ws") as ws: + data = ws.receive_json() + assert data["type"] == "connected" + assert "conversation_id" in data + + def test_ws_chat_flow(self, client, skill_registry): + _register_skill(skill_registry, "chat_skill") + + with client.websocket_connect("/api/v1/portal/ws") as ws: + # Receive connected message + connected = ws.receive_json() + assert connected["type"] == "connected" + + # Send chat message + ws.send_json({"type": "chat", "message": "hello chat"}) + + # Should receive routing info + routing = ws.receive_json() + assert routing["type"] == "routing" + assert "skill" in routing + + # Then receive step/result messages until we get a result + messages = [] + while True: + msg = ws.receive_json() + messages.append(msg) + if msg["type"] == "result": + break + if msg["type"] == "error": + break + + # At least one message should have been received + assert len(messages) >= 1 + + def test_ws_cancel(self, client): + with client.websocket_connect("/api/v1/portal/ws") as ws: + connected = ws.receive_json() + assert connected["type"] == "connected" + + ws.send_json({"type": "cancel"}) + result = ws.receive_json() + assert result["type"] == "result" + assert result["data"]["status"] == "cancelled" + + def test_ws_no_skills_error(self, client): + with client.websocket_connect("/api/v1/portal/ws") as ws: + connected = ws.receive_json() + assert connected["type"] == "connected" + + ws.send_json({"type": "chat", "message": "hello"}) + msg = ws.receive_json() + assert msg["type"] == "error" + assert "No skills available" in msg["data"]["message"]