This commit is contained in:
ViperEkura 2026-04-15 22:09:20 +08:00
parent 8089d94e78
commit 69b11ea7d4
9 changed files with 1631 additions and 4 deletions

View File

@ -32,6 +32,10 @@ const navItems = [
path: '/conversations', path: '/conversations',
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>` icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>`
}, },
{
path: '/agents',
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><line x1="9" y1="9" x2="15" y2="9"></line><line x1="9" y1="13" x2="15" y2="13"></line><line x1="9" y1="17" x2="12" y2="17"></line></svg>`
},
{ {
path: '/tools', path: '/tools',
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path></svg>` icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path></svg>`

View File

@ -32,6 +32,18 @@ const routes = [
component: () => import('../views/ToolsView.vue'), component: () => import('../views/ToolsView.vue'),
meta: { requiresAuth: true } meta: { requiresAuth: true }
}, },
{
path: '/agents',
name: 'Agents',
component: () => import('../views/AgentView.vue'),
meta: { requiresAuth: true }
},
{
path: '/agents/:taskId',
name: 'AgentTask',
component: () => import('../views/AgentView.vue'),
meta: { requiresAuth: true }
},
// 首页重定向 // 首页重定向
{ {
path: '/home', path: '/home',

View File

@ -0,0 +1,381 @@
/**
* AgentWs - WebSocket client for agent real-time communication
*
* 功能
* 1. 连接 WebSocket 订阅 DAG 进度
* 2. 处理服务端推送的事件
* 3. 支持心跳检测和自动重连
*/
import { ref, reactive } from 'vue'
class AgentWsClient {
constructor() {
// WebSocket 连接
this.ws = null
// 当前订阅的任务 ID
this.taskId = null
// 连接状态
this.connected = ref(false)
// 状态
this.status = ref('disconnected')
// DAG 数据
this.dagData = reactive({
id: null,
name: '',
description: '',
nodes: [],
edges: [],
progress: 0,
completed_count: 0,
total_count: 0
})
// 节点状态映射
this.nodeStatus = reactive({})
// 事件回调
this.callbacks = {}
// 重连配置
this.reconnectAttempts = 0
this.maxReconnectAttempts = 5
this.reconnectDelay = 3000
// 心跳
this.pingInterval = null
this.pingIntervalTime = 25000
// 订阅确认
this.subscribed = ref(false)
}
/**
* 连接到 WebSocket 并订阅任务
* @param {string} taskId - 任务 ID
*/
connect(taskId) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
if (this.taskId === taskId) {
return // 已连接到此任务
}
this.disconnect()
}
this.taskId = taskId
this.status.value = 'connecting'
// 构建 WebSocket URL
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host
const wsUrl = `${protocol}//${host}/api/ws/dag/${taskId}`
try {
this.ws = new WebSocket(wsUrl)
this._setupEventHandlers()
} catch (e) {
console.error('WebSocket connection error:', e)
this.status.value = 'error'
}
}
/**
* 设置 WebSocket 事件处理器
*/
_setupEventHandlers() {
this.ws.onopen = () => {
console.log('WebSocket connected')
this.connected.value = true
this.status.value = 'connected'
this.reconnectAttempts = 0
// 发送订阅消息
this.send({ type: 'subscribe' })
}
this.ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data)
this._handleMessage(message)
} catch (e) {
console.error('Failed to parse WebSocket message:', e)
}
}
this.ws.onerror = (error) => {
console.error('WebSocket error:', error)
this.status.value = 'error'
}
this.ws.onclose = () => {
console.log('WebSocket closed')
this.connected.value = false
this.status.value = 'disconnected'
this.subscribed.value = false
this._clearPingInterval()
// 尝试重连
if (this.reconnectAttempts < this.maxReconnectAttempts && this.taskId) {
this.reconnectAttempts++
console.log(`Reconnecting... attempt ${this.reconnectAttempts}`)
setTimeout(() => {
if (this.taskId) {
this.connect(this.taskId)
}
}, this.reconnectDelay)
}
}
}
/**
* 处理收到的消息
*/
_handleMessage(message) {
const type = message.type
const data = message.data || {}
switch (type) {
case 'subscribed':
console.log('Subscribed to task:', message.task_id)
this.subscribed.value = true
this.status.value = 'subscribed'
this._triggerCallback('subscribed', message)
break
case 'heartbeat':
// 心跳响应
break
case 'dag_start':
this._updateDagData(data.graph)
this._triggerCallback('dag_start', data)
break
case 'dag_progress':
this.dagData.progress = data.progress
this._triggerCallback('dag_progress', data)
break
case 'dag_status':
if (data) {
this._updateDagData(data)
}
this._triggerCallback('dag_status', data)
break
case 'node_start':
this._updateNodeStatus(data.node_id, 'running', data)
this._triggerCallback('node_start', data)
break
case 'node_progress':
this._updateNodeProgress(data.node_id, data.progress, data.message)
this._triggerCallback('node_progress', data)
break
case 'node_complete':
this._updateNodeStatus(data.node_id, 'completed', data)
this._updateNodeResult(data.node_id, data)
this._triggerCallback('node_complete', data)
break
case 'node_error':
this._updateNodeStatus(data.node_id, 'failed', { error: data.error })
this._triggerCallback('node_error', data)
break
case 'dag_complete':
this._triggerCallback('dag_complete', data)
break
case 'task_cancelled':
this._triggerCallback('task_cancelled', data)
break
case 'pong':
// 心跳响应
break
case 'error':
console.error('Server error:', message.message)
this._triggerCallback('error', message)
break
}
}
/**
* 更新 DAG 数据
*/
_updateDagData(graph) {
if (!graph) return
this.dagData.id = graph.id
this.dagData.name = graph.name
this.dagData.description = graph.description
this.dagData.nodes = graph.nodes || []
this.dagData.edges = graph.edges || []
this.dagData.progress = graph.progress || 0
this.dagData.completed_count = graph.completed_count || 0
this.dagData.total_count = graph.total_count || 0
// 初始化节点状态
for (const node of this.dagData.nodes) {
if (!this.nodeStatus[node.id]) {
this.nodeStatus[node.id] = reactive({
status: node.status,
progress: node.progress || 0,
message: node.progress_message || '',
result: null,
error: null
})
}
}
}
/**
* 更新节点状态
*/
_updateNodeStatus(nodeId, status, data) {
if (!this.nodeStatus[nodeId]) {
this.nodeStatus[nodeId] = reactive({})
}
this.nodeStatus[nodeId].status = status
this.nodeStatus[nodeId].data = data
// 更新 DAG 中的节点
const node = this.dagData.nodes.find(n => n.id === nodeId)
if (node) {
node.status = status
}
}
/**
* 更新节点进度
*/
_updateNodeProgress(nodeId, progress, message) {
if (this.nodeStatus[nodeId]) {
this.nodeStatus[nodeId].progress = progress
if (message) {
this.nodeStatus[nodeId].message = message
}
}
}
/**
* 更新节点结果
*/
_updateNodeResult(nodeId, data) {
if (this.nodeStatus[nodeId]) {
this.nodeStatus[nodeId].result = data.result
this.nodeStatus[nodeId].output_data = data.output_data
}
}
/**
* 发送消息到服务器
*/
send(message) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message))
}
}
/**
* 获取状态
*/
getStatus() {
this.send({ type: 'get_status' })
}
/**
* 发送 ping
*/
ping() {
this.send({ type: 'ping' })
}
/**
* 取消任务
*/
cancelTask() {
this.send({ type: 'cancel_task' })
}
/**
* 断开连接
*/
disconnect() {
this._clearPingInterval()
if (this.ws) {
this.ws.close()
this.ws = null
}
this.taskId = null
this.connected.value = false
this.subscribed.value = false
this.status.value = 'disconnected'
}
/**
* 设置心跳间隔
*/
_startPingInterval() {
this._clearPingInterval()
this.pingInterval = setInterval(() => {
this.ping()
}, this.pingIntervalTime)
}
/**
* 清除心跳间隔
*/
_clearPingInterval() {
if (this.pingInterval) {
clearInterval(this.pingInterval)
this.pingInterval = null
}
}
/**
* 注册回调
*/
on(event, callback) {
if (!this.callbacks[event]) {
this.callbacks[event] = []
}
this.callbacks[event].push(callback)
}
/**
* 移除回调
*/
off(event, callback) {
if (this.callbacks[event]) {
const index = this.callbacks[event].indexOf(callback)
if (index > -1) {
this.callbacks[event].splice(index, 1)
}
}
}
/**
* 触发回调
*/
_triggerCallback(event, data) {
if (this.callbacks[event]) {
this.callbacks[event].forEach(cb => cb(data))
}
}
/**
* 获取节点状态
*/
getNodeStatus(nodeId) {
return this.nodeStatus[nodeId]
}
/**
* 获取所有节点状态
*/
getAllNodeStatus() {
return this.nodeStatus
}
}
// 导出单例
export const agentWs = new AgentWsClient()
export default agentWs

View File

@ -210,4 +210,19 @@ export const providersAPI = {
test: (id) => api.post(`/providers/${id}/test`) test: (id) => api.post(`/providers/${id}/test`)
} }
// ============ Agent 接口 ============
export const agentsAPI = {
// 创建 Agent 任务
create: (data) => api.post('/agents/request', data),
// 获取任务状态
getTask: (taskId) => api.get(`/agents/task/${taskId}`),
// 取消任务
cancelTask: (taskId) => api.post(`/agents/task/${taskId}/cancel`),
// 列出用户的任务
listTasks: (params) => api.get('/agents/tasks', { params }),
// 删除任务
deleteTask: (taskId) => api.delete(`/agents/task/${taskId}`)
}
export default api export default api

View File

@ -0,0 +1,910 @@
<template>
<div class="page-container agent-view">
<!-- 任务创建界面 taskId 时显示 -->
<div v-if="!activeTaskId" class="task-create-section">
<h1>创建 Agent 任务</h1>
<!-- 对话选择 -->
<div class="form-group">
<label>选择对话</label>
<div class="conversation-selector">
<select v-model="selectedConversationId" class="select-input">
<option value="">-- 选择现有对话 --</option>
<option v-for="conv in conversations" :key="conv.id" :value="conv.id">
{{ conv.title || conv.first_message || '无标题对话' }}
</option>
</select>
<button @click="showNewConversation = true" class="btn-secondary">
新建对话
</button>
</div>
</div>
<!-- 新建对话表单 -->
<div v-if="showNewConversation" class="new-conversation-form">
<input
v-model="newConversationTitle"
placeholder="对话标题(可选)"
class="text-input"
/>
<button @click="createConversation" class="btn-primary">创建</button>
<button @click="showNewConversation = false" class="btn-secondary">取消</button>
</div>
<!-- 任务输入 -->
<div class="form-group">
<label>任务描述</label>
<textarea
v-model="taskInput"
placeholder="描述你要完成的任务..."
class="textarea-input"
rows="4"
></textarea>
</div>
<!-- 执行选项 -->
<div class="form-group">
<label>执行选项</label>
<div class="options-row">
<label class="checkbox-label">
<input type="checkbox" v-model="options.thinking_enabled" />
启用思考模式
</label>
<label class="checkbox-label">
<input type="checkbox" v-model="options.auto_execute" />
自动执行
</label>
</div>
</div>
<!-- 开始按钮 -->
<button
@click="startTask"
class="btn-start"
:disabled="!canStartTask || isCreating"
>
{{ isCreating ? '创建中...' : '开始执行' }}
</button>
</div>
<!-- 任务执行界面 taskId 时显示 -->
<div v-else class="task-progress-section">
<div class="header">
<h1>Agent 执行进度</h1>
<div class="controls">
<button v-if="!agentWs.connected.value" @click="reconnect" class="btn-primary">
连接
</button>
<button v-else @click="cancelTask" class="btn-danger" :disabled="!canCancel">
取消任务
</button>
</div>
</div>
<!-- 连接状态 -->
<div class="status-bar" :class="statusClass">
<span class="status-indicator"></span>
<span class="status-text">{{ statusText }}</span>
</div>
<!-- DAG 概览 -->
<div class="dag-overview" v-if="agentWs.dagData.id">
<div class="dag-info">
<h2>{{ agentWs.dagData.name || '任务执行中' }}</h2>
<p class="dag-description">{{ agentWs.dagData.description }}</p>
</div>
<div class="progress-container">
<div class="progress-bar">
<div class="progress-fill" :style="{ width: `${agentWs.dagData.progress * 100}%` }"></div>
</div>
<div class="progress-text">
{{ Math.round(agentWs.dagData.progress * 100) }}%
({{ agentWs.dagData.completed_count }} / {{ agentWs.dagData.total_count }})
</div>
</div>
</div>
<!-- 节点列表 -->
<div class="nodes-section">
<h3>任务节点</h3>
<div class="nodes-list">
<div
v-for="node in agentWs.dagData.nodes"
:key="node.id"
class="node-card"
:class="getNodeStatusClass(node.id)"
>
<div class="node-header">
<span class="node-id">{{ node.id }}</span>
<span class="node-status-badge" :class="getNodeStatusClass(node.id)">
{{ getNodeStatusText(node.id) }}
</span>
</div>
<div class="node-name">{{ node.name }}</div>
<div class="node-description">{{ node.description }}</div>
<div class="node-progress" v-if="getNodeProgress(node.id) > 0">
<div class="mini-progress-bar">
<div class="mini-progress-fill"
:style="{ width: `${getNodeProgress(node.id) * 100}%` }">
</div>
</div>
<span class="node-progress-text">
{{ Math.round(getNodeProgress(node.id) * 100) }}%
</span>
</div>
<div class="node-message" v-if="getNodeMessage(node.id)">
{{ getNodeMessage(node.id) }}
</div>
<div class="node-error" v-if="getNodeError(node.id)">
错误: {{ getNodeError(node.id) }}
</div>
<div class="node-result" v-if="getNodeResult(node.id)">
<details>
<summary>查看结果</summary>
<pre>{{ formatResult(getNodeResult(node.id)) }}</pre>
</details>
</div>
</div>
</div>
</div>
<!-- 执行日志 -->
<div class="execution-log">
<h3>执行日志</h3>
<div class="log-entries">
<div
v-for="(log, index) in logs"
:key="index"
class="log-entry"
:class="log.type"
>
<span class="log-time">{{ log.time }}</span>
<span class="log-message">{{ log.message }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { agentWs } from '../utils/agentWs.js'
import { conversationsAPI, agentsAPI } from '../utils/api.js'
const route = useRoute()
const router = useRouter()
//
const activeTaskId = ref(null) // ID
const conversations = ref([])
const selectedConversationId = ref('')
const showNewConversation = ref(false)
const newConversationTitle = ref('')
const taskInput = ref('')
const isCreating = ref(false)
const options = ref({
thinking_enabled: false,
auto_execute: true
})
//
const logs = ref([])
// taskId
const taskIdFromRoute = computed(() => route.params.taskId || null)
//
const canStartTask = computed(() => {
// ID
return !!selectedConversationId.value && !!taskInput.value.trim()
})
const statusClass = computed(() => {
const status = agentWs.status.value
return {
'disconnected': status === 'disconnected',
'connecting': status === 'connecting',
'connected': status === 'connected' || status === 'subscribed',
'error': status === 'error'
}
})
const statusText = computed(() => {
switch (agentWs.status.value) {
case 'disconnected': return '未连接'
case 'connecting': return '连接中...'
case 'connected': return '已连接'
case 'subscribed': return '已订阅'
case 'error': return '错误'
default: return '未知状态'
}
})
const canCancel = computed(() => {
return agentWs.subscribed.value && agentWs.dagData.progress < 1
})
//
async function loadConversations() {
try {
const res = await conversationsAPI.list({ page: 1, page_size: 100 })
if (res.success) {
conversations.value = res.data?.items || []
}
} catch (e) {
console.error('Failed to load conversations:', e)
}
}
async function createConversation() {
try {
const res = await conversationsAPI.create({
title: newConversationTitle.value || undefined
})
if (res.success) {
selectedConversationId.value = res.data.id
conversations.value.unshift(res.data)
showNewConversation.value = false
newConversationTitle.value = ''
}
} catch (e) {
console.error('Failed to create conversation:', e)
}
}
async function startTask() {
console.log('startTask called', {
canStartTask: canStartTask.value,
selectedConversationId: selectedConversationId.value,
taskInput: taskInput.value
})
if (!canStartTask.value) {
console.log('canStartTask is false, not proceeding')
return
}
isCreating.value = true
try {
console.log('Calling agentsAPI.create...')
const res = await agentsAPI.create({
conversation_id: selectedConversationId.value,
task: taskInput.value,
options: options.value
})
console.log('agentsAPI.create response:', res)
if (res.success && res.data?.task_id) {
activeTaskId.value = res.data.task_id
// 使 router
router.push(`/agents/${res.data.task_id}`)
}
} catch (e) {
console.error('Failed to create task:', e)
addLog('error', `创建任务失败: ${e.message || e}`)
} finally {
isCreating.value = false
}
}
//
watch(taskIdFromRoute, (newId) => {
if (newId) {
activeTaskId.value = newId
}
}, { immediate: true })
//
function getNodeStatusClass(nodeId) {
const status = agentWs.nodeStatus[nodeId]
if (!status) return ''
return `status-${status.status}`
}
function getNodeStatusText(nodeId) {
const status = agentWs.nodeStatus[nodeId]
if (!status) return 'pending'
switch (status.status) {
case 'pending': return '等待中'
case 'ready': return '就绪'
case 'running': return '执行中'
case 'completed': return '已完成'
case 'failed': return '失败'
case 'cancelled': return '已取消'
case 'blocked': return '阻塞'
default: return status.status
}
}
function getNodeProgress(nodeId) {
return agentWs.nodeStatus[nodeId]?.progress || 0
}
function getNodeMessage(nodeId) {
return agentWs.nodeStatus[nodeId]?.message || ''
}
function getNodeError(nodeId) {
const status = agentWs.nodeStatus[nodeId]
if (status?.status === 'failed') {
return status.data?.error || '未知错误'
}
return null
}
function getNodeResult(nodeId) {
return agentWs.nodeStatus[nodeId]?.result
}
function formatResult(result) {
try {
return JSON.stringify(result, null, 2)
} catch {
return String(result)
}
}
//
function addLog(type, message) {
const now = new Date()
const time = now.toLocaleTimeString()
logs.value.push({ type, message, time })
//
if (logs.value.length > 100) {
logs.value = logs.value.slice(-100)
}
}
//
function reconnect() {
if (activeTaskId.value) {
agentWs.connect(activeTaskId.value)
}
}
function cancelTask() {
agentWs.cancelTask()
}
//
function handleDagStart(data) {
addLog('info', `DAG 开始执行: ${data.graph?.name || '未知任务'}`)
}
function handleNodeStart(data) {
addLog('info', `节点开始: ${data.node_id} - ${data.name}`)
}
function handleNodeProgress(data) {
//
}
function handleNodeComplete(data) {
addLog('success', `节点完成: ${data.node_id}`)
}
function handleNodeError(data) {
addLog('error', `节点错误: ${data.node_id} - ${data.error}`)
}
function handleDagComplete(data) {
if (data.success) {
addLog('success', '所有任务执行完成')
} else {
addLog('error', '任务执行失败')
}
}
function handleSubscribed(data) {
addLog('info', `已订阅任务: ${data.task_id}`)
}
function handleError(data) {
addLog('error', `错误: ${data.message}`)
}
//
onMounted(async () => {
//
await loadConversations()
//
agentWs.on('subscribed', handleSubscribed)
agentWs.on('dag_start', handleDagStart)
agentWs.on('node_start', handleNodeStart)
agentWs.on('node_progress', handleNodeProgress)
agentWs.on('node_complete', handleNodeComplete)
agentWs.on('node_error', handleNodeError)
agentWs.on('dag_complete', handleDagComplete)
agentWs.on('error', handleError)
//
if (activeTaskId.value) {
agentWs.connect(activeTaskId.value)
}
})
onUnmounted(() => {
//
agentWs.off('subscribed', handleSubscribed)
agentWs.off('dag_start', handleDagStart)
agentWs.off('node_start', handleNodeStart)
agentWs.off('node_progress', handleNodeProgress)
agentWs.off('node_complete', handleNodeComplete)
agentWs.off('node_error', handleNodeError)
agentWs.off('dag_complete', handleDagComplete)
agentWs.off('error', handleError)
//
agentWs.disconnect()
})
</script>
<style scoped>
.agent-view {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}
/* 任务创建界面样式 */
.task-create-section {
max-width: 600px;
margin: 0 auto;
}
.task-create-section h1 {
font-size: 1.75rem;
font-weight: 600;
margin-bottom: 2rem;
text-align: center;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
font-weight: 500;
margin-bottom: 0.5rem;
color: var(--text-primary);
}
.conversation-selector {
display: flex;
gap: 0.5rem;
}
.select-input {
flex: 1;
padding: 0.5rem;
border: 1px solid var(--border-light);
border-radius: 6px;
font-size: 0.9rem;
background: var(--bg-primary);
color: var(--text-primary);
}
.text-input {
flex: 1;
padding: 0.5rem;
border: 1px solid var(--border-light);
border-radius: 6px;
font-size: 0.9rem;
background: var(--bg-primary);
color: var(--text-primary);
}
.textarea-input {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border-light);
border-radius: 6px;
font-size: 0.9rem;
background: var(--bg-primary);
color: var(--text-primary);
resize: vertical;
font-family: inherit;
}
.options-row {
display: flex;
gap: 1.5rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: normal;
cursor: pointer;
}
.checkbox-label input {
cursor: pointer;
}
.btn-start {
width: 100%;
padding: 0.75rem;
background: var(--accent-primary);
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.btn-start:hover:not(:disabled) {
background: var(--accent-primary-hover);
}
.btn-start:disabled {
background: #ccc;
cursor: not-allowed;
}
.new-conversation-form {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
padding: 1rem;
background: var(--bg-primary);
border: 1px solid var(--border-light);
border-radius: 8px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.header h1 {
font-size: 1.75rem;
font-weight: 600;
}
.controls {
display: flex;
gap: 1rem;
}
.btn-primary {
padding: 0.5rem 1.25rem;
background: var(--accent-primary);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
}
.btn-primary:hover {
opacity: 0.9;
}
.btn-secondary {
padding: 0.5rem 1rem;
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-light);
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.15s;
}
.btn-secondary:hover {
background: var(--bg-hover);
border-color: var(--border-medium);
}
.btn-danger {
padding: 0.5rem 1.25rem;
background: #dc2626;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
}
.btn-danger:disabled {
background: #ccc;
cursor: not-allowed;
}
.status-bar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-radius: 8px;
margin-bottom: 1.5rem;
}
.status-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
}
.status-bar.disconnected {
background: #f3f4f6;
color: #6b7280;
}
.status-bar.disconnected .status-indicator {
background: #9ca3af;
}
.status-bar.connecting {
background: #fef3c7;
color: #92400e;
}
.status-bar.connecting .status-indicator {
background: #f59e0b;
}
.status-bar.connected {
background: #d1fae5;
color: #065f46;
}
.status-bar.connected .status-indicator {
background: #10b981;
}
.status-bar.error {
background: #fee2e2;
color: #991b1b;
}
.status-bar.error .status-indicator {
background: #ef4444;
}
.dag-overview {
background: var(--bg-primary);
border: 1px solid var(--border-light);
border-radius: 12px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.dag-info h2 {
font-size: 1.25rem;
margin-bottom: 0.5rem;
}
.dag-description {
color: var(--text-secondary);
font-size: 0.9rem;
}
.progress-container {
margin-top: 1rem;
}
.progress-bar {
height: 8px;
background: var(--border-light);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--accent-primary);
transition: width 0.3s ease;
}
.progress-text {
margin-top: 0.5rem;
font-size: 0.85rem;
color: var(--text-secondary);
}
.nodes-section {
margin-bottom: 1.5rem;
}
.nodes-section h3 {
font-size: 1.1rem;
margin-bottom: 1rem;
}
.nodes-list {
display: grid;
gap: 1rem;
}
.node-card {
background: var(--bg-primary);
border: 1px solid var(--border-light);
border-radius: 8px;
padding: 1rem;
}
.node-card.status-running {
border-color: var(--accent-primary);
box-shadow: 0 0 0 1px var(--accent-primary);
}
.node-card.status-completed {
border-color: #10b981;
}
.node-card.status-failed {
border-color: #ef4444;
}
.node-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.node-id {
font-family: monospace;
font-size: 0.8rem;
color: var(--text-secondary);
}
.node-status-badge {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
background: #e5e7eb;
color: #374151;
}
.node-status-badge.status-running {
background: #dbeafe;
color: #1e40af;
}
.node-status-badge.status-completed {
background: #d1fae5;
color: #065f46;
}
.node-status-badge.status-failed {
background: #fee2e2;
color: #991b1b;
}
.node-name {
font-weight: 500;
margin-bottom: 0.25rem;
}
.node-description {
font-size: 0.85rem;
color: var(--text-secondary);
}
.node-progress {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
}
.mini-progress-bar {
flex: 1;
height: 4px;
background: var(--border-light);
border-radius: 2px;
overflow: hidden;
}
.mini-progress-fill {
height: 100%;
background: var(--accent-primary);
}
.node-progress-text {
font-size: 0.75rem;
color: var(--text-secondary);
}
.node-message {
margin-top: 0.5rem;
font-size: 0.85rem;
color: var(--accent-primary);
}
.node-error {
margin-top: 0.5rem;
font-size: 0.85rem;
color: #dc2626;
}
.node-result {
margin-top: 0.75rem;
font-size: 0.85rem;
}
.node-result details {
background: #f9fafb;
border-radius: 4px;
padding: 0.5rem;
}
.node-result summary {
cursor: pointer;
color: var(--text-secondary);
}
.node-result pre {
margin-top: 0.5rem;
white-space: pre-wrap;
font-size: 0.8rem;
}
.execution-log h3 {
font-size: 1.1rem;
margin-bottom: 1rem;
}
.log-entries {
background: var(--bg-primary);
border: 1px solid var(--border-light);
border-radius: 8px;
max-height: 300px;
overflow-y: auto;
font-family: monospace;
font-size: 0.85rem;
}
.log-entry {
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--border-light);
display: flex;
gap: 1rem;
}
.log-entry:last-child {
border-bottom: none;
}
.log-entry.info {
color: var(--text-primary);
}
.log-entry.success {
color: #065f46;
background: #d1fae5;
}
.log-entry.error {
color: #991b1b;
background: #fee2e2;
}
.log-time {
color: var(--text-secondary);
flex-shrink: 0;
}
.log-message {
flex: 1;
}
</style>

View File

@ -111,18 +111,21 @@ Guidelines:
# Call LLM # Call LLM
try: try:
logger.info(f"Calling LLM with model: {self.agent.config.model}, messages count: {len(messages)}")
logger.info(f"LLM client - api_url: {self.llm_client.api_url}, api_key set: {bool(self.llm_client.api_key)}")
response = await self.llm_client.sync_call( response = await self.llm_client.sync_call(
model=self.agent.config.model, model=self.agent.config.model,
messages=messages, messages=messages,
temperature=self.agent.config.temperature, temperature=self.agent.config.temperature,
max_tokens=self.agent.config.max_tokens max_tokens=self.agent.config.max_tokens
) )
logger.info(f"LLM response received: {response}")
if progress_callback: if progress_callback:
progress_callback(0.5, "Processing decomposition...") progress_callback(0.5, "Processing decomposition...")
# Parse LLM response to extract DAG # Parse LLM response to extract DAG
dag = self._parse_dag_from_response(response.content, task) dag = self._parse_dag_from_response(response.content if response else "", task)
# Add assistant response to context # Add assistant response to context
self.agent.add_message("assistant", response.content) self.agent.add_message("assistant", response.content)

View File

@ -3,6 +3,7 @@ from fastapi import APIRouter
from luxx.routes import auth, conversations, messages, tools, providers from luxx.routes import auth, conversations, messages, tools, providers
from luxx.routes.agents_ws import router as agents_ws_router from luxx.routes.agents_ws import router as agents_ws_router
from luxx.routes.agents import router as agents_router
api_router = APIRouter() api_router = APIRouter()
@ -14,3 +15,4 @@ api_router.include_router(messages.router)
api_router.include_router(tools.router) api_router.include_router(tools.router)
api_router.include_router(providers.router) api_router.include_router(providers.router)
api_router.include_router(agents_ws_router) api_router.include_router(agents_ws_router)
api_router.include_router(agents_router)

295
luxx/routes/agents.py Normal file
View File

@ -0,0 +1,295 @@
"""Agent routes - REST API for agent task management"""
import asyncio
import logging
from typing import Optional
from fastapi import APIRouter, Depends, WebSocket
from pydantic import BaseModel
from sqlalchemy.orm import Session
from luxx.database import get_db
from luxx.models import User
from luxx.routes.auth import get_current_user
from luxx.utils.helpers import success_response, error_response, generate_id
from luxx.agents.core import AgentConfig, AgentType, AgentStatus
from luxx.agents.registry import AgentRegistry
from luxx.agents.supervisor import SupervisorAgent
from luxx.agents.worker import WorkerAgent
from luxx.agents.dag_scheduler import SchedulerPool, DAGScheduler
from luxx.services.llm_client import LLMClient
from luxx.tools.executor import ToolExecutor
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/agents", tags=["Agents"])
# ============ Request/Response Models ============
class AgentCreateRequest(BaseModel):
"""Create agent task request"""
conversation_id: str
task: str
options: Optional[dict] = None
class AgentTaskResponse(BaseModel):
"""Agent task response"""
task_id: str
status: str
conversation_id: str
# ============ 全局实例 ============
# Scheduler pool for managing concurrent DAG executions
scheduler_pool = SchedulerPool(max_concurrent=10)
# Tool executor
tool_executor = ToolExecutor()
# ============ Routes ============
@router.post("/request", response_model=dict)
async def create_agent_task(
data: AgentCreateRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""
Create a new agent task
This will:
1. Create a Supervisor agent
2. Decompose the task into a DAG
3. Start DAG execution
4. Return task_id for WebSocket subscription
"""
from luxx.config import config
from luxx.models import Conversation, LLMProvider
# Get conversation to find provider
conversation = db.query(Conversation).filter(
Conversation.id == data.conversation_id,
Conversation.user_id == current_user.id
).first()
if not conversation:
return error_response("Conversation not found", 404)
# Determine LLM client configuration
llm_api_key = None
llm_api_url = None
llm_model = None
logger.info(f"Conversation provider_id: {conversation.provider_id}")
if conversation.provider_id:
provider = db.query(LLMProvider).filter(LLMProvider.id == conversation.provider_id).first()
logger.info(f"Provider found: {provider}")
if provider:
llm_api_key = provider.api_key
llm_api_url = provider.base_url
llm_model = provider.default_model
logger.info(f"Provider config - api_key: {'set' if llm_api_key else 'None'}, url: {llm_api_url}, model: {llm_model}")
# Fallback to config if no provider
if not llm_api_key:
llm_api_key = config.llm_api_key
llm_api_url = config.llm_api_url
llm_model = "deepseek-chat"
# Check if LLM API key is configured
if not llm_api_key:
return error_response(
"LLM API key not configured. Please set up a provider in settings or set DEEPSEEK_API_KEY environment variable.",
500
)
task_id = generate_id("task")
try:
# Create LLM client with proper configuration
llm_client = LLMClient(
api_key=llm_api_key,
api_url=llm_api_url,
model=llm_model
)
# Create supervisor agent
agent_registry = AgentRegistry()
supervisor_config = AgentConfig(
name=f"supervisor_{task_id}",
agent_type=AgentType.SUPERVISOR,
description=f"Supervisor for task {task_id}",
model=llm_model, # Use the model's default model
max_turns=10
)
supervisor_agent = agent_registry.create_agent(
config=supervisor_config,
user_id=current_user.id,
conversation_id=data.conversation_id
)
# Create supervisor instance
supervisor = SupervisorAgent(
agent=supervisor_agent,
llm_client=llm_client
)
# Decompose task into DAG
context = {
"user_id": current_user.id,
"username": current_user.username,
"conversation_id": data.conversation_id
}
dag = await supervisor.decompose_task(data.task, context)
# Create worker factory
def worker_factory():
# Create new LLM client for each worker with proper config
worker_llm_client = LLMClient(
api_key=llm_api_key,
api_url=llm_api_url,
model=llm_model
)
worker_config = AgentConfig(
name=f"worker_{task_id}",
agent_type=AgentType.WORKER,
description=f"Worker for task {task_id}",
model=llm_model,
max_turns=5
)
worker_agent = agent_registry.create_agent(
config=worker_config,
user_id=current_user.id,
conversation_id=data.conversation_id
)
return WorkerAgent(
agent=worker_agent,
llm_client=worker_llm_client,
tool_executor=tool_executor
)
# Create scheduler
scheduler = scheduler_pool.create_scheduler(
task_id=task_id,
dag=dag,
supervisor=supervisor,
worker_factory=worker_factory,
max_workers=3
)
# Start execution in background
asyncio.create_task(_execute_dag_background(
task_id=task_id,
scheduler=scheduler,
context=context,
task=data.task,
supervisor_agent=supervisor_agent
))
return success_response(data={
"task_id": task_id,
"status": "planning",
"conversation_id": data.conversation_id
}, message="Agent task created successfully")
except Exception as e:
logger.error(f"Failed to create agent task: {e}")
return error_response(f"Failed to create task: {str(e)}", 500)
async def _execute_dag_background(
task_id: str,
scheduler: DAGScheduler,
context: dict,
task: str,
supervisor_agent
):
"""Execute DAG in background and handle completion"""
try:
result = await scheduler.execute(context, task)
# Update supervisor status
supervisor_agent.status = AgentStatus.COMPLETED if result["success"] else AgentStatus.FAILED
# Emit completion event via WebSocket
from luxx.routes.agents_ws import emit_dag_complete, emit_node_complete
# Emit node complete events for each completed node
for node_id, node_result in result.get("results", {}).items():
if node_result.get("success"):
# Find the node in the DAG
node = scheduler.dag.nodes.get(node_id)
if node:
await emit_node_complete(task_id, node)
# Emit DAG complete event
await emit_dag_complete(task_id, result["success"], result)
except Exception as e:
logger.error(f"DAG execution failed for task {task_id}: {e}")
@router.get("/task/{task_id}", response_model=dict)
async def get_task_status(
task_id: str,
current_user: User = Depends(get_current_user)
):
"""Get task status and DAG info"""
scheduler = scheduler_pool.get(task_id)
if not scheduler:
return error_response("Task not found", 404)
return success_response(data={
"task_id": task_id,
"status": "executing",
"dag": scheduler.dag.to_dict(),
"progress": scheduler.dag.progress
})
@router.post("/task/{task_id}/cancel", response_model=dict)
async def cancel_task(
task_id: str,
current_user: User = Depends(get_current_user)
):
"""Cancel a running task"""
if scheduler_pool.cancel(task_id):
return success_response(message="Task cancelled")
return error_response("Task not found or already completed", 404)
@router.delete("/task/{task_id}", response_model=dict)
async def delete_task(
task_id: str,
current_user: User = Depends(get_current_user)
):
"""Delete a task"""
if scheduler_pool.remove(task_id):
return success_response(message="Task deleted")
return error_response("Task not found", 404)
@router.get("/tasks", response_model=dict)
async def list_tasks(
page: int = 1,
page_size: int = 20,
current_user: User = Depends(get_current_user)
):
"""List user's agent tasks"""
# Get active schedulers for this user
# Note: In a real implementation, you'd store task metadata in database
tasks = []
return success_response(data={
"items": tasks,
"total": len(tasks),
"page": page,
"page_size": page_size
})

View File

@ -95,10 +95,15 @@ class LLMClient:
tool_calls = None tool_calls = None
usage = None usage = None
if "choices" in data: logger.info(f"Parsing LLM response: {data}")
if "choices" in data and data["choices"]:
choice = data["choices"][0] choice = data["choices"][0]
content = choice.get("message", {}).get("content", "") message = choice.get("message") if choice else {}
tool_calls = choice.get("message", {}).get("tool_calls") content = message.get("content", "") if message else ""
tool_calls = message.get("tool_calls") if message else None
else:
logger.warning(f"No choices in LLM response: {data}")
if "usage" in data: if "usage" in data:
usage = data["usage"] usage = data["usage"]