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
This commit is contained in:
chiguyong 2026-06-10 01:06:48 +08:00
parent 901e4d9d0a
commit a1deeecede
30 changed files with 2674 additions and 3 deletions

View File

@ -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

7
src/agentkit/server/frontend/env.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}

View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fischer AgentKit</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@ -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"
}
}

View File

@ -0,0 +1,33 @@
<template>
<a-config-provider :locale="zhCN">
<AppLayout />
</a-config-provider>
</template>
<script setup lang="ts">
import { ConfigProvider as AConfigProvider } from 'ant-design-vue'
import zhCN from 'ant-design-vue/es/locale/zh_CN'
import AppLayout from './components/layout/AppLayout.vue'
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #app {
height: 100%;
width: 100%;
overflow: hidden;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
</style>

View File

@ -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<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
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<T>
}
/** Send a chat message via REST API */
async chat(request: IChatRequest): Promise<IChatResponse> {
return this.request<IChatResponse>('/chat', {
method: 'POST',
body: JSON.stringify(request),
})
}
/** Get all available capabilities */
async getCapabilities(): Promise<ICapabilitiesResponse> {
return this.request<ICapabilitiesResponse>('/capabilities')
}
/** Get conversation list */
async getConversations(): Promise<IConversation[]> {
return this.request<IConversation[]>('/conversations')
}
/** Get a single conversation by ID */
async getConversation(id: string): Promise<IConversation> {
return this.request<IConversation>(`/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 }

View File

@ -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
}

View File

@ -0,0 +1,83 @@
<template>
<div class="chat-input">
<a-textarea
v-model:value="inputText"
:placeholder="placeholder"
:auto-size="{ minRows: 1, maxRows: 4 }"
:disabled="disabled"
@pressEnter="handlePressEnter"
class="chat-input__textarea"
/>
<a-button
type="primary"
:disabled="!canSend"
:loading="disabled"
@click="handleSend"
class="chat-input__send"
>
<template #icon><SendOutlined /></template>
发送
</a-button>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { Input as AInput, Button as AButton } from 'ant-design-vue'
import { SendOutlined } from '@ant-design/icons-vue'
const ATextarea = AInput.TextArea
interface IProps {
disabled?: boolean
placeholder?: string
}
const props = withDefaults(defineProps<IProps>(), {
disabled: false,
placeholder: '输入消息,按 Enter 发送...',
})
const emit = defineEmits<{
send: [message: string]
}>()
const inputText = ref('')
const canSend = computed(() => {
return inputText.value.trim().length > 0 && !props.disabled
})
function handleSend(): void {
const message = inputText.value.trim()
if (!message || props.disabled) return
emit('send', message)
inputText.value = ''
}
function handlePressEnter(event: KeyboardEvent): void {
if (event.shiftKey) return // Shift+Enter for new line
event.preventDefault()
handleSend()
}
</script>
<style scoped>
.chat-input {
display: flex;
align-items: flex-end;
gap: 8px;
padding: 12px 16px;
background: #ffffff;
border-top: 1px solid #f0f0f0;
}
.chat-input__textarea {
flex: 1;
}
.chat-input__send {
flex-shrink: 0;
height: 40px;
}
</style>

View File

@ -0,0 +1,138 @@
<template>
<div class="chat-message" :class="[`chat-message--${message.role}`]">
<div class="chat-message__avatar">
<a-avatar
v-if="message.role === 'assistant'"
:size="36"
style="background-color: #1677ff"
>
<template #icon><RobotOutlined /></template>
</a-avatar>
<a-avatar
v-else
:size="36"
style="background-color: #52c41a"
>
<template #icon><UserOutlined /></template>
</a-avatar>
</div>
<div class="chat-message__body">
<div class="chat-message__content" :class="[`chat-message__content--${message.role}`]">
<span v-html="renderedContent"></span>
<a-spin v-if="isLoading" size="small" class="chat-message__loading" />
</div>
<div v-if="showRouting" class="chat-message__routing">
<a-tag color="blue">
<ThunderboltOutlined /> {{ message.matched_skill }}
</a-tag>
<a-tag v-if="message.confidence !== undefined" color="green">
置信度: {{ (message.confidence * 100).toFixed(1) }}%
</a-tag>
<a-tag v-if="message.routing_method" color="default">
{{ message.routing_method }}
</a-tag>
</div>
<div class="chat-message__time">
{{ formattedTime }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Avatar as AAvatar, Tag as ATag, Spin as ASpin } from 'ant-design-vue'
import { RobotOutlined, UserOutlined, ThunderboltOutlined } from '@ant-design/icons-vue'
import type { IChatMessage } from '@/api/types'
interface IProps {
message: IChatMessage
}
const props = defineProps<IProps>()
const isLoading = computed(() => {
return props.message.role === 'assistant' && props.message.status === 'pending' && !props.message.content
})
const showRouting = computed(() => {
return props.message.role === 'assistant' && props.message.matched_skill
})
const formattedTime = computed(() => {
const date = new Date(props.message.timestamp)
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
})
const renderedContent = computed(() => {
// Simple rendering: escape HTML and convert newlines
const escaped = props.message.content
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
return escaped.replace(/\n/g, '<br/>')
})
</script>
<style scoped>
.chat-message {
display: flex;
gap: 12px;
padding: 12px 16px;
}
.chat-message--user {
flex-direction: row-reverse;
}
.chat-message__avatar {
flex-shrink: 0;
}
.chat-message__body {
max-width: 70%;
display: flex;
flex-direction: column;
gap: 4px;
}
.chat-message--user .chat-message__body {
align-items: flex-end;
}
.chat-message__content {
padding: 10px 14px;
border-radius: 12px;
line-height: 1.6;
font-size: 14px;
word-break: break-word;
}
.chat-message__content--user {
background: #1677ff;
color: #ffffff;
border-bottom-right-radius: 4px;
}
.chat-message__content--assistant {
background: #ffffff;
color: #333333;
border: 1px solid #f0f0f0;
border-bottom-left-radius: 4px;
}
.chat-message__loading {
margin-left: 8px;
}
.chat-message__routing {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.chat-message__time {
font-size: 12px;
color: #999999;
}
</style>

View File

@ -0,0 +1,137 @@
<template>
<div class="chat-sidebar">
<div class="chat-sidebar__header">
<a-button type="primary" block @click="handleCreate">
<template #icon><PlusOutlined /></template>
新建对话
</a-button>
</div>
<div class="chat-sidebar__list">
<a-empty v-if="conversations.length === 0" description="暂无对话" />
<div
v-for="conv in conversations"
:key="conv.id"
class="chat-sidebar__item"
:class="{ 'chat-sidebar__item--active': conv.id === currentId }"
@click="handleSelect(conv.id)"
>
<MessageOutlined class="chat-sidebar__item-icon" />
<span class="chat-sidebar__item-title">{{ conv.title }}</span>
<span class="chat-sidebar__item-time">{{ formatRelativeTime(conv.updated_at) }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Button as AButton, Empty as AEmpty } from 'ant-design-vue'
import { PlusOutlined, MessageOutlined } from '@ant-design/icons-vue'
import type { IConversation } from '@/api/types'
interface IProps {
conversations: IConversation[]
currentId: string | null
}
defineProps<IProps>()
const emit = defineEmits<{
create: []
select: [id: string]
}>()
function handleCreate(): void {
emit('create')
}
function handleSelect(id: string): void {
emit('select', id)
}
function formatRelativeTime(dateStr: string): string {
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMin = Math.floor(diffMs / 60000)
if (diffMin < 1) return '刚刚'
if (diffMin < 60) return `${diffMin}分钟前`
const diffHour = Math.floor(diffMin / 60)
if (diffHour < 24) return `${diffHour}小时前`
const diffDay = Math.floor(diffHour / 24)
if (diffDay < 7) return `${diffDay}天前`
return date.toLocaleDateString('zh-CN')
}
</script>
<style scoped>
.chat-sidebar {
width: 280px;
height: 100%;
background: #ffffff;
border-right: 1px solid #f0f0f0;
display: flex;
flex-direction: column;
}
.chat-sidebar__header {
padding: 16px;
border-bottom: 1px solid #f0f0f0;
}
.chat-sidebar__list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.chat-sidebar__item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
}
.chat-sidebar__item:hover {
background: #f5f5f5;
}
.chat-sidebar__item--active {
background: #e6f4ff;
}
.chat-sidebar__item--active:hover {
background: #e6f4ff;
}
.chat-sidebar__item-icon {
color: #999999;
font-size: 14px;
flex-shrink: 0;
}
.chat-sidebar__item--active .chat-sidebar__item-icon {
color: #1677ff;
}
.chat-sidebar__item-title {
flex: 1;
font-size: 14px;
color: #333333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-sidebar__item-time {
font-size: 12px;
color: #999999;
flex-shrink: 0;
}
</style>

View File

@ -0,0 +1,26 @@
<template>
<a-layout class="app-layout">
<SideNav />
<a-layout class="app-layout__main">
<router-view />
</a-layout>
</a-layout>
</template>
<script setup lang="ts">
import { Layout as ALayout } from 'ant-design-vue'
import SideNav from './SideNav.vue'
</script>
<style scoped>
.app-layout {
height: 100vh;
width: 100vw;
}
.app-layout__main {
flex: 1;
overflow: hidden;
background: #f5f5f5;
}
</style>

View File

@ -0,0 +1,136 @@
<template>
<a-layout-sider
class="side-nav"
:width="240"
theme="dark"
:trigger="null"
>
<div class="side-nav__logo">
<h1 class="side-nav__title">Fischer AgentKit</h1>
</div>
<a-menu
v-model:selectedKeys="selectedKeys"
theme="dark"
mode="inline"
@click="handleMenuClick"
>
<a-menu-item key="/">
<template #icon><MessageOutlined /></template>
<span>智能对话</span>
</a-menu-item>
<a-menu-item key="/workflow">
<template #icon><ApartmentOutlined /></template>
<span>工作流</span>
</a-menu-item>
<a-menu-item key="/knowledge">
<template #icon><BookOutlined /></template>
<span>知识库</span>
</a-menu-item>
<a-menu-item key="/skills">
<template #icon><AppstoreOutlined /></template>
<span>技能</span>
</a-menu-item>
<a-menu-item key="/terminal">
<template #icon><CodeOutlined /></template>
<span>终端</span>
</a-menu-item>
<a-menu-item key="/computer-use">
<template #icon><DesktopOutlined /></template>
<span>Computer Use</span>
</a-menu-item>
<a-menu-item key="/evolution">
<template #icon><RiseOutlined /></template>
<span>自进化</span>
</a-menu-item>
<a-menu-divider />
<a-menu-item key="/settings">
<template #icon><SettingOutlined /></template>
<span>设置</span>
</a-menu-item>
</a-menu>
<div class="side-nav__footer">
<a-badge
:status="wsConnected ? 'success' : 'error'"
:text="wsConnected ? '已连接' : '未连接'"
/>
</div>
</a-layout-sider>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { LayoutSider as ALayoutSider, Menu as AMenu, MenuItem as AMenuItem, MenuDivider as AMenuDivider, Badge as ABadge } from 'ant-design-vue'
import {
MessageOutlined,
ApartmentOutlined,
BookOutlined,
AppstoreOutlined,
CodeOutlined,
DesktopOutlined,
RiseOutlined,
SettingOutlined,
} from '@ant-design/icons-vue'
import { useChatStore } from '@/stores/chat'
const router = useRouter()
const route = useRoute()
const chatStore = useChatStore()
const selectedKeys = ref<string[]>([route.path])
const wsConnected = computed(() => chatStore.isWsConnected)
watch(
() => route.path,
(path) => {
selectedKeys.value = [path]
}
)
function handleMenuClick({ key }: { key: string }): void {
router.push(key)
}
</script>
<style scoped>
.side-nav {
height: 100vh;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.side-nav :deep(.ant-layout-sider-children) {
display: flex;
flex-direction: column;
height: 100%;
}
.side-nav__logo {
height: 64px;
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.side-nav__title {
color: #ffffff;
font-size: 18px;
font-weight: 600;
margin: 0;
white-space: nowrap;
}
.side-nav__footer {
margin-top: auto;
padding: 16px 24px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.side-nav__footer :deep(.ant-badge-status-text) {
color: rgba(255, 255, 255, 0.65);
font-size: 12px;
}
</style>

View File

@ -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')

View File

@ -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

View File

@ -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<ICapabilityInfo[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
// --- Getters ---
const enabledCapabilities = computed<ICapabilityInfo[]>(() => {
return capabilities.value.filter((c) => c.enabled)
})
const capabilityNames = computed<string[]>(() => {
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<void> {
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,
}
})

View File

@ -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<IConversation[]>([])
const currentConversationId = ref<string | null>(null)
const isLoading = ref(false)
const isWsConnected = ref(false)
const ws = ref<WebSocket | null>(null)
const streamingSteps = ref<string[]>([])
// --- Getters ---
const currentConversation = computed<IConversation | undefined>(() => {
return conversations.value.find((c) => c.id === currentConversationId.value)
})
const currentMessages = computed<IChatMessage[]>(() => {
return currentConversation.value?.messages ?? []
})
// --- Actions ---
/** Load all conversations from the server */
async function loadConversations(): Promise<void> {
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<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)
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<IChatMessage>
): 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,
}
})

View File

@ -0,0 +1,185 @@
<template>
<div class="chat-view">
<ChatSidebar
:conversations="chatStore.conversations"
:current-id="chatStore.currentConversationId"
@create="chatStore.createConversation"
@select="chatStore.selectConversation"
/>
<div class="chat-view__main">
<div v-if="!chatStore.currentConversationId" class="chat-view__empty">
<a-empty description="选择一个对话或创建新对话开始聊天">
<a-button type="primary" @click="chatStore.createConversation">
<template #icon><PlusOutlined /></template>
新建对话
</a-button>
</a-empty>
</div>
<template v-else>
<div class="chat-view__messages" ref="messagesContainer">
<div v-if="chatStore.currentMessages.length === 0" class="chat-view__welcome">
<RobotOutlined class="chat-view__welcome-icon" />
<h2>Fischer AgentKit</h2>
<p>企业级 AI 智能体平台输入消息开始对话</p>
</div>
<ChatMessage
v-for="msg in chatStore.currentMessages"
:key="msg.id"
:message="msg"
/>
<!-- Streaming steps -->
<div v-if="chatStore.streamingSteps.length > 0" class="chat-view__steps">
<a-typography-text type="secondary">
<LoadingOutlined /> 处理中...
</a-typography-text>
<div v-for="(step, idx) in chatStore.streamingSteps" :key="idx" class="chat-view__step">
<RightOutlined class="chat-view__step-icon" />
<span>{{ step }}</span>
</div>
</div>
</div>
<ChatInput
:disabled="chatStore.isLoading"
@send="handleSend"
/>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { Empty as AEmpty, Button as AButton, Typography as ATypography } from 'ant-design-vue'
import {
PlusOutlined,
RobotOutlined,
LoadingOutlined,
RightOutlined,
} from '@ant-design/icons-vue'
import { useChatStore } from '@/stores/chat'
import ChatSidebar from '@/components/chat/ChatSidebar.vue'
import ChatMessage from '@/components/chat/ChatMessage.vue'
import ChatInput from '@/components/chat/ChatInput.vue'
const ATypographyText = ATypography.Text
const chatStore = useChatStore()
const messagesContainer = ref<HTMLElement | null>(null)
onMounted(() => {
chatStore.loadConversations()
chatStore.connectWebSocket()
})
onUnmounted(() => {
chatStore.disconnectWebSocket()
})
// Auto-scroll to bottom when new messages arrive
watch(
() => chatStore.currentMessages.length,
async () => {
await nextTick()
scrollToBottom()
}
)
watch(
() => chatStore.streamingSteps.length,
async () => {
await nextTick()
scrollToBottom()
}
)
function scrollToBottom(): void {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
}
function handleSend(message: string): void {
if (chatStore.isWsConnected) {
chatStore.sendWsMessage(message)
} else {
chatStore.sendMessage(message)
}
}
</script>
<style scoped>
.chat-view {
display: flex;
height: 100%;
overflow: hidden;
}
.chat-view__main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: #f5f5f5;
}
.chat-view__empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.chat-view__messages {
flex: 1;
overflow-y: auto;
padding: 16px 0;
}
.chat-view__welcome {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #999999;
}
.chat-view__welcome-icon {
font-size: 48px;
color: #1677ff;
margin-bottom: 16px;
}
.chat-view__welcome h2 {
color: #333333;
font-size: 24px;
margin-bottom: 8px;
}
.chat-view__welcome p {
font-size: 14px;
color: #999999;
}
.chat-view__steps {
padding: 8px 16px;
margin: 0 16px;
background: #ffffff;
border-radius: 8px;
border: 1px solid #f0f0f0;
}
.chat-view__step {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 0;
font-size: 13px;
color: #666666;
}
.chat-view__step-icon {
font-size: 10px;
color: #1677ff;
}
</style>

View File

@ -0,0 +1,28 @@
<template>
<div class="placeholder-view">
<a-result
:icon="h(DesktopOutlined)"
title="Computer Use"
sub-title="计算机视觉与操作,即将上线"
>
<template #extra>
<a-button type="primary" disabled>即将推出</a-button>
</template>
</a-result>
</div>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { Result as AResult, Button as AButton } from 'ant-design-vue'
import { DesktopOutlined } from '@ant-design/icons-vue'
</script>
<style scoped>
.placeholder-view {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
</style>

View File

@ -0,0 +1,28 @@
<template>
<div class="placeholder-view">
<a-result
:icon="h(RiseOutlined)"
title="自进化"
sub-title="智能体自进化与优化,即将上线"
>
<template #extra>
<a-button type="primary" disabled>即将推出</a-button>
</template>
</a-result>
</div>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { Result as AResult, Button as AButton } from 'ant-design-vue'
import { RiseOutlined } from '@ant-design/icons-vue'
</script>
<style scoped>
.placeholder-view {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
</style>

View File

@ -0,0 +1,28 @@
<template>
<div class="placeholder-view">
<a-result
:icon="h(BookOutlined)"
title="知识库"
sub-title="知识库管理与检索,即将上线"
>
<template #extra>
<a-button type="primary" disabled>即将推出</a-button>
</template>
</a-result>
</div>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { Result as AResult, Button as AButton } from 'ant-design-vue'
import { BookOutlined } from '@ant-design/icons-vue'
</script>
<style scoped>
.placeholder-view {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
</style>

View File

@ -0,0 +1,28 @@
<template>
<div class="placeholder-view">
<a-result
:icon="h(SettingOutlined)"
title="设置"
sub-title="系统配置与偏好设置,即将上线"
>
<template #extra>
<a-button type="primary" disabled>即将推出</a-button>
</template>
</a-result>
</div>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { Result as AResult, Button as AButton } from 'ant-design-vue'
import { SettingOutlined } from '@ant-design/icons-vue'
</script>
<style scoped>
.placeholder-view {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
</style>

View File

@ -0,0 +1,28 @@
<template>
<div class="placeholder-view">
<a-result
:icon="h(AppstoreOutlined)"
title="技能"
sub-title="技能注册与管理,即将上线"
>
<template #extra>
<a-button type="primary" disabled>即将推出</a-button>
</template>
</a-result>
</div>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { Result as AResult, Button as AButton } from 'ant-design-vue'
import { AppstoreOutlined } from '@ant-design/icons-vue'
</script>
<style scoped>
.placeholder-view {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
</style>

View File

@ -0,0 +1,28 @@
<template>
<div class="placeholder-view">
<a-result
:icon="h(CodeOutlined)"
title="终端"
sub-title="远程终端与命令执行,即将上线"
>
<template #extra>
<a-button type="primary" disabled>即将推出</a-button>
</template>
</a-result>
</div>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { Result as AResult, Button as AButton } from 'ant-design-vue'
import { CodeOutlined } from '@ant-design/icons-vue'
</script>
<style scoped>
.placeholder-view {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
</style>

View File

@ -0,0 +1,28 @@
<template>
<div class="placeholder-view">
<a-result
:icon="h(ApartmentOutlined)"
title="工作流"
sub-title="可视化工作流编排与管理,即将上线"
>
<template #extra>
<a-button type="primary" disabled>即将推出</a-button>
</template>
</a-result>
</div>
</template>
<script setup lang="ts">
import { h } from 'vue'
import { Result as AResult, Button as AButton } from 'ant-design-vue'
import { ApartmentOutlined } from '@ant-design/icons-vue'
</script>
<style scoped>
.placeholder-view {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
</style>

View File

@ -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" }]
}

View File

@ -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"]
}

View File

@ -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,
},
},
},
})

View File

@ -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"]

View File

@ -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

View File

@ -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"]