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
+
+
+
+ 智能对话
+
+
+
+ 工作流
+
+
+
+ 知识库
+
+
+
+ 技能
+
+
+
+ 终端
+
+
+
+ Computer Use
+
+
+
+ 自进化
+
+
+
+
+ 设置
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
Fischer AgentKit
+
企业级 AI 智能体平台,输入消息开始对话
+
+
+
+
+
+ 处理中...
+
+
+
+ {{ step }}
+
+
+
+
+
+
+
+
+
+
+
+
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"]