feat: Agent 运行时、对话 API、作业助手与引擎修复及前端执行超时

- agent_runtime 模块与 agent_chat API,前端 AgentChat 视图与路由对接
- workflow_engine: code 节点命名空间与 json 引用修复
- llm_service: 工具调用 extra_body(如 DeepSeek)
- create_homework_manager_agent / _3 脚本与测试脚本扩展
- frontend: WORKFLOW_EXECUTION_HTTP_TIMEOUT_MS、AgentChatPreview/MainLayout 等
- 文档:架构说明与自主 Agent 改造完成情况

Made-with: Cursor
This commit is contained in:
renjianbo
2026-05-01 11:31:48 +08:00
parent 4366312946
commit 09467568ec
23 changed files with 2798 additions and 77 deletions

View File

@@ -39,6 +39,9 @@ const getApiBaseURL = () => {
return 'http://localhost:8037'
}
/** Agent/Celery + LLM 等长耗时接口的单次 HTTP 超时(与 run_agent_test_cases 默认 max_wait 300s 量级一致) */
export const WORKFLOW_EXECUTION_HTTP_TIMEOUT_MS = 300000
const api = axios.create({
baseURL: getApiBaseURL(),
timeout: 30000

View File

@@ -244,7 +244,7 @@ import {
Promotion,
Document
} from '@element-plus/icons-vue'
import api from '@/api'
import api, { WORKFLOW_EXECUTION_HTTP_TIMEOUT_MS } from '@/api'
import { useUserStore } from '@/stores/user'
interface Message {
@@ -404,6 +404,14 @@ function getPreviewContextUserId(agentId: string): string {
return getPreviewSessionUserId(agentId)
}
/** 本轮发送前已有对话(不含即将追加的当前用户句),供后端注入 LLM 上下文 */
function conversationHistoryPayloadBeforeNewUserTurn(): Array<{ role: string; content: string }> {
return messages.value.map((m) => ({
role: m.role === 'user' ? 'user' : 'assistant',
content: typeof m.content === 'string' ? m.content : String(m.content ?? '')
}))
}
/** 未登录时的浏览器会话 id见 agent记忆实现方案.md */
function getPreviewSessionUserId(agentId: string): string {
const key = `agent_preview_uid_${agentId}`
@@ -846,6 +854,8 @@ const handleSendMessage = async () => {
userBubble = head
}
const conversation_history = conversationHistoryPayloadBeforeNewUserTurn()
// 添加用户消息
const userAttachments: MessageAttachment[] = attachSnap.map((a) => ({
relative_path: a.relative_path,
@@ -870,15 +880,21 @@ const handleSendMessage = async () => {
// 发送到Agent
loading.value = true
try {
const response = await api.post('/api/v1/executions', {
agent_id: props.agentId,
input_data: {
USER_INPUT: mergedForModel,
query: mergedForModel,
user_id: getPreviewContextUserId(props.agentId),
attachments: attachSnap
}
})
const response = await api.post(
'/api/v1/executions',
{
agent_id: props.agentId,
input_data: {
USER_INPUT: mergedForModel,
query: mergedForModel,
user_id: getPreviewContextUserId(props.agentId),
attachments: attachSnap,
conversation_history,
memory: { conversation_history }
}
},
{ timeout: WORKFLOW_EXECUTION_HTTP_TIMEOUT_MS }
)
const execution = response.data
blobsPendingRevokeAfterRun.value = attachSnap.length ? attachSnap : null
@@ -904,14 +920,18 @@ const handleSendMessage = async () => {
}
// 获取详细执行状态(包含节点执行信息)
const statusResponse = await api.get(`/api/v1/executions/${execution.id}/status`)
const statusResponse = await api.get(`/api/v1/executions/${execution.id}/status`, {
timeout: WORKFLOW_EXECUTION_HTTP_TIMEOUT_MS
})
const status = statusResponse.data
// 将执行状态传递给父组件,用于显示工作流动画
emit('execution-status', status)
// 获取执行详情(用于提取输出结果)
const execResponse = await api.get(`/api/v1/executions/${execution.id}`)
const execResponse = await api.get(`/api/v1/executions/${execution.id}`, {
timeout: WORKFLOW_EXECUTION_HTTP_TIMEOUT_MS
})
const exec = execResponse.data
if (exec.status === 'completed') {

View File

@@ -31,6 +31,10 @@
<el-icon><User /></el-icon>
<span>Agent管理</span>
</el-menu-item>
<el-menu-item index="agent-chat" @click="router.push('/agent-chat')">
<el-icon><ChatLineSquare /></el-icon>
<span>Agent对话</span>
</el-menu-item>
<el-menu-item index="executions">
<el-icon><List /></el-icon>
<span>执行历史</span>

View File

@@ -99,6 +99,18 @@ const router = createRouter({
name: 'node-templates',
component: () => import('@/views/NodeTemplates.vue'),
meta: { requiresAuth: true }
},
{
path: '/agent-chat',
name: 'agent-chat',
component: () => import('@/views/AgentChat.vue'),
meta: { requiresAuth: true }
},
{
path: '/agent-chat/:id',
name: 'agent-chat-with-agent',
component: () => import('@/views/AgentChat.vue'),
meta: { requiresAuth: true }
}
]
})

View File

@@ -3,7 +3,7 @@
*/
import { defineStore } from 'pinia'
import { ref } from 'vue'
import api from '@/api'
import api, { WORKFLOW_EXECUTION_HTTP_TIMEOUT_MS } from '@/api'
export interface Execution {
id: string
@@ -55,7 +55,9 @@ export const useExecutionStore = defineStore('execution', () => {
const fetchExecution = async (id: string) => {
loading.value = true
try {
const response = await api.get(`/api/v1/executions/${id}`)
const response = await api.get(`/api/v1/executions/${id}`, {
timeout: WORKFLOW_EXECUTION_HTTP_TIMEOUT_MS
})
currentExecution.value = response.data
return response.data
} finally {
@@ -71,7 +73,9 @@ export const useExecutionStore = defineStore('execution', () => {
}) => {
loading.value = true
try {
const response = await api.post('/api/v1/executions', data)
const response = await api.post('/api/v1/executions', data, {
timeout: WORKFLOW_EXECUTION_HTTP_TIMEOUT_MS
})
executions.value.unshift(response.data)
currentExecution.value = response.data
return response.data
@@ -83,7 +87,9 @@ export const useExecutionStore = defineStore('execution', () => {
// 获取执行状态
const fetchExecutionStatus = async (id: string) => {
try {
const response = await api.get(`/api/v1/executions/${id}/status`)
const response = await api.get(`/api/v1/executions/${id}/status`, {
timeout: WORKFLOW_EXECUTION_HTTP_TIMEOUT_MS
})
// 更新当前执行记录的状态
if (currentExecution.value?.id === id) {
currentExecution.value.status = response.data.status

View File

@@ -0,0 +1,453 @@
<template>
<div class="agent-chat-page">
<div class="chat-header">
<div class="header-left">
<h2>{{ agent ? agent.name : 'AI Agent 对话' }}</h2>
<span v-if="agent" class="agent-status" :class="agent.status">{{ agent.status }}</span>
</div>
<div class="header-actions">
<el-select
v-model="currentAgentId"
placeholder="选择 Agent"
@change="switchAgent"
style="width: 220px"
clearable
>
<el-option
v-for="a in agents"
:key="a.id"
:label="a.name"
:value="a.id"
>
<span>{{ a.name }}</span>
<span class="agent-option-desc">{{ a.description?.slice(0, 30) }}</span>
</el-option>
</el-select>
<el-button @click="clearChat" :disabled="messages.length === 0">
清空对话
</el-button>
</div>
</div>
<div class="chat-messages" ref="messagesRef">
<div v-if="messages.length === 0" class="chat-empty">
<el-icon :size="48"><ChatLineSquare /></el-icon>
<p>选择一个 Agent 开始对话</p>
<p class="hint">Agent 可以使用内置工具帮你完成任务</p>
</div>
<div
v-for="(msg, i) in messages"
:key="i"
class="message"
:class="[msg.role, msg.status === 'error' ? 'error' : '']"
>
<div class="message-avatar">
<el-avatar :size="36" :icon="msg.role === 'user' ? UserFilled : Promotion" />
</div>
<div class="message-bubble">
<div class="message-text" v-html="renderMarkdown(msg.content)"></div>
<div v-if="msg.tool_calls && msg.tool_calls.length > 0" class="tool-calls">
<div class="tool-calls-header">
<el-icon><Tools /></el-icon>
工具调用 ({{ msg.tool_calls.length }})
</div>
<div
v-for="(tc, j) in msg.tool_calls"
:key="j"
class="tool-call-item"
>
<span class="tool-name">{{ tc.function?.name || '?' }}</span>
<el-tag size="small" type="info">
{{ Object.keys(JSON.parse(tc.function?.arguments || '{}')).length }} 个参数
</el-tag>
</div>
</div>
<div class="message-meta">
{{ msg.role === 'user' ? '用户' : 'Agent' }} ·
{{ formatTime(msg.timestamp) }}
<span v-if="msg.iterations" class="meta-iterations">
· {{ msg.iterations }} · {{ msg.tool_calls_made }} 次工具调用
</span>
</div>
</div>
</div>
<div v-if="loading" class="message assistant">
<div class="message-avatar">
<el-avatar :size="36" icon="Promotion" />
</div>
<div class="message-bubble">
<div class="thinking">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</div>
</div>
</div>
</div>
<div class="chat-input">
<el-input
v-model="inputMessage"
type="textarea"
:rows="3"
placeholder="输入你的问题Agent 会自动使用工具来帮助你..."
@keydown.enter.exact.prevent="sendMessage"
:disabled="loading"
/>
<el-button
type="primary"
@click="sendMessage"
:loading="loading"
:disabled="!inputMessage.trim()"
class="send-btn"
>
{{ loading ? '思考中...' : '发送' }}
</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
ChatLineSquare,
UserFilled,
Promotion,
Tools,
} from '@element-plus/icons-vue'
import api from '@/api'
import type { Agent } from '@/stores/agent'
interface ChatMessage {
role: 'user' | 'assistant'
content: string
tool_calls?: any[]
timestamp: number
iterations?: number
tool_calls_made?: number
status?: string
}
const route = useRoute()
const agents = ref<Agent[]>([])
const currentAgentId = ref('')
const messages = ref<ChatMessage[]>([])
const inputMessage = ref('')
const loading = ref(false)
const messagesRef = ref<HTMLElement | null>(null)
const sessionId = ref('')
const agent = ref<Agent | null>(null)
onMounted(async () => {
await loadAgents()
if (route.params.id) {
currentAgentId.value = route.params.id as string
await switchAgent()
}
})
async function loadAgents() {
try {
const resp = await api.get('/api/v1/agents')
agents.value = resp.data || []
} catch (e) {
console.error('加载 Agent 列表失败:', e)
}
}
async function switchAgent() {
if (!currentAgentId.value) {
agent.value = null
return
}
try {
const resp = await api.get(`/api/v1/agents/${currentAgentId.value}`)
agent.value = resp.data
} catch (e: any) {
ElMessage.error('加载 Agent 失败')
agent.value = null
}
}
async function sendMessage() {
const text = inputMessage.value.trim()
if (!text || loading.value) return
messages.value.push({
role: 'user',
content: text,
timestamp: Date.now(),
})
inputMessage.value = ''
loading.value = true
scrollToBottom()
try {
const endpoint = currentAgentId.value
? `/api/v1/agent-chat/${currentAgentId.value}`
: '/api/v1/agent-chat/bare'
const resp = await api.post(endpoint, {
message: text,
session_id: sessionId.value || undefined,
})
const data = resp.data
sessionId.value = data.session_id
messages.value.push({
role: 'assistant',
content: data.content,
timestamp: Date.now(),
iterations: data.iterations_used,
tool_calls_made: data.tool_calls_made,
status: data.truncated ? 'error' : 'success',
})
} catch (e: any) {
messages.value.push({
role: 'assistant',
content: `错误:${e.response?.data?.detail || e.message || '请求失败'}`,
timestamp: Date.now(),
status: 'error',
})
} finally {
loading.value = false
scrollToBottom()
}
}
function clearChat() {
messages.value = []
sessionId.value = ''
}
function scrollToBottom() {
nextTick(() => {
if (messagesRef.value) {
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
}
})
}
function formatTime(ts: number) {
return new Date(ts).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
})
}
function renderMarkdown(text: string): string {
if (!text) return ''
// 简单的 Markdown 渲染(代码块、加粗、链接)
let html = text
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// 代码块
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code class="language-$1">$2</code></pre>')
// 行内代码
.replace(/`([^`]+)`/g, '<code>$1</code>')
// 加粗
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
// 换行
.replace(/\n/g, '<br>')
return html
}
</script>
<style scoped>
.agent-chat-page {
display: flex;
flex-direction: column;
height: calc(100vh - 120px);
max-width: 900px;
margin: 0 auto;
padding: 16px;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--el-border-color-light);
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
}
.header-left h2 {
margin: 0;
font-size: 18px;
}
.header-actions {
display: flex;
gap: 8px;
align-items: center;
}
.agent-status {
font-size: 12px;
padding: 2px 8px;
border-radius: 10px;
background: var(--el-color-info-light-8);
}
.agent-status.published { background: var(--el-color-success-light-8); color: var(--el-color-success); }
.agent-status.draft { background: var(--el-color-warning-light-8); color: var(--el-color-warning); }
.agent-option-desc {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-left: 8px;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 12px 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.chat-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--el-text-color-secondary);
gap: 12px;
}
.chat-empty .hint {
font-size: 13px;
color: var(--el-text-color-placeholder);
}
.message {
display: flex;
gap: 12px;
max-width: 85%;
}
.message.user { align-self: flex-end; flex-direction: row-reverse; }
.message.assistant { align-self: flex-start; }
.message-bubble {
padding: 10px 14px;
border-radius: 12px;
background: var(--el-fill-color-light);
line-height: 1.6;
font-size: 14px;
}
.message.user .message-bubble {
background: var(--el-color-primary-light-8);
}
.message.error .message-bubble {
border: 1px solid var(--el-color-danger-light-5);
}
.message-text :deep(pre) {
background: var(--el-fill-color);
padding: 12px;
border-radius: 8px;
overflow-x: auto;
font-size: 13px;
}
.message-text :deep(code) {
background: var(--el-fill-color);
padding: 2px 6px;
border-radius: 4px;
font-size: 13px;
}
.tool-calls {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed var(--el-border-color-light);
}
.tool-calls-header {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
margin-bottom: 4px;
}
.tool-call-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
font-size: 12px;
}
.tool-name {
font-weight: 500;
color: var(--el-color-primary);
}
.message-meta {
font-size: 11px;
color: var(--el-text-color-placeholder);
margin-top: 4px;
}
.meta-iterations {
color: var(--el-color-info);
}
.thinking {
display: flex;
gap: 4px;
padding: 8px 0;
}
.dot {
width: 8px;
height: 8px;
background: var(--el-text-color-placeholder);
border-radius: 50%;
animation: bounce 1.4s infinite ease-in-out;
}
.dot:nth-child(2) { animation-delay: 0.16s; }
.dot:nth-child(3) { animation-delay: 0.32s; }
@keyframes bounce {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
.chat-input {
display: flex;
gap: 12px;
padding-top: 12px;
border-top: 1px solid var(--el-border-color-light);
}
.chat-input .el-textarea {
flex: 1;
}
.send-btn {
align-self: flex-end;
min-width: 100px;
}
</style>

View File

@@ -123,7 +123,7 @@ import WorkflowEditor from '@/components/WorkflowEditor/WorkflowEditor.vue'
import AgentChatPreview from '@/components/AgentChatPreview.vue'
import { useWorkflowStore } from '@/stores/workflow'
import { useAgentStore } from '@/stores/agent'
import api from '@/api'
import api, { WORKFLOW_EXECUTION_HTTP_TIMEOUT_MS } from '@/api'
const route = useRoute()
const router = useRouter()
@@ -240,10 +240,14 @@ const handleRunTest = async () => {
}
// 调用执行API
const response = await api.post('/api/v1/executions', {
agent_id: agentId.value,
input_data: inputData
})
const response = await api.post(
'/api/v1/executions',
{
agent_id: agentId.value,
input_data: inputData
},
{ timeout: WORKFLOW_EXECUTION_HTTP_TIMEOUT_MS }
)
const execution = response.data
ElMessage.success('Agent执行已启动')
@@ -265,7 +269,9 @@ const handleRunTest = async () => {
}
// 获取执行状态和节点信息
const statusResponse = await api.get(`/api/v1/executions/${execution.id}/status`)
const statusResponse = await api.get(`/api/v1/executions/${execution.id}/status`, {
timeout: WORKFLOW_EXECUTION_HTTP_TIMEOUT_MS
})
const status = statusResponse.data
console.log('[rjb] Execution status response:', JSON.stringify(status, null, 2))
executionStatus.value = status
@@ -273,7 +279,9 @@ const handleRunTest = async () => {
// 同时获取执行详情(如果失败,使用状态信息)
let exec: any = null
try {
const execResponse = await api.get(`/api/v1/executions/${execution.id}`)
const execResponse = await api.get(`/api/v1/executions/${execution.id}`, {
timeout: WORKFLOW_EXECUTION_HTTP_TIMEOUT_MS
})
exec = execResponse.data
} catch (execError: any) {
// 如果获取执行详情失败,使用状态信息