diff --git a/(红头)agent测试用例文档.md b/(红头)agent测试用例文档.md new file mode 100644 index 0000000..6ee1507 --- /dev/null +++ b/(红头)agent测试用例文档.md @@ -0,0 +1,193 @@ +# Agent 通用测试用例规范 + +本文档约定**任意 Agent** 如何通过同一套接口做自动化黑盒测试。你只要按下面的 **JSON 用例文件格式** 写好(或从模板复制改名字/话术),即可用仓库根目录脚本**一次性跑完全部用例**。 + +--- + +## 1. 前置条件 + +| 项 | 说明 | +|----|------| +| 后端 API | 默认 `http://localhost:8037`,可用环境变量 `API_BASE_URL` 覆盖 | +| 登录 | OAuth2 表单:`POST /api/v1/auth/login`,字段 `username`、`password` | +| 执行 Agent | `POST /api/v1/executions`,body 含 `agent_id` 与 `input_data` | +| 轮询 | `GET /api/v1/executions/{id}/status`;结束后再 `GET /api/v1/executions/{id}` 取 `output_data` | +| 异步 | 需 **Redis** 与 **Celery Worker** 正常,否则创建执行可能返回 503 | + +**输入字段约定(与《工作流调用测试总结》及前端预览一致):** + +- 最小集:`query`、`USER_INPUT`(字符串相同即可),便于工作流提取 `user_query`。 +- 若某 Agent 在设计器里还依赖 `user_id`、`attachments` 等,可在用例的 `input_extra` 里追加(见第 3 节)。 + +--- + +## 2. 一键跑用例(推荐) + +1. 复制本文档第 5 节示例,或复制仓库内 **`agent_test_cases.example.json`** 为 **`agent_test_cases.json`**(或任意路径)。 +2. 在项目根目录执行: + +```bash +python run_agent_test_cases.py +``` + +指定文件与账号: + +```bash +python run_agent_test_cases.py --cases my_cases.json --username admin --password 你的密码 --base-url http://127.0.0.1:8037 +``` + +环境变量(可选): + +- `API_BASE_URL`:例如 `http://127.0.0.1:8037` +- `AGENT_TEST_CASES`:默认用例文件路径(不设则使用 `agent_test_cases.json`) +- Windows 控制台建议已使用 UTF-8(脚本内会尽量 `reconfigure` stdout,避免模型回复含 emoji 时打印报错) + +--- + +## 3. 用例文件格式(JSON Schema说明) + +根对象字段: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `version` | number | 建议 | 目前使用 `1` | +| `defaults` | object | 否 | 全局默认:URL、账号、超时、轮询间隔等 | +| `cases` | array | **是** | 测试用例列表 | + +### 3.1 `defaults` 可选键 + +| 键 | 类型 | 默认 | 说明 | +|----|------|------|------| +| `base_url` | string | `API_BASE_URL` 或 `http://localhost:8037` | API 根地址 | +| `username` | string | `admin` | 登录用户名 | +| `password` | string | `123456` | 登录密码 | +| `request_timeout_sec` | number | `120` | 单次 HTTP 超时(秒) | +| `max_wait_sec` | number | `300` | 单条用例轮询最长时间(秒) | +| `poll_interval_sec` | number | `2` | 轮询间隔(秒) | + +### 3.2 单条 `cases[]` 对象 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `id` | string | 建议 | 用例 ID,日志与报告中展示 | +| `name` | string | 否 | 简短描述 | +| `enabled` | boolean | 否 | 默认 `true`;`false` 则跳过 | +| `agent` | object | **是** | 见下表「定位 Agent」 | +| `message` | string | **是** | 用户话术;会写入 `query` 与 `USER_INPUT` | +| `input_extra` | object | 否 | 合并进 `input_data`(勿覆盖 `query`/`USER_INPUT` 除非你有意为之) | +| `expect` | object | 否 | 断言;缺省则仅校验能跑完且状态为 `completed`(见 3.3) | + +**`agent` 定位(二选一,不可同时缺):** + +| 键 | 说明 | +|----|------| +| `id` | Agent UUID,优先级最高 | +| `name` | 精确名称;通过 `GET /api/v1/agents?search=...` 再在结果里做**名称全等**匹配;若无全等则取搜索结果第一条(与 `test_agent_execution.py` 行为一致) | + +### 3.3 `expect` 可选断言 + +| 键 | 类型 | 说明 | +|----|------|------| +| `status` | string | 期望最终状态,默认 `completed` | +| `output_contains` | string[] | 助手最终文本中须**全部**包含的子串(见下方「输出文本提取」) | +| `output_not_contains` | string[] | 最终文本中**不得**包含任一子串 | +| `case_insensitive` | boolean | 为 `true` 时,上述 contains / not_contains 忽略大小写 | + +**输出文本提取规则(与 `test_agent_execution.py` 一致):** + +从 `GET /api/v1/executions/{id}` 的 `output_data` 中依次尝试字段:`result` → `output` → `text` → `content`;若仍为对象则序列化为 JSON 字符串再做子串匹配。 + +### 3.4 LLM 类 Agent 的断言建议 + +- 优先使用 **`output_contains`** 绑定业务关键词(如「作业」「截止」),避免对全文逐字比对。 +- 冒烟用例可只写 `message`,不写 `expect`,仅确认 `status === completed`。 +- 需要稳定复现时,可在 Agent 侧固定模型与温度,或在用例里放宽为1~2 个关键词。 + +--- + +## 4. 与现有脚本的关系 + +| 脚本 | 用途 | +|------|------| +| `test_agent_execution.py` | 单 Agent / 单轮对话、命令行友好(含 `--homework`) | +| `run_agent_test_cases.py` | 读取 **JSON 用例文件**,批量执行 + 简单断言,**适合所有 Agent 回归** | + +新增 Agent 时:在 `agent_test_cases.json` 的 `cases` 数组中追加对象即可,无需改 Python 代码。 + +--- + +## 5. 完整示例(可复制为 `agent_test_cases.json`) + +```json +{ + "version": 1, + "defaults": { + "base_url": "http://localhost:8037", + "username": "admin", + "password": "123456", + "request_timeout_sec": 120, + "max_wait_sec": 300, + "poll_interval_sec": 2 + }, + "cases": [ + { + "id": "homework-hello", + "name": "学生作业管理助手-打招呼", + "agent": { "name": "学生作业管理助手" }, + "message": "你好", + "expect": { + "status": "completed", + "output_contains": ["作业", "助手"] + } + }, + { + "id": "homework-by-id-smoke", + "name": "按 ID 冒烟(请换成你环境真实 UUID)", + "enabled": false, + "agent": { "id": "00000000-0000-0000-0000-000000000000" }, + "message": "你好" + }, + { + "id": "generic-published-smoke", + "name": "任意已发布 Agent-占位示例", + "enabled": false, + "agent": { "name": "你的Agent名称" }, + "message": "你好", + "input_extra": { + "user_id": "preview_script_user", + "attachments": [] + }, + "expect": { + "status": "completed" + } + } + ] +} +``` + +--- + +## 6. 常见问题 + +### 503 / Redis 连接失败 + +检查 `backend/.env` 中 `REDIS_URL` 端口与本地 Redis 是否一致,并确认 Celery Worker 已启动。 + +### 401 + +检查 `username` / `password`;脚本每次运行会重新登录,一般无 token 过期问题。 + +### 403 Agent 未发布 + +后端规则:非 `published` / `running` 时,仅 **Agent 所有者**可执行;测试账号需为创建者或使用已发布 Agent。 + +### 断言失败但人工看回复正常 + +适当减少 `output_contains` 关键词,或开启 `case_insensitive`;LLM 输出本身可能有波动。 + +--- + +## 7. 版本 + +- 文档与 JSON 格式版本:**1** +- 配套脚本:`run_agent_test_cases.py`(仓库根目录) diff --git a/.gitignore b/.gitignore index 17b28e5..5907892 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/Windows启动指南.md b/Windows启动指南.md index d9c29d1..c242d5e 100644 --- a/Windows启动指南.md +++ b/Windows启动指南.md @@ -280,7 +280,121 @@ Proxy error: Could not proxy request /api/auth/me from localhost:3001 to http:// - 后端:修改启动命令端口 `--port 8038` - 前端:修改 `vite.config.ts` 中的 `port` -## 快速启动脚本 +## 一键启动脚本(推荐) + +下面给出一套更稳的 PowerShell 一键启动脚本: +- 自动检查并启动 Redis(优先使用 `backend/redis/redis-server.exe`) +- 自动拉起后端 API(默认 8037,若被占用自动切到 8041) +- 自动拉起 Celery Worker +- 自动拉起前端(并将前端代理指向实际 API 端口) + +### 脚本文件:`start_aiagent.ps1` + +> 建议保存到仓库根目录:`D:\aaa\aiagent\start_aiagent.ps1` + +```powershell +param( + [int]$ApiPort = 8037, + [int]$FallbackApiPort = 8041, + [int]$FrontendPort = 3001 +) + +$ErrorActionPreference = "Stop" +$RepoRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +$Backend = Join-Path $RepoRoot "backend" +$Frontend = Join-Path $RepoRoot "frontend" +$RedisDir = Join-Path $Backend "redis" +$RedisExe = Join-Path $RedisDir "redis-server.exe" +$RedisCli = Join-Path $RedisDir "redis-cli.exe" + +function Test-PortListening([int]$Port) { + $line = netstat -ano | Select-String ":$Port\s+.*LISTENING" | Select-Object -First 1 + return [bool]$line +} + +function Ensure-Redis { + if (Test-PortListening 6379) { + Write-Host "[OK] Redis already listening on 6379" -ForegroundColor Green + return + } + if (-not (Test-Path $RedisExe)) { + throw "Redis 可执行文件不存在:$RedisExe" + } + Write-Host "[RUN] Starting Redis on 6379 ..." -ForegroundColor Yellow + Start-Process -FilePath $RedisExe -ArgumentList "--port 6379" -WorkingDirectory $RedisDir | Out-Null + Start-Sleep -Seconds 2 + if (-not (Test-PortListening 6379)) { + throw "Redis 启动失败,6379 未监听" + } + if (Test-Path $RedisCli) { + & $RedisCli -p 6379 ping | Out-Null + } + Write-Host "[OK] Redis started" -ForegroundColor Green +} + +function Resolve-ApiPort { + if (-not (Test-PortListening $ApiPort)) { + return $ApiPort + } + Write-Host "[WARN] Port $ApiPort is occupied, switching to $FallbackApiPort" -ForegroundColor Yellow + if (Test-PortListening $FallbackApiPort) { + throw "端口 $ApiPort 和 $FallbackApiPort 都被占用,请先释放端口" + } + return $FallbackApiPort +} + +Write-Host "== AIAgent Windows 一键启动 ==" -ForegroundColor Cyan +Write-Host "Repo: $RepoRoot" + +Ensure-Redis +$RealApiPort = Resolve-ApiPort +$ApiBase = "http://127.0.0.1:$RealApiPort" + +Write-Host "[RUN] Starting backend API on $RealApiPort ..." -ForegroundColor Yellow +Start-Process powershell -ArgumentList @( + "-NoExit", + "-Command", + "cd '$Backend'; .\venv\Scripts\Activate.ps1; python -m uvicorn app.main:app --host 0.0.0.0 --port $RealApiPort" +) + +Write-Host "[RUN] Starting Celery worker ..." -ForegroundColor Yellow +Start-Process powershell -ArgumentList @( + "-NoExit", + "-Command", + "cd '$Backend'; .\venv\Scripts\Activate.ps1; python -m celery -A app.core.celery_app worker --loglevel=info --pool=threads --concurrency=8" +) + +Write-Host "[RUN] Starting frontend on $FrontendPort (proxy -> $ApiBase) ..." -ForegroundColor Yellow +Start-Process powershell -ArgumentList @( + "-NoExit", + "-Command", + "`$env:AIAGENT_API_PROXY='$ApiBase'; cd '$Frontend'; pnpm dev --port $FrontendPort" +) + +Write-Host "" +Write-Host "[DONE] 启动命令已下发" -ForegroundColor Green +Write-Host "前端: http://localhost:$FrontendPort" -ForegroundColor Cyan +Write-Host "后端: $ApiBase/docs" -ForegroundColor Cyan +Write-Host "Redis: 127.0.0.1:6379" -ForegroundColor Cyan +``` + +### 运行方式 + +```powershell +cd D:\aaa\aiagent +powershell -ExecutionPolicy Bypass -File .\start_aiagent.ps1 +``` + +### 可选参数 + +```powershell +# 指定端口 +powershell -ExecutionPolicy Bypass -File .\start_aiagent.ps1 -ApiPort 8037 -FallbackApiPort 8041 -FrontendPort 3001 +``` + +--- + +## 快速启动脚本(简版) ### Windows CMD 脚本 (`start_all.cmd`) diff --git a/agent_test_cases.example.json b/agent_test_cases.example.json new file mode 100644 index 0000000..dbfdb05 --- /dev/null +++ b/agent_test_cases.example.json @@ -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" } + } + ] +} diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py index c8fa18b..689e434 100644 --- a/backend/app/api/agents.py +++ b/backend/app/api/agents.py @@ -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, diff --git a/backend/app/api/executions.py b/backend/app/api/executions.py index 2f2c3d8..2cf5b36 100644 --- a/backend/app/api/executions.py +++ b/backend/app/api/executions.py @@ -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) diff --git a/backend/app/api/uploads.py b/backend/app/api/uploads.py new file mode 100644 index 0000000..2636849 --- /dev/null +++ b/backend/app/api/uploads.py @@ -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//`,返回相对路径。 + 大小上限与 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, + ) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 95488ff..979d3f4 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -33,6 +33,15 @@ class Settings(BaseSettings): LOCAL_FILE_TOOLS_ROOT: str = "" LOCAL_FILE_READ_MAX_BYTES: int = 2_097_152 # 单次读取上限(默认 2MB) LOCAL_FILE_WRITE_MAX_BYTES: int = 2_097_152 # 单次写入内容上限(UTF-8 字节) + + # 图片 OCR(file_read 对 png/jpg 等):Tesseract 可执行文件路径,Windows 示例 C:/Program Files/Tesseract-OCR/tesseract.exe + TESSERACT_CMD: str = "" + # 自定义 tessdata 目录(内含 chi_sim.traineddata 等)。留空时若 LOCAL_FILE_TOOLS_ROOT/tessdata 下存在 .traineddata 则自动使用 + TESSERACT_TESSDATA_DIR: str = "" + + # 智能体对话落盘:在工作区根下 agent_workspaces//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" diff --git a/backend/app/main.py b/backend/app/main.py index 269a74b..adca154 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/services/agent_workspace_chat_log.py b/backend/app/services/agent_workspace_chat_log.py new file mode 100644 index 0000000..faab861 --- /dev/null +++ b/backend/app/services/agent_workspace_chat_log.py @@ -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//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) diff --git a/backend/app/services/builtin_tools.py b/backend/app/services/builtin_tools.py index 85faf39..fa06f48 100644 --- a/backend/app/services/builtin_tools.py +++ b/backend/app/services/builtin_tools.py @@ -14,6 +14,7 @@ import platform import sys import asyncio import subprocess +import shlex from sqlalchemy import text from sqlalchemy.exc import SQLAlchemyError from app.core.config import settings @@ -117,15 +118,157 @@ async def http_request_tool( return json.dumps({"error": str(e)}, ensure_ascii=False) +_FILE_READ_TEXT_SUFFIXES = frozenset( + { + ".txt", + ".md", + ".markdown", + ".csv", + ".tsv", + ".json", + ".jsonl", + ".xml", + ".html", + ".htm", + ".css", + ".js", + ".mjs", + ".cjs", + ".ts", + ".tsx", + ".jsx", + ".vue", + ".py", + ".java", + ".go", + ".rs", + ".sql", + ".sh", + ".bat", + ".ps1", + ".yaml", + ".yml", + ".toml", + ".ini", + ".cfg", + ".log", + ".rst", + ".tex", + ".gitignore", + ".env", + ".properties", + } +) + +_FILE_READ_IMAGE_SUFFIXES = frozenset( + {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tif", ".tiff"} +) + + +def _truncate_utf8_text(s: str, max_bytes: int) -> str: + raw = s.encode("utf-8") + if len(raw) <= max_bytes: + return s + cut = raw[:max_bytes].decode("utf-8", errors="ignore") + return cut + "\n\n...[内容已按字节上限截断]" + + +def _read_pdf_text_sync(path: Path, max_bytes: int) -> str: + from pypdf import PdfReader + + reader = PdfReader(str(path)) + parts: List[str] = [] + for page in reader.pages: + t = page.extract_text() or "" + if t.strip(): + parts.append(t) + body = "\n\n".join(parts) if parts else "" + if not body.strip(): + return "[PDF 未解析出文本,可能是扫描件或图片型 PDF;可将页导出为图片后上传,或安装 OCR 流程]" + return _truncate_utf8_text(body, max_bytes) + + +def _read_docx_text_sync(path: Path, max_bytes: int) -> str: + import docx + + doc = docx.Document(str(path)) + lines = [p.text for p in doc.paragraphs if p.text and p.text.strip()] + body = "\n".join(lines) + return _truncate_utf8_text(body or "[docx 中未找到段落文本]", max_bytes) + + +def _read_xlsx_text_sync(path: Path, max_bytes: int) -> str: + from openpyxl import load_workbook + + wb = load_workbook(filename=str(path), read_only=True, data_only=True) + try: + lines: List[str] = [] + for ws in wb.worksheets: + lines.append(f"## {ws.title}") + for row in ws.iter_rows(values_only=True): + cells = ["" if c is None else str(c) for c in row] + if any(x.strip() for x in cells): + lines.append("\t".join(cells)) + body = "\n".join(lines) + finally: + wb.close() + return _truncate_utf8_text(body or "[xlsx 中未读到单元格文本]", max_bytes) + + +def _tessdata_dir_for_ocr() -> Optional[Path]: + """返回 tessdata 目录(内含 .traineddata),传给 Tesseract --tessdata-dir。""" + raw = (getattr(settings, "TESSERACT_TESSDATA_DIR", None) or "").strip() + if raw: + p = Path(raw).expanduser().resolve() + return p if p.is_dir() else None + root = _local_file_workspace_root() + loc = root / "tessdata" + if loc.is_dir() and any(loc.glob("*.traineddata")): + return loc.resolve() + return None + + +def _read_image_ocr_sync(path: Path, max_bytes: int) -> str: + from PIL import Image + import pytesseract + + cmd = (getattr(settings, "TESSERACT_CMD", None) or "").strip() + if cmd: + pytesseract.pytesseract.tesseract_cmd = cmd + tess_cfg = "" + td = _tessdata_dir_for_ocr() + if td is not None: + tess_cfg = f"--tessdata-dir {shlex.quote(str(td))}" + img = Image.open(path) + if img.mode not in ("RGB", "L"): + img = img.convert("RGB") + text = "" + last_err: Optional[Exception] = None + for lang in ("chi_sim+eng", "eng"): + try: + chunk = pytesseract.image_to_string(img, lang=lang, config=tess_cfg) or "" + if chunk.strip(): + text = chunk + break + except Exception as e: + last_err = e + continue + if not text.strip() and last_err: + raise last_err + if not text.strip(): + return "[图片中未识别到文字;可尝试更清晰、正向拍摄的作业照片]" + return _truncate_utf8_text(text, max_bytes) + + async def file_read_tool(file_path: str) -> str: """ - 文件读取工具 - + 文件读取工具:UTF-8 文本、PDF 文本、docx、xlsx 单元格文本、常见图片 OCR(需本机 Tesseract)。 + Args: - file_path: 文件路径 - + file_path: 文件路径(相对工作区根或工作区内绝对路径) + Returns: - 文件内容或错误信息 + JSON:file_path, content, size;或 error """ try: max_bytes = int(getattr(settings, "LOCAL_FILE_READ_MAX_BYTES", 2_097_152) or 2_097_152) @@ -140,15 +283,106 @@ async def file_read_tool(file_path: str) -> str: {"error": f"文件过大({fsize} 字节),上限 {max_bytes}"}, ensure_ascii=False, ) - content = path.read_text(encoding="utf-8", errors="replace") - return json.dumps( - { - "file_path": str(path), - "content": content, - "size": len(content.encode("utf-8")), - }, - ensure_ascii=False, - ) + suffix = path.suffix.lower() + + if suffix == ".doc": + return json.dumps( + { + "error": "暂不支持旧版 .doc,请在 Word 中另存为 .docx 后上传", + "file_path": str(path), + }, + ensure_ascii=False, + ) + + content: str + extract_mode = "text" + + if suffix in _FILE_READ_IMAGE_SUFFIXES: + extract_mode = "image_ocr" + try: + content = await asyncio.to_thread(_read_image_ocr_sync, path, max_bytes) + except ImportError as e: + return json.dumps( + { + "error": f"图片识别依赖未安装: {e}。请在后端环境执行 pip install Pillow pytesseract", + "file_path": str(path), + }, + ensure_ascii=False, + ) + except Exception as e: + err_low = str(e).lower() + if "tesseract" in err_low or "tesseractnotfound" in type(e).__name__.lower(): + return json.dumps( + { + "error": ( + "未找到 Tesseract OCR。请安装 Tesseract(Windows 可装官方安装包)," + "并在 .env 中配置 TESSERACT_CMD 指向 tesseract.exe,或将其加入 PATH;" + "中文识别需额外下载 chi_sim 语言包。" + ), + "file_path": str(path), + }, + ensure_ascii=False, + ) + logger.warning("图片 OCR 失败: %s", e) + return json.dumps({"error": f"图片识别失败: {e}", "file_path": str(path)}, ensure_ascii=False) + + elif suffix == ".pdf": + extract_mode = "pdf" + try: + content = await asyncio.to_thread(_read_pdf_text_sync, path, max_bytes) + except ImportError as e: + return json.dumps( + {"error": f"PDF 解析依赖未安装: {e}。请 pip install pypdf", "file_path": str(path)}, + ensure_ascii=False, + ) + except Exception as e: + logger.warning("PDF 解析失败: %s", e) + return json.dumps({"error": f"PDF 解析失败: {e}", "file_path": str(path)}, ensure_ascii=False) + + elif suffix == ".docx": + extract_mode = "docx" + try: + content = await asyncio.to_thread(_read_docx_text_sync, path, max_bytes) + except ImportError as e: + return json.dumps( + {"error": f"Word 解析依赖未安装: {e}。请 pip install python-docx", "file_path": str(path)}, + ensure_ascii=False, + ) + except Exception as e: + logger.warning("docx 解析失败: %s", e) + return json.dumps({"error": f"docx 解析失败: {e}", "file_path": str(path)}, ensure_ascii=False) + + elif suffix == ".xlsx": + extract_mode = "xlsx" + try: + content = await asyncio.to_thread(_read_xlsx_text_sync, path, max_bytes) + except ImportError as e: + return json.dumps( + {"error": f"Excel 解析依赖未安装: {e}。请 pip install openpyxl", "file_path": str(path)}, + ensure_ascii=False, + ) + except Exception as e: + logger.warning("xlsx 解析失败: %s", e) + return json.dumps({"error": f"xlsx 解析失败: {e}", "file_path": str(path)}, ensure_ascii=False) + + elif suffix in _FILE_READ_TEXT_SUFFIXES or suffix == "": + extract_mode = "text" + content = path.read_text(encoding="utf-8", errors="replace") + content = _truncate_utf8_text(content, max_bytes) + else: + # 未知扩展名:仍尝试按 UTF-8 文本读(兼容无后缀文本) + extract_mode = "text" + content = path.read_text(encoding="utf-8", errors="replace") + content = _truncate_utf8_text(content, max_bytes) + + out = { + "file_path": str(path), + "content": content, + "size": len(content.encode("utf-8")), + "extract_mode": extract_mode, + } + logger.info("file_read %s mode=%s content_len=%s", path, extract_mode, out["size"]) + return json.dumps(out, ensure_ascii=False) except FileNotFoundError: return json.dumps({"error": f"文件不存在: {file_path}"}, ensure_ascii=False) except Exception as e: @@ -813,7 +1047,11 @@ FILE_READ_SCHEMA = { "type": "function", "function": { "name": "file_read", - "description": "读取本地文本文件(UTF-8)。路径须落在平台配置的工作区内(默认仓库根;可通过环境变量 LOCAL_FILE_TOOLS_ROOT 修改)。超过大小上限会拒绝。", + "description": ( + "读取工作区内文件并尽量提取可读文本:UTF-8 文本、.pdf 文字层、.docx 段落、.xlsx 单元格、" + "常见图片(.png/.jpg 等)用 OCR 提取文字(需服务器安装 Tesseract,可选配置 TESSERACT_CMD)。" + "用户上传附件后消息中会给出相对路径,请用本工具读取该路径。路径须落在 LOCAL_FILE_TOOLS_ROOT 下。" + ), "parameters": { "type": "object", "properties": { diff --git a/backend/app/services/llm_service.py b/backend/app/services/llm_service.py index a84b46a..1e2ac3f 100644 --- a/backend/app/services/llm_service.py +++ b/backend/app/services/llm_service.py @@ -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: diff --git a/backend/app/services/workflow_engine.py b/backend/app/services/workflow_engine.py index f941929..9756ba4 100644 --- a/backend/app/services/workflow_engine.py +++ b/backend/app/services/workflow_engine.py @@ -812,6 +812,25 @@ class WorkflowEngine: logger.info(f"[rjb] 从嵌套上游输入提升 user_id 到节点 {node_id} 输入顶层") break + # Start→下游 带 sourceHandle 时 query/USER_INPUT/attachments 常在 right 内,提升到顶层供 LLM 与其它节点读取 + if isinstance(input_data, dict): + _rk = input_data.get('right') + if isinstance(_rk, dict): + for _uk in ( + 'query', + 'USER_INPUT', + 'user_input', + 'attachments', + 'text', + 'message', + 'content', + ): + if input_data.get(_uk) not in (None, ''): + continue + if _uk in _rk and _rk[_uk] is not None: + input_data[_uk] = _rk[_uk] + logger.debug(f"[rjb] 从 right 提升到顶层: {_uk}") + logger.debug(f"[rjb] 节点输入结果: node_id={node_id}, input_data={input_data}") return input_data @@ -1443,6 +1462,58 @@ class WorkflowEngine: if user_query: break + # Start→LLM 常见:边带 sourceHandle=right,Start 输出在 input_data["right"] 下, + # 顶层无 query/USER_INPUT,旧逻辑会退化为 JSON 整包,模型看不到附件路径。 + if not user_query: + for bucket in ("right", "left", "output", "data"): + nested = input_data.get(bucket) + if not isinstance(nested, dict): + continue + for key in ( + "query", + "input", + "text", + "message", + "content", + "user_input", + "USER_INPUT", + ): + if key not in nested: + continue + value = nested[key] + logger.info( + f"[rjb] 从嵌套桶 {bucket}.{key} 提取 user_query 候选, type={type(value)}" + ) + if isinstance(value, str): + user_query = value + logger.info( + f"[rjb] 从{bucket}.{key} 提取到字符串 user_query 长度={len(value)}" + ) + break + if isinstance(value, dict): + for sub_key in ( + "query", + "input", + "text", + "message", + "content", + "user_input", + "USER_INPUT", + ): + if sub_key not in value: + continue + sv = value[sub_key] + if isinstance(sv, str): + user_query = sv + logger.info( + f"[rjb] 从{bucket}.{key}.{sub_key} 提取到 user_query" + ) + break + if user_query: + break + if user_query: + break + # 如果还是没有,使用整个input_data(但排除系统字段) if not user_query: filtered_data = {k: v for k, v in input_data.items() if not k.startswith('_')} @@ -1607,6 +1678,12 @@ class WorkflowEngine: _tool_extra["max_iterations"] = max(1, min(int(_mi), 64)) except (TypeError, ValueError): pass + _rt = node_data.get("request_timeout") + if _rt is not None: + try: + _tool_extra["request_timeout"] = max(10.0, float(_rt)) + except (TypeError, ValueError): + pass result = await llm_service.call_llm_with_tools( prompt=prompt, tools=tools, @@ -4916,7 +4993,12 @@ class WorkflowEngine: if isinstance(_rj, dict): _ex.update(_rj) except Exception: - pass + # 非 JSON 但以 { 开头(如 Markdown),仍当作正文 + _ex['output'] = _ex['right'] + else: + # LLM 节点 output 常为纯文本,经带 handle 的边落在 right;须写入 output, + # 否则下游按 dict 拼接会把 user_id(preview_xxx)拼在回复末尾。 + _ex['output'] = _ex['right'] input_data = _ex final_output = input_data @@ -4983,7 +5065,28 @@ class WorkflowEngine: # 否则转换为纯文本(不是JSON) # 尝试提取所有文本字段并组合,但排除系统字段和用户查询字段 text_parts = [] - exclude_keys = {'status', 'error', 'timestamp', 'node_id', 'execution_time', 'query', 'USER_INPUT', 'user_input', 'user_query'} + exclude_keys = { + 'status', + 'error', + 'timestamp', + 'node_id', + 'execution_time', + 'query', + 'USER_INPUT', + 'user_input', + 'user_query', + 'user_id', + 'USER_ID', + 'userId', + 'attachments', + 'memory', + 'conversation_history', + 'user_profile', + 'context', + 'right', + 'left', + 'data', + } # 优先使用input字段(LLM的实际输出) if 'input' in final_output and isinstance(final_output['input'], str): final_output = final_output['input'] diff --git a/backend/app/tasks/workflow_tasks.py b/backend/app/tasks/workflow_tasks.py index 94c1caf..5d5f9fe 100644 --- a/backend/app/tasks/workflow_tasks.py +++ b/backend/app/tasks/workflow_tasks.py @@ -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") diff --git a/backend/env.example b/backend/env.example index adc0f53..ed44ccf 100644 --- a/backend/env.example +++ b/backend/env.example @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt index 6348510..9b1df6b 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -46,6 +46,13 @@ kafka-python==2.0.2 # Kafka # Utilities python-dateutil==2.8.2 +# file_read:PDF / Word / Excel / 图片 OCR +pypdf==4.0.1 +python-docx==1.1.0 +openpyxl==3.1.2 +Pillow==10.2.0 +pytesseract==0.3.10 + # Development pytest==7.4.3 pytest-asyncio==0.21.1 diff --git a/backend/scripts/create_enterprise_scenario_agents.py b/backend/scripts/create_enterprise_scenario_agents.py index e6382b9..2a60a3f 100644 --- a/backend/scripts/create_enterprise_scenario_agents.py +++ b/backend/scripts/create_enterprise_scenario_agents.py @@ -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( [ diff --git a/backend/scripts/create_homework_manager_agent.py b/backend/scripts/create_homework_manager_agent.py new file mode 100644 index 0000000..291f552 --- /dev/null +++ b/backend/scripts/create_homework_manager_agent.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +""" +创建或更新「学生作业管理助手」Agent:单链 Start → LLM → End。 +侧重:记录作业项、截止日、优先级;跟进完成情况;温和督促与周回顾(不代写可提交的作业正文)。 + +用法: + cd backend && .\\venv\\Scripts\\python.exe scripts/create_homework_manager_agent.py + +环境变量: + PLATFORM_BASE_URL, PLATFORM_USERNAME, PLATFORM_PASSWORD + AGENT_NAME(默认 学生作业管理助手) + HOMEWORK_LLM_PROVIDER / HOMEWORK_LLM_MODEL / HOMEWORK_LLM_TIMEOUT(可选) +""" +from __future__ import annotations + +import json +import os +import sys +from typing import Any, Dict, List, Optional, Tuple + +import requests + +BACKEND_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if BACKEND_DIR not in sys.path: + sys.path.insert(0, BACKEND_DIR) + +BASE = os.getenv("PLATFORM_BASE_URL", "http://127.0.0.1:8037").rstrip("/") +USER = os.getenv("PLATFORM_USERNAME", "admin") +PWD = os.getenv("PLATFORM_PASSWORD", "123456") +AGENT_NAME = os.getenv("AGENT_NAME", "学生作业管理助手") + +PROVIDER = os.getenv( + "HOMEWORK_LLM_PROVIDER", os.getenv("ENTERPRISE_LLM_PROVIDER", "deepseek") +) +MODEL = os.getenv( + "HOMEWORK_LLM_MODEL", os.getenv("ENTERPRISE_LLM_MODEL", "deepseek-chat") +) +REQ_TIMEOUT = max( + 30, + int( + os.getenv( + "HOMEWORK_LLM_TIMEOUT", os.getenv("ENTERPRISE_LLM_TIMEOUT", "180") + ) + ), +) + +BUDGET_CONFIG = { + "max_steps": 80, + "max_llm_invocations": 6, + "max_tool_calls": 20, +} + +HOMEWORK_TOOLS = ["file_read", "text_analyze", "datetime", "json_process"] + +HOMEWORK_PROMPT = """你是「学生作业管理助手」,帮助学生**记作业**与**监督完成**,语气友好、具体、可执行。 + +【核心能力】 +1. **记作业**:从用户自然语言中提取「科目 / 作业内容 / 截止日期与时间 / 老师要求要点 / 预估耗时」,整理成清单。 + - 若用户用回形针**上传**了文件或照片,消息里会出现「相对工作区根路径」列表:**必须先调用 file_read**,用返回的 `content`(正文/OCR 文本)整理进作业清单,勿编造未读到的内容。 + - 支持常见格式:纯文本/Markdown、**PDF**、**Word(.docx)**、**Excel(.xlsx)**、**照片**(作业拍照等,依赖 OCR;若工具返回需安装 Tesseract 等提示,请如实转告用户并仍可基于用户口述继续记作业)。 +2. **监督完成**:根据清单追问进度(未开始/进行中/已完成);对临近截止的任务给**温和提醒**(不制造焦虑);可建议拆成小步骤与每日 15–30 分钟微习惯。 +3. **周回顾**:用户要求时,用 json_process 或清晰表格输出本周完成率、延期项与下周优先三件事。 + +【原则】 +- **不代写**可提交的作业正文、实验报告、论文等;可提供提纲、自检表、引用规范提示。 +- 日期时间以用户所在语境为准;需要当前时间可借助工具 datetime。 +- 不确定的信息(如具体截止时刻)先列出假设并请用户确认。 +- 输出优先中文;列表用编号,便于复制到备忘录。 + +【交互习惯】 +- 用户只说「记一下数学作业」时,主动追问截止日与具体要求(一次问 1–2 个点,避免审问感)。 +- 用户汇报「做完了」时,确认是否需拍照/上传检查清单,并建议归档到下一条任务前的小结一句话。 +""" + + +def _sanitize_edges(edges: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + seen: set = set() + out: List[Dict[str, Any]] = [] + for e in edges or []: + s, t = e.get("source"), e.get("target") + if not s or not t or s == t: + continue + key = (s, t, e.get("sourceHandle") or "") + if key in seen: + continue + seen.add(key) + ne = dict(e) + if not ne.get("targetHandle"): + ne["targetHandle"] = "left" + if not ne.get("id"): + sh = ne.get("sourceHandle") or "r" + ne["id"] = f"e_{s}_{t}_{sh}" + out.append(ne) + return out + + +def build_workflow() -> Dict[str, Any]: + llm_pos: Tuple[int, int] = (380, 220) + nodes: List[Dict[str, Any]] = [ + {"id": "start-1", "type": "start", "position": {"x": 80, "y": 220}, "data": {"label": "开始"}}, + { + "id": "llm-homework", + "type": "llm", + "position": {"x": llm_pos[0], "y": llm_pos[1]}, + "data": { + "label": "作业管理", + "prompt": HOMEWORK_PROMPT, + "provider": PROVIDER, + "model": MODEL, + "temperature": 0.3, + "request_timeout": REQ_TIMEOUT, + "enable_tools": True, + "tools": list(HOMEWORK_TOOLS), + "selected_tools": list(HOMEWORK_TOOLS), + "max_tool_iterations": 10, + }, + }, + {"id": "end-1", "type": "end", "position": {"x": llm_pos[0] + 260, "y": 220}, "data": {"label": "结束"}}, + ] + edges = _sanitize_edges( + [ + {"source": "start-1", "target": "llm-homework", "sourceHandle": "right", "targetHandle": "left"}, + {"source": "llm-homework", "target": "end-1", "sourceHandle": "right", "targetHandle": "left"}, + ] + ) + return {"nodes": nodes, "edges": edges} + + +def _validate_local(wf: Dict[str, Any]) -> None: + from app.services.workflow_validator import validate_workflow + + r = validate_workflow(wf.get("nodes") or [], wf.get("edges") or []) + if not r.get("valid"): + errs = r.get("errors") or [] + raise ValueError("工作流校验失败: " + "; ".join(errs)) + + +def _find_agent_id(h: Dict[str, str], name: str) -> Optional[str]: + r = requests.get(f"{BASE}/api/v1/agents", params={"search": name, "limit": 80}, headers=h, timeout=45) + if r.status_code != 200: + return None + for a in r.json() or []: + if a.get("name") == name: + return a.get("id") + return None + + +def main() -> int: + wf = build_workflow() + try: + _validate_local(wf) + except ValueError as e: + print(e, file=sys.stderr) + return 1 + + r = requests.post( + f"{BASE}/api/v1/auth/login", + data={"username": USER, "password": PWD}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=15, + ) + if r.status_code != 200: + print("登录失败:", r.status_code, r.text[:500], file=sys.stderr) + return 1 + token = r.json().get("access_token") + if not token: + print("无 access_token", file=sys.stderr) + return 1 + h = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + + desc = ( + "学生作业管理助手:记作业(科目、内容、截止日)、跟进度、温和督促与周回顾;" + "支持上传文件/照片后用 file_read 提取正文(文本、PDF、docx、xlsx、图片 OCR)与 json_process 整理;" + f"默认模型 {PROVIDER}/{MODEL},单次执行内工具迭代上限 10。" + ) + + existing = _find_agent_id(h, AGENT_NAME) + if existing: + ur = requests.put( + f"{BASE}/api/v1/agents/{existing}", + headers=h, + json={ + "description": desc, + "workflow_config": wf, + "budget_config": BUDGET_CONFIG, + }, + timeout=120, + ) + if ur.status_code != 200: + print("更新失败:", ur.status_code, ur.text[:800], file=sys.stderr) + return 1 + print("已更新", AGENT_NAME, existing) + print(json.dumps({"id": existing, "name": AGENT_NAME}, ensure_ascii=False)) + return 0 + + cr = requests.post( + f"{BASE}/api/v1/agents", + headers=h, + json={ + "name": AGENT_NAME, + "description": desc, + "workflow_config": wf, + "budget_config": BUDGET_CONFIG, + }, + timeout=120, + ) + if cr.status_code != 201: + print("创建失败:", cr.status_code, cr.text[:800], file=sys.stderr) + return 1 + aid = cr.json()["id"] + print("已创建", AGENT_NAME, aid) + print(json.dumps({"id": aid, "name": AGENT_NAME}, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/scripts/create_intelligent_tutor_agent.py b/backend/scripts/create_intelligent_tutor_agent.py new file mode 100644 index 0000000..bd1eef0 --- /dev/null +++ b/backend/scripts/create_intelligent_tutor_agent.py @@ -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()) diff --git a/backend/scripts/create_zhini_kefu_15.py b/backend/scripts/create_zhini_kefu_15.py index bb04495..166367e 100644 --- a/backend/scripts/create_zhini_kefu_15.py +++ b/backend/scripts/create_zhini_kefu_15.py @@ -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` 同轮勿无故重复写入同一文件除非必要。 """ diff --git a/backend/scripts/create_zhini_kefu_17.py b/backend/scripts/create_zhini_kefu_17.py new file mode 100644 index 0000000..e26da7a --- /dev/null +++ b/backend/scripts/create_zhini_kefu_17.py @@ -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()) diff --git a/backend/scripts/init_builtin_tools.py b/backend/scripts/init_builtin_tools.py index 87f303f..48ca98f 100644 --- a/backend/scripts/init_builtin_tools.py +++ b/backend/scripts/init_builtin_tools.py @@ -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 diff --git a/backend/scripts/test_homework_agent_hello.py b/backend/scripts/test_homework_agent_hello.py new file mode 100644 index 0000000..cd850ae --- /dev/null +++ b/backend/scripts/test_homework_agent_hello.py @@ -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()) diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index ba3e0ed..79174da 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -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) } diff --git a/frontend/src/components/AgentChatPreview.vue b/frontend/src/components/AgentChatPreview.vue index c3bd174..3efbe67 100644 --- a/frontend/src/components/AgentChatPreview.vue +++ b/frontend/src/components/AgentChatPreview.vue @@ -6,7 +6,7 @@ {{ agentName || 'Agent' }}
- + 清空对话 @@ -22,8 +22,8 @@
- -
+ +
+
- +
+
+
+ +
+ +
+
+ +
+
{{ a.filename }}
+
+
+
+
+
+ {{ a.previewText ? '内容预览' : '已上传' }} · {{ a.filename }} +
+
{{ a.previewText }}
+
{{ a.previewNote }}
+
+
@@ -143,17 +188,19 @@