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:
renjianbo
2026-04-13 20:17:18 +08:00
parent 0608161c82
commit df4fab1e6e
31 changed files with 3784 additions and 251 deletions

View 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 侧固定模型与温度或在用例里放宽为12 个关键词。
---
## 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
View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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 字节)
# 图片 OCRfile_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"

View File

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

View 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)

View File

@@ -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:
文件内容或错误信息
JSONfile_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。请安装 TesseractWindows 可装官方安装包),"
"并在 .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": {

View File

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

View File

@@ -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=rightStart 输出在 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_idpreview_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']

View File

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

View File

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

View File

@@ -46,6 +46,13 @@ kafka-python==2.0.2 # Kafka
# Utilities
python-dateutil==2.8.2
# file_readPDF / 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

View File

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

View 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. **监督完成**:根据清单追问进度(未开始/进行中/已完成);对临近截止的任务给**温和提醒**(不制造焦虑);可建议拆成小步骤与每日 1530 分钟微习惯。
3. **周回顾**:用户要求时,用 json_process 或清晰表格输出本周完成率、延期项与下周优先三件事。
【原则】
- **不代写**可提交的作业正文、实验报告、论文等;可提供提纲、自检表、引用规范提示。
- 日期时间以用户所在语境为准;需要当前时间可借助工具 datetime。
- 不确定的信息(如具体截止时刻)先列出假设并请用户确认。
- 输出优先中文;列表用编号,便于复制到备忘录。
【交互习惯】
- 用户只说「记一下数学作业」时,主动追问截止日与具体要求(一次问 12 个点,避免审问感)。
- 用户汇报「做完了」时,确认是否需拍照/上传检查清单,并建议归档到下一条任务前的小结一句话。
"""
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())

View 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())

View File

@@ -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` 同轮勿无故重复写入同一文件除非必要。
"""

View 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())

View File

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

View 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())

View File

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

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
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>

View File

@@ -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
View 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
View 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
View 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) 停后端 APIuvicorn 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) 停前端 devvite / 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

View File

@@ -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}」的 Agentsearch 无结果)")
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,
)

View 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`