feat: 工作流记忆与内置工具、知你客服脚本、Agent管理技能展示与能力配置、文档与Windows启动脚本;忽略 redis_temp 二进制目录

Made-with: Cursor
This commit is contained in:
renjianbo
2026-04-08 11:44:24 +08:00
parent 599b8f2851
commit bd3f8be781
66 changed files with 10104 additions and 469 deletions

3
.gitignore vendored
View File

@@ -16,3 +16,6 @@ logs/
# 操作系统
.DS_Store
Thumbs.db
# 本地 Redis Windows 分发(勿提交二进制)
redis_temp/

2
123.md Normal file
View File

@@ -0,0 +1,2 @@
# 123
e2e zhini12 123.md marker

1
456.md Normal file
View File

@@ -0,0 +1 @@
# 456.md 文件已创建</|DSML|parameter>

317
Windows启动指南.md Normal file
View 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)。

View 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` |
---
*文档随实现迭代可能变化,以仓库内代码与平台工作流配置为准。*

View File

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

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

View File

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

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

View File

@@ -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}")
# 不抛出异常,允许应用继续启动

View File

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

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

View File

@@ -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": {}

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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."

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

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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>')
}
// 格式化时间

View File

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

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

View File

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

View File

@@ -11,7 +11,7 @@ export default defineConfig({
}
},
server: {
port: 3000,
port: 3001,
proxy: {
'/api': {
target: 'http://localhost:8037',

219
start_windows.cmd Normal file
View 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
View 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
View 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
View File

3
user_data/aaa.md Normal file
View 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
View File

@@ -0,0 +1 @@
# abb

1
user_data/abc.md Normal file
View File

@@ -0,0 +1 @@
# abc

1
user_data/bbb.md Normal file
View File

@@ -0,0 +1 @@
# bbb

1
user_data/bbbb.md Normal file
View File

@@ -0,0 +1 @@

1
user_data/ccc.md Normal file
View File

@@ -0,0 +1 @@

1
user_data/xxx.md Normal file
View File

@@ -0,0 +1 @@
# test from script

1
user_profile.json Normal file
View File

@@ -0,0 +1 @@
{"name": "小七"}</|DSML|parameter>

2
上传git仓.md Normal file
View File

@@ -0,0 +1,2 @@
将修改上传到git仓rjb_win_dev分支
http://101.43.95.130:3001/admin/aiagent.git

30
望庐山瀑布.md Normal file
View File

@@ -0,0 +1,30 @@
# 望庐山瀑布
**作者:李白(唐)**
日照香炉生紫烟,
遥看瀑布挂前川。
飞流直下三千尺,
疑是银河落九天。
---
## 诗歌赏析
### 创作背景
《望庐山瀑布》是唐代伟大诗人李白创作的一首七言绝句,描绘了庐山瀑布的壮丽景色,展现了诗人豪放不羁的个性和对大自然的热爱。
### 诗句解析
1. **"日照香炉生紫烟"**:阳光照耀下的香炉峰升起紫色的烟雾,为瀑布营造了神秘的氛围。
2. **"遥看瀑布挂前川"**:远远望去,瀑布像一条白练悬挂在山川之间。
3. **"飞流直下三千尺"**:瀑布从高处飞泻而下,气势磅礴,夸张手法突显其雄伟。
4. **"疑是银河落九天"**:诗人展开想象,怀疑这是天上的银河落到了人间,极富浪漫主义色彩。
### 艺术特色
- **夸张手法**:"三千尺"的夸张描写增强了瀑布的雄伟气势
- **比喻精妙**:将瀑布比作银河,形象生动
- **意境开阔**:从近景到远景,从现实到想象,意境层层递进
- **语言简练**:短短四句,却描绘出完整的画面
### 历史评价
这首诗被誉为描写瀑布的千古绝唱,充分展现了李白诗歌的浪漫主义风格和超凡的想象力,成为中国古代山水诗的典范之作。
---
## 扩展阅读
### 庐山简介
庐山位于江西省九江市,是中国著名的风景名胜区和避暑胜地,以雄、奇、险、秀闻名于世,素有"匡庐奇秀甲天下"之美誉。
### 李白与庐山
李白曾多次游览庐山,留下了多首描写庐山的诗篇。《望庐山瀑布》是其最著名的庐山诗作之一,充分展现了他对自然山水的热爱和独特的艺术视角。
### 相关诗作
1. **《庐山谣寄卢侍御

View 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 配置为准。*

View 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` |
---
*随平台版本与仓库迭代,以实际接口与工作流配置为准。*

4
静夜思.md Normal file
View File

@@ -0,0 +1,4 @@
# 静夜思
床前明月光,疑是地上霜。
举头望明月,低头思故乡。

462
项目核心文档汇总.md Normal file
View 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)。*