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:
parent
901e4d9d0a
commit
a1deeecede
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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')
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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" }]
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
Loading…
Reference in New Issue