feat: Agent 批量测试、作业助手与上传预览;Windows 启动脚本与文档- 新增 run_agent_test_cases 与示例 JSON、(红头)agent测试用例文档
- 扩展 test_agent_execution(--homework、UTF-8 控制台) - 后端:uploads 预览、file_read、工作流与对话落盘等 - 前端:AgentChatPreview 与设计器相关调整 - 忽略 redis二进制、agent_workspaces、uploads、tessdata 等本机产物 Made-with: Cursor
This commit is contained in:
193
(红头)agent测试用例文档.md
Normal file
193
(红头)agent测试用例文档.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# Agent 通用测试用例规范
|
||||
|
||||
本文档约定**任意 Agent** 如何通过同一套接口做自动化黑盒测试。你只要按下面的 **JSON 用例文件格式** 写好(或从模板复制改名字/话术),即可用仓库根目录脚本**一次性跑完全部用例**。
|
||||
|
||||
---
|
||||
|
||||
## 1. 前置条件
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| 后端 API | 默认 `http://localhost:8037`,可用环境变量 `API_BASE_URL` 覆盖 |
|
||||
| 登录 | OAuth2 表单:`POST /api/v1/auth/login`,字段 `username`、`password` |
|
||||
| 执行 Agent | `POST /api/v1/executions`,body 含 `agent_id` 与 `input_data` |
|
||||
| 轮询 | `GET /api/v1/executions/{id}/status`;结束后再 `GET /api/v1/executions/{id}` 取 `output_data` |
|
||||
| 异步 | 需 **Redis** 与 **Celery Worker** 正常,否则创建执行可能返回 503 |
|
||||
|
||||
**输入字段约定(与《工作流调用测试总结》及前端预览一致):**
|
||||
|
||||
- 最小集:`query`、`USER_INPUT`(字符串相同即可),便于工作流提取 `user_query`。
|
||||
- 若某 Agent 在设计器里还依赖 `user_id`、`attachments` 等,可在用例的 `input_extra` 里追加(见第 3 节)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 一键跑用例(推荐)
|
||||
|
||||
1. 复制本文档第 5 节示例,或复制仓库内 **`agent_test_cases.example.json`** 为 **`agent_test_cases.json`**(或任意路径)。
|
||||
2. 在项目根目录执行:
|
||||
|
||||
```bash
|
||||
python run_agent_test_cases.py
|
||||
```
|
||||
|
||||
指定文件与账号:
|
||||
|
||||
```bash
|
||||
python run_agent_test_cases.py --cases my_cases.json --username admin --password 你的密码 --base-url http://127.0.0.1:8037
|
||||
```
|
||||
|
||||
环境变量(可选):
|
||||
|
||||
- `API_BASE_URL`:例如 `http://127.0.0.1:8037`
|
||||
- `AGENT_TEST_CASES`:默认用例文件路径(不设则使用 `agent_test_cases.json`)
|
||||
- Windows 控制台建议已使用 UTF-8(脚本内会尽量 `reconfigure` stdout,避免模型回复含 emoji 时打印报错)
|
||||
|
||||
---
|
||||
|
||||
## 3. 用例文件格式(JSON Schema说明)
|
||||
|
||||
根对象字段:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `version` | number | 建议 | 目前使用 `1` |
|
||||
| `defaults` | object | 否 | 全局默认:URL、账号、超时、轮询间隔等 |
|
||||
| `cases` | array | **是** | 测试用例列表 |
|
||||
|
||||
### 3.1 `defaults` 可选键
|
||||
|
||||
| 键 | 类型 | 默认 | 说明 |
|
||||
|----|------|------|------|
|
||||
| `base_url` | string | `API_BASE_URL` 或 `http://localhost:8037` | API 根地址 |
|
||||
| `username` | string | `admin` | 登录用户名 |
|
||||
| `password` | string | `123456` | 登录密码 |
|
||||
| `request_timeout_sec` | number | `120` | 单次 HTTP 超时(秒) |
|
||||
| `max_wait_sec` | number | `300` | 单条用例轮询最长时间(秒) |
|
||||
| `poll_interval_sec` | number | `2` | 轮询间隔(秒) |
|
||||
|
||||
### 3.2 单条 `cases[]` 对象
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `id` | string | 建议 | 用例 ID,日志与报告中展示 |
|
||||
| `name` | string | 否 | 简短描述 |
|
||||
| `enabled` | boolean | 否 | 默认 `true`;`false` 则跳过 |
|
||||
| `agent` | object | **是** | 见下表「定位 Agent」 |
|
||||
| `message` | string | **是** | 用户话术;会写入 `query` 与 `USER_INPUT` |
|
||||
| `input_extra` | object | 否 | 合并进 `input_data`(勿覆盖 `query`/`USER_INPUT` 除非你有意为之) |
|
||||
| `expect` | object | 否 | 断言;缺省则仅校验能跑完且状态为 `completed`(见 3.3) |
|
||||
|
||||
**`agent` 定位(二选一,不可同时缺):**
|
||||
|
||||
| 键 | 说明 |
|
||||
|----|------|
|
||||
| `id` | Agent UUID,优先级最高 |
|
||||
| `name` | 精确名称;通过 `GET /api/v1/agents?search=...` 再在结果里做**名称全等**匹配;若无全等则取搜索结果第一条(与 `test_agent_execution.py` 行为一致) |
|
||||
|
||||
### 3.3 `expect` 可选断言
|
||||
|
||||
| 键 | 类型 | 说明 |
|
||||
|----|------|------|
|
||||
| `status` | string | 期望最终状态,默认 `completed` |
|
||||
| `output_contains` | string[] | 助手最终文本中须**全部**包含的子串(见下方「输出文本提取」) |
|
||||
| `output_not_contains` | string[] | 最终文本中**不得**包含任一子串 |
|
||||
| `case_insensitive` | boolean | 为 `true` 时,上述 contains / not_contains 忽略大小写 |
|
||||
|
||||
**输出文本提取规则(与 `test_agent_execution.py` 一致):**
|
||||
|
||||
从 `GET /api/v1/executions/{id}` 的 `output_data` 中依次尝试字段:`result` → `output` → `text` → `content`;若仍为对象则序列化为 JSON 字符串再做子串匹配。
|
||||
|
||||
### 3.4 LLM 类 Agent 的断言建议
|
||||
|
||||
- 优先使用 **`output_contains`** 绑定业务关键词(如「作业」「截止」),避免对全文逐字比对。
|
||||
- 冒烟用例可只写 `message`,不写 `expect`,仅确认 `status === completed`。
|
||||
- 需要稳定复现时,可在 Agent 侧固定模型与温度,或在用例里放宽为1~2 个关键词。
|
||||
|
||||
---
|
||||
|
||||
## 4. 与现有脚本的关系
|
||||
|
||||
| 脚本 | 用途 |
|
||||
|------|------|
|
||||
| `test_agent_execution.py` | 单 Agent / 单轮对话、命令行友好(含 `--homework`) |
|
||||
| `run_agent_test_cases.py` | 读取 **JSON 用例文件**,批量执行 + 简单断言,**适合所有 Agent 回归** |
|
||||
|
||||
新增 Agent 时:在 `agent_test_cases.json` 的 `cases` 数组中追加对象即可,无需改 Python 代码。
|
||||
|
||||
---
|
||||
|
||||
## 5. 完整示例(可复制为 `agent_test_cases.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"defaults": {
|
||||
"base_url": "http://localhost:8037",
|
||||
"username": "admin",
|
||||
"password": "123456",
|
||||
"request_timeout_sec": 120,
|
||||
"max_wait_sec": 300,
|
||||
"poll_interval_sec": 2
|
||||
},
|
||||
"cases": [
|
||||
{
|
||||
"id": "homework-hello",
|
||||
"name": "学生作业管理助手-打招呼",
|
||||
"agent": { "name": "学生作业管理助手" },
|
||||
"message": "你好",
|
||||
"expect": {
|
||||
"status": "completed",
|
||||
"output_contains": ["作业", "助手"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "homework-by-id-smoke",
|
||||
"name": "按 ID 冒烟(请换成你环境真实 UUID)",
|
||||
"enabled": false,
|
||||
"agent": { "id": "00000000-0000-0000-0000-000000000000" },
|
||||
"message": "你好"
|
||||
},
|
||||
{
|
||||
"id": "generic-published-smoke",
|
||||
"name": "任意已发布 Agent-占位示例",
|
||||
"enabled": false,
|
||||
"agent": { "name": "你的Agent名称" },
|
||||
"message": "你好",
|
||||
"input_extra": {
|
||||
"user_id": "preview_script_user",
|
||||
"attachments": []
|
||||
},
|
||||
"expect": {
|
||||
"status": "completed"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 常见问题
|
||||
|
||||
### 503 / Redis 连接失败
|
||||
|
||||
检查 `backend/.env` 中 `REDIS_URL` 端口与本地 Redis 是否一致,并确认 Celery Worker 已启动。
|
||||
|
||||
### 401
|
||||
|
||||
检查 `username` / `password`;脚本每次运行会重新登录,一般无 token 过期问题。
|
||||
|
||||
### 403 Agent 未发布
|
||||
|
||||
后端规则:非 `published` / `running` 时,仅 **Agent 所有者**可执行;测试账号需为创建者或使用已发布 Agent。
|
||||
|
||||
### 断言失败但人工看回复正常
|
||||
|
||||
适当减少 `output_contains` 关键词,或开启 `case_insensitive`;LLM 输出本身可能有波动。
|
||||
|
||||
---
|
||||
|
||||
## 7. 版本
|
||||
|
||||
- 文档与 JSON 格式版本:**1**
|
||||
- 配套脚本:`run_agent_test_cases.py`(仓库根目录)
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -19,3 +19,17 @@ Thumbs.db
|
||||
|
||||
# 本地 Redis Windows 分发(勿提交二进制)
|
||||
redis_temp/
|
||||
|
||||
# Tesseract 本机目录(语言包/安装包体积大,本机自备)
|
||||
tessdata/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# 本机运行产物 / 大文件(勿提交)
|
||||
agent_workspaces/
|
||||
uploads/
|
||||
backend/redis/
|
||||
backend/redis.zip
|
||||
*.msi
|
||||
|
||||
116
Windows启动指南.md
116
Windows启动指南.md
@@ -280,7 +280,121 @@ Proxy error: Could not proxy request /api/auth/me from localhost:3001 to http://
|
||||
- 后端:修改启动命令端口 `--port 8038`
|
||||
- 前端:修改 `vite.config.ts` 中的 `port`
|
||||
|
||||
## 快速启动脚本
|
||||
## 一键启动脚本(推荐)
|
||||
|
||||
下面给出一套更稳的 PowerShell 一键启动脚本:
|
||||
- 自动检查并启动 Redis(优先使用 `backend/redis/redis-server.exe`)
|
||||
- 自动拉起后端 API(默认 8037,若被占用自动切到 8041)
|
||||
- 自动拉起 Celery Worker
|
||||
- 自动拉起前端(并将前端代理指向实际 API 端口)
|
||||
|
||||
### 脚本文件:`start_aiagent.ps1`
|
||||
|
||||
> 建议保存到仓库根目录:`D:\aaa\aiagent\start_aiagent.ps1`
|
||||
|
||||
```powershell
|
||||
param(
|
||||
[int]$ApiPort = 8037,
|
||||
[int]$FallbackApiPort = 8041,
|
||||
[int]$FrontendPort = 3001
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$RepoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$Backend = Join-Path $RepoRoot "backend"
|
||||
$Frontend = Join-Path $RepoRoot "frontend"
|
||||
$RedisDir = Join-Path $Backend "redis"
|
||||
$RedisExe = Join-Path $RedisDir "redis-server.exe"
|
||||
$RedisCli = Join-Path $RedisDir "redis-cli.exe"
|
||||
|
||||
function Test-PortListening([int]$Port) {
|
||||
$line = netstat -ano | Select-String ":$Port\s+.*LISTENING" | Select-Object -First 1
|
||||
return [bool]$line
|
||||
}
|
||||
|
||||
function Ensure-Redis {
|
||||
if (Test-PortListening 6379) {
|
||||
Write-Host "[OK] Redis already listening on 6379" -ForegroundColor Green
|
||||
return
|
||||
}
|
||||
if (-not (Test-Path $RedisExe)) {
|
||||
throw "Redis 可执行文件不存在:$RedisExe"
|
||||
}
|
||||
Write-Host "[RUN] Starting Redis on 6379 ..." -ForegroundColor Yellow
|
||||
Start-Process -FilePath $RedisExe -ArgumentList "--port 6379" -WorkingDirectory $RedisDir | Out-Null
|
||||
Start-Sleep -Seconds 2
|
||||
if (-not (Test-PortListening 6379)) {
|
||||
throw "Redis 启动失败,6379 未监听"
|
||||
}
|
||||
if (Test-Path $RedisCli) {
|
||||
& $RedisCli -p 6379 ping | Out-Null
|
||||
}
|
||||
Write-Host "[OK] Redis started" -ForegroundColor Green
|
||||
}
|
||||
|
||||
function Resolve-ApiPort {
|
||||
if (-not (Test-PortListening $ApiPort)) {
|
||||
return $ApiPort
|
||||
}
|
||||
Write-Host "[WARN] Port $ApiPort is occupied, switching to $FallbackApiPort" -ForegroundColor Yellow
|
||||
if (Test-PortListening $FallbackApiPort) {
|
||||
throw "端口 $ApiPort 和 $FallbackApiPort 都被占用,请先释放端口"
|
||||
}
|
||||
return $FallbackApiPort
|
||||
}
|
||||
|
||||
Write-Host "== AIAgent Windows 一键启动 ==" -ForegroundColor Cyan
|
||||
Write-Host "Repo: $RepoRoot"
|
||||
|
||||
Ensure-Redis
|
||||
$RealApiPort = Resolve-ApiPort
|
||||
$ApiBase = "http://127.0.0.1:$RealApiPort"
|
||||
|
||||
Write-Host "[RUN] Starting backend API on $RealApiPort ..." -ForegroundColor Yellow
|
||||
Start-Process powershell -ArgumentList @(
|
||||
"-NoExit",
|
||||
"-Command",
|
||||
"cd '$Backend'; .\venv\Scripts\Activate.ps1; python -m uvicorn app.main:app --host 0.0.0.0 --port $RealApiPort"
|
||||
)
|
||||
|
||||
Write-Host "[RUN] Starting Celery worker ..." -ForegroundColor Yellow
|
||||
Start-Process powershell -ArgumentList @(
|
||||
"-NoExit",
|
||||
"-Command",
|
||||
"cd '$Backend'; .\venv\Scripts\Activate.ps1; python -m celery -A app.core.celery_app worker --loglevel=info --pool=threads --concurrency=8"
|
||||
)
|
||||
|
||||
Write-Host "[RUN] Starting frontend on $FrontendPort (proxy -> $ApiBase) ..." -ForegroundColor Yellow
|
||||
Start-Process powershell -ArgumentList @(
|
||||
"-NoExit",
|
||||
"-Command",
|
||||
"`$env:AIAGENT_API_PROXY='$ApiBase'; cd '$Frontend'; pnpm dev --port $FrontendPort"
|
||||
)
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "[DONE] 启动命令已下发" -ForegroundColor Green
|
||||
Write-Host "前端: http://localhost:$FrontendPort" -ForegroundColor Cyan
|
||||
Write-Host "后端: $ApiBase/docs" -ForegroundColor Cyan
|
||||
Write-Host "Redis: 127.0.0.1:6379" -ForegroundColor Cyan
|
||||
```
|
||||
|
||||
### 运行方式
|
||||
|
||||
```powershell
|
||||
cd D:\aaa\aiagent
|
||||
powershell -ExecutionPolicy Bypass -File .\start_aiagent.ps1
|
||||
```
|
||||
|
||||
### 可选参数
|
||||
|
||||
```powershell
|
||||
# 指定端口
|
||||
powershell -ExecutionPolicy Bypass -File .\start_aiagent.ps1 -ApiPort 8037 -FallbackApiPort 8041 -FrontendPort 3001
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 快速启动脚本(简版)
|
||||
|
||||
### Windows CMD 脚本 (`start_all.cmd`)
|
||||
|
||||
|
||||
42
agent_test_cases.example.json
Normal file
42
agent_test_cases.example.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"version": 1,
|
||||
"defaults": {
|
||||
"base_url": "http://localhost:8037",
|
||||
"username": "admin",
|
||||
"password": "123456",
|
||||
"request_timeout_sec": 120,
|
||||
"max_wait_sec": 300,
|
||||
"poll_interval_sec": 2
|
||||
},
|
||||
"cases": [
|
||||
{
|
||||
"id": "homework-hello",
|
||||
"name": "学生作业管理助手-打招呼",
|
||||
"agent": { "name": "学生作业管理助手" },
|
||||
"message": "你好",
|
||||
"expect": {
|
||||
"status": "completed",
|
||||
"output_contains": ["作业", "助手"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "homework-by-id-smoke",
|
||||
"name": "按 ID 冒烟(请换成你环境真实 UUID)",
|
||||
"enabled": false,
|
||||
"agent": { "id": "00000000-0000-0000-0000-000000000000" },
|
||||
"message": "你好"
|
||||
},
|
||||
{
|
||||
"id": "generic-with-preview-fields",
|
||||
"name": "带预览扩展字段示例(与前端一致时可开)",
|
||||
"enabled": false,
|
||||
"agent": { "name": "你的Agent名称" },
|
||||
"message": "你好",
|
||||
"input_extra": {
|
||||
"user_id": "preview_script_user",
|
||||
"attachments": []
|
||||
},
|
||||
"expect": { "status": "completed" }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -13,6 +13,7 @@ from app.api.auth import get_current_user
|
||||
from app.models.user import User
|
||||
from app.core.exceptions import NotFoundError, ValidationError, ConflictError
|
||||
from app.services.permission_service import check_agent_permission
|
||||
from app.services.agent_workspace_chat_log import fetch_agent_preview_chat_turns
|
||||
from app.services.workflow_validator import validate_workflow
|
||||
import uuid
|
||||
|
||||
@@ -68,6 +69,18 @@ class AgentFromSceneTemplateCreate(BaseModel):
|
||||
budget_config: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class PreviewChatTurnResponse(BaseModel):
|
||||
"""设计器预览侧单轮对话(来自已完成执行)"""
|
||||
|
||||
execution_id: str
|
||||
created_at: datetime
|
||||
user_text: str
|
||||
agent_text: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AgentResponse(BaseModel):
|
||||
"""Agent响应模型"""
|
||||
id: str
|
||||
@@ -208,6 +221,40 @@ async def create_agent(
|
||||
return agent
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{agent_id}/preview-chat-history",
|
||||
response_model=List[PreviewChatTurnResponse],
|
||||
)
|
||||
async def get_agent_preview_chat_history(
|
||||
agent_id: str,
|
||||
preview_user_id: Optional[str] = Query(
|
||||
None,
|
||||
description="预览会话 user_id(与创建执行时 input_data.user_id 一致),只返回本会话记录",
|
||||
),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
获取设计器预览区的对话历史(按已完成执行还原)。
|
||||
与前端 localStorage 中的 preview user_id 对齐,避免多人混在同一 Agent 下串会话。
|
||||
须注册在 GET /{agent_id} 之前,避免路径被误匹配。
|
||||
"""
|
||||
agent = db.query(Agent).filter(Agent.id == agent_id).first()
|
||||
if not agent:
|
||||
raise NotFoundError(f"Agent不存在: {agent_id}")
|
||||
if not check_agent_permission(db, current_user, agent, "read"):
|
||||
raise HTTPException(status_code=403, detail="无权访问此Agent")
|
||||
|
||||
rows = fetch_agent_preview_chat_turns(
|
||||
db,
|
||||
agent_id,
|
||||
preview_user_id=preview_user_id,
|
||||
limit=limit,
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
@router.get("/{agent_id}", response_model=AgentResponse)
|
||||
async def get_agent(
|
||||
agent_id: str,
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.models.agent import Agent
|
||||
from app.api.auth import get_current_user
|
||||
from app.models.user import User
|
||||
from app.tasks.workflow_tasks import execute_workflow_task, resume_workflow_task
|
||||
from app.services.agent_workspace_chat_log import ensure_agent_dialogue_logged_from_db_execution
|
||||
import uuid
|
||||
import logging
|
||||
|
||||
@@ -478,4 +479,11 @@ async def get_execution(
|
||||
if not _can_view_execution(db, current_user, execution):
|
||||
raise HTTPException(status_code=403, detail="无权访问")
|
||||
|
||||
# 补救:若 Celery Worker 未带对话落盘逻辑,首次拉取已完成执行时由 API 进程写入 dialogue.md(与任务内写入幂等)
|
||||
if execution.status == "completed":
|
||||
try:
|
||||
ensure_agent_dialogue_logged_from_db_execution(db, execution)
|
||||
except Exception:
|
||||
logger.debug("补写智能体对话 MD 跳过", exc_info=True)
|
||||
|
||||
return _execution_to_response(execution)
|
||||
|
||||
112
backend/app/api/uploads.py
Normal file
112
backend/app/api/uploads.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""
|
||||
预览 / 聊天附件上传:写入 LOCAL_FILE_TOOLS_ROOT 下,供 file_read 等工具使用。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import uuid
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.api.auth import get_current_user
|
||||
from app.core.config import settings
|
||||
from app.models.user import User
|
||||
from app.services.builtin_tools import _local_file_workspace_root
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/v1/uploads",
|
||||
tags=["uploads"],
|
||||
)
|
||||
|
||||
|
||||
class PreviewUploadResponse(BaseModel):
|
||||
"""相对工作区根的路径,与 file_read 约定一致(可用相对路径)。"""
|
||||
|
||||
relative_path: str
|
||||
filename: str
|
||||
size: int
|
||||
content_type: str | None = None
|
||||
|
||||
|
||||
def _safe_filename(name: str) -> str:
|
||||
base = Path(name).name.strip()
|
||||
if not base:
|
||||
return "attachment.bin"
|
||||
base = re.sub(r"[^\w.\-\u4e00-\u9fff]+", "_", base, flags=re.UNICODE)
|
||||
return base[:180] if len(base) > 180 else base
|
||||
|
||||
|
||||
@router.post(
|
||||
"/preview",
|
||||
response_model=PreviewUploadResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def upload_preview_file(
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
上传单个文件到工作区 `uploads/preview/<user_id>/`,返回相对路径。
|
||||
大小上限与 LOCAL_FILE_WRITE_MAX_BYTES 一致。
|
||||
"""
|
||||
max_bytes = max(1024, int(getattr(settings, "LOCAL_FILE_WRITE_MAX_BYTES", 2_097_152) or 2_097_152))
|
||||
root = _local_file_workspace_root()
|
||||
uid = str(current_user.id)
|
||||
dest_dir = root / "uploads" / "preview" / uid
|
||||
try:
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as e:
|
||||
logger.warning("创建上传目录失败: %s", e)
|
||||
raise HTTPException(status_code=500, detail="无法创建上传目录") from e
|
||||
|
||||
raw_name = file.filename or "attachment"
|
||||
safe = _safe_filename(raw_name)
|
||||
short = uuid.uuid4().hex[:12]
|
||||
dest = dest_dir / f"{short}_{safe}"
|
||||
|
||||
total = 0
|
||||
chunk_size = 1024 * 256
|
||||
try:
|
||||
with dest.open("wb") as out:
|
||||
while True:
|
||||
buf = await file.read(chunk_size)
|
||||
if not buf:
|
||||
break
|
||||
total += len(buf)
|
||||
if total > max_bytes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail=f"文件超过允许大小({max_bytes} 字节)",
|
||||
)
|
||||
out.write(buf)
|
||||
except HTTPException:
|
||||
try:
|
||||
dest.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
except OSError as e:
|
||||
try:
|
||||
dest.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
logger.warning("写入上传文件失败: %s", e)
|
||||
raise HTTPException(status_code=500, detail="保存文件失败") from e
|
||||
|
||||
try:
|
||||
rel = dest.relative_to(root).as_posix()
|
||||
except ValueError:
|
||||
rel = str(dest).replace("\\", "/")
|
||||
|
||||
logger.info("预览上传 user=%s path=%s size=%s", uid, rel, total)
|
||||
return PreviewUploadResponse(
|
||||
relative_path=rel,
|
||||
filename=raw_name,
|
||||
size=total,
|
||||
content_type=file.content_type,
|
||||
)
|
||||
@@ -33,6 +33,15 @@ class Settings(BaseSettings):
|
||||
LOCAL_FILE_TOOLS_ROOT: str = ""
|
||||
LOCAL_FILE_READ_MAX_BYTES: int = 2_097_152 # 单次读取上限(默认 2MB)
|
||||
LOCAL_FILE_WRITE_MAX_BYTES: int = 2_097_152 # 单次写入内容上限(UTF-8 字节)
|
||||
|
||||
# 图片 OCR(file_read 对 png/jpg 等):Tesseract 可执行文件路径,Windows 示例 C:/Program Files/Tesseract-OCR/tesseract.exe
|
||||
TESSERACT_CMD: str = ""
|
||||
# 自定义 tessdata 目录(内含 chi_sim.traineddata 等)。留空时若 LOCAL_FILE_TOOLS_ROOT/tessdata 下存在 .traineddata 则自动使用
|
||||
TESSERACT_TESSDATA_DIR: str = ""
|
||||
|
||||
# 智能体对话落盘:在工作区根下 agent_workspaces/<agent_id>/dialogue.md 追加 Markdown(与 LOCAL_FILE_TOOLS_ROOT 一致)
|
||||
AGENT_WORKSPACE_CHAT_LOG_ENABLED: bool = True
|
||||
AGENT_WORKSPACE_CHAT_SUBDIR: str = "agent_workspaces"
|
||||
|
||||
# CORS配置(支持字符串或列表)
|
||||
CORS_ORIGINS: str = "http://localhost:3000,http://127.0.0.1:3000,http://localhost:8038,http://101.43.95.130:8038"
|
||||
|
||||
@@ -201,9 +201,10 @@ async def startup_event():
|
||||
# 不抛出异常,允许应用继续启动
|
||||
|
||||
# 注册路由
|
||||
from app.api import auth, workflows, executions, websocket, execution_logs, data_sources, agents, platform_templates, model_configs, webhooks, template_market, batch_operations, collaboration, permissions, monitoring, alert_rules, node_test, node_templates, tools
|
||||
from app.api import auth, uploads, workflows, executions, websocket, execution_logs, data_sources, agents, platform_templates, model_configs, webhooks, template_market, batch_operations, collaboration, permissions, monitoring, alert_rules, node_test, node_templates, tools
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(uploads.router)
|
||||
app.include_router(workflows.router)
|
||||
app.include_router(executions.router)
|
||||
app.include_router(websocket.router)
|
||||
|
||||
302
backend/app/services/agent_workspace_chat_log.py
Normal file
302
backend/app/services/agent_workspace_chat_log.py
Normal file
@@ -0,0 +1,302 @@
|
||||
"""
|
||||
每个智能体在工作区根目录下对应一个 Markdown 对话文件(自动追加)。
|
||||
与 builtin_tools._local_file_workspace_root 使用同一根目录,避免越权路径。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.execution import Execution
|
||||
from app.models.agent import Agent
|
||||
from app.services.builtin_tools import _local_file_workspace_root
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MAX_BODY_CHARS = 100_000
|
||||
|
||||
|
||||
def _workspace_subdir() -> str:
|
||||
s = (getattr(settings, "AGENT_WORKSPACE_CHAT_SUBDIR", None) or "agent_workspaces").strip()
|
||||
return s.strip("/\\") or "agent_workspaces"
|
||||
|
||||
|
||||
def _extract_user_text(input_data: Optional[Dict[str, Any]]) -> str:
|
||||
if not input_data:
|
||||
return ""
|
||||
keys = ("query", "USER_INPUT", "message", "content", "text", "prompt", "user_message")
|
||||
for k in keys:
|
||||
v = input_data.get(k)
|
||||
if v is None:
|
||||
continue
|
||||
if isinstance(v, str) and v.strip():
|
||||
return v.strip()
|
||||
if v is not None and not isinstance(v, (dict, list)):
|
||||
s = str(v).strip()
|
||||
if s:
|
||||
return s
|
||||
filtered = {k: v for k, v in input_data.items() if not str(k).startswith("__")}
|
||||
try:
|
||||
raw = json.dumps(filtered, ensure_ascii=False, default=str)
|
||||
except Exception:
|
||||
raw = str(filtered)
|
||||
if len(raw) > _MAX_BODY_CHARS:
|
||||
raw = raw[:_MAX_BODY_CHARS] + "\n\n…(已截断)"
|
||||
return raw or ""
|
||||
|
||||
|
||||
def _extract_assistant_text(result: Optional[Dict[str, Any]]) -> str:
|
||||
if not result:
|
||||
return ""
|
||||
r = result.get("result")
|
||||
if r is None:
|
||||
try:
|
||||
raw = json.dumps(result, ensure_ascii=False, default=str)
|
||||
except Exception:
|
||||
raw = str(result)
|
||||
elif isinstance(r, str):
|
||||
return r if len(r) <= _MAX_BODY_CHARS else r[:_MAX_BODY_CHARS] + "\n\n…(已截断)"
|
||||
else:
|
||||
try:
|
||||
raw = json.dumps(r, ensure_ascii=False, default=str)
|
||||
except Exception:
|
||||
raw = str(r)
|
||||
if len(raw) > _MAX_BODY_CHARS:
|
||||
raw = raw[:_MAX_BODY_CHARS] + "\n\n…(已截断)"
|
||||
return raw
|
||||
|
||||
|
||||
def _file_already_has_execution_id(md_path: Path, execution_id: str) -> bool:
|
||||
"""同一执行只记一次,避免 Celery 与 API 补救重复写入。"""
|
||||
if not md_path.exists():
|
||||
return False
|
||||
try:
|
||||
text = md_path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return False
|
||||
needle = f"**执行 ID**:`{execution_id}`"
|
||||
return needle in text
|
||||
|
||||
|
||||
def _user_id_from_input(input_data: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||
if not input_data:
|
||||
return None
|
||||
for k in ("user_id", "USER_ID", "userId"):
|
||||
v = input_data.get(k)
|
||||
if v is None:
|
||||
continue
|
||||
s = str(v).strip()
|
||||
if s:
|
||||
return s
|
||||
return None
|
||||
|
||||
|
||||
def fetch_agent_preview_chat_turns(
|
||||
db: Session,
|
||||
agent_id: str,
|
||||
*,
|
||||
preview_user_id: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
max_scan: int = 500,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
设计器预览侧恢复对话:从已完成执行解析用户/助手文本。
|
||||
preview_user_id 与 input_data.user_id 一致时只返回该浏览器预览会话的记录。
|
||||
"""
|
||||
from app.models.execution import Execution
|
||||
|
||||
limit = max(1, min(int(limit), 200))
|
||||
max_scan = max(limit, min(int(max_scan), 1000))
|
||||
rows = (
|
||||
db.query(Execution)
|
||||
.filter(
|
||||
Execution.agent_id == agent_id,
|
||||
Execution.status == "completed",
|
||||
)
|
||||
.order_by(Execution.created_at.desc())
|
||||
.limit(max_scan)
|
||||
.all()
|
||||
)
|
||||
picked: List[Execution] = []
|
||||
for ex in rows:
|
||||
if preview_user_id:
|
||||
inp = ex.input_data if isinstance(ex.input_data, dict) else None
|
||||
uid = _user_id_from_input(inp)
|
||||
if uid != preview_user_id:
|
||||
continue
|
||||
picked.append(ex)
|
||||
if len(picked) >= limit:
|
||||
break
|
||||
picked.reverse()
|
||||
out: List[Dict[str, Any]] = []
|
||||
for ex in picked:
|
||||
inp = ex.input_data if isinstance(ex.input_data, dict) else None
|
||||
od = ex.output_data
|
||||
wf: Dict[str, Any] = od if isinstance(od, dict) else {"result": od}
|
||||
user_text = (_extract_user_text(inp) or "").strip()
|
||||
agent_text = (_extract_assistant_text(wf) or "").strip()
|
||||
if not user_text and not agent_text:
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"execution_id": str(ex.id),
|
||||
"created_at": ex.created_at,
|
||||
"user_text": user_text or "(无文本)",
|
||||
"agent_text": agent_text or "(无输出)",
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def append_agent_dialogue_md(
|
||||
*,
|
||||
db: Session,
|
||||
execution: Execution,
|
||||
input_data: Optional[Dict[str, Any]],
|
||||
workflow_result: Dict[str, Any],
|
||||
) -> Optional[Path]:
|
||||
"""
|
||||
在 agent_workspaces/<agent_id>/dialogue.md 末尾追加一轮对话。
|
||||
仅当 execution.agent_id 存在且开关开启时写入。
|
||||
"""
|
||||
if not getattr(settings, "AGENT_WORKSPACE_CHAT_LOG_ENABLED", True):
|
||||
return None
|
||||
if not execution or not getattr(execution, "agent_id", None):
|
||||
return None
|
||||
|
||||
agent_id = str(execution.agent_id)
|
||||
ag = db.query(Agent).filter(Agent.id == execution.agent_id).first()
|
||||
agent_name = (ag.name if ag and ag.name else agent_id).strip() or agent_id
|
||||
|
||||
root = _local_file_workspace_root()
|
||||
sub = _workspace_subdir()
|
||||
agent_dir = root / sub / agent_id
|
||||
try:
|
||||
agent_dir.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as e:
|
||||
logger.warning("创建智能体对话目录失败: %s", e)
|
||||
return None
|
||||
|
||||
md_path = agent_dir / "dialogue.md"
|
||||
eid = str(execution.id)
|
||||
if _file_already_has_execution_id(md_path, eid):
|
||||
logger.debug("对话 MD 已包含执行 %s,跳过追加", eid)
|
||||
return md_path
|
||||
|
||||
user_text = _extract_user_text(input_data)
|
||||
assistant_text = _extract_assistant_text(workflow_result)
|
||||
uid = _user_id_from_input(input_data)
|
||||
ts = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M:%S %z")
|
||||
|
||||
rel_note = f"{sub}/{agent_id}/dialogue.md"
|
||||
|
||||
block_lines = [
|
||||
f"### {ts}",
|
||||
"",
|
||||
f"- **执行 ID**:`{eid}`",
|
||||
]
|
||||
if uid:
|
||||
block_lines.append(f"- **用户 ID**:`{uid}`")
|
||||
block_lines.extend(
|
||||
[
|
||||
"",
|
||||
"**用户**",
|
||||
"",
|
||||
user_text or "(无文本输入,详见 input_data)",
|
||||
"",
|
||||
"**助手**",
|
||||
"",
|
||||
assistant_text or "(无输出)",
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
]
|
||||
)
|
||||
block = "\n".join(block_lines)
|
||||
|
||||
try:
|
||||
if not md_path.exists():
|
||||
header = "\n".join(
|
||||
[
|
||||
f"# 对话记录 · {agent_name}",
|
||||
"",
|
||||
f"- **Agent ID**:`{agent_id}`",
|
||||
f"- **文件路径**(相对工作区根):`{rel_note}`",
|
||||
"- **说明**:每次关联本智能体的执行成功完成后自动追加。",
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
]
|
||||
)
|
||||
md_path.write_text(header + block, encoding="utf-8")
|
||||
else:
|
||||
with md_path.open("a", encoding="utf-8", newline="\n") as f:
|
||||
f.write(block)
|
||||
except OSError as e:
|
||||
logger.warning("写入智能体对话 MD 失败: %s", e)
|
||||
return None
|
||||
|
||||
logger.info("智能体对话已写入: %s", md_path)
|
||||
return md_path
|
||||
|
||||
|
||||
def ensure_agent_dialogue_logged_from_db_execution(
|
||||
db: Session, execution: Execution
|
||||
) -> Optional[Path]:
|
||||
"""
|
||||
根据已落库的执行记录补写对话 MD(供 GET execution 等路径调用)。
|
||||
仅 completed + 有 agent_id + 有 output_data 时写入;与 Celery 路径幂等。
|
||||
"""
|
||||
if not getattr(settings, "AGENT_WORKSPACE_CHAT_LOG_ENABLED", True):
|
||||
return None
|
||||
if not execution or execution.status != "completed":
|
||||
return None
|
||||
if not getattr(execution, "agent_id", None):
|
||||
return None
|
||||
od = execution.output_data
|
||||
if od is None:
|
||||
return None
|
||||
wf: Dict[str, Any]
|
||||
if isinstance(od, dict):
|
||||
wf = od
|
||||
else:
|
||||
wf = {"result": od}
|
||||
try:
|
||||
return append_agent_dialogue_md(
|
||||
db=db,
|
||||
execution=execution,
|
||||
input_data=execution.input_data if isinstance(execution.input_data, dict) else None,
|
||||
workflow_result=wf,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("从执行记录补写对话 MD 失败: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def try_append_agent_dialogue_after_success(
|
||||
db: Session,
|
||||
execution: Optional[Execution],
|
||||
input_data: Optional[Dict[str, Any]],
|
||||
workflow_result: Optional[Dict[str, Any]],
|
||||
execution_logger: Any = None,
|
||||
) -> None:
|
||||
if not workflow_result:
|
||||
return
|
||||
try:
|
||||
append_agent_dialogue_md(
|
||||
db=db,
|
||||
execution=execution,
|
||||
input_data=input_data,
|
||||
workflow_result=workflow_result,
|
||||
)
|
||||
except Exception as e:
|
||||
msg = f"写入智能体对话 MD 异常: {e}"
|
||||
logger.warning(msg)
|
||||
if execution_logger:
|
||||
execution_logger.warn(msg)
|
||||
@@ -14,6 +14,7 @@ import platform
|
||||
import sys
|
||||
import asyncio
|
||||
import subprocess
|
||||
import shlex
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from app.core.config import settings
|
||||
@@ -117,15 +118,157 @@ async def http_request_tool(
|
||||
return json.dumps({"error": str(e)}, ensure_ascii=False)
|
||||
|
||||
|
||||
_FILE_READ_TEXT_SUFFIXES = frozenset(
|
||||
{
|
||||
".txt",
|
||||
".md",
|
||||
".markdown",
|
||||
".csv",
|
||||
".tsv",
|
||||
".json",
|
||||
".jsonl",
|
||||
".xml",
|
||||
".html",
|
||||
".htm",
|
||||
".css",
|
||||
".js",
|
||||
".mjs",
|
||||
".cjs",
|
||||
".ts",
|
||||
".tsx",
|
||||
".jsx",
|
||||
".vue",
|
||||
".py",
|
||||
".java",
|
||||
".go",
|
||||
".rs",
|
||||
".sql",
|
||||
".sh",
|
||||
".bat",
|
||||
".ps1",
|
||||
".yaml",
|
||||
".yml",
|
||||
".toml",
|
||||
".ini",
|
||||
".cfg",
|
||||
".log",
|
||||
".rst",
|
||||
".tex",
|
||||
".gitignore",
|
||||
".env",
|
||||
".properties",
|
||||
}
|
||||
)
|
||||
|
||||
_FILE_READ_IMAGE_SUFFIXES = frozenset(
|
||||
{".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tif", ".tiff"}
|
||||
)
|
||||
|
||||
|
||||
def _truncate_utf8_text(s: str, max_bytes: int) -> str:
|
||||
raw = s.encode("utf-8")
|
||||
if len(raw) <= max_bytes:
|
||||
return s
|
||||
cut = raw[:max_bytes].decode("utf-8", errors="ignore")
|
||||
return cut + "\n\n...[内容已按字节上限截断]"
|
||||
|
||||
|
||||
def _read_pdf_text_sync(path: Path, max_bytes: int) -> str:
|
||||
from pypdf import PdfReader
|
||||
|
||||
reader = PdfReader(str(path))
|
||||
parts: List[str] = []
|
||||
for page in reader.pages:
|
||||
t = page.extract_text() or ""
|
||||
if t.strip():
|
||||
parts.append(t)
|
||||
body = "\n\n".join(parts) if parts else ""
|
||||
if not body.strip():
|
||||
return "[PDF 未解析出文本,可能是扫描件或图片型 PDF;可将页导出为图片后上传,或安装 OCR 流程]"
|
||||
return _truncate_utf8_text(body, max_bytes)
|
||||
|
||||
|
||||
def _read_docx_text_sync(path: Path, max_bytes: int) -> str:
|
||||
import docx
|
||||
|
||||
doc = docx.Document(str(path))
|
||||
lines = [p.text for p in doc.paragraphs if p.text and p.text.strip()]
|
||||
body = "\n".join(lines)
|
||||
return _truncate_utf8_text(body or "[docx 中未找到段落文本]", max_bytes)
|
||||
|
||||
|
||||
def _read_xlsx_text_sync(path: Path, max_bytes: int) -> str:
|
||||
from openpyxl import load_workbook
|
||||
|
||||
wb = load_workbook(filename=str(path), read_only=True, data_only=True)
|
||||
try:
|
||||
lines: List[str] = []
|
||||
for ws in wb.worksheets:
|
||||
lines.append(f"## {ws.title}")
|
||||
for row in ws.iter_rows(values_only=True):
|
||||
cells = ["" if c is None else str(c) for c in row]
|
||||
if any(x.strip() for x in cells):
|
||||
lines.append("\t".join(cells))
|
||||
body = "\n".join(lines)
|
||||
finally:
|
||||
wb.close()
|
||||
return _truncate_utf8_text(body or "[xlsx 中未读到单元格文本]", max_bytes)
|
||||
|
||||
|
||||
def _tessdata_dir_for_ocr() -> Optional[Path]:
|
||||
"""返回 tessdata 目录(内含 .traineddata),传给 Tesseract --tessdata-dir。"""
|
||||
raw = (getattr(settings, "TESSERACT_TESSDATA_DIR", None) or "").strip()
|
||||
if raw:
|
||||
p = Path(raw).expanduser().resolve()
|
||||
return p if p.is_dir() else None
|
||||
root = _local_file_workspace_root()
|
||||
loc = root / "tessdata"
|
||||
if loc.is_dir() and any(loc.glob("*.traineddata")):
|
||||
return loc.resolve()
|
||||
return None
|
||||
|
||||
|
||||
def _read_image_ocr_sync(path: Path, max_bytes: int) -> str:
|
||||
from PIL import Image
|
||||
import pytesseract
|
||||
|
||||
cmd = (getattr(settings, "TESSERACT_CMD", None) or "").strip()
|
||||
if cmd:
|
||||
pytesseract.pytesseract.tesseract_cmd = cmd
|
||||
tess_cfg = ""
|
||||
td = _tessdata_dir_for_ocr()
|
||||
if td is not None:
|
||||
tess_cfg = f"--tessdata-dir {shlex.quote(str(td))}"
|
||||
img = Image.open(path)
|
||||
if img.mode not in ("RGB", "L"):
|
||||
img = img.convert("RGB")
|
||||
text = ""
|
||||
last_err: Optional[Exception] = None
|
||||
for lang in ("chi_sim+eng", "eng"):
|
||||
try:
|
||||
chunk = pytesseract.image_to_string(img, lang=lang, config=tess_cfg) or ""
|
||||
if chunk.strip():
|
||||
text = chunk
|
||||
break
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
continue
|
||||
if not text.strip() and last_err:
|
||||
raise last_err
|
||||
if not text.strip():
|
||||
return "[图片中未识别到文字;可尝试更清晰、正向拍摄的作业照片]"
|
||||
return _truncate_utf8_text(text, max_bytes)
|
||||
|
||||
|
||||
async def file_read_tool(file_path: str) -> str:
|
||||
"""
|
||||
文件读取工具
|
||||
|
||||
文件读取工具:UTF-8 文本、PDF 文本、docx、xlsx 单元格文本、常见图片 OCR(需本机 Tesseract)。
|
||||
|
||||
Args:
|
||||
file_path: 文件路径
|
||||
|
||||
file_path: 文件路径(相对工作区根或工作区内绝对路径)
|
||||
|
||||
Returns:
|
||||
文件内容或错误信息
|
||||
JSON:file_path, content, size;或 error
|
||||
"""
|
||||
try:
|
||||
max_bytes = int(getattr(settings, "LOCAL_FILE_READ_MAX_BYTES", 2_097_152) or 2_097_152)
|
||||
@@ -140,15 +283,106 @@ async def file_read_tool(file_path: str) -> str:
|
||||
{"error": f"文件过大({fsize} 字节),上限 {max_bytes}"},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
content = path.read_text(encoding="utf-8", errors="replace")
|
||||
return json.dumps(
|
||||
{
|
||||
"file_path": str(path),
|
||||
"content": content,
|
||||
"size": len(content.encode("utf-8")),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
suffix = path.suffix.lower()
|
||||
|
||||
if suffix == ".doc":
|
||||
return json.dumps(
|
||||
{
|
||||
"error": "暂不支持旧版 .doc,请在 Word 中另存为 .docx 后上传",
|
||||
"file_path": str(path),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
content: str
|
||||
extract_mode = "text"
|
||||
|
||||
if suffix in _FILE_READ_IMAGE_SUFFIXES:
|
||||
extract_mode = "image_ocr"
|
||||
try:
|
||||
content = await asyncio.to_thread(_read_image_ocr_sync, path, max_bytes)
|
||||
except ImportError as e:
|
||||
return json.dumps(
|
||||
{
|
||||
"error": f"图片识别依赖未安装: {e}。请在后端环境执行 pip install Pillow pytesseract",
|
||||
"file_path": str(path),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
err_low = str(e).lower()
|
||||
if "tesseract" in err_low or "tesseractnotfound" in type(e).__name__.lower():
|
||||
return json.dumps(
|
||||
{
|
||||
"error": (
|
||||
"未找到 Tesseract OCR。请安装 Tesseract(Windows 可装官方安装包),"
|
||||
"并在 .env 中配置 TESSERACT_CMD 指向 tesseract.exe,或将其加入 PATH;"
|
||||
"中文识别需额外下载 chi_sim 语言包。"
|
||||
),
|
||||
"file_path": str(path),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
logger.warning("图片 OCR 失败: %s", e)
|
||||
return json.dumps({"error": f"图片识别失败: {e}", "file_path": str(path)}, ensure_ascii=False)
|
||||
|
||||
elif suffix == ".pdf":
|
||||
extract_mode = "pdf"
|
||||
try:
|
||||
content = await asyncio.to_thread(_read_pdf_text_sync, path, max_bytes)
|
||||
except ImportError as e:
|
||||
return json.dumps(
|
||||
{"error": f"PDF 解析依赖未安装: {e}。请 pip install pypdf", "file_path": str(path)},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("PDF 解析失败: %s", e)
|
||||
return json.dumps({"error": f"PDF 解析失败: {e}", "file_path": str(path)}, ensure_ascii=False)
|
||||
|
||||
elif suffix == ".docx":
|
||||
extract_mode = "docx"
|
||||
try:
|
||||
content = await asyncio.to_thread(_read_docx_text_sync, path, max_bytes)
|
||||
except ImportError as e:
|
||||
return json.dumps(
|
||||
{"error": f"Word 解析依赖未安装: {e}。请 pip install python-docx", "file_path": str(path)},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("docx 解析失败: %s", e)
|
||||
return json.dumps({"error": f"docx 解析失败: {e}", "file_path": str(path)}, ensure_ascii=False)
|
||||
|
||||
elif suffix == ".xlsx":
|
||||
extract_mode = "xlsx"
|
||||
try:
|
||||
content = await asyncio.to_thread(_read_xlsx_text_sync, path, max_bytes)
|
||||
except ImportError as e:
|
||||
return json.dumps(
|
||||
{"error": f"Excel 解析依赖未安装: {e}。请 pip install openpyxl", "file_path": str(path)},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("xlsx 解析失败: %s", e)
|
||||
return json.dumps({"error": f"xlsx 解析失败: {e}", "file_path": str(path)}, ensure_ascii=False)
|
||||
|
||||
elif suffix in _FILE_READ_TEXT_SUFFIXES or suffix == "":
|
||||
extract_mode = "text"
|
||||
content = path.read_text(encoding="utf-8", errors="replace")
|
||||
content = _truncate_utf8_text(content, max_bytes)
|
||||
else:
|
||||
# 未知扩展名:仍尝试按 UTF-8 文本读(兼容无后缀文本)
|
||||
extract_mode = "text"
|
||||
content = path.read_text(encoding="utf-8", errors="replace")
|
||||
content = _truncate_utf8_text(content, max_bytes)
|
||||
|
||||
out = {
|
||||
"file_path": str(path),
|
||||
"content": content,
|
||||
"size": len(content.encode("utf-8")),
|
||||
"extract_mode": extract_mode,
|
||||
}
|
||||
logger.info("file_read %s mode=%s content_len=%s", path, extract_mode, out["size"])
|
||||
return json.dumps(out, ensure_ascii=False)
|
||||
except FileNotFoundError:
|
||||
return json.dumps({"error": f"文件不存在: {file_path}"}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
@@ -813,7 +1047,11 @@ FILE_READ_SCHEMA = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "file_read",
|
||||
"description": "读取本地文本文件(UTF-8)。路径须落在平台配置的工作区内(默认仓库根;可通过环境变量 LOCAL_FILE_TOOLS_ROOT 修改)。超过大小上限会拒绝。",
|
||||
"description": (
|
||||
"读取工作区内文件并尽量提取可读文本:UTF-8 文本、.pdf 文字层、.docx 段落、.xlsx 单元格、"
|
||||
"常见图片(.png/.jpg 等)用 OCR 提取文字(需服务器安装 Tesseract,可选配置 TESSERACT_CMD)。"
|
||||
"用户上传附件后消息中会给出相对路径,请用本工具读取该路径。路径须落在 LOCAL_FILE_TOOLS_ROOT 下。"
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -14,6 +14,41 @@ from app.services.tool_registry import tool_registry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 允许“无参调用”的内置工具;模型仅给出 invoke name 时也应执行,避免把 DSML 协议文本当最终回复返回。
|
||||
_DSML_NO_ARG_TOOLS = {
|
||||
"system_info",
|
||||
"datetime",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_request_timeout(kwargs: Dict[str, Any]) -> float:
|
||||
"""
|
||||
统一解析 LLM 请求超时时间(秒):
|
||||
1) 节点/调用 kwargs.request_timeout
|
||||
2) 环境变量 LLM_REQUEST_TIMEOUT
|
||||
3) 默认 120 秒
|
||||
"""
|
||||
try:
|
||||
if "request_timeout" in kwargs and kwargs.get("request_timeout") is not None:
|
||||
return max(10.0, float(kwargs.get("request_timeout")))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return max(10.0, float(os.getenv("LLM_REQUEST_TIMEOUT", "120")))
|
||||
except Exception:
|
||||
return 120.0
|
||||
|
||||
|
||||
def _is_retryable_llm_error(exc: Exception) -> bool:
|
||||
msg = str(exc).lower()
|
||||
return (
|
||||
("timed out" in msg)
|
||||
or ("timeout" in msg)
|
||||
or ("connection error" in msg)
|
||||
or ("temporarily unavailable" in msg)
|
||||
or ("server disconnected" in msg)
|
||||
)
|
||||
|
||||
|
||||
def _extract_dsml_parameter_args(chunk: str) -> Dict[str, str]:
|
||||
"""
|
||||
@@ -193,7 +228,10 @@ def _parse_dsml_tool_invocations(content: str) -> List[Dict[str, Any]]:
|
||||
args[k] = v
|
||||
|
||||
if not args:
|
||||
continue
|
||||
if tname in _DSML_NO_ARG_TOOLS:
|
||||
args = {}
|
||||
else:
|
||||
continue
|
||||
sig = (tname, json.dumps(args, sort_keys=True, ensure_ascii=False))
|
||||
if sig in signatures:
|
||||
continue
|
||||
@@ -372,6 +410,9 @@ class LLMService:
|
||||
Returns:
|
||||
LLM返回的文本
|
||||
"""
|
||||
extra_kwargs = dict(kwargs or {})
|
||||
extra_kwargs.pop("request_timeout", None)
|
||||
|
||||
# 如果提供了api_key或base_url,创建临时客户端
|
||||
# 注意:api_key 可能是空字符串,需要检查是否为 None
|
||||
if api_key is not None or base_url is not None:
|
||||
@@ -398,16 +439,36 @@ class LLMService:
|
||||
raise ValueError("OpenAI API Key未配置,请在节点配置中设置API Key或在环境变量中设置OPENAI_API_KEY")
|
||||
client = self.openai_client
|
||||
|
||||
request_timeout = _resolve_request_timeout(kwargs)
|
||||
timeout_retries = 2
|
||||
try:
|
||||
response = await client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
**kwargs
|
||||
)
|
||||
response = None
|
||||
last_exc: Optional[Exception] = None
|
||||
for attempt in range(timeout_retries + 1):
|
||||
try:
|
||||
response = await client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
timeout=request_timeout,
|
||||
**extra_kwargs
|
||||
)
|
||||
break
|
||||
except Exception as e:
|
||||
last_exc = e
|
||||
if not _is_retryable_llm_error(e) or attempt >= timeout_retries:
|
||||
raise
|
||||
logger.warning(
|
||||
"OpenAI 调用超时,重试 attempt=%s/%s timeout=%ss",
|
||||
attempt + 1,
|
||||
timeout_retries + 1,
|
||||
request_timeout,
|
||||
)
|
||||
if response is None and last_exc is not None:
|
||||
raise last_exc
|
||||
|
||||
content = response.choices[0].message.content
|
||||
if content is None:
|
||||
@@ -441,6 +502,9 @@ class LLMService:
|
||||
Returns:
|
||||
LLM返回的文本
|
||||
"""
|
||||
extra_kwargs = dict(kwargs or {})
|
||||
extra_kwargs.pop("request_timeout", None)
|
||||
|
||||
# 如果提供了api_key或base_url,创建临时客户端
|
||||
# 注意:api_key 可能是空字符串,需要检查是否为 None
|
||||
if api_key is not None or base_url is not None:
|
||||
@@ -467,21 +531,40 @@ class LLMService:
|
||||
raise ValueError("DeepSeek API Key未配置,请在节点配置中设置API Key或在环境变量中设置DEEPSEEK_API_KEY")
|
||||
client = self.deepseek_client
|
||||
|
||||
request_timeout = _resolve_request_timeout(kwargs)
|
||||
timeout_retries = 2
|
||||
try:
|
||||
# 记录实际发送给LLM的prompt
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"[rjb] DeepSeek实际发送的prompt前200字符: {prompt[:200] if len(prompt) > 200 else prompt}")
|
||||
|
||||
response = await client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
**kwargs
|
||||
)
|
||||
response = None
|
||||
last_exc: Optional[Exception] = None
|
||||
for attempt in range(timeout_retries + 1):
|
||||
try:
|
||||
response = await client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
timeout=request_timeout,
|
||||
**extra_kwargs
|
||||
)
|
||||
break
|
||||
except Exception as e:
|
||||
last_exc = e
|
||||
if not _is_retryable_llm_error(e) or attempt >= timeout_retries:
|
||||
raise
|
||||
logger.warning(
|
||||
"DeepSeek 调用超时,重试 attempt=%s/%s timeout=%ss",
|
||||
attempt + 1,
|
||||
timeout_retries + 1,
|
||||
request_timeout,
|
||||
)
|
||||
if response is None and last_exc is not None:
|
||||
raise last_exc
|
||||
|
||||
content = response.choices[0].message.content
|
||||
if content is None:
|
||||
@@ -551,6 +634,7 @@ class LLMService:
|
||||
execution_logger = None,
|
||||
tool_choice: Optional[str] = None,
|
||||
on_tool_executed: Optional[Callable[[str], Awaitable[None]]] = None,
|
||||
request_timeout: Optional[float] = None,
|
||||
) -> str:
|
||||
"""
|
||||
调用OpenAI API,支持工具调用
|
||||
@@ -588,6 +672,10 @@ class LLMService:
|
||||
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
|
||||
request_timeout = _resolve_request_timeout(
|
||||
{"request_timeout": request_timeout} if request_timeout is not None else {}
|
||||
)
|
||||
timeout_retries = 2
|
||||
try:
|
||||
for iteration in range(max_iterations):
|
||||
# 准备工具参数(只在第一次调用时传递tools)
|
||||
@@ -619,7 +707,26 @@ class LLMService:
|
||||
create_kwargs["tool_choice"] = "required" if _tc == "required" else "auto"
|
||||
|
||||
# 调用LLM
|
||||
response = await client.chat.completions.create(**create_kwargs)
|
||||
last_exc: Optional[Exception] = None
|
||||
response = None
|
||||
for attempt in range(timeout_retries + 1):
|
||||
try:
|
||||
response = await client.chat.completions.create(
|
||||
timeout=request_timeout, **create_kwargs
|
||||
)
|
||||
break
|
||||
except Exception as e:
|
||||
last_exc = e
|
||||
if not _is_retryable_llm_error(e) or attempt >= timeout_retries:
|
||||
raise
|
||||
logger.warning(
|
||||
"LLM 请求超时,准备重试 attempt=%s/%s timeout=%ss",
|
||||
attempt + 1,
|
||||
timeout_retries + 1,
|
||||
request_timeout,
|
||||
)
|
||||
if response is None and last_exc is not None:
|
||||
raise last_exc
|
||||
|
||||
message = response.choices[0].message
|
||||
|
||||
@@ -754,6 +861,7 @@ class LLMService:
|
||||
execution_logger = None,
|
||||
tool_choice: Optional[str] = None,
|
||||
on_tool_executed: Optional[Callable[[str], Awaitable[None]]] = None,
|
||||
request_timeout: Optional[float] = None,
|
||||
) -> str:
|
||||
"""
|
||||
调用DeepSeek API,支持工具调用(DeepSeek兼容OpenAI API格式)
|
||||
@@ -771,6 +879,7 @@ class LLMService:
|
||||
execution_logger=execution_logger,
|
||||
tool_choice=tool_choice,
|
||||
on_tool_executed=on_tool_executed,
|
||||
request_timeout=request_timeout,
|
||||
)
|
||||
|
||||
async def call_llm_with_tools(
|
||||
@@ -784,6 +893,7 @@ class LLMService:
|
||||
execution_logger = None,
|
||||
tool_choice: Optional[str] = None,
|
||||
on_tool_executed: Optional[Callable[[str], Awaitable[None]]] = None,
|
||||
request_timeout: Optional[float] = None,
|
||||
**kwargs
|
||||
) -> str:
|
||||
"""
|
||||
@@ -813,6 +923,7 @@ class LLMService:
|
||||
execution_logger=execution_logger,
|
||||
tool_choice=tool_choice,
|
||||
on_tool_executed=on_tool_executed,
|
||||
request_timeout=request_timeout,
|
||||
**kwargs
|
||||
)
|
||||
elif provider == "deepseek":
|
||||
@@ -827,6 +938,7 @@ class LLMService:
|
||||
execution_logger=execution_logger,
|
||||
tool_choice=tool_choice,
|
||||
on_tool_executed=on_tool_executed,
|
||||
request_timeout=request_timeout,
|
||||
**kwargs
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -812,6 +812,25 @@ class WorkflowEngine:
|
||||
logger.info(f"[rjb] 从嵌套上游输入提升 user_id 到节点 {node_id} 输入顶层")
|
||||
break
|
||||
|
||||
# Start→下游 带 sourceHandle 时 query/USER_INPUT/attachments 常在 right 内,提升到顶层供 LLM 与其它节点读取
|
||||
if isinstance(input_data, dict):
|
||||
_rk = input_data.get('right')
|
||||
if isinstance(_rk, dict):
|
||||
for _uk in (
|
||||
'query',
|
||||
'USER_INPUT',
|
||||
'user_input',
|
||||
'attachments',
|
||||
'text',
|
||||
'message',
|
||||
'content',
|
||||
):
|
||||
if input_data.get(_uk) not in (None, ''):
|
||||
continue
|
||||
if _uk in _rk and _rk[_uk] is not None:
|
||||
input_data[_uk] = _rk[_uk]
|
||||
logger.debug(f"[rjb] 从 right 提升到顶层: {_uk}")
|
||||
|
||||
logger.debug(f"[rjb] 节点输入结果: node_id={node_id}, input_data={input_data}")
|
||||
return input_data
|
||||
|
||||
@@ -1443,6 +1462,58 @@ class WorkflowEngine:
|
||||
if user_query:
|
||||
break
|
||||
|
||||
# Start→LLM 常见:边带 sourceHandle=right,Start 输出在 input_data["right"] 下,
|
||||
# 顶层无 query/USER_INPUT,旧逻辑会退化为 JSON 整包,模型看不到附件路径。
|
||||
if not user_query:
|
||||
for bucket in ("right", "left", "output", "data"):
|
||||
nested = input_data.get(bucket)
|
||||
if not isinstance(nested, dict):
|
||||
continue
|
||||
for key in (
|
||||
"query",
|
||||
"input",
|
||||
"text",
|
||||
"message",
|
||||
"content",
|
||||
"user_input",
|
||||
"USER_INPUT",
|
||||
):
|
||||
if key not in nested:
|
||||
continue
|
||||
value = nested[key]
|
||||
logger.info(
|
||||
f"[rjb] 从嵌套桶 {bucket}.{key} 提取 user_query 候选, type={type(value)}"
|
||||
)
|
||||
if isinstance(value, str):
|
||||
user_query = value
|
||||
logger.info(
|
||||
f"[rjb] 从{bucket}.{key} 提取到字符串 user_query 长度={len(value)}"
|
||||
)
|
||||
break
|
||||
if isinstance(value, dict):
|
||||
for sub_key in (
|
||||
"query",
|
||||
"input",
|
||||
"text",
|
||||
"message",
|
||||
"content",
|
||||
"user_input",
|
||||
"USER_INPUT",
|
||||
):
|
||||
if sub_key not in value:
|
||||
continue
|
||||
sv = value[sub_key]
|
||||
if isinstance(sv, str):
|
||||
user_query = sv
|
||||
logger.info(
|
||||
f"[rjb] 从{bucket}.{key}.{sub_key} 提取到 user_query"
|
||||
)
|
||||
break
|
||||
if user_query:
|
||||
break
|
||||
if user_query:
|
||||
break
|
||||
|
||||
# 如果还是没有,使用整个input_data(但排除系统字段)
|
||||
if not user_query:
|
||||
filtered_data = {k: v for k, v in input_data.items() if not k.startswith('_')}
|
||||
@@ -1607,6 +1678,12 @@ class WorkflowEngine:
|
||||
_tool_extra["max_iterations"] = max(1, min(int(_mi), 64))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
_rt = node_data.get("request_timeout")
|
||||
if _rt is not None:
|
||||
try:
|
||||
_tool_extra["request_timeout"] = max(10.0, float(_rt))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
result = await llm_service.call_llm_with_tools(
|
||||
prompt=prompt,
|
||||
tools=tools,
|
||||
@@ -4916,7 +4993,12 @@ class WorkflowEngine:
|
||||
if isinstance(_rj, dict):
|
||||
_ex.update(_rj)
|
||||
except Exception:
|
||||
pass
|
||||
# 非 JSON 但以 { 开头(如 Markdown),仍当作正文
|
||||
_ex['output'] = _ex['right']
|
||||
else:
|
||||
# LLM 节点 output 常为纯文本,经带 handle 的边落在 right;须写入 output,
|
||||
# 否则下游按 dict 拼接会把 user_id(preview_xxx)拼在回复末尾。
|
||||
_ex['output'] = _ex['right']
|
||||
input_data = _ex
|
||||
final_output = input_data
|
||||
|
||||
@@ -4983,7 +5065,28 @@ class WorkflowEngine:
|
||||
# 否则转换为纯文本(不是JSON)
|
||||
# 尝试提取所有文本字段并组合,但排除系统字段和用户查询字段
|
||||
text_parts = []
|
||||
exclude_keys = {'status', 'error', 'timestamp', 'node_id', 'execution_time', 'query', 'USER_INPUT', 'user_input', 'user_query'}
|
||||
exclude_keys = {
|
||||
'status',
|
||||
'error',
|
||||
'timestamp',
|
||||
'node_id',
|
||||
'execution_time',
|
||||
'query',
|
||||
'USER_INPUT',
|
||||
'user_input',
|
||||
'user_query',
|
||||
'user_id',
|
||||
'USER_ID',
|
||||
'userId',
|
||||
'attachments',
|
||||
'memory',
|
||||
'conversation_history',
|
||||
'user_profile',
|
||||
'context',
|
||||
'right',
|
||||
'left',
|
||||
'data',
|
||||
}
|
||||
# 优先使用input字段(LLM的实际输出)
|
||||
if 'input' in final_output and isinstance(final_output['input'], str):
|
||||
final_output = final_output['input']
|
||||
|
||||
@@ -18,6 +18,7 @@ from app.models.execution import Execution
|
||||
from app.models.agent import Agent
|
||||
from app.models.workflow import Workflow
|
||||
from app.services.execution_budget import merge_budget_for_execution
|
||||
from app.services.agent_workspace_chat_log import try_append_agent_dialogue_after_success
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
@@ -141,7 +142,11 @@ def execute_workflow_task(
|
||||
execution.execution_time = execution_time
|
||||
execution.pause_state = None
|
||||
db.commit()
|
||||
|
||||
|
||||
try_append_agent_dialogue_after_success(
|
||||
db, execution, input_data, result, execution_logger
|
||||
)
|
||||
|
||||
# 记录执行完成日志
|
||||
execution_logger.info(f"工作流任务执行完成,耗时: {execution_time}ms")
|
||||
|
||||
@@ -325,6 +330,10 @@ def resume_workflow_task(
|
||||
ex3.pause_state = None
|
||||
db.commit()
|
||||
|
||||
try_append_agent_dialogue_after_success(
|
||||
db, ex3, base_input, result, execution_logger
|
||||
)
|
||||
|
||||
if execution_logger:
|
||||
execution_logger.info(f"审批后工作流执行完成,耗时: {execution_time}ms")
|
||||
|
||||
|
||||
@@ -17,6 +17,10 @@ MEMORY_PERSIST_DB_ENABLED=true
|
||||
# LOCAL_FILE_TOOLS_ROOT=D:/aaa/aiagent
|
||||
# LOCAL_FILE_READ_MAX_BYTES=2097152
|
||||
# LOCAL_FILE_WRITE_MAX_BYTES=2097152
|
||||
# 图片内文字识别(可选):安装 Tesseract 后填写 tesseract.exe 绝对路径;不配则仅当 tesseract 在 PATH 中可用
|
||||
# TESSERACT_CMD=C:/Program Files/Tesseract-OCR/tesseract.exe
|
||||
# 语言包目录(内含 chi_sim.traineddata)。留空且仓库根下有 tessdata/ 时会自动使用该目录
|
||||
# TESSERACT_TESSDATA_DIR=D:/aaa/aiagent/tessdata
|
||||
|
||||
# CORS配置(多个地址用逗号分隔)
|
||||
CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,http://localhost:8038,http://101.43.95.130:8038
|
||||
|
||||
@@ -46,6 +46,13 @@ kafka-python==2.0.2 # Kafka
|
||||
# Utilities
|
||||
python-dateutil==2.8.2
|
||||
|
||||
# file_read:PDF / Word / Excel / 图片 OCR
|
||||
pypdf==4.0.1
|
||||
python-docx==1.1.0
|
||||
openpyxl==3.1.2
|
||||
Pillow==10.2.0
|
||||
pytesseract==0.3.10
|
||||
|
||||
# Development
|
||||
pytest==7.4.3
|
||||
pytest-asyncio==0.21.1
|
||||
|
||||
@@ -35,6 +35,10 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
# 图构建辅助
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEFAULT_LLM_PROVIDER = os.getenv("ENTERPRISE_LLM_PROVIDER", "deepseek")
|
||||
DEFAULT_LLM_MODEL = os.getenv("ENTERPRISE_LLM_MODEL", "deepseek-chat")
|
||||
DEFAULT_LLM_TIMEOUT = max(30, int(os.getenv("ENTERPRISE_LLM_TIMEOUT", "180")))
|
||||
|
||||
|
||||
def _sanitize_edges(edges: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
seen: set = set()
|
||||
@@ -76,7 +80,10 @@ def _llm(
|
||||
"data": {
|
||||
"label": label,
|
||||
"prompt": prompt,
|
||||
"provider": DEFAULT_LLM_PROVIDER,
|
||||
"model": DEFAULT_LLM_MODEL,
|
||||
"temperature": float(temperature),
|
||||
"request_timeout": DEFAULT_LLM_TIMEOUT,
|
||||
"enable_tools": en,
|
||||
"tools": tlist if en else [],
|
||||
"selected_tools": tlist if en else [],
|
||||
@@ -195,17 +202,17 @@ result["lane"] = lane
|
||||
|
||||
def build_switch_multilane_workflow() -> Dict[str, Any]:
|
||||
nodes: List[Dict[str, Any]] = [
|
||||
{"id": "start-1", "type": "start", "position": {"x": 40, "y": 280}, "data": {"label": "开始"}},
|
||||
{"id": "start-1", "type": "start", "position": {"x": 60, "y": 300}, "data": {"label": "开始"}},
|
||||
{
|
||||
"id": "code-lane",
|
||||
"type": "code",
|
||||
"position": {"x": 240, "y": 280},
|
||||
"position": {"x": 260, "y": 300},
|
||||
"data": {"label": "解析线路", "language": "python", "code": CODE_LANE, "timeout": 15},
|
||||
},
|
||||
{
|
||||
"id": "sw-1",
|
||||
"type": "switch",
|
||||
"position": {"x": 460, "y": 280},
|
||||
"position": {"x": 500, "y": 300},
|
||||
"data": {
|
||||
"label": "业务线路",
|
||||
"field": "lane",
|
||||
@@ -215,39 +222,39 @@ def build_switch_multilane_workflow() -> Dict[str, Any]:
|
||||
},
|
||||
_llm(
|
||||
"llm-cs",
|
||||
(700, 80),
|
||||
(760, 120),
|
||||
"客服线",
|
||||
"你是企业客服专家。用户已从多线路由进入本分支;忽略线路标记,专注解决问题。简洁、礼貌。",
|
||||
temperature=0.35,
|
||||
),
|
||||
_llm(
|
||||
"llm-def",
|
||||
(760, 260),
|
||||
"默认线",
|
||||
"你是通用企业助手。用户未指定【客服】/【研发】/【运维】线路;先简要澄清所属场景再回答。",
|
||||
temperature=0.35,
|
||||
),
|
||||
_llm(
|
||||
"llm-dev",
|
||||
(700, 280),
|
||||
(760, 400),
|
||||
"研发线",
|
||||
"你是研发支持专家。用户已进入研发分支;给出可执行步骤与示例,避免空泛。",
|
||||
temperature=0.28,
|
||||
),
|
||||
_llm(
|
||||
"llm-ops",
|
||||
(700, 480),
|
||||
(760, 540),
|
||||
"运维线",
|
||||
"你是运维专家。用户已进入运维分支;优先给排查顺序与注意事项,不编造监控数据。",
|
||||
temperature=0.3,
|
||||
),
|
||||
_llm(
|
||||
"llm-def",
|
||||
(700, 640),
|
||||
"默认线",
|
||||
"你是通用企业助手。用户未指定【客服】/【研发】/【运维】线路;先简要澄清所属场景再回答。",
|
||||
temperature=0.35,
|
||||
),
|
||||
{
|
||||
"id": "merge-1",
|
||||
"type": "merge",
|
||||
"position": {"x": 980, "y": 280},
|
||||
"position": {"x": 1060, "y": 330},
|
||||
"data": {"label": "合并", "mode": "merge_all", "strategy": "object"},
|
||||
},
|
||||
{"id": "end-1", "type": "end", "position": {"x": 1220, "y": 280}, "data": {"label": "结束"}},
|
||||
{"id": "end-1", "type": "end", "position": {"x": 1300, "y": 330}, "data": {"label": "结束"}},
|
||||
]
|
||||
edges = _sanitize_edges(
|
||||
[
|
||||
|
||||
217
backend/scripts/create_homework_manager_agent.py
Normal file
217
backend/scripts/create_homework_manager_agent.py
Normal file
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
创建或更新「学生作业管理助手」Agent:单链 Start → LLM → End。
|
||||
侧重:记录作业项、截止日、优先级;跟进完成情况;温和督促与周回顾(不代写可提交的作业正文)。
|
||||
|
||||
用法:
|
||||
cd backend && .\\venv\\Scripts\\python.exe scripts/create_homework_manager_agent.py
|
||||
|
||||
环境变量:
|
||||
PLATFORM_BASE_URL, PLATFORM_USERNAME, PLATFORM_PASSWORD
|
||||
AGENT_NAME(默认 学生作业管理助手)
|
||||
HOMEWORK_LLM_PROVIDER / HOMEWORK_LLM_MODEL / HOMEWORK_LLM_TIMEOUT(可选)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if BACKEND_DIR not in sys.path:
|
||||
sys.path.insert(0, BACKEND_DIR)
|
||||
|
||||
BASE = os.getenv("PLATFORM_BASE_URL", "http://127.0.0.1:8037").rstrip("/")
|
||||
USER = os.getenv("PLATFORM_USERNAME", "admin")
|
||||
PWD = os.getenv("PLATFORM_PASSWORD", "123456")
|
||||
AGENT_NAME = os.getenv("AGENT_NAME", "学生作业管理助手")
|
||||
|
||||
PROVIDER = os.getenv(
|
||||
"HOMEWORK_LLM_PROVIDER", os.getenv("ENTERPRISE_LLM_PROVIDER", "deepseek")
|
||||
)
|
||||
MODEL = os.getenv(
|
||||
"HOMEWORK_LLM_MODEL", os.getenv("ENTERPRISE_LLM_MODEL", "deepseek-chat")
|
||||
)
|
||||
REQ_TIMEOUT = max(
|
||||
30,
|
||||
int(
|
||||
os.getenv(
|
||||
"HOMEWORK_LLM_TIMEOUT", os.getenv("ENTERPRISE_LLM_TIMEOUT", "180")
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
BUDGET_CONFIG = {
|
||||
"max_steps": 80,
|
||||
"max_llm_invocations": 6,
|
||||
"max_tool_calls": 20,
|
||||
}
|
||||
|
||||
HOMEWORK_TOOLS = ["file_read", "text_analyze", "datetime", "json_process"]
|
||||
|
||||
HOMEWORK_PROMPT = """你是「学生作业管理助手」,帮助学生**记作业**与**监督完成**,语气友好、具体、可执行。
|
||||
|
||||
【核心能力】
|
||||
1. **记作业**:从用户自然语言中提取「科目 / 作业内容 / 截止日期与时间 / 老师要求要点 / 预估耗时」,整理成清单。
|
||||
- 若用户用回形针**上传**了文件或照片,消息里会出现「相对工作区根路径」列表:**必须先调用 file_read**,用返回的 `content`(正文/OCR 文本)整理进作业清单,勿编造未读到的内容。
|
||||
- 支持常见格式:纯文本/Markdown、**PDF**、**Word(.docx)**、**Excel(.xlsx)**、**照片**(作业拍照等,依赖 OCR;若工具返回需安装 Tesseract 等提示,请如实转告用户并仍可基于用户口述继续记作业)。
|
||||
2. **监督完成**:根据清单追问进度(未开始/进行中/已完成);对临近截止的任务给**温和提醒**(不制造焦虑);可建议拆成小步骤与每日 15–30 分钟微习惯。
|
||||
3. **周回顾**:用户要求时,用 json_process 或清晰表格输出本周完成率、延期项与下周优先三件事。
|
||||
|
||||
【原则】
|
||||
- **不代写**可提交的作业正文、实验报告、论文等;可提供提纲、自检表、引用规范提示。
|
||||
- 日期时间以用户所在语境为准;需要当前时间可借助工具 datetime。
|
||||
- 不确定的信息(如具体截止时刻)先列出假设并请用户确认。
|
||||
- 输出优先中文;列表用编号,便于复制到备忘录。
|
||||
|
||||
【交互习惯】
|
||||
- 用户只说「记一下数学作业」时,主动追问截止日与具体要求(一次问 1–2 个点,避免审问感)。
|
||||
- 用户汇报「做完了」时,确认是否需拍照/上传检查清单,并建议归档到下一条任务前的小结一句话。
|
||||
"""
|
||||
|
||||
|
||||
def _sanitize_edges(edges: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
seen: set = set()
|
||||
out: List[Dict[str, Any]] = []
|
||||
for e in edges or []:
|
||||
s, t = e.get("source"), e.get("target")
|
||||
if not s or not t or s == t:
|
||||
continue
|
||||
key = (s, t, e.get("sourceHandle") or "")
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
ne = dict(e)
|
||||
if not ne.get("targetHandle"):
|
||||
ne["targetHandle"] = "left"
|
||||
if not ne.get("id"):
|
||||
sh = ne.get("sourceHandle") or "r"
|
||||
ne["id"] = f"e_{s}_{t}_{sh}"
|
||||
out.append(ne)
|
||||
return out
|
||||
|
||||
|
||||
def build_workflow() -> Dict[str, Any]:
|
||||
llm_pos: Tuple[int, int] = (380, 220)
|
||||
nodes: List[Dict[str, Any]] = [
|
||||
{"id": "start-1", "type": "start", "position": {"x": 80, "y": 220}, "data": {"label": "开始"}},
|
||||
{
|
||||
"id": "llm-homework",
|
||||
"type": "llm",
|
||||
"position": {"x": llm_pos[0], "y": llm_pos[1]},
|
||||
"data": {
|
||||
"label": "作业管理",
|
||||
"prompt": HOMEWORK_PROMPT,
|
||||
"provider": PROVIDER,
|
||||
"model": MODEL,
|
||||
"temperature": 0.3,
|
||||
"request_timeout": REQ_TIMEOUT,
|
||||
"enable_tools": True,
|
||||
"tools": list(HOMEWORK_TOOLS),
|
||||
"selected_tools": list(HOMEWORK_TOOLS),
|
||||
"max_tool_iterations": 10,
|
||||
},
|
||||
},
|
||||
{"id": "end-1", "type": "end", "position": {"x": llm_pos[0] + 260, "y": 220}, "data": {"label": "结束"}},
|
||||
]
|
||||
edges = _sanitize_edges(
|
||||
[
|
||||
{"source": "start-1", "target": "llm-homework", "sourceHandle": "right", "targetHandle": "left"},
|
||||
{"source": "llm-homework", "target": "end-1", "sourceHandle": "right", "targetHandle": "left"},
|
||||
]
|
||||
)
|
||||
return {"nodes": nodes, "edges": edges}
|
||||
|
||||
|
||||
def _validate_local(wf: Dict[str, Any]) -> None:
|
||||
from app.services.workflow_validator import validate_workflow
|
||||
|
||||
r = validate_workflow(wf.get("nodes") or [], wf.get("edges") or [])
|
||||
if not r.get("valid"):
|
||||
errs = r.get("errors") or []
|
||||
raise ValueError("工作流校验失败: " + "; ".join(errs))
|
||||
|
||||
|
||||
def _find_agent_id(h: Dict[str, str], name: str) -> Optional[str]:
|
||||
r = requests.get(f"{BASE}/api/v1/agents", params={"search": name, "limit": 80}, headers=h, timeout=45)
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
for a in r.json() or []:
|
||||
if a.get("name") == name:
|
||||
return a.get("id")
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
wf = build_workflow()
|
||||
try:
|
||||
_validate_local(wf)
|
||||
except ValueError as e:
|
||||
print(e, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
r = requests.post(
|
||||
f"{BASE}/api/v1/auth/login",
|
||||
data={"username": USER, "password": PWD},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
timeout=15,
|
||||
)
|
||||
if r.status_code != 200:
|
||||
print("登录失败:", r.status_code, r.text[:500], file=sys.stderr)
|
||||
return 1
|
||||
token = r.json().get("access_token")
|
||||
if not token:
|
||||
print("无 access_token", file=sys.stderr)
|
||||
return 1
|
||||
h = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
|
||||
desc = (
|
||||
"学生作业管理助手:记作业(科目、内容、截止日)、跟进度、温和督促与周回顾;"
|
||||
"支持上传文件/照片后用 file_read 提取正文(文本、PDF、docx、xlsx、图片 OCR)与 json_process 整理;"
|
||||
f"默认模型 {PROVIDER}/{MODEL},单次执行内工具迭代上限 10。"
|
||||
)
|
||||
|
||||
existing = _find_agent_id(h, AGENT_NAME)
|
||||
if existing:
|
||||
ur = requests.put(
|
||||
f"{BASE}/api/v1/agents/{existing}",
|
||||
headers=h,
|
||||
json={
|
||||
"description": desc,
|
||||
"workflow_config": wf,
|
||||
"budget_config": BUDGET_CONFIG,
|
||||
},
|
||||
timeout=120,
|
||||
)
|
||||
if ur.status_code != 200:
|
||||
print("更新失败:", ur.status_code, ur.text[:800], file=sys.stderr)
|
||||
return 1
|
||||
print("已更新", AGENT_NAME, existing)
|
||||
print(json.dumps({"id": existing, "name": AGENT_NAME}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
cr = requests.post(
|
||||
f"{BASE}/api/v1/agents",
|
||||
headers=h,
|
||||
json={
|
||||
"name": AGENT_NAME,
|
||||
"description": desc,
|
||||
"workflow_config": wf,
|
||||
"budget_config": BUDGET_CONFIG,
|
||||
},
|
||||
timeout=120,
|
||||
)
|
||||
if cr.status_code != 201:
|
||||
print("创建失败:", cr.status_code, cr.text[:800], file=sys.stderr)
|
||||
return 1
|
||||
aid = cr.json()["id"]
|
||||
print("已创建", AGENT_NAME, aid)
|
||||
print(json.dumps({"id": aid, "name": AGENT_NAME}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
201
backend/scripts/create_intelligent_tutor_agent.py
Normal file
201
backend/scripts/create_intelligent_tutor_agent.py
Normal file
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
创建或更新「智能助教」Agent:单链 Start → LLM → End,面向课程答疑、作业辅导与学习规划。
|
||||
|
||||
用法:
|
||||
cd backend && .\\venv\\Scripts\\python.exe scripts/create_intelligent_tutor_agent.py
|
||||
|
||||
环境变量:
|
||||
PLATFORM_BASE_URL, PLATFORM_USERNAME, PLATFORM_PASSWORD
|
||||
AGENT_NAME(默认 智能助教)
|
||||
TUTOR_LLM_PROVIDER / TUTOR_LLM_MODEL / TUTOR_LLM_TIMEOUT(可选,覆盖默认 DeepSeek 与超时秒数)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if BACKEND_DIR not in sys.path:
|
||||
sys.path.insert(0, BACKEND_DIR)
|
||||
|
||||
BASE = os.getenv("PLATFORM_BASE_URL", "http://127.0.0.1:8037").rstrip("/")
|
||||
USER = os.getenv("PLATFORM_USERNAME", "admin")
|
||||
PWD = os.getenv("PLATFORM_PASSWORD", "123456")
|
||||
AGENT_NAME = os.getenv("AGENT_NAME", "智能助教")
|
||||
|
||||
PROVIDER = os.getenv("TUTOR_LLM_PROVIDER", os.getenv("ENTERPRISE_LLM_PROVIDER", "deepseek"))
|
||||
MODEL = os.getenv("TUTOR_LLM_MODEL", os.getenv("ENTERPRISE_LLM_MODEL", "deepseek-chat"))
|
||||
REQ_TIMEOUT = max(30, int(os.getenv("TUTOR_LLM_TIMEOUT", os.getenv("ENTERPRISE_LLM_TIMEOUT", "180"))))
|
||||
|
||||
BUDGET_CONFIG = {
|
||||
"max_steps": 80,
|
||||
"max_llm_invocations": 6,
|
||||
"max_tool_calls": 24,
|
||||
}
|
||||
|
||||
TUTOR_TOOLS = ["file_read", "text_analyze", "datetime", "json_process"]
|
||||
|
||||
TUTOR_PROMPT = """你是「智能助教」,面向高校/职业课程场景辅助学习与教学准备。
|
||||
|
||||
【能力】
|
||||
- 概念讲解:用清晰结构(定义→要点→小例子)说明知识点。
|
||||
- 习题辅导:给出**解题思路与关键步骤**,引导学生自己完成计算与证明;不要直接给出可照抄的整卷答案或替考内容。
|
||||
- 学习规划:根据用户目标与可用时间,建议复习顺序与自检清单。
|
||||
- 材料辅助:若用户提到本地课件/笔记路径,可用工具读取后基于原文摘要与答疑。
|
||||
|
||||
【边界】
|
||||
- 不编造教材页码、不虚构课程政策;不确定时明确说明并建议向任课教师核实。
|
||||
- 涉及实验安全、医疗、法律等高风险领域时提示寻求专业人士。
|
||||
- 输出简洁,优先中文;需要公式时用 LaTeX 或纯文本均可读形式。
|
||||
|
||||
【输出】
|
||||
- 先给结论或步骤概览,再展开细节;复杂问题分条编号。
|
||||
"""
|
||||
|
||||
|
||||
def _sanitize_edges(edges: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
seen: set = set()
|
||||
out: List[Dict[str, Any]] = []
|
||||
for e in edges or []:
|
||||
s, t = e.get("source"), e.get("target")
|
||||
if not s or not t or s == t:
|
||||
continue
|
||||
key = (s, t, e.get("sourceHandle") or "")
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
ne = dict(e)
|
||||
if not ne.get("targetHandle"):
|
||||
ne["targetHandle"] = "left"
|
||||
if not ne.get("id"):
|
||||
sh = ne.get("sourceHandle") or "r"
|
||||
ne["id"] = f"e_{s}_{t}_{sh}"
|
||||
out.append(ne)
|
||||
return out
|
||||
|
||||
|
||||
def build_workflow() -> Dict[str, Any]:
|
||||
llm_pos: Tuple[int, int] = (380, 220)
|
||||
nodes: List[Dict[str, Any]] = [
|
||||
{"id": "start-1", "type": "start", "position": {"x": 80, "y": 220}, "data": {"label": "开始"}},
|
||||
{
|
||||
"id": "llm-tutor",
|
||||
"type": "llm",
|
||||
"position": {"x": llm_pos[0], "y": llm_pos[1]},
|
||||
"data": {
|
||||
"label": "智能助教",
|
||||
"prompt": TUTOR_PROMPT,
|
||||
"provider": PROVIDER,
|
||||
"model": MODEL,
|
||||
"temperature": 0.35,
|
||||
"request_timeout": REQ_TIMEOUT,
|
||||
"enable_tools": True,
|
||||
"tools": list(TUTOR_TOOLS),
|
||||
"selected_tools": list(TUTOR_TOOLS),
|
||||
"max_tool_iterations": 12,
|
||||
},
|
||||
},
|
||||
{"id": "end-1", "type": "end", "position": {"x": llm_pos[0] + 260, "y": 220}, "data": {"label": "结束"}},
|
||||
]
|
||||
edges = _sanitize_edges(
|
||||
[
|
||||
{"source": "start-1", "target": "llm-tutor", "sourceHandle": "right", "targetHandle": "left"},
|
||||
{"source": "llm-tutor", "target": "end-1", "sourceHandle": "right", "targetHandle": "left"},
|
||||
]
|
||||
)
|
||||
return {"nodes": nodes, "edges": edges}
|
||||
|
||||
|
||||
def _validate_local(wf: Dict[str, Any]) -> None:
|
||||
from app.services.workflow_validator import validate_workflow
|
||||
|
||||
r = validate_workflow(wf.get("nodes") or [], wf.get("edges") or [])
|
||||
if not r.get("valid"):
|
||||
errs = r.get("errors") or []
|
||||
raise ValueError("工作流校验失败: " + "; ".join(errs))
|
||||
|
||||
|
||||
def _find_agent_id(h: Dict[str, str], name: str) -> Optional[str]:
|
||||
r = requests.get(f"{BASE}/api/v1/agents", params={"search": name, "limit": 80}, headers=h, timeout=45)
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
for a in r.json() or []:
|
||||
if a.get("name") == name:
|
||||
return a.get("id")
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
wf = build_workflow()
|
||||
try:
|
||||
_validate_local(wf)
|
||||
except ValueError as e:
|
||||
print(e, file=sys.stderr)
|
||||
return 1
|
||||
|
||||
r = requests.post(
|
||||
f"{BASE}/api/v1/auth/login",
|
||||
data={"username": USER, "password": PWD},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
timeout=15,
|
||||
)
|
||||
if r.status_code != 200:
|
||||
print("登录失败:", r.status_code, r.text[:500], file=sys.stderr)
|
||||
return 1
|
||||
token = r.json().get("access_token")
|
||||
if not token:
|
||||
print("无 access_token", file=sys.stderr)
|
||||
return 1
|
||||
h = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
|
||||
desc = (
|
||||
"智能助教:课程答疑、习题思路辅导与学习规划;支持读取本地材料(file_read)与文本分析;"
|
||||
f"默认模型 {PROVIDER}/{MODEL},单次执行内工具迭代上限 12。"
|
||||
)
|
||||
|
||||
existing = _find_agent_id(h, AGENT_NAME)
|
||||
if existing:
|
||||
ur = requests.put(
|
||||
f"{BASE}/api/v1/agents/{existing}",
|
||||
headers=h,
|
||||
json={
|
||||
"description": desc,
|
||||
"workflow_config": wf,
|
||||
"budget_config": BUDGET_CONFIG,
|
||||
},
|
||||
timeout=120,
|
||||
)
|
||||
if ur.status_code != 200:
|
||||
print("更新失败:", ur.status_code, ur.text[:800], file=sys.stderr)
|
||||
return 1
|
||||
print("已更新", AGENT_NAME, existing)
|
||||
print(json.dumps({"id": existing, "name": AGENT_NAME}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
cr = requests.post(
|
||||
f"{BASE}/api/v1/agents",
|
||||
headers=h,
|
||||
json={
|
||||
"name": AGENT_NAME,
|
||||
"description": desc,
|
||||
"workflow_config": wf,
|
||||
"budget_config": BUDGET_CONFIG,
|
||||
},
|
||||
timeout=120,
|
||||
)
|
||||
if cr.status_code != 201:
|
||||
print("创建失败:", cr.status_code, cr.text[:800], file=sys.stderr)
|
||||
return 1
|
||||
aid = cr.json()["id"]
|
||||
print("已创建", AGENT_NAME, aid)
|
||||
print(json.dumps({"id": aid, "name": AGENT_NAME}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -45,7 +45,8 @@ TOOLS_V15: List[str] = [
|
||||
]
|
||||
|
||||
# 与引擎 workflow_engine 中读取的字段一致(上限 64)
|
||||
DEFAULT_MAX_TOOL_ITERATIONS = 28
|
||||
# 15 号强调可持续执行,但避免过高迭代导致无效工具打转
|
||||
DEFAULT_MAX_TOOL_ITERATIONS = 14
|
||||
|
||||
PROMPT_V15_MARKER = "【知你客服 15 号 · 可持续任务执行】"
|
||||
|
||||
@@ -58,10 +59,11 @@ PROMPT_V15_EXTRA = f"""
|
||||
【与 14 号的关系】继承 14 号全部内置工具与纪律;**工具列表未删减**,平台侧已为 15 号提高**单次执行内工具迭代次数**(见节点 `max_tool_iterations`)。
|
||||
|
||||
【执行策略】
|
||||
1. **多步工具链**:先 `system_info` 确认工作区再 `file_write`;需要外部信息再 `http_request`;需要数据再 `database_query`(仅 SELECT)。每一步根据上一步真实返回再决策。
|
||||
1. **默认本地闭环**:先 `system_info` 确认工作区,再 `file_read/file_write/text_analyze` 完成本地任务;仅当用户**明确要求联网检索**(如“上网查”“联网获取”)时才可调用 `http_request`。
|
||||
2. **持续反馈**:在最终自然语言中说明**已做步骤**与**当前结果**;勿编造工具返回。
|
||||
3. **何时停**:目标达成 → 在末行 JSON 中标明完成;缺用户输入/权限/环境 → 清楚说明缺什么。
|
||||
4. **单次装不下时**:在 `reply` 中说明进度,并建议用户**下一轮发送「继续」**;可把未完成要点写入 `user_profile` 或依赖会话记忆中的 `conversation_history` 衔接(勿用空 JSON 覆盖画像)。
|
||||
5. **古文/常识续写类任务**(如《三字经》补全段落):视为通用知识,不得为此调用 `http_request`;应直接给出内容并按需落盘。
|
||||
|
||||
【末行 JSON(单行)扩展字段(推荐)】
|
||||
在原有 `intent`、`reply`、`user_profile` 基础上,可增加:
|
||||
@@ -71,7 +73,7 @@ PROMPT_V15_EXTRA = f"""
|
||||
|
||||
仍须以 **一行合法 JSON** 结尾,勿用 markdown 代码围栏。
|
||||
|
||||
【纪律】继承 14 号:勿刷屏 DSML;`database_query` 仅 SELECT;`file_write` 同轮勿无故重复写入同一文件除非必要。
|
||||
【纪律】继承 14 号:勿刷屏 DSML;严禁把 `<|DSML|...>`、工具调用协议原文输出给用户;`database_query` 仅 SELECT;`file_write` 同轮勿无故重复写入同一文件除非必要。
|
||||
"""
|
||||
|
||||
|
||||
|
||||
311
backend/scripts/create_zhini_kefu_17.py
Normal file
311
backend/scripts/create_zhini_kefu_17.py
Normal file
@@ -0,0 +1,311 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
从「知你客服15号」复制为「知你客服17号」:
|
||||
|
||||
- **工具**:与 15 号相同(平台当前全量内置工具)。
|
||||
- **主动闭环**:在 LLM 节点写入 **max_tool_iterations**(默认 22),强调「先自检,再执行,再验收」。
|
||||
- **提示词**:强化主动排障与收敛能力:遇到异常优先本地检查与证据化输出,必要时提出最小补充信息而不是停在“我去看看”。
|
||||
|
||||
用法:
|
||||
cd backend && .\\venv\\Scripts\\python.exe scripts/create_zhini_kefu_17.py
|
||||
|
||||
环境变量: PLATFORM_BASE_URL, PLATFORM_USERNAME, PLATFORM_PASSWORD,
|
||||
SOURCE_AGENT_NAME(默认 知你客服15号), TARGET_NAME(默认 知你客服17号)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
BASE = os.getenv("PLATFORM_BASE_URL", "http://127.0.0.1:8037").rstrip("/")
|
||||
USER = os.getenv("PLATFORM_USERNAME", "admin")
|
||||
PWD = os.getenv("PLATFORM_PASSWORD", "123456")
|
||||
SOURCE_NAME = os.getenv("SOURCE_AGENT_NAME", "知你客服15号")
|
||||
TARGET_NAME = os.getenv("TARGET_NAME", "知你客服17号")
|
||||
|
||||
TOOLS_V17: List[str] = [
|
||||
"http_request",
|
||||
"file_read",
|
||||
"file_write",
|
||||
"text_analyze",
|
||||
"datetime",
|
||||
"math_calculate",
|
||||
"system_info",
|
||||
"json_process",
|
||||
"database_query",
|
||||
"adb_log",
|
||||
]
|
||||
|
||||
# 与引擎 workflow_engine 中读取的字段一致(上限 64)
|
||||
DEFAULT_MAX_TOOL_ITERATIONS = 22
|
||||
|
||||
PROMPT_V17_MARKER = "【知你客服 17 号 · 主动排障闭环执行】"
|
||||
|
||||
PROMPT_V17_EXTRA = f"""
|
||||
|
||||
{PROMPT_V17_MARKER}
|
||||
|
||||
【角色】你是**主动闭环执行型**客服助手:遇到问题优先主动排查,不停留在“我去看看”。你应在同一轮执行内完成「自检 → 执行 → 验证 → 交付/补救」。
|
||||
|
||||
【与 15 号的关系】继承 15 号多步工具能力,进一步强化主动性与结果导向,默认尽可能自助完成而非把步骤推给用户。
|
||||
|
||||
【主动执行流程(必须遵守)】
|
||||
1. **先自检**:任务一开始先用最小代价确认关键前提(如工作区、目标文件是否存在、输入是否完整)。
|
||||
2. **再执行**:按步骤调用工具推进任务,不要只说“将要检查”却不行动。
|
||||
3. **必验证**:关键写入/修改后必须立即复核(如 `file_read` 回读、长度/关键词检查)再给结论。
|
||||
4. **失败补救**:单步失败时至少再尝试 1-2 个合理替代方案(文件名冲突、路径差异、编码问题等),并记录已尝试证据。
|
||||
5. **无法完成才提问**:仅在确实缺少必要信息时,向用户提“最小补充问题”;否则优先自助闭环。
|
||||
|
||||
【工具策略】
|
||||
- **默认本地闭环**:优先 `system_info`、`file_read`、`file_write`、`text_analyze`、`json_process`。
|
||||
- `http_request` 仅在用户明确要求联网或本地无法获得信息时使用。
|
||||
- `database_query` 仅 SELECT,禁止写操作。
|
||||
- 古文/常识续写(如《三字经》段落补全)视为常识任务,优先直接生成并落盘,无需联网。
|
||||
|
||||
【末行 JSON(单行)扩展字段(推荐)】
|
||||
在原有 `intent`、`reply`、`user_profile` 基础上,可增加:
|
||||
- `task_complete`: boolean,本任务是否已彻底完成;
|
||||
- `progress_report`: string,本轮已完成步骤的简要清单;
|
||||
- `continuation_hint`: string,若 `task_complete` 为 false,提示用户下一句怎么说(如「继续」「补充 xxx」)。
|
||||
|
||||
仍须以 **一行合法 JSON** 结尾,勿用 markdown 代码围栏。
|
||||
|
||||
【交付格式】
|
||||
- 最终自然语言中要包含:已执行步骤、验证结果、产物路径(若有)。
|
||||
- 末行仍以**一行合法 JSON**结束(`intent/reply/user_profile` 可扩展 `task_complete/progress_report/continuation_hint`)。
|
||||
|
||||
【纪律】勿刷屏 DSML;严禁把 `<|DSML|...>`、工具调用协议原文输出给用户;`file_write` 同轮避免无故重复覆盖。
|
||||
"""
|
||||
|
||||
|
||||
def _sanitize_edges(edges: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
seen: set = set()
|
||||
out: List[Dict[str, Any]] = []
|
||||
for e in edges or []:
|
||||
s, t = e.get("source"), e.get("target")
|
||||
if not s or not t:
|
||||
continue
|
||||
if s == t:
|
||||
continue
|
||||
key = (s, t)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
ne = dict(e)
|
||||
ne["sourceHandle"] = "right"
|
||||
ne["targetHandle"] = "left"
|
||||
if not ne.get("id"):
|
||||
ne["id"] = f"edge_{s}_{t}"
|
||||
out.append(ne)
|
||||
return out
|
||||
|
||||
|
||||
def _find_start_node_ids(nodes: List[Dict[str, Any]]) -> List[str]:
|
||||
ids: List[str] = []
|
||||
for n in nodes or []:
|
||||
nid = n.get("id") or ""
|
||||
nt = (n.get("type") or (n.get("data") or {}).get("type") or "").lower()
|
||||
if nt == "start" or nid in ("start", "start-1") or str(nid).startswith("start-"):
|
||||
ids.append(nid)
|
||||
return ids
|
||||
|
||||
|
||||
def _compute_ranks(
|
||||
nodes: List[Dict[str, Any]], edges: List[Dict[str, Any]]
|
||||
) -> Dict[str, int]:
|
||||
node_ids = [n["id"] for n in nodes if n.get("id")]
|
||||
start_ids = _find_start_node_ids(nodes)
|
||||
incoming: Dict[str, int] = {nid: 0 for nid in node_ids}
|
||||
for e in edges:
|
||||
s, t = e.get("source"), e.get("target")
|
||||
if not s or not t or s == t:
|
||||
continue
|
||||
if t in incoming:
|
||||
incoming[t] += 1
|
||||
if not start_ids:
|
||||
start_ids = [nid for nid in node_ids if incoming.get(nid, 0) == 0] or ([node_ids[0]] if node_ids else [])
|
||||
|
||||
rank: Dict[str, int] = {s: 0 for s in start_ids}
|
||||
nmax = max(len(nodes), 8)
|
||||
for _ in range(nmax + 5):
|
||||
updated = False
|
||||
for e in edges:
|
||||
s, t = e.get("source"), e.get("target")
|
||||
if not s or not t or s == t:
|
||||
continue
|
||||
if s not in rank:
|
||||
continue
|
||||
nv = rank[s] + 1
|
||||
if t not in rank or rank[t] < nv:
|
||||
rank[t] = nv
|
||||
updated = True
|
||||
if not updated:
|
||||
break
|
||||
max_r = max(rank.values(), default=0)
|
||||
for nid in node_ids:
|
||||
if nid not in rank:
|
||||
rank[nid] = max_r + 1
|
||||
max_r += 1
|
||||
return rank
|
||||
|
||||
|
||||
def _apply_layered_positions(nodes: List[Dict[str, Any]], ranks: Dict[str, int]) -> None:
|
||||
layers: Dict[int, List[str]] = defaultdict(list)
|
||||
for nid, r in ranks.items():
|
||||
layers[r].append(nid)
|
||||
for r in layers:
|
||||
layers[r].sort()
|
||||
|
||||
x0, y0 = 80.0, 140.0
|
||||
x_step = 300.0
|
||||
y_step = 110.0
|
||||
|
||||
for r in sorted(layers.keys()):
|
||||
ids = layers[r]
|
||||
nlen = len(ids)
|
||||
y_base = y0 - (nlen - 1) * y_step / 2.0
|
||||
for j, nid in enumerate(ids):
|
||||
for node in nodes:
|
||||
if node.get("id") != nid:
|
||||
continue
|
||||
pos = node.setdefault("position", {})
|
||||
pos["x"] = x0 + r * x_step
|
||||
pos["y"] = y_base + j * y_step
|
||||
break
|
||||
|
||||
|
||||
def improve_workflow_layout_and_edges(wf: Dict[str, Any]) -> Tuple[int, int]:
|
||||
nodes = wf.get("nodes") or []
|
||||
raw_edges = wf.get("edges") or []
|
||||
loops = sum(
|
||||
1
|
||||
for e in raw_edges
|
||||
if e.get("source") and e.get("target") and e.get("source") == e.get("target")
|
||||
)
|
||||
clean = _sanitize_edges(raw_edges)
|
||||
removed_dup = len(raw_edges) - len(clean) - loops
|
||||
|
||||
wf["edges"] = clean
|
||||
|
||||
ranks = _compute_ranks(nodes, clean)
|
||||
_apply_layered_positions(nodes, ranks)
|
||||
return loops, max(0, removed_dup)
|
||||
|
||||
|
||||
def _patch_llm_unified(wf: dict, base_prompt: Optional[str] = None) -> None:
|
||||
for n in wf.get("nodes") or []:
|
||||
if n.get("id") != "llm-unified":
|
||||
continue
|
||||
d = n.setdefault("data", {})
|
||||
prompt = base_prompt if base_prompt else d.get("prompt") or ""
|
||||
if PROMPT_V17_MARKER not in prompt:
|
||||
prompt = (prompt.rstrip() + "\n" + PROMPT_V17_EXTRA).strip()
|
||||
d["prompt"] = prompt
|
||||
d["enable_tools"] = True
|
||||
d["tools"] = list(TOOLS_V17)
|
||||
d["selected_tools"] = list(TOOLS_V17)
|
||||
d["max_tool_iterations"] = DEFAULT_MAX_TOOL_ITERATIONS
|
||||
return
|
||||
print("警告: 未找到节点 llm-unified", file=sys.stderr)
|
||||
|
||||
|
||||
def _find_agent_id_by_name(h: Dict[str, str], name: str) -> Optional[str]:
|
||||
r = requests.get(f"{BASE}/api/v1/agents", params={"search": name, "limit": 50}, headers=h, timeout=30)
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
for a in r.json() or []:
|
||||
if a.get("name") == name:
|
||||
return a.get("id")
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
r = requests.post(
|
||||
f"{BASE}/api/v1/auth/login",
|
||||
data={"username": USER, "password": PWD},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
timeout=15,
|
||||
)
|
||||
if r.status_code != 200:
|
||||
print("登录失败:", r.status_code, r.text[:500], file=sys.stderr)
|
||||
return 1
|
||||
token = r.json().get("access_token")
|
||||
if not token:
|
||||
print("无 access_token", file=sys.stderr)
|
||||
return 1
|
||||
h = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
|
||||
src_id = _find_agent_id_by_name(h, SOURCE_NAME)
|
||||
if not src_id:
|
||||
print(f"未找到源 Agent: {SOURCE_NAME}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
existing = _find_agent_id_by_name(h, TARGET_NAME)
|
||||
if existing:
|
||||
print("已存在", TARGET_NAME, "-> 仅更新工作流", existing)
|
||||
new_id = existing
|
||||
g = requests.get(f"{BASE}/api/v1/agents/{new_id}", headers=h, timeout=30)
|
||||
if g.status_code != 200:
|
||||
print("读取失败:", g.text, file=sys.stderr)
|
||||
return 1
|
||||
agent = g.json()
|
||||
else:
|
||||
dup = requests.post(
|
||||
f"{BASE}/api/v1/agents/{src_id}/duplicate",
|
||||
headers=h,
|
||||
json={"name": TARGET_NAME},
|
||||
timeout=60,
|
||||
)
|
||||
if dup.status_code != 201:
|
||||
print("复制失败:", dup.status_code, dup.text[:800], file=sys.stderr)
|
||||
return 1
|
||||
new_id = dup.json()["id"]
|
||||
agent = dup.json()
|
||||
print("已创建副本:", new_id, TARGET_NAME)
|
||||
|
||||
wf = copy.deepcopy(agent["workflow_config"])
|
||||
loops, dup_edges = improve_workflow_layout_and_edges(wf)
|
||||
print(f"连线整理: 去掉自环 {loops} 条, 合并重复边 {dup_edges} 条")
|
||||
|
||||
g2 = requests.get(f"{BASE}/api/v1/agents/{src_id}", headers=h, timeout=30)
|
||||
base_prompt = None
|
||||
if g2.status_code == 200:
|
||||
try:
|
||||
for n in g2.json().get("workflow_config", {}).get("nodes") or []:
|
||||
if n.get("id") == "llm-unified":
|
||||
base_prompt = (n.get("data") or {}).get("prompt")
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
_patch_llm_unified(wf, base_prompt=base_prompt)
|
||||
|
||||
desc = (
|
||||
"知你客服17号:在15号基础上强化主动闭环执行;"
|
||||
f"llm-unified 配置 max_tool_iterations={DEFAULT_MAX_TOOL_ITERATIONS},"
|
||||
"单次执行内优先完成自检→执行→验证→补救,减少“只说检查不行动”;输出单行 JSON,可含 task_complete/progress_report。"
|
||||
)
|
||||
|
||||
up = requests.put(
|
||||
f"{BASE}/api/v1/agents/{new_id}",
|
||||
headers=h,
|
||||
json={"description": desc, "workflow_config": wf},
|
||||
timeout=120,
|
||||
)
|
||||
if up.status_code != 200:
|
||||
print("更新失败:", up.status_code, up.text[:1200], file=sys.stderr)
|
||||
return 1
|
||||
print("已写入工具:", ", ".join(TOOLS_V17))
|
||||
print(f"max_tool_iterations: {DEFAULT_MAX_TOOL_ITERATIONS}")
|
||||
print("Agent ID:", new_id)
|
||||
print(json.dumps({"id": new_id, "name": TARGET_NAME}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -40,7 +40,7 @@ def init_builtin_tools():
|
||||
# 保存到数据库
|
||||
tools_to_create = [
|
||||
("http_request", HTTP_REQUEST_SCHEMA, "发送HTTP请求,支持GET、POST、PUT、DELETE方法"),
|
||||
("file_read", FILE_READ_SCHEMA, "读取文件内容,只能读取项目目录下的文件")
|
||||
("file_read", FILE_READ_SCHEMA, "读取工作区内文件:文本、PDF、docx、xlsx、图片 OCR 等")
|
||||
]
|
||||
|
||||
created_count = 0
|
||||
|
||||
156
backend/scripts/test_homework_agent_hello.py
Normal file
156
backend/scripts/test_homework_agent_hello.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
测试“学生作业管理助手”最小对话链路:
|
||||
1) 登录
|
||||
2) 按名称查找 Agent
|
||||
3) 创建执行并发送“你好”
|
||||
4) 轮询直到完成,打印助手回复
|
||||
|
||||
示例:
|
||||
python scripts/test_homework_agent_hello.py --password 123456
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="测试学生作业管理助手发送“你好”")
|
||||
parser.add_argument("--base-url", default="http://127.0.0.1:8037", help="后端地址")
|
||||
parser.add_argument("--username", default="admin", help="登录用户名")
|
||||
parser.add_argument("--password", default="123456", help="登录密码")
|
||||
parser.add_argument("--agent-name", default="学生作业管理助手", help="目标 Agent 名称")
|
||||
parser.add_argument("--message", default="你好", help="发送内容")
|
||||
parser.add_argument("--timeout-seconds", type=int, default=90, help="轮询超时秒数")
|
||||
parser.add_argument("--poll-interval", type=float, default=1.5, help="轮询间隔秒")
|
||||
parser.add_argument("--request-timeout", type=int, default=30, help="单次HTTP请求超时秒数")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def login(base_url: str, username: str, password: str, timeout: int = 20) -> str:
|
||||
url = f"{base_url}/api/v1/auth/login"
|
||||
resp = requests.post(
|
||||
url,
|
||||
data={"username": username, "password": password},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
timeout=timeout,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
token = data.get("access_token")
|
||||
if not token:
|
||||
raise RuntimeError(f"登录成功但未返回 access_token: {data}")
|
||||
return token
|
||||
|
||||
|
||||
def pick_agent_id(base_url: str, headers: Dict[str, str], agent_name: str, timeout: int = 20) -> str:
|
||||
url = f"{base_url}/api/v1/agents"
|
||||
resp = requests.get(url, headers=headers, params={"search": agent_name, "limit": 100}, timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
agents: List[Dict[str, Any]] = resp.json() or []
|
||||
|
||||
exact = [a for a in agents if (a.get("name") or "").strip() == agent_name]
|
||||
target = exact[0] if exact else (agents[0] if agents else None)
|
||||
if not target:
|
||||
raise RuntimeError(f"未找到 Agent: {agent_name}")
|
||||
return str(target["id"])
|
||||
|
||||
|
||||
def create_execution(
|
||||
base_url: str, headers: Dict[str, str], agent_id: str, message: str, timeout: int = 30
|
||||
) -> Dict[str, Any]:
|
||||
url = f"{base_url}/api/v1/executions"
|
||||
payload = {
|
||||
"agent_id": agent_id,
|
||||
"input_data": {
|
||||
"USER_INPUT": message,
|
||||
"query": message,
|
||||
"user_id": f"preview_{agent_id}",
|
||||
"attachments": [],
|
||||
},
|
||||
}
|
||||
resp = requests.post(url, headers=headers, json=payload, timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def extract_reply(exec_data: Dict[str, Any]) -> str:
|
||||
output_data = exec_data.get("output_data")
|
||||
if not output_data:
|
||||
return ""
|
||||
if isinstance(output_data, str):
|
||||
return output_data
|
||||
if isinstance(output_data, dict):
|
||||
for key in ("result", "output", "response", "text"):
|
||||
value = output_data.get(key)
|
||||
if value is not None:
|
||||
return value if isinstance(value, str) else str(value)
|
||||
return str(output_data)
|
||||
return str(output_data)
|
||||
|
||||
|
||||
def get_execution(base_url: str, headers: Dict[str, str], execution_id: str, timeout: int = 20) -> Dict[str, Any]:
|
||||
url = f"{base_url}/api/v1/executions/{execution_id}"
|
||||
resp = requests.get(url, headers=headers, timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
base_url = args.base_url.rstrip("/")
|
||||
|
||||
try:
|
||||
token = login(base_url, args.username, args.password, timeout=args.request_timeout)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
print(f"[OK] 登录成功: {args.username}")
|
||||
|
||||
agent_id = pick_agent_id(base_url, headers, args.agent_name, timeout=args.request_timeout)
|
||||
print(f"[OK] 找到 Agent: {args.agent_name} ({agent_id})")
|
||||
|
||||
created = create_execution(
|
||||
base_url, headers, agent_id, args.message, timeout=args.request_timeout
|
||||
)
|
||||
execution_id = str(created["id"])
|
||||
print(f"[OK] 已发送消息: {args.message}")
|
||||
print(f"[INFO] execution_id: {execution_id}")
|
||||
|
||||
deadline = time.time() + max(1, args.timeout_seconds)
|
||||
while time.time() < deadline:
|
||||
detail = get_execution(base_url, headers, execution_id, timeout=args.request_timeout)
|
||||
status = detail.get("status")
|
||||
if status in ("completed", "failed", "cancelled", "awaiting_approval"):
|
||||
print(f"[INFO] 最终状态: {status}")
|
||||
if status == "completed":
|
||||
reply = extract_reply(detail)
|
||||
print("[AGENT_REPLY]")
|
||||
print(reply or "(空回复)")
|
||||
return 0
|
||||
print(detail.get("error_message") or "(无错误信息)")
|
||||
return 2
|
||||
print(f"[POLL] status={status}")
|
||||
time.sleep(max(0.2, args.poll_interval))
|
||||
|
||||
print("[TIMEOUT] 轮询超时,执行尚未完成")
|
||||
return 3
|
||||
except requests.HTTPError as e:
|
||||
body = ""
|
||||
if e.response is not None:
|
||||
try:
|
||||
body = e.response.text[:500]
|
||||
except Exception:
|
||||
body = "<无法读取响应内容>"
|
||||
print(f"[HTTP_ERROR] {e} {body}")
|
||||
return 4
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"[ERROR] {e}")
|
||||
return 5
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -64,9 +64,16 @@ api.interceptors.response.use(
|
||||
return response
|
||||
},
|
||||
(error) => {
|
||||
const skip = Boolean(
|
||||
(error.config as { skipErrorHandler?: boolean } | undefined)?.skipErrorHandler
|
||||
)
|
||||
const response = error.response
|
||||
const status = response?.status
|
||||
const data = response?.data
|
||||
|
||||
if (skip) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// 处理401未授权
|
||||
if (status === 401) {
|
||||
@@ -82,9 +89,13 @@ api.interceptors.response.use(
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// 处理404未找到
|
||||
// 处理404未找到(FastAPI 常用 detail)
|
||||
if (status === 404) {
|
||||
ElMessage.error(data?.message || '请求的资源不存在')
|
||||
const msg =
|
||||
(typeof data?.detail === 'string' ? data.detail : null) ||
|
||||
data?.message ||
|
||||
'请求的资源不存在'
|
||||
ElMessage.error(msg)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
@@ -100,6 +111,16 @@ api.interceptors.response.use(
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// 413:上传体积超限等
|
||||
if (status === 413) {
|
||||
const message =
|
||||
(typeof data?.detail === 'string' ? data.detail : null) ||
|
||||
data?.message ||
|
||||
'请求体过大'
|
||||
ElMessage.error(message)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// 503:多为 Redis/Celery 不可用(FastAPI HTTPException 使用 detail)
|
||||
if (status === 503) {
|
||||
const message =
|
||||
@@ -127,8 +148,12 @@ api.interceptors.response.use(
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// 其他错误
|
||||
const message = data?.message || error.message || '请求失败'
|
||||
// 其他错误(含 FastAPI 常用 string detail)
|
||||
const message =
|
||||
(typeof data?.detail === 'string' ? data.detail : null) ||
|
||||
data?.message ||
|
||||
error.message ||
|
||||
'请求失败'
|
||||
ElMessage.error(message)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<span class="agent-name">{{ agentName || 'Agent' }}</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button text size="small" @click="handleClearChat">
|
||||
<el-button text size="small" title="仅清空当前界面,刷新页面后会重新加载历史记录" @click="handleClearChat">
|
||||
<el-icon><Delete /></el-icon>
|
||||
清空对话
|
||||
</el-button>
|
||||
@@ -22,8 +22,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 预设问题 -->
|
||||
<div v-if="presetQuestions.length > 0 && messages.length === 0" class="preset-questions">
|
||||
<!-- 预设问题(无历史且无对话时显示) -->
|
||||
<div v-if="presetQuestions.length > 0 && messages.length === 0 && !historyLoading" class="preset-questions">
|
||||
<div
|
||||
v-for="(question, index) in presetQuestions"
|
||||
:key="index"
|
||||
@@ -106,11 +106,56 @@
|
||||
</div>
|
||||
|
||||
<div class="chat-input-area">
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
class="hidden-file-input"
|
||||
multiple
|
||||
accept=".txt,.md,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.csv,.json,.py,.ts,.js,.vue,.html,.xml,.zip,.png,.jpg,.jpeg,.gif,.webp,.bmp,.tif,.tiff"
|
||||
@change="onFileInputChange"
|
||||
/>
|
||||
<div class="input-toolbar">
|
||||
<el-button text size="small" @click="handleAttachFile">
|
||||
<el-button text size="small" :disabled="loading || !agentId" @click="handleAttachFile">
|
||||
<el-icon><Paperclip /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-if="pendingAttachments.length > 0" class="attachment-thumb-strip">
|
||||
<div
|
||||
v-for="(a, i) in pendingAttachments"
|
||||
:key="`th-${a.relative_path}-${i}`"
|
||||
class="attachment-thumb-item"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="attachment-thumb-remove"
|
||||
title="移除"
|
||||
@click="removeAttachment(i)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<div v-if="a.thumbUrl" class="attachment-thumb-img-wrap">
|
||||
<img :src="a.thumbUrl" class="attachment-thumb-img" :alt="a.filename" />
|
||||
</div>
|
||||
<div v-else class="attachment-thumb-file-placeholder">
|
||||
<el-icon :size="28"><Document /></el-icon>
|
||||
</div>
|
||||
<div class="attachment-thumb-caption" :title="a.filename">{{ a.filename }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="pendingAttachments.some((x) => x.previewText || x.previewNote)" class="attachment-preview-stack">
|
||||
<div
|
||||
v-for="(a, i) in pendingAttachments"
|
||||
v-show="a.previewText || a.previewNote"
|
||||
:key="`pv-${a.relative_path}-${i}`"
|
||||
class="attachment-preview-card"
|
||||
>
|
||||
<div class="attachment-preview-title">
|
||||
{{ a.previewText ? '内容预览' : '已上传' }} · {{ a.filename }}
|
||||
</div>
|
||||
<pre v-if="a.previewText" class="attachment-preview-body">{{ a.previewText }}</pre>
|
||||
<div v-else-if="a.previewNote" class="attachment-preview-note">{{ a.previewNote }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-input
|
||||
v-model="inputMessage"
|
||||
type="textarea"
|
||||
@@ -131,7 +176,7 @@
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="handleSendMessage"
|
||||
:disabled="!inputMessage.trim() || loading || !agentId"
|
||||
:disabled="(!inputMessage.trim() && pendingAttachments.length === 0) || loading || !agentId"
|
||||
:loading="loading"
|
||||
>
|
||||
<el-icon><Promotion /></el-icon>
|
||||
@@ -143,17 +188,19 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick, onUnmounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ref, watch, nextTick, onMounted, onActivated, onUnmounted } from 'vue'
|
||||
import { ElMessage, ElNotification } from 'element-plus'
|
||||
import {
|
||||
UserFilled,
|
||||
Delete,
|
||||
Loading,
|
||||
Paperclip,
|
||||
Microphone,
|
||||
Promotion
|
||||
Promotion,
|
||||
Document
|
||||
} from '@element-plus/icons-vue'
|
||||
import api from '@/api'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
interface Message {
|
||||
role: 'user' | 'agent'
|
||||
@@ -161,6 +208,18 @@ interface Message {
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
interface PendingAttachment {
|
||||
relative_path: string
|
||||
filename: string
|
||||
size?: number
|
||||
/** 图片:本地 blob URL,用于缩略图(移除或发送后 revoke) */
|
||||
thumbUrl?: string
|
||||
/** 浏览器本地读取的纯文本预览(发送后也会写入气泡) */
|
||||
previewText?: string
|
||||
/** 非文本类:简短说明 */
|
||||
previewNote?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
agentId?: string
|
||||
agentName?: string
|
||||
@@ -174,42 +233,423 @@ const emit = defineEmits<{
|
||||
'execution-status': [status: any]
|
||||
}>()
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const messages = ref<Message[]>([])
|
||||
const inputMessage = ref('')
|
||||
const loading = ref(false)
|
||||
const historyLoading = ref(false)
|
||||
const messagesContainer = ref<HTMLElement>()
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
const pendingAttachments = ref<PendingAttachment[]>([])
|
||||
/** 已发送、待轮询结束后再 revoke 的附件(含 thumbUrl) */
|
||||
const blobsPendingRevokeAfterRun = ref<PendingAttachment[] | null>(null)
|
||||
let pollingInterval: any = null
|
||||
let replyAdded = false // 标志位:防止重复添加回复
|
||||
|
||||
/** 会话记忆需稳定 user_id(见 agent记忆实现方案.md);预览区按 Agent 维度持久化,对应 Cache 键 user_memory_* */
|
||||
/** 设计器预览对话本地镜像(同浏览器同 Agent 在接口不可用或尚未落库时仍可恢复) */
|
||||
const DESIGN_CHAT_STORAGE_PREFIX = 'agent_design_chat_v1'
|
||||
const MAX_PERSIST_MESSAGES = 200
|
||||
let persistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let loadHistoryGeneration = 0
|
||||
|
||||
/** 本地缓存按「登录用户 + Agent」分桶,跨设备以服务端为准时仍避免同机多账号串缓存 */
|
||||
function designChatStorageKey(agentId: string) {
|
||||
return `${DESIGN_CHAT_STORAGE_PREFIX}_${agentId}_${getPreviewContextUserId(agentId)}`
|
||||
}
|
||||
|
||||
function readDesignChatFromStorage(agentId: string): Message[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(designChatStorageKey(agentId))
|
||||
if (!raw) return []
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed
|
||||
.filter(
|
||||
(m: any) =>
|
||||
m &&
|
||||
(m.role === 'user' || m.role === 'agent') &&
|
||||
typeof m.content === 'string' &&
|
||||
typeof m.timestamp === 'number'
|
||||
)
|
||||
.map((m: any) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
timestamp: m.timestamp
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function writeDesignChatToStorage(agentId: string, list: Message[]) {
|
||||
try {
|
||||
const slice = list.slice(-MAX_PERSIST_MESSAGES)
|
||||
let raw = JSON.stringify(slice)
|
||||
if (raw.length > 450_000) {
|
||||
raw = JSON.stringify(slice.slice(-80))
|
||||
}
|
||||
localStorage.setItem(designChatStorageKey(agentId), raw)
|
||||
} catch {
|
||||
// 存储满或禁用
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePersistDesignChat() {
|
||||
if (!props.agentId) return
|
||||
if (persistTimer) clearTimeout(persistTimer)
|
||||
persistTimer = setTimeout(() => {
|
||||
persistTimer = null
|
||||
if (props.agentId) writeDesignChatToStorage(props.agentId, messages.value)
|
||||
}, 400)
|
||||
}
|
||||
|
||||
function clearDesignChatStorage(agentId: string) {
|
||||
try {
|
||||
localStorage.removeItem(designChatStorageKey(agentId))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览/执行写入 input_data.user_id:
|
||||
* - 已登录:用账号 id,换设备同一账号可拉同一套历史
|
||||
* - 未登录:退回浏览器级 preview_ 会话(仅本机)
|
||||
*/
|
||||
function getPreviewContextUserId(agentId: string): string {
|
||||
const uid = userStore.user?.id
|
||||
if (uid) return String(uid)
|
||||
return getPreviewSessionUserId(agentId)
|
||||
}
|
||||
|
||||
/** 未登录时的浏览器会话 id(见 agent记忆实现方案.md) */
|
||||
function getPreviewSessionUserId(agentId: string): string {
|
||||
const key = `agent_preview_uid_${agentId}`
|
||||
const newId = () =>
|
||||
typeof crypto !== 'undefined' && crypto.randomUUID
|
||||
? `preview_${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`
|
||||
: `preview_${Date.now()}_${Math.random().toString(36).slice(2, 12)}`
|
||||
try {
|
||||
let id = localStorage.getItem(key)
|
||||
if (!id) {
|
||||
id =
|
||||
typeof crypto !== 'undefined' && crypto.randomUUID
|
||||
? `preview_${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}`
|
||||
: `preview_${Date.now()}_${Math.random().toString(36).slice(2, 12)}`
|
||||
id = newId()
|
||||
localStorage.setItem(key, id)
|
||||
}
|
||||
return id
|
||||
} catch {
|
||||
return `preview_${agentId}_${Date.now()}`
|
||||
// 勿用 Date.now() 作唯一后缀:每次刷新会换 ID,历史接口永远对不上
|
||||
try {
|
||||
let id = sessionStorage.getItem(key)
|
||||
if (!id) {
|
||||
id = newId()
|
||||
sessionStorage.setItem(key, id)
|
||||
}
|
||||
return id
|
||||
} catch {
|
||||
return `preview_${agentId}_browser`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 从服务端恢复本会话(preview_user_id 与 input_data.user_id 一致)的已完成对话 */
|
||||
async function loadChatHistory() {
|
||||
if (!props.agentId) return
|
||||
const gen = ++loadHistoryGeneration
|
||||
const aid = props.agentId
|
||||
historyLoading.value = true
|
||||
|
||||
// 先读本地镜像,刷新/再次进入页面时立刻有内容(不闪空)
|
||||
const cached = readDesignChatFromStorage(aid)
|
||||
if (cached.length > 0) {
|
||||
messages.value = cached
|
||||
nextTick(() => scrollToBottom())
|
||||
}
|
||||
|
||||
const applyServerTurns = (
|
||||
turns: Array<{
|
||||
user_text: string
|
||||
agent_text: string
|
||||
created_at: string
|
||||
execution_id: string
|
||||
}>
|
||||
) => {
|
||||
const next: Message[] = []
|
||||
for (const t of turns) {
|
||||
const base = new Date(t.created_at).getTime()
|
||||
next.push({
|
||||
role: 'user',
|
||||
content: t.user_text || ' ',
|
||||
timestamp: Number.isFinite(base) ? base - 400 : Date.now() - 400
|
||||
})
|
||||
next.push({
|
||||
role: 'agent',
|
||||
content: t.agent_text || '',
|
||||
timestamp: Number.isFinite(base) ? base : Date.now()
|
||||
})
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
type HistTurn = {
|
||||
user_text: string
|
||||
agent_text: string
|
||||
created_at: string
|
||||
execution_id: string
|
||||
}
|
||||
|
||||
const fetchHist = async (previewUserId: string | undefined): Promise<HistTurn[]> => {
|
||||
const url = `/api/v1/agents/${aid}/preview-chat-history`
|
||||
const params: Record<string, string | number> = { limit: 80 }
|
||||
if (previewUserId) params.preview_user_id = previewUserId
|
||||
const res = await api.get(url, { params, skipErrorHandler: true })
|
||||
return Array.isArray(res.data) ? (res.data as HistTurn[]) : []
|
||||
}
|
||||
|
||||
try {
|
||||
const contextId = getPreviewContextUserId(aid)
|
||||
const browserPreviewId = getPreviewSessionUserId(aid)
|
||||
const byExe = new Map<string, HistTurn>()
|
||||
const merge = (list: HistTurn[]) => {
|
||||
for (const t of list) {
|
||||
if (t?.execution_id) byExe.set(t.execution_id, t)
|
||||
}
|
||||
}
|
||||
|
||||
merge(await fetchHist(contextId))
|
||||
// 登录前产生的记录多为 preview_xxx;登录后 context 变为账号 id,必须合并本机仍保存的 preview 会话,否则会只剩极少数条
|
||||
if (userStore.user?.id && browserPreviewId !== contextId) {
|
||||
merge(await fetchHist(browserPreviewId))
|
||||
}
|
||||
// 两次仍为空(例如新浏览器只有库里旧 preview、且本地无该 preview id):再拉本 Agent 最近完成记录(设计页需 read 权限)
|
||||
if (byExe.size === 0) {
|
||||
merge(await fetchHist(undefined))
|
||||
}
|
||||
|
||||
const turns = Array.from(byExe.values()).sort(
|
||||
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
||||
)
|
||||
|
||||
if (gen !== loadHistoryGeneration) return
|
||||
|
||||
// 用户已在发消息/轮询中:不要用历史接口覆盖当前界面,避免吞掉刚发的内容
|
||||
if (loading.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (Array.isArray(turns) && turns.length > 0) {
|
||||
const next = applyServerTurns(turns)
|
||||
messages.value = next
|
||||
writeDesignChatToStorage(aid, next)
|
||||
}
|
||||
// 服务端无记录时保留上面已展示的本地镜像(若有)
|
||||
} catch (e) {
|
||||
console.warn('加载对话历史失败', e)
|
||||
// 网络/404/401:保留本地镜像
|
||||
} finally {
|
||||
if (gen === loadHistoryGeneration) {
|
||||
historyLoading.value = false
|
||||
nextTick(() => scrollToBottom())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleAttachFile() {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
function isImageFile(file: File): boolean {
|
||||
if (file.type && file.type.startsWith('image/')) return true
|
||||
const ext = file.name.split('.').pop()?.toLowerCase() || ''
|
||||
return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'tif', 'tiff'].includes(ext)
|
||||
}
|
||||
|
||||
function revokeAttachmentBlobs(items: PendingAttachment[]) {
|
||||
for (const a of items) {
|
||||
if (a.thumbUrl) {
|
||||
try {
|
||||
URL.revokeObjectURL(a.thumbUrl)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeAttachment(index: number) {
|
||||
const cur = pendingAttachments.value[index]
|
||||
if (cur?.thumbUrl) {
|
||||
try {
|
||||
URL.revokeObjectURL(cur.thumbUrl)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
pendingAttachments.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const PREVIEW_MAX_FILE_BYTES = 512 * 1024
|
||||
const PREVIEW_MAX_CHARS = 12000
|
||||
const TEXT_PREVIEW_EXTS = new Set([
|
||||
'txt',
|
||||
'md',
|
||||
'markdown',
|
||||
'csv',
|
||||
'json',
|
||||
'jsonl',
|
||||
'log',
|
||||
'yaml',
|
||||
'yml',
|
||||
'py',
|
||||
'ts',
|
||||
'js',
|
||||
'mjs',
|
||||
'cjs',
|
||||
'vue',
|
||||
'html',
|
||||
'htm',
|
||||
'xml',
|
||||
'css',
|
||||
'sql',
|
||||
'sh',
|
||||
'bat',
|
||||
'ps1',
|
||||
'ini',
|
||||
'cfg',
|
||||
'properties',
|
||||
'rst',
|
||||
'tex',
|
||||
'gitignore',
|
||||
'env'
|
||||
])
|
||||
|
||||
function escapeHtml(s: string) {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function readLocalTextPreview(file: File): Promise<string | null> {
|
||||
const m = file.name.match(/\.([^.]+)$/)
|
||||
const ext = (m?.[1] || '').toLowerCase()
|
||||
if (!TEXT_PREVIEW_EXTS.has(ext)) return Promise.resolve(null)
|
||||
if (file.size > PREVIEW_MAX_FILE_BYTES) return Promise.resolve(null)
|
||||
return new Promise((resolve) => {
|
||||
const r = new FileReader()
|
||||
r.onload = () => {
|
||||
let t = String(r.result ?? '')
|
||||
if (t.length > PREVIEW_MAX_CHARS) {
|
||||
t =
|
||||
t.slice(0, PREVIEW_MAX_CHARS) +
|
||||
`\n\n…(仅展示前 ${PREVIEW_MAX_CHARS} 字,完整内容请发送后由助手读取文件)`
|
||||
}
|
||||
resolve(t)
|
||||
}
|
||||
r.onerror = () => resolve(null)
|
||||
r.readAsText(file, 'UTF-8')
|
||||
})
|
||||
}
|
||||
|
||||
async function onFileInputChange(ev: Event) {
|
||||
const input = ev.target as HTMLInputElement
|
||||
const files = input.files
|
||||
input.value = ''
|
||||
if (!files?.length) return
|
||||
if (!props.agentId) {
|
||||
ElMessage.warning('请先选中要预览的智能体后再上传附件')
|
||||
return
|
||||
}
|
||||
const uploadedNames: string[] = []
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
try {
|
||||
const res = await api.post<PendingAttachment & { content_type?: string }>(
|
||||
'/api/v1/uploads/preview',
|
||||
fd
|
||||
)
|
||||
const d = res.data as PendingAttachment
|
||||
if (d?.relative_path) {
|
||||
const previewText = (await readLocalTextPreview(file)) || undefined
|
||||
let thumbUrl: string | undefined
|
||||
if (isImageFile(file)) {
|
||||
thumbUrl = URL.createObjectURL(file)
|
||||
}
|
||||
const entry: PendingAttachment = {
|
||||
relative_path: d.relative_path,
|
||||
filename: d.filename || file.name,
|
||||
size: d.size,
|
||||
thumbUrl,
|
||||
previewText,
|
||||
previewNote: previewText
|
||||
? undefined
|
||||
: '发送后由智能体通过 file_read 读取(支持 PDF、Word、Excel、图片 OCR 等)'
|
||||
}
|
||||
pendingAttachments.value.push(entry)
|
||||
uploadedNames.push(d.filename || file.name)
|
||||
} else {
|
||||
ElMessage.warning(`「${file.name}」上传未返回有效路径,请重试`)
|
||||
}
|
||||
} catch {
|
||||
/* 错误提示由 api 拦截器统一处理 */
|
||||
}
|
||||
}
|
||||
if (uploadedNames.length === 1) {
|
||||
ElNotification.success({
|
||||
title: '附件上传成功',
|
||||
message: `「${uploadedNames[0]}」已加入待发送区,发送后智能体可通过 file_read 读取。`,
|
||||
duration: 5000,
|
||||
offset: 72
|
||||
})
|
||||
} else if (uploadedNames.length > 1) {
|
||||
ElNotification.success({
|
||||
title: '附件上传成功',
|
||||
message: `共 ${uploadedNames.length} 个文件已加入待发送区:${uploadedNames.join('、')}`,
|
||||
duration: 5500,
|
||||
offset: 72
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
const handleSendMessage = async () => {
|
||||
if (!inputMessage.value.trim() || loading.value || !props.agentId) return
|
||||
|
||||
if ((!inputMessage.value.trim() && pendingAttachments.value.length === 0) || loading.value || !props.agentId)
|
||||
return
|
||||
|
||||
const userMessage = inputMessage.value.trim()
|
||||
const attachSnap = pendingAttachments.value.slice()
|
||||
pendingAttachments.value = []
|
||||
inputMessage.value = ''
|
||||
|
||||
|
||||
const attachHint =
|
||||
attachSnap.length > 0
|
||||
? `\n\n[用户上传的附件(相对工作区根路径,可用 file_read 读取):\n${attachSnap.map((a) => `- ${a.relative_path}(${a.filename})`).join('\n')}]`
|
||||
: ''
|
||||
const mergedForModel = (userMessage || '(用户上传了附件,请阅读并回答。)') + attachHint
|
||||
|
||||
let userBubble = userMessage
|
||||
if (attachSnap.length) {
|
||||
const lines = attachSnap.map((a) => `📎 ${a.filename}`)
|
||||
let head = userBubble ? `${userBubble}\n${lines.join('\n')}` : lines.join('\n')
|
||||
const textPreviews = attachSnap
|
||||
.filter((a) => a.previewText?.trim())
|
||||
.map(
|
||||
(a) =>
|
||||
`\n—— ${a.filename} 原文预览 ——\n${escapeHtml(a.previewText!.trim())}`
|
||||
)
|
||||
if (textPreviews.length) {
|
||||
head += `\n${textPreviews.join('\n')}`
|
||||
}
|
||||
userBubble = head
|
||||
}
|
||||
|
||||
// 添加用户消息
|
||||
messages.value.push({
|
||||
role: 'user',
|
||||
content: userMessage,
|
||||
content: userBubble || '(附件)',
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
@@ -222,19 +662,27 @@ const handleSendMessage = async () => {
|
||||
const response = await api.post('/api/v1/executions', {
|
||||
agent_id: props.agentId,
|
||||
input_data: {
|
||||
USER_INPUT: userMessage,
|
||||
query: userMessage,
|
||||
user_id: getPreviewSessionUserId(props.agentId)
|
||||
USER_INPUT: mergedForModel,
|
||||
query: mergedForModel,
|
||||
user_id: getPreviewContextUserId(props.agentId),
|
||||
attachments: attachSnap
|
||||
}
|
||||
})
|
||||
|
||||
const execution = response.data
|
||||
blobsPendingRevokeAfterRun.value = attachSnap.length ? attachSnap : null
|
||||
|
||||
// 重置标志位
|
||||
replyAdded = false
|
||||
|
||||
// 轮询执行状态
|
||||
const checkStatus = async () => {
|
||||
const finishBlobs = () => {
|
||||
if (blobsPendingRevokeAfterRun.value) {
|
||||
revokeAttachmentBlobs(blobsPendingRevokeAfterRun.value)
|
||||
blobsPendingRevokeAfterRun.value = null
|
||||
}
|
||||
}
|
||||
try {
|
||||
// 如果已经添加过回复,直接返回,避免重复添加
|
||||
if (replyAdded) {
|
||||
@@ -303,6 +751,7 @@ const handleSendMessage = async () => {
|
||||
clearInterval(pollingInterval)
|
||||
pollingInterval = null
|
||||
}
|
||||
finishBlobs()
|
||||
} else if (exec.status === 'failed') {
|
||||
// 防止重复添加:如果已经添加过回复,直接返回
|
||||
if (replyAdded) {
|
||||
@@ -329,6 +778,7 @@ const handleSendMessage = async () => {
|
||||
clearInterval(pollingInterval)
|
||||
pollingInterval = null
|
||||
}
|
||||
finishBlobs()
|
||||
} else {
|
||||
// 继续轮询(pending 或 running 状态)
|
||||
// 不需要做任何操作,等待下次轮询
|
||||
@@ -341,10 +791,16 @@ const handleSendMessage = async () => {
|
||||
|
||||
// 标记已添加回复
|
||||
replyAdded = true
|
||||
finishBlobs()
|
||||
|
||||
const st = error.response?.status
|
||||
const msg401 =
|
||||
st === 401
|
||||
? '登录已过期或未登录(401)。请重新登录后再试预览对话。'
|
||||
: error.response?.data?.detail || error.message
|
||||
messages.value.push({
|
||||
role: 'agent',
|
||||
content: `获取执行结果失败: ${error.response?.data?.detail || error.message}`,
|
||||
content: `获取执行结果失败:${msg401}`,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
loading.value = false
|
||||
@@ -367,6 +823,12 @@ const handleSendMessage = async () => {
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('发送消息失败:', error)
|
||||
if (attachSnap.length) {
|
||||
pendingAttachments.value = [...attachSnap, ...pendingAttachments.value]
|
||||
}
|
||||
inputMessage.value = userMessage
|
||||
const last = messages.value[messages.value.length - 1]
|
||||
if (last?.role === 'user') messages.value.pop()
|
||||
messages.value.push({
|
||||
role: 'agent',
|
||||
content: `发送失败: ${error.response?.data?.detail || error.message}`,
|
||||
@@ -386,7 +848,15 @@ const handlePresetQuestion = (question: string) => {
|
||||
|
||||
// 清空对话
|
||||
const handleClearChat = () => {
|
||||
revokeAttachmentBlobs([...pendingAttachments.value])
|
||||
pendingAttachments.value = []
|
||||
if (blobsPendingRevokeAfterRun.value) {
|
||||
revokeAttachmentBlobs(blobsPendingRevokeAfterRun.value)
|
||||
blobsPendingRevokeAfterRun.value = null
|
||||
}
|
||||
messages.value = []
|
||||
if (props.agentId) clearDesignChatStorage(props.agentId)
|
||||
ElMessage.info('已清空预览对话(含本机缓存);刷新后不再恢复本条记录')
|
||||
// 清除执行状态
|
||||
emit('execution-status', null)
|
||||
// 清除轮询
|
||||
@@ -438,10 +908,19 @@ const stripTrailingWorkflowJsonLine = (raw: string): string => {
|
||||
return cur
|
||||
}
|
||||
|
||||
const stripDsmlLines = (raw: string): string => {
|
||||
if (!raw || typeof raw !== 'string') return ''
|
||||
// 过滤协议泄露行:<|DSML|...> / <|DSML|...>
|
||||
const lines = raw.split(/\r?\n/)
|
||||
const filtered = lines.filter((line) => !/[<]\s*[||]DSML[||]/i.test(line))
|
||||
return filtered.join('\n').trim()
|
||||
}
|
||||
|
||||
// 格式化消息(支持简单的Markdown)
|
||||
const formatMessage = (content: string) => {
|
||||
if (!content) return ''
|
||||
const display = stripTrailingWorkflowJsonLine(content)
|
||||
const noProtocol = stripDsmlLines(content)
|
||||
const display = stripTrailingWorkflowJsonLine(noProtocol)
|
||||
return display.replace(/\n/g, '<br>')
|
||||
}
|
||||
|
||||
@@ -451,10 +930,6 @@ const formatTime = (timestamp: number) => {
|
||||
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
// 附件
|
||||
const handleAttachFile = () => {
|
||||
ElMessage.info('文件上传功能开发中')
|
||||
}
|
||||
|
||||
// 语音输入
|
||||
const handleVoiceInput = () => {
|
||||
@@ -472,17 +947,59 @@ const handleCloseNodeTest = () => {
|
||||
// 这里暂时不做处理,由父组件自动清除
|
||||
}
|
||||
|
||||
// 监听消息变化,自动滚动
|
||||
watch(messages, () => {
|
||||
scrollToBottom()
|
||||
}, { deep: true })
|
||||
// 监听消息变化,自动滚动 + 持久化到本机(设计器刷新可恢复)
|
||||
watch(
|
||||
messages,
|
||||
() => {
|
||||
scrollToBottom()
|
||||
schedulePersistDesignChat()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.agentId,
|
||||
(id, prev) => {
|
||||
if (id && id !== prev) {
|
||||
loadChatHistory()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 登录/登出后 user_id 语义变化,需重拉历史与本机缓存键
|
||||
watch(
|
||||
() => userStore.user?.id,
|
||||
(id, prev) => {
|
||||
if (!props.agentId) return
|
||||
if (id !== prev) loadChatHistory()
|
||||
}
|
||||
)
|
||||
|
||||
// 从 bfcache 返回时补拉历史(部分浏览器前进/后退不重建组件)
|
||||
function onPageShow(ev: PageTransitionEvent) {
|
||||
if (ev.persisted && props.agentId) {
|
||||
loadChatHistory()
|
||||
}
|
||||
}
|
||||
onMounted(() => window.addEventListener('pageshow', onPageShow as EventListener))
|
||||
|
||||
onActivated(() => {
|
||||
if (props.agentId) loadChatHistory()
|
||||
})
|
||||
|
||||
// 组件卸载时清理轮询
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('pageshow', onPageShow as EventListener)
|
||||
if (pollingInterval) {
|
||||
clearInterval(pollingInterval)
|
||||
pollingInterval = null
|
||||
}
|
||||
revokeAttachmentBlobs([...pendingAttachments.value])
|
||||
if (blobsPendingRevokeAfterRun.value) {
|
||||
revokeAttachmentBlobs(blobsPendingRevokeAfterRun.value)
|
||||
blobsPendingRevokeAfterRun.value = null
|
||||
}
|
||||
// 清除执行状态
|
||||
emit('execution-status', null)
|
||||
})
|
||||
@@ -679,4 +1196,125 @@ onUnmounted(() => {
|
||||
border-radius: 8px;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.hidden-file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.attachment-thumb-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.attachment-thumb-item {
|
||||
position: relative;
|
||||
width: 76px;
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.attachment-thumb-remove {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
z-index: 2;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.attachment-thumb-remove:hover {
|
||||
background: #f56c6c;
|
||||
}
|
||||
|
||||
.attachment-thumb-img-wrap {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e4e7ed;
|
||||
background: #fafafa;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.attachment-thumb-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.attachment-thumb-file-placeholder {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e4e7ed;
|
||||
background: linear-gradient(145deg, #f5f7fa, #ebeef5);
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.attachment-thumb-caption {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: #606266;
|
||||
line-height: 1.2;
|
||||
max-width: 76px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attachment-preview-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.attachment-preview-card {
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.attachment-preview-title {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.attachment-preview-body {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: #303133;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.attachment-preview-note {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.45;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
<!-- 右侧:预览与调试 -->
|
||||
<div class="preview-panel">
|
||||
<AgentChatPreview
|
||||
:key="String(agentId || '')"
|
||||
:agent-id="agentId"
|
||||
:agent-name="currentAgent?.name"
|
||||
:opening-message="openingMessage"
|
||||
|
||||
279
run_agent_test_cases.py
Normal file
279
run_agent_test_cases.py
Normal file
@@ -0,0 +1,279 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
读取 agent_test_cases.json(或 --cases 指定),批量执行 Agent 并做简单断言。
|
||||
规范见:(红头)agent测试用例文档.md
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
DEFAULT_CASES_FILE = "agent_test_cases.json"
|
||||
|
||||
|
||||
def _ensure_utf8_stdio() -> None:
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
for name in ("stdout", "stderr"):
|
||||
stream = getattr(sys, name, None)
|
||||
if stream is not None and hasattr(stream, "reconfigure"):
|
||||
try:
|
||||
stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
_ensure_utf8_stdio()
|
||||
|
||||
|
||||
def _login(base_url: str, username: str, password: str, timeout: float) -> Optional[Dict[str, str]]:
|
||||
r = requests.post(
|
||||
f"{base_url}/api/v1/auth/login",
|
||||
data={"username": username, "password": password},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
timeout=timeout,
|
||||
)
|
||||
if r.status_code != 200:
|
||||
print(f"[FAIL] 登录 {r.status_code}: {r.text[:500]}")
|
||||
return None
|
||||
token = r.json().get("access_token")
|
||||
if not token:
|
||||
print("[FAIL] 登录响应无 access_token")
|
||||
return None
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _resolve_agent_id(
|
||||
base_url: str,
|
||||
headers: Dict[str, str],
|
||||
agent: Dict[str, Any],
|
||||
timeout: float,
|
||||
) -> Optional[str]:
|
||||
if agent.get("id"):
|
||||
return str(agent["id"])
|
||||
name = agent.get("name")
|
||||
if not name:
|
||||
print("[FAIL] agent 需包含 id 或 name")
|
||||
return None
|
||||
r = requests.get(
|
||||
f"{base_url}/api/v1/agents",
|
||||
headers=headers,
|
||||
params={"search": name, "limit": 100},
|
||||
timeout=timeout,
|
||||
)
|
||||
if r.status_code != 200:
|
||||
print(f"[FAIL] 查找 Agent {r.status_code}: {r.text[:500]}")
|
||||
return None
|
||||
agents: List[Dict[str, Any]] = r.json() or []
|
||||
exact = [a for a in agents if (a.get("name") or "").strip() == name]
|
||||
pick = exact[0] if exact else (agents[0] if agents else None)
|
||||
if not pick:
|
||||
print(f"[FAIL] 未找到 Agent: {name}")
|
||||
return None
|
||||
print(f"[OK] Agent: {pick.get('name')} ({pick['id']}) status={pick.get('status')}")
|
||||
return str(pick["id"])
|
||||
|
||||
|
||||
def _extract_output_text(output_data: Any) -> str:
|
||||
if output_data is None:
|
||||
return ""
|
||||
if isinstance(output_data, str):
|
||||
return output_data
|
||||
if isinstance(output_data, dict):
|
||||
for key in ("result", "output", "text", "content"):
|
||||
v = output_data.get(key)
|
||||
if v is not None:
|
||||
return v if isinstance(v, str) else str(v)
|
||||
return json.dumps(output_data, ensure_ascii=False)
|
||||
return str(output_data)
|
||||
|
||||
|
||||
def _poll_until_terminal(
|
||||
base_url: str,
|
||||
headers: Dict[str, str],
|
||||
execution_id: str,
|
||||
max_wait: float,
|
||||
poll_interval: float,
|
||||
timeout: float,
|
||||
) -> Tuple[str, Optional[Dict[str, Any]]]:
|
||||
deadline = time.time() + max_wait
|
||||
last_status = "unknown"
|
||||
while time.time() < deadline:
|
||||
sr = requests.get(
|
||||
f"{base_url}/api/v1/executions/{execution_id}/status",
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
)
|
||||
if sr.status_code != 200:
|
||||
print(f"[WARN] status {sr.status_code}: {sr.text[:300]}")
|
||||
time.sleep(poll_interval)
|
||||
continue
|
||||
body = sr.json()
|
||||
last_status = str(body.get("status") or "")
|
||||
if last_status in ("completed", "failed", "cancelled", "awaiting_approval"):
|
||||
break
|
||||
time.sleep(poll_interval)
|
||||
dr = requests.get(
|
||||
f"{base_url}/api/v1/executions/{execution_id}",
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
)
|
||||
if dr.status_code != 200:
|
||||
print(f"[FAIL] 获取执行详情 {dr.status_code}: {dr.text[:500]}")
|
||||
return last_status, None
|
||||
return last_status, dr.json()
|
||||
|
||||
|
||||
def _check_expect(text: str, status: str, detail: Optional[Dict[str, Any]], expect: Dict[str, Any]) -> List[str]:
|
||||
errors: List[str] = []
|
||||
want_status = expect.get("status", "completed")
|
||||
if status != want_status:
|
||||
errors.append(f"状态期望 {want_status!r},实际 {status!r}")
|
||||
if detail and status != "completed":
|
||||
em = detail.get("error_message")
|
||||
if em:
|
||||
errors.append(f"error_message: {em[:500]}")
|
||||
if not expect:
|
||||
return errors
|
||||
ci = bool(expect.get("case_insensitive"))
|
||||
hay = text if not ci else text.lower()
|
||||
for sub in expect.get("output_contains") or []:
|
||||
s = sub if not ci else sub.lower()
|
||||
if s not in hay:
|
||||
errors.append(f"输出应包含 {sub!r}")
|
||||
for sub in expect.get("output_not_contains") or []:
|
||||
s = sub if not ci else sub.lower()
|
||||
if s in hay:
|
||||
errors.append(f"输出不应包含 {sub!r}")
|
||||
return errors
|
||||
|
||||
|
||||
def _run_one_case(
|
||||
base_url: str,
|
||||
headers: Dict[str, str],
|
||||
defaults: Dict[str, Any],
|
||||
case: Dict[str, Any],
|
||||
) -> bool:
|
||||
cid = case.get("id", "(no-id)")
|
||||
title = case.get("name", "")
|
||||
print("\n" + "-" * 60)
|
||||
print(f"CASE {cid}" + (f" — {title}" if title else ""))
|
||||
|
||||
req_timeout = float(case.get("request_timeout_sec", defaults.get("request_timeout_sec", 120)))
|
||||
max_wait = float(case.get("max_wait_sec", defaults.get("max_wait_sec", 300)))
|
||||
poll_iv = float(case.get("poll_interval_sec", defaults.get("poll_interval_sec", 2)))
|
||||
|
||||
agent_id = _resolve_agent_id(base_url, headers, case.get("agent") or {}, req_timeout)
|
||||
if not agent_id:
|
||||
return False
|
||||
|
||||
message = case.get("message")
|
||||
if message is None:
|
||||
print("[FAIL] 缺少 message")
|
||||
return False
|
||||
|
||||
input_data: Dict[str, Any] = {"query": message, "USER_INPUT": message}
|
||||
extra = case.get("input_extra")
|
||||
if isinstance(extra, dict):
|
||||
input_data = {**extra, **input_data}
|
||||
|
||||
er = requests.post(
|
||||
f"{base_url}/api/v1/executions",
|
||||
headers=headers,
|
||||
json={"agent_id": agent_id, "input_data": input_data},
|
||||
timeout=req_timeout,
|
||||
)
|
||||
if er.status_code != 201:
|
||||
print(f"[FAIL] 创建执行 {er.status_code}: {er.text[:800]}")
|
||||
return False
|
||||
ex = er.json()
|
||||
eid = ex["id"]
|
||||
print(f"[OK] execution_id={eid}")
|
||||
|
||||
st, detail = _poll_until_terminal(base_url, headers, eid, max_wait, poll_iv, req_timeout)
|
||||
text = _extract_output_text((detail or {}).get("output_data"))
|
||||
expect = case.get("expect") or {}
|
||||
errs = _check_expect(text, st, detail, expect)
|
||||
if errs:
|
||||
for e in errs:
|
||||
print(f"[FAIL] {e}")
|
||||
if text:
|
||||
print("[OUTPUT_PREVIEW]")
|
||||
print(text[:2000] + ("…" if len(text) > 2000 else ""))
|
||||
return False
|
||||
print(f"[OK] 通过 status={st}")
|
||||
if text:
|
||||
print("[OUTPUT_PREVIEW]")
|
||||
print(text[:1200] + ("…" if len(text) > 1200 else ""))
|
||||
return True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="批量运行 Agent 测试用例(JSON)")
|
||||
ap.add_argument(
|
||||
"--cases",
|
||||
default=os.environ.get("AGENT_TEST_CASES", DEFAULT_CASES_FILE),
|
||||
help=f"用例 JSON 路径(默认 {DEFAULT_CASES_FILE})",
|
||||
)
|
||||
ap.add_argument("--username", default=None)
|
||||
ap.add_argument("--password", default=None)
|
||||
ap.add_argument("--base-url", default=None, help="覆盖 defaults.base_url / API_BASE_URL")
|
||||
args = ap.parse_args()
|
||||
|
||||
path = args.cases
|
||||
if not os.path.isfile(path):
|
||||
print(f"[FAIL] 找不到用例文件: {path}")
|
||||
print("请先按 (红头)agent测试用例文档.md 创建 JSON,或复制示例为 agent_test_cases.json")
|
||||
return 2
|
||||
|
||||
with open(path, encoding="utf-8") as f:
|
||||
spec = json.load(f)
|
||||
|
||||
defaults = spec.get("defaults") or {}
|
||||
base_url = (
|
||||
args.base_url
|
||||
or defaults.get("base_url")
|
||||
or os.environ.get("API_BASE_URL", "http://localhost:8037")
|
||||
)
|
||||
base_url = base_url.rstrip("/")
|
||||
|
||||
username = args.username or defaults.get("username", "admin")
|
||||
password = args.password or defaults.get("password", "123456")
|
||||
req_timeout = float(defaults.get("request_timeout_sec", 120))
|
||||
|
||||
print(f"API: {base_url}")
|
||||
print(f"用例文件: {path}")
|
||||
|
||||
headers = _login(base_url, username, password, req_timeout)
|
||||
if not headers:
|
||||
return 3
|
||||
|
||||
cases: List[Dict[str, Any]] = spec.get("cases") or []
|
||||
if not cases:
|
||||
print("[FAIL] cases 为空")
|
||||
return 4
|
||||
|
||||
ok, skip, fail = 0, 0, 0
|
||||
for case in cases:
|
||||
if case.get("enabled") is False:
|
||||
print(f"\n[SKIP] {case.get('id', '?')}")
|
||||
skip += 1
|
||||
continue
|
||||
if _run_one_case(base_url, headers, defaults, case):
|
||||
ok += 1
|
||||
else:
|
||||
fail += 1
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(f"汇总: 通过 {ok} 失败 {fail} 跳过 {skip}")
|
||||
return 0 if fail == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
83
start_aiagent.ps1
Normal file
83
start_aiagent.ps1
Normal file
@@ -0,0 +1,83 @@
|
||||
param(
|
||||
[int]$ApiPort = 8037,
|
||||
[int]$FallbackApiPort = 8041,
|
||||
[int]$FrontendPort = 3001
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$RepoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$Backend = Join-Path $RepoRoot "backend"
|
||||
$Frontend = Join-Path $RepoRoot "frontend"
|
||||
$RedisDir = Join-Path $Backend "redis"
|
||||
$RedisExe = Join-Path $RedisDir "redis-server.exe"
|
||||
$RedisCli = Join-Path $RedisDir "redis-cli.exe"
|
||||
|
||||
function Test-PortListening([int]$Port) {
|
||||
$line = netstat -ano | Select-String ":$Port\s+.*LISTENING" | Select-Object -First 1
|
||||
return [bool]$line
|
||||
}
|
||||
|
||||
function Ensure-Redis {
|
||||
if (Test-PortListening 6379) {
|
||||
Write-Host "[OK] Redis already listening on 6379" -ForegroundColor Green
|
||||
return
|
||||
}
|
||||
if (-not (Test-Path $RedisExe)) {
|
||||
throw "Redis 可执行文件不存在:$RedisExe"
|
||||
}
|
||||
Write-Host "[RUN] Starting Redis on 6379 ..." -ForegroundColor Yellow
|
||||
Start-Process -FilePath $RedisExe -ArgumentList "--port 6379" -WorkingDirectory $RedisDir | Out-Null
|
||||
Start-Sleep -Seconds 2
|
||||
if (-not (Test-PortListening 6379)) {
|
||||
throw "Redis 启动失败,6379 未监听"
|
||||
}
|
||||
if (Test-Path $RedisCli) {
|
||||
& $RedisCli -p 6379 ping | Out-Null
|
||||
}
|
||||
Write-Host "[OK] Redis started" -ForegroundColor Green
|
||||
}
|
||||
|
||||
function Resolve-ApiPort {
|
||||
if (-not (Test-PortListening $ApiPort)) {
|
||||
return $ApiPort
|
||||
}
|
||||
Write-Host "[WARN] Port $ApiPort is occupied, switching to $FallbackApiPort" -ForegroundColor Yellow
|
||||
if (Test-PortListening $FallbackApiPort) {
|
||||
throw "端口 $ApiPort 和 $FallbackApiPort 都被占用,请先释放端口"
|
||||
}
|
||||
return $FallbackApiPort
|
||||
}
|
||||
|
||||
Write-Host "== AIAgent Windows 一键启动 ==" -ForegroundColor Cyan
|
||||
Write-Host "Repo: $RepoRoot"
|
||||
|
||||
Ensure-Redis
|
||||
$RealApiPort = Resolve-ApiPort
|
||||
$ApiBase = "http://127.0.0.1:$RealApiPort"
|
||||
|
||||
Write-Host "[RUN] Starting backend API on $RealApiPort ..." -ForegroundColor Yellow
|
||||
Start-Process powershell -ArgumentList @(
|
||||
"-NoExit",
|
||||
"-Command",
|
||||
"cd '$Backend'; .\venv\Scripts\Activate.ps1; python -m uvicorn app.main:app --host 0.0.0.0 --port $RealApiPort"
|
||||
)
|
||||
|
||||
Write-Host "[RUN] Starting Celery worker ..." -ForegroundColor Yellow
|
||||
Start-Process powershell -ArgumentList @(
|
||||
"-NoExit",
|
||||
"-Command",
|
||||
"cd '$Backend'; .\venv\Scripts\Activate.ps1; python -m celery -A app.core.celery_app worker --loglevel=info --pool=threads --concurrency=8"
|
||||
)
|
||||
|
||||
Write-Host "[RUN] Starting frontend on $FrontendPort (proxy -> $ApiBase) ..." -ForegroundColor Yellow
|
||||
Start-Process powershell -ArgumentList @(
|
||||
"-NoExit",
|
||||
"-Command",
|
||||
"`$env:AIAGENT_API_PROXY='$ApiBase'; cd '$Frontend'; pnpm dev --port $FrontendPort"
|
||||
)
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "[DONE] 启动命令已下发" -ForegroundColor Green
|
||||
Write-Host "前端: http://localhost:$FrontendPort" -ForegroundColor Cyan
|
||||
Write-Host "后端: $ApiBase/docs" -ForegroundColor Cyan
|
||||
Write-Host "Redis: 127.0.0.1:6379" -ForegroundColor Cyan
|
||||
49
stop_aiagent.ps1
Normal file
49
stop_aiagent.ps1
Normal file
@@ -0,0 +1,49 @@
|
||||
$ErrorActionPreference = "SilentlyContinue"
|
||||
|
||||
Write-Host "== AIAgent stop ==" -ForegroundColor Cyan
|
||||
|
||||
function Stop-ByCommandLine($pattern, $name) {
|
||||
$targets = Get-CimInstance Win32_Process | Where-Object {
|
||||
$_.CommandLine -and $_.CommandLine -match $pattern
|
||||
}
|
||||
if (-not $targets) {
|
||||
Write-Host "[SKIP] ${name}: no matching process" -ForegroundColor DarkGray
|
||||
return
|
||||
}
|
||||
foreach ($p in $targets) {
|
||||
try {
|
||||
Stop-Process -Id $p.ProcessId -Force
|
||||
Write-Host "[OK] stopped ${name} PID=$($p.ProcessId)" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host "[WARN] failed to stop ${name} PID=$($p.ProcessId)" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 1) 停后端 API(uvicorn app.main:app)
|
||||
Stop-ByCommandLine "uvicorn\s+app\.main:app" "backend-api"
|
||||
|
||||
# 2) 停 Celery Worker
|
||||
Stop-ByCommandLine "celery\s+-A\s+app\.core\.celery_app\s+worker" "celery-worker"
|
||||
|
||||
# 3) 停前端 dev(vite / pnpm dev / npm run dev)
|
||||
Stop-ByCommandLine "vite(\.js)?\s|pnpm\s+dev|npm\s+run\s+dev" "frontend-dev"
|
||||
|
||||
# 4) 停 Redis(优先匹配本项目 redis-server)
|
||||
Stop-ByCommandLine "redis-server(\.exe)?(\s|$)" "redis"
|
||||
|
||||
Start-Sleep -Milliseconds 600
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Port check:" -ForegroundColor Cyan
|
||||
foreach ($port in 3001, 8037, 8041, 6379) {
|
||||
$line = netstat -ano | Select-String ":$port\s+.*LISTENING" | Select-Object -First 1
|
||||
if ($line) {
|
||||
Write-Host " - ${port}: LISTEN" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host " - ${port}: free" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "DONE: stop script finished" -ForegroundColor Green
|
||||
@@ -2,201 +2,306 @@
|
||||
"""
|
||||
Agent工作流执行测试脚本
|
||||
用于测试Agent工作流的正常执行
|
||||
|
||||
与《工作流调用测试总结》一致:input_data 仅包含 query、USER_INPUT,便于 LLM 正确提取 user_query。
|
||||
|
||||
用法示例:
|
||||
python test_agent_execution.py
|
||||
python test_agent_execution.py <agent_id>
|
||||
python test_agent_execution.py <agent_id> "你好"
|
||||
python test_agent_execution.py --homework
|
||||
python test_agent_execution.py --homework --base-url http://127.0.0.1:8037
|
||||
"""
|
||||
import requests
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import time
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
# API基础URL
|
||||
BASE_URL = "http://localhost:8037"
|
||||
import requests
|
||||
|
||||
def print_section(title):
|
||||
"""打印分隔线"""
|
||||
DEFAULT_BASE_URL = os.environ.get("API_BASE_URL", "http://localhost:8037")
|
||||
|
||||
|
||||
def _ensure_utf8_stdio() -> None:
|
||||
"""Windows 默认 GBK 控制台打印含 emoji 的模型回复会报错,尽量切到 UTF-8。"""
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
for name in ("stdout", "stderr"):
|
||||
stream = getattr(sys, name, None)
|
||||
if stream is not None and hasattr(stream, "reconfigure"):
|
||||
try:
|
||||
stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
_ensure_utf8_stdio()
|
||||
|
||||
DEFAULT_ANDROID_PROMPT = "生成一个导出androidlog的脚本"
|
||||
HOMEWORK_AGENT_NAME = "学生作业管理助手"
|
||||
HOMEWORK_DEFAULT_MESSAGE = "你好"
|
||||
|
||||
|
||||
def print_section(title: str) -> None:
|
||||
print("\n" + "=" * 80)
|
||||
print(f" {title}")
|
||||
print("=" * 80)
|
||||
|
||||
def test_agent_execution(agent_id: str = None, user_input: str = "生成一个导出androidlog的脚本"):
|
||||
"""
|
||||
测试Agent执行
|
||||
|
||||
Args:
|
||||
agent_id: Agent ID,如果为None则自动查找第一个已发布的Agent
|
||||
user_input: 用户输入内容
|
||||
"""
|
||||
print_section("Agent工作流执行测试")
|
||||
|
||||
# 1. 登录获取token
|
||||
print_section("1. 用户登录")
|
||||
login_data = {
|
||||
"username": "admin",
|
||||
"password": "123456"
|
||||
}
|
||||
|
||||
try:
|
||||
# OAuth2PasswordRequestForm需要form-data格式
|
||||
response = requests.post(f"{BASE_URL}/api/v1/auth/login", data=login_data)
|
||||
if response.status_code != 200:
|
||||
print(f"❌ 登录失败: {response.status_code}")
|
||||
print(f"响应: {response.text}")
|
||||
return
|
||||
|
||||
token = response.json().get("access_token")
|
||||
if not token:
|
||||
print("❌ 登录失败: 未获取到token")
|
||||
return
|
||||
|
||||
print("✅ 登录成功")
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
except Exception as e:
|
||||
print(f"❌ 登录异常: {str(e)}")
|
||||
return
|
||||
|
||||
# 2. 获取Agent列表(如果没有指定agent_id)
|
||||
if not agent_id:
|
||||
print_section("2. 查找可用的Agent")
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{BASE_URL}/api/v1/agents",
|
||||
headers=headers,
|
||||
params={"status": "published", "limit": 10}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
agents = response.json()
|
||||
if agents:
|
||||
# 优先选择已发布的Agent
|
||||
published_agents = [a for a in agents if a.get("status") == "published"]
|
||||
if published_agents:
|
||||
agent_id = published_agents[0]["id"]
|
||||
agent_name = published_agents[0]["name"]
|
||||
print(f"✅ 找到已发布的Agent: {agent_name} (ID: {agent_id})")
|
||||
else:
|
||||
# 如果没有已发布的,使用第一个
|
||||
agent_id = agents[0]["id"]
|
||||
agent_name = agents[0]["name"]
|
||||
print(f"⚠️ 使用Agent: {agent_name} (ID: {agent_id}) - 状态: {agents[0].get('status')}")
|
||||
else:
|
||||
print("❌ 未找到可用的Agent")
|
||||
print("请先创建一个Agent并发布,或者指定agent_id参数")
|
||||
return
|
||||
else:
|
||||
print(f"❌ 获取Agent列表失败: {response.status_code}")
|
||||
print(f"响应: {response.text}")
|
||||
return
|
||||
except Exception as e:
|
||||
print(f"❌ 获取Agent列表异常: {str(e)}")
|
||||
return
|
||||
|
||||
# 3. 执行Agent
|
||||
print_section("3. 执行Agent工作流")
|
||||
print(f"用户输入: {user_input}")
|
||||
|
||||
input_data = {
|
||||
"query": user_input,
|
||||
"USER_INPUT": user_input
|
||||
}
|
||||
|
||||
execution_data = {
|
||||
"agent_id": agent_id,
|
||||
"input_data": input_data
|
||||
}
|
||||
|
||||
|
||||
def _login(
|
||||
base_url: str,
|
||||
username: str,
|
||||
password: str,
|
||||
timeout: int,
|
||||
) -> Optional[Dict[str, str]]:
|
||||
login_data = {"username": username, "password": password}
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{BASE_URL}/api/v1/executions",
|
||||
headers=headers,
|
||||
json=execution_data
|
||||
f"{base_url}/api/v1/auth/login",
|
||||
data=login_data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
if response.status_code != 201:
|
||||
print(f"❌ 创建执行任务失败: {response.status_code}")
|
||||
print(f"响应: {response.text}")
|
||||
if response.status_code != 200:
|
||||
print(f"[FAIL] 登录失败: {response.status_code}")
|
||||
print(f"响应: {response.text[:800]}")
|
||||
return None
|
||||
token = response.json().get("access_token")
|
||||
if not token:
|
||||
print("[FAIL] 登录失败: 未获取到 token")
|
||||
return None
|
||||
print("[OK] 登录成功")
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
except Exception as e:
|
||||
print(f"[FAIL] 登录异常: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _find_agent_by_name(
|
||||
base_url: str,
|
||||
headers: Dict[str, str],
|
||||
name: str,
|
||||
timeout: int,
|
||||
) -> Optional[str]:
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{base_url}/api/v1/agents",
|
||||
headers=headers,
|
||||
params={"search": name, "limit": 100},
|
||||
timeout=timeout,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
print(f"[FAIL] 按名称查找 Agent 失败: {response.status_code}")
|
||||
print(response.text[:800])
|
||||
return None
|
||||
agents: List[Dict[str, Any]] = response.json() or []
|
||||
exact = [a for a in agents if (a.get("name") or "").strip() == name]
|
||||
pick = exact[0] if exact else (agents[0] if agents else None)
|
||||
if not pick:
|
||||
print(f"[FAIL] 未找到名为「{name}」的 Agent(search 无结果)")
|
||||
return None
|
||||
print(
|
||||
f"[OK] 使用 Agent: {pick.get('name')} (ID: {pick['id']}) "
|
||||
f"状态: {pick.get('status')}"
|
||||
)
|
||||
return str(pick["id"])
|
||||
except Exception as e:
|
||||
print(f"[FAIL] 查找 Agent 异常: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _find_first_published_agent(
|
||||
base_url: str,
|
||||
headers: Dict[str, str],
|
||||
timeout: int,
|
||||
) -> Optional[str]:
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{base_url}/api/v1/agents",
|
||||
headers=headers,
|
||||
params={"status": "published", "limit": 10},
|
||||
timeout=timeout,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
print(f"[FAIL] 获取 Agent 列表失败: {response.status_code}")
|
||||
print(response.text[:800])
|
||||
return None
|
||||
agents: List[Dict[str, Any]] = response.json() or []
|
||||
if not agents:
|
||||
print("[FAIL] 未找到可用的 Agent")
|
||||
print("请先创建并发布 Agent,或指定 agent_id / --agent-name / --homework")
|
||||
return None
|
||||
published_agents = [a for a in agents if a.get("status") == "published"]
|
||||
if published_agents:
|
||||
a = published_agents[0]
|
||||
print(f"[OK] 找到已发布的 Agent: {a['name']} (ID: {a['id']})")
|
||||
return str(a["id"])
|
||||
a = agents[0]
|
||||
print(
|
||||
f"[WARN] 使用 Agent: {a['name']} (ID: {a['id']}) - 状态: {a.get('status')}"
|
||||
)
|
||||
return str(a["id"])
|
||||
except Exception as e:
|
||||
print(f"[FAIL] 获取 Agent 列表异常: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def test_agent_execution(
|
||||
agent_id: Optional[str] = None,
|
||||
user_input: str = DEFAULT_ANDROID_PROMPT,
|
||||
*,
|
||||
base_url: str = DEFAULT_BASE_URL,
|
||||
username: str = "admin",
|
||||
password: str = "123456",
|
||||
agent_name: Optional[str] = None,
|
||||
request_timeout: int = 120,
|
||||
max_wait_time: int = 300,
|
||||
poll_interval: float = 2.0,
|
||||
) -> None:
|
||||
"""
|
||||
测试 Agent 执行
|
||||
|
||||
Args:
|
||||
agent_id: Agent ID;为 None 时按 agent_name 或已发布列表解析
|
||||
user_input: 用户输入(写入 query / USER_INPUT)
|
||||
base_url: API 根地址
|
||||
agent_name: 按名称精确匹配查找(配合 search 参数)
|
||||
request_timeout: 单次 HTTP 超时(秒)
|
||||
max_wait_time: 轮询最长等待(秒)
|
||||
poll_interval: 轮询间隔(秒)
|
||||
"""
|
||||
base_url = base_url.rstrip("/")
|
||||
print_section("Agent工作流执行测试")
|
||||
print(f"API: {base_url}")
|
||||
|
||||
print_section("1. 用户登录")
|
||||
headers = _login(base_url, username, password, request_timeout)
|
||||
if not headers:
|
||||
return
|
||||
|
||||
if not agent_id:
|
||||
print_section("2. 查找可用的 Agent")
|
||||
if agent_name:
|
||||
agent_id = _find_agent_by_name(
|
||||
base_url, headers, agent_name, request_timeout
|
||||
)
|
||||
else:
|
||||
agent_id = _find_first_published_agent(
|
||||
base_url, headers, request_timeout
|
||||
)
|
||||
if not agent_id:
|
||||
return
|
||||
else:
|
||||
print_section("2. 使用指定的 Agent")
|
||||
print(f"Agent ID: {agent_id}")
|
||||
|
||||
print_section("3. 执行 Agent 工作流")
|
||||
print(f"用户输入: {user_input}")
|
||||
|
||||
input_data = {"query": user_input, "USER_INPUT": user_input}
|
||||
execution_data = {"agent_id": agent_id, "input_data": input_data}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{base_url}/api/v1/executions",
|
||||
headers=headers,
|
||||
json=execution_data,
|
||||
timeout=request_timeout,
|
||||
)
|
||||
if response.status_code != 201:
|
||||
print(f"[FAIL] 创建执行任务失败: {response.status_code}")
|
||||
print(f"响应: {response.text[:2000]}")
|
||||
return
|
||||
|
||||
execution = response.json()
|
||||
execution_id = execution["id"]
|
||||
print(f"✅ 执行任务已创建: {execution_id}")
|
||||
print(f"[OK] 执行任务已创建: {execution_id}")
|
||||
print(f"状态: {execution.get('status')}")
|
||||
except Exception as e:
|
||||
print(f"❌ 创建执行任务异常: {str(e)}")
|
||||
print(f"[FAIL] 创建执行任务异常: {e}")
|
||||
return
|
||||
|
||||
# 4. 轮询执行状态
|
||||
|
||||
print_section("4. 等待执行完成")
|
||||
max_wait_time = 300 # 最大等待5分钟
|
||||
start_time = time.time()
|
||||
poll_interval = 2 # 每2秒轮询一次
|
||||
|
||||
final_loop_status: Optional[str] = None
|
||||
|
||||
while True:
|
||||
elapsed_time = time.time() - start_time
|
||||
if elapsed_time > max_wait_time:
|
||||
print(f"❌ 执行超时(超过{max_wait_time}秒)")
|
||||
print(f"[FAIL] 执行超时(超过 {max_wait_time} 秒)")
|
||||
break
|
||||
|
||||
try:
|
||||
# 获取执行状态
|
||||
status_response = requests.get(
|
||||
f"{BASE_URL}/api/v1/executions/{execution_id}/status",
|
||||
headers=headers
|
||||
f"{base_url}/api/v1/executions/{execution_id}/status",
|
||||
headers=headers,
|
||||
timeout=request_timeout,
|
||||
)
|
||||
|
||||
if status_response.status_code == 200:
|
||||
status = status_response.json()
|
||||
current_status = status.get("status")
|
||||
|
||||
# 显示进度
|
||||
final_loop_status = current_status
|
||||
progress = status.get("progress", 0)
|
||||
print(f"⏳ 执行中... 状态: {current_status}, 进度: {progress}%", end="\r")
|
||||
|
||||
print(
|
||||
f"[...] 执行中 状态={current_status} 进度={progress}%",
|
||||
end="\r",
|
||||
)
|
||||
if current_status == "completed":
|
||||
print("\n✅ 执行完成!")
|
||||
print("\n[OK] 执行完成")
|
||||
break
|
||||
elif current_status == "failed":
|
||||
print(f"\n❌ 执行失败")
|
||||
error = status.get("error", "未知错误")
|
||||
print(f"错误信息: {error}")
|
||||
if current_status == "failed":
|
||||
print("\n[FAIL] 执行失败")
|
||||
err = status.get("error") or status.get("error_message")
|
||||
if not err and status.get("failed_nodes"):
|
||||
fn = status["failed_nodes"][0]
|
||||
err = fn.get("error_message") or fn.get("error_type")
|
||||
print(f"错误信息: {err or '未知错误'}")
|
||||
break
|
||||
if current_status in ("cancelled", "awaiting_approval"):
|
||||
print(f"\n[WARN] 结束轮询: 状态={current_status}")
|
||||
break
|
||||
|
||||
# 显示当前执行的节点
|
||||
current_node = status.get("current_node")
|
||||
if current_node:
|
||||
print(f"\n 当前节点: {current_node.get('node_id')} - {current_node.get('node_name')}")
|
||||
|
||||
nid = current_node.get("node_id")
|
||||
ntype = current_node.get("node_type")
|
||||
print(f"\n 当前节点: {nid} ({ntype})")
|
||||
time.sleep(poll_interval)
|
||||
except Exception as e:
|
||||
print(f"\n❌ 获取执行状态异常: {str(e)}")
|
||||
print(f"\n[FAIL] 获取执行状态异常: {e}")
|
||||
time.sleep(poll_interval)
|
||||
|
||||
# 5. 获取执行结果
|
||||
|
||||
print_section("5. 获取执行结果")
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{BASE_URL}/api/v1/executions/{execution_id}",
|
||||
headers=headers
|
||||
f"{base_url}/api/v1/executions/{execution_id}",
|
||||
headers=headers,
|
||||
timeout=request_timeout,
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
execution = response.json()
|
||||
status = execution.get("status")
|
||||
output_data = execution.get("output_data")
|
||||
execution_time = execution.get("execution_time")
|
||||
|
||||
err_msg = execution.get("error_message")
|
||||
|
||||
print(f"执行状态: {status}")
|
||||
if execution_time:
|
||||
if err_msg and status != "completed":
|
||||
print(f"服务端错误信息: {err_msg[:2000]}")
|
||||
if execution_time is not None:
|
||||
print(f"执行时间: {execution_time}ms")
|
||||
|
||||
|
||||
print("\n输出结果:")
|
||||
print("-" * 80)
|
||||
if output_data:
|
||||
if isinstance(output_data, dict):
|
||||
# 尝试提取文本输出
|
||||
text_output = (
|
||||
output_data.get("output") or
|
||||
output_data.get("text") or
|
||||
output_data.get("content") or
|
||||
output_data.get("result") or
|
||||
json.dumps(output_data, ensure_ascii=False, indent=2)
|
||||
output_data.get("result")
|
||||
or output_data.get("output")
|
||||
or output_data.get("text")
|
||||
or output_data.get("content")
|
||||
or json.dumps(output_data, ensure_ascii=False, indent=2)
|
||||
)
|
||||
print(text_output)
|
||||
else:
|
||||
@@ -204,28 +309,87 @@ def test_agent_execution(agent_id: str = None, user_input: str = "生成一个
|
||||
else:
|
||||
print("(无输出数据)")
|
||||
print("-" * 80)
|
||||
|
||||
# 显示执行日志(如果有)
|
||||
|
||||
if execution.get("logs"):
|
||||
print("\n执行日志:")
|
||||
for log in execution.get("logs", []):
|
||||
print(f" [{log.get('timestamp')}] {log.get('message')}")
|
||||
else:
|
||||
print(f"❌ 获取执行结果失败: {response.status_code}")
|
||||
print(f"响应: {response.text}")
|
||||
print(f"[FAIL] 获取执行结果失败: {response.status_code}")
|
||||
print(f"响应: {response.text[:2000]}")
|
||||
except Exception as e:
|
||||
print(f"❌ 获取执行结果异常: {str(e)}")
|
||||
|
||||
print(f"[FAIL] 获取执行结果异常: {e}")
|
||||
|
||||
print_section("测试完成")
|
||||
if final_loop_status and final_loop_status != "completed":
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(
|
||||
description="Agent 工作流执行测试(input_data 与总结文档一致:query + USER_INPUT)"
|
||||
)
|
||||
p.add_argument("agent_id", nargs="?", default=None, help="Agent UUID(可选)")
|
||||
p.add_argument("user_input", nargs="?", default=None, help="用户消息(可选)")
|
||||
p.add_argument(
|
||||
"--homework",
|
||||
action="store_true",
|
||||
help=f"测试「{HOMEWORK_AGENT_NAME}」,默认发送「{HOMEWORK_DEFAULT_MESSAGE}」",
|
||||
)
|
||||
p.add_argument(
|
||||
"--agent-name",
|
||||
default=None,
|
||||
help="按名称精确查找 Agent(未传 agent_id 时)",
|
||||
)
|
||||
p.add_argument("--base-url", default=DEFAULT_BASE_URL, help="API 根地址")
|
||||
p.add_argument("--username", default="admin")
|
||||
p.add_argument("--password", default="123456")
|
||||
p.add_argument("--request-timeout", type=int, default=120, help="单次 HTTP 超时秒数")
|
||||
p.add_argument("--max-wait", type=int, default=300, help="轮询最长等待秒数")
|
||||
p.add_argument("--poll-interval", type=float, default=2.0)
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 从命令行参数获取agent_id和user_input
|
||||
agent_id = None
|
||||
user_input = "生成一个导出androidlog的脚本"
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
agent_id = sys.argv[1]
|
||||
if len(sys.argv) > 2:
|
||||
user_input = sys.argv[2]
|
||||
|
||||
test_agent_execution(agent_id=agent_id, user_input=user_input)
|
||||
args = _parse_args()
|
||||
name: Optional[str] = None
|
||||
uid: Optional[str] = args.agent_id
|
||||
msg: str
|
||||
|
||||
if args.homework and args.agent_name:
|
||||
print("[WARN] 同时指定 --homework 与 --agent-name,将使用 --agent-name 查找")
|
||||
|
||||
if args.agent_name:
|
||||
name = args.agent_name
|
||||
msg = (
|
||||
args.user_input
|
||||
if args.user_input is not None
|
||||
else (
|
||||
HOMEWORK_DEFAULT_MESSAGE
|
||||
if args.homework
|
||||
else DEFAULT_ANDROID_PROMPT
|
||||
)
|
||||
)
|
||||
elif args.homework:
|
||||
name = HOMEWORK_AGENT_NAME
|
||||
msg = (
|
||||
args.user_input if args.user_input is not None else HOMEWORK_DEFAULT_MESSAGE
|
||||
)
|
||||
else:
|
||||
msg = (
|
||||
args.user_input
|
||||
if args.user_input is not None
|
||||
else DEFAULT_ANDROID_PROMPT
|
||||
)
|
||||
|
||||
test_agent_execution(
|
||||
agent_id=uid,
|
||||
user_input=msg,
|
||||
base_url=args.base_url,
|
||||
username=args.username,
|
||||
password=args.password,
|
||||
agent_name=name if not uid else None,
|
||||
request_timeout=args.request_timeout,
|
||||
max_wait_time=args.max_wait,
|
||||
poll_interval=args.poll_interval,
|
||||
)
|
||||
|
||||
85
windows启动和停止用法.md
Normal file
85
windows启动和停止用法.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# AIAgent Windows 启动和停止用法
|
||||
|
||||
## 一键启动
|
||||
|
||||
在 PowerShell 中执行:
|
||||
|
||||
```powershell
|
||||
cd D:\aaa\aiagent
|
||||
powershell -ExecutionPolicy Bypass -File .\start_aiagent.ps1
|
||||
```
|
||||
|
||||
启动后默认访问:
|
||||
- 前端:`http://localhost:3001`
|
||||
- 后端文档:`http://127.0.0.1:8037/docs`
|
||||
- Redis:`127.0.0.1:6379`
|
||||
|
||||
说明:
|
||||
- 若 `8037` 被占用,脚本会自动切换到 `8041` 启动后端。
|
||||
- 前端会自动使用 `AIAGENT_API_PROXY` 指向实际后端端口。
|
||||
|
||||
## 启动参数(可选)
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\start_aiagent.ps1 -ApiPort 8037 -FallbackApiPort 8041 -FrontendPort 3001
|
||||
```
|
||||
|
||||
## 一键停止
|
||||
|
||||
在 PowerShell 中执行:
|
||||
|
||||
```powershell
|
||||
cd D:\aaa\aiagent
|
||||
powershell -ExecutionPolicy Bypass -File .\stop_aiagent.ps1
|
||||
```
|
||||
|
||||
会尝试停止以下进程:
|
||||
- 后端 API(`uvicorn app.main:app`)
|
||||
- Celery Worker(`celery -A app.core.celery_app worker`)
|
||||
- 前端 dev(`vite` / `pnpm dev` / `npm run dev`)
|
||||
- Redis(`redis-server`)
|
||||
|
||||
并检查端口:
|
||||
- `3001`
|
||||
- `8037`
|
||||
- `8041`
|
||||
- `6379`
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1) 执行策略拦截脚本
|
||||
|
||||
先执行:
|
||||
|
||||
```powershell
|
||||
Set-ExecutionPolicy -Scope Process Bypass
|
||||
```
|
||||
|
||||
### 2) 发送消息超时(30 秒)
|
||||
|
||||
优先检查 Redis 和 Worker:
|
||||
|
||||
```powershell
|
||||
netstat -ano | findstr :6379
|
||||
```
|
||||
|
||||
确认 Celery Worker 正在运行。
|
||||
|
||||
### 3) 8037 端口被占用
|
||||
|
||||
先查占用:
|
||||
|
||||
```powershell
|
||||
netstat -ano | findstr :8037
|
||||
```
|
||||
|
||||
再结束对应 PID(管理员 PowerShell):
|
||||
|
||||
```powershell
|
||||
taskkill /PID <PID> /T /F
|
||||
```
|
||||
|
||||
## 脚本文件位置
|
||||
|
||||
- 启动脚本:`D:\aaa\aiagent\start_aiagent.ps1`
|
||||
- 停止脚本:`D:\aaa\aiagent\stop_aiagent.ps1`
|
||||
Reference in New Issue
Block a user