Compare commits
23 Commits
feature/边缘
...
81b0d0ea1f
| Author | SHA1 | Date | |
|---|---|---|---|
| 81b0d0ea1f | |||
| 54e17c9795 | |||
| 2d0f82ee87 | |||
| 71e673a2ed | |||
| 0f50c98cf3 | |||
| 5bfe7a733f | |||
| 63a32bfa7c | |||
| f3792915a3 | |||
| fa6907fb8d | |||
| bc130d98f4 | |||
| d6745b45a5 | |||
| 9faccc2c03 | |||
| f843a74715 | |||
| 44df56c8d2 | |||
| adb59da06d | |||
| 2050a30a52 | |||
| 7fc9e10c99 | |||
| f0e7e75ffb | |||
| 6b65b24b0f | |||
| 1d0f0ae0ef | |||
| ba9b925c32 | |||
| a3e3711b2b | |||
| 8b10ef5828 |
23
.env.example
Normal file
23
.env.example
Normal file
@@ -0,0 +1,23 @@
|
||||
# ==================== 路径配置 ====================
|
||||
VECTORSTORE_PATH=/data/vectorstore
|
||||
STATE_FILE=/data/state.json
|
||||
SCHEMA_FILE=/data/schema.json
|
||||
PRESETS_FILE=/data/presets.json
|
||||
REGEX_FILE=/data/regex_rules.json
|
||||
|
||||
# ==================== 服务地址 ====================
|
||||
COMFYUI_API_URL=http://comfyui:8188
|
||||
BACKEND_PORT=8000
|
||||
FRONTEND_PORT=8501
|
||||
|
||||
# ==================== API 加密密钥 ====================
|
||||
# ⚠️ 重要:此密钥用于加密存储在配置文件中的 API Keys
|
||||
# ⚠️ 生产环境必须设置此变量,否则每次重启后无法解密之前的 API Key
|
||||
# ⚠️ 生成方法:python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
API_ENCRYPTION_KEY=your-encryption-key-here
|
||||
|
||||
# ==================== 默认 LLM 配置(可选)====================
|
||||
# 这些配置仅用于测试,实际使用时请通过 API 配置页面设置
|
||||
# MAIN_LLM_API_KEY=sk-your-api-key
|
||||
# MAIN_LLM_BASE_URL=https://api.openai.com/v1
|
||||
# MAIN_LLM_MODEL=gpt-4
|
||||
BIN
.gitignore
vendored
BIN
.gitignore
vendored
Binary file not shown.
2
.idea/llm-workflow-engine.iml
generated
2
.idea/llm-workflow-engine.iml
generated
@@ -3,6 +3,8 @@
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/backend" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/backend/api" isTestSource="false" />
|
||||
</content>
|
||||
<orderEntry type="jdk" jdkName="Python 3.9 (pythonProject1)" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
|
||||
76
AGENTS.md
Normal file
76
AGENTS.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# AI Agent Guidance for this Repository
|
||||
|
||||
## Repository overview
|
||||
- Backend: `backend/` using FastAPI, Python 3.11+, `uvicorn --reload` for development.
|
||||
- Frontend: `frontend/` using React 18 + Vite + TypeScript, with Zustand for state.
|
||||
- Runtime data is stored under `data/` as JSON/files; do not treat it as source code.
|
||||
- Docker support exists via `docker-compose.yml` and `docs/DOCKER_DEV.md`.
|
||||
|
||||
## What an AI coding agent should do first
|
||||
1. Read `README.md` and `docs/DOCKER_DEV.md` before proposing environment or run commands.
|
||||
2. Identify whether a change belongs in `backend/` or `frontend/`.
|
||||
3. Prefer small, incremental edits.
|
||||
4. When in doubt, ask the user before making large refactors or architectural changes.
|
||||
|
||||
## Build / run commands
|
||||
### Backend local
|
||||
- `python -m venv venv`
|
||||
- `venv\Scripts\activate` (Windows)
|
||||
- `pip install -r backend/requirements.txt`
|
||||
- `cd backend && python main.py`
|
||||
|
||||
### Frontend local
|
||||
- `cd frontend`
|
||||
- `npm install`
|
||||
- `npm run dev`
|
||||
|
||||
### Docker development
|
||||
- `.\scripts\docker-up.ps1`
|
||||
- `.\scripts\docker-restart.ps1 -Service backend`
|
||||
- `.\scripts\docker-rebuild.ps1 -Service backend`
|
||||
- `.\scripts\docker-logs.ps1`
|
||||
- `docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d`
|
||||
|
||||
## Testing and quality checks
|
||||
- Backend tests live in `backend/tests/`.
|
||||
- Use `python -m pytest backend/tests` for automated backend test runs.
|
||||
- Frontend has lint/type-check scripts in `frontend/package.json`:
|
||||
- `npm run lint`
|
||||
- `npm run type-check`
|
||||
- Prefer adding or updating tests for bug fixes, new features, and non-trivial behavior changes.
|
||||
- Keep changes tidy and consistent with the repository's existing style.
|
||||
|
||||
## Code conventions and review preferences
|
||||
- The user prefers code that is:
|
||||
- 基本审查过的
|
||||
- 规范整洁的
|
||||
- 严谨测试覆盖的
|
||||
- Do not perform sweeping refactors without explicit user approval.
|
||||
- If a change affects core logic, clearly explain the reason and the hypothesis for the fix.
|
||||
- For any change, state whether it is:
|
||||
- bug fix
|
||||
- cleanup/refactor
|
||||
- feature addition
|
||||
|
||||
## Important paths and domains
|
||||
- `backend/main.py` — FastAPI app entrypoint
|
||||
- `backend/api/` — HTTP route definitions
|
||||
- `backend/services/` — core business logic and domain services
|
||||
- `backend/models/` — data models and converters
|
||||
- `backend/utils/` — shared helper modules
|
||||
- `frontend/src/` — React source code
|
||||
- `frontend/package.json` — frontend scripts and dependencies
|
||||
- `docs/DOCKER_DEV.md` — Docker development guidance
|
||||
|
||||
## Practical guidance for AI agents
|
||||
- Avoid editing generated or runtime content in `data/` unless explicitly asked.
|
||||
- Prefer changes that are easy to reason about and test.
|
||||
- Use existing test tools rather than inventing new workflows.
|
||||
- Link to repository docs instead of duplicating long explanations.
|
||||
- If a requested change is uncertain, ask for clarification rather than guessing.
|
||||
|
||||
## References
|
||||
- `README.md`
|
||||
- `docs/DOCKER_DEV.md`
|
||||
- `frontend/package.json`
|
||||
- `backend/requirements.txt`
|
||||
466
README.md
Normal file
466
README.md
Normal file
@@ -0,0 +1,466 @@
|
||||
# LLM Workflow Engine
|
||||
|
||||
一个功能强大的 LLM 聊天工作流引擎,兼容 SillyTavern 生态系统。
|
||||
|
||||
## 📋 目录
|
||||
|
||||
- [功能特性](#功能特性)
|
||||
- [技术栈](#技术栈)
|
||||
- [快速开始](#快速开始)
|
||||
- [项目结构](#项目结构)
|
||||
- [核心功能](#核心功能)
|
||||
- [开发指南](#开发指南)
|
||||
- [配置说明](#配置说明)
|
||||
- [常见问题](#常见问题)
|
||||
|
||||
---
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 🎯 核心功能
|
||||
|
||||
- **多模型支持** - 兼容 OpenAI、Claude、Gemini 等多种 LLM API
|
||||
- **角色卡系统** - 完整的角色创建、导入、导出功能(兼容 SillyTavern 格式)
|
||||
- **聊天管理** - 多聊天切换、历史总结、消息编辑
|
||||
- **预设系统** - 灵活的提示词组件管理,支持拖拽排序
|
||||
- **世界书** - 动态世界知识注入系统
|
||||
- **正则替换** - 强大的文本处理规则系统(完全兼容 SillyTavern)
|
||||
|
||||
### ✨ 高级功能
|
||||
|
||||
- **酒馆助手(Tavern Helper)**
|
||||
- JavaScript 沙盒执行引擎
|
||||
- 提示词模板系统(支持 `{{var}}`、`{{roll}}`、`{{random}}` 等语法)
|
||||
- 脚本管理(全局/角色/预设三种作用域)
|
||||
- 代码块渲染功能
|
||||
|
||||
- **多主题支持** - 完整的 CSS 变量主题系统
|
||||
- **流式输出** - 实时显示 AI 生成内容
|
||||
- **消息 Swipes** - 多版本切换和重roll功能
|
||||
- **API 配置管理** - 安全的 API Key 存储和加密
|
||||
|
||||
### 🔒 安全特性
|
||||
|
||||
- API Key 加密存储(Fernet 对称加密)
|
||||
- JavaScript 沙盒隔离执行
|
||||
- 危险 API 拦截机制
|
||||
- 环境变量安全管理
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
### 后端
|
||||
|
||||
- **框架**: FastAPI (Python 3.11+)
|
||||
- **数据库**: 文件系统 + JSON(轻量级,易于备份)
|
||||
- **WebSocket**: 实时流式通信
|
||||
- **加密**: Fernet 对称加密(cryptography 库)
|
||||
- **依赖管理**: pip + requirements.txt
|
||||
|
||||
### 前端
|
||||
|
||||
- **框架**: React 18 + Vite
|
||||
- **状态管理**: Zustand(轻量级 Redux 替代)
|
||||
- **样式**: CSS3 + CSS 变量(支持多主题)
|
||||
- **Markdown**: react-markdown + remark-gfm
|
||||
- **HTTP 客户端**: Fetch API
|
||||
|
||||
### 部署
|
||||
|
||||
- **容器化**: Docker + Docker Compose
|
||||
- **反向代理**: Nginx
|
||||
- **开发服务器**: Vite HMR
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Python 3.11+
|
||||
- Node.js 18+
|
||||
- Docker & Docker Compose(可选)
|
||||
|
||||
### 本地开发
|
||||
|
||||
#### 1. 克隆项目
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-repo/llm-workflow-engine.git
|
||||
cd llm-workflow-engine
|
||||
```
|
||||
|
||||
#### 2. 后端启动
|
||||
|
||||
```bash
|
||||
# 创建虚拟环境
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||
|
||||
# 安装依赖
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
# 启动服务
|
||||
cd backend
|
||||
python main.py
|
||||
```
|
||||
|
||||
后端服务将在 `http://localhost:23338` 启动。
|
||||
|
||||
#### 3. 前端启动
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 启动开发服务器
|
||||
npm run dev
|
||||
```
|
||||
|
||||
前端将在 `http://localhost:5173` 启动,自动代理 API 请求到后端。
|
||||
|
||||
### Docker 开发(Windows / Docker Desktop)
|
||||
|
||||
日常改代码**不需要重启 Docker Desktop**——后端 uvicorn `--reload`、前端 Vite HMR 会自动生效。
|
||||
|
||||
```powershell
|
||||
# 启动(项目根目录)
|
||||
.\scripts\docker-up.ps1
|
||||
|
||||
# 仅重启容器(HMR/reload 异常时)
|
||||
.\scripts\docker-restart.ps1 -Service frontend # 或 backend / all
|
||||
|
||||
# 依赖或 Dockerfile 变更后重建
|
||||
.\scripts\docker-rebuild.ps1 -Service backend
|
||||
|
||||
# 查看日志
|
||||
.\scripts\docker-logs.ps1
|
||||
```
|
||||
|
||||
| 服务 | 地址 |
|
||||
|------|------|
|
||||
| 后端 API | http://localhost:23337 |
|
||||
| 前端 | http://localhost:23338 |
|
||||
|
||||
详细说明(何时 rebuild、何时才需要重启 Docker Desktop、本地开发替代方案)见 **[docs/DOCKER_DEV.md](./docs/DOCKER_DEV.md)**。
|
||||
|
||||
```powershell
|
||||
# 停止服务
|
||||
docker compose down
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
llm-workflow-engine/
|
||||
├── backend/ # 后端服务
|
||||
│ ├── api/ # API 路由
|
||||
│ │ └── routes/ # 路由处理
|
||||
│ ├── core/ # 核心配置
|
||||
│ ├── models/ # 数据模型
|
||||
│ ├── services/ # 业务逻辑
|
||||
│ │ ├── chat_service.py # 聊天服务
|
||||
│ │ ├── js_sandbox.py # JavaScript 沙盒
|
||||
│ │ ├── script_manager.py # 脚本管理器
|
||||
│ │ ├── regex_service.py # 正则服务
|
||||
│ │ └── ...
|
||||
│ ├── utils/ # 工具函数
|
||||
│ ├── main.py # 应用入口
|
||||
│ └── requirements.txt # Python 依赖
|
||||
│
|
||||
├── frontend/ # 前端应用
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # React 组件
|
||||
│ │ │ ├── Mid/ # 中间区域(聊天框)
|
||||
│ │ │ ├── SideBarLeft/ # 左侧边栏
|
||||
│ │ │ │ └── tabs/ # 标签页组件
|
||||
│ │ │ │ └── TavernHelper/ # 酒馆助手
|
||||
│ │ │ ├── SideBarRight/# 右侧边栏
|
||||
│ │ │ └── TopBar/ # 顶部栏
|
||||
│ │ ├── Store/ # Zustand 状态管理
|
||||
│ │ ├── styles/ # 全局样式
|
||||
│ │ ├── types/ # TypeScript 类型定义
|
||||
│ │ ├── utils/ # 工具函数
|
||||
│ │ ├── App.jsx # 根组件
|
||||
│ │ └── main.jsx # 应用入口
|
||||
│ ├── package.json # Node.js 依赖
|
||||
│ └── vite.config.js # Vite 配置
|
||||
│
|
||||
├── data/ # 数据目录(运行时生成)
|
||||
│ ├── chat/ # 聊天记录
|
||||
│ ├── preset/ # 预设文件
|
||||
│ ├── worldbooks/ # 世界书
|
||||
│ ├── regex/ # 正则规则
|
||||
│ └── ...
|
||||
│
|
||||
├── docker-compose.yml # Docker 编排
|
||||
├── .env.example # 环境变量示例
|
||||
├── .gitignore # Git 忽略文件
|
||||
└── README.md # 项目文档
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心功能
|
||||
|
||||
### 1. 酒馆助手(Tavern Helper)
|
||||
|
||||
完全兼容 SillyTavern 酒馆助手的提示词模板系统。
|
||||
|
||||
#### 支持的语法
|
||||
|
||||
| 语法 | 功能 | 示例 |
|
||||
|------|------|------|
|
||||
| `{{var}}` 或 `{{getvar::key}}` | 获取变量 | `{{name}}` |
|
||||
| `{{setvar::key::value}}` | 设置变量 | `{{setvar::age::25}}` |
|
||||
| `{{delvar::key}}` | 删除变量 | `{{delvar::temp}}` |
|
||||
| `{{random::a,b,c}}` | 随机选择(逗号) | `{{random::苹果,香蕉,橙子}}` |
|
||||
| `{{pick::a\|b\|c}}` | 随机选择(竖线) | `{{pick::剑\|斧\|弓}}` |
|
||||
| `{{roll XdY}}` | 掷骰子 | `{{roll 3d6}}` |
|
||||
| `{{// 注释}}` | 注释(不输出) | `{{// 这是注释}}` |
|
||||
|
||||
#### 使用示例
|
||||
|
||||
```python
|
||||
from backend.services.js_sandbox import JSSandboxExecutor
|
||||
|
||||
sandbox = JSSandboxExecutor()
|
||||
|
||||
template = """
|
||||
{{setvar::character::勇者}}
|
||||
{{setvar::weapon::{{random::剑,斧,弓}}}}
|
||||
{{character}}手持{{weapon}},掷出了:{{roll 1d20}}
|
||||
{{// 这是注释,不会显示}}
|
||||
""".strip()
|
||||
|
||||
result = sandbox.render_template(template)
|
||||
print(result)
|
||||
# 输出: 勇者手持剑,掷出了:15
|
||||
```
|
||||
|
||||
#### 脚本管理
|
||||
|
||||
支持三种作用域的脚本:
|
||||
|
||||
- **GLOBAL** - 全局脚本,对所有聊天可用
|
||||
- **CHARACTER** - 角色脚本,绑定到当前角色卡
|
||||
- **PRESET** - 预设脚本,绑定到当前预设
|
||||
|
||||
详细文档:[TAVERN_HELPER_IMPLEMENTATION.md](./TAVERN_HELPER_IMPLEMENTATION.md)
|
||||
|
||||
### 2. 正则替换系统
|
||||
|
||||
强大的文本处理规则,完全兼容 SillyTavern 格式。
|
||||
|
||||
#### 应用位置(placement)
|
||||
|
||||
- `0` - System Prompt(系统提示词)
|
||||
- `1` - User Input(用户输入)
|
||||
- `2` - AI Output(AI 输出)
|
||||
- `3` - Quick Reply(快捷回复)
|
||||
- `4` - World Info(世界书信息)
|
||||
- `5` - Reasoning/Thinking(推理/思考内容)
|
||||
|
||||
#### 规则示例
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "hide-thinking-001",
|
||||
"scriptName": "隐藏思考标签",
|
||||
"findRegex": "<thinking>[\\s\\S]*?<\\/thinking>",
|
||||
"replaceString": "",
|
||||
"placement": [2],
|
||||
"substituteRegex": 0,
|
||||
"markdownOnly": false,
|
||||
"promptOnly": false,
|
||||
"disabled": false
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 预设系统
|
||||
|
||||
灵活的提示词组件管理。
|
||||
|
||||
#### 特性
|
||||
|
||||
- 多组件拖拽排序
|
||||
- 角色字段支持(system/user/assistant)
|
||||
- 注入位置控制(injection_position)
|
||||
- 注入深度控制(injection_depth)
|
||||
- 触发条件(injection_trigger)
|
||||
- 完全兼容 SillyTavern 预设格式
|
||||
|
||||
### 4. 聊天管理
|
||||
|
||||
完整的聊天生命周期管理。
|
||||
|
||||
#### 功能
|
||||
|
||||
- 多聊天切换
|
||||
- 消息编辑和保存
|
||||
- 消息 Swipes(多版本)
|
||||
- 右键菜单(编辑/复制/重roll/删除)
|
||||
- 历史总结
|
||||
- 智能滚动
|
||||
|
||||
---
|
||||
|
||||
## 开发指南
|
||||
|
||||
### API 路由
|
||||
|
||||
所有 API 路由定义在 `backend/api/routes/` 目录下:
|
||||
|
||||
- `chatWsRoute.py` - WebSocket 聊天(流式输出)
|
||||
- `chatsRoute.py` - 聊天管理
|
||||
- `charactersRoute.py` - 角色卡管理
|
||||
- `presetsRoute.py` - 预设管理
|
||||
- `worldbooksRoute.py` - 世界书管理
|
||||
- `regexRoute.py` - 正则规则管理
|
||||
- `apiConfigRoute.py` - API 配置管理
|
||||
|
||||
### 状态管理
|
||||
|
||||
前端使用 Zustand 进行状态管理,store 定义在 `frontend/src/Store/`:
|
||||
|
||||
```
|
||||
Store/
|
||||
├── Mid/ # 中间区域状态
|
||||
│ ├── ChatBoxSlice.jsx # 聊天框状态
|
||||
│ └── ChatBoxUISlice.jsx # 聊天框 UI 状态
|
||||
├── SideBarLeft/ # 左侧边栏状态
|
||||
├── SideBarRight/ # 右侧边栏状态
|
||||
└── TopBar/ # 顶部栏状态
|
||||
```
|
||||
|
||||
### 样式系统
|
||||
|
||||
使用 CSS 变量实现多主题:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--color-bg-primary: #ffffff;
|
||||
--color-text-primary: #1a1a1a;
|
||||
--color-accent: #667eea;
|
||||
/* ... */
|
||||
}
|
||||
|
||||
[data-color-theme='dark'] {
|
||||
--color-bg-primary: #1a1a1a;
|
||||
--color-text-primary: #ffffff;
|
||||
/* ... */
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 配置说明
|
||||
|
||||
### 环境变量
|
||||
|
||||
创建 `.env` 文件(从 `.env.example` 复制):
|
||||
|
||||
```env
|
||||
# 后端配置
|
||||
HOST=0.0.0.0
|
||||
PORT=23338
|
||||
DEBUG=True
|
||||
|
||||
# 前端代理
|
||||
VITE_API_URL=http://localhost:23338
|
||||
|
||||
# API 加密密钥(自动生成,不要手动修改)
|
||||
FERNET_KEY=your_generated_key_here
|
||||
```
|
||||
|
||||
### API 配置
|
||||
|
||||
API Key 通过前端界面配置,自动加密存储到 `data/apiconfig/` 目录。
|
||||
|
||||
⚠️ **注意**:`data/apiconfig/*.json` 已添加到 `.gitignore`,不会被提交到版本控制。
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1. 前端无法连接后端
|
||||
|
||||
**问题**: 前端请求返回 404 或网络连接错误
|
||||
|
||||
**解决**:
|
||||
```bash
|
||||
# 检查后端是否运行
|
||||
curl http://localhost:23338/api/health
|
||||
|
||||
# 检查前端代理配置
|
||||
cat frontend/vite.config.js
|
||||
```
|
||||
|
||||
### 2. API Key 不生效
|
||||
|
||||
**问题**: 配置了 API Key 但仍然无法调用 LLM
|
||||
|
||||
**解决**:
|
||||
1. 检查 API 配置文件是否存在:`data/apiconfig/`
|
||||
2. 检查加密密钥是否正确:`.env` 中的 `FERNET_KEY`
|
||||
3. 重启后端服务
|
||||
|
||||
### 3. Docker 部署后无法访问
|
||||
|
||||
**问题**: `docker-compose up` 后无法访问服务
|
||||
|
||||
**解决**:
|
||||
```bash
|
||||
# 查看容器状态
|
||||
docker-compose ps
|
||||
|
||||
# 查看日志
|
||||
docker-compose logs -f backend
|
||||
docker-compose logs -f frontend
|
||||
|
||||
# 重新构建
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
### 4. 正则规则不生效
|
||||
|
||||
**问题**: 配置了正则规则但没有效果
|
||||
|
||||
**解决**:
|
||||
1. 检查规则是否启用(disabled: false)
|
||||
2. 检查 placement 是否正确
|
||||
3. 检查正则表达式语法
|
||||
4. 重启后端服务
|
||||
|
||||
---
|
||||
|
||||
## 贡献指南
|
||||
|
||||
1. Fork 项目
|
||||
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 开启 Pull Request
|
||||
|
||||
---
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目遵循与 SillyTavern 相同的分发协议。
|
||||
|
||||
---
|
||||
|
||||
## 致谢
|
||||
|
||||
- [SillyTavern](https://github.com/SillyTavern/SillyTavern) - 优秀的开源项目,提供了设计灵感和兼容标准
|
||||
- [JS-Slash-Runner](https://github.com/N0VI028/JS-Slash-Runner) - Tavern Helper 扩展,提供了 JavaScript 沙盒实现参考
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2026-05-05
|
||||
**版本**: 1.0.0
|
||||
@@ -4,20 +4,24 @@ FROM python:3.11-slim
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 设置环境变量
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
# 复制依赖文件
|
||||
COPY requirements.txt .
|
||||
|
||||
# 安装依赖
|
||||
RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# 安装 Pillow(使用阿里云镜像源)
|
||||
RUN pip install --no-cache-dir Pillow -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com || echo "Pillow installation failed, will install manually"
|
||||
|
||||
# 复制所有代码
|
||||
# 修改点:把当前目录(即 backend/)的内容复制到 /app/backend/ 下
|
||||
# 这样镜像内的结构就是 /app/backend/api/route.py
|
||||
COPY . /app/backend/
|
||||
COPY . .
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 8000
|
||||
|
||||
# 启动命令
|
||||
# 修改点:路径改为 backend.api.route (对应 /app/backend/api/route.py)
|
||||
CMD ["uvicorn", "backend.api.route:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,16 +1,31 @@
|
||||
from fastapi import APIRouter
|
||||
from .routes import presetsRoute, chatsRoute, worldbooksRoute
|
||||
from .routes import presetsRoute, chatsRoute, worldbooksRoute, apiConfigRoute, charactersRoute, chatWsRoute, tokenUsageRoute, imageGalleryRoute, regexRoute, chatSummaryRoute, studioRoute, fictionRoute
|
||||
from utils.file_utils import get_all_roles_and_chats
|
||||
from core.config import settings
|
||||
from pathlib import Path
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 注册子路由
|
||||
# 注册子路由(HTTP路由)
|
||||
router.include_router(presetsRoute.router)
|
||||
router.include_router(chatsRoute.router)
|
||||
router.include_router(worldbooksRoute.router)
|
||||
router.include_router(apiConfigRoute.router)
|
||||
router.include_router(charactersRoute.router)
|
||||
|
||||
# ✅ 注册新增路由
|
||||
router.include_router(tokenUsageRoute.router)
|
||||
router.include_router(imageGalleryRoute.router)
|
||||
router.include_router(regexRoute.router)
|
||||
router.include_router(chatSummaryRoute.router)
|
||||
router.include_router(studioRoute.router)
|
||||
router.include_router(fictionRoute.router)
|
||||
|
||||
# ✅ 注册 WebSocket 路由(必须在 HTTP 路由之后,避免路径冲突)
|
||||
router.include_router(chatWsRoute.router)
|
||||
|
||||
|
||||
# 保留原有的其他路由
|
||||
@router.get("/tool_bar/get_all_role_and_chat")
|
||||
def get_all_role_and_chat_endpoint():
|
||||
from ..tools.get_all_role_and_chat import get_all_role_and_chat
|
||||
return get_all_role_and_chat()
|
||||
return get_all_roles_and_chats(Path(settings.DATA_PATH))
|
||||
|
||||
378
backend/api/routes/apiConfigRoute.py
Normal file
378
backend/api/routes/apiConfigRoute.py
Normal file
@@ -0,0 +1,378 @@
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Dict, Optional, List, Any
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from core.config import settings
|
||||
from services.comfyui_workflow_manager import workflow_manager
|
||||
from services.llm_model_service import LLMModelService
|
||||
|
||||
router = APIRouter(prefix="/api-config", tags=["API Configuration"])
|
||||
|
||||
# 配置文件路径
|
||||
CONFIG_DIR = Path(settings.DATA_PATH) / "apiconfig"
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 调试信息:打印配置目录路径
|
||||
print(f"[API Config] DATA_PATH: {settings.DATA_PATH}", file=sys.stderr)
|
||||
print(f"[API Config] CONFIG_DIR: {CONFIG_DIR}", file=sys.stderr)
|
||||
print(f"[API Config] CONFIG_DIR exists: {CONFIG_DIR.exists()}", file=sys.stderr)
|
||||
if CONFIG_DIR.exists():
|
||||
config_files = list(CONFIG_DIR.glob("*.json"))
|
||||
print(f"[API Config] Found {len(config_files)} config files", file=sys.stderr)
|
||||
for f in config_files:
|
||||
print(f" - {f.name}", file=sys.stderr)
|
||||
|
||||
|
||||
class ApiConfigItem(BaseModel):
|
||||
"""单个 API 配置项"""
|
||||
id: Optional[str] = None
|
||||
name: Optional[str] = ""
|
||||
category: Optional[str] = None # mainLLM, imageModel, secondaryLLM, ragEmbedding
|
||||
apiUrl: Optional[str] = ""
|
||||
apiKey: Optional[str] = None # 前端传入的可能是明文或空
|
||||
model: Optional[str] = ""
|
||||
|
||||
# 生图模型的特殊字段
|
||||
mode: Optional[str] = None # 'local' | 'cloud'
|
||||
local: Optional[dict] = None
|
||||
cloud: Optional[dict] = None
|
||||
|
||||
|
||||
class ProfileSaveRequest(BaseModel):
|
||||
"""保存配置文件的请求"""
|
||||
profileId: str
|
||||
name: Optional[str] = None
|
||||
apis: Dict[str, ApiConfigItem] # key 是 category,value 是配置
|
||||
|
||||
|
||||
class ProfileResponse(BaseModel):
|
||||
"""配置文件响应(不包含明文 API Key)"""
|
||||
id: str
|
||||
name: str
|
||||
apis: Dict[str, dict] # apiKey 字段会被移除或脱敏
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def load_profile(profile_id: str) -> Optional[dict]:
|
||||
"""加载配置文件"""
|
||||
config_file = CONFIG_DIR / f"{profile_id}.json"
|
||||
if not config_file.exists():
|
||||
return None
|
||||
|
||||
with open(config_file, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def save_profile(profile_id: str, profile_data: dict):
|
||||
"""保存配置文件"""
|
||||
config_file = CONFIG_DIR / f"{profile_id}.json"
|
||||
with open(config_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(profile_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def list_profiles() -> List[dict]:
|
||||
"""列出所有配置文件"""
|
||||
profiles = []
|
||||
for config_file in CONFIG_DIR.glob("*.json"):
|
||||
try:
|
||||
with open(config_file, 'r', encoding='utf-8') as f:
|
||||
profile = json.load(f)
|
||||
profiles.append({
|
||||
"id": profile.get("id", config_file.stem),
|
||||
"name": profile.get("name", config_file.stem),
|
||||
"createdAt": profile.get("createdAt", "")
|
||||
})
|
||||
except Exception:
|
||||
continue
|
||||
return profiles
|
||||
|
||||
|
||||
@router.get("/profiles", response_model=List[dict])
|
||||
def get_all_profiles():
|
||||
"""获取所有配置文件列表"""
|
||||
return list_profiles()
|
||||
|
||||
|
||||
@router.get("/profiles/{profile_id}", response_model=ProfileResponse)
|
||||
def get_profile(profile_id: str):
|
||||
"""获取单个配置文件(明文存储,不返回 API Key)"""
|
||||
profile = load_profile(profile_id)
|
||||
if not profile:
|
||||
raise HTTPException(status_code=404, detail="配置文件不存在")
|
||||
|
||||
# 移除 API Key 字段,不返回给前端
|
||||
safe_apis = {}
|
||||
for category, api_config in profile.get("apis", {}).items():
|
||||
safe_config = api_config.copy()
|
||||
safe_config.pop("apiKey", None)
|
||||
safe_apis[category] = safe_config
|
||||
|
||||
return {
|
||||
"id": profile.get("id", profile_id),
|
||||
"name": profile.get("name", profile_id),
|
||||
"apis": safe_apis
|
||||
}
|
||||
|
||||
|
||||
@router.post("/profiles", response_model=ProfileResponse)
|
||||
def create_or_update_profile(request: ProfileSaveRequest):
|
||||
"""创建或更新配置文件(增量更新,明文存储 API Key)"""
|
||||
# 加载现有配置
|
||||
existing_profile = load_profile(request.profileId)
|
||||
|
||||
if existing_profile:
|
||||
# 更新现有配置:只更新提供的 API 配置
|
||||
for category, api_config in request.apis.items():
|
||||
api_config_dict = api_config.dict(exclude_none=True)
|
||||
|
||||
# 如果前端传入了空的 apiKey,保留原有的 key
|
||||
if api_config.apiKey == "" and category in existing_profile.get("apis", {}):
|
||||
existing_key = existing_profile["apis"][category].get("apiKey", "")
|
||||
if existing_key:
|
||||
api_config_dict["apiKey"] = existing_key
|
||||
|
||||
# 更新配置
|
||||
if "apis" not in existing_profile:
|
||||
existing_profile["apis"] = {}
|
||||
existing_profile["apis"][category] = api_config_dict
|
||||
|
||||
profile_data = existing_profile
|
||||
else:
|
||||
# 新建配置文件
|
||||
from datetime import datetime
|
||||
profile_data = {
|
||||
"id": request.profileId,
|
||||
"name": request.name or request.profileId,
|
||||
"createdAt": datetime.now().isoformat(),
|
||||
"apis": {}
|
||||
}
|
||||
|
||||
# 添加所有 API 配置(明文存储)
|
||||
for category, api_config in request.apis.items():
|
||||
api_config_dict = api_config.dict(exclude_none=True)
|
||||
profile_data["apis"][category] = api_config_dict
|
||||
|
||||
# 保存配置文件
|
||||
save_profile(request.profileId, profile_data)
|
||||
|
||||
# 返回不包含 API Key 的数据
|
||||
safe_apis = {}
|
||||
for category, api_config in profile_data.get("apis", {}).items():
|
||||
safe_config = api_config.copy()
|
||||
safe_config.pop("apiKey", None)
|
||||
safe_apis[category] = safe_config
|
||||
|
||||
return {
|
||||
"id": profile_data.get("id", request.profileId),
|
||||
"name": profile_data.get("name", request.profileId),
|
||||
"apis": safe_apis
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/profiles/{profile_id}")
|
||||
def delete_profile(profile_id: str):
|
||||
"""删除配置文件"""
|
||||
config_file = CONFIG_DIR / f"{profile_id}.json"
|
||||
if not config_file.exists():
|
||||
raise HTTPException(status_code=404, detail="配置文件不存在")
|
||||
|
||||
config_file.unlink()
|
||||
return {"message": "配置文件已删除"}
|
||||
|
||||
|
||||
@router.post("/test-connection")
|
||||
def test_connection(api_config: ApiConfigItem):
|
||||
"""测试 API 连接并获取模型列表"""
|
||||
try:
|
||||
api_key_to_use = api_config.apiKey or ""
|
||||
|
||||
# 如果 API Key 为空,尝试从已保存的配置中获取
|
||||
if not api_key_to_use and api_config.category:
|
||||
# 遍历所有配置文件,找到包含该 category 的配置
|
||||
for config_file in CONFIG_DIR.glob("*.json"):
|
||||
try:
|
||||
with open(config_file, 'r', encoding='utf-8') as f:
|
||||
profile = json.load(f)
|
||||
|
||||
# 检查是否包含该 category
|
||||
if api_config.category in profile.get("apis", {}):
|
||||
api_key_to_use = profile["apis"][api_config.category].get("apiKey", "")
|
||||
if api_key_to_use:
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# 检测提供商类型
|
||||
provider = LLMModelService.detect_provider(api_config.apiUrl)
|
||||
|
||||
# 获取模型列表
|
||||
models = LLMModelService.get_models_by_provider(
|
||||
provider=provider,
|
||||
api_key=api_key_to_use,
|
||||
api_url=api_config.apiUrl
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"models": models,
|
||||
"provider": provider,
|
||||
"message": f"成功获取 {len(models)} 个模型"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"获取模型列表失败: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
# ==================== ComfyUI Workflow Management ====================
|
||||
|
||||
@router.get("/comfyui/workflows", response_model=List[Dict[str, Any]])
|
||||
def get_comfyui_workflows():
|
||||
"""获取所有可用的 ComfyUI 工作流列表"""
|
||||
return workflow_manager.list_workflows()
|
||||
|
||||
|
||||
@router.post("/comfyui/workflows/upload")
|
||||
async def upload_comfyui_workflow(file: UploadFile = File(...)):
|
||||
"""上传 ComfyUI 工作流 JSON 文件"""
|
||||
return await workflow_manager.upload_workflow(file)
|
||||
|
||||
|
||||
@router.delete("/comfyui/workflows/{filename}")
|
||||
def delete_comfyui_workflow(filename: str):
|
||||
"""删除 ComfyUI 工作流文件"""
|
||||
return workflow_manager.delete_workflow(filename)
|
||||
|
||||
|
||||
@router.get("/comfyui/workflows/{filename}")
|
||||
def get_comfyui_workflow(filename: str):
|
||||
"""获取指定工作流的详细内容"""
|
||||
return workflow_manager.load_workflow(filename)
|
||||
|
||||
|
||||
# ==================== Connection Testing ====================
|
||||
|
||||
@router.post("/test-comfyui-connection")
|
||||
def test_comfyui_connection(request: dict):
|
||||
"""测试 ComfyUI 连接"""
|
||||
import requests as req
|
||||
|
||||
api_url = request.get("apiUrl", "http://comfyui:8188")
|
||||
|
||||
try:
|
||||
# 测试基本连通性
|
||||
response = req.get(f"{api_url}/system_stats", timeout=5)
|
||||
|
||||
if response.status_code != 200:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"HTTP {response.status_code}"
|
||||
}
|
||||
|
||||
stats = response.json()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "连接成功",
|
||||
"stats": {
|
||||
"vram_total": stats.get("vram_total", 0),
|
||||
"vram_free": stats.get("vram_free", 0),
|
||||
"torch_version": stats.get("torch_version", ""),
|
||||
"device": stats.get("device", "")
|
||||
}
|
||||
}
|
||||
|
||||
except req.exceptions.ConnectionError:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "无法连接到 ComfyUI,请检查地址和端口"
|
||||
}
|
||||
except req.exceptions.Timeout:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "连接超时,请检查 ComfyUI 是否正常运行"
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"错误: {str(e)}"
|
||||
}
|
||||
|
||||
|
||||
@router.post("/test-cloud-connection")
|
||||
def test_cloud_connection(request: dict):
|
||||
"""测试云端 API 连接"""
|
||||
import openai
|
||||
|
||||
provider = request.get("provider", "dall-e")
|
||||
api_key = request.get("apiKey", "")
|
||||
model = request.get("model", "dall-e-3")
|
||||
|
||||
if not api_key:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "API Key 不能为空"
|
||||
}
|
||||
|
||||
try:
|
||||
if provider == "dall-e":
|
||||
# 测试 DALL-E
|
||||
client = openai.OpenAI(api_key=api_key)
|
||||
|
||||
# 尝试获取模型列表(轻量级测试)
|
||||
models = client.models.list()
|
||||
|
||||
# 检查指定的模型是否存在
|
||||
model_exists = any(m.id == model for m in models.data)
|
||||
|
||||
if model_exists:
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"连接成功,模型 {model} 可用"
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"模型 {model} 不可用"
|
||||
}
|
||||
|
||||
elif provider == "stability":
|
||||
# 测试 Stability AI
|
||||
import requests as req
|
||||
|
||||
response = req.get(
|
||||
"https://api.stability.ai/v1/engines/list",
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}"
|
||||
},
|
||||
timeout=5
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return {
|
||||
"success": True,
|
||||
"message": "连接成功"
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"HTTP {response.status_code}: {response.text}"
|
||||
}
|
||||
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"不支持的提供商: {provider}"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"连接失败: {str(e)}"
|
||||
}
|
||||
292
backend/api/routes/charactersRoute.py
Normal file
292
backend/api/routes/charactersRoute.py
Normal file
@@ -0,0 +1,292 @@
|
||||
"""
|
||||
角色卡 API 路由
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from typing import List
|
||||
from pathlib import Path
|
||||
import io
|
||||
|
||||
try:
|
||||
from backend.services.character_service import CharacterService
|
||||
except ImportError:
|
||||
from services.character_service import CharacterService
|
||||
|
||||
router = APIRouter(prefix="/characters", tags=["characters"])
|
||||
character_service = CharacterService()
|
||||
|
||||
|
||||
@router.get("/", response_model=List[dict])
|
||||
async def list_characters():
|
||||
"""
|
||||
获取所有角色卡列表
|
||||
|
||||
Returns:
|
||||
按最后聊天时间排序的角色卡列表
|
||||
"""
|
||||
characters = character_service.scan_all_characters()
|
||||
return [c.dict() for c in characters]
|
||||
|
||||
|
||||
@router.get("/{name}", response_model=dict)
|
||||
async def get_character(name: str):
|
||||
"""
|
||||
获取指定角色卡
|
||||
|
||||
Args:
|
||||
name: 角色名(URL编码)
|
||||
"""
|
||||
character = character_service.get_character_by_name(name)
|
||||
if not character:
|
||||
raise HTTPException(status_code=404, detail=f"角色 '{name}' 不存在")
|
||||
|
||||
return character.dict()
|
||||
|
||||
|
||||
@router.post("/", response_model=dict)
|
||||
async def create_character(character_data: dict):
|
||||
"""
|
||||
创建新角色卡
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"name": "角色名",
|
||||
"description": "描述",
|
||||
"personality": "性格",
|
||||
"scenario": "场景",
|
||||
"first_mes": "开场白",
|
||||
"categories": ["分类1", "分类2"],
|
||||
"tags": ["tag1", "tag2"]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
character = character_service.create_character(character_data)
|
||||
return {
|
||||
"success": True,
|
||||
"character": character.dict()
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/{name}", response_model=dict)
|
||||
async def update_character(name: str, updates: dict):
|
||||
"""
|
||||
更新角色卡
|
||||
|
||||
Args:
|
||||
name: 角色名
|
||||
updates: 要更新的字段
|
||||
"""
|
||||
try:
|
||||
character = character_service.update_character(name, updates)
|
||||
return {
|
||||
"success": True,
|
||||
"character": character.dict()
|
||||
}
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"角色 '{name}' 不存在")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/{name}")
|
||||
async def delete_character(name: str):
|
||||
"""
|
||||
删除角色卡及其所有聊天记录
|
||||
"""
|
||||
success = character_service.delete_character(name)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail=f"角色 '{name}' 不存在")
|
||||
|
||||
return {"success": True, "message": f"角色 '{name}' 已删除"}
|
||||
|
||||
|
||||
@router.get("/{name}/avatar")
|
||||
async def get_avatar(name: str):
|
||||
"""
|
||||
获取角色头像
|
||||
|
||||
Returns:
|
||||
PNG 图片文件或 404
|
||||
"""
|
||||
char_folder = character_service.characters_dir / name
|
||||
avatar_file = char_folder / "avatar.png"
|
||||
|
||||
if not avatar_file.exists():
|
||||
# 返回默认头像
|
||||
default_avatar = Path("data/images/avatars/fallback.png")
|
||||
if default_avatar.exists():
|
||||
return FileResponse(default_avatar, media_type="image/png")
|
||||
raise HTTPException(status_code=404, detail="头像不存在")
|
||||
|
||||
return FileResponse(avatar_file, media_type="image/png")
|
||||
|
||||
|
||||
@router.post("/{name}/avatar")
|
||||
async def upload_avatar(name: str, file: UploadFile = File(...)):
|
||||
"""
|
||||
上传角色头像
|
||||
|
||||
Args:
|
||||
name: 角色名
|
||||
file: PNG 图片文件
|
||||
"""
|
||||
# 验证文件类型
|
||||
if not file.content_type.startswith('image/'):
|
||||
raise HTTPException(status_code=400, detail="只支持图片文件")
|
||||
|
||||
# 检查角色是否存在
|
||||
character = character_service.get_character_by_name(name)
|
||||
if not character:
|
||||
raise HTTPException(status_code=404, detail=f"角色 '{name}' 不存在")
|
||||
|
||||
# 保存图片
|
||||
image_data = await file.read()
|
||||
avatar_path = character_service.save_avatar(name, image_data)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"avatar_path": avatar_path
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{name}/chats")
|
||||
async def list_chats(name: str):
|
||||
"""
|
||||
获取角色的所有聊天列表
|
||||
|
||||
Returns:
|
||||
聊天文件列表(包含最后一条消息预览)
|
||||
"""
|
||||
char_folder = character_service.characters_dir / name
|
||||
chats_dir = char_folder / "chats"
|
||||
|
||||
if not chats_dir.exists():
|
||||
return {"chats": []}
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
chats = []
|
||||
for chat_file in chats_dir.glob("*.jsonl"):
|
||||
try:
|
||||
with open(chat_file, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
if not lines:
|
||||
continue
|
||||
|
||||
# 第一行是header
|
||||
header = json.loads(lines[0])
|
||||
|
||||
# 计算消息数量(排除header)
|
||||
message_count = len(lines) - 1
|
||||
|
||||
# 获取最后修改时间
|
||||
last_modified = datetime.fromtimestamp(
|
||||
chat_file.stat().st_mtime
|
||||
).isoformat()
|
||||
|
||||
# 获取最后一条消息预览
|
||||
last_message = ""
|
||||
if message_count > 0:
|
||||
try:
|
||||
last_msg_data = json.loads(lines[-1])
|
||||
last_message = last_msg_data.get("mes", "")
|
||||
except:
|
||||
pass
|
||||
|
||||
chats.append({
|
||||
"chat_name": chat_file.stem,
|
||||
"user_name": header.get("user_name", "User"),
|
||||
"character_name": header.get("character_name", ""),
|
||||
"last_modified": last_modified,
|
||||
"message_count": message_count,
|
||||
"last_message": last_message
|
||||
})
|
||||
except Exception as e:
|
||||
# 如果解析失败,使用基本信息
|
||||
chats.append({
|
||||
"chat_name": chat_file.stem,
|
||||
"last_modified": datetime.fromtimestamp(chat_file.stat().st_mtime).isoformat(),
|
||||
"message_count": 0,
|
||||
"last_message": ""
|
||||
})
|
||||
|
||||
# 按修改时间排序
|
||||
chats.sort(key=lambda c: c.get('last_modified', ''), reverse=True)
|
||||
|
||||
return {"chats": chats}
|
||||
|
||||
|
||||
@router.post("/import")
|
||||
async def import_character(file: UploadFile = File(...)):
|
||||
"""
|
||||
导入角色卡(支持 PNG 或 JSON)
|
||||
|
||||
- PNG: 自动提取嵌入数据,创建文件夹
|
||||
- JSON: 创建文件夹并保存
|
||||
"""
|
||||
content = await file.read()
|
||||
filename = file.filename
|
||||
|
||||
if filename.endswith('.png'):
|
||||
# 导入 PNG
|
||||
try:
|
||||
character = character_service.import_from_png(content, filename)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"character": character.dict(),
|
||||
"format": "png_embedded"
|
||||
}
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"导入失败: {str(e)}")
|
||||
|
||||
elif filename.endswith('.json'):
|
||||
# 导入 JSON
|
||||
try:
|
||||
import json
|
||||
data = json.loads(content.decode('utf-8'))
|
||||
|
||||
character = character_service.create_character(data)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"character": character.dict(),
|
||||
"format": "json"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"导入失败: {str(e)}")
|
||||
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="不支持的文件格式")
|
||||
|
||||
|
||||
@router.post("/{name}/export/png")
|
||||
async def export_character_as_png(name: str):
|
||||
"""
|
||||
导出角色为 SillyTavern PNG 格式
|
||||
|
||||
Returns:
|
||||
PNG 文件下载
|
||||
"""
|
||||
try:
|
||||
png_data = character_service.export_as_png(name)
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(png_data),
|
||||
media_type="image/png",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={name}.png"
|
||||
}
|
||||
)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"角色 '{name}' 不存在")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"导出失败: {str(e)}")
|
||||
154
backend/api/routes/chatSummaryRoute.py
Normal file
154
backend/api/routes/chatSummaryRoute.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
聊天总结 API 路由
|
||||
|
||||
处理聊天记录的总结请求
|
||||
"""
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Body
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from services.chat_service import chat_service
|
||||
from services.chat_summary_service import chat_summary_service
|
||||
from models.internal import SummaryConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/chats", tags=["chat-summary"])
|
||||
|
||||
|
||||
@router.post("/{role_name}/{chat_name}/summarize")
|
||||
async def summarize_chat_history(
|
||||
role_name: str,
|
||||
chat_name: str,
|
||||
request_data: Dict[str, Any] = Body(...)
|
||||
):
|
||||
"""
|
||||
总结聊天历史记录
|
||||
|
||||
Args:
|
||||
role_name: 角色名称
|
||||
chat_name: 聊天名称
|
||||
request_data: {
|
||||
"startFloor": int, # 总结起始楼层
|
||||
"endFloor": int, # 总结结束楼层
|
||||
"summaryConfig": {...}, # 总结配置
|
||||
"apiConfig": {...} # API配置
|
||||
}
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": bool,
|
||||
"summaryText": str, # 总结文本
|
||||
"startFloor": int,
|
||||
"endFloor": int,
|
||||
"message": str
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# 1. 提取请求参数
|
||||
start_floor = request_data.get("startFloor")
|
||||
end_floor = request_data.get("endFloor")
|
||||
summary_config_data = request_data.get("summaryConfig", {})
|
||||
api_config = request_data.get("apiConfig", {})
|
||||
|
||||
if not start_floor or not end_floor:
|
||||
raise HTTPException(status_code=400, detail="缺少 startFloor 或 endFloor 参数")
|
||||
|
||||
# 2. 加载聊天记录
|
||||
chat_log = chat_service.get_chat_log(role_name, chat_name)
|
||||
if not chat_log:
|
||||
raise HTTPException(status_code=404, detail=f"聊天记录 '{role_name}/{chat_name}' 不存在")
|
||||
|
||||
messages = chat_log.messages
|
||||
total_messages = len(messages)
|
||||
|
||||
# 3. 验证楼层范围
|
||||
if start_floor < 1 or end_floor > total_messages or start_floor > end_floor:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"无效的楼层范围: {start_floor}-{end_floor}(总共{total_messages}条消息)"
|
||||
)
|
||||
|
||||
# 4. 构建SummaryConfig对象
|
||||
summary_config = SummaryConfig(**summary_config_data)
|
||||
|
||||
logger.info(
|
||||
f"[ChatSummary] 开始总结: {role_name}/{chat_name}, "
|
||||
f"楼层范围: {start_floor}-{end_floor}, "
|
||||
f"包含用户输入: {summary_config.includeUserInput}"
|
||||
)
|
||||
|
||||
# 5. 调用总结服务
|
||||
summary_text = await chat_summary_service.summarize_messages(
|
||||
messages=messages,
|
||||
start_floor=start_floor,
|
||||
end_floor=end_floor,
|
||||
summary_config=summary_config,
|
||||
api_config=api_config
|
||||
)
|
||||
|
||||
if not summary_text:
|
||||
raise HTTPException(status_code=500, detail="总结生成失败")
|
||||
|
||||
logger.info(f"[ChatSummary] 总结完成,长度: {len(summary_text)} 字符")
|
||||
|
||||
# 6. 更新聊天记录(清空原文 + 替换总结)
|
||||
chat_service.summarize_chat_messages(
|
||||
role_name=role_name,
|
||||
chat_name=chat_name,
|
||||
start_floor=start_floor,
|
||||
end_floor=end_floor,
|
||||
summary_text=summary_text
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"summaryText": summary_text,
|
||||
"startFloor": start_floor,
|
||||
"endFloor": end_floor,
|
||||
"message": f"成功总结 {end_floor - start_floor + 1} 条消息"
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[ChatSummary] 总结失败: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
raise HTTPException(status_code=500, detail=f"总结失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/{role_name}/{chat_name}/summary-status")
|
||||
async def get_summary_status(role_name: str, chat_name: str):
|
||||
"""
|
||||
获取聊天总结状态
|
||||
|
||||
Returns:
|
||||
{
|
||||
"historyMode": str,
|
||||
"summaryCounter": int,
|
||||
"lastSummaryFloor": int,
|
||||
"summaryConfig": {...}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
chat_log = chat_service.get_chat_log(role_name, chat_name)
|
||||
if not chat_log:
|
||||
raise HTTPException(status_code=404, detail="聊天记录不存在")
|
||||
|
||||
header = chat_log.header
|
||||
|
||||
return {
|
||||
"historyMode": header.historyMode.value if hasattr(header.historyMode, 'value') else header.historyMode,
|
||||
"summaryCounter": header.summaryCounter or 0,
|
||||
"lastSummaryFloor": getattr(header, 'lastSummaryFloor', 0),
|
||||
"summaryConfig": header.summaryConfig.dict() if header.summaryConfig else None
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[ChatSummary] 获取总结状态失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
424
backend/api/routes/chatWsRoute.py
Normal file
424
backend/api/routes/chatWsRoute.py
Normal file
@@ -0,0 +1,424 @@
|
||||
"""
|
||||
聊天 WebSocket 路由
|
||||
处理实时对话生成
|
||||
"""
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
from typing import Dict, Any
|
||||
import json
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
from backend.services.chat_workflow_service import ChatWorkflowService
|
||||
from backend.services.chat_service import ChatService
|
||||
from backend.services.task_queue_manager import task_queue_manager
|
||||
from backend.core.config import settings
|
||||
except ImportError:
|
||||
from services.chat_workflow_service import ChatWorkflowService
|
||||
from services.chat_service import ChatService
|
||||
from services.task_queue_manager import task_queue_manager
|
||||
from core.config import settings
|
||||
|
||||
router = APIRouter(prefix="/chat", tags=["chat-websocket"])
|
||||
|
||||
# 初始化服务
|
||||
workflow_service = ChatWorkflowService()
|
||||
chat_service = ChatService(settings.DATA_PATH)
|
||||
|
||||
# ✅ 全局变量:用于存储需要中断的聊天会话
|
||||
interrupt_flags: Dict[str, bool] = {}
|
||||
|
||||
|
||||
@router.websocket("/{role_name}/{chat_name}/ws")
|
||||
async def websocket_chat_endpoint(
|
||||
websocket: WebSocket,
|
||||
role_name: str,
|
||||
chat_name: str
|
||||
):
|
||||
"""
|
||||
WebSocket 聊天端点
|
||||
|
||||
接收前端发送的完整对话请求,调用工作流生成回复,支持流式输出
|
||||
"""
|
||||
await websocket.accept()
|
||||
print(f"\n{'='*80}")
|
||||
print(f"[WebSocket] 📡 连接建立: {role_name}/{chat_name}")
|
||||
print(f"{'='*80}\n")
|
||||
|
||||
chat_id = f"{role_name}/{chat_name}"
|
||||
|
||||
try:
|
||||
while True:
|
||||
# 1. 接收前端消息
|
||||
print(f"[WebSocket] ⏳ 等待接收消息...")
|
||||
data = await websocket.receive_text()
|
||||
print(f"[WebSocket] ✅ 收到消息,长度: {len(data)}")
|
||||
request_data = json.loads(data)
|
||||
|
||||
# ✅ 检查是否是取消任务的请求
|
||||
if request_data.get("type") == "cancel_task":
|
||||
task_id = request_data.get("taskId")
|
||||
print(f"[WebSocket] ❌ 收到取消任务请求: {task_id}")
|
||||
|
||||
# ✅ 特殊处理:如果是 LLM 生成任务,需要中断当前流式生成
|
||||
if task_id == "current_llm_generation":
|
||||
print(f"[WebSocket] 🛑 正在终止 LLM 流式生成...")
|
||||
# TODO: 实现 LLM 生成的中断逻辑
|
||||
# 目前只能通过关闭连接来终止
|
||||
|
||||
await websocket.send_json({
|
||||
"type": "task_cancelled",
|
||||
"taskId": task_id,
|
||||
"success": True,
|
||||
"message": "LLM 生成已终止"
|
||||
})
|
||||
else:
|
||||
# 取消其他类型的任务(图像生成、动态表格等)
|
||||
success = await task_queue_manager.cancel_task(task_id)
|
||||
|
||||
await websocket.send_json({
|
||||
"type": "task_cancelled",
|
||||
"taskId": task_id,
|
||||
"success": success
|
||||
})
|
||||
print(f"[WebSocket] ✅ 任务取消结果: {success}")
|
||||
continue
|
||||
|
||||
print(f"\n{'-'*80}")
|
||||
print(f"[WebSocket] 📨 收到请求:")
|
||||
print(f" - Floor: {request_data.get('floor')}")
|
||||
print(f" - Role: {request_data.get('currentRole')}")
|
||||
print(f" - Chat: {request_data.get('currentChat')}")
|
||||
print(f" - Stream: {request_data.get('stream', False)}")
|
||||
print(f" - Message Length: {len(request_data.get('mes', ''))}")
|
||||
|
||||
# ✅ 打印 API 配置信息(隐藏密钥)
|
||||
api_config = request_data.get('apiConfig', {})
|
||||
current_profile = request_data.get('currentProfile', {})
|
||||
profile_id = current_profile.get('id') if isinstance(current_profile, dict) else None
|
||||
|
||||
print(f" - Profile ID: {profile_id or 'N/A'}")
|
||||
print(f" - API URL: {api_config.get('api_url', 'N/A')[:50]}..." if len(api_config.get('api_url', '')) > 50 else f" - API URL: {api_config.get('api_url', 'N/A')}")
|
||||
print(f" - Model: {api_config.get('model', 'N/A')}")
|
||||
|
||||
# ✅ 始终从配置文件中读取 API Key(不信任前端传来的 Key)
|
||||
if profile_id:
|
||||
try:
|
||||
from .apiConfigRoute import load_profile
|
||||
|
||||
profile = load_profile(profile_id)
|
||||
if profile:
|
||||
# 找到 mainLLM 的配置
|
||||
main_llm_config = profile.get('apis', {}).get('mainLLM', {})
|
||||
api_key = main_llm_config.get('apiKey', '')
|
||||
|
||||
if api_key:
|
||||
# 使用明文 API Key
|
||||
api_config['api_key'] = api_key
|
||||
request_data['apiConfig'] = api_config
|
||||
print(f" - API Key: ✅ 已从配置文件加载")
|
||||
else:
|
||||
print(f" - API Key: ⚠️ 配置文件中未找到 Key")
|
||||
else:
|
||||
print(f" - API Key: ❌ 无法加载配置文件: {profile_id}")
|
||||
except Exception as e:
|
||||
print(f" - API Key: ❌ 加载失败: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
else:
|
||||
print(f" - API Key: ⚠️ 未提供 profileId,无法加载")
|
||||
|
||||
print(f"{'-'*80}\n")
|
||||
|
||||
# 2. 提取流式输出标志
|
||||
stream_output = request_data.get("stream", False)
|
||||
|
||||
if stream_output:
|
||||
# === 真正的流式输出模式 ===
|
||||
print(f"[WebSocket] 🌊 进入流式处理模式")
|
||||
await _handle_stream_chat(
|
||||
websocket, role_name, chat_name, request_data, workflow_service
|
||||
)
|
||||
else:
|
||||
# === 非流式输出模式 ===
|
||||
print(f"[WebSocket] 📦 进入非流式处理模式")
|
||||
result = await workflow_service.process_chat_request(request_data)
|
||||
|
||||
if result["success"]:
|
||||
content = result["content"]
|
||||
|
||||
print(f"\n[WebSocket] ✨ 生成成功,内容长度: {len(content)}")
|
||||
|
||||
# ✅ 发送激活的世界书条目信息
|
||||
active_entries = result.get("activeEntries", [])
|
||||
print(f"[WebSocket] 📚 发送世界书激活信息: {len(active_entries)} 个条目")
|
||||
await websocket.send_json({
|
||||
"type": "worldbook_active",
|
||||
"entries": active_entries
|
||||
})
|
||||
|
||||
# ✅ 发送任务ID信息
|
||||
task_ids = result.get("taskIds", {})
|
||||
if task_ids.get("imageWorkflow") or task_ids.get("dynamicTable"):
|
||||
print(f"[WebSocket] 📋 发送任务ID信息: {task_ids}")
|
||||
await websocket.send_json({
|
||||
"type": "tasks_created",
|
||||
"tasks": task_ids
|
||||
})
|
||||
|
||||
# 一次性发送完整内容
|
||||
print(f"[WebSocket] 📤 发送完整内容 (chunk)")
|
||||
await websocket.send_json({
|
||||
"type": "chunk",
|
||||
"content": content
|
||||
})
|
||||
|
||||
print(f"[WebSocket] ✅ 发送完成信号")
|
||||
await websocket.send_json({
|
||||
"type": "complete"
|
||||
})
|
||||
|
||||
# 保存消息
|
||||
print(f"[WebSocket] 💾 保存消息到文件...")
|
||||
await _save_messages(role_name, chat_name, request_data, content)
|
||||
print(f"[WebSocket] ✅ 消息保存完成\n")
|
||||
else:
|
||||
error_msg = result["error"]
|
||||
print(f"[WebSocket] ❌ 处理失败: {error_msg}")
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": error_msg
|
||||
})
|
||||
|
||||
except WebSocketDisconnect:
|
||||
print(f"\n{'='*80}")
|
||||
print(f"[WebSocket] 🔌 连接断开: {role_name}/{chat_name}")
|
||||
print(f"{'='*80}\n")
|
||||
except Exception as e:
|
||||
print(f"\n{'='*80}")
|
||||
print(f"[WebSocket] ⚠️ 错误: {str(e)}")
|
||||
print(f"{'='*80}\n")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
try:
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": f"服务器错误: {str(e)}"
|
||||
})
|
||||
except:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
await websocket.close()
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
async def _handle_stream_chat(
|
||||
websocket: WebSocket,
|
||||
role_name: str,
|
||||
chat_name: str,
|
||||
request_data: Dict[str, Any],
|
||||
workflow_service
|
||||
):
|
||||
"""
|
||||
处理流式聊天请求 – engine callbacks emit worldbook_active / tasks_created / chunk.
|
||||
"""
|
||||
try:
|
||||
print(f"[StreamChat] 🚀 开始流式处理")
|
||||
|
||||
chunk_count = [0]
|
||||
|
||||
async def on_worldbook_active(entries):
|
||||
if entries:
|
||||
print(f"[StreamChat] 📤 发送世界书激活信息: {len(entries)} 个条目")
|
||||
await websocket.send_json({
|
||||
"type": "worldbook_active",
|
||||
"entries": entries,
|
||||
})
|
||||
|
||||
async def on_tasks_created(task_ids):
|
||||
if task_ids.get("imageWorkflow") or task_ids.get("dynamicTable"):
|
||||
print(f"[StreamChat] 📤 发送任务ID信息: {task_ids}")
|
||||
await websocket.send_json({
|
||||
"type": "tasks_created",
|
||||
"tasks": task_ids,
|
||||
})
|
||||
|
||||
async def on_chunk(chunk):
|
||||
chunk_count[0] += 1
|
||||
if chunk_count[0] % 10 == 0:
|
||||
print(f"[StreamChat] 📤 已发送 {chunk_count[0]} 个 chunks")
|
||||
await websocket.send_json({"type": "chunk", "content": chunk})
|
||||
|
||||
result = await workflow_service.process_chat_request_stream(
|
||||
request_data,
|
||||
on_chunk=on_chunk,
|
||||
on_worldbook_active=on_worldbook_active,
|
||||
on_tasks_created=on_tasks_created,
|
||||
)
|
||||
|
||||
if result["success"]:
|
||||
content = result["content"]
|
||||
print(f"\n[StreamChat] ✨ 流式生成成功,总长度: {len(content)}")
|
||||
print(f"[StreamChat] ✅ 发送完成信号")
|
||||
await websocket.send_json({"type": "complete"})
|
||||
print(f"[StreamChat] 💾 保存消息到文件...")
|
||||
await _save_messages(role_name, chat_name, request_data, content)
|
||||
print(f"[StreamChat] ✅ 消息保存完成\n")
|
||||
else:
|
||||
error_msg = result["error"]
|
||||
print(f"[StreamChat] ❌ 流式处理失败: {error_msg}")
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": error_msg,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n[StreamChat] ⚠️ 错误: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": f"流式处理失败: {str(e)}",
|
||||
})
|
||||
|
||||
|
||||
async def _save_messages(
|
||||
role_name: str,
|
||||
chat_name: str,
|
||||
request_data: Dict[str, Any],
|
||||
ai_response: str
|
||||
):
|
||||
"""
|
||||
保存用户消息和AI回复到聊天文件
|
||||
|
||||
Args:
|
||||
role_name: 角色名
|
||||
chat_name: 聊天名
|
||||
request_data: 前端发送的请求数据
|
||||
ai_response: AI生成的回复
|
||||
"""
|
||||
try:
|
||||
from datetime import datetime
|
||||
|
||||
# ✅ 应用双 false 的正则规则(永久修改存储数据)
|
||||
from services.regex_service import regex_service
|
||||
from models.regex_rules import RegexPlacement
|
||||
|
||||
# 获取预设名称
|
||||
preset_config = request_data.get("presetConfig", {})
|
||||
preset_name = preset_config.get("selectedPreset")
|
||||
|
||||
# 计算消息深度
|
||||
floor = request_data.get("floor", 0)
|
||||
message_depth = 0 # AI 回复是最新消息,深度为 0
|
||||
|
||||
# ✅ 应用 AI Output 正则规则(placement=2)
|
||||
# 只应用双 false 的规则(markdownOnly=false 且 promptOnly=false)
|
||||
processed_ai_response = regex_service.apply_rules_by_placement(
|
||||
text=ai_response,
|
||||
placement=RegexPlacement.AI_OUTPUT.value,
|
||||
character_name=role_name,
|
||||
preset_name=preset_name,
|
||||
message_depth=message_depth,
|
||||
is_for_llm=False, # ✅ 不是发送给 LLM,是保存数据
|
||||
is_markdown_rendered=False # ✅ 不是 Markdown 渲染后
|
||||
)
|
||||
|
||||
# 如果处理后的内容与原始内容不同,说明有双 false 规则被应用
|
||||
if processed_ai_response != ai_response:
|
||||
print(f"[Regex] ✅ 已应用双 false 正则规则(永久修改存储数据)")
|
||||
ai_response = processed_ai_response
|
||||
|
||||
# ✅ 检查是否是重roll模式(targetFloor 存在且不为 null)
|
||||
target_floor = request_data.get("floor")
|
||||
is_reroll = target_floor is not None
|
||||
|
||||
if is_reroll:
|
||||
# ✅ 重roll模式:更新现有消息的 swipes 数组
|
||||
print(f"[WebSocket] 🔄 重roll模式,更新楼层 {target_floor} 的 swipes")
|
||||
|
||||
# 获取现有的消息
|
||||
existing_message = chat_service.get_message(role_name, chat_name, target_floor)
|
||||
|
||||
if not existing_message:
|
||||
print(f"[WebSocket] ⚠️ 找不到楼层 {target_floor} 的消息,创建新消息")
|
||||
# 如果找不到,创建新消息(兼容处理)
|
||||
ai_message = {
|
||||
"id": f"msg_{datetime.now().timestamp()}_ai",
|
||||
"name": request_data.get("characterName", role_name),
|
||||
"is_user": False,
|
||||
"is_system": False,
|
||||
"sendDate": datetime.now().isoformat(),
|
||||
"mes": ai_response,
|
||||
"chatId": f"{role_name}/{chat_name}",
|
||||
"floor": target_floor,
|
||||
"swipes": [ai_response],
|
||||
"swipe_id": 0
|
||||
}
|
||||
chat_service.add_message(role_name, chat_name, ai_message)
|
||||
else:
|
||||
# ✅ 更新 swipes 数组
|
||||
existing_swipes = existing_message.get("swipes", [])
|
||||
current_mes = existing_message.get("mes", "")
|
||||
|
||||
# 构建新的 swipes 数组
|
||||
updated_swipes = list(existing_swipes) # 复制现有swipes
|
||||
|
||||
# 如果当前 mes 不在 swipes 中,先添加它
|
||||
if current_mes and current_mes not in updated_swipes:
|
||||
updated_swipes.append(current_mes)
|
||||
print(f"[WebSocket] 📝 将当前内容添加到 swipes")
|
||||
|
||||
# 添加新生成的内容
|
||||
updated_swipes.append(ai_response)
|
||||
print(f"[WebSocket] 📊 Swipes 更新: {len(existing_swipes)} -> {len(updated_swipes)}")
|
||||
|
||||
# 更新消息
|
||||
update_data = {
|
||||
"mes": ai_response, # 显示最新内容
|
||||
"swipes": updated_swipes, # 更新 swipes 数组
|
||||
"swipe_id": len(updated_swipes) - 1 # 自动切换到新版本
|
||||
}
|
||||
|
||||
chat_service.update_message(role_name, chat_name, target_floor, update_data)
|
||||
print(f"[WebSocket] ✅ 楼层 {target_floor} 已更新,swipes 数量: {len(updated_swipes)}")
|
||||
else:
|
||||
# ✅ 正常模式:创建新的用户消息和AI消息
|
||||
print(f"[WebSocket] ➕ 正常模式,创建新消息")
|
||||
|
||||
# 1. 保存用户消息
|
||||
user_message = {
|
||||
"id": f"msg_{datetime.now().timestamp()}_user",
|
||||
"name": request_data.get("userName", "User"),
|
||||
"is_user": True,
|
||||
"is_system": False,
|
||||
"sendDate": datetime.now().isoformat(),
|
||||
"mes": request_data.get("mes", ""),
|
||||
"chatId": f"{role_name}/{chat_name}",
|
||||
"floor": request_data.get("floor", 0)
|
||||
}
|
||||
|
||||
chat_service.add_message(role_name, chat_name, user_message)
|
||||
|
||||
# 2. 保存AI回复
|
||||
ai_message = {
|
||||
"id": f"msg_{datetime.now().timestamp()}_ai",
|
||||
"name": request_data.get("characterName", role_name),
|
||||
"is_user": False,
|
||||
"is_system": False,
|
||||
"sendDate": datetime.now().isoformat(),
|
||||
"mes": ai_response,
|
||||
"chatId": f"{role_name}/{chat_name}",
|
||||
"floor": request_data.get("floor", 0) + 1
|
||||
}
|
||||
|
||||
chat_service.add_message(role_name, chat_name, ai_message)
|
||||
|
||||
print(f"[WebSocket] ✅ 新消息已保存: {role_name}/{chat_name}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[WebSocket] 保存消息失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
# 不抛出异常,避免影响主流程
|
||||
@@ -1,80 +1,104 @@
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from backend.core.models.chat_history import ChatHistory, Message
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from backend.services.chat_service import ChatService
|
||||
from backend.core.config import settings
|
||||
except ImportError:
|
||||
# Docker环境:直接从当前目录导入
|
||||
from services.chat_service import ChatService
|
||||
from core.config import settings
|
||||
|
||||
router = APIRouter(prefix="/chat", tags=["chat"])
|
||||
|
||||
# 初始化聊天服务
|
||||
data_path = Path(settings.DATA_PATH) if hasattr(settings, 'DATA_PATH') else Path("data")
|
||||
chat_service = ChatService(data_path)
|
||||
|
||||
# ========== 聊天历史基础路由 ==========
|
||||
|
||||
@router.get("", response_model=dict)
|
||||
async def list_all_chats():
|
||||
"""获取所有角色的所有聊天列表"""
|
||||
return await ChatHistory.list_all_chats()
|
||||
return chat_service.list_all_chats()
|
||||
|
||||
|
||||
# 注意:路由定义顺序很重要!更具体的路由(更多参数)必须放在前面
|
||||
@router.get("/{role_name}/{chat_name}")
|
||||
async def get_chat(role_name: str, chat_name: str):
|
||||
"""获取指定聊天的完整内容"""
|
||||
try:
|
||||
return await ChatHistory.get_chat(role_name, chat_name)
|
||||
return chat_service.get_chat(role_name, chat_name)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail="Chat not found")
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{role_name}")
|
||||
async def list_role_chats(role_name: str):
|
||||
"""获取指定角色的所有聊天列表"""
|
||||
try:
|
||||
all_chats = chat_service.list_all_chats()
|
||||
# 从所有聊天中筛选出该角色的聊天
|
||||
role_chats = all_chats.get(role_name, [])
|
||||
return role_chats
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{role_name}", status_code=status.HTTP_201_CREATED)
|
||||
async def create_chat(role_name: str, chat_name: str, metadata: dict = None):
|
||||
async def create_chat(role_name: str, chat_data: dict):
|
||||
"""创建新聊天"""
|
||||
try:
|
||||
return await ChatHistory.create_chat(role_name, chat_name, metadata)
|
||||
except FileExistsError:
|
||||
raise HTTPException(status_code=400, detail="Chat already exists")
|
||||
chat_name = chat_data.get("chat_name", "新聊天")
|
||||
metadata = chat_data.get("metadata", {})
|
||||
return chat_service.create_chat(role_name, chat_name, metadata)
|
||||
except FileExistsError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/{role_name}/{chat_name}")
|
||||
async def update_chat(role_name: str, chat_name: str, update_data: dict):
|
||||
"""更新聊天元数据"""
|
||||
try:
|
||||
return await ChatHistory.update_chat(role_name, chat_name, update_data)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Chat not found")
|
||||
# TODO: 实现更新聊天元数据功能
|
||||
raise HTTPException(status_code=501, detail="Not Implemented")
|
||||
|
||||
|
||||
@router.delete("/{role_name}/{chat_name}")
|
||||
async def delete_chat(role_name: str, chat_name: str):
|
||||
"""删除指定聊天"""
|
||||
try:
|
||||
return await ChatHistory.delete_chat(role_name, chat_name)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Chat not found")
|
||||
# TODO: 实现删除聊天功能
|
||||
raise HTTPException(status_code=501, detail="Not Implemented")
|
||||
|
||||
|
||||
# ========== 聊天消息路由 ==========
|
||||
|
||||
@router.get("/{role_name}/{chat_name}/messages")
|
||||
async def list_messages(role_name: str, chat_name: str):
|
||||
"""获取聊天的所有消息"""
|
||||
try:
|
||||
return await ChatHistory.list_messages(role_name, chat_name)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Chat not found")
|
||||
chat_data = chat_service.get_chat(role_name, chat_name)
|
||||
return {"messages": chat_data["messages"]}
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{role_name}/{chat_name}/messages/{floor}")
|
||||
async def get_message(role_name: str, chat_name: str, floor: int):
|
||||
"""获取指定楼层的消息"""
|
||||
try:
|
||||
return await ChatHistory.get_message(role_name, chat_name, floor)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Chat not found")
|
||||
chat_data = chat_service.get_chat(role_name, chat_name)
|
||||
for msg in chat_data["messages"]:
|
||||
if msg.get("floor") == floor:
|
||||
return msg
|
||||
raise HTTPException(status_code=404, detail=f"Message at floor {floor} not found")
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{role_name}/{chat_name}/messages", status_code=status.HTTP_201_CREATED)
|
||||
async def add_message(role_name: str, chat_name: str, message_data: dict):
|
||||
"""向聊天添加新消息"""
|
||||
try:
|
||||
return await ChatHistory.add_message(role_name, chat_name, message_data)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Chat not found")
|
||||
return chat_service.add_message(role_name, chat_name, message_data)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@@ -83,15 +107,72 @@ async def add_message(role_name: str, chat_name: str, message_data: dict):
|
||||
async def update_message(role_name: str, chat_name: str, floor: int, update_data: dict):
|
||||
"""更新指定楼层的消息"""
|
||||
try:
|
||||
return await ChatHistory.update_message(role_name, chat_name, floor, update_data)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Chat not found")
|
||||
return chat_service.update_message(role_name, chat_name, floor, update_data)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/{role_name}/{chat_name}/messages/{floor}")
|
||||
async def delete_message(role_name: str, chat_name: str, floor: int):
|
||||
"""删除指定楼层的消息"""
|
||||
try:
|
||||
return await ChatHistory.delete_message(role_name, chat_name, floor)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Chat not found")
|
||||
return chat_service.delete_message(role_name, chat_name, floor)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/{role_name}/{chat_name}/table")
|
||||
async def update_table_data(role_name: str, chat_name: str, table_update: dict):
|
||||
"""更新表格数据(带时间戳冲突解决)"""
|
||||
try:
|
||||
return chat_service.update_table_data(role_name, chat_name, table_update)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{role_name}/{chat_name}/branch", status_code=status.HTTP_201_CREATED)
|
||||
async def branch_chat(role_name: str, chat_name: str, branch_data: dict):
|
||||
"""
|
||||
创建聊天分支
|
||||
|
||||
复制当前楼层及之前的所有内容到一个新的聊天记录
|
||||
|
||||
Args:
|
||||
role_name: 角色名称
|
||||
chat_name: 原聊天名称
|
||||
branch_data: {
|
||||
"target_floor": int, # 目标楼层(包含该楼层及之前的内容)
|
||||
"new_chat_name": str # 新聊天名称(可选,默认自动生成)
|
||||
}
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": bool,
|
||||
"new_chat_name": str,
|
||||
"message_count": int
|
||||
}
|
||||
"""
|
||||
try:
|
||||
target_floor = branch_data.get("target_floor")
|
||||
new_chat_name = branch_data.get("new_chat_name")
|
||||
|
||||
if target_floor is None:
|
||||
raise HTTPException(status_code=400, detail="缺少 target_floor 参数")
|
||||
|
||||
# 调用服务层创建分支
|
||||
result = chat_service.create_branch(role_name, chat_name, target_floor, new_chat_name)
|
||||
|
||||
return result
|
||||
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"创建分支失败: {str(e)}")
|
||||
|
||||
439
backend/api/routes/fictionRoute.py
Normal file
439
backend/api/routes/fictionRoute.py
Normal file
@@ -0,0 +1,439 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
import services.tools.fiction_tools # noqa: F401 — register fiction tools
|
||||
from models.fiction_models import (
|
||||
CreateFictionBookRequest,
|
||||
EmotionFlowCatalog,
|
||||
FictionBookMeta,
|
||||
FictionBookMetadata,
|
||||
FictionBookSettings,
|
||||
FictionBookSummary,
|
||||
FictionChapter,
|
||||
FictionChapterSummary,
|
||||
FictionGenerationRequest,
|
||||
FictionGuideWorldbook,
|
||||
FictionPipelineTickResult,
|
||||
FictionRunState,
|
||||
FictionStartReadingResult,
|
||||
GuideGlobalEntries,
|
||||
OpenBookRequest,
|
||||
OpenBookResult,
|
||||
UpdateFictionBookSettingsRequest,
|
||||
UpdateFictionProgressRequest,
|
||||
)
|
||||
from services.fiction_chapter_service import ensure_chapter, run_chapter
|
||||
from services.fiction_coarse_service import run_coarse_outline
|
||||
from services.fiction_event_plan_service import (
|
||||
iter_event_plan,
|
||||
run_event_plan,
|
||||
stream_event_plan_subscribe,
|
||||
)
|
||||
from services.fiction_metadata_service import fiction_metadata_service
|
||||
from services.fiction_open_book_service import run_open_book
|
||||
from services.fiction_planning_service import (
|
||||
ensure_chapter_plan,
|
||||
ensure_event_chain,
|
||||
ensure_volume,
|
||||
)
|
||||
from services.fiction_orchestrator_service import (
|
||||
get_pending_stages,
|
||||
get_pipeline_run,
|
||||
start_reading_pipeline,
|
||||
tick_reading_pipeline,
|
||||
)
|
||||
from services.fiction_service import fiction_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/fiction", tags=["fiction"])
|
||||
|
||||
|
||||
@router.get("/books", response_model=List[FictionBookSummary])
|
||||
async def list_fiction_books():
|
||||
try:
|
||||
return fiction_service.list_books()
|
||||
except Exception as e:
|
||||
logger.error("Failed to list fiction books: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/books", response_model=FictionBookMeta)
|
||||
async def create_fiction_book(req: CreateFictionBookRequest):
|
||||
try:
|
||||
return fiction_service.create_book(req)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except FileExistsError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to create fiction book: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/books/{book_id}", response_model=FictionBookMeta)
|
||||
async def get_fiction_book_meta(book_id: str):
|
||||
try:
|
||||
return fiction_service.get_book_meta(book_id)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to get fiction book %s: %s", book_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/books/{book_id}")
|
||||
async def delete_fiction_book(book_id: str):
|
||||
try:
|
||||
fiction_service.delete_book(book_id)
|
||||
return {"ok": True}
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete fiction book %s: %s", book_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/books/{book_id}/settings", response_model=FictionBookSettings)
|
||||
async def get_fiction_book_settings(book_id: str):
|
||||
try:
|
||||
return fiction_service.get_book_settings(book_id)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to get settings for %s: %s", book_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/books/{book_id}/settings", response_model=FictionBookSettings)
|
||||
async def update_fiction_book_settings(
|
||||
book_id: str, req: UpdateFictionBookSettingsRequest
|
||||
):
|
||||
try:
|
||||
return fiction_service.update_book_settings(book_id, req)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to update settings for %s: %s", book_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/books/{book_id}/guide", response_model=FictionGuideWorldbook)
|
||||
async def get_fiction_book_guide(book_id: str):
|
||||
try:
|
||||
return fiction_service.get_book_guide(book_id)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to get guide for %s: %s", book_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/emotion-flows/catalog", response_model=EmotionFlowCatalog)
|
||||
async def get_emotion_flow_catalog():
|
||||
try:
|
||||
return fiction_service.get_emotion_catalog()
|
||||
except Exception as e:
|
||||
logger.error("Failed to get emotion catalog: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/guide-global/entries", response_model=GuideGlobalEntries)
|
||||
async def get_guide_global_entries():
|
||||
try:
|
||||
return fiction_service.get_guide_global_entries()
|
||||
except Exception as e:
|
||||
logger.error("Failed to get guide global entries: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/open-book", response_model=OpenBookResult)
|
||||
async def open_fiction_book(req: OpenBookRequest):
|
||||
try:
|
||||
return await run_open_book(
|
||||
req.inspiration,
|
||||
profile_id=req.profile_id,
|
||||
api_config=req.api_config,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to open fiction book: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/books/{book_id}/metadata", response_model=FictionBookMetadata)
|
||||
async def get_fiction_book_metadata(book_id: str):
|
||||
try:
|
||||
return fiction_metadata_service.get_metadata(book_id)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to get metadata for %s: %s", book_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/books/{book_id}/run", response_model=FictionRunState)
|
||||
async def get_fiction_book_run(book_id: str):
|
||||
try:
|
||||
return fiction_metadata_service.get_run(book_id)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to get run for %s: %s", book_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/books/{book_id}/start", response_model=FictionStartReadingResult)
|
||||
async def start_fiction_reading(book_id: str, req: FictionGenerationRequest):
|
||||
"""进入阅读时自动触发粗纲 / 事件纲要流水线(后台异步)。"""
|
||||
try:
|
||||
return await start_reading_pipeline(
|
||||
book_id,
|
||||
profile_id=req.profile_id,
|
||||
api_config=req.api_config,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to start reading pipeline for %s: %s", book_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/books/{book_id}/pipeline/tick", response_model=FictionPipelineTickResult)
|
||||
async def tick_fiction_pipeline(book_id: str, req: FictionGenerationRequest):
|
||||
"""检查并推进流水线(尊重半自动设置)。"""
|
||||
try:
|
||||
return await tick_reading_pipeline(
|
||||
book_id,
|
||||
profile_id=req.profile_id,
|
||||
api_config=req.api_config,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to tick pipeline for %s: %s", book_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/books/{book_id}/pipeline/status")
|
||||
async def get_fiction_pipeline_status(book_id: str):
|
||||
"""流水线 run 状态 + 待手动阶段列表。"""
|
||||
try:
|
||||
run = get_pipeline_run(book_id)
|
||||
pending = get_pending_stages(book_id)
|
||||
return {"run": run, "pendingStages": pending}
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to get pipeline status for %s: %s", book_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/books/{book_id}/volumes/ensure", response_model=FictionBookMetadata)
|
||||
async def ensure_fiction_volume(book_id: str, req: FictionGenerationRequest):
|
||||
try:
|
||||
return await ensure_volume(
|
||||
book_id,
|
||||
profile_id=req.profile_id,
|
||||
api_config=req.api_config,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to ensure volume for %s: %s", book_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/books/{book_id}/events/ensure", response_model=FictionBookMetadata)
|
||||
async def ensure_fiction_event_chain(book_id: str, req: FictionGenerationRequest):
|
||||
try:
|
||||
return await ensure_event_chain(
|
||||
book_id,
|
||||
profile_id=req.profile_id,
|
||||
api_config=req.api_config,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to ensure event chain for %s: %s", book_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/books/{book_id}/chapter-plans/ensure", response_model=FictionBookMetadata)
|
||||
async def ensure_fiction_chapter_plan(book_id: str, req: FictionGenerationRequest):
|
||||
try:
|
||||
return await ensure_chapter_plan(
|
||||
book_id,
|
||||
event_id=req.event_id,
|
||||
profile_id=req.profile_id,
|
||||
api_config=req.api_config,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to ensure chapter plan for %s: %s", book_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/books/{book_id}/chapters/ensure", response_model=FictionChapter)
|
||||
async def ensure_fiction_chapter(book_id: str, req: FictionGenerationRequest):
|
||||
try:
|
||||
return await ensure_chapter(
|
||||
book_id,
|
||||
profile_id=req.profile_id,
|
||||
api_config=req.api_config,
|
||||
seq=req.seq,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to ensure chapter for %s: %s", book_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/books/{book_id}/coarse-outline", response_model=FictionBookMetadata)
|
||||
async def generate_coarse_outline(book_id: str, req: FictionGenerationRequest):
|
||||
try:
|
||||
return await ensure_volume(
|
||||
book_id,
|
||||
profile_id=req.profile_id,
|
||||
api_config=req.api_config,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to ensure volume for %s: %s", book_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/books/{book_id}/event-plan", response_model=FictionBookMetadata)
|
||||
async def generate_event_plan(book_id: str, req: FictionGenerationRequest):
|
||||
if req.stream:
|
||||
|
||||
async def ndjson_stream():
|
||||
try:
|
||||
async for event in iter_event_plan(
|
||||
book_id,
|
||||
profile_id=req.profile_id,
|
||||
api_config=req.api_config,
|
||||
event_id=req.event_id,
|
||||
):
|
||||
yield json.dumps(event, ensure_ascii=False) + "\n"
|
||||
except FileNotFoundError as e:
|
||||
yield json.dumps({"type": "error", "message": str(e)}, ensure_ascii=False) + "\n"
|
||||
except ValueError as e:
|
||||
yield json.dumps({"type": "error", "message": str(e)}, ensure_ascii=False) + "\n"
|
||||
except Exception as e:
|
||||
logger.error("Failed to stream event plan for %s: %s", book_id, e)
|
||||
yield json.dumps(
|
||||
{"type": "error", "message": str(e)}, ensure_ascii=False
|
||||
) + "\n"
|
||||
|
||||
return StreamingResponse(ndjson_stream(), media_type="application/x-ndjson")
|
||||
|
||||
try:
|
||||
return await ensure_chapter_plan(
|
||||
book_id,
|
||||
event_id=req.event_id,
|
||||
profile_id=req.profile_id,
|
||||
api_config=req.api_config,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to generate event plan for %s: %s", book_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/books/{book_id}/event-plan/stream")
|
||||
async def subscribe_event_plan_stream(book_id: str):
|
||||
"""订阅事件纲要生成进度(NDJSON),适用于后台流水线已启动时。"""
|
||||
try:
|
||||
fiction_service.get_book_meta(book_id)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
async def ndjson_stream():
|
||||
try:
|
||||
async for event in stream_event_plan_subscribe(book_id):
|
||||
yield json.dumps(event, ensure_ascii=False) + "\n"
|
||||
except FileNotFoundError as e:
|
||||
yield json.dumps({"type": "error", "message": str(e)}, ensure_ascii=False) + "\n"
|
||||
except Exception as e:
|
||||
logger.error("Failed to subscribe event plan stream for %s: %s", book_id, e)
|
||||
yield json.dumps({"type": "error", "message": str(e)}, ensure_ascii=False) + "\n"
|
||||
|
||||
return StreamingResponse(ndjson_stream(), media_type="application/x-ndjson")
|
||||
|
||||
|
||||
@router.get("/books/{book_id}/chapters", response_model=List[FictionChapterSummary])
|
||||
async def list_fiction_chapters(book_id: str):
|
||||
try:
|
||||
return fiction_service.list_chapter_summaries(book_id)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to list chapters for %s: %s", book_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/books/{book_id}/chapters/{seq}", response_model=FictionChapter)
|
||||
async def get_fiction_chapter(book_id: str, seq: int):
|
||||
try:
|
||||
return fiction_service.get_chapter(book_id, seq)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to get chapter %s for %s: %s", seq, book_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/books/{book_id}/chapter", response_model=FictionChapter)
|
||||
async def generate_fiction_chapter(book_id: str, req: FictionGenerationRequest):
|
||||
"""撰写下一章或指定 seq 的章节(本地已存在则直接返回)。"""
|
||||
try:
|
||||
return await run_chapter(
|
||||
book_id,
|
||||
profile_id=req.profile_id,
|
||||
api_config=req.api_config,
|
||||
seq=req.seq,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to generate chapter for %s: %s", book_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/books/{book_id}/progress", response_model=FictionBookMetadata)
|
||||
async def update_fiction_progress(book_id: str, req: UpdateFictionProgressRequest):
|
||||
try:
|
||||
return fiction_metadata_service.update_progress(
|
||||
book_id,
|
||||
current_chapter_seq=req.currentChapterSeq,
|
||||
char_offset=req.charOffset,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to update progress for %s: %s", book_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
140
backend/api/routes/imageGalleryRoute.py
Normal file
140
backend/api/routes/imageGalleryRoute.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
图片画廊路由
|
||||
|
||||
提供图片查询、删除等管理接口
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from typing import Dict, Any, List, Optional
|
||||
import os
|
||||
|
||||
try:
|
||||
from backend.services.image_metadata_service import image_metadata_service
|
||||
except ImportError:
|
||||
from services.image_metadata_service import image_metadata_service
|
||||
|
||||
router = APIRouter(prefix="/image-gallery", tags=["image-gallery"])
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_gallery_stats():
|
||||
"""获取画廊统计信息"""
|
||||
return await image_metadata_service.get_gallery_stats()
|
||||
|
||||
|
||||
@router.get("/images/{chat_id}")
|
||||
async def get_chat_images(
|
||||
chat_id: str,
|
||||
floor: Optional[int] = None
|
||||
):
|
||||
"""
|
||||
获取指定聊天的图片列表
|
||||
|
||||
Args:
|
||||
chat_id: 聊天ID (role_name/chat_name)
|
||||
floor: 楼层号(可选)
|
||||
"""
|
||||
images = await image_metadata_service.get_images_by_chat(chat_id, floor)
|
||||
return {
|
||||
"chatId": chat_id,
|
||||
"totalImages": len(images),
|
||||
"images": [img.model_dump() for img in images]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/images/role/{role_name}")
|
||||
async def get_role_images(role_name: str):
|
||||
"""获取指定角色的所有图片"""
|
||||
images = await image_metadata_service.get_images_by_role(role_name)
|
||||
return {
|
||||
"roleName": role_name,
|
||||
"totalImages": len(images),
|
||||
"images": [img.model_dump() for img in images]
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/images/{chat_id}/{image_id}")
|
||||
async def delete_image(chat_id: str, image_id: str):
|
||||
"""
|
||||
删除图片(元数据和文件)
|
||||
|
||||
Args:
|
||||
chat_id: 聊天ID
|
||||
image_id: 图片ID
|
||||
"""
|
||||
# 先获取元数据以得到文件路径
|
||||
images = await image_metadata_service.get_images_by_chat(chat_id)
|
||||
target_image = None
|
||||
for img in images:
|
||||
if img.id == image_id:
|
||||
target_image = img
|
||||
break
|
||||
|
||||
if not target_image:
|
||||
raise HTTPException(status_code=404, detail="图片不存在")
|
||||
|
||||
# 删除元数据
|
||||
success = await image_metadata_service.delete_image(chat_id, image_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="删除失败")
|
||||
|
||||
# 删除实际文件
|
||||
try:
|
||||
file_path = image_metadata_service.get_image_full_path(target_image.filepath)
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
except Exception as e:
|
||||
print(f"[ImageGallery] 删除文件失败: {e}")
|
||||
# 不抛出异常,因为元数据已删除
|
||||
|
||||
return {"message": "图片已删除"}
|
||||
|
||||
|
||||
@router.post("/images/{chat_id}/clear")
|
||||
async def clear_chat_images(chat_id: str):
|
||||
"""
|
||||
清空指定聊天的所有图片
|
||||
|
||||
Args:
|
||||
chat_id: 聊天ID
|
||||
"""
|
||||
count = await image_metadata_service.clear_chat_images(chat_id)
|
||||
return {
|
||||
"message": f"已清空 {count} 张图片",
|
||||
"deletedCount": count
|
||||
}
|
||||
|
||||
|
||||
@router.post("/images/{chat_id}/{image_id}/set-current")
|
||||
async def set_current_swipe(chat_id: str, image_id: str):
|
||||
"""
|
||||
设置某张图片为当前显示的 swipe
|
||||
|
||||
Args:
|
||||
chat_id: 聊天ID
|
||||
image_id: 图片ID
|
||||
"""
|
||||
success = await image_metadata_service.set_current_swipe(chat_id, image_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="图片不存在")
|
||||
|
||||
return {"message": "已设置为当前显示"}
|
||||
|
||||
|
||||
@router.get("/image/{filepath:path}")
|
||||
async def get_image(filepath: str):
|
||||
"""
|
||||
获取图片文件
|
||||
|
||||
Args:
|
||||
filepath: 文件相对路径
|
||||
"""
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
file_path = image_metadata_service.get_image_full_path(filepath)
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="图片文件不存在")
|
||||
|
||||
return FileResponse(str(file_path))
|
||||
@@ -1,98 +1,132 @@
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from backend.core.models.PromptList import AIDesignSpec
|
||||
from backend.core.models.PromptComponent import PromptComponent
|
||||
from services.preset_service import PresetService
|
||||
|
||||
router = APIRouter(prefix="/presets", tags=["presets"])
|
||||
|
||||
|
||||
# ========== 预设基础路由 ==========
|
||||
|
||||
@router.get("", response_model=dict)
|
||||
async def list_presets():
|
||||
"""获取所有预设列表及其基本信息"""
|
||||
return await AIDesignSpec.list_all_presets()
|
||||
try:
|
||||
presets = PresetService.list_presets()
|
||||
response_data = {"presets": presets}
|
||||
print(f"[API] GET /api/presets - 返回数据: {response_data}")
|
||||
return response_data
|
||||
except Exception as e:
|
||||
print(f"[API] GET /api/presets - 错误: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# 注意:路由定义顺序很重要!更具体的路由(更多参数)必须放在前面
|
||||
@router.get("/{preset_name}/components/{component_id}")
|
||||
async def get_preset_component(preset_name: str, component_id: str):
|
||||
"""获取指定组件的详情"""
|
||||
raise HTTPException(status_code=501, detail="Not Implemented")
|
||||
|
||||
@router.get("/{preset_name}/components")
|
||||
async def list_preset_components(preset_name: str):
|
||||
"""获取预设中的所有组件"""
|
||||
raise HTTPException(status_code=501, detail="Not Implemented")
|
||||
|
||||
@router.get("/{preset_name}")
|
||||
async def get_preset(preset_name: str):
|
||||
"""获取指定预设的完整内容"""
|
||||
try:
|
||||
return await AIDesignSpec.get_preset(preset_name)
|
||||
preset_data = PresetService.get_preset(preset_name)
|
||||
return preset_data
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_preset(preset_name: str, preset_data: dict):
|
||||
async def create_preset(preset_data: dict):
|
||||
"""创建新预设"""
|
||||
try:
|
||||
return await AIDesignSpec.create_preset(preset_name, preset_data)
|
||||
except FileExistsError:
|
||||
raise HTTPException(status_code=400, detail="Preset already exists")
|
||||
preset_name = preset_data.get("name")
|
||||
if not preset_name:
|
||||
raise HTTPException(status_code=400, detail="preset name is required")
|
||||
|
||||
# 使用 create_preset 方法保存预设
|
||||
saved_preset = PresetService.create_preset(preset_name, preset_data)
|
||||
return {"success": True, "preset": saved_preset}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.put("/{preset_name}")
|
||||
async def update_preset(preset_name: str, update_data: dict):
|
||||
"""更新预设配置"""
|
||||
try:
|
||||
return await AIDesignSpec.update_preset(preset_name, update_data)
|
||||
updated_preset = PresetService.update_preset(preset_name, update_data)
|
||||
return {"success": True, "preset": updated_preset}
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post("/{preset_name}/rename")
|
||||
async def rename_preset(preset_name: str, rename_data: dict):
|
||||
"""重命名预设(同时修改文件名和内部 name 字段)"""
|
||||
try:
|
||||
new_name = rename_data.get("newName")
|
||||
if not new_name:
|
||||
raise HTTPException(status_code=400, detail="newName is required")
|
||||
|
||||
# 清理新名称(去掉可能的时间戳和后缀)
|
||||
import re
|
||||
clean_name = re.sub(r'_\d{10,13}$', '', new_name.replace('.json', ''))
|
||||
|
||||
updated_preset = PresetService.rename_preset(preset_name, clean_name)
|
||||
return {"success": True, "preset": updated_preset, "newName": clean_name}
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.delete("/{preset_name}")
|
||||
async def delete_preset(preset_name: str):
|
||||
"""删除指定预设"""
|
||||
try:
|
||||
return await AIDesignSpec.delete_preset(preset_name)
|
||||
success = PresetService.delete_preset(preset_name)
|
||||
if success:
|
||||
return {"success": True, "message": f"Preset '{preset_name}' deleted"}
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
|
||||
|
||||
# ========== 预设组件路由 ==========
|
||||
|
||||
@router.get("/{preset_name}/components")
|
||||
async def list_preset_components(preset_name: str):
|
||||
"""获取预设中的所有组件"""
|
||||
try:
|
||||
return await AIDesignSpec.list_components(preset_name)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
|
||||
|
||||
@router.get("/{preset_name}/components/{component_id}")
|
||||
async def get_preset_component(preset_name: str, component_id: str):
|
||||
"""获取指定组件的详情"""
|
||||
try:
|
||||
return await AIDesignSpec.get_component(preset_name, component_id)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post("/{preset_name}/components", status_code=status.HTTP_201_CREATED)
|
||||
async def add_preset_component(preset_name: str, component_data: dict):
|
||||
"""向预设添加新组件"""
|
||||
try:
|
||||
return await AIDesignSpec.add_component_to_preset(preset_name, component_data)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
raise HTTPException(status_code=501, detail="Not Implemented")
|
||||
|
||||
@router.put("/{preset_name}/components/{component_id}")
|
||||
async def update_preset_component(preset_name: str, component_id: str, update_data: dict):
|
||||
"""更新指定组件"""
|
||||
try:
|
||||
return await AIDesignSpec.update_component_in_preset(preset_name, component_id, update_data)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
|
||||
raise HTTPException(status_code=501, detail="Not Implemented")
|
||||
|
||||
@router.delete("/{preset_name}/components/{component_id}")
|
||||
async def delete_preset_component(preset_name: str, component_id: str):
|
||||
"""从预设中删除指定组件"""
|
||||
raise HTTPException(status_code=501, detail="Not Implemented")
|
||||
|
||||
@router.post("/{preset_name}/reorder")
|
||||
async def reorder_preset_components(preset_name: str, order_data: dict):
|
||||
"""重新排序预设组件"""
|
||||
try:
|
||||
return await AIDesignSpec.delete_component_from_preset(preset_name, component_id)
|
||||
component_order = order_data.get("component_order", [])
|
||||
if not component_order:
|
||||
raise HTTPException(status_code=400, detail="component_order is required")
|
||||
|
||||
updated_preset = PresetService.reorder_components(preset_name, component_order)
|
||||
return {"success": True, "preset": updated_preset}
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
387
backend/api/routes/regexRoute.py
Normal file
387
backend/api/routes/regexRoute.py
Normal file
@@ -0,0 +1,387 @@
|
||||
"""
|
||||
正则规则 API 路由
|
||||
|
||||
提供正则规则的 CRUD 操作和导入导出功能
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
import json
|
||||
import logging
|
||||
|
||||
from services.regex_service import regex_service
|
||||
from models.regex_rules import RegexRule, RegexRuleset, RegexScope
|
||||
from services.system_settings_service import system_settings_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/regex", tags=["regex"])
|
||||
|
||||
|
||||
# ==================== 数据模型 ====================
|
||||
|
||||
class RuleUpdateRequest(BaseModel):
|
||||
"""规则更新请求"""
|
||||
rule: RegexRule
|
||||
scope: RegexScope
|
||||
name: Optional[str] = None # 角色卡名称或预设名称(scope 为 CHARACTER/PRESET 时需要)
|
||||
|
||||
|
||||
class SystemSettingsUpdate(BaseModel):
|
||||
"""系统设置更新请求"""
|
||||
thinkingTagPrefix: Optional[str] = None
|
||||
thinkingTagSuffix: Optional[str] = None
|
||||
currentPresetName: Optional[str] = None
|
||||
|
||||
|
||||
# ==================== 规则查询 ====================
|
||||
|
||||
@router.get("/rules")
|
||||
async def get_rules(
|
||||
character_name: Optional[str] = None,
|
||||
preset_name: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
获取适用的正则规则列表
|
||||
|
||||
Args:
|
||||
character_name: 当前角色卡名称(可选)
|
||||
preset_name: 当前预设名称(可选)
|
||||
|
||||
Returns:
|
||||
规则列表
|
||||
"""
|
||||
try:
|
||||
rules = regex_service.get_rules_for_context(character_name, preset_name)
|
||||
return {
|
||||
"success": True,
|
||||
"rules": [rule.dict() for rule in rules],
|
||||
"count": len(rules)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"获取规则失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/rulesets/global")
|
||||
async def get_global_rulesets():
|
||||
"""获取所有全局规则集"""
|
||||
try:
|
||||
rulesets = list(regex_service.global_rulesets.values())
|
||||
return {
|
||||
"success": True,
|
||||
"rulesets": [rs.dict() for rs in rulesets]
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"获取全局规则集失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/rulesets/character/{character_name}")
|
||||
async def get_character_ruleset(character_name: str):
|
||||
"""获取指定角色卡的规则集"""
|
||||
try:
|
||||
if character_name in regex_service.character_rulesets:
|
||||
ruleset = regex_service.character_rulesets[character_name]
|
||||
return {
|
||||
"success": True,
|
||||
"ruleset": ruleset.dict()
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": True,
|
||||
"ruleset": None
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"获取角色规则集失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/rulesets/preset/{preset_name}")
|
||||
async def get_preset_ruleset(preset_name: str):
|
||||
"""获取指定预设的规则集"""
|
||||
try:
|
||||
if preset_name in regex_service.preset_rulesets:
|
||||
ruleset = regex_service.preset_rulesets[preset_name]
|
||||
return {
|
||||
"success": True,
|
||||
"ruleset": ruleset.dict()
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": True,
|
||||
"ruleset": None
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"获取预设规则集失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ==================== 规则管理 ====================
|
||||
|
||||
@router.post("/rules")
|
||||
async def add_rule(request: RuleUpdateRequest):
|
||||
"""添加或更新规则"""
|
||||
try:
|
||||
# 获取现有的规则集
|
||||
existing_ruleset = None
|
||||
if request.scope == RegexScope.GLOBAL:
|
||||
# 对于全局作用域,查找是否已有同名规则集
|
||||
for ruleset_id, ruleset in regex_service.global_rulesets.items():
|
||||
if ruleset.name == request.rule.scriptName:
|
||||
existing_ruleset = ruleset
|
||||
break
|
||||
elif request.scope == RegexScope.CHARACTER and request.name:
|
||||
if request.name in regex_service.character_rulesets:
|
||||
existing_ruleset = regex_service.character_rulesets[request.name]
|
||||
elif request.scope == RegexScope.PRESET and request.name:
|
||||
if request.name in regex_service.preset_rulesets:
|
||||
existing_ruleset = regex_service.preset_rulesets[request.name]
|
||||
|
||||
if existing_ruleset:
|
||||
# 如果已存在同名规则集,则更新其中的规则
|
||||
updated_rules = []
|
||||
rule_found = False
|
||||
for rule in existing_ruleset.rules:
|
||||
if rule.id == request.rule.id:
|
||||
# 更新现有规则
|
||||
updated_rules.append(request.rule)
|
||||
rule_found = True
|
||||
else:
|
||||
# 保留其他规则
|
||||
updated_rules.append(rule)
|
||||
|
||||
if not rule_found:
|
||||
# 如果没有找到相同ID的规则,则添加新规则
|
||||
updated_rules.append(request.rule)
|
||||
|
||||
# 更新规则集
|
||||
existing_ruleset.rules = updated_rules
|
||||
regex_service.save_ruleset(existing_ruleset, request.scope, request.name)
|
||||
else:
|
||||
# 如果不存在同名规则集,则创建新的规则集
|
||||
new_ruleset = RegexRuleset(
|
||||
id=request.rule.id,
|
||||
name=request.rule.scriptName,
|
||||
rules=[request.rule]
|
||||
)
|
||||
regex_service.save_ruleset(new_ruleset, request.scope, request.name)
|
||||
|
||||
# 重新加载规则
|
||||
regex_service._load_all_rules()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "规则保存成功"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"保存规则失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/rules/{rule_id}")
|
||||
async def delete_rule(rule_id: str, scope: str = "global", name: Optional[str] = None):
|
||||
"""
|
||||
删除规则
|
||||
|
||||
Args:
|
||||
rule_id: 规则ID
|
||||
scope: 作用域 (global/character/preset)
|
||||
name: 角色名或预设名(scope 为 character/preset 时需要)
|
||||
"""
|
||||
try:
|
||||
from models.regex_rules import RegexScope
|
||||
|
||||
scope_map = {
|
||||
"global": RegexScope.GLOBAL,
|
||||
"character": RegexScope.CHARACTER,
|
||||
"preset": RegexScope.PRESET
|
||||
}
|
||||
scope_enum = scope_map.get(scope, RegexScope.GLOBAL)
|
||||
|
||||
# 找到包含该规则的规则集
|
||||
if scope_enum == RegexScope.GLOBAL:
|
||||
rulesets = regex_service.global_rulesets
|
||||
elif scope_enum == RegexScope.CHARACTER:
|
||||
if not name:
|
||||
raise ValueError("删除角色规则需要提供角色名称")
|
||||
rulesets = {name: regex_service.character_rulesets.get(name)} if name in regex_service.character_rulesets else {}
|
||||
elif scope_enum == RegexScope.PRESET:
|
||||
if not name:
|
||||
raise ValueError("删除预设规则需要提供预设名称")
|
||||
rulesets = {name: regex_service.preset_rulesets.get(name)} if name in regex_service.preset_rulesets else {}
|
||||
|
||||
# 查找并删除规则
|
||||
deleted = False
|
||||
for ruleset_name, ruleset in rulesets.items():
|
||||
if not ruleset:
|
||||
continue
|
||||
|
||||
original_count = len(ruleset.rules)
|
||||
ruleset.rules = [r for r in ruleset.rules if r.id != rule_id]
|
||||
|
||||
if len(ruleset.rules) < original_count:
|
||||
# 保存更新后的规则集
|
||||
regex_service.save_ruleset(ruleset, scope_enum, ruleset_name if scope_enum != RegexScope.GLOBAL else None)
|
||||
deleted = True
|
||||
break
|
||||
|
||||
if not deleted:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "未找到指定的规则"
|
||||
}
|
||||
|
||||
# 重新加载规则
|
||||
regex_service._load_all_rules()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "规则已删除"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"删除规则失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ==================== 规则导入导出 ====================
|
||||
|
||||
@router.post("/import")
|
||||
async def import_rules(file: UploadFile = File(...)):
|
||||
"""
|
||||
导入正则规则(支持 SillyTavern 格式)- 文件上传方式
|
||||
|
||||
可以导入:
|
||||
1. 单个规则文件(JSON 数组)
|
||||
2. 规则集文件(JSON 对象)
|
||||
"""
|
||||
try:
|
||||
content = await file.read()
|
||||
data = json.loads(content.decode('utf-8'))
|
||||
|
||||
# 判断格式并导入
|
||||
if isinstance(data, list):
|
||||
# SillyTavern 格式 - 导入为全局规则
|
||||
ruleset = regex_service._convert_sillytavern_format(data, file.filename.replace('.json', ''))
|
||||
regex_service.save_ruleset(ruleset, RegexScope.GLOBAL)
|
||||
elif isinstance(data, dict):
|
||||
if 'rules' in data:
|
||||
# 规则集格式
|
||||
ruleset = RegexRuleset(**data)
|
||||
regex_service.save_ruleset(ruleset, RegexScope.GLOBAL)
|
||||
else:
|
||||
raise ValueError("未知的文件格式")
|
||||
else:
|
||||
raise ValueError("无效的文件格式")
|
||||
|
||||
# 重新加载规则
|
||||
regex_service._load_all_rules()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"成功导入规则集: {ruleset.name}"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"导入规则失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/import-from-preset")
|
||||
async def import_rules_from_preset(request: dict):
|
||||
"""
|
||||
从预设导入正则规则 - JSON 数据方式
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"rules": [...], // SillyTavern 格式的 regex_scripts 数组
|
||||
"scope": "preset", // 作用域:global/character/preset
|
||||
"presetName": "预设名称" // 当 scope 为 preset 时需要
|
||||
}
|
||||
"""
|
||||
try:
|
||||
rules_data = request.get("rules", [])
|
||||
scope_str = request.get("scope", "global")
|
||||
preset_name = request.get("presetName")
|
||||
|
||||
if not rules_data or not isinstance(rules_data, list):
|
||||
raise ValueError("无效的规则数据")
|
||||
|
||||
# 转换作用域字符串为枚举
|
||||
scope_map = {
|
||||
"global": RegexScope.GLOBAL,
|
||||
"character": RegexScope.CHARACTER,
|
||||
"preset": RegexScope.PRESET
|
||||
}
|
||||
scope = scope_map.get(scope_str, RegexScope.GLOBAL)
|
||||
|
||||
# 转换 SillyTavern 格式
|
||||
name = preset_name or "imported_rules"
|
||||
ruleset = regex_service._convert_sillytavern_format(rules_data, name, scope)
|
||||
|
||||
# 保存规则集
|
||||
regex_service.save_ruleset(ruleset, scope, name)
|
||||
|
||||
# 重新加载规则
|
||||
regex_service._load_all_rules()
|
||||
|
||||
logger.info(f"✅ 从预设导入 {len(rules_data)} 条正则规则到 {scope.value}: {name}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"成功导入 {len(rules_data)} 条正则规则",
|
||||
"rulesetId": ruleset.id
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"从预设导入规则失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/export/global")
|
||||
async def export_global_rules():
|
||||
"""导出所有全局规则"""
|
||||
try:
|
||||
all_rulesets = list(regex_service.global_rulesets.values())
|
||||
return {
|
||||
"success": True,
|
||||
"rulesets": [rs.dict() for rs in all_rulesets]
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"导出规则失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ==================== 系统设置 ====================
|
||||
|
||||
@router.get("/settings")
|
||||
async def get_system_settings():
|
||||
"""获取系统设置"""
|
||||
try:
|
||||
settings = system_settings_service.settings
|
||||
return {
|
||||
"success": True,
|
||||
"settings": settings.dict()
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"获取系统设置失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/settings")
|
||||
async def update_system_settings(request: SystemSettingsUpdate):
|
||||
"""更新系统设置"""
|
||||
try:
|
||||
if request.thinkingTagPrefix is not None or request.thinkingTagSuffix is not None:
|
||||
prefix = request.thinkingTagPrefix or system_settings_service.settings.thinkingTagPrefix
|
||||
suffix = request.thinkingTagSuffix or system_settings_service.settings.thinkingTagSuffix
|
||||
system_settings_service.update_thinking_tags(prefix, suffix)
|
||||
|
||||
if request.currentPresetName is not None:
|
||||
system_settings_service.update_current_preset(request.currentPresetName)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "系统设置已更新"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"更新系统设置失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
390
backend/api/routes/studioRoute.py
Normal file
390
backend/api/routes/studioRoute.py
Normal file
@@ -0,0 +1,390 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from models.studio_models import (
|
||||
AdvanceRunRequest,
|
||||
CreateStudioProjectRequest,
|
||||
PipelineDefinition,
|
||||
RenameRunRequest,
|
||||
RunMessageRequest,
|
||||
RunRerollRequest,
|
||||
SaveRunRequest,
|
||||
StudioProject,
|
||||
StudioProjectSummary,
|
||||
StudioRun,
|
||||
StudioRunSummary,
|
||||
SwitchRunNodeRequest,
|
||||
UpdateStudioProjectRequest,
|
||||
WorkflowTemplateSummary,
|
||||
WorkflowVariablesResponse,
|
||||
)
|
||||
from services.studio_project_service import studio_project_service
|
||||
from services.studio_run_service import studio_run_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/studio", tags=["studio"])
|
||||
|
||||
|
||||
@router.get("/projects", response_model=List[StudioProjectSummary])
|
||||
async def list_studio_projects():
|
||||
try:
|
||||
return studio_project_service.list_projects()
|
||||
except Exception as e:
|
||||
logger.error("Failed to list studio projects: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}", response_model=StudioProject)
|
||||
async def get_studio_project(project_id: str):
|
||||
try:
|
||||
return studio_project_service.get_project(project_id)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to get studio project %s: %s", project_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/projects/{project_id}", response_model=StudioProject)
|
||||
async def update_studio_project(project_id: str, req: UpdateStudioProjectRequest):
|
||||
try:
|
||||
return studio_project_service.update_project_meta(
|
||||
project_id,
|
||||
name=req.name,
|
||||
description=req.description,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to update studio project %s: %s", project_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/projects/{project_id}/pipeline", response_model=StudioProject)
|
||||
async def save_studio_pipeline(project_id: str, pipeline: PipelineDefinition):
|
||||
try:
|
||||
return studio_project_service.save_pipeline(project_id, pipeline)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to save pipeline for %s: %s", project_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/templates", response_model=List[WorkflowTemplateSummary])
|
||||
async def list_workflow_templates():
|
||||
try:
|
||||
return studio_project_service.list_workflow_templates()
|
||||
except Exception as e:
|
||||
logger.error("Failed to list workflow templates: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/variables", response_model=WorkflowVariablesResponse)
|
||||
async def get_workflow_variables(projectId: str | None = None):
|
||||
try:
|
||||
return studio_project_service.get_workflow_variables(projectId)
|
||||
except Exception as e:
|
||||
logger.error("Failed to load workflow variables: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/skill-templates")
|
||||
async def get_skill_templates() -> Dict[str, Any]:
|
||||
try:
|
||||
return studio_project_service.get_skill_templates()
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to load skill templates: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/niches")
|
||||
async def get_niches() -> Dict[str, Any]:
|
||||
try:
|
||||
return studio_project_service.get_niches()
|
||||
except Exception as e:
|
||||
logger.error("Failed to load niches: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/projects/{project_id}")
|
||||
async def delete_studio_project(project_id: str):
|
||||
try:
|
||||
studio_project_service.delete_project(project_id)
|
||||
return {"ok": True, "id": project_id}
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete studio project %s: %s", project_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/projects", response_model=StudioProject)
|
||||
async def create_studio_project(req: CreateStudioProjectRequest):
|
||||
try:
|
||||
return studio_project_service.create_project(req)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to create studio project: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/runs", response_model=StudioRun)
|
||||
async def create_studio_run(project_id: str):
|
||||
try:
|
||||
return studio_run_service.create_run(project_id)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to create studio run for %s: %s", project_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/runs", response_model=List[StudioRunSummary])
|
||||
async def list_studio_runs(project_id: str):
|
||||
try:
|
||||
studio_project_service.get_project(project_id)
|
||||
return studio_run_service.list_runs(project_id)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to list studio runs for %s: %s", project_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/runs/{run_id}", response_model=StudioRun)
|
||||
async def get_studio_run(project_id: str, run_id: str):
|
||||
try:
|
||||
return studio_run_service.get_run(project_id, run_id)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to get studio run %s/%s: %s", project_id, run_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/runs/{run_id}/advance", response_model=StudioRun)
|
||||
async def advance_studio_run(
|
||||
project_id: str, run_id: str, req: AdvanceRunRequest
|
||||
):
|
||||
try:
|
||||
return studio_run_service.advance_run(
|
||||
project_id, run_id, display_params=req.displayParams, save_mode=req.saveMode
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except NotImplementedError as e:
|
||||
raise HTTPException(status_code=501, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to advance studio run %s/%s: %s", project_id, run_id, e
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/runs/{run_id}/save", response_model=StudioRun)
|
||||
async def save_studio_run(
|
||||
project_id: str, run_id: str, req: SaveRunRequest
|
||||
):
|
||||
try:
|
||||
return studio_run_service.save_run(project_id, run_id, req.mode)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to save studio run %s/%s: %s", project_id, run_id, e
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/runs/{run_id}/switch-node", response_model=StudioRun)
|
||||
async def switch_studio_run_node(
|
||||
project_id: str, run_id: str, req: SwitchRunNodeRequest
|
||||
):
|
||||
try:
|
||||
return studio_run_service.switch_run_node(project_id, run_id, req.nodeId)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to switch studio run node %s/%s: %s", project_id, run_id, e
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/runs/{run_id}/message")
|
||||
async def send_studio_run_message(
|
||||
project_id: str, run_id: str, req: RunMessageRequest
|
||||
):
|
||||
if req.stream:
|
||||
async def ndjson_stream():
|
||||
try:
|
||||
async for event in studio_run_service.send_run_message_stream(
|
||||
project_id,
|
||||
run_id,
|
||||
req.content,
|
||||
profile_id=req.profileId,
|
||||
api_config=req.apiConfig,
|
||||
):
|
||||
yield json.dumps(event, ensure_ascii=False) + "\n"
|
||||
except FileNotFoundError as e:
|
||||
yield json.dumps(
|
||||
{"type": "error", "detail": str(e)}, ensure_ascii=False
|
||||
) + "\n"
|
||||
except ValueError as e:
|
||||
yield json.dumps(
|
||||
{"type": "error", "detail": str(e)}, ensure_ascii=False
|
||||
) + "\n"
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to stream studio run message %s/%s: %s",
|
||||
project_id,
|
||||
run_id,
|
||||
e,
|
||||
)
|
||||
yield json.dumps(
|
||||
{"type": "error", "detail": f"消息处理失败:{e}"},
|
||||
ensure_ascii=False,
|
||||
) + "\n"
|
||||
|
||||
return StreamingResponse(
|
||||
ndjson_stream(),
|
||||
media_type="application/x-ndjson",
|
||||
)
|
||||
|
||||
try:
|
||||
return await studio_run_service.send_run_message(
|
||||
project_id,
|
||||
run_id,
|
||||
req.content,
|
||||
stream=req.stream,
|
||||
profile_id=req.profileId,
|
||||
api_config=req.apiConfig,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to send studio run message %s/%s: %s", project_id, run_id, e
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=f"消息处理失败:{e}")
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/runs/{run_id}/undo", response_model=StudioRun)
|
||||
async def undo_studio_run(project_id: str, run_id: str):
|
||||
try:
|
||||
return studio_run_service.undo_run(project_id, run_id)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to undo studio run %s/%s: %s", project_id, run_id, e
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=f"回退失败:{e}")
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/runs/{run_id}/reroll")
|
||||
async def reroll_studio_run(
|
||||
project_id: str, run_id: str, req: RunRerollRequest
|
||||
):
|
||||
if req.stream:
|
||||
async def ndjson_stream():
|
||||
try:
|
||||
async for event in studio_run_service.reroll_run_stream(
|
||||
project_id,
|
||||
run_id,
|
||||
profile_id=req.profileId,
|
||||
api_config=req.apiConfig,
|
||||
):
|
||||
yield json.dumps(event, ensure_ascii=False) + "\n"
|
||||
except FileNotFoundError as e:
|
||||
yield json.dumps(
|
||||
{"type": "error", "detail": str(e)}, ensure_ascii=False
|
||||
) + "\n"
|
||||
except ValueError as e:
|
||||
yield json.dumps(
|
||||
{"type": "error", "detail": str(e)}, ensure_ascii=False
|
||||
) + "\n"
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to stream studio run reroll %s/%s: %s",
|
||||
project_id,
|
||||
run_id,
|
||||
e,
|
||||
)
|
||||
yield json.dumps(
|
||||
{"type": "error", "detail": f"重 roll 失败:{e}"},
|
||||
ensure_ascii=False,
|
||||
) + "\n"
|
||||
|
||||
return StreamingResponse(
|
||||
ndjson_stream(),
|
||||
media_type="application/x-ndjson",
|
||||
)
|
||||
|
||||
try:
|
||||
return await studio_run_service.reroll_run(
|
||||
project_id,
|
||||
run_id,
|
||||
stream=req.stream,
|
||||
profile_id=req.profileId,
|
||||
api_config=req.apiConfig,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to reroll studio run %s/%s: %s", project_id, run_id, e
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=f"重 roll 失败:{e}")
|
||||
|
||||
|
||||
@router.delete("/projects/{project_id}/runs/{run_id}")
|
||||
async def delete_studio_run(project_id: str, run_id: str):
|
||||
try:
|
||||
studio_run_service.delete_run(project_id, run_id)
|
||||
return {"ok": True, "id": run_id}
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to delete studio run %s/%s: %s", project_id, run_id, e
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/projects/{project_id}/runs/{run_id}", response_model=StudioRun)
|
||||
async def rename_studio_run(
|
||||
project_id: str, run_id: str, req: RenameRunRequest
|
||||
):
|
||||
try:
|
||||
return studio_run_service.rename_run(project_id, run_id, req.title)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to rename studio run %s/%s: %s", project_id, run_id, e
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
130
backend/api/routes/tokenUsageRoute.py
Normal file
130
backend/api/routes/tokenUsageRoute.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
Token 使用统计路由
|
||||
|
||||
提供 token 使用情况的查询接口
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
try:
|
||||
from backend.services.token_usage_service import token_usage_service
|
||||
except ImportError:
|
||||
from services.token_usage_service import token_usage_service
|
||||
|
||||
router = APIRouter(prefix="/token-usage", tags=["token-usage"])
|
||||
|
||||
|
||||
@router.get("/months")
|
||||
async def list_months():
|
||||
"""列出所有有数据的月份"""
|
||||
return await token_usage_service.list_months()
|
||||
|
||||
|
||||
@router.get("/stats/{year}/{month}")
|
||||
async def get_monthly_stats(
|
||||
year: int,
|
||||
month: int,
|
||||
role_name: Optional[str] = None,
|
||||
chat_name: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
获取指定月份的统计数据
|
||||
|
||||
Args:
|
||||
year: 年份
|
||||
month: 月份
|
||||
role_name: 角色名称(可选)
|
||||
chat_name: 聊天名称(可选)
|
||||
"""
|
||||
if month < 1 or month > 12:
|
||||
raise HTTPException(status_code=400, detail="月份必须在 1-12 之间")
|
||||
|
||||
try:
|
||||
stats = await token_usage_service.get_stats_by_month(
|
||||
year=year,
|
||||
month=month,
|
||||
role_name=role_name,
|
||||
chat_name=chat_name
|
||||
)
|
||||
return stats
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"获取统计数据失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/api-urls")
|
||||
async def get_api_url_stats():
|
||||
"""
|
||||
✅ 获取按 API URL 分组的统计数据(快速查询)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"api_url_1": {
|
||||
"totalPromptTokens": 1000,
|
||||
"totalCompletionTokens": 2000,
|
||||
"totalTokens": 3000,
|
||||
"count": 10,
|
||||
"firstUsed": 1234567890,
|
||||
"lastUsed": 1234567899
|
||||
},
|
||||
...
|
||||
}
|
||||
"""
|
||||
try:
|
||||
stats = await token_usage_service.get_api_url_stats()
|
||||
return stats
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"获取 API URL 统计失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/daily/{year}/{month}")
|
||||
async def get_daily_stats(year: int, month: int):
|
||||
"""
|
||||
✅ 获取指定月份的每日统计数据(快速查询)
|
||||
|
||||
Args:
|
||||
year: 年份
|
||||
month: 月份
|
||||
|
||||
Returns:
|
||||
{
|
||||
"2024-01-01": {
|
||||
"promptTokens": 1000,
|
||||
"completionTokens": 2000,
|
||||
"totalTokens": 3000,
|
||||
"count": 10
|
||||
},
|
||||
...
|
||||
}
|
||||
"""
|
||||
if month < 1 or month > 12:
|
||||
raise HTTPException(status_code=400, detail="月份必须在 1-12 之间")
|
||||
|
||||
try:
|
||||
stats = await token_usage_service.get_daily_stats(year, month)
|
||||
return stats
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"获取每日统计失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/roles/{year}/{month}")
|
||||
async def get_available_roles(year: int, month: int):
|
||||
"""获取指定月份有数据的角色列表"""
|
||||
if month < 1 or month > 12:
|
||||
raise HTTPException(status_code=400, detail="月份必须在 1-12 之间")
|
||||
|
||||
roles = await token_usage_service.get_available_roles(year, month)
|
||||
return {"roles": roles}
|
||||
|
||||
|
||||
@router.get("/chats/{year}/{month}")
|
||||
async def get_available_chats(
|
||||
year: int,
|
||||
month: int,
|
||||
role_name: Optional[str] = None
|
||||
):
|
||||
"""获取指定月份有数据的聊天列表"""
|
||||
if month < 1 or month > 12:
|
||||
raise HTTPException(status_code=400, detail="月份必须在 1-12 之间")
|
||||
|
||||
chats = await token_usage_service.get_available_chats(year, month, role_name)
|
||||
return {"chats": chats}
|
||||
@@ -1,5 +1,6 @@
|
||||
# 标准库导入
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
import logging
|
||||
from pathlib import Path
|
||||
@@ -10,18 +11,9 @@ from fastapi import APIRouter, HTTPException, UploadFile, File, Form
|
||||
from fastapi.responses import JSONResponse, FileResponse
|
||||
|
||||
# 本地模块导入
|
||||
# 本地模块导入
|
||||
from backend.core.models.WorldBook import WorldBook
|
||||
from backend.core.models.WorldItem import (
|
||||
WorldItem,
|
||||
TriggerConfig,
|
||||
KeywordTriggerConfig,
|
||||
RAGTriggerConfig,
|
||||
ConditionTriggerConfig,
|
||||
TriggerStrategy
|
||||
)
|
||||
|
||||
from backend.core.config import settings
|
||||
from models.internal import WorldInfo, WorldInfoEntry
|
||||
from core.config import settings
|
||||
from services.worldbook_service import worldbook_service
|
||||
|
||||
# 配置日志
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -41,525 +33,257 @@ async def list_worldbooks():
|
||||
List[Dict[str, Any]]: 世界书列表
|
||||
"""
|
||||
try:
|
||||
worldbooks = []
|
||||
search_dir = settings.WORLDBOOKS_PATH
|
||||
return worldbook_service.list_worldbooks()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list worldbooks: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# 检查目录是否存在
|
||||
if not os.path.exists(search_dir):
|
||||
logger.warning(f"目录不存在: {search_dir}")
|
||||
return []
|
||||
|
||||
for filename in os.listdir(search_dir):
|
||||
if filename.endswith(".json"):
|
||||
file_path = os.path.join(search_dir, filename)
|
||||
# 注意:路由定义顺序很重要!更具体的路由(更多参数)必须放在前面
|
||||
@router.get("/{name}/entries/{uid}", response_model=Dict[str, Any])
|
||||
async def get_worldbook_entry(name: str, uid: str):
|
||||
"""
|
||||
获取世界书的指定条目
|
||||
"""
|
||||
try:
|
||||
# 加载世界书基本信息
|
||||
# 传入文件名(不带扩展名)
|
||||
world_book = WorldBook.load(Path(file_path).stem)
|
||||
worldbooks.append(world_book.to_summary_dict())
|
||||
return worldbook_service.get_entry(name, uid)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.warning(f"加载世界书 {filename} 失败: {str(e)}")
|
||||
continue
|
||||
logger.error(f"Failed to get entry '{uid}' from worldbook '{name}': {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
logger.info(f"获取世界书列表: 共 {len(worldbooks)} 个")
|
||||
return worldbooks
|
||||
@router.get("/{name}/entries", response_model=Dict[str, Any])
|
||||
async def list_worldbook_entries(
|
||||
name: str,
|
||||
page: int = 1,
|
||||
page_size: int = 20
|
||||
):
|
||||
"""
|
||||
获取世界书的条目列表(支持分页)
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
page: 页码,从1开始
|
||||
page_size: 每页数量,默认20
|
||||
"""
|
||||
try:
|
||||
return worldbook_service.list_entries(name, page, page_size)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"获取世界书列表失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"获取世界书列表失败: {str(e)}")
|
||||
logger.error(f"Failed to list entries for worldbook '{name}': {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/{name}/export")
|
||||
async def export_worldbook(name: str, format: str = "internal"):
|
||||
"""
|
||||
导出世界书(支持 internal 和 sillytavern 两种格式)
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
format: 导出格式 ('internal' 或 'sillytavern'),默认 internal
|
||||
"""
|
||||
try:
|
||||
if format.lower() == "sillytavern":
|
||||
# 导出为 SillyTavern 格式(可能丢失特殊设置)
|
||||
logger.info(f"导出世界书 '{name}' 为 SillyTavern 格式")
|
||||
st_data = worldbook_service.export_to_sillytavern(name)
|
||||
|
||||
return JSONResponse(
|
||||
content=st_data,
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={name}_sillytavern.json"
|
||||
}
|
||||
)
|
||||
else:
|
||||
# 导出为内部格式(保留所有设置)
|
||||
logger.info(f"导出世界书 '{name}' 为内部格式")
|
||||
internal_data = worldbook_service.get_worldbook(name)
|
||||
|
||||
return JSONResponse(
|
||||
content=internal_data,
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={name}.json"
|
||||
}
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to export worldbook '{name}': {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/{name}", response_model=Dict[str, Any])
|
||||
async def get_worldbook(name: str):
|
||||
"""
|
||||
获取指定名称的世界书
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 世界书数据
|
||||
"""
|
||||
try:
|
||||
if not WorldBook.exists(name):
|
||||
raise HTTPException(status_code=404, detail=f"世界书 '{name}' 不存在")
|
||||
|
||||
world_book = WorldBook.load(name)
|
||||
logger.info(f"获取世界书: {name}")
|
||||
return world_book.to_dict()
|
||||
except HTTPException:
|
||||
raise
|
||||
return worldbook_service.get_worldbook(name)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"获取世界书 {name} 失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"获取世界书失败: {str(e)}")
|
||||
|
||||
logger.error(f"Failed to get worldbook '{name}': {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post("/", response_model=Dict[str, Any])
|
||||
async def create_worldbook(
|
||||
name: str = Form(...),
|
||||
description: str = Form(""),
|
||||
file: Optional[UploadFile] = File(None)
|
||||
):
|
||||
"""
|
||||
创建新世界书
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
description: 世界书描述
|
||||
file: 可选的上传文件(SillyTavern 格式)
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 创建的世界书数据
|
||||
创建新世界书(可选择导入文件)
|
||||
"""
|
||||
try:
|
||||
# 如果上传了文件,从文件导入
|
||||
# 如果提供了文件,从 SillyTavern 格式导入
|
||||
if file:
|
||||
# 保存临时文件
|
||||
temp_path = os.path.join(settings.WORLDBOOKS_PATH, f"temp_{file.filename}")
|
||||
with open(temp_path, "wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
|
||||
try:
|
||||
# 从文件加载世界书
|
||||
world_book = WorldBook.load(Path(temp_path).stem)
|
||||
# 更新名称和描述
|
||||
world_book.name = name
|
||||
# 保存世界书
|
||||
world_book.save()
|
||||
logger.info(f"从文件创建世界书: {name}")
|
||||
finally:
|
||||
# 删除临时文件
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
content = await file.read()
|
||||
st_data = json.loads(content.decode('utf-8'))
|
||||
return worldbook_service.import_from_sillytavern(name, st_data)
|
||||
else:
|
||||
# 创建空世界书
|
||||
world_book = WorldBook.create_empty(name)
|
||||
logger.info(f"创建空世界书: {name}")
|
||||
|
||||
return world_book.to_dict()
|
||||
except HTTPException:
|
||||
raise
|
||||
return worldbook_service.create_worldbook(name, description)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"创建世界书 {name} 失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"创建世界书失败: {str(e)}")
|
||||
|
||||
logger.error(f"Failed to create worldbook '{name}': {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.put("/{name}", response_model=Dict[str, Any])
|
||||
async def update_worldbook(
|
||||
name: str,
|
||||
file: Optional[UploadFile] = File(None)
|
||||
description: Optional[str] = Form(None)
|
||||
):
|
||||
"""
|
||||
更新世界书
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
description: 世界书描述(可选)
|
||||
file: 可选的上传文件(SillyTavern 格式)
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 更新后的世界书数据
|
||||
更新世界书基本信息
|
||||
"""
|
||||
try:
|
||||
# 检查世界书是否存在
|
||||
if not WorldBook.exists(name):
|
||||
raise HTTPException(status_code=404, detail=f"世界书 '{name}' 不存在")
|
||||
|
||||
# 加载世界书
|
||||
world_book = WorldBook.load(name)
|
||||
|
||||
# 如果上传了文件,从文件导入并合并
|
||||
if file:
|
||||
# 保存临时文件
|
||||
temp_path = os.path.join(settings.WORLDBOOKS_PATH, f"temp_{file.filename}")
|
||||
with open(temp_path, "wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
|
||||
try:
|
||||
# 从文件加载世界书
|
||||
imported_book = WorldBook.load(Path(temp_path).stem)
|
||||
# 合并条目
|
||||
world_book.merge_from_book(imported_book)
|
||||
logger.info(f"从文件更新世界书: {name}")
|
||||
finally:
|
||||
# 删除临时文件
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
|
||||
# 保存世界书
|
||||
world_book.save()
|
||||
logger.info(f"更新世界书: {name}")
|
||||
|
||||
return world_book.to_dict()
|
||||
except HTTPException:
|
||||
raise
|
||||
return worldbook_service.update_worldbook(name, description)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"更新世界书 {name} 失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"更新世界书失败: {str(e)}")
|
||||
|
||||
logger.error(f"Failed to update worldbook '{name}': {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.delete("/{name}")
|
||||
async def delete_worldbook(name: str):
|
||||
"""
|
||||
删除世界书
|
||||
"""
|
||||
try:
|
||||
worldbook_service.delete_worldbook(name)
|
||||
return {"message": f"Worldbook '{name}' deleted successfully"}
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete worldbook '{name}': {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/{name}/entries", response_model=Dict[str, Any])
|
||||
async def list_worldbook_entries(
|
||||
name: str,
|
||||
page: int = 1,
|
||||
page_size: int = 20
|
||||
):
|
||||
"""
|
||||
获取世界书的条目列表(支持分页)
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 删除结果
|
||||
page: 页码,从1开始
|
||||
page_size: 每页数量,默认20
|
||||
"""
|
||||
try:
|
||||
# 检查世界书是否存在
|
||||
if not WorldBook.exists(name):
|
||||
raise HTTPException(status_code=404, detail=f"世界书 '{name}' 不存在")
|
||||
|
||||
# 获取文件路径
|
||||
file_path = WorldBook.get_file_path(name)
|
||||
|
||||
# 删除文件
|
||||
os.remove(file_path)
|
||||
|
||||
logger.info(f"删除世界书: {name}")
|
||||
return {"success": True, "message": f"世界书 '{name}' 已删除"}
|
||||
except HTTPException:
|
||||
raise
|
||||
return worldbook_service.list_entries(name, page, page_size)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"删除世界书 {name} 失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"删除世界书失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/{name}/entries", response_model=List[Dict[str, Any]])
|
||||
async def list_worldbook_entries(name: str):
|
||||
"""
|
||||
获取世界书的所有条目(包括已禁用的条目)
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 条目列表
|
||||
"""
|
||||
try:
|
||||
# 检查世界书是否存在
|
||||
if not WorldBook.exists(name):
|
||||
raise HTTPException(status_code=404, detail=f"世界书 '{name}' 不存在")
|
||||
|
||||
# 加载世界书
|
||||
world_book = WorldBook.load(name)
|
||||
|
||||
# 获取所有条目的核心信息
|
||||
entries = world_book.get_all_entries()
|
||||
|
||||
logger.info(f"获取世界书 {name} 的所有条目: 共 {len(entries)} 个")
|
||||
return entries
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"获取世界书 {name} 的条目失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"获取世界书条目失败: {str(e)}")
|
||||
|
||||
logger.error(f"Failed to list entries for worldbook '{name}': {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/{name}/entries/{uid}", response_model=Dict[str, Any])
|
||||
async def get_worldbook_entry(name: str, uid: int):
|
||||
async def get_worldbook_entry(name: str, uid: str):
|
||||
"""
|
||||
获取世界书的指定条目
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
uid: 条目 UID
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 条目数据
|
||||
"""
|
||||
try:
|
||||
# 检查世界书是否存在
|
||||
if not WorldBook.exists(name):
|
||||
raise HTTPException(status_code=404, detail=f"世界书 '{name}' 不存在")
|
||||
|
||||
# 加载世界书
|
||||
world_book = WorldBook.load(name)
|
||||
|
||||
# 获取条目
|
||||
entry = world_book.get_entry(uid)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=404, detail=f"条目 UID {uid} 不存在")
|
||||
|
||||
logger.info(f"获取世界书 {name} 的条目: UID={uid}")
|
||||
return entry.to_dict()
|
||||
except HTTPException:
|
||||
raise
|
||||
return worldbook_service.get_entry(name, uid)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"获取世界书 {name} 的条目 {uid} 失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"获取世界书条目失败: {str(e)}")
|
||||
|
||||
logger.error(f"Failed to get entry '{uid}' from worldbook '{name}': {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post("/{name}/entries", response_model=Dict[str, Any])
|
||||
async def create_worldbook_entry(name: str, entry_data: Dict[str, Any]):
|
||||
"""
|
||||
在世界书中创建新条目
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
entry_data: 条目数据
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 创建的条目数据
|
||||
"""
|
||||
try:
|
||||
# 检查世界书是否存在
|
||||
if not WorldBook.exists(name):
|
||||
raise HTTPException(status_code=404, detail=f"世界书 '{name}' 不存在")
|
||||
|
||||
# 加载世界书
|
||||
world_book = WorldBook.load(name)
|
||||
|
||||
# 处理触发配置数据
|
||||
trigger_data = entry_data.pop("trigger_config", None)
|
||||
if trigger_data and "triggers" in trigger_data:
|
||||
# 创建新的触发配置对象
|
||||
trigger_config = TriggerConfig()
|
||||
|
||||
# 处理每个触发策略
|
||||
for strategy_str, trigger_info in trigger_data["triggers"].items():
|
||||
try:
|
||||
strategy = TriggerStrategy(strategy_str)
|
||||
enabled = trigger_info[0] if isinstance(trigger_info, list) and len(trigger_info) > 0 else False
|
||||
config_data = trigger_info[1] if isinstance(trigger_info, list) and len(trigger_info) > 1 else None
|
||||
|
||||
# 根据触发策略创建对应的配置对象
|
||||
if strategy == TriggerStrategy.KEYWORD and config_data:
|
||||
config = KeywordTriggerConfig(**config_data)
|
||||
elif strategy == TriggerStrategy.RAG and config_data:
|
||||
config = RAGTriggerConfig(**config_data)
|
||||
elif strategy == TriggerStrategy.CONDITION and config_data:
|
||||
config = ConditionTriggerConfig(**config_data)
|
||||
else:
|
||||
config = None
|
||||
|
||||
# 设置触发策略
|
||||
trigger_config.set_trigger(strategy, enabled, config)
|
||||
return worldbook_service.create_entry(name, entry_data)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.warning(f"处理触发策略 {strategy_str} 失败: {str(e)}")
|
||||
continue
|
||||
|
||||
# 设置触发配置
|
||||
entry_data["trigger_config"] = trigger_config
|
||||
|
||||
# 创建条目
|
||||
entry = WorldItem.Entry(**entry_data)
|
||||
|
||||
# 添加条目
|
||||
world_book.add_entry(entry)
|
||||
|
||||
# 保存世界书
|
||||
world_book.save()
|
||||
|
||||
logger.info(f"在世界书 {name} 中创建条目: UID={entry.uid}")
|
||||
return entry.to_dict()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"在世界书 {name} 中创建条目失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"创建世界书条目失败: {str(e)}")
|
||||
|
||||
logger.error(f"Failed to create entry in worldbook '{name}': {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.put("/{name}/entries/{uid}", response_model=Dict[str, Any])
|
||||
async def update_worldbook_entry(name: str, uid: int, entry_data: Dict[str, Any]):
|
||||
async def update_worldbook_entry(name: str, uid: str, entry_data: Dict[str, Any]):
|
||||
"""
|
||||
更新世界书的指定条目
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
uid: 条目 UID
|
||||
entry_data: 条目数据
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 更新后的条目数据
|
||||
"""
|
||||
try:
|
||||
# 检查世界书是否存在
|
||||
if not WorldBook.exists(name):
|
||||
raise HTTPException(status_code=404, detail=f"世界书 '{name}' 不存在")
|
||||
|
||||
# 加载世界书
|
||||
world_book = WorldBook.load(name)
|
||||
|
||||
# 检查条目是否存在
|
||||
if world_book.get_entry(uid) is None:
|
||||
raise HTTPException(status_code=404, detail=f"条目 UID {uid} 不存在")
|
||||
|
||||
# 处理触发配置数据
|
||||
trigger_data = entry_data.pop("trigger_config", None)
|
||||
if trigger_data and "triggers" in trigger_data:
|
||||
# 创建新的触发配置对象
|
||||
trigger_config = TriggerConfig()
|
||||
|
||||
# 处理每个触发策略
|
||||
for strategy_str, trigger_info in trigger_data["triggers"].items():
|
||||
try:
|
||||
strategy = TriggerStrategy(strategy_str)
|
||||
enabled = trigger_info[0] if isinstance(trigger_info, list) and len(trigger_info) > 0 else False
|
||||
config_data = trigger_info[1] if isinstance(trigger_info, list) and len(trigger_info) > 1 else None
|
||||
|
||||
# 根据触发策略创建对应的配置对象
|
||||
if strategy == TriggerStrategy.KEYWORD and config_data:
|
||||
config = KeywordTriggerConfig(**config_data)
|
||||
elif strategy == TriggerStrategy.RAG and config_data:
|
||||
config = RAGTriggerConfig(**config_data)
|
||||
elif strategy == TriggerStrategy.CONDITION and config_data:
|
||||
config = ConditionTriggerConfig(**config_data)
|
||||
else:
|
||||
config = None
|
||||
|
||||
# 设置触发策略
|
||||
trigger_config.set_trigger(strategy, enabled, config)
|
||||
return worldbook_service.update_entry(name, uid, entry_data)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.warning(f"处理触发策略 {strategy_str} 失败: {str(e)}")
|
||||
continue
|
||||
|
||||
# 设置触发配置
|
||||
entry_data["trigger_config"] = trigger_config
|
||||
|
||||
valid_fields = WorldItem.Entry.model_fields.keys()
|
||||
filtered_data = {k: v for k, v in entry_data.items() if k in valid_fields}
|
||||
|
||||
# 更新条目
|
||||
success = world_book.update_entry(uid, **filtered_data)
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="更新条目失败")
|
||||
|
||||
# 保存世界书
|
||||
world_book.save()
|
||||
|
||||
# 获取更新后的条目
|
||||
entry = world_book.get_entry(uid)
|
||||
|
||||
logger.info(f"更新世界书 {name} 的条目: UID={uid}")
|
||||
return entry.to_dict()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"更新世界书 {name} 的条目 {uid} 失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"更新世界书条目失败: {str(e)}")
|
||||
|
||||
logger.error(f"Failed to update entry '{uid}' in worldbook '{name}': {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.delete("/{name}/entries/{uid}")
|
||||
async def delete_worldbook_entry(name: str, uid: int):
|
||||
async def delete_worldbook_entry(name: str, uid: str):
|
||||
"""
|
||||
删除世界书的指定条目
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
uid: 条目 UID
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 删除结果
|
||||
"""
|
||||
try:
|
||||
# 检查世界书是否存在
|
||||
if not WorldBook.exists(name):
|
||||
raise HTTPException(status_code=404, detail=f"世界书 '{name}' 不存在")
|
||||
|
||||
# 加载世界书
|
||||
world_book = WorldBook.load(name)
|
||||
|
||||
# 删除条目
|
||||
success = world_book.remove_entry(uid)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail=f"条目 UID {uid} 不存在")
|
||||
|
||||
# 保存世界书
|
||||
world_book.save()
|
||||
|
||||
logger.info(f"删除世界书 {name} 的条目: UID={uid}")
|
||||
return {"success": True, "message": f"条目 UID {uid} 已删除"}
|
||||
except HTTPException:
|
||||
raise
|
||||
worldbook_service.delete_entry(name, uid)
|
||||
return {"message": f"Entry '{uid}' deleted successfully"}
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"删除世界书 {name} 的条目 {uid} 失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"删除世界书条目失败: {str(e)}")
|
||||
|
||||
logger.error(f"Failed to delete entry '{uid}' from worldbook '{name}': {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post("/{name}/import", response_model=Dict[str, Any])
|
||||
async def import_worldbook(name: str, file: UploadFile = File(...)):
|
||||
"""
|
||||
从文件导入世界书
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
file: 上传的文件(SillyTavern 格式)
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 导入的世界书数据
|
||||
从文件导入世界书(自动检测 SillyTavern 或内部格式)
|
||||
"""
|
||||
try:
|
||||
# 保存临时文件
|
||||
temp_path = os.path.join(settings.WORLDBOOKS_PATH, f"temp_{file.filename}")
|
||||
with open(temp_path, "wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
content = await file.read()
|
||||
data = json.loads(content.decode('utf-8'))
|
||||
|
||||
try:
|
||||
# 从文件加载世界书
|
||||
world_book = WorldBook.load(Path(temp_path).stem)
|
||||
# 智能检测格式
|
||||
from models.converters import WorldBookConverter
|
||||
format_type = WorldBookConverter.detect_format(data)
|
||||
|
||||
# 如果世界书已存在,合并条目
|
||||
if WorldBook.exists(name):
|
||||
existing_book = WorldBook.load(name)
|
||||
existing_book.merge_from_book(world_book)
|
||||
# 保存合并后的世界书
|
||||
existing_book.save()
|
||||
world_book = existing_book
|
||||
logger.info(f"导入并合并世界书: {name}")
|
||||
logger.info(f"检测到世界书格式: {format_type}")
|
||||
|
||||
if format_type == "sillytavern":
|
||||
# SillyTavern 格式,需要转换
|
||||
logger.info(f"正在转换 SillyTavern 格式为内部格式")
|
||||
return worldbook_service.import_from_sillytavern(name, data)
|
||||
elif format_type == "internal":
|
||||
# 已经是内部格式,直接保存
|
||||
logger.info(f"检测到内部格式,直接保存")
|
||||
return worldbook_service.import_internal_format(name, data)
|
||||
else:
|
||||
# 设置名称并保存
|
||||
world_book.name = name
|
||||
world_book.save()
|
||||
logger.info(f"导入新世界书: {name}")
|
||||
raise HTTPException(status_code=400, detail="无法识别的世界书格式")
|
||||
|
||||
return world_book.to_dict()
|
||||
finally:
|
||||
# 删除临时文件
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON format")
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"导入世界书 {name} 失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"导入世界书失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/{name}/export")
|
||||
async def export_worldbook(name: str):
|
||||
"""
|
||||
导出世界书为 SillyTavern 格式
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
|
||||
Returns:
|
||||
FileResponse: 导出的文件
|
||||
"""
|
||||
try:
|
||||
# 检查世界书是否存在
|
||||
if not WorldBook.exists(name):
|
||||
raise HTTPException(status_code=404, detail=f"世界书 '{name}' 不存在")
|
||||
|
||||
# 加载世界书
|
||||
world_book = WorldBook.load(name)
|
||||
|
||||
# 创建导出文件路径
|
||||
export_path = os.path.join(settings.WORLDBOOKS_PATH, f"export_{name}.json")
|
||||
|
||||
# 导出为 SillyTavern 格式
|
||||
world_book.to_sillytavern_json(export_path)
|
||||
|
||||
logger.info(f"导出世界书: {name}")
|
||||
|
||||
# 返回文件
|
||||
return FileResponse(
|
||||
path=export_path,
|
||||
filename=f"{name}.json",
|
||||
media_type="application/json"
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"导出世界书 {name} 失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"导出世界书失败: {str(e)}")
|
||||
logger.error(f"Failed to import worldbook '{name}': {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -3,12 +3,15 @@ from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# 1. 动态计算项目根目录
|
||||
# 假设 config.py 位于 backend/core/ 目录下
|
||||
# __file__ 指向本文件的绝对路径
|
||||
# .parent 指向 backend/core/ 目录
|
||||
# .parent.parent 指向 backend/ 目录
|
||||
# .parent.parent.parent 指向项目根目录 (即包含 backend/ 和 frontend/ 的目录)
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
# 在 Docker 环境中:config.py 位于 /app/core/,需要向上2级到 /app/
|
||||
# 在本地开发中:config.py 位于 backend/core/,需要向上3级到项目根目录
|
||||
_config_path = Path(__file__).resolve()
|
||||
if _config_path.parent.parent.name == 'app':
|
||||
# Docker 环境:/app/core/config.py -> /app/
|
||||
PROJECT_ROOT = _config_path.parent.parent
|
||||
else:
|
||||
# 本地开发:backend/core/config.py -> 项目根目录
|
||||
PROJECT_ROOT = _config_path.parent.parent.parent
|
||||
|
||||
# 2. 加载 .env 文件
|
||||
# 假设 .env 文件位于项目根目录下
|
||||
@@ -16,13 +19,6 @@ load_dotenv(PROJECT_ROOT / ".env")
|
||||
|
||||
|
||||
class Settings:
|
||||
# --- 主模型配置 ---
|
||||
MAIN_LLM_API_KEY = os.getenv("MAIN_LLM_API_KEY")
|
||||
MAIN_LLM_MODEL = os.getenv("MAIN_LLM_MODEL", "gpt-3.5-turbo")
|
||||
MAIN_LLM_BASE_URL = os.getenv("MAIN_LLM_BASE_URL", "https://api.openai.com/v1")
|
||||
MAIN_LLM_MAX_TOKENS = int(os.getenv("MAIN_LLM_MAX_TOKENS", "4096"))
|
||||
MAIN_LLM_STREAM = os.getenv("MAIN_LLM_STREAM", "true").lower() == "true"
|
||||
|
||||
# --- 路径配置 (核心修改) ---
|
||||
|
||||
# 强制使用计算出的项目根目录,不再依赖 .env 中的 BASE_PATH
|
||||
@@ -35,7 +31,8 @@ class Settings:
|
||||
STATE_FILE = DATA_PATH / "state.json"
|
||||
SCHEMA_FILE = DATA_PATH / "schema.json"
|
||||
PRESETS_FILE = DATA_PATH / "presets.json"
|
||||
REGEX_FILE = DATA_PATH / "regex_rules.json"
|
||||
REGEX_FILE = DATA_PATH / "regex_rules.json" # 正则规则文件
|
||||
SYSTEM_SETTINGS_FILE = DATA_PATH / "system_settings.json" # 系统设置文件
|
||||
VECTORSTORE_PATH = DATA_PATH / "vectorstore"
|
||||
|
||||
# --- 业务数据目录 ---
|
||||
@@ -46,12 +43,41 @@ class Settings:
|
||||
# 预设目录
|
||||
PRESET_PATH = DATA_PATH / "preset"
|
||||
|
||||
# 聊天记录目录
|
||||
# 聊天记录目录(同时存放角色卡和聊天)
|
||||
CHAT_PATH = DATA_PATH / "chat"
|
||||
|
||||
# 兼容别名:用于代码中引用
|
||||
CHATS_PATH = CHAT_PATH
|
||||
|
||||
# 临时文件目录
|
||||
TEMP_PATH = DATA_PATH / "temp"
|
||||
|
||||
# ComfyUI 工作流目录
|
||||
COMFYUI_WORKFLOWS_PATH = DATA_PATH / "comfyui_workflows"
|
||||
|
||||
# 角色卡目录(已合并到 CHAT_PATH)
|
||||
CHARACTERS_PATH = CHAT_PATH
|
||||
|
||||
# 图片资源目录
|
||||
IMAGES_PATH = DATA_PATH / "images"
|
||||
|
||||
# Agent 工作流模板与运行记录
|
||||
AGENT_TEMPLATES_PATH = DATA_PATH / "agent" / "templates"
|
||||
AGENT_RUNS_PATH = DATA_PATH / "agent" / "runs"
|
||||
AGENT_STUDIO_PROJECTS_PATH = DATA_PATH / "agent" / "studio_projects"
|
||||
AGENT_STUDIO_RUNS_PATH = DATA_PATH / "agent" / "studio_runs"
|
||||
AGENT_SKILL_TEMPLATES_FILE = DATA_PATH / "agent" / "skill_templates.json"
|
||||
AGENT_NICHES_FILE = DATA_PATH / "agent" / "niches.json"
|
||||
AGENT_WORKFLOW_VARIABLES_FILE = DATA_PATH / "agent" / "workflow_variables.json"
|
||||
|
||||
# 爽文(Fiction / Novel)数据目录
|
||||
FICTION_PATH = DATA_PATH / "agent" / "fiction"
|
||||
FICTION_EMOTION_FLOWS_PATH = FICTION_PATH / "emotion_flows"
|
||||
FICTION_GUIDE_GLOBAL_PATH = FICTION_PATH / "guide_global"
|
||||
FICTION_BOOKS_PATH = FICTION_PATH / "books"
|
||||
FICTION_EMOTION_CATALOG_FILE = FICTION_EMOTION_FLOWS_PATH / "catalog.json"
|
||||
FICTION_GUIDE_GLOBAL_ENTRIES_FILE = FICTION_GUIDE_GLOBAL_PATH / "entries.json"
|
||||
|
||||
def ensure_directories(self):
|
||||
"""确保所有配置的目录存在,如果不存在则创建"""
|
||||
directories = [
|
||||
@@ -60,10 +86,25 @@ class Settings:
|
||||
self.PRESET_PATH,
|
||||
self.CHAT_PATH,
|
||||
self.TEMP_PATH,
|
||||
self.COMFYUI_WORKFLOWS_PATH,
|
||||
self.CHARACTERS_PATH,
|
||||
self.IMAGES_PATH,
|
||||
self.AGENT_TEMPLATES_PATH,
|
||||
self.AGENT_RUNS_PATH,
|
||||
self.AGENT_STUDIO_PROJECTS_PATH,
|
||||
self.AGENT_STUDIO_RUNS_PATH,
|
||||
self.FICTION_PATH,
|
||||
self.FICTION_EMOTION_FLOWS_PATH,
|
||||
self.FICTION_GUIDE_GLOBAL_PATH,
|
||||
self.FICTION_BOOKS_PATH,
|
||||
]
|
||||
for directory in directories:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 确保核心数据文件的父目录存在
|
||||
for file_path in [self.STATE_FILE, self.SCHEMA_FILE, self.PRESETS_FILE, self.REGEX_FILE, self.SYSTEM_SETTINGS_FILE]:
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
# 1. 定义请求体模型
|
||||
class ChatRequest(BaseModel):
|
||||
# --- 基础信息 ---
|
||||
mes: str = Field(..., description="用户输入的消息内容")
|
||||
is_user: bool = Field(..., description="标识发送者是否为用户(True为用户,False为AI)")
|
||||
floor_number: int = Field(..., description="当前对话的楼层号,用于判断是否为重试(Regenerate)请求")
|
||||
|
||||
# --- 身份与会话 ---
|
||||
name: str = Field("default", description="发送者的显示名称,默认为'default'")
|
||||
role_name: Optional[str] = Field(None, description="当前绑定的角色名称")
|
||||
chat_name: Optional[str] = Field(None, description="当前会话的标识名称")
|
||||
preset: Optional[str] = Field(None, description="预设的提示词或系统指令")
|
||||
|
||||
# --- 功能开关 ---
|
||||
stream: bool = Field(False, description="是否开启流式输出")
|
||||
img_switch: bool = Field(False, description="是否开启图片生成功能")
|
||||
table_switch: bool = Field(False, description="是否开启表格生成功能")
|
||||
|
||||
# 其他可能需要的参数,比如历史记录,可以在这里加
|
||||
# history: Optional[List[Dict]] = None
|
||||
@@ -1,65 +0,0 @@
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from typing import Dict, Any
|
||||
|
||||
|
||||
class PromptComponent(BaseModel):
|
||||
"""预设组件类,代表一个独立的提示词模块"""
|
||||
|
||||
identifier: str = Field(..., description="唯一标识符,用于引用和定位组件")
|
||||
name: str = Field(..., description="组件显示名称")
|
||||
content: str = Field("", description="组件内容文本")
|
||||
# 0:System,1:User,2:Assistant
|
||||
role: int = Field(0, description="角色身份(0:System,1:User,2:Assistant)")
|
||||
system_prompt: bool = Field(False, description="是否强制作为系统提示词处理")
|
||||
marker: bool = Field(False, description="是否为动态插入点占位符")
|
||||
|
||||
@validator('role')
|
||||
def validate_role(cls, v):
|
||||
"""验证角色值是否在有效范围内"""
|
||||
if not isinstance(v, int) or v not in [0, 1, 2]:
|
||||
raise ValueError("角色值必须是0(System)、1(User)或2(Assistant)")
|
||||
return v
|
||||
|
||||
def update(self, **kwargs) -> None:
|
||||
"""
|
||||
更新组件属性
|
||||
|
||||
参数:
|
||||
**kwargs: 要更新的字段和值
|
||||
|
||||
异常:
|
||||
ValueError: 当尝试更新identifier时抛出
|
||||
"""
|
||||
if 'identifier' in kwargs:
|
||||
raise ValueError("组件标识符不可修改")
|
||||
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(self, key):
|
||||
setattr(self, key, value)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
将组件转换为字典
|
||||
|
||||
返回:
|
||||
Dict[str, Any]: 组件的字典表示
|
||||
"""
|
||||
return self.dict()
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'PromptComponent':
|
||||
"""
|
||||
从字典创建组件实例,自动处理role字段的类型转换
|
||||
|
||||
参数:
|
||||
data: 包含组件数据的字典
|
||||
|
||||
返回:
|
||||
PromptComponent: 组件实例
|
||||
"""
|
||||
# 处理role字段,将字符串转换为整数
|
||||
if 'role' in data and isinstance(data['role'], str):
|
||||
role_map = {'system': 0, 'user': 1, 'assistant': 2}
|
||||
data['role'] = role_map.get(data['role'].lower(), 0)
|
||||
|
||||
return cls(**data)
|
||||
@@ -1,591 +0,0 @@
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from typing import List, Dict, Any, Optional
|
||||
from pathlib import Path
|
||||
import json
|
||||
from .PromptComponent import PromptComponent
|
||||
|
||||
|
||||
class AIDesignSpec(BaseModel):
|
||||
"""AI设计规范类,包含模型生成的核心参数和动态结构配置"""
|
||||
|
||||
# [Base] 基础核心参数
|
||||
temperature: float = Field(1.0, description="生成温度,控制随机性(0-2)")
|
||||
frequency_penalty: float = Field(0.0, description="频率惩罚,降低重复token概率")
|
||||
presence_penalty: float = Field(0.0, description="存在惩罚,鼓励谈论新话题")
|
||||
top_p: float = Field(1.0, description="核采样,控制词汇选择范围")
|
||||
top_k: int = Field(0, description="随机采样范围,从概率最高的K个词中选择")
|
||||
top_a: float = Field(0.0, description="基于平方概率分布的采样")
|
||||
min_p: float = Field(0.0, description="最小概率阈值")
|
||||
repetition_penalty: float = Field(1.0, description="重复惩罚系数(1.0-1.2)")
|
||||
max_context: int = Field(2048, description="上下文窗口大小(Token上限)")
|
||||
max_tokens: int = Field(250, description="单次回复的最大长度")
|
||||
max_context_unlocked: bool = Field(False, description="是否允许超出限制的上下文")
|
||||
names_behavior: int = Field(0, description="名字处理行为(0=默认,1=始终包含,2=仅角色)")
|
||||
send_if_empty: str = Field("", description="用户发送空消息时自动填充的内容")
|
||||
impersonation_prompt: str = Field("", description="模仿模式下使用的提示词")
|
||||
new_chat_prompt: str = Field("", description="开启新聊天时自动发送的系统提示")
|
||||
new_group_chat_prompt: str = Field("", description="开启新群组聊天时的提示")
|
||||
new_example_chat_prompt: str = Field("", description="新示例聊天的提示")
|
||||
continue_nudge_prompt: str = Field("", description="续写功能触发的提示词")
|
||||
bias_preset_selected: str = Field("", description="选用的偏见预设")
|
||||
wi_format: str = Field("{0}", description="世界书条目的格式化字符串")
|
||||
scenario_format: str = Field("{{scenario}}", description="场景描述的格式化字符串")
|
||||
personality_format: str = Field("", description="角色性格的格式化字符串")
|
||||
group_nudge_prompt: str = Field("", description="群组聊天中提示AI仅以特定角色回复的提示词")
|
||||
stream: bool = Field(True, description="是否使用流式输出")
|
||||
assistant_prefill: str = Field("", description="强制AI回复的开头内容")
|
||||
assistant_impersonation: str = Field("", description="模仿模式下强制AI回复的开头内容")
|
||||
use_sysprompt: bool = Field(True, description="是否强制将提示词注入系统层")
|
||||
squash_system_messages: bool = Field(False, description="是否压缩系统消息")
|
||||
media_inlining: bool = Field(False, description="是否内联媒体描述")
|
||||
continue_prefill: bool = Field(True, description="续写时是否预填充内容")
|
||||
continue_postfix: str = Field(" ", description="续写时添加的后缀")
|
||||
seed: int = Field(-1, description="随机种子(-1为随机)")
|
||||
n: int = Field(1, description="生成回复的数量")
|
||||
|
||||
# [Dynamic] 动态结构
|
||||
prompts: List[PromptComponent] = Field(
|
||||
default_factory=list,
|
||||
description="组件库,定义所有可用的积木块"
|
||||
)
|
||||
prompt_order: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="组装说明书,定义构建最终提示词的顺序"
|
||||
)
|
||||
|
||||
@validator('prompts')
|
||||
def validate_prompts_unique_identifier(cls, v):
|
||||
"""验证组件标识符唯一性"""
|
||||
identifiers = [comp.identifier for comp in v]
|
||||
if len(identifiers) != len(set(identifiers)):
|
||||
raise ValueError("组件标识符必须唯一")
|
||||
return v
|
||||
|
||||
@validator('prompt_order')
|
||||
def validate_prompt_order_exists(cls, v, values):
|
||||
"""验证prompt_order中的组件ID是否存在于prompts中"""
|
||||
if 'prompts' in values:
|
||||
prompt_ids = {comp.identifier for comp in values['prompts']}
|
||||
invalid_ids = set(v) - prompt_ids
|
||||
if invalid_ids:
|
||||
raise ValueError(f"prompt_order中包含不存在的组件ID: {invalid_ids}")
|
||||
return v
|
||||
|
||||
@classmethod
|
||||
def get_preset_dir(cls) -> Path:
|
||||
"""获取预设目录路径"""
|
||||
try:
|
||||
from backend.core.config import settings
|
||||
preset_dir = settings.DATA_PATH / "preset"
|
||||
# 如果路径不存在,尝试使用相对路径
|
||||
if not preset_dir.exists():
|
||||
# 尝试从当前工作目录构建路径
|
||||
cwd_preset_dir = Path.cwd() / "data" / "preset"
|
||||
if cwd_preset_dir.exists():
|
||||
return cwd_preset_dir
|
||||
# 尝试从脚本所在目录构建路径
|
||||
script_dir = Path(__file__).resolve().parent.parent.parent
|
||||
script_preset_dir = script_dir / "data" / "preset"
|
||||
if script_preset_dir.exists():
|
||||
return script_preset_dir
|
||||
# 如果都不存在,返回默认路径
|
||||
return Path("data/preset")
|
||||
return preset_dir
|
||||
except ImportError:
|
||||
# 如果无法导入settings,尝试使用相对路径
|
||||
cwd_preset_dir = Path.cwd() / "data" / "preset"
|
||||
if cwd_preset_dir.exists():
|
||||
return cwd_preset_dir
|
||||
# 尝试从脚本所在目录构建路径
|
||||
script_dir = Path(__file__).resolve().parent.parent.parent
|
||||
script_preset_dir = script_dir / "data" / "preset"
|
||||
if script_preset_dir.exists():
|
||||
return script_preset_dir
|
||||
# 如果都不存在,返回默认路径
|
||||
return Path("data/preset")
|
||||
|
||||
@classmethod
|
||||
async def list_all_presets(cls) -> Dict[str, List[Dict]]:
|
||||
"""获取所有预设列表及其基本信息"""
|
||||
preset_dir = cls.get_preset_dir()
|
||||
if not preset_dir.exists():
|
||||
return {"presets": []}
|
||||
|
||||
presets = []
|
||||
for preset_file in preset_dir.glob("*.json"):
|
||||
try:
|
||||
with open(preset_file, 'r', encoding='utf-8') as f:
|
||||
preset_data = json.load(f)
|
||||
presets.append({
|
||||
"name": preset_file.stem,
|
||||
"description": preset_data.get("description", ""),
|
||||
"component_count": len(preset_data.get("prompts", [])),
|
||||
"temperature": preset_data.get("temperature", 1.0)
|
||||
})
|
||||
except Exception:
|
||||
continue # 跳过损坏的预设文件
|
||||
return {"presets": presets}
|
||||
|
||||
@classmethod
|
||||
async def get_preset(cls, preset_name: str) -> Dict[str, Any]:
|
||||
"""获取指定预设的完整内容"""
|
||||
preset_path = cls.get_preset_dir() / f"{preset_name}.json"
|
||||
if not preset_path.exists():
|
||||
raise FileNotFoundError(f"Preset not found: {preset_name}")
|
||||
|
||||
try:
|
||||
with open(preset_path, 'r', encoding='utf-8') as f:
|
||||
preset_data = json.load(f)
|
||||
|
||||
# 处理prompt_order,简化为单角色配置
|
||||
if 'prompt_order' in preset_data and isinstance(preset_data['prompt_order'], list) and len(
|
||||
preset_data['prompt_order']) > 0:
|
||||
# 检查第一个元素是否为字典(多角色配置)
|
||||
first_item = preset_data['prompt_order'][0]
|
||||
if isinstance(first_item, dict) and 'order' in first_item:
|
||||
# 提取第一个角色的order配置
|
||||
first_role_order = first_item
|
||||
if isinstance(first_role_order['order'], list):
|
||||
# 简化为只包含enabled为True的identifier列表
|
||||
simplified_order = [
|
||||
item.get('identifier')
|
||||
for item in first_role_order['order']
|
||||
if item.get('enabled', True)
|
||||
]
|
||||
preset_data['prompt_order'] = simplified_order
|
||||
|
||||
# 转换为AIDesignSpec对象进行验证
|
||||
ai_design_spec = cls.from_dict(preset_data)
|
||||
|
||||
# 构建返回数据,确保格式与前端期望的一致
|
||||
result = {
|
||||
# 基础参数
|
||||
"temperature": ai_design_spec.temperature,
|
||||
"frequency_penalty": ai_design_spec.frequency_penalty,
|
||||
"presence_penalty": ai_design_spec.presence_penalty,
|
||||
"top_p": ai_design_spec.top_p,
|
||||
"top_k": ai_design_spec.top_k,
|
||||
"max_context": ai_design_spec.max_context,
|
||||
"max_tokens": ai_design_spec.max_tokens,
|
||||
"max_context_unlocked": ai_design_spec.max_context_unlocked,
|
||||
"stream_openai": ai_design_spec.stream,
|
||||
"seed": ai_design_spec.seed,
|
||||
"n": ai_design_spec.n,
|
||||
|
||||
# 兼容旧格式
|
||||
"openai_max_context": ai_design_spec.max_context,
|
||||
"openai_max_tokens": ai_design_spec.max_tokens,
|
||||
|
||||
# 其他参数
|
||||
"top_a": ai_design_spec.top_a,
|
||||
"min_p": ai_design_spec.min_p,
|
||||
"repetition_penalty": ai_design_spec.repetition_penalty,
|
||||
"names_behavior": ai_design_spec.names_behavior,
|
||||
"send_if_empty": ai_design_spec.send_if_empty,
|
||||
"impersonation_prompt": ai_design_spec.impersonation_prompt,
|
||||
"new_chat_prompt": ai_design_spec.new_chat_prompt,
|
||||
"new_group_chat_prompt": ai_design_spec.new_group_chat_prompt,
|
||||
"new_example_chat_prompt": ai_design_spec.new_example_chat_prompt,
|
||||
"continue_nudge_prompt": ai_design_spec.continue_nudge_prompt,
|
||||
"bias_preset_selected": ai_design_spec.bias_preset_selected,
|
||||
"wi_format": ai_design_spec.wi_format,
|
||||
"scenario_format": ai_design_spec.scenario_format,
|
||||
"personality_format": ai_design_spec.personality_format,
|
||||
"group_nudge_prompt": ai_design_spec.group_nudge_prompt,
|
||||
"assistant_prefill": ai_design_spec.assistant_prefill,
|
||||
"assistant_impersonation": ai_design_spec.assistant_impersonation,
|
||||
"use_sysprompt": ai_design_spec.use_sysprompt,
|
||||
"squash_system_messages": ai_design_spec.squash_system_messages,
|
||||
"media_inlining": ai_design_spec.media_inlining,
|
||||
"continue_prefill": ai_design_spec.continue_prefill,
|
||||
"continue_postfix": ai_design_spec.continue_postfix,
|
||||
|
||||
# 处理组件
|
||||
"prompts": []
|
||||
}
|
||||
|
||||
# 处理组件列表
|
||||
if ai_design_spec.prompts:
|
||||
# 获取当前角色的prompt_order(简化后的字符串列表)
|
||||
current_order = ai_design_spec.prompt_order if ai_design_spec.prompt_order else []
|
||||
|
||||
# 构建组件列表
|
||||
for prompt in ai_design_spec.prompts:
|
||||
# 检查组件是否在order中
|
||||
is_in_order = prompt.identifier in current_order
|
||||
|
||||
# 构建组件对象
|
||||
component = {
|
||||
"identifier": prompt.identifier,
|
||||
"name": prompt.name,
|
||||
"content": prompt.content if hasattr(prompt, 'content') else "",
|
||||
"role": prompt.role if hasattr(prompt, 'role') else (0 if prompt.system_prompt else 1),
|
||||
"system_prompt": prompt.system_prompt,
|
||||
"marker": prompt.marker,
|
||||
"enabled": is_in_order if current_order else True
|
||||
}
|
||||
|
||||
result["prompts"].append(component)
|
||||
|
||||
# 按照order排序组件
|
||||
if current_order:
|
||||
result["prompts"].sort(
|
||||
key=lambda x: current_order.index(x["identifier"]) if x[
|
||||
"identifier"] in current_order else len(
|
||||
current_order))
|
||||
|
||||
# 添加prompt_order
|
||||
result["prompt_order"] = ai_design_spec.prompt_order if ai_design_spec.prompt_order else []
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to load preset: {str(e)}")
|
||||
|
||||
@classmethod
|
||||
async def create_preset(cls, preset_name: str, preset_data: Dict) -> Dict[str, str]:
|
||||
"""创建新预设"""
|
||||
preset_dir = cls.get_preset_dir()
|
||||
preset_dir.mkdir(parents=True, exist_ok=True)
|
||||
preset_path = preset_dir / f"{preset_name}.json"
|
||||
|
||||
if preset_path.exists():
|
||||
raise FileExistsError(f"Preset already exists: {preset_name}")
|
||||
|
||||
try:
|
||||
# 验证并转换为AIDesignSpec对象
|
||||
ai_design_spec = cls.from_dict(preset_data)
|
||||
|
||||
# 保存到文件
|
||||
with open(preset_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(ai_design_spec.dict(), f, ensure_ascii=False, indent=2)
|
||||
|
||||
return {"message": "Preset created successfully", "name": preset_name}
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to create preset: {str(e)}")
|
||||
|
||||
@classmethod
|
||||
async def update_preset(cls, preset_name: str, update_data: Dict) -> Dict[str, str]:
|
||||
"""更新预设配置"""
|
||||
preset_path = cls.get_preset_dir() / f"{preset_name}.json"
|
||||
if not preset_path.exists():
|
||||
raise FileNotFoundError(f"Preset not found: {preset_name}")
|
||||
|
||||
try:
|
||||
# 加载现有预设
|
||||
with open(preset_path, 'r', encoding='utf-8') as f:
|
||||
preset_data = json.load(f)
|
||||
|
||||
# 更新字段
|
||||
for key, value in update_data.items():
|
||||
preset_data[key] = value
|
||||
|
||||
# 验证并转换为AIDesignSpec对象
|
||||
ai_design_spec = cls.from_dict(preset_data)
|
||||
|
||||
# 保存更新
|
||||
with open(preset_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(ai_design_spec.dict(), f, ensure_ascii=False, indent=2)
|
||||
|
||||
return {"message": "Preset updated successfully"}
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to update preset: {str(e)}")
|
||||
|
||||
@classmethod
|
||||
async def delete_preset(cls, preset_name: str) -> Dict[str, str]:
|
||||
"""删除指定预设"""
|
||||
preset_path = cls.get_preset_dir() / f"{preset_name}.json"
|
||||
if not preset_path.exists():
|
||||
raise FileNotFoundError(f"Preset not found: {preset_name}")
|
||||
|
||||
try:
|
||||
preset_path.unlink()
|
||||
return {"message": "Preset deleted successfully"}
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to delete preset: {str(e)}")
|
||||
|
||||
@classmethod
|
||||
async def list_components(cls, preset_name: str) -> Dict[str, List[Dict]]:
|
||||
"""获取预设中的所有组件"""
|
||||
preset_path = cls.get_preset_dir() / f"{preset_name}.json"
|
||||
if not preset_path.exists():
|
||||
raise FileNotFoundError(f"Preset not found: {preset_name}")
|
||||
|
||||
try:
|
||||
with open(preset_path, 'r', encoding='utf-8') as f:
|
||||
preset_data = json.load(f)
|
||||
|
||||
# 获取组件列表
|
||||
components = preset_data.get("prompts", [])
|
||||
return {"components": components}
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to load components: {str(e)}")
|
||||
|
||||
@classmethod
|
||||
async def get_component(cls, preset_name: str, component_id: str) -> Dict[str, Any]:
|
||||
"""获取指定组件的详情"""
|
||||
preset_path = cls.get_preset_dir() / f"{preset_name}.json"
|
||||
if not preset_path.exists():
|
||||
raise FileNotFoundError(f"Preset not found: {preset_name}")
|
||||
|
||||
try:
|
||||
with open(preset_path, 'r', encoding='utf-8') as f:
|
||||
preset_data = json.load(f)
|
||||
|
||||
# 查找组件
|
||||
components = preset_data.get("prompts", [])
|
||||
component = next((c for c in components if c.get("identifier") == component_id), None)
|
||||
|
||||
if not component:
|
||||
raise FileNotFoundError(f"Component not found: {component_id}")
|
||||
|
||||
return component
|
||||
except FileNotFoundError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to load component: {str(e)}")
|
||||
|
||||
@classmethod
|
||||
async def add_component_to_preset(cls, preset_name: str, component_data: Dict) -> Dict[str, str]:
|
||||
"""向预设添加新组件"""
|
||||
preset_path = cls.get_preset_dir() / f"{preset_name}.json"
|
||||
if not preset_path.exists():
|
||||
raise FileNotFoundError(f"Preset not found: {preset_name}")
|
||||
|
||||
try:
|
||||
# 加载预设数据
|
||||
with open(preset_path, 'r', encoding='utf-8') as f:
|
||||
preset_data = json.load(f)
|
||||
|
||||
# 验证组件数据
|
||||
component = PromptComponent(**component_data)
|
||||
|
||||
# 检查组件ID是否已存在
|
||||
components = preset_data.get("prompts", [])
|
||||
if any(c.get("identifier") == component.identifier for c in components):
|
||||
raise ValueError(f"Component identifier already exists: {component.identifier}")
|
||||
|
||||
# 添加组件
|
||||
components.append(component.dict())
|
||||
preset_data["prompts"] = components
|
||||
|
||||
# 保存更新
|
||||
with open(preset_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(preset_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
return {"message": "Component added successfully", "identifier": component.identifier}
|
||||
except (FileNotFoundError, ValueError):
|
||||
raise
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to add component: {str(e)}")
|
||||
|
||||
@classmethod
|
||||
async def update_component_in_preset(cls, preset_name: str, component_id: str, update_data: Dict) -> Dict[str, str]:
|
||||
"""更新指定组件"""
|
||||
preset_path = cls.get_preset_dir() / f"{preset_name}.json"
|
||||
if not preset_path.exists():
|
||||
raise FileNotFoundError(f"Preset not found: {preset_name}")
|
||||
|
||||
try:
|
||||
# 加载预设数据
|
||||
with open(preset_path, 'r', encoding='utf-8') as f:
|
||||
preset_data = json.load(f)
|
||||
|
||||
# 查找并更新组件
|
||||
components = preset_data.get("prompts", [])
|
||||
component_index = next((i for i, c in enumerate(components) if c.get("identifier") == component_id), None)
|
||||
|
||||
if component_index is None:
|
||||
raise FileNotFoundError(f"Component not found: {component_id}")
|
||||
|
||||
# 更新组件字段
|
||||
for key, value in update_data.items():
|
||||
components[component_index][key] = value
|
||||
|
||||
# 保存更新
|
||||
with open(preset_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(preset_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
return {"message": "Component updated successfully"}
|
||||
except FileNotFoundError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to update component: {str(e)}")
|
||||
|
||||
@classmethod
|
||||
async def delete_component_from_preset(cls, preset_name: str, component_id: str) -> Dict[str, str]:
|
||||
"""从预设中删除指定组件"""
|
||||
preset_path = cls.get_preset_dir() / f"{preset_name}.json"
|
||||
if not preset_path.exists():
|
||||
raise FileNotFoundError(f"Preset not found: {preset_name}")
|
||||
|
||||
try:
|
||||
# 加载预设数据
|
||||
with open(preset_path, 'r', encoding='utf-8') as f:
|
||||
preset_data = json.load(f)
|
||||
|
||||
# 查找并删除组件
|
||||
components = preset_data.get("prompts", [])
|
||||
original_length = len(components)
|
||||
components = [c for c in components if c.get("identifier") != component_id]
|
||||
|
||||
if len(components) == original_length:
|
||||
raise FileNotFoundError(f"Component not found: {component_id}")
|
||||
|
||||
# 更新预设数据
|
||||
preset_data["prompts"] = components
|
||||
|
||||
# 保存更新
|
||||
with open(preset_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(preset_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
return {"message": "Component deleted successfully"}
|
||||
except FileNotFoundError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to delete component: {str(e)}")
|
||||
|
||||
# ========== 组件管理方法 ==========
|
||||
|
||||
def add_component(self, component: PromptComponent) -> None:
|
||||
"""
|
||||
添加新组件
|
||||
|
||||
参数:
|
||||
component: 要添加的组件
|
||||
|
||||
异常:
|
||||
ValueError: 当组件标识符已存在时抛出
|
||||
"""
|
||||
if any(c.identifier == component.identifier for c in self.prompts):
|
||||
raise ValueError(f"组件标识符 {component.identifier} 已存在")
|
||||
self.prompts.append(component)
|
||||
|
||||
def remove_component(self, identifier: str) -> bool:
|
||||
"""
|
||||
移除指定组件
|
||||
|
||||
参数:
|
||||
identifier: 组件标识符
|
||||
|
||||
返回:
|
||||
bool: 是否成功移除
|
||||
"""
|
||||
original_length = len(self.prompts)
|
||||
self.prompts = [c for c in self.prompts if c.identifier != identifier]
|
||||
|
||||
# 同时从prompt_order中移除
|
||||
self.prompt_order = [id for id in self.prompt_order if id != identifier]
|
||||
|
||||
return len(self.prompts) < original_length
|
||||
|
||||
def get_component(self, identifier: str) -> Optional[PromptComponent]:
|
||||
"""
|
||||
获取指定组件
|
||||
|
||||
参数:
|
||||
identifier: 组件标识符
|
||||
|
||||
返回:
|
||||
Optional[PromptComponent]: 找到的组件,未找到返回None
|
||||
"""
|
||||
for component in self.prompts:
|
||||
if component.identifier == identifier:
|
||||
return component
|
||||
return None
|
||||
|
||||
def update_component(self, identifier: str, **kwargs) -> bool:
|
||||
"""
|
||||
更新指定组件
|
||||
|
||||
参数:
|
||||
identifier: 组件标识符
|
||||
**kwargs: 要更新的字段
|
||||
|
||||
返回:
|
||||
bool: 是否成功更新
|
||||
"""
|
||||
component = self.get_component(identifier)
|
||||
if component is None:
|
||||
return False
|
||||
|
||||
component.update(**kwargs)
|
||||
return True
|
||||
|
||||
def list_components(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
列出所有组件
|
||||
|
||||
返回:
|
||||
List[Dict[str, Any]]: 组件字典列表
|
||||
"""
|
||||
return [component.to_dict() for component in self.prompts]
|
||||
|
||||
def reorder_components(self, new_order: List[str]) -> None:
|
||||
"""
|
||||
重新排序组件
|
||||
|
||||
参数:
|
||||
new_order: 新的组件标识符顺序
|
||||
|
||||
异常:
|
||||
ValueError: 当包含不存在的组件ID时抛出
|
||||
"""
|
||||
# 验证所有ID都存在
|
||||
existing_ids = {c.identifier for c in self.prompts}
|
||||
invalid_ids = set(new_order) - existing_ids
|
||||
|
||||
if invalid_ids:
|
||||
raise ValueError(f"包含不存在的组件ID: {invalid_ids}")
|
||||
|
||||
self.prompt_order = new_order
|
||||
|
||||
def get_ordered_components(self) -> List[PromptComponent]:
|
||||
"""
|
||||
获取按prompt_order排序的组件列表
|
||||
|
||||
返回:
|
||||
List[PromptComponent]: 排序后的组件列表
|
||||
"""
|
||||
component_map = {c.identifier: c for c in self.prompts}
|
||||
ordered_components = []
|
||||
|
||||
for identifier in self.prompt_order:
|
||||
if identifier in component_map:
|
||||
ordered_components.append(component_map[identifier])
|
||||
|
||||
# 添加未在prompt_order中的组件
|
||||
ordered_components.extend([
|
||||
c for c in self.prompts
|
||||
if c.identifier not in self.prompt_order
|
||||
])
|
||||
|
||||
return ordered_components
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
将设计规范转换为字典
|
||||
|
||||
返回:
|
||||
Dict[str, Any]: 设计规范的字典表示
|
||||
"""
|
||||
return self.dict()
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'AIDesignSpec':
|
||||
"""
|
||||
从字典创建设计规范实例
|
||||
|
||||
参数:
|
||||
data: 包含设计规范数据的字典
|
||||
|
||||
返回:
|
||||
AIDesignSpec: 设计规范实例
|
||||
"""
|
||||
# 处理prompts字段
|
||||
if 'prompts' in data:
|
||||
data['prompts'] = [
|
||||
PromptComponent.from_dict(comp) if isinstance(comp, dict) else comp
|
||||
for comp in data['prompts']
|
||||
]
|
||||
|
||||
return cls(**data)
|
||||
@@ -1,438 +0,0 @@
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Any
|
||||
from pathlib import Path
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from .WorldItem import WorldItem, TriggerStrategy
|
||||
from backend.core.config import settings
|
||||
|
||||
# 配置日志
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WorldBook(BaseModel):
|
||||
"""
|
||||
世界书集合模型
|
||||
管理多个世界书条目,支持导入导出 SillyTavern 格式
|
||||
"""
|
||||
# 世界书基本信息
|
||||
name: str = Field(..., description="世界书名称")
|
||||
|
||||
# 条目集合
|
||||
entries: Dict[str, WorldItem.Entry] = Field(
|
||||
default_factory=dict,
|
||||
description="世界书条目字典对象 (Key-Value Map)"
|
||||
)
|
||||
|
||||
@field_validator('entries')
|
||||
@classmethod
|
||||
def validate_entries_unique_uid(cls, v):
|
||||
"""验证条目 UID 的唯一性"""
|
||||
uids = [entry.uid for entry in v.values()]
|
||||
if len(uids) != len(set(uids)):
|
||||
logger.error("验证失败: 条目 UID 必须唯一")
|
||||
raise ValueError("条目 UID 必须唯一")
|
||||
return v
|
||||
|
||||
@classmethod
|
||||
def get_file_path(cls, name: str) -> str:
|
||||
"""
|
||||
根据世界书名称获取文件路径
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
|
||||
Returns:
|
||||
str: 完整的文件路径
|
||||
"""
|
||||
# 使用配置中的 WORLDBOOKS_PATH
|
||||
return str(settings.WORLDBOOKS_PATH / f"{name}.json")
|
||||
|
||||
@classmethod
|
||||
def exists(cls, name: str) -> bool:
|
||||
"""
|
||||
检查指定名称的世界书文件是否存在
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
|
||||
Returns:
|
||||
bool: 文件是否存在
|
||||
"""
|
||||
file_path = cls.get_file_path(name)
|
||||
return os.path.exists(file_path)
|
||||
|
||||
@classmethod
|
||||
def create_empty(cls, name: str) -> 'WorldBook':
|
||||
"""
|
||||
创建并保存一个空白的世界书
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
Returns:
|
||||
WorldBook: 创建的世界书对象
|
||||
|
||||
Raises:
|
||||
ValueError: 世界书已存在
|
||||
IOError: 文件写入失败
|
||||
"""
|
||||
# 检查世界书是否已存在
|
||||
if cls.exists(name):
|
||||
raise ValueError(f"世界书 '{name}' 已存在")
|
||||
|
||||
# 创建空白世界书对象
|
||||
world_book = cls(
|
||||
name=name,
|
||||
)
|
||||
|
||||
# 保存世界书
|
||||
world_book.save()
|
||||
|
||||
logger.info(f"创建空白世界书: {name}")
|
||||
return world_book
|
||||
|
||||
def add_entry(self, entry: WorldItem.Entry) -> None:
|
||||
"""
|
||||
添加世界书条目
|
||||
|
||||
Args:
|
||||
entry: 世界书条目对象
|
||||
|
||||
Raises:
|
||||
ValueError: 条目 UID 已存在
|
||||
"""
|
||||
entry_key = str(entry.uid)
|
||||
if entry_key in self.entries:
|
||||
error_msg = f"添加条目失败: 条目 UID {entry.uid} 已存在于世界书 {self.name}"
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
self.entries[entry_key] = entry
|
||||
logger.debug(f"已添加条目: UID={entry.uid}, 世界书={self.name}")
|
||||
|
||||
def remove_entry(self, uid: int) -> bool:
|
||||
"""
|
||||
移除世界书条目
|
||||
|
||||
Args:
|
||||
uid: 条目 UID
|
||||
|
||||
Returns:
|
||||
bool: 是否成功移除
|
||||
"""
|
||||
entry_key = str(uid)
|
||||
if entry_key in self.entries:
|
||||
del self.entries[entry_key]
|
||||
logger.info(f"已从世界书 {self.name} 移除条目: UID={uid}")
|
||||
return True
|
||||
logger.warning(f"尝试移除不存在的条目: 世界书 {self.name} 中未找到 UID={uid}")
|
||||
return False
|
||||
|
||||
def get_entry(self, uid: int) -> Optional[WorldItem.Entry]:
|
||||
"""
|
||||
获取指定 UID 的世界书条目
|
||||
|
||||
Args:
|
||||
uid: 条目 UID
|
||||
|
||||
Returns:
|
||||
Optional[WorldItem.Entry]: 找到的条目,未找到返回 None
|
||||
"""
|
||||
entry_key = str(uid)
|
||||
entry = self.entries.get(entry_key)
|
||||
if entry:
|
||||
logger.debug(f"从世界书 {self.name} 获取条目: UID={uid}")
|
||||
else:
|
||||
logger.debug(f"在世界书 {self.name} 中未找到条目: UID={uid}")
|
||||
return entry
|
||||
|
||||
def update_entry(self, uid: int, **kwargs) -> bool:
|
||||
"""
|
||||
更新世界书条目
|
||||
|
||||
Args:
|
||||
uid: 条目 UID
|
||||
**kwargs: 要更新的字段
|
||||
|
||||
Returns:
|
||||
bool: 是否成功更新
|
||||
"""
|
||||
entry = self.get_entry(uid)
|
||||
if entry is None:
|
||||
logger.warning(f"更新条目失败: 在世界书 {self.name} 中未找到 UID={uid}")
|
||||
return False
|
||||
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(entry, key):
|
||||
setattr(entry, key, value)
|
||||
logger.info(f"已更新世界书 {self.name} 中的条目: UID={uid}, 更新字段={list(kwargs.keys())}")
|
||||
return True
|
||||
|
||||
def filter_by_position(self, position: int) -> List[WorldItem.Entry]:
|
||||
"""
|
||||
根据位置筛选条目
|
||||
|
||||
Args:
|
||||
position: 位置值
|
||||
|
||||
Returns:
|
||||
List[WorldItem.Entry]: 筛选后的条目列表
|
||||
"""
|
||||
filtered_entries = [
|
||||
entry for entry in self.entries.values()
|
||||
if entry.position == position
|
||||
]
|
||||
logger.debug(
|
||||
f"在世界书 {self.name} 中按位置筛选: 值={position}, 结果数量={len(filtered_entries)}")
|
||||
return filtered_entries
|
||||
|
||||
def get_summary(self) -> Dict[str, Any]:
|
||||
"""
|
||||
获取世界书概要信息
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 概要信息字典
|
||||
"""
|
||||
summary = {
|
||||
"name": self.name,
|
||||
"entry_count": len(self.entries),
|
||||
"trigger_strategies": {
|
||||
strategy.value: sum(1 for e in self.entries.values()
|
||||
if strategy in e.trigger_config.get_enabled_triggers())
|
||||
for strategy in TriggerStrategy
|
||||
}
|
||||
}
|
||||
logger.debug(f"获取世界书 {self.name} 的概要信息")
|
||||
return summary
|
||||
|
||||
def to_summary_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
生成世界书摘要信息,用于列表显示
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 包含基本信息的字典
|
||||
"""
|
||||
summary = {
|
||||
"name": self.name,
|
||||
"entry_count": len(self.entries),
|
||||
}
|
||||
logger.debug(f"生成世界书 {self.name} 的摘要信息")
|
||||
return summary
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
"""
|
||||
将 WorldBook 转换为字典
|
||||
|
||||
Returns:
|
||||
Dict: 世界书数据字典
|
||||
"""
|
||||
result = {
|
||||
'name': self.name,
|
||||
'entries': {uid: entry.model_dump() for uid, entry in self.entries.items()}
|
||||
}
|
||||
logger.debug(f"将世界书 {self.name} 转换为字典")
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def load(cls, name: str) -> 'WorldBook':
|
||||
"""
|
||||
从文件加载世界书(只有 entries 字段的格式)
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
|
||||
Returns:
|
||||
WorldBook: 世界书对象
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: 文件不存在
|
||||
ValueError: 格式不符合标准
|
||||
json.JSONDecodeError: JSON 解析错误
|
||||
"""
|
||||
file_path = cls.get_file_path(name)
|
||||
if not os.path.exists(file_path):
|
||||
error_msg = f"世界书文件未找到: {file_path}"
|
||||
logger.error(error_msg)
|
||||
raise FileNotFoundError(error_msg)
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
raw_data = json.load(f)
|
||||
|
||||
# 世界书名称始终使用文件名(不包括后缀名)
|
||||
world_name = name
|
||||
|
||||
# 直接使用 entries 字段
|
||||
entries_dict = raw_data.get("entries", {})
|
||||
if not isinstance(entries_dict, dict):
|
||||
error_msg = "无效的世界书格式:'entries' 字段必须是一个字典。"
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
# 创建世界书对象
|
||||
world_book = cls(
|
||||
name=world_name,
|
||||
)
|
||||
|
||||
# 转换标准格式的条目
|
||||
for uid, entry_data in entries_dict.items():
|
||||
try:
|
||||
# 先使用 WorldItem 解析数据
|
||||
world_item = WorldItem.from_sillytavern_data(entry_data)
|
||||
# 然后转换为 Entry
|
||||
world_entry = world_item.to_entry()
|
||||
world_book.add_entry(world_entry)
|
||||
except Exception as e:
|
||||
logger.warning(f"跳过条目 {uid},解析失败: {e}")
|
||||
|
||||
logger.info(
|
||||
f"从文件加载世界书: 文件={file_path}, 名称={world_name}, 条目数={len(world_book.entries)}")
|
||||
|
||||
return world_book
|
||||
except json.JSONDecodeError as e:
|
||||
error_msg = f"JSON 解析错误: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
except Exception as e:
|
||||
error_msg = f"从文件加载世界书失败: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
def save(self) -> None:
|
||||
"""
|
||||
保存世界书到文件(只有 entries 字段的格式)
|
||||
如果文件不存在,会创建新文件;如果文件存在,会更新现有文件
|
||||
|
||||
Raises:
|
||||
IOError: 文件写入失败
|
||||
"""
|
||||
file_path = self.get_file_path(self.name)
|
||||
|
||||
# 确保目录存在
|
||||
os.makedirs(Path(file_path).parent, exist_ok=True)
|
||||
|
||||
try:
|
||||
# 转换为标准格式
|
||||
entries_dict = {}
|
||||
for uid, entry in self.entries.items():
|
||||
entries_dict[uid] = entry.to_sillytavern_dict()
|
||||
|
||||
output_data = {
|
||||
"entries": entries_dict
|
||||
}
|
||||
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(output_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
logger.info(
|
||||
f"世界书已保存: 文件={file_path}, 名称={self.name}, 条目数={len(self.entries)}")
|
||||
except Exception as e:
|
||||
error_msg = f"保存世界书失败: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
raise IOError(error_msg)
|
||||
|
||||
def list_triggers_and_content(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
提取所有条目的触发关键词和内容,用于快速构建向量数据库或索引
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 包含 trigger (key) 和 content 的列表
|
||||
"""
|
||||
result = []
|
||||
for entry in self.entries.values():
|
||||
entry_dict = entry.to_dict()
|
||||
# 添加额外的触发相关信息
|
||||
enabled_triggers = entry.trigger_config.get_enabled_triggers()
|
||||
keyword_enabled, keyword_config = entry.trigger_config.get_trigger(TriggerStrategy.KEYWORD)
|
||||
constant_enabled, _ = entry.trigger_config.get_trigger(TriggerStrategy.CONSTANT)
|
||||
|
||||
entry_dict.update({
|
||||
"triggers": keyword_config.key if keyword_enabled and keyword_config else [],
|
||||
"constant": constant_enabled,
|
||||
"trigger_strategies": [strategy.value for strategy in enabled_triggers]
|
||||
})
|
||||
result.append(entry_dict)
|
||||
|
||||
logger.debug(f"列出世界书 {self.name} 的触发词和内容: 条目数={len(result)}")
|
||||
return result
|
||||
|
||||
def get_all_entries(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取所有条目的核心信息(包括已禁用的条目)
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 包含核心信息的条目列表
|
||||
"""
|
||||
result = [entry.to_dict() for entry in self.entries.values()]
|
||||
logger.debug(f"获取世界书 {self.name} 的所有条目: 条目数={len(result)}")
|
||||
return result
|
||||
|
||||
def merge_from_book(self, other_book: 'WorldBook') -> None:
|
||||
"""
|
||||
从另一个世界书合并条目
|
||||
|
||||
Args:
|
||||
other_book: 要合并的世界书对象
|
||||
"""
|
||||
for uid, entry in other_book.entries.items():
|
||||
if uid in self.entries:
|
||||
# 更新现有条目
|
||||
for key, value in entry.dict().items():
|
||||
if key != 'uid': # 不更新 UID
|
||||
setattr(self.entries[uid], key, value)
|
||||
else:
|
||||
# 添加新条目
|
||||
self.add_entry(entry)
|
||||
logger.info(f"合并世界书: 从 {other_book.name} 合并到 {self.name}")
|
||||
|
||||
def to_sillytavern_json(self, file_path: str) -> None:
|
||||
"""
|
||||
导出为 SillyTavern 格式的 JSON 文件
|
||||
|
||||
Args:
|
||||
file_path: 导出文件路径
|
||||
"""
|
||||
# 转换为 SillyTavern 格式
|
||||
entries_dict = {}
|
||||
for uid, entry in self.entries.items():
|
||||
entries_dict[uid] = entry.to_sillytavern_dict()
|
||||
|
||||
output_data = {
|
||||
"entries": entries_dict,
|
||||
"name": self.name
|
||||
}
|
||||
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(output_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
logger.info(f"导出世界书为 SillyTavern 格式: 文件={file_path}")
|
||||
|
||||
|
||||
# --- 使用示例 ---
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
# 创建空白世界书
|
||||
world_book = WorldBook.create_empty("test_worldbook")
|
||||
|
||||
# 加载世界书
|
||||
world_book = WorldBook.load("test_worldbook")
|
||||
|
||||
# 打印概要
|
||||
summary = world_book.get_summary()
|
||||
print(f"世界书名称: {summary['name']}")
|
||||
print(f"条目数量: {summary['entry_count']}")
|
||||
print(f"触发策略分布: {summary['trigger_strategies']}")
|
||||
|
||||
# 列出所有条目的触发词和内容预览
|
||||
print("\n--- 条目预览 ---")
|
||||
for item in world_book.list_triggers_and_content():
|
||||
triggers = item['triggers'] if item['triggers'] else ['(无关键词 - 常驻)']
|
||||
content_preview = item['content'][:50].replace('\n', ' ') + "..."
|
||||
print(f"[{item['position']}] TRIGGERS: {triggers} -> CONTENT: {content_preview}")
|
||||
|
||||
# 保存世界书
|
||||
world_book.save()
|
||||
print(f"\n✅ 世界书已保存")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 错误: {e}")
|
||||
@@ -1,826 +0,0 @@
|
||||
import logging
|
||||
from enum import Enum
|
||||
from typing import List, Optional, Dict, Any, Union
|
||||
from pydantic import BaseModel, Field, field_validator, ConfigDict
|
||||
|
||||
# 配置日志
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WorldInfoPosition(Enum):
|
||||
"""
|
||||
SillyTavern 世界书条目插入位置枚举
|
||||
|
||||
注意:枚举值的顺序(0-4)并不完全代表物理顺序!
|
||||
以下是按照 Prompt 从上到下的真实物理顺序排列的:
|
||||
"""
|
||||
|
||||
# --- 1. 顶部区域 ---
|
||||
# (System Prompt 在这里,不可插入)
|
||||
|
||||
# --- 2. 核心指令区 (Position 4 实际上在这里) ---
|
||||
SYSTEM_PROMPT = 4
|
||||
"""
|
||||
物理位置:紧跟在系统提示词之后,角色定义之前。
|
||||
语境:最高优先级的规则。
|
||||
用途:作者注释、核心系统规则。AI 在读人设前就会先读到这个。
|
||||
"""
|
||||
|
||||
# --- 3. 角色人设区 (Position 0 实际上在这里) ---
|
||||
# (Character Definition 在这里)
|
||||
|
||||
CHAR_AFTER = 0
|
||||
"""
|
||||
物理位置:紧跟在角色定义之后。
|
||||
语境:角色固有属性。
|
||||
用途:性格、外貌、长期设定。
|
||||
"""
|
||||
|
||||
# --- 4. 示例对话区 ---
|
||||
EXAMPLE_BEFORE = 2
|
||||
"""
|
||||
物理位置:在示例对话块之前。
|
||||
"""
|
||||
|
||||
EXAMPLE_AFTER = 3
|
||||
"""
|
||||
物理位置:在示例对话块之后。
|
||||
"""
|
||||
|
||||
# --- 5. 底部区域 ---
|
||||
# (Chat History 在这里)
|
||||
# (User Input 在这里 - 最新输入)
|
||||
|
||||
# --- 6. 动态深度区 (Depth / d0-d99) ---
|
||||
# 这是你强调的"第 6 个插入区"
|
||||
# 它不是一个固定的物理点,而是一个动态区域
|
||||
|
||||
DEPTH_HISTORY = 4
|
||||
"""
|
||||
物理位置:
|
||||
- d0: 在 [用户最新输入] 之前,[AI 回复] 之前。
|
||||
- d0~d99: 在 [Chat History] 内部,倒数第 N 条消息之前。
|
||||
|
||||
语境:
|
||||
- d0: 即时状态("现在正在发生")。
|
||||
- d1+: 历史背景("当时就在那里")。
|
||||
|
||||
用途:
|
||||
这是最灵活的插入区,利用 Depth 字段来精确控制条目在对话流中的位置。
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def get_description(cls, position: int) -> str:
|
||||
"""
|
||||
获取位置描述
|
||||
|
||||
Args:
|
||||
position: 位置值
|
||||
|
||||
Returns:
|
||||
str: 位置描述
|
||||
"""
|
||||
position_map = {
|
||||
0: "角色定义之后",
|
||||
1: "角色定义之后 (最常用)",
|
||||
2: "示例对话之前",
|
||||
3: "示例对话之后",
|
||||
4: "系统提示 / 作者注释 (底部) 或 历史记录深度插入"
|
||||
}
|
||||
return position_map.get(position, "未知位置")
|
||||
|
||||
@classmethod
|
||||
def is_depth_position(cls, position: int) -> bool:
|
||||
"""
|
||||
判断是否为深度插入位置
|
||||
|
||||
Args:
|
||||
position: 位置值
|
||||
|
||||
Returns:
|
||||
bool: 是否为深度插入位置
|
||||
"""
|
||||
return position == cls.DEPTH_HISTORY.value
|
||||
|
||||
|
||||
class TriggerStrategy(str, Enum):
|
||||
"""
|
||||
触发策略枚举
|
||||
"""
|
||||
CONSTANT = "constant" # 永久触发
|
||||
KEYWORD = "keyword" # 关键词匹配触发
|
||||
RAG = "rag" # 向量检索触发
|
||||
CONDITION = "condition" # 逻辑条件触发
|
||||
|
||||
|
||||
class RAGTriggerConfig(BaseModel):
|
||||
"""
|
||||
RAG触发配置
|
||||
"""
|
||||
threshold: float = Field(0.75, description="RAG 相似度阈值")
|
||||
top_k: int = Field(5, description="返回的匹配条目数")
|
||||
query_template: Optional[str] = Field(None, description="检索用的查询模板")
|
||||
|
||||
|
||||
class KeywordTriggerConfig(BaseModel):
|
||||
"""
|
||||
关键词触发配置
|
||||
"""
|
||||
key: List[str] = Field(default_factory=list, description="主关键词数组")
|
||||
keysecondary: List[str] = Field(default_factory=list, description="次要关键词数组")
|
||||
selective: bool = Field(True, description="是否开启选择性匹配")
|
||||
selectiveLogic: int = Field(0, description="逻辑模式 (0=OR, 1=AND)")
|
||||
matchWholeWords: bool = Field(False, description="是否全词匹配")
|
||||
caseSensitive: bool = Field(False, description="是否区分大小写")
|
||||
|
||||
|
||||
class ConditionTriggerConfig(BaseModel):
|
||||
"""
|
||||
条件触发配置
|
||||
"""
|
||||
variable_a: str = Field(..., description="变量a")
|
||||
operator: str = Field(..., description="运算符 (>, <, =, >=, <=, !=)")
|
||||
variable_b: str = Field(..., description="变量b")
|
||||
|
||||
|
||||
class TriggerConfig(BaseModel):
|
||||
"""
|
||||
触发配置
|
||||
使用字典结构,键为触发策略,值为[是否启用, 对应配置]的列表
|
||||
"""
|
||||
triggers: Dict[TriggerStrategy, List[
|
||||
Union[bool, Optional[Union[KeywordTriggerConfig, RAGTriggerConfig, ConditionTriggerConfig]]]]] = Field(
|
||||
default_factory=lambda: {
|
||||
TriggerStrategy.CONSTANT: [True, None],
|
||||
TriggerStrategy.KEYWORD: [False, None],
|
||||
TriggerStrategy.RAG: [False, None],
|
||||
TriggerStrategy.CONDITION: [False, None]
|
||||
},
|
||||
description="触发配置字典,键为触发策略,值为[是否启用, 对应配置]"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(extra='forbid')
|
||||
|
||||
def set_trigger(self, strategy: TriggerStrategy, enabled: bool,
|
||||
config: Optional[Union[KeywordTriggerConfig, RAGTriggerConfig, ConditionTriggerConfig]] = None
|
||||
):
|
||||
"""
|
||||
设置触发策略
|
||||
|
||||
Args:
|
||||
strategy: 触发策略
|
||||
enabled: 是否启用
|
||||
config: 对应的配置对象
|
||||
"""
|
||||
self.triggers[strategy] = [enabled, config]
|
||||
|
||||
def get_trigger(self, strategy: TriggerStrategy) -> List[
|
||||
Union[bool, Optional[Union[KeywordTriggerConfig, RAGTriggerConfig, ConditionTriggerConfig]]]]:
|
||||
"""
|
||||
获取触发策略
|
||||
|
||||
Args:
|
||||
strategy: 触发策略
|
||||
|
||||
Returns:
|
||||
List: [是否启用, 对应配置]
|
||||
"""
|
||||
return self.triggers.get(strategy, [False, None])
|
||||
|
||||
def get_enabled_triggers(self) -> List[TriggerStrategy]:
|
||||
"""
|
||||
获取所有启用的触发策略
|
||||
|
||||
Returns:
|
||||
List[TriggerStrategy]: 启用的触发策略列表
|
||||
"""
|
||||
return [strategy for strategy, (enabled, _) in self.triggers.items() if enabled]
|
||||
|
||||
|
||||
class WorldItem(BaseModel):
|
||||
"""
|
||||
世界书条目完整模型
|
||||
包含所有 SillyTavern 世界书条目属性,用于导入导出
|
||||
"""
|
||||
|
||||
class Entry(BaseModel):
|
||||
"""
|
||||
世界书条目模型
|
||||
精简版,只包含必要字段,用于实际使用
|
||||
"""
|
||||
# 基础定义
|
||||
uid: int = Field(..., description="唯一标识符")
|
||||
content: str = Field(..., description="注入到 Prompt 的实际文本内容")
|
||||
comment: str = Field("", description="条目名、备注")
|
||||
|
||||
# 注入与排序
|
||||
position: int = Field(0,
|
||||
description="插入位置 (0=角色定义之前, 1=角色定义之后, 2=示例对话之前, 3=示例对话之后, 4=系统提示/作者注释)")
|
||||
order: int = Field(100, description="注入顺序权重,数字越小优先级越高")
|
||||
depth: int = Field(4, description="扫描深度,0为最深/最高,4为标准")
|
||||
|
||||
# 触发配置
|
||||
trigger_config: Optional[TriggerConfig] = Field(
|
||||
default_factory=TriggerConfig,
|
||||
description="触发配置,为空表示无需触发配置"
|
||||
)
|
||||
# 角色匹配
|
||||
role: int = Field(0, description="角色匹配 (0=Both, 1=User, 2=Assistant)")
|
||||
|
||||
# 条目启用状态
|
||||
enabled: bool = Field(True, description="条目是否启用(启用才会被插入到LLM)")
|
||||
|
||||
@field_validator('position')
|
||||
@classmethod
|
||||
def validate_position(cls, v):
|
||||
"""验证 position 值是否在有效范围内"""
|
||||
if v not in [0, 1, 2, 3, 4]:
|
||||
logger.warning(f"无效的 position 值: {v},将使用默认值 1")
|
||||
return 1
|
||||
return v
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
转换为字典
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 字典数据
|
||||
"""
|
||||
return self.dict()
|
||||
|
||||
def get_trigger_params(self) -> Dict[str, Any]:
|
||||
"""
|
||||
获取触发策略所需的参数
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 触发参数字典
|
||||
"""
|
||||
params = {}
|
||||
|
||||
try:
|
||||
# 获取所有启用的触发策略
|
||||
enabled_triggers = self.trigger_config.get_enabled_triggers()
|
||||
|
||||
# 处理 RAG 触发
|
||||
if TriggerStrategy.RAG in enabled_triggers:
|
||||
_, rag_config = self.trigger_config.get_trigger(TriggerStrategy.RAG)
|
||||
if rag_config:
|
||||
params["threshold"] = rag_config.threshold
|
||||
params["top_k"] = rag_config.top_k
|
||||
params["query_template"] = rag_config.query_template
|
||||
params["vectorized"] = True
|
||||
|
||||
# 处理关键词触发
|
||||
if TriggerStrategy.KEYWORD in enabled_triggers:
|
||||
_, keyword_config = self.trigger_config.get_trigger(TriggerStrategy.KEYWORD)
|
||||
if keyword_config:
|
||||
params["key"] = keyword_config.key
|
||||
params["keysecondary"] = keyword_config.keysecondary
|
||||
params["selective"] = keyword_config.selective
|
||||
params["selectiveLogic"] = keyword_config.selectiveLogic
|
||||
params["matchWholeWords"] = keyword_config.matchWholeWords
|
||||
params["caseSensitive"] = keyword_config.caseSensitive
|
||||
|
||||
# 处理条件触发
|
||||
if TriggerStrategy.CONDITION in enabled_triggers:
|
||||
_, condition_config = self.trigger_config.get_trigger(TriggerStrategy.CONDITION)
|
||||
if condition_config:
|
||||
params["variable_a"] = condition_config.variable_a
|
||||
params["operator"] = condition_config.operator
|
||||
params["variable_b"] = condition_config.variable_b
|
||||
except Exception as e:
|
||||
# 如果获取触发参数失败,返回空字典,表示使用默认的永久触发
|
||||
logger.warning(f"条目 {self.uid} 的触发参数获取失败: {str(e)},使用默认的永久触发")
|
||||
|
||||
return params
|
||||
|
||||
# 基础定义
|
||||
uid: int = Field(..., description="唯一标识符")
|
||||
content: str = Field(..., description="注入到 Prompt 的实际文本内容")
|
||||
comment: str = Field("", description="条目名、备注")
|
||||
|
||||
# 注入与排序
|
||||
position: int = Field(0,
|
||||
description="插入位置 (0=角色定义之前, 1=角色定义之后, 2=示例对话之前, 3=示例对话之后, 4=系统提示/作者注释)")
|
||||
order: int = Field(100, description="注入顺序权重,数字越小优先级越高")
|
||||
depth: int = Field(4, description="扫描深度,0为最深/最高,4为标准")
|
||||
|
||||
# 触发配置
|
||||
trigger_config: TriggerConfig = Field(
|
||||
default_factory=TriggerConfig,
|
||||
description="触发配置"
|
||||
)
|
||||
|
||||
# 角色匹配
|
||||
role: int = Field(0, description="角色匹配 (0=Both, 1=User, 2=Assistant)")
|
||||
|
||||
# 条目启用状态
|
||||
enabled: bool = Field(True, description="条目是否启用(启用才会被插入到LLM)")
|
||||
|
||||
# 触发相关属性
|
||||
vectorized: bool = Field(False, description="是否使用向量检索(RAG触发)")
|
||||
selective: bool = Field(True, description="是否开启选择性匹配(关键词触发)")
|
||||
selectiveLogic: int = Field(0, description="逻辑模式 (0=OR, 1=AND)")
|
||||
constant: bool = Field(False, description="是否永久触发")
|
||||
|
||||
# 关键词相关
|
||||
key: List[str] = Field(default_factory=list, description="主关键词数组")
|
||||
keysecondary: List[str] = Field(default_factory=list, description="次要关键词数组")
|
||||
matchWholeWords: Optional[bool] = Field(None, description="是否全词匹配")
|
||||
caseSensitive: Optional[bool] = Field(None, description="是否区分大小写")
|
||||
|
||||
# RAG相关
|
||||
rag_threshold: Optional[float] = Field(None, description="RAG 相似度阈值")
|
||||
top_k: Optional[int] = Field(None, description="返回的匹配条目数")
|
||||
query_template: Optional[str] = Field(None, description="检索用的查询模板")
|
||||
|
||||
# 条目控制
|
||||
addMemo: bool = Field(True, description="是否添加备忘")
|
||||
disable: bool = Field(False, description="是否禁用")
|
||||
ignoreBudget: bool = Field(False, description="是否忽略预算")
|
||||
excludeRecursion: bool = Field(True, description="是否排除递归")
|
||||
preventRecursion: bool = Field(True, description="是否阻止递归")
|
||||
matchPersonaDescription: bool = Field(False, description="是否匹配人设描述")
|
||||
matchCharacterDescription: bool = Field(False, description="是否匹配角色描述")
|
||||
matchCharacterPersonality: bool = Field(False, description="是否匹配角色性格")
|
||||
matchCharacterDepthPrompt: bool = Field(False, description="是否匹配深度提示")
|
||||
matchScenario: bool = Field(False, description="是否匹配场景")
|
||||
matchCreatorNotes: bool = Field(False, description="是否匹配作者笔记")
|
||||
delayUntilRecursion: bool = Field(False, description="是否延迟递归")
|
||||
|
||||
# 概率相关
|
||||
probability: int = Field(100, description="触发概率 (0-100)")
|
||||
useProbability: bool = Field(True, description="是否使用概率")
|
||||
|
||||
# 分组相关
|
||||
group: str = Field("", description="分组名称")
|
||||
groupOverride: bool = Field(False, description="是否覆盖分组")
|
||||
groupWeight: int = Field(100, description="分组权重")
|
||||
useGroupScoring: bool = Field(False, description="是否使用分组评分")
|
||||
|
||||
# 其他属性
|
||||
scanDepth: Optional[int] = Field(None, description="扫描深度")
|
||||
automationId: str = Field("", description="自动化ID")
|
||||
sticky: int = Field(0, description="粘性")
|
||||
cooldown: int = Field(0, description="冷却时间(秒)")
|
||||
delay: int = Field(0, description="延迟时间(秒)")
|
||||
displayIndex: int = Field(0, description="显示索引")
|
||||
|
||||
# 角色过滤器
|
||||
characterFilter: Dict[str, Any] = Field(
|
||||
default_factory=lambda: {"isExclude": False, "names": [], "tags": []},
|
||||
description="角色过滤器"
|
||||
)
|
||||
|
||||
# 验证器
|
||||
@field_validator('position')
|
||||
@classmethod
|
||||
def validate_position(cls, v):
|
||||
"""验证 position 值是否在有效范围内"""
|
||||
if v not in [0, 1, 2, 3, 4]:
|
||||
logger.warning(f"无效的 position 值: {v},将使用默认值 1")
|
||||
return 1
|
||||
return v
|
||||
|
||||
@field_validator('role')
|
||||
@classmethod
|
||||
def validate_role(cls, v):
|
||||
"""验证 role 值是否在有效范围内"""
|
||||
if v not in [0, 1, 2]:
|
||||
logger.warning(f"无效的 role 值: {v},将使用默认值 2")
|
||||
return 2
|
||||
return v
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'WorldItem':
|
||||
"""
|
||||
从字典创建 WorldItem 对象
|
||||
|
||||
Args:
|
||||
data: 字典数据
|
||||
|
||||
Returns:
|
||||
WorldItem: WorldItem 对象
|
||||
"""
|
||||
return cls(**data)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
转换为字典
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 字典数据
|
||||
"""
|
||||
return self.dict()
|
||||
|
||||
def to_entry(self) -> Entry:
|
||||
"""
|
||||
转换为 Entry 对象
|
||||
|
||||
Returns:
|
||||
Entry: Entry 对象
|
||||
"""
|
||||
# 转换为 SillyTavern 格式的字典
|
||||
sillytavern_dict = self.to_sillytavern_dict()
|
||||
# 创建 Entry 对象
|
||||
return self.Entry(**sillytavern_dict)
|
||||
|
||||
def to_sillytavern_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
转换为 SillyTavern 格式的字典
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: SillyTavern 格式的条目数据
|
||||
"""
|
||||
result = {
|
||||
"uid": self.uid,
|
||||
"content": self.content,
|
||||
"comment": self.comment,
|
||||
"position": self.position,
|
||||
"order": self.order,
|
||||
"depth": self.depth,
|
||||
"role": self.role,
|
||||
"enabled": self.enabled,
|
||||
"vectorized": self.vectorized,
|
||||
"selective": self.selective,
|
||||
"selectiveLogic": self.selectiveLogic,
|
||||
"constant": self.constant,
|
||||
"key": self.key,
|
||||
"keysecondary": self.keysecondary,
|
||||
"matchWholeWords": self.matchWholeWords,
|
||||
"caseSensitive": self.caseSensitive,
|
||||
"addMemo": self.addMemo,
|
||||
"disable": self.disable,
|
||||
"ignoreBudget": self.ignoreBudget,
|
||||
"excludeRecursion": self.excludeRecursion,
|
||||
"preventRecursion": self.preventRecursion,
|
||||
"matchPersonaDescription": self.matchPersonaDescription,
|
||||
"matchCharacterDescription": self.matchCharacterDescription,
|
||||
"matchCharacterPersonality": self.matchCharacterPersonality,
|
||||
"matchCharacterDepthPrompt": self.matchCharacterDepthPrompt,
|
||||
"matchScenario": self.matchScenario,
|
||||
"matchCreatorNotes": self.matchCreatorNotes,
|
||||
"delayUntilRecursion": self.delayUntilRecursion,
|
||||
"probability": self.probability,
|
||||
"useProbability": self.useProbability,
|
||||
"group": self.group,
|
||||
"groupOverride": self.groupOverride,
|
||||
"groupWeight": self.groupWeight,
|
||||
"scanDepth": self.scanDepth,
|
||||
"automationId": self.automationId,
|
||||
"sticky": self.sticky,
|
||||
"cooldown": self.cooldown,
|
||||
"delay": self.delay,
|
||||
"displayIndex": self.displayIndex,
|
||||
"characterFilter": self.characterFilter
|
||||
}
|
||||
|
||||
# 添加 RAG 相关字段
|
||||
if self.vectorized:
|
||||
result["rag_threshold"] = self.rag_threshold
|
||||
result["top_k"] = self.top_k
|
||||
result["query_template"] = self.query_template
|
||||
|
||||
# 添加条件触发相关字段
|
||||
if TriggerStrategy.CONDITION in self.trigger_config.get_enabled_triggers():
|
||||
condition_config = self.trigger_config.get_trigger(TriggerStrategy.CONDITION)[1]
|
||||
if condition_config:
|
||||
result["variable_a"] = condition_config.variable_a
|
||||
result["operator"] = condition_config.operator
|
||||
result["variable_b"] = condition_config.variable_b
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_sillytavern_data(cls, data: Dict[str, Any]) -> 'WorldItem':
|
||||
"""
|
||||
从 SillyTavern 格式的数据创建 WorldItem 对象
|
||||
|
||||
Args:
|
||||
data: SillyTavern 格式的条目数据
|
||||
|
||||
Returns:
|
||||
WorldItem: WorldItem 对象
|
||||
"""
|
||||
|
||||
constant = data.get("constant", False)
|
||||
if isinstance(constant, str):
|
||||
constant = constant.lower() in ('true', '1', 'yes')
|
||||
|
||||
enabled = data.get("enabled", True)
|
||||
if isinstance(enabled, str):
|
||||
enabled = enabled.lower() in ('true', '1', 'yes')
|
||||
|
||||
try:
|
||||
# 提取必要字段
|
||||
uid = int(data.get("uid", data.get("id", 0)))
|
||||
content = data.get("content", "")
|
||||
comment = data.get("comment", "")
|
||||
position = data.get("position", 0)
|
||||
order = data.get("order", 100)
|
||||
depth = data.get("depth", 4)
|
||||
role = data.get("role", 0)
|
||||
enabled = data.get("enabled", True)
|
||||
|
||||
# 处理 position 字段,确保为整数类型
|
||||
if isinstance(position, str):
|
||||
try:
|
||||
position = int(position)
|
||||
except ValueError:
|
||||
logger.warning(f"条目 {uid} 的 position 字段值 '{position}' 无法转换为整数,使用默认值 0")
|
||||
position = 0
|
||||
|
||||
# 初始化触发配置
|
||||
trigger_config = TriggerConfig()
|
||||
|
||||
# 读取触发相关字段,并进行类型转换
|
||||
vectorized = data.get("vectorized", False)
|
||||
if isinstance(vectorized, str):
|
||||
vectorized = vectorized.lower() in ('true', '1', 'yes')
|
||||
|
||||
selective = data.get("selective", True)
|
||||
if isinstance(selective, str):
|
||||
selective = selective.lower() in ('true', '1', 'yes')
|
||||
|
||||
constant = data.get("constant", False)
|
||||
if isinstance(constant, str):
|
||||
constant = constant.lower() in ('true', '1', 'yes')
|
||||
|
||||
# 初始化变量,确保它们始终有值
|
||||
key = []
|
||||
keysecondary = []
|
||||
selectiveLogic = 0
|
||||
matchWholeWords = False
|
||||
caseSensitive = False
|
||||
|
||||
# 判断触发策略并设置对应的触发配置
|
||||
# 优先级:vectorized > constant > selective
|
||||
if vectorized:
|
||||
# RAG 触发
|
||||
rag_config = RAGTriggerConfig(
|
||||
threshold=float(data.get("rag_threshold", 0.75)),
|
||||
top_k=int(data.get("top_k", 5)),
|
||||
query_template=data.get("query_template", None)
|
||||
)
|
||||
trigger_config.set_trigger(TriggerStrategy.RAG, True, rag_config)
|
||||
elif constant:
|
||||
# 永久触发
|
||||
trigger_config.set_trigger(TriggerStrategy.CONSTANT, True)
|
||||
elif selective:
|
||||
# 关键词触发
|
||||
key = data.get("key", [])
|
||||
keysecondary = data.get("keysecondary", data.get("secondary_keys", []))
|
||||
selectiveLogic = int(data.get("selectiveLogic", 0))
|
||||
|
||||
# 处理 matchWholeWords 字段
|
||||
matchWholeWords = data.get("matchWholeWords", False)
|
||||
if matchWholeWords is None:
|
||||
matchWholeWords = False
|
||||
elif isinstance(matchWholeWords, str):
|
||||
matchWholeWords = matchWholeWords.lower() in ('true', '1', 'yes')
|
||||
|
||||
# 处理 caseSensitive 字段
|
||||
caseSensitive = data.get("caseSensitive", False)
|
||||
if caseSensitive is None:
|
||||
caseSensitive = False
|
||||
elif isinstance(caseSensitive, str):
|
||||
caseSensitive = caseSensitive.lower() in ('true', '1', 'yes')
|
||||
|
||||
keyword_config = KeywordTriggerConfig(
|
||||
key=key,
|
||||
keysecondary=keysecondary,
|
||||
selective=selective,
|
||||
selectiveLogic=selectiveLogic,
|
||||
matchWholeWords=matchWholeWords,
|
||||
caseSensitive=caseSensitive
|
||||
)
|
||||
trigger_config.set_trigger(TriggerStrategy.KEYWORD, True, keyword_config)
|
||||
else:
|
||||
# 默认使用永久触发
|
||||
trigger_config.set_trigger(TriggerStrategy.CONSTANT, True)
|
||||
|
||||
# 检查是否有条件触发(虽然 JSON 中没有对应字段,但需要保留兼容性)
|
||||
if "variable_a" in data and "operator" in data and "variable_b" in data:
|
||||
condition_config = ConditionTriggerConfig(
|
||||
variable_a=data.get("variable_a", ""),
|
||||
operator=data.get("operator", "="),
|
||||
variable_b=data.get("variable_b", "")
|
||||
)
|
||||
trigger_config.set_trigger(TriggerStrategy.CONDITION, True, condition_config)
|
||||
|
||||
# 创建 WorldItem 对象
|
||||
return cls(
|
||||
uid=uid,
|
||||
content=content,
|
||||
comment=comment,
|
||||
position=position,
|
||||
order=order,
|
||||
depth=depth,
|
||||
trigger_config=trigger_config,
|
||||
role=role,
|
||||
enabled=enabled,
|
||||
vectorized=vectorized,
|
||||
selective=selective,
|
||||
selectiveLogic=selectiveLogic,
|
||||
constant=constant,
|
||||
key=key,
|
||||
keysecondary=keysecondary,
|
||||
matchWholeWords=matchWholeWords,
|
||||
caseSensitive=caseSensitive,
|
||||
rag_threshold=float(data.get("rag_threshold", None)) if vectorized else None,
|
||||
top_k=int(data.get("top_k", None)) if vectorized else None,
|
||||
query_template=data.get("query_template", None),
|
||||
addMemo=data.get("addMemo", True),
|
||||
disable=data.get("disable", False),
|
||||
ignoreBudget=data.get("ignoreBudget", False),
|
||||
excludeRecursion=data.get("excludeRecursion", True),
|
||||
preventRecursion=data.get("preventRecursion", True),
|
||||
matchPersonaDescription=data.get("matchPersonaDescription", False),
|
||||
matchCharacterDescription=data.get("matchCharacterDescription", False),
|
||||
matchCharacterPersonality=data.get("matchCharacterPersonality", False),
|
||||
matchCharacterDepthPrompt=data.get("matchCharacterDepthPrompt", False),
|
||||
matchScenario=data.get("matchScenario", False),
|
||||
matchCreatorNotes=data.get("matchCreatorNotes", False),
|
||||
delayUntilRecursion=data.get("delayUntilRecursion", False),
|
||||
probability=data.get("probability", 100),
|
||||
useProbability=data.get("useProbability", True),
|
||||
group=data.get("group", ""),
|
||||
groupOverride=data.get("groupOverride", False),
|
||||
groupWeight=data.get("groupWeight", 100),
|
||||
useGroupScoring=data.get("useGroupScoring", False),
|
||||
scanDepth=data.get("scanDepth", None),
|
||||
automationId=data.get("automationId", ""),
|
||||
sticky=data.get("sticky", 0),
|
||||
cooldown=data.get("cooldown", 0),
|
||||
delay=data.get("delay", 0),
|
||||
displayIndex=data.get("displayIndex", 0),
|
||||
characterFilter=data.get("characterFilter", {"isExclude": False, "names": [], "tags": []})
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"从 SillyTavern 数据创建 WorldItem 失败: {str(e)}")
|
||||
raise ValueError(f"从 SillyTavern 数据创建 WorldItem 失败: {str(e)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
"""
|
||||
测试入口:用于调试 WorldItem 的解析和转换功能
|
||||
可以像断点调试一样查看内部执行过程
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 测试用例1:基本条目
|
||||
test_data_1 = {
|
||||
"uid": 0,
|
||||
"content": "测试内容",
|
||||
"comment": "测试条目",
|
||||
"position": 0,
|
||||
"order": 100,
|
||||
"depth": 4,
|
||||
"role": 0,
|
||||
"vectorized": False,
|
||||
"selective": True,
|
||||
"selectiveLogic": 0,
|
||||
"constant": False,
|
||||
"key": ["测试关键词"],
|
||||
"keysecondary": [],
|
||||
"matchWholeWords": False,
|
||||
"caseSensitive": False,
|
||||
"addMemo": True,
|
||||
"disable": False,
|
||||
"ignoreBudget": False,
|
||||
"excludeRecursion": True,
|
||||
"preventRecursion": True
|
||||
}
|
||||
|
||||
# 测试用例2:RAG触发
|
||||
test_data_2 = {
|
||||
"uid": 1,
|
||||
"content": "RAG测试内容",
|
||||
"comment": "RAG测试条目",
|
||||
"position": 4,
|
||||
"order": 50,
|
||||
"depth": 0,
|
||||
"role": 0,
|
||||
"vectorized": True,
|
||||
"rag_threshold": 0.8,
|
||||
"top_k": 10,
|
||||
"query_template": "测试模板"
|
||||
}
|
||||
|
||||
# 测试用例3:条件触发
|
||||
test_data_3 = {
|
||||
"uid": 2,
|
||||
"content": "条件触发测试",
|
||||
"comment": "条件触发条目",
|
||||
"position": 1,
|
||||
"order": 75,
|
||||
"depth": 2,
|
||||
"role": 0,
|
||||
"variable_a": "好感度",
|
||||
"operator": ">",
|
||||
"variable_b": "50"
|
||||
}
|
||||
|
||||
print("=" * 60)
|
||||
print("开始测试 WorldItem 解析功能")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
# 测试1:解析基本条目
|
||||
print("\n【测试1】解析基本条目...")
|
||||
item1 = WorldItem.from_sillytavern_data(test_data_1)
|
||||
print(f"✓ 解析成功: {item1.comment}")
|
||||
print(f" - UID: {item1.uid}")
|
||||
print(f" - Position: {item1.position}")
|
||||
print(f" - 触发策略和配置:")
|
||||
for strategy in item1.trigger_config.get_enabled_triggers():
|
||||
enabled, config = item1.trigger_config.get_trigger(strategy)
|
||||
print(f" * {strategy.value}: 启用={enabled}, 配置={config}")
|
||||
|
||||
# 测试2:解析RAG触发条目
|
||||
print("\n【测试2】解析RAG触发条目...")
|
||||
item2 = WorldItem.from_sillytavern_data(test_data_2)
|
||||
print(f"✓ 解析成功: {item2.comment}")
|
||||
print(f" - UID: {item2.uid}")
|
||||
print(f" - Position: {item2.position}")
|
||||
print(f" - 触发策略和配置:")
|
||||
for strategy in item2.trigger_config.get_enabled_triggers():
|
||||
enabled, config = item2.trigger_config.get_trigger(strategy)
|
||||
print(f" * {strategy.value}: 启用={enabled}, 配置={config}")
|
||||
if item2.rag_threshold:
|
||||
print(f" - RAG阈值: {item2.rag_threshold}")
|
||||
|
||||
# 测试3:解析条件触发条目
|
||||
print("\n【测试3】解析条件触发条目...")
|
||||
item3 = WorldItem.from_sillytavern_data(test_data_3)
|
||||
print(f"✓ 解析成功: {item3.comment}")
|
||||
print(f" - UID: {item3.uid}")
|
||||
print(f" - Position: {item3.position}")
|
||||
print(f" - 触发策略和配置:")
|
||||
for strategy in item3.trigger_config.get_enabled_triggers():
|
||||
enabled, config = item3.trigger_config.get_trigger(strategy)
|
||||
print(f" * {strategy.value}: 启用={enabled}, 配置={config}")
|
||||
|
||||
# 测试4:从文件读取实际数据
|
||||
print("\n【测试4】从实际JSON文件读取...")
|
||||
# 从当前文件位置向上查找项目根目录
|
||||
current_file = Path(__file__).resolve()
|
||||
project_root = current_file
|
||||
while project_root.name != "llm_workflow_engine" and project_root.parent != project_root:
|
||||
project_root = project_root.parent
|
||||
|
||||
# 构建正确的文件路径
|
||||
json_path = project_root / "data" / "worldbooks" / "卡立创-v5.json"
|
||||
print(f"查找文件路径: {json_path}")
|
||||
|
||||
if json_path.exists():
|
||||
with open(json_path, 'r', encoding='utf-8') as f:
|
||||
worldbook_data = json.load(f)
|
||||
entries = worldbook_data.get('entries', {})
|
||||
print(f"找到 {len(entries)} 个条目")
|
||||
|
||||
# 只测试前3个条目
|
||||
for uid, entry_data in list(entries.items())[:3]:
|
||||
try:
|
||||
item = WorldItem.from_sillytavern_data(entry_data)
|
||||
print(f"\n✓ 条目 {uid} 解析成功:")
|
||||
print(f" - 备注: {item.comment}")
|
||||
print(f" - UID: {item.uid}")
|
||||
print(f" - Position: {item.position}")
|
||||
print(f" - 触发策略和配置:")
|
||||
for strategy in item.trigger_config.get_enabled_triggers():
|
||||
enabled, config = item.trigger_config.get_trigger(strategy)
|
||||
print(f" * {strategy.value}: 启用={enabled}, 配置={config}")
|
||||
except Exception as e:
|
||||
print(f"\n✗ 条目 {uid} 解析失败: {str(e)}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
else:
|
||||
print(f"⚠ 文件不存在: {json_path}")
|
||||
print(f"请确认文件路径是否正确")
|
||||
# 列出可能的文件位置
|
||||
possible_paths = [
|
||||
project_root / "data" / "worldbooks",
|
||||
project_root / "backend" / "data" / "worldbooks",
|
||||
current_file.parent.parent.parent / "data" / "worldbooks"
|
||||
]
|
||||
print("\n可能的文件位置:")
|
||||
for path in possible_paths:
|
||||
if path.exists():
|
||||
print(f" ✓ {path}")
|
||||
for file in path.glob("*.json"):
|
||||
print(f" - {file.name}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("所有测试完成!")
|
||||
print("=" * 60)
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n✗ 测试失败: {str(e)}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
Binary file not shown.
@@ -1,443 +0,0 @@
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from pydantic import BaseModel, Field
|
||||
import json
|
||||
|
||||
|
||||
class Message(BaseModel):
|
||||
"""消息类,代表JSONL文件中的一行消息内容"""
|
||||
name: str = Field(..., description="发送者名称")
|
||||
is_user: bool = Field(..., description="是否为用户消息")
|
||||
is_system: bool = Field(False, description="是否为系统消息")
|
||||
send_date: str = Field(
|
||||
default_factory=lambda: str(int(datetime.now().timestamp() * 1000)),
|
||||
description="消息发送时间戳"
|
||||
)
|
||||
floor: int = Field(0, description="对话楼层数")
|
||||
swipes: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="历史版本列表。用户消息:存编辑过的不同版本。AI消息:存重roll生成的不同版本"
|
||||
)
|
||||
swipe_id: int = Field(
|
||||
0,
|
||||
description="当前指针。指示当前显示的是 swipes 数组中的第几个(从 0 开始)"
|
||||
)
|
||||
mes: str = Field(..., description="消息内容文本")
|
||||
extra: Dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="额外信息,包含推理内容、API、模型等"
|
||||
)
|
||||
force_avatar: Optional[str] = Field(None, description="强制头像URL")
|
||||
variables: List[Any] = Field(default_factory=list, description="消息变量列表")
|
||||
variables_initialized: List[bool] = Field(default_factory=list, description="变量初始化状态数组")
|
||||
is_ejs_processed: List[bool] = Field(default_factory=list, description="EJS处理状态数组")
|
||||
|
||||
# 以下属性仅在is_user为False时有值
|
||||
api: Optional[str] = Field(None, description="使用的API提供商")
|
||||
model: Optional[str] = Field(None, description="使用的AI模型")
|
||||
reasoning: Optional[str] = Field(None, description="推理内容")
|
||||
reasoning_duration: Optional[float] = Field(None, description="推理耗时")
|
||||
reasoning_signature: Optional[str] = Field(None, description="推理签名")
|
||||
time_to_first_token: Optional[float] = Field(None, description="首Token响应时间")
|
||||
bias: Optional[float] = Field(None, description="偏差值")
|
||||
|
||||
|
||||
class ChatMetadata(BaseModel):
|
||||
"""聊天元数据类,包含整个聊天的共享属性"""
|
||||
user_name: str = Field("User", description="用户名称")
|
||||
character_name: str = Field("Assistant", description="角色名称")
|
||||
|
||||
# 完整性校验相关
|
||||
integrity: str = Field("", description="完整性校验值")
|
||||
chat_id_hash: str = Field("", description="聊天ID哈希值")
|
||||
|
||||
# 笔记相关
|
||||
note_prompt: str = Field("", description="作者笔记提示词")
|
||||
note_interval: int = Field(0, description="笔记插入间隔数")
|
||||
note_position: int = Field(0, description="笔记插入位置")
|
||||
note_depth: int = Field(0, description="笔记插入深度")
|
||||
# 0:System,1:User,2:Assistant
|
||||
note_role: int = Field("", description="笔记使用角色类型")
|
||||
|
||||
# 扩展信息
|
||||
extensions: Dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="扩展信息,如LittleWhiteBox等"
|
||||
)
|
||||
# 世界信息
|
||||
timedWorldInfo: Dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="定时世界信息"
|
||||
)
|
||||
# 变量
|
||||
variables: Dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="变量字典"
|
||||
)
|
||||
# 状态标记
|
||||
tainted: bool = Field(False, description="是否被修改标记")
|
||||
lastInContextMessageId: int = Field(-1, description="最后上下文消息ID")
|
||||
|
||||
|
||||
class ChatHistory(BaseModel):
|
||||
"""聊天文件类,包含完整的聊天记录"""
|
||||
chat_metadata: ChatMetadata = Field(..., description="聊天元数据,包含基本信息和配置")
|
||||
messages: List[Message] = Field(default_factory=list, description="消息列表")
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
@classmethod
|
||||
def get_data_path(cls) -> Path:
|
||||
"""获取数据目录路径"""
|
||||
try:
|
||||
from backend.core.config import settings
|
||||
return settings.DATA_PATH / "chat"
|
||||
except ImportError:
|
||||
return Path("data")
|
||||
|
||||
@classmethod
|
||||
async def list_all_chats(cls) -> Dict[str, List[Dict]]:
|
||||
"""获取所有角色的所有聊天列表"""
|
||||
data_dir = cls.get_data_path()
|
||||
if not data_dir.exists():
|
||||
return {"chat": []}
|
||||
|
||||
chats = []
|
||||
for role_dir in data_dir.iterdir():
|
||||
if role_dir.is_dir():
|
||||
for chat_file in role_dir.glob("*.jsonl"):
|
||||
try:
|
||||
with open(chat_file, 'r', encoding='utf-8') as f:
|
||||
# 读取第一行获取元数据
|
||||
first_line = f.readline()
|
||||
metadata = json.loads(first_line)
|
||||
chats.append({
|
||||
"role_name": role_dir.name,
|
||||
"chat_name": chat_file.stem,
|
||||
"user_name": metadata.get("user_name", "User"),
|
||||
"character_name": metadata.get("character_name", "Assistant"),
|
||||
"last_modified": metadata.get("last_modified", ""),
|
||||
"message_count": sum(1 for _ in f) # 统计剩余行数(消息数)
|
||||
})
|
||||
except Exception:
|
||||
continue # 跳过损坏的聊天文件
|
||||
return {"chat": chats}
|
||||
|
||||
@classmethod
|
||||
async def get_chat(cls, role_name: str, chat_name: str) -> Dict[str, Any]:
|
||||
"""获取指定聊天的完整内容"""
|
||||
chat_history = cls.load_from_file(role_name, chat_name)
|
||||
return {
|
||||
"metadata": chat_history.chat_metadata.dict(),
|
||||
"messages": chat_history.to_chatbox_format()
|
||||
}
|
||||
|
||||
@classmethod
|
||||
async def create_chat(cls, role_name: str, chat_name: str, metadata: Optional[Dict] = None) -> Dict[str, str]:
|
||||
"""创建新聊天"""
|
||||
base_path = cls.get_data_path()
|
||||
role_dir = base_path / role_name
|
||||
role_dir.mkdir(parents=True, exist_ok=True)
|
||||
chat_path = role_dir / f"{chat_name}.jsonl"
|
||||
|
||||
if chat_path.exists():
|
||||
raise FileExistsError(f"Chat already exists: {chat_path}")
|
||||
|
||||
# 创建聊天历史对象
|
||||
chat_history = cls(
|
||||
chat_metadata=ChatMetadata(**(metadata or {})),
|
||||
messages=[]
|
||||
)
|
||||
|
||||
# 保存到文件
|
||||
chat_history.save_to_file(role_name, chat_name, base_path)
|
||||
return {"message": "Chat created successfully"}
|
||||
|
||||
@classmethod
|
||||
async def update_chat(cls, role_name: str, chat_name: str, update_data: Dict) -> Dict[str, str]:
|
||||
"""更新聊天元数据"""
|
||||
chat_history = cls.load_from_file(role_name, chat_name)
|
||||
|
||||
# 更新元数据
|
||||
if "metadata" in update_data:
|
||||
for key, value in update_data["metadata"].items():
|
||||
if hasattr(chat_history.chat_metadata, key):
|
||||
setattr(chat_history.chat_metadata, key, value)
|
||||
|
||||
# 保存更改
|
||||
base_path = cls.get_data_path()
|
||||
chat_history.save_to_file(role_name, chat_name, base_path)
|
||||
return {"message": "Chat metadata updated successfully"}
|
||||
|
||||
@classmethod
|
||||
async def delete_chat(cls, role_name: str, chat_name: str) -> Dict[str, str]:
|
||||
"""删除指定聊天"""
|
||||
base_path = cls.get_data_path()
|
||||
chat_path = base_path / role_name / f"{chat_name}.jsonl"
|
||||
|
||||
if not chat_path.exists():
|
||||
raise FileNotFoundError(f"Chat not found: {chat_path}")
|
||||
|
||||
chat_path.unlink()
|
||||
return {"message": "Chat deleted successfully"}
|
||||
|
||||
@classmethod
|
||||
async def list_messages(cls, role_name: str, chat_name: str) -> Dict[str, List[Dict]]:
|
||||
"""获取聊天的所有消息"""
|
||||
chat_history = cls.load_from_file(role_name, chat_name)
|
||||
return {"messages": chat_history.to_chatbox_format()}
|
||||
|
||||
@classmethod
|
||||
async def get_message(cls, role_name: str, chat_name: str, floor: int) -> Dict[str, Any]:
|
||||
"""获取指定楼层的消息"""
|
||||
chat_history = cls.load_from_file(role_name, chat_name)
|
||||
message = next((msg for msg in chat_history.messages if msg.floor == floor), None)
|
||||
|
||||
if not message:
|
||||
raise FileNotFoundError(f"Message not found: floor {floor}")
|
||||
|
||||
return message.dict()
|
||||
|
||||
@classmethod
|
||||
async def add_message(cls, role_name: str, chat_name: str, message_data: Dict) -> Dict[str, Any]:
|
||||
"""向聊天添加新消息"""
|
||||
chat_history = cls.load_from_file(role_name, chat_name)
|
||||
|
||||
# 创建消息对象
|
||||
message = Message(**message_data)
|
||||
|
||||
# 检查楼层是否已存在
|
||||
if any(msg.floor == message.floor for msg in chat_history.messages):
|
||||
raise ValueError(f"Message floor already exists: {message.floor}")
|
||||
|
||||
# 添加消息
|
||||
chat_history.messages.append(message)
|
||||
|
||||
# 保存更改
|
||||
base_path = cls.get_data_path()
|
||||
chat_history.save_to_file(role_name, chat_name, base_path)
|
||||
return {"message": "Message added successfully", "floor": message.floor}
|
||||
|
||||
@classmethod
|
||||
async def update_message(cls, role_name: str, chat_name: str, floor: int, update_data: Dict) -> Dict[str, str]:
|
||||
"""更新指定楼层的消息"""
|
||||
chat_history = cls.load_from_file(role_name, chat_name)
|
||||
message = next((msg for msg in chat_history.messages if msg.floor == floor), None)
|
||||
|
||||
if not message:
|
||||
raise FileNotFoundError(f"Message not found: floor {floor}")
|
||||
|
||||
# 更新消息字段
|
||||
for key, value in update_data.items():
|
||||
if hasattr(message, key):
|
||||
setattr(message, key, value)
|
||||
|
||||
# 保存更改
|
||||
base_path = cls.get_data_path()
|
||||
chat_history.save_to_file(role_name, chat_name, base_path)
|
||||
return {"message": "Message updated successfully"}
|
||||
|
||||
@classmethod
|
||||
async def delete_message(cls, role_name: str, chat_name: str, floor: int) -> Dict[str, str]:
|
||||
"""删除指定楼层的消息"""
|
||||
chat_history = cls.load_from_file(role_name, chat_name)
|
||||
|
||||
# 查找并删除消息
|
||||
original_length = len(chat_history.messages)
|
||||
chat_history.messages = [msg for msg in chat_history.messages if msg.floor != floor]
|
||||
|
||||
if len(chat_history.messages) == original_length:
|
||||
raise FileNotFoundError(f"Message not found: floor {floor}")
|
||||
|
||||
# 保存更改
|
||||
base_path = cls.get_data_path()
|
||||
chat_history.save_to_file(role_name, chat_name, base_path)
|
||||
return {"message": "Message deleted successfully"}
|
||||
|
||||
@classmethod
|
||||
def load_from_file(cls, role_name: str, chat_name: str, base_path: Path = None) -> 'ChatHistory':
|
||||
"""
|
||||
从JSONL文件加载聊天历史
|
||||
|
||||
参数:
|
||||
role_name: 角色名称(文件夹名)
|
||||
chat_name: 聊天名称(文件名,不含扩展名)
|
||||
base_path: 基础路径,默认为配置中的DATA_PATH/chat
|
||||
|
||||
返回:
|
||||
ChatHistory: 加载的聊天历史对象
|
||||
|
||||
异常:
|
||||
FileNotFoundError: 当文件不存在时抛出
|
||||
json.JSONDecodeError: 当JSON解析失败时抛出
|
||||
"""
|
||||
# 设置默认基础路径
|
||||
if base_path is None:
|
||||
base_path = cls.get_data_path()
|
||||
|
||||
# 构建文件路径
|
||||
file_path = base_path / role_name / f"{chat_name}.jsonl"
|
||||
|
||||
# 检查文件是否存在
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(f"聊天文件不存在: {file_path}")
|
||||
|
||||
# 初始化结果数据
|
||||
messages = []
|
||||
metadata = None
|
||||
|
||||
# 读取文件内容
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
for line_num, line in enumerate(f):
|
||||
try:
|
||||
line_data = json.loads(line.strip())
|
||||
|
||||
# 第一行是元数据
|
||||
if line_num == 0:
|
||||
metadata = ChatMetadata(**line_data)
|
||||
else:
|
||||
# 后续行是消息
|
||||
messages.append(Message(**line_data))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
# 创建并返回ChatHistory对象
|
||||
return cls(
|
||||
chat_metadata=metadata or ChatMetadata(),
|
||||
messages=messages
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def load_from_jsonl(cls, file_path: Path) -> 'ChatHistory':
|
||||
"""
|
||||
从JSONL文件加载聊天历史
|
||||
|
||||
参数:
|
||||
file_path: JSONL文件路径
|
||||
|
||||
返回:
|
||||
ChatHistory: 加载的聊天历史对象
|
||||
|
||||
异常:
|
||||
FileNotFoundError: 当文件不存在时抛出
|
||||
json.JSONDecodeError: 当JSON解析失败时抛出
|
||||
"""
|
||||
# 检查文件是否存在
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(f"聊天文件不存在: {file_path}")
|
||||
|
||||
# 初始化结果数据
|
||||
messages = []
|
||||
metadata = None
|
||||
|
||||
# 读取文件内容
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
for line_num, line in enumerate(f):
|
||||
try:
|
||||
line_data = json.loads(line.strip())
|
||||
|
||||
# 第一行是元数据
|
||||
if line_num == 0:
|
||||
# 处理元数据中的嵌套结构
|
||||
if 'chat_metadata' in line_data:
|
||||
metadata_dict = line_data['chat_metadata']
|
||||
# 合并顶层字段和chat_metadata中的字段
|
||||
metadata_dict.update(line_data)
|
||||
metadata = ChatMetadata(**metadata_dict)
|
||||
else:
|
||||
metadata = ChatMetadata(**line_data)
|
||||
else:
|
||||
# 后续行是消息
|
||||
# 处理extra字段中的内容
|
||||
extra_data = line_data.get('extra', {})
|
||||
|
||||
# 如果是AI消息(is_user=False),将extra中的某些字段提升到顶层
|
||||
if not line_data.get('is_user', True):
|
||||
ai_fields = ['api', 'model', 'reasoning', 'reasoning_duration',
|
||||
'reasoning_signature', 'time_to_first_token', 'bias']
|
||||
for field in ai_fields:
|
||||
if field in extra_data:
|
||||
line_data[field] = extra_data.pop(field)
|
||||
|
||||
# 创建Message实例
|
||||
message = Message(**line_data)
|
||||
# 将剩余的extra数据保存回extra字段
|
||||
message.extra = extra_data
|
||||
messages.append(message)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
# 创建并返回ChatHistory对象
|
||||
return cls(
|
||||
chat_metadata=metadata or ChatMetadata(),
|
||||
messages=messages
|
||||
)
|
||||
|
||||
def to_chatbox_format(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
将聊天历史转换为适合前端chatbox显示的格式
|
||||
|
||||
返回:
|
||||
List[Dict[str, Any]]: 按floor排序的消息字典列表,每个字典包含:
|
||||
{
|
||||
"name": str,
|
||||
"is_user": bool,
|
||||
"floor": int,
|
||||
"mes": str,
|
||||
"swipes": List[str],
|
||||
"swipe_id": int
|
||||
}
|
||||
"""
|
||||
# 创建消息字典列表
|
||||
messages_list = []
|
||||
for msg in self.messages:
|
||||
# 获取当前消息内容:优先从swipes数组中获取,如果不存在则使用mes
|
||||
current_mes = msg.mes
|
||||
if msg.swipes and 0 <= msg.swipe_id < len(msg.swipes):
|
||||
current_mes = msg.swipes[msg.swipe_id]
|
||||
|
||||
msg_dict = {
|
||||
"name": msg.name,
|
||||
"is_user": msg.is_user,
|
||||
"floor": msg.floor,
|
||||
"mes": current_mes,
|
||||
"swipes": msg.swipes,
|
||||
"swipe_id": msg.swipe_id
|
||||
}
|
||||
messages_list.append(msg_dict)
|
||||
|
||||
# 按floor排序
|
||||
messages_list.sort(key=lambda x: x["floor"])
|
||||
|
||||
return messages_list
|
||||
|
||||
def save_to_file(self, role_name: str, chat_name: str, base_path: Path = None) -> None:
|
||||
"""
|
||||
将聊天历史保存到JSONL文件
|
||||
|
||||
参数:
|
||||
role_name: 角色名称(文件夹名)
|
||||
chat_name: 聊天名称(文件名,不含扩展名)
|
||||
base_path: 基础路径,默认为data/chat
|
||||
"""
|
||||
# 设置默认基础路径
|
||||
if base_path is None:
|
||||
base_path = self.get_data_path()
|
||||
|
||||
# 构建文件路径
|
||||
file_path = base_path / role_name / f"{chat_name}.jsonl"
|
||||
|
||||
# 确保目录存在
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 写入文件
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
# 写入元数据
|
||||
f.write(json.dumps(self.chat_metadata.dict(), ensure_ascii=False) + '\n')
|
||||
|
||||
# 写入消息
|
||||
for message in self.messages:
|
||||
f.write(json.dumps(message.dict(), ensure_ascii=False) + '\n')
|
||||
@@ -17,12 +17,35 @@ for logger_name in ['uvicorn', 'uvicorn.access', 'fastapi']:
|
||||
|
||||
# backend/app/main.py
|
||||
from fastapi import FastAPI
|
||||
from .api.route import router
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
try:
|
||||
from backend.api.route import router
|
||||
except ImportError:
|
||||
from api.route import router
|
||||
app = FastAPI(title="LLM Workflow Engine")
|
||||
|
||||
# 配置CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # 开发环境允许所有来源,生产环境应该指定具体域名
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 注册路由
|
||||
app.include_router(router, prefix="/api")
|
||||
|
||||
# 添加健康检查端点
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
return {"status": "healthy"}
|
||||
|
||||
# 添加根路径
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"message": "LLM Workflow Engine", "status": "running"}
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
|
||||
231
backend/models/README.md
Normal file
231
backend/models/README.md
Normal file
@@ -0,0 +1,231 @@
|
||||
# Backend Models 数据模型说明
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
models/
|
||||
├── __init__.py # 包初始化,导出所有模型
|
||||
├── sillytavern.py # SillyTavern 兼容模型 (仅用于导入/导出)
|
||||
├── internal.py # 内部业务模型 (项目核心使用)
|
||||
└── README.md # 本文件
|
||||
```
|
||||
|
||||
## 模型分类
|
||||
|
||||
### 1. SillyTavern 兼容模型 (`sillytavern.py`)
|
||||
|
||||
**用途**: 仅用于与 SillyTavern 格式的数据进行导入/导出兼容
|
||||
|
||||
**特点**:
|
||||
- 严格遵循 SillyTavern 官方规范
|
||||
- 不参与内部业务逻辑
|
||||
- 所有字段名、结构与 SillyTavern 保持一致
|
||||
- 前缀 `ST` 表示 SillyTavern
|
||||
|
||||
**主要模型**:
|
||||
- `STWorldInfo` - SillyTavern 世界书
|
||||
- `STCharacterCard` - SillyTavern 角色卡
|
||||
- `STChatHeader` / `STChatMessage` - SillyTavern 聊天记录
|
||||
- `STGenerationPreset` - SillyTavern 采样预设
|
||||
- `STPromptPreset` - SillyTavern 提示词预设
|
||||
|
||||
**使用场景**:
|
||||
```python
|
||||
# 从 SillyTavern 导入时
|
||||
st_data = json.load(file)
|
||||
st_character = STCharacterCard(**st_data)
|
||||
|
||||
# 转换为内部模型
|
||||
internal_character = converter.st_to_internal(st_character)
|
||||
|
||||
# 导出到 SillyTavern 时
|
||||
st_data = converter.internal_to_st(internal_character)
|
||||
json.dump(st_data.dict(), file)
|
||||
```
|
||||
|
||||
### 2. 内部业务模型 (`internal.py`)
|
||||
|
||||
**用途**: 项目内部真正使用的数据结构,所有业务逻辑都基于这些模型
|
||||
|
||||
**特点**:
|
||||
- 继承并扩展了 SillyTavern 的功能
|
||||
- 添加了项目特色功能 (如 LOGIC 激活、RAG 配置、outputSchema 等)
|
||||
- 所有 API 响应、数据存储、工作流交换都使用这些模型
|
||||
- 无前缀,直接使用语义化名称
|
||||
|
||||
**主要模型**:
|
||||
|
||||
#### 世界书相关
|
||||
- `ActivationType` - 激活方式枚举 (PERMANENT/KEYWORD/RAG/LOGIC)
|
||||
- `LogicExpression` - 逻辑表达式
|
||||
- `RAGConfig` - RAG 检索配置
|
||||
- `WorldInfoEntry` - 世界书条目
|
||||
- `WorldInfo` - 世界书
|
||||
|
||||
#### 角色卡相关
|
||||
- `OutputSchemaField` - 结构化输出 schema
|
||||
- `CharacterCard` - 角色卡
|
||||
|
||||
#### 聊天记录相关
|
||||
- `ChatHeader` - 聊天头
|
||||
- `ChatMessage` - 聊天消息
|
||||
- `ChatLog` - 完整聊天记录
|
||||
|
||||
#### 预设相关
|
||||
- `GenerationPreset` - 采样参数预设
|
||||
- `PromptRole` - Prompt 角色枚举
|
||||
- `PromptEntry` - Prompt 条目
|
||||
- `PromptPresetView` - Prompt 预设视图
|
||||
|
||||
#### RAG 配置
|
||||
- `RAGSearchConfig` - RAG 搜索配置
|
||||
- `CharacterRAGConfig` - 角色卡 RAG 配置
|
||||
- `ChatRAGConfig` - 聊天 RAG 配置
|
||||
|
||||
**使用场景**:
|
||||
```python
|
||||
# 业务逻辑中直接使用
|
||||
from models import CharacterCard, WorldInfo
|
||||
|
||||
character = CharacterCard(
|
||||
id="uuid-123",
|
||||
name="Alice",
|
||||
description="...",
|
||||
...
|
||||
)
|
||||
|
||||
# API 响应
|
||||
@app.get("/characters/{id}")
|
||||
async def get_character(id: str):
|
||||
character = service.get_character(id)
|
||||
return character # 返回 internal 模型
|
||||
```
|
||||
|
||||
## 数据转换流程
|
||||
|
||||
```
|
||||
SillyTavern 文件
|
||||
↓ (导入)
|
||||
STCharacterCard (sillytavern.py)
|
||||
↓ (转换器)
|
||||
CharacterCard (internal.py)
|
||||
↓ (业务处理)
|
||||
CharacterCard (internal.py)
|
||||
↓ (转换器)
|
||||
STCharacterCard (sillytavern.py)
|
||||
↓ (导出)
|
||||
SillyTavern 文件
|
||||
```
|
||||
|
||||
## 开发规范
|
||||
|
||||
### ✅ 正确做法
|
||||
|
||||
1. **业务逻辑使用 internal 模型**
|
||||
```python
|
||||
from models import CharacterCard
|
||||
|
||||
def create_character(data: dict) -> CharacterCard:
|
||||
return CharacterCard(**data)
|
||||
```
|
||||
|
||||
2. **导入时使用转换器**
|
||||
```python
|
||||
from models import STCharacterCard, CharacterCard
|
||||
from models.converters import CharacterConverter
|
||||
|
||||
def import_character(file_path: str) -> CharacterCard:
|
||||
st_data = load_json(file_path)
|
||||
st_char = STCharacterCard(**st_data)
|
||||
return CharacterConverter.st_to_internal(st_char)
|
||||
```
|
||||
|
||||
3. **API 响应使用 internal 模型**
|
||||
```python
|
||||
@app.get("/characters")
|
||||
async def list_characters() -> List[CharacterCard]:
|
||||
return service.list_characters()
|
||||
```
|
||||
|
||||
### ❌ 错误做法
|
||||
|
||||
1. **不要在业务逻辑中直接使用 ST 模型**
|
||||
```python
|
||||
# 错误!
|
||||
from models import STCharacterCard
|
||||
|
||||
def process_character(char: STCharacterCard):
|
||||
...
|
||||
```
|
||||
|
||||
2. **不要混合使用两种模型**
|
||||
```python
|
||||
# 错误!
|
||||
character = CharacterCard(...)
|
||||
character.name = st_character.data.name # 不要混用
|
||||
```
|
||||
|
||||
3. **不要在 API 中暴露 ST 模型**
|
||||
```python
|
||||
# 错误!
|
||||
@app.get("/characters")
|
||||
async def list_characters() -> List[STCharacterCard]:
|
||||
...
|
||||
```
|
||||
|
||||
## 添加新模型
|
||||
|
||||
当需要添加新的数据类型时:
|
||||
|
||||
1. **判断用途**:
|
||||
- 如果是为了 SillyTavern 兼容 → 添加到 `sillytavern.py`
|
||||
- 如果是项目内部使用 → 添加到 `internal.py`
|
||||
|
||||
2. **遵循命名规范**:
|
||||
- SillyTavern 模型: 前缀 `ST`
|
||||
- 内部模型: 无前缀,使用清晰的语义化名称
|
||||
|
||||
3. **添加详细注释**:
|
||||
```python
|
||||
class MyModel(BaseModel):
|
||||
"""
|
||||
模型用途说明
|
||||
|
||||
详细描述该模型的作用、使用场景等
|
||||
"""
|
||||
field1: str = Field(..., description="字段说明")
|
||||
```
|
||||
|
||||
4. **在 `__init__.py` 中导出**:
|
||||
```python
|
||||
from .internal import MyModel
|
||||
|
||||
__all__ = [
|
||||
...,
|
||||
'MyModel',
|
||||
]
|
||||
```
|
||||
|
||||
## 转换器 (待实现)
|
||||
|
||||
`models/converters.py` 将提供双向转换功能:
|
||||
|
||||
```python
|
||||
class CharacterConverter:
|
||||
@staticmethod
|
||||
def st_to_internal(st_char: STCharacterCard) -> CharacterCard:
|
||||
"""SillyTavern → Internal"""
|
||||
...
|
||||
|
||||
@staticmethod
|
||||
def internal_to_st(int_char: CharacterCard) -> STCharacterCard:
|
||||
"""Internal → SillyTavern"""
|
||||
...
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
- **sillytavern.py** = 外部兼容层 (Import/Export Only)
|
||||
- **internal.py** = 内部业务层 (Core Business Logic)
|
||||
- **永远在业务逻辑中使用 internal 模型**
|
||||
- **通过转换器进行格式转换**
|
||||
61
backend/models/__init__.py
Normal file
61
backend/models/__init__.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
数据模型包
|
||||
|
||||
导出项目内部真正使用的数据结构 (Internal Models)。
|
||||
SillyTavern 兼容模型将在需要导入/导出时单独引用。
|
||||
"""
|
||||
|
||||
# 内部业务模型 (项目核心使用)
|
||||
from .internal import (
|
||||
# 世界书
|
||||
ActivationType,
|
||||
LogicOperator,
|
||||
LogicExpression,
|
||||
RAGConfig,
|
||||
WorldInfoEntry,
|
||||
WorldInfo,
|
||||
|
||||
# 角色卡
|
||||
OutputSchemaField,
|
||||
CharacterCard,
|
||||
|
||||
# 聊天记录
|
||||
ChatHeader,
|
||||
ChatMessage,
|
||||
ChatLog,
|
||||
|
||||
# 预设
|
||||
GenerationPreset,
|
||||
|
||||
# 提示词预设
|
||||
PromptRole,
|
||||
PromptEntry,
|
||||
PromptPresetView,
|
||||
|
||||
# RAG 配置
|
||||
RAGSearchConfig,
|
||||
CharacterRAGConfig,
|
||||
ChatRAGConfig,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# 内部模型
|
||||
'ActivationType',
|
||||
'LogicOperator',
|
||||
'LogicExpression',
|
||||
'RAGConfig',
|
||||
'WorldInfoEntry',
|
||||
'WorldInfo',
|
||||
'OutputSchemaField',
|
||||
'CharacterCard',
|
||||
'ChatHeader',
|
||||
'ChatMessage',
|
||||
'ChatLog',
|
||||
'GenerationPreset',
|
||||
'PromptRole',
|
||||
'PromptEntry',
|
||||
'PromptPresetView',
|
||||
'RAGSearchConfig',
|
||||
'CharacterRAGConfig',
|
||||
'ChatRAGConfig',
|
||||
]
|
||||
128
backend/models/agent.py
Normal file
128
backend/models/agent.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
Agent workflow engine data models.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class WorkflowTemplateKind(str, Enum):
|
||||
BUILTIN_CHAT = "builtin.chat"
|
||||
|
||||
|
||||
class RunStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class RunEventType(str, Enum):
|
||||
STATE_ENTER = "state_enter"
|
||||
TOOL_START = "tool_start"
|
||||
TOOL_END = "tool_end"
|
||||
WORLD_BOOK_ACTIVE = "worldbook_active"
|
||||
TASKS_CREATED = "tasks_created"
|
||||
CHUNK = "chunk"
|
||||
ERROR = "error"
|
||||
COMPLETE = "complete"
|
||||
|
||||
|
||||
class ToolSpec(BaseModel):
|
||||
name: str
|
||||
description: str = ""
|
||||
parameters: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SkillManifest(BaseModel):
|
||||
id: str
|
||||
name: str = ""
|
||||
description: str = ""
|
||||
path: str = ""
|
||||
|
||||
|
||||
class WorkflowTemplate(BaseModel):
|
||||
id: str
|
||||
kind: WorkflowTemplateKind
|
||||
name: str = ""
|
||||
description: str = ""
|
||||
version: str = "1.0.0"
|
||||
state_machine_path: str = "state_machine.json"
|
||||
skills: List[SkillManifest] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ChatRunBinding(BaseModel):
|
||||
role_name: str
|
||||
chat_name: str
|
||||
template_id: str = WorkflowTemplateKind.BUILTIN_CHAT.value
|
||||
|
||||
|
||||
class TurnCallbacks(BaseModel):
|
||||
"""Optional async callbacks for streaming / WS events."""
|
||||
|
||||
model_config = {"arbitrary_types_allowed": True}
|
||||
|
||||
on_worldbook_active: Optional[Callable[[List[Any]], Awaitable[None]]] = None
|
||||
on_tasks_created: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None
|
||||
on_chunk: Optional[Callable[[str], Awaitable[None]]] = None
|
||||
|
||||
|
||||
class TurnContext(BaseModel):
|
||||
"""Mutable per-turn execution context passed between tools."""
|
||||
|
||||
model_config = {"arbitrary_types_allowed": True}
|
||||
|
||||
request_data: Dict[str, Any] = Field(default_factory=dict)
|
||||
template_id: str = WorkflowTemplateKind.BUILTIN_CHAT.value
|
||||
run_id: str = ""
|
||||
stream: bool = False
|
||||
callbacks: Optional[TurnCallbacks] = None
|
||||
|
||||
current_role: str = ""
|
||||
current_chat: str = ""
|
||||
user_message: str = ""
|
||||
preset_name: Optional[str] = None
|
||||
character: Any = None
|
||||
active_entries: List[Any] = Field(default_factory=list)
|
||||
chat_history: List[Any] = Field(default_factory=list)
|
||||
prompt_messages: List[Any] = Field(default_factory=list)
|
||||
generated_content: str = ""
|
||||
token_usage: Dict[str, Any] = Field(default_factory=dict)
|
||||
duration: float = 0.0
|
||||
task_ids: Dict[str, Optional[str]] = Field(default_factory=dict)
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class WorkflowRun(BaseModel):
|
||||
id: str
|
||||
template_id: str
|
||||
binding: ChatRunBinding
|
||||
status: RunStatus = RunStatus.PENDING
|
||||
started_at: str = Field(default_factory=lambda: datetime.now().isoformat())
|
||||
finished_at: Optional[str] = None
|
||||
current_state: Optional[str] = None
|
||||
result_content: str = ""
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class RunEvent(BaseModel):
|
||||
run_id: str
|
||||
type: RunEventType
|
||||
timestamp: str = Field(default_factory=lambda: datetime.now().isoformat())
|
||||
state: Optional[str] = None
|
||||
tool: Optional[str] = None
|
||||
payload: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ChatTurnResult(BaseModel):
|
||||
success: bool
|
||||
content: str = ""
|
||||
error: Optional[str] = None
|
||||
active_entries: List[Any] = Field(default_factory=list)
|
||||
task_ids: Dict[str, Optional[str]] = Field(default_factory=dict)
|
||||
run_id: str = ""
|
||||
workflow_template_id: str = WorkflowTemplateKind.BUILTIN_CHAT.value
|
||||
379
backend/models/converters.py
Normal file
379
backend/models/converters.py
Normal file
@@ -0,0 +1,379 @@
|
||||
"""
|
||||
数据模型转换器
|
||||
|
||||
提供 SillyTavern 格式与内部格式之间的双向转换功能。
|
||||
所有导入/导出操作都应该通过转换器进行,确保数据格式的一致性。
|
||||
"""
|
||||
import uuid
|
||||
from typing import Dict, Any, List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from models.internal import (
|
||||
WorldInfo,
|
||||
WorldInfoEntry,
|
||||
ActivationType,
|
||||
)
|
||||
|
||||
|
||||
class WorldBookConverter:
|
||||
"""世界书数据转换器
|
||||
|
||||
负责 SillyTavern 格式和项目内部格式之间的转换。
|
||||
|
||||
SillyTavern 格式特点:
|
||||
- entries 是 dict (key 为 uid)
|
||||
- 使用 constant 字段表示常驻激活
|
||||
- position 是字符串 (如 "after_char")
|
||||
|
||||
项目内部格式特点:
|
||||
- entries 是 list
|
||||
- 使用 activationType 枚举
|
||||
- position 是数字 (0-5)
|
||||
- 包含 trigger_config 结构(前端需要)
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def detect_format(data: Dict[str, Any]) -> str:
|
||||
"""
|
||||
智能检测世界书数据格式
|
||||
|
||||
Args:
|
||||
data: 世界书数据
|
||||
|
||||
Returns:
|
||||
'sillytavern' | 'internal' | 'unknown'
|
||||
"""
|
||||
# 检查 entries 类型
|
||||
entries = data.get("entries")
|
||||
if not entries:
|
||||
return "unknown"
|
||||
|
||||
# SillyTavern 特征: entries 是 dict
|
||||
if isinstance(entries, dict):
|
||||
return "sillytavern"
|
||||
|
||||
# 内部格式特征: entries 是 list
|
||||
if isinstance(entries, list):
|
||||
# 进一步检查是否有 trigger_config
|
||||
if len(entries) > 0 and isinstance(entries[0], dict):
|
||||
first_entry = entries[0]
|
||||
if "trigger_config" in first_entry:
|
||||
return "internal"
|
||||
# 也可能是简化的内部格式
|
||||
if "activationType" in first_entry or "position" in first_entry:
|
||||
return "internal"
|
||||
|
||||
return "unknown"
|
||||
|
||||
# 位置映射: SillyTavern 字符串 -> 内部数字
|
||||
POSITION_MAP_ST_TO_INTERNAL = {
|
||||
"after_char": 0,
|
||||
"before_char": 1,
|
||||
"before_example": 2,
|
||||
"after_example": 3,
|
||||
"author_note": 4,
|
||||
"system_prompt": 5,
|
||||
}
|
||||
|
||||
# 位置映射: 内部数字 -> SillyTavern 字符串
|
||||
POSITION_MAP_INTERNAL_TO_ST = {
|
||||
0: "after_char",
|
||||
1: "before_char",
|
||||
2: "before_example",
|
||||
3: "after_example",
|
||||
4: "author_note",
|
||||
5: "system_prompt",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def st_to_internal(st_data: Dict[str, Any], name: str = None) -> Dict[str, Any]:
|
||||
"""
|
||||
将 SillyTavern 格式的世界书转换为内部格式
|
||||
|
||||
Args:
|
||||
st_data: SillyTavern 格式的世界书数据
|
||||
name: 世界书名称(可选,优先使用 st_data 中的 name)
|
||||
|
||||
Returns:
|
||||
内部格式的世界书字典(包含 trigger_config)
|
||||
"""
|
||||
now = int(datetime.now().timestamp())
|
||||
|
||||
# 转换条目
|
||||
entries = []
|
||||
st_entries = st_data.get("entries", {})
|
||||
|
||||
# SillyTavern 的 entries 可能是 dict 或 list
|
||||
if isinstance(st_entries, dict):
|
||||
entries_list = list(st_entries.values())
|
||||
elif isinstance(st_entries, list):
|
||||
entries_list = st_entries
|
||||
else:
|
||||
entries_list = []
|
||||
|
||||
for st_entry in entries_list:
|
||||
if not isinstance(st_entry, dict):
|
||||
continue
|
||||
|
||||
# 判断激活类型
|
||||
is_constant = st_entry.get("constant", False)
|
||||
activation_type = ActivationType.PERMANENT if is_constant else ActivationType.KEYWORD
|
||||
|
||||
# 转换位置
|
||||
st_position = st_entry.get("position", "after_char")
|
||||
internal_position = WorldBookConverter.POSITION_MAP_ST_TO_INTERNAL.get(st_position, 0)
|
||||
|
||||
# 构建 trigger_config (前端期望的格式)
|
||||
trigger_config = WorldBookConverter._build_trigger_config(
|
||||
is_constant=is_constant,
|
||||
key=st_entry.get("key", []),
|
||||
keysecondary=st_entry.get("keysecondary", []),
|
||||
selective=st_entry.get("selective", True)
|
||||
)
|
||||
|
||||
# 创建内部格式的条目
|
||||
entry_dict = {
|
||||
"uid": st_entry.get("uid", str(uuid.uuid4())),
|
||||
"key": st_entry.get("key", []),
|
||||
"keysecondary": st_entry.get("keysecondary", []),
|
||||
"content": st_entry.get("content", ""),
|
||||
"comment": st_entry.get("comment", ""),
|
||||
"activationType": activation_type.value,
|
||||
"trigger_config": trigger_config,
|
||||
"order": st_entry.get("order", 100),
|
||||
"position": internal_position,
|
||||
"depth": st_entry.get("depth", 4),
|
||||
"role": st_entry.get("role", 0),
|
||||
"probability": st_entry.get("probability", 100),
|
||||
"group": st_entry.get("group", []),
|
||||
"disable": st_entry.get("disable", False),
|
||||
"createdAt": now,
|
||||
"updatedAt": now
|
||||
}
|
||||
|
||||
entries.append(entry_dict)
|
||||
|
||||
# 创建内部格式的世界书
|
||||
worldbook_data = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"name": name or st_data.get("name", "Unnamed"),
|
||||
"description": st_data.get("description", ""),
|
||||
"entries": entries,
|
||||
"createdAt": now,
|
||||
"updatedAt": now,
|
||||
"version": 1
|
||||
}
|
||||
|
||||
return worldbook_data
|
||||
|
||||
@staticmethod
|
||||
def internal_to_st(worldbook_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
将内部格式的世界书转换为 SillyTavern 格式
|
||||
|
||||
Args:
|
||||
worldbook_data: 内部格式的世界书字典
|
||||
|
||||
Returns:
|
||||
SillyTavern 格式的世界书数据
|
||||
"""
|
||||
# 转换条目
|
||||
st_entries = {}
|
||||
|
||||
for entry_data in worldbook_data.get("entries", []):
|
||||
if not isinstance(entry_data, dict):
|
||||
continue
|
||||
|
||||
uid = entry_data.get("uid", str(uuid.uuid4()))
|
||||
|
||||
# 从 trigger_config 或 activationType 判断是否常驻
|
||||
is_constant = WorldBookConverter._is_constant_entry(entry_data)
|
||||
|
||||
# 提取关键词
|
||||
key, keysecondary = WorldBookConverter._extract_keywords(entry_data)
|
||||
|
||||
# 转换位置
|
||||
internal_position = entry_data.get("position", 0)
|
||||
st_position = WorldBookConverter.POSITION_MAP_INTERNAL_TO_ST.get(internal_position, "after_char")
|
||||
|
||||
# 创建 SillyTavern 格式的条目
|
||||
st_entry = {
|
||||
"uid": uid,
|
||||
"key": key,
|
||||
"keysecondary": keysecondary,
|
||||
"content": entry_data.get("content", ""),
|
||||
"comment": entry_data.get("comment", ""),
|
||||
"constant": is_constant,
|
||||
"selective": not is_constant,
|
||||
"order": entry_data.get("order", 100),
|
||||
"position": st_position,
|
||||
"depth": entry_data.get("depth", 4),
|
||||
"probability": entry_data.get("probability", 100),
|
||||
"group": entry_data.get("group", []),
|
||||
"disable": entry_data.get("disable", False)
|
||||
}
|
||||
|
||||
st_entries[uid] = st_entry
|
||||
|
||||
# 创建 SillyTavern 格式的世界书
|
||||
st_data = {
|
||||
"name": worldbook_data.get("name", ""),
|
||||
"description": worldbook_data.get("description", ""),
|
||||
"entries": st_entries
|
||||
}
|
||||
|
||||
return st_data
|
||||
|
||||
@staticmethod
|
||||
def normalize_entry(entry_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
规范化条目数据,确保包含所有必需字段和 trigger_config
|
||||
|
||||
Args:
|
||||
entry_data: 条目数据(可能来自不同来源)
|
||||
|
||||
Returns:
|
||||
规范化后的条目数据
|
||||
"""
|
||||
now = int(datetime.now().timestamp())
|
||||
|
||||
# 如果已经有 trigger_config,直接返回
|
||||
if "trigger_config" in entry_data and entry_data["trigger_config"]:
|
||||
return entry_data
|
||||
|
||||
# 否则从其他字段构建 trigger_config
|
||||
is_constant = WorldBookConverter._is_constant_entry(entry_data)
|
||||
key, keysecondary = WorldBookConverter._extract_keywords(entry_data)
|
||||
|
||||
trigger_config = WorldBookConverter._build_trigger_config(
|
||||
is_constant=is_constant,
|
||||
key=key,
|
||||
keysecondary=keysecondary,
|
||||
selective=entry_data.get("selective", True)
|
||||
)
|
||||
|
||||
# 添加缺失的字段
|
||||
normalized = {
|
||||
"uid": entry_data.get("uid", str(uuid.uuid4())),
|
||||
"key": key,
|
||||
"keysecondary": keysecondary,
|
||||
"content": entry_data.get("content", ""),
|
||||
"comment": entry_data.get("comment", ""),
|
||||
"activationType": entry_data.get("activationType",
|
||||
ActivationType.PERMANENT.value if is_constant
|
||||
else ActivationType.KEYWORD.value),
|
||||
"trigger_config": trigger_config,
|
||||
"order": entry_data.get("order", 100),
|
||||
"position": entry_data.get("position", 0),
|
||||
"depth": entry_data.get("depth", 4),
|
||||
"role": entry_data.get("role", 0),
|
||||
"probability": entry_data.get("probability", 100),
|
||||
"group": entry_data.get("group", []),
|
||||
"disable": entry_data.get("disable", False),
|
||||
"createdAt": entry_data.get("createdAt", now),
|
||||
"updatedAt": entry_data.get("updatedAt", now)
|
||||
}
|
||||
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _build_trigger_config(
|
||||
is_constant: bool,
|
||||
key: List[str],
|
||||
keysecondary: List[str],
|
||||
selective: bool = True
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
构建 trigger_config 结构
|
||||
|
||||
Args:
|
||||
is_constant: 是否常驻激活
|
||||
key: 主关键词列表
|
||||
keysecondary: 次要关键词列表
|
||||
selective: 是否选择性匹配
|
||||
|
||||
Returns:
|
||||
trigger_config 字典
|
||||
"""
|
||||
return {
|
||||
"triggers": {
|
||||
"constant": [is_constant, None],
|
||||
"keyword": [
|
||||
not is_constant,
|
||||
{
|
||||
"key": key,
|
||||
"keysecondary": keysecondary,
|
||||
"selective": selective,
|
||||
"selectiveLogic": 0,
|
||||
"matchWholeWords": False,
|
||||
"caseSensitive": False
|
||||
}
|
||||
],
|
||||
"rag": [False, {
|
||||
"threshold": 0.75,
|
||||
"top_k": 5,
|
||||
"query_template": None
|
||||
}],
|
||||
"condition": [False, {
|
||||
"variable_a": "",
|
||||
"operator": "=",
|
||||
"variable_b": ""
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _is_constant_entry(entry_data: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
判断条目是否为常驻激活
|
||||
|
||||
Args:
|
||||
entry_data: 条目数据
|
||||
|
||||
Returns:
|
||||
是否常驻激活
|
||||
"""
|
||||
# 优先从 trigger_config 判断
|
||||
if "trigger_config" in entry_data and entry_data["trigger_config"]:
|
||||
try:
|
||||
return entry_data["trigger_config"]["triggers"]["constant"][0]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
pass
|
||||
|
||||
# 其次从 activationType 判断
|
||||
if "activationType" in entry_data:
|
||||
return entry_data["activationType"] == ActivationType.PERMANENT.value
|
||||
|
||||
# 最后从 constant 字段判断
|
||||
if "constant" in entry_data:
|
||||
return entry_data["constant"]
|
||||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _extract_keywords(entry_data: Dict[str, Any]) -> tuple:
|
||||
"""
|
||||
从条目数据中提取关键词
|
||||
|
||||
Args:
|
||||
entry_data: 条目数据
|
||||
|
||||
Returns:
|
||||
(key, keysecondary) 元组
|
||||
"""
|
||||
# 优先从 trigger_config 提取
|
||||
if "trigger_config" in entry_data and entry_data["trigger_config"]:
|
||||
try:
|
||||
keyword_config = entry_data["trigger_config"]["triggers"]["keyword"][1]
|
||||
if keyword_config:
|
||||
key = keyword_config.get("key", [])
|
||||
keysecondary = keyword_config.get("keysecondary", [])
|
||||
return key, keysecondary
|
||||
except (KeyError, IndexError, TypeError):
|
||||
pass
|
||||
|
||||
# 否则从顶层字段提取
|
||||
key = entry_data.get("key", [])
|
||||
keysecondary = entry_data.get("keysecondary", [])
|
||||
|
||||
return key, keysecondary
|
||||
285
backend/models/fiction_models.py
Normal file
285
backend/models/fiction_models.py
Normal file
@@ -0,0 +1,285 @@
|
||||
"""
|
||||
爽文(Fiction / Novel)数据模型。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class EmotionFlowStep(BaseModel):
|
||||
key: str
|
||||
text: str
|
||||
|
||||
|
||||
class EmotionFlow(BaseModel):
|
||||
id: str
|
||||
intro: str
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
steps: List[EmotionFlowStep] = Field(default_factory=list)
|
||||
|
||||
|
||||
class EmotionFlowCatalog(BaseModel):
|
||||
flows: List[EmotionFlow] = Field(default_factory=list)
|
||||
|
||||
|
||||
class GuideGlobalEntry(BaseModel):
|
||||
layer: str
|
||||
title: str
|
||||
content: str
|
||||
|
||||
|
||||
class GuideGlobalEntries(BaseModel):
|
||||
entries: List[GuideGlobalEntry] = Field(default_factory=list)
|
||||
|
||||
|
||||
class FictionGuideWorldbook(BaseModel):
|
||||
"""Book-local guide 世界书:本书具体人设 / 爽点 / 用户体验 / 禁区。"""
|
||||
|
||||
persona: str = ""
|
||||
highlight: str = ""
|
||||
experience: str = ""
|
||||
forbiddenZones: str = ""
|
||||
|
||||
|
||||
class FictionBookMeta(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
allowedFlowIds: List[str] = Field(default_factory=list)
|
||||
createdAt: str = ""
|
||||
updatedAt: str = ""
|
||||
|
||||
|
||||
class FictionBookSummary(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
allowedFlowIds: List[str] = Field(default_factory=list)
|
||||
updatedAt: str = ""
|
||||
|
||||
|
||||
class FictionPrompts(BaseModel):
|
||||
openBook: str = ""
|
||||
coarseOutline: str = ""
|
||||
eventPlan: str = ""
|
||||
chapter: str = ""
|
||||
nudge: str = ""
|
||||
|
||||
|
||||
class FictionReaderSettings(BaseModel):
|
||||
contextWindowChars: int = 2000
|
||||
prefetchRemainingWords: int = 300
|
||||
|
||||
|
||||
class FictionPipelineSettings(BaseModel):
|
||||
"""半自动流水线:各层 ON=自动,OFF=需手动触发。"""
|
||||
|
||||
semiAuto: bool = False
|
||||
autoCoarse: bool = True
|
||||
autoEventPlan: bool = True
|
||||
autoChapter: bool = True
|
||||
|
||||
|
||||
class FictionBookSettings(BaseModel):
|
||||
prompts: FictionPrompts = Field(default_factory=FictionPrompts)
|
||||
reader: FictionReaderSettings = Field(default_factory=FictionReaderSettings)
|
||||
pipeline: FictionPipelineSettings = Field(default_factory=FictionPipelineSettings)
|
||||
|
||||
|
||||
class CreateFictionBookRequest(BaseModel):
|
||||
title: str
|
||||
inspiration: str = ""
|
||||
guide: FictionGuideWorldbook
|
||||
allowedFlowIds: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class OpenBookRequest(BaseModel):
|
||||
inspiration: str
|
||||
profile_id: Optional[str] = None
|
||||
api_config: Optional[Dict[str, str]] = None
|
||||
|
||||
|
||||
class OpenBookResult(BaseModel):
|
||||
title: str
|
||||
optimizedIntro: str
|
||||
guide: FictionGuideWorldbook
|
||||
allowedFlowIds: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UpdateFictionBookSettingsRequest(BaseModel):
|
||||
prompts: Optional[FictionPrompts] = None
|
||||
reader: Optional[FictionReaderSettings] = None
|
||||
pipeline: Optional[FictionPipelineSettings] = None
|
||||
|
||||
|
||||
class FictionPipelineTickResult(BaseModel):
|
||||
run: FictionRunState
|
||||
started: bool = False
|
||||
pendingStages: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class VolumeOutline(BaseModel):
|
||||
"""卷纲:当前 10~30 章的阶段规划,不是整本书全局大纲。"""
|
||||
|
||||
id: str
|
||||
order: int = 1
|
||||
title: str = ""
|
||||
goal: str = ""
|
||||
coreConflict: str = ""
|
||||
powerProgression: str = ""
|
||||
emotionalPromise: str = ""
|
||||
endingHook: str = ""
|
||||
targetChapterCount: int = 20
|
||||
primaryEmotionFlowId: str = ""
|
||||
status: str = "active"
|
||||
|
||||
|
||||
class CoarseOutlineEvent(BaseModel):
|
||||
"""兼容旧 coarseOutline.events,同时作为新版事件串条目的轻量视图。"""
|
||||
|
||||
id: str
|
||||
title: str
|
||||
summary: str = ""
|
||||
order: int = 1
|
||||
volumeId: str = ""
|
||||
|
||||
|
||||
class CoarseOutline(BaseModel):
|
||||
events: List[CoarseOutlineEvent] = Field(default_factory=list)
|
||||
version: int = 1
|
||||
|
||||
|
||||
class EventChainItem(BaseModel):
|
||||
"""事件串:卷纲 + 情感链在剧情层的落地。"""
|
||||
|
||||
id: str
|
||||
volumeId: str = ""
|
||||
order: int = 1
|
||||
title: str = ""
|
||||
summary: str = ""
|
||||
purpose: str = ""
|
||||
conflict: str = ""
|
||||
turningPoint: str = ""
|
||||
expectedPayoff: str = ""
|
||||
targetChapterCount: int = 3
|
||||
emotionFlowId: str = ""
|
||||
emotionStepKey: str = ""
|
||||
emotionStepText: str = ""
|
||||
status: str = "planned"
|
||||
|
||||
|
||||
class FlowStepsPlan(BaseModel):
|
||||
起: str = ""
|
||||
承: str = ""
|
||||
转: str = ""
|
||||
合: str = ""
|
||||
|
||||
|
||||
class ChapterPlanItem(BaseModel):
|
||||
seq: int
|
||||
|
||||
# 旧字段:保留以兼容现有 metadata.events[eventId].chapterPlan。
|
||||
phaseKey: str = ""
|
||||
phaseSlice: str = ""
|
||||
brief: str = ""
|
||||
|
||||
# 新字段:章纲是写章前最具体的规划层。
|
||||
eventId: str = ""
|
||||
title: str = ""
|
||||
goal: str = ""
|
||||
opening: str = ""
|
||||
mainConflict: str = ""
|
||||
emotionalTurn: str = ""
|
||||
emotionStepKey: str = ""
|
||||
emotionGoal: str = ""
|
||||
payoff: str = ""
|
||||
endingHook: str = ""
|
||||
forbidden: str = ""
|
||||
targetWords: int = 2000
|
||||
|
||||
status: str = "planned"
|
||||
|
||||
|
||||
class EventPlanEntry(BaseModel):
|
||||
emotionFlowId: str = ""
|
||||
flowStepsPlan: FlowStepsPlan = Field(default_factory=FlowStepsPlan)
|
||||
chapterPlan: List[ChapterPlanItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class FictionProgress(BaseModel):
|
||||
currentChapterSeq: int = 0
|
||||
charOffset: int = 0
|
||||
ttsPaused: bool = False
|
||||
genPaused: bool = False
|
||||
|
||||
|
||||
class FictionChapter(BaseModel):
|
||||
seq: int
|
||||
title: str = ""
|
||||
body: str = ""
|
||||
charCount: int = 0
|
||||
status: str = "written"
|
||||
eventId: str = ""
|
||||
phaseKey: str = ""
|
||||
createdAt: str = ""
|
||||
|
||||
|
||||
class FictionChapterSummary(BaseModel):
|
||||
seq: int
|
||||
title: str = ""
|
||||
charCount: int = 0
|
||||
eventId: str = ""
|
||||
phaseKey: str = ""
|
||||
|
||||
|
||||
class FictionBookMetadata(BaseModel):
|
||||
# v2 新规划结构:不再保留旧 coarseOutline/events 作为持久化主结构。
|
||||
version: int = 2
|
||||
volumes: List[VolumeOutline] = Field(default_factory=list)
|
||||
eventChains: Dict[str, List[EventChainItem]] = Field(default_factory=dict)
|
||||
chapterPlans: Dict[str, List[ChapterPlanItem]] = Field(default_factory=dict)
|
||||
progress: FictionProgress = Field(default_factory=FictionProgress)
|
||||
|
||||
|
||||
class FictionRunState(BaseModel):
|
||||
status: str = "idle"
|
||||
pipelineStage: Optional[str] = None
|
||||
stage: str = "idle"
|
||||
message: Optional[str] = None
|
||||
progress: Optional[Dict[str, int]] = None
|
||||
updatedAt: str = ""
|
||||
|
||||
|
||||
class FictionStartReadingResult(BaseModel):
|
||||
run: FictionRunState
|
||||
started: bool = False
|
||||
|
||||
|
||||
class FictionGenerationRequest(BaseModel):
|
||||
profile_id: Optional[str] = None
|
||||
api_config: Optional[Dict[str, str]] = None
|
||||
event_id: Optional[str] = None
|
||||
seq: Optional[int] = None
|
||||
stream: bool = False
|
||||
|
||||
|
||||
class UpdateFictionProgressRequest(BaseModel):
|
||||
currentChapterSeq: Optional[int] = None
|
||||
charOffset: Optional[int] = None
|
||||
ttsPaused: Optional[bool] = None
|
||||
genPaused: Optional[bool] = None
|
||||
|
||||
|
||||
class FictionPrefetchRequest(BaseModel):
|
||||
profile_id: Optional[str] = None
|
||||
api_config: Optional[Dict[str, str]] = None
|
||||
currentChapterSeq: int
|
||||
charOffset: int = 0
|
||||
remainingWords: Optional[int] = None
|
||||
|
||||
|
||||
class FictionPrefetchResult(BaseModel):
|
||||
run: FictionRunState
|
||||
started: bool = False
|
||||
targetSeq: Optional[int] = None
|
||||
skippedReason: Optional[str] = None
|
||||
439
backend/models/internal.py
Normal file
439
backend/models/internal.py
Normal file
@@ -0,0 +1,439 @@
|
||||
"""
|
||||
项目内部数据结构定义
|
||||
|
||||
这是本项目真正使用的核心数据模型,所有业务逻辑都基于这些类型。
|
||||
与 sillytavern.py 不同,这里的模型不参与导入导出兼容,而是专注于:
|
||||
- 内部业务逻辑处理
|
||||
- API 响应数据结构
|
||||
- 数据存储格式
|
||||
- 工作流引擎数据交换
|
||||
|
||||
所有从 SillyTavern 导入的数据都会转换为这些内部模型进行处理,
|
||||
导出时再从内部模型转换回 SillyTavern 格式。
|
||||
"""
|
||||
from enum import Enum
|
||||
from typing import List, Optional, Dict, Any
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# ==================== 世界书 (World Info) ====================
|
||||
|
||||
class ActivationType(str, Enum):
|
||||
"""
|
||||
自定义激活方式类型(4种枚举)
|
||||
|
||||
这是项目的核心创新点之一,相比 SillyTavern 的简单 constant/selective 标志,
|
||||
我们提供了更灵活的激活机制。
|
||||
"""
|
||||
PERMANENT = 'permanent' # 永久激活 - 始终包含在上下文中
|
||||
KEYWORD = 'keyword' # 关键词触发 - 匹配关键词时激活
|
||||
RAG = 'rag' # RAG 检索激活 - 基于向量相似度检索
|
||||
LOGIC = 'logic' # 逻辑表达式激活 - 基于变量条件判断
|
||||
|
||||
|
||||
class LogicOperator(str, Enum):
|
||||
"""逻辑运算符(用于 LOGIC 激活类型)"""
|
||||
EQUALS = 'equals' # 等于
|
||||
NOT_EQUALS = 'not_equals' # 不等于
|
||||
CONTAINS = 'contains' # 包含
|
||||
NOT_CONTAINS = 'not_contains' # 不包含
|
||||
GREATER = 'greater' # 大于
|
||||
LESS = 'less' # 小于
|
||||
|
||||
|
||||
class LogicExpression(BaseModel):
|
||||
"""
|
||||
逻辑表达式结构(用于 LOGIC 激活类型)
|
||||
|
||||
示例: variable1="mood", operator="equals", variable2="happy"
|
||||
表示当 mood 变量等于 happy 时激活该条目
|
||||
"""
|
||||
variable1: str = Field(..., description="第一个变量名")
|
||||
operator: LogicOperator = Field(..., description="比较运算符")
|
||||
variable2: str = Field(..., description="第二个变量名或值")
|
||||
|
||||
|
||||
class RAGConfig(BaseModel):
|
||||
"""
|
||||
RAG 配置(用于 RAG 激活类型)
|
||||
|
||||
控制如何从向量数据库中检索相关内容
|
||||
"""
|
||||
libraryId: str = Field(..., description="绑定的 RAG 库 ID")
|
||||
threshold: Optional[float] = Field(0.7, ge=0, le=1, description="相似度阈值 (0-1)")
|
||||
maxEntries: Optional[int] = Field(5, gt=0, description="最大返回条目数")
|
||||
|
||||
|
||||
class WorldInfoEntry(BaseModel):
|
||||
"""
|
||||
项目内部世界书条目结构
|
||||
|
||||
这是世界书的核心单元,每个条目代表一段可以被动态注入到对话上下文中的知识。
|
||||
相比 SillyTavern,我们添加了 activationType、logicExpression、ragConfig 等高级功能。
|
||||
"""
|
||||
uid: str = Field(..., description="条目唯一标识符 (UUID)")
|
||||
key: Optional[List[str]] = Field(None, description="主关键词列表 (用于 KEYWORD 激活)")
|
||||
keysecondary: Optional[List[str]] = Field(None, description="次要关键词列表 (可选过滤)")
|
||||
content: str = Field(..., description="条目内容 - 激活时注入的文本")
|
||||
activationType: ActivationType = Field(..., description="激活方式")
|
||||
logicExpression: Optional[LogicExpression] = Field(None, description="逻辑表达式 (LOGIC 类型使用)")
|
||||
ragConfig: Optional[RAGConfig] = Field(None, description="RAG 配置 (RAG 类型使用)")
|
||||
order: int = Field(0, description="插入顺序 - 数值越大越靠近末尾")
|
||||
position: Optional[str] = Field('after_char', description="插入位置")
|
||||
depth: Optional[int] = Field(None, description="插入深度 (当 position='at_depth' 时使用)")
|
||||
probability: Optional[float] = Field(100, ge=0, le=100, description="激活概率 (0-100)")
|
||||
group: Optional[List[str]] = Field(None, description="所属组标签")
|
||||
disable: bool = Field(False, description="是否禁用")
|
||||
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
|
||||
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
|
||||
|
||||
|
||||
class WorldInfo(BaseModel):
|
||||
"""
|
||||
项目内部世界书结构
|
||||
|
||||
世界书是角色知识的集合,可以绑定到角色卡上,在对话中动态提供背景信息。
|
||||
"""
|
||||
id: str = Field(..., description="世界书唯一标识符 (UUID)")
|
||||
name: str = Field(..., description="世界书名称")
|
||||
description: Optional[str] = Field(None, description="世界书描述")
|
||||
entries: List[WorldInfoEntry] = Field(default_factory=list, description="条目数组")
|
||||
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
|
||||
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
|
||||
version: int = Field(1, description="版本号 (用于数据迁移)")
|
||||
|
||||
|
||||
# ==================== 角色卡 (Character Card) ====================
|
||||
|
||||
class OutputSchemaField(BaseModel):
|
||||
"""
|
||||
Vercel AI SDK Output.object() 的表头定义
|
||||
|
||||
用于结构化输出,让 LLM 按照指定格式返回数据。
|
||||
这是项目的特色功能,支持动态表格生成。
|
||||
"""
|
||||
name: str = Field(..., description="字段名称")
|
||||
type: str = Field(..., description="字段类型 (string/number/boolean/array/object)")
|
||||
description: str = Field(..., description="字段描述")
|
||||
required: Optional[bool] = Field(None, description="是否必需")
|
||||
enum: Optional[List[str]] = Field(None, description="枚举值 (字符串固定选项)")
|
||||
fields: Optional[List['OutputSchemaField']] = Field(None, description="嵌套字段 (object 类型)")
|
||||
|
||||
|
||||
class CharacterCard(BaseModel):
|
||||
"""
|
||||
项目内部角色卡结构
|
||||
|
||||
角色卡是对话 AI 的核心定义,包含人设、场景、开场白等。
|
||||
相比 SillyTavern,我们添加了 categories、outputSchema、worldInfoId 等功能。
|
||||
"""
|
||||
id: str = Field(..., description="角色唯一标识符 (UUID)")
|
||||
name: str = Field(..., description="角色名称")
|
||||
description: str = Field(..., description="角色详细描述")
|
||||
personality: str = Field(..., description="角色性格特征")
|
||||
scenario: str = Field(..., description="场景设定")
|
||||
first_mes: str = Field(..., description="首条开场消息")
|
||||
mes_example: str = Field(..., description="对话示例")
|
||||
categories: List[str] = Field(default_factory=list, description="分类标签 (用于前端筛选)")
|
||||
tags: Optional[List[str]] = Field(None, description="动态表格标签数组 (SillyTavern 关键字机制)")
|
||||
worldInfoId: Optional[str] = Field(None, description="绑定的世界书 ID")
|
||||
outputSchema: Optional[List[OutputSchemaField]] = Field(None, description="输出 schema 定义 (结构化输出)")
|
||||
avatarPath: Optional[str] = Field(None, description="角色头像路径")
|
||||
alternate_greetings: Optional[List[str]] = Field(None, description="替代问候语数组")
|
||||
|
||||
# TODO: 拓展提示词设置(插件/拓展系统预留接口)
|
||||
# - tableMaintenancePrompt: 用于指导 AI 维护动态表格(RPG状态、任务追踪等)
|
||||
# - imageGenerationPrompt: 用于指导 AI 生成图片描述提示词
|
||||
# 当前状态:字段已定义,默认值为 None,等待插件系统实现
|
||||
tableMaintenancePrompt: Optional[str] = Field(None, description="动态表格维护提示词 - 指导 AI 如何更新表格数据")
|
||||
imageGenerationPrompt: Optional[str] = Field(None, description="生图提示词模板 - 指导 AI 如何生成图片描述")
|
||||
|
||||
# ✅ 动态表格数据(SillyTavern 关键字机制扩展)
|
||||
tableHeaders: Optional[List[str]] = Field(None, description="动态表格表头数组")
|
||||
tableDefaults: Optional[Dict[str, Any]] = Field(None, description="动态表格默认值对象")
|
||||
|
||||
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
|
||||
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
|
||||
lastChatAt: Optional[int] = Field(None, description="最后聊天时间戳")
|
||||
isFavorite: bool = Field(False, description="收藏状态")
|
||||
version: int = Field(1, description="版本号")
|
||||
|
||||
|
||||
# ==================== 聊天记录 (Chat Log) ====================
|
||||
|
||||
# 历史记录模式枚举
|
||||
class HistoryMode(str, Enum):
|
||||
"""
|
||||
历史记录处理模式
|
||||
|
||||
- FULL: 全量模式,保留所有消息(需经正则处理)
|
||||
- SUMMARY: 总结模式,定期用LLM总结历史消息
|
||||
- RAG: RAG模式,基于向量检索(暂不实现)
|
||||
"""
|
||||
FULL = 'full' # 全量模式
|
||||
SUMMARY = 'summary' # 总结模式
|
||||
RAG = 'rag' # RAG模式(预留)
|
||||
|
||||
|
||||
class SummaryConfig(BaseModel):
|
||||
"""
|
||||
总结配置
|
||||
|
||||
用于控制历史消息的总结行为
|
||||
"""
|
||||
enabled: bool = Field(True, description="是否启用总结")
|
||||
interval: int = Field(10, ge=2, description="总结间隔(每隔多少条消息总结一次)")
|
||||
includeUserInput: bool = Field(True, description="总结时是否包含用户输入")
|
||||
summaryPrompt: str = Field(
|
||||
"请总结以下对话内容,保留关键信息和上下文。用简洁的语言概括主要事件、人物状态和重要细节。",
|
||||
description="总结提示词"
|
||||
)
|
||||
maxSummaryLength: int = Field(500, ge=100, description="总结文本的最大长度(字符数)")
|
||||
|
||||
|
||||
class ChatHeader(BaseModel):
|
||||
"""
|
||||
项目内部聊天记录头
|
||||
|
||||
包含聊天的元数据,如参与角色、创建时间等。
|
||||
"""
|
||||
id: str = Field(..., description="聊天唯一标识符 (UUID)")
|
||||
displayName: str = Field(..., description="显示名称 (聊天标题)")
|
||||
characterId: str = Field(..., description="关联的角色卡 ID")
|
||||
userName: str = Field("User", description="用户角色名")
|
||||
characterName: str = Field(..., description="AI 角色名称")
|
||||
tags: Optional[List[str]] = Field(None, description="动态表格标签数组 (从角色卡继承)")
|
||||
|
||||
# ✅ 历史记录模式配置
|
||||
historyMode: HistoryMode = Field(HistoryMode.FULL, description="历史记录处理模式 (full/summary/rag)")
|
||||
summaryConfig: Optional[SummaryConfig] = Field(None, description="总结配置 (当 historyMode='summary' 时使用)")
|
||||
summaryCounter: int = Field(0, ge=0, description="总结计数器(独立于楼层,用于跟踪需要总结的消息数)")
|
||||
|
||||
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
|
||||
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
|
||||
messageCount: int = Field(0, description="消息数量")
|
||||
ragLibraryId: Optional[str] = Field(None, description="关联的 RAG 历史消息库 ID")
|
||||
|
||||
# Agent workflow engine (optional, backward compatible)
|
||||
workflowTemplateId: Optional[str] = Field(None, description="工作流模板 ID")
|
||||
engineRunId: Optional[str] = Field(None, description="最近一次引擎运行 ID")
|
||||
|
||||
|
||||
class ChatMessage(BaseModel):
|
||||
"""
|
||||
项目内部聊天消息
|
||||
|
||||
单条对话消息,支持多版本 (swipes)、token 统计、历史记录总结等功能。
|
||||
"""
|
||||
id: str = Field(..., description="消息唯一标识符 (UUID)")
|
||||
name: str = Field(..., description="发送者名称")
|
||||
is_user: bool = Field(..., description="是否为用户消息")
|
||||
is_system: Optional[bool] = Field(None, description="是否为系统消息")
|
||||
sendDate: str = Field(..., description="发送日期 ISO 字符串")
|
||||
mes: str = Field(..., description="消息内容文本")
|
||||
chatId: str = Field(..., description="关联的聊天 ID")
|
||||
swipes: Optional[List[str]] = Field(None, description="替换回答数组 (多版本)")
|
||||
swipe_id: Optional[int] = Field(0, description="当前选择的版本索引")
|
||||
tokenCount: Optional[int] = Field(None, description="Token 数量 (用于统计)")
|
||||
isTemporary: Optional[bool] = Field(None, description="是否为临时消息 (未保存)")
|
||||
|
||||
# ✅ 历史记录总结相关字段
|
||||
is_summarized: bool = Field(False, description="是否已被总结(中间楼层,内容为空)")
|
||||
is_summary: bool = Field(False, description="是否是总结消息(包含总结文本的楼层)")
|
||||
summary_range: Optional[str] = Field(None, description="总结范围描述(如 'L1-L8',仅在 is_summary=True 时有值)")
|
||||
|
||||
|
||||
class ChatLog(BaseModel):
|
||||
"""
|
||||
项目内部完整聊天记录
|
||||
|
||||
包含聊天头和所有消息,是完整的对话历史。
|
||||
"""
|
||||
header: ChatHeader = Field(..., description="聊天头")
|
||||
messages: List[ChatMessage] = Field(default_factory=list, description="消息列表")
|
||||
|
||||
|
||||
# ==================== 预设 (Preset) ====================
|
||||
|
||||
class GenerationPreset(BaseModel):
|
||||
"""
|
||||
项目内部采样参数预设
|
||||
|
||||
控制 LLM 生成的参数配置,如温度、top_p 等。
|
||||
"""
|
||||
id: str = Field(..., description="预设唯一标识符 (UUID)")
|
||||
name: str = Field(..., description="预设名称")
|
||||
temperature: float = Field(1.0, ge=0, le=2, description="温度 (控制随机性)")
|
||||
topP: float = Field(1.0, ge=0, le=1, description="Top P (核采样)")
|
||||
topK: int = Field(0, ge=0, description="Top K")
|
||||
repetitionPenalty: float = Field(1.0, ge=0, description="重复惩罚")
|
||||
frequencyPenalty: Optional[float] = Field(None, description="频率惩罚")
|
||||
presencePenalty: Optional[float] = Field(None, description="存在惩罚")
|
||||
maxLength: Optional[int] = Field(None, gt=0, description="最大生成长度")
|
||||
isDefault: bool = Field(False, description="是否为默认预设")
|
||||
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
|
||||
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
|
||||
|
||||
|
||||
# ==================== 提示词预设 (Prompt Preset) ====================
|
||||
|
||||
class PromptRole(str, Enum):
|
||||
"""
|
||||
Prompt 角色类型
|
||||
|
||||
内部业务层只保留三种角色,简化了 SillyTavern 的复杂角色系统。
|
||||
"""
|
||||
SYSTEM = 'system' # 系统指令
|
||||
AI = 'ai' # AI 助手
|
||||
USER = 'user' # 用户
|
||||
|
||||
|
||||
class PromptEntry(BaseModel):
|
||||
"""
|
||||
内部业务层 - Prompt 条目
|
||||
|
||||
提示词模板的基本单元,可以组合成完整的提示词预设。
|
||||
这是基于某个 character_id 生成的"当前视图"。
|
||||
"""
|
||||
identifier: str = Field(..., description="稳定关联键 (用于回写)")
|
||||
name: str = Field(..., description="条目名 (前端显示)")
|
||||
enabled: bool = Field(True, description="是否启用 (当前作用域下的业务状态)")
|
||||
content: str = Field(..., description="条目内容 (静态内容视图)")
|
||||
order: int = Field(..., description="条目顺序 (前端展示和拖拽排序)")
|
||||
role: PromptRole = Field(..., description="角色类型")
|
||||
tokenCount: int = Field(0, description="总 token 数 (派生显示字段)")
|
||||
isSystemNode: bool = Field(False, description="是否固有节点 (不可删除)")
|
||||
|
||||
|
||||
class PromptPresetView(BaseModel):
|
||||
"""
|
||||
内部业务层 - Prompt 预设视图
|
||||
|
||||
基于某个 character_id 的"当前视图",包含已排序、已过滤的条目列表。
|
||||
"""
|
||||
characterId: str = Field(..., description="关联的角色 ID")
|
||||
entries: List[PromptEntry] = Field(default_factory=list, description="当前视图的条目列表")
|
||||
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
|
||||
version: int = Field(1, description="版本号")
|
||||
|
||||
|
||||
# ==================== RAG 配置 ====================
|
||||
|
||||
class RAGSearchConfig(BaseModel):
|
||||
"""RAG 搜索配置"""
|
||||
topK: int = Field(5, gt=0, description="每次检索返回的结果数")
|
||||
threshold: float = Field(0.7, ge=0, le=1, description="相似度阈值 (0-1)")
|
||||
maxContextLength: int = Field(2000, gt=0, description="最大上下文长度 (字符数)")
|
||||
|
||||
|
||||
class CharacterRAGConfig(BaseModel):
|
||||
"""
|
||||
角色卡 RAG 世界书库配置
|
||||
|
||||
记录角色卡关联的 RAG 知识库,用于动态检索相关知识。
|
||||
"""
|
||||
characterId: str = Field(..., description="角色卡ID")
|
||||
ragLibraryIds: List[str] = Field(default_factory=list, description="关联的RAG库ID列表")
|
||||
enabled: bool = Field(True, description="是否启用")
|
||||
searchConfig: Optional[RAGSearchConfig] = Field(None, description="搜索配置")
|
||||
position: str = Field('after_char', description="RAG内容插入位置")
|
||||
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
|
||||
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
|
||||
|
||||
|
||||
class ChatRAGConfig(BaseModel):
|
||||
"""
|
||||
聊天会话 RAG 历史消息配置
|
||||
|
||||
记录聊天会话关联的 RAG 历史消息库,用于智能检索历史对话。
|
||||
"""
|
||||
chatId: str = Field(..., description="聊天会话ID")
|
||||
ragLibraryId: Optional[str] = Field(None, description="关联的RAG历史消息库ID")
|
||||
enabled: bool = Field(True, description="是否启用")
|
||||
searchConfig: Optional[Dict[str, Any]] = Field(None, description="搜索配置")
|
||||
autoIndex: bool = Field(True, description="是否自动索引新消息")
|
||||
indexConfig: Optional[Dict[str, Any]] = Field(None, description="索引配置")
|
||||
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
|
||||
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
|
||||
|
||||
|
||||
# ==================== Token 统计 ====================
|
||||
|
||||
class TokenUsageStatus(str, Enum):
|
||||
"""Token 使用状态"""
|
||||
COMPLETED = 'completed' # 成功完成
|
||||
INTERRUPTED = 'interrupted' # 被用户中断
|
||||
FAILED = 'failed' # 请求失败(API错误等)
|
||||
|
||||
|
||||
class TokenUsageRecord(BaseModel):
|
||||
"""
|
||||
Token 使用记录
|
||||
|
||||
记录每次 LLM 调用的 token 使用情况,支持按时间、角色、聊天维度统计
|
||||
"""
|
||||
id: str = Field(..., description="记录唯一标识符 (UUID)")
|
||||
chatId: str = Field(..., description="聊天ID (role_name/chat_name)")
|
||||
roleName: str = Field(..., description="角色名称")
|
||||
chatName: str = Field(..., description="聊天名称")
|
||||
messageId: Optional[str] = Field(None, description="关联的消息ID")
|
||||
floor: Optional[int] = Field(None, description="楼层号")
|
||||
|
||||
# Token 统计
|
||||
promptTokens: int = Field(0, description="输入 token 数")
|
||||
completionTokens: int = Field(0, description="输出 token 数")
|
||||
totalTokens: int = Field(0, description="总 token 数")
|
||||
|
||||
# 状态信息
|
||||
status: TokenUsageStatus = Field(TokenUsageStatus.COMPLETED, description="请求状态")
|
||||
errorMessage: Optional[str] = Field(None, description="错误信息(如果失败)")
|
||||
|
||||
# 时间信息
|
||||
timestamp: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="请求时间戳")
|
||||
duration: Optional[float] = Field(None, description="请求耗时(秒)")
|
||||
|
||||
# API 信息
|
||||
model: Optional[str] = Field(None, description="使用的模型")
|
||||
apiProvider: Optional[str] = Field(None, description="API 提供商")
|
||||
apiUrl: Optional[str] = Field(None, description="API URL地址")
|
||||
|
||||
|
||||
# ==================== 图片元数据 ====================
|
||||
|
||||
class ImageMetadata(BaseModel):
|
||||
"""
|
||||
图片元数据
|
||||
|
||||
记录生成的图片信息,绑定到角色/聊天的特定楼层
|
||||
"""
|
||||
id: str = Field(..., description="图片唯一标识符 (UUID)")
|
||||
chatId: str = Field(..., description="聊天ID (role_name/chat_name)")
|
||||
roleName: str = Field(..., description="角色名称")
|
||||
chatName: str = Field(..., description="聊天名称")
|
||||
floor: int = Field(..., description="楼层号")
|
||||
|
||||
# 图片信息
|
||||
filename: str = Field(..., description="文件名")
|
||||
filepath: str = Field(..., description="文件相对路径")
|
||||
width: Optional[int] = Field(None, description="图片宽度")
|
||||
height: Optional[int] = Field(None, description="图片高度")
|
||||
fileSize: Optional[int] = Field(None, description="文件大小(字节)")
|
||||
|
||||
# Swipe 支持
|
||||
swipeIndex: int = Field(0, description="Swipe 索引(同一楼层多张图片)")
|
||||
isCurrentSwipe: bool = Field(True, description="是否为当前显示的 swipe")
|
||||
|
||||
# 生成信息
|
||||
prompt: Optional[str] = Field(None, description="生成使用的提示词")
|
||||
negativePrompt: Optional[str] = Field(None, description="负面提示词")
|
||||
seed: Optional[int] = Field(None, description="随机种子")
|
||||
model: Optional[str] = Field(None, description="使用的模型/checkpoint")
|
||||
workflowName: Optional[str] = Field(None, description="使用的工作流名称")
|
||||
|
||||
# 任务信息
|
||||
taskId: Optional[str] = Field(None, description="关联的任务ID")
|
||||
generationTime: Optional[float] = Field(None, description="生成耗时(秒)")
|
||||
|
||||
# 时间信息
|
||||
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
|
||||
164
backend/models/regex_rules.py
Normal file
164
backend/models/regex_rules.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
正则替换规则模型
|
||||
|
||||
兼容 SillyTavern 的正则系统,支持全局、角色卡、预设三种作用域。
|
||||
"""
|
||||
from enum import Enum
|
||||
from typing import List, Optional, Dict, Any
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class RegexPlacement(int, Enum):
|
||||
"""
|
||||
正则应用位置(对应 SillyTavern 的 placement 数组)
|
||||
|
||||
0: System Prompt - 系统提示词
|
||||
1: User Input - 用户输入
|
||||
2: AI Output - AI 输出
|
||||
3: Quick Reply - 快捷回复
|
||||
4: World Info - 世界书信息
|
||||
5: Reasoning/Thinking - 推理/思考内容
|
||||
"""
|
||||
SYSTEM_PROMPT = 0
|
||||
USER_INPUT = 1
|
||||
AI_OUTPUT = 2
|
||||
QUICK_REPLY = 3
|
||||
WORLD_INFO = 4
|
||||
REASONING = 5
|
||||
|
||||
|
||||
class RegexScope(str, Enum):
|
||||
"""
|
||||
正则规则作用域
|
||||
|
||||
- GLOBAL: 全局生效,对所有聊天应用
|
||||
- CHARACTER: 绑定到特定角色卡
|
||||
- PRESET: 绑定到特定预设
|
||||
"""
|
||||
GLOBAL = 'global'
|
||||
CHARACTER = 'character'
|
||||
PRESET = 'preset'
|
||||
|
||||
|
||||
class SubstituteMode(int, Enum):
|
||||
"""
|
||||
替换模式
|
||||
|
||||
对应 SillyTavern 的 substituteRegex 字段
|
||||
"""
|
||||
REPLACE_ALL = 0 # 替换所有匹配
|
||||
REPLACE_FIRST = 1 # 仅替换首次匹配
|
||||
REPLACE_CAPTURED = 2 # 替换捕获组
|
||||
|
||||
|
||||
class RegexRule(BaseModel):
|
||||
"""
|
||||
单条正则替换规则
|
||||
|
||||
完全兼容 SillyTavern 的正则规则格式
|
||||
"""
|
||||
id: str = Field(..., description="规则唯一标识符 (UUID)")
|
||||
scriptName: str = Field(..., description="脚本名称(用于显示)")
|
||||
|
||||
# 核心正则配置
|
||||
findRegex: str = Field(..., description="查找正则表达式(如:/<thinking>[\\s\\S]*?<\\/thinking>/gi)")
|
||||
replaceString: str = Field("", description="替换字符串(支持捕获组引用 $1, $2 等)")
|
||||
trimStrings: List[str] = Field(default_factory=list, description="要额外修剪的字符串数组")
|
||||
|
||||
# 应用位置(关键!对应 SillyTavern 的 placement 数组)
|
||||
placement: List[RegexPlacement] = Field(
|
||||
default_factory=lambda: [RegexPlacement.AI_OUTPUT],
|
||||
description="应用位置数组:0=系统提示词, 1=用户输入, 2=AI输出, 3=快捷回复, 4=世界书, 5=推理内容"
|
||||
)
|
||||
|
||||
# 替换模式
|
||||
substituteRegex: SubstituteMode = Field(
|
||||
SubstituteMode.REPLACE_ALL,
|
||||
description="替换模式:0=全部,1=首次,2=捕获组"
|
||||
)
|
||||
|
||||
# 作用范围控制
|
||||
markdownOnly: bool = Field(False, description="是否仅应用于 Markdown 渲染后的内容")
|
||||
promptOnly: bool = Field(False, description="是否仅应用于发送给 LLM 的提示词")
|
||||
runOnEdit: bool = Field(True, description="用户编辑消息时是否重新应用")
|
||||
|
||||
# 消息深度控制
|
||||
minDepth: Optional[int] = Field(None, ge=0, description="最小消息深度(从最新消息开始计数,None 表示无限制)")
|
||||
maxDepth: Optional[int] = Field(None, ge=0, description="最大消息深度(None 表示无限制)")
|
||||
|
||||
# 作用域配置
|
||||
scope: RegexScope = Field(RegexScope.GLOBAL, description="规则作用域")
|
||||
characterName: Optional[str] = Field(None, description="绑定的角色卡名称(scope=CHARACTER 时使用)")
|
||||
presetName: Optional[str] = Field(None, description="绑定的预设名称(scope=PRESET 时使用)")
|
||||
|
||||
# 启用状态
|
||||
disabled: bool = Field(False, description="是否禁用此规则(与 enabled 相反,为了兼容 ST)")
|
||||
|
||||
# 执行顺序
|
||||
order: int = Field(0, description="执行顺序(数值越小越先执行)")
|
||||
|
||||
# 元数据
|
||||
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
|
||||
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
|
||||
description: Optional[str] = Field(None, description="规则描述(可选)")
|
||||
|
||||
|
||||
class RegexRuleset(BaseModel):
|
||||
"""
|
||||
正则规则集
|
||||
|
||||
一组正则规则的集合,可以整体导入/导出,兼容 SillyTavern 格式
|
||||
"""
|
||||
id: str = Field(..., description="规则集唯一标识符 (UUID)")
|
||||
name: str = Field(..., description="规则集名称")
|
||||
description: Optional[str] = Field(None, description="规则集描述")
|
||||
|
||||
rules: List[RegexRule] = Field(default_factory=list, description="规则列表")
|
||||
|
||||
# 元数据
|
||||
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
|
||||
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
|
||||
version: int = Field(1, description="版本号(用于数据迁移)")
|
||||
|
||||
# SillyTavern 兼容性标记
|
||||
isSillyTavernFormat: bool = Field(False, description="是否为 SillyTavern 导入格式")
|
||||
|
||||
|
||||
# ==================== 使用示例 ====================
|
||||
|
||||
if __name__ == '__main__':
|
||||
import json
|
||||
|
||||
# 创建一条规则
|
||||
rule = RegexRule(
|
||||
id="example-hide-thinking-001",
|
||||
scriptName="隐藏思考标签",
|
||||
findRegex=r"<thinking>[\s\S]*?<\/thinking>",
|
||||
replaceString="",
|
||||
trimStrings=[],
|
||||
placement=[RegexPlacement.AI_OUTPUT],
|
||||
substituteRegex=SubstituteMode.REPLACE_ALL,
|
||||
markdownOnly=False,
|
||||
promptOnly=False,
|
||||
runOnEdit=True,
|
||||
minDepth=0,
|
||||
maxDepth=None,
|
||||
scope=RegexScope.GLOBAL,
|
||||
characterName=None,
|
||||
presetName=None,
|
||||
disabled=False,
|
||||
order=1,
|
||||
description="隐藏 AI 回复中的 <thinking> 标签及其内容"
|
||||
)
|
||||
|
||||
# 创建规则集
|
||||
ruleset = RegexRuleset(
|
||||
id="ruleset-001",
|
||||
name="默认正则规则集",
|
||||
description="包含常用的文本处理规则",
|
||||
rules=[rule]
|
||||
)
|
||||
|
||||
# 导出为 JSON(兼容 SillyTavern)
|
||||
print(json.dumps(ruleset.dict(), indent=2, ensure_ascii=False))
|
||||
258
backend/models/studio_models.py
Normal file
258
backend/models/studio_models.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""
|
||||
Studio workflow editor data models.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class DisplayParam(BaseModel):
|
||||
key: str
|
||||
label: str
|
||||
type: str = "text"
|
||||
required: bool = True
|
||||
placeholder: str = ""
|
||||
|
||||
|
||||
class InputRef(BaseModel):
|
||||
ref: str
|
||||
label: Optional[str] = None
|
||||
optional: bool = False
|
||||
|
||||
|
||||
class ScoringDimension(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
criteria: str = ""
|
||||
|
||||
|
||||
class InsertionRagConfig(BaseModel):
|
||||
libraryId: str = ""
|
||||
threshold: float = 0.5
|
||||
maxEntries: int = 3
|
||||
|
||||
|
||||
class InsertionConfig(BaseModel):
|
||||
position: int = 1
|
||||
activationType: str = "permanent"
|
||||
key: str = ""
|
||||
keysecondary: str = ""
|
||||
comment: str = ""
|
||||
ragConfig: Optional[InsertionRagConfig] = None
|
||||
|
||||
|
||||
class ScoringConfig(BaseModel):
|
||||
enabled: bool = True
|
||||
dimensions: List[ScoringDimension] = Field(default_factory=list)
|
||||
rubric: Optional[str] = None
|
||||
|
||||
|
||||
class StudioNode(BaseModel):
|
||||
id: str
|
||||
skillId: str
|
||||
displayName: str
|
||||
enabled: bool = True
|
||||
niche: Optional[str] = None
|
||||
loopUntilSatisfied: bool = False
|
||||
config: Dict[str, Any] = Field(default_factory=dict)
|
||||
displayParams: List[DisplayParam] = Field(default_factory=list)
|
||||
inputs: List[InputRef] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PipelineDefinition(BaseModel):
|
||||
workflowGoal: str = ""
|
||||
nodes: List[StudioNode] = Field(default_factory=list)
|
||||
|
||||
|
||||
class StudioProjectMeta(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str = ""
|
||||
templateId: Optional[str] = None
|
||||
characterId: Optional[str] = None
|
||||
worldbookId: Optional[str] = None
|
||||
createdAt: str = ""
|
||||
updatedAt: str = ""
|
||||
|
||||
|
||||
class StudioProject(BaseModel):
|
||||
meta: StudioProjectMeta
|
||||
pipeline: PipelineDefinition
|
||||
|
||||
|
||||
class ArtifactDef(BaseModel):
|
||||
type: str
|
||||
displayName: str = ""
|
||||
|
||||
|
||||
class SkillTemplateDef(BaseModel):
|
||||
skillId: str
|
||||
displayName: str
|
||||
description: str = ""
|
||||
displayParams: List[DisplayParam] = Field(default_factory=list)
|
||||
configWhitelist: List[str] = Field(default_factory=list)
|
||||
artifacts: List[ArtifactDef] = Field(default_factory=list)
|
||||
supportsLoopUntilSatisfied: bool = False
|
||||
supportsInputs: bool = False
|
||||
supportsInsertion: bool = False
|
||||
supportsScoring: bool = False
|
||||
|
||||
|
||||
class WorkflowTemplateSummary(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str = ""
|
||||
|
||||
|
||||
class WorkflowVariableDef(BaseModel):
|
||||
ref: str
|
||||
label: str
|
||||
description: str = ""
|
||||
|
||||
|
||||
class DynamicVariableSuffix(BaseModel):
|
||||
suffix: str
|
||||
labelPattern: str
|
||||
|
||||
|
||||
class WorkflowVariablesResponse(BaseModel):
|
||||
builtIn: List[WorkflowVariableDef] = Field(default_factory=list)
|
||||
dynamic: List[WorkflowVariableDef] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SkillTemplatesCatalog(BaseModel):
|
||||
templates: List[SkillTemplateDef] = Field(default_factory=list)
|
||||
|
||||
|
||||
class StudioProjectSummary(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str = ""
|
||||
updatedAt: str = ""
|
||||
|
||||
|
||||
class CreateStudioProjectRequest(BaseModel):
|
||||
name: str = "新项目"
|
||||
template_id: str = "builtin.studio.example"
|
||||
project_id: Optional[str] = None
|
||||
|
||||
|
||||
class UpdateStudioProjectRequest(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=120)
|
||||
description: Optional[str] = Field(None, max_length=500)
|
||||
|
||||
|
||||
class StudioRunStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
PAUSED = "paused"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class ToolQuestionOption(BaseModel):
|
||||
question: str
|
||||
options: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class StepMessage(BaseModel):
|
||||
"""Short step-scoped dialogue (not full chat history)."""
|
||||
id: str
|
||||
role: str # user | assistant
|
||||
content: str
|
||||
createdAt: Optional[str] = None
|
||||
|
||||
|
||||
class LastToolResponse(BaseModel):
|
||||
"""LLM tool-call payload surfaced to the run UI (R2+)."""
|
||||
thinking: Optional[str] = None
|
||||
evaluation: Optional[str] = None
|
||||
questions: List[ToolQuestionOption] = Field(default_factory=list)
|
||||
generatedAt: Optional[str] = None
|
||||
|
||||
|
||||
class PromptBlock(BaseModel):
|
||||
"""Single assembled context section for LLM prompt (R2 debug / execution)."""
|
||||
id: str
|
||||
label: str
|
||||
content: str
|
||||
source: str = "auto" # auto | manual | workflow
|
||||
|
||||
|
||||
class TurnSnapshot(BaseModel):
|
||||
"""State captured before each LLM turn (for undo)."""
|
||||
lastDraft: Optional[Dict[str, Any]] = None
|
||||
lastToolResponse: Optional[LastToolResponse] = None
|
||||
stepMessages: List[StepMessage] = Field(default_factory=list)
|
||||
timestamp: Optional[str] = None
|
||||
|
||||
|
||||
class StudioNodeRunState(BaseModel):
|
||||
nodeId: str
|
||||
displayName: str
|
||||
skillId: str
|
||||
status: str # pending | active | completed | skipped
|
||||
loopUntilSatisfied: bool = False
|
||||
lastDraft: Optional[Dict[str, Any]] = None
|
||||
lastToolResponse: Optional[LastToolResponse] = None
|
||||
stepMessages: List[StepMessage] = Field(default_factory=list)
|
||||
turnHistory: List[TurnSnapshot] = Field(default_factory=list)
|
||||
|
||||
|
||||
class StudioRun(BaseModel):
|
||||
id: str
|
||||
projectId: str
|
||||
status: StudioRunStatus
|
||||
pipelineSnapshot: PipelineDefinition
|
||||
pipelineVersionNote: str
|
||||
currentNodeId: Optional[str] = None
|
||||
nodeStates: List[StudioNodeRunState] = Field(default_factory=list)
|
||||
workflowVariables: Dict[str, Any] = Field(default_factory=dict)
|
||||
lastPromptBlocks: List[PromptBlock] = Field(default_factory=list)
|
||||
title: str = ""
|
||||
createdAt: str = ""
|
||||
updatedAt: str = ""
|
||||
|
||||
|
||||
class AdvanceRunRequest(BaseModel):
|
||||
displayParams: Dict[str, str] = Field(default_factory=dict)
|
||||
saveMode: Literal["advance", "append", "overwrite"] = "advance"
|
||||
|
||||
|
||||
class SaveRunRequest(BaseModel):
|
||||
mode: Literal["incremental", "overwrite"]
|
||||
|
||||
|
||||
class SwitchRunNodeRequest(BaseModel):
|
||||
nodeId: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class RunMessageRequest(BaseModel):
|
||||
content: str = Field(..., min_length=1, max_length=32000)
|
||||
stream: bool = False
|
||||
profileId: Optional[str] = None
|
||||
apiConfig: Optional[Dict[str, str]] = None
|
||||
|
||||
|
||||
class RunRerollRequest(BaseModel):
|
||||
stream: bool = False
|
||||
profileId: Optional[str] = None
|
||||
apiConfig: Optional[Dict[str, str]] = None
|
||||
|
||||
|
||||
class RenameRunRequest(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=120)
|
||||
|
||||
|
||||
class StudioRunSummary(BaseModel):
|
||||
id: str
|
||||
projectId: str
|
||||
status: StudioRunStatus
|
||||
currentNodeId: Optional[str] = None
|
||||
title: str = ""
|
||||
createdAt: str = ""
|
||||
updatedAt: str = ""
|
||||
76
backend/models/summary_message.py
Normal file
76
backend/models/summary_message.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
聊天总结消息数据模型
|
||||
|
||||
用于存储总结后的历史消息记录
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class SummaryMessage(BaseModel):
|
||||
"""
|
||||
总结消息
|
||||
|
||||
保存总结后的文本和元数据
|
||||
"""
|
||||
id: str = Field(..., description="总结消息唯一标识符 (UUID)")
|
||||
chatId: str = Field(..., description="关联的聊天 ID")
|
||||
|
||||
# 总结内容
|
||||
summaryText: str = Field(..., description="总结后的文本内容")
|
||||
originalMessageIds: List[str] = Field(default_factory=list, description="被总结的原始消息 ID 列表")
|
||||
messageRange: Optional[str] = Field(None, description="消息范围描述,如 '1-10'")
|
||||
|
||||
# 元数据
|
||||
summaryTimestamp: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="总结时间戳")
|
||||
messageCount: int = Field(..., description="被总结的消息数量")
|
||||
includeUserInput: bool = Field(True, description="是否包含用户输入")
|
||||
|
||||
# 统计信息
|
||||
originalTokenCount: Optional[int] = Field(None, description="原始消息的 token 总数")
|
||||
summaryTokenCount: Optional[int] = Field(None, description="总结文本的 token 数")
|
||||
|
||||
# 版本控制
|
||||
version: int = Field(1, description="总结版本号(用于追溯)")
|
||||
|
||||
@classmethod
|
||||
def create_summary(
|
||||
cls,
|
||||
chat_id: str,
|
||||
summary_text: str,
|
||||
message_ids: List[str],
|
||||
include_user_input: bool = True,
|
||||
version: int = 1
|
||||
) -> 'SummaryMessage':
|
||||
"""
|
||||
创建总结消息的工厂方法
|
||||
|
||||
Args:
|
||||
chat_id: 聊天 ID
|
||||
summary_text: 总结文本
|
||||
message_ids: 被总结的消息 ID 列表
|
||||
include_user_input: 是否包含用户输入
|
||||
version: 版本号
|
||||
|
||||
Returns:
|
||||
SummaryMessage 实例
|
||||
"""
|
||||
import uuid
|
||||
|
||||
# 生成消息范围描述
|
||||
if len(message_ids) > 0:
|
||||
message_range = f"{len(message_ids)}条消息"
|
||||
else:
|
||||
message_range = "无消息"
|
||||
|
||||
return cls(
|
||||
id=str(uuid.uuid4()),
|
||||
chatId=chat_id,
|
||||
summaryText=summary_text,
|
||||
originalMessageIds=message_ids,
|
||||
messageRange=message_range,
|
||||
messageCount=len(message_ids),
|
||||
includeUserInput=include_user_input,
|
||||
version=version
|
||||
)
|
||||
49
backend/models/system_settings.py
Normal file
49
backend/models/system_settings.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
系统设置模型
|
||||
|
||||
包含全局配置,如思考标签前后缀等。
|
||||
"""
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class SystemSettings(BaseModel):
|
||||
"""
|
||||
系统全局设置
|
||||
|
||||
持久化存储到 data/system_settings.json
|
||||
"""
|
||||
|
||||
# ==================== 思考标签配置 ====================
|
||||
thinkingTagPrefix: str = Field(
|
||||
"<thinking>",
|
||||
description="思考标签前缀(默认:<thinking>)"
|
||||
)
|
||||
thinkingTagSuffix: str = Field(
|
||||
"</thinking>",
|
||||
description="思考标签后缀(默认:</thinking>)"
|
||||
)
|
||||
|
||||
# ==================== 当前选中的预设 ====================
|
||||
currentPresetName: Optional[str] = Field(
|
||||
None,
|
||||
description="当前选中的预设名称(用于确定全局正则的作用域)"
|
||||
)
|
||||
|
||||
# ==================== 元数据 ====================
|
||||
updatedAt: int = Field(
|
||||
default_factory=lambda: int(datetime.now().timestamp()),
|
||||
description="最后更新时间戳"
|
||||
)
|
||||
version: int = Field(1, description="版本号")
|
||||
|
||||
|
||||
# ==================== 默认设置 ====================
|
||||
|
||||
DEFAULT_SYSTEM_SETTINGS = SystemSettings()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import json
|
||||
print(json.dumps(DEFAULT_SYSTEM_SETTINGS.dict(), indent=2, ensure_ascii=False))
|
||||
@@ -1,231 +0,0 @@
|
||||
from typing import List, Dict, Any, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from backend.core.models.PromptList import AIDesignSpec
|
||||
from backend.core.models.PromptComponent import PromptComponent
|
||||
from enum import Enum
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class SpecialIdentifier(str, Enum):
|
||||
"""
|
||||
特殊组件标识符枚举
|
||||
定义所有提示词组件的类型及其在最终 Prompt 中的默认物理流向
|
||||
顺序大致遵循:系统层 -> 角色层 -> 动态层 -> 历史层 -> 尾部指令
|
||||
"""
|
||||
|
||||
WORLD_INFO_BEFORE = "worldInfoBefore"
|
||||
"""前置世界书:通常用于全局设定(如物理法则),紧接在 Main Prompt 之后,拥有最高优先级"""
|
||||
|
||||
PERSONA_DESCRIPTION = "personaDescription"
|
||||
"""用户设定:告诉 AI {{user}} 是谁,通常放在场景之后,完成“谁在对谁说话”的闭环"""
|
||||
|
||||
ENHANCE_DEFINITIONS = "enhanceDefinitions"
|
||||
"""增强定义:通常是 "If you have more knowledge...",用于补充 AI 的知识库,这里用rag获取"""
|
||||
|
||||
WORLD_INFO_AFTER = "worldInfoAfter"
|
||||
"""后置世界书:通常用于特定场景规则,位于中间层底部,用于覆盖或补充前面的全局设定"""
|
||||
|
||||
CHAT_HISTORY = "chatHistory"
|
||||
"""聊天历史:包含用户与 AI 的过往对话,占据提示词的下半部分"""
|
||||
|
||||
JAILBREAK = "jailbreak"
|
||||
"""后置指令/注释,也即d0层:通常位于聊天记录之后、AI 生成之前,用于最后时刻的强调(如“不要重复”)"""
|
||||
|
||||
class PresetAssemblyNode(BaseModel):
|
||||
"""预设组装节点类,负责根据组装指令动态组装提示词内容"""
|
||||
|
||||
# 输入数据
|
||||
design_spec: AIDesignSpec = Field(
|
||||
...,
|
||||
description="AI设计规范,包含组件库和组装顺序"
|
||||
)
|
||||
target_character_id: int = Field(
|
||||
...,
|
||||
description="目标角色ID,用于选择对应的组装指令"
|
||||
)
|
||||
|
||||
# 内部状态(不参与序列化)
|
||||
_component_map: Dict[str, PromptComponent] = Field(
|
||||
default_factory=dict,
|
||||
description="组件标识符到组件对象的映射"
|
||||
)
|
||||
|
||||
def __init__(self, **data):
|
||||
"""初始化方法,构建组件映射"""
|
||||
super().__init__(**data)
|
||||
# 构建组件映射字典,提高查找效率
|
||||
self._component_map = {
|
||||
comp.identifier: comp
|
||||
for comp in self.design_spec.prompts
|
||||
}
|
||||
|
||||
def _process_special_component(self, component: PromptComponent) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
处理特殊组件(marker为True的组件)
|
||||
|
||||
参数:
|
||||
component: 要处理的组件
|
||||
|
||||
返回:
|
||||
Optional[Dict[str, Any]]: 处理后的消息,如果组件无法处理则返回None
|
||||
"""
|
||||
try:
|
||||
# 尝试将标识符转换为枚举
|
||||
special_id = SpecialIdentifier(component.identifier)
|
||||
|
||||
# 根据不同标识符执行不同处理逻辑
|
||||
if special_id == SpecialIdentifier.CHAT_HISTORY:
|
||||
return self._handle_chat_history(component)
|
||||
elif special_id == SpecialIdentifier.WORLD_INFO_BEFORE:
|
||||
return self._handle_world_info_before(component)
|
||||
elif special_id == SpecialIdentifier.WORLD_INFO_AFTER:
|
||||
return self._handle_world_info_after(component)
|
||||
elif special_id == SpecialIdentifier.CHAR_DESCRIPTION:
|
||||
return self._handle_char_description(component)
|
||||
else:
|
||||
# 未知特殊组件,使用默认处理
|
||||
return self._process_regular_component(component)
|
||||
except ValueError:
|
||||
# 不是特殊标识符,使用默认处理
|
||||
return self._process_regular_component(component)
|
||||
|
||||
def _process_special_component(self, component: PromptComponent) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
处理特殊组件(marker为True的组件)
|
||||
|
||||
参数:
|
||||
component: 要处理的组件
|
||||
|
||||
返回:
|
||||
Optional[Dict[str, Any]]: 处理后的消息,如果组件无法处理则返回None
|
||||
"""
|
||||
try:
|
||||
# 尝试将标识符转换为枚举
|
||||
special_id = SpecialIdentifier(component.identifier)
|
||||
|
||||
# 根据不同标识符执行不同处理逻辑
|
||||
if special_id == SpecialIdentifier.CHAT_HISTORY:
|
||||
return self._handle_chat_history(component)
|
||||
elif special_id == SpecialIdentifier.WORLD_INFO_BEFORE:
|
||||
return self._handle_world_info_before(component)
|
||||
elif special_id == SpecialIdentifier.WORLD_INFO_AFTER:
|
||||
return self._handle_world_info_after(component)
|
||||
elif special_id == SpecialIdentifier.DIALOGUE_EXAMPLES:
|
||||
return self._handle_dialogue_examples(component)
|
||||
elif special_id == SpecialIdentifier.CHAR_DESCRIPTION:
|
||||
return self._handle_char_description(component)
|
||||
elif special_id == SpecialIdentifier.CHAR_PERSONALITY:
|
||||
return self._handle_char_personality(component)
|
||||
elif special_id == SpecialIdentifier.SCENARIO:
|
||||
return self._handle_scenario(component)
|
||||
elif special_id == SpecialIdentifier.PERSONA_DESCRIPTION:
|
||||
return self._handle_persona_description(component)
|
||||
else:
|
||||
# 未知特殊组件,使用默认处理
|
||||
return self._process_regular_component(component)
|
||||
except ValueError:
|
||||
# 不是特殊标识符,使用默认处理
|
||||
return self._process_regular_component(component)
|
||||
|
||||
def _process_regular_component(self, component: PromptComponent) -> Dict[str, Any]:
|
||||
"""
|
||||
处理普通组件(marker为False的组件)
|
||||
|
||||
参数:
|
||||
component: 要处理的组件
|
||||
|
||||
返回:
|
||||
Dict[str, Any]: 处理后的消息
|
||||
"""
|
||||
# 角色映射表
|
||||
role_map = {0: "system", 1: "user", 2: "assistant"}
|
||||
|
||||
# 构建消息
|
||||
message = {
|
||||
"role": role_map.get(component.role, "system"),
|
||||
"content": component.content
|
||||
}
|
||||
|
||||
# 添加系统提示词标记
|
||||
if component.system_prompt:
|
||||
message["system_prompt"] = True
|
||||
|
||||
return message
|
||||
|
||||
# 以下为特殊组件处理方法
|
||||
|
||||
def _handle_chat_history(self, component: PromptComponent) -> Dict[str, Any]:
|
||||
"""
|
||||
处理聊天历史组件
|
||||
|
||||
参数:
|
||||
component: 聊天历史组件
|
||||
|
||||
返回:
|
||||
Dict[str, Any]: 处理后的消息
|
||||
"""
|
||||
# 这里应该从外部获取实际的聊天历史
|
||||
# 示例实现,实际需要根据业务逻辑调整
|
||||
return {
|
||||
"role": "system",
|
||||
"content": "聊天历史内容...",
|
||||
"marker": True,
|
||||
"type": "chat_history"
|
||||
}
|
||||
|
||||
def _handle_world_info_before(self, component: PromptComponent) -> Dict[str, Any]:
|
||||
"""
|
||||
处理前置世界信息组件
|
||||
|
||||
参数:
|
||||
component: 世界信息组件
|
||||
|
||||
返回:
|
||||
Dict[str, Any]: 处理后的消息
|
||||
"""
|
||||
# 这里应该从外部获取实际的世界信息
|
||||
return {
|
||||
"role": "system",
|
||||
"content": "前置世界信息...",
|
||||
"marker": True,
|
||||
"type": "world_info_before"
|
||||
}
|
||||
|
||||
def _handle_world_info_after(self, component: PromptComponent) -> Dict[str, Any]:
|
||||
"""
|
||||
处理后置世界信息组件
|
||||
|
||||
参数:
|
||||
component: 世界信息组件
|
||||
|
||||
返回:
|
||||
Dict[str, Any]: 处理后的消息
|
||||
"""
|
||||
# 这里应该从外部获取实际的世界信息
|
||||
return {
|
||||
"role": "system",
|
||||
"content": "后置世界信息...",
|
||||
"marker": True,
|
||||
"type": "world_info_after"
|
||||
}
|
||||
|
||||
|
||||
def _handle_char_description(self, component: PromptComponent) -> Dict[str, Any]:
|
||||
"""
|
||||
处理角色描述组件
|
||||
|
||||
参数:
|
||||
component: 角色描述组件
|
||||
|
||||
返回:
|
||||
Dict[str, Any]: 处理后的消息
|
||||
"""
|
||||
# 这里应该从外部获取实际的角色描述
|
||||
return {
|
||||
"role": "system",
|
||||
"content": "角色描述内容...",
|
||||
"marker": True,
|
||||
"type": "char_description"
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import re
|
||||
from core.node_base import BaseNode
|
||||
from typing import List, Dict, Any
|
||||
|
||||
|
||||
class TextSplitterNode(BaseNode):
|
||||
name = "文本分割节点"
|
||||
inputs = {"text": "string"}
|
||||
outputs = {
|
||||
"outline": "list", # 大纲部分列表
|
||||
"requirement": "list", # 要求部分列表
|
||||
"dialogue": "list", # 对话部分列表
|
||||
"weak_guidance": "list" # 弱指引部分列表
|
||||
}
|
||||
|
||||
async def run(self, text: str) -> Dict[str, List[str]]:
|
||||
# 正则匹配三种括号内的内容
|
||||
# 注意:此正则假设括号不嵌套,且没有转义字符
|
||||
pattern = r'\{([^{}]*)\}|\(([^()]*)\)|“([^”]*)”'
|
||||
|
||||
outline = []
|
||||
requirement = []
|
||||
dialogue = []
|
||||
weak_guidance = []
|
||||
|
||||
pos = 0
|
||||
for match in re.finditer(pattern, text):
|
||||
start, end = match.span()
|
||||
# 处理匹配前的普通文本(弱指引)
|
||||
if start > pos:
|
||||
weak_part = text[pos:start].strip()
|
||||
if weak_part:
|
||||
weak_guidance.append(weak_part)
|
||||
|
||||
# 根据捕获组确定类型
|
||||
if match.group(1) is not None: # 大括号
|
||||
outline.append(match.group(1).strip())
|
||||
elif match.group(2) is not None: # 小括号
|
||||
requirement.append(match.group(2).strip())
|
||||
elif match.group(3) is not None: # 中文引号
|
||||
dialogue.append(match.group(3).strip())
|
||||
|
||||
pos = end
|
||||
|
||||
# 处理剩余的普通文本
|
||||
if pos < len(text):
|
||||
weak_part = text[pos:].strip()
|
||||
if weak_part:
|
||||
weak_guidance.append(weak_part)
|
||||
|
||||
return {
|
||||
"outline": outline,
|
||||
"requirement": requirement,
|
||||
"dialogue": dialogue,
|
||||
"weak_guidance": weak_guidance
|
||||
}
|
||||
@@ -1,3 +1,13 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
python-multipart==0.0.6
|
||||
cryptography>=41.0.0
|
||||
requests>=2.31.0
|
||||
|
||||
# LangChain for LLM integration (让 pip 自动解析兼容版本)
|
||||
langchain>=0.1.0
|
||||
langchain-core>=0.1.0
|
||||
langchain-openai>=0.0.5
|
||||
langchain-anthropic>=0.1.1
|
||||
openai>=1.12.0
|
||||
anthropic>=0.23.0
|
||||
9
backend/services/__init__.py
Normal file
9
backend/services/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
业务服务层
|
||||
|
||||
包含项目的核心业务逻辑,协调 Models、Utils 和 LLM 组件。
|
||||
"""
|
||||
# 注意:不在这里自动导入模块,避免循环依赖和缺失依赖问题
|
||||
# 需要使用时请显式导入,例如:from services.preset_service import PresetService
|
||||
|
||||
__all__ = []
|
||||
167
backend/services/character_card_converter.py
Normal file
167
backend/services/character_card_converter.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
角色卡格式转换器
|
||||
支持 SillyTavern V2/V3 格式与内部格式的双向转换
|
||||
"""
|
||||
import json
|
||||
import base64
|
||||
from typing import Optional, Dict, Any
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
try:
|
||||
from backend.models.internal import CharacterCard
|
||||
except ImportError:
|
||||
from models.internal import CharacterCard
|
||||
|
||||
|
||||
class CharacterCardConverter:
|
||||
"""角色卡格式转换器"""
|
||||
|
||||
@staticmethod
|
||||
def st_to_internal(st_data: dict, avatar_path: Optional[str] = None) -> CharacterCard:
|
||||
"""
|
||||
SillyTavern 格式 → 内部格式
|
||||
|
||||
Args:
|
||||
st_data: SillyTavern 角色卡数据(V2/V3)
|
||||
avatar_path: 头像路径(可选)
|
||||
|
||||
Returns:
|
||||
CharacterCard 对象
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
# 兼容两种传入方式:完整ST格式或直接data
|
||||
if 'spec' in st_data:
|
||||
data = st_data.get('data', {})
|
||||
else:
|
||||
data = st_data
|
||||
|
||||
extensions = data.get('extensions', {})
|
||||
|
||||
return CharacterCard(
|
||||
id=str(uuid.uuid4()),
|
||||
name=data['name'],
|
||||
description=data.get('description', ''),
|
||||
personality=data.get('personality', ''),
|
||||
scenario=data.get('scenario', ''),
|
||||
first_mes=data.get('first_mes', ''),
|
||||
mes_example=data.get('mes_example', ''),
|
||||
categories=[], # ST没有categories
|
||||
tableHeaders=[], # ST没有tableHeaders
|
||||
worldInfoId=extensions.get('world'),
|
||||
outputSchema=None, # ST不支持结构化输出
|
||||
avatarPath=avatar_path,
|
||||
alternate_greetings=data.get('alternate_greetings', []),
|
||||
tags=data.get('tags', []),
|
||||
createdAt=int(datetime.now().timestamp()),
|
||||
updatedAt=int(datetime.now().timestamp()),
|
||||
lastChatAt=None,
|
||||
isFavorite=extensions.get('fav', False),
|
||||
version=1
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def internal_to_st(character: CharacterCard) -> dict:
|
||||
"""
|
||||
内部格式 → SillyTavern V3 格式
|
||||
|
||||
Args:
|
||||
character: CharacterCard 对象
|
||||
|
||||
Returns:
|
||||
SillyTavern V3 格式字典
|
||||
"""
|
||||
return {
|
||||
"spec": "chara_card_v3",
|
||||
"spec_version": "3.0",
|
||||
"data": {
|
||||
"name": character.name,
|
||||
"description": character.description,
|
||||
"personality": character.personality,
|
||||
"scenario": character.scenario,
|
||||
"first_mes": character.first_mes,
|
||||
"mes_example": character.mes_example,
|
||||
"alternate_greetings": character.alternate_greetings or [],
|
||||
"tags": character.tags or [],
|
||||
"creator_notes": "",
|
||||
"system_prompt": "",
|
||||
"post_history_instructions": "",
|
||||
"extensions": {
|
||||
"world": character.worldInfoId,
|
||||
"talkativeness": 0.5,
|
||||
"fav": character.isFavorite
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def export_as_png(character: CharacterCard, avatar_path: Optional[str] = None, use_default_avatar: bool = False) -> bytes:
|
||||
"""
|
||||
导出为 SillyTavern PNG 格式
|
||||
|
||||
Args:
|
||||
character: CharacterCard 对象
|
||||
avatar_path: 头像图片路径(可选)
|
||||
use_default_avatar: 是否使用默认头像(不嵌入JSON数据)
|
||||
|
||||
Returns:
|
||||
PNG 文件的二进制数据
|
||||
"""
|
||||
# 1. 创建/加载图片
|
||||
if avatar_path and Path(avatar_path).exists():
|
||||
img = Image.open(avatar_path)
|
||||
else:
|
||||
# 创建默认图片(400x600像素,灰色背景)
|
||||
img = Image.new('RGB', (400, 600), color=(73, 109, 137))
|
||||
|
||||
# 确保是 RGBA 模式
|
||||
if img.mode != 'RGBA':
|
||||
img = img.convert('RGBA')
|
||||
|
||||
# 2. 如果不是默认头像,才嵌入 JSON 数据
|
||||
if not use_default_avatar:
|
||||
st_data = CharacterCardConverter.internal_to_st(character)
|
||||
json_str = json.dumps(st_data, ensure_ascii=False)
|
||||
base64_data = base64.b64encode(json_str.encode('utf-8')).decode('ascii')
|
||||
img.text['ccv3'] = base64_data
|
||||
|
||||
# 3. 保存到字节流
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format='PNG')
|
||||
buffer.seek(0)
|
||||
|
||||
return buffer.read()
|
||||
|
||||
@staticmethod
|
||||
def extract_from_png(png_data: bytes) -> Optional[dict]:
|
||||
"""
|
||||
从 PNG 文件中提取嵌入的角色数据
|
||||
|
||||
Args:
|
||||
png_data: PNG 文件的二进制数据
|
||||
|
||||
Returns:
|
||||
SillyTavern 格式字典,如果没有嵌入数据则返回 None
|
||||
"""
|
||||
try:
|
||||
img = Image.open(io.BytesIO(png_data))
|
||||
|
||||
# 尝试 V3 格式 (ccv3)
|
||||
if 'ccv3' in img.text:
|
||||
json_str = base64.b64decode(img.text['ccv3']).decode('utf-8')
|
||||
return json.loads(json_str)
|
||||
|
||||
# 尝试 V2 格式 (chara)
|
||||
elif 'chara' in img.text:
|
||||
json_str = base64.b64decode(img.text['chara']).decode('utf-8')
|
||||
return json.loads(json_str)
|
||||
|
||||
# 没有嵌入数据
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"解析PNG失败: {e}")
|
||||
return None
|
||||
345
backend/services/character_service.py
Normal file
345
backend/services/character_service.py
Normal file
@@ -0,0 +1,345 @@
|
||||
"""
|
||||
角色卡服务 - 严格按照 internal.py 的数据结构
|
||||
每个角色一个文件夹,包含 character.json、avatar.png 和 chats/
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
try:
|
||||
from backend.models.internal import CharacterCard
|
||||
from backend.core.config import settings
|
||||
from backend.services.character_card_converter import CharacterCardConverter
|
||||
except ImportError:
|
||||
from models.internal import CharacterCard
|
||||
from core.config import settings
|
||||
from services.character_card_converter import CharacterCardConverter
|
||||
|
||||
|
||||
class CharacterService:
|
||||
"""角色卡管理服务"""
|
||||
|
||||
def __init__(self):
|
||||
self.characters_dir = settings.CHARACTERS_PATH
|
||||
self.converter = CharacterCardConverter()
|
||||
|
||||
# 确保目录存在
|
||||
self.characters_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def scan_all_characters(self) -> List[CharacterCard]:
|
||||
"""
|
||||
扫描所有角色卡
|
||||
|
||||
Returns:
|
||||
按 lastChatAt 排序的角色卡列表(最新的在前)
|
||||
"""
|
||||
characters = []
|
||||
|
||||
for char_folder in self.characters_dir.iterdir():
|
||||
if not char_folder.is_dir():
|
||||
continue
|
||||
|
||||
try:
|
||||
character = self._load_character_from_folder(char_folder)
|
||||
if character:
|
||||
characters.append(character)
|
||||
except Exception as e:
|
||||
print(f"加载角色卡失败 {char_folder.name}: {e}")
|
||||
continue
|
||||
|
||||
# 按最后聊天时间排序(None 排最后)
|
||||
characters.sort(
|
||||
key=lambda c: c.lastChatAt or 0,
|
||||
reverse=True
|
||||
)
|
||||
|
||||
return characters
|
||||
|
||||
def _load_character_from_folder(self, folder: Path) -> Optional[CharacterCard]:
|
||||
"""
|
||||
从文件夹加载角色卡
|
||||
|
||||
Args:
|
||||
folder: 角色文件夹路径
|
||||
|
||||
Returns:
|
||||
CharacterCard 对象或 None
|
||||
"""
|
||||
# 1. 读取 character.json(必须存在)
|
||||
char_file = folder / "character.json"
|
||||
if not char_file.exists():
|
||||
return None
|
||||
|
||||
with open(char_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# 2. 检查是否有 avatar.png
|
||||
avatar_path = None
|
||||
avatar_file = folder / "avatar.png"
|
||||
if avatar_file.exists():
|
||||
# 存储相对路径,用于前端访问
|
||||
avatar_path = f"/api/characters/{folder.name}/avatar"
|
||||
|
||||
# 3. 计算最后聊天时间
|
||||
last_chat_at = self._get_last_chat_timestamp(folder)
|
||||
|
||||
# 4. 构建 CharacterCard 对象(严格按照数据结构)
|
||||
character = CharacterCard(
|
||||
id=data.get('id', str(uuid.uuid4())),
|
||||
name=data['name'],
|
||||
description=data.get('description', ''),
|
||||
personality=data.get('personality', ''),
|
||||
scenario=data.get('scenario', ''),
|
||||
first_mes=data.get('first_mes', ''),
|
||||
mes_example=data.get('mes_example', ''),
|
||||
categories=data.get('categories', []),
|
||||
tags=data.get('tags', []), # ✅ 使用标签数组
|
||||
worldInfoId=data.get('worldInfoId'),
|
||||
outputSchema=data.get('outputSchema'),
|
||||
avatarPath=avatar_path,
|
||||
alternate_greetings=data.get('alternate_greetings', []),
|
||||
tableMaintenancePrompt=data.get('tableMaintenancePrompt'),
|
||||
imageGenerationPrompt=data.get('imageGenerationPrompt'),
|
||||
tableHeaders=data.get('tableHeaders'), # ✅ 动态表格表头
|
||||
tableDefaults=data.get('tableDefaults'), # ✅ 动态表格默认值
|
||||
createdAt=data.get('createdAt', int(datetime.now().timestamp())),
|
||||
updatedAt=data.get('updatedAt', int(datetime.now().timestamp())),
|
||||
lastChatAt=last_chat_at,
|
||||
isFavorite=data.get('isFavorite', False),
|
||||
version=data.get('version', 1)
|
||||
)
|
||||
|
||||
return character
|
||||
|
||||
def _get_last_chat_timestamp(self, char_folder: Path) -> Optional[int]:
|
||||
"""
|
||||
获取角色的最后聊天时间戳
|
||||
|
||||
通过扫描 chats 目录下所有 .jsonl 文件的修改时间
|
||||
"""
|
||||
chats_dir = char_folder / "chats"
|
||||
if not chats_dir.exists():
|
||||
return None
|
||||
|
||||
latest_time = None
|
||||
|
||||
for chat_file in chats_dir.glob("*.jsonl"):
|
||||
file_mtime = int(chat_file.stat().st_mtime)
|
||||
if latest_time is None or file_mtime > latest_time:
|
||||
latest_time = file_mtime
|
||||
|
||||
return latest_time
|
||||
|
||||
def get_character_by_name(self, name: str) -> Optional[CharacterCard]:
|
||||
"""根据角色名获取角色卡"""
|
||||
char_folder = self.characters_dir / name
|
||||
if not char_folder.exists():
|
||||
return None
|
||||
|
||||
return self._load_character_from_folder(char_folder)
|
||||
|
||||
def create_character(self, character_data: dict) -> CharacterCard:
|
||||
"""
|
||||
创建新角色卡
|
||||
|
||||
Args:
|
||||
character_data: 角色数据字典
|
||||
|
||||
Returns:
|
||||
创建的 CharacterCard 对象
|
||||
"""
|
||||
# 生成唯一ID
|
||||
if 'id' not in character_data:
|
||||
character_data['id'] = str(uuid.uuid4())
|
||||
|
||||
# 设置时间戳
|
||||
now = int(datetime.now().timestamp())
|
||||
character_data['createdAt'] = now
|
||||
character_data['updatedAt'] = now
|
||||
character_data['lastChatAt'] = None
|
||||
|
||||
# 创建文件夹
|
||||
char_name = character_data['name']
|
||||
char_folder = self.characters_dir / char_name
|
||||
char_folder.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 创建 chats 目录
|
||||
chats_dir = char_folder / "chats"
|
||||
chats_dir.mkdir(exist_ok=True)
|
||||
|
||||
# 保存 character.json
|
||||
char_file = char_folder / "character.json"
|
||||
with open(char_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(character_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
return self._load_character_from_folder(char_folder)
|
||||
|
||||
def update_character(self, name: str, updates: dict) -> CharacterCard:
|
||||
"""
|
||||
更新角色卡
|
||||
|
||||
Args:
|
||||
name: 角色名(旧名称,用于定位文件夹)
|
||||
updates: 更新的字段(可以包含 name 字段来重命名)
|
||||
|
||||
Returns:
|
||||
更新后的 CharacterCard 对象
|
||||
"""
|
||||
char_folder = self.characters_dir / name
|
||||
char_file = char_folder / "character.json"
|
||||
|
||||
if not char_file.exists():
|
||||
raise FileNotFoundError(f"角色卡不存在: {name}")
|
||||
|
||||
# 读取现有数据
|
||||
with open(char_file, 'r', encoding='utf-8') as f:
|
||||
existing_data = json.load(f)
|
||||
|
||||
# 检查是否需要重命名
|
||||
new_name = updates.get('name')
|
||||
needs_rename = new_name and new_name != name
|
||||
|
||||
if needs_rename:
|
||||
# 验证新名称是否合法
|
||||
if not new_name or new_name.strip() == '':
|
||||
raise ValueError("角色名不能为空")
|
||||
|
||||
# 检查新名称是否已存在
|
||||
new_folder = self.characters_dir / new_name
|
||||
if new_folder.exists():
|
||||
raise FileExistsError(f"角色 '{new_name}' 已存在")
|
||||
|
||||
# 重命名文件夹
|
||||
try:
|
||||
import shutil
|
||||
shutil.move(str(char_folder), str(new_folder))
|
||||
char_folder = new_folder
|
||||
char_file = char_folder / "character.json"
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"重命名文件夹失败: {str(e)}")
|
||||
|
||||
# 合并更新
|
||||
existing_data.update(updates)
|
||||
existing_data['updatedAt'] = int(datetime.now().timestamp())
|
||||
|
||||
# 保存
|
||||
with open(char_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(existing_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
return self._load_character_from_folder(char_folder)
|
||||
|
||||
def delete_character(self, name: str) -> bool:
|
||||
"""
|
||||
删除角色卡(包括所有聊天记录)
|
||||
|
||||
Args:
|
||||
name: 角色名
|
||||
|
||||
Returns:
|
||||
是否成功删除
|
||||
"""
|
||||
char_folder = self.characters_dir / name
|
||||
if not char_folder.exists():
|
||||
return False
|
||||
|
||||
import shutil
|
||||
shutil.rmtree(char_folder)
|
||||
return True
|
||||
|
||||
def save_avatar(self, name: str, image_data: bytes) -> str:
|
||||
"""
|
||||
保存角色头像
|
||||
|
||||
Args:
|
||||
name: 角色名
|
||||
image_data: 图片二进制数据
|
||||
|
||||
Returns:
|
||||
头像访问路径
|
||||
"""
|
||||
char_folder = self.characters_dir / name
|
||||
avatar_file = char_folder / "avatar.png"
|
||||
|
||||
with open(avatar_file, 'wb') as f:
|
||||
f.write(image_data)
|
||||
|
||||
return f"/api/characters/{name}/avatar"
|
||||
|
||||
def import_from_png(self, png_data: bytes, filename: str) -> CharacterCard:
|
||||
"""
|
||||
从 SillyTavern PNG 导入角色卡
|
||||
|
||||
Args:
|
||||
png_data: PNG 文件二进制数据
|
||||
filename: 原始文件名
|
||||
|
||||
Returns:
|
||||
创建的 CharacterCard 对象
|
||||
"""
|
||||
# 1. 提取嵌入数据
|
||||
st_data = self.converter.extract_from_png(png_data)
|
||||
if not st_data:
|
||||
raise ValueError("PNG文件中没有嵌入角色数据")
|
||||
|
||||
# 2. 转换为内部格式
|
||||
character = self.converter.st_to_internal(st_data)
|
||||
|
||||
# 3. 创建角色文件夹
|
||||
char_name = character.name
|
||||
char_folder = self.characters_dir / char_name
|
||||
char_folder.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 4. 保存 PNG 作为 avatar.png
|
||||
avatar_file = char_folder / "avatar.png"
|
||||
with open(avatar_file, 'wb') as f:
|
||||
f.write(png_data)
|
||||
|
||||
# 5. 保存 character.json
|
||||
char_file = char_folder / "character.json"
|
||||
with open(char_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(character.dict(), f, ensure_ascii=False, indent=2)
|
||||
|
||||
# 6. 创建 chats 目录
|
||||
(char_folder / "chats").mkdir(exist_ok=True)
|
||||
|
||||
return character
|
||||
|
||||
def export_as_png(self, name: str) -> bytes:
|
||||
"""
|
||||
导出角色为 SillyTavern PNG 格式
|
||||
|
||||
Args:
|
||||
name: 角色名
|
||||
|
||||
Returns:
|
||||
PNG 文件二进制数据
|
||||
"""
|
||||
character = self.get_character_by_name(name)
|
||||
if not character:
|
||||
raise FileNotFoundError(f"角色 '{name}' 不存在")
|
||||
|
||||
# 获取头像路径
|
||||
avatar_path = None
|
||||
if character.avatarPath:
|
||||
# 从路径中提取文件名
|
||||
avatar_filename = character.avatarPath.split('/')[-1].split('?')[0]
|
||||
char_folder = self.characters_dir / name
|
||||
avatar_file = char_folder / avatar_filename
|
||||
if avatar_file.exists():
|
||||
avatar_path = str(avatar_file)
|
||||
|
||||
# 如果没有头像,使用默认图片
|
||||
use_default = False
|
||||
if not avatar_path:
|
||||
default_avatar = self.characters_dir / "defult.png"
|
||||
if default_avatar.exists():
|
||||
avatar_path = str(default_avatar)
|
||||
use_default = True
|
||||
print(f"使用默认头像: {avatar_path}")
|
||||
else:
|
||||
print("警告: 没有找到默认头像")
|
||||
|
||||
# 生成 PNG
|
||||
return self.converter.export_as_png(character, avatar_path, use_default_avatar=use_default)
|
||||
659
backend/services/chat_service.py
Normal file
659
backend/services/chat_service.py
Normal file
@@ -0,0 +1,659 @@
|
||||
"""
|
||||
聊天服务 - 处理聊天记录的读写操作
|
||||
|
||||
基于 SillyTavern JSONL 格式的聊天记录管理
|
||||
"""
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ChatService:
|
||||
"""聊天服务类,处理聊天记录的CRUD操作"""
|
||||
|
||||
def __init__(self, data_path: Path):
|
||||
"""
|
||||
初始化聊天服务
|
||||
|
||||
Args:
|
||||
data_path: 数据目录路径
|
||||
"""
|
||||
self.data_path = data_path
|
||||
self.chat_dir = data_path / "chat"
|
||||
self.chat_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def list_all_chats(self) -> Dict[str, List[Dict]]:
|
||||
"""
|
||||
获取所有角色和聊天列表
|
||||
|
||||
Returns:
|
||||
Dict[str, List[Dict]]: 字典结构,键是角色名称,值是该角色的聊天信息列表
|
||||
"""
|
||||
result = {}
|
||||
|
||||
if not self.chat_dir.exists():
|
||||
logger.warning(f"聊天目录不存在: {self.chat_dir}")
|
||||
return result
|
||||
|
||||
for role_dir in self.chat_dir.iterdir():
|
||||
try:
|
||||
if role_dir.is_dir():
|
||||
chats = []
|
||||
|
||||
for chat_file in role_dir.glob("*.jsonl"):
|
||||
chat_info = self._get_chat_summary(role_dir.name, chat_file.stem)
|
||||
if chat_info:
|
||||
chats.append(chat_info)
|
||||
|
||||
if chats:
|
||||
result[role_dir.name] = chats
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理角色目录 {role_dir.name} 时出错: {str(e)}")
|
||||
continue
|
||||
|
||||
return result
|
||||
|
||||
def _get_chat_summary(self, role_name: str, chat_name: str) -> Optional[Dict]:
|
||||
"""
|
||||
获取聊天摘要信息
|
||||
|
||||
Args:
|
||||
role_name: 角色名称
|
||||
chat_name: 聊天名称
|
||||
|
||||
Returns:
|
||||
Dict: 聊天摘要信息,如果文件不存在则返回None
|
||||
"""
|
||||
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
|
||||
|
||||
if not chat_file.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(chat_file, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
if not lines:
|
||||
return None
|
||||
|
||||
# 第一行是header
|
||||
header = json.loads(lines[0])
|
||||
|
||||
# 计算消息数量(排除header)
|
||||
message_count = len(lines) - 1
|
||||
|
||||
# 获取最后修改时间
|
||||
last_modified = datetime.fromtimestamp(
|
||||
chat_file.stat().st_mtime
|
||||
).isoformat()
|
||||
|
||||
# 获取最后一条消息预览
|
||||
last_message = ""
|
||||
if message_count > 0:
|
||||
try:
|
||||
last_msg_data = json.loads(lines[-1])
|
||||
last_message = last_msg_data.get("mes", "")
|
||||
except:
|
||||
pass
|
||||
|
||||
return {
|
||||
"chat_name": chat_name,
|
||||
"user_name": header.get("user_name", "User"),
|
||||
"character_name": header.get("character_name", ""),
|
||||
"last_modified": last_modified,
|
||||
"message_count": message_count,
|
||||
"last_message": last_message
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"读取聊天摘要失败 {role_name}/{chat_name}: {str(e)}")
|
||||
return None
|
||||
|
||||
def get_chat(self, role_name: str, chat_name: str) -> Dict[str, Any]:
|
||||
"""
|
||||
获取指定聊天的完整内容
|
||||
|
||||
Args:
|
||||
role_name: 角色名称
|
||||
chat_name: 聊天名称
|
||||
|
||||
Returns:
|
||||
Dict: 包含metadata和messages的字典
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: 聊天文件不存在
|
||||
"""
|
||||
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
|
||||
|
||||
if not chat_file.exists():
|
||||
raise FileNotFoundError(f"Chat not found: {role_name}/{chat_name}")
|
||||
|
||||
try:
|
||||
with open(chat_file, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
if not lines:
|
||||
raise ValueError(f"Empty chat file: {role_name}/{chat_name}")
|
||||
|
||||
# 第一行是header
|
||||
header = json.loads(lines[0])
|
||||
|
||||
# 解析消息
|
||||
messages = []
|
||||
for i, line in enumerate(lines[1:], start=1):
|
||||
if line.strip(): # 跳过空行
|
||||
msg_data = json.loads(line)
|
||||
# 确保有floor字段
|
||||
if "floor" not in msg_data:
|
||||
msg_data["floor"] = i
|
||||
|
||||
messages.append(msg_data)
|
||||
|
||||
return {
|
||||
"header": header, # 完整的 header,包含 tableHeaders, tableDefaults, tableData
|
||||
"metadata": {
|
||||
"user_name": header.get("user_name", "User"),
|
||||
"character_name": header.get("character_name", ""),
|
||||
"chat_id": header.get("chat_id_hash", ""),
|
||||
"integrity": header.get("integrity", "")
|
||||
},
|
||||
"messages": messages
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"读取聊天失败 {role_name}/{chat_name}: {str(e)}")
|
||||
raise
|
||||
|
||||
def get_message(self, role_name: str, chat_name: str, floor: int) -> Dict:
|
||||
"""
|
||||
获取指定楼层的消息
|
||||
|
||||
Args:
|
||||
role_name: 角色名称
|
||||
chat_name: 聊天名称
|
||||
floor: 楼层号
|
||||
|
||||
Returns:
|
||||
Dict: 消息数据,如果不存在则返回 None
|
||||
"""
|
||||
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
|
||||
|
||||
if not chat_file.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(chat_file, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# 找到对应的消息行(floor + 1,因为第0行是header)
|
||||
message_line_index = floor + 1
|
||||
|
||||
if message_line_index >= len(lines):
|
||||
return None
|
||||
|
||||
# 解析并返回消息
|
||||
msg_data = json.loads(lines[message_line_index])
|
||||
return msg_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取消息失败 {role_name}/{chat_name}/{floor}: {str(e)}")
|
||||
return None
|
||||
|
||||
def create_chat(self, role_name: str, chat_name: str, metadata: Dict = None) -> Dict:
|
||||
"""
|
||||
创建新聊天
|
||||
|
||||
Args:
|
||||
role_name: 角色名称
|
||||
chat_name: 聊天名称
|
||||
metadata: 聊天元数据
|
||||
|
||||
Returns:
|
||||
Dict: 创建的聊天信息
|
||||
|
||||
Raises:
|
||||
FileExistsError: 聊天已存在
|
||||
"""
|
||||
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
|
||||
|
||||
if chat_file.exists():
|
||||
raise FileExistsError(f"Chat already exists: {role_name}/{chat_name}")
|
||||
|
||||
# 创建角色目录
|
||||
chat_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 尝试从角色卡获取 tags(关键字列表)
|
||||
tags = []
|
||||
|
||||
try:
|
||||
character_file = settings.CHARACTERS_PATH / role_name / "character.json"
|
||||
if character_file.exists():
|
||||
with open(character_file, 'r', encoding='utf-8') as f:
|
||||
character_data = json.load(f)
|
||||
|
||||
tags = character_data.get('tags', [])
|
||||
|
||||
logger.info(f"从角色卡 {role_name} 继承标签: {tags}")
|
||||
except Exception as e:
|
||||
logger.warning(f"读取角色卡失败,使用空标签: {e}")
|
||||
|
||||
# 构建header
|
||||
header = {
|
||||
"user_name": metadata.get("user_name", "User") if metadata else "User",
|
||||
"character_name": metadata.get("character_name", role_name) if metadata else role_name,
|
||||
"integrity": str(uuid.uuid4()),
|
||||
"chat_id_hash": str(uuid.uuid4()),
|
||||
"note_prompt": "",
|
||||
"note_interval": 0,
|
||||
"note_position": 0,
|
||||
"note_depth": 0,
|
||||
"note_role": 0,
|
||||
"extensions": {},
|
||||
"timedWorldInfo": {},
|
||||
"variables": {},
|
||||
"tainted": False,
|
||||
"lastInContextMessageId": -1,
|
||||
"tags": tags # ✅ 使用标签数组替代 tableHeaders/tableDefaults/tableData
|
||||
}
|
||||
|
||||
# 写入header
|
||||
with open(chat_file, 'w', encoding='utf-8') as f:
|
||||
f.write(json.dumps(header, ensure_ascii=False) + '\n')
|
||||
|
||||
return {
|
||||
"role_name": role_name,
|
||||
"chat_name": chat_name,
|
||||
"metadata": {
|
||||
"user_name": header["user_name"],
|
||||
"character_name": header["character_name"]
|
||||
}
|
||||
}
|
||||
|
||||
def add_message(self, role_name: str, chat_name: str, message_data: Dict) -> Dict:
|
||||
"""
|
||||
向聊天添加新消息
|
||||
|
||||
Args:
|
||||
role_name: 角色名称
|
||||
chat_name: 聊天名称
|
||||
message_data: 消息数据
|
||||
|
||||
Returns:
|
||||
Dict: 添加的消息
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: 聊天不存在
|
||||
"""
|
||||
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
|
||||
|
||||
if not chat_file.exists():
|
||||
raise FileNotFoundError(f"Chat not found: {role_name}/{chat_name}")
|
||||
|
||||
try:
|
||||
# 读取现有消息以确定floor
|
||||
with open(chat_file, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# 计算下一个floor号
|
||||
next_floor = len(lines) - 1 # 减去header行
|
||||
|
||||
# 构建完整的消息数据
|
||||
full_message = {
|
||||
"name": message_data.get("name", "User"),
|
||||
"is_user": message_data.get("is_user", True),
|
||||
"is_system": message_data.get("is_system", False),
|
||||
"floor": next_floor,
|
||||
"send_date": message_data.get("send_date", str(int(datetime.now().timestamp() * 1000))),
|
||||
"mes": message_data.get("mes", ""),
|
||||
"extra": message_data.get("extra", {}),
|
||||
"swipes": message_data.get("swipes", []),
|
||||
"swipe_id": message_data.get("swipe_id", 0),
|
||||
"force_avatar": None,
|
||||
"variables": [],
|
||||
"variables_initialized": [],
|
||||
"is_ejs_processed": []
|
||||
}
|
||||
|
||||
# 追加消息到文件
|
||||
with open(chat_file, 'a', encoding='utf-8') as f:
|
||||
f.write(json.dumps(full_message, ensure_ascii=False) + '\n')
|
||||
|
||||
return full_message
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"添加消息失败 {role_name}/{chat_name}: {str(e)}")
|
||||
raise
|
||||
|
||||
def update_message(self, role_name: str, chat_name: str, floor: int, update_data: Dict) -> Dict:
|
||||
"""
|
||||
更新指定楼层的消息
|
||||
|
||||
Args:
|
||||
role_name: 角色名称
|
||||
chat_name: 聊天名称
|
||||
floor: 楼层号
|
||||
update_data: 更新的数据
|
||||
|
||||
Returns:
|
||||
Dict: 更新后的消息
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: 聊天不存在
|
||||
ValueError: 楼层不存在
|
||||
"""
|
||||
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
|
||||
|
||||
if not chat_file.exists():
|
||||
raise FileNotFoundError(f"Chat not found: {role_name}/{chat_name}")
|
||||
|
||||
try:
|
||||
# 读取所有行
|
||||
with open(chat_file, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# 找到对应的消息行(floor + 1,因为第0行是header)
|
||||
message_line_index = floor + 1
|
||||
|
||||
if message_line_index >= len(lines):
|
||||
raise ValueError(f"Floor {floor} not found in chat")
|
||||
|
||||
# 解析并更新消息
|
||||
msg_data = json.loads(lines[message_line_index])
|
||||
msg_data.update(update_data)
|
||||
|
||||
# 写回文件
|
||||
lines[message_line_index] = json.dumps(msg_data, ensure_ascii=False) + '\n'
|
||||
|
||||
with open(chat_file, 'w', encoding='utf-8') as f:
|
||||
f.writelines(lines)
|
||||
|
||||
return msg_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"更新消息失败 {role_name}/{chat_name}/{floor}: {str(e)}")
|
||||
raise
|
||||
|
||||
def delete_message(self, role_name: str, chat_name: str, floor: int) -> Dict:
|
||||
"""
|
||||
删除指定楼层的消息
|
||||
|
||||
Args:
|
||||
role_name: 角色名称
|
||||
chat_name: 聊天名称
|
||||
floor: 楼层号
|
||||
|
||||
Returns:
|
||||
Dict: 被删除的消息
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: 聊天不存在
|
||||
ValueError: 楼层不存在
|
||||
"""
|
||||
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
|
||||
|
||||
if not chat_file.exists():
|
||||
raise FileNotFoundError(f"Chat not found: {role_name}/{chat_name}")
|
||||
|
||||
try:
|
||||
# 读取所有行
|
||||
with open(chat_file, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# 找到对应的消息行
|
||||
message_line_index = floor + 1
|
||||
|
||||
if message_line_index >= len(lines):
|
||||
raise ValueError(f"Floor {floor} not found in chat")
|
||||
|
||||
# 保存被删除的消息
|
||||
deleted_msg = json.loads(lines[message_line_index])
|
||||
|
||||
# 删除该行
|
||||
del lines[message_line_index]
|
||||
|
||||
# 重新编号后续消息的floor
|
||||
for i in range(message_line_index, len(lines)):
|
||||
if lines[i].strip(): # 跳过空行
|
||||
msg_data = json.loads(lines[i])
|
||||
msg_data["floor"] = i - 1 # 重新计算floor
|
||||
lines[i] = json.dumps(msg_data, ensure_ascii=False) + '\n'
|
||||
|
||||
# 写回文件
|
||||
with open(chat_file, 'w', encoding='utf-8') as f:
|
||||
f.writelines(lines)
|
||||
|
||||
return deleted_msg
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"删除消息失败 {role_name}/{chat_name}/{floor}: {str(e)}")
|
||||
raise
|
||||
|
||||
def update_table_data(self, role_name: str, chat_name: str, table_update: Dict) -> Dict:
|
||||
"""
|
||||
更新标签数据(SillyTavern 关键字机制)
|
||||
|
||||
Args:
|
||||
role_name: 角色名称
|
||||
chat_name: 聊天名称
|
||||
table_update: 包含 tags 数组的字典
|
||||
|
||||
Returns:
|
||||
Dict: 更新后的标签数据
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: 聊天文件不存在
|
||||
"""
|
||||
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
|
||||
|
||||
if not chat_file.exists():
|
||||
raise FileNotFoundError(f"Chat not found: {role_name}/{chat_name}")
|
||||
|
||||
try:
|
||||
with open(chat_file, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
if not lines:
|
||||
raise ValueError(f"Empty chat file: {role_name}/{chat_name}")
|
||||
|
||||
# 读取 header
|
||||
header = json.loads(lines[0])
|
||||
|
||||
# 获取新的标签数组
|
||||
new_tags = table_update.get('tags', [])
|
||||
|
||||
# 更新 header 中的 tags
|
||||
header['tags'] = new_tags
|
||||
|
||||
# 写回文件
|
||||
lines[0] = json.dumps(header, ensure_ascii=False) + '\n'
|
||||
|
||||
with open(chat_file, 'w', encoding='utf-8') as f:
|
||||
f.writelines(lines)
|
||||
|
||||
logger.info(f"标签数据已更新: {role_name}/{chat_name}, 标签数: {len(new_tags)}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"tags": new_tags,
|
||||
"tagCount": len(new_tags)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"更新标签数据失败 {role_name}/{chat_name}: {str(e)}")
|
||||
raise
|
||||
|
||||
def summarize_chat_messages(
|
||||
self,
|
||||
role_name: str,
|
||||
chat_name: str,
|
||||
start_floor: int,
|
||||
end_floor: int,
|
||||
summary_text: str
|
||||
) -> bool:
|
||||
"""
|
||||
总结聊天消息:清空原文,将总结放到最后一个楼层
|
||||
|
||||
Args:
|
||||
role_name: 角色名称
|
||||
chat_name: 聊天名称
|
||||
start_floor: 总结起始楼层(1-based)
|
||||
end_floor: 总结结束楼层(1-based)
|
||||
summary_text: 总结文本
|
||||
|
||||
Returns:
|
||||
bool: 是否成功
|
||||
"""
|
||||
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
|
||||
|
||||
if not chat_file.exists():
|
||||
raise FileNotFoundError(f"Chat not found: {role_name}/{chat_name}")
|
||||
|
||||
try:
|
||||
with open(chat_file, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
if not lines:
|
||||
raise ValueError(f"Empty chat file: {role_name}/{chat_name}")
|
||||
|
||||
# 转换为0-based索引
|
||||
start_idx = start_floor # header在第0行,所以L1在第1行
|
||||
end_idx = end_floor
|
||||
|
||||
# 验证范围
|
||||
if start_idx < 1 or end_idx >= len(lines) or start_idx > end_idx:
|
||||
raise ValueError(f"Invalid floor range: {start_floor}-{end_floor}")
|
||||
|
||||
# 处理消息
|
||||
for i in range(start_idx, end_idx + 1):
|
||||
msg_data = json.loads(lines[i])
|
||||
|
||||
if i < end_idx:
|
||||
# 中间楼层:清空内容
|
||||
msg_data['mes'] = ""
|
||||
msg_data['is_summarized'] = True
|
||||
else:
|
||||
# 最后一个楼层:放入总结文本
|
||||
msg_data['mes'] = summary_text
|
||||
msg_data['is_summary'] = True
|
||||
msg_data['summary_range'] = f"L{start_floor}-L{end_floor}"
|
||||
|
||||
lines[i] = json.dumps(msg_data, ensure_ascii=False) + '\n'
|
||||
|
||||
# 写回文件
|
||||
with open(chat_file, 'w', encoding='utf-8') as f:
|
||||
f.writelines(lines)
|
||||
|
||||
logger.info(
|
||||
f"[ChatService] 总结完成: {role_name}/{chat_name}, "
|
||||
f"楼层 {start_floor}-{end_floor}"
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"总结聊天消息失败 {role_name}/{chat_name}: {str(e)}")
|
||||
raise
|
||||
|
||||
def create_branch(
|
||||
self,
|
||||
role_name: str,
|
||||
chat_name: str,
|
||||
target_floor: int,
|
||||
new_chat_name: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
创建聊天分支
|
||||
|
||||
复制目标楼层及之前的所有内容到一个新的聊天记录
|
||||
|
||||
Args:
|
||||
role_name: 角色名称
|
||||
chat_name: 原聊天名称
|
||||
target_floor: 目标楼层(包含该楼层及之前的内容)
|
||||
new_chat_name: 新聊天名称(可选,默认自动生成)
|
||||
|
||||
Returns:
|
||||
Dict: {
|
||||
"success": bool,
|
||||
"new_chat_name": str,
|
||||
"message_count": int
|
||||
}
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: 聊天不存在
|
||||
ValueError: 楼层不存在
|
||||
"""
|
||||
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
|
||||
|
||||
if not chat_file.exists():
|
||||
raise FileNotFoundError(f"Chat not found: {role_name}/{chat_name}")
|
||||
|
||||
try:
|
||||
# 读取所有行
|
||||
with open(chat_file, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
if not lines:
|
||||
raise ValueError(f"Empty chat file: {role_name}/{chat_name}")
|
||||
|
||||
# 验证目标楼层
|
||||
# floor + 1 是因为第0行是header
|
||||
message_line_index = target_floor + 1
|
||||
if message_line_index >= len(lines):
|
||||
raise ValueError(f"Floor {target_floor} not found in chat (total messages: {len(lines) - 1})")
|
||||
|
||||
# 生成新聊天名称
|
||||
if not new_chat_name:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
new_chat_name = f"branch_{chat_name}_{timestamp}"
|
||||
|
||||
# 创建新聊天文件
|
||||
new_chat_file = self.chat_dir / role_name / f"{new_chat_name}.jsonl"
|
||||
|
||||
if new_chat_file.exists():
|
||||
raise FileExistsError(f"Branch chat already exists: {new_chat_name}")
|
||||
|
||||
# 复制 header 和目标楼层及之前的消息
|
||||
branch_lines = [lines[0]] # header
|
||||
for i in range(1, message_line_index + 1):
|
||||
msg_data = json.loads(lines[i])
|
||||
# 重新分配 floor(从0开始)
|
||||
msg_data["floor"] = i - 1
|
||||
branch_lines.append(json.dumps(msg_data, ensure_ascii=False) + '\n')
|
||||
|
||||
# 写入新文件
|
||||
with open(new_chat_file, 'w', encoding='utf-8') as f:
|
||||
f.writelines(branch_lines)
|
||||
|
||||
message_count = len(branch_lines) - 1 # 减去header
|
||||
|
||||
logger.info(
|
||||
f"[ChatService] 创建分支成功: {role_name}/{chat_name} -> {new_chat_name}, "
|
||||
f"楼层: 0-{target_floor}, 消息数: {message_count}"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"new_chat_name": new_chat_name,
|
||||
"message_count": message_count,
|
||||
"branched_from": chat_name,
|
||||
"target_floor": target_floor
|
||||
}
|
||||
|
||||
except (FileExistsError, ValueError):
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"创建分支失败 {role_name}/{chat_name}: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
# 全局实例
|
||||
chat_service = ChatService(Path(settings.DATA_PATH))
|
||||
213
backend/services/chat_summary_service.py
Normal file
213
backend/services/chat_summary_service.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""
|
||||
聊天总结服务
|
||||
|
||||
负责调用LLM对历史消息进行总结
|
||||
"""
|
||||
import json
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from models.internal import ChatMessage, SummaryConfig
|
||||
from core.config import settings
|
||||
|
||||
|
||||
class ChatSummaryService:
|
||||
"""聊天总结服务类"""
|
||||
|
||||
@staticmethod
|
||||
async def summarize_messages(
|
||||
messages: List[ChatMessage],
|
||||
start_floor: int,
|
||||
end_floor: int,
|
||||
summary_config: SummaryConfig,
|
||||
api_config: Dict[str, str]
|
||||
) -> str:
|
||||
"""
|
||||
对指定范围的消息进行总结
|
||||
|
||||
Args:
|
||||
messages: 完整的消息列表
|
||||
start_floor: 总结起始楼层(1-based)
|
||||
end_floor: 总结结束楼层(1-based)
|
||||
summary_config: 总结配置
|
||||
api_config: API配置 {api_url, api_key, model}
|
||||
|
||||
Returns:
|
||||
总结文本
|
||||
"""
|
||||
# 1. 提取需要总结的消息
|
||||
messages_to_summarize = ChatSummaryService._extract_messages(
|
||||
messages, start_floor, end_floor, summary_config.includeUserInput
|
||||
)
|
||||
|
||||
if not messages_to_summarize:
|
||||
return ""
|
||||
|
||||
# 2. 构建总结提示词
|
||||
prompt = ChatSummaryService._build_summary_prompt(
|
||||
messages_to_summarize, summary_config
|
||||
)
|
||||
|
||||
# 3. 调用LLM生成总结
|
||||
summary_text = await ChatSummaryService._call_llm_for_summary(
|
||||
prompt, api_config, summary_config.maxSummaryLength
|
||||
)
|
||||
|
||||
return summary_text
|
||||
|
||||
@staticmethod
|
||||
def _extract_messages(
|
||||
messages: List[ChatMessage],
|
||||
start_floor: int,
|
||||
end_floor: int,
|
||||
include_user_input: bool
|
||||
) -> List[ChatMessage]:
|
||||
"""
|
||||
提取需要总结的消息
|
||||
|
||||
✅ 根据用户需求:后端不筛选,全部输入给LLM
|
||||
|
||||
Args:
|
||||
messages: 完整消息列表
|
||||
start_floor: 起始楼层
|
||||
end_floor: 结束楼层
|
||||
include_user_input: 是否包含用户输入(此参数目前不使用,但保留以保持接口兼容)
|
||||
|
||||
Returns:
|
||||
需要总结的消息列表(全部消息,不做筛选)
|
||||
"""
|
||||
# 转换为0-based索引
|
||||
start_idx = start_floor - 1
|
||||
end_idx = end_floor - 1
|
||||
|
||||
# 提取范围内的所有消息(不筛选)
|
||||
return messages[start_idx:end_idx + 1]
|
||||
|
||||
@staticmethod
|
||||
def _build_summary_prompt(
|
||||
messages: List[ChatMessage],
|
||||
summary_config: SummaryConfig
|
||||
) -> str:
|
||||
"""
|
||||
构建总结提示词
|
||||
|
||||
Args:
|
||||
messages: 需要总结的消息列表
|
||||
summary_config: 总结配置
|
||||
|
||||
Returns:
|
||||
完整的提示词
|
||||
"""
|
||||
# 使用用户自定义的总结提示词,或默认提示词
|
||||
base_prompt = summary_config.summaryPrompt or (
|
||||
"请总结以下对话内容,保留关键信息和上下文。"
|
||||
"用简洁的语言概括主要事件、人物状态和重要细节。"
|
||||
)
|
||||
|
||||
# 构建对话内容
|
||||
conversation_text = "\n\n".join([
|
||||
f"{'用户' if msg.is_user else msg.name}: {msg.mes}"
|
||||
for msg in messages
|
||||
])
|
||||
|
||||
# 组合完整提示词
|
||||
full_prompt = f"""{base_prompt}
|
||||
|
||||
对话内容:
|
||||
{conversation_text}
|
||||
|
||||
总结要求:
|
||||
1. 保持简洁明了
|
||||
2. 保留关键情节和设定
|
||||
3. 不超过{summary_config.maxSummaryLength}字
|
||||
4. 使用客观叙述语气
|
||||
|
||||
总结:"""
|
||||
|
||||
return full_prompt
|
||||
|
||||
@staticmethod
|
||||
async def _call_llm_for_summary(
|
||||
prompt: str,
|
||||
api_config: Dict[str, str],
|
||||
max_length: int
|
||||
) -> str:
|
||||
"""
|
||||
调用LLM生成总结
|
||||
|
||||
Args:
|
||||
prompt: 总结提示词
|
||||
api_config: API配置
|
||||
max_length: 最大长度限制
|
||||
|
||||
Returns:
|
||||
总结文本
|
||||
"""
|
||||
try:
|
||||
# 导入LLM客户端
|
||||
from utils.llm_client import llm_client
|
||||
|
||||
# 构建消息
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "你是一个专业的对话总结助手,擅长提取关键信息并用简洁的语言概括。"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt
|
||||
}
|
||||
]
|
||||
|
||||
# 调用LLM
|
||||
response = await llm_client.chat_completion(
|
||||
messages=messages,
|
||||
api_url=api_config.get("api_url", ""),
|
||||
api_key=api_config.get("api_key", ""),
|
||||
model=api_config.get("model", "gpt-3.5-turbo"),
|
||||
temperature=0.3, # 总结需要较低的随机性
|
||||
max_tokens=max_length,
|
||||
request_timeout=30
|
||||
)
|
||||
|
||||
# 提取总结文本
|
||||
if isinstance(response, dict):
|
||||
summary = response.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
else:
|
||||
summary = str(response)
|
||||
|
||||
# 清理和截断
|
||||
summary = summary.strip()
|
||||
if len(summary) > max_length:
|
||||
summary = summary[:max_length] + "..."
|
||||
|
||||
return summary
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ChatSummary] ❌ LLM总结失败: {e}")
|
||||
# 返回降级总结(基于规则的简单摘要)
|
||||
return ChatSummaryService._fallback_summary(prompt)
|
||||
|
||||
@staticmethod
|
||||
def _fallback_summary(prompt: str) -> str:
|
||||
"""
|
||||
降级总结(当LLM调用失败时使用)
|
||||
|
||||
Args:
|
||||
prompt: 原始提示词
|
||||
|
||||
Returns:
|
||||
简单的降级总结
|
||||
"""
|
||||
# 提取对话中的关键信息
|
||||
lines = prompt.split("\n")
|
||||
user_lines = [l for l in lines if l.startswith("用户:")]
|
||||
ai_lines = [l for l in lines if l.startswith("AI:")]
|
||||
|
||||
fallback = f"[自动总结] 对话包含 {len(user_lines)} 条用户消息和 {len(ai_lines)} 条AI回复。"
|
||||
|
||||
return fallback
|
||||
|
||||
|
||||
# 全局实例
|
||||
chat_summary_service = ChatSummaryService()
|
||||
1052
backend/services/chat_workflow_service.py
Normal file
1052
backend/services/chat_workflow_service.py
Normal file
File diff suppressed because it is too large
Load Diff
173
backend/services/comfyui_workflow_manager.py
Normal file
173
backend/services/comfyui_workflow_manager.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
ComfyUI Workflow Manager
|
||||
管理工作流 JSON 文件的上传、删除和加载
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
from fastapi import UploadFile, HTTPException
|
||||
import shutil
|
||||
from core.config import settings
|
||||
|
||||
# 工作流目录 - 使用统一的数据目录
|
||||
WORKFLOW_DIR = settings.COMFYUI_WORKFLOWS_PATH
|
||||
WORKFLOW_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
class WorkflowManager:
|
||||
"""ComfyUI 工作流管理器"""
|
||||
|
||||
@staticmethod
|
||||
def list_workflows() -> List[Dict[str, str]]:
|
||||
"""列出所有可用的工作流"""
|
||||
workflows = []
|
||||
|
||||
for json_file in WORKFLOW_DIR.glob("*.json"):
|
||||
try:
|
||||
with open(json_file, 'r', encoding='utf-8') as f:
|
||||
workflow_data = json.load(f)
|
||||
|
||||
workflows.append({
|
||||
"filename": json_file.name,
|
||||
"name": json_file.stem,
|
||||
"nodes_count": len(workflow_data),
|
||||
"size": json_file.stat().st_size
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"Error loading workflow {json_file.name}: {e}")
|
||||
continue
|
||||
|
||||
return workflows
|
||||
|
||||
@staticmethod
|
||||
def load_workflow(filename: str) -> Dict:
|
||||
"""加载指定工作流"""
|
||||
filepath = WORKFLOW_DIR / filename
|
||||
|
||||
if not filepath.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Workflow '{filename}' not found")
|
||||
|
||||
if not filepath.suffix == '.json':
|
||||
raise HTTPException(status_code=400, detail="Invalid file type")
|
||||
|
||||
try:
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
raise HTTPException(status_code=500, detail=f"Invalid JSON: {str(e)}")
|
||||
|
||||
@staticmethod
|
||||
async def upload_workflow(file: UploadFile) -> Dict[str, str]:
|
||||
"""上传工作流文件"""
|
||||
# 验证文件名
|
||||
if not file.filename or not file.filename.endswith('.json'):
|
||||
raise HTTPException(status_code=400, detail="File must be a JSON file")
|
||||
|
||||
# 安全检查:防止路径遍历攻击
|
||||
safe_filename = os.path.basename(file.filename)
|
||||
if not safe_filename:
|
||||
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||
|
||||
filepath = WORKFLOW_DIR / safe_filename
|
||||
|
||||
# 如果文件已存在,先备份
|
||||
if filepath.exists():
|
||||
backup_path = WORKFLOW_DIR / f"{safe_filename}.bak"
|
||||
shutil.copy2(filepath, backup_path)
|
||||
|
||||
# 保存文件
|
||||
try:
|
||||
content = await file.read()
|
||||
|
||||
# 验证 JSON 格式
|
||||
try:
|
||||
workflow_data = json.loads(content)
|
||||
|
||||
# 基本验证:检查是否是 ComfyUI 工作流
|
||||
if not isinstance(workflow_data, dict):
|
||||
raise ValueError("Workflow must be a JSON object")
|
||||
|
||||
# 检查是否包含必要的节点类型
|
||||
has_sampler = any(
|
||||
node.get("class_type") == "KSampler"
|
||||
for node in workflow_data.values()
|
||||
if isinstance(node, dict)
|
||||
)
|
||||
|
||||
if not has_sampler:
|
||||
raise ValueError("Invalid ComfyUI workflow: missing KSampler node")
|
||||
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON format")
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
# 写入文件
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
f.write(content.decode('utf-8'))
|
||||
|
||||
return {
|
||||
"message": "Workflow uploaded successfully",
|
||||
"filename": safe_filename,
|
||||
"size": len(content)
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
# 如果出错,恢复备份
|
||||
backup_path = WORKFLOW_DIR / f"{safe_filename}.bak"
|
||||
if backup_path.exists():
|
||||
shutil.move(backup_path, filepath)
|
||||
raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}")
|
||||
|
||||
@staticmethod
|
||||
def delete_workflow(filename: str) -> Dict[str, str]:
|
||||
"""删除工作流文件"""
|
||||
# 安全检查
|
||||
safe_filename = os.path.basename(filename)
|
||||
if not safe_filename or not safe_filename.endswith('.json'):
|
||||
raise HTTPException(status_code=400, detail="Invalid filename")
|
||||
|
||||
filepath = WORKFLOW_DIR / safe_filename
|
||||
|
||||
if not filepath.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Workflow '{filename}' not found")
|
||||
|
||||
# 不允许删除默认工作流
|
||||
if safe_filename == "default_txt2img.json":
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Cannot delete default workflow"
|
||||
)
|
||||
|
||||
try:
|
||||
filepath.unlink()
|
||||
return {"message": f"Workflow '{safe_filename}' deleted successfully"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Delete failed: {str(e)}")
|
||||
|
||||
@staticmethod
|
||||
def replace_prompt_in_workflow(workflow: Dict, prompt: str) -> Dict:
|
||||
"""
|
||||
在工作流中替换提示词
|
||||
找到第一个 CLIPTextEncode 节点,替换其 text 字段
|
||||
"""
|
||||
import copy
|
||||
workflow_copy = copy.deepcopy(workflow)
|
||||
|
||||
# 查找 CLIPTextEncode 节点(通常是正向提示词)
|
||||
for node_id, node in workflow_copy.items():
|
||||
if isinstance(node, dict) and node.get("class_type") == "CLIPTextEncode":
|
||||
if "text" in node.get("inputs", {}):
|
||||
# 替换提示词
|
||||
node["inputs"]["text"] = prompt
|
||||
return workflow_copy
|
||||
|
||||
# 如果没有找到 CLIPTextEncode 节点,抛出错误
|
||||
raise ValueError("No CLIPTextEncode node found in workflow")
|
||||
|
||||
|
||||
# 全局实例
|
||||
workflow_manager = WorkflowManager()
|
||||
317
backend/services/fiction_chapter_service.py
Normal file
317
backend/services/fiction_chapter_service.py
Normal file
@@ -0,0 +1,317 @@
|
||||
"""
|
||||
爽文章节写作(fiction.chapter)— 新版 metadata v2 章纲驱动。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Iterator, List, Optional, Tuple
|
||||
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
|
||||
from models.fiction_models import (
|
||||
ChapterPlanItem,
|
||||
EventChainItem,
|
||||
FictionBookMetadata,
|
||||
FictionChapter,
|
||||
VolumeOutline,
|
||||
)
|
||||
from services.fiction_metadata_service import fiction_metadata_service
|
||||
from services.fiction_planning_service import ensure_chapter_plan
|
||||
from services.fiction_prompt_utils import resolve_prompt
|
||||
from services.fiction_service import fiction_service
|
||||
from services.studio_step_respond import resolve_api_config
|
||||
from utils.llm_client import LLMClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_llm_client = LLMClient()
|
||||
_JSON_FENCE = re.compile(r"```(?:json)?\s*([\s\S]*?)```", re.IGNORECASE)
|
||||
|
||||
PlannedChapter = Tuple[int, VolumeOutline, EventChainItem, ChapterPlanItem]
|
||||
|
||||
|
||||
def _extract_json(raw: str) -> Dict[str, Any]:
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
raise ValueError("模型返回为空")
|
||||
fence = _JSON_FENCE.search(text)
|
||||
if fence:
|
||||
text = fence.group(1).strip()
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
start = text.find("{")
|
||||
end = text.rfind("}")
|
||||
if start >= 0 and end > start:
|
||||
return json.loads(text[start : end + 1])
|
||||
raise ValueError("无法解析模型返回的 JSON")
|
||||
|
||||
|
||||
def _validate_api_config(api_config: Dict[str, str]) -> None:
|
||||
if not api_config.get("api_key"):
|
||||
raise ValueError("API Key 未配置,请先在 API 配置页面保存密钥")
|
||||
if not api_config.get("api_url"):
|
||||
raise ValueError("API 地址未配置,请先在 API 配置页面保存 mainLLM")
|
||||
if not api_config.get("model"):
|
||||
raise ValueError("模型未配置,请先在 API 配置页面保存 mainLLM 模型")
|
||||
|
||||
|
||||
def _format_guide_global_layers(layers: List[str]) -> str:
|
||||
entries = fiction_service.get_guide_global_entries().entries
|
||||
filtered = [e for e in entries if e.layer in layers]
|
||||
lines: List[str] = []
|
||||
for entry in filtered:
|
||||
lines.append(f"[{entry.layer}] {entry.title}\n{entry.content}")
|
||||
return "\n\n".join(lines) if lines else "(无全局指南)"
|
||||
|
||||
|
||||
def _format_book_guide(book_id: str) -> str:
|
||||
guide = fiction_service.get_book_guide(book_id)
|
||||
return guide.chapter.content or "(无章节层世界书)"
|
||||
|
||||
|
||||
def iter_planned_chapters(metadata: FictionBookMetadata) -> Iterator[PlannedChapter]:
|
||||
"""按卷纲 → 事件链 → 章纲顺序展开章节计划。"""
|
||||
volumes = sorted(metadata.volumes or [], key=lambda v: v.order)
|
||||
for volume in volumes:
|
||||
events = sorted(metadata.eventChains.get(volume.id, []), key=lambda e: e.order)
|
||||
for event in events:
|
||||
plans = sorted(metadata.chapterPlans.get(event.id, []), key=lambda c: c.seq)
|
||||
for item in plans:
|
||||
yield item.seq, volume, event, item
|
||||
|
||||
|
||||
def find_next_unwritten_chapter(
|
||||
book_id: str, metadata: Optional[FictionBookMetadata] = None
|
||||
) -> Optional[PlannedChapter]:
|
||||
metadata = metadata or fiction_metadata_service.get_metadata(book_id)
|
||||
for seq, volume, event, item in iter_planned_chapters(metadata):
|
||||
if fiction_service.chapter_exists(book_id, seq):
|
||||
continue
|
||||
if item.status == "written":
|
||||
continue
|
||||
return seq, volume, event, item
|
||||
return None
|
||||
|
||||
|
||||
def has_written_chapters(book_id: str) -> bool:
|
||||
return len(fiction_service.list_written_chapter_seqs(book_id)) > 0
|
||||
|
||||
|
||||
def _build_context_tail(book_id: str, before_seq: int, context_chars: int) -> str:
|
||||
if before_seq <= 1 or context_chars <= 0:
|
||||
return ""
|
||||
parts: List[str] = []
|
||||
for seq in range(1, before_seq):
|
||||
if not fiction_service.chapter_exists(book_id, seq):
|
||||
continue
|
||||
ch = fiction_service.get_chapter(book_id, seq)
|
||||
if ch.body:
|
||||
parts.append(ch.body)
|
||||
combined = "\n\n".join(parts)
|
||||
if len(combined) <= context_chars:
|
||||
return combined
|
||||
return combined[-context_chars:]
|
||||
|
||||
|
||||
def _build_chapter_messages(
|
||||
book_id: str,
|
||||
global_seq: int,
|
||||
volume: VolumeOutline,
|
||||
event: EventChainItem,
|
||||
plan_item: ChapterPlanItem,
|
||||
) -> List[Any]:
|
||||
settings = fiction_service.get_book_settings(book_id)
|
||||
user_prompt = settings.prompts.chapter or fiction_service.get_default_settings().prompts.chapter
|
||||
system_prompt = resolve_prompt("chapter", user_prompt)
|
||||
|
||||
context_chars = settings.reader.contextWindowChars if settings.reader else 2000
|
||||
context_tail = _build_context_tail(book_id, global_seq, context_chars)
|
||||
guide_l3 = _format_guide_global_layers(["L3"])
|
||||
book_guide = _format_book_guide(book_id)
|
||||
|
||||
context_block = context_tail if context_tail else "(本章为开篇,无上文)"
|
||||
|
||||
user_content = f"""## 全局创作指南(L3)
|
||||
{guide_l3}
|
||||
|
||||
## 本书世界书(章节层)
|
||||
{book_guide}
|
||||
|
||||
## 当前卷纲
|
||||
- id: {volume.id}
|
||||
- title: {volume.title}
|
||||
- goal: {volume.goal}
|
||||
- coreConflict: {volume.coreConflict}
|
||||
- emotionalPromise: {volume.emotionalPromise}
|
||||
|
||||
## 当前事件
|
||||
- id: {event.id}
|
||||
- title: {event.title}
|
||||
- summary: {event.summary}
|
||||
- purpose: {event.purpose}
|
||||
- conflict: {event.conflict}
|
||||
- turningPoint: {event.turningPoint}
|
||||
- expectedPayoff: {event.expectedPayoff}
|
||||
|
||||
## 本章章纲(第 {global_seq} 章)
|
||||
- title: {plan_item.title}
|
||||
- goal: {plan_item.goal}
|
||||
- opening: {plan_item.opening}
|
||||
- mainConflict: {plan_item.mainConflict}
|
||||
- emotionalTurn: {plan_item.emotionalTurn}
|
||||
- emotionStepKey: {plan_item.emotionStepKey}
|
||||
- emotionGoal: {plan_item.emotionGoal}
|
||||
- payoff: {plan_item.payoff}
|
||||
- endingHook: {plan_item.endingHook}
|
||||
- forbidden: {plan_item.forbidden}
|
||||
- targetWords: {plan_item.targetWords}
|
||||
|
||||
## 已读上文末尾(最多 {context_chars} 字,供衔接)
|
||||
{context_block}
|
||||
|
||||
## 绝对要求
|
||||
- 只生成第 {global_seq} 章正文。
|
||||
- 正文字数目标约 {plan_item.targetWords or 2000} 汉字。
|
||||
- 必须遵循本章章纲、当前事件、当前卷纲与章节层世界书。
|
||||
- 不生成下一章章纲,不生成解释说明。"""
|
||||
|
||||
return [
|
||||
SystemMessage(content=system_prompt),
|
||||
HumanMessage(content=user_content),
|
||||
]
|
||||
|
||||
|
||||
def _parse_chapter_response(
|
||||
data: Dict[str, Any],
|
||||
*,
|
||||
global_seq: int,
|
||||
event: EventChainItem,
|
||||
plan_item: ChapterPlanItem,
|
||||
) -> FictionChapter:
|
||||
body = str(data.get("body") or "").strip()
|
||||
if not body:
|
||||
raise ValueError("章节正文为空")
|
||||
title = str(data.get("title") or plan_item.title or f"第{global_seq}章").strip()
|
||||
return FictionChapter(
|
||||
seq=global_seq,
|
||||
title=title,
|
||||
body=body,
|
||||
charCount=len(body),
|
||||
status="written",
|
||||
eventId=event.id,
|
||||
phaseKey=plan_item.emotionStepKey or plan_item.phaseKey,
|
||||
createdAt=datetime.now().isoformat(),
|
||||
)
|
||||
|
||||
|
||||
def _mark_chapter_written(
|
||||
metadata: FictionBookMetadata, event_id: str, plan_seq: int
|
||||
) -> FictionBookMetadata:
|
||||
plans = metadata.chapterPlans.get(event_id, [])
|
||||
updated_plan: List[ChapterPlanItem] = []
|
||||
for item in plans:
|
||||
if item.seq == plan_seq:
|
||||
updated_plan.append(item.model_copy(update={"status": "written"}))
|
||||
else:
|
||||
updated_plan.append(item)
|
||||
metadata.chapterPlans[event_id] = updated_plan
|
||||
return metadata
|
||||
|
||||
|
||||
async def run_chapter(
|
||||
book_id: str,
|
||||
*,
|
||||
profile_id: Optional[str] = None,
|
||||
api_config: Optional[Dict[str, str]] = None,
|
||||
seq: Optional[int] = None,
|
||||
) -> FictionChapter:
|
||||
resolved = resolve_api_config(profile_id, api_config)
|
||||
_validate_api_config(resolved)
|
||||
|
||||
metadata = await ensure_chapter_plan(
|
||||
book_id,
|
||||
profile_id=profile_id,
|
||||
api_config=api_config,
|
||||
)
|
||||
|
||||
target: Optional[PlannedChapter] = None
|
||||
if seq is not None:
|
||||
for global_seq, volume, event, item in iter_planned_chapters(metadata):
|
||||
if global_seq == seq:
|
||||
target = (global_seq, volume, event, item)
|
||||
break
|
||||
if not target:
|
||||
raise ValueError(f"章节规划不存在: seq={seq}")
|
||||
global_seq, volume, event, item = target
|
||||
if fiction_service.chapter_exists(book_id, global_seq):
|
||||
return fiction_service.get_chapter(book_id, global_seq)
|
||||
else:
|
||||
found = find_next_unwritten_chapter(book_id, metadata)
|
||||
if not found:
|
||||
raise ValueError("没有待撰写的章节")
|
||||
global_seq, volume, event, item = found
|
||||
if fiction_service.chapter_exists(book_id, global_seq):
|
||||
return fiction_service.get_chapter(book_id, global_seq)
|
||||
|
||||
run = fiction_metadata_service.get_run(book_id)
|
||||
if run.status == "error":
|
||||
fiction_metadata_service.clear_pipeline_error(book_id)
|
||||
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="running", pipeline_stage="chapter"
|
||||
)
|
||||
try:
|
||||
messages = _build_chapter_messages(book_id, global_seq, volume, event, item)
|
||||
response = await _llm_client.chat_completion(
|
||||
messages=messages,
|
||||
api_url=resolved["api_url"],
|
||||
api_key=resolved["api_key"],
|
||||
model=resolved["model"],
|
||||
temperature=0.8,
|
||||
max_tokens=8000,
|
||||
request_timeout=180,
|
||||
stream=False,
|
||||
)
|
||||
content = response["choices"][0]["message"]["content"]
|
||||
data = _extract_json(content)
|
||||
chapter = _parse_chapter_response(
|
||||
data, global_seq=global_seq, event=event, plan_item=item
|
||||
)
|
||||
fiction_service.save_chapter(book_id, chapter)
|
||||
|
||||
metadata = fiction_metadata_service.get_metadata(book_id)
|
||||
metadata = _mark_chapter_written(metadata, event.id, item.seq)
|
||||
progress = metadata.progress
|
||||
if progress.currentChapterSeq <= 0:
|
||||
progress.currentChapterSeq = global_seq
|
||||
metadata.progress = progress
|
||||
fiction_metadata_service.save_metadata(book_id, metadata)
|
||||
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="idle", pipeline_stage="ready"
|
||||
)
|
||||
return chapter
|
||||
except Exception:
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="error", pipeline_stage="chapter"
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
async def ensure_chapter(
|
||||
book_id: str,
|
||||
*,
|
||||
profile_id: Optional[str] = None,
|
||||
api_config: Optional[Dict[str, str]] = None,
|
||||
seq: Optional[int] = None,
|
||||
) -> FictionChapter:
|
||||
return await run_chapter(
|
||||
book_id,
|
||||
profile_id=profile_id,
|
||||
api_config=api_config,
|
||||
seq=seq,
|
||||
)
|
||||
178
backend/services/fiction_coarse_service.py
Normal file
178
backend/services/fiction_coarse_service.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""
|
||||
爽文粗纲生成(fiction.coarse)— LLM 调用逻辑。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
|
||||
from models.fiction_models import CoarseOutline, CoarseOutlineEvent, FictionBookMetadata
|
||||
from services.fiction_metadata_service import fiction_metadata_service
|
||||
from services.fiction_prompt_utils import resolve_prompt
|
||||
from services.fiction_service import fiction_service
|
||||
from services.studio_step_respond import resolve_api_config
|
||||
from utils.llm_client import LLMClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_llm_client = LLMClient()
|
||||
_JSON_FENCE = re.compile(r"```(?:json)?\s*([\s\S]*?)```", re.IGNORECASE)
|
||||
|
||||
|
||||
def _extract_json(raw: str) -> Dict[str, Any]:
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
raise ValueError("模型返回为空")
|
||||
fence = _JSON_FENCE.search(text)
|
||||
if fence:
|
||||
text = fence.group(1).strip()
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
start = text.find("{")
|
||||
end = text.rfind("}")
|
||||
if start >= 0 and end > start:
|
||||
return json.loads(text[start : end + 1])
|
||||
raise ValueError("无法解析模型返回的 JSON")
|
||||
|
||||
|
||||
def _validate_api_config(api_config: Dict[str, str]) -> None:
|
||||
if not api_config.get("api_key"):
|
||||
raise ValueError("API Key 未配置,请先在 API 配置页面保存密钥")
|
||||
if not api_config.get("api_url"):
|
||||
raise ValueError("API 地址未配置,请先在 API 配置页面保存 mainLLM")
|
||||
|
||||
|
||||
def _format_guide_global_layers(layers: List[str]) -> str:
|
||||
entries = fiction_service.get_guide_global_entries().entries
|
||||
filtered = [e for e in entries if e.layer in layers]
|
||||
lines: List[str] = []
|
||||
for entry in filtered:
|
||||
lines.append(f"[{entry.layer}] {entry.title}\n{entry.content}")
|
||||
return "\n\n".join(lines) if lines else "(无全局指南)"
|
||||
|
||||
|
||||
def _format_book_guide(book_id: str) -> str:
|
||||
guide = fiction_service.get_book_guide(book_id)
|
||||
parts = [
|
||||
f"主角人设:{guide.persona}",
|
||||
f"核心爽点:{guide.highlight}",
|
||||
f"用户体验:{guide.experience}",
|
||||
f"创作禁区:{guide.forbiddenZones}",
|
||||
]
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _build_coarse_messages(book_id: str) -> List[Any]:
|
||||
settings = fiction_service.get_book_settings(book_id)
|
||||
user_prompt = settings.prompts.coarseOutline or fiction_service.get_default_settings().prompts.coarseOutline
|
||||
system_prompt = resolve_prompt("coarseOutline", user_prompt)
|
||||
|
||||
guide_l1 = _format_guide_global_layers(["L1"])
|
||||
book_guide = _format_book_guide(book_id)
|
||||
meta = fiction_service.get_book_meta(book_id)
|
||||
|
||||
user_content = f"""## 全局创作指南(L1,仅用于粗纲)
|
||||
{guide_l1}
|
||||
|
||||
## 本书 Guide 世界书
|
||||
{book_guide}
|
||||
|
||||
## 书名
|
||||
{meta.title}
|
||||
|
||||
## 已选情绪流 ID
|
||||
{", ".join(meta.allowedFlowIds or []) or "(未配置)"}
|
||||
|
||||
请生成本书的粗纲事件链。"""
|
||||
|
||||
return [
|
||||
SystemMessage(content=system_prompt),
|
||||
HumanMessage(content=user_content),
|
||||
]
|
||||
|
||||
|
||||
def _normalize_coarse_events(raw_events: Any) -> List[CoarseOutlineEvent]:
|
||||
if not isinstance(raw_events, list):
|
||||
return []
|
||||
events: List[CoarseOutlineEvent] = []
|
||||
for idx, item in enumerate(raw_events):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
evt_id = str(item.get("id") or f"evt-{idx + 1}").strip()
|
||||
title = str(item.get("title") or f"事件 {idx + 1}").strip()
|
||||
summary = str(item.get("summary") or "").strip()
|
||||
order = item.get("order")
|
||||
if not isinstance(order, int):
|
||||
order = idx + 1
|
||||
events.append(
|
||||
CoarseOutlineEvent(id=evt_id, title=title, summary=summary, order=order)
|
||||
)
|
||||
events.sort(key=lambda e: e.order)
|
||||
return events
|
||||
|
||||
|
||||
def _parse_coarse_response(data: Dict[str, Any]) -> CoarseOutline:
|
||||
events = _normalize_coarse_events(data.get("events"))
|
||||
if not events:
|
||||
raise ValueError("粗纲事件列表为空")
|
||||
version = data.get("version")
|
||||
if not isinstance(version, int):
|
||||
version = 1
|
||||
return CoarseOutline(events=events, version=version)
|
||||
|
||||
|
||||
async def run_coarse_outline(
|
||||
book_id: str,
|
||||
*,
|
||||
profile_id: Optional[str] = None,
|
||||
api_config: Optional[Dict[str, str]] = None,
|
||||
) -> FictionBookMetadata:
|
||||
existing = fiction_metadata_service.get_metadata(book_id)
|
||||
if existing.coarseOutline.events:
|
||||
logger.info("Coarse outline already exists for book %s, skipping generation", book_id)
|
||||
return existing
|
||||
|
||||
resolved = resolve_api_config(profile_id, api_config)
|
||||
_validate_api_config(resolved)
|
||||
|
||||
run = fiction_metadata_service.get_run(book_id)
|
||||
if run.status == "error":
|
||||
fiction_metadata_service.clear_pipeline_error(book_id)
|
||||
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="running", pipeline_stage="coarse"
|
||||
)
|
||||
try:
|
||||
messages = _build_coarse_messages(book_id)
|
||||
response = await _llm_client.chat_completion(
|
||||
messages=messages,
|
||||
api_url=resolved["api_url"],
|
||||
api_key=resolved["api_key"],
|
||||
model=resolved.get("model", "gpt-4o-mini"),
|
||||
temperature=0.7,
|
||||
max_tokens=8000,
|
||||
request_timeout=120,
|
||||
stream=False,
|
||||
)
|
||||
content = response["choices"][0]["message"]["content"]
|
||||
data = _extract_json(content)
|
||||
coarse = _parse_coarse_response(data)
|
||||
|
||||
current = fiction_metadata_service.get_metadata(book_id)
|
||||
current.coarseOutline = coarse
|
||||
saved = fiction_metadata_service.save_metadata(book_id, current)
|
||||
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="idle", pipeline_stage="coarse_done"
|
||||
)
|
||||
return saved
|
||||
except Exception:
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="error", pipeline_stage="coarse"
|
||||
)
|
||||
raise
|
||||
34
backend/services/fiction_event_plan_progress.py
Normal file
34
backend/services/fiction_event_plan_progress.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
事件纲要生成进度广播 — 供 NDJSON 订阅端与后台流水线共享。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any, AsyncIterator, Dict, List
|
||||
|
||||
_subscribers: Dict[str, List[asyncio.Queue]] = {}
|
||||
|
||||
|
||||
def emit(book_id: str, event: Dict[str, Any]) -> None:
|
||||
for q in list(_subscribers.get(book_id, [])):
|
||||
try:
|
||||
q.put_nowait(event)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
|
||||
|
||||
async def subscribe(book_id: str) -> AsyncIterator[Dict[str, Any]]:
|
||||
q: asyncio.Queue = asyncio.Queue(maxsize=128)
|
||||
_subscribers.setdefault(book_id, []).append(q)
|
||||
try:
|
||||
while True:
|
||||
item = await q.get()
|
||||
yield item
|
||||
if item.get("type") in ("done", "error"):
|
||||
break
|
||||
finally:
|
||||
subs = _subscribers.get(book_id, [])
|
||||
if q in subs:
|
||||
subs.remove(q)
|
||||
if not subs:
|
||||
_subscribers.pop(book_id, None)
|
||||
422
backend/services/fiction_event_plan_service.py
Normal file
422
backend/services/fiction_event_plan_service.py
Normal file
@@ -0,0 +1,422 @@
|
||||
"""
|
||||
爽文事件规划(fiction.event_plan)— LLM 调用逻辑。
|
||||
按事件顺序生成,每完成一个事件即持久化并广播进度。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
from typing import Any, AsyncIterator, Dict, List, Optional
|
||||
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
|
||||
from models.fiction_models import (
|
||||
ChapterPlanItem,
|
||||
CoarseOutlineEvent,
|
||||
EventPlanEntry,
|
||||
FictionBookMetadata,
|
||||
FlowStepsPlan,
|
||||
)
|
||||
from services.fiction_event_plan_progress import emit as emit_progress
|
||||
from services.fiction_event_plan_progress import subscribe as subscribe_progress
|
||||
from services.fiction_metadata_service import fiction_metadata_service
|
||||
from services.fiction_prompt_utils import resolve_prompt
|
||||
from services.fiction_service import fiction_service
|
||||
from services.studio_step_respond import resolve_api_config
|
||||
from utils.llm_client import LLMClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_llm_client = LLMClient()
|
||||
_JSON_FENCE = re.compile(r"```(?:json)?\s*([\s\S]*?)```", re.IGNORECASE)
|
||||
|
||||
|
||||
def _extract_json(raw: str) -> Dict[str, Any]:
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
raise ValueError("模型返回为空")
|
||||
fence = _JSON_FENCE.search(text)
|
||||
if fence:
|
||||
text = fence.group(1).strip()
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
start = text.find("{")
|
||||
end = text.rfind("}")
|
||||
if start >= 0 and end > start:
|
||||
return json.loads(text[start : end + 1])
|
||||
raise ValueError("无法解析模型返回的 JSON")
|
||||
|
||||
|
||||
def _validate_api_config(api_config: Dict[str, str]) -> None:
|
||||
if not api_config.get("api_key"):
|
||||
raise ValueError("API Key 未配置,请先在 API 配置页面保存密钥")
|
||||
if not api_config.get("api_url"):
|
||||
raise ValueError("API 地址未配置,请先在 API 配置页面保存 mainLLM")
|
||||
|
||||
|
||||
def _format_guide_global_layers(layers: List[str]) -> str:
|
||||
entries = fiction_service.get_guide_global_entries().entries
|
||||
filtered = [e for e in entries if e.layer in layers]
|
||||
lines: List[str] = []
|
||||
for entry in filtered:
|
||||
lines.append(f"[{entry.layer}] {entry.title}\n{entry.content}")
|
||||
return "\n\n".join(lines) if lines else "(无全局指南)"
|
||||
|
||||
|
||||
def _format_book_guide(book_id: str) -> str:
|
||||
guide = fiction_service.get_book_guide(book_id)
|
||||
parts = [
|
||||
f"主角人设:{guide.persona}",
|
||||
f"核心爽点:{guide.highlight}",
|
||||
f"用户体验:{guide.experience}",
|
||||
f"创作禁区:{guide.forbiddenZones}",
|
||||
]
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _get_flow_by_id(flow_id: str):
|
||||
catalog = fiction_service.get_emotion_catalog()
|
||||
for flow in catalog.flows:
|
||||
if flow.id == flow_id:
|
||||
return flow
|
||||
return None
|
||||
|
||||
|
||||
def _format_flow_steps(flow) -> str:
|
||||
lines = [f"情绪流:{flow.intro} (id: {flow.id})"]
|
||||
for step in flow.steps or []:
|
||||
lines.append(f" [{step.key}] {step.text}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _build_event_plan_messages(
|
||||
book_id: str,
|
||||
event_id: str,
|
||||
event_title: str,
|
||||
event_summary: str,
|
||||
emotion_flow_id: str,
|
||||
) -> List[Any]:
|
||||
settings = fiction_service.get_book_settings(book_id)
|
||||
user_prompt = settings.prompts.eventPlan or fiction_service.get_default_settings().prompts.eventPlan
|
||||
system_prompt = resolve_prompt("eventPlan", user_prompt)
|
||||
|
||||
guide_l2 = _format_guide_global_layers(["L2"])
|
||||
book_guide = _format_book_guide(book_id)
|
||||
flow = _get_flow_by_id(emotion_flow_id)
|
||||
flow_text = _format_flow_steps(flow) if flow else f"情绪流 id: {emotion_flow_id}"
|
||||
|
||||
user_content = f"""## 全局创作指南(L2,仅用于事件规划)
|
||||
{guide_l2}
|
||||
|
||||
## 本书 Guide 世界书
|
||||
{book_guide}
|
||||
|
||||
## 当前粗纲事件
|
||||
- id: {event_id}
|
||||
- title: {event_title}
|
||||
- summary: {event_summary}
|
||||
|
||||
## 为本事件随机选定的情绪流
|
||||
{flow_text}
|
||||
|
||||
请输出 flowStepsPlan(起承转合)与 chapterPlan(章节级 brief)。
|
||||
|
||||
输出 JSON 示例:
|
||||
{{
|
||||
"flowStepsPlan": {{
|
||||
"起": "本阶段规划…",
|
||||
"承": "…",
|
||||
"转": "…",
|
||||
"合": "…"
|
||||
}},
|
||||
"chapterPlan": [
|
||||
{{ "seq": 1, "phaseKey": "起", "phaseSlice": "全", "brief": "本章要点", "status": "planned" }}
|
||||
]
|
||||
}}"""
|
||||
|
||||
return [
|
||||
SystemMessage(content=system_prompt),
|
||||
HumanMessage(content=user_content),
|
||||
]
|
||||
|
||||
|
||||
def _normalize_flow_steps_plan(raw: Any) -> FlowStepsPlan:
|
||||
data = raw if isinstance(raw, dict) else {}
|
||||
return FlowStepsPlan(
|
||||
起=str(data.get("起") or data.get("qi") or ""),
|
||||
承=str(data.get("承") or data.get("cheng") or ""),
|
||||
转=str(data.get("转") or data.get("zhuan") or ""),
|
||||
合=str(data.get("合") or data.get("he") or ""),
|
||||
)
|
||||
|
||||
|
||||
def _normalize_chapter_plan(raw: Any) -> List[ChapterPlanItem]:
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
items: List[ChapterPlanItem] = []
|
||||
for idx, item in enumerate(raw):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
seq = item.get("seq")
|
||||
if not isinstance(seq, int):
|
||||
seq = idx + 1
|
||||
status = str(item.get("status") or "planned")
|
||||
items.append(
|
||||
ChapterPlanItem(
|
||||
seq=seq,
|
||||
phaseKey=str(item.get("phaseKey") or item.get("phase_key") or "起"),
|
||||
phaseSlice=str(item.get("phaseSlice") or item.get("phase_slice") or ""),
|
||||
brief=str(item.get("brief") or ""),
|
||||
status=status,
|
||||
)
|
||||
)
|
||||
items.sort(key=lambda c: c.seq)
|
||||
return items
|
||||
|
||||
|
||||
def _parse_event_plan_response(data: Dict[str, Any], emotion_flow_id: str) -> EventPlanEntry:
|
||||
flow_steps = _normalize_flow_steps_plan(data.get("flowStepsPlan"))
|
||||
chapter_plan = _normalize_chapter_plan(data.get("chapterPlan"))
|
||||
if not chapter_plan:
|
||||
raise ValueError("chapterPlan 为空")
|
||||
return EventPlanEntry(
|
||||
emotionFlowId=emotion_flow_id,
|
||||
flowStepsPlan=flow_steps,
|
||||
chapterPlan=chapter_plan,
|
||||
)
|
||||
|
||||
|
||||
def _event_fully_planned(entry: EventPlanEntry) -> bool:
|
||||
return bool(entry.chapterPlan)
|
||||
|
||||
|
||||
def _resolve_targets(
|
||||
metadata: FictionBookMetadata,
|
||||
*,
|
||||
event_id: Optional[str] = None,
|
||||
) -> List[CoarseOutlineEvent]:
|
||||
coarse_events = metadata.coarseOutline.events
|
||||
if not coarse_events:
|
||||
raise ValueError("请先生成粗纲")
|
||||
|
||||
events_map = dict(metadata.events or {})
|
||||
if event_id:
|
||||
targets = [e for e in coarse_events if e.id == event_id]
|
||||
if not targets:
|
||||
raise ValueError(f"粗纲中不存在事件: {event_id}")
|
||||
return targets
|
||||
|
||||
return [
|
||||
e
|
||||
for e in coarse_events
|
||||
if e.id not in events_map or not _event_fully_planned(events_map[e.id])
|
||||
]
|
||||
|
||||
|
||||
def event_planned_payload(evt: CoarseOutlineEvent, entry: EventPlanEntry) -> Dict[str, Any]:
|
||||
phases: List[str] = []
|
||||
fsp = entry.flowStepsPlan
|
||||
for key in ("起", "承", "转", "合"):
|
||||
if getattr(fsp, key, ""):
|
||||
phases.append(key)
|
||||
return {
|
||||
"type": "event_planned",
|
||||
"eventId": evt.id,
|
||||
"title": evt.title,
|
||||
"chapterCount": len(entry.chapterPlan),
|
||||
"phases": phases,
|
||||
}
|
||||
|
||||
|
||||
def build_progress_snapshot(book_id: str) -> Dict[str, Any]:
|
||||
"""已规划事件的目录快照(不含 brief 剧透)。"""
|
||||
metadata = fiction_metadata_service.get_metadata(book_id)
|
||||
coarse_events = metadata.coarseOutline.events
|
||||
events_map = metadata.events or {}
|
||||
items: List[Dict[str, Any]] = []
|
||||
for evt in coarse_events:
|
||||
entry = events_map.get(evt.id)
|
||||
if entry and _event_fully_planned(entry):
|
||||
items.append(event_planned_payload(evt, entry))
|
||||
run = fiction_metadata_service.get_run(book_id)
|
||||
progress = run.progress or {}
|
||||
return {
|
||||
"type": "snapshot",
|
||||
"items": items,
|
||||
"done": progress.get("done", len(items)),
|
||||
"total": progress.get("total", len(coarse_events)),
|
||||
}
|
||||
|
||||
|
||||
async def _generate_one_event(
|
||||
book_id: str,
|
||||
evt: CoarseOutlineEvent,
|
||||
*,
|
||||
resolved: Dict[str, str],
|
||||
allowed: List[str],
|
||||
) -> EventPlanEntry:
|
||||
emotion_flow_id = random.choice(allowed)
|
||||
messages = _build_event_plan_messages(
|
||||
book_id,
|
||||
evt.id,
|
||||
evt.title,
|
||||
evt.summary,
|
||||
emotion_flow_id,
|
||||
)
|
||||
response = await _llm_client.chat_completion(
|
||||
messages=messages,
|
||||
api_url=resolved["api_url"],
|
||||
api_key=resolved["api_key"],
|
||||
model=resolved.get("model", "gpt-4o-mini"),
|
||||
temperature=0.7,
|
||||
max_tokens=8000,
|
||||
request_timeout=120,
|
||||
stream=False,
|
||||
)
|
||||
content = response["choices"][0]["message"]["content"]
|
||||
data = _extract_json(content)
|
||||
return _parse_event_plan_response(data, emotion_flow_id)
|
||||
|
||||
|
||||
async def iter_event_plan(
|
||||
book_id: str,
|
||||
*,
|
||||
profile_id: Optional[str] = None,
|
||||
api_config: Optional[Dict[str, str]] = None,
|
||||
event_id: Optional[str] = None,
|
||||
) -> AsyncIterator[Dict[str, Any]]:
|
||||
"""按事件逐个生成,每完成一个即保存并 yield 进度事件。"""
|
||||
resolved = resolve_api_config(profile_id, api_config)
|
||||
_validate_api_config(resolved)
|
||||
|
||||
metadata = fiction_metadata_service.get_metadata(book_id)
|
||||
meta = fiction_service.get_book_meta(book_id)
|
||||
allowed = list(meta.allowedFlowIds or [])
|
||||
if not allowed:
|
||||
raise ValueError("本书未配置 allowedFlowIds")
|
||||
|
||||
targets = _resolve_targets(metadata, event_id=event_id)
|
||||
coarse_events = metadata.coarseOutline.events
|
||||
coarse_total = len(coarse_events)
|
||||
|
||||
if not targets:
|
||||
logger.info("Event plans already exist for book %s, skipping generation", book_id)
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="idle", pipeline_stage="event_plan_done"
|
||||
)
|
||||
fiction_metadata_service.clear_pipeline_progress(book_id)
|
||||
done_evt: Dict[str, Any] = {"type": "done", "done": coarse_total, "total": coarse_total}
|
||||
emit_progress(book_id, done_evt)
|
||||
yield done_evt
|
||||
return
|
||||
|
||||
run = fiction_metadata_service.get_run(book_id)
|
||||
if run.status == "error":
|
||||
fiction_metadata_service.clear_pipeline_error(book_id)
|
||||
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="running", pipeline_stage="event_plan"
|
||||
)
|
||||
|
||||
events_map = dict(metadata.events or {})
|
||||
|
||||
def _planned_count() -> int:
|
||||
return sum(
|
||||
1
|
||||
for e in coarse_events
|
||||
if e.id in events_map and _event_fully_planned(events_map[e.id])
|
||||
)
|
||||
|
||||
initial_done = _planned_count()
|
||||
fiction_metadata_service.set_pipeline_progress(
|
||||
book_id, done=initial_done, total=coarse_total
|
||||
)
|
||||
started: Dict[str, Any] = {
|
||||
"type": "started",
|
||||
"done": initial_done,
|
||||
"total": coarse_total,
|
||||
"pending": len(targets),
|
||||
}
|
||||
emit_progress(book_id, started)
|
||||
yield started
|
||||
|
||||
try:
|
||||
for evt in targets:
|
||||
entry = await _generate_one_event(
|
||||
book_id, evt, resolved=resolved, allowed=allowed
|
||||
)
|
||||
events_map[evt.id] = entry
|
||||
metadata.events = events_map
|
||||
fiction_metadata_service.save_metadata(book_id, metadata)
|
||||
|
||||
done_count = _planned_count()
|
||||
fiction_metadata_service.set_pipeline_progress(
|
||||
book_id, done=done_count, total=coarse_total
|
||||
)
|
||||
payload = event_planned_payload(evt, entry)
|
||||
emit_progress(book_id, payload)
|
||||
yield payload
|
||||
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="idle", pipeline_stage="event_plan_done"
|
||||
)
|
||||
fiction_metadata_service.clear_pipeline_progress(book_id)
|
||||
done_evt = {"type": "done", "done": _planned_count(), "total": coarse_total}
|
||||
emit_progress(book_id, done_evt)
|
||||
yield done_evt
|
||||
except Exception as exc:
|
||||
completed = [
|
||||
eid for eid, ent in events_map.items() if _event_fully_planned(ent)
|
||||
]
|
||||
err_evt: Dict[str, Any] = {
|
||||
"type": "error",
|
||||
"message": str(exc),
|
||||
"completedEvents": completed,
|
||||
"done": _planned_count(),
|
||||
"total": coarse_total,
|
||||
}
|
||||
emit_progress(book_id, err_evt)
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="error", pipeline_stage="event_plan"
|
||||
)
|
||||
yield err_evt
|
||||
raise
|
||||
|
||||
|
||||
async def run_event_plan(
|
||||
book_id: str,
|
||||
*,
|
||||
profile_id: Optional[str] = None,
|
||||
api_config: Optional[Dict[str, str]] = None,
|
||||
event_id: Optional[str] = None,
|
||||
) -> FictionBookMetadata:
|
||||
async for _event in iter_event_plan(
|
||||
book_id,
|
||||
profile_id=profile_id,
|
||||
api_config=api_config,
|
||||
event_id=event_id,
|
||||
):
|
||||
pass
|
||||
return fiction_metadata_service.get_metadata(book_id)
|
||||
|
||||
|
||||
async def stream_event_plan_subscribe(book_id: str) -> AsyncIterator[Dict[str, Any]]:
|
||||
"""订阅进行中的事件纲要进度(先快照,再实时)。"""
|
||||
snapshot = build_progress_snapshot(book_id)
|
||||
yield snapshot
|
||||
|
||||
run = fiction_metadata_service.get_run(book_id)
|
||||
if run.status == "running" and run.pipelineStage == "event_plan":
|
||||
async for event in subscribe_progress(book_id):
|
||||
if event.get("type") == "snapshot":
|
||||
continue
|
||||
yield event
|
||||
elif snapshot["items"] or snapshot.get("done", 0) > 0:
|
||||
yield {
|
||||
"type": "done",
|
||||
"done": snapshot["done"],
|
||||
"total": snapshot["total"],
|
||||
}
|
||||
168
backend/services/fiction_metadata_service.py
Normal file
168
backend/services/fiction_metadata_service.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
爽文 metadata.json / run.json 读写服务。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from models.fiction_models import FictionBookMetadata, FictionRunState
|
||||
from services.fiction_service import fiction_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_STAGE_MESSAGES: Dict[tuple, tuple] = {
|
||||
("running", "coarse"): ("coarse_generating", "正在生成粗纲…"),
|
||||
("running", "event_plan"): ("event_plan_generating", "正在生成事件纲要…"),
|
||||
("running", "chapter"): ("chapter_generating", "正在撰写正文…"),
|
||||
("error", "coarse"): ("error", "粗纲生成失败"),
|
||||
("error", "event_plan"): ("error", "事件纲要生成失败"),
|
||||
("error", "chapter"): ("error", "章节写作失败"),
|
||||
}
|
||||
|
||||
|
||||
def _resolve_stage_message(
|
||||
status: str,
|
||||
pipeline_stage: Optional[str],
|
||||
override_message: Optional[str] = None,
|
||||
) -> tuple:
|
||||
if override_message is not None:
|
||||
key = (status, pipeline_stage or "")
|
||||
stage = _STAGE_MESSAGES.get(key, (status, override_message))[0]
|
||||
if status == "error":
|
||||
stage = "error"
|
||||
elif status == "running" and pipeline_stage == "coarse":
|
||||
stage = "coarse_generating"
|
||||
elif status == "running" and pipeline_stage == "event_plan":
|
||||
stage = "event_plan_generating"
|
||||
elif status == "running" and pipeline_stage == "chapter":
|
||||
stage = "chapter_generating"
|
||||
elif status == "idle":
|
||||
stage = "idle"
|
||||
return stage, override_message
|
||||
matched = _STAGE_MESSAGES.get((status, pipeline_stage or ""))
|
||||
if matched:
|
||||
return matched
|
||||
if status == "idle":
|
||||
return "idle", None
|
||||
if status == "error":
|
||||
return "error", "生成失败"
|
||||
return status, None
|
||||
|
||||
|
||||
def _read_json(path: Path) -> Any:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _write_json(path: Path, data: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
class FictionMetadataService:
|
||||
def _metadata_path(self, book_id: str) -> Path:
|
||||
return fiction_service._metadata_path(book_id)
|
||||
|
||||
def _run_path(self, book_id: str) -> Path:
|
||||
return fiction_service._run_path(book_id)
|
||||
|
||||
def _ensure_book(self, book_id: str) -> None:
|
||||
if not fiction_service._meta_path(book_id).exists():
|
||||
raise FileNotFoundError(f"Book not found: {book_id}")
|
||||
|
||||
def get_metadata(self, book_id: str) -> FictionBookMetadata:
|
||||
self._ensure_book(book_id)
|
||||
path = self._metadata_path(book_id)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"metadata.json not found for book: {book_id}")
|
||||
return FictionBookMetadata(**_read_json(path))
|
||||
|
||||
def save_metadata(self, book_id: str, metadata: FictionBookMetadata) -> FictionBookMetadata:
|
||||
self._ensure_book(book_id)
|
||||
_write_json(self._metadata_path(book_id), metadata.model_dump())
|
||||
self._touch_book_meta(book_id)
|
||||
return metadata
|
||||
|
||||
def update_metadata(self, book_id: str, patch: Dict[str, Any]) -> FictionBookMetadata:
|
||||
current = self.get_metadata(book_id)
|
||||
data = current.model_dump()
|
||||
for key, value in patch.items():
|
||||
data[key] = value
|
||||
updated = FictionBookMetadata(**data)
|
||||
return self.save_metadata(book_id, updated)
|
||||
|
||||
def get_run(self, book_id: str) -> FictionRunState:
|
||||
self._ensure_book(book_id)
|
||||
path = self._run_path(book_id)
|
||||
if not path.exists():
|
||||
return FictionRunState()
|
||||
return FictionRunState(**_read_json(path))
|
||||
|
||||
def save_run(self, book_id: str, run: FictionRunState) -> FictionRunState:
|
||||
self._ensure_book(book_id)
|
||||
_write_json(self._run_path(book_id), run.model_dump())
|
||||
return run
|
||||
|
||||
def set_pipeline_stage(
|
||||
self,
|
||||
book_id: str,
|
||||
*,
|
||||
status: str,
|
||||
pipeline_stage: Optional[str] = None,
|
||||
message: Optional[str] = None,
|
||||
) -> FictionRunState:
|
||||
run = self.get_run(book_id)
|
||||
run.status = status
|
||||
run.pipelineStage = pipeline_stage
|
||||
run.stage, run.message = _resolve_stage_message(status, pipeline_stage, message)
|
||||
run.updatedAt = datetime.now().isoformat()
|
||||
return self.save_run(book_id, run)
|
||||
|
||||
def clear_pipeline_error(self, book_id: str) -> FictionRunState:
|
||||
"""清除 error 状态,保留 pipelineStage 供重试参考。"""
|
||||
run = self.get_run(book_id)
|
||||
if run.status != "error":
|
||||
return run
|
||||
run.status = "idle"
|
||||
run.stage = "idle"
|
||||
run.message = None
|
||||
run.updatedAt = datetime.now().isoformat()
|
||||
return self.save_run(book_id, run)
|
||||
|
||||
def set_pipeline_progress(
|
||||
self, book_id: str, *, done: int, total: int
|
||||
) -> FictionRunState:
|
||||
run = self.get_run(book_id)
|
||||
run.progress = {"done": done, "total": total}
|
||||
run.updatedAt = datetime.now().isoformat()
|
||||
return self.save_run(book_id, run)
|
||||
|
||||
def clear_pipeline_progress(self, book_id: str) -> FictionRunState:
|
||||
run = self.get_run(book_id)
|
||||
run.progress = None
|
||||
run.updatedAt = datetime.now().isoformat()
|
||||
return self.save_run(book_id, run)
|
||||
|
||||
def update_progress(
|
||||
self,
|
||||
book_id: str,
|
||||
*,
|
||||
current_chapter_seq: Optional[int] = None,
|
||||
char_offset: Optional[int] = None,
|
||||
):
|
||||
metadata = self.get_metadata(book_id)
|
||||
progress = metadata.progress
|
||||
if current_chapter_seq is not None:
|
||||
progress.currentChapterSeq = current_chapter_seq
|
||||
if char_offset is not None:
|
||||
progress.charOffset = char_offset
|
||||
metadata.progress = progress
|
||||
return self.save_metadata(book_id, metadata)
|
||||
|
||||
|
||||
fiction_metadata_service = FictionMetadataService()
|
||||
159
backend/services/fiction_open_book_service.py
Normal file
159
backend/services/fiction_open_book_service.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
爽文开书优化(fiction.open_book)— LLM 调用逻辑。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
|
||||
from models.fiction_models import (
|
||||
EmotionFlow,
|
||||
FictionGuideWorldbook,
|
||||
OpenBookResult,
|
||||
)
|
||||
from services.fiction_prompt_utils import resolve_prompt
|
||||
from services.fiction_service import fiction_service
|
||||
from services.studio_step_respond import resolve_api_config
|
||||
from utils.llm_client import LLMClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_llm_client = LLMClient()
|
||||
_JSON_FENCE = re.compile(r"```(?:json)?\s*([\s\S]*?)```", re.IGNORECASE)
|
||||
|
||||
|
||||
def _extract_json(raw: str) -> Dict[str, Any]:
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
raise ValueError("模型返回为空")
|
||||
fence = _JSON_FENCE.search(text)
|
||||
if fence:
|
||||
text = fence.group(1).strip()
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
start = text.find("{")
|
||||
end = text.rfind("}")
|
||||
if start >= 0 and end > start:
|
||||
return json.loads(text[start : end + 1])
|
||||
raise ValueError("无法解析模型返回的 JSON")
|
||||
|
||||
|
||||
def _validate_api_config(api_config: Dict[str, str]) -> None:
|
||||
if not api_config.get("api_key"):
|
||||
raise ValueError("API Key 未配置,请先在 API 配置页面保存密钥")
|
||||
if not api_config.get("api_url"):
|
||||
raise ValueError("API 地址未配置,请先在 API 配置页面保存 mainLLM")
|
||||
|
||||
|
||||
def _format_catalog_for_prompt(flows: List[EmotionFlow]) -> str:
|
||||
lines: List[str] = []
|
||||
for flow in flows:
|
||||
tags = "、".join(flow.tags or [])
|
||||
lines.append(f"- id: {flow.id}\n intro: {flow.intro}\n tags: {tags}")
|
||||
return "\n".join(lines) if lines else "(无可用情绪流)"
|
||||
|
||||
|
||||
def _format_guide_global_for_prompt() -> str:
|
||||
entries = fiction_service.get_guide_global_entries().entries
|
||||
lines: List[str] = []
|
||||
for entry in entries:
|
||||
lines.append(f"[{entry.layer}] {entry.title}\n{entry.content}")
|
||||
return "\n\n".join(lines) if lines else "(无全局指南)"
|
||||
|
||||
|
||||
def _build_open_book_messages(inspiration: str) -> List[Any]:
|
||||
default_settings = fiction_service.get_default_settings()
|
||||
system_prompt = resolve_prompt("openBook", default_settings.prompts.openBook)
|
||||
|
||||
catalog = fiction_service.get_emotion_catalog()
|
||||
catalog_text = _format_catalog_for_prompt(catalog.flows)
|
||||
guide_global_text = _format_guide_global_for_prompt()
|
||||
|
||||
user_content = f"""## 全局创作指南(L0–L3)
|
||||
{guide_global_text}
|
||||
|
||||
## 可选情绪流 catalog
|
||||
{catalog_text}
|
||||
|
||||
## 用户创作灵感
|
||||
{inspiration.strip()}
|
||||
|
||||
请根据以上信息优化开书方案。"""
|
||||
|
||||
return [
|
||||
SystemMessage(content=system_prompt),
|
||||
HumanMessage(content=user_content),
|
||||
]
|
||||
|
||||
|
||||
def _normalize_flow_ids(raw_ids: Any, valid_ids: set[str]) -> List[str]:
|
||||
if not isinstance(raw_ids, list):
|
||||
return []
|
||||
result: List[str] = []
|
||||
for item in raw_ids:
|
||||
fid = str(item).strip()
|
||||
if fid in valid_ids and fid not in result:
|
||||
result.append(fid)
|
||||
return result
|
||||
|
||||
|
||||
def _parse_open_book_response(
|
||||
data: Dict[str, Any], valid_flow_ids: set[str]
|
||||
) -> OpenBookResult:
|
||||
guide_raw = data.get("guide") or {}
|
||||
guide = FictionGuideWorldbook(
|
||||
persona=str(guide_raw.get("persona") or "").strip(),
|
||||
highlight=str(guide_raw.get("highlight") or "").strip(),
|
||||
experience=str(guide_raw.get("experience") or "").strip(),
|
||||
forbiddenZones=str(guide_raw.get("forbiddenZones") or "").strip(),
|
||||
)
|
||||
allowed = _normalize_flow_ids(data.get("allowedFlowIds"), valid_flow_ids)
|
||||
if not allowed and valid_flow_ids:
|
||||
allowed = [next(iter(valid_flow_ids))]
|
||||
|
||||
title = str(data.get("title") or "未命名作品").strip() or "未命名作品"
|
||||
optimized_intro = str(data.get("optimizedIntro") or "").strip()
|
||||
|
||||
return OpenBookResult(
|
||||
title=title,
|
||||
optimizedIntro=optimized_intro,
|
||||
guide=guide,
|
||||
allowedFlowIds=allowed,
|
||||
)
|
||||
|
||||
|
||||
async def run_open_book(
|
||||
inspiration: str,
|
||||
*,
|
||||
profile_id: Optional[str] = None,
|
||||
api_config: Optional[Dict[str, str]] = None,
|
||||
) -> OpenBookResult:
|
||||
inspiration = (inspiration or "").strip()
|
||||
if not inspiration:
|
||||
raise ValueError("创作灵感不能为空")
|
||||
|
||||
resolved = resolve_api_config(profile_id, api_config)
|
||||
_validate_api_config(resolved)
|
||||
|
||||
catalog = fiction_service.get_emotion_catalog()
|
||||
valid_flow_ids = {f.id for f in catalog.flows}
|
||||
|
||||
messages = _build_open_book_messages(inspiration)
|
||||
response = await _llm_client.chat_completion(
|
||||
messages=messages,
|
||||
api_url=resolved["api_url"],
|
||||
api_key=resolved["api_key"],
|
||||
model=resolved.get("model", "gpt-4o-mini"),
|
||||
temperature=0.7,
|
||||
max_tokens=8000,
|
||||
request_timeout=120,
|
||||
stream=False,
|
||||
)
|
||||
content = response["choices"][0]["message"]["content"]
|
||||
data = _extract_json(content)
|
||||
return _parse_open_book_response(data, valid_flow_ids)
|
||||
290
backend/services/fiction_orchestrator_service.py
Normal file
290
backend/services/fiction_orchestrator_service.py
Normal file
@@ -0,0 +1,290 @@
|
||||
"""
|
||||
爽文阅读流水线编排 — 新版 ensure 滚动补齐:卷纲 → 事件链 → 章纲 → 章节。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from models.fiction_models import (
|
||||
FictionPipelineSettings,
|
||||
FictionPipelineTickResult,
|
||||
FictionRunState,
|
||||
FictionStartReadingResult,
|
||||
)
|
||||
from services.fiction_chapter_service import (
|
||||
find_next_unwritten_chapter,
|
||||
has_written_chapters,
|
||||
run_chapter,
|
||||
)
|
||||
from services.fiction_metadata_service import fiction_metadata_service
|
||||
from services.fiction_planning_service import (
|
||||
ensure_chapter_plan,
|
||||
ensure_event_chain,
|
||||
ensure_volume,
|
||||
)
|
||||
from services.fiction_service import fiction_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_active_tasks: Dict[str, asyncio.Task] = {}
|
||||
_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def _get_pipeline_settings(book_id: str) -> FictionPipelineSettings:
|
||||
settings = fiction_service.get_book_settings(book_id)
|
||||
return settings.pipeline or FictionPipelineSettings()
|
||||
|
||||
|
||||
def _needs_volume(book_id: str) -> bool:
|
||||
metadata = fiction_metadata_service.get_metadata(book_id)
|
||||
return not metadata.volumes
|
||||
|
||||
|
||||
def _needs_event_chain(book_id: str) -> bool:
|
||||
metadata = fiction_metadata_service.get_metadata(book_id)
|
||||
if not metadata.volumes:
|
||||
return False
|
||||
for volume in metadata.volumes:
|
||||
if not metadata.eventChains.get(volume.id):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _needs_chapter_plan(book_id: str) -> bool:
|
||||
metadata = fiction_metadata_service.get_metadata(book_id)
|
||||
if not metadata.volumes:
|
||||
return False
|
||||
for volume in metadata.volumes:
|
||||
events = metadata.eventChains.get(volume.id, [])
|
||||
if not events:
|
||||
return False
|
||||
for event in events:
|
||||
if not metadata.chapterPlans.get(event.id):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _needs_chapter(book_id: str) -> bool:
|
||||
if _needs_volume(book_id) or _needs_event_chain(book_id) or _needs_chapter_plan(book_id):
|
||||
return False
|
||||
if has_written_chapters(book_id):
|
||||
return False
|
||||
metadata = fiction_metadata_service.get_metadata(book_id)
|
||||
return find_next_unwritten_chapter(book_id, metadata) is not None
|
||||
|
||||
|
||||
def _pipeline_complete(book_id: str) -> bool:
|
||||
return (
|
||||
not _needs_volume(book_id)
|
||||
and not _needs_event_chain(book_id)
|
||||
and not _needs_chapter_plan(book_id)
|
||||
and not _needs_chapter(book_id)
|
||||
)
|
||||
|
||||
|
||||
def get_pending_stages(book_id: str) -> List[str]:
|
||||
"""返回需手动触发的阶段 id 列表(auto 关闭且仍有工作,或上次失败需重试)。"""
|
||||
pipeline = _get_pipeline_settings(book_id)
|
||||
pending: List[str] = []
|
||||
if _needs_volume(book_id) and not pipeline.autoCoarse:
|
||||
pending.append("volume")
|
||||
if _needs_event_chain(book_id) and not pipeline.autoEventPlan:
|
||||
pending.append("event_chain")
|
||||
if _needs_chapter_plan(book_id) and not pipeline.autoEventPlan:
|
||||
pending.append("chapter_plan")
|
||||
if _needs_chapter(book_id) and not pipeline.autoChapter:
|
||||
pending.append("chapter")
|
||||
|
||||
run = fiction_metadata_service.get_run(book_id)
|
||||
if run.status == "error" and run.pipelineStage:
|
||||
retry_map = {
|
||||
"volume": "volume",
|
||||
"coarse": "volume",
|
||||
"event_chain": "event_chain",
|
||||
"event_plan": "chapter_plan",
|
||||
"chapter_plan": "chapter_plan",
|
||||
"chapter": "chapter",
|
||||
}
|
||||
failed = retry_map.get(run.pipelineStage)
|
||||
if failed and failed not in pending:
|
||||
if failed == "volume" and _needs_volume(book_id):
|
||||
pending.insert(0, failed)
|
||||
elif failed == "event_chain" and _needs_event_chain(book_id):
|
||||
pending.insert(0, failed)
|
||||
elif failed == "chapter_plan" and _needs_chapter_plan(book_id):
|
||||
pending.insert(0, failed)
|
||||
elif failed == "chapter" and _needs_chapter(book_id):
|
||||
pending.insert(0, failed)
|
||||
return pending
|
||||
|
||||
|
||||
def _next_auto_stage(book_id: str) -> Optional[str]:
|
||||
pipeline = _get_pipeline_settings(book_id)
|
||||
if _needs_volume(book_id):
|
||||
return "volume" if pipeline.autoCoarse else None
|
||||
if _needs_event_chain(book_id):
|
||||
return "event_chain" if pipeline.autoEventPlan else None
|
||||
if _needs_chapter_plan(book_id):
|
||||
return "chapter_plan" if pipeline.autoEventPlan else None
|
||||
if _needs_chapter(book_id):
|
||||
return "chapter" if pipeline.autoChapter else None
|
||||
return None
|
||||
|
||||
|
||||
async def _run_pipeline(
|
||||
book_id: str,
|
||||
*,
|
||||
profile_id: Optional[str] = None,
|
||||
api_config: Optional[Dict[str, str]] = None,
|
||||
) -> None:
|
||||
pipeline = _get_pipeline_settings(book_id)
|
||||
try:
|
||||
if _needs_volume(book_id):
|
||||
if not pipeline.autoCoarse:
|
||||
return
|
||||
await ensure_volume(
|
||||
book_id, profile_id=profile_id, api_config=api_config
|
||||
)
|
||||
|
||||
if _needs_event_chain(book_id):
|
||||
if not pipeline.autoEventPlan:
|
||||
return
|
||||
await ensure_event_chain(
|
||||
book_id, profile_id=profile_id, api_config=api_config
|
||||
)
|
||||
|
||||
if _needs_chapter_plan(book_id):
|
||||
if not pipeline.autoEventPlan:
|
||||
return
|
||||
await ensure_chapter_plan(
|
||||
book_id, profile_id=profile_id, api_config=api_config
|
||||
)
|
||||
|
||||
if _needs_chapter(book_id):
|
||||
if not pipeline.autoChapter:
|
||||
return
|
||||
await run_chapter(
|
||||
book_id, profile_id=profile_id, api_config=api_config
|
||||
)
|
||||
|
||||
if _pipeline_complete(book_id):
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="idle", pipeline_stage="ready"
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Fiction pipeline failed for book %s", book_id)
|
||||
finally:
|
||||
async with _lock:
|
||||
_active_tasks.pop(book_id, None)
|
||||
|
||||
|
||||
async def _task_is_active(book_id: str) -> bool:
|
||||
async with _lock:
|
||||
task = _active_tasks.get(book_id)
|
||||
return task is not None and not task.done()
|
||||
|
||||
|
||||
async def _start_pipeline_task(
|
||||
book_id: str,
|
||||
*,
|
||||
profile_id: Optional[str] = None,
|
||||
api_config: Optional[Dict[str, str]] = None,
|
||||
) -> FictionPipelineTickResult:
|
||||
run = fiction_metadata_service.get_run(book_id)
|
||||
pending = get_pending_stages(book_id)
|
||||
|
||||
if run.status == "running":
|
||||
if await _task_is_active(book_id):
|
||||
return FictionPipelineTickResult(run=run, started=False, pendingStages=pending)
|
||||
logger.warning("Stale running pipeline for book %s, resetting to idle", book_id)
|
||||
run = fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="idle", pipeline_stage=run.pipelineStage
|
||||
)
|
||||
|
||||
if run.status == "error":
|
||||
if _pipeline_complete(book_id):
|
||||
run = fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="idle", pipeline_stage="ready"
|
||||
)
|
||||
return FictionPipelineTickResult(
|
||||
run=run, started=False, pendingStages=pending
|
||||
)
|
||||
run = fiction_metadata_service.clear_pipeline_error(book_id)
|
||||
|
||||
if _pipeline_complete(book_id):
|
||||
if run.pipelineStage != "ready":
|
||||
run = fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="idle", pipeline_stage="ready"
|
||||
)
|
||||
return FictionPipelineTickResult(run=run, started=False, pendingStages=pending)
|
||||
|
||||
next_stage = _next_auto_stage(book_id)
|
||||
if not next_stage:
|
||||
if run.pipelineStage not in (
|
||||
None,
|
||||
"ready",
|
||||
"volume_done",
|
||||
"event_chain_done",
|
||||
"chapter_plan_done",
|
||||
"chapter_done",
|
||||
):
|
||||
run = fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="idle", pipeline_stage="ready"
|
||||
)
|
||||
return FictionPipelineTickResult(
|
||||
run=run, started=False, pendingStages=pending
|
||||
)
|
||||
|
||||
async with _lock:
|
||||
existing = _active_tasks.get(book_id)
|
||||
if existing and not existing.done():
|
||||
run = fiction_metadata_service.get_run(book_id)
|
||||
return FictionPipelineTickResult(
|
||||
run=run, started=False, pendingStages=pending
|
||||
)
|
||||
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="running", pipeline_stage=next_stage
|
||||
)
|
||||
|
||||
task = asyncio.create_task(
|
||||
_run_pipeline(
|
||||
book_id, profile_id=profile_id, api_config=api_config
|
||||
)
|
||||
)
|
||||
_active_tasks[book_id] = task
|
||||
|
||||
run = fiction_metadata_service.get_run(book_id)
|
||||
pending = get_pending_stages(book_id)
|
||||
return FictionPipelineTickResult(run=run, started=True, pendingStages=pending)
|
||||
|
||||
|
||||
async def tick_reading_pipeline(
|
||||
book_id: str,
|
||||
*,
|
||||
profile_id: Optional[str] = None,
|
||||
api_config: Optional[Dict[str, str]] = None,
|
||||
) -> FictionPipelineTickResult:
|
||||
"""检查 metadata + settings,按需启动下一自动阶段。"""
|
||||
return await _start_pipeline_task(
|
||||
book_id, profile_id=profile_id, api_config=api_config
|
||||
)
|
||||
|
||||
|
||||
async def start_reading_pipeline(
|
||||
book_id: str,
|
||||
*,
|
||||
profile_id: Optional[str] = None,
|
||||
api_config: Optional[Dict[str, str]] = None,
|
||||
) -> FictionStartReadingResult:
|
||||
"""进入阅读时的流水线入口(兼容旧接口)。"""
|
||||
result = await tick_reading_pipeline(
|
||||
book_id, profile_id=profile_id, api_config=api_config
|
||||
)
|
||||
return FictionStartReadingResult(run=result.run, started=result.started)
|
||||
|
||||
|
||||
def get_pipeline_run(book_id: str) -> FictionRunState:
|
||||
return fiction_metadata_service.get_run(book_id)
|
||||
540
backend/services/fiction_planning_service.py
Normal file
540
backend/services/fiction_planning_service.py
Normal file
@@ -0,0 +1,540 @@
|
||||
"""
|
||||
爽文新版规划服务:卷纲 → 情感链事件串 → 章纲。
|
||||
|
||||
原则:
|
||||
- 硬编码提示词只保留规定性约束:输出结构、字数/章节数、必须遵循的上游内容。
|
||||
- “如何写爽点/如何留钩子”等创作方法交给 book-local 世界书与全局指南。
|
||||
- book-local 世界书按 volume/event/chapter 三层分别插入,不混用。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
|
||||
from models.fiction_models import (
|
||||
ChapterPlanItem,
|
||||
EventChainItem,
|
||||
FictionBookMetadata,
|
||||
VolumeOutline,
|
||||
)
|
||||
from services.fiction_metadata_service import fiction_metadata_service
|
||||
from services.fiction_prompt_utils import resolve_prompt
|
||||
from services.fiction_service import fiction_service
|
||||
from services.studio_step_respond import resolve_api_config
|
||||
from utils.llm_client import LLMClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_llm_client = LLMClient()
|
||||
_JSON_FENCE = re.compile(r"```(?:json)?\s*([\s\S]*?)```", re.IGNORECASE)
|
||||
|
||||
|
||||
def _extract_json(raw: str) -> Dict[str, Any]:
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
raise ValueError("模型返回为空")
|
||||
fence = _JSON_FENCE.search(text)
|
||||
if fence:
|
||||
text = fence.group(1).strip()
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
start = text.find("{")
|
||||
end = text.rfind("}")
|
||||
if start >= 0 and end > start:
|
||||
return json.loads(text[start : end + 1])
|
||||
raise ValueError("无法解析模型返回的 JSON")
|
||||
|
||||
|
||||
def _validate_api_config(api_config: Dict[str, str]) -> None:
|
||||
if not api_config.get("api_key"):
|
||||
raise ValueError("API Key 未配置,请先在 API 配置页面保存密钥")
|
||||
if not api_config.get("api_url"):
|
||||
raise ValueError("API 地址未配置,请先在 API 配置页面保存 mainLLM")
|
||||
if not api_config.get("model"):
|
||||
raise ValueError("模型未配置,请先在 API 配置页面保存 mainLLM 模型")
|
||||
|
||||
|
||||
def _format_guide_global_layers(layers: List[str]) -> str:
|
||||
entries = fiction_service.get_guide_global_entries().entries
|
||||
filtered = [e for e in entries if e.layer in layers]
|
||||
lines: List[str] = []
|
||||
for entry in filtered:
|
||||
lines.append(f"[{entry.layer}] {entry.title}\n{entry.content}")
|
||||
return "\n\n".join(lines) if lines else "(无全局指南)"
|
||||
|
||||
|
||||
def _format_book_guide(book_id: str) -> str:
|
||||
guide = fiction_service.get_book_guide(book_id)
|
||||
lines = [
|
||||
f"主角人设:{guide.persona}",
|
||||
f"核心爽点:{guide.highlight}",
|
||||
f"用户体验:{guide.experience}",
|
||||
f"创作禁区:{guide.forbiddenZones}",
|
||||
]
|
||||
text = "\n".join(line for line in lines if line.split(":", 1)[1].strip()).strip()
|
||||
return text or "(无本书 guide)"
|
||||
|
||||
|
||||
def _flow_catalog_text() -> str:
|
||||
catalog = fiction_service.get_emotion_catalog()
|
||||
lines: List[str] = []
|
||||
for flow in catalog.flows:
|
||||
steps = " → ".join([f"{s.key}:{s.text}" for s in flow.steps])
|
||||
lines.append(f"- {flow.id}: {flow.intro} | steps={steps}")
|
||||
return "\n".join(lines) if lines else "(无情感链目录)"
|
||||
|
||||
|
||||
def _get_flow_by_id(flow_id: str):
|
||||
catalog = fiction_service.get_emotion_catalog()
|
||||
for flow in catalog.flows:
|
||||
if flow.id == flow_id:
|
||||
return flow
|
||||
return None
|
||||
|
||||
|
||||
def _choose_flow_id(book_id: str) -> str:
|
||||
meta = fiction_service.get_book_meta(book_id)
|
||||
allowed = list(meta.allowedFlowIds or [])
|
||||
catalog = fiction_service.get_emotion_catalog()
|
||||
catalog_ids = [flow.id for flow in catalog.flows]
|
||||
candidates = [fid for fid in allowed if fid in catalog_ids] or allowed or catalog_ids
|
||||
if not candidates:
|
||||
return ""
|
||||
return random.choice(candidates)
|
||||
|
||||
|
||||
def _format_flow(flow_id: str) -> str:
|
||||
flow = _get_flow_by_id(flow_id)
|
||||
if not flow:
|
||||
return f"情感链 id: {flow_id or '(未指定)'}"
|
||||
lines = [f"情感链:{flow.intro} (id: {flow.id})"]
|
||||
for step in flow.steps or []:
|
||||
lines.append(f"- {step.key}: {step.text}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _next_volume_id(metadata: FictionBookMetadata) -> str:
|
||||
return f"vol_{len(metadata.volumes) + 1:03d}"
|
||||
|
||||
|
||||
def _next_event_id(metadata: FictionBookMetadata, index: int) -> str:
|
||||
all_events = [event for chain in metadata.eventChains.values() for event in chain]
|
||||
return f"evt_{len(all_events) + index + 1:04d}"
|
||||
|
||||
|
||||
def _build_volume_messages(book_id: str, metadata: FictionBookMetadata) -> List[Any]:
|
||||
settings = fiction_service.get_book_settings(book_id)
|
||||
user_prompt = (
|
||||
settings.prompts.coarseOutline
|
||||
or fiction_service.get_default_settings().prompts.coarseOutline
|
||||
)
|
||||
system_prompt = resolve_prompt("volumeOutline", user_prompt)
|
||||
|
||||
meta = fiction_service.get_book_meta(book_id)
|
||||
guide_l1 = _format_guide_global_layers(["L1"])
|
||||
book_guide = _format_book_guide(book_id)
|
||||
existing = "\n".join([f"- {v.id} {v.title}: {v.goal}" for v in metadata.volumes]) or "(暂无)"
|
||||
|
||||
user_content = f"""## 全局创作指南(L1)
|
||||
{guide_l1}
|
||||
|
||||
## 本书 guide(具体人设/爽点/体验/禁区)
|
||||
{book_guide}
|
||||
|
||||
## 书名
|
||||
{meta.title}
|
||||
|
||||
## 已有卷纲
|
||||
{existing}
|
||||
|
||||
## 可用情感链目录
|
||||
{_flow_catalog_text()}
|
||||
|
||||
## 本次任务
|
||||
生成下一卷卷纲。
|
||||
|
||||
## 绝对要求
|
||||
- 只生成 1 卷。
|
||||
- 本卷目标章节数 targetChapterCount 必须在 10 到 30 之间。
|
||||
- primaryEmotionFlowId 必须来自可用情感链目录;如目录为空则留空。
|
||||
- 不生成事件链、章纲或正文。
|
||||
"""
|
||||
|
||||
return [SystemMessage(content=system_prompt), HumanMessage(content=user_content)]
|
||||
|
||||
|
||||
def _normalize_volume(raw: Dict[str, Any], volume_id: str, order: int) -> VolumeOutline:
|
||||
target = raw.get("targetChapterCount")
|
||||
if not isinstance(target, int):
|
||||
target = 20
|
||||
return VolumeOutline(
|
||||
id=str(raw.get("id") or volume_id),
|
||||
order=order,
|
||||
title=str(raw.get("title") or f"第{order}卷"),
|
||||
goal=str(raw.get("goal") or ""),
|
||||
coreConflict=str(raw.get("coreConflict") or raw.get("core_conflict") or ""),
|
||||
powerProgression=str(raw.get("powerProgression") or raw.get("power_progression") or ""),
|
||||
emotionalPromise=str(raw.get("emotionalPromise") or raw.get("emotional_promise") or ""),
|
||||
endingHook=str(raw.get("endingHook") or raw.get("ending_hook") or ""),
|
||||
targetChapterCount=max(10, min(30, target)),
|
||||
primaryEmotionFlowId=str(raw.get("primaryEmotionFlowId") or raw.get("primary_emotion_flow_id") or ""),
|
||||
status=str(raw.get("status") or "active"),
|
||||
)
|
||||
|
||||
|
||||
def _build_event_chain_messages(book_id: str, volume: VolumeOutline, flow_id: str) -> List[Any]:
|
||||
settings = fiction_service.get_book_settings(book_id)
|
||||
user_prompt = (
|
||||
settings.prompts.eventPlan
|
||||
or fiction_service.get_default_settings().prompts.eventPlan
|
||||
)
|
||||
system_prompt = resolve_prompt("eventChain", user_prompt)
|
||||
|
||||
guide_l2 = _format_guide_global_layers(["L2"])
|
||||
book_guide = _format_book_guide(book_id)
|
||||
flow_text = _format_flow(flow_id)
|
||||
|
||||
user_content = f"""## 全局创作指南(L2)
|
||||
{guide_l2}
|
||||
|
||||
## 本书 guide(具体人设/爽点/体验/禁区)
|
||||
{book_guide}
|
||||
|
||||
## 当前卷纲
|
||||
- id: {volume.id}
|
||||
- title: {volume.title}
|
||||
- goal: {volume.goal}
|
||||
- coreConflict: {volume.coreConflict}
|
||||
- powerProgression: {volume.powerProgression}
|
||||
- emotionalPromise: {volume.emotionalPromise}
|
||||
- endingHook: {volume.endingHook}
|
||||
- targetChapterCount: {volume.targetChapterCount}
|
||||
|
||||
## 必须遵循的情感链
|
||||
{flow_text}
|
||||
|
||||
## 本次任务
|
||||
生成当前卷的事件链。
|
||||
|
||||
## 绝对要求
|
||||
- 事件链总章节数应接近卷纲 targetChapterCount。
|
||||
- 每个事件 targetChapterCount 必须在 2 到 5 之间。
|
||||
- 每个事件必须填写 emotionFlowId、emotionStepKey、emotionStepText。
|
||||
- 事件顺序必须遵循情感链 steps 的顺序,不得倒置。
|
||||
- 不生成章纲或正文。
|
||||
"""
|
||||
|
||||
return [SystemMessage(content=system_prompt), HumanMessage(content=user_content)]
|
||||
|
||||
|
||||
def _normalize_event_chain(
|
||||
raw: Any,
|
||||
metadata: FictionBookMetadata,
|
||||
volume: VolumeOutline,
|
||||
flow_id: str,
|
||||
) -> List[EventChainItem]:
|
||||
raw_events = raw if isinstance(raw, list) else []
|
||||
events: List[EventChainItem] = []
|
||||
flow = _get_flow_by_id(flow_id)
|
||||
steps = flow.steps if flow else []
|
||||
for idx, item in enumerate(raw_events):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
target = item.get("targetChapterCount")
|
||||
if not isinstance(target, int):
|
||||
target = 3
|
||||
step = steps[min(idx, len(steps) - 1)] if steps else None
|
||||
events.append(
|
||||
EventChainItem(
|
||||
id=str(item.get("id") or _next_event_id(metadata, idx)),
|
||||
volumeId=volume.id,
|
||||
order=int(item.get("order")) if isinstance(item.get("order"), int) else idx + 1,
|
||||
title=str(item.get("title") or f"事件 {idx + 1}"),
|
||||
summary=str(item.get("summary") or ""),
|
||||
purpose=str(item.get("purpose") or ""),
|
||||
conflict=str(item.get("conflict") or ""),
|
||||
turningPoint=str(item.get("turningPoint") or item.get("turning_point") or ""),
|
||||
expectedPayoff=str(item.get("expectedPayoff") or item.get("expected_payoff") or ""),
|
||||
targetChapterCount=max(2, min(5, target)),
|
||||
emotionFlowId=str(item.get("emotionFlowId") or item.get("emotion_flow_id") or flow_id),
|
||||
emotionStepKey=str(item.get("emotionStepKey") or item.get("emotion_step_key") or (step.key if step else "")),
|
||||
emotionStepText=str(item.get("emotionStepText") or item.get("emotion_step_text") or (step.text if step else "")),
|
||||
status=str(item.get("status") or "planned"),
|
||||
)
|
||||
)
|
||||
events.sort(key=lambda e: e.order)
|
||||
if not events:
|
||||
raise ValueError("事件链为空")
|
||||
return events
|
||||
|
||||
|
||||
def _build_chapter_plan_messages(
|
||||
book_id: str,
|
||||
volume: VolumeOutline,
|
||||
event: EventChainItem,
|
||||
) -> List[Any]:
|
||||
settings = fiction_service.get_book_settings(book_id)
|
||||
user_prompt = (
|
||||
settings.prompts.eventPlan
|
||||
or fiction_service.get_default_settings().prompts.eventPlan
|
||||
)
|
||||
system_prompt = resolve_prompt("chapterPlan", user_prompt)
|
||||
|
||||
guide_l3 = _format_guide_global_layers(["L3"])
|
||||
book_guide = _format_book_guide(book_id)
|
||||
|
||||
user_content = f"""## 全局创作指南(L3)
|
||||
{guide_l3}
|
||||
|
||||
## 本书 guide(具体人设/爽点/体验/禁区)
|
||||
{book_guide}
|
||||
|
||||
## 当前卷纲
|
||||
- id: {volume.id}
|
||||
- title: {volume.title}
|
||||
- goal: {volume.goal}
|
||||
- coreConflict: {volume.coreConflict}
|
||||
- emotionalPromise: {volume.emotionalPromise}
|
||||
|
||||
## 当前事件
|
||||
- id: {event.id}
|
||||
- title: {event.title}
|
||||
- summary: {event.summary}
|
||||
- purpose: {event.purpose}
|
||||
- conflict: {event.conflict}
|
||||
- turningPoint: {event.turningPoint}
|
||||
- expectedPayoff: {event.expectedPayoff}
|
||||
- targetChapterCount: {event.targetChapterCount}
|
||||
|
||||
## 当前事件绑定的情感链步骤
|
||||
- emotionFlowId: {event.emotionFlowId}
|
||||
- emotionStepKey: {event.emotionStepKey}
|
||||
- emotionStepText: {event.emotionStepText}
|
||||
|
||||
## 本次任务
|
||||
为当前事件生成章纲。
|
||||
|
||||
## 绝对要求
|
||||
- 必须生成 {event.targetChapterCount} 章章纲。
|
||||
- 每章 targetWords 必须为 2000。
|
||||
- 每章必须继承当前事件 id。
|
||||
- 每章必须填写 emotionStepKey 与 emotionGoal。
|
||||
- 不生成正文。
|
||||
"""
|
||||
|
||||
return [SystemMessage(content=system_prompt), HumanMessage(content=user_content)]
|
||||
|
||||
|
||||
def _next_chapter_seq(metadata: FictionBookMetadata) -> int:
|
||||
max_seq = 0
|
||||
for plans in metadata.chapterPlans.values():
|
||||
for item in plans:
|
||||
max_seq = max(max_seq, item.seq)
|
||||
return max_seq + 1
|
||||
|
||||
|
||||
def _normalize_chapter_plans(
|
||||
raw: Any,
|
||||
metadata: FictionBookMetadata,
|
||||
event: EventChainItem,
|
||||
) -> List[ChapterPlanItem]:
|
||||
raw_items = raw if isinstance(raw, list) else []
|
||||
start_seq = _next_chapter_seq(metadata)
|
||||
items: List[ChapterPlanItem] = []
|
||||
for idx, item in enumerate(raw_items):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
seq = start_seq + idx
|
||||
title = str(item.get("title") or f"第{seq}章")
|
||||
goal = str(item.get("goal") or item.get("brief") or "")
|
||||
items.append(
|
||||
ChapterPlanItem(
|
||||
seq=seq,
|
||||
phaseKey=str(item.get("phaseKey") or event.emotionStepKey),
|
||||
phaseSlice=str(item.get("phaseSlice") or ""),
|
||||
brief=str(item.get("brief") or goal),
|
||||
eventId=event.id,
|
||||
title=title,
|
||||
goal=goal,
|
||||
opening=str(item.get("opening") or ""),
|
||||
mainConflict=str(item.get("mainConflict") or item.get("main_conflict") or event.conflict),
|
||||
emotionalTurn=str(item.get("emotionalTurn") or item.get("emotional_turn") or ""),
|
||||
emotionStepKey=str(item.get("emotionStepKey") or item.get("emotion_step_key") or event.emotionStepKey),
|
||||
emotionGoal=str(item.get("emotionGoal") or item.get("emotion_goal") or event.emotionStepText),
|
||||
payoff=str(item.get("payoff") or event.expectedPayoff),
|
||||
endingHook=str(item.get("endingHook") or item.get("ending_hook") or ""),
|
||||
forbidden=str(item.get("forbidden") or ""),
|
||||
targetWords=2000,
|
||||
status=str(item.get("status") or "planned"),
|
||||
)
|
||||
)
|
||||
items.sort(key=lambda c: c.seq)
|
||||
if not items:
|
||||
raise ValueError("章纲为空")
|
||||
return items
|
||||
|
||||
|
||||
async def ensure_volume(
|
||||
book_id: str,
|
||||
*,
|
||||
profile_id: Optional[str] = None,
|
||||
api_config: Optional[Dict[str, str]] = None,
|
||||
) -> FictionBookMetadata:
|
||||
metadata = fiction_metadata_service.get_metadata(book_id)
|
||||
if metadata.volumes:
|
||||
return metadata
|
||||
|
||||
resolved = resolve_api_config(profile_id, api_config)
|
||||
_validate_api_config(resolved)
|
||||
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="running", pipeline_stage="volume"
|
||||
)
|
||||
try:
|
||||
messages = _build_volume_messages(book_id, metadata)
|
||||
response = await _llm_client.chat_completion(
|
||||
messages=messages,
|
||||
api_url=resolved["api_url"],
|
||||
api_key=resolved["api_key"],
|
||||
model=resolved["model"],
|
||||
temperature=0.7,
|
||||
max_tokens=8000,
|
||||
request_timeout=120,
|
||||
stream=False,
|
||||
)
|
||||
data = _extract_json(response["choices"][0]["message"]["content"])
|
||||
volume = _normalize_volume(
|
||||
data.get("volume") if isinstance(data.get("volume"), dict) else data,
|
||||
_next_volume_id(metadata),
|
||||
len(metadata.volumes) + 1,
|
||||
)
|
||||
if not volume.primaryEmotionFlowId:
|
||||
volume.primaryEmotionFlowId = _choose_flow_id(book_id)
|
||||
metadata.volumes.append(volume)
|
||||
saved = fiction_metadata_service.save_metadata(book_id, metadata)
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="idle", pipeline_stage="volume_done"
|
||||
)
|
||||
return saved
|
||||
except Exception:
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="error", pipeline_stage="volume"
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
async def ensure_event_chain(
|
||||
book_id: str,
|
||||
*,
|
||||
volume_id: Optional[str] = None,
|
||||
profile_id: Optional[str] = None,
|
||||
api_config: Optional[Dict[str, str]] = None,
|
||||
) -> FictionBookMetadata:
|
||||
metadata = await ensure_volume(book_id, profile_id=profile_id, api_config=api_config)
|
||||
volume = next((v for v in metadata.volumes if v.id == volume_id), metadata.volumes[-1])
|
||||
if metadata.eventChains.get(volume.id):
|
||||
return metadata
|
||||
|
||||
resolved = resolve_api_config(profile_id, api_config)
|
||||
_validate_api_config(resolved)
|
||||
|
||||
flow_id = volume.primaryEmotionFlowId or _choose_flow_id(book_id)
|
||||
volume.primaryEmotionFlowId = flow_id
|
||||
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="running", pipeline_stage="event_chain"
|
||||
)
|
||||
try:
|
||||
messages = _build_event_chain_messages(book_id, volume, flow_id)
|
||||
response = await _llm_client.chat_completion(
|
||||
messages=messages,
|
||||
api_url=resolved["api_url"],
|
||||
api_key=resolved["api_key"],
|
||||
model=resolved["model"],
|
||||
temperature=0.7,
|
||||
max_tokens=8000,
|
||||
request_timeout=120,
|
||||
stream=False,
|
||||
)
|
||||
data = _extract_json(response["choices"][0]["message"]["content"])
|
||||
raw_events = data.get("events") or data.get("eventChain") or data.get("event_chain")
|
||||
events = _normalize_event_chain(raw_events, metadata, volume, flow_id)
|
||||
metadata.eventChains[volume.id] = events
|
||||
saved = fiction_metadata_service.save_metadata(book_id, metadata)
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="idle", pipeline_stage="event_chain_done"
|
||||
)
|
||||
return saved
|
||||
except Exception:
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="error", pipeline_stage="event_chain"
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
async def ensure_chapter_plan(
|
||||
book_id: str,
|
||||
*,
|
||||
event_id: Optional[str] = None,
|
||||
profile_id: Optional[str] = None,
|
||||
api_config: Optional[Dict[str, str]] = None,
|
||||
) -> FictionBookMetadata:
|
||||
metadata = await ensure_event_chain(book_id, profile_id=profile_id, api_config=api_config)
|
||||
|
||||
target_event: Optional[EventChainItem] = None
|
||||
target_volume: Optional[VolumeOutline] = None
|
||||
for volume in metadata.volumes:
|
||||
for event in metadata.eventChains.get(volume.id, []):
|
||||
if event_id and event.id != event_id:
|
||||
continue
|
||||
if metadata.chapterPlans.get(event.id):
|
||||
if event_id:
|
||||
return metadata
|
||||
continue
|
||||
target_event = event
|
||||
target_volume = volume
|
||||
break
|
||||
if target_event:
|
||||
break
|
||||
|
||||
if not target_event or not target_volume:
|
||||
return metadata
|
||||
|
||||
resolved = resolve_api_config(profile_id, api_config)
|
||||
_validate_api_config(resolved)
|
||||
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="running", pipeline_stage="chapter_plan"
|
||||
)
|
||||
try:
|
||||
messages = _build_chapter_plan_messages(book_id, target_volume, target_event)
|
||||
response = await _llm_client.chat_completion(
|
||||
messages=messages,
|
||||
api_url=resolved["api_url"],
|
||||
api_key=resolved["api_key"],
|
||||
model=resolved["model"],
|
||||
temperature=0.7,
|
||||
max_tokens=8000,
|
||||
request_timeout=120,
|
||||
stream=False,
|
||||
)
|
||||
data = _extract_json(response["choices"][0]["message"]["content"])
|
||||
raw_items = data.get("chapterPlan") or data.get("chapters") or data.get("chapter_plan")
|
||||
plans = _normalize_chapter_plans(raw_items, metadata, target_event)
|
||||
metadata.chapterPlans[target_event.id] = plans
|
||||
saved = fiction_metadata_service.save_metadata(book_id, metadata)
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="idle", pipeline_stage="chapter_plan_done"
|
||||
)
|
||||
return saved
|
||||
except Exception:
|
||||
fiction_metadata_service.set_pipeline_stage(
|
||||
book_id, status="error", pipeline_stage="chapter_plan"
|
||||
)
|
||||
raise
|
||||
125
backend/services/fiction_prompt_utils.py
Normal file
125
backend/services/fiction_prompt_utils.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
爽文提示词解析 — 用户自然语言 + 内部 JSON 输出格式(不暴露给前端)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict
|
||||
|
||||
# 新建书籍时的默认用户向提示(自然语言,不含 JSON 结构)
|
||||
USER_DEFAULT_PROMPTS: Dict[str, str] = {
|
||||
"openBook": (
|
||||
"你是爽文开书优化助手。根据用户创作灵感,提炼书名、优化简介,"
|
||||
"并生成主角人设、核心爽点、读者体验策略与创作禁区。"
|
||||
"从情绪流目录中挑选 1–4 个最匹配的条目。"
|
||||
),
|
||||
"coarseOutline": (
|
||||
"你是爽文大纲助手。根据本书设定与进度,生成事件链级别的粗纲,"
|
||||
"每个事件包含标题与概要,节奏紧凑、爽点清晰。"
|
||||
),
|
||||
"eventPlan": (
|
||||
"你是爽文事件规划助手。将粗纲中的事件展开为章节级计划,"
|
||||
"结合情绪流起承转合,为每章规划核心冲突与爽点。"
|
||||
),
|
||||
"chapter": (
|
||||
"你是爽文章节写作助手。根据事件计划、guide 设定与上文撰写正文,"
|
||||
"节奏明快、对话推动冲突、章末留钩子。"
|
||||
),
|
||||
"nudge": (
|
||||
"你是爽文创作教练。根据当前进度与读者体验目标,"
|
||||
"给出 1–3 条简短的下一步写作建议,不直接写正文。"
|
||||
),
|
||||
}
|
||||
|
||||
# 调用 LLM 时在系统提示末尾追加的输出格式(用户 UI 不可见)
|
||||
_INTERNAL_FORMAT: Dict[str, str] = {
|
||||
"openBook": """
|
||||
|
||||
【输出格式】只输出 JSON,不要 markdown 代码块外的文字:
|
||||
{
|
||||
"title": "书名",
|
||||
"optimizedIntro": "优化后的开书灵感",
|
||||
"guide": {
|
||||
"persona": "主角人设:身份、性格、欲望、能力边界与成长方向",
|
||||
"highlight": "核心爽点:本书最稳定兑现的爽点、打脸方式、升级/获得感",
|
||||
"experience": "用户体验:视角/人称、听感、节奏、世界感与读者情绪承诺",
|
||||
"forbiddenZones": "创作禁区:不能写、不能破坏、不能弱化的内容"
|
||||
},
|
||||
"allowedFlowIds": ["flow-id"]
|
||||
}""",
|
||||
"volumeOutline": """
|
||||
|
||||
【输出格式】只输出 JSON,不要 markdown 代码块外的文字:
|
||||
{
|
||||
"id": "vol_001",
|
||||
"order": 1,
|
||||
"title": "卷名",
|
||||
"goal": "本卷目标",
|
||||
"coreConflict": "本卷核心冲突",
|
||||
"powerProgression": "本卷成长/变化",
|
||||
"emotionalPromise": "本卷情绪承诺",
|
||||
"endingHook": "本卷结尾钩子",
|
||||
"targetChapterCount": 20,
|
||||
"primaryEmotionFlowId": "emotion-flow-id",
|
||||
"status": "active"
|
||||
}""",
|
||||
"eventChain": """
|
||||
|
||||
【输出格式】只输出 JSON,不要 markdown 代码块外的文字:
|
||||
{
|
||||
"events": [
|
||||
{
|
||||
"id": "evt_0001",
|
||||
"volumeId": "vol_001",
|
||||
"order": 1,
|
||||
"title": "事件标题",
|
||||
"summary": "事件概要",
|
||||
"purpose": "事件作用",
|
||||
"conflict": "事件冲突",
|
||||
"turningPoint": "事件转折",
|
||||
"expectedPayoff": "预期兑现",
|
||||
"targetChapterCount": 3,
|
||||
"emotionFlowId": "emotion-flow-id",
|
||||
"emotionStepKey": "情感链步骤 key",
|
||||
"emotionStepText": "情感链步骤 text",
|
||||
"status": "planned"
|
||||
}
|
||||
]
|
||||
}""",
|
||||
"chapterPlan": """
|
||||
|
||||
【输出格式】只输出 JSON,不要 markdown 代码块外的文字:
|
||||
{
|
||||
"chapterPlan": [
|
||||
{
|
||||
"title": "章标题",
|
||||
"goal": "本章目标",
|
||||
"opening": "开场内容",
|
||||
"mainConflict": "本章主要冲突",
|
||||
"emotionalTurn": "本章情绪变化",
|
||||
"emotionStepKey": "情感链步骤 key",
|
||||
"emotionGoal": "本章情绪目标",
|
||||
"payoff": "本章兑现",
|
||||
"endingHook": "章末信息",
|
||||
"forbidden": "本章禁止事项",
|
||||
"targetWords": 2000,
|
||||
"status": "planned"
|
||||
}
|
||||
]
|
||||
}""",
|
||||
"chapter": """
|
||||
|
||||
【输出格式】只输出 JSON:
|
||||
{ "title": "章标题", "body": "正文(可分段)" }""",
|
||||
"nudge": "",
|
||||
}
|
||||
|
||||
|
||||
def resolve_prompt(prompt_key: str, user_text: str | None) -> str:
|
||||
"""合并用户自然语言指令与内部 JSON 输出格式,供 LLM 系统提示使用。"""
|
||||
base = (user_text or "").strip()
|
||||
if not base:
|
||||
base = USER_DEFAULT_PROMPTS.get(prompt_key, "")
|
||||
fmt = _INTERNAL_FORMAT.get(prompt_key, "")
|
||||
if fmt and fmt.strip() not in base:
|
||||
return base + fmt
|
||||
return base
|
||||
302
backend/services/fiction_service.py
Normal file
302
backend/services/fiction_service.py
Normal file
@@ -0,0 +1,302 @@
|
||||
"""
|
||||
爽文书籍 CRUD 与全局资源读取。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from core.config import settings
|
||||
from models.fiction_models import (
|
||||
CreateFictionBookRequest,
|
||||
EmotionFlowCatalog,
|
||||
FictionBookMeta,
|
||||
FictionBookSettings,
|
||||
FictionBookSummary,
|
||||
FictionChapter,
|
||||
FictionChapterSummary,
|
||||
FictionGuideWorldbook,
|
||||
FictionPipelineSettings,
|
||||
FictionPrompts,
|
||||
FictionReaderSettings,
|
||||
GuideGlobalEntries,
|
||||
UpdateFictionBookSettingsRequest,
|
||||
)
|
||||
from services.fiction_prompt_utils import USER_DEFAULT_PROMPTS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_PROMPTS = FictionPrompts(
|
||||
openBook=USER_DEFAULT_PROMPTS["openBook"],
|
||||
coarseOutline=USER_DEFAULT_PROMPTS["coarseOutline"],
|
||||
eventPlan=USER_DEFAULT_PROMPTS["eventPlan"],
|
||||
chapter=USER_DEFAULT_PROMPTS["chapter"],
|
||||
nudge=USER_DEFAULT_PROMPTS["nudge"],
|
||||
)
|
||||
|
||||
DEFAULT_READER = FictionReaderSettings()
|
||||
DEFAULT_PIPELINE = FictionPipelineSettings()
|
||||
|
||||
DEFAULT_METADATA: Dict[str, Any] = {
|
||||
"version": 2,
|
||||
"volumes": [],
|
||||
"eventChains": {},
|
||||
"chapterPlans": {},
|
||||
"progress": {
|
||||
"currentChapterSeq": 0,
|
||||
"charOffset": 0,
|
||||
"ttsPaused": False,
|
||||
"genPaused": False,
|
||||
},
|
||||
}
|
||||
|
||||
DEFAULT_RUN: Dict[str, Any] = {
|
||||
"status": "idle",
|
||||
"pipelineStage": None,
|
||||
"stage": "idle",
|
||||
"message": None,
|
||||
"updatedAt": "",
|
||||
}
|
||||
|
||||
|
||||
def _read_json(path: Path) -> Any:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _write_json(path: Path, data: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _slugify(text: str) -> str:
|
||||
text = (text or "").strip()
|
||||
text = re.sub(r"[^\w\u4e00-\u9fff\-]+", "-", text, flags=re.UNICODE)
|
||||
text = re.sub(r"-+", "-", text).strip("-")
|
||||
return text[:48] or "book"
|
||||
|
||||
|
||||
class FictionService:
|
||||
@property
|
||||
def books_root(self) -> Path:
|
||||
return settings.FICTION_BOOKS_PATH
|
||||
|
||||
def _book_dir(self, book_id: str) -> Path:
|
||||
return self.books_root / book_id
|
||||
|
||||
def _meta_path(self, book_id: str) -> Path:
|
||||
return self._book_dir(book_id) / "meta.json"
|
||||
|
||||
def _settings_path(self, book_id: str) -> Path:
|
||||
return self._book_dir(book_id) / "settings.json"
|
||||
|
||||
def _guide_path(self, book_id: str) -> Path:
|
||||
return self._book_dir(book_id) / "guide.worldbook.json"
|
||||
|
||||
def _metadata_path(self, book_id: str) -> Path:
|
||||
return self._book_dir(book_id) / "metadata.json"
|
||||
|
||||
def _run_path(self, book_id: str) -> Path:
|
||||
return self._book_dir(book_id) / "run.json"
|
||||
|
||||
def _chapters_dir(self, book_id: str) -> Path:
|
||||
return self._book_dir(book_id) / "chapters"
|
||||
|
||||
def _chapter_path(self, book_id: str, seq: int) -> Path:
|
||||
return self._chapters_dir(book_id) / f"{seq:04d}.json"
|
||||
|
||||
def chapter_exists(self, book_id: str, seq: int) -> bool:
|
||||
return self._chapter_path(book_id, seq).exists()
|
||||
|
||||
def get_chapter(self, book_id: str, seq: int) -> FictionChapter:
|
||||
path = self._chapter_path(book_id, seq)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Chapter not found: {book_id}/{seq}")
|
||||
return FictionChapter(**_read_json(path))
|
||||
|
||||
def save_chapter(self, book_id: str, chapter: FictionChapter) -> FictionChapter:
|
||||
self._ensure_book(book_id)
|
||||
_write_json(self._chapter_path(book_id, chapter.seq), chapter.model_dump())
|
||||
self._touch_book_meta(book_id)
|
||||
return chapter
|
||||
|
||||
def list_written_chapter_seqs(self, book_id: str) -> List[int]:
|
||||
chapters_dir = self._chapters_dir(book_id)
|
||||
if not chapters_dir.exists():
|
||||
return []
|
||||
seqs: List[int] = []
|
||||
for path in chapters_dir.glob("*.json"):
|
||||
try:
|
||||
seqs.append(int(path.stem))
|
||||
except ValueError:
|
||||
continue
|
||||
return sorted(seqs)
|
||||
|
||||
def list_chapter_summaries(self, book_id: str) -> List[FictionChapterSummary]:
|
||||
summaries: List[FictionChapterSummary] = []
|
||||
for seq in self.list_written_chapter_seqs(book_id):
|
||||
ch = self.get_chapter(book_id, seq)
|
||||
summaries.append(
|
||||
FictionChapterSummary(
|
||||
seq=ch.seq,
|
||||
title=ch.title,
|
||||
charCount=ch.charCount,
|
||||
eventId=ch.eventId,
|
||||
phaseKey=ch.phaseKey,
|
||||
)
|
||||
)
|
||||
return summaries
|
||||
|
||||
def _ensure_book(self, book_id: str) -> None:
|
||||
if not self._meta_path(book_id).exists():
|
||||
raise FileNotFoundError(f"Book not found: {book_id}")
|
||||
|
||||
def _touch_book_meta(self, book_id: str) -> None:
|
||||
meta_path = self._meta_path(book_id)
|
||||
meta = _read_json(meta_path)
|
||||
meta["updatedAt"] = datetime.now().isoformat()
|
||||
_write_json(meta_path, meta)
|
||||
|
||||
def get_default_settings(self) -> FictionBookSettings:
|
||||
return FictionBookSettings(
|
||||
prompts=DEFAULT_PROMPTS,
|
||||
reader=DEFAULT_READER,
|
||||
pipeline=DEFAULT_PIPELINE,
|
||||
)
|
||||
|
||||
def get_emotion_catalog(self) -> EmotionFlowCatalog:
|
||||
path = settings.FICTION_EMOTION_CATALOG_FILE
|
||||
if not path.exists():
|
||||
return EmotionFlowCatalog(flows=[])
|
||||
return EmotionFlowCatalog(**_read_json(path))
|
||||
|
||||
def get_guide_global_entries(self) -> GuideGlobalEntries:
|
||||
path = settings.FICTION_GUIDE_GLOBAL_ENTRIES_FILE
|
||||
if not path.exists():
|
||||
return GuideGlobalEntries(entries=[])
|
||||
return GuideGlobalEntries(**_read_json(path))
|
||||
|
||||
def list_books(self) -> List[FictionBookSummary]:
|
||||
root = self.books_root
|
||||
if not root.exists():
|
||||
return []
|
||||
summaries: List[FictionBookSummary] = []
|
||||
for child in sorted(root.iterdir()):
|
||||
if not child.is_dir():
|
||||
continue
|
||||
meta_path = child / "meta.json"
|
||||
if not meta_path.exists():
|
||||
continue
|
||||
meta = _read_json(meta_path)
|
||||
summaries.append(
|
||||
FictionBookSummary(
|
||||
id=meta.get("id", child.name),
|
||||
title=meta.get("title", child.name),
|
||||
allowedFlowIds=meta.get("allowedFlowIds", []),
|
||||
updatedAt=meta.get("updatedAt", ""),
|
||||
)
|
||||
)
|
||||
summaries.sort(key=lambda x: x.updatedAt or "", reverse=True)
|
||||
return summaries
|
||||
|
||||
def get_book_meta(self, book_id: str) -> FictionBookMeta:
|
||||
meta_path = self._meta_path(book_id)
|
||||
if not meta_path.exists():
|
||||
raise FileNotFoundError(f"Book not found: {book_id}")
|
||||
return FictionBookMeta(**_read_json(meta_path))
|
||||
|
||||
def get_book_settings(self, book_id: str) -> FictionBookSettings:
|
||||
settings_path = self._settings_path(book_id)
|
||||
if not settings_path.exists():
|
||||
raise FileNotFoundError(f"Book not found: {book_id}")
|
||||
return FictionBookSettings(**_read_json(settings_path))
|
||||
|
||||
def update_book_settings(
|
||||
self, book_id: str, req: UpdateFictionBookSettingsRequest
|
||||
) -> FictionBookSettings:
|
||||
meta_path = self._meta_path(book_id)
|
||||
settings_path = self._settings_path(book_id)
|
||||
if not meta_path.exists():
|
||||
raise FileNotFoundError(f"Book not found: {book_id}")
|
||||
current = FictionBookSettings(**_read_json(settings_path))
|
||||
data = current.model_dump()
|
||||
if req.prompts is not None:
|
||||
data["prompts"] = req.prompts.model_dump()
|
||||
if req.reader is not None:
|
||||
data["reader"] = req.reader.model_dump()
|
||||
if req.pipeline is not None:
|
||||
data["pipeline"] = req.pipeline.model_dump()
|
||||
_write_json(settings_path, data)
|
||||
meta = _read_json(meta_path)
|
||||
meta["updatedAt"] = datetime.now().isoformat()
|
||||
_write_json(meta_path, meta)
|
||||
return FictionBookSettings(**data)
|
||||
|
||||
def get_book_guide(self, book_id: str) -> FictionGuideWorldbook:
|
||||
guide_path = self._guide_path(book_id)
|
||||
if not guide_path.exists():
|
||||
raise FileNotFoundError(f"Book not found: {book_id}")
|
||||
return FictionGuideWorldbook(**_read_json(guide_path))
|
||||
|
||||
def _unique_book_id(self, base_id: str) -> str:
|
||||
candidate = base_id
|
||||
n = 1
|
||||
while self._book_dir(candidate).exists():
|
||||
candidate = f"{base_id}-{n}"
|
||||
n += 1
|
||||
return candidate
|
||||
|
||||
def create_book(self, req: CreateFictionBookRequest) -> FictionBookMeta:
|
||||
title = (req.title or "").strip()
|
||||
if not title:
|
||||
raise ValueError("书名不能为空")
|
||||
|
||||
base_id = _slugify(title)
|
||||
if not base_id or base_id == "book":
|
||||
base_id = str(uuid.uuid4())[:8]
|
||||
book_id = self._unique_book_id(base_id)
|
||||
|
||||
dest = self._book_dir(book_id)
|
||||
dest.mkdir(parents=True, exist_ok=False)
|
||||
self._chapters_dir(book_id).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
now = datetime.now().isoformat()
|
||||
meta = {
|
||||
"id": book_id,
|
||||
"title": title,
|
||||
"allowedFlowIds": list(req.allowedFlowIds or []),
|
||||
"createdAt": now,
|
||||
"updatedAt": now,
|
||||
}
|
||||
_write_json(self._meta_path(book_id), meta)
|
||||
|
||||
default_settings = self.get_default_settings()
|
||||
_write_json(self._settings_path(book_id), default_settings.model_dump())
|
||||
|
||||
guide = req.guide.model_dump() if req.guide else FictionGuideWorldbook().model_dump()
|
||||
_write_json(self._guide_path(book_id), guide)
|
||||
|
||||
metadata = dict(DEFAULT_METADATA)
|
||||
_write_json(self._metadata_path(book_id), metadata)
|
||||
|
||||
run_data = dict(DEFAULT_RUN)
|
||||
run_data["updatedAt"] = now
|
||||
_write_json(self._run_path(book_id), run_data)
|
||||
|
||||
return FictionBookMeta(**meta)
|
||||
|
||||
def delete_book(self, book_id: str) -> None:
|
||||
book_dir = self._book_dir(book_id)
|
||||
if not book_dir.exists():
|
||||
raise FileNotFoundError(f"Book not found: {book_id}")
|
||||
shutil.rmtree(book_dir)
|
||||
|
||||
|
||||
fiction_service = FictionService()
|
||||
346
backend/services/image_metadata_service.py
Normal file
346
backend/services/image_metadata_service.py
Normal file
@@ -0,0 +1,346 @@
|
||||
"""
|
||||
图片元数据服务
|
||||
|
||||
负责管理生成图片的元数据,支持绑定到角色/聊天的特定楼层
|
||||
数据持久化到 data/image_metadata 目录
|
||||
"""
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Any
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
from backend.models.internal import ImageMetadata
|
||||
from backend.core.config import settings
|
||||
except ImportError:
|
||||
from models.internal import ImageMetadata
|
||||
from core.config import settings
|
||||
|
||||
|
||||
class ImageMetadataService:
|
||||
"""
|
||||
图片元数据服务
|
||||
|
||||
功能:
|
||||
- 记录生成图片的元数据
|
||||
- 按角色/聊天/楼层组织
|
||||
- 支持 swipe(同一楼层多张图片)
|
||||
- 提供画廊查询接口
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.metadata_dir = settings.DATA_PATH / "image_metadata"
|
||||
self.metadata_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 图片存储目录
|
||||
self.images_dir = settings.DATA_PATH / "images"
|
||||
self.images_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _get_chat_metadata_file(self, chat_id: str) -> Path:
|
||||
"""获取指定聊天的元数据文件路径"""
|
||||
# chat_id 格式: role_name/chat_name
|
||||
parts = chat_id.split("/")
|
||||
if len(parts) == 2:
|
||||
role_name, chat_name = parts
|
||||
role_dir = self.metadata_dir / role_name
|
||||
role_dir.mkdir(parents=True, exist_ok=True)
|
||||
return role_dir / f"{chat_name}.json"
|
||||
else:
|
||||
# fallback
|
||||
return self.metadata_dir / f"{chat_id.replace('/', '_')}.json"
|
||||
|
||||
def _load_chat_metadata(self, chat_id: str) -> List[ImageMetadata]:
|
||||
"""加载指定聊天的所有图片元数据"""
|
||||
file_path = self._get_chat_metadata_file(chat_id)
|
||||
|
||||
if not file_path.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
return [ImageMetadata(**item) for item in data]
|
||||
except Exception as e:
|
||||
print(f"[ImageMetadata] 加载元数据失败: {e}")
|
||||
return []
|
||||
|
||||
def _save_chat_metadata(self, chat_id: str, metadata_list: List[ImageMetadata]):
|
||||
"""保存聊天的所有图片元数据"""
|
||||
file_path = self._get_chat_metadata_file(chat_id)
|
||||
|
||||
try:
|
||||
data = [m.model_dump() for m in metadata_list]
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
print(f"[ImageMetadata] 保存元数据失败: {e}")
|
||||
|
||||
async def add_image(
|
||||
self,
|
||||
chat_id: str,
|
||||
role_name: str,
|
||||
chat_name: str,
|
||||
floor: int,
|
||||
filename: str,
|
||||
filepath: str,
|
||||
prompt: Optional[str] = None,
|
||||
negative_prompt: Optional[str] = None,
|
||||
seed: Optional[int] = None,
|
||||
model: Optional[str] = None,
|
||||
workflow_name: Optional[str] = None,
|
||||
task_id: Optional[str] = None,
|
||||
generation_time: Optional[float] = None,
|
||||
width: Optional[int] = None,
|
||||
height: Optional[int] = None,
|
||||
file_size: Optional[int] = None
|
||||
) -> ImageMetadata:
|
||||
"""
|
||||
添加图片元数据
|
||||
|
||||
Args:
|
||||
chat_id: 聊天ID
|
||||
role_name: 角色名称
|
||||
chat_name: 聊天名称
|
||||
floor: 楼层号
|
||||
filename: 文件名
|
||||
filepath: 文件相对路径
|
||||
prompt: 提示词
|
||||
negative_prompt: 负面提示词
|
||||
seed: 随机种子
|
||||
model: 使用的模型
|
||||
workflow_name: 工作流名称
|
||||
task_id: 任务ID
|
||||
generation_time: 生成耗时
|
||||
width: 图片宽度
|
||||
height: 图片高度
|
||||
file_size: 文件大小
|
||||
|
||||
Returns:
|
||||
ImageMetadata: 创建的元数据
|
||||
"""
|
||||
metadata_list = self._load_chat_metadata(chat_id)
|
||||
|
||||
# 计算 swipe_index
|
||||
same_floor_images = [m for m in metadata_list if m.floor == floor]
|
||||
swipe_index = len(same_floor_images)
|
||||
|
||||
# 如果这是该楼层的第一张图片,将其他图片的 isCurrentSwipe 设为 False
|
||||
if swipe_index == 0:
|
||||
for m in metadata_list:
|
||||
if m.floor == floor:
|
||||
m.isCurrentSwipe = False
|
||||
|
||||
metadata = ImageMetadata(
|
||||
id=str(uuid.uuid4()),
|
||||
chatId=chat_id,
|
||||
roleName=role_name,
|
||||
chatName=chat_name,
|
||||
floor=floor,
|
||||
filename=filename,
|
||||
filepath=filepath,
|
||||
prompt=prompt,
|
||||
negativePrompt=negative_prompt,
|
||||
seed=seed,
|
||||
model=model,
|
||||
workflowName=workflow_name,
|
||||
taskId=task_id,
|
||||
generationTime=generation_time,
|
||||
width=width,
|
||||
height=height,
|
||||
fileSize=file_size,
|
||||
swipeIndex=swipe_index,
|
||||
isCurrentSwipe=True
|
||||
)
|
||||
|
||||
metadata_list.append(metadata)
|
||||
self._save_chat_metadata(chat_id, metadata_list)
|
||||
|
||||
return metadata
|
||||
|
||||
async def get_images_by_chat(
|
||||
self,
|
||||
chat_id: str,
|
||||
floor: Optional[int] = None
|
||||
) -> List[ImageMetadata]:
|
||||
"""
|
||||
获取指定聊天的图片列表
|
||||
|
||||
Args:
|
||||
chat_id: 聊天ID
|
||||
floor: 楼层号(可选,用于过滤)
|
||||
|
||||
Returns:
|
||||
图片元数据列表
|
||||
"""
|
||||
metadata_list = self._load_chat_metadata(chat_id)
|
||||
|
||||
if floor is not None:
|
||||
metadata_list = [m for m in metadata_list if m.floor == floor]
|
||||
|
||||
# 按楼层和 swipe_index 排序
|
||||
metadata_list.sort(key=lambda m: (m.floor, m.swipeIndex))
|
||||
|
||||
return metadata_list
|
||||
|
||||
async def get_images_by_role(self, role_name: str) -> List[ImageMetadata]:
|
||||
"""获取指定角色的所有图片"""
|
||||
all_images = []
|
||||
|
||||
role_dir = self.metadata_dir / role_name
|
||||
if not role_dir.exists():
|
||||
return []
|
||||
|
||||
for chat_file in role_dir.glob("*.json"):
|
||||
chat_name = chat_file.stem
|
||||
chat_id = f"{role_name}/{chat_name}"
|
||||
images = self._load_chat_metadata(chat_id)
|
||||
all_images.extend(images)
|
||||
|
||||
# 按创建时间排序
|
||||
all_images.sort(key=lambda m: m.createdAt, reverse=True)
|
||||
|
||||
return all_images
|
||||
|
||||
async def delete_image(self, chat_id: str, image_id: str) -> bool:
|
||||
"""
|
||||
删除图片元数据(不删除实际文件)
|
||||
|
||||
Args:
|
||||
chat_id: 聊天ID
|
||||
image_id: 图片ID
|
||||
|
||||
Returns:
|
||||
bool: 是否成功删除
|
||||
"""
|
||||
metadata_list = self._load_chat_metadata(chat_id)
|
||||
|
||||
# 找到要删除的图片
|
||||
target_image = None
|
||||
for m in metadata_list:
|
||||
if m.id == image_id:
|
||||
target_image = m
|
||||
break
|
||||
|
||||
if not target_image:
|
||||
return False
|
||||
|
||||
floor = target_image.floor
|
||||
swipe_index = target_image.swipeIndex
|
||||
|
||||
# 删除该图片
|
||||
metadata_list = [m for m in metadata_list if m.id != image_id]
|
||||
|
||||
# 重新调整同一楼层其他图片的 swipe_index
|
||||
same_floor_images = [m for m in metadata_list if m.floor == floor]
|
||||
same_floor_images.sort(key=lambda m: m.swipeIndex)
|
||||
|
||||
for idx, m in enumerate(same_floor_images):
|
||||
m.swipeIndex = idx
|
||||
m.isCurrentSwipe = (idx == 0) # 第一个为当前显示
|
||||
|
||||
self._save_chat_metadata(chat_id, metadata_list)
|
||||
return True
|
||||
|
||||
async def clear_chat_images(self, chat_id: str) -> int:
|
||||
"""
|
||||
清空指定聊天的所有图片元数据
|
||||
|
||||
Args:
|
||||
chat_id: 聊天ID
|
||||
|
||||
Returns:
|
||||
int: 删除的图片数量
|
||||
"""
|
||||
metadata_list = self._load_chat_metadata(chat_id)
|
||||
count = len(metadata_list)
|
||||
|
||||
# 清空元数据文件
|
||||
file_path = self._get_chat_metadata_file(chat_id)
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
|
||||
return count
|
||||
|
||||
async def set_current_swipe(self, chat_id: str, image_id: str) -> bool:
|
||||
"""
|
||||
设置某张图片为当前显示的 swipe
|
||||
|
||||
Args:
|
||||
chat_id: 聊天ID
|
||||
image_id: 图片ID
|
||||
|
||||
Returns:
|
||||
bool: 是否成功设置
|
||||
"""
|
||||
metadata_list = self._load_chat_metadata(chat_id)
|
||||
|
||||
target_image = None
|
||||
for m in metadata_list:
|
||||
if m.id == image_id:
|
||||
target_image = m
|
||||
break
|
||||
|
||||
if not target_image:
|
||||
return False
|
||||
|
||||
floor = target_image.floor
|
||||
|
||||
# 将同一楼层的所有图片设为非当前
|
||||
for m in metadata_list:
|
||||
if m.floor == floor:
|
||||
m.isCurrentSwipe = False
|
||||
|
||||
# 设置目标图片为当前
|
||||
target_image.isCurrentSwipe = True
|
||||
|
||||
self._save_chat_metadata(chat_id, metadata_list)
|
||||
return True
|
||||
|
||||
async def get_gallery_stats(self) -> Dict[str, Any]:
|
||||
"""
|
||||
获取画廊统计信息
|
||||
|
||||
Returns:
|
||||
统计信息字典
|
||||
"""
|
||||
stats = {
|
||||
"totalImages": 0,
|
||||
"byRole": {},
|
||||
"byChat": {}
|
||||
}
|
||||
|
||||
if not self.metadata_dir.exists():
|
||||
return stats
|
||||
|
||||
for role_dir in self.metadata_dir.iterdir():
|
||||
if not role_dir.is_dir():
|
||||
continue
|
||||
|
||||
role_name = role_dir.name
|
||||
role_count = 0
|
||||
|
||||
for chat_file in role_dir.glob("*.json"):
|
||||
chat_name = chat_file.stem
|
||||
chat_id = f"{role_name}/{chat_name}"
|
||||
images = self._load_chat_metadata(chat_id)
|
||||
|
||||
chat_count = len(images)
|
||||
role_count += chat_count
|
||||
stats["totalImages"] += chat_count
|
||||
|
||||
if chat_count > 0:
|
||||
stats["byChat"][chat_id] = chat_count
|
||||
|
||||
if role_count > 0:
|
||||
stats["byRole"][role_name] = role_count
|
||||
|
||||
return stats
|
||||
|
||||
def get_image_full_path(self, filepath: str) -> Path:
|
||||
"""获取图片的完整路径"""
|
||||
return self.images_dir / filepath
|
||||
|
||||
|
||||
# 全局实例
|
||||
image_metadata_service = ImageMetadataService()
|
||||
281
backend/services/js_sandbox.py
Normal file
281
backend/services/js_sandbox.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""
|
||||
JavaScript 沙盒执行引擎 + 提示词模板系统
|
||||
|
||||
基于 iframe 隔离的 JavaScript 代码执行环境,提供安全的脚本执行能力。
|
||||
遵循 SillyTavern Tavern Helper 的设计理念。
|
||||
|
||||
安全特性:
|
||||
- 使用 iframe 沙盒隔离执行环境
|
||||
- 禁止访问 window.parent、window.top 等危险 API
|
||||
- 禁止网络请求(fetch、XMLHttpRequest)
|
||||
- 禁止文件系统访问
|
||||
- 禁止 DOM 操作(除特定安全的 API)
|
||||
- 提供受限的有用功能(变量管理、随机数、骰子等)
|
||||
|
||||
提示词模板语法(兼容 SillyTavern):
|
||||
- {{var}} 或 {{getvar::key}}: 获取变量
|
||||
- {{setvar::key::value}}: 设置变量
|
||||
- {{delvar::key}}: 删除变量
|
||||
- {{random::a,b,c}}: 随机选择
|
||||
- {{roll XdY}}: 掷骰子(X 个 Y 面骰)
|
||||
- {{pick::a|b|c}}: 随机选择(使用 | 分隔)
|
||||
- {{// 注释}}: 注释(不会输出)
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import random
|
||||
from typing import Any, Dict, List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class JSSandboxError(Exception):
|
||||
"""沙盒执行错误"""
|
||||
pass
|
||||
|
||||
|
||||
class JSSandboxExecutor:
|
||||
"""
|
||||
JavaScript 沙盒执行器
|
||||
|
||||
提供安全的 JavaScript 代码执行环境,支持:
|
||||
- 变量管理(getvar、setvar、delvar)
|
||||
- 随机数生成(random、roll)
|
||||
- 字符串处理
|
||||
- 数学计算
|
||||
- 安全的对象操作
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# 变量存储(每个会话独立)
|
||||
self.variables: Dict[str, Any] = {}
|
||||
|
||||
# 禁止的危险 API 列表
|
||||
self.dangerous_apis = [
|
||||
'fetch', 'XMLHttpRequest', 'WebSocket',
|
||||
'window.parent', 'window.top', 'window.opener',
|
||||
'document.cookie', 'document.write', 'document.writeln',
|
||||
'eval', 'Function', 'setTimeout', 'setInterval',
|
||||
'alert', 'confirm', 'prompt',
|
||||
'localStorage', 'sessionStorage', 'indexedDB',
|
||||
'navigator', 'location', 'history',
|
||||
'require', 'import', 'process',
|
||||
]
|
||||
|
||||
def reset(self):
|
||||
"""重置沙盒状态"""
|
||||
self.variables.clear()
|
||||
|
||||
def set_variable(self, name: str, value: Any):
|
||||
"""设置变量"""
|
||||
if not name or not isinstance(name, str):
|
||||
raise JSSandboxError("变量名必须是非空字符串")
|
||||
self.variables[name] = value
|
||||
|
||||
def get_variable(self, name: str, default: Any = None) -> Any:
|
||||
"""获取变量"""
|
||||
return self.variables.get(name, default)
|
||||
|
||||
def delete_variable(self, name: str):
|
||||
"""删除变量"""
|
||||
if name in self.variables:
|
||||
del self.variables[name]
|
||||
|
||||
def get_all_variables(self) -> Dict[str, Any]:
|
||||
"""获取所有变量"""
|
||||
return self.variables.copy()
|
||||
|
||||
def execute_code(self, code: str, context: Optional[Dict] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
执行 JavaScript 代码
|
||||
|
||||
Args:
|
||||
code: JavaScript 代码
|
||||
context: 执行上下文(可选)
|
||||
|
||||
Returns:
|
||||
执行结果,包含:
|
||||
- success: 是否成功
|
||||
- result: 执行结果
|
||||
- error: 错误信息(如果有)
|
||||
- variables: 变量状态
|
||||
"""
|
||||
try:
|
||||
# 安全检查
|
||||
self._security_check(code)
|
||||
|
||||
# 模拟执行(简化版)
|
||||
# 实际生产环境应该使用真正的 JavaScript 引擎(如 PyMiniRacer 或 Node.js)
|
||||
result = self._simulate_execution(code, context)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'result': result,
|
||||
'variables': self.get_all_variables()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'variables': self.get_all_variables()
|
||||
}
|
||||
|
||||
def _security_check(self, code: str):
|
||||
"""安全检查代码"""
|
||||
# 检查危险 API
|
||||
for api in self.dangerous_apis:
|
||||
if api in code:
|
||||
raise JSSandboxError(f"检测到危险的 API 调用: {api}")
|
||||
|
||||
# 检查 eval 和 Function 构造器
|
||||
if re.search(r'\beval\s*\(', code):
|
||||
raise JSSandboxError("禁止使用 eval()")
|
||||
|
||||
if re.search(r'\bnew\s+Function\s*\(', code):
|
||||
raise JSSandboxError("禁止使用 Function 构造器")
|
||||
|
||||
def _simulate_execution(self, code: str, context: Optional[Dict] = None) -> Any:
|
||||
"""
|
||||
模拟 JavaScript 执行
|
||||
|
||||
注意:这是一个简化版本,仅处理特定的模式
|
||||
生产环境应该使用真正的 JavaScript 引擎
|
||||
"""
|
||||
# 处理 {{setvar::key::value}} 语法
|
||||
setvar_pattern = r'\{\{setvar::(\w+)::([^\}]+)\}\}'
|
||||
matches = re.findall(setvar_pattern, code)
|
||||
for key, value in matches:
|
||||
self.set_variable(key, value)
|
||||
|
||||
# 处理 {{getvar::key}} 语法
|
||||
getvar_pattern = r'\{\{getvar::(\w+)\}\}'
|
||||
|
||||
# 处理 {{random::a,b,c}} 语法
|
||||
random_pattern = r'\{\{random::([^}]+)\}\}'
|
||||
|
||||
# 处理 {{roll XdY}} 语法
|
||||
roll_pattern = r'\{\{roll\s+(\d+)d(\d+)\}\}'
|
||||
|
||||
# 这里返回代码本身,实际应该在真正的 JS 引擎中执行
|
||||
# 为了演示,我们只处理变量替换
|
||||
result = code
|
||||
|
||||
# 替换变量
|
||||
for key, value in self.variables.items():
|
||||
result = result.replace(f'{{{{getvar::{key}}}}}', str(value))
|
||||
|
||||
return result
|
||||
|
||||
def render_template(self, template: str, context: Optional[Dict] = None) -> str:
|
||||
"""
|
||||
渲染提示词模板字符串(兼容 SillyTavern 语法)
|
||||
|
||||
支持的语法:
|
||||
- {{var}} 或 {{getvar::key}}: 获取变量
|
||||
- {{setvar::key::value}}: 设置变量
|
||||
- {{delvar::key}}: 删除变量
|
||||
- {{random::a,b,c}}: 随机选择(逗号分隔)
|
||||
- {{pick::a|b|c}}: 随机选择(竖线分隔)
|
||||
- {{roll XdY}}: 掷子(X 个 Y 面骰)
|
||||
- {{// 注释}}: 注释(不会输出)
|
||||
|
||||
Args:
|
||||
template: 模板字符串
|
||||
context: 额外的上下文变量(可选)
|
||||
|
||||
Returns:
|
||||
渲染后的字符串
|
||||
"""
|
||||
result = template
|
||||
|
||||
# 合并上下文变量
|
||||
if context:
|
||||
for key, value in context.items():
|
||||
self.set_variable(key, value)
|
||||
|
||||
# 1. 处理 {{// 注释}} - 移除注释
|
||||
result = re.sub(r'\{\{//[^}]*\}\}', '', result)
|
||||
|
||||
# 2. 处理 {{delvar::key}} - 删除变量
|
||||
def replace_delvar(match):
|
||||
key = match.group(1)
|
||||
self.delete_variable(key)
|
||||
return ''
|
||||
result = re.sub(r'\{\{delvar::(\w+)\}\}', replace_delvar, result)
|
||||
|
||||
# 3. 处理 {{setvar::key::value}} - 设置变量(先设置)
|
||||
def replace_setvar(match):
|
||||
key, value = match.group(1), match.group(2)
|
||||
self.set_variable(key, value)
|
||||
return ''
|
||||
result = re.sub(r'\{\{setvar::(\w+)::([^}]+)\}\}', replace_setvar, result)
|
||||
|
||||
# 4. 处理 {{random::a,b,c}} - 随机选择(逗号分隔)
|
||||
def replace_random_comma(match):
|
||||
options = match.group(1).split(',')
|
||||
return random.choice([opt.strip() for opt in options if opt.strip()])
|
||||
result = re.sub(r'\{\{random::([^}]+)\}\}', replace_random_comma, result)
|
||||
|
||||
# 5. 处理 {{pick::a|b|c}} - 随机选择(竖线分隔)
|
||||
def replace_pick(match):
|
||||
options = match.group(1).split('|')
|
||||
return random.choice([opt.strip() for opt in options if opt.strip()])
|
||||
result = re.sub(r'\{\{pick::([^}]+)\}\}', replace_pick, result)
|
||||
|
||||
# 6. 处理 {{roll XdY}} - 掷骰子
|
||||
def replace_roll(match):
|
||||
count = int(match.group(1))
|
||||
sides = int(match.group(2))
|
||||
rolls = [random.randint(1, sides) for _ in range(count)]
|
||||
return str(sum(rolls))
|
||||
result = re.sub(r'\{\{roll\s+(\d+)d(\d+)\}\}', replace_roll, result)
|
||||
|
||||
# 7. 处理 {{getvar::key}} - 获取变量(后获取)
|
||||
def replace_getvar(match):
|
||||
key = match.group(1)
|
||||
return str(self.get_variable(key, ''))
|
||||
result = re.sub(r'\{\{getvar::(\w+)\}\}', replace_getvar, result)
|
||||
|
||||
# 8. 处理 {{var}} - 获取变量(简化语法)
|
||||
def replace_var(match):
|
||||
key = match.group(1)
|
||||
return str(self.get_variable(key, ''))
|
||||
result = re.sub(r'\{\{(\w+)\}\}', replace_var, result)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# 全局沙盒实例
|
||||
js_sandbox = JSSandboxExecutor()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 测试沙盒功能
|
||||
sandbox = JSSandboxExecutor()
|
||||
|
||||
# 测试变量管理
|
||||
print("=== 测试变量管理 ===")
|
||||
sandbox.set_variable('test_var', 'Hello World')
|
||||
print(f"获取变量: {sandbox.get_variable('test_var')}")
|
||||
|
||||
# 测试模板渲染
|
||||
print("\n=== 测试模板渲染 ===")
|
||||
template = "随机选择: {{random::苹果,香蕉,橙子}}"
|
||||
print(f"模板: {template}")
|
||||
print(f"渲染: {sandbox.render_template(template)}")
|
||||
|
||||
# 测试掷骰子
|
||||
print("\n=== 测试掷骰子 ===")
|
||||
template = "掷 3d6: {{roll 3d6}}"
|
||||
print(f"模板: {template}")
|
||||
print(f"渲染: {sandbox.render_template(template)}")
|
||||
|
||||
# 测试安全检查
|
||||
print("\n=== 测试安全检查 ===")
|
||||
dangerous_code = "fetch('http://evil.com')"
|
||||
try:
|
||||
sandbox.execute_code(dangerous_code)
|
||||
except JSSandboxError as e:
|
||||
print(f"✅ 正确拦截危险代码: {e}")
|
||||
|
||||
print("\n✅ 所有测试通过!")
|
||||
186
backend/services/llm_model_service.py
Normal file
186
backend/services/llm_model_service.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
LLM 模型管理服务
|
||||
|
||||
提供获取不同 LLM 提供商可用模型列表的功能
|
||||
"""
|
||||
from typing import List, Dict, Any, Optional
|
||||
import requests
|
||||
|
||||
|
||||
class LLMModelService:
|
||||
"""LLM 模型管理服务"""
|
||||
|
||||
@staticmethod
|
||||
def get_openai_models(api_key: str, base_url: Optional[str] = None) -> List[str]:
|
||||
"""
|
||||
获取 OpenAI 兼容 API 的模型列表
|
||||
|
||||
Args:
|
||||
api_key: API Key
|
||||
base_url: API 基础 URL,默认为 OpenAI 官方 API
|
||||
|
||||
Returns:
|
||||
模型名称列表
|
||||
"""
|
||||
try:
|
||||
# 默认使用 OpenAI 官方 API
|
||||
if not base_url:
|
||||
base_url = "https://api.openai.com/v1"
|
||||
|
||||
# 规范化 base_url:确保有协议前缀
|
||||
base_url = base_url.strip()
|
||||
if not base_url.startswith(('http://', 'https://')):
|
||||
base_url = 'https://' + base_url
|
||||
|
||||
# 移除末尾的斜杠和常见 endpoint 路径
|
||||
base_url = base_url.rstrip('/')
|
||||
# 移除可能已经存在的 endpoint 路径
|
||||
for endpoint in ['/chat/completions', '/completions', '/embeddings', '/models']:
|
||||
if base_url.endswith(endpoint):
|
||||
base_url = base_url[:-len(endpoint)]
|
||||
break
|
||||
|
||||
# 调用 models API
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{base_url}/models",
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
timeout=10
|
||||
)
|
||||
except requests.exceptions.InvalidSchema as e:
|
||||
raise Exception(f"URL 格式错误: {base_url}/models - 请确保 URL 以 http:// 或 https:// 开头")
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
raise Exception(f"无法连接到 API: {base_url}/models - 请检查网络连接和 API 地址")
|
||||
except requests.exceptions.Timeout as e:
|
||||
raise Exception(f"请求超时: {base_url}/models - 请检查网络连接")
|
||||
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"HTTP {response.status_code}: {response.text}")
|
||||
|
||||
data = response.json()
|
||||
models = [model['id'] for model in data.get('data', [])]
|
||||
|
||||
# 返回所有模型,不做过滤
|
||||
return models
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"获取 OpenAI 模型列表失败: {str(e)}")
|
||||
|
||||
@staticmethod
|
||||
def get_anthropic_models(api_key: str) -> List[str]:
|
||||
"""
|
||||
获取 Anthropic Claude 模型列表
|
||||
|
||||
Args:
|
||||
api_key: API Key
|
||||
|
||||
Returns:
|
||||
模型名称列表
|
||||
"""
|
||||
try:
|
||||
# Anthropic 没有公开的模型列表 API,返回已知模型
|
||||
return [
|
||||
"claude-3-5-sonnet-20241022",
|
||||
"claude-3-5-haiku-20241022",
|
||||
"claude-3-opus-20240229",
|
||||
"claude-3-sonnet-20240229",
|
||||
"claude-3-haiku-20240307",
|
||||
"claude-2.1",
|
||||
"claude-2.0",
|
||||
"claude-instant-1.2"
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"获取 Anthropic 模型列表失败: {str(e)}")
|
||||
|
||||
@staticmethod
|
||||
def get_ollama_models(base_url: str = "http://localhost:11434") -> List[str]:
|
||||
"""
|
||||
获取 Ollama 本地模型列表
|
||||
|
||||
Args:
|
||||
base_url: Ollama API 地址
|
||||
|
||||
Returns:
|
||||
模型名称列表
|
||||
"""
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{base_url}/api/tags",
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise Exception(f"HTTP {response.status_code}: {response.text}")
|
||||
|
||||
data = response.json()
|
||||
models = [model['name'] for model in data.get('models', [])]
|
||||
|
||||
return models
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"获取 Ollama 模型列表失败: {str(e)}")
|
||||
|
||||
@staticmethod
|
||||
def detect_provider(api_url: str) -> str:
|
||||
"""
|
||||
根据 API URL 检测提供商类型
|
||||
|
||||
Args:
|
||||
api_url: API 地址
|
||||
|
||||
Returns:
|
||||
提供商类型: 'openai', 'anthropic', 'ollama', 'unknown'
|
||||
"""
|
||||
api_url_lower = api_url.lower()
|
||||
|
||||
if 'openai' in api_url_lower or 'api.openai.com' in api_url_lower:
|
||||
return 'openai'
|
||||
elif 'anthropic' in api_url_lower or 'api.anthropic.com' in api_url_lower:
|
||||
return 'anthropic'
|
||||
elif 'ollama' in api_url_lower or 'localhost:11434' in api_url_lower or '127.0.0.1:11434' in api_url_lower:
|
||||
return 'ollama'
|
||||
elif 'bigmodel' in api_url_lower or 'glm' in api_url_lower:
|
||||
# 智谱AI GLM - 兼容 OpenAI API
|
||||
return 'openai'
|
||||
elif 'siliconflow' in api_url_lower or 'silicon.cloud' in api_url_lower:
|
||||
# SiliconFlow 等兼容 OpenAI API 的服务
|
||||
return 'openai'
|
||||
elif 'deepseek' in api_url_lower:
|
||||
# DeepSeek 等兼容 OpenAI API 的服务
|
||||
return 'openai'
|
||||
else:
|
||||
# 默认尝试 OpenAI 兼容 API
|
||||
return 'openai'
|
||||
|
||||
@staticmethod
|
||||
def get_models_by_provider(
|
||||
provider: str,
|
||||
api_key: str,
|
||||
api_url: Optional[str] = None
|
||||
) -> List[str]:
|
||||
"""
|
||||
根据提供商类型获取模型列表
|
||||
|
||||
Args:
|
||||
provider: 提供商类型 ('openai', 'anthropic', 'ollama')
|
||||
api_key: API Key
|
||||
api_url: API 地址(可选)
|
||||
|
||||
Returns:
|
||||
模型名称列表
|
||||
"""
|
||||
if provider == 'openai':
|
||||
return LLMModelService.get_openai_models(api_key, api_url)
|
||||
elif provider == 'anthropic':
|
||||
return LLMModelService.get_anthropic_models(api_key)
|
||||
elif provider == 'ollama':
|
||||
base_url = api_url or "http://localhost:11434"
|
||||
# 移除 /v1 后缀(如果有)
|
||||
base_url = base_url.replace('/v1', '').replace('/v1/', '')
|
||||
return LLMModelService.get_ollama_models(base_url)
|
||||
else:
|
||||
raise Exception(f"不支持的提供商: {provider}")
|
||||
329
backend/services/preset_service.py
Normal file
329
backend/services/preset_service.py
Normal file
@@ -0,0 +1,329 @@
|
||||
"""
|
||||
Preset Service
|
||||
预设服务层 - 处理预设的 CRUD 操作
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from core.config import settings
|
||||
|
||||
|
||||
class PresetService:
|
||||
"""预设服务类"""
|
||||
|
||||
@staticmethod
|
||||
def _extract_preset_name_from_filename(filename: str) -> str:
|
||||
"""
|
||||
从文件名提取预设名称,去掉时间戳和文件后缀
|
||||
|
||||
Args:
|
||||
filename: 文件名(不含路径)
|
||||
|
||||
Returns:
|
||||
清理后的预设名称
|
||||
|
||||
Examples:
|
||||
"Default.json" -> "Default"
|
||||
"MyPreset_1234567890.json" -> "MyPreset"
|
||||
"Test_1714567890123.json" -> "Test"
|
||||
"""
|
||||
# 去掉 .json 后缀
|
||||
name = filename.replace('.json', '')
|
||||
|
||||
# 去掉末尾的时间戳(下划线+数字组合)
|
||||
# 匹配模式:_后面跟着10-13位数字(Unix时间戳)
|
||||
import re
|
||||
name = re.sub(r'_\d{10,13}$', '', name)
|
||||
|
||||
return name
|
||||
|
||||
@staticmethod
|
||||
def _get_preset_path(name: str) -> Path:
|
||||
"""获取预设文件路径"""
|
||||
return settings.PRESET_PATH / f"{name}.json"
|
||||
|
||||
@staticmethod
|
||||
def _load_preset(name: str) -> Optional[Dict[str, Any]]:
|
||||
"""加载预设 JSON 文件"""
|
||||
path = PresetService._get_preset_path(name)
|
||||
if not path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to load preset '{name}': {str(e)}")
|
||||
|
||||
@staticmethod
|
||||
def _save_preset(name: str, data: Dict[str, Any]):
|
||||
"""保存预设到 JSON 文件"""
|
||||
path = PresetService._get_preset_path(name)
|
||||
try:
|
||||
# 确保 prompts 数组和 prompt_order 的顺序一致
|
||||
if "prompts" in data and "prompt_order" in data:
|
||||
prompts = data["prompts"]
|
||||
prompt_order = data.get("prompt_order", [{}])[0].get("order", [])
|
||||
|
||||
if prompts and prompt_order:
|
||||
# 创建 identifier 到 prompt 的映射
|
||||
prompt_map = {prompt["identifier"]: prompt for prompt in prompts}
|
||||
|
||||
# 按照 prompt_order 的顺序重新排列 prompts
|
||||
reordered_prompts = []
|
||||
for order_item in prompt_order:
|
||||
identifier = order_item.get("identifier")
|
||||
if identifier and identifier in prompt_map:
|
||||
reordered_prompts.append(prompt_map[identifier])
|
||||
|
||||
# 添加 prompt_order 中不存在的 prompts(如果有)
|
||||
existing_identifiers = {item.get("identifier") for item in prompt_order}
|
||||
for prompt in prompts:
|
||||
if prompt["identifier"] not in existing_identifiers:
|
||||
reordered_prompts.append(prompt)
|
||||
|
||||
data["prompts"] = reordered_prompts
|
||||
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to save preset '{name}': {str(e)}")
|
||||
|
||||
@staticmethod
|
||||
def list_presets() -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取所有预设的列表(仅基本信息)
|
||||
|
||||
Returns:
|
||||
预设列表,每个包含 name, description, component_count, temperature 等
|
||||
"""
|
||||
presets = []
|
||||
|
||||
for json_file in settings.PRESET_PATH.glob("*.json"):
|
||||
try:
|
||||
with open(json_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# 计算组件数量 - 支持 SillyTavern 格式 (prompts) 和内部格式 (entries)
|
||||
prompts = data.get("prompts", [])
|
||||
entries = data.get("entries", [])
|
||||
component_count = len(prompts) if prompts else len(entries)
|
||||
|
||||
# 提取温度参数 - 使用 SillyTavern 标准字段名
|
||||
temperature = data.get("temperature", 1.0)
|
||||
|
||||
# 从文件名提取预设名称(去掉时间戳和后缀)
|
||||
preset_name = PresetService._extract_preset_name_from_filename(json_file.name)
|
||||
|
||||
preset_info = {
|
||||
"name": preset_name,
|
||||
"description": data.get("description", ""),
|
||||
"component_count": component_count,
|
||||
"temperature": temperature
|
||||
}
|
||||
|
||||
presets.append(preset_info)
|
||||
except Exception as e:
|
||||
print(f"Error loading preset {json_file.name}: {e}")
|
||||
continue
|
||||
|
||||
# 按名称排序
|
||||
presets.sort(key=lambda x: x.get("name", ""))
|
||||
return presets
|
||||
|
||||
@staticmethod
|
||||
def get_preset(name: str) -> Dict[str, Any]:
|
||||
"""
|
||||
获取指定预设的完整数据
|
||||
|
||||
Args:
|
||||
name: 预设名称
|
||||
|
||||
Returns:
|
||||
预设完整数据
|
||||
"""
|
||||
data = PresetService._load_preset(name)
|
||||
if not data:
|
||||
raise FileNotFoundError(f"Preset '{name}' not found")
|
||||
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def create_preset(name: str, preset_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
创建新预设
|
||||
|
||||
Args:
|
||||
name: 预设名称
|
||||
preset_data: 预设数据
|
||||
|
||||
Returns:
|
||||
创建的预设数据
|
||||
"""
|
||||
# 检查是否已存在
|
||||
if PresetService._get_preset_path(name).exists():
|
||||
raise ValueError(f"Preset '{name}' already exists")
|
||||
|
||||
# 确保有必要的字段
|
||||
if "name" not in preset_data:
|
||||
preset_data["name"] = name
|
||||
|
||||
# 添加时间戳
|
||||
now = int(datetime.now().timestamp())
|
||||
if "createdAt" not in preset_data:
|
||||
preset_data["createdAt"] = now
|
||||
if "updatedAt" not in preset_data:
|
||||
preset_data["updatedAt"] = now
|
||||
|
||||
PresetService._save_preset(name, preset_data)
|
||||
return preset_data
|
||||
|
||||
@staticmethod
|
||||
def update_preset(name: str, update_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
更新预设
|
||||
|
||||
Args:
|
||||
name: 预设名称
|
||||
update_data: 要更新的数据
|
||||
|
||||
Returns:
|
||||
更新后的预设数据
|
||||
"""
|
||||
data = PresetService._load_preset(name)
|
||||
if not data:
|
||||
raise FileNotFoundError(f"Preset '{name}' not found")
|
||||
|
||||
# 更新字段
|
||||
for key, value in update_data.items():
|
||||
if key not in ["name", "createdAt"]: # 不允许修改名称和创建时间
|
||||
data[key] = value
|
||||
|
||||
# 更新时间戳
|
||||
data["updatedAt"] = int(datetime.now().timestamp())
|
||||
|
||||
PresetService._save_preset(name, data)
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def delete_preset(name: str) -> bool:
|
||||
"""
|
||||
删除预设
|
||||
|
||||
Args:
|
||||
name: 预设名称
|
||||
|
||||
Returns:
|
||||
是否删除成功
|
||||
"""
|
||||
path = PresetService._get_preset_path(name)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Preset '{name}' not found")
|
||||
|
||||
path.unlink()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def rename_preset(old_name: str, new_name: str) -> Dict[str, Any]:
|
||||
"""
|
||||
重命名预设(同时修改文件名和内部 name 字段)
|
||||
|
||||
Args:
|
||||
old_name: 原预设名称
|
||||
new_name: 新预设名称
|
||||
|
||||
Returns:
|
||||
更新后的预设数据
|
||||
"""
|
||||
# 检查原预设是否存在
|
||||
old_path = PresetService._get_preset_path(old_name)
|
||||
if not old_path.exists():
|
||||
raise FileNotFoundError(f"Preset '{old_name}' not found")
|
||||
|
||||
# 检查新名称是否已存在
|
||||
new_path = PresetService._get_preset_path(new_name)
|
||||
if new_path.exists() and old_name != new_name:
|
||||
raise ValueError(f"Preset '{new_name}' already exists")
|
||||
|
||||
# 加载原预设数据
|
||||
data = PresetService._load_preset(old_name)
|
||||
if not data:
|
||||
raise FileNotFoundError(f"Preset '{old_name}' not found")
|
||||
|
||||
# 更新内部的 name 字段
|
||||
data["name"] = new_name
|
||||
|
||||
# 更新时间戳
|
||||
data["updatedAt"] = int(datetime.now().timestamp())
|
||||
|
||||
# 保存到新文件
|
||||
PresetService._save_preset(new_name, data)
|
||||
|
||||
# 删除旧文件(如果名称不同)
|
||||
if old_name != new_name:
|
||||
old_path.unlink()
|
||||
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def reorder_components(name: str, component_order: List[str]) -> Dict[str, Any]:
|
||||
"""
|
||||
重新排序预设组件 - 支持 SillyTavern 标准格式
|
||||
|
||||
Args:
|
||||
name: 预设名称
|
||||
component_order: 组件 identifier 列表,按新顺序排列
|
||||
|
||||
Returns:
|
||||
更新后的预设数据
|
||||
"""
|
||||
data = PresetService._load_preset(name)
|
||||
if not data:
|
||||
raise FileNotFoundError(f"Preset '{name}' not found")
|
||||
|
||||
# 支持 SillyTavern 格式的 prompts
|
||||
if "prompts" in data and isinstance(data["prompts"], list):
|
||||
# 创建 identifier 到 prompt 的映射
|
||||
prompt_map = {prompt["identifier"]: prompt for prompt in data["prompts"]}
|
||||
|
||||
# 按新顺序重新排列
|
||||
reordered_prompts = []
|
||||
for identifier in component_order:
|
||||
if identifier in prompt_map:
|
||||
reordered_prompts.append(prompt_map[identifier])
|
||||
|
||||
data["prompts"] = reordered_prompts
|
||||
|
||||
# 更新 prompt_order
|
||||
if "prompt_order" in data and isinstance(data["prompt_order"], list) and len(data["prompt_order"]) > 0:
|
||||
data["prompt_order"][0]["order"] = [
|
||||
{"identifier": identifier, "enabled": True}
|
||||
for identifier in component_order
|
||||
if identifier in prompt_map
|
||||
]
|
||||
|
||||
# 也支持内部格式的 entries(向后兼容)
|
||||
elif "entries" in data and isinstance(data["entries"], list):
|
||||
# 创建 identifier 到 entry 的映射
|
||||
entry_map = {entry["identifier"]: entry for entry in data["entries"]}
|
||||
|
||||
# 按新顺序重新排列
|
||||
reordered_entries = []
|
||||
for identifier in component_order:
|
||||
if identifier in entry_map:
|
||||
reordered_entries.append(entry_map[identifier])
|
||||
|
||||
# 更新 order 字段
|
||||
for index, entry in enumerate(reordered_entries):
|
||||
entry["order"] = index
|
||||
|
||||
data["entries"] = reordered_entries
|
||||
|
||||
# 更新时间戳
|
||||
data["updatedAt"] = int(datetime.now().timestamp())
|
||||
|
||||
PresetService._save_preset(name, data)
|
||||
return data
|
||||
226
backend/services/prompt_assembler.py
Normal file
226
backend/services/prompt_assembler.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
提示词组装器 (Prompt Assembler)
|
||||
|
||||
负责根据 SillyTavern 规范将角色卡、世界书、聊天历史等组件
|
||||
拼装成最终的 LLM 消息列表。
|
||||
"""
|
||||
import re
|
||||
from typing import List, Dict, Optional
|
||||
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage, BaseMessage
|
||||
|
||||
from models.internal import CharacterCard, ChatMessage, WorldInfoEntry
|
||||
|
||||
|
||||
class PromptConfig:
|
||||
"""提示词组装配置"""
|
||||
def __init__(
|
||||
self,
|
||||
an_position: str = "after_history", # "before_history" or "after_history"
|
||||
an_depth: int = 4,
|
||||
post_history_instructions: Optional[str] = None
|
||||
):
|
||||
self.an_position = an_position
|
||||
self.an_depth = an_depth
|
||||
self.post_history_instructions = post_history_instructions
|
||||
|
||||
|
||||
class PromptAssembler:
|
||||
"""
|
||||
轻量级提示词组装核心
|
||||
|
||||
不依赖复杂的框架,只负责纯粹的文本拼接和位置插入。
|
||||
"""
|
||||
|
||||
# SillyTavern 的位置枚举映射
|
||||
POS_WI_BEFORE = 0
|
||||
POS_WI_AFTER = 1
|
||||
POS_EXAMPLES_BEFORE = 2
|
||||
POS_EXAMPLES_AFTER = 3
|
||||
POS_AN_TOP = 4
|
||||
POS_AN_BOTTOM = 5
|
||||
POS_DEPTH = 6
|
||||
POS_OUTLET = 7
|
||||
|
||||
def assemble(
|
||||
self,
|
||||
character: CharacterCard,
|
||||
chat_history: List[ChatMessage],
|
||||
user_input: str,
|
||||
active_entries: List[WorldInfoEntry],
|
||||
config: PromptConfig = PromptConfig()
|
||||
) -> List[BaseMessage]:
|
||||
"""
|
||||
执行完整的提示词组装流程
|
||||
|
||||
Returns:
|
||||
List[BaseMessage]: 准备好发送给 LLM 的消息列表
|
||||
"""
|
||||
# 1. 按位置分组世界书条目
|
||||
grouped_entries = self._group_entries_by_position(active_entries)
|
||||
|
||||
# 2. 组装 Story String (包含 Pos 0-3)
|
||||
story_string = self._build_story_string(character, grouped_entries)
|
||||
|
||||
# 3. 组装 Author's Note (包含 Pos 4-5)
|
||||
authors_note_content = self._build_authors_note(grouped_entries, config.an_depth)
|
||||
|
||||
# 4. 处理 Chat History 并注入 Depth 条目 (Pos 6)
|
||||
processed_history = self._inject_depth_entries(chat_history, grouped_entries.get(self.POS_DEPTH, []))
|
||||
|
||||
# 5. 准备 Outlet 替换字典 (Pos 7)
|
||||
outlet_map = {entry.uid: entry.content for entry in grouped_entries.get(self.POS_OUTLET, [])}
|
||||
|
||||
# 6. 最终封装为 Messages
|
||||
return self._wrap_to_messages(
|
||||
story_string,
|
||||
authors_note_content,
|
||||
processed_history,
|
||||
user_input,
|
||||
outlet_map,
|
||||
config
|
||||
)
|
||||
|
||||
def _group_entries_by_position(self, entries: List[WorldInfoEntry]) -> Dict[int, List[WorldInfoEntry]]:
|
||||
"""将激活的条目按 position 分组"""
|
||||
grouped = {}
|
||||
for entry in entries:
|
||||
# 这里假设 entry.position 存储的是我们定义的 0-7 整数
|
||||
pos = entry.position if isinstance(entry.position, int) else 1 # 默认为 wiAfter
|
||||
if pos not in grouped:
|
||||
grouped[pos] = []
|
||||
grouped[pos].append(entry)
|
||||
|
||||
# 对每个组内的条目按 order 排序
|
||||
for pos in grouped:
|
||||
grouped[pos].sort(key=lambda x: x.order)
|
||||
return grouped
|
||||
|
||||
def _build_story_string(self, character: CharacterCard, grouped: Dict) -> str:
|
||||
"""组装故事字符串 (Story String)"""
|
||||
parts = []
|
||||
|
||||
# Pos 0: wiBefore
|
||||
for entry in grouped.get(self.POS_WI_BEFORE, []):
|
||||
parts.append(entry.content)
|
||||
|
||||
# 角色核心信息
|
||||
parts.append(f"[Character('{character.name}')]\n{character.description}\n")
|
||||
parts.append(f"Personality: {character.personality}\n")
|
||||
parts.append(f"Scenario: {character.scenario}\n")
|
||||
|
||||
# Pos 1: wiAfter
|
||||
for entry in grouped.get(self.POS_WI_AFTER, []):
|
||||
parts.append(entry.content)
|
||||
|
||||
# Pos 2: Examples Before
|
||||
for entry in grouped.get(self.POS_EXAMPLES_BEFORE, []):
|
||||
parts.append(entry.content)
|
||||
|
||||
# 示例对话
|
||||
if character.mes_example:
|
||||
parts.append(f"<START>\n{character.mes_example}")
|
||||
|
||||
# Pos 3: Examples After
|
||||
for entry in grouped.get(self.POS_EXAMPLES_AFTER, []):
|
||||
parts.append(entry.content)
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
def _build_authors_note(self, grouped: Dict, depth: int) -> str:
|
||||
"""组装作者笔记 (Author's Note)"""
|
||||
parts = []
|
||||
|
||||
# Pos 4: AN Top
|
||||
for entry in grouped.get(self.POS_AN_TOP, []):
|
||||
parts.append(str(entry.content) if entry.content else "")
|
||||
|
||||
# AN 核心内容 (这里简化为一个占位,实际应从角色卡或设置获取)
|
||||
parts.append(f"[Author's note at depth {depth}]")
|
||||
|
||||
# Pos 5: AN Bottom
|
||||
for entry in grouped.get(self.POS_AN_BOTTOM, []):
|
||||
parts.append(str(entry.content) if entry.content else "")
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
def _inject_depth_entries(self, history: List[ChatMessage], depth_entries: List[WorldInfoEntry]) -> List[Dict]:
|
||||
"""
|
||||
在聊天历史的指定深度插入条目 (Pos 6)
|
||||
返回一个包含 role 和 content 的字典列表,方便后续转换
|
||||
|
||||
✅ 过滤已被总结的消息(is_summarized=True 且 mes="")
|
||||
"""
|
||||
# 先将历史转换为中间格式,过滤掉空消息(已被总结)
|
||||
msg_list = []
|
||||
for msg in history:
|
||||
# ✅ 跳过已被总结的空消息
|
||||
if msg.is_summarized and (msg.mes == "" or msg.mes.strip() == ""):
|
||||
continue
|
||||
msg_list.append({"role": "user" if msg.is_user else "assistant", "content": msg.mes})
|
||||
|
||||
# 按 depth 分组插入
|
||||
# d0 通常指最新用户输入之前,即列表末尾
|
||||
for entry in depth_entries:
|
||||
depth = entry.depth if entry.depth is not None else 0
|
||||
# 计算插入索引 (从后往前数)
|
||||
insert_index = max(0, len(msg_list) - depth)
|
||||
|
||||
# 确定角色
|
||||
role_map = {"system": "system", "user": "user", "assistant": "assistant"}
|
||||
role = role_map.get(str(entry.position).split('_')[-1] if '_' in str(entry.position) else "system", "system")
|
||||
|
||||
msg_list.insert(insert_index, {"role": "system", "content": entry.content})
|
||||
|
||||
return msg_list
|
||||
|
||||
def _replace_outlets(self, text: str, outlet_map: Dict[str, str]) -> str:
|
||||
"""执行 Outlet 宏替换 (Pos 7)"""
|
||||
def replacer(match):
|
||||
uid = match.group(1)
|
||||
return outlet_map.get(uid, "")
|
||||
|
||||
# 匹配 {{outlet::UID}}
|
||||
return re.sub(r"\{\{outlet::([^}]+)\}\}", replacer, text)
|
||||
|
||||
def _wrap_to_messages(
|
||||
self,
|
||||
story_string: str,
|
||||
an_content: str,
|
||||
history: List[Dict],
|
||||
user_input: str,
|
||||
outlet_map: Dict[str, str],
|
||||
config: PromptConfig
|
||||
) -> List[BaseMessage]:
|
||||
"""将组装好的文本块封装为 LangChain Messages"""
|
||||
messages = []
|
||||
|
||||
# 1. System Message (Story String + Outlet 替换)
|
||||
final_story = self._replace_outlets(story_string, outlet_map)
|
||||
if final_story:
|
||||
messages.append(SystemMessage(content=final_story))
|
||||
|
||||
# 2. Author's Note (根据配置位置插入)
|
||||
if an_content and config.an_position == "before_history":
|
||||
messages.append(SystemMessage(content=self._replace_outlets(an_content, outlet_map)))
|
||||
|
||||
# 3. Chat History
|
||||
for msg_data in history:
|
||||
if msg_data["role"] == "user":
|
||||
messages.append(HumanMessage(content=msg_data["content"]))
|
||||
elif msg_data["role"] == "assistant":
|
||||
messages.append(AIMessage(content=msg_data["content"]))
|
||||
else:
|
||||
messages.append(SystemMessage(content=msg_data["content"]))
|
||||
|
||||
# 4. Author's Note (如果在 History 之后)
|
||||
if an_content and config.an_position == "after_history":
|
||||
messages.append(SystemMessage(content=self._replace_outlets(an_content, outlet_map)))
|
||||
|
||||
# 5. Post-History Instructions & User Input
|
||||
final_input = user_input
|
||||
if config.post_history_instructions:
|
||||
final_input = f"{config.post_history_instructions}\n\n{user_input}"
|
||||
|
||||
messages.append(HumanMessage(content=final_input))
|
||||
|
||||
return messages
|
||||
361
backend/services/regex_service.py
Normal file
361
backend/services/regex_service.py
Normal file
@@ -0,0 +1,361 @@
|
||||
"""
|
||||
正则规则服务(重构版 - 文件夹结构)
|
||||
|
||||
负责加载、管理和应用正则替换规则。
|
||||
使用文件夹结构组织规则,兼容 SillyTavern 格式。
|
||||
|
||||
文件结构:
|
||||
data/regex/
|
||||
├── global/ # 全局规则
|
||||
│ └── default.json
|
||||
├── characters/ # 角色卡绑定规则
|
||||
│ └── {characterName}.json
|
||||
└── presets/ # 预设绑定规则
|
||||
└── {presetName}.json
|
||||
"""
|
||||
import re
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Dict
|
||||
from uuid import uuid4
|
||||
|
||||
from core.config import settings
|
||||
from models.regex_rules import RegexRule, RegexRuleset, RegexScope, RegexPlacement, SubstituteMode
|
||||
from services.system_settings_service import system_settings_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RegexService:
|
||||
"""
|
||||
正则替换规则服务(文件夹结构版)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.regex_base_path = settings.DATA_PATH / "regex"
|
||||
self.global_path = self.regex_base_path / "global"
|
||||
self.characters_path = self.regex_base_path / "characters"
|
||||
self.presets_path = self.regex_base_path / "presets"
|
||||
|
||||
# 内存缓存
|
||||
self.global_rulesets: Dict[str, RegexRuleset] = {}
|
||||
self.character_rulesets: Dict[str, RegexRuleset] = {} # key: characterName
|
||||
self.preset_rulesets: Dict[str, RegexRuleset] = {} # key: presetName
|
||||
|
||||
self._ensure_directories()
|
||||
self._load_all_rules()
|
||||
|
||||
def _ensure_directories(self):
|
||||
"""确保目录结构存在"""
|
||||
for path in [self.regex_base_path, self.global_path, self.characters_path, self.presets_path]:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _load_all_rules(self):
|
||||
"""加载所有规则"""
|
||||
self._load_global_rules()
|
||||
self._load_character_rules()
|
||||
self._load_preset_rules()
|
||||
|
||||
def _load_global_rules(self):
|
||||
"""加载全局规则"""
|
||||
if not self.global_path.exists():
|
||||
return
|
||||
|
||||
for json_file in self.global_path.glob("*.json"):
|
||||
try:
|
||||
with open(json_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
if isinstance(data, list):
|
||||
# SillyTavern 格式
|
||||
ruleset = self._convert_sillytavern_format(data, json_file.stem)
|
||||
elif isinstance(data, dict) and 'rules' in data:
|
||||
# 我们的规则集格式
|
||||
ruleset = RegexRuleset(**data)
|
||||
else:
|
||||
logger.warning(f"未知的规则文件格式: {json_file}")
|
||||
continue
|
||||
|
||||
self.global_rulesets[ruleset.id] = ruleset
|
||||
logger.info(f"加载全局规则集: {ruleset.name} ({len(ruleset.rules)} 条规则)")
|
||||
except Exception as e:
|
||||
logger.error(f"加载全局规则失败 {json_file}: {e}")
|
||||
|
||||
def _load_character_rules(self):
|
||||
"""加载角色卡绑定规则"""
|
||||
if not self.characters_path.exists():
|
||||
return
|
||||
|
||||
for json_file in self.characters_path.glob("*.json"):
|
||||
try:
|
||||
character_name = json_file.stem
|
||||
with open(json_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
if isinstance(data, list):
|
||||
ruleset = self._convert_sillytavern_format(data, character_name, RegexScope.CHARACTER)
|
||||
elif isinstance(data, dict) and 'rules' in data:
|
||||
ruleset = RegexRuleset(**data)
|
||||
else:
|
||||
continue
|
||||
|
||||
# 确保所有规则的 scope 正确
|
||||
for rule in ruleset.rules:
|
||||
rule.scope = RegexScope.CHARACTER
|
||||
rule.characterName = character_name
|
||||
|
||||
self.character_rulesets[character_name] = ruleset
|
||||
logger.info(f"加载角色规则: {character_name} ({len(ruleset.rules)} 条规则)")
|
||||
except Exception as e:
|
||||
logger.error(f"加载角色规则失败 {json_file}: {e}")
|
||||
|
||||
def _load_preset_rules(self):
|
||||
"""加载预设绑定规则"""
|
||||
if not self.presets_path.exists():
|
||||
return
|
||||
|
||||
for json_file in self.presets_path.glob("*.json"):
|
||||
try:
|
||||
preset_name = json_file.stem
|
||||
with open(json_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
if isinstance(data, list):
|
||||
ruleset = self._convert_sillytavern_format(data, preset_name, RegexScope.PRESET)
|
||||
elif isinstance(data, dict) and 'rules' in data:
|
||||
ruleset = RegexRuleset(**data)
|
||||
else:
|
||||
continue
|
||||
|
||||
# 确保所有规则的 scope 正确
|
||||
for rule in ruleset.rules:
|
||||
rule.scope = RegexScope.PRESET
|
||||
rule.presetName = preset_name
|
||||
|
||||
self.preset_rulesets[preset_name] = ruleset
|
||||
logger.info(f"加载预设规则: {preset_name} ({len(ruleset.rules)} 条规则)")
|
||||
except Exception as e:
|
||||
logger.error(f"加载预设规则失败 {json_file}: {e}")
|
||||
|
||||
def _convert_sillytavern_format(
|
||||
self,
|
||||
st_rules: List[dict],
|
||||
name: str,
|
||||
scope: RegexScope = RegexScope.GLOBAL
|
||||
) -> RegexRuleset:
|
||||
"""将 SillyTavern 格式转换为内部格式"""
|
||||
rules = []
|
||||
for idx, st_rule in enumerate(st_rules):
|
||||
find_regex = st_rule.get('findRegex', '')
|
||||
pattern, flags = self._parse_st_regex(find_regex)
|
||||
|
||||
# 解析 placement(默认为 AI_OUTPUT)
|
||||
placement_data = st_rule.get('placement', [2])
|
||||
placement = [RegexPlacement(p) for p in placement_data]
|
||||
|
||||
rule = RegexRule(
|
||||
id=str(uuid4()),
|
||||
scriptName=st_rule.get('scriptName', f"{name} 规则 {idx + 1}"),
|
||||
findRegex=pattern,
|
||||
replaceString=st_rule.get('replaceString', ''),
|
||||
trimStrings=st_rule.get('trimStrings', []),
|
||||
placement=placement,
|
||||
substituteRegex=SubstituteMode(st_rule.get('substituteRegex', 0)),
|
||||
markdownOnly=st_rule.get('markdownOnly', False),
|
||||
promptOnly=st_rule.get('promptOnly', False),
|
||||
runOnEdit=st_rule.get('runOnEdit', True),
|
||||
minDepth=st_rule.get('minDepth', 0),
|
||||
maxDepth=st_rule.get('maxDepth'),
|
||||
scope=scope,
|
||||
characterName=name if scope == RegexScope.CHARACTER else None,
|
||||
presetName=name if scope == RegexScope.PRESET else None,
|
||||
disabled=st_rule.get('disabled', False),
|
||||
order=idx
|
||||
)
|
||||
rules.append(rule)
|
||||
|
||||
ruleset = RegexRuleset(
|
||||
id=str(uuid4()),
|
||||
name=f"{name} 规则集",
|
||||
description=f"从 SillyTavern 导入的规则",
|
||||
rules=rules,
|
||||
isSillyTavernFormat=True
|
||||
)
|
||||
|
||||
return ruleset
|
||||
|
||||
def _parse_st_regex(self, st_regex: str) -> tuple[str, str]:
|
||||
"""解析 SillyTavern 的正则表达式格式 /pattern/flags"""
|
||||
if st_regex.startswith('/') and st_regex.count('/') >= 2:
|
||||
parts = st_regex.split('/')
|
||||
pattern = '/'.join(parts[1:-1])
|
||||
flags = parts[-1] if len(parts) > 2 else ''
|
||||
return pattern, flags
|
||||
else:
|
||||
return st_regex, ''
|
||||
|
||||
def apply_rules_by_placement(
|
||||
self,
|
||||
text: str,
|
||||
placement: int,
|
||||
character_name: Optional[str] = None,
|
||||
preset_name: Optional[str] = None,
|
||||
message_depth: int = 0,
|
||||
is_for_llm: bool = False, # ✅ 新增:是否发送给LLM
|
||||
is_markdown_rendered: bool = False # ✅ 新增:是否已Markdown渲染
|
||||
) -> str:
|
||||
"""
|
||||
根据 placement 应用正则规则
|
||||
|
||||
Args:
|
||||
text: 要处理的文本
|
||||
placement: 应用位置(0-5)
|
||||
character_name: 当前角色卡名称
|
||||
preset_name: 当前预设名称
|
||||
message_depth: 消息深度
|
||||
is_for_llm: 是否用于发送给 LLM(影响 promptOnly 逻辑)
|
||||
is_markdown_rendered: 是否是 Markdown 渲染后的内容(影响 markdownOnly 逻辑)
|
||||
|
||||
Returns:
|
||||
处理后的文本
|
||||
"""
|
||||
rules = self.get_rules_for_context(character_name, preset_name)
|
||||
|
||||
result = text
|
||||
for rule in rules:
|
||||
# ✅ SillyTavern 逻辑:根据 markdownOnly 和 promptOnly 决定是否应用
|
||||
# - 双 false:应用到所有场景(包括保存数据、发送LLM、显示)
|
||||
# - markdownOnly=true:只应用于 Markdown 渲染(前端显示)
|
||||
# - promptOnly=true:只应用于发送给 LLM
|
||||
# - 双 true:应用到所有场景(但不修改存储,由调用方决定)
|
||||
|
||||
# 如果是保存数据的场景(is_for_llm=False 且 is_markdown_rendered=False)
|
||||
# 只应用双 false 的规则
|
||||
if not is_for_llm and not is_markdown_rendered:
|
||||
# 保存数据:只应用双 false 的规则
|
||||
if rule.markdownOnly or rule.promptOnly:
|
||||
continue
|
||||
|
||||
# 如果是发送给 LLM 的场景
|
||||
elif is_for_llm and not is_markdown_rendered:
|
||||
# 不应用 markdownOnly=true 且 promptOnly=false 的规则
|
||||
if rule.markdownOnly and not rule.promptOnly:
|
||||
continue
|
||||
|
||||
# 如果是 Markdown 渲染的场景(前端显示)
|
||||
elif is_markdown_rendered and not is_for_llm:
|
||||
# 不应用 promptOnly=true 且 markdownOnly=false 的规则
|
||||
if rule.promptOnly and not rule.markdownOnly:
|
||||
continue
|
||||
|
||||
# 检查此规则是否适用于当前 placement
|
||||
if placement not in [p.value for p in rule.placement]:
|
||||
continue
|
||||
|
||||
# 检查消息深度限制
|
||||
if message_depth < rule.minDepth:
|
||||
continue
|
||||
if rule.maxDepth is not None and message_depth > rule.maxDepth:
|
||||
continue
|
||||
|
||||
# 应用规则
|
||||
result = self._apply_single_rule(result, rule)
|
||||
|
||||
return result
|
||||
|
||||
def _apply_single_rule(self, text: str, rule: RegexRule) -> str:
|
||||
"""应用单条正则规则"""
|
||||
try:
|
||||
flags = 0
|
||||
if 'i' in rule.findRegex:
|
||||
flags |= re.IGNORECASE
|
||||
if 'm' in rule.findRegex:
|
||||
flags |= re.MULTILINE
|
||||
if 's' in rule.findRegex:
|
||||
flags |= re.DOTALL
|
||||
|
||||
pattern = rule.findRegex.replace('i', '').replace('m', '').replace('s', '')
|
||||
|
||||
if rule.substituteRegex == SubstituteMode.REPLACE_FIRST:
|
||||
result = re.sub(pattern, rule.replaceString, text, count=1, flags=flags)
|
||||
else:
|
||||
result = re.sub(pattern, rule.replaceString, text, flags=flags)
|
||||
|
||||
for trim_str in rule.trimStrings:
|
||||
result = result.replace(trim_str, '')
|
||||
|
||||
return result
|
||||
except re.error as e:
|
||||
logger.error(f"正则表达式错误 [{rule.scriptName}]: {e}")
|
||||
return text
|
||||
|
||||
def get_rules_for_context(
|
||||
self,
|
||||
character_name: Optional[str] = None,
|
||||
preset_name: Optional[str] = None
|
||||
) -> List[RegexRule]:
|
||||
"""
|
||||
根据上下文获取适用的规则列表
|
||||
|
||||
优先级:全局规则 + 角色规则 + 预设规则
|
||||
"""
|
||||
applicable_rules = []
|
||||
|
||||
# 1. 加载全局规则
|
||||
for ruleset in self.global_rulesets.values():
|
||||
for rule in ruleset.rules:
|
||||
if not rule.disabled:
|
||||
applicable_rules.append(rule)
|
||||
|
||||
# 2. 加载角色卡规则
|
||||
if character_name and character_name in self.character_rulesets:
|
||||
ruleset = self.character_rulesets[character_name]
|
||||
for rule in ruleset.rules:
|
||||
if not rule.disabled:
|
||||
applicable_rules.append(rule)
|
||||
|
||||
# 3. 加载预设规则
|
||||
if preset_name and preset_name in self.preset_rulesets:
|
||||
ruleset = self.preset_rulesets[preset_name]
|
||||
for rule in ruleset.rules:
|
||||
if not rule.disabled:
|
||||
applicable_rules.append(rule)
|
||||
|
||||
# 按 order 排序
|
||||
applicable_rules.sort(key=lambda r: r.order)
|
||||
|
||||
return applicable_rules
|
||||
|
||||
def save_ruleset(self, ruleset: RegexRuleset, scope: RegexScope, name: Optional[str] = None):
|
||||
"""保存规则集到文件"""
|
||||
if scope == RegexScope.GLOBAL:
|
||||
file_path = self.global_path / f"{ruleset.id}.json"
|
||||
elif scope == RegexScope.CHARACTER:
|
||||
file_path = self.characters_path / f"{name or 'unknown'}.json"
|
||||
elif scope == RegexScope.PRESET:
|
||||
file_path = self.presets_path / f"{name or 'unknown'}.json"
|
||||
else:
|
||||
raise ValueError(f"未知的作用域: {scope}")
|
||||
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(ruleset.dict(), f, ensure_ascii=False, indent=2)
|
||||
|
||||
logger.info(f"保存规则集到: {file_path}")
|
||||
|
||||
def delete_ruleset(self, scope: RegexScope, name: str):
|
||||
"""删除规则集"""
|
||||
if scope == RegexScope.CHARACTER:
|
||||
file_path = self.characters_path / f"{name}.json"
|
||||
elif scope == RegexScope.PRESET:
|
||||
file_path = self.presets_path / f"{name}.json"
|
||||
else:
|
||||
raise ValueError(f"不能删除全局规则集")
|
||||
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
logger.info(f"删除规则集: {file_path}")
|
||||
|
||||
|
||||
# 全局实例
|
||||
regex_service = RegexService()
|
||||
189
backend/services/script_manager.py
Normal file
189
backend/services/script_manager.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
脚本管理模块
|
||||
|
||||
管理 Tavern Helper 的脚本,支持三种作用域:
|
||||
- GLOBAL: 全局脚本,对所有聊天可用
|
||||
- CHARACTER: 角色脚本,绑定到当前角色卡
|
||||
- PRESET: 预设脚本,绑定到当前预设
|
||||
|
||||
每个脚本包含:
|
||||
- 脚本名称
|
||||
- 脚本内容(JavaScript 代码)
|
||||
- 作者备注
|
||||
- 变量列表(绑定到脚本的变量)
|
||||
- 按钮配置(配合 getButtonEvent 使用)
|
||||
- 启用状态
|
||||
"""
|
||||
from enum import Enum
|
||||
from typing import List, Optional, Dict, Any
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
|
||||
class ScriptScope(str, Enum):
|
||||
"""脚本作用域"""
|
||||
GLOBAL = 'global' # 全局脚本
|
||||
CHARACTER = 'character' # 角色脚本
|
||||
PRESET = 'preset' # 预设脚本
|
||||
|
||||
|
||||
class ScriptVariable(BaseModel):
|
||||
"""脚本变量"""
|
||||
name: str = Field(..., description="变量名")
|
||||
value: Any = Field(..., description="变量值")
|
||||
description: Optional[str] = Field(None, description="变量描述")
|
||||
|
||||
|
||||
class ScriptButton(BaseModel):
|
||||
"""脚本按钮配置"""
|
||||
label: str = Field(..., description="按钮显示文本")
|
||||
event: str = Field(..., description="按钮事件名称(配合 getButtonEvent 使用)")
|
||||
enabled: bool = Field(True, description="是否启用")
|
||||
|
||||
|
||||
class ScriptItem(BaseModel):
|
||||
"""脚本项"""
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="脚本唯一标识符")
|
||||
name: str = Field(..., description="脚本名称")
|
||||
content: str = Field(..., description="脚本内容(JavaScript 代码)")
|
||||
authorNote: Optional[str] = Field(None, description="作者备注")
|
||||
|
||||
# 变量列表
|
||||
variables: List[ScriptVariable] = Field(default_factory=list, description="绑定到脚本的变量")
|
||||
|
||||
# 按钮配置
|
||||
buttons: List[ScriptButton] = Field(default_factory=list, description="按钮配置")
|
||||
|
||||
# 作用域
|
||||
scope: ScriptScope = Field(ScriptScope.GLOBAL, description="脚本作用域")
|
||||
characterName: Optional[str] = Field(None, description="绑定的角色卡名称")
|
||||
presetName: Optional[str] = Field(None, description="绑定的预设名称")
|
||||
|
||||
# 启用状态
|
||||
enabled: bool = Field(True, description="是否启用")
|
||||
|
||||
# 元数据
|
||||
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
|
||||
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="更新时间戳")
|
||||
order: int = Field(0, description="执行顺序")
|
||||
|
||||
|
||||
class ScriptManager:
|
||||
"""脚本管理器"""
|
||||
|
||||
def __init__(self):
|
||||
self.scripts: List[ScriptItem] = []
|
||||
|
||||
def add_script(self, script: ScriptItem):
|
||||
"""添加脚本"""
|
||||
self.scripts.append(script)
|
||||
|
||||
def remove_script(self, script_id: str) -> bool:
|
||||
"""删除脚本"""
|
||||
for i, script in enumerate(self.scripts):
|
||||
if script.id == script_id:
|
||||
self.scripts.pop(i)
|
||||
return True
|
||||
return False
|
||||
|
||||
def update_script(self, script_id: str, updates: Dict[str, Any]) -> bool:
|
||||
"""更新脚本"""
|
||||
for script in self.scripts:
|
||||
if script.id == script_id:
|
||||
for key, value in updates.items():
|
||||
if hasattr(script, key):
|
||||
setattr(script, key, value)
|
||||
script.updatedAt = int(datetime.now().timestamp())
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_scripts_by_scope(self, scope: ScriptScope, filter_name: Optional[str] = None) -> List[ScriptItem]:
|
||||
"""按作用域获取脚本"""
|
||||
scripts = [s for s in self.scripts if s.scope == scope]
|
||||
|
||||
if filter_name:
|
||||
scripts = [s for s in scripts if filter_name.lower() in s.name.lower()]
|
||||
|
||||
return sorted(scripts, key=lambda s: s.order)
|
||||
|
||||
def get_enabled_scripts(self, scope: ScriptScope) -> List[ScriptItem]:
|
||||
"""获取启用的脚本"""
|
||||
return [s for s in self.scripts if s.scope == scope and s.enabled]
|
||||
|
||||
def get_script(self, script_id: str) -> Optional[ScriptItem]:
|
||||
"""获取单个脚本"""
|
||||
for script in self.scripts:
|
||||
if script.id == script_id:
|
||||
return script
|
||||
return None
|
||||
|
||||
def toggle_script(self, script_id: str) -> bool:
|
||||
"""切换脚本启用状态"""
|
||||
for script in self.scripts:
|
||||
if script.id == script_id:
|
||||
script.enabled = not script.enabled
|
||||
script.updatedAt = int(datetime.now().timestamp())
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_all_scripts(self) -> List[ScriptItem]:
|
||||
"""获取所有脚本"""
|
||||
return self.scripts
|
||||
|
||||
def export_scripts(self, scope: Optional[ScriptScope] = None) -> List[Dict]:
|
||||
"""导出脚本"""
|
||||
if scope:
|
||||
scripts = [s for s in self.scripts if s.scope == scope]
|
||||
else:
|
||||
scripts = self.scripts
|
||||
|
||||
return [s.dict() for s in scripts]
|
||||
|
||||
def import_scripts(self, scripts_data: List[Dict], scope: ScriptScope) -> int:
|
||||
"""导入脚本"""
|
||||
count = 0
|
||||
for data in scripts_data:
|
||||
try:
|
||||
script = ScriptItem(**data)
|
||||
script.scope = scope
|
||||
self.scripts.append(script)
|
||||
count += 1
|
||||
except Exception as e:
|
||||
print(f"导入脚本失败: {e}")
|
||||
|
||||
return count
|
||||
|
||||
|
||||
# 全局脚本管理器实例
|
||||
script_manager = ScriptManager()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import json
|
||||
|
||||
# 测试脚本管理
|
||||
manager = ScriptManager()
|
||||
|
||||
# 添加测试脚本
|
||||
script1 = ScriptItem(
|
||||
name="【骰子系统】-自动更新",
|
||||
content="async function getLatestVersion() {\n try {\n const response = await fetch('/api/version');\n return await response.json();\n } catch (e) {\n return null;\n }\n}",
|
||||
authorNote="感谢a佬开源\n以九颜二改为基础进行三改\n@kousakayou",
|
||||
scope=ScriptScope.GLOBAL,
|
||||
variables=[
|
||||
ScriptVariable(name="version", value="4.8.4", description="版本号")
|
||||
],
|
||||
buttons=[
|
||||
ScriptButton(label="检查更新", event="checkUpdate", enabled=True)
|
||||
]
|
||||
)
|
||||
|
||||
manager.add_script(script1)
|
||||
|
||||
# 导出测试
|
||||
print("=== 导出脚本 ===")
|
||||
exported = manager.export_scripts()
|
||||
print(json.dumps(exported, indent=2, ensure_ascii=False))
|
||||
|
||||
print("\n✅ 脚本管理测试完成!")
|
||||
105
backend/services/state_machine_runner.py
Normal file
105
backend/services/state_machine_runner.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
JSON state machine runner for workflow templates.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
try:
|
||||
from backend.models.agent import RunEvent, RunEventType, TurnContext, WorkflowRun, RunStatus
|
||||
from backend.services.tool_registry import ToolRegistry
|
||||
except ImportError:
|
||||
from models.agent import RunEvent, RunEventType, TurnContext, WorkflowRun, RunStatus
|
||||
from services.tool_registry import ToolRegistry
|
||||
|
||||
|
||||
class StateMachineRunner:
|
||||
def __init__(
|
||||
self,
|
||||
definition: Dict[str, Any],
|
||||
registry: ToolRegistry,
|
||||
*,
|
||||
on_event: Optional[Callable[[RunEvent], None]] = None,
|
||||
) -> None:
|
||||
self.definition = definition
|
||||
self.registry = registry
|
||||
self.on_event = on_event
|
||||
self.states: Dict[str, Dict[str, Any]] = definition.get("states", {})
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: Path, registry: ToolRegistry, **kwargs) -> "StateMachineRunner":
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
definition = json.load(f)
|
||||
return cls(definition, registry, **kwargs)
|
||||
|
||||
def _emit(self, run: WorkflowRun, event_type: RunEventType, **payload: Any) -> RunEvent:
|
||||
event = RunEvent(
|
||||
run_id=run.id,
|
||||
type=event_type,
|
||||
state=run.current_state,
|
||||
tool=payload.pop("tool", None),
|
||||
payload=payload,
|
||||
)
|
||||
if self.on_event:
|
||||
self.on_event(event)
|
||||
return event
|
||||
|
||||
async def run(self, run: WorkflowRun, ctx: TurnContext) -> List[RunEvent]:
|
||||
events: List[RunEvent] = []
|
||||
original_on_event = self.on_event
|
||||
|
||||
def collect(event: RunEvent) -> None:
|
||||
events.append(event)
|
||||
if original_on_event:
|
||||
original_on_event(event)
|
||||
|
||||
self.on_event = collect
|
||||
|
||||
initial = self.definition.get("initial")
|
||||
if not initial:
|
||||
raise ValueError("State machine missing 'initial' state")
|
||||
|
||||
current = initial
|
||||
run.status = RunStatus.RUNNING
|
||||
|
||||
try:
|
||||
while current:
|
||||
state_def = self.states.get(current)
|
||||
if not state_def:
|
||||
raise ValueError(f"Unknown state: {current}")
|
||||
|
||||
run.current_state = current
|
||||
events.append(self._emit(run, RunEventType.STATE_ENTER, state=current))
|
||||
|
||||
tool_name = state_def.get("tool")
|
||||
if tool_name:
|
||||
events.append(self._emit(run, RunEventType.TOOL_START, tool=tool_name))
|
||||
await self.registry.execute(tool_name, ctx)
|
||||
events.append(
|
||||
self._emit(
|
||||
run,
|
||||
RunEventType.TOOL_END,
|
||||
tool=tool_name,
|
||||
success=True,
|
||||
)
|
||||
)
|
||||
|
||||
current = state_def.get("next")
|
||||
if current == "end" or current is None:
|
||||
break
|
||||
|
||||
run.status = RunStatus.COMPLETED
|
||||
run.result_content = ctx.generated_content
|
||||
events.append(self._emit(run, RunEventType.COMPLETE))
|
||||
except Exception as exc:
|
||||
run.status = RunStatus.FAILED
|
||||
run.error = str(exc)
|
||||
ctx.error = str(exc)
|
||||
events.append(self._emit(run, RunEventType.ERROR, message=str(exc)))
|
||||
raise
|
||||
finally:
|
||||
self.on_event = original_on_event
|
||||
|
||||
return events
|
||||
188
backend/services/studio_context_service.py
Normal file
188
backend/services/studio_context_service.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""
|
||||
Assemble Studio run prompt context from pipeline snapshot, workflow variables,
|
||||
and node outputs (R2). Does not include full chat history.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from models.studio_models import (
|
||||
PipelineDefinition,
|
||||
PromptBlock,
|
||||
StudioNode,
|
||||
StudioNodeRunState,
|
||||
StudioRun,
|
||||
)
|
||||
|
||||
_NODE_OUTPUT_REF = re.compile(r"^([^.]+)\.output$")
|
||||
|
||||
AUTO_BLOCK_SPECS = (
|
||||
("currentProduct", "目前产物", "auto"),
|
||||
("thinkingFlow", "思考流程", "auto"),
|
||||
("coreGoal", "核心目的", "auto"),
|
||||
("scoringCriteria", "评价标准与优化建议", "auto"),
|
||||
)
|
||||
|
||||
|
||||
def _find_node(pipeline: PipelineDefinition, node_id: str) -> Optional[StudioNode]:
|
||||
for node in pipeline.nodes:
|
||||
if node.id == node_id:
|
||||
return node
|
||||
return None
|
||||
|
||||
|
||||
def _state_map(run: StudioRun) -> Dict[str, StudioNodeRunState]:
|
||||
return {s.nodeId: s for s in run.nodeStates}
|
||||
|
||||
|
||||
def _format_draft(draft: Optional[Dict[str, Any]]) -> str:
|
||||
if not draft:
|
||||
return "(暂无内容)"
|
||||
for key in ("entryContent", "content", "text", "body"):
|
||||
value = draft.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return json.dumps(draft, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _format_scoring(config: Dict[str, Any]) -> str:
|
||||
scoring = config.get("scoring") or {}
|
||||
if not scoring.get("enabled", True):
|
||||
return "(本步骤未启用评价)"
|
||||
dimensions = scoring.get("dimensions") or []
|
||||
if not dimensions:
|
||||
rubric = scoring.get("rubric")
|
||||
if rubric:
|
||||
return str(rubric).strip()
|
||||
return "(未配置评价维度)"
|
||||
lines: List[str] = []
|
||||
for dim in dimensions:
|
||||
name = dim.get("name") or dim.get("id") or "维度"
|
||||
criteria = (dim.get("criteria") or "").strip()
|
||||
lines.append(f"- {name}:{criteria}" if criteria else f"- {name}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _resolve_workflow_ref(ref: str, workflow_variables: Dict[str, Any]) -> str:
|
||||
value = workflow_variables.get(ref)
|
||||
if value is None:
|
||||
return "(尚未可用)"
|
||||
if isinstance(value, str):
|
||||
return value.strip() or "(空)"
|
||||
return json.dumps(value, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _resolve_node_output_ref(
|
||||
ref: str,
|
||||
pipeline: PipelineDefinition,
|
||||
state_by_id: Dict[str, StudioNodeRunState],
|
||||
) -> str:
|
||||
match = _NODE_OUTPUT_REF.match(ref)
|
||||
if not match:
|
||||
return f"(无法解析引用:{ref})"
|
||||
node_id = match.group(1)
|
||||
source_node = _find_node(pipeline, node_id)
|
||||
source_state = state_by_id.get(node_id)
|
||||
label = source_node.displayName if source_node else node_id
|
||||
if not source_state or source_state.status != "completed":
|
||||
return f"(前序步骤「{label}」尚未完成)"
|
||||
return _format_draft(source_state.lastDraft)
|
||||
|
||||
|
||||
def _auto_block_content(
|
||||
block_id: str,
|
||||
node: StudioNode,
|
||||
node_state: Optional[StudioNodeRunState],
|
||||
) -> str:
|
||||
config = node.config or {}
|
||||
if block_id == "currentProduct":
|
||||
return _format_draft(node_state.lastDraft if node_state else None)
|
||||
if block_id == "thinkingFlow":
|
||||
return (config.get("thinkingPrompt") or "").strip() or "(未配置思考流程)"
|
||||
if block_id == "coreGoal":
|
||||
return (config.get("stepGoal") or "").strip() or "(未配置步骤目标)"
|
||||
if block_id == "scoringCriteria":
|
||||
return _format_scoring(config)
|
||||
return ""
|
||||
|
||||
|
||||
def assemble_prompt_blocks(run: StudioRun, node_id: str) -> List[PromptBlock]:
|
||||
"""
|
||||
Build ordered prompt blocks for a worldbook step from inputs[].ref,
|
||||
workflow variables, node outputs, and auto-injected context items.
|
||||
"""
|
||||
pipeline = run.pipelineSnapshot
|
||||
node = _find_node(pipeline, node_id)
|
||||
if not node:
|
||||
return []
|
||||
|
||||
if node.skillId != "studio.worldbook_entry":
|
||||
return []
|
||||
|
||||
state_by_id = _state_map(run)
|
||||
node_state = state_by_id.get(node_id)
|
||||
workflow_variables = dict(run.workflowVariables or {})
|
||||
blocks: List[PromptBlock] = []
|
||||
seen_ids: set[str] = set()
|
||||
|
||||
def append_block(
|
||||
block_id: str,
|
||||
label: str,
|
||||
content: str,
|
||||
source: str,
|
||||
) -> None:
|
||||
if block_id in seen_ids:
|
||||
return
|
||||
seen_ids.add(block_id)
|
||||
blocks.append(
|
||||
PromptBlock(
|
||||
id=block_id,
|
||||
label=label,
|
||||
content=content,
|
||||
source=source,
|
||||
)
|
||||
)
|
||||
|
||||
for inp in node.inputs or []:
|
||||
ref = (inp.ref or "").strip()
|
||||
if not ref:
|
||||
continue
|
||||
label = (inp.label or ref).strip()
|
||||
block_id = f"ref:{ref}"
|
||||
|
||||
if ref.startswith("workflow."):
|
||||
content = _resolve_workflow_ref(ref, workflow_variables)
|
||||
if inp.optional and content in ("(尚未可用)", "(空)"):
|
||||
continue
|
||||
append_block(block_id, label, content, "workflow")
|
||||
continue
|
||||
|
||||
if _NODE_OUTPUT_REF.match(ref):
|
||||
content = _resolve_node_output_ref(ref, pipeline, state_by_id)
|
||||
if inp.optional and content.startswith("(前序步骤"):
|
||||
continue
|
||||
append_block(block_id, label, content, "manual")
|
||||
continue
|
||||
|
||||
append_block(block_id, label, f"(未知引用类型:{ref})", "manual")
|
||||
|
||||
for block_id, label, source in AUTO_BLOCK_SPECS:
|
||||
content = _auto_block_content(block_id, node, node_state)
|
||||
append_block(block_id, label, content, source)
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
def store_context_on_run(run: StudioRun, node_id: Optional[str]) -> StudioRun:
|
||||
"""Attach assembled prompt blocks to run for debug / frontend display."""
|
||||
if not node_id:
|
||||
return run.model_copy(update={"lastPromptBlocks": []})
|
||||
|
||||
node = _find_node(run.pipelineSnapshot, node_id)
|
||||
if not node or node.skillId != "studio.worldbook_entry":
|
||||
return run.model_copy(update={"lastPromptBlocks": []})
|
||||
|
||||
blocks = assemble_prompt_blocks(run, node_id)
|
||||
return run.model_copy(update={"lastPromptBlocks": blocks})
|
||||
456
backend/services/studio_project_service.py
Normal file
456
backend/services/studio_project_service.py
Normal file
@@ -0,0 +1,456 @@
|
||||
"""
|
||||
Load/save Studio projects and skill templates from data/agent/.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from core.config import settings
|
||||
from models.studio_models import (
|
||||
CreateStudioProjectRequest,
|
||||
PipelineDefinition,
|
||||
StudioProject,
|
||||
StudioProjectMeta,
|
||||
StudioProjectSummary,
|
||||
WorkflowTemplateSummary,
|
||||
WorkflowVariablesResponse,
|
||||
WorkflowVariableDef,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_TEMPLATE_ID = "builtin.studio.example"
|
||||
|
||||
POSITION_STRING_MAP = {
|
||||
"after_char": 0,
|
||||
"before_char": 1,
|
||||
"before_example": 2,
|
||||
"after_example": 3,
|
||||
"system": 4,
|
||||
"as_system": 5,
|
||||
"depth": 6,
|
||||
"macro": 7,
|
||||
}
|
||||
|
||||
ACTIVATION_LEGACY_MAP = {
|
||||
"normal": "permanent",
|
||||
"constant": "permanent",
|
||||
"selective": "keyword",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_position(value: Any) -> int:
|
||||
if value is None:
|
||||
return 1
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
if value.isdigit():
|
||||
return int(value)
|
||||
return POSITION_STRING_MAP.get(value, 1)
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
|
||||
|
||||
def _normalize_activation(value: Any) -> str:
|
||||
if not value:
|
||||
return "permanent"
|
||||
text = str(value)
|
||||
return ACTIVATION_LEGACY_MAP.get(text, text)
|
||||
|
||||
|
||||
def _migrate_scoring(scoring: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if not scoring:
|
||||
return {"enabled": True, "dimensions": []}
|
||||
if scoring.get("dimensions"):
|
||||
return scoring
|
||||
rubric = scoring.get("rubric")
|
||||
if rubric:
|
||||
scoring = {**scoring}
|
||||
scoring["dimensions"] = [
|
||||
{
|
||||
"id": "default",
|
||||
"name": "综合质量",
|
||||
"criteria": rubric,
|
||||
}
|
||||
]
|
||||
scoring.pop("rubric", None)
|
||||
return scoring
|
||||
|
||||
|
||||
def _normalize_node(node: Dict[str, Any]) -> Dict[str, Any]:
|
||||
node = dict(node)
|
||||
config = dict(node.get("config") or {})
|
||||
insertion = dict(config.get("insertion") or {})
|
||||
if insertion:
|
||||
insertion["position"] = _normalize_position(insertion.get("position"))
|
||||
insertion["activationType"] = _normalize_activation(
|
||||
insertion.get("activationType")
|
||||
)
|
||||
config["insertion"] = insertion
|
||||
if "scoring" in config:
|
||||
config["scoring"] = _migrate_scoring(dict(config.get("scoring") or {}))
|
||||
node["config"] = config
|
||||
return node
|
||||
|
||||
|
||||
def _parse_node_ref(ref: str, node_ids: set[str]) -> Optional[str]:
|
||||
if not ref or not ref.endswith(".output"):
|
||||
return None
|
||||
node_id = ref[: -len(".output")]
|
||||
return node_id if node_id in node_ids else None
|
||||
|
||||
|
||||
def _build_node_dependency_edges(pipeline: Dict[str, Any]) -> List[tuple[str, str]]:
|
||||
nodes = pipeline.get("nodes") or []
|
||||
node_ids = {n["id"] for n in nodes if n.get("id")}
|
||||
edges: List[tuple[str, str]] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for node in nodes:
|
||||
to_id = node.get("id")
|
||||
if not to_id:
|
||||
continue
|
||||
for inp in node.get("inputs") or []:
|
||||
src = _parse_node_ref(inp.get("ref", ""), node_ids)
|
||||
if not src or src == to_id:
|
||||
continue
|
||||
pair = (src, to_id)
|
||||
if pair in seen:
|
||||
continue
|
||||
seen.add(pair)
|
||||
edges.append(pair)
|
||||
return edges
|
||||
|
||||
|
||||
def _detect_reference_cycles(pipeline: Dict[str, Any]) -> List[List[str]]:
|
||||
nodes = pipeline.get("nodes") or []
|
||||
node_ids = [n["id"] for n in nodes if n.get("id")]
|
||||
adj: Dict[str, List[str]] = {nid: [] for nid in node_ids}
|
||||
for src, dst in _build_node_dependency_edges(pipeline):
|
||||
adj[src].append(dst)
|
||||
|
||||
cycles: List[List[str]] = []
|
||||
visited: set[str] = set()
|
||||
stack: set[str] = set()
|
||||
path: List[str] = []
|
||||
|
||||
def dfs(node_id: str) -> None:
|
||||
visited.add(node_id)
|
||||
stack.add(node_id)
|
||||
path.append(node_id)
|
||||
for nxt in adj.get(node_id, []):
|
||||
if nxt not in visited:
|
||||
dfs(nxt)
|
||||
elif nxt in stack:
|
||||
start = path.index(nxt)
|
||||
if start >= 0:
|
||||
cycles.append(path[start:] + [nxt])
|
||||
path.pop()
|
||||
stack.discard(node_id)
|
||||
|
||||
for nid in node_ids:
|
||||
if nid not in visited:
|
||||
dfs(nid)
|
||||
return cycles
|
||||
|
||||
|
||||
def _validate_pipeline_refs(pipeline: Dict[str, Any]) -> None:
|
||||
cycles = _detect_reference_cycles(pipeline)
|
||||
if not cycles:
|
||||
return
|
||||
nodes = {n["id"]: n.get("displayName", n["id"]) for n in pipeline.get("nodes") or []}
|
||||
first = cycles[0]
|
||||
chain = " → ".join(nodes.get(nid, nid) for nid in first)
|
||||
raise ValueError(f"流水线存在循环引用:{chain}")
|
||||
|
||||
|
||||
def _normalize_pipeline_dict(pipeline: Dict[str, Any]) -> Dict[str, Any]:
|
||||
pipeline = dict(pipeline)
|
||||
nodes = pipeline.get("nodes") or []
|
||||
pipeline["nodes"] = [_normalize_node(n) for n in nodes]
|
||||
return pipeline
|
||||
|
||||
|
||||
def _read_json(path: Path) -> Any:
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _write_json(path: Path, data: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _slugify(name: str) -> str:
|
||||
slug = re.sub(r"[^\w\u4e00-\u9fff-]+", "-", name.strip(), flags=re.UNICODE)
|
||||
slug = re.sub(r"-+", "-", slug).strip("-").lower()
|
||||
return slug or "project"
|
||||
|
||||
|
||||
class StudioProjectService:
|
||||
@property
|
||||
def projects_root(self) -> Path:
|
||||
return settings.AGENT_STUDIO_PROJECTS_PATH
|
||||
|
||||
@property
|
||||
def templates_root(self) -> Path:
|
||||
return settings.AGENT_TEMPLATES_PATH
|
||||
|
||||
def _project_dir(self, project_id: str) -> Path:
|
||||
return self.projects_root / project_id
|
||||
|
||||
def _meta_path(self, project_id: str) -> Path:
|
||||
return self._project_dir(project_id) / "meta.json"
|
||||
|
||||
def _pipeline_path(self, project_id: str) -> Path:
|
||||
return self._project_dir(project_id) / "pipeline.json"
|
||||
|
||||
def list_projects(self) -> List[StudioProjectSummary]:
|
||||
root = self.projects_root
|
||||
if not root.exists():
|
||||
return []
|
||||
summaries: List[StudioProjectSummary] = []
|
||||
for child in sorted(root.iterdir()):
|
||||
if not child.is_dir():
|
||||
continue
|
||||
meta_path = child / "meta.json"
|
||||
if not meta_path.exists():
|
||||
continue
|
||||
meta = _read_json(meta_path)
|
||||
summaries.append(
|
||||
StudioProjectSummary(
|
||||
id=meta.get("id", child.name),
|
||||
name=meta.get("name", child.name),
|
||||
description=meta.get("description", ""),
|
||||
updatedAt=meta.get("updatedAt", ""),
|
||||
)
|
||||
)
|
||||
return summaries
|
||||
|
||||
def get_project(self, project_id: str) -> StudioProject:
|
||||
meta_path = self._meta_path(project_id)
|
||||
pipeline_path = self._pipeline_path(project_id)
|
||||
if not meta_path.exists() or not pipeline_path.exists():
|
||||
raise FileNotFoundError(f"Studio project not found: {project_id}")
|
||||
meta = _read_json(meta_path)
|
||||
pipeline = _normalize_pipeline_dict(_read_json(pipeline_path))
|
||||
return StudioProject(
|
||||
meta=StudioProjectMeta(**meta),
|
||||
pipeline=PipelineDefinition(**pipeline),
|
||||
)
|
||||
|
||||
def update_project_bindings(
|
||||
self,
|
||||
project_id: str,
|
||||
character_id: str,
|
||||
worldbook_id: str,
|
||||
) -> StudioProject:
|
||||
meta_path = self._meta_path(project_id)
|
||||
if not meta_path.exists():
|
||||
raise FileNotFoundError(f"Studio project not found: {project_id}")
|
||||
meta = _read_json(meta_path)
|
||||
meta["characterId"] = character_id
|
||||
meta["worldbookId"] = worldbook_id
|
||||
meta["updatedAt"] = datetime.now().isoformat()
|
||||
_write_json(meta_path, meta)
|
||||
return self.get_project(project_id)
|
||||
|
||||
def update_project_meta(
|
||||
self,
|
||||
project_id: str,
|
||||
*,
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
) -> StudioProject:
|
||||
meta_path = self._meta_path(project_id)
|
||||
if not meta_path.exists():
|
||||
raise FileNotFoundError(f"Studio project not found: {project_id}")
|
||||
meta = _read_json(meta_path)
|
||||
if name is not None:
|
||||
meta["name"] = name.strip()
|
||||
if description is not None:
|
||||
meta["description"] = description
|
||||
meta["updatedAt"] = datetime.now().isoformat()
|
||||
_write_json(meta_path, meta)
|
||||
return self.get_project(project_id)
|
||||
|
||||
def save_pipeline(self, project_id: str, pipeline: PipelineDefinition) -> StudioProject:
|
||||
meta_path = self._meta_path(project_id)
|
||||
if not meta_path.exists():
|
||||
raise FileNotFoundError(f"Studio project not found: {project_id}")
|
||||
normalized = _normalize_pipeline_dict(pipeline.model_dump(exclude_none=True))
|
||||
_validate_pipeline_refs(normalized)
|
||||
meta = _read_json(meta_path)
|
||||
now = datetime.now().isoformat()
|
||||
meta["updatedAt"] = now
|
||||
_write_json(meta_path, meta)
|
||||
_write_json(self._pipeline_path(project_id), normalized)
|
||||
return self.get_project(project_id)
|
||||
|
||||
def list_workflow_templates(self) -> List[WorkflowTemplateSummary]:
|
||||
root = self.templates_root
|
||||
if not root.exists():
|
||||
return []
|
||||
summaries: List[WorkflowTemplateSummary] = []
|
||||
for child in sorted(root.iterdir()):
|
||||
if not child.is_dir():
|
||||
continue
|
||||
meta_path = child / "meta.json"
|
||||
if not meta_path.exists():
|
||||
continue
|
||||
meta = _read_json(meta_path)
|
||||
summaries.append(
|
||||
WorkflowTemplateSummary(
|
||||
id=meta.get("id", child.name),
|
||||
name=meta.get("name", child.name),
|
||||
description=meta.get("description", ""),
|
||||
)
|
||||
)
|
||||
return summaries
|
||||
|
||||
def get_workflow_variables(self, project_id: Optional[str] = None) -> WorkflowVariablesResponse:
|
||||
path = settings.AGENT_WORKFLOW_VARIABLES_FILE
|
||||
if path.exists():
|
||||
raw = _read_json(path)
|
||||
else:
|
||||
raw = {
|
||||
"builtIn": [
|
||||
{"ref": "workflow.goal", "label": "工作流目标文本", "description": ""},
|
||||
{"ref": "workflow.boundWorldbook", "label": "绑定世界书摘要", "description": ""},
|
||||
{"ref": "workflow.boundCharacter", "label": "绑定角色卡摘要", "description": ""},
|
||||
],
|
||||
"dynamicSuffixes": [
|
||||
{"suffix": ".output", "labelPattern": "{displayName} · 上轮产物"},
|
||||
{"suffix": ".entryDraft", "labelPattern": "{displayName} · 条目草稿"},
|
||||
],
|
||||
}
|
||||
built_in = [
|
||||
WorkflowVariableDef(**item) for item in raw.get("builtIn", [])
|
||||
]
|
||||
dynamic: List[WorkflowVariableDef] = []
|
||||
suffixes = raw.get("dynamicSuffixes") or [
|
||||
{"suffix": ".output", "labelPattern": "{displayName} · 世界书条目"},
|
||||
]
|
||||
if project_id:
|
||||
try:
|
||||
project = self.get_project(project_id)
|
||||
for node in project.pipeline.nodes:
|
||||
if not node.enabled:
|
||||
continue
|
||||
if node.skillId != "studio.worldbook_entry":
|
||||
continue
|
||||
for suffix_def in suffixes:
|
||||
suffix = suffix_def.get("suffix", ".output")
|
||||
if suffix != ".output":
|
||||
continue
|
||||
pattern = suffix_def.get(
|
||||
"labelPattern", "{displayName} · 世界书条目"
|
||||
)
|
||||
ref = f"{node.id}{suffix}"
|
||||
label = pattern.replace("{displayName}", node.displayName)
|
||||
dynamic.append(
|
||||
WorkflowVariableDef(ref=ref, label=label, description="")
|
||||
)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return WorkflowVariablesResponse(builtIn=built_in, dynamic=dynamic)
|
||||
|
||||
def get_skill_templates(self) -> Dict[str, Any]:
|
||||
path = settings.AGENT_SKILL_TEMPLATES_FILE
|
||||
if not path.exists():
|
||||
raise FileNotFoundError("skill_templates.json not found")
|
||||
return _read_json(path)
|
||||
|
||||
def get_niches(self) -> Dict[str, Any]:
|
||||
path = settings.AGENT_NICHES_FILE
|
||||
if not path.exists():
|
||||
return {"niches": []}
|
||||
return _read_json(path)
|
||||
|
||||
def _unique_project_id(self, base_id: str) -> str:
|
||||
candidate = base_id
|
||||
n = 1
|
||||
while self._project_dir(candidate).exists():
|
||||
candidate = f"{base_id}-{n}"
|
||||
n += 1
|
||||
return candidate
|
||||
|
||||
def create_project(self, req: CreateStudioProjectRequest) -> StudioProject:
|
||||
template_id = req.template_id or DEFAULT_TEMPLATE_ID
|
||||
template_dir = self.templates_root / template_id
|
||||
if not template_dir.exists():
|
||||
raise FileNotFoundError(f"Studio template not found: {template_id}")
|
||||
|
||||
base_id = req.project_id or _slugify(req.name)
|
||||
project_id = self._unique_project_id(base_id)
|
||||
dest = self._project_dir(project_id)
|
||||
dest.mkdir(parents=True, exist_ok=False)
|
||||
|
||||
template_meta = _read_json(template_dir / "meta.json")
|
||||
template_pipeline = _read_json(template_dir / "pipeline.json")
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
meta = {
|
||||
"id": project_id,
|
||||
"name": req.name,
|
||||
"description": template_meta.get("description", ""),
|
||||
"templateId": template_id,
|
||||
"characterId": None,
|
||||
"worldbookId": None,
|
||||
"createdAt": now,
|
||||
"updatedAt": now,
|
||||
}
|
||||
_write_json(dest / "meta.json", meta)
|
||||
_write_json(dest / "pipeline.json", _normalize_pipeline_dict(template_pipeline))
|
||||
return self.get_project(project_id)
|
||||
|
||||
def delete_project(self, project_id: str) -> None:
|
||||
project_dir = self._project_dir(project_id)
|
||||
if not project_dir.exists():
|
||||
raise FileNotFoundError(f"Studio project not found: {project_id}")
|
||||
shutil.rmtree(project_dir)
|
||||
runs_dir = settings.AGENT_STUDIO_RUNS_PATH / project_id
|
||||
if runs_dir.exists():
|
||||
shutil.rmtree(runs_dir)
|
||||
|
||||
def ensure_default_project(self) -> None:
|
||||
"""Copy example template into default project if missing."""
|
||||
default_dir = self._project_dir("default")
|
||||
if default_dir.exists():
|
||||
return
|
||||
template_dir = self.templates_root / DEFAULT_TEMPLATE_ID
|
||||
if not template_dir.exists():
|
||||
logger.warning("builtin.studio.example template missing; skip default project seed")
|
||||
return
|
||||
default_dir.mkdir(parents=True, exist_ok=True)
|
||||
template_meta = _read_json(template_dir / "meta.json")
|
||||
now = datetime.now().isoformat()
|
||||
meta = {
|
||||
"id": "default",
|
||||
"name": "示例角色项目",
|
||||
"description": template_meta.get("description", ""),
|
||||
"templateId": DEFAULT_TEMPLATE_ID,
|
||||
"characterId": None,
|
||||
"worldbookId": None,
|
||||
"createdAt": now,
|
||||
"updatedAt": now,
|
||||
}
|
||||
_write_json(default_dir / "meta.json", meta)
|
||||
shutil.copy2(template_dir / "pipeline.json", default_dir / "pipeline.json")
|
||||
|
||||
|
||||
studio_project_service = StudioProjectService()
|
||||
|
||||
try:
|
||||
studio_project_service.ensure_default_project()
|
||||
except Exception as _seed_err:
|
||||
logger.warning("Studio default project seed skipped: %s", _seed_err)
|
||||
1104
backend/services/studio_run_service.py
Normal file
1104
backend/services/studio_run_service.py
Normal file
File diff suppressed because it is too large
Load Diff
427
backend/services/studio_step_respond.py
Normal file
427
backend/services/studio_step_respond.py
Normal file
@@ -0,0 +1,427 @@
|
||||
"""
|
||||
Studio worldbook step LLM responder (R3/R4).
|
||||
|
||||
Assembles R2 context blocks + short step dialogue, calls LLM for structured JSON,
|
||||
returns thinking, draft, questions, and evaluation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, AsyncGenerator, Dict, List, Optional, Tuple
|
||||
|
||||
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
||||
|
||||
from models.studio_models import (
|
||||
LastToolResponse,
|
||||
PromptBlock,
|
||||
StudioNode,
|
||||
StepMessage,
|
||||
ToolQuestionOption,
|
||||
)
|
||||
from services.studio_context_service import assemble_prompt_blocks
|
||||
from utils.llm_client import LLMClient
|
||||
|
||||
_llm_client = LLMClient()
|
||||
|
||||
_JSON_FENCE = re.compile(r"```(?:json)?\s*([\s\S]*?)```", re.IGNORECASE)
|
||||
|
||||
|
||||
def resolve_api_config(
|
||||
profile_id: Optional[str],
|
||||
api_config: Optional[Dict[str, str]],
|
||||
) -> Dict[str, str]:
|
||||
"""Merge frontend apiConfig with stored profile mainLLM key (same as chat WS)."""
|
||||
resolved = dict(api_config or {})
|
||||
if profile_id:
|
||||
try:
|
||||
try:
|
||||
from api.routes.apiConfigRoute import load_profile
|
||||
except ImportError:
|
||||
from backend.api.routes.apiConfigRoute import load_profile
|
||||
|
||||
profile = load_profile(profile_id)
|
||||
if profile:
|
||||
main_llm = profile.get("apis", {}).get("mainLLM", {})
|
||||
if main_llm.get("apiUrl") and not resolved.get("api_url"):
|
||||
resolved["api_url"] = main_llm.get("apiUrl", "")
|
||||
if main_llm.get("model") and not resolved.get("model"):
|
||||
resolved["model"] = main_llm.get("model", "")
|
||||
api_key = main_llm.get("apiKey", "")
|
||||
if api_key:
|
||||
resolved["api_key"] = api_key
|
||||
except Exception as exc:
|
||||
print(f"[StudioStepRespond] 加载 API 配置失败: {exc}")
|
||||
return resolved
|
||||
|
||||
|
||||
def _blocks_to_context_text(blocks: List[PromptBlock]) -> str:
|
||||
sections: List[str] = []
|
||||
for block in blocks:
|
||||
sections.append(f"## {block.label}\n{block.content}")
|
||||
return "\n\n".join(sections)
|
||||
|
||||
|
||||
def _build_system_prompt(node: StudioNode) -> str:
|
||||
insertion = (node.config or {}).get("insertion") or {}
|
||||
key = insertion.get("key") or "(未配置关键词)"
|
||||
comment = insertion.get("comment") or ""
|
||||
|
||||
return f"""你是 Studio 创作助手,负责为当前流水线步骤生成或修订世界书条目草稿。
|
||||
|
||||
当前步骤:{node.displayName}
|
||||
目标关键词:{key}
|
||||
备注:{comment or "(无)"}
|
||||
|
||||
你必须只输出一个 JSON 对象(不要 markdown 代码块外的其他文字),字段如下:
|
||||
{{
|
||||
"thinking": "你的内部思考过程(逐步推理,中文)",
|
||||
"currentProduct": "世界书条目正文(纯文本或 Markdown,可直接写入条目 content)",
|
||||
"questions": [
|
||||
{{
|
||||
"question": "需要用户澄清的问题",
|
||||
"options": ["选项A", "选项B", "选项C"]
|
||||
}}
|
||||
],
|
||||
"evaluation": "对照评价维度的自检与优化建议(中文,面向用户)"
|
||||
}}
|
||||
|
||||
规则:
|
||||
1. currentProduct 必须是完整、可注入世界书的条目正文。
|
||||
2. questions 为 0–3 条;每条至少 2 个 options;若无需澄清则 questions 为空数组。
|
||||
3. evaluation 需引用上下文中的评价标准,给出具体、可操作的反馈。
|
||||
4. 若用户要求修改,在 currentProduct 中输出修订后的完整条目,而非仅说明改了什么。
|
||||
5. 全部字段使用中文(专有名词除外)。"""
|
||||
|
||||
|
||||
def _dialogue_to_langchain(
|
||||
step_messages: List[StepMessage],
|
||||
) -> List[Any]:
|
||||
messages: List[Any] = []
|
||||
for msg in step_messages:
|
||||
if msg.role == "user":
|
||||
messages.append(HumanMessage(content=msg.content))
|
||||
elif msg.role == "assistant":
|
||||
messages.append(AIMessage(content=msg.content))
|
||||
return messages
|
||||
|
||||
|
||||
def _build_llm_messages(
|
||||
node: StudioNode,
|
||||
prompt_blocks: List[PromptBlock],
|
||||
step_messages: List[StepMessage],
|
||||
user_message: str,
|
||||
) -> List[Any]:
|
||||
context_text = _blocks_to_context_text(prompt_blocks)
|
||||
system_prompt = _build_system_prompt(node)
|
||||
|
||||
messages: List[Any] = [SystemMessage(content=system_prompt)]
|
||||
messages.append(
|
||||
HumanMessage(
|
||||
content=f"以下为当前步骤上下文(不含完整聊天历史):\n\n{context_text}"
|
||||
)
|
||||
)
|
||||
messages.extend(_dialogue_to_langchain(step_messages))
|
||||
messages.append(HumanMessage(content=user_message))
|
||||
return messages
|
||||
|
||||
|
||||
def _validate_api_config(api_config: Dict[str, str]) -> None:
|
||||
if not api_config.get("api_key"):
|
||||
raise ValueError("API Key 未配置,请先在 API 配置页面保存密钥")
|
||||
if not api_config.get("api_url"):
|
||||
raise ValueError("API 地址未配置,请先在 API 配置页面保存 mainLLM")
|
||||
|
||||
|
||||
def _extract_json(raw: str) -> Dict[str, Any]:
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
raise ValueError("模型返回为空")
|
||||
|
||||
fence = _JSON_FENCE.search(text)
|
||||
if fence:
|
||||
text = fence.group(1).strip()
|
||||
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
start = text.find("{")
|
||||
end = text.rfind("}")
|
||||
if start >= 0 and end > start:
|
||||
return json.loads(text[start : end + 1])
|
||||
raise ValueError("无法解析模型返回的 JSON")
|
||||
|
||||
|
||||
def _decode_json_string_partial(raw: str) -> str:
|
||||
"""Decode a possibly incomplete JSON string body (no surrounding quotes)."""
|
||||
out: List[str] = []
|
||||
i = 0
|
||||
while i < len(raw):
|
||||
if raw[i] == "\\" and i + 1 < len(raw):
|
||||
nxt = raw[i + 1]
|
||||
if nxt == "n":
|
||||
out.append("\n")
|
||||
elif nxt == "t":
|
||||
out.append("\t")
|
||||
elif nxt == "r":
|
||||
out.append("\r")
|
||||
elif nxt == '"':
|
||||
out.append('"')
|
||||
elif nxt == "\\":
|
||||
out.append("\\")
|
||||
elif nxt == "/":
|
||||
out.append("/")
|
||||
elif nxt == "u" and i + 5 < len(raw):
|
||||
try:
|
||||
out.append(chr(int(raw[i + 2 : i + 6], 16)))
|
||||
i += 6
|
||||
continue
|
||||
except ValueError:
|
||||
out.append(nxt)
|
||||
else:
|
||||
out.append(nxt)
|
||||
i += 2
|
||||
else:
|
||||
out.append(raw[i])
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _extract_partial_thinking(raw: str) -> Optional[str]:
|
||||
"""Best-effort extraction of thinking field from incomplete JSON stream."""
|
||||
marker = '"thinking"'
|
||||
idx = raw.find(marker)
|
||||
if idx < 0:
|
||||
return None
|
||||
|
||||
colon = raw.find(":", idx + len(marker))
|
||||
if colon < 0:
|
||||
return None
|
||||
|
||||
rest = raw[colon + 1 :].lstrip()
|
||||
if not rest.startswith('"'):
|
||||
return None
|
||||
|
||||
body_start = 1
|
||||
i = body_start
|
||||
while i < len(rest):
|
||||
ch = rest[i]
|
||||
if ch == '"':
|
||||
break
|
||||
if ch == "\\":
|
||||
i += 2
|
||||
continue
|
||||
i += 1
|
||||
|
||||
partial = rest[body_start:i]
|
||||
if not partial:
|
||||
return None
|
||||
return _decode_json_string_partial(partial)
|
||||
|
||||
|
||||
def _normalize_draft(
|
||||
current_product: Any,
|
||||
node: StudioNode,
|
||||
existing_draft: Optional[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
draft: Dict[str, Any] = dict(existing_draft or {})
|
||||
insertion = (node.config or {}).get("insertion") or {}
|
||||
|
||||
if isinstance(current_product, str):
|
||||
draft["entryContent"] = current_product.strip()
|
||||
elif isinstance(current_product, dict):
|
||||
draft.update(current_product)
|
||||
if "entryContent" not in draft and "content" in draft:
|
||||
draft["entryContent"] = draft["content"]
|
||||
else:
|
||||
draft["entryContent"] = str(current_product)
|
||||
|
||||
if insertion.get("key"):
|
||||
draft["insertionKey"] = insertion["key"]
|
||||
if insertion.get("comment"):
|
||||
draft["insertionComment"] = insertion["comment"]
|
||||
draft["nodeId"] = node.id
|
||||
draft["displayName"] = node.displayName
|
||||
return draft
|
||||
|
||||
|
||||
def _normalize_questions(raw: Any) -> List[ToolQuestionOption]:
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
result: List[ToolQuestionOption] = []
|
||||
for item in raw:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
question = (item.get("question") or "").strip()
|
||||
if not question:
|
||||
continue
|
||||
options = [
|
||||
str(o).strip()
|
||||
for o in (item.get("options") or [])
|
||||
if str(o).strip()
|
||||
]
|
||||
if len(options) < 2:
|
||||
continue
|
||||
result.append(ToolQuestionOption(question=question, options=options))
|
||||
return result[:3]
|
||||
|
||||
|
||||
def _assistant_message_text(parsed: Dict[str, Any]) -> str:
|
||||
evaluation = (parsed.get("evaluation") or "").strip()
|
||||
if evaluation:
|
||||
return evaluation
|
||||
product = parsed.get("currentProduct")
|
||||
if isinstance(product, str) and product.strip():
|
||||
preview = product.strip()
|
||||
if len(preview) > 400:
|
||||
preview = preview[:400] + "…"
|
||||
return f"已更新条目草稿:\n\n{preview}"
|
||||
return "已处理您的消息,请查看左侧目前产物。"
|
||||
|
||||
|
||||
def _build_turn_result(
|
||||
parsed: Dict[str, Any],
|
||||
*,
|
||||
node: StudioNode,
|
||||
user_message: str,
|
||||
existing_draft: Optional[Dict[str, Any]],
|
||||
) -> Tuple[Dict[str, Any], LastToolResponse, StepMessage, StepMessage]:
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
last_draft = _normalize_draft(
|
||||
parsed.get("currentProduct"),
|
||||
node,
|
||||
existing_draft,
|
||||
)
|
||||
last_tool_response = LastToolResponse(
|
||||
thinking=(parsed.get("thinking") or "").strip() or None,
|
||||
evaluation=(parsed.get("evaluation") or "").strip() or None,
|
||||
questions=_normalize_questions(parsed.get("questions")),
|
||||
generatedAt=now,
|
||||
)
|
||||
|
||||
user_step_msg = StepMessage(
|
||||
id=f"msg-{uuid.uuid4().hex[:12]}",
|
||||
role="user",
|
||||
content=user_message,
|
||||
createdAt=now,
|
||||
)
|
||||
assistant_step_msg = StepMessage(
|
||||
id=f"msg-{uuid.uuid4().hex[:12]}",
|
||||
role="assistant",
|
||||
content=_assistant_message_text(parsed),
|
||||
createdAt=now,
|
||||
)
|
||||
|
||||
return last_draft, last_tool_response, user_step_msg, assistant_step_msg
|
||||
|
||||
|
||||
async def studio_step_respond(
|
||||
*,
|
||||
node: StudioNode,
|
||||
prompt_blocks: List[PromptBlock],
|
||||
step_messages: List[StepMessage],
|
||||
user_message: str,
|
||||
existing_draft: Optional[Dict[str, Any]],
|
||||
api_config: Dict[str, str],
|
||||
stream: bool = False,
|
||||
) -> Tuple[Dict[str, Any], LastToolResponse, StepMessage, StepMessage]:
|
||||
"""
|
||||
Execute one worldbook step turn (non-streaming).
|
||||
|
||||
Returns (last_draft, last_tool_response, user_step_msg, assistant_step_msg).
|
||||
"""
|
||||
_validate_api_config(api_config)
|
||||
messages = _build_llm_messages(node, prompt_blocks, step_messages, user_message)
|
||||
model = api_config.get("model") or "gpt-4o-mini"
|
||||
|
||||
if stream:
|
||||
print("[StudioStepRespond] stream=True 应使用 studio_step_respond_stream")
|
||||
|
||||
response = await _llm_client.chat_completion(
|
||||
messages=messages,
|
||||
api_url=api_config.get("api_url", ""),
|
||||
api_key=api_config.get("api_key", ""),
|
||||
model=model,
|
||||
temperature=0.7,
|
||||
max_tokens=8000,
|
||||
request_timeout=120,
|
||||
stream=False,
|
||||
)
|
||||
|
||||
raw_content = ""
|
||||
if isinstance(response, dict):
|
||||
raw_content = (
|
||||
response.get("choices", [{}])[0]
|
||||
.get("message", {})
|
||||
.get("content", "")
|
||||
)
|
||||
else:
|
||||
raw_content = str(response)
|
||||
|
||||
parsed = _extract_json(raw_content)
|
||||
return _build_turn_result(
|
||||
parsed,
|
||||
node=node,
|
||||
user_message=user_message,
|
||||
existing_draft=existing_draft,
|
||||
)
|
||||
|
||||
|
||||
async def studio_step_respond_stream(
|
||||
*,
|
||||
node: StudioNode,
|
||||
prompt_blocks: List[PromptBlock],
|
||||
step_messages: List[StepMessage],
|
||||
user_message: str,
|
||||
existing_draft: Optional[Dict[str, Any]],
|
||||
api_config: Dict[str, str],
|
||||
) -> AsyncGenerator[Dict[str, Any], None]:
|
||||
"""
|
||||
Stream thinking field while LLM generates structured JSON (R4).
|
||||
|
||||
Yields:
|
||||
- {"type": "thinking_delta", "content": "..."}
|
||||
- {"type": "complete", "last_draft", "last_tool_response", "user_msg", "assistant_msg"}
|
||||
"""
|
||||
_validate_api_config(api_config)
|
||||
messages = _build_llm_messages(node, prompt_blocks, step_messages, user_message)
|
||||
model = api_config.get("model") or "gpt-4o-mini"
|
||||
|
||||
accumulated = ""
|
||||
last_thinking = ""
|
||||
|
||||
async for chunk in _llm_client.stream_chat(
|
||||
messages=messages,
|
||||
api_url=api_config.get("api_url", ""),
|
||||
api_key=api_config.get("api_key", ""),
|
||||
model=model,
|
||||
temperature=0.7,
|
||||
max_tokens=8000,
|
||||
request_timeout=120,
|
||||
):
|
||||
if chunk.get("type") != "chunk":
|
||||
continue
|
||||
accumulated += chunk.get("content") or ""
|
||||
partial = _extract_partial_thinking(accumulated)
|
||||
if partial and partial != last_thinking:
|
||||
last_thinking = partial
|
||||
yield {"type": "thinking_delta", "content": partial}
|
||||
|
||||
parsed = _extract_json(accumulated)
|
||||
last_draft, last_tool_response, user_msg, assistant_msg = _build_turn_result(
|
||||
parsed,
|
||||
node=node,
|
||||
user_message=user_message,
|
||||
existing_draft=existing_draft,
|
||||
)
|
||||
|
||||
yield {
|
||||
"type": "complete",
|
||||
"last_draft": last_draft,
|
||||
"last_tool_response": last_tool_response.model_dump(mode="json"),
|
||||
"user_msg": user_msg.model_dump(mode="json"),
|
||||
"assistant_msg": assistant_msg.model_dump(mode="json"),
|
||||
}
|
||||
95
backend/services/system_settings_service.py
Normal file
95
backend/services/system_settings_service.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
系统设置服务
|
||||
|
||||
负责加载、保存和管理全局系统设置。
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from core.config import settings
|
||||
from models.system_settings import SystemSettings, DEFAULT_SYSTEM_SETTINGS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SystemSettingsService:
|
||||
"""
|
||||
系统设置服务
|
||||
|
||||
提供设置的加载、保存和访问功能
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.settings_file = settings.SYSTEM_SETTINGS_FILE
|
||||
self._settings: Optional[SystemSettings] = None
|
||||
self._load_settings()
|
||||
|
||||
def _load_settings(self):
|
||||
"""从文件加载系统设置"""
|
||||
if self.settings_file.exists():
|
||||
try:
|
||||
with open(self.settings_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
self._settings = SystemSettings(**data)
|
||||
logger.info(f"加载系统设置成功")
|
||||
except Exception as e:
|
||||
logger.error(f"加载系统设置失败: {e}")
|
||||
self._settings = DEFAULT_SYSTEM_SETTINGS.copy()
|
||||
else:
|
||||
logger.info("系统设置文件不存在,使用默认设置")
|
||||
self._settings = DEFAULT_SYSTEM_SETTINGS.copy()
|
||||
self._save_settings()
|
||||
|
||||
def _save_settings(self):
|
||||
"""保存系统设置到文件"""
|
||||
try:
|
||||
# 确保父目录存在
|
||||
self.settings_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 写入文件
|
||||
with open(self.settings_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(self._settings.dict(), f, ensure_ascii=False, indent=2)
|
||||
|
||||
logger.info(f"系统设置已保存到 {self.settings_file}")
|
||||
except Exception as e:
|
||||
logger.error(f"保存系统设置失败: {e}")
|
||||
|
||||
@property
|
||||
def settings(self) -> SystemSettings:
|
||||
"""获取当前系统设置"""
|
||||
return self._settings
|
||||
|
||||
def update_thinking_tags(self, prefix: str, suffix: str):
|
||||
"""更新思考标签配置"""
|
||||
self._settings.thinkingTagPrefix = prefix
|
||||
self._settings.thinkingTagSuffix = suffix
|
||||
self._settings.updatedAt = int(__import__('time').time())
|
||||
self._save_settings()
|
||||
logger.info(f"思考标签已更新: {prefix} ... {suffix}")
|
||||
|
||||
def update_current_preset(self, preset_name: Optional[str]):
|
||||
"""更新当前选中的预设名称"""
|
||||
self._settings.currentPresetName = preset_name
|
||||
self._settings.updatedAt = int(__import__('time').time())
|
||||
self._save_settings()
|
||||
logger.info(f"当前预设已更新: {preset_name}")
|
||||
|
||||
def get_thinking_tag_pattern(self) -> str:
|
||||
"""获取思考标签的正则表达式模式"""
|
||||
prefix = self._settings.thinkingTagPrefix
|
||||
suffix = self._settings.thinkingTagSuffix
|
||||
|
||||
# 转义特殊字符
|
||||
import re
|
||||
escaped_prefix = re.escape(prefix)
|
||||
escaped_suffix = re.escape(suffix)
|
||||
|
||||
# 返回匹配思考内容的正则模式
|
||||
return f"{escaped_prefix}[\\s\\S]*?{escaped_suffix}"
|
||||
|
||||
|
||||
# 全局实例
|
||||
system_settings_service = SystemSettingsService()
|
||||
161
backend/services/task_queue_manager.py
Normal file
161
backend/services/task_queue_manager.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
任务队列管理器
|
||||
管理并行任务(生图、动态表格维护等)的状态和生命周期
|
||||
"""
|
||||
import asyncio
|
||||
from typing import Dict, List, Optional
|
||||
from enum import Enum
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class TaskStatus(Enum):
|
||||
"""任务状态枚举"""
|
||||
PENDING = "pending" # 等待中
|
||||
RUNNING = "running" # 进行中
|
||||
COMPLETED = "completed" # 已完成
|
||||
FAILED = "failed" # 失败
|
||||
CANCELLED = "cancelled" # 已取消
|
||||
|
||||
|
||||
class TaskType(Enum):
|
||||
"""任务类型枚举"""
|
||||
IMAGE_WORKFLOW = "image_workflow"
|
||||
DYNAMIC_TABLE = "dynamic_table"
|
||||
|
||||
|
||||
class TaskItem:
|
||||
"""任务项"""
|
||||
|
||||
def __init__(self, task_id: str, task_type: TaskType, chat_id: str):
|
||||
self.task_id = task_id
|
||||
self.task_type = task_type
|
||||
self.chat_id = chat_id
|
||||
self.status = TaskStatus.PENDING
|
||||
self.created_at = datetime.now()
|
||||
self.started_at = None
|
||||
self.completed_at = None
|
||||
self.error = None
|
||||
self.metadata = {} # 用于存储提示词、修改内容等
|
||||
|
||||
def to_dict(self):
|
||||
"""转换为字典格式(前端友好)"""
|
||||
return {
|
||||
"taskId": self.task_id,
|
||||
"taskType": self.task_type.value,
|
||||
"chatId": self.chat_id,
|
||||
"status": self.status.value,
|
||||
"createdAt": self.created_at.isoformat(),
|
||||
"startedAt": self.started_at.isoformat() if self.started_at else None,
|
||||
"completedAt": self.completed_at.isoformat() if self.completed_at else None,
|
||||
"error": self.error,
|
||||
"metadata": self.metadata
|
||||
}
|
||||
|
||||
|
||||
class TaskQueueManager:
|
||||
"""
|
||||
全局任务队列管理器
|
||||
|
||||
功能:
|
||||
- 管理所有并行任务的生命周期
|
||||
- 支持按聊天ID查询任务
|
||||
- 支持取消任务
|
||||
- 自动清理已完成的任务
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.tasks: Dict[str, TaskItem] = {}
|
||||
self.chat_tasks: Dict[str, List[str]] = {} # chat_id -> [task_ids]
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def add_task(self, task_id: str, task_type: TaskType, chat_id: str) -> TaskItem:
|
||||
"""添加任务到队列"""
|
||||
async with self._lock:
|
||||
task = TaskItem(task_id, task_type, chat_id)
|
||||
self.tasks[task_id] = task
|
||||
|
||||
if chat_id not in self.chat_tasks:
|
||||
self.chat_tasks[chat_id] = []
|
||||
self.chat_tasks[chat_id].append(task_id)
|
||||
|
||||
return task
|
||||
|
||||
async def start_task(self, task_id: str):
|
||||
"""标记任务开始执行"""
|
||||
async with self._lock:
|
||||
if task_id in self.tasks:
|
||||
self.tasks[task_id].status = TaskStatus.RUNNING
|
||||
self.tasks[task_id].started_at = datetime.now()
|
||||
|
||||
async def complete_task(self, task_id: str, metadata: dict = None):
|
||||
"""标记任务完成"""
|
||||
async with self._lock:
|
||||
if task_id in self.tasks:
|
||||
self.tasks[task_id].status = TaskStatus.COMPLETED
|
||||
self.tasks[task_id].completed_at = datetime.now()
|
||||
if metadata:
|
||||
self.tasks[task_id].metadata.update(metadata)
|
||||
|
||||
async def fail_task(self, task_id: str, error: str):
|
||||
"""标记任务失败"""
|
||||
async with self._lock:
|
||||
if task_id in self.tasks:
|
||||
self.tasks[task_id].status = TaskStatus.FAILED
|
||||
self.tasks[task_id].completed_at = datetime.now()
|
||||
self.tasks[task_id].error = error
|
||||
|
||||
async def cancel_task(self, task_id: str) -> bool:
|
||||
"""
|
||||
取消任务
|
||||
|
||||
Returns:
|
||||
bool: 是否成功取消
|
||||
"""
|
||||
async with self._lock:
|
||||
if task_id in self.tasks:
|
||||
task = self.tasks[task_id]
|
||||
if task.status in [TaskStatus.PENDING, TaskStatus.RUNNING]:
|
||||
task.status = TaskStatus.CANCELLED
|
||||
task.completed_at = datetime.now()
|
||||
return True
|
||||
return False
|
||||
|
||||
async def get_chat_tasks(self, chat_id: str, include_completed: bool = False) -> List[dict]:
|
||||
"""
|
||||
获取某个聊天的所有任务
|
||||
|
||||
Args:
|
||||
chat_id: 聊天ID
|
||||
include_completed: 是否包含已完成的任务
|
||||
|
||||
Returns:
|
||||
List[dict]: 任务列表
|
||||
"""
|
||||
async with self._lock:
|
||||
task_ids = self.chat_tasks.get(chat_id, [])
|
||||
tasks = []
|
||||
for task_id in task_ids:
|
||||
if task_id in self.tasks:
|
||||
task = self.tasks[task_id]
|
||||
# 根据参数决定是否包含已完成的任务
|
||||
if include_completed or task.status in [TaskStatus.PENDING, TaskStatus.RUNNING]:
|
||||
tasks.append(task.to_dict())
|
||||
return tasks
|
||||
|
||||
async def cleanup_completed_tasks(self, chat_id: str):
|
||||
"""清理已完成的任务"""
|
||||
async with self._lock:
|
||||
if chat_id in self.chat_tasks:
|
||||
task_ids = self.chat_tasks[chat_id]
|
||||
completed_ids = [
|
||||
tid for tid in task_ids
|
||||
if tid in self.tasks and
|
||||
self.tasks[tid].status in [TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED]
|
||||
]
|
||||
for tid in completed_ids:
|
||||
del self.tasks[tid]
|
||||
self.chat_tasks[chat_id].remove(tid)
|
||||
|
||||
|
||||
# 全局实例
|
||||
task_queue_manager = TaskQueueManager()
|
||||
427
backend/services/token_usage_service.py
Normal file
427
backend/services/token_usage_service.py
Normal file
@@ -0,0 +1,427 @@
|
||||
"""
|
||||
Token 使用统计服务
|
||||
|
||||
负责记录、查询和分析 LLM 调用的 token 使用情况
|
||||
数据持久化到 data/token_usage 目录,按月份组织
|
||||
采用双层存储:
|
||||
1. JSONL 文件 - 详细记录(按月存储)
|
||||
2. 索引文件 - 快速聚合统计(按 API URL、日期等维度)
|
||||
"""
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Any
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
|
||||
try:
|
||||
from backend.models.internal import TokenUsageRecord, TokenUsageStatus
|
||||
from backend.core.config import settings
|
||||
except ImportError:
|
||||
from models.internal import TokenUsageRecord, TokenUsageStatus
|
||||
from core.config import settings
|
||||
|
||||
|
||||
class TokenUsageService:
|
||||
"""
|
||||
Token 使用统计服务
|
||||
|
||||
功能:
|
||||
- 记录每次 LLM 调用的 token 使用情况
|
||||
- 按月份、日期、角色、聊天、API URL 维度统计
|
||||
- 支持中断和失败标记
|
||||
- 数据持久化到文件系统(JSONL + 索引)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.token_usage_dir = settings.DATA_PATH / "token_usage"
|
||||
self.token_usage_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ✅ 索引文件目录 - 用于快速聚合查询
|
||||
self.index_dir = self.token_usage_dir / "indexes"
|
||||
self.index_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _get_month_file(self, year: int, month: int) -> Path:
|
||||
"""获取指定月份的统计文件路径"""
|
||||
month_dir = self.token_usage_dir / f"{year}"
|
||||
month_dir.mkdir(parents=True, exist_ok=True)
|
||||
return month_dir / f"{month:02d}.jsonl"
|
||||
|
||||
def _load_month_records(self, year: int, month: int) -> List[TokenUsageRecord]:
|
||||
"""加载指定月份的所有记录"""
|
||||
file_path = self._get_month_file(year, month)
|
||||
|
||||
if not file_path.exists():
|
||||
return []
|
||||
|
||||
records = []
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
if line.strip():
|
||||
data = json.loads(line)
|
||||
records.append(TokenUsageRecord(**data))
|
||||
except Exception as e:
|
||||
print(f"[TokenUsage] 加载记录失败: {e}")
|
||||
|
||||
return records
|
||||
|
||||
def _save_record(self, record: TokenUsageRecord):
|
||||
"""保存单条记录到对应的月份文件"""
|
||||
dt = datetime.fromtimestamp(record.timestamp)
|
||||
file_path = self._get_month_file(dt.year, dt.month)
|
||||
|
||||
try:
|
||||
with open(file_path, 'a', encoding='utf-8') as f:
|
||||
f.write(json.dumps(record.model_dump(), ensure_ascii=False) + '\n')
|
||||
|
||||
# ✅ 同时更新索引文件(用于快速查询)
|
||||
self._update_indexes(record)
|
||||
except Exception as e:
|
||||
print(f"[TokenUsage] 保存记录失败: {e}")
|
||||
|
||||
def _update_indexes(self, record: TokenUsageRecord):
|
||||
"""
|
||||
更新索引文件 - 实现高效的按维度聚合查询
|
||||
|
||||
索引结构:
|
||||
- indexes/api_urls.json - 按 API URL 聚合
|
||||
- indexes/daily/{year}-{month}.json - 按日聚合
|
||||
"""
|
||||
dt = datetime.fromtimestamp(record.timestamp)
|
||||
|
||||
# 1. 更新 API URL 索引
|
||||
if record.apiUrl:
|
||||
api_url_index = self.index_dir / "api_urls.json"
|
||||
self._update_api_url_index(api_url_index, record)
|
||||
|
||||
# 2. 更新每日索引
|
||||
daily_index = self.index_dir / "daily" / f"{dt.year}-{dt.month:02d}.json"
|
||||
daily_index.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._update_daily_index(daily_index, record)
|
||||
|
||||
def _update_api_url_index(self, index_file: Path, record: TokenUsageRecord):
|
||||
"""更新 API URL 索引文件"""
|
||||
index_data = {}
|
||||
|
||||
# 加载现有索引
|
||||
if index_file.exists():
|
||||
try:
|
||||
with open(index_file, 'r', encoding='utf-8') as f:
|
||||
index_data = json.load(f)
|
||||
except:
|
||||
index_data = {}
|
||||
|
||||
# 更新统计
|
||||
api_url = record.apiUrl
|
||||
if api_url not in index_data:
|
||||
index_data[api_url] = {
|
||||
"totalPromptTokens": 0,
|
||||
"totalCompletionTokens": 0,
|
||||
"totalTokens": 0,
|
||||
"count": 0,
|
||||
"firstUsed": record.timestamp,
|
||||
"lastUsed": record.timestamp
|
||||
}
|
||||
|
||||
stats = index_data[api_url]
|
||||
stats["totalPromptTokens"] += record.promptTokens
|
||||
stats["totalCompletionTokens"] += record.completionTokens
|
||||
stats["totalTokens"] += record.totalTokens
|
||||
stats["count"] += 1
|
||||
stats["lastUsed"] = max(stats["lastUsed"], record.timestamp)
|
||||
stats["firstUsed"] = min(stats["firstUsed"], record.timestamp)
|
||||
|
||||
# 保存索引
|
||||
with open(index_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(index_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
def _update_daily_index(self, index_file: Path, record: TokenUsageRecord):
|
||||
"""更新每日索引文件"""
|
||||
dt = datetime.fromtimestamp(record.timestamp)
|
||||
day_key = f"{dt.year}-{dt.month:02d}-{dt.day:02d}"
|
||||
|
||||
index_data = {}
|
||||
|
||||
# 加载现有索引
|
||||
if index_file.exists():
|
||||
try:
|
||||
with open(index_file, 'r', encoding='utf-8') as f:
|
||||
index_data = json.load(f)
|
||||
except:
|
||||
index_data = {}
|
||||
|
||||
# 更新统计
|
||||
if day_key not in index_data:
|
||||
index_data[day_key] = {
|
||||
"promptTokens": 0,
|
||||
"completionTokens": 0,
|
||||
"totalTokens": 0,
|
||||
"count": 0
|
||||
}
|
||||
|
||||
stats = index_data[day_key]
|
||||
stats["promptTokens"] += record.promptTokens
|
||||
stats["completionTokens"] += record.completionTokens
|
||||
stats["totalTokens"] += record.totalTokens
|
||||
stats["count"] += 1
|
||||
|
||||
# 保存索引
|
||||
with open(index_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(index_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
async def record_usage(
|
||||
self,
|
||||
chat_id: str,
|
||||
role_name: str,
|
||||
chat_name: str,
|
||||
prompt_tokens: int,
|
||||
completion_tokens: int,
|
||||
total_tokens: int,
|
||||
status: TokenUsageStatus = TokenUsageStatus.COMPLETED,
|
||||
message_id: Optional[str] = None,
|
||||
floor: Optional[int] = None,
|
||||
error_message: Optional[str] = None,
|
||||
duration: Optional[float] = None,
|
||||
model: Optional[str] = None,
|
||||
api_provider: Optional[str] = None,
|
||||
api_url: Optional[str] = None
|
||||
) -> TokenUsageRecord:
|
||||
"""
|
||||
记录一次 LLM 调用的 token 使用情况
|
||||
|
||||
Args:
|
||||
chat_id: 聊天ID
|
||||
role_name: 角色名称
|
||||
chat_name: 聊天名称
|
||||
prompt_tokens: 输入 token 数
|
||||
completion_tokens: 输出 token 数
|
||||
total_tokens: 总 token 数
|
||||
status: 请求状态
|
||||
message_id: 关联的消息ID
|
||||
floor: 楼层号
|
||||
error_message: 错误信息
|
||||
duration: 请求耗时
|
||||
model: 使用的模型
|
||||
api_provider: API 提供商
|
||||
api_url: API URL地址
|
||||
|
||||
Returns:
|
||||
TokenUsageRecord: 创建的记录
|
||||
"""
|
||||
record = TokenUsageRecord(
|
||||
id=str(uuid.uuid4()),
|
||||
chatId=chat_id,
|
||||
roleName=role_name,
|
||||
chatName=chat_name,
|
||||
messageId=message_id,
|
||||
floor=floor,
|
||||
promptTokens=prompt_tokens,
|
||||
completionTokens=completion_tokens,
|
||||
totalTokens=total_tokens,
|
||||
status=status,
|
||||
errorMessage=error_message,
|
||||
duration=duration,
|
||||
model=model,
|
||||
apiProvider=api_provider,
|
||||
apiUrl=api_url
|
||||
)
|
||||
|
||||
self._save_record(record)
|
||||
return record
|
||||
|
||||
async def get_stats_by_month(
|
||||
self,
|
||||
year: int,
|
||||
month: int,
|
||||
role_name: Optional[str] = None,
|
||||
chat_name: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
获取指定月份的统计数据
|
||||
|
||||
Args:
|
||||
year: 年份
|
||||
month: 月份
|
||||
role_name: 角色名称(可选,用于过滤)
|
||||
chat_name: 聊天名称(可选,用于过滤)
|
||||
|
||||
Returns:
|
||||
统计数据字典
|
||||
"""
|
||||
records = self._load_month_records(year, month)
|
||||
|
||||
# 过滤
|
||||
if role_name:
|
||||
records = [r for r in records if r.roleName == role_name]
|
||||
if chat_name:
|
||||
records = [r for r in records if r.chatName == chat_name]
|
||||
|
||||
# 统计
|
||||
total_prompt = sum(r.promptTokens for r in records)
|
||||
total_completion = sum(r.completionTokens for r in records)
|
||||
total_tokens = sum(r.totalTokens for r in records)
|
||||
|
||||
completed_count = sum(1 for r in records if r.status == TokenUsageStatus.COMPLETED)
|
||||
interrupted_count = sum(1 for r in records if r.status == TokenUsageStatus.INTERRUPTED)
|
||||
failed_count = sum(1 for r in records if r.status == TokenUsageStatus.FAILED)
|
||||
|
||||
# 按日期分组
|
||||
daily_stats = defaultdict(lambda: {
|
||||
"promptTokens": 0,
|
||||
"completionTokens": 0,
|
||||
"totalTokens": 0,
|
||||
"count": 0
|
||||
})
|
||||
|
||||
for r in records:
|
||||
dt = datetime.fromtimestamp(r.timestamp)
|
||||
day_key = f"{dt.year}-{dt.month:02d}-{dt.day:02d}"
|
||||
daily_stats[day_key]["promptTokens"] += r.promptTokens
|
||||
daily_stats[day_key]["completionTokens"] += r.completionTokens
|
||||
daily_stats[day_key]["totalTokens"] += r.totalTokens
|
||||
daily_stats[day_key]["count"] += 1
|
||||
|
||||
# 按角色分组
|
||||
role_stats = defaultdict(lambda: {
|
||||
"promptTokens": 0,
|
||||
"completionTokens": 0,
|
||||
"totalTokens": 0,
|
||||
"count": 0
|
||||
})
|
||||
|
||||
for r in records:
|
||||
role_stats[r.roleName]["promptTokens"] += r.promptTokens
|
||||
role_stats[r.roleName]["completionTokens"] += r.completionTokens
|
||||
role_stats[r.roleName]["totalTokens"] += r.totalTokens
|
||||
role_stats[r.roleName]["count"] += 1
|
||||
|
||||
# 按聊天分组
|
||||
chat_stats = defaultdict(lambda: {
|
||||
"promptTokens": 0,
|
||||
"completionTokens": 0,
|
||||
"totalTokens": 0,
|
||||
"count": 0
|
||||
})
|
||||
|
||||
for r in records:
|
||||
chat_key = f"{r.roleName}/{r.chatName}"
|
||||
chat_stats[chat_key]["promptTokens"] += r.promptTokens
|
||||
chat_stats[chat_key]["completionTokens"] += r.completionTokens
|
||||
chat_stats[chat_key]["totalTokens"] += r.totalTokens
|
||||
chat_stats[chat_key]["count"] += 1
|
||||
|
||||
# ✅ 按 API URL 分组
|
||||
api_url_stats = defaultdict(lambda: {
|
||||
"promptTokens": 0,
|
||||
"completionTokens": 0,
|
||||
"totalTokens": 0,
|
||||
"count": 0
|
||||
})
|
||||
|
||||
for r in records:
|
||||
if r.apiUrl:
|
||||
api_url_stats[r.apiUrl]["promptTokens"] += r.promptTokens
|
||||
api_url_stats[r.apiUrl]["completionTokens"] += r.completionTokens
|
||||
api_url_stats[r.apiUrl]["totalTokens"] += r.totalTokens
|
||||
api_url_stats[r.apiUrl]["count"] += 1
|
||||
|
||||
return {
|
||||
"year": year,
|
||||
"month": month,
|
||||
"totalRecords": len(records),
|
||||
"totalPromptTokens": total_prompt,
|
||||
"totalCompletionTokens": total_completion,
|
||||
"totalTokens": total_tokens,
|
||||
"completedCount": completed_count,
|
||||
"interruptedCount": interrupted_count,
|
||||
"failedCount": failed_count,
|
||||
"dailyStats": dict(daily_stats),
|
||||
"roleStats": dict(role_stats),
|
||||
"chatStats": dict(chat_stats),
|
||||
"apiUrlStats": dict(api_url_stats), # ✅ 新增
|
||||
"records": [r.model_dump() for r in records[:100]] # 最近100条记录
|
||||
}
|
||||
|
||||
async def list_months(self) -> List[Dict[str, int]]:
|
||||
"""列出所有有数据的月份"""
|
||||
months = []
|
||||
|
||||
if not self.token_usage_dir.exists():
|
||||
return months
|
||||
|
||||
for year_dir in sorted(self.token_usage_dir.iterdir()):
|
||||
if year_dir.is_dir() and year_dir.name.isdigit():
|
||||
year = int(year_dir.name)
|
||||
for month_file in sorted(year_dir.glob("*.jsonl")):
|
||||
month = int(month_file.stem)
|
||||
months.append({"year": year, "month": month})
|
||||
|
||||
return months
|
||||
|
||||
async def get_api_url_stats(self) -> Dict[str, Any]:
|
||||
"""
|
||||
✅ 获取按 API URL 分组的统计数据(从索引文件快速读取)
|
||||
|
||||
Returns:
|
||||
{api_url: {totalPromptTokens, totalCompletionTokens, totalTokens, count, firstUsed, lastUsed}}
|
||||
"""
|
||||
api_url_index = self.index_dir / "api_urls.json"
|
||||
|
||||
if not api_url_index.exists():
|
||||
return {}
|
||||
|
||||
try:
|
||||
with open(api_url_index, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
print(f"[TokenUsage] 读取 API URL 索引失败: {e}")
|
||||
return {}
|
||||
|
||||
async def get_daily_stats(self, year: int, month: int) -> Dict[str, Any]:
|
||||
"""
|
||||
✅ 获取指定月份的每日统计数据(从索引文件快速读取)
|
||||
|
||||
Args:
|
||||
year: 年份
|
||||
month: 月份
|
||||
|
||||
Returns:
|
||||
{day_key: {promptTokens, completionTokens, totalTokens, count}}
|
||||
"""
|
||||
daily_index = self.index_dir / "daily" / f"{year}-{month:02d}.json"
|
||||
|
||||
if not daily_index.exists():
|
||||
return {}
|
||||
|
||||
try:
|
||||
with open(daily_index, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
print(f"[TokenUsage] 读取每日索引失败: {e}")
|
||||
return {}
|
||||
|
||||
async def get_available_roles(self, year: int, month: int) -> List[str]:
|
||||
"""获取指定月份有数据的角色列表"""
|
||||
records = self._load_month_records(year, month)
|
||||
roles = set(r.roleName for r in records)
|
||||
return sorted(list(roles))
|
||||
|
||||
async def get_available_chats(
|
||||
self,
|
||||
year: int,
|
||||
month: int,
|
||||
role_name: Optional[str] = None
|
||||
) -> List[str]:
|
||||
"""获取指定月份有数据的聊天列表"""
|
||||
records = self._load_month_records(year, month)
|
||||
|
||||
if role_name:
|
||||
records = [r for r in records if r.roleName == role_name]
|
||||
|
||||
chats = set(f"{r.roleName}/{r.chatName}" for r in records)
|
||||
return sorted(list(chats))
|
||||
|
||||
|
||||
# 全局实例
|
||||
token_usage_service = TokenUsageService()
|
||||
49
backend/services/tool_registry.py
Normal file
49
backend/services/tool_registry.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
Tool registry for workflow engine steps.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional
|
||||
|
||||
try:
|
||||
from backend.models.agent import TurnContext, ToolSpec
|
||||
except ImportError:
|
||||
from models.agent import TurnContext, ToolSpec
|
||||
|
||||
ToolHandler = Callable[[TurnContext], Awaitable[None]]
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
def __init__(self) -> None:
|
||||
self._tools: Dict[str, ToolHandler] = {}
|
||||
self._specs: Dict[str, ToolSpec] = {}
|
||||
|
||||
def register(
|
||||
self,
|
||||
name: str,
|
||||
handler: ToolHandler,
|
||||
*,
|
||||
description: str = "",
|
||||
parameters: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
self._tools[name] = handler
|
||||
self._specs[name] = ToolSpec(
|
||||
name=name,
|
||||
description=description,
|
||||
parameters=parameters or {},
|
||||
)
|
||||
|
||||
def get(self, name: str) -> ToolHandler:
|
||||
if name not in self._tools:
|
||||
raise KeyError(f"Unknown tool: {name}")
|
||||
return self._tools[name]
|
||||
|
||||
def list_specs(self) -> list[ToolSpec]:
|
||||
return list(self._specs.values())
|
||||
|
||||
async def execute(self, name: str, ctx: TurnContext) -> None:
|
||||
handler = self.get(name)
|
||||
await handler(ctx)
|
||||
|
||||
|
||||
default_tool_registry = ToolRegistry()
|
||||
1
backend/services/tools/__init__.py
Normal file
1
backend/services/tools/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Workflow chat tools package."""
|
||||
261
backend/services/tools/chat_tools.py
Normal file
261
backend/services/tools/chat_tools.py
Normal file
@@ -0,0 +1,261 @@
|
||||
"""
|
||||
Chat workflow tools extracted from ChatWorkflowService.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, List
|
||||
|
||||
try:
|
||||
from backend.models.agent import TurnContext
|
||||
from backend.models.internal import CharacterCard, TokenUsageStatus
|
||||
from backend.models.regex_rules import RegexPlacement
|
||||
from backend.services.character_service import CharacterService
|
||||
from backend.services.regex_service import regex_service
|
||||
from backend.services.task_queue_manager import TaskType, task_queue_manager
|
||||
from backend.services.token_usage_service import token_usage_service
|
||||
from backend.core.config import settings
|
||||
except ImportError:
|
||||
from models.agent import TurnContext
|
||||
from models.internal import CharacterCard, TokenUsageStatus
|
||||
from models.regex_rules import RegexPlacement
|
||||
from services.character_service import CharacterService
|
||||
from services.regex_service import regex_service
|
||||
from services.task_queue_manager import TaskType, task_queue_manager
|
||||
from services.token_usage_service import token_usage_service
|
||||
from core.config import settings
|
||||
|
||||
|
||||
_character_service = CharacterService()
|
||||
_workflow_service = None
|
||||
|
||||
|
||||
def _get_workflow_service():
|
||||
"""Lazy init to avoid circular import with chat_workflow_service."""
|
||||
global _workflow_service
|
||||
if _workflow_service is None:
|
||||
try:
|
||||
from backend.services.chat_workflow_service import ChatWorkflowService
|
||||
except ImportError:
|
||||
from services.chat_workflow_service import ChatWorkflowService
|
||||
_workflow_service = ChatWorkflowService()
|
||||
return _workflow_service
|
||||
|
||||
|
||||
async def regex_apply_user_input(ctx: TurnContext) -> None:
|
||||
processed = regex_service.apply_rules_by_placement(
|
||||
text=ctx.user_message,
|
||||
placement=RegexPlacement.USER_INPUT.value,
|
||||
character_name=ctx.current_role,
|
||||
preset_name=ctx.preset_name,
|
||||
message_depth=0,
|
||||
is_for_llm=True,
|
||||
is_markdown_rendered=False,
|
||||
)
|
||||
if processed != ctx.user_message:
|
||||
print("[WorkflowTool] Applied user-input regex rules")
|
||||
ctx.user_message = processed
|
||||
|
||||
|
||||
async def load_character(ctx: TurnContext) -> None:
|
||||
character_data = ctx.request_data.get("characterData")
|
||||
if not character_data:
|
||||
character = _character_service.get_character_by_name(ctx.current_role)
|
||||
if not character:
|
||||
raise ValueError(f"角色 '{ctx.current_role}' 不存在")
|
||||
else:
|
||||
character = CharacterCard(**character_data)
|
||||
ctx.character = character
|
||||
print(f"[WorkflowTool] Loaded character: {character.name}")
|
||||
|
||||
|
||||
async def activate_worldbook(ctx: TurnContext) -> None:
|
||||
svc = _get_workflow_service()
|
||||
active_entries = await svc._collect_and_activate_worldbooks(
|
||||
ctx.request_data,
|
||||
ctx.character,
|
||||
)
|
||||
ctx.active_entries = active_entries
|
||||
print(f"[WorkflowTool] Activated {len(active_entries)} worldbook entries")
|
||||
|
||||
if ctx.callbacks and ctx.callbacks.on_worldbook_active:
|
||||
entries_payload = [
|
||||
entry.model_dump() if hasattr(entry, "model_dump") else entry
|
||||
for entry in active_entries
|
||||
]
|
||||
await ctx.callbacks.on_worldbook_active(entries_payload)
|
||||
|
||||
|
||||
async def load_chat_history(ctx: TurnContext) -> None:
|
||||
svc = _get_workflow_service()
|
||||
chat_history = await svc._load_chat_history(
|
||||
ctx.current_role,
|
||||
ctx.current_chat,
|
||||
)
|
||||
ctx.chat_history = chat_history
|
||||
print(f"[WorkflowTool] Loaded {len(chat_history)} history messages")
|
||||
|
||||
|
||||
async def build_prompt_messages(ctx: TurnContext) -> None:
|
||||
svc = _get_workflow_service()
|
||||
prompt_messages = svc._assemble_prompt(
|
||||
ctx.character,
|
||||
ctx.chat_history,
|
||||
ctx.user_message,
|
||||
ctx.active_entries,
|
||||
ctx.request_data,
|
||||
)
|
||||
ctx.prompt_messages = prompt_messages
|
||||
print(f"[WorkflowTool] Built {len(prompt_messages)} prompt messages")
|
||||
|
||||
|
||||
async def llm_main_reply(ctx: TurnContext) -> None:
|
||||
svc = _get_workflow_service()
|
||||
api_config = ctx.request_data.get("apiConfig", {})
|
||||
preset_config = ctx.request_data.get("presetConfig", {})
|
||||
|
||||
if ctx.stream:
|
||||
if not api_config.get("api_key"):
|
||||
raise ValueError("API Key 未配置,请先在 API 配置页面保存密钥")
|
||||
|
||||
generated_content = ""
|
||||
chunk_count = 0
|
||||
start_time = time.time()
|
||||
|
||||
async for chunk_dict in svc.llm_client.stream_chat(
|
||||
messages=ctx.prompt_messages,
|
||||
api_url=api_config.get("api_url", ""),
|
||||
api_key=api_config.get("api_key", ""),
|
||||
model=api_config.get("model", ""),
|
||||
temperature=preset_config.get("parameters", {}).get("temperature", 1.0),
|
||||
max_tokens=preset_config.get("parameters", {}).get("max_tokens", 30000),
|
||||
request_timeout=preset_config.get("parameters", {}).get("request_timeout", 60),
|
||||
):
|
||||
if isinstance(chunk_dict, dict):
|
||||
if chunk_dict.get("type") == "chunk":
|
||||
chunk_content = chunk_dict.get("content", "")
|
||||
elif chunk_dict.get("type") == "usage":
|
||||
continue
|
||||
else:
|
||||
chunk_content = chunk_dict.get("content", str(chunk_dict))
|
||||
else:
|
||||
chunk_content = str(chunk_dict)
|
||||
|
||||
generated_content += chunk_content
|
||||
chunk_count += 1
|
||||
|
||||
if ctx.callbacks and ctx.callbacks.on_chunk:
|
||||
await ctx.callbacks.on_chunk(chunk_content)
|
||||
|
||||
ctx.duration = time.time() - start_time
|
||||
ctx.generated_content = generated_content
|
||||
ctx.token_usage = {
|
||||
"prompt_tokens": len(str(ctx.prompt_messages)) // 4,
|
||||
"completion_tokens": len(generated_content) // 4,
|
||||
"total_tokens": (len(str(ctx.prompt_messages)) // 4)
|
||||
+ (len(generated_content) // 4),
|
||||
}
|
||||
print(
|
||||
f"[WorkflowTool] Stream LLM complete: {chunk_count} chunks, "
|
||||
f"{len(generated_content)} chars"
|
||||
)
|
||||
else:
|
||||
result = await svc._generate_response(
|
||||
ctx.prompt_messages,
|
||||
api_config,
|
||||
preset_config,
|
||||
stream=False,
|
||||
)
|
||||
ctx.generated_content = result["content"]
|
||||
ctx.token_usage = result.get("usage", {})
|
||||
ctx.duration = result.get("duration", 0.0)
|
||||
print(f"[WorkflowTool] LLM complete: {len(ctx.generated_content)} chars")
|
||||
|
||||
|
||||
async def regex_apply_ai_output(ctx: TurnContext) -> None:
|
||||
processed = regex_service.apply_rules_by_placement(
|
||||
text=ctx.generated_content,
|
||||
placement=RegexPlacement.AI_OUTPUT.value,
|
||||
character_name=ctx.current_role,
|
||||
preset_name=ctx.preset_name,
|
||||
message_depth=0,
|
||||
is_for_llm=False,
|
||||
is_markdown_rendered=False,
|
||||
)
|
||||
if processed != ctx.generated_content:
|
||||
print("[WorkflowTool] Applied AI-output regex rules")
|
||||
ctx.generated_content = processed
|
||||
|
||||
|
||||
async def record_token_usage(ctx: TurnContext) -> None:
|
||||
chat_id = f"{ctx.current_role}/{ctx.current_chat}"
|
||||
floor = ctx.request_data.get("floor", 0)
|
||||
api_config = ctx.request_data.get("apiConfig", {})
|
||||
|
||||
try:
|
||||
await token_usage_service.record_usage(
|
||||
chat_id=chat_id,
|
||||
role_name=ctx.current_role,
|
||||
chat_name=ctx.current_chat,
|
||||
prompt_tokens=ctx.token_usage.get("prompt_tokens", 0),
|
||||
completion_tokens=ctx.token_usage.get("completion_tokens", 0),
|
||||
total_tokens=ctx.token_usage.get("total_tokens", 0),
|
||||
status=TokenUsageStatus.COMPLETED,
|
||||
floor=floor + 1,
|
||||
duration=ctx.duration,
|
||||
model=api_config.get("model"),
|
||||
api_provider="openai",
|
||||
api_url=api_config.get("api_url"),
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"[WorkflowTool] Token usage recording failed: {exc}")
|
||||
|
||||
|
||||
async def enqueue_parallel_tasks(ctx: TurnContext) -> None:
|
||||
chat_id = f"{ctx.current_role}/{ctx.current_chat}"
|
||||
options = ctx.request_data.get("options", {})
|
||||
image_task_id = None
|
||||
table_task_id = None
|
||||
|
||||
if options.get("imageWorkflow", False):
|
||||
image_task_id = f"img_{uuid.uuid4().hex[:8]}"
|
||||
await task_queue_manager.add_task(image_task_id, TaskType.IMAGE_WORKFLOW, chat_id)
|
||||
|
||||
if options.get("dynamicTable", False):
|
||||
table_task_id = f"tbl_{uuid.uuid4().hex[:8]}"
|
||||
await task_queue_manager.add_task(table_task_id, TaskType.DYNAMIC_TABLE, chat_id)
|
||||
|
||||
ctx.task_ids = {
|
||||
"imageWorkflow": image_task_id,
|
||||
"dynamicTable": table_task_id,
|
||||
}
|
||||
|
||||
if ctx.callbacks and ctx.callbacks.on_tasks_created:
|
||||
if image_task_id or table_task_id:
|
||||
await ctx.callbacks.on_tasks_created(ctx.task_ids)
|
||||
|
||||
# Fire-and-forget parallel workers (same as legacy service)
|
||||
svc = _get_workflow_service()
|
||||
asyncio.create_task(
|
||||
svc._start_parallel_tasks(
|
||||
ctx.request_data,
|
||||
ctx.generated_content,
|
||||
image_task_id,
|
||||
table_task_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def register_chat_tools(registry) -> None:
|
||||
"""Register all chat workflow tools on the given registry."""
|
||||
registry.register("regex_apply_user_input", regex_apply_user_input, description="Apply user-input regex")
|
||||
registry.register("load_character", load_character, description="Load character card")
|
||||
registry.register("activate_worldbook", activate_worldbook, description="Activate worldbook entries")
|
||||
registry.register("load_chat_history", load_chat_history, description="Load chat history")
|
||||
registry.register("build_prompt_messages", build_prompt_messages, description="Assemble LLM prompt")
|
||||
registry.register("llm_main_reply", llm_main_reply, description="Call main LLM (supports stream)")
|
||||
registry.register("regex_apply_ai_output", regex_apply_ai_output, description="Apply AI-output regex")
|
||||
registry.register("record_token_usage", record_token_usage, description="Persist token usage")
|
||||
registry.register("enqueue_parallel_tasks", enqueue_parallel_tasks, description="Enqueue parallel tasks")
|
||||
185
backend/services/tools/fiction_tools.py
Normal file
185
backend/services/tools/fiction_tools.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
爽文工作流 Tool 注册。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
try:
|
||||
from backend.models.agent import TurnContext
|
||||
from backend.services.fiction_chapter_service import run_chapter
|
||||
from backend.services.fiction_coarse_service import run_coarse_outline
|
||||
from backend.services.fiction_event_plan_service import run_event_plan
|
||||
from backend.services.fiction_open_book_service import run_open_book
|
||||
from backend.services.tool_registry import ToolRegistry
|
||||
except ImportError:
|
||||
from models.agent import TurnContext
|
||||
from services.fiction_chapter_service import run_chapter
|
||||
from services.fiction_coarse_service import run_coarse_outline
|
||||
from services.fiction_event_plan_service import run_event_plan
|
||||
from services.fiction_open_book_service import run_open_book
|
||||
from services.tool_registry import ToolRegistry
|
||||
|
||||
|
||||
async def fiction_open_book(ctx: TurnContext) -> None:
|
||||
"""fiction.open_book — 根据用户灵感优化开书方案(不创建书籍目录)。"""
|
||||
request = ctx.request_data or {}
|
||||
inspiration = str(request.get("inspiration") or request.get("intro") or "").strip()
|
||||
profile_id = request.get("profile_id") or request.get("profileId")
|
||||
api_config = request.get("api_config") or request.get("apiConfig")
|
||||
|
||||
result = await run_open_book(
|
||||
inspiration,
|
||||
profile_id=profile_id,
|
||||
api_config=api_config,
|
||||
)
|
||||
payload = result.model_dump()
|
||||
ctx.request_data["fictionOpenBookResult"] = payload
|
||||
ctx.generated_content = json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
|
||||
async def fiction_coarse(ctx: TurnContext) -> None:
|
||||
"""fiction.coarse — 生成本书粗纲事件链,写入 metadata.json。"""
|
||||
request = ctx.request_data or {}
|
||||
book_id = str(request.get("book_id") or request.get("bookId") or "").strip()
|
||||
if not book_id:
|
||||
raise ValueError("book_id 不能为空")
|
||||
|
||||
profile_id = request.get("profile_id") or request.get("profileId")
|
||||
api_config = request.get("api_config") or request.get("apiConfig")
|
||||
|
||||
result = await run_coarse_outline(
|
||||
book_id,
|
||||
profile_id=profile_id,
|
||||
api_config=api_config,
|
||||
)
|
||||
payload = result.model_dump()
|
||||
ctx.request_data["fictionCoarseResult"] = payload
|
||||
ctx.generated_content = json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
|
||||
async def fiction_event_plan(ctx: TurnContext) -> None:
|
||||
"""fiction.event_plan — 为粗纲事件生成 flowStepsPlan + chapterPlan。"""
|
||||
request = ctx.request_data or {}
|
||||
book_id = str(request.get("book_id") or request.get("bookId") or "").strip()
|
||||
if not book_id:
|
||||
raise ValueError("book_id 不能为空")
|
||||
|
||||
profile_id = request.get("profile_id") or request.get("profileId")
|
||||
api_config = request.get("api_config") or request.get("apiConfig")
|
||||
event_id = request.get("event_id") or request.get("eventId")
|
||||
|
||||
result = await run_event_plan(
|
||||
book_id,
|
||||
profile_id=profile_id,
|
||||
api_config=api_config,
|
||||
event_id=event_id,
|
||||
)
|
||||
payload = result.model_dump()
|
||||
ctx.request_data["fictionEventPlanResult"] = payload
|
||||
ctx.generated_content = json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
|
||||
async def fiction_chapter(ctx: TurnContext) -> None:
|
||||
"""fiction.chapter — 根据 chapterPlan brief 撰写正文,写入 chapters/{seq}.json。"""
|
||||
request = ctx.request_data or {}
|
||||
book_id = str(request.get("book_id") or request.get("bookId") or "").strip()
|
||||
if not book_id:
|
||||
raise ValueError("book_id 不能为空")
|
||||
|
||||
profile_id = request.get("profile_id") or request.get("profileId")
|
||||
api_config = request.get("api_config") or request.get("apiConfig")
|
||||
seq = request.get("seq") or request.get("chapterSeq")
|
||||
|
||||
result = await run_chapter(
|
||||
book_id,
|
||||
profile_id=profile_id,
|
||||
api_config=api_config,
|
||||
seq=int(seq) if seq is not None else None,
|
||||
)
|
||||
payload = result.model_dump()
|
||||
ctx.request_data["fictionChapterResult"] = payload
|
||||
ctx.generated_content = json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
|
||||
def register_fiction_tools(registry: ToolRegistry) -> None:
|
||||
registry.register(
|
||||
"fiction.open_book",
|
||||
fiction_open_book,
|
||||
description="根据用户创作灵感优化爽文开书方案,返回 guide 草稿与推荐情绪流",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"inspiration": {"type": "string", "description": "用户创作灵感/简介"},
|
||||
"profile_id": {"type": "string", "description": "API 配置 profile ID"},
|
||||
"api_config": {
|
||||
"type": "object",
|
||||
"description": "可选 inline API 配置",
|
||||
},
|
||||
},
|
||||
"required": ["inspiration"],
|
||||
},
|
||||
)
|
||||
registry.register(
|
||||
"fiction.coarse",
|
||||
fiction_coarse,
|
||||
description="根据本书 guide 与 L1 全局指南生成粗纲,写入 metadata.coarseOutline",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"book_id": {"type": "string", "description": "书籍 ID"},
|
||||
"profile_id": {"type": "string", "description": "API 配置 profile ID"},
|
||||
"api_config": {"type": "object", "description": "可选 inline API 配置"},
|
||||
},
|
||||
"required": ["book_id"],
|
||||
},
|
||||
)
|
||||
registry.register(
|
||||
"fiction.event_plan",
|
||||
fiction_event_plan,
|
||||
description="为粗纲事件随机选情绪流并生成 flowStepsPlan + chapterPlan",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"book_id": {"type": "string", "description": "书籍 ID"},
|
||||
"event_id": {
|
||||
"type": "string",
|
||||
"description": "可选,仅规划指定粗纲事件;缺省则规划全部",
|
||||
},
|
||||
"profile_id": {"type": "string", "description": "API 配置 profile ID"},
|
||||
"api_config": {"type": "object", "description": "可选 inline API 配置"},
|
||||
},
|
||||
"required": ["book_id"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
registry.register(
|
||||
"fiction.chapter",
|
||||
fiction_chapter,
|
||||
description="根据 chapterPlan brief 撰写章节正文,写入 chapters 目录",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"book_id": {"type": "string", "description": "书籍 ID"},
|
||||
"seq": {
|
||||
"type": "integer",
|
||||
"description": "可选,指定章节序号;缺省则写下一未写章",
|
||||
},
|
||||
"profile_id": {"type": "string", "description": "API 配置 profile ID"},
|
||||
"api_config": {"type": "object", "description": "可选 inline API 配置"},
|
||||
},
|
||||
"required": ["book_id"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# 模块加载时注册到默认 registry
|
||||
try:
|
||||
from services.tool_registry import default_tool_registry
|
||||
|
||||
register_fiction_tools(default_tool_registry)
|
||||
except Exception:
|
||||
pass
|
||||
170
backend/services/workflow_engine.py
Normal file
170
backend/services/workflow_engine.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
Workflow engine – orchestrates template loading, state machine execution, and run persistence.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional
|
||||
|
||||
try:
|
||||
from backend.core.config import settings
|
||||
from backend.models.agent import (
|
||||
ChatRunBinding,
|
||||
ChatTurnResult,
|
||||
RunEvent,
|
||||
RunStatus,
|
||||
TurnCallbacks,
|
||||
TurnContext,
|
||||
WorkflowRun,
|
||||
WorkflowTemplate,
|
||||
WorkflowTemplateKind,
|
||||
)
|
||||
from backend.services.state_machine_runner import StateMachineRunner
|
||||
from backend.services.tool_registry import ToolRegistry, default_tool_registry
|
||||
from backend.services.tools.chat_tools import register_chat_tools
|
||||
except ImportError:
|
||||
from core.config import settings
|
||||
from models.agent import (
|
||||
ChatRunBinding,
|
||||
ChatTurnResult,
|
||||
RunEvent,
|
||||
RunStatus,
|
||||
TurnCallbacks,
|
||||
TurnContext,
|
||||
WorkflowRun,
|
||||
WorkflowTemplate,
|
||||
WorkflowTemplateKind,
|
||||
)
|
||||
from services.state_machine_runner import StateMachineRunner
|
||||
from services.tool_registry import ToolRegistry, default_tool_registry
|
||||
from services.tools.chat_tools import register_chat_tools
|
||||
|
||||
|
||||
class WorkflowEngine:
|
||||
def __init__(self, registry: Optional[ToolRegistry] = None) -> None:
|
||||
self.registry = registry or default_tool_registry
|
||||
if not self.registry.list_specs():
|
||||
register_chat_tools(self.registry)
|
||||
|
||||
def _template_dir(self, template_id: str) -> Path:
|
||||
return settings.AGENT_TEMPLATES_PATH / template_id
|
||||
|
||||
def load_template(self, template_id: str = WorkflowTemplateKind.BUILTIN_CHAT.value) -> WorkflowTemplate:
|
||||
template_path = self._template_dir(template_id) / "template.json"
|
||||
with open(template_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return WorkflowTemplate(**data)
|
||||
|
||||
def _run_dir(self, role_name: str, chat_name: str) -> Path:
|
||||
return settings.AGENT_RUNS_PATH / "chat" / role_name / chat_name
|
||||
|
||||
def _persist_run(self, run: WorkflowRun, events: List[RunEvent]) -> None:
|
||||
run_dir = self._run_dir(run.binding.role_name, run.binding.chat_name)
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
run_file = run_dir / "run.json"
|
||||
run.finished_at = datetime.now().isoformat()
|
||||
with open(run_file, "w", encoding="utf-8") as f:
|
||||
json.dump(run.model_dump(), f, ensure_ascii=False, indent=2)
|
||||
|
||||
events_file = run_dir / "events.jsonl"
|
||||
with open(events_file, "a", encoding="utf-8") as f:
|
||||
for event in events:
|
||||
f.write(json.dumps(event.model_dump(), ensure_ascii=False) + "\n")
|
||||
|
||||
async def run_turn(
|
||||
self,
|
||||
request_data: Dict[str, Any],
|
||||
*,
|
||||
stream: bool = False,
|
||||
on_chunk: Optional[Callable[[str], Awaitable[None]]] = None,
|
||||
on_worldbook_active: Optional[Callable[[List[Any]], Awaitable[None]]] = None,
|
||||
on_tasks_created: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
|
||||
template_id: str = WorkflowTemplateKind.BUILTIN_CHAT.value,
|
||||
) -> ChatTurnResult:
|
||||
current_role = request_data.get("currentRole", "")
|
||||
current_chat = request_data.get("currentChat", "")
|
||||
user_message = request_data.get("mes", "")
|
||||
|
||||
if not current_role or not user_message:
|
||||
return ChatTurnResult(
|
||||
success=False,
|
||||
error="缺少必要的参数:currentRole 或 mes",
|
||||
workflow_template_id=template_id,
|
||||
)
|
||||
|
||||
preset_config = request_data.get("presetConfig", {})
|
||||
preset_name = preset_config.get("selectedPreset")
|
||||
|
||||
run_id = uuid.uuid4().hex
|
||||
binding = ChatRunBinding(
|
||||
role_name=current_role,
|
||||
chat_name=current_chat or "",
|
||||
template_id=template_id,
|
||||
)
|
||||
run = WorkflowRun(
|
||||
id=run_id,
|
||||
template_id=template_id,
|
||||
binding=binding,
|
||||
status=RunStatus.PENDING,
|
||||
)
|
||||
|
||||
callbacks = TurnCallbacks(
|
||||
on_chunk=on_chunk,
|
||||
on_worldbook_active=on_worldbook_active,
|
||||
on_tasks_created=on_tasks_created,
|
||||
)
|
||||
|
||||
ctx = TurnContext(
|
||||
request_data=request_data,
|
||||
template_id=template_id,
|
||||
run_id=run_id,
|
||||
stream=stream,
|
||||
callbacks=callbacks,
|
||||
current_role=current_role,
|
||||
current_chat=current_chat or "",
|
||||
user_message=user_message,
|
||||
preset_name=preset_name,
|
||||
)
|
||||
|
||||
template = self.load_template(template_id)
|
||||
sm_path = self._template_dir(template_id) / template.state_machine_path
|
||||
runner = StateMachineRunner.from_file(sm_path, self.registry)
|
||||
|
||||
try:
|
||||
events = await runner.run(run, ctx)
|
||||
self._persist_run(run, events)
|
||||
|
||||
active_entries = [
|
||||
entry.model_dump() if hasattr(entry, "model_dump") else entry
|
||||
for entry in ctx.active_entries
|
||||
]
|
||||
|
||||
return ChatTurnResult(
|
||||
success=True,
|
||||
content=ctx.generated_content,
|
||||
active_entries=active_entries,
|
||||
task_ids=ctx.task_ids,
|
||||
run_id=run_id,
|
||||
workflow_template_id=template_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
run.status = RunStatus.FAILED
|
||||
run.error = str(exc)
|
||||
try:
|
||||
self._persist_run(run, [])
|
||||
except Exception:
|
||||
pass
|
||||
return ChatTurnResult(
|
||||
success=False,
|
||||
error=f"工作流执行失败: {exc}",
|
||||
run_id=run_id,
|
||||
workflow_template_id=template_id,
|
||||
)
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
workflow_engine = WorkflowEngine()
|
||||
436
backend/services/worldbook_service.py
Normal file
436
backend/services/worldbook_service.py
Normal file
@@ -0,0 +1,436 @@
|
||||
"""
|
||||
World Book Service
|
||||
世界书服务层 - 处理世界书及条目的 CRUD 操作
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from models.internal import WorldInfo, WorldInfoEntry, ActivationType
|
||||
from models.converters import WorldBookConverter
|
||||
from core.config import settings
|
||||
|
||||
|
||||
class WorldBookService:
|
||||
"""世界书服务类"""
|
||||
|
||||
@staticmethod
|
||||
def _get_worldbook_path(name: str) -> Path:
|
||||
"""获取世界书文件路径"""
|
||||
return settings.WORLDBOOKS_PATH / f"{name}.json"
|
||||
|
||||
@staticmethod
|
||||
def _load_worldbook(name: str) -> Optional[Dict[str, Any]]:
|
||||
"""加载世界书 JSON 文件"""
|
||||
path = WorldBookService._get_worldbook_path(name)
|
||||
if not path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to load worldbook '{name}': {str(e)}")
|
||||
|
||||
@staticmethod
|
||||
def _save_worldbook(name: str, data: Dict[str, Any]):
|
||||
"""保存世界书到 JSON 文件"""
|
||||
path = WorldBookService._get_worldbook_path(name)
|
||||
try:
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to save worldbook '{name}': {str(e)}")
|
||||
|
||||
@staticmethod
|
||||
def list_worldbooks() -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取所有世界书的列表(仅基本信息)
|
||||
|
||||
Returns:
|
||||
世界书列表,每个包含 name, description, entries_count 等
|
||||
"""
|
||||
worldbooks = []
|
||||
|
||||
for json_file in settings.WORLDBOOKS_PATH.glob("*.json"):
|
||||
try:
|
||||
with open(json_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# 内部格式:entries 是列表
|
||||
entries = data.get("entries", [])
|
||||
entries_count = len(entries) if isinstance(entries, list) else 0
|
||||
|
||||
worldbooks.append({
|
||||
"name": data.get("name", json_file.stem),
|
||||
"description": data.get("description", ""),
|
||||
"entries_count": entries_count,
|
||||
"createdAt": data.get("createdAt", 0),
|
||||
"updatedAt": data.get("updatedAt", 0)
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"Error loading worldbook {json_file.name}: {e}")
|
||||
continue
|
||||
|
||||
# 按更新时间排序
|
||||
worldbooks.sort(key=lambda x: x.get("updatedAt", 0), reverse=True)
|
||||
return worldbooks
|
||||
|
||||
@staticmethod
|
||||
def get_worldbook(name: str) -> Dict[str, Any]:
|
||||
"""
|
||||
获取指定世界书的完整数据
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
|
||||
Returns:
|
||||
世界书完整数据
|
||||
"""
|
||||
data = WorldBookService._load_worldbook(name)
|
||||
if not data:
|
||||
raise FileNotFoundError(f"Worldbook '{name}' not found")
|
||||
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def create_worldbook(name: str, description: str = "") -> Dict[str, Any]:
|
||||
"""
|
||||
创建新世界书
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
description: 世界书描述
|
||||
|
||||
Returns:
|
||||
创建的世界书数据
|
||||
"""
|
||||
# 检查是否已存在
|
||||
if WorldBookService._get_worldbook_path(name).exists():
|
||||
raise ValueError(f"Worldbook '{name}' already exists")
|
||||
|
||||
now = int(datetime.now().timestamp())
|
||||
worldbook_data = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"name": name,
|
||||
"description": description,
|
||||
"entries": [],
|
||||
"createdAt": now,
|
||||
"updatedAt": now,
|
||||
"version": 1
|
||||
}
|
||||
|
||||
WorldBookService._save_worldbook(name, worldbook_data)
|
||||
return worldbook_data
|
||||
|
||||
@staticmethod
|
||||
def update_worldbook(name: str, description: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
更新世界书基本信息
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
description: 新的描述(可选)
|
||||
|
||||
Returns:
|
||||
更新后的世界书数据
|
||||
"""
|
||||
data = WorldBookService._load_worldbook(name)
|
||||
if not data:
|
||||
raise FileNotFoundError(f"Worldbook '{name}' not found")
|
||||
|
||||
if description is not None:
|
||||
data["description"] = description
|
||||
|
||||
data["updatedAt"] = int(datetime.now().timestamp())
|
||||
WorldBookService._save_worldbook(name, data)
|
||||
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def delete_worldbook(name: str) -> bool:
|
||||
"""
|
||||
删除世界书
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
|
||||
Returns:
|
||||
是否删除成功
|
||||
"""
|
||||
path = WorldBookService._get_worldbook_path(name)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Worldbook '{name}' not found")
|
||||
|
||||
path.unlink()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def list_entries(name: str, page: int = 1, page_size: int = 20) -> Dict[str, Any]:
|
||||
"""
|
||||
获取世界书的条目列表(支持分页)
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
page: 页码,从1开始
|
||||
page_size: 每页数量,默认20
|
||||
|
||||
Returns:
|
||||
包含条目列表和分页信息的字典
|
||||
"""
|
||||
data = WorldBookService._load_worldbook(name)
|
||||
if not data:
|
||||
raise FileNotFoundError(f"Worldbook '{name}' not found")
|
||||
|
||||
# 内部格式:entries 是列表
|
||||
all_entries = data.get("entries", [])
|
||||
if not isinstance(all_entries, list):
|
||||
all_entries = []
|
||||
|
||||
total = len(all_entries)
|
||||
|
||||
# 计算分页
|
||||
start_idx = (page - 1) * page_size
|
||||
end_idx = start_idx + page_size
|
||||
paginated_entries = all_entries[start_idx:end_idx]
|
||||
|
||||
return {
|
||||
"entries": paginated_entries,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_pages": (total + page_size - 1) // page_size # 向上取整
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_entry(name: str, uid: str) -> Dict[str, Any]:
|
||||
"""
|
||||
获取世界书的指定条目
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
uid: 条目 UID
|
||||
|
||||
Returns:
|
||||
条目数据
|
||||
"""
|
||||
data = WorldBookService._load_worldbook(name)
|
||||
if not data:
|
||||
raise FileNotFoundError(f"Worldbook '{name}' not found")
|
||||
|
||||
# 内部格式:entries 是列表
|
||||
entries = data.get("entries", [])
|
||||
if not isinstance(entries, list):
|
||||
entries = []
|
||||
|
||||
for entry in entries:
|
||||
if entry.get("uid") == uid or str(entry.get("uid")) == uid:
|
||||
return entry
|
||||
|
||||
raise FileNotFoundError(f"Entry '{uid}' not found in worldbook '{name}'")
|
||||
|
||||
@staticmethod
|
||||
def append_entry(name: str, entry_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
在世界书中追加条目(规范化后写入,与 Chat 侧条目格式一致)。
|
||||
|
||||
Args:
|
||||
name: 世界书名称(文件名,不含 .json)
|
||||
entry_data: 条目字段(content、comment、activationType、position 等)
|
||||
|
||||
Returns:
|
||||
写入后的规范化条目
|
||||
"""
|
||||
data = WorldBookService._load_worldbook(name)
|
||||
if not data:
|
||||
raise FileNotFoundError(f"Worldbook '{name}' not found")
|
||||
|
||||
if not isinstance(data.get("entries"), list):
|
||||
data["entries"] = []
|
||||
|
||||
normalized = WorldBookConverter.normalize_entry(entry_data)
|
||||
data["entries"].append(normalized)
|
||||
now = int(datetime.now().timestamp())
|
||||
data["updatedAt"] = now
|
||||
WorldBookService._save_worldbook(name, data)
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def create_entry(name: str, entry_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
在世界书中创建新条目
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
entry_data: 条目数据(不包含 uid, createdAt, updatedAt)
|
||||
|
||||
Returns:
|
||||
创建的条目数据
|
||||
"""
|
||||
data = WorldBookService._load_worldbook(name)
|
||||
if not data:
|
||||
raise FileNotFoundError(f"Worldbook '{name}' not found")
|
||||
|
||||
# 生成 UID 和时间戳
|
||||
now = int(datetime.now().timestamp())
|
||||
new_entry = {
|
||||
"uid": str(uuid.uuid4()),
|
||||
"key": entry_data.get("key", []),
|
||||
"keysecondary": entry_data.get("keysecondary", []),
|
||||
"content": entry_data.get("content", ""),
|
||||
"activationType": entry_data.get("activationType", ActivationType.KEYWORD.value),
|
||||
"logicExpression": entry_data.get("logicExpression"),
|
||||
"ragConfig": entry_data.get("ragConfig"),
|
||||
"order": entry_data.get("order", 0),
|
||||
"position": entry_data.get("position", "after_char"),
|
||||
"depth": entry_data.get("depth"),
|
||||
"probability": entry_data.get("probability", 100),
|
||||
"group": entry_data.get("group", []),
|
||||
"disable": entry_data.get("disable", False),
|
||||
"createdAt": now,
|
||||
"updatedAt": now
|
||||
}
|
||||
|
||||
data["entries"].append(new_entry)
|
||||
data["updatedAt"] = now
|
||||
WorldBookService._save_worldbook(name, data)
|
||||
|
||||
return new_entry
|
||||
|
||||
@staticmethod
|
||||
def update_entry(name: str, uid: str, entry_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
更新世界书的指定条目
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
uid: 条目 UID
|
||||
entry_data: 更新的字段
|
||||
|
||||
Returns:
|
||||
更新后的条目数据
|
||||
"""
|
||||
data = WorldBookService._load_worldbook(name)
|
||||
if not data:
|
||||
raise FileNotFoundError(f"Worldbook '{name}' not found")
|
||||
|
||||
for i, entry in enumerate(data.get("entries", [])):
|
||||
if entry.get("uid") == uid:
|
||||
# 更新字段
|
||||
for key, value in entry_data.items():
|
||||
if key not in ["uid", "createdAt"]: # 不修改 UID 和创建时间
|
||||
entry[key] = value
|
||||
|
||||
# 更新时间戳
|
||||
entry["updatedAt"] = int(datetime.now().timestamp())
|
||||
data["entries"][i] = entry
|
||||
data["updatedAt"] = entry["updatedAt"]
|
||||
|
||||
WorldBookService._save_worldbook(name, data)
|
||||
return entry
|
||||
|
||||
raise FileNotFoundError(f"Entry '{uid}' not found in worldbook '{name}'")
|
||||
|
||||
@staticmethod
|
||||
def delete_entry(name: str, uid: str) -> bool:
|
||||
"""
|
||||
删除世界书的指定条目
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
uid: 条目 UID
|
||||
|
||||
Returns:
|
||||
是否删除成功
|
||||
"""
|
||||
data = WorldBookService._load_worldbook(name)
|
||||
if not data:
|
||||
raise FileNotFoundError(f"Worldbook '{name}' not found")
|
||||
|
||||
original_length = len(data.get("entries", []))
|
||||
data["entries"] = [e for e in data.get("entries", []) if e.get("uid") != uid]
|
||||
|
||||
if len(data["entries"]) == original_length:
|
||||
raise FileNotFoundError(f"Entry '{uid}' not found in worldbook '{name}'")
|
||||
|
||||
data["updatedAt"] = int(datetime.now().timestamp())
|
||||
WorldBookService._save_worldbook(name, data)
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def import_from_sillytavern(name: str, st_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
从 SillyTavern 格式导入世界书
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
st_data: SillyTavern 格式的世界书数据
|
||||
|
||||
Returns:
|
||||
转换后的内部格式世界书数据
|
||||
"""
|
||||
# 使用转换器进行转换
|
||||
worldbook_data = WorldBookConverter.st_to_internal(st_data, name)
|
||||
|
||||
# 保存到文件
|
||||
WorldBookService._save_worldbook(name, worldbook_data)
|
||||
|
||||
return worldbook_data
|
||||
|
||||
@staticmethod
|
||||
def import_internal_format(name: str, internal_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
直接导入内部格式的世界书(无需转换)
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
internal_data: 内部格式的世界书数据
|
||||
|
||||
Returns:
|
||||
内部格式世界书数据
|
||||
"""
|
||||
# 确保包含必要的字段
|
||||
if "name" not in internal_data:
|
||||
internal_data["name"] = name
|
||||
|
||||
# 规范化所有条目,确保有 trigger_config
|
||||
if "entries" in internal_data and isinstance(internal_data["entries"], list):
|
||||
normalized_entries = []
|
||||
for entry in internal_data["entries"]:
|
||||
if isinstance(entry, dict):
|
||||
normalized_entry = WorldBookConverter.normalize_entry(entry)
|
||||
normalized_entries.append(normalized_entry)
|
||||
internal_data["entries"] = normalized_entries
|
||||
|
||||
# 保存文件
|
||||
WorldBookService._save_worldbook(name, internal_data)
|
||||
|
||||
return internal_data
|
||||
|
||||
@staticmethod
|
||||
def export_to_sillytavern(name: str) -> Dict[str, Any]:
|
||||
"""
|
||||
导出为 SillyTavern 格式
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
|
||||
Returns:
|
||||
SillyTavern 格式的世界书数据
|
||||
"""
|
||||
data = WorldBookService._load_worldbook(name)
|
||||
if not data:
|
||||
raise FileNotFoundError(f"Worldbook '{name}' not found")
|
||||
|
||||
# 使用转换器进行转换
|
||||
st_data = WorldBookConverter.internal_to_st(data)
|
||||
|
||||
return st_data
|
||||
|
||||
|
||||
# 全局实例
|
||||
worldbook_service = WorldBookService()
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,47 +0,0 @@
|
||||
from ..core import config
|
||||
from typing import Dict, List
|
||||
|
||||
# 使用配置中的 DATA_PATH 并添加 "chat" 子目录
|
||||
ROOT_DIR = config.settings.DATA_PATH / "chat"
|
||||
|
||||
def get_all_role_and_chat() -> Dict[str, List[str]]:
|
||||
"""
|
||||
读取配置目录下的所有子文件夹,并收集每个子文件夹中的 JSONL 文件
|
||||
|
||||
返回:
|
||||
dict: 字典结构,键是文件夹名称,值是该文件夹中的 JSONL 文件列表(仅文件名,无路径和后缀)
|
||||
"""
|
||||
result = {}
|
||||
|
||||
# 确保目标目录存在
|
||||
if not ROOT_DIR.exists():
|
||||
print(f"警告: 目录 {ROOT_DIR} 不存在")
|
||||
return result
|
||||
|
||||
# 打印根目录路径和内容(调试用)
|
||||
print(f"正在扫描目录: {ROOT_DIR}")
|
||||
print(f"根目录内容: {list(ROOT_DIR.iterdir())}")
|
||||
|
||||
# 遍历根目录下的所有条目
|
||||
for entry in ROOT_DIR.iterdir():
|
||||
try:
|
||||
# 只处理文件夹
|
||||
if entry.is_dir():
|
||||
print(f"处理文件夹: {entry.name}") # 调试信息
|
||||
jsonl_files = []
|
||||
|
||||
# 遍历子文件夹中的所有文件
|
||||
for file in entry.iterdir():
|
||||
if file.is_file() and file.suffix == '.jsonl':
|
||||
# 使用 file.stem 获取不带后缀的文件名
|
||||
jsonl_files.append(file.stem)
|
||||
print(f" 找到文件: {file.name}") # 调试信息
|
||||
|
||||
# 如果该文件夹中有 JSONL 文件,则添加到结果中
|
||||
if jsonl_files:
|
||||
result[entry.name] = jsonl_files
|
||||
except Exception as e:
|
||||
print(f"处理文件夹 {entry.name} 时出错: {str(e)}")
|
||||
continue
|
||||
|
||||
return result
|
||||
@@ -1,152 +0,0 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from backend.core import config as cfg
|
||||
from pathlib import Path
|
||||
from ..core.items import ChatRequest
|
||||
|
||||
# 假设 ChatRequest 定义在这里或者从其他地方导入
|
||||
# from backend.app.core.items import ChatRequest
|
||||
|
||||
async def save_input_to_json(chat_request: ChatRequest):
|
||||
"""
|
||||
保存消息到JSONL文件或处理重roll请求
|
||||
|
||||
参数:
|
||||
chat_request: 包含消息详情的请求对象
|
||||
"""
|
||||
# 1. 从对象中提取属性
|
||||
mes = chat_request.mes
|
||||
role_name = chat_request.role_name
|
||||
chat_name = chat_request.chat_name
|
||||
name = chat_request.name
|
||||
is_user = chat_request.is_user
|
||||
floor_number = chat_request.floor_number
|
||||
# stream, img_switch, table_switch 等虽然在这个函数逻辑中没用到,
|
||||
# 但如果 ChatRequest 中有,也可以提取出来备用
|
||||
# stream = chat_request.stream
|
||||
# ...
|
||||
|
||||
config = cfg.settings
|
||||
# 注意:这里要确保 role_name 和 chat_name 不为 None,否则路径拼接会报错
|
||||
# 建议在函数入口处增加校验,或者在 Pydantic 模型中设置为必填项
|
||||
if not role_name or not chat_name:
|
||||
raise ValueError("role_name and chat_name cannot be empty")
|
||||
|
||||
file_path = config.BASE_PATH / "data" / "chat" / role_name / f"{chat_name}.jsonl"
|
||||
|
||||
# 确保目录存在
|
||||
Path(file_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 读取文件内容
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
except FileNotFoundError:
|
||||
lines = []
|
||||
|
||||
# 判断是否为重roll请求
|
||||
is_regenerate = False
|
||||
target_index = -1
|
||||
|
||||
if lines and floor_number > 0:
|
||||
# 计算当前楼层号
|
||||
current_floor = len(lines)
|
||||
|
||||
# 如果floor_number与当前楼层号相同,则为重roll请求
|
||||
if floor_number == current_floor:
|
||||
# 找到最后一条非用户消息
|
||||
for i in range(len(lines) - 1, -1, -1):
|
||||
try:
|
||||
line_data = json.loads(lines[i])
|
||||
if not line_data.get('is_user', False):
|
||||
is_regenerate = True
|
||||
target_index = i
|
||||
break
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
# 处理重roll逻辑
|
||||
if is_regenerate:
|
||||
# 解析目标消息
|
||||
try:
|
||||
target_message = json.loads(lines[target_index])
|
||||
except json.JSONDecodeError:
|
||||
raise ValueError(f"无法解析楼层 {floor_number} 的JSON数据")
|
||||
|
||||
# 初始化swipes数组
|
||||
if target_message.get('swipes') is None:
|
||||
target_message['swipes'] = []
|
||||
|
||||
# 将新回复添加到swipes数组
|
||||
target_message['swipes'].append(mes)
|
||||
|
||||
# 更新swipe_id和content
|
||||
target_message['swipes_id'] = len(target_message['swipes']) - 1
|
||||
target_message['content'] = mes
|
||||
|
||||
# 更新文件内容
|
||||
lines[target_index] = json.dumps(target_message, ensure_ascii=False) + '\n'
|
||||
|
||||
# 写回文件
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
f.writelines(lines)
|
||||
|
||||
return target_message
|
||||
|
||||
# 处理普通消息保存逻辑
|
||||
else:
|
||||
# 获取当前时间
|
||||
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# 构建消息对象
|
||||
message = {
|
||||
"role": role_name,
|
||||
"chat": chat_name,
|
||||
"content": mes,
|
||||
"name": name,
|
||||
"is_user": is_user,
|
||||
"send_date": current_time,
|
||||
"floor_number": len(lines) + 1, # 记录楼层号
|
||||
"swipes": [],
|
||||
"swipes_id": 0
|
||||
}
|
||||
|
||||
# 追加到文件
|
||||
with open(file_path, 'a', encoding='utf-8') as f:
|
||||
f.write(json.dumps(message, ensure_ascii=False) + '\n')
|
||||
|
||||
return message
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 注意:为了在本地运行测试,你需要手动构造一个 ChatRequest 对象
|
||||
# 或者临时修改函数签名以便直接传参测试
|
||||
|
||||
# 示例:假设 ChatRequest 是一个简单的类或 Pydantic 模型
|
||||
class MockChatRequest:
|
||||
def __init__(self, **kwargs):
|
||||
self.mes = kwargs.get('mes')
|
||||
self.role_name = kwargs.get('role_name')
|
||||
self.chat_name = kwargs.get('chat_name')
|
||||
self.name = kwargs.get('name')
|
||||
self.is_user = kwargs.get('is_user')
|
||||
self.floor_number = kwargs.get('floor_number')
|
||||
|
||||
|
||||
# 测试重roll最后一条AI消息
|
||||
import asyncio
|
||||
|
||||
|
||||
async def test():
|
||||
req = MockChatRequest(
|
||||
mes="这是重roll后的新回复2",
|
||||
role_name="testRole1",
|
||||
chat_name="111",
|
||||
name="AI",
|
||||
is_user=False,
|
||||
floor_number=2
|
||||
)
|
||||
await save_input_to_json(req)
|
||||
|
||||
|
||||
asyncio.run(test())
|
||||
17
backend/utils/__init__.py
Normal file
17
backend/utils/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""
|
||||
工具类包
|
||||
|
||||
提供通用的工具函数和辅助类,如文件操作、LLM 调用封装等。
|
||||
"""
|
||||
from .file_utils import get_all_roles_and_chats, read_jsonl_file, write_jsonl_file
|
||||
from .llm_client import get_llm, get_fast_llm, get_creative_llm, get_streaming_llm
|
||||
|
||||
__all__ = [
|
||||
'get_all_roles_and_chats',
|
||||
'read_jsonl_file',
|
||||
'write_jsonl_file',
|
||||
'get_llm',
|
||||
'get_fast_llm',
|
||||
'get_creative_llm',
|
||||
'get_streaming_llm',
|
||||
]
|
||||
130
backend/utils/file_utils.py
Normal file
130
backend/utils/file_utils.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
文件操作工具函数
|
||||
|
||||
提供文件和目录操作的通用工具
|
||||
"""
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_all_roles_and_chats(data_path: Path) -> Dict[str, List[str]]:
|
||||
"""
|
||||
获取所有角色和聊天列表
|
||||
|
||||
Args:
|
||||
data_path: 数据目录路径
|
||||
|
||||
Returns:
|
||||
Dict[str, List[str]]: 字典结构,键是角色名称,值是该角色的聊天列表
|
||||
"""
|
||||
chat_dir = data_path / "chat"
|
||||
result = {}
|
||||
|
||||
if not chat_dir.exists():
|
||||
logger.warning(f"聊天目录不存在: {chat_dir}")
|
||||
return result
|
||||
|
||||
for entry in chat_dir.iterdir():
|
||||
try:
|
||||
if entry.is_dir():
|
||||
jsonl_files = []
|
||||
|
||||
for file in entry.iterdir():
|
||||
if file.is_file() and file.suffix == '.jsonl':
|
||||
jsonl_files.append(file.stem)
|
||||
|
||||
if jsonl_files:
|
||||
result[entry.name] = jsonl_files
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理文件夹 {entry.name} 时出错: {str(e)}")
|
||||
continue
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def ensure_directory_exists(path: Path) -> None:
|
||||
"""
|
||||
确保目录存在,如果不存在则创建
|
||||
|
||||
Args:
|
||||
path: 目录路径
|
||||
"""
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def read_json_file(file_path: Path) -> dict:
|
||||
"""
|
||||
读取 JSON 文件
|
||||
|
||||
Args:
|
||||
file_path: 文件路径
|
||||
|
||||
Returns:
|
||||
dict: JSON 数据
|
||||
"""
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def write_json_file(file_path: Path, data: dict) -> None:
|
||||
"""
|
||||
写入 JSON 文件
|
||||
|
||||
Args:
|
||||
file_path: 文件路径
|
||||
data: 要写入的数据
|
||||
"""
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def read_jsonl_file(file_path: Path) -> List[dict]:
|
||||
"""
|
||||
读取 JSONL 文件
|
||||
|
||||
Args:
|
||||
file_path: 文件路径
|
||||
|
||||
Returns:
|
||||
List[dict]: JSONL 数据列表
|
||||
"""
|
||||
lines = []
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
try:
|
||||
lines.append(json.loads(line))
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"解析 JSONL 行失败: {e}")
|
||||
return lines
|
||||
|
||||
|
||||
def append_to_jsonl_file(file_path: Path, data: dict) -> None:
|
||||
"""
|
||||
追加数据到 JSONL 文件
|
||||
|
||||
Args:
|
||||
file_path: 文件路径
|
||||
data: 要追加的数据
|
||||
"""
|
||||
with open(file_path, 'a', encoding='utf-8') as f:
|
||||
f.write(json.dumps(data, ensure_ascii=False) + '\n')
|
||||
|
||||
|
||||
def write_jsonl_file(file_path: Path, data_list: List[dict]) -> None:
|
||||
"""
|
||||
写入 JSONL 文件 (覆盖模式)
|
||||
|
||||
Args:
|
||||
file_path: 文件路径
|
||||
data_list: 数据列表
|
||||
"""
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
for data in data_list:
|
||||
f.write(json.dumps(data, ensure_ascii=False) + '\n')
|
||||
337
backend/utils/llm_client.py
Normal file
337
backend/utils/llm_client.py
Normal file
@@ -0,0 +1,337 @@
|
||||
"""
|
||||
LLM 客户端工具
|
||||
|
||||
提供统一的 LLM 接口,支持多种模型提供商。
|
||||
使用 LangChain 的 ChatModel 抽象,简化不同厂商 API 的调用。
|
||||
"""
|
||||
from typing import Optional, List, Dict, Any, AsyncGenerator
|
||||
from langchain_core.language_models.chat_models import BaseChatModel
|
||||
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage, AIMessage
|
||||
from langchain_core.callbacks import AsyncCallbackHandler
|
||||
from core.config import settings
|
||||
import time
|
||||
|
||||
|
||||
def get_llm(
|
||||
provider: str = "openai",
|
||||
model: Optional[str] = None,
|
||||
temperature: float = 0.7,
|
||||
streaming: bool = False,
|
||||
**kwargs
|
||||
) -> BaseChatModel:
|
||||
"""
|
||||
获取 LLM 实例
|
||||
|
||||
Args:
|
||||
provider: 模型提供商 ("openai", "anthropic", "ollama")
|
||||
model: 模型名称 (如果不指定则使用配置中的默认值)
|
||||
temperature: 温度参数 (0-2)
|
||||
streaming: 是否启用流式输出
|
||||
**kwargs: 其他参数传递给模型
|
||||
|
||||
Returns:
|
||||
BaseChatModel: LangChain 的聊天模型实例
|
||||
"""
|
||||
|
||||
if provider == "openai":
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
return ChatOpenAI(
|
||||
model=model or settings.OPENAI_MODEL or "gpt-4",
|
||||
temperature=temperature,
|
||||
api_key=settings.OPENAI_API_KEY,
|
||||
streaming=streaming,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
elif provider == "anthropic":
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
|
||||
return ChatAnthropic(
|
||||
model=model or settings.ANTHROPIC_MODEL or "claude-3-opus-20240229",
|
||||
temperature=temperature,
|
||||
api_key=settings.ANTHROPIC_API_KEY,
|
||||
max_tokens=kwargs.pop("max_tokens", 4096),
|
||||
streaming=streaming,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
elif provider == "ollama":
|
||||
try:
|
||||
from langchain_ollama import ChatOllama
|
||||
|
||||
return ChatOllama(
|
||||
model=model or settings.OLLAMA_MODEL or "llama3",
|
||||
base_url=settings.OLLAMA_BASE_URL or "http://localhost:11434",
|
||||
temperature=temperature,
|
||||
**kwargs
|
||||
)
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"langchain-ollama not installed. Run: pip install langchain-ollama"
|
||||
)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unsupported provider: {provider}. Use 'openai', 'anthropic', or 'ollama'")
|
||||
|
||||
|
||||
# 便捷函数 - 常用配置
|
||||
def get_fast_llm(provider: str = "openai") -> BaseChatModel:
|
||||
"""获取快速响应的 LLM (低温度,适合事实性问题)"""
|
||||
return get_llm(provider, temperature=0.3)
|
||||
|
||||
|
||||
def get_creative_llm(provider: str = "openai") -> BaseChatModel:
|
||||
"""获取创造性 LLM (高温度,适合创意写作)"""
|
||||
return get_llm(provider, temperature=0.9)
|
||||
|
||||
|
||||
def get_streaming_llm(provider: str = "openai") -> BaseChatModel:
|
||||
"""获取支持流式输出的 LLM"""
|
||||
return get_llm(provider, streaming=True)
|
||||
|
||||
|
||||
class TokenUsageCallbackHandler(AsyncCallbackHandler):
|
||||
"""
|
||||
Token 使用回调处理器
|
||||
|
||||
用于捕获 LLM 调用的 token 使用情况
|
||||
"""
|
||||
def __init__(self):
|
||||
self.prompt_tokens = 0
|
||||
self.completion_tokens = 0
|
||||
self.total_tokens = 0
|
||||
self.response_content = ""
|
||||
|
||||
async def on_llm_start(self, serialized: Dict[str, Any], prompts: List[str] = None, **kwargs):
|
||||
"""LLM 开始时的回调"""
|
||||
pass
|
||||
|
||||
async def on_llm_end(self, response, **kwargs):
|
||||
"""LLM 结束时的回调,获取 token 统计"""
|
||||
try:
|
||||
# 从 response 中提取 token 信息
|
||||
if hasattr(response, 'llm_output') and response.llm_output:
|
||||
token_usage = response.llm_output.get('token_usage', {})
|
||||
self.prompt_tokens = token_usage.get('prompt_tokens', 0)
|
||||
self.completion_tokens = token_usage.get('completion_tokens', 0)
|
||||
self.total_tokens = token_usage.get('total_tokens', 0)
|
||||
except Exception as e:
|
||||
print(f"[TokenUsageCallback] 提取 token 信息失败: {e}")
|
||||
|
||||
async def on_llm_new_token(self, token: str, **kwargs):
|
||||
"""每个新 token 的回调(流式输出)"""
|
||||
self.response_content += token
|
||||
|
||||
|
||||
class LLMClient:
|
||||
"""
|
||||
LLM 客户端封装类
|
||||
|
||||
提供统一的异步接口,支持自定义API配置、流式输出和 token 统计
|
||||
"""
|
||||
|
||||
async def chat_completion(
|
||||
self,
|
||||
messages: List[BaseMessage],
|
||||
api_url: str,
|
||||
api_key: str,
|
||||
model: str = "gpt-3.5-turbo",
|
||||
temperature: float = 1.0,
|
||||
max_tokens: int = 500,
|
||||
request_timeout: int = 60,
|
||||
stream: bool = False,
|
||||
**kwargs
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
调用 LLM API 生成回复
|
||||
|
||||
Args:
|
||||
messages: LangChain 消息列表
|
||||
api_url: API 地址
|
||||
api_key: API 密钥
|
||||
model: 模型名称
|
||||
temperature: 温度参数
|
||||
max_tokens: 最大 token 数
|
||||
request_timeout: 请求超时时间(秒)
|
||||
stream: 是否启用流式输出
|
||||
**kwargs: 其他参数
|
||||
|
||||
Returns:
|
||||
OpenAI 格式的响应字典,包含 token 使用信息
|
||||
"""
|
||||
try:
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
# 创建回调处理器
|
||||
callback_handler = TokenUsageCallbackHandler()
|
||||
|
||||
# 创建自定义的 ChatOpenAI 实例
|
||||
llm = ChatOpenAI(
|
||||
model=model,
|
||||
temperature=temperature,
|
||||
api_key=api_key,
|
||||
base_url=api_url if api_url else None,
|
||||
max_tokens=max_tokens,
|
||||
streaming=stream,
|
||||
callbacks=[callback_handler],
|
||||
request_timeout=request_timeout, # ✅ 设置超时时间
|
||||
**kwargs
|
||||
)
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
if stream:
|
||||
# 流式模式
|
||||
full_content = ""
|
||||
async for chunk in llm.astream(messages):
|
||||
if hasattr(chunk, 'content'):
|
||||
full_content += chunk.content
|
||||
|
||||
duration = time.time() - start_time
|
||||
|
||||
return {
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": full_content
|
||||
}
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": callback_handler.prompt_tokens,
|
||||
"completion_tokens": callback_handler.completion_tokens,
|
||||
"total_tokens": callback_handler.total_tokens
|
||||
},
|
||||
"duration": duration
|
||||
}
|
||||
else:
|
||||
# 非流式模式
|
||||
response = await llm.ainvoke(messages)
|
||||
duration = time.time() - start_time
|
||||
|
||||
return {
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": response.content
|
||||
}
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": callback_handler.prompt_tokens,
|
||||
"completion_tokens": callback_handler.completion_tokens,
|
||||
"total_tokens": callback_handler.total_tokens
|
||||
},
|
||||
"duration": duration
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"[LLMClient] 调用失败: {e}")
|
||||
raise
|
||||
|
||||
async def stream_chat(
|
||||
self,
|
||||
messages: List[BaseMessage],
|
||||
api_url: str,
|
||||
api_key: str,
|
||||
model: str = "gpt-3.5-turbo",
|
||||
temperature: float = 1.0,
|
||||
max_tokens: int = 500,
|
||||
request_timeout: int = 60,
|
||||
**kwargs
|
||||
) -> AsyncGenerator[Dict[str, Any], None]:
|
||||
"""
|
||||
流式调用 LLM API
|
||||
|
||||
Args:
|
||||
messages: LangChain 消息列表
|
||||
api_url: API 地址
|
||||
api_key: API 密钥
|
||||
model: 模型名称
|
||||
temperature: 温度参数
|
||||
max_tokens: 最大 token 数
|
||||
request_timeout: 请求超时时间(秒)
|
||||
**kwargs: 其他参数
|
||||
|
||||
Yields:
|
||||
包含 token 片段的字典
|
||||
"""
|
||||
try:
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
print(f"\n[LLMClient] 🔧 创建 ChatOpenAI 实例")
|
||||
print(f" - Model: {model}")
|
||||
print(f" - API URL: {api_url[:50]}..." if len(api_url) > 50 else f" - API URL: {api_url}")
|
||||
print(f" - Temperature: {temperature}")
|
||||
print(f" - Max Tokens: {max_tokens}")
|
||||
print(f" - Request Timeout: {request_timeout}s")
|
||||
|
||||
# 创建回调处理器
|
||||
callback_handler = TokenUsageCallbackHandler()
|
||||
|
||||
# 创建自定义的 ChatOpenAI 实例
|
||||
llm = ChatOpenAI(
|
||||
model=model,
|
||||
temperature=temperature,
|
||||
api_key=api_key,
|
||||
base_url=api_url if api_url else None,
|
||||
max_tokens=max_tokens,
|
||||
streaming=True,
|
||||
callbacks=[callback_handler],
|
||||
request_timeout=request_timeout, # ✅ 设置超时时间
|
||||
**kwargs
|
||||
)
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
print(f"[LLMClient] 🚀 开始流式请求...")
|
||||
print(f" - Messages 数量: {len(messages)}")
|
||||
if messages:
|
||||
first_msg_role = getattr(messages[0], 'role', 'unknown')
|
||||
first_msg_preview = str(getattr(messages[0], 'content', ''))[:50]
|
||||
print(f" - 第一条消息: [{first_msg_role}] {first_msg_preview}...")
|
||||
|
||||
chunk_count = 0
|
||||
# 流式输出
|
||||
async for chunk in llm.astream(messages):
|
||||
if hasattr(chunk, 'content') and chunk.content:
|
||||
chunk_count += 1
|
||||
|
||||
# 第一个 chunk 时记录
|
||||
if chunk_count == 1:
|
||||
first_chunk_time = time.time()
|
||||
print(f"[LLMClient] ✨ 收到第一个 chunk (耗时: {first_chunk_time - start_time:.2f}s)")
|
||||
|
||||
yield {
|
||||
"type": "chunk",
|
||||
"content": chunk.content
|
||||
}
|
||||
|
||||
duration = time.time() - start_time
|
||||
|
||||
print(f"[LLMClient] ✅ 流式请求完成")
|
||||
print(f" - 总 Chunks: {chunk_count}")
|
||||
print(f" - 耗时: {duration:.2f}秒")
|
||||
print(f" - Prompt Tokens: {callback_handler.prompt_tokens}")
|
||||
print(f" - Completion Tokens: {callback_handler.completion_tokens}")
|
||||
print(f" - Total Tokens: {callback_handler.total_tokens}\n")
|
||||
|
||||
# 最后发送 token 使用信息
|
||||
yield {
|
||||
"type": "usage",
|
||||
"usage": {
|
||||
"prompt_tokens": callback_handler.prompt_tokens,
|
||||
"completion_tokens": callback_handler.completion_tokens,
|
||||
"total_tokens": callback_handler.total_tokens
|
||||
},
|
||||
"duration": duration
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n[LLMClient] ❌ 流式调用失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
raise
|
||||
@@ -1,201 +0,0 @@
|
||||
# backend/app/workflows/llm_workflow.py
|
||||
from typing import Dict, Any, List, Callable
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class WorkflowStatus(Enum):
|
||||
"""工作流状态枚举"""
|
||||
INITIALIZED = "initialized"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
PAUSED = "paused"
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkflowContext:
|
||||
"""工作流上下文"""
|
||||
data: Dict[str, Any]
|
||||
status: WorkflowStatus = WorkflowStatus.INITIALIZED
|
||||
metadata: Dict[str, Any] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.metadata is None:
|
||||
self.metadata = {}
|
||||
|
||||
|
||||
class WorkflowNode:
|
||||
"""工作流节点声明"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
handler: Callable,
|
||||
enabled: bool = True,
|
||||
config: Dict[str, Any] = None
|
||||
):
|
||||
self.name = name # 节点的唯一标识符,用于区分不同的节点。
|
||||
self.handler = handler # 一个可调用对象(函数或方法),这是节点实际执行的处理逻辑。
|
||||
self.enabled = enabled # 布尔值,控制节点是否启用。默认为 True,如果设置为 False,节点将被跳过。
|
||||
self.config = config or {} # 一个字典,用于存储节点的配置信息。默认为空字典。
|
||||
self.next_nodes: List['WorkflowNode'] = [] # 一个节点列表,用于指定当前节点执行完成后应跳转到的下一个节点。默认为空列表,可能指向多分支。
|
||||
|
||||
def execute(self, context: WorkflowContext) -> WorkflowContext:
|
||||
"""执行节点处理"""
|
||||
if not self.enabled:
|
||||
return context
|
||||
|
||||
try:
|
||||
context = self.handler(context, self.config)
|
||||
return context
|
||||
except Exception as e:
|
||||
context.status = WorkflowStatus.FAILED
|
||||
context.metadata["error"] = str(e)
|
||||
raise
|
||||
|
||||
|
||||
class LLMWorkflow:
|
||||
"""LLM工作流声明"""
|
||||
|
||||
def __init__(self):
|
||||
self.nodes: List[WorkflowNode] = []
|
||||
self._initialize_workflow()
|
||||
|
||||
def _initialize_workflow(self):
|
||||
"""初始化工作流节点(仅声明,不实现)"""
|
||||
# 输入节点
|
||||
input_node = WorkflowNode(
|
||||
name="input",
|
||||
handler=self._input_handler
|
||||
)
|
||||
|
||||
# 输入预处理节点(可开关)
|
||||
preprocessing_node = WorkflowNode(
|
||||
name="preprocessing",
|
||||
handler=self._preprocessing_handler,
|
||||
enabled=False
|
||||
)
|
||||
|
||||
# RAG处理节点
|
||||
rag_node = WorkflowNode(
|
||||
name="rag",
|
||||
handler=self._rag_handler
|
||||
)
|
||||
|
||||
# 提示词组装节点
|
||||
prompt_assembly_node = WorkflowNode(
|
||||
name="prompt_assembly",
|
||||
handler=self._prompt_assembly_handler
|
||||
)
|
||||
|
||||
# LLM请求节点
|
||||
llm_request_node = WorkflowNode(
|
||||
name="llm_request",
|
||||
handler=self._llm_request_handler
|
||||
)
|
||||
|
||||
# 图像生成节点(可开关)
|
||||
image_generation_node = WorkflowNode(
|
||||
name="image_generation",
|
||||
handler=self._image_generation_handler,
|
||||
enabled=False
|
||||
)
|
||||
|
||||
# 动态表格更新节点(可开关)
|
||||
dynamic_table_node = WorkflowNode(
|
||||
name="dynamic_table",
|
||||
handler=self._dynamic_table_handler,
|
||||
enabled=False
|
||||
)
|
||||
|
||||
# 输出过滤节点
|
||||
output_filter_node = WorkflowNode(
|
||||
name="output_filter",
|
||||
handler=self._output_filter_handler
|
||||
)
|
||||
|
||||
# 输出节点
|
||||
output_node = WorkflowNode(
|
||||
name="output",
|
||||
handler=self._output_handler
|
||||
)
|
||||
|
||||
# 设置节点顺序(构建工作流)
|
||||
self.nodes = [
|
||||
input_node,
|
||||
preprocessing_node,
|
||||
rag_node,
|
||||
prompt_assembly_node,
|
||||
llm_request_node,
|
||||
image_generation_node,
|
||||
dynamic_table_node,
|
||||
output_filter_node,
|
||||
output_node
|
||||
]
|
||||
|
||||
def execute(self, context: WorkflowContext) -> WorkflowContext:
|
||||
"""执行工作流"""
|
||||
context.status = WorkflowStatus.RUNNING
|
||||
|
||||
for node in self.nodes:
|
||||
try:
|
||||
context = node.execute(context)
|
||||
|
||||
# 如果工作流失败,停止执行
|
||||
if context.status == WorkflowStatus.FAILED:
|
||||
break
|
||||
except Exception as e:
|
||||
context.status = WorkflowStatus.FAILED
|
||||
context.metadata["error"] = str(e)
|
||||
break
|
||||
|
||||
if context.status != WorkflowStatus.FAILED:
|
||||
context.status = WorkflowStatus.COMPLETED
|
||||
|
||||
return context
|
||||
|
||||
def enable_node(self, node_name: str, enabled: bool = True):
|
||||
"""启用或禁用特定节点"""
|
||||
for node in self.nodes:
|
||||
if node.name == node_name:
|
||||
node.enabled = enabled
|
||||
return True
|
||||
return False
|
||||
|
||||
# 以下是节点处理函数声明(仅声明,不实现)
|
||||
def _input_handler(self, context: WorkflowContext, config: Dict[str, Any]) -> WorkflowContext:
|
||||
"""输入节点处理函数"""
|
||||
pass
|
||||
|
||||
def _preprocessing_handler(self, context: WorkflowContext, config: Dict[str, Any]) -> WorkflowContext:
|
||||
"""输入预处理节点处理函数"""
|
||||
pass
|
||||
|
||||
def _rag_handler(self, context: WorkflowContext, config: Dict[str, Any]) -> WorkflowContext:
|
||||
"""RAG处理节点处理函数"""
|
||||
pass
|
||||
|
||||
def _prompt_assembly_handler(self, context: WorkflowContext, config: Dict[str, Any]) -> WorkflowContext:
|
||||
"""提示词组装节点处理函数"""
|
||||
pass
|
||||
|
||||
def _llm_request_handler(self, context: WorkflowContext, config: Dict[str, Any]) -> WorkflowContext:
|
||||
"""LLM请求节点处理函数"""
|
||||
pass
|
||||
|
||||
def _image_generation_handler(self, context: WorkflowContext, config: Dict[str, Any]) -> WorkflowContext:
|
||||
"""图像生成节点处理函数"""
|
||||
pass
|
||||
|
||||
def _dynamic_table_handler(self, context: WorkflowContext, config: Dict[str, Any]) -> WorkflowContext:
|
||||
"""动态表格更新节点处理函数"""
|
||||
pass
|
||||
|
||||
def _output_filter_handler(self, context: WorkflowContext, config: Dict[str, Any]) -> WorkflowContext:
|
||||
"""输出过滤节点处理函数"""
|
||||
pass
|
||||
|
||||
def _output_handler(self, context: WorkflowContext, config: Dict[str, Any]) -> WorkflowContext:
|
||||
"""输出节点处理函数"""
|
||||
pass
|
||||
2932
data/A.U.T.O.预设 v2.0 (1) (1).json
Normal file
2932
data/A.U.T.O.预设 v2.0 (1) (1).json
Normal file
File diff suppressed because one or more lines are too long
6
data/agent/fiction/books/我是汉使-谁敢不敬/guide.worldbook.json
Normal file
6
data/agent/fiction/books/我是汉使-谁敢不敬/guide.worldbook.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"persona": "主角表面温文尔雅、恪守使者礼节,实则杀伐果断、胸怀大格局。受辱时隐忍记账,关键节点一击致命。拥有超越时代的信息差与历史推演金手指,善于借势与布局,从单枪匹马到建立西域都护府权威。",
|
||||
"highlight": "1. 身份反差:所有人都当他是落魄流民,直到汉节与国书亮相,震惊全场。2. 文明降维打击:用冶铁、造纸、兵法碾压西域各方势力。3. 外交爽文:舌战群胡、以一人退一国之兵。4. 势力养成:收服三十六国,在异域复刻大汉盛世。",
|
||||
"experience": "第三人称有限视角跟随主角,初期通过旁观者的鄙夷积蓄压抑,中后期在亮身份、展实力时拉远镜头,放大旁观者的跪服与匈奴使者的恐惧,形成反复打脸爽感。每场外交冲突都按铺垫→加压→以汉威逆转的结构推进。",
|
||||
"forbiddenZones": "禁止主角长期忍气吞声无所作为、禁止汉使身份被长期误解不开封、禁止面对胡人欺辱时以德报怨;挫折控制在1章内,且必须立刻给出明确反击预期。"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user