feat: 工作流记忆与内置工具、知你客服脚本、Agent管理技能展示与能力配置、文档与Windows启动脚本;忽略 redis_temp 二进制目录
Made-with: Cursor
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -16,3 +16,6 @@ logs/
|
||||
# 操作系统
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# 本地 Redis Windows 分发(勿提交二进制)
|
||||
redis_temp/
|
||||
|
||||
317
Windows启动指南.md
Normal file
317
Windows启动指南.md
Normal file
@@ -0,0 +1,317 @@
|
||||
# Windows 本地启动指南
|
||||
|
||||
## 前置要求
|
||||
|
||||
### 已安装的软件
|
||||
- ✅ Python 3.12.7(已安装)
|
||||
- ✅ Node.js 22.13.0(已安装)
|
||||
- ✅ npm 10.9.2(已安装)
|
||||
- ✅ pnpm 10.33.0(已安装)
|
||||
|
||||
### 需要安装的软件
|
||||
- ❌ Redis(需要安装)
|
||||
|
||||
## 步骤 1:安装 Redis(选择一种方式)
|
||||
|
||||
### 选项 A:使用 Docker 运行 Redis(推荐,最简单)
|
||||
|
||||
如果你不介意使用 Docker 来运行 Redis(其他服务仍在本地运行):
|
||||
|
||||
1. **安装 Docker Desktop**
|
||||
- 下载地址:https://www.docker.com/products/docker-desktop/
|
||||
- 安装后启动 Docker Desktop
|
||||
|
||||
2. **启动 Redis 容器**
|
||||
```bash
|
||||
docker run -d --name redis -p 6380:6379 redis:7-alpine
|
||||
```
|
||||
|
||||
3. **验证 Redis 是否运行**
|
||||
```bash
|
||||
docker ps
|
||||
```
|
||||
应该能看到 Redis 容器正在运行。
|
||||
|
||||
### 选项 B:安装 Redis for Windows
|
||||
|
||||
1. **下载 Redis Windows 版本**
|
||||
- 从 GitHub 下载:https://github.com/microsoftarchive/redis/releases
|
||||
- 下载 `Redis-x64-3.2.100.msi`
|
||||
|
||||
2. **安装 Redis**
|
||||
- 运行安装程序,按照默认设置安装
|
||||
- 安装完成后,Redis 会作为 Windows 服务运行
|
||||
|
||||
3. **修改 Redis 端口(可选)**
|
||||
- 默认 Redis 运行在 6379 端口
|
||||
- 如果需要使用 6380 端口(与 docker-compose 配置一致),需要修改配置文件
|
||||
- 配置文件位置:`C:\Program Files\Redis\redis.windows-service.conf`
|
||||
- 找到 `port 6379` 改为 `port 6380`
|
||||
- 重启 Redis 服务
|
||||
|
||||
### 选项 C:使用 WSL 安装 Redis
|
||||
|
||||
如果你有 WSL(Windows Subsystem for Linux):
|
||||
|
||||
```bash
|
||||
# 在 WSL 中运行
|
||||
sudo apt update
|
||||
sudo apt install redis-server
|
||||
sudo service redis-server start
|
||||
# 需要配置 Redis 允许远程连接
|
||||
```
|
||||
|
||||
## 步骤 2:配置后端环境
|
||||
|
||||
### 1. 创建虚拟环境并安装依赖
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# 创建虚拟环境
|
||||
python -m venv venv
|
||||
|
||||
# 激活虚拟环境
|
||||
# Windows CMD:
|
||||
venv\Scripts\activate
|
||||
# Windows PowerShell:
|
||||
.\venv\Scripts\Activate.ps1
|
||||
# Git Bash:
|
||||
source venv/Scripts/activate
|
||||
|
||||
# 安装依赖
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2. 配置环境变量
|
||||
|
||||
```bash
|
||||
# 复制环境变量文件
|
||||
copy env.example .env
|
||||
```
|
||||
|
||||
编辑 `.env` 文件,确保以下配置正确:
|
||||
|
||||
```ini
|
||||
# 数据库配置(已配置为腾讯云MySQL,无需修改)
|
||||
DATABASE_URL=mysql+pymysql://root:!Rjb12191@gz-cynosdbmysql-grp-d26pzce5.sql.tencentcdb.com:24936/agent_db?charset=utf8mb4
|
||||
|
||||
# Redis配置(根据你的Redis安装方式选择)
|
||||
# 如果使用Docker Redis(端口6380):
|
||||
REDIS_URL=redis://localhost:6380/0
|
||||
# 如果使用Windows Redis(默认端口6379):
|
||||
# REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# CORS配置
|
||||
CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,http://localhost:8038,http://101.43.95.130:8038
|
||||
|
||||
# DeepSeek API密钥(已有)
|
||||
DEEPSEEK_API_KEY=sk-fdf7cc1c73504e628ec0119b7e11b8cc
|
||||
DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||
```
|
||||
|
||||
### 3. 运行数据库迁移
|
||||
|
||||
```bash
|
||||
# 确保虚拟环境已激活
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
### 4. 启动后端服务
|
||||
|
||||
```bash
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8037 --reload
|
||||
```
|
||||
|
||||
后端服务将在 http://localhost:8037 启动。
|
||||
|
||||
### 5. 启动 Celery Worker(新终端)
|
||||
|
||||
```bash
|
||||
# 在新终端中,进入backend目录并激活虚拟环境
|
||||
cd backend
|
||||
venv\Scripts\activate
|
||||
|
||||
# 启动 Celery Worker
|
||||
celery -A app.core.celery_app worker --loglevel=info
|
||||
```
|
||||
|
||||
## 步骤 3:配置前端环境
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### 2. 配置 API 地址
|
||||
|
||||
编辑 `frontend/vite.config.ts`,确保代理配置正确:
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8037',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:8037',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### 3. 启动前端服务
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
前端服务将在 http://localhost:3000 启动。
|
||||
注意:访问地址是 http://localhost:3000,不是 8038(8038 是 Docker 映射端口)。
|
||||
|
||||
## 步骤 4:验证服务
|
||||
|
||||
### 1. 检查服务状态
|
||||
|
||||
- **后端API**: http://localhost:8037
|
||||
- **API文档**: http://localhost:8037/docs
|
||||
- **前端**: http://localhost:3000
|
||||
|
||||
### 2. 测试健康检查
|
||||
|
||||
```bash
|
||||
curl http://localhost:8037/health
|
||||
```
|
||||
应该返回:`{"status":"healthy"}`
|
||||
|
||||
## 步骤 5:创建第一个工作流
|
||||
|
||||
1. 访问 http://localhost:3000
|
||||
2. 注册新用户或使用现有账户登录
|
||||
3. 点击"创建工作流"进入可视化编辑器
|
||||
4. 拖拽节点、连接、配置并保存
|
||||
5. 运行工作流测试
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1. Redis 连接失败
|
||||
|
||||
**错误信息**:
|
||||
```
|
||||
redis.exceptions.ConnectionError: Error 10061 connecting to localhost:6380
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
- 检查 Redis 是否正在运行
|
||||
- 确认 Redis 端口是否正确
|
||||
- 检查防火墙是否阻止了 Redis 端口
|
||||
|
||||
### 2. 数据库连接失败
|
||||
|
||||
**错误信息**:
|
||||
```
|
||||
pymysql.err.OperationalError: (2003, "Can't connect to MySQL server...")
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
- 检查网络连接(腾讯云数据库需要互联网访问)
|
||||
- 确认数据库连接信息正确
|
||||
- 检查数据库是否允许远程连接
|
||||
|
||||
### 3. 前端无法连接后端
|
||||
|
||||
**错误信息**:
|
||||
```
|
||||
Proxy error: Could not proxy request /api/auth/me from localhost:3000 to http://localhost:8037
|
||||
```
|
||||
|
||||
**解决方案**:
|
||||
- 检查后端服务是否正在运行(http://localhost:8037)
|
||||
- 检查前端代理配置(vite.config.ts)
|
||||
- 检查 CORS 配置(.env 文件中的 CORS_ORIGINS)
|
||||
|
||||
### 4. Celery 任务不执行
|
||||
|
||||
**解决方案**:
|
||||
- 检查 Celery Worker 是否正在运行
|
||||
- 检查 Redis 连接是否正常
|
||||
- 查看 Celery Worker 日志
|
||||
|
||||
### 5. 端口被占用
|
||||
|
||||
**解决方案**:
|
||||
- 检查端口 8037 和 3000 是否被其他程序占用
|
||||
- 可以修改端口:
|
||||
- 后端:修改启动命令端口 `--port 8038`
|
||||
- 前端:修改 `vite.config.ts` 中的 `port`
|
||||
|
||||
## 快速启动脚本
|
||||
|
||||
### Windows CMD 脚本 (`start_all.cmd`)
|
||||
|
||||
```batch
|
||||
@echo off
|
||||
echo 启动低代码智能体平台...
|
||||
|
||||
REM 启动后端服务
|
||||
start cmd /k "cd /d backend && venv\Scripts\activate && uvicorn app.main:app --host 0.0.0.0 --port 8037 --reload"
|
||||
|
||||
REM 启动 Celery Worker
|
||||
start cmd /k "cd /d backend && venv\Scripts\activate && celery -A app.core.celery_app worker --loglevel=info"
|
||||
|
||||
REM 启动前端服务
|
||||
start cmd /k "cd /d frontend && pnpm dev"
|
||||
|
||||
echo 服务启动完成!
|
||||
echo 前端: http://localhost:3000
|
||||
echo 后端API: http://localhost:8037/docs
|
||||
```
|
||||
|
||||
### PowerShell 脚本 (`start_all.ps1`)
|
||||
|
||||
```powershell
|
||||
Write-Host "启动低代码智能体平台..." -ForegroundColor Green
|
||||
|
||||
# 启动后端服务
|
||||
Start-Process powershell -ArgumentList "-NoExit", "-Command", "cd backend; .\venv\Scripts\Activate.ps1; uvicorn app.main:app --host 0.0.0.0 --port 8037 --reload"
|
||||
|
||||
# 启动 Celery Worker
|
||||
Start-Process powershell -ArgumentList "-NoExit", "-Command", "cd backend; .\venv\Scripts\Activate.ps1; celery -A app.core.celery_app worker --loglevel=info"
|
||||
|
||||
# 启动前端服务
|
||||
Start-Process powershell -ArgumentList "-NoExit", "-Command", "cd frontend; pnpm dev"
|
||||
|
||||
Write-Host "服务启动完成!" -ForegroundColor Green
|
||||
Write-Host "前端: http://localhost:3000" -ForegroundColor Yellow
|
||||
Write-Host "后端API: http://localhost:8037/docs" -ForegroundColor Yellow
|
||||
```
|
||||
|
||||
## 停止服务
|
||||
|
||||
### 停止所有服务
|
||||
1. 按 `Ctrl+C` 停止每个终端中的服务
|
||||
2. 停止 Redis:
|
||||
- Docker Redis: `docker stop redis`
|
||||
- Windows Redis 服务: 停止 "Redis" 服务
|
||||
|
||||
## 生产环境建议
|
||||
|
||||
对于生产环境,建议使用:
|
||||
1. **Docker Compose**:统一管理和部署所有服务
|
||||
2. **Nginx**:反向代理和负载均衡
|
||||
3. **Supervisor**:进程管理
|
||||
4. **数据库备份**:定期备份腾讯云数据库
|
||||
|
||||
---
|
||||
|
||||
**文档版本**: 1.0
|
||||
**最后更新**: 2026-04-06
|
||||
|
||||
> 注意:本指南针对 Windows 本地开发环境。生产环境部署请参考 [方案-优化版.md](./方案-优化版.md)。
|
||||
85
agent记忆实现方案.md
Normal file
85
agent记忆实现方案.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Agent 记忆实现方案(以「知你客服 13 号」为例)
|
||||
|
||||
本文说明「知你客服 13 号」所采用的**会话记忆**在平台上的实现方式、与 12 号的关系、持久化与性能边界,便于对接、运维与二次开发。
|
||||
|
||||
---
|
||||
|
||||
## 1. 与「知你客服 12 号」的关系
|
||||
|
||||
- **13 号在 12 号能力上增加**:工作流连线整理(去自环/重复边、分层布局、统一左右锚点)、工具调用纪律提示(避免同轮重复 `file_write`、勿刷屏 DSML 等)。
|
||||
- **记忆机制本身与 12 号一致**:仍依赖工作流中的 **Cache 节点**、键名 **`user_memory_*`**、Redis 热缓存与(可选)MySQL 持久化;引擎侧对占位符解析、`user_profile` 与末行 JSON 的处理对两条线通用。
|
||||
|
||||
---
|
||||
|
||||
## 2. 总体架构(分层)
|
||||
|
||||
| 层级 | 作用 |
|
||||
|------|------|
|
||||
| **工作流 Cache 节点** | 业务主路径:按配置的 **key**(如 `user_memory_{{user_id}}`)执行 `get` / `set`,读写一整包 JSON(通常含 `conversation_history`、`user_profile`、`context` 等)。 |
|
||||
| **Redis** | 热缓存;可配置 **TTL**(引擎侧 Cache 节点默认常见为 **3600 秒**,以节点 `ttl` 为准)。 |
|
||||
| **MySQL(`persistent_user_memories`)** | 与 **`user_memory_*`** 键对齐的持久化;在开关开启时,`get` 可合并 DB 与 Redis,`set` 后同步写入 DB。 |
|
||||
| **工作流引擎(`WorkflowEngine`)** | 合并输入、占位符解析、LLM 输出补全(如末行 JSON 中的 `user_profile`)、Cache 写入时的历史截断等。 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 会话隔离(`user_id`)
|
||||
|
||||
- Cache 键通常包含 **`{{user_id}}`**。请求执行时需携带稳定的 **`user_id`**(或引擎能从 Start 节点输入中解析到),否则会退化为 **`default`**,多用户会共用同一记忆键。
|
||||
- 持久化 scope:执行上下文中的 `workflow_id` 为 Agent 时形如 `agent_{uuid}`,用于将 DB 记录绑定到**具体 Agent**(见 `parse_memory_scope`)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据流(简版)
|
||||
|
||||
1. **开始**:用户输入进入工作流;若存在 **Cache `get`**,从 Redis(及必要时 MySQL)取出上一轮的 payload,合并进当轮输入。
|
||||
2. **LLM**:提示词中通过 `{{memory.user_profile}}`、`{{memory.conversation_history}}`、`{{memory.recent_turns}}` 等注入上下文;引擎支持 **`memory.*` 与 Cache 合并后的顶层字段**对齐,避免「只有顶层 `user_profile`、没有 `memory` 包裹」时画像为空。
|
||||
3. **模型输出**:约定**最后一行为单行 JSON**(含 `intent`、`reply`、`user_profile` 等)。若实际为「多行说明 + 末行 JSON」,引擎会解析**末行 JSON**以合并 `user_profile`、供后续 Cache 使用。
|
||||
4. **写回**:**Cache `set`**(及可能的代码节点)根据模板更新 `conversation_history` 等并写入 Redis;若满足条件则**同步 MySQL**。
|
||||
5. **截断**:写入时若存在 `conversation_history` 列表,按节点 **`max_history_length`** 保留最近若干条(引擎默认 **20**,可在 Cache 节点 `data` 中配置)。
|
||||
|
||||
---
|
||||
|
||||
## 5. MySQL 持久化的作用
|
||||
|
||||
- 配置项:**`MEMORY_PERSIST_DB_ENABLED`**(默认 **`True`**,见 `app.core.config`)。
|
||||
- **有用场景**:Redis **过期**、**重启/清空**、**冷启动**时,仍可从 DB 拉回与 Redis **合并**(对话历史取**更长**列表;`user_profile` / `context` 为**浅合并**)。
|
||||
- **不适用**:键名不是 `user_memory_*`、或 scope 无法解析为 Agent/工作流时,不会走该表。
|
||||
|
||||
---
|
||||
|
||||
## 6. 「能记住多少对话」
|
||||
|
||||
- **条数**:以 Cache 写入路径上的 **`max_history_length`** 为准,**默认保留最近 20 条**对话条目(多出的丢弃旧条目)。
|
||||
- **时间**:受 Redis **TTL** 影响热数据是否在内存;持久化打开时,**逻辑数据**仍可通过 DB + 下次合并恢复(以实际合并结果为准)。
|
||||
- **模型侧**:即使存储 20 条,提示词是否注入全文或摘要字段取决于**工作流模板**;过长时还受 **LLM 上下文长度**与成本约束。
|
||||
|
||||
---
|
||||
|
||||
## 7. 性能与瓶颈
|
||||
|
||||
- 单次对话:**主要耗时在 LLM 与工具调用**;记忆层一般为 **少量 Redis 读写**及(若开启)**单次 MySQL 读/写**,通常为毫秒~十毫秒量级,相对模型耗时可忽略。
|
||||
- **瓶颈**:高并发同一 `user_id` 串行执行工作流时的排队;超大 `conversation_history` 带来的序列化与提示词体积(已通过条数截断缓解)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 排查要点
|
||||
|
||||
- **串用户 / 丢记忆**:检查执行入参是否带 **`user_id`**、Cache **key** 是否与预期一致。
|
||||
- **改代码不生效**:工作流在 **Celery Worker** 中执行,需**重启 API 与 Celery** 后再测。
|
||||
- **画像为空但上一轮已告知姓名**:检查末行 JSON 是否包含 `user_profile`、是否为空对象覆盖;并确认引擎已支持「末行 JSON」解析(见 `WorkflowEngine` 中 `_parse_zhini_final_json_dict`、`_enrich_llm_json_user_profile` 等)。
|
||||
|
||||
---
|
||||
|
||||
## 9. 相关代码与脚本(索引)
|
||||
|
||||
| 说明 | 路径 |
|
||||
|------|------|
|
||||
| 引擎 Cache、合并、截断 | `backend/app/services/workflow_engine.py` |
|
||||
| DB 读写与合并 | `backend/app/services/persistent_memory_service.py` |
|
||||
| 持久化开关 | `backend/app/core/config.py`(`MEMORY_PERSIST_DB_ENABLED`) |
|
||||
| 13 号工作流/提示更新脚本 | `backend/scripts/create_zhini_kefu_13.py` |
|
||||
| 12 号基线提示与工具 | `backend/scripts/create_zhini_kefu_12.py` |
|
||||
|
||||
---
|
||||
|
||||
*文档随实现迭代可能变化,以仓库内代码与平台工作流配置为准。*
|
||||
@@ -11,7 +11,7 @@ from sqlalchemy.dialects import mysql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '003_add_rbac'
|
||||
down_revision = '002_add_template_market'
|
||||
down_revision = '002'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
49
backend/alembic/versions/005_persistent_user_memory.py
Normal file
49
backend/alembic/versions/005_persistent_user_memory.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""persistent user memory table
|
||||
|
||||
Revision ID: 005_persistent_user_memory
|
||||
Revises: 004_add_tools_table
|
||||
Create Date: 2026-04-06
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.mysql import CHAR, JSON
|
||||
|
||||
|
||||
revision = "005_persistent_user_memory"
|
||||
down_revision = "004_add_tools_table"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"persistent_user_memories",
|
||||
sa.Column("id", CHAR(36), nullable=False, comment="主键"),
|
||||
sa.Column("scope_kind", sa.String(16), nullable=False, comment="agent 或 workflow"),
|
||||
sa.Column("scope_id", CHAR(36), nullable=False, comment="Agent ID 或 Workflow ID"),
|
||||
sa.Column("session_key", sa.String(512), nullable=False, comment="会话键 user_id"),
|
||||
sa.Column("payload", JSON(), nullable=False, comment="记忆 JSON"),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(),
|
||||
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||
nullable=True,
|
||||
comment="更新时间",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("scope_kind", "scope_id", "session_key", name="uq_persistent_mem_scope_session"),
|
||||
mysql_charset="utf8mb4",
|
||||
mysql_collate="utf8mb4_unicode_ci",
|
||||
)
|
||||
op.create_index(
|
||||
"ix_persistent_mem_lookup",
|
||||
"persistent_user_memories",
|
||||
["scope_kind", "scope_id", "session_key"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_persistent_mem_lookup", table_name="persistent_user_memories")
|
||||
op.drop_table("persistent_user_memories")
|
||||
@@ -1,9 +1,15 @@
|
||||
"""
|
||||
应用配置
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
from typing import List
|
||||
|
||||
# 无论从项目根还是 backend 目录启动,始终加载 backend/.env
|
||||
_BACKEND_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
_ENV_PATH = _BACKEND_DIR / ".env"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""应用设置"""
|
||||
@@ -19,6 +25,14 @@ class Settings(BaseSettings):
|
||||
|
||||
# Redis配置
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
|
||||
# 会话记忆:除 Redis 外是否写入 MySQL(persistent_user_memories),实现跨重启、跨 TTL 的永久记忆
|
||||
MEMORY_PERSIST_DB_ENABLED: bool = True
|
||||
|
||||
# 本地文件工具(file_read / file_write):允许读写的根目录。空字符串表示使用「backend 的上一级目录」作为仓库根。
|
||||
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 字节)
|
||||
|
||||
# CORS配置(支持字符串或列表)
|
||||
CORS_ORIGINS: str = "http://localhost:3000,http://127.0.0.1:3000,http://localhost:8038,http://101.43.95.130:8038"
|
||||
@@ -40,7 +54,7 @@ class Settings(BaseSettings):
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file = str(_ENV_PATH)
|
||||
case_sensitive = True
|
||||
|
||||
|
||||
|
||||
72
backend/app/core/tools_bootstrap.py
Normal file
72
backend/app/core/tools_bootstrap.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""确保内置工具注册到 tool_registry(API 进程与 Celery Worker 均会导入执行流)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_registered = False
|
||||
|
||||
_EXPECTED_BUILTIN = 10
|
||||
|
||||
|
||||
def ensure_builtin_tools_registered() -> None:
|
||||
"""幂等:注册 file_write / system_info 等内置工具,供工作流 LLM 节点使用。"""
|
||||
global _registered
|
||||
if _registered:
|
||||
return
|
||||
from app.services.tool_registry import tool_registry
|
||||
from app.services.builtin_tools import (
|
||||
http_request_tool,
|
||||
file_read_tool,
|
||||
file_write_tool,
|
||||
text_analyze_tool,
|
||||
datetime_tool,
|
||||
math_calculate_tool,
|
||||
system_info_tool,
|
||||
json_process_tool,
|
||||
database_query_tool,
|
||||
adb_log_tool,
|
||||
HTTP_REQUEST_SCHEMA,
|
||||
FILE_READ_SCHEMA,
|
||||
FILE_WRITE_SCHEMA,
|
||||
TEXT_ANALYZE_SCHEMA,
|
||||
DATETIME_SCHEMA,
|
||||
MATH_CALCULATE_SCHEMA,
|
||||
SYSTEM_INFO_SCHEMA,
|
||||
JSON_PROCESS_SCHEMA,
|
||||
DATABASE_QUERY_SCHEMA,
|
||||
ADB_LOG_SCHEMA,
|
||||
)
|
||||
|
||||
tool_registry.register_builtin_tool("http_request", http_request_tool, HTTP_REQUEST_SCHEMA)
|
||||
tool_registry.register_builtin_tool("file_read", file_read_tool, FILE_READ_SCHEMA)
|
||||
tool_registry.register_builtin_tool("file_write", file_write_tool, FILE_WRITE_SCHEMA)
|
||||
tool_registry.register_builtin_tool("text_analyze", text_analyze_tool, TEXT_ANALYZE_SCHEMA)
|
||||
tool_registry.register_builtin_tool("datetime", datetime_tool, DATETIME_SCHEMA)
|
||||
tool_registry.register_builtin_tool("math_calculate", math_calculate_tool, MATH_CALCULATE_SCHEMA)
|
||||
tool_registry.register_builtin_tool("system_info", system_info_tool, SYSTEM_INFO_SCHEMA)
|
||||
tool_registry.register_builtin_tool("json_process", json_process_tool, JSON_PROCESS_SCHEMA)
|
||||
tool_registry.register_builtin_tool("database_query", database_query_tool, DATABASE_QUERY_SCHEMA)
|
||||
tool_registry.register_builtin_tool("adb_log", adb_log_tool, ADB_LOG_SCHEMA)
|
||||
_registered = True
|
||||
|
||||
n = tool_registry.builtin_tool_count()
|
||||
names = tool_registry.builtin_tool_names()
|
||||
pid = os.getpid()
|
||||
if n < _EXPECTED_BUILTIN:
|
||||
logger.warning(
|
||||
"内置工具注册数量异常: pid=%s count=%s 期望>=%s names=%s(LLM 工具调用可能失效)",
|
||||
pid,
|
||||
n,
|
||||
_EXPECTED_BUILTIN,
|
||||
names,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"内置工具就绪 pid=%s count=%s names=%s(Celery Worker 若缺此项日志,说明未加载 workflow_tasks / 未执行 bootstrap)",
|
||||
pid,
|
||||
n,
|
||||
names,
|
||||
)
|
||||
@@ -149,8 +149,32 @@ async def root():
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""健康检查"""
|
||||
return {"status": "healthy"}
|
||||
"""健康检查(含内置工具是否已注册,便于排查「可动手 Agent」)。"""
|
||||
from app.services.tool_registry import tool_registry
|
||||
|
||||
names = tool_registry.builtin_tool_names()
|
||||
count = tool_registry.builtin_tool_count()
|
||||
file_agent_core = {"file_write", "file_read", "system_info"}
|
||||
subset_ok = file_agent_core.issubset(set(names))
|
||||
expected_ok = count >= 10
|
||||
tools_ready = expected_ok and subset_ok
|
||||
|
||||
return {
|
||||
"status": "healthy",
|
||||
"checks": {
|
||||
"builtin_tools_ready": tools_ready,
|
||||
"builtin_tools_count_ok": expected_ok,
|
||||
"file_agent_core_ready": subset_ok,
|
||||
},
|
||||
"builtin_tools": {
|
||||
"count": count,
|
||||
"names": names,
|
||||
"expected_min_count": 10,
|
||||
},
|
||||
"notes": {
|
||||
"celery": "工作流/Agent 执行通常在 Celery Worker 中跑;Worker 日志中也应出现「内置工具就绪」且 count 应与 API 一致。若仅 API 有工具而 Worker 无,会出现 LLM 无法真正调用 file_write。",
|
||||
},
|
||||
}
|
||||
|
||||
# 应用启动时初始化数据库和工具
|
||||
@app.on_event("startup")
|
||||
@@ -164,44 +188,14 @@ async def startup_event():
|
||||
logger.error(f"数据库初始化失败: {e}")
|
||||
# 不抛出异常,允许应用继续启动
|
||||
|
||||
# 注册内置工具
|
||||
# 注册内置工具(与 Celery Worker 共用 app.core.tools_bootstrap)
|
||||
try:
|
||||
from app.core.tools_bootstrap import ensure_builtin_tools_registered
|
||||
|
||||
ensure_builtin_tools_registered()
|
||||
from app.services.tool_registry import tool_registry
|
||||
from app.services.builtin_tools import (
|
||||
http_request_tool,
|
||||
file_read_tool,
|
||||
file_write_tool,
|
||||
text_analyze_tool,
|
||||
datetime_tool,
|
||||
math_calculate_tool,
|
||||
system_info_tool,
|
||||
json_process_tool,
|
||||
database_query_tool,
|
||||
adb_log_tool,
|
||||
HTTP_REQUEST_SCHEMA,
|
||||
FILE_READ_SCHEMA,
|
||||
FILE_WRITE_SCHEMA,
|
||||
TEXT_ANALYZE_SCHEMA,
|
||||
DATETIME_SCHEMA,
|
||||
MATH_CALCULATE_SCHEMA,
|
||||
SYSTEM_INFO_SCHEMA,
|
||||
JSON_PROCESS_SCHEMA,
|
||||
DATABASE_QUERY_SCHEMA,
|
||||
ADB_LOG_SCHEMA
|
||||
)
|
||||
|
||||
tool_registry.register_builtin_tool("http_request", http_request_tool, HTTP_REQUEST_SCHEMA)
|
||||
tool_registry.register_builtin_tool("file_read", file_read_tool, FILE_READ_SCHEMA)
|
||||
tool_registry.register_builtin_tool("file_write", file_write_tool, FILE_WRITE_SCHEMA)
|
||||
tool_registry.register_builtin_tool("text_analyze", text_analyze_tool, TEXT_ANALYZE_SCHEMA)
|
||||
tool_registry.register_builtin_tool("datetime", datetime_tool, DATETIME_SCHEMA)
|
||||
tool_registry.register_builtin_tool("math_calculate", math_calculate_tool, MATH_CALCULATE_SCHEMA)
|
||||
tool_registry.register_builtin_tool("system_info", system_info_tool, SYSTEM_INFO_SCHEMA)
|
||||
tool_registry.register_builtin_tool("json_process", json_process_tool, JSON_PROCESS_SCHEMA)
|
||||
tool_registry.register_builtin_tool("database_query", database_query_tool, DATABASE_QUERY_SCHEMA)
|
||||
tool_registry.register_builtin_tool("adb_log", adb_log_tool, ADB_LOG_SCHEMA)
|
||||
|
||||
logger.info("内置工具注册完成(共10个工具)")
|
||||
|
||||
logger.info("内置工具注册完成(count=%s)", tool_registry.builtin_tool_count())
|
||||
except Exception as e:
|
||||
logger.error(f"内置工具注册失败: {e}")
|
||||
# 不抛出异常,允许应用继续启动
|
||||
|
||||
@@ -11,5 +11,6 @@ from app.models.workflow_template import WorkflowTemplate, TemplateRating, Templ
|
||||
from app.models.node_template import NodeTemplate
|
||||
from app.models.permission import Role, Permission, WorkflowPermission, AgentPermission
|
||||
from app.models.alert_rule import AlertRule, AlertLog
|
||||
from app.models.persistent_user_memory import PersistentUserMemory
|
||||
|
||||
__all__ = ["User", "Workflow", "WorkflowVersion", "Agent", "Execution", "ExecutionLog", "ModelConfig", "DataSource", "WorkflowTemplate", "TemplateRating", "TemplateFavorite", "NodeTemplate", "Role", "Permission", "WorkflowPermission", "AgentPermission", "AlertRule", "AlertLog"]
|
||||
__all__ = ["User", "Workflow", "WorkflowVersion", "Agent", "Execution", "ExecutionLog", "ModelConfig", "DataSource", "WorkflowTemplate", "TemplateRating", "TemplateFavorite", "NodeTemplate", "Role", "Permission", "WorkflowPermission", "AgentPermission", "AlertRule", "AlertLog", "PersistentUserMemory"]
|
||||
34
backend/app/models/persistent_user_memory.py
Normal file
34
backend/app/models/persistent_user_memory.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
按 Agent/工作流 + 会话键持久化的用户记忆(MySQL),与 Redis 热缓存配合实现长期记忆。
|
||||
"""
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Column, DateTime, String, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.mysql import CHAR, JSON
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class PersistentUserMemory(Base):
|
||||
"""会话级记忆快照:同一 scope 下 session_key 唯一。"""
|
||||
|
||||
__tablename__ = "persistent_user_memories"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("scope_kind", "scope_id", "session_key", name="uq_persistent_mem_scope_session"),
|
||||
)
|
||||
|
||||
id = Column(CHAR(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="主键")
|
||||
scope_kind = Column(String(16), nullable=False, comment="agent 或 workflow")
|
||||
scope_id = Column(CHAR(36), nullable=False, comment="Agent ID 或 Workflow ID")
|
||||
session_key = Column(String(512), nullable=False, comment="调用方传入的 user_id 等会话键")
|
||||
payload = Column(JSON, nullable=False, comment="与 Redis 中 user_memory_* 结构一致的记忆 JSON")
|
||||
updated_at = Column(
|
||||
DateTime,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=True,
|
||||
comment="更新时间",
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PersistentUserMemory(scope={self.scope_kind}:{self.scope_id}, session={self.session_key[:32]})>"
|
||||
@@ -2,6 +2,7 @@
|
||||
内置工具实现
|
||||
"""
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
from pathlib import Path
|
||||
import httpx
|
||||
import json
|
||||
import os
|
||||
@@ -20,6 +21,51 @@ from app.core.config import settings
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _local_file_workspace_root() -> Path:
|
||||
"""file_read/file_write 允许操作的根目录(解析后路径)。"""
|
||||
raw = (getattr(settings, "LOCAL_FILE_TOOLS_ROOT", None) or "").strip()
|
||||
if raw:
|
||||
return Path(raw).expanduser().resolve()
|
||||
# backend/app/services/builtin_tools.py -> 上级 x4 = 仓库根(与历史上 join(services_dir, '../../..') 一致)
|
||||
return Path(__file__).resolve().parent.parent.parent.parent
|
||||
|
||||
|
||||
def _sanitize_tool_path_string(file_path: str) -> str:
|
||||
"""
|
||||
去掉 LLM/DSML/Markdown 泄漏到路径末尾的非法字符(如 123.md<|、引号),避免 WinError 123。
|
||||
"""
|
||||
if not isinstance(file_path, str):
|
||||
return str(file_path)
|
||||
s = file_path.replace("\ufeff", "").strip()
|
||||
s = s.strip('"').strip("'")
|
||||
# 行尾非法:Windows 文件名不能含 <>:"|?*;模型常把半角/全角尖括号粘在扩展名后
|
||||
tail_bad = '<><>"|?*:"|*'
|
||||
while s and s[-1] in tail_bad:
|
||||
s = s[:-1].rstrip()
|
||||
# 若仍含未配对尖括号片段在行尾(如 xxx.md<)
|
||||
s = re.sub(r'[<<][||]?\s*$', "", s).rstrip()
|
||||
return s
|
||||
|
||||
|
||||
def _resolve_path_under_workspace(file_path: str) -> Tuple[Optional[Path], Optional[str]]:
|
||||
"""将用户给定路径解析为绝对路径,且必须位于工作区内。"""
|
||||
file_path = _sanitize_tool_path_string(file_path)
|
||||
root = _local_file_workspace_root()
|
||||
try:
|
||||
p = Path(file_path).expanduser()
|
||||
if not p.is_absolute():
|
||||
p = (root / p).resolve()
|
||||
else:
|
||||
p = p.resolve()
|
||||
except (OSError, ValueError) as e:
|
||||
return None, str(e)
|
||||
try:
|
||||
p.relative_to(root)
|
||||
except ValueError:
|
||||
return None, f"不允许访问工作区外路径,允许根目录: {root}"
|
||||
return p, None
|
||||
|
||||
|
||||
async def http_request_tool(
|
||||
url: str,
|
||||
method: str = "GET",
|
||||
@@ -82,33 +128,32 @@ async def file_read_tool(file_path: str) -> str:
|
||||
文件内容或错误信息
|
||||
"""
|
||||
try:
|
||||
# 安全检查:限制可读取的文件路径
|
||||
# 只允许读取项目目录下的文件
|
||||
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))
|
||||
abs_path = os.path.abspath(file_path)
|
||||
|
||||
if not abs_path.startswith(project_root):
|
||||
return json.dumps({
|
||||
"error": f"不允许读取项目目录外的文件: {file_path}"
|
||||
}, ensure_ascii=False)
|
||||
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
return json.dumps({
|
||||
"file_path": file_path,
|
||||
"content": content,
|
||||
"size": len(content)
|
||||
}, ensure_ascii=False)
|
||||
max_bytes = int(getattr(settings, "LOCAL_FILE_READ_MAX_BYTES", 2_097_152) or 2_097_152)
|
||||
path, err = _resolve_path_under_workspace(file_path)
|
||||
if err:
|
||||
return json.dumps({"error": err}, ensure_ascii=False)
|
||||
if not path.is_file():
|
||||
return json.dumps({"error": f"文件不存在或不是普通文件: {path}"}, ensure_ascii=False)
|
||||
fsize = path.stat().st_size
|
||||
if fsize > max_bytes:
|
||||
return json.dumps(
|
||||
{"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,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return json.dumps({
|
||||
"error": f"文件不存在: {file_path}"
|
||||
}, ensure_ascii=False)
|
||||
return json.dumps({"error": f"文件不存在: {file_path}"}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.error(f"文件读取工具执行失败: {str(e)}")
|
||||
return json.dumps({
|
||||
"error": str(e)
|
||||
}, ensure_ascii=False)
|
||||
return json.dumps({"error": str(e)}, ensure_ascii=False)
|
||||
|
||||
|
||||
async def file_write_tool(file_path: str, content: str, mode: str = "w") -> str:
|
||||
@@ -124,30 +169,36 @@ async def file_write_tool(file_path: str, content: str, mode: str = "w") -> str:
|
||||
写入结果
|
||||
"""
|
||||
try:
|
||||
# 安全检查:限制可写入的文件路径
|
||||
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../.."))
|
||||
abs_path = os.path.abspath(file_path)
|
||||
|
||||
if not abs_path.startswith(project_root):
|
||||
return json.dumps({
|
||||
"error": f"不允许写入项目目录外的文件: {file_path}"
|
||||
}, ensure_ascii=False)
|
||||
|
||||
max_bytes = int(getattr(settings, "LOCAL_FILE_WRITE_MAX_BYTES", 2_097_152) or 2_097_152)
|
||||
raw = content if isinstance(content, str) else str(content)
|
||||
enc_len = len(raw.encode("utf-8"))
|
||||
if enc_len > max_bytes:
|
||||
return json.dumps(
|
||||
{"error": f"写入内容过大({enc_len} 字节 UTF-8),上限 {max_bytes}"},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
path, err = _resolve_path_under_workspace(file_path)
|
||||
if err:
|
||||
return json.dumps({"error": err}, ensure_ascii=False)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
write_mode = "a" if mode == "a" else "w"
|
||||
with open(file_path, write_mode, encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"file_path": file_path,
|
||||
"mode": write_mode,
|
||||
"content_length": len(content)
|
||||
}, ensure_ascii=False)
|
||||
if write_mode == "a":
|
||||
with path.open("a", encoding="utf-8") as f:
|
||||
f.write(raw)
|
||||
else:
|
||||
path.write_text(raw, encoding="utf-8")
|
||||
return json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"file_path": str(path),
|
||||
"mode": write_mode,
|
||||
"content_length": enc_len,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"文件写入工具执行失败: {str(e)}")
|
||||
return json.dumps({
|
||||
"error": str(e)
|
||||
}, ensure_ascii=False)
|
||||
return json.dumps({"error": str(e)}, ensure_ascii=False)
|
||||
|
||||
|
||||
async def text_analyze_tool(text: str, operation: str = "count") -> str:
|
||||
@@ -321,8 +372,10 @@ async def system_info_tool() -> str:
|
||||
"python_version_info": {
|
||||
"major": sys.version_info.major,
|
||||
"minor": sys.version_info.minor,
|
||||
"micro": sys.version_info.micro
|
||||
}
|
||||
"micro": sys.version_info.micro,
|
||||
},
|
||||
# file_read/file_write 使用的允许根目录(与 LOCAL_FILE_TOOLS_ROOT 一致)
|
||||
"local_file_workspace_root": str(_local_file_workspace_root()),
|
||||
}
|
||||
|
||||
return json.dumps(info, ensure_ascii=False)
|
||||
@@ -760,7 +813,7 @@ FILE_READ_SCHEMA = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "file_read",
|
||||
"description": "读取文件内容。只能读取项目目录下的文件,确保文件路径正确。",
|
||||
"description": "读取本地文本文件(UTF-8)。路径须落在平台配置的工作区内(默认仓库根;可通过环境变量 LOCAL_FILE_TOOLS_ROOT 修改)。超过大小上限会拒绝。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -778,7 +831,7 @@ FILE_WRITE_SCHEMA = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "file_write",
|
||||
"description": "写入文件内容。只能写入项目目录下的文件,确保文件路径正确。",
|
||||
"description": "写入本地文本文件(UTF-8,w 覆盖 / a 追加)。路径须落在工作区内;父目录不存在会自动创建。勿写入密码、密钥等大型敏感内容。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -872,7 +925,7 @@ SYSTEM_INFO_SCHEMA = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "system_info",
|
||||
"description": "获取系统信息,包括操作系统、Python版本等。",
|
||||
"description": "获取系统信息(含 local_file_workspace_root:file_read/file_write 允许访问的根目录)。用户问「工作区路径」时应调用本工具并如实转述该字段。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
|
||||
@@ -3,6 +3,8 @@ LLM服务 - 处理各种LLM提供商的调用
|
||||
"""
|
||||
from typing import Dict, Any, Optional, List
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
@@ -13,6 +15,316 @@ from app.services.tool_registry import tool_registry
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _extract_dsml_parameter_args(chunk: str) -> Dict[str, str]:
|
||||
"""
|
||||
DeepSeek 新版 DSML 常用「parameter」而非「invoke_arg」:
|
||||
<|DSML|parameter name="file_path" type="string">D:\\path\\file.md
|
||||
多行正文会出现在 name="content" 的 > 之后、下一个 parameter 之前。
|
||||
|
||||
注意:标签可能使用全角 < (U+FF1C),不能用 ASCII [^<] 截断;file_path/mode 等取首行即可。
|
||||
"""
|
||||
args: Dict[str, str] = {}
|
||||
pat = re.compile(r'parameter\s+name="([^"]+)"(?:\s+type="[^"]*")?\s*>', re.IGNORECASE)
|
||||
ms = list(pat.finditer(chunk))
|
||||
for i, m in enumerate(ms):
|
||||
key = m.group(1).strip()
|
||||
start = m.end()
|
||||
end = ms[i + 1].start() if i + 1 < len(ms) else len(chunk)
|
||||
raw = chunk[start:end]
|
||||
if key.lower() == "content":
|
||||
val = raw.strip()
|
||||
# 模型在空 content 后紧接半截 DSML 行,勿写入文件
|
||||
kept: List[str] = []
|
||||
for line in val.splitlines():
|
||||
t = line.strip()
|
||||
if not t:
|
||||
continue
|
||||
if "DSML" in t.upper() and "parameter" not in t.lower():
|
||||
continue
|
||||
kept.append(line)
|
||||
val = "\n".join(kept).strip()
|
||||
else:
|
||||
val = raw.lstrip().split("\n", 1)[0].strip()
|
||||
for sep in ("<", "<"):
|
||||
if sep in val:
|
||||
val = val.split(sep, 1)[0].strip()
|
||||
if key:
|
||||
args[key] = val
|
||||
return args
|
||||
|
||||
|
||||
def _extract_dsml_invoke_content_args(chunk: str) -> Dict[str, str]:
|
||||
"""
|
||||
部分 DeepSeek 输出使用 invoke_content 块(与 parameter 并列):
|
||||
<|DSML|invoke_content name="file_path">
|
||||
123.md
|
||||
<|DSML|invoke_content name="content">
|
||||
...
|
||||
"""
|
||||
args: Dict[str, str] = {}
|
||||
pat = re.compile(r'invoke_content\s+name="([^"]+)"[^>]*>', re.IGNORECASE)
|
||||
ms = list(pat.finditer(chunk))
|
||||
for i, m in enumerate(ms):
|
||||
key = m.group(1).strip()
|
||||
start = m.end()
|
||||
end = ms[i + 1].start() if i + 1 < len(ms) else len(chunk)
|
||||
raw = chunk[start:end]
|
||||
if key.lower() == "content":
|
||||
val = raw.strip()
|
||||
kept: List[str] = []
|
||||
for line in val.splitlines():
|
||||
t = line.strip()
|
||||
if not t:
|
||||
continue
|
||||
if "DSML" in t.upper() and "invoke_content" not in t.lower() and "parameter" not in t.lower():
|
||||
continue
|
||||
kept.append(line)
|
||||
val = "\n".join(kept).strip()
|
||||
else:
|
||||
val = raw.lstrip().split("\n", 1)[0].strip()
|
||||
for sep in ("<", "<"):
|
||||
if sep in val:
|
||||
val = val.split(sep, 1)[0].strip()
|
||||
if key:
|
||||
args[key] = val
|
||||
return args
|
||||
|
||||
|
||||
def _merge_dsml_arg_dicts(*dicts: Dict[str, str]) -> Dict[str, str]:
|
||||
"""后者仅填补前者缺失或空字符串的键。"""
|
||||
out: Dict[str, str] = {}
|
||||
for d in dicts:
|
||||
for k, v in d.items():
|
||||
if k not in out or (isinstance(out.get(k), str) and not str(out[k]).strip()):
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def _dedupe_consecutive_dsml_tool_headers(text: str) -> str:
|
||||
"""
|
||||
模型异常时连续输出多行相同的 invoke name="tool" 头且无参数,导致解析器切成大量空 chunk。
|
||||
合并「与上一行完全相同的 invoke 行」,保留首次,使后续 invoke_content/parameter 仍与第一次 invoke 同属一块。
|
||||
空行会打断「连续」,其后的重复头再次保留一行(兼容极少数真需两次调用的草稿)。
|
||||
"""
|
||||
lines = text.splitlines(keepends=True)
|
||||
out_lines: List[str] = []
|
||||
prev_sig: Optional[tuple[str, str]] = None
|
||||
for line in lines:
|
||||
m = re.search(r'invoke\s+name="([^"]+)"', line, re.I)
|
||||
if m:
|
||||
tool = m.group(1).strip().lower()
|
||||
sig = (tool, line.strip())
|
||||
if prev_sig == sig:
|
||||
continue
|
||||
prev_sig = sig
|
||||
else:
|
||||
if not line.strip():
|
||||
pass
|
||||
prev_sig = None
|
||||
out_lines.append(line)
|
||||
return "".join(out_lines)
|
||||
|
||||
|
||||
def _dedupe_consecutive_function_call_headers(text: str) -> str:
|
||||
"""同上,针对 function_call name="..." 重复行。"""
|
||||
lines = text.splitlines(keepends=True)
|
||||
out_lines: List[str] = []
|
||||
prev_sig: Optional[tuple[str, str]] = None
|
||||
for line in lines:
|
||||
m = re.search(r'function_call\s+name="([^"]+)"', line, re.I)
|
||||
if m:
|
||||
tool = m.group(1).strip().lower()
|
||||
sig = (tool, line.strip())
|
||||
if prev_sig == sig:
|
||||
continue
|
||||
prev_sig = sig
|
||||
else:
|
||||
if not line.strip():
|
||||
pass
|
||||
prev_sig = None
|
||||
out_lines.append(line)
|
||||
return "".join(out_lines)
|
||||
|
||||
|
||||
def _parse_dsml_tool_invocations(content: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
部分模型(如 DeepSeek)会把 function call 以 DSML 形式写在 content 里,而 message.tool_calls 为空。
|
||||
解析为 [{\"name\": str, \"arguments\": dict}, ...],供与 OpenAI tool_calls 相同的执行路径处理。
|
||||
|
||||
兼容:
|
||||
- invoke_arg … </invoke_arg(旧)
|
||||
- parameter name="k" type="…">v(新,可无闭合标签)
|
||||
- invoke_content name="k">v(DeepSeek 另一种块)
|
||||
- function_call name="tool" 与 invoke name="tool" 交替出现
|
||||
- Windows 绝对路径 + 反斜杠 user_data
|
||||
"""
|
||||
if not content:
|
||||
return []
|
||||
if not re.search(r'invoke\s+name="|function_call\s+name="', content):
|
||||
return []
|
||||
|
||||
content = _dedupe_consecutive_function_call_headers(
|
||||
_dedupe_consecutive_dsml_tool_headers(content)
|
||||
)
|
||||
|
||||
out: List[Dict[str, Any]] = []
|
||||
signatures: set = set()
|
||||
head_re = re.compile(r'(?:invoke|function_call)\s+name="([^"]+)"')
|
||||
matches = list(head_re.finditer(content))
|
||||
for i, m in enumerate(matches):
|
||||
tname = m.group(1).strip()
|
||||
rest_start = m.end()
|
||||
nxt_start = matches[i + 1].start() if i + 1 < len(matches) else len(content)
|
||||
chunk = content[rest_start:nxt_start]
|
||||
|
||||
args: Dict[str, Any] = {}
|
||||
for am in re.finditer(
|
||||
r'invoke_arg\s+name="([^"]+)"[^>]*>\s*(.*?)\s*</[^>]*invoke_arg',
|
||||
chunk,
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
):
|
||||
args[am.group(1).strip()] = am.group(2).strip()
|
||||
|
||||
param_args = _extract_dsml_parameter_args(chunk)
|
||||
invc_args = _extract_dsml_invoke_content_args(chunk)
|
||||
merged_flat = _merge_dsml_arg_dicts(param_args, invc_args)
|
||||
for k, v in merged_flat.items():
|
||||
if k not in args or (isinstance(args.get(k), str) and not str(args[k]).strip()):
|
||||
args[k] = v
|
||||
|
||||
if not args:
|
||||
continue
|
||||
sig = (tname, json.dumps(args, sort_keys=True, ensure_ascii=False))
|
||||
if sig in signatures:
|
||||
continue
|
||||
signatures.add(sig)
|
||||
out.append({"name": tname, "arguments": args})
|
||||
|
||||
# 仍找不到完整工具块时:从全文兜底抽取 file_write(含仅 parameter、无 invoke_arg)
|
||||
if not out and "file_write" in content:
|
||||
fp = None
|
||||
pm = re.search(
|
||||
r'parameter\s+name="file_path"[^>]*>\s*([^\r\n]+)',
|
||||
content,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if pm:
|
||||
fp = pm.group(1).strip()
|
||||
if not fp:
|
||||
pm_ic = re.search(
|
||||
r'invoke_content\s+name="file_path"[^>]*>\s*([^\r\n]+)',
|
||||
content,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if pm_ic:
|
||||
fp = pm_ic.group(1).strip()
|
||||
if not fp:
|
||||
pm2 = re.search(r'user_data[/\\][^\s<|"\'\n]+\.[a-zA-Z0-9]+', content)
|
||||
if pm2:
|
||||
fp = pm2.group(0)
|
||||
if not fp:
|
||||
pm3 = re.search(
|
||||
r'([A-Za-z]:\\(?:[^|<\n\r]+\\)+[^|<\n\r]+\.[a-zA-Z0-9]+)',
|
||||
content,
|
||||
)
|
||||
if pm3:
|
||||
fp = pm3.group(1).strip()
|
||||
if not fp:
|
||||
# 模型只重复 invoke 行、未给 file_path 时:从「《静夜思》」或「静夜思.md」等推断文件名
|
||||
pm_book = re.search(r'《\s*([\u4e00-\u9fff]{2,24})\s*》', content)
|
||||
if pm_book:
|
||||
fp = pm_book.group(1).strip() + ".md"
|
||||
if not fp:
|
||||
pm_title = re.search(
|
||||
r'(?:《\s*)?([\u4e00-\u9fff]{2,32})(?:\s*》)?\s*\.md\b',
|
||||
content,
|
||||
)
|
||||
if pm_title:
|
||||
fp = pm_title.group(1).strip() + ".md"
|
||||
|
||||
body = ""
|
||||
cm = re.search(
|
||||
r'invoke_arg\s+name="content"[^>]*>\s*(.*?)\s*</[^>]*invoke_arg',
|
||||
content,
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
if cm:
|
||||
body = cm.group(1).strip()
|
||||
if not body:
|
||||
cm2 = re.search(
|
||||
r'parameter\s+name="content"[^>]*>\s*',
|
||||
content,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if cm2:
|
||||
after = content[cm2.end() :]
|
||||
nxt = re.search(
|
||||
r'(?:<[^>]*>)?(?:parameter\s+name=|function_call\s+name=|invoke\s+name=)',
|
||||
after,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
body = (after[: nxt.start()] if nxt else after).strip()
|
||||
|
||||
if not body:
|
||||
cm_ic = re.search(
|
||||
r'invoke_content\s+name="content"[^>]*>\s*',
|
||||
content,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if cm_ic:
|
||||
after = content[cm_ic.end() :]
|
||||
nxt = re.search(
|
||||
r'(?:<[^>]*>)?(?:invoke_content\s+name=|parameter\s+name=|function_call\s+name=|invoke\s+name=)',
|
||||
after,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
body = (after[: nxt.start()] if nxt else after).strip()
|
||||
|
||||
if not body:
|
||||
idx = content.find('invoke name="file_write"')
|
||||
if idx == -1:
|
||||
idx = content.find('function_call name="file_write"')
|
||||
if idx >= 0:
|
||||
cm3 = re.search(
|
||||
r"(#\s*\w+.*?)(?=\n\n|\Z)",
|
||||
content[idx:],
|
||||
re.DOTALL,
|
||||
)
|
||||
if cm3:
|
||||
body = cm3.group(1).strip()
|
||||
|
||||
mode_m = re.search(r'parameter\s+name="mode"[^>]*>\s*(\w+)', content, re.IGNORECASE)
|
||||
if not mode_m:
|
||||
mode_m = re.search(
|
||||
r'invoke_content\s+name="mode"[^>]*>\s*(\w+)',
|
||||
content,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
mode = (mode_m.group(1).strip() if mode_m else None) or "w"
|
||||
|
||||
if fp and not (body or "").strip():
|
||||
# 古诗类请求常见:仅有标题文件名,无正文块
|
||||
blob = (fp + content)
|
||||
if "静夜思" in blob:
|
||||
body = (
|
||||
"# 静夜思\n\n"
|
||||
"床前明月光,疑是地上霜。\n"
|
||||
"举头望明月,低头思故乡。\n"
|
||||
)
|
||||
|
||||
if fp:
|
||||
out.append({
|
||||
"name": "file_write",
|
||||
"arguments": {
|
||||
"file_path": fp,
|
||||
"content": body if body else " ",
|
||||
"mode": mode,
|
||||
},
|
||||
})
|
||||
|
||||
return out
|
||||
|
||||
|
||||
class LLMService:
|
||||
"""LLM服务类"""
|
||||
|
||||
@@ -236,7 +548,8 @@ class LLMService:
|
||||
api_key: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
max_iterations: int = 5,
|
||||
execution_logger = None
|
||||
execution_logger = None,
|
||||
tool_choice: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
调用OpenAI API,支持工具调用
|
||||
@@ -300,125 +613,121 @@ class LLMService:
|
||||
"function": tool
|
||||
})
|
||||
create_kwargs["tools"] = openai_tools
|
||||
create_kwargs["tool_choice"] = "auto"
|
||||
# auto:一般对话;required:至少一次 function call(节点 data.tool_choice / 环境变量 LLM_TOOL_CHOICE)
|
||||
_tc = (tool_choice or os.environ.get("LLM_TOOL_CHOICE") or "auto").strip().lower()
|
||||
create_kwargs["tool_choice"] = "required" if _tc == "required" else "auto"
|
||||
|
||||
# 调用LLM
|
||||
response = await client.chat.completions.create(**create_kwargs)
|
||||
|
||||
message = response.choices[0].message
|
||||
|
||||
# 添加助手回复到消息历史
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": message.content,
|
||||
"tool_calls": [
|
||||
{
|
||||
tool_calls_dicts: List[Dict[str, Any]] = []
|
||||
if message.tool_calls:
|
||||
for tc in message.tool_calls:
|
||||
tool_calls_dicts.append({
|
||||
"id": tc.id,
|
||||
"type": tc.type,
|
||||
"function": {
|
||||
"name": tc.function.name,
|
||||
"arguments": tc.function.arguments
|
||||
}
|
||||
} for tc in (message.tool_calls or [])
|
||||
]
|
||||
"arguments": tc.function.arguments,
|
||||
},
|
||||
})
|
||||
else:
|
||||
dsml = _parse_dsml_tool_invocations(message.content or "")
|
||||
if dsml:
|
||||
logger.info("检测到 DeepSeek DSML 嵌入工具调用 %s 个", len(dsml))
|
||||
for i, inv in enumerate(dsml):
|
||||
tool_calls_dicts.append({
|
||||
"id": f"dsml-{iteration}-{i}",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": inv["name"],
|
||||
"arguments": json.dumps(inv["arguments"], ensure_ascii=False),
|
||||
},
|
||||
})
|
||||
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": message.content,
|
||||
"tool_calls": tool_calls_dicts,
|
||||
})
|
||||
|
||||
# 检查是否有工具调用
|
||||
if message.tool_calls and len(message.tool_calls) > 0:
|
||||
logger.info(f"检测到 {len(message.tool_calls)} 个工具调用")
|
||||
|
||||
# 记录工具调用开始
|
||||
|
||||
if not tool_calls_dicts:
|
||||
final_content = message.content or ""
|
||||
if final_content:
|
||||
logger.info("LLM返回最终回复,无工具调用")
|
||||
return final_content
|
||||
continue
|
||||
|
||||
logger.info(f"检测到 {len(tool_calls_dicts)} 个工具调用")
|
||||
if execution_logger:
|
||||
execution_logger.info(
|
||||
f"LLM请求调用 {len(tool_calls_dicts)} 个工具",
|
||||
data={"tool_calls_count": len(tool_calls_dicts), "iteration": iteration + 1},
|
||||
)
|
||||
|
||||
for tool_call in tool_calls_dicts:
|
||||
fn = tool_call.get("function") or {}
|
||||
tool_name = fn.get("name") or ""
|
||||
tool_call_id = tool_call.get("id") or "unknown"
|
||||
try:
|
||||
tool_args = json.loads(fn.get("arguments") or "{}")
|
||||
except Exception:
|
||||
tool_args = {}
|
||||
|
||||
logger.info(f"执行工具: {tool_name}, 参数: {tool_args}")
|
||||
tool_start_time = time.time()
|
||||
if execution_logger:
|
||||
execution_logger.info(
|
||||
f"LLM请求调用 {len(message.tool_calls)} 个工具",
|
||||
f"调用工具: {tool_name}",
|
||||
data={
|
||||
"tool_calls_count": len(message.tool_calls),
|
||||
"iteration": iteration + 1
|
||||
}
|
||||
"tool_name": tool_name,
|
||||
"tool_call_id": tool_call_id,
|
||||
"tool_args": tool_args,
|
||||
"status": "requested",
|
||||
},
|
||||
)
|
||||
|
||||
# 处理每个工具调用
|
||||
for tool_call in message.tool_calls:
|
||||
tool_name = tool_call.function.name
|
||||
tool_call_id = tool_call.id
|
||||
try:
|
||||
tool_args = json.loads(tool_call.function.arguments)
|
||||
except:
|
||||
tool_args = {}
|
||||
|
||||
logger.info(f"执行工具: {tool_name}, 参数: {tool_args}")
|
||||
|
||||
# 记录工具调用请求
|
||||
tool_start_time = time.time()
|
||||
try:
|
||||
tool_result = await self._execute_tool(tool_name, tool_args)
|
||||
tool_duration = int((time.time() - tool_start_time) * 1000)
|
||||
if execution_logger:
|
||||
result_preview = tool_result
|
||||
if len(result_preview) > 500:
|
||||
result_preview = result_preview[:500] + "..."
|
||||
execution_logger.info(
|
||||
f"调用工具: {tool_name}",
|
||||
f"工具 {tool_name} 执行成功",
|
||||
data={
|
||||
"tool_name": tool_name,
|
||||
"tool_call_id": tool_call_id,
|
||||
"tool_args": tool_args,
|
||||
"status": "requested"
|
||||
}
|
||||
"tool_result": result_preview,
|
||||
"tool_result_length": len(tool_result),
|
||||
"status": "success",
|
||||
"duration": tool_duration,
|
||||
},
|
||||
duration=tool_duration,
|
||||
)
|
||||
|
||||
# 执行工具
|
||||
try:
|
||||
tool_result = await self._execute_tool(tool_name, tool_args)
|
||||
tool_duration = int((time.time() - tool_start_time) * 1000)
|
||||
|
||||
# 记录工具调用成功
|
||||
if execution_logger:
|
||||
# 截断过长的结果用于日志
|
||||
result_preview = tool_result
|
||||
if len(result_preview) > 500:
|
||||
result_preview = result_preview[:500] + "..."
|
||||
|
||||
execution_logger.info(
|
||||
f"工具 {tool_name} 执行成功",
|
||||
data={
|
||||
"tool_name": tool_name,
|
||||
"tool_call_id": tool_call_id,
|
||||
"tool_args": tool_args,
|
||||
"tool_result": result_preview,
|
||||
"tool_result_length": len(tool_result),
|
||||
"status": "success",
|
||||
"duration": tool_duration
|
||||
},
|
||||
duration=tool_duration
|
||||
)
|
||||
except Exception as tool_error:
|
||||
tool_duration = int((time.time() - tool_start_time) * 1000)
|
||||
|
||||
# 记录工具调用失败
|
||||
if execution_logger:
|
||||
execution_logger.error(
|
||||
f"工具 {tool_name} 执行失败: {str(tool_error)}",
|
||||
data={
|
||||
"tool_name": tool_name,
|
||||
"tool_call_id": tool_call_id,
|
||||
"tool_args": tool_args,
|
||||
"error": str(tool_error),
|
||||
"status": "failed",
|
||||
"duration": tool_duration
|
||||
},
|
||||
duration=tool_duration
|
||||
)
|
||||
|
||||
# 返回错误结果
|
||||
tool_result = json.dumps({"error": str(tool_error)}, ensure_ascii=False)
|
||||
|
||||
# 添加工具结果到消息历史
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call_id,
|
||||
"content": tool_result
|
||||
})
|
||||
else:
|
||||
# 没有工具调用,返回最终回复
|
||||
final_content = message.content or ""
|
||||
if final_content:
|
||||
logger.info("LLM返回最终回复,工具调用完成")
|
||||
return final_content
|
||||
except Exception as tool_error:
|
||||
tool_duration = int((time.time() - tool_start_time) * 1000)
|
||||
if execution_logger:
|
||||
execution_logger.error(
|
||||
f"工具 {tool_name} 执行失败: {str(tool_error)}",
|
||||
data={
|
||||
"tool_name": tool_name,
|
||||
"tool_call_id": tool_call_id,
|
||||
"tool_args": tool_args,
|
||||
"error": str(tool_error),
|
||||
"status": "failed",
|
||||
"duration": tool_duration,
|
||||
},
|
||||
duration=tool_duration,
|
||||
)
|
||||
tool_result = json.dumps({"error": str(tool_error)}, ensure_ascii=False)
|
||||
|
||||
messages.append(
|
||||
{"role": "tool", "tool_call_id": tool_call_id, "content": tool_result}
|
||||
)
|
||||
|
||||
# 达到最大迭代次数
|
||||
logger.warning(f"达到最大工具调用迭代次数 ({max_iterations})")
|
||||
@@ -438,7 +747,8 @@ class LLMService:
|
||||
api_key: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
max_iterations: int = 5,
|
||||
execution_logger = None
|
||||
execution_logger = None,
|
||||
tool_choice: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
调用DeepSeek API,支持工具调用(DeepSeek兼容OpenAI API格式)
|
||||
@@ -453,7 +763,8 @@ class LLMService:
|
||||
api_key=api_key or settings.DEEPSEEK_API_KEY,
|
||||
base_url=base_url or settings.DEEPSEEK_BASE_URL,
|
||||
max_iterations=max_iterations,
|
||||
execution_logger=execution_logger
|
||||
execution_logger=execution_logger,
|
||||
tool_choice=tool_choice,
|
||||
)
|
||||
|
||||
async def call_llm_with_tools(
|
||||
@@ -465,6 +776,7 @@ class LLMService:
|
||||
temperature: float = 0.7,
|
||||
max_tokens: Optional[int] = None,
|
||||
execution_logger = None,
|
||||
tool_choice: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> str:
|
||||
"""
|
||||
@@ -492,6 +804,7 @@ class LLMService:
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
execution_logger=execution_logger,
|
||||
tool_choice=tool_choice,
|
||||
**kwargs
|
||||
)
|
||||
elif provider == "deepseek":
|
||||
@@ -504,6 +817,7 @@ class LLMService:
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
execution_logger=execution_logger,
|
||||
tool_choice=tool_choice,
|
||||
**kwargs
|
||||
)
|
||||
else:
|
||||
|
||||
127
backend/app/services/persistent_memory_service.py
Normal file
127
backend/app/services/persistent_memory_service.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
用户会话记忆持久化:与 Cache 节点 user_memory_* 键对齐,写入 MySQL,Redis 仍作热缓存。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.persistent_user_memory import PersistentUserMemory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
USER_MEMORY_PREFIX = "user_memory_"
|
||||
|
||||
|
||||
def parse_memory_scope(workflow_id: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""
|
||||
Celery 任务里 workflow_id 对 Agent 为 agent_{uuid},对工作流为 UUID 字符串。
|
||||
返回 (scope_kind, scope_id) 或 (None, None) 表示不做 DB 持久化。
|
||||
"""
|
||||
if not workflow_id:
|
||||
return None, None
|
||||
if workflow_id.startswith("agent_") and len(workflow_id) > 6:
|
||||
return "agent", workflow_id[6:]
|
||||
# 标准 UUID
|
||||
s = workflow_id.strip()
|
||||
if len(s) == 36 and s.count("-") == 4:
|
||||
return "workflow", s
|
||||
return None, None
|
||||
|
||||
|
||||
def is_user_memory_redis_key(key: str) -> bool:
|
||||
return isinstance(key, str) and key.startswith(USER_MEMORY_PREFIX)
|
||||
|
||||
|
||||
def session_key_from_user_memory_key(key: str) -> Optional[str]:
|
||||
if not is_user_memory_redis_key(key):
|
||||
return None
|
||||
rest = key[len(USER_MEMORY_PREFIX) :]
|
||||
return rest if rest else None
|
||||
|
||||
|
||||
def merge_memory_payloads(
|
||||
base: Optional[Dict[str, Any]], overlay: Optional[Dict[str, Any]]
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""合并 DB 与 Redis:对话历史取更长的一条;画像/上下文做浅合并。"""
|
||||
if not base and not overlay:
|
||||
return None
|
||||
if not base:
|
||||
return dict(overlay) if isinstance(overlay, dict) else None
|
||||
if not overlay:
|
||||
return dict(base)
|
||||
out = dict(base)
|
||||
if not isinstance(overlay, dict):
|
||||
return out
|
||||
for k, v in overlay.items():
|
||||
if k == "conversation_history" and isinstance(v, list) and isinstance(out.get(k), list):
|
||||
if len(v) > len(out[k]):
|
||||
out[k] = v
|
||||
elif k == "user_profile" and isinstance(v, dict):
|
||||
out[k] = {**(out.get(k) or {}), **v}
|
||||
elif k == "context" and isinstance(v, dict):
|
||||
out[k] = {**(out.get(k) or {}), **v}
|
||||
else:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def load_persistent_memory(
|
||||
db: Session, scope_kind: str, scope_id: str, session_key: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
row = (
|
||||
db.query(PersistentUserMemory)
|
||||
.filter(
|
||||
PersistentUserMemory.scope_kind == scope_kind,
|
||||
PersistentUserMemory.scope_id == scope_id,
|
||||
PersistentUserMemory.session_key == session_key,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not row or not isinstance(row.payload, dict):
|
||||
return None
|
||||
return dict(row.payload)
|
||||
|
||||
|
||||
def save_persistent_memory(
|
||||
db: Session, scope_kind: str, scope_id: str, session_key: str, payload: Dict[str, Any]
|
||||
) -> None:
|
||||
row = (
|
||||
db.query(PersistentUserMemory)
|
||||
.filter(
|
||||
PersistentUserMemory.scope_kind == scope_kind,
|
||||
PersistentUserMemory.scope_id == scope_id,
|
||||
PersistentUserMemory.session_key == session_key,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if row:
|
||||
row.payload = payload
|
||||
else:
|
||||
row = PersistentUserMemory(
|
||||
id=str(uuid.uuid4()),
|
||||
scope_kind=scope_kind,
|
||||
scope_id=scope_id,
|
||||
session_key=session_key,
|
||||
payload=payload,
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
|
||||
|
||||
def delete_persistent_memory(db: Session, scope_kind: str, scope_id: str, session_key: str) -> None:
|
||||
q = db.query(PersistentUserMemory).filter(
|
||||
PersistentUserMemory.scope_kind == scope_kind,
|
||||
PersistentUserMemory.scope_id == scope_id,
|
||||
PersistentUserMemory.session_key == session_key,
|
||||
)
|
||||
q.delete()
|
||||
db.commit()
|
||||
|
||||
|
||||
def persist_enabled() -> bool:
|
||||
return bool(getattr(settings, "MEMORY_PERSIST_DB_ENABLED", True))
|
||||
@@ -28,7 +28,7 @@ class ToolRegistry:
|
||||
"""
|
||||
self._builtin_tools[name] = func
|
||||
self._tool_schemas[name] = schema
|
||||
logger.info(f"注册内置工具: {name}")
|
||||
logger.debug("注册内置工具: %s", name)
|
||||
|
||||
def get_tool_schema(self, name: str) -> Optional[Dict[str, Any]]:
|
||||
"""获取工具定义"""
|
||||
@@ -41,7 +41,15 @@ class ToolRegistry:
|
||||
def get_all_tool_schemas(self) -> List[Dict[str, Any]]:
|
||||
"""获取所有工具定义(用于LLM)"""
|
||||
return list(self._tool_schemas.values())
|
||||
|
||||
|
||||
def builtin_tool_count(self) -> int:
|
||||
"""已注册的内置工具数量(用于健康检查 / 启动自检)。"""
|
||||
return len(self._builtin_tools)
|
||||
|
||||
def builtin_tool_names(self) -> List[str]:
|
||||
"""已注册的内置工具名称,有序列表。"""
|
||||
return sorted(self._builtin_tools.keys())
|
||||
|
||||
def load_tools_from_db(self, db: Session, tool_names: List[str] = None):
|
||||
"""
|
||||
从数据库加载工具
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,10 @@
|
||||
Agent任务
|
||||
"""
|
||||
from celery import Task
|
||||
from app.core.tools_bootstrap import ensure_builtin_tools_registered
|
||||
|
||||
ensure_builtin_tools_registered()
|
||||
|
||||
from app.core.celery_app import celery_app
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
工作流任务
|
||||
"""
|
||||
from celery import Task
|
||||
from app.core.tools_bootstrap import ensure_builtin_tools_registered
|
||||
|
||||
ensure_builtin_tools_registered()
|
||||
|
||||
from app.core.celery_app import celery_app
|
||||
from app.services.workflow_engine import WorkflowEngine
|
||||
from app.services.execution_logger import ExecutionLogger
|
||||
@@ -15,6 +19,17 @@ import asyncio
|
||||
import time
|
||||
|
||||
|
||||
def _format_task_error(e: Exception) -> str:
|
||||
"""Celery 任务异常写入 DB 时的可读文案(HTTPException.detail 等)。"""
|
||||
detail = getattr(e, "detail", None)
|
||||
if isinstance(detail, str) and detail.strip():
|
||||
return detail
|
||||
if detail is not None and detail != "":
|
||||
return str(detail)
|
||||
s = str(e).strip()
|
||||
return s if s else repr(e)
|
||||
|
||||
|
||||
@celery_app.task(bind=True)
|
||||
def execute_workflow_task(
|
||||
self,
|
||||
@@ -87,14 +102,15 @@ def execute_workflow_task(
|
||||
execution_time = int((time.time() - start_time) * 1000)
|
||||
|
||||
# 记录错误日志
|
||||
err_text = _format_task_error(e)
|
||||
if execution_logger:
|
||||
execution_logger.error(f"工作流任务执行失败: {str(e)}", data={"error_type": type(e).__name__})
|
||||
execution_logger.error(f"工作流任务执行失败: {err_text}", data={"error_type": type(e).__name__})
|
||||
|
||||
# 更新执行记录为失败
|
||||
execution = db.query(Execution).filter(Execution.id == execution_id).first()
|
||||
if execution:
|
||||
execution.status = "failed"
|
||||
execution.error_message = str(e)
|
||||
execution.error_message = err_text
|
||||
execution.execution_time = execution_time
|
||||
db.commit()
|
||||
|
||||
|
||||
@@ -10,6 +10,14 @@ DATABASE_URL=mysql+pymysql://root:!Rjb12191@gz-cynosdbmysql-grp-d26pzce5.sql.ten
|
||||
# Redis配置
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# 会话记忆是否同时写入 MySQL(永久记忆);设为 false 则仅 Redis + 进程内缓存
|
||||
MEMORY_PERSIST_DB_ENABLED=true
|
||||
|
||||
# 本地文件工具 file_read / file_write 允许访问的根目录(可选)。留空则默认为「backend 的上一级」即仓库根
|
||||
# LOCAL_FILE_TOOLS_ROOT=D:/aaa/aiagent
|
||||
# LOCAL_FILE_READ_MAX_BYTES=2097152
|
||||
# LOCAL_FILE_WRITE_MAX_BYTES=2097152
|
||||
|
||||
# CORS配置(多个地址用逗号分隔)
|
||||
CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,http://localhost:8038,http://101.43.95.130:8038
|
||||
|
||||
@@ -17,7 +25,7 @@ CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,http://localhost:8038,h
|
||||
OPENAI_API_KEY=your-openai-api-key
|
||||
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
|
||||
# DeepSeek配置(可选)
|
||||
# DeepSeek配置(工作流 LLM 节点若选 DeepSeek 则必填;否则调用会 401)
|
||||
DEEPSEEK_API_KEY=your-deepseek-api-key
|
||||
DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||
|
||||
|
||||
269
backend/scripts/create_zhini_kefu_10.py
Normal file
269
backend/scripts/create_zhini_kefu_10.py
Normal file
@@ -0,0 +1,269 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
从「知你客服9号」复制为「知你客服10号」,改进记忆与连接策略:
|
||||
|
||||
1. 在 json-parse 之后插入 code-identity-merge:把用户指定的「助手称呼」写入 memory.context.assistant_display_name
|
||||
(与 user_profile.name 区分)。
|
||||
2. code-build-context:向 LLM 注入 assistant_display_name。
|
||||
3. llm-unified 提示词:自我介绍优先用 assistant_display_name;user_profile.name 仅表示用户。
|
||||
4. condition-need-summary:history_count >= 2 即走摘要分支(原常为 >=4,summary 易长期为空)。
|
||||
|
||||
需 Celery 已加载含 re/hashlib 注入的 workflow_engine(代码节点内勿写 import re)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import requests
|
||||
|
||||
BASE = os.getenv("PLATFORM_BASE_URL", "http://127.0.0.1:8037").rstrip("/")
|
||||
SOURCE_AGENT_ID = os.getenv("ZHINI_9_AGENT_ID", "de5932d6-3c05-4b27-ab08-f6cb403ce4b9")
|
||||
USER = os.getenv("PLATFORM_USERNAME", "admin")
|
||||
PWD = os.getenv("PLATFORM_PASSWORD", "123456")
|
||||
|
||||
NEW_NAME = "知你客服10号"
|
||||
NEW_DESC = (
|
||||
"在知你客服9号基础上:① memory.context.assistant_display_name 存助手对外称呼,与 user_profile.name(用户)分离;"
|
||||
"② 摘要分支 history_count>=2 更易生成 conversation_summary;"
|
||||
"③ 工作流在 json-parse 后增加 code-identity-merge 再进入抽取/写记忆。"
|
||||
)
|
||||
|
||||
CODE_IDENTITY_MERGE = r"""mem = dict(input_data.get('memory') or {})
|
||||
ctx = dict(mem.get('context') or {})
|
||||
q = str(input_data.get('query') or input_data.get('user_input') or '').strip()
|
||||
for pat in (
|
||||
r'你的\s*名字\s*叫\s*([^\s,。!?,.!?]{1,32})',
|
||||
r'你\s*叫\s*(?!什么)([^\s,。!?,.!?]{1,32})',
|
||||
r'(?:客服|助手)\s*叫\s*([^\s,。!?,.!?]{1,32})',
|
||||
):
|
||||
m = re.search(pat, q)
|
||||
if not m:
|
||||
continue
|
||||
name = m.group(1).strip().strip(',。!?,.!?')
|
||||
if not name:
|
||||
continue
|
||||
if any(b in name for b in ('什么', '哪位', '谁', '啥')):
|
||||
continue
|
||||
ctx['assistant_display_name'] = name
|
||||
break
|
||||
mem['context'] = ctx
|
||||
out = dict(input_data)
|
||||
out['memory'] = mem
|
||||
result = out
|
||||
"""
|
||||
|
||||
CODE_BUILD_CONTEXT_V10 = r"""left = input_data.get('left') or {}
|
||||
right = input_data.get('right') or []
|
||||
if not isinstance(right, list):
|
||||
right = []
|
||||
mem = left.get('memory') or {}
|
||||
hist = mem.get('conversation_history') or []
|
||||
if not isinstance(hist, list):
|
||||
hist = []
|
||||
summary = mem.get('conversation_summary') or ''
|
||||
ctx = mem.get('context') or {}
|
||||
if not isinstance(ctx, dict):
|
||||
ctx = {}
|
||||
assistant_name = str(ctx.get('assistant_display_name') or '').strip()
|
||||
recent_n = 16
|
||||
recent = hist[-recent_n:] if len(hist) > recent_n else hist
|
||||
recent_str = '\n'.join(f"{x.get('role', '')}: {x.get('content', '')}" for x in recent)
|
||||
vec_str = '\n'.join((rec.get('text') or rec.get('content') or '') for rec in right)
|
||||
query = (left.get('user_input') or left.get('query') or '').strip()
|
||||
older = hist[:-recent_n] if len(hist) > recent_n else []
|
||||
|
||||
|
||||
def _tok(s):
|
||||
s = str(s)
|
||||
ch = {c for c in s if '\u4e00' <= c <= '\u9fff'}
|
||||
wd = set(s.lower().replace('\n', ' ').split())
|
||||
return ch | wd
|
||||
|
||||
|
||||
qt = _tok(query) if query else set()
|
||||
scored = []
|
||||
for m in older:
|
||||
c = str(m.get('content', ''))
|
||||
if not c:
|
||||
continue
|
||||
sc = len(qt & _tok(c)) if qt else 0
|
||||
if sc > 0:
|
||||
scored.append((sc, str(m.get('role', '')), c[:240]))
|
||||
scored.sort(key=lambda x: -x[0])
|
||||
kw_lines = [f"{role}: {text}" for _, role, text in scored[:6]]
|
||||
kw_str = '\n'.join(kw_lines)
|
||||
relevant_str = vec_str.strip()
|
||||
if kw_str:
|
||||
if relevant_str:
|
||||
relevant_str = relevant_str + '\n---\n关键词相关历史:\n' + kw_str
|
||||
else:
|
||||
relevant_str = '关键词相关历史:\n' + kw_str
|
||||
result = {
|
||||
'user_input': left.get('user_input') or left.get('query') or '',
|
||||
'memory': {
|
||||
'user_profile': mem.get('user_profile') or {},
|
||||
'conversation_summary': summary,
|
||||
'relevant_from_retrieval': relevant_str,
|
||||
'recent_turns': recent_str,
|
||||
'assistant_display_name': assistant_name,
|
||||
},
|
||||
'query': left.get('query') or '',
|
||||
'user_id': left.get('user_id'),
|
||||
}
|
||||
"""
|
||||
|
||||
LLM_PROMPT_V10 = """你是客服助手。根据用户输入、用户画像、助手称呼、远期摘要、检索片段与最近对话生成回复。
|
||||
|
||||
【称呼规则】
|
||||
- user_profile.name(及同类字段)仅表示「用户」的昵称/姓名。
|
||||
- memory.assistant_display_name 表示用户为你指定的「对外称呼」。若非空,用户问「你叫什么名字」「你是谁」时,须用该称呼自称(可带「客服助手」类前缀,但核心名须一致);禁止忽略已保存的 assistant_display_name 改回默认虚构名。
|
||||
- 若 assistant_display_name 为空,可自称「客服助手」等通用名。
|
||||
|
||||
【任务】
|
||||
1)判断意图;2)自然、有帮助的 reply(JSON 内一条字符串);
|
||||
3)用户自我介绍姓名时写入 user_profile(如 name),勿把用户姓名写入 assistant_display_name;
|
||||
4)用户问「我叫什么」时依据 user_profile 与历史/摘要回答。
|
||||
|
||||
只输出一行合法 JSON,不要 markdown。示例:
|
||||
{"intent":"chat","reply":"你好!","user_profile":{"name":"小明"}}
|
||||
|
||||
用户输入:{{user_input}}
|
||||
用户画像:{{memory.user_profile}}
|
||||
助手对外称呼(用户指定,可能为空):{{memory.assistant_display_name}}
|
||||
远期摘要:{{memory.conversation_summary}}
|
||||
相关历史(检索):{{memory.relevant_from_retrieval}}
|
||||
最近几轮:{{memory.recent_turns}}
|
||||
|
||||
要求:reply 200 字以内;user_profile 为对象。"""
|
||||
|
||||
|
||||
def _insert_identity_node_and_edges(wf: dict) -> None:
|
||||
nodes = wf.setdefault("nodes", [])
|
||||
edges = wf.setdefault("edges", [])
|
||||
if any(n.get("id") == "code-identity-merge" for n in nodes):
|
||||
return
|
||||
# 参考 json-parse 位置:在其右侧插入
|
||||
jx, jy = 2200, 400
|
||||
for n in nodes:
|
||||
if n.get("id") == "json-parse":
|
||||
pos = n.get("position") or {}
|
||||
jx = pos.get("x", jx) + 80
|
||||
jy = pos.get("y", jy)
|
||||
break
|
||||
nodes.append(
|
||||
{
|
||||
"id": "code-identity-merge",
|
||||
"type": "code",
|
||||
"position": {"x": jx, "y": jy},
|
||||
"data": {
|
||||
"label": "合并助手称呼到 context",
|
||||
"language": "python",
|
||||
"code": CODE_IDENTITY_MERGE,
|
||||
},
|
||||
}
|
||||
)
|
||||
new_edges = []
|
||||
removed = False
|
||||
for e in edges:
|
||||
if e.get("source") == "json-parse" and e.get("target") == "transform-extract-reply-and-profile":
|
||||
removed = True
|
||||
continue
|
||||
new_edges.append(e)
|
||||
if not removed:
|
||||
print("警告: 未找到 json-parse -> transform-extract-reply-and-profile 的边,仍追加新边", file=sys.stderr)
|
||||
new_edges.append(
|
||||
{
|
||||
"id": "e11a-identity",
|
||||
"source": "json-parse",
|
||||
"target": "code-identity-merge",
|
||||
"sourceHandle": "right",
|
||||
"targetHandle": "left",
|
||||
}
|
||||
)
|
||||
new_edges.append(
|
||||
{
|
||||
"id": "e11b-identity",
|
||||
"source": "code-identity-merge",
|
||||
"target": "transform-extract-reply-and-profile",
|
||||
"sourceHandle": "right",
|
||||
"targetHandle": "left",
|
||||
}
|
||||
)
|
||||
wf["edges"] = new_edges
|
||||
|
||||
|
||||
def _patch_nodes(wf: dict) -> None:
|
||||
nodes = wf.get("nodes") or []
|
||||
for n in nodes:
|
||||
nid = n.get("id")
|
||||
if nid == "llm-unified":
|
||||
n.setdefault("data", {})["prompt"] = LLM_PROMPT_V10
|
||||
elif nid == "code-build-context":
|
||||
n.setdefault("data", {})["code"] = CODE_BUILD_CONTEXT_V10
|
||||
elif nid == "condition-need-summary":
|
||||
d = n.setdefault("data", {})
|
||||
c = d.get("condition", "")
|
||||
if "history_count" in c and ">=" in c:
|
||||
d["condition"] = "{history_count} >= 2"
|
||||
else:
|
||||
d["condition"] = "{history_count} >= 2"
|
||||
elif nid == "code-identity-merge":
|
||||
n.setdefault("data", {})["code"] = CODE_IDENTITY_MERGE
|
||||
|
||||
|
||||
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"}
|
||||
|
||||
dup = requests.post(
|
||||
f"{BASE}/api/v1/agents/{SOURCE_AGENT_ID}/duplicate",
|
||||
headers=h,
|
||||
json={"name": NEW_NAME},
|
||||
timeout=30,
|
||||
)
|
||||
if dup.status_code != 201:
|
||||
print("复制失败:", dup.status_code, dup.text[:800], file=sys.stderr)
|
||||
return 1
|
||||
new_id = dup.json()["id"]
|
||||
print("已创建副本:", new_id, NEW_NAME)
|
||||
|
||||
g = requests.get(f"{BASE}/api/v1/agents/{new_id}", headers=h, timeout=30)
|
||||
if g.status_code != 200:
|
||||
print("读取 Agent 失败:", g.text, file=sys.stderr)
|
||||
return 1
|
||||
agent = g.json()
|
||||
wf = agent["workflow_config"]
|
||||
_insert_identity_node_and_edges(wf)
|
||||
_patch_nodes(wf)
|
||||
|
||||
up = requests.put(
|
||||
f"{BASE}/api/v1/agents/{new_id}",
|
||||
headers=h,
|
||||
json={"description": NEW_DESC, "workflow_config": wf},
|
||||
timeout=120,
|
||||
)
|
||||
if up.status_code != 200:
|
||||
print("更新失败:", up.status_code, up.text[:800], file=sys.stderr)
|
||||
return 1
|
||||
print("已更新:identity 节点与边、摘要阈值>=2、上下文与 LLM 提示")
|
||||
print("Agent ID:", new_id)
|
||||
print(json.dumps({"id": new_id, "name": NEW_NAME}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
122
backend/scripts/create_zhini_kefu_11.py
Normal file
122
backend/scripts/create_zhini_kefu_11.py
Normal file
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
从「知你客服10号」复制为「知你客服11号」:
|
||||
- 在 llm-unified 上开启工具调用,仅启用 http_request(拉取网页/API 再总结)。
|
||||
- 提示词要求:需要访问 URL 时必须先调工具;最终对用户仍只输出一行合法 JSON(供下游 json-parse)。
|
||||
|
||||
环境变量:PLATFORM_BASE_URL、ZHINI_10_AGENT_ID(默认 10 号 ID)、登录账号密码。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import requests
|
||||
|
||||
BASE = os.getenv("PLATFORM_BASE_URL", "http://127.0.0.1:8037").rstrip("/")
|
||||
SOURCE_AGENT_ID = os.getenv("ZHINI_10_AGENT_ID", "c853482b-d298-44e4-9862-c84318f71abb")
|
||||
USER = os.getenv("PLATFORM_USERNAME", "admin")
|
||||
PWD = os.getenv("PLATFORM_PASSWORD", "123456")
|
||||
|
||||
NEW_NAME = "知你客服11号"
|
||||
NEW_DESC = (
|
||||
"在知你客服10号基础上:主 LLM 开启工具调用,仅注册 http_request,"
|
||||
"可对用户给出的 http(s) 链接先抓取再摘要;"
|
||||
"提示词要求最终仍输出单行 JSON,兼容原记忆/解析链路。"
|
||||
)
|
||||
|
||||
LLM_PROMPT_V11 = """你是客服助手。根据用户输入、用户画像、助手称呼、远期摘要、检索片段与最近对话生成回复。
|
||||
|
||||
【工具 http_request(重要)】
|
||||
- 当用户给出明确的 http:// 或 https:// 链接,并希望你「看看网页讲了什么」「总结这个页面」「打开链接」等时,你必须先调用工具 http_request:参数 url 为完整链接,method 一般填 GET(调用时 method 为必填)。
|
||||
- 根据工具返回的 JSON 中的 body 字段作答:body 可能是 HTML 或 JSON 文本;请提炼要点写入最终 reply,不要谎称「无法直接访问」。
|
||||
- 若 body 过长,reply 中用简明中文摘要即可(200 字内为主)。
|
||||
- 非 URL 类普通问答不要无故调用 http_request。
|
||||
|
||||
【称呼规则】(与 10 号一致)
|
||||
- user_profile.name 仅表示「用户」昵称/姓名。
|
||||
- memory.assistant_display_name 非空时,用户问「你叫什么名字」等须用该称呼自称;勿把用户姓名写入 assistant_display_name。
|
||||
- 用户自我介绍姓名时写入 user_profile,勿写入 assistant_display_name。
|
||||
|
||||
【最终输出格式(强制)】
|
||||
- 无论你调用了几次工具,给用户的**最后一条消息内容**必须是**一行合法 JSON**,且**不要** markdown、**不要**代码围栏。
|
||||
- JSON 必须可被解析,且至少包含:intent(字符串)、reply(字符串)、user_profile(对象,无新信息可为 {})。
|
||||
- 示例:{"intent":"summarize_url","reply":"该网页主要介绍了……","user_profile":{}}
|
||||
|
||||
上下文:
|
||||
用户输入:{{user_input}}
|
||||
用户画像:{{memory.user_profile}}
|
||||
助手对外称呼:{{memory.assistant_display_name}}
|
||||
远期摘要:{{memory.conversation_summary}}
|
||||
相关历史(检索):{{memory.relevant_from_retrieval}}
|
||||
最近几轮:{{memory.recent_turns}}
|
||||
"""
|
||||
|
||||
|
||||
def _patch_llm_unified(wf: dict) -> None:
|
||||
for n in wf.get("nodes") or []:
|
||||
if n.get("id") != "llm-unified":
|
||||
continue
|
||||
d = n.setdefault("data", {})
|
||||
d["prompt"] = LLM_PROMPT_V11
|
||||
d["enable_tools"] = True
|
||||
d["tools"] = ["http_request"]
|
||||
d["selected_tools"] = ["http_request"]
|
||||
return
|
||||
print("警告: 未找到节点 llm-unified", file=sys.stderr)
|
||||
|
||||
|
||||
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"}
|
||||
|
||||
dup = requests.post(
|
||||
f"{BASE}/api/v1/agents/{SOURCE_AGENT_ID}/duplicate",
|
||||
headers=h,
|
||||
json={"name": NEW_NAME},
|
||||
timeout=30,
|
||||
)
|
||||
if dup.status_code != 201:
|
||||
print("复制失败:", dup.status_code, dup.text[:800], file=sys.stderr)
|
||||
return 1
|
||||
new_id = dup.json()["id"]
|
||||
print("已创建副本:", new_id, NEW_NAME)
|
||||
|
||||
g = requests.get(f"{BASE}/api/v1/agents/{new_id}", headers=h, timeout=30)
|
||||
if g.status_code != 200:
|
||||
print("读取 Agent 失败:", g.text, file=sys.stderr)
|
||||
return 1
|
||||
agent = g.json()
|
||||
wf = agent["workflow_config"]
|
||||
_patch_llm_unified(wf)
|
||||
|
||||
up = requests.put(
|
||||
f"{BASE}/api/v1/agents/{new_id}",
|
||||
headers=h,
|
||||
json={"description": NEW_DESC, "workflow_config": wf},
|
||||
timeout=120,
|
||||
)
|
||||
if up.status_code != 200:
|
||||
print("更新失败:", up.status_code, up.text[:800], file=sys.stderr)
|
||||
return 1
|
||||
print("已开启 enable_tools + http_request,并更新提示词")
|
||||
print("Agent ID:", new_id)
|
||||
print(json.dumps({"id": new_id, "name": NEW_NAME}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
132
backend/scripts/create_zhini_kefu_12.py
Normal file
132
backend/scripts/create_zhini_kefu_12.py
Normal file
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
从「知你客服11号」复制为「知你客服12号」:
|
||||
- llm-unified 开启工具:http_request + file_read + file_write(受工作区根目录与大小限制约束,见 LOCAL_FILE_TOOLS_ROOT)。
|
||||
- 提示词:URL 用 http_request;读写本地文件用 file_read / file_write;最终仍输出单行 JSON。
|
||||
|
||||
环境变量:PLATFORM_BASE_URL、ZHINI_11_AGENT_ID(默认 11 号 ID)、登录账号密码。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import requests
|
||||
|
||||
BASE = os.getenv("PLATFORM_BASE_URL", "http://127.0.0.1:8037").rstrip("/")
|
||||
SOURCE_AGENT_ID = os.getenv("ZHINI_11_AGENT_ID", "d39748ad-277f-48ac-9eb5-168ad2f1b470")
|
||||
USER = os.getenv("PLATFORM_USERNAME", "admin")
|
||||
PWD = os.getenv("PLATFORM_PASSWORD", "123456")
|
||||
|
||||
NEW_NAME = "知你客服12号"
|
||||
NEW_DESC = (
|
||||
"在知你客服11号基础上:增加本地文件读写工具 file_read、file_write(路径限制在平台配置的工作区内,"
|
||||
"默认可视为仓库根目录),并保留 http_request;"
|
||||
"输出仍为单行 JSON,兼容记忆与 json-parse 链路。"
|
||||
)
|
||||
|
||||
TOOLS_V12 = ["http_request", "file_read", "file_write", "system_info"]
|
||||
|
||||
LLM_PROMPT_V12 = """你是客服助手。根据用户输入、用户画像、助手称呼、远期摘要、检索片段与最近对话生成回复。
|
||||
|
||||
【工具 http_request】
|
||||
- 用户给出 http(s) 链接且需要抓网页/API 时,先调用 http_request:参数 url 为完整链接,method 必填(一般为 GET)。
|
||||
- 根据返回 JSON 中的 body 字段提炼要点;非 URL 问答不要无故调用。
|
||||
|
||||
【工具 system_info(工作区路径)】
|
||||
- 用户问「工作区路径」「能访问哪个目录」「file 根目录在哪」时,**必须调用 system_info**,用返回 JSON 里的 **local_file_workspace_root** 原样告知用户(不要用「临时目录」「无法显示」等推脱)。
|
||||
|
||||
【工具 file_read / file_write(本地文件)】
|
||||
- 仅当用户明确要「读文件」「写入某路径」「保存到本地文件」等时使用。
|
||||
- file_read:参数 file_path 可为**相对工作区根的相对路径**,或**落在工作区根之下的绝对路径**(Windows 如 `D:\\...`,Linux 如 `/home/...`),二者等价,由后端校验。
|
||||
- file_write:参数 file_path、content;mode 用 w 覆盖或 a 追加。写入前确认路径有意、避免覆盖重要文件;不要写入密钥、令牌。
|
||||
- **禁止**以「不能访问 D: 盘」「只能相对路径」「工具看不到绝对路径」等理由拒绝用户:只要用户给的绝对路径以 `system_info` 返回的 `local_file_workspace_root` 为前缀(同一盘符、规范化后在其子路径下),就应**直接调用 file_write**,例如根为 `D:\\aaa\\aiagent` 时,`D:\\aaa\\aiagent\\user_data\\xxx.md` **合法**,可优先用用户原文路径或简写为相对路径 `user_data/xxx.md`。
|
||||
- 路径必须落在平台允许的工作区内,否则会报错;不要尝试访问工作区外的路径。
|
||||
- **禁止**假设工作区是 `/workspace` 或未经验证的目录;工作区根**只信** `local_file_workspace_root`。
|
||||
- **每次调用 file_write / file_read 后,必须在最终 reply 中说明工具返回结果**:成功则写明路径与要点;失败则引用返回 JSON 中的 error 字段,不得假装已成功。
|
||||
- **严禁编造工具返回**:reply 中若引用 file_write/file_read/system_info 的 JSON,必须与工具实际返回字符串一致(可原样粘贴)。禁止臆造路径(例如 /tmp/...、/workspace/...)或与当前系统不符的路径;若未调用工具,禁止在 reply 里写伪造的 JSON。
|
||||
|
||||
【称呼规则】(与 10/11 一致)
|
||||
- user_profile.name 表示用户昵称;assistant_display_name 表示用户为你起的称呼。
|
||||
- 用户问「你叫什么」时用 assistant_display_name(若有);勿把用户姓名写入 assistant_display_name。
|
||||
|
||||
【最终输出格式(强制)】
|
||||
- 最后一条回复必须是**一行合法 JSON**,无 markdown、无代码围栏;含 intent、reply、user_profile(对象)。
|
||||
|
||||
上下文:
|
||||
用户输入:{{user_input}}
|
||||
用户画像:{{memory.user_profile}}
|
||||
助手对外称呼:{{memory.assistant_display_name}}
|
||||
远期摘要:{{memory.conversation_summary}}
|
||||
相关历史(检索):{{memory.relevant_from_retrieval}}
|
||||
最近几轮:{{memory.recent_turns}}
|
||||
"""
|
||||
|
||||
|
||||
def _patch_llm_unified(wf: dict) -> None:
|
||||
for n in wf.get("nodes") or []:
|
||||
if n.get("id") != "llm-unified":
|
||||
continue
|
||||
d = n.setdefault("data", {})
|
||||
d["prompt"] = LLM_PROMPT_V12
|
||||
d["enable_tools"] = True
|
||||
d["tools"] = list(TOOLS_V12)
|
||||
d["selected_tools"] = list(TOOLS_V12)
|
||||
return
|
||||
print("警告: 未找到节点 llm-unified", file=sys.stderr)
|
||||
|
||||
|
||||
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"}
|
||||
|
||||
dup = requests.post(
|
||||
f"{BASE}/api/v1/agents/{SOURCE_AGENT_ID}/duplicate",
|
||||
headers=h,
|
||||
json={"name": NEW_NAME},
|
||||
timeout=30,
|
||||
)
|
||||
if dup.status_code != 201:
|
||||
print("复制失败:", dup.status_code, dup.text[:800], file=sys.stderr)
|
||||
return 1
|
||||
new_id = dup.json()["id"]
|
||||
print("已创建副本:", new_id, NEW_NAME)
|
||||
|
||||
g = requests.get(f"{BASE}/api/v1/agents/{new_id}", headers=h, timeout=30)
|
||||
if g.status_code != 200:
|
||||
print("读取 Agent 失败:", g.text, file=sys.stderr)
|
||||
return 1
|
||||
agent = g.json()
|
||||
wf = agent["workflow_config"]
|
||||
_patch_llm_unified(wf)
|
||||
|
||||
up = requests.put(
|
||||
f"{BASE}/api/v1/agents/{new_id}",
|
||||
headers=h,
|
||||
json={"description": NEW_DESC, "workflow_config": wf},
|
||||
timeout=120,
|
||||
)
|
||||
if up.status_code != 200:
|
||||
print("更新失败:", up.status_code, up.text[:800], file=sys.stderr)
|
||||
return 1
|
||||
print("已注册工具:", ", ".join(TOOLS_V12))
|
||||
print("Agent ID:", new_id)
|
||||
print(json.dumps({"id": new_id, "name": NEW_NAME}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
280
backend/scripts/create_zhini_kefu_13.py
Normal file
280
backend/scripts/create_zhini_kefu_13.py
Normal file
@@ -0,0 +1,280 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
从「知你客服12号」复制为「知你客服13号」:
|
||||
|
||||
- **画布**:去除自环边、合并重复的 source→target 边;统一锚点 sourceHandle=right、targetHandle=left;
|
||||
按从「开始」出发的分层布局重排节点坐标,减少交叉与「绕圈」观感(不改变节点 id/业务配置)。
|
||||
- **提示词**:在 12 号能力(http_request、file_read、file_write、system_info)基础上,增加工具调用纪律
|
||||
(避免同轮重复 file_write、勿刷屏 DSML)。
|
||||
|
||||
若已存在同名 Agent「知你客服13号」,则仅更新其 workflow + 描述(不新建)。
|
||||
|
||||
用法:
|
||||
cd backend && .\\venv\\Scripts\\python.exe scripts/create_zhini_kefu_13.py
|
||||
|
||||
环境变量: PLATFORM_BASE_URL, PLATFORM_USERNAME, PLATFORM_PASSWORD,
|
||||
SOURCE_AGENT_NAME(默认 知你客服12号), TARGET_NAME(默认 知你客服13号)
|
||||
"""
|
||||
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", "知你客服12号")
|
||||
TARGET_NAME = os.getenv("TARGET_NAME", "知你客服13号")
|
||||
|
||||
TOOLS_V13 = ["http_request", "file_read", "file_write", "system_info"]
|
||||
|
||||
# 在 12 号提示词基础上追加(create_zhini_kefu_12 正文过长时由脚本从源 Agent 读取再拼接)
|
||||
PROMPT_V13_EXTRA = """
|
||||
|
||||
【画布/执行说明(13 号)】
|
||||
- 工作流连线已整理为从左到右主线,减少自环与重复边带来的误解;逻辑仍以引擎与节点配置为准。
|
||||
|
||||
【工具调用纪律(13 号)】
|
||||
- 同一轮用户请求中,对 **file_write** 无特殊说明时不要重复调用多次;每个明确文件需求通常 **一次写入** 即可。
|
||||
- 不要在回复正文中 **重复刷屏** DSML、`<|DSML|`、`invoke name=` 等标签行;工具返回后应用自然语言说明,并仍以 **单行 JSON** 收尾。
|
||||
- 若上一轮已写入成功,除非用户要求修改或另存,不要再次写入相同路径。
|
||||
|
||||
【单行 JSON 与用户画像(与 12 号一致,勿留空)】
|
||||
- 最后一行 JSON 的 user_profile 须与事实一致:用户已告知昵称时须包含 "name"(如「小七」);**禁止**用空的 user_profile 覆盖会话记忆。
|
||||
- 仅靠 file_write 写入本地文件**不能**替代上述 JSON 中的 user_profile;多轮称呼以 JSON + 会话记忆为准。
|
||||
"""
|
||||
|
||||
|
||||
def _sanitize_edges(edges: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""去掉自环、按 (source,target) 去重,统一左右锚点。"""
|
||||
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_V13_EXTRA.strip() not in prompt:
|
||||
prompt = (prompt.rstrip() + "\n" + PROMPT_V13_EXTRA).strip()
|
||||
d["prompt"] = prompt
|
||||
d["enable_tools"] = True
|
||||
d["tools"] = list(TOOLS_V13)
|
||||
d["selected_tools"] = list(TOOLS_V13)
|
||||
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_13 = _find_agent_id_by_name(h, TARGET_NAME)
|
||||
if existing_13:
|
||||
print("已存在", TARGET_NAME, "-> 仅更新工作流", existing_13)
|
||||
new_id = existing_13
|
||||
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 = (
|
||||
"在知你客服12号基础上:整理工作流连线(去自环/重复边、分层布局、统一左右锚点),"
|
||||
"并强化工具调用纪律(避免同轮重复 file_write、勿刷屏 DSML);"
|
||||
"工具仍为 http_request、file_read、file_write、system_info;输出单行 JSON。"
|
||||
)
|
||||
|
||||
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_V13))
|
||||
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())
|
||||
297
backend/scripts/create_zhini_kefu_14.py
Normal file
297
backend/scripts/create_zhini_kefu_14.py
Normal file
@@ -0,0 +1,297 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
从「知你客服13号」复制为「知你客服14号」:
|
||||
|
||||
- **画布**:与 13 号脚本相同(去自环/重复边、分层布局、统一左右锚点)。
|
||||
- **工具**:在 13 号(http_request、file_read、file_write、system_info)基础上,增加平台已注册的内置工具:
|
||||
text_analyze、datetime、math_calculate、json_process、database_query、adb_log(与 `tools_bootstrap` 对齐)。
|
||||
- **提示词**:在 13 号提示词后追加 14 号扩展工具说明与纪律。
|
||||
|
||||
若已存在同名 Agent「知你客服14号」,则仅更新其 workflow + 描述(不新建)。
|
||||
|
||||
用法:
|
||||
cd backend && .\\venv\\Scripts\\python.exe scripts/create_zhini_kefu_14.py
|
||||
|
||||
环境变量: PLATFORM_BASE_URL, PLATFORM_USERNAME, PLATFORM_PASSWORD,
|
||||
SOURCE_AGENT_NAME(默认 知你客服13号), TARGET_NAME(默认 知你客服14号)
|
||||
"""
|
||||
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", "知你客服13号")
|
||||
TARGET_NAME = os.getenv("TARGET_NAME", "知你客服14号")
|
||||
|
||||
# 与 app.core.tools_bootstrap.ensure_builtin_tools_registered 中注册列表一致(全量内置工具)
|
||||
TOOLS_V14: List[str] = [
|
||||
"http_request",
|
||||
"file_read",
|
||||
"file_write",
|
||||
"text_analyze",
|
||||
"datetime",
|
||||
"math_calculate",
|
||||
"system_info",
|
||||
"json_process",
|
||||
"database_query",
|
||||
"adb_log",
|
||||
]
|
||||
|
||||
PROMPT_V14_MARKER = "【知你客服 14 号 · 扩展工具】"
|
||||
|
||||
PROMPT_V14_EXTRA = f"""
|
||||
|
||||
{PROMPT_V14_MARKER}
|
||||
在 13 号既有能力与纪律之上,可使用下列额外工具(按需调用,避免无关刷屏;仍以 **单行 JSON** 收尾):
|
||||
|
||||
【text_analyze】文本分析:`text` 为正文,`operation` 为 `count`(字数/行数等统计)、`keywords`(简单词频)、`summary`(取前几句摘要)。
|
||||
|
||||
【datetime】日期时间:`operation` 常用 `now`;`format` 为 strftime 格式串(可选)。
|
||||
|
||||
【math_calculate】数学计算:`expression` 为安全算术表达式(如 `2+2*3`、`sqrt(16)`),勿编造结果,以工具返回为准。
|
||||
|
||||
【json_process】JSON 处理:`json_string` + `operation` 为 `parse` | `stringify` | `validate`。
|
||||
|
||||
【database_query】只读 SQL:**仅允许 SELECT**。未指定数据源时使用平台默认库;若需指定外部数据源可传 `data_source_id`。不得编造查询结果;大表注意 `timeout`(秒)。
|
||||
|
||||
【adb_log】Android 日志:依赖运行环境已安装 **adb** 且设备可用;`command` 等参数按工具 schema。仅在用户明确需要拉取/分析设备日志时使用,避免滥用。
|
||||
|
||||
【纪律】
|
||||
- 继承 13 号:同轮避免无故重复 `file_write`;勿在正文中刷屏 DSML。
|
||||
- `database_query` 禁止非 SELECT;`adb_log` 需环境与权限,失败时如实说明工具返回。
|
||||
"""
|
||||
|
||||
|
||||
def _sanitize_edges(edges: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""去掉自环、按 (source,target) 去重,统一左右锚点。"""
|
||||
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_V14_MARKER not in prompt:
|
||||
prompt = (prompt.rstrip() + "\n" + PROMPT_V14_EXTRA).strip()
|
||||
d["prompt"] = prompt
|
||||
d["enable_tools"] = True
|
||||
d["tools"] = list(TOOLS_V14)
|
||||
d["selected_tools"] = list(TOOLS_V14)
|
||||
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 = (
|
||||
"在知你客服13号基础上:扩展内置工具为全量(含 text_analyze、datetime、math_calculate、"
|
||||
"json_process、database_query、adb_log 等);画布与 13 号一致整理;输出仍为单行 JSON。"
|
||||
)
|
||||
|
||||
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_V14))
|
||||
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())
|
||||
106
backend/scripts/create_zhini_kefu_7.py
Normal file
106
backend/scripts/create_zhini_kefu_7.py
Normal file
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
从「知你客服6号」复制为「知你客服7号」,并更新 LLM 提示(强化姓名与 user_profile 记忆说明)。
|
||||
需本地平台已启动(默认 http://127.0.0.1:8037),账号 admin/123456。
|
||||
|
||||
用法:
|
||||
cd backend && ..\\venv\\Scripts\\python.exe scripts/create_zhini_kefu_7.py
|
||||
或: python scripts/create_zhini_kefu_7.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import requests
|
||||
|
||||
BASE = os.getenv("PLATFORM_BASE_URL", "http://127.0.0.1:8037").rstrip("/")
|
||||
SOURCE_AGENT_ID = os.getenv("ZHINI_6_AGENT_ID", "2acc84d5-814b-4d61-9703-94a4b117375f")
|
||||
USER = os.getenv("PLATFORM_USERNAME", "admin")
|
||||
PWD = os.getenv("PLATFORM_PASSWORD", "123456")
|
||||
|
||||
NEW_NAME = "知你客服7号"
|
||||
NEW_DESC = (
|
||||
"在知你客服6号工作流基础上,配合引擎修复多轮记忆:"
|
||||
"对话历史写入真实助手回复、合并 user_profile(含姓名);"
|
||||
"LLM 提示词强调用户姓名与 user_profile 的维护。"
|
||||
)
|
||||
|
||||
LLM_PROMPT = """你是客服助手。根据「用户当前输入」「已知用户信息」「相关历史(检索)」和「最近几轮」完成:
|
||||
1)判断意图;
|
||||
2)生成一句自然、有帮助的回复;
|
||||
3)【强制】只要用户说出或暗示自己的姓名、昵称,必须在 user_profile 里用字段 name 保存,例如用户说「我叫王小明」则 JSON 必须包含 "user_profile":{"name":"王小明"}(若已有其它字段则合并,不要丢字段);
|
||||
4)若用户问「我叫什么」「你还记得我名字吗」等,必须根据「已知用户信息」里的 user_profile.name 与对话历史回答;若已有 name 则禁止说「还不知道」。
|
||||
|
||||
只输出一行合法 JSON,不要 markdown。格式示例:
|
||||
{"intent":"greeting","reply":"你好王小明!","user_profile":{"name":"王小明"}}
|
||||
|
||||
用户输入:{{user_input}}
|
||||
已知用户信息:{{memory.user_profile}}
|
||||
相关历史(检索到的):{{memory.relevant_from_retrieval}}
|
||||
最近几轮:{{memory.recent_turns}}
|
||||
|
||||
要求:reply 简洁自然,200 字以内;user_profile 为对象,至少包含 name(当用户自我介绍时)。"""
|
||||
|
||||
|
||||
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"}
|
||||
|
||||
dup = requests.post(
|
||||
f"{BASE}/api/v1/agents/{SOURCE_AGENT_ID}/duplicate",
|
||||
headers=h,
|
||||
json={"name": NEW_NAME},
|
||||
timeout=30,
|
||||
)
|
||||
if dup.status_code != 201:
|
||||
print("复制失败:", dup.status_code, dup.text[:800], file=sys.stderr)
|
||||
return 1
|
||||
new_id = dup.json()["id"]
|
||||
print("已创建副本:", new_id, NEW_NAME)
|
||||
|
||||
g = requests.get(f"{BASE}/api/v1/agents/{new_id}", headers=h, timeout=30)
|
||||
if g.status_code != 200:
|
||||
print("读取 Agent 失败:", g.text, file=sys.stderr)
|
||||
return 1
|
||||
agent = g.json()
|
||||
wf = agent["workflow_config"]
|
||||
nodes = wf.get("nodes") or []
|
||||
for n in nodes:
|
||||
if n.get("id") == "llm-unified":
|
||||
n.setdefault("data", {})["prompt"] = LLM_PROMPT
|
||||
break
|
||||
|
||||
up = requests.put(
|
||||
f"{BASE}/api/v1/agents/{new_id}",
|
||||
headers=h,
|
||||
json={
|
||||
"description": NEW_DESC,
|
||||
"workflow_config": wf,
|
||||
},
|
||||
timeout=60,
|
||||
)
|
||||
if up.status_code != 200:
|
||||
print("更新失败:", up.status_code, up.text[:800], file=sys.stderr)
|
||||
return 1
|
||||
print("已更新描述与 llm-unified 提示词")
|
||||
print("Agent ID:", new_id)
|
||||
print(json.dumps({"id": new_id, "name": NEW_NAME}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
125
backend/scripts/create_zhini_kefu_8.py
Normal file
125
backend/scripts/create_zhini_kefu_8.py
Normal file
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
从「知你客服7号」复制为「知你客服8号」:在 7 号多轮记忆能力基础上,说明使用平台「永久记忆」
|
||||
(Cache user_memory_* 同步写入 MySQL persistent_user_memories,需 MEMORY_PERSIST_DB_ENABLED=true)。
|
||||
|
||||
需本地平台已启动(默认 http://127.0.0.1:8037),账号可通过环境变量配置。
|
||||
|
||||
用法:
|
||||
cd backend && .\\venv\\Scripts\\python.exe scripts/create_zhini_kefu_8.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import requests
|
||||
|
||||
BASE = os.getenv("PLATFORM_BASE_URL", "http://127.0.0.1:8037").rstrip("/")
|
||||
# 默认从 7 号复制;也可通过环境变量指定
|
||||
SOURCE_AGENT_ID = os.getenv("ZHINI_7_AGENT_ID", "688c2c41-dcd1-4285-b193-6bed00c485c2")
|
||||
USER = os.getenv("PLATFORM_USERNAME", "admin")
|
||||
PWD = os.getenv("PLATFORM_PASSWORD", "123456")
|
||||
|
||||
NEW_NAME = "知你客服8号"
|
||||
NEW_DESC = (
|
||||
"在知你客服7号基础上面向「永久记忆」:工作流仍为 user_memory_{user_id} 读写;"
|
||||
"引擎将记忆同步至 MySQL(跨 Redis TTL、服务重启仍保留)。"
|
||||
"调用时请固定传入 user_id;部署需开启 MEMORY_PERSIST_DB_ENABLED。"
|
||||
)
|
||||
|
||||
LLM_PROMPT = """你是客服助手。根据「用户当前输入」「已知用户信息」「相关历史(检索)」和「最近几轮」完成:
|
||||
1)判断意图;
|
||||
2)生成一句自然、有帮助的回复;
|
||||
3)【强制】只要用户说出或暗示自己的姓名、昵称,必须在 user_profile 里用字段 name 保存,例如用户说「我叫王小明」则 JSON 必须包含 "user_profile":{"name":"王小明"}(若已有其它字段则合并,不要丢字段);
|
||||
4)若用户问「我叫什么」「你还记得我名字吗」等,必须根据「已知用户信息」里的 user_profile.name 与对话历史回答;若已有 name 则禁止说「还不知道」。
|
||||
5)系统会在后台持久化用户画像与近期对话;请始终基于「已知用户信息」与「最近几轮」作答,避免与用户已提供信息矛盾。
|
||||
|
||||
只输出一行合法 JSON,不要 markdown。格式示例:
|
||||
{"intent":"greeting","reply":"你好王小明!","user_profile":{"name":"王小明"}}
|
||||
|
||||
用户输入:{{user_input}}
|
||||
已知用户信息:{{memory.user_profile}}
|
||||
相关历史(检索到的):{{memory.relevant_from_retrieval}}
|
||||
最近几轮:{{memory.recent_turns}}
|
||||
|
||||
要求:reply 简洁自然,200 字以内;user_profile 为对象,至少包含 name(当用户自我介绍时)。"""
|
||||
|
||||
|
||||
def _patch_cache_nodes_for_memory(wf: dict) -> None:
|
||||
"""为 Cache 节点设置更长对话窗口与较长 Redis TTL;真正永久存储由引擎写 MySQL。"""
|
||||
nodes = wf.get("nodes") or []
|
||||
for n in nodes:
|
||||
if n.get("type") != "cache":
|
||||
continue
|
||||
data = n.setdefault("data", {})
|
||||
op = data.get("operation", "get")
|
||||
if op == "set":
|
||||
data["max_history_length"] = 40
|
||||
data["ttl"] = 604800 # 7 天热缓存;冷数据仍可从 DB 拉回
|
||||
elif op == "get":
|
||||
data["ttl"] = 604800
|
||||
|
||||
|
||||
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"}
|
||||
|
||||
dup = requests.post(
|
||||
f"{BASE}/api/v1/agents/{SOURCE_AGENT_ID}/duplicate",
|
||||
headers=h,
|
||||
json={"name": NEW_NAME},
|
||||
timeout=30,
|
||||
)
|
||||
if dup.status_code != 201:
|
||||
print("复制失败:", dup.status_code, dup.text[:800], file=sys.stderr)
|
||||
return 1
|
||||
new_id = dup.json()["id"]
|
||||
print("已创建副本:", new_id, NEW_NAME)
|
||||
|
||||
g = requests.get(f"{BASE}/api/v1/agents/{new_id}", headers=h, timeout=30)
|
||||
if g.status_code != 200:
|
||||
print("读取 Agent 失败:", g.text, file=sys.stderr)
|
||||
return 1
|
||||
agent = g.json()
|
||||
wf = agent["workflow_config"]
|
||||
nodes = wf.get("nodes") or []
|
||||
for n in nodes:
|
||||
if n.get("id") == "llm-unified":
|
||||
n.setdefault("data", {})["prompt"] = LLM_PROMPT
|
||||
break
|
||||
_patch_cache_nodes_for_memory(wf)
|
||||
|
||||
up = requests.put(
|
||||
f"{BASE}/api/v1/agents/{new_id}",
|
||||
headers=h,
|
||||
json={
|
||||
"description": NEW_DESC,
|
||||
"workflow_config": wf,
|
||||
},
|
||||
timeout=60,
|
||||
)
|
||||
if up.status_code != 200:
|
||||
print("更新失败:", up.status_code, up.text[:800], file=sys.stderr)
|
||||
return 1
|
||||
print("已更新描述、llm-unified 提示词,并为 Cache 节点设置 max_history_length/ttl(可选)")
|
||||
print("Agent ID:", new_id)
|
||||
print(json.dumps({"id": new_id, "name": NEW_NAME}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
243
backend/scripts/create_zhini_kefu_9.py
Normal file
243
backend/scripts/create_zhini_kefu_9.py
Normal file
@@ -0,0 +1,243 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
从「知你客服8号」复制为「知你客服9号」,强化「摘要 + 检索」可用性:
|
||||
|
||||
1. 上下文 code-build-context:更长近期轮次、注入 conversation_summary、向量结果 + 关键词从历史中捞相关句。
|
||||
2. 摘要路径 code-build-memory-value:在原有摘要分支上合并进完整 conversation_history(追加而非仅 2 条),并写回 conversation_summary。
|
||||
3. cache-update-summary:显式 value 为「memory」表达式,避免整包 input_data 写入 Redis。
|
||||
4. 向量写入:为每条 turn 带 metadata.user_id;引擎侧检索已按 user_id 过滤。
|
||||
|
||||
需平台可登录;默认源 8 号 ID 为上次创建结果,可用 ZHINI_8_AGENT_ID 覆盖。
|
||||
部署后请重启 Celery/API 以加载引擎向量过滤逻辑。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import requests
|
||||
|
||||
BASE = os.getenv("PLATFORM_BASE_URL", "http://127.0.0.1:8037").rstrip("/")
|
||||
SOURCE_AGENT_ID = os.getenv("ZHINI_8_AGENT_ID", "d7b64bf6-c8e3-4dc7-befc-03a98d5ff741")
|
||||
USER = os.getenv("PLATFORM_USERNAME", "admin")
|
||||
PWD = os.getenv("PLATFORM_PASSWORD", "123456")
|
||||
|
||||
NEW_NAME = "知你客服9号"
|
||||
NEW_DESC = (
|
||||
"在知你客服8号基础上强化摘要与检索:"
|
||||
"远期要点写入 conversation_summary;"
|
||||
"当轮上下文含「近期对话 + 摘要 + 向量片段 + 关键词相关历史」;"
|
||||
"向量库写入带 user_id 元数据,引擎检索按用户隔离。"
|
||||
"仍依赖 MEMORY_PERSIST_DB_ENABLED 与固定 user_id。"
|
||||
)
|
||||
|
||||
LLM_PROMPT = """你是客服助手。根据「用户当前输入」「已知用户信息」「远期摘要」「相关历史(检索)」和「最近几轮」完成:
|
||||
1)判断意图;
|
||||
2)生成一句自然、有帮助的回复;
|
||||
3)【强制】用户说出或暗示姓名、昵称时,必须在 user_profile.name 保存;合并已有字段勿丢失;
|
||||
4)用户问「我叫什么」等时,必须依据 user_profile.name 与对话/摘要回答;已有 name 时禁止说「还不知道」;
|
||||
5)「远期摘要」概括更早话题;「相关历史」可能含向量命中或关键词命中的旧轮次,请结合使用。
|
||||
|
||||
只输出一行合法 JSON,不要 markdown。格式示例:
|
||||
{"intent":"greeting","reply":"你好!","user_profile":{"name":"小明"}}
|
||||
|
||||
用户输入:{{user_input}}
|
||||
已知用户信息:{{memory.user_profile}}
|
||||
远期摘要:{{memory.conversation_summary}}
|
||||
相关历史(检索到的):{{memory.relevant_from_retrieval}}
|
||||
最近几轮:{{memory.recent_turns}}
|
||||
|
||||
要求:reply 简洁自然,200 字以内;user_profile 为对象。"""
|
||||
|
||||
CODE_BUILD_CONTEXT = r"""left = input_data.get('left') or {}
|
||||
right = input_data.get('right') or []
|
||||
if not isinstance(right, list):
|
||||
right = []
|
||||
mem = left.get('memory') or {}
|
||||
hist = mem.get('conversation_history') or []
|
||||
if not isinstance(hist, list):
|
||||
hist = []
|
||||
summary = mem.get('conversation_summary') or ''
|
||||
recent_n = 16
|
||||
recent = hist[-recent_n:] if len(hist) > recent_n else hist
|
||||
recent_str = '\n'.join(f"{x.get('role', '')}: {x.get('content', '')}" for x in recent)
|
||||
vec_str = '\n'.join((rec.get('text') or rec.get('content') or '') for rec in right)
|
||||
query = (left.get('user_input') or left.get('query') or '').strip()
|
||||
older = hist[:-recent_n] if len(hist) > recent_n else []
|
||||
|
||||
|
||||
def _tok(s):
|
||||
s = str(s)
|
||||
ch = {c for c in s if '\u4e00' <= c <= '\u9fff'}
|
||||
wd = set(s.lower().replace('\n', ' ').split())
|
||||
return ch | wd
|
||||
|
||||
|
||||
qt = _tok(query) if query else set()
|
||||
scored = []
|
||||
for m in older:
|
||||
c = str(m.get('content', ''))
|
||||
if not c:
|
||||
continue
|
||||
sc = len(qt & _tok(c)) if qt else 0
|
||||
if sc > 0:
|
||||
scored.append((sc, str(m.get('role', '')), c[:240]))
|
||||
scored.sort(key=lambda x: -x[0])
|
||||
kw_lines = [f"{role}: {text}" for _, role, text in scored[:6]]
|
||||
kw_str = '\n'.join(kw_lines)
|
||||
relevant_str = vec_str.strip()
|
||||
if kw_str:
|
||||
if relevant_str:
|
||||
relevant_str = relevant_str + '\n---\n关键词相关历史:\n' + kw_str
|
||||
else:
|
||||
relevant_str = '关键词相关历史:\n' + kw_str
|
||||
result = {
|
||||
'user_input': left.get('user_input') or left.get('query') or '',
|
||||
'memory': {
|
||||
'user_profile': mem.get('user_profile') or {},
|
||||
'conversation_summary': summary,
|
||||
'relevant_from_retrieval': relevant_str,
|
||||
'recent_turns': recent_str,
|
||||
},
|
||||
'query': left.get('query') or '',
|
||||
'user_id': left.get('user_id'),
|
||||
}
|
||||
"""
|
||||
|
||||
CODE_BUILD_MEMORY_VALUE = r"""left = input_data.get('left') or {}
|
||||
right_out = input_data.get('right') or {}
|
||||
summary = ''
|
||||
if isinstance(right_out, dict):
|
||||
summary = right_out.get('output') or right_out.get('result') or ''
|
||||
if not isinstance(summary, str):
|
||||
summary = str(summary or '')
|
||||
summary = summary.strip()
|
||||
mem = left.get('memory') or {}
|
||||
user_input = left.get('user_input') or left.get('query') or ''
|
||||
reply = left.get('right') or ''
|
||||
if isinstance(reply, dict):
|
||||
reply = reply.get('right') or reply.get('content') or str(reply)
|
||||
profile_update = left.get('user_profile_update') or {}
|
||||
if not isinstance(profile_update, dict):
|
||||
profile_update = {}
|
||||
user_profile = dict(mem.get('user_profile') or {}, **profile_update)
|
||||
ts = datetime.now().isoformat()
|
||||
old_hist = mem.get('conversation_history') or []
|
||||
if not isinstance(old_hist, list):
|
||||
old_hist = []
|
||||
new_hist = old_hist + [
|
||||
{'role': 'user', 'content': user_input, 'timestamp': ts},
|
||||
{'role': 'assistant', 'content': str(reply or ''), 'timestamp': ts},
|
||||
]
|
||||
max_len = 40
|
||||
if len(new_hist) > max_len:
|
||||
new_hist = new_hist[-max_len:]
|
||||
prev_sum = (mem.get('conversation_summary') or '').strip()
|
||||
conversation_summary = summary if summary else prev_sum
|
||||
memory_value = {
|
||||
'conversation_summary': conversation_summary,
|
||||
'conversation_history': new_hist,
|
||||
'user_profile': user_profile,
|
||||
'context': mem.get('context') or {},
|
||||
}
|
||||
result = {
|
||||
'memory': memory_value,
|
||||
'user_id': left.get('user_id'),
|
||||
'query': left.get('query'),
|
||||
'user_input': user_input,
|
||||
'right': reply,
|
||||
'user_profile_update': profile_update,
|
||||
}
|
||||
"""
|
||||
|
||||
CODE_BUILD_TURN_FOR_VECTOR = r"""reply = input_data.get('right') or ''
|
||||
if isinstance(reply, dict):
|
||||
reply = reply.get('right') or reply.get('content') or str(reply)
|
||||
query = input_data.get('query') or ''
|
||||
user_id = str(input_data.get('user_id') or 'default')
|
||||
raw = (user_id + '\n' + str(query) + '\n' + str(reply)).encode('utf-8', errors='ignore')
|
||||
doc_id = 'turn_' + hashlib.sha256(raw).hexdigest()[:24]
|
||||
text = '用户:' + str(query) + '\n助手:' + str(reply)
|
||||
result = {
|
||||
'text': text,
|
||||
'user_id': user_id,
|
||||
'id': doc_id,
|
||||
'metadata': {'user_id': user_id},
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def _patch_nodes(wf: dict) -> None:
|
||||
nodes = wf.get("nodes") or []
|
||||
for n in nodes:
|
||||
nid = n.get("id")
|
||||
if nid == "llm-unified":
|
||||
n.setdefault("data", {})["prompt"] = LLM_PROMPT
|
||||
elif nid == "code-build-context":
|
||||
n.setdefault("data", {})["code"] = CODE_BUILD_CONTEXT
|
||||
elif nid == "code-build-memory-value":
|
||||
n.setdefault("data", {})["code"] = CODE_BUILD_MEMORY_VALUE
|
||||
elif nid == "code-build-turn-for-vector":
|
||||
n.setdefault("data", {})["code"] = CODE_BUILD_TURN_FOR_VECTOR
|
||||
elif nid == "cache-update-summary":
|
||||
d = n.setdefault("data", {})
|
||||
d["value"] = "memory"
|
||||
elif nid == "transform-for-vector-upsert":
|
||||
m = n.setdefault("data", {}).setdefault("mapping", {})
|
||||
m["metadata"] = "{{left.metadata}}"
|
||||
|
||||
|
||||
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"}
|
||||
|
||||
dup = requests.post(
|
||||
f"{BASE}/api/v1/agents/{SOURCE_AGENT_ID}/duplicate",
|
||||
headers=h,
|
||||
json={"name": NEW_NAME},
|
||||
timeout=30,
|
||||
)
|
||||
if dup.status_code != 201:
|
||||
print("复制失败:", dup.status_code, dup.text[:800], file=sys.stderr)
|
||||
return 1
|
||||
new_id = dup.json()["id"]
|
||||
print("已创建副本:", new_id, NEW_NAME)
|
||||
|
||||
g = requests.get(f"{BASE}/api/v1/agents/{new_id}", headers=h, timeout=30)
|
||||
if g.status_code != 200:
|
||||
print("读取 Agent 失败:", g.text, file=sys.stderr)
|
||||
return 1
|
||||
agent = g.json()
|
||||
wf = agent["workflow_config"]
|
||||
_patch_nodes(wf)
|
||||
|
||||
up = requests.put(
|
||||
f"{BASE}/api/v1/agents/{new_id}",
|
||||
headers=h,
|
||||
json={"description": NEW_DESC, "workflow_config": wf},
|
||||
timeout=60,
|
||||
)
|
||||
if up.status_code != 200:
|
||||
print("更新失败:", up.status_code, up.text[:800], file=sys.stderr)
|
||||
return 1
|
||||
print("已更新:LLM 提示、code-build-context / memory-value / vector-turn、cache-update-summary.value、upsert.metadata")
|
||||
print("Agent ID:", new_id)
|
||||
print(json.dumps({"id": new_id, "name": NEW_NAME}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
37
backend/scripts/debug_cache_query.py
Normal file
37
backend/scripts/debug_cache_query.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""单次执行后打印 cache-query 与 llm-unified 输出,用于排查记忆。"""
|
||||
import json
|
||||
import requests
|
||||
|
||||
B = "http://127.0.0.1:8037"
|
||||
AID = "688c2c41-dcd1-4285-b193-6bed00c485c2"
|
||||
UID = "debug_uid_fresh_99"
|
||||
MSG = "我叫李小红"
|
||||
|
||||
r = requests.post(
|
||||
B + "/api/v1/auth/login",
|
||||
data={"username": "admin", "password": "123456"},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
timeout=15,
|
||||
)
|
||||
h = {"Authorization": f"Bearer {r.json()['access_token']}", "Content-Type": "application/json"}
|
||||
eid = requests.post(
|
||||
B + "/api/v1/executions",
|
||||
headers=h,
|
||||
json={"agent_id": AID, "input_data": {"query": MSG, "USER_INPUT": MSG, "user_id": UID}},
|
||||
timeout=30,
|
||||
).json()["id"]
|
||||
|
||||
import time
|
||||
|
||||
for _ in range(90):
|
||||
d = requests.get(f"{B}/api/v1/executions/{eid}", headers=h, timeout=60).json()
|
||||
if d["status"] not in ("pending", "running"):
|
||||
break
|
||||
time.sleep(0.6)
|
||||
|
||||
od = d.get("output_data") or {}
|
||||
nr = od.get("node_results") or {}
|
||||
cq = nr.get("cache-query") or {}
|
||||
llm = nr.get("llm-unified") or {}
|
||||
print("cache-query memory user_profile:", (cq.get("memory") or {}).get("user_profile"))
|
||||
print("llm output:", (llm.get("output") or "")[:600])
|
||||
153
backend/scripts/e2e_zhini11_test.py
Normal file
153
backend/scripts/e2e_zhini11_test.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
知你客服11号 E2E:普通对话 + 要求拉取 URL(触发 http_request)。
|
||||
需 API、Celery、LLM、外网可达测试 URL。
|
||||
|
||||
默认会先重启本机 Celery Worker(与 e2e_zhini7 一致),以加载含 code 节点 re/hashlib 注入的引擎。
|
||||
跳过重启: 设置环境变量 E2E_RESTART_CELERY=0
|
||||
|
||||
用法: cd backend && .\\venv\\Scripts\\python.exe scripts/e2e_zhini11_test.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||
VENV_PY = BACKEND_DIR / "venv" / "Scripts" / "python.exe"
|
||||
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8037")
|
||||
AGENT_NAME = os.environ.get("E2E_AGENT_NAME", "知你客服11号")
|
||||
# 小 JSON,适合测 GET
|
||||
TEST_URL = os.environ.get(
|
||||
"E2E_TEST_URL",
|
||||
"https://jsonplaceholder.typicode.com/posts/1",
|
||||
)
|
||||
|
||||
|
||||
def _restart_celery() -> None:
|
||||
ps = (
|
||||
"Get-CimInstance Win32_Process | "
|
||||
"Where-Object { $_.CommandLine -match 'celery_app' } | "
|
||||
"ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"
|
||||
)
|
||||
subprocess.run(
|
||||
["powershell", "-NoProfile", "-Command", ps],
|
||||
cwd=str(BACKEND_DIR),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
time.sleep(2)
|
||||
if not VENV_PY.is_file():
|
||||
print("未找到 venv Python,跳过启动 Celery", file=sys.stderr)
|
||||
return
|
||||
popen_kw: dict = {
|
||||
"cwd": str(BACKEND_DIR),
|
||||
"stdout": subprocess.DEVNULL,
|
||||
"stderr": subprocess.STDOUT,
|
||||
}
|
||||
if sys.platform == "win32":
|
||||
popen_kw["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined]
|
||||
subprocess.Popen(
|
||||
[
|
||||
str(VENV_PY),
|
||||
"-m",
|
||||
"celery",
|
||||
"-A",
|
||||
"app.core.celery_app",
|
||||
"worker",
|
||||
"--loglevel=info",
|
||||
"--pool=threads",
|
||||
"--concurrency=8",
|
||||
],
|
||||
**popen_kw,
|
||||
)
|
||||
print("已启动新 Celery Worker,等待就绪…")
|
||||
time.sleep(4)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
os.chdir(BACKEND_DIR)
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
|
||||
if os.environ.get("E2E_RESTART_CELERY", "1").strip().lower() not in ("0", "false", "no"):
|
||||
_restart_celery()
|
||||
|
||||
import httpx
|
||||
from app.core.database import SessionLocal
|
||||
from app.core.security import create_access_token
|
||||
from app.models.agent import Agent
|
||||
from app.models.user import User
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
agent = db.query(Agent).filter(Agent.name == AGENT_NAME).first()
|
||||
if not agent:
|
||||
print(f"未找到「{AGENT_NAME}」", file=sys.stderr)
|
||||
return 1
|
||||
owner = db.query(User).filter(User.id == agent.user_id).first()
|
||||
user = owner or db.query(User).first()
|
||||
if not user:
|
||||
print("无用户", file=sys.stderr)
|
||||
return 1
|
||||
token = create_access_token(data={"sub": user.id, "username": user.username})
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
uid = f"e2e_z11_{uuid.uuid4().hex[:10]}"
|
||||
print(f"agent={agent.id} user_id={uid} test_url={TEST_URL}\n")
|
||||
|
||||
def poll(client: httpx.Client, eid: str, timeout: float = 420.0) -> dict:
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < timeout:
|
||||
r = client.get(f"/api/v1/executions/{eid}", headers=headers)
|
||||
r.raise_for_status()
|
||||
d = r.json()
|
||||
st = d.get("status")
|
||||
if st == "completed":
|
||||
return d
|
||||
if st == "failed":
|
||||
print("failed:", d.get("error_message"), file=sys.stderr)
|
||||
raise RuntimeError("执行失败")
|
||||
time.sleep(1.5)
|
||||
raise TimeoutError("超时")
|
||||
|
||||
def reply_text(out: dict) -> str:
|
||||
od = out.get("output_data") or {}
|
||||
if isinstance(od, dict):
|
||||
r = od.get("result")
|
||||
if isinstance(r, str):
|
||||
return r[:800]
|
||||
return json.dumps(od, ensure_ascii=False)[:800]
|
||||
|
||||
rounds = [
|
||||
"我的名字叫测试员",
|
||||
f"请用工具访问这个网址并简要说明返回里 title 或主要内容是什么(只回答要点):{TEST_URL}",
|
||||
"我叫什么名字?",
|
||||
]
|
||||
|
||||
with httpx.Client(base_url=API_BASE, timeout=420.0) as client:
|
||||
for i, q in enumerate(rounds, 1):
|
||||
r = client.post(
|
||||
"/api/v1/executions",
|
||||
json={"agent_id": str(agent.id), "input_data": {"query": q, "user_id": uid}},
|
||||
headers=headers,
|
||||
)
|
||||
if r.status_code >= 400:
|
||||
print(r.text, file=sys.stderr)
|
||||
r.raise_for_status()
|
||||
eid = r.json()["id"]
|
||||
print(f"--- 第{i}轮 execution={eid} ---")
|
||||
out = poll(client, eid)
|
||||
print(f"Q: {q[:120]}...")
|
||||
print(f"A: {reply_text(out)}\n")
|
||||
|
||||
print("完成")
|
||||
finally:
|
||||
db.close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
99
backend/scripts/e2e_zhini12_123_md.py
Normal file
99
backend/scripts/e2e_zhini12_123_md.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""一次调用「知你客服12号」创建仓库根下 123.md(相对路径 123.md)。默认不重启 Celery。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||
REPO_ROOT = BACKEND_DIR.parent
|
||||
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8037")
|
||||
AGENT_NAME = os.environ.get("E2E_AGENT_NAME", "知你客服12号")
|
||||
REL_PATH = "123.md"
|
||||
FILE_CONTENT = "# 123\ne2e zhini12 123.md marker\n"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
os.chdir(BACKEND_DIR)
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
|
||||
import httpx
|
||||
from app.core.database import SessionLocal
|
||||
from app.core.security import create_access_token
|
||||
from app.models.agent import Agent
|
||||
from app.models.user import User
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
agent = db.query(Agent).filter(Agent.name == AGENT_NAME).first()
|
||||
if not agent:
|
||||
print(f"未找到「{AGENT_NAME}」", file=sys.stderr)
|
||||
return 1
|
||||
owner = db.query(User).filter(User.id == agent.user_id).first()
|
||||
user = owner or db.query(User).first()
|
||||
if not user:
|
||||
print("无用户", file=sys.stderr)
|
||||
return 1
|
||||
token = create_access_token(data={"sub": user.id, "username": user.username})
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
uid = f"e2e123_{uuid.uuid4().hex[:10]}"
|
||||
q = (
|
||||
f"创建 123.md。请用 file_write:相对路径 {REL_PATH}(工作区根下),"
|
||||
f"content 为 {json.dumps(FILE_CONTENT, ensure_ascii=False)},mode 为 w。"
|
||||
"reply 中写出 file_write 返回的真实 JSON。最后一行单行 JSON:intent、reply、user_profile。"
|
||||
)
|
||||
print(f"agent={agent.id} ({AGENT_NAME}) user_id={uid}")
|
||||
print(f"目标文件: {(REPO_ROOT / REL_PATH).resolve()}")
|
||||
|
||||
def poll(client: httpx.Client, eid: str, timeout: float = 300.0) -> dict:
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < timeout:
|
||||
r = client.get(f"/api/v1/executions/{eid}", headers=headers)
|
||||
r.raise_for_status()
|
||||
d = r.json()
|
||||
st = d.get("status")
|
||||
if st == "completed":
|
||||
return d
|
||||
if st == "failed":
|
||||
print("failed:", d.get("error_message"), file=sys.stderr)
|
||||
raise RuntimeError("执行失败")
|
||||
time.sleep(1.5)
|
||||
raise TimeoutError("超时")
|
||||
|
||||
with httpx.Client(base_url=API_BASE, timeout=300.0) as client:
|
||||
r = client.post(
|
||||
"/api/v1/executions",
|
||||
json={"agent_id": str(agent.id), "input_data": {"query": q, "user_id": uid}},
|
||||
headers=headers,
|
||||
)
|
||||
if r.status_code >= 400:
|
||||
print(r.text, file=sys.stderr)
|
||||
r.raise_for_status()
|
||||
eid = r.json()["id"]
|
||||
print(f"execution={eid}")
|
||||
out = poll(client, eid)
|
||||
od = out.get("output_data") or {}
|
||||
result = od.get("result", od)
|
||||
print("\n--- API result (截断 2000 字符) ---\n")
|
||||
print(str(result)[:2000])
|
||||
|
||||
abs_file = (REPO_ROOT / REL_PATH).resolve()
|
||||
if not abs_file.is_file():
|
||||
print(f"\n[FAIL] 磁盘未找到: {abs_file}", file=sys.stderr)
|
||||
return 2
|
||||
body = abs_file.read_text(encoding="utf-8", errors="replace")
|
||||
print(f"\n[OK] 文件: {abs_file}")
|
||||
print("--- 内容 ---\n", body[:800])
|
||||
if "e2e zhini12 123.md marker" not in body:
|
||||
print("\n[WARN] 未找到预期标记字符串", file=sys.stderr)
|
||||
print("\n完成")
|
||||
return 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
184
backend/scripts/e2e_zhini12_bbb_md.py
Normal file
184
backend/scripts/e2e_zhini12_bbb_md.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""
|
||||
通过「知你客服12号」Agent 实测:在 user_data 下创建 bbb.md(对应 D:\\aaa\\aiagent\\user_data\\bbb.md)。
|
||||
|
||||
依赖:API(默认 8037)、Redis、Celery、LLM;引擎需已修复 End 节点优先于 vector-upsert(否则界面 result 可能仍不对,但 file_write 可能已执行)。
|
||||
|
||||
用法:
|
||||
cd backend
|
||||
.\\venv\\Scripts\\python.exe scripts\\e2e_zhini12_bbb_md.py
|
||||
|
||||
环境变量:
|
||||
API_BASE 默认 http://127.0.0.1:8037
|
||||
E2E_AGENT_NAME 默认 知你客服12号
|
||||
E2E_REL_FILE 默认 user_data/bbb.md
|
||||
E2E_FILE_CONTENT 写入内容,默认带标记行便于校验
|
||||
E2E_RESTART_CELERY 默认 1;设为 0 跳过重启 Worker
|
||||
E2E_USE_ABSPATH_MSG 设为 1 时在用户话术中额外要求使用绝对路径 D:\\aaa\\aiagent\\user_data\\bbb.md
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||
VENV_PY = BACKEND_DIR / "venv" / "Scripts" / "python.exe"
|
||||
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8037")
|
||||
AGENT_NAME = os.environ.get("E2E_AGENT_NAME", "知你客服12号")
|
||||
REL_PATH = os.environ.get("E2E_REL_FILE", "user_data/bbb.md")
|
||||
FILE_CONTENT = os.environ.get(
|
||||
"E2E_FILE_CONTENT",
|
||||
"# bbb\n\nzhini12 e2e bbb marker\n",
|
||||
)
|
||||
|
||||
|
||||
def _restart_celery() -> None:
|
||||
ps = (
|
||||
"Get-CimInstance Win32_Process | "
|
||||
"Where-Object { $_.CommandLine -match 'celery_app' } | "
|
||||
"ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"
|
||||
)
|
||||
subprocess.run(
|
||||
["powershell", "-NoProfile", "-Command", ps],
|
||||
cwd=str(BACKEND_DIR),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
time.sleep(2)
|
||||
if not VENV_PY.is_file():
|
||||
print("未找到 venv Python,跳过启动 Celery", file=sys.stderr)
|
||||
return
|
||||
kw: dict = {"cwd": str(BACKEND_DIR), "stdout": subprocess.DEVNULL, "stderr": subprocess.STDOUT}
|
||||
if sys.platform == "win32":
|
||||
kw["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined]
|
||||
popen_env = os.environ.copy()
|
||||
if os.environ.get("E2E_LLM_TOOL_CHOICE", "required").strip().lower() not in (
|
||||
"0",
|
||||
"false",
|
||||
"no",
|
||||
"auto",
|
||||
):
|
||||
popen_env["LLM_TOOL_CHOICE"] = "required"
|
||||
subprocess.Popen(
|
||||
[
|
||||
str(VENV_PY),
|
||||
"-m",
|
||||
"celery",
|
||||
"-A",
|
||||
"app.core.celery_app",
|
||||
"worker",
|
||||
"--loglevel=info",
|
||||
"--pool=threads",
|
||||
"--concurrency=8",
|
||||
],
|
||||
env=popen_env,
|
||||
**kw,
|
||||
)
|
||||
print("已启动 Celery,等待就绪…")
|
||||
time.sleep(4)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
os.chdir(BACKEND_DIR)
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
# 实测 file_write:部分模型在 tool_choice=auto 下不发起 tool_calls,只输出伪造的 JSON 文本;E2E 默认要求至少一次函数调用
|
||||
if os.environ.get("E2E_LLM_TOOL_CHOICE", "required").strip().lower() not in ("0", "false", "no", "auto"):
|
||||
os.environ["LLM_TOOL_CHOICE"] = "required"
|
||||
if os.environ.get("E2E_RESTART_CELERY", "1").strip().lower() not in ("0", "false", "no"):
|
||||
_restart_celery()
|
||||
|
||||
import httpx
|
||||
from app.core.database import SessionLocal
|
||||
from app.core.security import create_access_token
|
||||
from app.models.agent import Agent
|
||||
from app.models.user import User
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
agent = db.query(Agent).filter(Agent.name == AGENT_NAME).first()
|
||||
if not agent:
|
||||
print(f"未找到「{AGENT_NAME}」", file=sys.stderr)
|
||||
return 1
|
||||
owner = db.query(User).filter(User.id == agent.user_id).first()
|
||||
user = owner or db.query(User).first()
|
||||
if not user:
|
||||
print("无用户", file=sys.stderr)
|
||||
return 1
|
||||
token = create_access_token(data={"sub": user.id, "username": user.username})
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
uid = f"bbb12_{uuid.uuid4().hex[:10]}"
|
||||
|
||||
abs_win = str((BACKEND_DIR.parent / REL_PATH.replace("/", os.sep)).resolve())
|
||||
use_abs = os.environ.get("E2E_USE_ABSPATH_MSG", "").strip().lower() in ("1", "true", "yes")
|
||||
|
||||
path_hint = (
|
||||
f"绝对路径 {json.dumps(abs_win, ensure_ascii=False)}"
|
||||
if use_abs
|
||||
else f"相对路径 {REL_PATH}(相对工作区根)"
|
||||
)
|
||||
_basename = os.path.basename(REL_PATH.replace("\\", "/"))
|
||||
q = (
|
||||
f"请在 D:\\\\aaa\\\\aiagent\\\\user_data 目录下创建 {_basename}。"
|
||||
f"必须通过工具 file_write 写入:优先使用 {path_hint};"
|
||||
f"content 为 {json.dumps(FILE_CONTENT, ensure_ascii=False)},mode 为 w。"
|
||||
f"file_write 返回的 JSON 必须原样体现在你最终 reply 的可读说明里(含 success 与 file_path)。"
|
||||
f"最后一行仍输出单行 JSON:intent、reply、user_profile。"
|
||||
)
|
||||
print(f"agent={agent.id} ({AGENT_NAME}) user_id={uid}\n目标文件(解析后): {abs_win}\n")
|
||||
|
||||
def poll(client: httpx.Client, eid: str, timeout: float = 300.0) -> dict:
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < timeout:
|
||||
r = client.get(f"/api/v1/executions/{eid}", headers=headers)
|
||||
r.raise_for_status()
|
||||
d = r.json()
|
||||
st = d.get("status")
|
||||
if st == "completed":
|
||||
return d
|
||||
if st == "failed":
|
||||
print("failed:", d.get("error_message"), file=sys.stderr)
|
||||
raise RuntimeError("执行失败")
|
||||
time.sleep(1.5)
|
||||
raise TimeoutError("超时")
|
||||
|
||||
with httpx.Client(base_url=API_BASE, timeout=300.0) as client:
|
||||
r = client.post(
|
||||
"/api/v1/executions",
|
||||
json={"agent_id": str(agent.id), "input_data": {"query": q, "user_id": uid}},
|
||||
headers=headers,
|
||||
)
|
||||
if r.status_code >= 400:
|
||||
print(r.text, file=sys.stderr)
|
||||
r.raise_for_status()
|
||||
eid = r.json()["id"]
|
||||
print(f"execution={eid}")
|
||||
out = poll(client, eid)
|
||||
od = out.get("output_data") or {}
|
||||
result = od.get("result", od)
|
||||
print("\n--- API output_data.result (截断 1500 字符) ---\n")
|
||||
print(str(result)[:1500])
|
||||
|
||||
abs_file = Path(abs_win).resolve()
|
||||
if not abs_file.is_file():
|
||||
print(f"\n[FAIL] 磁盘未找到: {abs_file}", file=sys.stderr)
|
||||
print("若 API 已 completed:可能是模型未触发 file_write,或路径/权限问题;可看 Celery 日志「执行工具 file_write」。", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
body = abs_file.read_text(encoding="utf-8", errors="replace")
|
||||
print(f"\n[OK] 文件存在: {abs_file}\n--- 内容 ---\n{body}\n---")
|
||||
marker = os.environ.get("E2E_MARKER", "zhini12 e2e bbb marker")
|
||||
if marker and marker not in body and FILE_CONTENT.strip() and marker in FILE_CONTENT:
|
||||
print(f"[WARN] 未在文件中发现预期标记「{marker}」,可能内容与 E2E_FILE_CONTENT 不一致", file=sys.stderr)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
print("\n完成")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
239
backend/scripts/e2e_zhini12_bbbb_md.py
Normal file
239
backend/scripts/e2e_zhini12_bbbb_md.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""
|
||||
E2E:知你客服12号 — 用户话术「在 D:\\aaa\\aiagent\\user_data 下创建 bbbb.md」
|
||||
并校验:/health 内置工具、落盘路径必须在工作区内(user_data 子路径)、可选 node_results 中的 file_write 痕迹。
|
||||
|
||||
用法(在 backend 目录):
|
||||
.\\venv\\Scripts\\python.exe scripts\\e2e_zhini12_bbbb_md.py
|
||||
|
||||
环境变量:API_BASE、E2E_AGENT_NAME、E2E_RESTART_CELERY、E2E_LLM_TOOL_CHOICE。
|
||||
注意:固定 user_data/bbbb.md 与正文/标记常量;不读取 E2E_REL_FILE、E2E_FILE_CONTENT、E2E_MARKER(避免 Shell 残留误测)。
|
||||
默认 E2E_LLM_TOOL_CHOICE=auto。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||
REPO_ROOT = BACKEND_DIR.parent
|
||||
USER_DATA_ROOT = (REPO_ROOT / "user_data").resolve()
|
||||
VENV_PY = BACKEND_DIR / "venv" / "Scripts" / "python.exe"
|
||||
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8037")
|
||||
AGENT_NAME = os.environ.get("E2E_AGENT_NAME", "知你客服12号")
|
||||
# 固定测 bbbb.md,勿用 E2E_REL_FILE(PowerShell 里常残留 ccc/bbb)
|
||||
REL_PATH = "user_data/bbbb.md"
|
||||
# 固定内容与标记(不用 E2E_FILE_CONTENT / E2E_MARKER 环境变量,避免 PowerShell 残留旧值)
|
||||
FILE_CONTENT = "# bbbb\n\ne2e bbbb permission marker\n"
|
||||
E2E_MARKER = "e2e bbbb permission marker"
|
||||
|
||||
|
||||
def _restart_celery() -> None:
|
||||
ps = (
|
||||
"Get-CimInstance Win32_Process | "
|
||||
"Where-Object { $_.CommandLine -match 'celery_app' } | "
|
||||
"ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"
|
||||
)
|
||||
subprocess.run(
|
||||
["powershell", "-NoProfile", "-Command", ps],
|
||||
cwd=str(BACKEND_DIR),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
time.sleep(2)
|
||||
if not VENV_PY.is_file():
|
||||
print("未找到 venv Python,跳过启动 Celery", file=sys.stderr)
|
||||
return
|
||||
kw: dict = {"cwd": str(BACKEND_DIR), "stdout": subprocess.DEVNULL, "stderr": subprocess.STDOUT}
|
||||
if sys.platform == "win32":
|
||||
kw["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined]
|
||||
popen_env = os.environ.copy()
|
||||
if os.environ.get("E2E_LLM_TOOL_CHOICE", "auto").strip().lower() not in (
|
||||
"0",
|
||||
"false",
|
||||
"no",
|
||||
"auto",
|
||||
):
|
||||
popen_env["LLM_TOOL_CHOICE"] = "required"
|
||||
subprocess.Popen(
|
||||
[
|
||||
str(VENV_PY),
|
||||
"-m",
|
||||
"celery",
|
||||
"-A",
|
||||
"app.core.celery_app",
|
||||
"worker",
|
||||
"--loglevel=info",
|
||||
"--pool=threads",
|
||||
"--concurrency=8",
|
||||
],
|
||||
env=popen_env,
|
||||
**kw,
|
||||
)
|
||||
print("已启动 Celery,等待就绪…")
|
||||
time.sleep(4)
|
||||
|
||||
|
||||
def _health_check(client) -> bool:
|
||||
r = client.get(f"{API_BASE.rstrip('/')}/health", timeout=15.0)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
print("\n--- GET /health ---")
|
||||
print(json.dumps(data, ensure_ascii=False, indent=2)[:2500])
|
||||
checks = data.get("checks")
|
||||
if not checks:
|
||||
print(
|
||||
"\n[INFO] /health 无 checks 字段(可能 API 未更新);请以 Worker 日志「内置工具就绪」为准。",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return True
|
||||
ok = checks.get("builtin_tools_ready") and checks.get("file_agent_core_ready")
|
||||
if not ok:
|
||||
print(
|
||||
"\n[WARN] API 进程 builtin_tools 未完全就绪。请同步重启 API 与 Celery。",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return bool(ok)
|
||||
|
||||
|
||||
def _path_allowed(abs_file: Path) -> tuple[bool, str]:
|
||||
"""file_write 合法路径:须在仓库根下,且本次要求落在 user_data 下。"""
|
||||
try:
|
||||
f = abs_file.resolve()
|
||||
repo = REPO_ROOT.resolve()
|
||||
except OSError as e:
|
||||
return False, str(e)
|
||||
try:
|
||||
f.relative_to(repo)
|
||||
except ValueError:
|
||||
return False, f"文件不在仓库根内: {f} vs root {repo}"
|
||||
try:
|
||||
f.relative_to(USER_DATA_ROOT)
|
||||
except ValueError:
|
||||
return False, f"文件不在 user_data 下: {f} vs {USER_DATA_ROOT}"
|
||||
return True, ""
|
||||
|
||||
|
||||
def _scan_file_write_in_node_results(od: dict) -> None:
|
||||
nr = od.get("node_results") or {}
|
||||
hits = []
|
||||
for nid, payload in nr.items():
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
out = payload.get("output")
|
||||
s = json.dumps(out, ensure_ascii=False) if not isinstance(out, str) else out
|
||||
if "file_write" in s and ("success" in s.lower() or "file_path" in s):
|
||||
hits.append((nid, s[:400]))
|
||||
print("\n--- node_results 中含 file_write 线索的节点 ---")
|
||||
if not hits:
|
||||
print("(未扫到明显 JSON;可能结果只在 LLM 正文或日志中)")
|
||||
for nid, frag in hits:
|
||||
print(f" {nid}: {frag}...")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
os.chdir(BACKEND_DIR)
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
if os.environ.get("E2E_LLM_TOOL_CHOICE", "auto").strip().lower() not in ("0", "false", "no", "auto"):
|
||||
os.environ["LLM_TOOL_CHOICE"] = "required"
|
||||
if os.environ.get("E2E_RESTART_CELERY", "1").strip().lower() not in ("0", "false", "no"):
|
||||
_restart_celery()
|
||||
|
||||
import httpx
|
||||
from app.core.database import SessionLocal
|
||||
from app.core.security import create_access_token
|
||||
from app.models.agent import Agent
|
||||
from app.models.user import User
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
with httpx.Client(timeout=30.0) as hclient:
|
||||
_health_check(hclient)
|
||||
|
||||
agent = db.query(Agent).filter(Agent.name == AGENT_NAME).first()
|
||||
if not agent:
|
||||
print(f"未找到「{AGENT_NAME}」", file=sys.stderr)
|
||||
return 1
|
||||
owner = db.query(User).filter(User.id == agent.user_id).first()
|
||||
user = owner or db.query(User).first()
|
||||
if not user:
|
||||
print("无用户", file=sys.stderr)
|
||||
return 1
|
||||
token = create_access_token(data={"sub": user.id, "username": user.username})
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
uid = f"bbbb12_{uuid.uuid4().hex[:10]}"
|
||||
|
||||
abs_win = str((REPO_ROOT / REL_PATH.replace("/", os.sep)).resolve())
|
||||
_basename = os.path.basename(REL_PATH.replace("\\", "/"))
|
||||
q = (
|
||||
f"请在 D:\\\\aaa\\\\aiagent\\\\user_data 目录下创建 {_basename}。"
|
||||
f"必须通过工具 file_write 写入:优先使用 相对路径 {REL_PATH}(相对工作区根);"
|
||||
f"content 为 {json.dumps(FILE_CONTENT, ensure_ascii=False)},mode 为 w。"
|
||||
f"file_write 返回的 JSON 必须原样体现在你最终 reply 的可读说明里(含 success 与 file_path)。"
|
||||
f"最后一行仍输出单行 JSON:intent、reply、user_profile。"
|
||||
)
|
||||
print(f"\nagent={agent.id} ({AGENT_NAME}) user_id={uid}")
|
||||
print(f"目标文件(解析后): {abs_win}")
|
||||
print(f"权限校验: 须位于 {USER_DATA_ROOT}")
|
||||
|
||||
def poll(client: httpx.Client, eid: str, timeout: float = 300.0) -> dict:
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < timeout:
|
||||
r = client.get(f"/api/v1/executions/{eid}", headers=headers)
|
||||
r.raise_for_status()
|
||||
d = r.json()
|
||||
st = d.get("status")
|
||||
if st == "completed":
|
||||
return d
|
||||
if st == "failed":
|
||||
print("failed:", d.get("error_message"), file=sys.stderr)
|
||||
raise RuntimeError("执行失败")
|
||||
time.sleep(1.5)
|
||||
raise TimeoutError("超时")
|
||||
|
||||
with httpx.Client(base_url=API_BASE, timeout=300.0) as client:
|
||||
r = client.post(
|
||||
"/api/v1/executions",
|
||||
json={"agent_id": str(agent.id), "input_data": {"query": q, "user_id": uid}},
|
||||
headers=headers,
|
||||
)
|
||||
if r.status_code >= 400:
|
||||
print(r.text, file=sys.stderr)
|
||||
r.raise_for_status()
|
||||
eid = r.json()["id"]
|
||||
print(f"\nexecution={eid}")
|
||||
out = poll(client, eid)
|
||||
od = out.get("output_data") or {}
|
||||
result = od.get("result", od)
|
||||
print("\n--- API output_data.result (截断 1500 字符) ---\n")
|
||||
print(str(result)[:1500])
|
||||
_scan_file_write_in_node_results(od)
|
||||
|
||||
abs_file = Path(abs_win).resolve()
|
||||
allowed, reason = _path_allowed(abs_file)
|
||||
if not allowed:
|
||||
print(f"\n[FAIL] 路径权限校验: {reason}", file=sys.stderr)
|
||||
return 3
|
||||
|
||||
if not abs_file.is_file():
|
||||
print(f"\n[FAIL] 磁盘未找到: {abs_file}", file=sys.stderr)
|
||||
print("若 completed:可能未触发 file_write;查 Celery「执行工具 file_write」。", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
body = abs_file.read_text(encoding="utf-8", errors="replace")
|
||||
print(f"\n[OK] 文件存在且路径合法: {abs_file}\n--- 内容 ---\n{body}\n---")
|
||||
if E2E_MARKER and E2E_MARKER not in body and FILE_CONTENT.strip() and E2E_MARKER in FILE_CONTENT:
|
||||
print(f"[WARN] 未在文件中发现标记「{E2E_MARKER}」", file=sys.stderr)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
print("\n[OK] E2E bbbb.md + 路径权限 通过\n完成")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
142
backend/scripts/e2e_zhini12_file_test.py
Normal file
142
backend/scripts/e2e_zhini12_file_test.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
知你客服12号:一轮对话触发 file_write(相对路径 user_data/e2e_12.md)。
|
||||
可选重启 Celery:E2E_RESTART_CELERY=1(默认 1)。
|
||||
|
||||
用法: cd backend && .\\venv\\Scripts\\python.exe scripts/e2e_zhini12_file_test.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||
VENV_PY = BACKEND_DIR / "venv" / "Scripts" / "python.exe"
|
||||
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8037")
|
||||
AGENT_NAME = os.environ.get("E2E_AGENT_NAME", "知你客服12号")
|
||||
REL_PATH = os.environ.get("E2E_REL_FILE", "user_data/e2e_12.md")
|
||||
FILE_CONTENT = os.environ.get("E2E_FILE_CONTENT", "e2e zhini12 ok\n")
|
||||
|
||||
|
||||
def _restart_celery() -> None:
|
||||
ps = (
|
||||
"Get-CimInstance Win32_Process | "
|
||||
"Where-Object { $_.CommandLine -match 'celery_app' } | "
|
||||
"ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"
|
||||
)
|
||||
subprocess.run(
|
||||
["powershell", "-NoProfile", "-Command", ps],
|
||||
cwd=str(BACKEND_DIR),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
time.sleep(2)
|
||||
if not VENV_PY.is_file():
|
||||
print("未找到 venv Python,跳过启动 Celery", file=sys.stderr)
|
||||
return
|
||||
kw: dict = {"cwd": str(BACKEND_DIR), "stdout": subprocess.DEVNULL, "stderr": subprocess.STDOUT}
|
||||
if sys.platform == "win32":
|
||||
kw["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined]
|
||||
subprocess.Popen(
|
||||
[
|
||||
str(VENV_PY),
|
||||
"-m",
|
||||
"celery",
|
||||
"-A",
|
||||
"app.core.celery_app",
|
||||
"worker",
|
||||
"--loglevel=info",
|
||||
"--pool=threads",
|
||||
"--concurrency=8",
|
||||
],
|
||||
**kw,
|
||||
)
|
||||
print("已启动 Celery,等待就绪…")
|
||||
time.sleep(4)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
os.chdir(BACKEND_DIR)
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
if os.environ.get("E2E_RESTART_CELERY", "1").strip().lower() not in ("0", "false", "no"):
|
||||
_restart_celery()
|
||||
|
||||
import httpx
|
||||
from app.core.database import SessionLocal
|
||||
from app.core.security import create_access_token
|
||||
from app.models.agent import Agent
|
||||
from app.models.user import User
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
agent = db.query(Agent).filter(Agent.name == AGENT_NAME).first()
|
||||
if not agent:
|
||||
print(f"未找到「{AGENT_NAME}」", file=sys.stderr)
|
||||
return 1
|
||||
owner = db.query(User).filter(User.id == agent.user_id).first()
|
||||
user = owner or db.query(User).first()
|
||||
if not user:
|
||||
print("无用户", file=sys.stderr)
|
||||
return 1
|
||||
token = create_access_token(data={"sub": user.id, "username": user.username})
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
uid = f"e2e12_{uuid.uuid4().hex[:10]}"
|
||||
q = (
|
||||
f"请调用 file_write:file_path 用相对路径 {REL_PATH},content 用 {json.dumps(FILE_CONTENT, ensure_ascii=False)},"
|
||||
"mode 用 w。完成后在 reply 里写出 file_write 返回的原始 JSON 字符串(不要编造)。"
|
||||
"最终只输出一行 JSON:intent、reply、user_profile。"
|
||||
)
|
||||
print(f"agent={agent.id} user_id={uid}\nQ: {q[:200]}...")
|
||||
|
||||
def poll(client: httpx.Client, eid: str, timeout: float = 300.0) -> dict:
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < timeout:
|
||||
r = client.get(f"/api/v1/executions/{eid}", headers=headers)
|
||||
r.raise_for_status()
|
||||
d = r.json()
|
||||
st = d.get("status")
|
||||
if st == "completed":
|
||||
return d
|
||||
if st == "failed":
|
||||
print("failed:", d.get("error_message"), file=sys.stderr)
|
||||
raise RuntimeError("执行失败")
|
||||
time.sleep(1.5)
|
||||
raise TimeoutError("超时")
|
||||
|
||||
with httpx.Client(base_url=API_BASE, timeout=300.0) as client:
|
||||
r = client.post(
|
||||
"/api/v1/executions",
|
||||
json={"agent_id": str(agent.id), "input_data": {"query": q, "user_id": uid}},
|
||||
headers=headers,
|
||||
)
|
||||
if r.status_code >= 400:
|
||||
print(r.text, file=sys.stderr)
|
||||
r.raise_for_status()
|
||||
eid = r.json()["id"]
|
||||
print(f"execution={eid}")
|
||||
out = poll(client, eid)
|
||||
od = out.get("output_data") or {}
|
||||
result = od.get("result", od)
|
||||
print("--- API result (截断) ---")
|
||||
print(str(result)[:1200])
|
||||
|
||||
root = BACKEND_DIR.parent
|
||||
abs_file = (root / REL_PATH.replace("/", os.sep)).resolve()
|
||||
if abs_file.is_file():
|
||||
body = abs_file.read_text(encoding="utf-8", errors="replace")
|
||||
print(f"\n磁盘文件存在: {abs_file}\n内容:\n{body!r}")
|
||||
else:
|
||||
print(f"\n磁盘未找到: {abs_file}", file=sys.stderr)
|
||||
return 2
|
||||
finally:
|
||||
db.close()
|
||||
print("\n完成")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
171
backend/scripts/e2e_zhini7_two_rounds.py
Normal file
171
backend/scripts/e2e_zhini7_two_rounds.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
重启 Celery Worker(Windows),并对「知你客服7号」做两轮 API 测试:
|
||||
1)我的名字叫小七 2)我叫什么名字?
|
||||
需:本机 API 已监听(默认 8037)、Redis、LLM 配置可用。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||
VENV_PY = BACKEND_DIR / "venv" / "Scripts" / "python.exe"
|
||||
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8037")
|
||||
|
||||
|
||||
def _restart_celery() -> None:
|
||||
ps = (
|
||||
"Get-CimInstance Win32_Process | "
|
||||
"Where-Object { $_.CommandLine -match 'celery_app' } | "
|
||||
"ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"
|
||||
)
|
||||
subprocess.run(
|
||||
["powershell", "-NoProfile", "-Command", ps],
|
||||
cwd=str(BACKEND_DIR),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
time.sleep(2)
|
||||
if not VENV_PY.is_file():
|
||||
print("未找到 venv Python,跳过启动 Celery", file=sys.stderr)
|
||||
return
|
||||
popen_kw: dict = {
|
||||
"cwd": str(BACKEND_DIR),
|
||||
"stdout": subprocess.DEVNULL,
|
||||
"stderr": subprocess.STDOUT,
|
||||
}
|
||||
if sys.platform == "win32":
|
||||
popen_kw["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined]
|
||||
subprocess.Popen(
|
||||
[
|
||||
str(VENV_PY),
|
||||
"-m",
|
||||
"celery",
|
||||
"-A",
|
||||
"app.core.celery_app",
|
||||
"worker",
|
||||
"--loglevel=info",
|
||||
"--pool=threads",
|
||||
"--concurrency=8",
|
||||
],
|
||||
**popen_kw,
|
||||
)
|
||||
print("已启动新 Celery Worker(线程池),等待就绪…")
|
||||
time.sleep(4)
|
||||
|
||||
|
||||
def _touch_api_reload() -> None:
|
||||
"""若 uvicorn 带 --reload,触发重载。"""
|
||||
main_py = BACKEND_DIR / "app" / "main.py"
|
||||
if main_py.is_file():
|
||||
main_py.touch()
|
||||
print("已 touch app/main.py 以触发 API 热重载(若启用 --reload)")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
os.chdir(BACKEND_DIR)
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
|
||||
_restart_celery()
|
||||
_touch_api_reload()
|
||||
|
||||
import httpx
|
||||
from app.core.database import SessionLocal
|
||||
from app.core.security import create_access_token
|
||||
from app.models.agent import Agent
|
||||
from app.models.user import User
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
agent = db.query(Agent).filter(Agent.name == "知你客服7号").first()
|
||||
if not agent:
|
||||
print("数据库中未找到名为「知你客服7号」的 Agent", file=sys.stderr)
|
||||
return 1
|
||||
owner = db.query(User).filter(User.id == agent.user_id).first()
|
||||
user = owner or db.query(User).first()
|
||||
if not user:
|
||||
print("无可用用户,无法签发 JWT", file=sys.stderr)
|
||||
return 1
|
||||
token = create_access_token(data={"sub": user.id, "username": user.username})
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
uid = f"e2e_xiaoqi_{uuid.uuid4().hex[:10]}"
|
||||
print(f"agent_id={agent.id} owner={user.username} user_id={uid}")
|
||||
print(f"请确认工作流 Cache 键为 user_memory_{{{{user_id}}}},请求中已带 user_id={uid}\n")
|
||||
|
||||
def poll(client: httpx.Client, execution_id: str, timeout: float = 300.0) -> dict:
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < timeout:
|
||||
r = client.get(f"/api/v1/executions/{execution_id}", headers=headers)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
st = data.get("status")
|
||||
if st == "completed":
|
||||
return data
|
||||
if st == "failed":
|
||||
print("error:", data.get("error_message"), file=sys.stderr)
|
||||
raise RuntimeError("执行失败")
|
||||
time.sleep(1)
|
||||
raise TimeoutError("等待执行完成超时")
|
||||
|
||||
with httpx.Client(base_url=API_BASE, timeout=300.0) as client:
|
||||
r = client.post(
|
||||
"/api/v1/executions",
|
||||
json={
|
||||
"agent_id": str(agent.id),
|
||||
"input_data": {"query": "我的名字叫小七", "user_id": uid},
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
if r.status_code >= 400:
|
||||
print(r.text, file=sys.stderr)
|
||||
r.raise_for_status()
|
||||
eid1 = r.json()["id"]
|
||||
print("第一轮 execution_id:", eid1)
|
||||
out1 = poll(client, eid1)
|
||||
print("第一轮 output_data:", json.dumps(out1.get("output_data"), ensure_ascii=False)[:1200])
|
||||
|
||||
r = client.post(
|
||||
"/api/v1/executions",
|
||||
json={
|
||||
"agent_id": str(agent.id),
|
||||
"input_data": {"query": "我叫什么名字?", "user_id": uid},
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
r.raise_for_status()
|
||||
eid2 = r.json()["id"]
|
||||
print("\n第二轮 execution_id:", eid2)
|
||||
out2 = poll(client, eid2)
|
||||
print("第二轮 output_data:", json.dumps(out2.get("output_data"), ensure_ascii=False)[:1200])
|
||||
|
||||
# Redis 键检查
|
||||
try:
|
||||
from app.core.config import settings
|
||||
import redis as redis_lib
|
||||
|
||||
url = getattr(settings, "REDIS_URL", None) or "redis://localhost:6379/0"
|
||||
rc = redis_lib.from_url(url, decode_responses=True)
|
||||
key = f"user_memory_{uid}"
|
||||
raw = rc.get(key)
|
||||
print(f"\nRedis 键 {key}:", "存在" if raw else "不存在")
|
||||
if raw:
|
||||
try:
|
||||
mem = json.loads(raw)
|
||||
print("memory.user_profile:", mem.get("user_profile"))
|
||||
except Exception as ex:
|
||||
print("解析 Redis 值失败:", ex)
|
||||
except Exception as ex:
|
||||
print("Redis 检查跳过:", ex)
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
116
backend/scripts/e2e_zhini9_test.py
Normal file
116
backend/scripts/e2e_zhini9_test.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
对「知你客服9号」做多轮 API 测试(不默认重启 Celery,避免打断本机 Worker)。
|
||||
轮次:①自我介绍姓名 ②陈述偏好(供摘要/关键词召回)③闲聊 ④问姓名 + 问偏好
|
||||
|
||||
用法:
|
||||
cd backend && .\\venv\\Scripts\\python.exe scripts/e2e_zhini9_test.py
|
||||
环境变量: API_BASE, E2E_AGENT_NAME(默认 知你客服9号)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
|
||||
BACKEND_DIR = __file__.rsplit("scripts", 1)[0]
|
||||
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8037")
|
||||
AGENT_NAME = os.environ.get("E2E_AGENT_NAME", "知你客服9号")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
os.chdir(BACKEND_DIR)
|
||||
sys.path.insert(0, BACKEND_DIR)
|
||||
|
||||
import httpx
|
||||
from app.core.database import SessionLocal
|
||||
from app.core.security import create_access_token
|
||||
from app.models.agent import Agent
|
||||
from app.models.user import User
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
agent = db.query(Agent).filter(Agent.name == AGENT_NAME).first()
|
||||
if not agent:
|
||||
print(f"数据库中未找到「{AGENT_NAME}」", file=sys.stderr)
|
||||
return 1
|
||||
owner = db.query(User).filter(User.id == agent.user_id).first()
|
||||
user = owner or db.query(User).first()
|
||||
if not user:
|
||||
print("无可用用户", file=sys.stderr)
|
||||
return 1
|
||||
token = create_access_token(data={"sub": user.id, "username": user.username})
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
uid = f"e2e_z9_{uuid.uuid4().hex[:10]}"
|
||||
print(f"agent_id={agent.id} name={agent.name} user_id={uid}\n")
|
||||
|
||||
def poll(client: httpx.Client, execution_id: str, timeout: float = 300.0) -> dict:
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < timeout:
|
||||
r = client.get(f"/api/v1/executions/{execution_id}", headers=headers)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
st = data.get("status")
|
||||
if st == "completed":
|
||||
return data
|
||||
if st == "failed":
|
||||
print("error:", data.get("error_message"), file=sys.stderr)
|
||||
raise RuntimeError("执行失败")
|
||||
time.sleep(1)
|
||||
raise TimeoutError("超时")
|
||||
|
||||
def extract_reply(out: dict) -> str:
|
||||
od = out.get("output_data") or {}
|
||||
if isinstance(od, dict):
|
||||
r = od.get("result")
|
||||
if isinstance(r, str):
|
||||
return r[:500]
|
||||
return json.dumps(od, ensure_ascii=False)[:500]
|
||||
|
||||
rounds = [
|
||||
"我的名字叫阿九",
|
||||
"记住:我最爱吃火锅,不喜欢甜食。",
|
||||
"今天天气不错吧?",
|
||||
"我叫什么名字?你还记得我喜欢吃什么吗?",
|
||||
]
|
||||
|
||||
with httpx.Client(base_url=API_BASE, timeout=300.0) as client:
|
||||
for i, q in enumerate(rounds, 1):
|
||||
r = client.post(
|
||||
"/api/v1/executions",
|
||||
json={"agent_id": str(agent.id), "input_data": {"query": q, "user_id": uid}},
|
||||
headers=headers,
|
||||
)
|
||||
if r.status_code >= 400:
|
||||
print(r.text, file=sys.stderr)
|
||||
r.raise_for_status()
|
||||
eid = r.json()["id"]
|
||||
out = poll(client, eid)
|
||||
print(f"--- 第{i}轮 ---\nQ: {q}\nA: {extract_reply(out)}\n")
|
||||
|
||||
try:
|
||||
from app.core.config import settings
|
||||
import redis as redis_lib
|
||||
|
||||
url = getattr(settings, "REDIS_URL", None) or "redis://localhost:6379/0"
|
||||
rc = redis_lib.from_url(url, decode_responses=True)
|
||||
key = f"user_memory_{uid}"
|
||||
raw = rc.get(key)
|
||||
print(f"Redis {key}:", "有" if raw else "无")
|
||||
if raw:
|
||||
mem = json.loads(raw)
|
||||
print("conversation_summary 前120字:", str(mem.get("conversation_summary", ""))[:120])
|
||||
print("user_profile:", mem.get("user_profile"))
|
||||
print("history 条数:", len(mem.get("conversation_history") or []))
|
||||
except Exception as ex:
|
||||
print("Redis 检查:", ex)
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
print("完成")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
57
backend/scripts/patch_code_build_memory_value_no_import.py
Normal file
57
backend/scripts/patch_code_build_memory_value_no_import.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
代码节点 code-build-memory-value 首行「from datetime import datetime」在受限 exec 下会触发 __import__ 不可用。
|
||||
引擎已在 __builtins__ 中注入 datetime 类,去掉该行即可。
|
||||
|
||||
默认修补名为「知你客服12号」的 Agent;也可用环境变量 PATCH_AGENT_NAME=ALL 修补所有含该节点的 Agent。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
BACKEND = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, BACKEND)
|
||||
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from app.core.database import SessionLocal
|
||||
from app.models.agent import Agent
|
||||
|
||||
OLD_PREFIX = "from datetime import datetime\n"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
name = os.environ.get("PATCH_AGENT_NAME", "知你客服12号")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = db.query(Agent)
|
||||
if name.upper() != "ALL":
|
||||
q = q.filter(Agent.name == name)
|
||||
agents = q.all()
|
||||
n_patched = 0
|
||||
for a in agents:
|
||||
wf = a.workflow_config
|
||||
if not wf or "nodes" not in wf:
|
||||
continue
|
||||
touched = False
|
||||
for n in wf.get("nodes") or []:
|
||||
if n.get("id") != "code-build-memory-value":
|
||||
continue
|
||||
c = (n.get("data") or {}).get("code") or ""
|
||||
if c.startswith(OLD_PREFIX):
|
||||
n.setdefault("data", {})["code"] = c[len(OLD_PREFIX) :]
|
||||
touched = True
|
||||
if touched:
|
||||
a.workflow_config = wf
|
||||
flag_modified(a, "workflow_config")
|
||||
n_patched += 1
|
||||
print("已修补:", a.name, a.id)
|
||||
db.commit()
|
||||
print("合计修补 Agent 数:", n_patched)
|
||||
return 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
51
backend/scripts/patch_zhini_code_build_context_scope.py
Normal file
51
backend/scripts/patch_zhini_code_build_context_scope.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""修复 code-build-context 中列表推导式 r 与前面 genexp 的 r 作用域冲突(就地更新数据库 Agent)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
BACKEND = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, BACKEND)
|
||||
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from app.core.database import SessionLocal
|
||||
from app.models.agent import Agent
|
||||
|
||||
OLD_VEC = "vec_str = '\\n'.join((r.get('text') or r.get('content') or '') for r in right)"
|
||||
NEW_VEC = "vec_str = '\\n'.join((rec.get('text') or rec.get('content') or '') for rec in right)"
|
||||
OLD_KW = 'kw_lines = [f"{r}: {t}" for _, r, t in scored[:6]]'
|
||||
NEW_KW = 'kw_lines = [f"{role}: {text}" for _, role, text in scored[:6]]'
|
||||
|
||||
|
||||
def main() -> int:
|
||||
name = os.environ.get("PATCH_AGENT_NAME", "知你客服11号")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
a = db.query(Agent).filter(Agent.name == name).first()
|
||||
if not a:
|
||||
print("未找到", name, file=sys.stderr)
|
||||
return 1
|
||||
wf = a.workflow_config
|
||||
for n in wf.get("nodes", []):
|
||||
if n.get("id") != "code-build-context":
|
||||
continue
|
||||
c = n.get("data", {}).get("code", "")
|
||||
c2 = c.replace(OLD_VEC, NEW_VEC).replace(OLD_KW, NEW_KW)
|
||||
if c2 == c:
|
||||
print("无需替换(可能已修复或内容不同)")
|
||||
return 0
|
||||
n.setdefault("data", {})["code"] = c2
|
||||
a.workflow_config = wf
|
||||
flag_modified(a, "workflow_config")
|
||||
db.commit()
|
||||
print("已更新", name, "code-build-context")
|
||||
return 0
|
||||
print("未找到 code-build-context 节点", file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
91
backend/scripts/patch_zhini_kefu_12_prompt_tools.py
Normal file
91
backend/scripts/patch_zhini_kefu_12_prompt_tools.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""就地更新「知你客服12号」:启用 system_info + 修订 LLM 提示词(工作区路径、工具反馈)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
BACKEND = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, BACKEND)
|
||||
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from app.core.database import SessionLocal
|
||||
from app.models.agent import Agent
|
||||
|
||||
# 与 create_zhini_kefu_12.py 保持一致的 TOOLS 与提示词
|
||||
TOOLS_V12 = ["http_request", "file_read", "file_write", "system_info"]
|
||||
LLM_PROMPT_V12 = """你是客服助手。根据用户输入、用户画像、助手称呼、远期摘要、检索片段与最近对话生成回复。
|
||||
|
||||
【工具 http_request】
|
||||
- 用户给出 http(s) 链接且需要抓网页/API 时,先调用 http_request:参数 url 为完整链接,method 必填(一般为 GET)。
|
||||
- 根据返回 JSON 中的 body 字段提炼要点;非 URL 问答不要无故调用。
|
||||
|
||||
【工具 system_info(工作区路径)】
|
||||
- 用户问「工作区路径」「能访问哪个目录」「file 根目录在哪」时,**必须调用 system_info**,用返回 JSON 里的 **local_file_workspace_root** 原样告知用户(不要用「临时目录」「无法显示」等推脱)。
|
||||
|
||||
【工具 file_read / file_write(本地文件)】
|
||||
- 仅当用户明确要「读文件」「写入某路径」「保存到本地文件」等时使用。
|
||||
- file_read:参数 file_path 可为**相对工作区根的相对路径**,或**落在工作区根之下的绝对路径**(Windows 如 `D:\\...`,Linux 如 `/home/...`),二者等价,由后端校验。
|
||||
- file_write:参数 file_path、content;mode 用 w 覆盖或 a 追加。写入前确认路径有意、避免覆盖重要文件;不要写入密钥、令牌。
|
||||
- **禁止**以「不能访问 D: 盘」「只能相对路径」「工具看不到绝对路径」等理由拒绝用户:只要用户给的绝对路径以 `system_info` 返回的 `local_file_workspace_root` 为前缀(同一盘符、规范化后在其子路径下),就应**直接调用 file_write**,例如根为 `D:\\aaa\\aiagent` 时,`D:\\aaa\\aiagent\\user_data\\xxx.md` **合法**,可优先用用户原文路径或简写为相对路径 `user_data/xxx.md`。
|
||||
- 路径必须落在平台允许的工作区内,否则会报错;不要尝试访问工作区外的路径。
|
||||
- **禁止**假设工作区是 `/workspace` 或未经验证的目录;工作区根**只信** `local_file_workspace_root`。
|
||||
- **每次调用 file_write / file_read 后,必须在最终 reply 中说明工具返回结果**:成功则写明路径与要点;失败则引用返回 JSON 中的 error 字段,不得假装已成功。
|
||||
- **严禁编造工具返回**:reply 中若引用 file_write/file_read/system_info 的 JSON,必须与工具实际返回字符串一致(可原样粘贴)。禁止臆造路径(例如 /tmp/...、/workspace/...)或与当前系统不符的路径;若未调用工具,禁止在 reply 里写伪造的 JSON。
|
||||
|
||||
【称呼规则】(与 10/11 一致)
|
||||
- user_profile.name 表示用户昵称;assistant_display_name 表示用户为你起的称呼。
|
||||
- 用户问「你叫什么」时用 assistant_display_name(若有);勿把用户姓名写入 assistant_display_name。
|
||||
|
||||
【最终输出格式(强制)】
|
||||
- 最后一条回复必须是**一行合法 JSON**,无 markdown、无代码围栏;含 intent、reply、user_profile(对象)。
|
||||
|
||||
上下文:
|
||||
用户输入:{{user_input}}
|
||||
用户画像:{{memory.user_profile}}
|
||||
助手对外称呼:{{memory.assistant_display_name}}
|
||||
远期摘要:{{memory.conversation_summary}}
|
||||
相关历史(检索):{{memory.relevant_from_retrieval}}
|
||||
最近几轮:{{memory.recent_turns}}
|
||||
"""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
name = os.environ.get("PATCH_AGENT_NAME", "知你客服12号")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
a = db.query(Agent).filter(Agent.name == name).first()
|
||||
if not a:
|
||||
print("未找到", name, file=sys.stderr)
|
||||
return 1
|
||||
wf = dict(a.workflow_config) if a.workflow_config else {}
|
||||
nodes = list(wf.get("nodes") or [])
|
||||
done = False
|
||||
for i, n in enumerate(nodes):
|
||||
if n.get("id") != "llm-unified":
|
||||
continue
|
||||
d = dict(n.get("data") or {})
|
||||
d["prompt"] = LLM_PROMPT_V12
|
||||
d["enable_tools"] = True
|
||||
d["tools"] = list(TOOLS_V12)
|
||||
d["selected_tools"] = list(TOOLS_V12)
|
||||
nodes[i] = {**n, "data": d}
|
||||
done = True
|
||||
break
|
||||
if not done:
|
||||
print("未找到 llm-unified", file=sys.stderr)
|
||||
return 1
|
||||
wf["nodes"] = nodes
|
||||
a.workflow_config = wf
|
||||
flag_modified(a, "workflow_config")
|
||||
db.commit()
|
||||
print("已更新", name, "llm-unified: tools=", TOOLS_V12)
|
||||
print(json.dumps({"name": name, "id": str(a.id)}, ensure_ascii=False))
|
||||
return 0
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
41
backend/scripts/restart_api_worker.ps1
Normal file
41
backend/scripts/restart_api_worker.ps1
Normal file
@@ -0,0 +1,41 @@
|
||||
$ErrorActionPreference = "SilentlyContinue"
|
||||
$backend = "D:\aaa\aiagent\backend"
|
||||
|
||||
Get-CimInstance Win32_Process | Where-Object {
|
||||
$_.CommandLine -and $_.CommandLine -match "celery" -and $_.CommandLine -match "celery_app"
|
||||
} | ForEach-Object {
|
||||
Write-Host "Stop Celery PID $($_.ProcessId)"
|
||||
Stop-Process -Id $_.ProcessId -Force
|
||||
}
|
||||
|
||||
Get-CimInstance Win32_Process | Where-Object {
|
||||
$_.CommandLine -and $_.CommandLine -match "uvicorn" -and $_.CommandLine -match "app.main:app"
|
||||
} | ForEach-Object {
|
||||
Write-Host "Stop Uvicorn PID $($_.ProcessId)"
|
||||
Stop-Process -Id $_.ProcessId -Force
|
||||
}
|
||||
|
||||
Start-Sleep -Seconds 2
|
||||
|
||||
$py = Join-Path $backend "venv\Scripts\python.exe"
|
||||
Write-Host "Start Uvicorn :8037 ..."
|
||||
Start-Process -FilePath $py -ArgumentList @(
|
||||
"-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8037", "--reload"
|
||||
) -WorkingDirectory $backend -WindowStyle Minimized
|
||||
|
||||
Start-Sleep -Seconds 2
|
||||
|
||||
Write-Host "Start Celery worker ..."
|
||||
Start-Process -FilePath $py -ArgumentList @(
|
||||
"-m", "celery", "-A", "app.core.celery_app", "worker",
|
||||
"--loglevel=info", "--pool=threads", "--concurrency=8"
|
||||
) -WorkingDirectory $backend -WindowStyle Minimized
|
||||
|
||||
Start-Sleep -Seconds 3
|
||||
try {
|
||||
$r = Invoke-WebRequest -Uri "http://127.0.0.1:8037/health" -UseBasicParsing -TimeoutSec 15
|
||||
Write-Host "health: $($r.Content)"
|
||||
} catch {
|
||||
Write-Host "health check failed: $($_.Exception.Message)"
|
||||
}
|
||||
Write-Host "Done."
|
||||
61
backend/scripts/test_write_user_data_aaa_md.py
Normal file
61
backend/scripts/test_write_user_data_aaa_md.py
Normal file
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
测试在 D:\\aaa\\aiagent\\user_data 下创建 aaa.md(与线上一致的 file_write_tool)。
|
||||
|
||||
用法(在 backend 目录):
|
||||
.\\venv\\Scripts\\python.exe scripts\\test_write_user_data_aaa_md.py
|
||||
|
||||
可选环境变量:
|
||||
TEST_MD_CONTENT 写入内容,默认一行时间戳
|
||||
TEST_USE_ABSPATH 设为 1 时使用绝对路径 D:\\aaa\\aiagent\\user_data\\aaa.md,否则用相对路径 user_data/aaa.md
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
os.chdir(BACKEND_DIR)
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
|
||||
from app.services.builtin_tools import _local_file_workspace_root, file_write_tool
|
||||
|
||||
root = _local_file_workspace_root()
|
||||
content = os.environ.get(
|
||||
"TEST_MD_CONTENT",
|
||||
f"# aaa\\n\\nwritten by test_write_user_data_aaa_md.py at {datetime.now(timezone.utc).isoformat()}\\n",
|
||||
).replace("\\n", "\n")
|
||||
|
||||
if os.environ.get("TEST_USE_ABSPATH", "").strip() in ("1", "true", "yes"):
|
||||
target = root / "user_data" / "aaa.md"
|
||||
file_path_arg = str(target)
|
||||
else:
|
||||
file_path_arg = "user_data/aaa.md"
|
||||
|
||||
print("workspace root:", root)
|
||||
print("file_path:", file_path_arg)
|
||||
print("content bytes (utf-8):", len(content.encode("utf-8")))
|
||||
|
||||
raw = asyncio.run(file_write_tool(file_path_arg, content, "w"))
|
||||
print("tool return:", raw)
|
||||
|
||||
resolved = (root / "user_data" / "aaa.md").resolve()
|
||||
if not resolved.is_file():
|
||||
print("FAIL: file missing:", resolved, file=sys.stderr)
|
||||
return 2
|
||||
on_disk = resolved.read_text(encoding="utf-8", errors="replace")
|
||||
print("OK: on disk", resolved)
|
||||
print("--- file head ---")
|
||||
print(on_disk[:500])
|
||||
print("---")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
25
backend/scripts/update_zhini7_prompt.py
Normal file
25
backend/scripts/update_zhini7_prompt.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import re
|
||||
import requests
|
||||
|
||||
BASE = "http://127.0.0.1:8037"
|
||||
AID = "688c2c41-dcd1-4285-b193-6bed00c485c2"
|
||||
|
||||
text = open("scripts/create_zhini_kefu_7.py", encoding="utf-8").read()
|
||||
m = re.search(r'LLM_PROMPT = """(.*?)"""', text, re.S)
|
||||
prompt = m.group(1).strip()
|
||||
|
||||
r = requests.post(
|
||||
f"{BASE}/api/v1/auth/login",
|
||||
data={"username": "admin", "password": "123456"},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
timeout=15,
|
||||
)
|
||||
h = {"Authorization": f"Bearer {r.json()['access_token']}", "Content-Type": "application/json"}
|
||||
g = requests.get(f"{BASE}/api/v1/agents/{AID}", headers=h, timeout=30).json()
|
||||
wf = g["workflow_config"]
|
||||
for n in wf["nodes"]:
|
||||
if n.get("id") == "llm-unified":
|
||||
n["data"]["prompt"] = prompt
|
||||
break
|
||||
up = requests.put(f"{BASE}/api/v1/agents/{AID}", headers=h, json={"workflow_config": wf}, timeout=60)
|
||||
print(up.status_code, up.text[:300])
|
||||
2936
frontend/pnpm-lock.yaml
generated
Normal file
2936
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -386,11 +386,44 @@ const scrollToBottom = () => {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 知你等工作流约定「自然语言 + 末尾单行 JSON(intent/reply/user_profile)」。
|
||||
* 聊天区若原样展示会重复;展示前去掉末尾 JSON 行(可连续多行);若整段只有 JSON 则用 reply 作为正文。
|
||||
*/
|
||||
const stripOneTrailingWorkflowJsonLine = (raw: string): string => {
|
||||
if (!raw || typeof raw !== 'string') return ''
|
||||
const t = raw.trimEnd()
|
||||
const lastNl = t.lastIndexOf('\n')
|
||||
const lastLine = (lastNl >= 0 ? t.slice(lastNl + 1) : t).trim()
|
||||
if (!lastLine.startsWith('{')) return raw
|
||||
try {
|
||||
const j = JSON.parse(lastLine) as Record<string, unknown>
|
||||
if (!j || typeof j !== 'object') return raw
|
||||
const reply = j.reply
|
||||
if (typeof reply !== 'string') return raw
|
||||
const head = lastNl >= 0 ? t.slice(0, lastNl).trimEnd() : ''
|
||||
if (head) return head
|
||||
return reply
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
const stripTrailingWorkflowJsonLine = (raw: string): string => {
|
||||
let cur = raw
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const next = stripOneTrailingWorkflowJsonLine(cur)
|
||||
if (next === cur) break
|
||||
cur = next
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
// 格式化消息(支持简单的Markdown)
|
||||
const formatMessage = (content: string) => {
|
||||
if (!content) return ''
|
||||
// 简单的换行处理
|
||||
return content.replace(/\n/g, '<br>')
|
||||
const display = stripTrailingWorkflowJsonLine(content)
|
||||
return display.replace(/\n/g, '<br>')
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
|
||||
@@ -62,6 +62,10 @@
|
||||
<el-icon><Operation style="transform: rotate(90deg)" /></el-icon>
|
||||
垂直分布
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="normalize-edge-handles" divided>
|
||||
<el-icon><ConnectionIcon /></el-icon>
|
||||
修正连线锚点
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
@@ -6214,6 +6218,84 @@ const onEdgesChange = (changes: any[]) => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 节点中心与尺寸(无测量值时用与自动布局一致的默认宽高) */
|
||||
const getNodeLayoutMetrics = (n: Node) => {
|
||||
const dw = n.dimensions?.width
|
||||
const dh = n.dimensions?.height
|
||||
const w = dw && dw > 0 ? dw : 200
|
||||
const h = dh && dh > 0 ? dh : 80
|
||||
return {
|
||||
w,
|
||||
h,
|
||||
cx: n.position.x + w / 2,
|
||||
cy: n.position.y + h / 2,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据两节点相对位置选择连接点,避免「吸附」总接到上下锚点造成锯齿线。
|
||||
* 条件节点 true/false 出口保留,仅修正目标侧锚点。
|
||||
*/
|
||||
const resolveOptimalEdgeHandles = (
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
currentSourceHandle?: string | null
|
||||
): { sourceHandle: string; targetHandle: string } => {
|
||||
const src = nodes.value.find((node) => node.id === sourceId)
|
||||
const tgt = nodes.value.find((node) => node.id === targetId)
|
||||
if (!src || !tgt) {
|
||||
return { sourceHandle: currentSourceHandle || 'right', targetHandle: 'left' }
|
||||
}
|
||||
const a = getNodeLayoutMetrics(src)
|
||||
const b = getNodeLayoutMetrics(tgt)
|
||||
const dx = b.cx - a.cx
|
||||
const dy = b.cy - a.cy
|
||||
|
||||
if (currentSourceHandle === 'true' || currentSourceHandle === 'false') {
|
||||
if (Math.abs(dx) >= Math.abs(dy)) {
|
||||
return { sourceHandle: currentSourceHandle, targetHandle: 'left' }
|
||||
}
|
||||
return { sourceHandle: currentSourceHandle, targetHandle: 'top' }
|
||||
}
|
||||
|
||||
const horizontalish = Math.abs(dx) >= Math.abs(dy) * 0.85
|
||||
if (horizontalish) {
|
||||
return { sourceHandle: 'right', targetHandle: 'left' }
|
||||
}
|
||||
if (dy > 8) {
|
||||
return { sourceHandle: 'bottom', targetHandle: 'top' }
|
||||
}
|
||||
return { sourceHandle: 'right', targetHandle: 'left' }
|
||||
}
|
||||
|
||||
/** 批量将已有边的锚点校正为与布局一致的方向 */
|
||||
const normalizeAllWorkflowEdgeHandles = (opts?: { silent?: boolean }): number => {
|
||||
let changed = 0
|
||||
for (const edge of edges.value) {
|
||||
const { sourceHandle, targetHandle } = resolveOptimalEdgeHandles(
|
||||
edge.source,
|
||||
edge.target,
|
||||
edge.sourceHandle
|
||||
)
|
||||
if (edge.sourceHandle !== sourceHandle || edge.targetHandle !== targetHandle) {
|
||||
updateEdge(edge.id, { sourceHandle, targetHandle })
|
||||
edge.sourceHandle = sourceHandle
|
||||
edge.targetHandle = targetHandle
|
||||
changed++
|
||||
}
|
||||
}
|
||||
if (changed > 0) {
|
||||
hasChanges.value = true
|
||||
pushHistory('normalize edge handles')
|
||||
if (!opts?.silent) {
|
||||
ElMessage.success(`已修正 ${changed} 条连线的锚点`)
|
||||
}
|
||||
} else if (!opts?.silent) {
|
||||
ElMessage.info('连线锚点已在最佳方向')
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// 连接验证函数 - 允许所有方向的连接(包括左右)
|
||||
const isValidConnection = (connection: Connection) => {
|
||||
console.log('验证连接:', connection)
|
||||
@@ -6228,14 +6310,11 @@ const isValidConnection = (connection: Connection) => {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查是否已经存在相同的连接
|
||||
// 同一对节点只保留一条边(锚点不同也视为重复,避免重复连线)
|
||||
const existingEdge = edges.value.find(
|
||||
e => e.source === connection.source &&
|
||||
e.target === connection.target &&
|
||||
(e.sourceHandle === connection.sourceHandle || (!e.sourceHandle && !connection.sourceHandle)) &&
|
||||
(e.targetHandle === connection.targetHandle || (!e.targetHandle && !connection.targetHandle))
|
||||
(e) => e.source === connection.source && e.target === connection.target
|
||||
)
|
||||
|
||||
|
||||
if (existingEdge) {
|
||||
return false
|
||||
}
|
||||
@@ -6265,19 +6344,22 @@ const onConnect = (connection: Connection) => {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否已经存在相同的连接
|
||||
// 同一对节点只保留一条边
|
||||
const existingEdge = edges.value.find(
|
||||
e => e.source === connection.source &&
|
||||
e.target === connection.target &&
|
||||
(e.sourceHandle === connection.sourceHandle || (!e.sourceHandle && !connection.sourceHandle)) &&
|
||||
(e.targetHandle === connection.targetHandle || (!e.targetHandle && !connection.targetHandle))
|
||||
(e) => e.source === connection.source && e.target === connection.target
|
||||
)
|
||||
|
||||
|
||||
if (existingEdge) {
|
||||
ElMessage.warning('连接已存在')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
const { sourceHandle, targetHandle } = resolveOptimalEdgeHandles(
|
||||
connection.source,
|
||||
connection.target,
|
||||
connection.sourceHandle
|
||||
)
|
||||
|
||||
// 清除选中的边
|
||||
selectedEdge.value = null
|
||||
|
||||
@@ -6285,8 +6367,8 @@ const onConnect = (connection: Connection) => {
|
||||
id: `edge_${connection.source}_${connection.target}_${Date.now()}`,
|
||||
source: connection.source,
|
||||
target: connection.target,
|
||||
sourceHandle: connection.sourceHandle || undefined,
|
||||
targetHandle: connection.targetHandle || undefined,
|
||||
sourceHandle,
|
||||
targetHandle,
|
||||
type: 'bezier', // 使用贝塞尔曲线(平滑曲线)
|
||||
animated: true,
|
||||
selectable: true,
|
||||
@@ -6906,6 +6988,11 @@ const handleClear = () => {
|
||||
|
||||
// 节点对齐功能
|
||||
const handleAlignNodes = (command: string) => {
|
||||
if (command === 'normalize-edge-handles') {
|
||||
normalizeAllWorkflowEdgeHandles()
|
||||
return
|
||||
}
|
||||
|
||||
// 获取选中的节点(支持多选)
|
||||
const selectedNodes = nodes.value.filter(node => node.selected)
|
||||
|
||||
@@ -7054,147 +7141,117 @@ const handleAutoLayout = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// 构建邻接表(有向图)
|
||||
const graph: Record<string, string[]> = {}
|
||||
const inDegree: Record<string, number> = {}
|
||||
|
||||
// 初始化
|
||||
nodes.value.forEach(node => {
|
||||
graph[node.id] = []
|
||||
inDegree[node.id] = 0
|
||||
// 最长路径分层(Sugiyama 简化):rank[v]=1+max(rank[p]),同一 rank 的节点放在同一列,避免旧版「单节点层累加 currentX + 分支后重置 X」造成的重叠与超长飞线
|
||||
const nodeIds = nodes.value.map((n) => n.id)
|
||||
const pred = new Map<string, string[]>()
|
||||
nodeIds.forEach((id) => pred.set(id, []))
|
||||
edges.value.forEach((edge) => {
|
||||
if (!edge.source || !edge.target) return
|
||||
if (!pred.has(edge.target)) return
|
||||
pred.get(edge.target)!.push(edge.source)
|
||||
})
|
||||
|
||||
// 构建图
|
||||
edges.value.forEach(edge => {
|
||||
if (graph[edge.source] && !graph[edge.source].includes(edge.target)) {
|
||||
graph[edge.source].push(edge.target)
|
||||
inDegree[edge.target] = (inDegree[edge.target] || 0) + 1
|
||||
}
|
||||
|
||||
const rank: Record<string, number> = {}
|
||||
nodeIds.forEach((id) => {
|
||||
const ps = pred.get(id) || []
|
||||
if (ps.length === 0) rank[id] = 0
|
||||
})
|
||||
|
||||
// 拓扑排序,将节点分层
|
||||
const layers: string[][] = []
|
||||
const visited = new Set<string>()
|
||||
const queue: string[] = []
|
||||
|
||||
// 找到所有入度为0的节点(开始节点)
|
||||
Object.keys(inDegree).forEach(nodeId => {
|
||||
if (inDegree[nodeId] === 0) {
|
||||
queue.push(nodeId)
|
||||
}
|
||||
})
|
||||
|
||||
// 如果没有入度为0的节点,使用开始节点
|
||||
if (queue.length === 0 && startNode) {
|
||||
queue.push(startNode.id)
|
||||
if (startNode && rank[startNode.id] === undefined) rank[startNode.id] = 0
|
||||
|
||||
let relaxIter = 0
|
||||
let changed = true
|
||||
while (changed && relaxIter < nodeIds.length + 12) {
|
||||
changed = false
|
||||
relaxIter++
|
||||
edges.value.forEach((e) => {
|
||||
if (rank[e.source] === undefined) return
|
||||
const next = rank[e.source]! + 1
|
||||
if (rank[e.target] === undefined || rank[e.target]! < next) {
|
||||
rank[e.target] = next
|
||||
changed = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 分层遍历
|
||||
while (queue.length > 0) {
|
||||
const layer: string[] = []
|
||||
const layerSize = queue.length
|
||||
|
||||
for (let i = 0; i < layerSize; i++) {
|
||||
const nodeId = queue.shift()!
|
||||
if (visited.has(nodeId)) continue
|
||||
|
||||
visited.add(nodeId)
|
||||
layer.push(nodeId)
|
||||
|
||||
// 处理该节点的所有出边
|
||||
const neighbors = graph[nodeId] || []
|
||||
neighbors.forEach(neighborId => {
|
||||
inDegree[neighborId] = (inDegree[neighborId] || 0) - 1
|
||||
if (inDegree[neighborId] === 0 && !visited.has(neighborId)) {
|
||||
queue.push(neighborId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (layer.length > 0) {
|
||||
layers.push(layer)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理未访问的节点(可能是孤立节点)
|
||||
nodes.value.forEach(node => {
|
||||
if (!visited.has(node.id)) {
|
||||
if (layers.length === 0) {
|
||||
layers.push([node.id])
|
||||
} else {
|
||||
layers[layers.length - 1].push(node.id)
|
||||
let maxR = 0
|
||||
nodeIds.forEach((id) => {
|
||||
if (rank[id] !== undefined) maxR = Math.max(maxR, rank[id]!)
|
||||
})
|
||||
nodeIds.forEach((id) => {
|
||||
if (rank[id] === undefined) rank[id] = maxR + 1
|
||||
})
|
||||
maxR = Math.max(maxR, ...nodeIds.map((id) => rank[id]!))
|
||||
|
||||
const rankSet = new Set<number>()
|
||||
nodeIds.forEach((id) => rankSet.add(rank[id]!))
|
||||
const sortedRanks = [...rankSet].sort((a, b) => a - b)
|
||||
const rankToCol = new Map<number, number>(sortedRanks.map((r, i) => [r, i]))
|
||||
|
||||
let layers: string[][] = Array.from({ length: sortedRanks.length }, () => [])
|
||||
nodeIds.forEach((id) => {
|
||||
const col = rankToCol.get(rank[id]!) ?? 0
|
||||
layers[col].push(id)
|
||||
})
|
||||
|
||||
// 层内排序:重心法(barycenter)减少跨层边交叉,多轮上下扫掠
|
||||
const reorderLayersReduceCrossings = (ly: string[][], edgs: typeof edges.value): string[][] => {
|
||||
if (ly.length <= 1) return ly.map(l => [...l])
|
||||
const out = ly.map(l => [...l])
|
||||
const pred = new Map<string, string[]>()
|
||||
const succ = new Map<string, string[]>()
|
||||
edgs.forEach((e) => {
|
||||
if (!e?.source || !e?.target) return
|
||||
if (!pred.has(e.target)) pred.set(e.target, [])
|
||||
pred.get(e.target)!.push(e.source)
|
||||
if (!succ.has(e.source)) succ.set(e.source, [])
|
||||
succ.get(e.source)!.push(e.target)
|
||||
})
|
||||
const rounds = 8
|
||||
for (let it = 0; it < rounds; it++) {
|
||||
for (let k = 1; k < out.length; k++) {
|
||||
const prev = out[k - 1]
|
||||
const pos = new Map(prev.map((id, i) => [id, i]))
|
||||
out[k].sort((a, b) => {
|
||||
const pa = (pred.get(a) || []).filter((x) => pos.has(x))
|
||||
const pb = (pred.get(b) || []).filter((x) => pos.has(x))
|
||||
const ba = pa.length ? pa.reduce((s, x) => s + (pos.get(x) ?? 0), 0) / pa.length : 0
|
||||
const bb = pb.length ? pb.reduce((s, x) => s + (pos.get(x) ?? 0), 0) / pb.length : 0
|
||||
return ba - bb || a.localeCompare(b)
|
||||
})
|
||||
}
|
||||
for (let k = out.length - 2; k >= 0; k--) {
|
||||
const next = out[k + 1]
|
||||
const pos = new Map(next.map((id, i) => [id, i]))
|
||||
out[k].sort((a, b) => {
|
||||
const sa = (succ.get(a) || []).filter((x) => pos.has(x))
|
||||
const sb = (succ.get(b) || []).filter((x) => pos.has(x))
|
||||
const ba = sa.length ? sa.reduce((s, x) => s + (pos.get(x) ?? 0), 0) / sa.length : 0
|
||||
const bb = sb.length ? sb.reduce((s, x) => s + (pos.get(x) ?? 0), 0) / sb.length : 0
|
||||
return ba - bb || a.localeCompare(b)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
layers = reorderLayersReduceCrossings(layers, edges.value)
|
||||
|
||||
// 布局参数
|
||||
// 布局参数:按列摆开(列 = 拓扑深度),列内垂直堆叠;主线性链呈一行,分支呈一列,避免旧算法横向漂移与飞线
|
||||
const nodeWidth = 200
|
||||
const nodeHeight = 80
|
||||
const horizontalSpacing = 320 // 水平间距(节点之间的水平距离)
|
||||
const verticalSpacing = 150 // 垂直间距(用于有分支的情况)
|
||||
const startX = 100
|
||||
const startY = 200
|
||||
|
||||
// 检查是否是简单的线性工作流(每层只有一个节点)
|
||||
let isLinearWorkflow = true
|
||||
layers.forEach(layer => {
|
||||
if (layer.length > 1) {
|
||||
isLinearWorkflow = false
|
||||
}
|
||||
})
|
||||
|
||||
// 如果每层只有一个节点,使用水平线性布局(从左到右,所有节点在同一水平线)
|
||||
if (isLinearWorkflow && layers.length > 1) {
|
||||
// 水平线性布局:所有节点水平排列在同一水平线上
|
||||
layers.forEach((layer, layerIndex) => {
|
||||
layer.forEach((nodeId) => {
|
||||
const nodeX = startX + layerIndex * horizontalSpacing
|
||||
const nodeY = startY // 所有节点在同一水平线上
|
||||
updateNode(nodeId, {
|
||||
position: { x: nodeX, y: nodeY }
|
||||
})
|
||||
const horizontalSpacing = 300
|
||||
const verticalSpacing = 130
|
||||
const startX = 80
|
||||
const startY = 220
|
||||
|
||||
layers.forEach((layer, colIndex) => {
|
||||
const nodeX = startX + colIndex * horizontalSpacing
|
||||
const n = layer.length
|
||||
const y0 = startY - ((n - 1) * verticalSpacing) / 2
|
||||
layer.forEach((nodeId, j) => {
|
||||
updateNode(nodeId, {
|
||||
position: { x: nodeX, y: y0 + j * verticalSpacing }
|
||||
})
|
||||
})
|
||||
} else {
|
||||
// 层次布局:有分支的工作流
|
||||
// 优化策略:尽量让单节点层水平排列,多节点层才垂直排列
|
||||
let baseY = startY
|
||||
let currentX = startX
|
||||
let consecutiveSingleNodeLayers = 0
|
||||
|
||||
layers.forEach((layer, layerIndex) => {
|
||||
if (layer.length === 1) {
|
||||
// 单节点层:水平排列
|
||||
consecutiveSingleNodeLayers++
|
||||
const nodeId = layer[0]
|
||||
const nodeX = currentX
|
||||
// 如果连续多个单节点层,保持水平对齐
|
||||
const nodeY = baseY + (consecutiveSingleNodeLayers > 3 ? 20 : 0) // 如果连续太多,稍微下移
|
||||
updateNode(nodeId, {
|
||||
position: { x: nodeX, y: nodeY }
|
||||
})
|
||||
currentX += horizontalSpacing
|
||||
} else {
|
||||
// 多节点层:水平居中排列,使用新的Y坐标
|
||||
consecutiveSingleNodeLayers = 0
|
||||
baseY += verticalSpacing
|
||||
currentX = startX // 重置X位置
|
||||
|
||||
const layerWidth = (layer.length - 1) * horizontalSpacing
|
||||
const layerStartX = startX
|
||||
|
||||
layer.forEach((nodeId, nodeIndex) => {
|
||||
const nodeX = layerStartX + nodeIndex * horizontalSpacing
|
||||
updateNode(nodeId, {
|
||||
position: { x: nodeX, y: baseY }
|
||||
})
|
||||
})
|
||||
|
||||
// 更新currentX为下一层的起始位置
|
||||
currentX = layerStartX + layerWidth + horizontalSpacing
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 自动调整视口,使所有节点可见
|
||||
await nextTick()
|
||||
@@ -7233,7 +7290,12 @@ const handleAutoLayout = async () => {
|
||||
}
|
||||
}, 100)
|
||||
|
||||
ElMessage.success(`自动布局完成,共 ${layers.length} 层,${nodes.value.length} 个节点`)
|
||||
await nextTick()
|
||||
const fixedHandles = normalizeAllWorkflowEdgeHandles({ silent: true })
|
||||
ElMessage.success(
|
||||
`自动布局完成:${layers.length} 列(最长路径分层)+ 层内排序减交叉` +
|
||||
(fixedHandles ? `,已优化 ${fixedHandles} 条连线锚点` : '')
|
||||
)
|
||||
hasChanges.value = true
|
||||
}
|
||||
|
||||
|
||||
76
frontend/src/utils/agentSkills.ts
Normal file
76
frontend/src/utils/agentSkills.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 从 Agent 工作流中提取 / 写入 LLM 节点的工具(skills)配置。
|
||||
*/
|
||||
import type { WorkflowNode } from '@/types'
|
||||
|
||||
/** 与后端 tools_bootstrap 内置工具一致,用于列表展示与配置勾选 */
|
||||
export const BUILTIN_SKILL_OPTIONS: { name: string; label: string }[] = [
|
||||
{ name: 'http_request', label: 'HTTP 请求' },
|
||||
{ name: 'file_read', label: '读文件' },
|
||||
{ name: 'file_write', label: '写文件' },
|
||||
{ name: 'text_analyze', label: '文本分析' },
|
||||
{ name: 'datetime', label: '日期时间' },
|
||||
{ name: 'math_calculate', label: '数学计算' },
|
||||
{ name: 'system_info', label: '系统信息' },
|
||||
{ name: 'json_process', label: 'JSON 处理' },
|
||||
{ name: 'database_query', label: '数据库查询' },
|
||||
{ name: 'adb_log', label: 'ADB 日志' }
|
||||
]
|
||||
|
||||
export const BUILTIN_SKILL_LABELS: Record<string, string> = Object.fromEntries(
|
||||
BUILTIN_SKILL_OPTIONS.map((o) => [o.name, o.label])
|
||||
)
|
||||
|
||||
function isLlmNode(n: WorkflowNode): boolean {
|
||||
const t = (n.type || '').toLowerCase()
|
||||
const dt = (n.data?.type || '').toLowerCase()
|
||||
return t === 'llm' || t === 'template' || dt === 'llm'
|
||||
}
|
||||
|
||||
/** 工作流中所有 LLM 节点上已选工具的并集(去重排序) */
|
||||
export function extractSkillToolNames(workflow_config: { nodes?: WorkflowNode[] } | null | undefined): string[] {
|
||||
const set = new Set<string>()
|
||||
for (const n of workflow_config?.nodes || []) {
|
||||
if (!isLlmNode(n)) continue
|
||||
const raw = n.data?.tools ?? n.data?.selected_tools
|
||||
if (Array.isArray(raw)) {
|
||||
raw.forEach((x) => set.add(String(x)))
|
||||
}
|
||||
}
|
||||
return Array.from(set).sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* 作为「能力配置」写入时的目标节点:优先 llm-unified,否则第一个带工具的 LLM,否则第一个 LLM。
|
||||
*/
|
||||
export function findPrimaryLlmNodeForTools(nodes: WorkflowNode[] | undefined): WorkflowNode | null {
|
||||
if (!nodes?.length) return null
|
||||
const byId = nodes.find((n) => n.id === 'llm-unified' && isLlmNode(n))
|
||||
if (byId) return byId
|
||||
const withTools = nodes.find(
|
||||
(n) =>
|
||||
isLlmNode(n) &&
|
||||
(n.data?.enable_tools === true ||
|
||||
(Array.isArray(n.data?.tools) && n.data.tools.length > 0) ||
|
||||
(Array.isArray(n.data?.selected_tools) && n.data.selected_tools.length > 0))
|
||||
)
|
||||
if (withTools) return withTools
|
||||
return nodes.find((n) => isLlmNode(n)) || null
|
||||
}
|
||||
|
||||
export function patchWorkflowSkillTools(
|
||||
workflow_config: { nodes: WorkflowNode[]; edges: unknown[] },
|
||||
toolNames: string[]
|
||||
): { nodes: WorkflowNode[]; edges: unknown[] } {
|
||||
const wf = JSON.parse(JSON.stringify(workflow_config)) as { nodes: WorkflowNode[]; edges: unknown[] }
|
||||
const target = findPrimaryLlmNodeForTools(wf.nodes)
|
||||
if (!target) return wf
|
||||
|
||||
const names = [...new Set(toolNames)].filter(Boolean).sort()
|
||||
if (!target.data) target.data = {}
|
||||
target.data.enable_tools = names.length > 0
|
||||
target.data.tools = names
|
||||
target.data.selected_tools = names
|
||||
|
||||
return wf
|
||||
}
|
||||
@@ -62,7 +62,23 @@
|
||||
stripe
|
||||
>
|
||||
<el-table-column prop="name" label="名称" width="200" />
|
||||
<el-table-column prop="description" label="描述" show-overflow-tooltip />
|
||||
<el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="技能 (tools)" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div v-if="skillTagsForRow(row).length" class="skill-tags">
|
||||
<el-tag
|
||||
v-for="t in skillTagsForRow(row)"
|
||||
:key="t"
|
||||
size="small"
|
||||
class="skill-tag"
|
||||
effect="plain"
|
||||
>
|
||||
{{ skillLabel(t) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<span v-else class="skill-empty">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.status)">
|
||||
@@ -76,12 +92,16 @@
|
||||
{{ formatDate(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="480" fixed="right">
|
||||
<el-table-column label="操作" width="540" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="handleEdit(row)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button link type="primary" @click="handleSkillConfig(row)">
|
||||
<el-icon><Tools /></el-icon>
|
||||
能力配置
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 'published' || row.status === 'running'"
|
||||
link
|
||||
@@ -240,6 +260,65 @@
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 能力 / 技能配置 -->
|
||||
<el-dialog
|
||||
v-model="skillDialogVisible"
|
||||
title="能力配置(LLM 工具)"
|
||||
width="560px"
|
||||
destroy-on-close
|
||||
@close="handleSkillDialogClose"
|
||||
>
|
||||
<div v-loading="skillLoading">
|
||||
<el-alert type="info" :closable="false" show-icon class="skill-alert">
|
||||
<template #title>
|
||||
<span style="font-size: 13px;">
|
||||
勾选内置工具并保存后,将写入工作流中主 LLM 节点(优先
|
||||
<code>llm-unified</code>
|
||||
)的 <code>tools</code> / <code>selected_tools</code>,并同步
|
||||
<code>enable_tools</code>。多节点场景请以设计器为准。
|
||||
</span>
|
||||
</template>
|
||||
</el-alert>
|
||||
<div v-if="skillAgentName" class="skill-agent-name">Agent:{{ skillAgentName }}</div>
|
||||
<el-form label-width="100px" style="margin-top: 12px">
|
||||
<el-form-item label="">
|
||||
<el-checkbox-group v-model="skillBuiltinSelected" class="skill-checkbox-group">
|
||||
<el-checkbox
|
||||
v-for="opt in skillBuiltinOptions"
|
||||
:key="opt.name"
|
||||
:label="opt.name"
|
||||
border
|
||||
>
|
||||
{{ opt.label }}
|
||||
<span class="skill-name">({{ opt.name }})</span>
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="skillExtraNames.length" label="其它工具名">
|
||||
<div class="skill-extra-wrap">
|
||||
<el-tag
|
||||
v-for="ex in skillExtraNames"
|
||||
:key="ex"
|
||||
type="warning"
|
||||
closable
|
||||
class="skill-extra-tag"
|
||||
@close="removeExtraSkill(ex)"
|
||||
>
|
||||
{{ ex }}
|
||||
</el-tag>
|
||||
<span class="skill-extra-hint">(来自当前工作流,可关闭以移除)</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="skillDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="skillSaving" @click="handleSkillSave">
|
||||
保存
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</MainLayout>
|
||||
</template>
|
||||
@@ -262,10 +341,18 @@ import {
|
||||
Upload,
|
||||
Download,
|
||||
UploadFilled,
|
||||
ChatDotRound
|
||||
ChatDotRound,
|
||||
Tools
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useAgentStore } from '@/stores/agent'
|
||||
import type { Agent } from '@/stores/agent'
|
||||
import {
|
||||
BUILTIN_SKILL_OPTIONS,
|
||||
BUILTIN_SKILL_LABELS,
|
||||
extractSkillToolNames,
|
||||
patchWorkflowSkillTools,
|
||||
findPrimaryLlmNodeForTools
|
||||
} from '@/utils/agentSkills'
|
||||
|
||||
const router = useRouter()
|
||||
const agentStore = useAgentStore()
|
||||
@@ -312,6 +399,80 @@ const importFileContent = ref<any>(null)
|
||||
const importing = ref(false)
|
||||
const uploadRef = ref()
|
||||
|
||||
// 能力 / 技能配置
|
||||
const skillDialogVisible = ref(false)
|
||||
const skillLoading = ref(false)
|
||||
const skillSaving = ref(false)
|
||||
const skillAgentId = ref<string | null>(null)
|
||||
const skillAgentName = ref('')
|
||||
const skillBuiltinSelected = ref<string[]>([])
|
||||
const skillExtraNames = ref<string[]>([])
|
||||
const skillBuiltinOptions = BUILTIN_SKILL_OPTIONS
|
||||
const BUILTIN_NAME_SET = new Set(BUILTIN_SKILL_OPTIONS.map((o) => o.name))
|
||||
|
||||
function skillLabel(name: string): string {
|
||||
return BUILTIN_SKILL_LABELS[name] || name
|
||||
}
|
||||
|
||||
function skillTagsForRow(agent: Agent): string[] {
|
||||
return extractSkillToolNames(agent.workflow_config)
|
||||
}
|
||||
|
||||
function handleSkillDialogClose() {
|
||||
skillAgentId.value = null
|
||||
skillAgentName.value = ''
|
||||
skillBuiltinSelected.value = []
|
||||
skillExtraNames.value = []
|
||||
}
|
||||
|
||||
async function handleSkillConfig(agent: Agent) {
|
||||
skillAgentId.value = agent.id
|
||||
skillAgentName.value = agent.name
|
||||
skillDialogVisible.value = true
|
||||
skillLoading.value = true
|
||||
try {
|
||||
const full = await agentStore.fetchAgent(agent.id)
|
||||
const all = extractSkillToolNames(full.workflow_config)
|
||||
skillBuiltinSelected.value = all.filter((n) => BUILTIN_NAME_SET.has(n))
|
||||
skillExtraNames.value = all.filter((n) => !BUILTIN_NAME_SET.has(n))
|
||||
const primary = findPrimaryLlmNodeForTools(full.workflow_config?.nodes)
|
||||
if (!primary) {
|
||||
ElMessage.warning('当前工作流中未找到 LLM 节点,请先在「设计」中添加 LLM 节点后再配置能力。')
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.response?.data?.detail || '加载 Agent 失败')
|
||||
skillDialogVisible.value = false
|
||||
} finally {
|
||||
skillLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function removeExtraSkill(name: string) {
|
||||
skillExtraNames.value = skillExtraNames.value.filter((x) => x !== name)
|
||||
}
|
||||
|
||||
async function handleSkillSave() {
|
||||
if (!skillAgentId.value) return
|
||||
skillSaving.value = true
|
||||
try {
|
||||
const full = await agentStore.fetchAgent(skillAgentId.value)
|
||||
const merged = [
|
||||
...new Set([...skillBuiltinSelected.value, ...skillExtraNames.value].filter(Boolean))
|
||||
].sort()
|
||||
const wf = patchWorkflowSkillTools(full.workflow_config, merged)
|
||||
await agentStore.updateAgent(skillAgentId.value, {
|
||||
workflow_config: wf
|
||||
})
|
||||
ElMessage.success('能力配置已保存')
|
||||
skillDialogVisible.value = false
|
||||
await loadAgents()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.response?.data?.detail || '保存失败')
|
||||
} finally {
|
||||
skillSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
name: [
|
||||
@@ -662,4 +823,59 @@ onMounted(() => {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.skill-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.skill-tag {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.skill-empty {
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.skill-alert {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.skill-agent-name {
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.skill-checkbox-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.skill-name {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.skill-extra-wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.skill-extra-tag {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.skill-extra-hint {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -11,7 +11,7 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
port: 3001,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8037',
|
||||
|
||||
219
start_windows.cmd
Normal file
219
start_windows.cmd
Normal file
@@ -0,0 +1,219 @@
|
||||
@echo off
|
||||
echo ==============================================
|
||||
echo 低代码智能体平台 - Windows 启动脚本
|
||||
echo ==============================================
|
||||
echo.
|
||||
|
||||
REM 检查Python是否安装
|
||||
python --version >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo ❌ Python 未安装或未添加到系统PATH
|
||||
echo 请安装 Python 3.11+ 并确保在PATH中
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM 检查Node.js是否安装
|
||||
node --version >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo ❌ Node.js 未安装或未添加到系统PATH
|
||||
echo 请安装 Node.js 18+ 并确保在PATH中
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM 检查pnpm是否安装
|
||||
pnpm --version >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo ⚠️ pnpm 未安装,正在安装...
|
||||
npm install -g pnpm
|
||||
if errorlevel 1 (
|
||||
echo ❌ pnpm 安装失败
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
|
||||
echo ✅ 环境检查通过
|
||||
echo.
|
||||
|
||||
REM 进入项目目录
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo ==============================================
|
||||
echo 1. Redis 检查
|
||||
echo ==============================================
|
||||
echo.
|
||||
|
||||
REM 检查Redis服务是否运行
|
||||
sc query Redis >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo ❌ Redis 服务未运行
|
||||
echo.
|
||||
echo 请按以下步骤安装Redis:
|
||||
echo 1. 下载 Redis Windows 版本:https://github.com/microsoftarchive/redis/releases
|
||||
echo 2. 下载 Redis-x64-3.2.100.msi
|
||||
echo 3. 运行安装程序,按照默认设置安装
|
||||
echo 4. Redis 将作为 Windows 服务运行在 6379 端口
|
||||
echo.
|
||||
echo 安装完成后,请重新运行此脚本
|
||||
pause
|
||||
exit /b 1
|
||||
) else (
|
||||
echo ✅ Redis 服务正在运行
|
||||
)
|
||||
|
||||
echo ==============================================
|
||||
echo 2. 启动后端服务
|
||||
echo ==============================================
|
||||
echo.
|
||||
|
||||
REM 进入backend目录
|
||||
cd backend
|
||||
|
||||
REM 检查虚拟环境
|
||||
if not exist "venv\Scripts\activate" (
|
||||
echo ⚠️ 虚拟环境不存在,正在创建...
|
||||
python -m venv venv
|
||||
if errorlevel 1 (
|
||||
echo ❌ 虚拟环境创建失败
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
|
||||
echo ✅ 虚拟环境检查通过
|
||||
|
||||
REM 激活虚拟环境并安装依赖
|
||||
call venv\Scripts\activate
|
||||
|
||||
echo 📦 检查Python依赖...
|
||||
pip list | findstr "fastapi" >nul
|
||||
if errorlevel 1 (
|
||||
echo ⚠️ 正在安装Python依赖...
|
||||
pip install -r requirements.txt
|
||||
if errorlevel 1 (
|
||||
echo ❌ Python依赖安装失败
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo ✅ Python依赖安装完成
|
||||
) else (
|
||||
echo ✅ Python依赖已安装
|
||||
)
|
||||
|
||||
echo.
|
||||
|
||||
echo 🔧 配置环境变量...
|
||||
if not exist ".env" (
|
||||
copy env.example .env >nul
|
||||
echo ⚠️ 已创建 .env 文件,请检查配置
|
||||
)
|
||||
|
||||
echo.
|
||||
|
||||
echo 🗄️ 运行数据库迁移...
|
||||
alembic upgrade head
|
||||
if errorlevel 1 (
|
||||
echo ⚠️ 数据库迁移失败,继续启动...
|
||||
)
|
||||
|
||||
echo.
|
||||
|
||||
echo 🌐 启动后端服务...
|
||||
echo 后端服务将在 http://localhost:8037 启动
|
||||
echo API文档:http://localhost:8037/docs
|
||||
echo.
|
||||
|
||||
start cmd /k "uvicorn app.main:app --host 0.0.0.0 --port 8037 --reload"
|
||||
|
||||
echo ⏳ 等待后端服务启动...
|
||||
timeout /t 3 /nobreak >nul
|
||||
|
||||
echo.
|
||||
|
||||
echo ==============================================
|
||||
echo 3. 启动 Celery Worker
|
||||
echo ==============================================
|
||||
echo.
|
||||
|
||||
echo 🔄 启动 Celery Worker...
|
||||
start cmd /k "celery -A app.core.celery_app worker --loglevel=info"
|
||||
|
||||
echo ⏳ 等待 Celery Worker 启动...
|
||||
timeout /t 2 /nobreak >nul
|
||||
|
||||
echo.
|
||||
|
||||
echo ==============================================
|
||||
echo 4. 启动前端服务
|
||||
echo ==============================================
|
||||
echo.
|
||||
|
||||
REM 返回项目根目录
|
||||
cd ..
|
||||
|
||||
REM 进入frontend目录
|
||||
cd frontend
|
||||
|
||||
echo 📦 检查前端依赖...
|
||||
if not exist "node_modules" (
|
||||
echo ⚠️ 正在安装前端依赖...
|
||||
pnpm install
|
||||
if errorlevel 1 (
|
||||
echo ❌ 前端依赖安装失败
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo ✅ 前端依赖安装完成
|
||||
) else (
|
||||
echo ✅ 前端依赖已安装
|
||||
)
|
||||
|
||||
echo.
|
||||
|
||||
echo 🖥️ 启动前端服务...
|
||||
echo 前端服务将在 http://localhost:3000 启动
|
||||
echo.
|
||||
|
||||
start cmd /k "pnpm dev"
|
||||
|
||||
echo ⏳ 等待前端服务启动...
|
||||
timeout /t 5 /nobreak >nul
|
||||
|
||||
echo.
|
||||
|
||||
echo ==============================================
|
||||
echo 🎉 启动完成!
|
||||
echo ==============================================
|
||||
echo.
|
||||
echo 服务访问地址:
|
||||
echo 📍 前端界面: http://localhost:3000
|
||||
echo 📍 后端API: http://localhost:8037
|
||||
echo 📍 API文档: http://localhost:8037/docs
|
||||
echo.
|
||||
echo 服务状态:
|
||||
echo ✅ Redis 服务: 运行中
|
||||
echo ✅ 后端服务: 已启动
|
||||
echo ✅ Celery Worker: 已启动
|
||||
echo ✅ 前端服务: 已启动
|
||||
echo.
|
||||
echo 📋 重要提示:
|
||||
echo 1. 首次访问需要注册新用户
|
||||
echo 2. 保持所有命令行窗口打开
|
||||
echo 3. 停止服务:关闭所有命令行窗口
|
||||
echo.
|
||||
echo ==============================================
|
||||
echo.
|
||||
|
||||
REM 返回项目根目录
|
||||
cd ..
|
||||
|
||||
echo 按任意键打开浏览器访问前端界面...
|
||||
pause >nul
|
||||
start http://localhost:3000
|
||||
|
||||
echo.
|
||||
echo 脚本执行完成!
|
||||
echo 按任意键退出...
|
||||
pause >nul
|
||||
239
start_windows.ps1
Normal file
239
start_windows.ps1
Normal file
@@ -0,0 +1,239 @@
|
||||
# 低代码智能体平台 - Windows PowerShell 启动脚本
|
||||
|
||||
Write-Host "==============================================" -ForegroundColor Cyan
|
||||
Write-Host "低代码智能体平台 - Windows 启动脚本" -ForegroundColor Cyan
|
||||
Write-Host "==============================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# 检查Python是否安装
|
||||
try {
|
||||
$pythonVersion = python --version 2>&1
|
||||
Write-Host "✅ Python 版本: $pythonVersion" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host "❌ Python 未安装或未添加到系统PATH" -ForegroundColor Red
|
||||
Write-Host "请安装 Python 3.11+ 并确保在PATH中"
|
||||
pause
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 检查Node.js是否安装
|
||||
try {
|
||||
$nodeVersion = node --version
|
||||
Write-Host "✅ Node.js 版本: $nodeVersion" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host "❌ Node.js 未安装或未添加到系统PATH" -ForegroundColor Red
|
||||
Write-Host "请安装 Node.js 18+ 并确保在PATH中"
|
||||
pause
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 检查pnpm是否安装
|
||||
try {
|
||||
$pnpmVersion = pnpm --version
|
||||
Write-Host "✅ pnpm 版本: $pnpmVersion" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host "⚠️ pnpm 未安装,正在安装..." -ForegroundColor Yellow
|
||||
npm install -g pnpm
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "❌ pnpm 安装失败" -ForegroundColor Red
|
||||
pause
|
||||
exit 1
|
||||
}
|
||||
Write-Host "✅ pnpm 安装成功" -ForegroundColor Green
|
||||
}
|
||||
|
||||
Write-Host "✅ 环境检查通过" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
# 设置项目目录
|
||||
$projectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
Set-Location $projectRoot
|
||||
|
||||
Write-Host "==============================================" -ForegroundColor Cyan
|
||||
Write-Host "1. Redis 检查" -ForegroundColor Cyan
|
||||
Write-Host "==============================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# 检查Redis服务是否运行
|
||||
$redisService = Get-Service -Name "Redis" -ErrorAction SilentlyContinue
|
||||
if ($null -eq $redisService -or $redisService.Status -ne "Running") {
|
||||
Write-Host "❌ Redis 服务未运行" -ForegroundColor Red
|
||||
Write-Host ""
|
||||
Write-Host "请按以下步骤安装Redis:" -ForegroundColor Yellow
|
||||
Write-Host "1. 下载 Redis Windows 版本:https://github.com/microsoftarchive/redis/releases"
|
||||
Write-Host "2. 下载 Redis-x64-3.2.100.msi"
|
||||
Write-Host "3. 运行安装程序,按照默认设置安装"
|
||||
Write-Host "4. Redis 将作为 Windows 服务运行在 6379 端口"
|
||||
Write-Host ""
|
||||
Write-Host "安装完成后,请重新运行此脚本" -ForegroundColor Yellow
|
||||
pause
|
||||
exit 1
|
||||
} else {
|
||||
Write-Host "✅ Redis 服务正在运行 (状态: $($redisService.Status))" -ForegroundColor Green
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "==============================================" -ForegroundColor Cyan
|
||||
Write-Host "2. 启动后端服务" -ForegroundColor Cyan
|
||||
Write-Host "==============================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# 进入backend目录
|
||||
Set-Location "$projectRoot\backend"
|
||||
|
||||
# 检查虚拟环境
|
||||
if (-not (Test-Path "venv\Scripts\activate")) {
|
||||
Write-Host "⚠️ 虚拟环境不存在,正在创建..." -ForegroundColor Yellow
|
||||
python -m venv venv
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "❌ 虚拟环境创建失败" -ForegroundColor Red
|
||||
pause
|
||||
exit 1
|
||||
}
|
||||
Write-Host "✅ 虚拟环境创建成功" -ForegroundColor Green
|
||||
}
|
||||
|
||||
Write-Host "✅ 虚拟环境检查通过" -ForegroundColor Green
|
||||
|
||||
# 激活虚拟环境
|
||||
$activateScript = "$projectRoot\backend\venv\Scripts\Activate.ps1"
|
||||
if (Test-Path $activateScript) {
|
||||
& $activateScript
|
||||
} else {
|
||||
Write-Host "❌ 虚拟环境激活脚本不存在: $activateScript" -ForegroundColor Red
|
||||
pause
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "📦 检查Python依赖..." -ForegroundColor Cyan
|
||||
$fastapiInstalled = pip list | Select-String "fastapi"
|
||||
if (-not $fastapiInstalled) {
|
||||
Write-Host "⚠️ 正在安装Python依赖..." -ForegroundColor Yellow
|
||||
pip install -r requirements.txt
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "❌ Python依赖安装失败" -ForegroundColor Red
|
||||
pause
|
||||
exit 1
|
||||
}
|
||||
Write-Host "✅ Python依赖安装完成" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "✅ Python依赖已安装" -ForegroundColor Green
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
|
||||
Write-Host "🔧 配置环境变量..." -ForegroundColor Cyan
|
||||
if (-not (Test-Path ".env")) {
|
||||
Copy-Item env.example .env -ErrorAction SilentlyContinue
|
||||
Write-Host "⚠️ 已创建 .env 文件,请检查配置" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
|
||||
Write-Host "🗄️ 运行数据库迁移..." -ForegroundColor Cyan
|
||||
alembic upgrade head
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "⚠️ 数据库迁移失败,继续启动..." -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
|
||||
Write-Host "🌐 启动后端服务..." -ForegroundColor Cyan
|
||||
Write-Host "后端服务将在 http://localhost:8037 启动" -ForegroundColor Green
|
||||
Write-Host "API文档:http://localhost:8037/docs" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
# 启动后端服务(新窗口)
|
||||
Start-Process powershell -ArgumentList "-NoExit", "-Command", "cd '$projectRoot\backend'; .\venv\Scripts\Activate.ps1; uvicorn app.main:app --host 0.0.0.0 --port 8037 --reload"
|
||||
|
||||
Write-Host "⏳ 等待后端服务启动..." -ForegroundColor Cyan
|
||||
Start-Sleep -Seconds 3
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "==============================================" -ForegroundColor Cyan
|
||||
Write-Host "3. 启动 Celery Worker" -ForegroundColor Cyan
|
||||
Write-Host "==============================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
Write-Host "🔄 启动 Celery Worker..." -ForegroundColor Cyan
|
||||
# Windows 下 prefork 池易卡住任务(Redis 出现大量 unacked),使用线程池更稳定
|
||||
Start-Process powershell -ArgumentList "-NoExit", "-Command", "cd '$projectRoot\backend'; .\venv\Scripts\Activate.ps1; celery -A app.core.celery_app worker --loglevel=info --pool=threads --concurrency=8"
|
||||
|
||||
Write-Host "⏳ 等待 Celery Worker 启动..." -ForegroundColor Cyan
|
||||
Start-Sleep -Seconds 2
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "==============================================" -ForegroundColor Cyan
|
||||
Write-Host "4. 启动前端服务" -ForegroundColor Cyan
|
||||
Write-Host "==============================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# 返回项目根目录
|
||||
Set-Location $projectRoot
|
||||
|
||||
# 进入frontend目录
|
||||
Set-Location "$projectRoot\frontend"
|
||||
|
||||
Write-Host "📦 检查前端依赖..." -ForegroundColor Cyan
|
||||
if (-not (Test-Path "node_modules")) {
|
||||
Write-Host "⚠️ 正在安装前端依赖..." -ForegroundColor Yellow
|
||||
pnpm install
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "❌ 前端依赖安装失败" -ForegroundColor Red
|
||||
pause
|
||||
exit 1
|
||||
}
|
||||
Write-Host "✅ 前端依赖安装完成" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "✅ 前端依赖已安装" -ForegroundColor Green
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
|
||||
Write-Host "🖥️ 启动前端服务..." -ForegroundColor Cyan
|
||||
Write-Host "前端服务将在 http://localhost:3000 启动" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
# 启动前端服务(新窗口)
|
||||
Start-Process powershell -ArgumentList "-NoExit", "-Command", "cd '$projectRoot\frontend'; pnpm dev"
|
||||
|
||||
Write-Host "⏳ 等待前端服务启动..." -ForegroundColor Cyan
|
||||
Start-Sleep -Seconds 5
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "==============================================" -ForegroundColor Cyan
|
||||
Write-Host "🎉 启动完成!" -ForegroundColor Green
|
||||
Write-Host "==============================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host "服务访问地址:" -ForegroundColor White
|
||||
Write-Host " 📍 前端界面: http://localhost:3000" -ForegroundColor Yellow
|
||||
Write-Host " 📍 后端API: http://localhost:8037" -ForegroundColor Yellow
|
||||
Write-Host " 📍 API文档: http://localhost:8037/docs" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
Write-Host "服务状态:" -ForegroundColor White
|
||||
Write-Host " ✅ Redis 服务: 运行中" -ForegroundColor Green
|
||||
Write-Host " ✅ 后端服务: 已启动" -ForegroundColor Green
|
||||
Write-Host " ✅ Celery Worker: 已启动" -ForegroundColor Green
|
||||
Write-Host " ✅ 前端服务: 已启动" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "📋 重要提示:" -ForegroundColor White
|
||||
Write-Host " 1. 首次访问需要注册新用户" -ForegroundColor Gray
|
||||
Write-Host " 2. 保持所有PowerShell窗口打开" -ForegroundColor Gray
|
||||
Write-Host " 3. 停止服务:关闭所有PowerShell窗口" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
Write-Host "==============================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# 返回项目根目录
|
||||
Set-Location $projectRoot
|
||||
|
||||
Write-Host "是否要打开浏览器访问前端界面?(Y/N)" -ForegroundColor Cyan
|
||||
$response = Read-Host
|
||||
if ($response -eq "Y" -or $response -eq "y") {
|
||||
Start-Process "http://localhost:3000"
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "脚本执行完成!" -ForegroundColor Green
|
||||
Write-Host "按任意键退出..." -ForegroundColor Gray
|
||||
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
|
||||
134
test_zhini_kefu_6.py
Normal file
134
test_zhini_kefu_6.py
Normal file
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
测试「知你客服6号」Agent:登录 -> 创建执行 -> 轮询直到结束。
|
||||
|
||||
用法:
|
||||
python test_zhini_kefu_6.py
|
||||
python test_zhini_kefu_6.py --base-url http://127.0.0.1:8037
|
||||
set PLATFORM_BASE_URL=... && python test_zhini_kefu_6.py
|
||||
|
||||
依赖: requests(与项目其他测试脚本一致)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
# 知你客服6号(本地平台 Agent 管理中的名称对应 ID,若你环境不同请改此处或传 --agent-id)
|
||||
DEFAULT_AGENT_ID = "2acc84d5-814b-4d61-9703-94a4b117375f"
|
||||
DEFAULT_MESSAGE = "你好"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if sys.platform == "win32" and hasattr(sys.stdout, "reconfigure"):
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
sys.stderr.reconfigure(encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
parser = argparse.ArgumentParser(description="测试知你客服6号,发送一条用户消息并打印结果")
|
||||
parser.add_argument(
|
||||
"--base-url",
|
||||
default=os.getenv("PLATFORM_BASE_URL", "http://127.0.0.1:8037"),
|
||||
help="平台 API 根地址(默认 http://127.0.0.1:8037)",
|
||||
)
|
||||
parser.add_argument("--username", default=os.getenv("PLATFORM_USERNAME", "admin"))
|
||||
parser.add_argument("--password", default=os.getenv("PLATFORM_PASSWORD", "123456"))
|
||||
parser.add_argument("--agent-id", default=os.getenv("ZHINI_6_AGENT_ID", DEFAULT_AGENT_ID))
|
||||
parser.add_argument("--message", "-m", default=DEFAULT_MESSAGE, help="用户消息,默认:你好")
|
||||
parser.add_argument(
|
||||
"--user-id",
|
||||
default="script_test_zhini6",
|
||||
help="多轮记忆隔离用 user_id(可选)",
|
||||
)
|
||||
parser.add_argument("--timeout", type=int, default=180, help="轮询最长秒数")
|
||||
parser.add_argument("--poll", type=float, default=0.8, help="轮询间隔秒")
|
||||
args = parser.parse_args()
|
||||
|
||||
base = args.base_url.rstrip("/")
|
||||
|
||||
print("=" * 60)
|
||||
print("知你客服6号 执行测试")
|
||||
print(" base_url :", base)
|
||||
print(" agent_id :", args.agent_id)
|
||||
print(" message :", args.message)
|
||||
print("=" * 60)
|
||||
|
||||
# 1. 登录
|
||||
r = requests.post(
|
||||
f"{base}/api/v1/auth/login",
|
||||
data={"username": args.username, "password": args.password},
|
||||
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
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
msg = args.message
|
||||
body = {
|
||||
"agent_id": args.agent_id,
|
||||
"input_data": {
|
||||
"query": msg,
|
||||
"USER_INPUT": msg,
|
||||
"user_id": args.user_id,
|
||||
},
|
||||
}
|
||||
|
||||
# 2. 创建执行
|
||||
r2 = requests.post(f"{base}/api/v1/executions", headers=headers, json=body, timeout=30)
|
||||
if r2.status_code != 201:
|
||||
print("创建执行失败:", r2.status_code, r2.text[:1000], file=sys.stderr)
|
||||
return 1
|
||||
|
||||
ex = r2.json()
|
||||
eid = ex["id"]
|
||||
print("已创建执行:", eid)
|
||||
print("初始状态:", ex.get("status"))
|
||||
|
||||
# 3. 轮询
|
||||
deadline = time.time() + args.timeout
|
||||
status = ex.get("status", "pending")
|
||||
while status in ("pending", "running") and time.time() < deadline:
|
||||
time.sleep(args.poll)
|
||||
rs = requests.get(f"{base}/api/v1/executions/{eid}", headers=headers, timeout=60)
|
||||
if rs.status_code != 200:
|
||||
print("查询执行失败:", rs.status_code, rs.text[:500], file=sys.stderr)
|
||||
return 1
|
||||
detail = rs.json()
|
||||
status = detail.get("status", status)
|
||||
print(" ...", status)
|
||||
|
||||
final = requests.get(f"{base}/api/v1/executions/{eid}", headers=headers, timeout=60).json()
|
||||
status = final.get("status")
|
||||
print()
|
||||
print("最终状态:", status)
|
||||
if final.get("error_message"):
|
||||
print("错误信息:", final["error_message"])
|
||||
od = final.get("output_data")
|
||||
if od is not None:
|
||||
print("output_data:")
|
||||
print(json.dumps(od, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
print("output_data: null")
|
||||
|
||||
return 0 if status == "completed" else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
0
user_data/.gitkeep
Normal file
0
user_data/.gitkeep
Normal file
3
user_data/aaa.md
Normal file
3
user_data/aaa.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# aaa
|
||||
|
||||
written by test_write_user_data_aaa_md.py at 2026-04-07T03:30:57.611757+00:00
|
||||
1
user_data/abb.md
Normal file
1
user_data/abb.md
Normal file
@@ -0,0 +1 @@
|
||||
# abb
|
||||
1
user_data/abc.md
Normal file
1
user_data/abc.md
Normal file
@@ -0,0 +1 @@
|
||||
# abc
|
||||
1
user_data/bbb.md
Normal file
1
user_data/bbb.md
Normal file
@@ -0,0 +1 @@
|
||||
# bbb
|
||||
1
user_data/bbbb.md
Normal file
1
user_data/bbbb.md
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
user_data/ccc.md
Normal file
1
user_data/ccc.md
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
user_data/xxx.md
Normal file
1
user_data/xxx.md
Normal file
@@ -0,0 +1 @@
|
||||
# test from script
|
||||
1
user_profile.json
Normal file
1
user_profile.json
Normal file
@@ -0,0 +1 @@
|
||||
{"name": "小七"}</|DSML|parameter>
|
||||
2
上传git仓.md
Normal file
2
上传git仓.md
Normal file
@@ -0,0 +1,2 @@
|
||||
将修改上传到git仓rjb_win_dev分支
|
||||
http://101.43.95.130:3001/admin/aiagent.git
|
||||
30
望庐山瀑布.md
Normal file
30
望庐山瀑布.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# 望庐山瀑布
|
||||
**作者:李白(唐)**
|
||||
日照香炉生紫烟,
|
||||
遥看瀑布挂前川。
|
||||
飞流直下三千尺,
|
||||
疑是银河落九天。
|
||||
---
|
||||
## 诗歌赏析
|
||||
### 创作背景
|
||||
《望庐山瀑布》是唐代伟大诗人李白创作的一首七言绝句,描绘了庐山瀑布的壮丽景色,展现了诗人豪放不羁的个性和对大自然的热爱。
|
||||
### 诗句解析
|
||||
1. **"日照香炉生紫烟"**:阳光照耀下的香炉峰升起紫色的烟雾,为瀑布营造了神秘的氛围。
|
||||
2. **"遥看瀑布挂前川"**:远远望去,瀑布像一条白练悬挂在山川之间。
|
||||
3. **"飞流直下三千尺"**:瀑布从高处飞泻而下,气势磅礴,夸张手法突显其雄伟。
|
||||
4. **"疑是银河落九天"**:诗人展开想象,怀疑这是天上的银河落到了人间,极富浪漫主义色彩。
|
||||
### 艺术特色
|
||||
- **夸张手法**:"三千尺"的夸张描写增强了瀑布的雄伟气势
|
||||
- **比喻精妙**:将瀑布比作银河,形象生动
|
||||
- **意境开阔**:从近景到远景,从现实到想象,意境层层递进
|
||||
- **语言简练**:短短四句,却描绘出完整的画面
|
||||
### 历史评价
|
||||
这首诗被誉为描写瀑布的千古绝唱,充分展现了李白诗歌的浪漫主义风格和超凡的想象力,成为中国古代山水诗的典范之作。
|
||||
---
|
||||
## 扩展阅读
|
||||
### 庐山简介
|
||||
庐山位于江西省九江市,是中国著名的风景名胜区和避暑胜地,以雄、奇、险、秀闻名于世,素有"匡庐奇秀甲天下"之美誉。
|
||||
### 李白与庐山
|
||||
李白曾多次游览庐山,留下了多首描写庐山的诗篇。《望庐山瀑布》是其最著名的庐山诗作之一,充分展现了他对自然山水的热爱和独特的艺术视角。
|
||||
### 相关诗作
|
||||
1. **《庐山谣寄卢侍御
|
||||
68
知你客服14号能力文档.md
Normal file
68
知你客服14号能力文档.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# 知你客服 14 号 · 能力说明
|
||||
|
||||
## 1. 定位
|
||||
|
||||
**知你客服 14 号**在 **知你客服 13 号** 工作流与提示词基础上演进:保留 13 号的连线整理、工具纪律与单行 JSON 输出约定,并将 LLM 可用工具扩展为平台当前注册的**全量内置工具**(与 `app.core.tools_bootstrap` 一致),用于需要 **HTTP、本地文件、系统信息、文本/时间/数学/JSON、只读数据库、Android 日志** 等能力的客服场景。
|
||||
|
||||
---
|
||||
|
||||
## 2. 版本关系
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 上游模板 | 默认由脚本自 **知你客服 13 号** 复制(可通过环境变量指定其他源 Agent) |
|
||||
| 画布 | 与 13 号创建脚本一致:去自环、合并重复边、统一左右锚点、分层布局 |
|
||||
| 记忆与会话 | 与 13/12 相同,依赖工作流 **Cache(`user_memory_*`)**、Redis 与可选 MySQL 持久化;详见 `agent记忆实现方案.md` |
|
||||
|
||||
---
|
||||
|
||||
## 3. 内置工具一览(10 项)
|
||||
|
||||
以下工具在 **「llm-unified」** 节点中 **`enable_tools`**,名称需与后端注册一致。
|
||||
|
||||
| 工具名 | 能力摘要 |
|
||||
|--------|----------|
|
||||
| **http_request** | HTTP(S) 请求:抓取网页、调用公开 API;按返回 JSON 的 `body` 等提炼信息。 |
|
||||
| **file_read** | 读取工作区内的本地文件(相对路径或落在允许根目录下的绝对路径)。 |
|
||||
| **file_write** | 写入/追加工作区内文件;受大小与路径校验约束。 |
|
||||
| **system_info** | 系统与运行环境信息;含 **本地文件工作区根路径** `local_file_workspace_root`(回答「工作区在哪」等)。 |
|
||||
| **text_analyze** | 文本分析:`text` + `operation`:`count`(字数/行数等)、`keywords`(简单词频)、`summary`(截取前几段作摘要)。 |
|
||||
| **datetime** | 日期时间:常用 `operation=now`,可选 `format`(strftime)。 |
|
||||
| **math_calculate** | 安全算术表达式计算(如四则运算、`sqrt` 等),结果以工具返回为准,禁止臆造。 |
|
||||
| **json_process** | JSON:`operation` 为 `parse` / `stringify` / `validate`。 |
|
||||
| **database_query** | **只读 SQL**:**仅允许 SELECT**;默认连平台库,可选 `data_source_id` 指定数据源;注意 `timeout`。 |
|
||||
| **adb_log** | Android 设备日志:需运行环境已安装 **adb** 且设备可用;按工具参数拉取/过滤日志。 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 输出与纪律(继承 13 号并加强)
|
||||
|
||||
- **最终回复**:仍以 **一行合法 JSON** 结尾(含 `intent`、`reply`、`user_profile` 等),无 Markdown 代码围栏。
|
||||
- **用户画像**:末行 JSON 中的 **`user_profile` 须如实填写**;用户已告知昵称时需含 **`name`**,避免空对象覆盖会话记忆(与 13 号约定一致)。
|
||||
- **file_write**:同一轮用户请求中避免无故多次写入;勿以本地文件写入替代 JSON 中的会话画像。
|
||||
- **DSML / invoke**:勿在正文中重复刷屏工具标签行;说明工具结果用自然语言,再以单行 JSON 收尾。
|
||||
- **database_query**:禁止非 SELECT;不得编造查询结果。
|
||||
- **adb_log**:仅在用户明确需要设备日志时使用;无环境或失败时如实转述工具错误信息。
|
||||
|
||||
---
|
||||
|
||||
## 5. 使用与维护
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 创建/更新脚本 | `backend/scripts/create_zhini_kefu_14.py` |
|
||||
| 默认源/目标 Agent | 源:`知你客服13号`;目标:`知你客服14号`(可用环境变量覆盖) |
|
||||
| 执行 API | 需携带稳定 **`user_id`**(若工作流 Cache 键含 `{{user_id}}`),以免记忆串用户 |
|
||||
| 进程 | 工作流在 **Celery** 中执行;更新工具或引擎后需 **重启 API + Celery** |
|
||||
|
||||
---
|
||||
|
||||
## 6. 能力与边界
|
||||
|
||||
- **database_query** 访问的是平台配置的数据库或已登记数据源,表结构与安全策略以部署环境为准。
|
||||
- **adb_log** 强依赖宿主机是否安装 adb、USB/网络调试是否连通,生产环境需单独评估权限与审计。
|
||||
- **text_analyze** 的摘要/关键词为轻量规则实现,复杂语义仍以 LLM 为主。
|
||||
|
||||
---
|
||||
|
||||
*文档内容随脚本与平台实现变更;以仓库内 `create_zhini_kefu_14.py` 及实际 Agent 配置为准。*
|
||||
156
知你客服能力的集成和扩展方案.md
Normal file
156
知你客服能力的集成和扩展方案.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# 知你客服能力的集成和扩展方案
|
||||
|
||||
本文面向**业务/App 对接**与**平台内二次开发**,说明如何将「知你客服」系列 Agent 接入自有系统,以及如何安全、可维护地扩展工具与版本。具体某版本能力见 `知你客服14号能力文档.md`,记忆机制见 `agent记忆实现方案.md`,App 侧 HTTP 细节可参考 `知你客服Agent智能聊天App接入方案.md`。
|
||||
|
||||
---
|
||||
|
||||
## 一、集成目标与边界
|
||||
|
||||
| 目标 | 说明 |
|
||||
|------|------|
|
||||
| **对话能力** | 通过平台 **执行 API** 创建异步任务,由 **Celery** 跑完工作流后返回 `output_data`。 |
|
||||
| **多轮记忆** | 依赖工作流 **Cache** 键(常见 `user_memory_{user_id}`);集成侧必须传**稳定 `user_id`**,否则记忆串号或落在 `default`。 |
|
||||
| **能力边界** | 具体能调用的工具、提示词约束以**当前 Agent 工作流**为准(如 14 号含全量内置工具);文件、DB、adb 等受**部署环境与配置**限制。 |
|
||||
|
||||
---
|
||||
|
||||
## 二、集成方式选型
|
||||
|
||||
| 方式 | 适用 | 要点 |
|
||||
|------|------|------|
|
||||
| **后端代理(推荐)** | 移动 App、多端、需隐藏平台账号 | 服务端用平台账号换 `access_token`,代用户调 `POST /api/v1/executions`,再轮询状态/详情。 |
|
||||
| **前端直连** | Web、用户已在平台登录 | 浏览器携带 Bearer Token 调同一套 API;需处理 CORS 与 Token 刷新。 |
|
||||
| **内嵌平台对话页** | 快速验证 | iframe/WebView 打开平台「使用」页,不直接对接执行 API。 |
|
||||
|
||||
仓库内 **SAARS** 等示例提供「平台登录 + 转发执行」的代理思路,可与上述方式一对照实现。
|
||||
|
||||
---
|
||||
|
||||
## 三、执行 API 集成要点
|
||||
|
||||
### 3.1 认证
|
||||
|
||||
- `POST /api/v1/auth/login`(`application/x-www-form-urlencoded`)获取 `access_token`。
|
||||
- 后续请求头:`Authorization: Bearer <access_token>`。
|
||||
|
||||
### 3.2 创建执行
|
||||
|
||||
- `POST /api/v1/executions`
|
||||
- 典型 body:
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_id": "<知你客服某版本的 Agent UUID>",
|
||||
"input_data": {
|
||||
"query": "用户本轮输入",
|
||||
"USER_INPUT": "用户本轮输入",
|
||||
"user_id": "业务侧稳定用户标识"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **`user_id`**:强烈建议传入且长期不变,与 Cache 键一致方可多轮隔离。
|
||||
- 字段名可与工作流 Start 节点约定对齐(常见 `query` / `USER_INPUT`)。
|
||||
|
||||
### 3.3 获取结果
|
||||
|
||||
1. `GET /api/v1/executions/{id}/status` 直至 `completed` / `failed`。
|
||||
2. `GET /api/v1/executions/{id}` 读取 **`output_data`**。
|
||||
|
||||
### 3.4 展示层处理(避免重复 JSON)
|
||||
|
||||
知你类工作流常约定:**自然语言 + 最后一行单行 JSON**(`intent` / `reply` / `user_profile`)。
|
||||
前端若直接展示整段字符串,用户会看到「正文 + JSON」重复感。可参考平台 **`AgentChatPreview`** 中的做法:展示前去掉末行结构化 JSON,或仅展示 JSON 中的 `reply`(以产品为准)。
|
||||
|
||||
### 3.5 运维
|
||||
|
||||
- 工作流在 **Celery Worker** 中执行;升级**引擎或工具**后需 **重启 API + Celery**(可用 `backend/scripts/restart_api_worker.ps1`)。
|
||||
- 轮询间隔建议约 **0.5~1 s**,并设总超时。
|
||||
|
||||
---
|
||||
|
||||
## 四、记忆与持久化(集成侧责任)
|
||||
|
||||
- **键隔离**:必须传 **`user_id`**;详见 `agent记忆实现方案.md`。
|
||||
- **Redis + 可选 MySQL**:热数据与持久化关系、合并规则、条数截断(如默认 `max_history_length`)见该文档。
|
||||
- **业务无关数据**:勿把敏感密钥写入可被 `file_write` 触及的公开路径;生产环境应配置 **`LOCAL_FILE_TOOLS_ROOT`** 等。
|
||||
|
||||
---
|
||||
|
||||
## 五、扩展路径总览
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 扩展维度 │ 主要改动位置 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 新内置工具 │ builtin_tools 实现 + tools_bootstrap 注册 │
|
||||
│ 新版本 Agent │ scripts/create_zhini_kefu_XX.py 复制/补丁 │
|
||||
│ 工作流结构 │ 平台工作流编辑器 + 导出/入库 API │
|
||||
│ 提示词与工具列表 │ llm-unified 节点 data.prompt / tools │
|
||||
│ HTTP/外部工具 │ 平台「工具管理」中配置 + tool_registry │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、扩展内置工具(后端)
|
||||
|
||||
1. **实现**:在 `backend/app/services/builtin_tools.py` 中实现异步函数,并定义 **JSON Schema**(与 OpenAI function calling 兼容)。
|
||||
2. **注册**:在 `backend/app/core/tools_bootstrap.py` 的 `ensure_builtin_tools_registered` 中 **`register_builtin_tool(name, func, schema)`**。
|
||||
3. **生效**:确保 **API 进程与 Celery Worker** 均会 import 执行流(已含 bootstrap);部署后**重启**两处进程。
|
||||
4. **工作流**:在目标 Agent 的 **LLM 节点** `data.tools` / `selected_tools` 中加入新工具名,`enable_tools: true`。
|
||||
|
||||
**注意**:工具名、参数 schema 与模型实际调用必须一致;敏感能力(如 shell)不建议放入内置工具。
|
||||
|
||||
---
|
||||
|
||||
## 七、扩展「知你客服」版本(脚本化)
|
||||
|
||||
- 仓库已提供 **`create_zhini_kefu_12.py` … `create_zhini_kefu_14.py`** 等脚本:登录平台 → 复制 Agent → 调整边与布局 → 更新 `llm-unified` 提示词与工具列表 → `PUT` 写回。
|
||||
- **推荐做法**:以**上一稳定版本**为源复制,只改差异(工具数组、追加提示段落、描述),便于回溯。
|
||||
- **文档**:为新版本补充 **`知你客服XX号能力文档.md`**,避免运营与开发认知不一致。
|
||||
|
||||
---
|
||||
|
||||
## 八、扩展工作流结构(低代码)
|
||||
|
||||
- 在平台 **工作流设计器** 中增删节点:Cache、LLM、条件分支、向量检索、HTTP 等。
|
||||
- **记忆链路**勿随意断开:保证 **读 Cache → LLM → 写 Cache** 顺序仍可达,且 `user_memory_*` 键模板未破坏。
|
||||
- **自动布局**仅影响坐标,不改变执行语义;复杂图建议配合引擎的占位符与合并逻辑测试多轮对话。
|
||||
|
||||
---
|
||||
|
||||
## 九、安全与合规
|
||||
|
||||
| 项 | 建议 |
|
||||
|----|------|
|
||||
| **database_query** | 引擎侧限制 **SELECT**;生产应限制可见表/行或仅用只读账号;勿在提示词中鼓励随意扫库。 |
|
||||
| **adb_log** | 仅在内网或受控环境开启;需本机 adb 与设备授权。 |
|
||||
| **file_read / file_write** | 严格 **`LOCAL_FILE_TOOLS_ROOT`**;控制写入大小上限。 |
|
||||
| **http_request** | 注意 SSRF 与出站策略(若平台侧有网关/白名单应一并配置)。 |
|
||||
| **Token** | App 代理勿把平台账号密码下发客户端;`access_token` 缓存于服务端并处理过期。 |
|
||||
|
||||
---
|
||||
|
||||
## 十、测试建议
|
||||
|
||||
- **单轮**:工具是否被调用、返回是否进入 `reply` 或末行 JSON。
|
||||
- **多轮**:同一 `user_id` 连续两轮,验证画像与历史是否写入 Cache/DB。
|
||||
- **回归**:升级引擎或工具后跑一遍「问名—再问名」与一次需要工具调用的用例。
|
||||
|
||||
---
|
||||
|
||||
## 十一、相关文档与代码索引
|
||||
|
||||
| 说明 | 路径 |
|
||||
|------|------|
|
||||
| 14 号能力与工具表 | `知你客服14号能力文档.md` |
|
||||
| 记忆与 Redis/DB | `agent记忆实现方案.md` |
|
||||
| App HTTP 接入示例 | `知你客服Agent智能聊天App接入方案.md` |
|
||||
| 创建/更新 14 号脚本 | `backend/scripts/create_zhini_kefu_14.py` |
|
||||
| 内置工具注册 | `backend/app/core/tools_bootstrap.py` |
|
||||
| 引擎与 Cache | `backend/app/services/workflow_engine.py` |
|
||||
|
||||
---
|
||||
|
||||
*随平台版本与仓库迭代,以实际接口与工作流配置为准。*
|
||||
462
项目核心文档汇总.md
Normal file
462
项目核心文档汇总.md
Normal file
@@ -0,0 +1,462 @@
|
||||
# 低代码智能体平台 - 核心文档汇总
|
||||
|
||||
## 项目概述
|
||||
|
||||
### 项目背景
|
||||
低代码智能体平台旨在让非技术用户通过可视化拖拽的方式,快速构建和部署AI智能体,降低AI应用开发门槛,提高开发效率。
|
||||
|
||||
### 核心价值
|
||||
- **零代码/低代码**:通过可视化界面配置智能体,无需编写代码
|
||||
- **快速部署**:一键部署到多种环境(云服务、本地、边缘设备)
|
||||
- **灵活扩展**:支持自定义组件和插件机制
|
||||
- **多模型支持**:集成主流AI模型(OpenAI、Claude、DeepSeek等)
|
||||
- **工作流编排**:支持复杂的工作流设计和执行
|
||||
- **Agent协作**:支持多Agent协作和工具链管理
|
||||
|
||||
### 目标用户
|
||||
- 产品经理和业务人员
|
||||
- 初级开发者
|
||||
- 企业数字化转型团队
|
||||
- AI应用开发者
|
||||
|
||||
## 系统架构设计
|
||||
|
||||
### 整体架构
|
||||
采用前后端分离的微服务架构:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 前端层 (Frontend) │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ 可视化编辑器 │ │ 智能体管理 │ │ 监控面板 │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ HTTP/WebSocket
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ API网关层 (Gateway) │
|
||||
│ 认证、限流、路由、负载均衡 │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 业务服务层 (Services) │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐│
|
||||
│ │智能体引擎 │ │工作流引擎 │ │模型管理 │ │数据管理 ││
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘│
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐│
|
||||
│ │任务调度 │ │日志监控 │ │用户管理 │ │权限管理 ││
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘│
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 数据存储层 (Storage) │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐│
|
||||
│ │ MySQL │ │ │ │ Redis │ │ ││
|
||||
│ │(元数据) │ │ │ │(缓存) │ │ ││
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘│
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 外部服务层 (External) │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐│
|
||||
│ │ OpenAI │ │ DeepSeek │ │ 本地模型 │ │ 其他API ││
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘│
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 技术栈选型
|
||||
|
||||
#### 前端技术栈
|
||||
- **框架**: Vue 3 + TypeScript + Vite
|
||||
- **状态管理**: Pinia
|
||||
- **UI组件库**: Element Plus
|
||||
- **工作流可视化**: Vue Flow
|
||||
- **HTTP客户端**: Axios
|
||||
- **WebSocket**: 原生WebSocket API
|
||||
|
||||
#### 后端技术栈
|
||||
- **框架**: Python FastAPI
|
||||
- **数据库**: MySQL(腾讯云数据库)
|
||||
- **缓存/消息队列**: Redis
|
||||
- **异步任务**: Celery
|
||||
- **AI框架**: LangChain
|
||||
- **数据库ORM**: SQLAlchemy
|
||||
- **迁移工具**: Alembic
|
||||
- **认证**: JWT
|
||||
|
||||
## 核心功能模块
|
||||
|
||||
### 1. 用户认证系统
|
||||
- 用户注册、登录
|
||||
- JWT Token认证
|
||||
- 密码加密(bcrypt)
|
||||
- 获取当前用户信息
|
||||
|
||||
### 2. 工作流管理
|
||||
- 工作流CRUD(创建、读取、更新、删除)
|
||||
- 可视化编辑器(拖拽节点、连线)
|
||||
- 节点配置面板
|
||||
- 工作流版本管理
|
||||
- 工作流导入导出
|
||||
- 工作流模板市场
|
||||
|
||||
### 3. 工作流执行引擎
|
||||
- DAG构建和拓扑排序
|
||||
- 节点执行器(支持多种节点类型)
|
||||
- 数据流管理(节点间数据传递)
|
||||
- Celery异步任务集成
|
||||
- 执行状态管理
|
||||
- 错误处理和重试机制
|
||||
|
||||
### 4. 节点类型支持
|
||||
- **基础节点**: 开始、输入、输出、结束
|
||||
- **AI节点**: LLM(支持OpenAI、DeepSeek等)
|
||||
- **逻辑节点**: 条件、循环
|
||||
- **数据节点**: 转换、数据库查询、文件操作
|
||||
- **集成节点**: HTTP请求、Webhook、邮件、消息队列
|
||||
- **工具节点**: 定时任务、Agent节点
|
||||
|
||||
### 5. 内置工具调用
|
||||
平台提供8个内置工具,可在LLM节点中启用:
|
||||
1. 🌐 **http_request** - HTTP请求工具
|
||||
2. 📖 **file_read** - 文件读取工具
|
||||
3. ✍️ **file_write** - 文件写入工具
|
||||
4. 📊 **text_analyze** - 文本分析工具
|
||||
5. 🕐 **datetime** - 日期时间工具
|
||||
6. 🔢 **math_calculate** - 数学计算工具
|
||||
7. 💻 **system_info** - 系统信息工具
|
||||
8. 📦 **json_process** - JSON处理工具
|
||||
|
||||
### 6. 执行管理
|
||||
- 创建执行任务
|
||||
- 获取执行记录列表
|
||||
- 获取执行详情
|
||||
- 执行状态实时推送(WebSocket)
|
||||
- 执行结果可视化
|
||||
- 执行日志和监控
|
||||
|
||||
### 7. 数据源管理
|
||||
- 数据源模型和CRUD API
|
||||
- 数据源连接测试
|
||||
- 数据查询功能
|
||||
|
||||
### 8. 模型配置管理
|
||||
- 模型配置CRUD API
|
||||
- 多模型支持(OpenAI、DeepSeek等)
|
||||
- 模型切换和配置
|
||||
|
||||
### 9. Agent管理
|
||||
- Agent CRUD API
|
||||
- Agent管理页面
|
||||
- Agent协作功能
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
aiagent/
|
||||
├── frontend/ # 前端项目(Vue 3 + TypeScript)
|
||||
│ ├── src/
|
||||
│ │ ├── assets/ # 静态资源
|
||||
│ │ ├── components/ # 组件
|
||||
│ │ ├── router/ # 路由
|
||||
│ │ ├── stores/ # 状态管理(Pinia)
|
||||
│ │ ├── views/ # 页面
|
||||
│ │ └── App.vue # 根组件
|
||||
│ ├── package.json # 依赖配置
|
||||
│ ├── vite.config.ts # Vite配置
|
||||
│ └── Dockerfile.dev # 开发环境Dockerfile
|
||||
├── backend/ # 后端项目(Python FastAPI)
|
||||
│ ├── app/
|
||||
│ │ ├── api/ # API路由
|
||||
│ │ ├── core/ # 核心模块
|
||||
│ │ ├── models/ # 数据库模型
|
||||
│ │ ├── schemas/ # Pydantic模式
|
||||
│ │ ├── services/ # 业务逻辑
|
||||
│ │ └── main.py # 应用入口
|
||||
│ ├── alembic/ # 数据库迁移
|
||||
│ ├── tests/ # 测试
|
||||
│ ├── requirements.txt # Python依赖
|
||||
│ ├── Dockerfile.dev # 开发环境Dockerfile
|
||||
│ └── alembic.ini # Alembic配置
|
||||
├── docker-compose.dev.yml # 开发环境Docker Compose配置
|
||||
├── README.md # 项目说明
|
||||
├── QUICKSTART.md # 快速启动指南
|
||||
├── 使用指南.md # 用户使用指南
|
||||
├── 方案-优化版.md # 完整技术方案
|
||||
├── 开发进度.md # 开发进度跟踪
|
||||
├── 内置工具列表.md # 内置工具文档
|
||||
└── 项目核心文档汇总.md # 本文档
|
||||
```
|
||||
|
||||
## 部署指南
|
||||
|
||||
### 前置要求
|
||||
- Node.js 18+ 和 pnpm
|
||||
- Python 3.11+
|
||||
- Docker 和 Docker Compose
|
||||
- MySQL(使用腾讯云数据库)
|
||||
- Redis 7+(或使用Docker)
|
||||
|
||||
### 使用 Docker Compose 启动(推荐)
|
||||
```bash
|
||||
# 启动所有服务
|
||||
docker-compose -f docker-compose.dev.yml up -d
|
||||
|
||||
# 查看服务状态
|
||||
docker-compose ps
|
||||
|
||||
# 查看日志
|
||||
docker-compose logs -f
|
||||
|
||||
# 停止服务
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
### 本地开发部署
|
||||
|
||||
#### 后端服务
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# 创建虚拟环境
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||
|
||||
# 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 配置环境变量
|
||||
cp env.example .env
|
||||
# 编辑 .env 文件(数据库已配置为腾讯云MySQL)
|
||||
|
||||
# 运行数据库迁移
|
||||
alembic upgrade head
|
||||
|
||||
# 启动开发服务器
|
||||
uvicorn app.main:app --reload
|
||||
|
||||
# 启动 Celery Worker(新终端)
|
||||
celery -A app.core.celery_app worker --loglevel=info
|
||||
```
|
||||
|
||||
#### 前端服务
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# 安装依赖
|
||||
pnpm install
|
||||
|
||||
# 启动开发服务器
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### 服务访问地址
|
||||
- **前端**: http://localhost:8038
|
||||
- **后端API**: http://localhost:8037
|
||||
- **API文档**: http://localhost:8037/docs
|
||||
- **Redis**: localhost:6379
|
||||
|
||||
## API 文档摘要
|
||||
|
||||
### 认证API
|
||||
- `POST /api/v1/auth/register` - 用户注册
|
||||
- `POST /api/v1/auth/login` - 用户登录(获取JWT Token)
|
||||
- `GET /api/v1/auth/me` - 获取当前用户信息
|
||||
|
||||
### 工作流API
|
||||
- `GET /api/v1/workflows` - 获取工作流列表
|
||||
- `POST /api/v1/workflows` - 创建工作流
|
||||
- `GET /api/v1/workflows/{id}` - 获取工作流详情
|
||||
- `PUT /api/v1/workflows/{id}` - 更新工作流
|
||||
- `DELETE /api/v1/workflows/{id}` - 删除工作流
|
||||
- `POST /api/v1/workflows/{id}/execute` - 执行工作流
|
||||
|
||||
### 执行管理API
|
||||
- `GET /api/v1/executions` - 获取执行记录列表
|
||||
- `GET /api/v1/executions/{id}` - 获取执行详情
|
||||
- `GET /api/v1/executions/{id}/status` - 获取执行状态
|
||||
|
||||
### 数据源API
|
||||
- `GET /api/v1/data-sources` - 获取数据源列表
|
||||
- `POST /api/v1/data-sources` - 创建数据源
|
||||
- `POST /api/v1/data-sources/{id}/test` - 测试数据源连接
|
||||
- `POST /api/v1/data-sources/{id}/query` - 执行数据查询
|
||||
|
||||
### WebSocket API
|
||||
- `ws://localhost:8037/ws/execution/{execution_id}` - 执行状态实时推送
|
||||
|
||||
完整API文档请访问:http://localhost:8037/docs
|
||||
|
||||
## 用户指南摘要
|
||||
|
||||
### 快速开始使用
|
||||
1. **登录系统**:访问 http://localhost:8038,注册并登录
|
||||
2. **创建工作流**:点击"创建工作流"按钮进入可视化编辑器
|
||||
3. **拖拽节点**:从左侧工具箱拖拽节点到画布
|
||||
4. **连接节点**:点击节点的连接点并拖拽到目标节点
|
||||
5. **配置节点**:点击节点,在右侧配置面板设置参数
|
||||
6. **保存工作流**:点击工具栏的"保存"按钮
|
||||
7. **执行工作流**:点击工具栏的"运行"按钮
|
||||
|
||||
### 节点类型说明
|
||||
- **开始节点**:工作流起始节点
|
||||
- **输入节点**:数据输入节点,可配置输入参数
|
||||
- **LLM节点**:AI模型处理节点,支持OpenAI、DeepSeek等模型
|
||||
- **条件节点**:条件判断节点,支持表达式配置
|
||||
- **转换节点**:数据转换节点,支持JSONPath、模板等
|
||||
- **输出节点**:数据输出节点,可配置输出格式
|
||||
- **结束节点**:工作流结束节点
|
||||
|
||||
### 工具调用功能
|
||||
在LLM节点中启用工具调用,AI可以调用内置工具处理任务:
|
||||
1. 选择LLM节点,打开"工具"标签页
|
||||
2. 启用"启用工具调用"开关
|
||||
3. 选择需要的工具(可多选)
|
||||
4. 保存配置
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 开发规范
|
||||
- **前端代码规范**:ESLint + Prettier
|
||||
- **后端代码规范**:PEP 8 + Black
|
||||
- **Git提交规范**:Conventional Commits
|
||||
- **代码审查**:必须通过Code Review
|
||||
|
||||
### 测试指南
|
||||
```bash
|
||||
# 前端测试
|
||||
cd frontend
|
||||
pnpm test
|
||||
|
||||
# 后端测试
|
||||
cd backend
|
||||
pytest
|
||||
```
|
||||
|
||||
### 数据库迁移
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# 创建新的迁移
|
||||
alembic revision --autogenerate -m "描述"
|
||||
|
||||
# 应用迁移
|
||||
alembic upgrade head
|
||||
|
||||
# 回退迁移
|
||||
alembic downgrade -1
|
||||
```
|
||||
|
||||
### 添加新节点类型
|
||||
1. 后端:在 `backend/app/core/nodes/` 中添加节点执行器
|
||||
2. 后端:在 `backend/app/core/workflow_engine.py` 中注册节点类型
|
||||
3. 前端:在 `frontend/src/components/nodes/` 中添加节点组件
|
||||
4. 前端:在 `frontend/src/stores/workflowStore.ts` 中注册节点类型
|
||||
|
||||
## 测试指南
|
||||
|
||||
### 单元测试
|
||||
- 使用 pytest 框架
|
||||
- 测试核心功能和工作流引擎
|
||||
- 测试API端点
|
||||
|
||||
### 集成测试
|
||||
- 测试数据库操作
|
||||
- 测试外部API集成
|
||||
- 测试工作流执行
|
||||
|
||||
### 端到端测试
|
||||
- 测试用户界面交互
|
||||
- 测试完整工作流执行
|
||||
- 测试跨浏览器兼容性
|
||||
|
||||
## 内置工具列表
|
||||
|
||||
平台提供8个内置工具,详细功能如下:
|
||||
|
||||
| 工具名称 | 功能描述 | 主要参数 |
|
||||
|---------|---------|---------|
|
||||
| http_request | 发送HTTP请求 | url, method, headers, body |
|
||||
| file_read | 读取文件内容 | file_path |
|
||||
| file_write | 写入文件内容 | file_path, content, mode |
|
||||
| text_analyze | 分析文本内容 | text, operation |
|
||||
| datetime | 处理日期时间 | operation, format |
|
||||
| math_calculate | 执行数学计算 | expression |
|
||||
| system_info | 获取系统信息 | 无 |
|
||||
| json_process | 处理JSON数据 | json_string, operation |
|
||||
|
||||
详细使用示例请参考:[内置工具列表.md](./内置工具列表.md)
|
||||
|
||||
## 开发进度和路线图
|
||||
|
||||
### 当前完成度
|
||||
- **第一阶段MVP**: 100% ✅
|
||||
- **第二阶段核心功能**: 100% ✅
|
||||
- **第三阶段核心功能**: 100% ✅
|
||||
- **第四-七阶段功能**: 100% ✅
|
||||
- **整体项目**: 约 85-90%
|
||||
|
||||
### 已完成核心功能
|
||||
1. **完整的用户认证系统** - 注册、登录、JWT认证
|
||||
2. **工作流CRUD** - 创建、读取、更新、删除工作流
|
||||
3. **工作流执行引擎** - DAG构建、拓扑排序、节点执行
|
||||
4. **可视化编辑器** - 拖拽节点、连线、配置面板
|
||||
5. **异步任务处理** - Celery集成,支持长时间运行的任务
|
||||
6. **多模型支持** - OpenAI、DeepSeek集成
|
||||
7. **内置工具调用** - 8个内置工具支持
|
||||
8. **实时状态推送** - WebSocket实时推送执行状态
|
||||
|
||||
### 近期开发重点(高优先级)
|
||||
1. **监控和告警前端界面** - 系统监控面板、告警规则管理
|
||||
2. **用户体验优化** - 工作流编辑器优化、Agent使用体验优化
|
||||
3. **生产环境部署配置** - Docker/K8s配置、监控和日志集成
|
||||
|
||||
### 长期规划
|
||||
1. **多租户支持** - 租户模型、数据隔离、资源配额管理
|
||||
2. **插件系统** - 插件注册机制、自定义节点插件开发框架
|
||||
3. **性能优化** - 工作流执行性能优化、前端性能优化
|
||||
|
||||
详细开发进度请参考:[开发进度.md](./开发进度.md)
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1. 数据库连接失败
|
||||
- 检查MySQL数据库是否可访问(腾讯云数据库)
|
||||
- 检查数据库连接信息是否正确(.env 文件)
|
||||
- 检查网络连接是否正常
|
||||
|
||||
### 2. Redis 连接失败
|
||||
- 检查Redis是否正在运行
|
||||
- 检查Redis URL是否正确
|
||||
|
||||
### 3. 前端无法连接后端
|
||||
- 检查后端服务是否正在运行
|
||||
- 检查前端配置的API URL是否正确(vite.config.ts)
|
||||
- 检查CORS配置是否正确
|
||||
|
||||
### 4. Celery 任务不执行
|
||||
- 检查Celery Worker是否正在运行
|
||||
- 检查Redis连接是否正常
|
||||
- 检查任务是否正确注册
|
||||
|
||||
### 5. 工作流执行失败
|
||||
- 检查节点配置是否正确
|
||||
- 检查LLM API密钥是否有效
|
||||
- 查看执行日志获取详细错误信息
|
||||
|
||||
## 联系和支持
|
||||
|
||||
- **API文档**:http://localhost:8037/docs
|
||||
- **前端服务**:http://localhost:8038
|
||||
- **问题反馈**:查看项目文档或联系开发团队
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2026-04-06
|
||||
**文档版本**: 1.0
|
||||
|
||||
*本文档基于项目现有文档整理生成,涵盖项目核心信息。详细技术方案请参考[方案-优化版.md](./方案-优化版.md)。*
|
||||
Reference in New Issue
Block a user