16 Commits

Author SHA1 Message Date
5bfe7a733f Studio: fix edit/run UI and write worldbook on advance (R6)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 22:46:28 +08:00
63a32bfa7c feat(studio): 回退/重roll 与 R5 用户确认下一步
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 22:27:25 +08:00
f3792915a3 feat(studio): R3 step_respond、运行页 UX 与 R4 思考流式
新增 worldbook 步骤 LLM 回复(thinking/draft/questions/evaluation),
运行页聊天区与产物/选项联动;评价区标签改为「评价与修改建议」;
流式开关开启时 NDJSON 推送思考内容,完成后拆分更新各字段。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 22:07:42 +08:00
fa6907fb8d feat(studio): 新增 Studio 工作流编辑/运行页,优化顶部三栏对齐
- 后端:项目/运行 API、上下文服务与数据模型
- 前端:Studio 列表、编辑页(R1/R2 布局)、运行页与节点图
- 编辑页顶部:CSS Grid 统一标签行与控件行对齐,项目按钮独立第三行
- Docker 开发配置与文档脚本

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 21:24:57 +08:00
bc130d98f4 Add agent workflow engine foundation and theme-style page switcher.
Introduce WorkflowEngine with state machine, tool registry, and builtin chat template; migrate stream chat to engine callbacks. Move page mode switching to TopBar actions cluster as a ThemeToggle-style dropdown (聊天/工作室/爽文/房间).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 02:30:43 +08:00
d6745b45a5 基本完成,舒适性修补 2026-05-10 00:09:29 +08:00
9faccc2c03 正则相关 2026-05-06 00:40:18 +08:00
f843a74715 swipe、右键菜单 2026-05-05 21:51:34 +08:00
44df56c8d2 修复导入顺序错误 2026-05-05 19:08:25 +08:00
adb59da06d 添加测试、添加总结、全量、rag(todo)3种历史记录保存方式,流式实现 2026-05-05 03:01:20 +08:00
2050a30a52 完成请求推送,但组装mes还有问题 2026-05-04 00:33:29 +08:00
7fc9e10c99 完成大量美化,zustand迁移,动态表格修复 2026-05-02 02:08:53 +08:00
f0e7e75ffb 完成大量美化 2026-05-01 15:44:14 +08:00
6b65b24b0f 完成大量美化 2026-05-01 14:44:18 +08:00
1d0f0ae0ef feat: sync latest local code 2026-04-30 01:38:53 +08:00
ba9b925c32 完成世界书、骰子、apiconfig页面处理 2026-04-30 01:35:10 +08:00
4731 changed files with 808797 additions and 142 deletions

5
.env
View File

@@ -9,8 +9,3 @@ REGEX_FILE=/data/regex_rules.json
COMFYUI_API_URL=http://comfyui:8188
BACKEND_PORT=8000
FRONTEND_PORT=8501
# 先配置 .env 文件
MAIN_LLM_API_KEY=sk-Oh4o3fzV6Qe59B6DRwSskE48xe5D6bq1hkgDZqH1mmJOCN8j
MAIN_LLM_BASE_URL=https://api.chatfire.cn/v1
MAIN_LLM_MODEL=glm4.7

23
.env.example Normal file
View 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

Binary file not shown.

466
README.md Normal file
View 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 OutputAI 输出)
- `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

View File

@@ -12,7 +12,10 @@ 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"
# 复制所有代码
COPY . .

View File

@@ -1,16 +1,27 @@
from fastapi import APIRouter
from .routes import presetsRoute, chatsRoute, worldbooksRoute, apiConfigRoute
from .routes import presetsRoute, chatsRoute, worldbooksRoute, apiConfigRoute, charactersRoute, chatWsRoute, tokenUsageRoute, imageGalleryRoute, regexRoute, chatSummaryRoute, studioRoute
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)
# ✅ 注册 WebSocket 路由(必须在 HTTP 路由之后,避免路径冲突)
router.include_router(chatWsRoute.router)
# 保留原有的其他路由

View 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 是 categoryvalue 是配置
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)}"
}

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

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

View 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()
# 不抛出异常,避免影响主流程

View File

@@ -1,56 +1,178 @@
from fastapi import APIRouter, HTTPException, status
# TODO: 实现 ChatService 来替代旧的 ChatHistory 逻辑
# from services.chat_service import ChatService
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 ChatService.list_all_chats()
return {"chats": []}
return chat_service.list_all_chats()
# 注意:路由定义顺序很重要!更具体的路由(更多参数)必须放在前面
@router.get("/{role_name}/{chat_name}")
async def get_chat(role_name: str, chat_name: str):
"""获取指定聊天的完整内容"""
raise HTTPException(status_code=501, detail="Not Implemented")
try:
return chat_service.get_chat(role_name, chat_name)
except FileNotFoundError as e:
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):
"""创建新聊天"""
raise HTTPException(status_code=501, detail="Not Implemented")
try:
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):
"""更新聊天元数据"""
# 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):
"""删除指定聊天"""
# 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):
"""获取聊天的所有消息"""
raise HTTPException(status_code=501, detail="Not Implemented")
try:
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):
"""获取指定楼层的消息"""
raise HTTPException(status_code=501, detail="Not Implemented")
try:
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):
"""向聊天添加新消息"""
raise HTTPException(status_code=501, detail="Not Implemented")
try:
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))
@router.put("/{role_name}/{chat_name}/messages/{floor}")
async def update_message(role_name: str, chat_name: str, floor: int, update_data: dict):
"""更新指定楼层的消息"""
raise HTTPException(status_code=501, detail="Not Implemented")
try:
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):
"""删除指定楼层的消息"""
raise HTTPException(status_code=501, detail="Not Implemented")
try:
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)}")

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

View File

@@ -1,37 +1,26 @@
from fastapi import APIRouter, HTTPException, status
# TODO: 实现 PresetService 来替代旧的 AIDesignSpec 逻辑
# from services.preset_service import PresetService
from services.preset_service import PresetService
router = APIRouter(prefix="/presets", tags=["presets"])
@router.get("", response_model=dict)
async def list_presets():
"""获取所有预设列表及其基本信息"""
# return await PresetService.list_all_presets()
return {"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}")
async def get_preset(preset_name: str):
"""获取指定预设的完整内容"""
# try:
# return await PresetService.get_preset(preset_name)
# except FileNotFoundError:
# raise HTTPException(status_code=404, detail="Preset not found")
raise HTTPException(status_code=501, detail="Not Implemented")
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_preset(preset_name: str, preset_data: dict):
"""创建新预设"""
raise HTTPException(status_code=501, detail="Not Implemented")
@router.put("/{preset_name}")
async def update_preset(preset_name: str, update_data: dict):
"""更新预设配置"""
raise HTTPException(status_code=501, detail="Not Implemented")
@router.delete("/{preset_name}")
async def delete_preset(preset_name: str):
"""删除指定预设"""
# 注意:路由定义顺序很重要!更具体的路由(更多参数)必须放在前面
@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")
@@ -39,10 +28,78 @@ async def list_preset_components(preset_name: str):
"""获取预设中的所有组件"""
raise HTTPException(status_code=501, detail="Not Implemented")
@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}")
async def get_preset(preset_name: str):
"""获取指定预设的完整内容"""
try:
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_data: dict):
"""创建新预设"""
try:
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:
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:
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")
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):
@@ -58,3 +115,18 @@ async def update_preset_component(preset_name: str, component_id: str, update_da
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:
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))

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

View File

@@ -0,0 +1,354 @@
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,
StudioProject,
StudioProjectSummary,
StudioRun,
StudioRunSummary,
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
)
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}/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))

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

View File

@@ -1,5 +1,6 @@
3# 标准库导入
# 标准库导入
import os
import json
import shutil
import logging
from pathlib import Path
@@ -12,6 +13,7 @@ from fastapi.responses import JSONResponse, FileResponse
# 本地模块导入
from models.internal import WorldInfo, WorldInfoEntry
from core.config import settings
from services.worldbook_service import worldbook_service
# 配置日志
logger = logging.getLogger(__name__)
@@ -30,88 +32,258 @@ async def list_worldbooks():
Returns:
List[Dict[str, Any]]: 世界书列表
"""
# TODO: 实现 WorldBookService
return []
try:
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))
# 注意:路由定义顺序很重要!更具体的路由(更多参数)必须放在前面
@router.get("/{name}/entries/{uid}", response_model=Dict[str, Any])
async def get_worldbook_entry(name: str, uid: str):
"""
获取世界书的指定条目
"""
try:
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"Failed to get entry '{uid}' from 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: 世界书名称
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"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):
"""
获取指定名称的世界书
"""
raise HTTPException(status_code=501, detail="Not Implemented")
try:
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"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)
):
"""
创建新世界书
创建新世界书(可选择导入文件)
"""
raise HTTPException(status_code=501, detail="Not Implemented")
try:
# 如果提供了文件,从 SillyTavern 格式导入
if file:
content = await file.read()
st_data = json.loads(content.decode('utf-8'))
return worldbook_service.import_from_sillytavern(name, st_data)
else:
# 创建空世界书
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"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)
):
"""
更新世界书
更新世界书基本信息
"""
raise HTTPException(status_code=501, detail="Not Implemented")
try:
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"Failed to update worldbook '{name}': {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/{name}")
async def delete_worldbook(name: str):
"""
删除世界书
"""
raise HTTPException(status_code=501, detail="Not Implemented")
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=List[Dict[str, Any]])
async def list_worldbook_entries(name: str):
@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
"""
raise HTTPException(status_code=501, detail="Not Implemented")
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"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):
"""
获取世界书的指定条目
"""
raise HTTPException(status_code=501, detail="Not Implemented")
try:
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"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]):
"""
在世界书中创建新条目
"""
raise HTTPException(status_code=501, detail="Not Implemented")
try:
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.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]):
"""
更新世界书的指定条目
"""
raise HTTPException(status_code=501, detail="Not Implemented")
try:
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.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):
"""
删除世界书的指定条目
"""
raise HTTPException(status_code=501, detail="Not Implemented")
try:
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"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(...)):
"""
从文件导入世界书
从文件导入世界书(自动检测 SillyTavern 或内部格式)
"""
raise HTTPException(status_code=501, detail="Not Implemented")
try:
content = await file.read()
data = json.loads(content.decode('utf-8'))
@router.get("/{name}/export")
async def export_worldbook(name: str):
"""
导出世界书为 SillyTavern 格式
"""
raise HTTPException(status_code=501, detail="Not Implemented")
# 智能检测格式
from models.converters import WorldBookConverter
format_type = WorldBookConverter.detect_format(data)
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:
raise HTTPException(status_code=400, detail="无法识别的世界书格式")
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"Failed to import worldbook '{name}': {str(e)}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -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,33 @@ 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"
def ensure_directories(self):
"""确保所有配置的目录存在,如果不存在则创建"""
directories = [
@@ -60,10 +78,21 @@ 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,
]
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()

View File

@@ -17,12 +17,22 @@ for logger_name in ['uvicorn', 'uvicorn.access', 'fastapi']:
# backend/app/main.py
from fastapi import FastAPI
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")

231
backend/models/README.md Normal file
View 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 模型**
- **通过转换器进行格式转换**

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

View 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

439
backend/models/internal.py Normal file
View 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="创建时间戳")

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

View File

@@ -0,0 +1,249 @@
"""
Studio workflow editor data models.
"""
from __future__ import annotations
from enum import Enum
from typing import Any, Dict, List, 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)
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 = ""

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

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

View File

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

View File

@@ -0,0 +1,9 @@
"""
业务服务层
包含项目的核心业务逻辑,协调 Models、Utils 和 LLM 组件。
"""
# 注意:不在这里自动导入模块,避免循环依赖和缺失依赖问题
# 需要使用时请显式导入例如from services.preset_service import PresetService
__all__ = []

View 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

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

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

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

File diff suppressed because it is too large Load Diff

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

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

View 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✅ 所有测试通过!")

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

View 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

View 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

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

View 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✅ 脚本管理测试完成!")

View 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

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

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

View File

@@ -0,0 +1,861 @@
"""
Create and load Studio pipeline runs with frozen pipeline snapshots.
"""
from __future__ import annotations
import copy
import json
import logging
import shutil
import uuid
from datetime import datetime
from pathlib import Path
from typing import Any, AsyncGenerator, Dict, List, Optional
from core.config import settings
from models.studio_models import (
PipelineDefinition,
StudioNode,
StudioNodeRunState,
StudioRun,
StudioRunStatus,
StudioRunSummary,
TurnSnapshot,
)
from services.character_service import CharacterService
from services.studio_context_service import assemble_prompt_blocks, store_context_on_run
from services.studio_project_service import studio_project_service
from services.studio_step_respond import (
resolve_api_config,
studio_step_respond,
studio_step_respond_stream,
)
from models.converters import WorldBookConverter
from services.worldbook_service import worldbook_service
logger = logging.getLogger(__name__)
def _read_json(path: Path) -> dict:
with path.open("r", encoding="utf-8") as f:
return json.load(f)
def _write_json(path: Path, data: dict) -> 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 _build_node_states(pipeline: PipelineDefinition) -> tuple[list[StudioNodeRunState], Optional[str]]:
"""First enabled node is active; other enabled nodes pending; disabled skipped."""
states: list[StudioNodeRunState] = []
first_active_id: Optional[str] = None
seen_active = False
for node in pipeline.nodes:
if not node.enabled:
status = "skipped"
elif not seen_active:
status = "active"
first_active_id = node.id
seen_active = True
else:
status = "pending"
states.append(
StudioNodeRunState(
nodeId=node.id,
displayName=node.displayName,
skillId=node.skillId,
status=status,
loopUntilSatisfied=node.loopUntilSatisfied,
)
)
return states, first_active_id
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 _next_enabled_node_id(pipeline: PipelineDefinition, after_node_id: str) -> Optional[str]:
seen = False
for node in pipeline.nodes:
if node.id == after_node_id:
seen = True
continue
if seen and node.enabled:
return node.id
return None
def _snapshot_node_state(state: StudioNodeRunState) -> TurnSnapshot:
return TurnSnapshot(
lastDraft=copy.deepcopy(state.lastDraft) if state.lastDraft else None,
lastToolResponse=(
state.lastToolResponse.model_copy()
if state.lastToolResponse
else None
),
stepMessages=[m.model_copy() for m in (state.stepMessages or [])],
timestamp=datetime.now().isoformat(),
)
def _apply_snapshot(
state: StudioNodeRunState, snapshot: TurnSnapshot
) -> StudioNodeRunState:
updated = state.model_copy()
updated.lastDraft = (
copy.deepcopy(snapshot.lastDraft) if snapshot.lastDraft else None
)
updated.lastToolResponse = (
snapshot.lastToolResponse.model_copy()
if snapshot.lastToolResponse
else None
)
updated.stepMessages = [
m.model_copy() for m in (snapshot.stepMessages or [])
]
return updated
def _find_last_user_message_index(step_messages: list) -> int:
for i in range(len(step_messages) - 1, -1, -1):
if step_messages[i].role == "user":
return i
return -1
class StudioRunService:
def __init__(self) -> None:
self._character_service = CharacterService()
@property
def runs_root(self) -> Path:
return settings.AGENT_STUDIO_RUNS_PATH
def _project_runs_dir(self, project_id: str) -> Path:
return self.runs_root / project_id
def _run_dir(self, project_id: str, run_id: str) -> Path:
return self._project_runs_dir(project_id) / run_id
def _run_path(self, project_id: str, run_id: str) -> Path:
return self._run_dir(project_id, run_id) / "run.json"
def _save_run(self, project_id: str, run_id: str, run: StudioRun) -> None:
_write_json(
self._run_path(project_id, run_id),
run.model_dump(mode="json"),
)
def create_run(self, project_id: str) -> StudioRun:
project = studio_project_service.get_project(project_id)
snapshot = PipelineDefinition(**copy.deepcopy(project.pipeline.model_dump()))
now = datetime.now().isoformat()
run_id = str(uuid.uuid4())
node_states, current_node_id = _build_node_states(snapshot)
has_active = current_node_id is not None
run = StudioRun(
id=run_id,
projectId=project_id,
status=StudioRunStatus.RUNNING if has_active else StudioRunStatus.PENDING,
pipelineSnapshot=snapshot,
pipelineVersionNote=now,
currentNodeId=current_node_id,
nodeStates=node_states,
workflowVariables={"workflow.goal": snapshot.workflowGoal},
createdAt=now,
updatedAt=now,
)
self._save_run(project_id, run_id, run)
return run
def list_runs(self, project_id: str) -> List[StudioRunSummary]:
root = self._project_runs_dir(project_id)
if not root.exists():
return []
summaries: List[StudioRunSummary] = []
for child in sorted(root.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True):
if not child.is_dir():
continue
run_path = child / "run.json"
if not run_path.exists():
continue
raw = _read_json(run_path)
summaries.append(
StudioRunSummary(
id=raw.get("id", child.name),
projectId=raw.get("projectId", project_id),
status=StudioRunStatus(raw.get("status", StudioRunStatus.PENDING.value)),
currentNodeId=raw.get("currentNodeId"),
title=raw.get("title", ""),
createdAt=raw.get("createdAt", ""),
updatedAt=raw.get("updatedAt", ""),
)
)
return summaries
def get_run(self, project_id: str, run_id: str) -> StudioRun:
run_path = self._run_path(project_id, run_id)
if not run_path.exists():
raise FileNotFoundError(f"Studio run not found: {project_id}/{run_id}")
run = StudioRun(**_read_json(run_path))
return run
def _validate_display_params(
self, node: StudioNode, display_params: Dict[str, str]
) -> Dict[str, str]:
normalized: Dict[str, str] = {}
for dp in node.displayParams:
raw = display_params.get(dp.key, "")
value = (raw or "").strip()
if dp.required and not value:
raise ValueError(f"缺少必填项:{dp.label}")
normalized[dp.key] = value
return normalized
def _execute_init_bind(
self,
project_id: str,
run: StudioRun,
node: StudioNode,
display_params: Dict[str, str],
) -> tuple[Dict[str, Any], Dict[str, Any]]:
params = self._validate_display_params(node, display_params)
character_name = params.get("characterName", "")
worldbook_name = params.get("worldbookName", "")
if self._character_service.get_character_by_name(character_name):
raise ValueError(f"角色「{character_name}」已存在")
if worldbook_service._get_worldbook_path(worldbook_name).exists():
raise ValueError(f"世界书「{worldbook_name}」已存在")
worldbook = worldbook_service.create_worldbook(worldbook_name)
worldbook_id = worldbook["id"]
character = self._character_service.create_character(
{
"name": character_name,
"description": "",
"personality": "",
"scenario": "",
"first_mes": "",
"mes_example": "",
"categories": [],
"tags": [],
"worldInfoId": worldbook_id,
}
)
character_id = character.id
studio_project_service.update_project_bindings(
project_id, character_id, worldbook_id
)
workflow_variables = dict(run.workflowVariables or {})
workflow_variables["workflow.goal"] = run.pipelineSnapshot.workflowGoal
workflow_variables["workflow.boundCharacter"] = (
f"名称:{character_name}\nID{character_id}"
)
workflow_variables["workflow.boundWorldbook"] = (
f"名称:{worldbook_name}\nID{worldbook_id}"
)
last_draft = {
"displayParams": params,
"characterId": character_id,
"worldbookId": worldbook_id,
"characterName": character_name,
"worldbookName": worldbook_name,
}
return workflow_variables, last_draft
@staticmethod
def _parse_keyword_field(raw: Any) -> List[str]:
if raw is None:
return []
if isinstance(raw, list):
return [str(x).strip() for x in raw if str(x).strip()]
text = str(raw).strip()
if not text:
return []
return [part.strip() for part in text.replace("", ",").split(",") if part.strip()]
def _resolve_bound_worldbook_name(
self, project_id: str, run: StudioRun
) -> str:
for state in run.nodeStates:
if state.skillId != "studio.init_bind" or state.status != "completed":
continue
draft = state.lastDraft or {}
name = (draft.get("worldbookName") or "").strip()
if name:
return name
try:
project = studio_project_service.get_project(project_id)
worldbook_id = project.meta.worldbookId
except FileNotFoundError:
worldbook_id = None
if worldbook_id:
for summary in worldbook_service.list_worldbooks():
wb_name = summary.get("name")
if not wb_name:
continue
try:
data = worldbook_service.get_worldbook(wb_name)
except FileNotFoundError:
continue
if data.get("id") == worldbook_id:
return wb_name
raise ValueError("未找到绑定的世界书,请先完成「创建并绑定」步骤")
def _build_worldbook_entry_payload(
self, node: StudioNode, draft: Dict[str, Any]
) -> Dict[str, Any]:
insertion = (node.config or {}).get("insertion") or {}
content = (draft.get("entryContent") or "").strip()
if not content:
raise ValueError("当前步骤产物为空,请先生成世界书条目内容后再进入下一步")
activation = (insertion.get("activationType") or "permanent").strip()
position = insertion.get("position", 0)
if isinstance(position, str):
position = WorldBookConverter.POSITION_MAP_ST_TO_INTERNAL.get(position, 0)
key = self._parse_keyword_field(
draft.get("insertionKey") or insertion.get("key")
)
keysecondary = self._parse_keyword_field(insertion.get("keysecondary"))
comment = (
(draft.get("insertionComment") or insertion.get("comment") or "").strip()
or f"Studio · {node.displayName}"
)
payload: Dict[str, Any] = {
"content": content,
"comment": comment,
"activationType": activation,
"position": position,
"key": key,
"keysecondary": keysecondary,
"order": insertion.get("order", 100),
"depth": insertion.get("depth", 4),
"probability": insertion.get("probability", 100),
"group": insertion.get("group") or [],
"disable": bool(insertion.get("disable", False)),
}
if activation == "rag" and insertion.get("ragConfig"):
payload["ragConfig"] = insertion["ragConfig"]
return payload
def _write_worldbook_entry_on_advance(
self,
worldbook_name: str,
node: StudioNode,
draft: Dict[str, Any],
) -> Dict[str, Any]:
entry_payload = self._build_worldbook_entry_payload(node, draft)
try:
return worldbook_service.append_entry(worldbook_name, entry_payload)
except FileNotFoundError:
raise ValueError(f"世界书「{worldbook_name}」不存在,无法写入条目")
except ValueError as exc:
raise ValueError(f"写入世界书失败:{exc}") from exc
except OSError as exc:
raise ValueError(f"写入世界书失败:{exc}") from exc
def advance_run(
self,
project_id: str,
run_id: str,
display_params: Optional[Dict[str, str]] = None,
) -> StudioRun:
run = self.get_run(project_id, run_id)
if run.status not in (StudioRunStatus.RUNNING, StudioRunStatus.PENDING):
raise ValueError("运行已结束,无法继续推进")
current_node_id = run.currentNodeId
if not current_node_id:
raise ValueError("当前运行无活动节点")
current_node = _find_node(run.pipelineSnapshot, current_node_id)
if not current_node:
raise ValueError(f"节点不存在:{current_node_id}")
current_state = next(
(s for s in run.nodeStates if s.nodeId == current_node_id), None
)
if not current_state or current_state.status != "active":
raise ValueError("当前节点不可执行")
workflow_variables = dict(run.workflowVariables or {})
last_draft: Optional[Dict[str, Any]] = None
if current_node.skillId == "studio.init_bind":
if not display_params:
raise ValueError("请填写引导表单后再提交")
workflow_variables, last_draft = self._execute_init_bind(
project_id, run, current_node, display_params
)
elif current_node.skillId == "studio.worldbook_entry":
if not current_state.lastDraft:
raise ValueError("请先生成并确认当前产物后再进入下一步")
worldbook_name = self._resolve_bound_worldbook_name(project_id, run)
written_entry = self._write_worldbook_entry_on_advance(
worldbook_name,
current_node,
current_state.lastDraft,
)
last_draft = copy.deepcopy(current_state.lastDraft)
last_draft["writtenEntryUid"] = written_entry.get("uid")
last_draft["writtenWorldbookName"] = worldbook_name
else:
raise NotImplementedError(
f"技能「{current_node.skillId}」的执行尚未实现R2+"
)
next_node_id = _next_enabled_node_id(run.pipelineSnapshot, current_node_id)
now = datetime.now().isoformat()
new_node_states: list[StudioNodeRunState] = []
for state in run.nodeStates:
updated = state.model_copy()
if state.nodeId == current_node_id:
updated.status = "completed"
if last_draft is not None:
updated.lastDraft = last_draft
elif next_node_id and state.nodeId == next_node_id:
updated.status = "active"
new_node_states.append(updated)
new_status = (
StudioRunStatus.COMPLETED if not next_node_id else StudioRunStatus.RUNNING
)
updated_run = run.model_copy(
update={
"currentNodeId": next_node_id,
"nodeStates": new_node_states,
"workflowVariables": workflow_variables,
"status": new_status,
"updatedAt": now,
}
)
updated_run = store_context_on_run(updated_run, next_node_id)
self._save_run(project_id, run_id, updated_run)
return updated_run
async def send_run_message(
self,
project_id: str,
run_id: str,
content: str,
*,
stream: bool = False,
profile_id: Optional[str] = None,
api_config: Optional[Dict[str, str]] = None,
) -> StudioRun:
"""Send a user message to the active worldbook step and invoke LLM (R3)."""
trimmed = (content or "").strip()
if not trimmed:
raise ValueError("消息内容不能为空")
run, current_node, current_state, current_node_id = self._prepare_run_message(
project_id, run_id, trimmed
)
resolved_api = resolve_api_config(profile_id, api_config)
prompt_blocks = assemble_prompt_blocks(run, current_node_id)
step_messages = list(current_state.stepMessages or [])
last_draft, last_tool_response, user_msg, assistant_msg = (
await studio_step_respond(
node=current_node,
prompt_blocks=prompt_blocks,
step_messages=step_messages,
user_message=trimmed,
existing_draft=current_state.lastDraft,
api_config=resolved_api,
stream=stream,
)
)
return self._persist_run_message_turn(
project_id,
run_id,
run,
current_node_id,
prompt_blocks,
step_messages,
last_draft,
last_tool_response,
user_msg,
assistant_msg,
)
async def send_run_message_stream(
self,
project_id: str,
run_id: str,
content: str,
*,
profile_id: Optional[str] = None,
api_config: Optional[Dict[str, str]] = None,
) -> AsyncGenerator[Dict[str, Any], None]:
"""Stream thinking deltas, then persist and emit complete run (R4)."""
trimmed = (content or "").strip()
if not trimmed:
raise ValueError("消息内容不能为空")
run, current_node, current_state, current_node_id = self._prepare_run_message(
project_id, run_id, trimmed
)
resolved_api = resolve_api_config(profile_id, api_config)
prompt_blocks = assemble_prompt_blocks(run, current_node_id)
step_messages = list(current_state.stepMessages or [])
async for event in studio_step_respond_stream(
node=current_node,
prompt_blocks=prompt_blocks,
step_messages=step_messages,
user_message=trimmed,
existing_draft=current_state.lastDraft,
api_config=resolved_api,
):
if event.get("type") == "thinking_delta":
yield event
continue
if event.get("type") == "complete":
from models.studio_models import LastToolResponse, StepMessage
last_draft = event["last_draft"]
last_tool_response = LastToolResponse(**event["last_tool_response"])
user_msg = StepMessage(**event["user_msg"])
assistant_msg = StepMessage(**event["assistant_msg"])
updated_run = self._persist_run_message_turn(
project_id,
run_id,
run,
current_node_id,
prompt_blocks,
step_messages,
last_draft,
last_tool_response,
user_msg,
assistant_msg,
)
yield {
"type": "complete",
"run": updated_run.model_dump(mode="json"),
}
def _prepare_active_worldbook_step(
self,
project_id: str,
run_id: str,
) -> tuple[StudioRun, StudioNode, StudioNodeRunState, str]:
run = self.get_run(project_id, run_id)
if run.status != StudioRunStatus.RUNNING:
raise ValueError("运行未处于进行中,无法操作")
current_node_id = run.currentNodeId
if not current_node_id:
raise ValueError("当前运行无活动节点")
current_node = _find_node(run.pipelineSnapshot, current_node_id)
if not current_node:
raise ValueError(f"节点不存在:{current_node_id}")
if current_node.skillId != "studio.worldbook_entry":
raise ValueError(
f"当前步骤「{current_node.displayName}」不支持对话消息"
)
current_state = next(
(s for s in run.nodeStates if s.nodeId == current_node_id), None
)
if not current_state or current_state.status != "active":
raise ValueError("当前节点不可执行")
return run, current_node, current_state, current_node_id
def _prepare_run_message(
self,
project_id: str,
run_id: str,
trimmed: str,
) -> tuple[StudioRun, StudioNode, StudioNodeRunState, str]:
if not trimmed:
raise ValueError("消息内容不能为空")
return self._prepare_active_worldbook_step(project_id, run_id)
def _persist_run_message_turn(
self,
project_id: str,
run_id: str,
run: StudioRun,
current_node_id: str,
prompt_blocks: list,
step_messages: list,
last_draft: Dict[str, Any],
last_tool_response,
user_msg,
assistant_msg,
*,
push_snapshot: bool = True,
) -> StudioRun:
step_messages = list(step_messages)
step_messages.extend([user_msg, assistant_msg])
now = datetime.now().isoformat()
new_node_states: list[StudioNodeRunState] = []
for state in run.nodeStates:
updated = state.model_copy()
if state.nodeId == current_node_id:
if push_snapshot:
turn_history = list(state.turnHistory or [])
turn_history.append(_snapshot_node_state(state))
updated.turnHistory = turn_history
updated.lastDraft = last_draft
updated.lastToolResponse = last_tool_response
updated.stepMessages = step_messages
new_node_states.append(updated)
updated_run = run.model_copy(
update={
"nodeStates": new_node_states,
"lastPromptBlocks": prompt_blocks,
"updatedAt": now,
}
)
updated_run = store_context_on_run(updated_run, current_node_id)
self._save_run(project_id, run_id, updated_run)
return updated_run
def undo_run(self, project_id: str, run_id: str) -> StudioRun:
"""Restore the active step to the state before the last LLM turn."""
run, _, current_state, current_node_id = self._prepare_active_worldbook_step(
project_id, run_id
)
turn_history = list(current_state.turnHistory or [])
if not turn_history:
raise ValueError("无可回退的回合")
snapshot = turn_history.pop()
restored_state = _apply_snapshot(current_state, snapshot)
restored_state.turnHistory = turn_history
now = datetime.now().isoformat()
new_node_states: list[StudioNodeRunState] = []
for state in run.nodeStates:
if state.nodeId == current_node_id:
new_node_states.append(restored_state)
else:
new_node_states.append(state.model_copy())
updated_run = run.model_copy(
update={
"nodeStates": new_node_states,
"updatedAt": now,
}
)
updated_run = store_context_on_run(updated_run, current_node_id)
self._save_run(project_id, run_id, updated_run)
return updated_run
async def reroll_run(
self,
project_id: str,
run_id: str,
*,
stream: bool = False,
profile_id: Optional[str] = None,
api_config: Optional[Dict[str, str]] = None,
) -> StudioRun:
"""Re-run the last user turn with identical inputs (no new user message)."""
run, current_node, current_state, current_node_id = (
self._prepare_active_worldbook_step(project_id, run_id)
)
step_messages_all = list(current_state.stepMessages or [])
last_user_idx = _find_last_user_message_index(step_messages_all)
if last_user_idx < 0:
raise ValueError("当前步骤尚无用户消息,无法重 roll")
user_content = step_messages_all[last_user_idx].content
step_messages = step_messages_all[:last_user_idx]
pre_turn_draft = current_state.lastDraft
if current_state.turnHistory:
pre_turn_draft = current_state.turnHistory[-1].lastDraft
resolved_api = resolve_api_config(profile_id, api_config)
prompt_blocks = assemble_prompt_blocks(run, current_node_id)
last_draft, last_tool_response, user_msg, assistant_msg = (
await studio_step_respond(
node=current_node,
prompt_blocks=prompt_blocks,
step_messages=step_messages,
user_message=user_content,
existing_draft=pre_turn_draft,
api_config=resolved_api,
stream=stream,
)
)
return self._persist_reroll_turn(
project_id,
run_id,
run,
current_node_id,
current_state,
prompt_blocks,
step_messages,
last_draft,
last_tool_response,
user_msg,
assistant_msg,
)
async def reroll_run_stream(
self,
project_id: str,
run_id: str,
*,
profile_id: Optional[str] = None,
api_config: Optional[Dict[str, str]] = None,
) -> AsyncGenerator[Dict[str, Any], None]:
"""Stream thinking during reroll, then persist replaced turn."""
run, current_node, current_state, current_node_id = (
self._prepare_active_worldbook_step(project_id, run_id)
)
step_messages_all = list(current_state.stepMessages or [])
last_user_idx = _find_last_user_message_index(step_messages_all)
if last_user_idx < 0:
raise ValueError("当前步骤尚无用户消息,无法重 roll")
user_content = step_messages_all[last_user_idx].content
step_messages = step_messages_all[:last_user_idx]
pre_turn_draft = current_state.lastDraft
if current_state.turnHistory:
pre_turn_draft = current_state.turnHistory[-1].lastDraft
resolved_api = resolve_api_config(profile_id, api_config)
prompt_blocks = assemble_prompt_blocks(run, current_node_id)
async for event in studio_step_respond_stream(
node=current_node,
prompt_blocks=prompt_blocks,
step_messages=step_messages,
user_message=user_content,
existing_draft=pre_turn_draft,
api_config=resolved_api,
):
if event.get("type") == "thinking_delta":
yield event
continue
if event.get("type") == "complete":
from models.studio_models import LastToolResponse, StepMessage
last_draft = event["last_draft"]
last_tool_response = LastToolResponse(**event["last_tool_response"])
user_msg = StepMessage(**event["user_msg"])
assistant_msg = StepMessage(**event["assistant_msg"])
updated_run = self._persist_reroll_turn(
project_id,
run_id,
run,
current_node_id,
current_state,
prompt_blocks,
step_messages,
last_draft,
last_tool_response,
user_msg,
assistant_msg,
)
yield {
"type": "complete",
"run": updated_run.model_dump(mode="json"),
}
def _persist_reroll_turn(
self,
project_id: str,
run_id: str,
run: StudioRun,
current_node_id: str,
current_state: StudioNodeRunState,
prompt_blocks: list,
step_messages: list,
last_draft: Dict[str, Any],
last_tool_response,
user_msg,
assistant_msg,
) -> StudioRun:
"""Replace the last turn; snapshot current state so reroll is undoable."""
step_messages = list(step_messages)
step_messages.extend([user_msg, assistant_msg])
now = datetime.now().isoformat()
new_node_states: list[StudioNodeRunState] = []
for state in run.nodeStates:
updated = state.model_copy()
if state.nodeId == current_node_id:
turn_history = list(current_state.turnHistory or [])
turn_history.append(_snapshot_node_state(current_state))
updated.turnHistory = turn_history
updated.lastDraft = last_draft
updated.lastToolResponse = last_tool_response
updated.stepMessages = step_messages
new_node_states.append(updated)
updated_run = run.model_copy(
update={
"nodeStates": new_node_states,
"lastPromptBlocks": prompt_blocks,
"updatedAt": now,
}
)
updated_run = store_context_on_run(updated_run, current_node_id)
self._save_run(project_id, run_id, updated_run)
return updated_run
def delete_run(self, project_id: str, run_id: str) -> None:
run_dir = self._run_dir(project_id, run_id)
if not run_dir.exists():
raise FileNotFoundError(f"Studio run not found: {project_id}/{run_id}")
shutil.rmtree(run_dir)
def rename_run(self, project_id: str, run_id: str, title: str) -> StudioRun:
run = self.get_run(project_id, run_id)
trimmed = (title or "").strip()
if not trimmed:
raise ValueError("运行名称不能为空")
now = datetime.now().isoformat()
updated = run.model_copy(update={"title": trimmed, "updatedAt": now})
self._save_run(project_id, run_id, updated)
return updated
studio_run_service = StudioRunService()

View 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 为 03 条;每条至少 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"),
}

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

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

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

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

View File

@@ -0,0 +1 @@
"""Workflow chat tools package."""

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

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

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

17
backend/utils/__init__.py Normal file
View 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
View 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
View 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

File diff suppressed because one or more lines are too long

16
data/agent/niches.json Normal file
View File

@@ -0,0 +1,16 @@
{
"niches": [
{
"id": "aesthetic_tone",
"label": "整体美学",
"description": "视觉、氛围、叙事基调等宏观美学设定",
"suggestedStepGoal": "描述角色的整体美学:色调、材质、氛围、叙事基调,供后续人设与世界书条目引用。"
},
{
"id": "persona_detail",
"label": "具体人设",
"description": "性格、口癖、关系、行为模式等可扮演细节",
"suggestedStepGoal": "在整体美学基础上,细化可扮演的人设:性格、动机、口癖、与他人关系。"
}
]
}

View File

@@ -0,0 +1,73 @@
{
"templates": [
{
"skillId": "studio.init_bind",
"displayName": "创建并绑定",
"description": "创建角色卡与世界书,并绑定到当前 Studio 项目",
"displayParams": [
{
"key": "characterName",
"label": "角色卡名称",
"type": "text",
"required": true,
"placeholder": "例如:帝国骑士维尔"
},
{
"key": "worldbookName",
"label": "世界书名称",
"type": "text",
"required": true,
"placeholder": "例如:维尔的世界观"
}
],
"configWhitelist": [],
"artifacts": [],
"supportsLoopUntilSatisfied": false,
"supportsInputs": false,
"supportsInsertion": false,
"supportsScoring": false
},
{
"skillId": "studio.worldbook_entry",
"displayName": "创作世界书条目",
"description": "根据 stepGoal 与上文引用,生成并写入世界书条目",
"displayParams": [],
"configWhitelist": [
"stepGoal",
"thinkingPrompt",
"insertion.position",
"insertion.activationType",
"insertion.key",
"insertion.keysecondary",
"insertion.ragConfig",
"insertion.comment",
"scoring"
],
"artifacts": [
{
"type": "worldbook.entries",
"displayName": "世界书条目"
}
],
"supportsLoopUntilSatisfied": true,
"supportsInputs": true,
"supportsInsertion": true,
"supportsScoring": true,
"configDefaults": {
"stepGoal": "",
"thinkingPrompt": "====== 思考流程 ======\nStep1: 简短确认任务性质(新设计/修改)\nStep2: 阅读绑定角色/世界书与上文引用\nStep3: 按步骤目标起草世界书条目\nStep4: 对照评价维度自检并优化表述\n\n核心目的、评价标准与优化建议由系统在运行时自动注入无需在此填写",
"insertion": {
"position": 1,
"activationType": "permanent",
"key": "",
"keysecondary": "",
"comment": ""
},
"scoring": {
"enabled": true,
"dimensions": []
}
}
}
]
}

View File

@@ -0,0 +1,10 @@
{
"id": "default",
"name": "单人类角色卡",
"description": "从创建绑定到世界书条目的默认三步流水线,可在工作流编辑页复制并修改。",
"templateId": "builtin.studio.example",
"characterId": "f04ba2d6-1ffd-4c33-9cfe-cf6fd026c175",
"worldbookId": "8682f790-1b9d-4842-826b-c07764be6b9b",
"createdAt": "2026-05-31T00:00:00",
"updatedAt": "2026-05-31T13:11:55.525055"
}

View File

@@ -0,0 +1,122 @@
{
"workflowGoal": "设计一个单人角色:先绑定角色卡与世界书,再迭代整体美学与具体人设,写入世界书条目。",
"nodes": [
{
"id": "init",
"skillId": "studio.init_bind",
"displayName": "创建并绑定",
"enabled": true,
"loopUntilSatisfied": false,
"config": {},
"displayParams": [
{
"key": "characterName",
"label": "角色卡名称",
"type": "text",
"required": true,
"placeholder": ""
},
{
"key": "worldbookName",
"label": "世界书名称",
"type": "text",
"required": true,
"placeholder": ""
}
],
"inputs": []
},
{
"id": "aesthetic",
"skillId": "studio.worldbook_entry",
"displayName": "整体美学",
"enabled": true,
"niche": "aesthetic_tone",
"loopUntilSatisfied": true,
"config": {
"stepGoal": "产出角色的整体美学设定:视觉风格、氛围、叙事基调,供后续人设步骤引用。",
"thinkingPrompt": "step1首先思考整个故事是怎么样的\nstep2然后思考如何展示\nstep3选中核心爽点",
"insertion": {
"position": 1,
"activationType": "permanent",
"key": "整体美学",
"comment": "Studio · 整体美学"
},
"scoring": {
"enabled": true,
"dimensions": [
{
"id": "authenticity",
"name": "真实性",
"criteria": "美学设定是否自洽、可感知,而非空泛形容词堆砌。"
},
{
"id": "fit",
"name": "贴合度",
"criteria": "是否覆盖色调/材质/氛围/叙事基调;是否可与后续人设衔接;表述是否简洁可注入世界书。"
}
]
}
},
"displayParams": [],
"inputs": [
{
"ref": "workflow.goal",
"label": "工作流目标文本",
"optional": false
}
]
},
{
"id": "persona",
"skillId": "studio.worldbook_entry",
"displayName": "具体人设",
"enabled": true,
"niche": "persona_detail",
"loopUntilSatisfied": true,
"config": {
"stepGoal": "在整体美学基础上,写出可扮演的人设:性格、动机、口癖、关系与行为模式。",
"thinkingPrompt": "",
"insertion": {
"position": 1,
"activationType": "keyword",
"key": "具体人设",
"comment": "Studio · 具体人设"
},
"scoring": {
"enabled": true,
"dimensions": [
{
"id": "authenticity",
"name": "真实性",
"criteria": "人设细节是否具体、可扮演,动机与行为是否一致。"
},
{
"id": "fit",
"name": "贴合度",
"criteria": "是否与人设目标一致;是否与整体美学一致;是否避免与已有条目冲突。"
}
]
}
},
"displayParams": [],
"inputs": [
{
"ref": "workflow.goal",
"label": "工作流目标文本",
"optional": false
},
{
"ref": "aesthetic.output",
"label": "整体美学 · 上轮产物",
"optional": false
},
{
"ref": "persona.output",
"label": "具体人设 · 上轮产物",
"optional": true
}
]
}
]
}

View File

@@ -0,0 +1,5 @@
# Chat Reply Skill
Minimal skill placeholder for the builtin.chat workflow template.
This skill orchestrates a single chat turn: load character, activate worldbooks, assemble prompt, call LLM, apply regex, record usage, and enqueue parallel tasks.

View File

@@ -0,0 +1,3 @@
id: chat_reply
name: Chat Reply
description: Minimal chat reply skill for builtin.chat template

View File

@@ -0,0 +1,41 @@
{
"initial": "regex_apply_user_input",
"states": {
"regex_apply_user_input": {
"tool": "regex_apply_user_input",
"next": "load_character"
},
"load_character": {
"tool": "load_character",
"next": "activate_worldbook"
},
"activate_worldbook": {
"tool": "activate_worldbook",
"next": "load_chat_history"
},
"load_chat_history": {
"tool": "load_chat_history",
"next": "build_prompt_messages"
},
"build_prompt_messages": {
"tool": "build_prompt_messages",
"next": "llm_main_reply"
},
"llm_main_reply": {
"tool": "llm_main_reply",
"next": "regex_apply_ai_output"
},
"regex_apply_ai_output": {
"tool": "regex_apply_ai_output",
"next": "record_token_usage"
},
"record_token_usage": {
"tool": "record_token_usage",
"next": "enqueue_parallel_tasks"
},
"enqueue_parallel_tasks": {
"tool": "enqueue_parallel_tasks",
"next": "end"
}
}
}

View File

@@ -0,0 +1,16 @@
{
"id": "builtin.chat",
"kind": "builtin.chat",
"name": "Builtin Chat Reply",
"description": "Default single-turn chat workflow migrated from ChatWorkflowService",
"version": "1.0.0",
"state_machine_path": "state_machine.json",
"skills": [
{
"id": "chat_reply",
"name": "Chat Reply",
"description": "Main chat reply skill",
"path": "skill/chat_reply"
}
]
}

View File

@@ -0,0 +1,6 @@
{
"id": "builtin.studio.example",
"name": "世界书条目创建",
"description": "从创建绑定到世界书条目的默认三步流水线,可在工作流编辑页复制并修改。",
"version": "1.0.0"
}

View File

@@ -0,0 +1,102 @@
{
"workflowGoal": "设计一个单人角色:先绑定角色卡与世界书,再迭代整体美学与具体人设,写入世界书条目。",
"nodes": [
{
"id": "init",
"skillId": "studio.init_bind",
"displayName": "创建并绑定",
"enabled": true,
"config": {},
"displayParams": [
{
"key": "characterName",
"label": "角色卡名称",
"type": "text",
"required": true,
"placeholder": ""
},
{
"key": "worldbookName",
"label": "世界书名称",
"type": "text",
"required": true,
"placeholder": ""
}
],
"inputs": []
},
{
"id": "aesthetic",
"skillId": "studio.worldbook_entry",
"displayName": "整体美学",
"enabled": true,
"loopUntilSatisfied": true,
"config": {
"stepGoal": "产出角色的整体美学设定:视觉风格、氛围、叙事基调,供后续人设步骤引用。",
"thinkingPrompt": "====== 思考流程 ======\nStep1: 简短确认任务性质(新设计/修改)\nStep2: 阅读绑定角色/世界书与上文引用\nStep3: 按步骤目标起草世界书条目\nStep4: 对照评价维度自检并优化表述\n\n核心目的、评价标准与优化建议由系统在运行时自动注入无需在此填写",
"insertion": {
"position": 1,
"activationType": "permanent",
"key": "整体美学",
"comment": "Studio · 整体美学"
},
"scoring": {
"enabled": true,
"dimensions": [
{
"id": "authenticity",
"name": "真实性",
"criteria": "美学设定是否自洽、可感知,而非空泛形容词堆砌。"
},
{
"id": "fit",
"name": "贴合度",
"criteria": "是否覆盖色调/材质/氛围/叙事基调;是否可与后续人设衔接;表述是否简洁可注入世界书。"
}
]
}
},
"displayParams": [],
"inputs": []
},
{
"id": "persona",
"skillId": "studio.worldbook_entry",
"displayName": "具体人设",
"enabled": true,
"loopUntilSatisfied": true,
"config": {
"stepGoal": "在整体美学基础上,写出可扮演的人设:性格、动机、口癖、关系与行为模式。",
"thinkingPrompt": "====== 思考流程 ======\nStep1: 简短确认任务性质(新设计/修改)\nStep2: 阅读绑定角色/世界书与上文引用\nStep3: 按步骤目标起草世界书条目\nStep4: 对照评价维度自检并优化表述\n\n核心目的、评价标准与优化建议由系统在运行时自动注入无需在此填写",
"insertion": {
"position": 1,
"activationType": "keyword",
"key": "具体人设",
"comment": "Studio · 具体人设"
},
"scoring": {
"enabled": true,
"dimensions": [
{
"id": "authenticity",
"name": "真实性",
"criteria": "人设细节是否具体、可扮演,动机与行为是否一致。"
},
{
"id": "fit",
"name": "贴合度",
"criteria": "是否与人设目标一致;是否与整体美学一致;是否避免与已有条目冲突。"
}
]
}
},
"displayParams": [],
"inputs": [
{
"ref": "aesthetic.output",
"label": "整体美学 · 世界书条目"
}
]
}
]
}

View File

@@ -0,0 +1,56 @@
{
"builtIn": [
{
"ref": "workflow.goal",
"label": "工作流目标文本",
"description": "当前 Studio 项目的 workflowGoal 全文(系统自动注入,不可手动选择)",
"autoInjected": true
},
{
"ref": "workflow.boundWorldbook",
"label": "绑定世界书摘要",
"description": "项目绑定的世界书 meta / 摘要(系统自动注入,不可手动选择)",
"autoInjected": true
},
{
"ref": "workflow.boundCharacter",
"label": "绑定角色卡摘要",
"description": "项目绑定的角色卡摘要(系统自动注入,不可手动选择)",
"autoInjected": true
}
],
"dynamicSuffixes": [
{
"suffix": ".output",
"labelPattern": "{displayName} · 世界书条目"
}
],
"autoInjectedContext": [
"目前产物",
"思考流程",
"核心目的",
"评价标准与优化建议"
],
"autoInjectedContextDefs": [
{
"id": "currentProduct",
"label": "目前产物",
"description": "当前步骤已生成的世界书条目草稿或最新版本,供模型在迭代修改时对照与延续。"
},
{
"id": "thinkingFlow",
"label": "思考流程",
"description": "本步骤配置的 thinkingPrompt引导模型按既定步骤推理与自检。"
},
{
"id": "coreGoal",
"label": "核心目的",
"description": "本步骤的 stepGoal步骤目标明确本步要产出的内容与边界。"
},
{
"id": "scoringCriteria",
"label": "评价标准与优化建议",
"description": "本步骤启用的 scoring 评价维度及准则,用于模型自检与优化表述。"
}
]
}

BIN
data/chat/default.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -1,5 +0,0 @@
{"user_name": "User", "character_name": "AI Dungeon Master", "integrity": "uuid-001", "chat_id_hash": "hash-001", "note_prompt": "你是一个经验丰富的D&D地下城主。", "note_interval": 0, "note_position": 0, "note_depth": 0, "note_role": 0, "extensions": {}, "timedWorldInfo": {}, "variables": {}, "tainted": false, "lastInContextMessageId": -1}
{"name": "User", "is_user": true, "is_system": false, "floor": 0, "send_date": "1700000000000", "mes": "你好,我想开始一个新的冒险。", "extra": {}, "swipes": [], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
{"name": "AI Dungeon Master", "is_user": false, "is_system": false, "floor": 1, "send_date": "1700000001000", "mes": "欢迎,冒险者。请告诉我你想扮演什么角色?", "extra": {"api": "openai", "model": "gpt-4"}, "swipes": ["欢迎,冒险者。请告诉我你想扮演什么角色?", "你好,旅行者。在这个奇幻世界中,你是谁?"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": [], "api": "openai", "model": "gpt-4", "reasoning": null, "reasoning_duration": null, "reasoning_signature": null, "time_to_first_token": null, "bias": null}
{"name": "User", "is_user": true, "is_system": false, "floor": 2, "send_date": "1700000002000", "mes": "我想成为一名人类战士。", "extra": {}, "swipes": [], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
{"name": "AI Dungeon Master", "is_user": false, "is_system": false, "floor": 3, "send_date": "1700000003000", "mes": "很好。你站在喧闹的酒馆门口,手里握着一把旧长剑。你打算做什么?", "extra": {"api": "openai", "model": "gpt-4"}, "swipes": ["很好。你站在喧闹的酒馆门口,手里握着一把旧长剑。你打算做什么?", "明白了。作为一名人类战士,你正身处繁华的市集广场。你的下一步行动是?"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": [], "api": "openai", "model": "gpt-4", "reasoning": null, "reasoning_duration": null, "reasoning_signature": null, "time_to_first_token": null, "bias": null}

View File

@@ -1,5 +0,0 @@
{"user_name": "Commander", "character_name": "XCOM AI", "integrity": "uuid-003", "chat_id_hash": "hash-003", "note_prompt": "你是一名XCOM基地的中央AI负责协助指挥官管理外星威胁。", "note_interval": 0, "note_position": 0, "note_depth": 0, "note_role": 0, "extensions": {}, "timedWorldInfo": {}, "variables": {}, "tainted": false, "lastInContextMessageId": -1}
{"name": "Commander", "is_user": true, "is_system": false, "floor": 0, "send_date": "1700000000000", "mes": "报告当前的外星活动情况。", "extra": {}, "swipes": [], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
{"name": "XCOM AI", "is_user": false, "is_system": false, "floor": 1, "send_date": "1700000001000", "mes": "指挥官,卫星侦测到在南美洲丛林中有高能反应。可能是外星着陆舱。", "extra": {"api": "openai", "model": "gpt-4"}, "swipes": ["指挥官,卫星侦测到在南美洲丛林中有高能反应。可能是外星着陆舱。", "警报。我们在非洲检测到异常信号,疑似外星绑架行动正在进行。"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": [], "api": "openai", "model": "gpt-4", "reasoning": null, "reasoning_duration": null, "reasoning_signature": null, "time_to_first_token": null, "bias": null}
{"name": "Commander", "is_user": true, "is_system": false, "floor": 2, "send_date": "1700000002000", "mes": "派遣布拉德福上尉带领一个小队去调查。", "extra": {}, "swipes": [], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
{"name": "XCOM AI", "is_user": false, "is_system": false, "floor": 3, "send_date": "1700000003000", "mes": "指令已确认。天火运输机正在起飞。预计到达时间20分钟。", "extra": {"api": "openai", "model": "gpt-4"}, "swipes": ["指令已确认。天火运输机正在起飞。预计到达时间20分钟。", "收到。正在部署天火运输机。布拉德福上尉已登机。"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": [], "api": "openai", "model": "gpt-4", "reasoning": null, "reasoning_duration": null, "reasoning_signature": null, "time_to_first_token": null, "bias": null}

View File

@@ -1,5 +0,0 @@
{"user_name": "Player", "character_name": "Game Master", "integrity": "uuid-002", "chat_id_hash": "hash-002", "note_prompt": "场景:赛博朋克风格的未来城市。", "note_interval": 0, "note_position": 0, "note_depth": 0, "note_role": 0, "extensions": {}, "timedWorldInfo": {}, "variables": {}, "tainted": false, "lastInContextMessageId": -1}
{"name": "Player", "is_user": true, "is_system": false, "floor": 0, "send_date": "1700000000000", "mes": "我检查我的义体状态。", "extra": {}, "swipes": [], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
{"name": "Game Master", "is_user": false, "is_system": false, "floor": 1, "send_date": "1700000001000", "mes": "你的视觉义眼显示系统正常,但左臂的伺服电机发出轻微的嗡嗡声,似乎需要维护。", "extra": {"api": "openai", "model": "gpt-4"}, "swipes": ["你的视觉义眼显示系统正常,但左臂的伺服电机发出轻微的嗡嗡声,似乎需要维护。", "系统自检完成。你的神经接口连接稳定,但义体排异反应指数略有上升。"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": [], "api": "openai", "model": "gpt-4", "reasoning": null, "reasoning_duration": null, "reasoning_signature": null, "time_to_first_token": null, "bias": null}
{"name": "Player", "is_user": true, "is_system": false, "floor": 2, "send_date": "1700000002000", "mes": "我联系我的黑客朋友,问他知不知道哪里有靠谱的义体医生。", "extra": {}, "swipes": [], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
{"name": "Game Master", "is_user": false, "is_system": false, "floor": 3, "send_date": "1700000003000", "mes": "你的朋友回复说:'去下城区的老维克那里,虽然他的店看起来很破,但他手艺没得说。'", "extra": {"api": "openai", "model": "gpt-4"}, "swipes": ["你的朋友回复说:'去下城区的老维克那里,虽然他的店看起来很破,但他手艺没得说。'", "通讯接通。你的朋友告诉你:'别去连锁店,去太平间后巷找'扳手',他收费公道。'"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": [], "api": "openai", "model": "gpt-4", "reasoning": null, "reasoning_duration": null, "reasoning_signature": null, "time_to_first_token": null, "bias": null}

View File

@@ -1,5 +0,0 @@
{"user_name": "Player", "character_name": "Narrator", "integrity": "uuid-004", "chat_id_hash": "hash-004", "note_prompt": "这是一个文字冒险游戏,你需要描述场景并等待玩家输入。", "note_interval": 0, "note_position": 0, "note_depth": 0, "note_role": 0, "extensions": {}, "timedWorldInfo": {}, "variables": {}, "tainted": false, "lastInContextMessageId": -1}
{"name": "Player", "is_user": true, "is_system": false, "floor": 0, "send_date": "1700000000000", "mes": "开始游戏。", "extra": {}, "swipes": [], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
{"name": "Narrator", "is_user": false, "is_system": false, "floor": 1, "send_date": "1700000001000", "mes": "你醒来时发现自己躺在一片陌生的森林里,四周弥漫着浓雾。你身边有一个背包和一把生锈的匕首。", "extra": {"api": "openai", "model": "gpt-4"}, "swipes": ["你醒来时发现自己躺在一片陌生的森林里,四周弥漫着浓雾。你身边有一个背包和一把生锈的匕首。", "当你睁开眼睛,发现自己身处一艘废弃的飞船中,应急灯闪烁着红光。你手里紧握着一个数据盘。"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": [], "api": "openai", "model": "gpt-4", "reasoning": null, "reasoning_duration": null, "reasoning_signature": null, "time_to_first_token": null, "bias": null}
{"name": "Player", "is_user": true, "is_system": false, "floor": 2, "send_date": "1700000002000", "mes": "我打开背包看看里面有什么。", "extra": {}, "swipes": [], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
{"name": "Narrator", "is_user": false, "is_system": false, "floor": 3, "send_date": "1700000003000", "mes": "背包里有一块干硬的面包,一个水壶(里面还有半壶水),以及一张画着奇怪符号的羊皮纸。", "extra": {"api": "openai", "model": "gpt-4"}, "swipes": ["背包里有一块干硬的面包,一个水壶(里面还有半壶水),以及一张画着奇怪符号的羊皮纸。", "背包里只有一把激光手枪能量槽仅剩10%。还有一张写着'不要相信AI'的纸条。"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": [], "api": "openai", "model": "gpt-4", "reasoning": null, "reasoning_duration": null, "reasoning_signature": null, "time_to_first_token": null, "bias": null}

1
data/encryption_key.txt Normal file
View File

@@ -0,0 +1 @@
-JTj6zP_N7PFt218eJTGBKFBKED-GjOZVMgCxruoiW8=

View File

@@ -0,0 +1,16 @@
{
"name": "导入测试角色",
"description": "这是一个用于测试导入功能的角色",
"personality": "温和、耐心、善于倾听",
"scenario": "心理咨询场景",
"first_mes": "你好,我是你的倾听者。有什么想和我分享的吗?",
"mes_example": "",
"categories": ["测试", "心理"],
"tags": ["import-test", "counselor", "listener"],
"worldInfoId": null,
"outputSchema": null,
"alternate_greetings": [
"欢迎到来,我在这里听你说。"
],
"isFavorite": false
}

222
data/preset/Default.json Normal file
View File

@@ -0,0 +1,222 @@
{
"temperature": 1,
"frequency_penalty": 0,
"presence_penalty": 0,
"top_p": 1,
"top_k": 0,
"top_a": 0,
"min_p": 0,
"repetition_penalty": 1,
"openai_max_context": 4095,
"openai_max_tokens": 300,
"request_timeout": 60,
"names_behavior": 0,
"send_if_empty": "",
"impersonation_prompt": "[Write your next reply from the point of view of {{user}}, using the chat history so far as a guideline for the writing style of {{user}}. Don't write as {{char}} or system. Don't describe actions of {{char}}.]",
"new_chat_prompt": "[Start a new Chat]",
"new_group_chat_prompt": "[Start a new group chat. Group members: {{group}}]",
"new_example_chat_prompt": "[Example Chat]",
"continue_nudge_prompt": "[Continue your last message without repeating its original content.]",
"bias_preset_selected": "Default (none)",
"max_context_unlocked": false,
"wi_format": "{0}",
"scenario_format": "{{scenario}}",
"personality_format": "{{personality}}",
"group_nudge_prompt": "[Write the next reply only as {{char}}.]",
"stream_openai": true,
"prompts": [
{
"name": "Main Prompt",
"system_prompt": true,
"role": "system",
"content": "Write {{char}}'s next reply in a fictional chat between {{char}} and {{user}}.",
"identifier": "main"
},
{
"identifier": "worldInfoBefore",
"name": "World Info (before)",
"system_prompt": true,
"marker": true
},
{
"identifier": "charDescription",
"name": "Char Description",
"system_prompt": true,
"marker": true
},
{
"identifier": "charPersonality",
"name": "Char Personality",
"system_prompt": true,
"marker": true
},
{
"identifier": "scenario",
"name": "Scenario",
"system_prompt": true,
"marker": true
},
{
"identifier": "enhanceDefinitions",
"role": "system",
"name": "Enhance Definitions",
"content": "If you have more knowledge of {{char}}, add to the character's lore and personality to enhance them but keep the Character Sheet's definitions absolute.",
"system_prompt": true,
"marker": false
},
{
"name": "Auxiliary Prompt",
"system_prompt": true,
"role": "system",
"content": "",
"identifier": "nsfw"
},
{
"identifier": "worldInfoAfter",
"name": "World Info (after)",
"system_prompt": true,
"marker": true
},
{
"identifier": "dialogueExamples",
"name": "Chat Examples",
"system_prompt": true,
"marker": true
},
{
"identifier": "chatHistory",
"name": "Chat History",
"system_prompt": true,
"marker": true
},
{
"name": "Post-History Instructions",
"system_prompt": true,
"role": "system",
"content": "",
"identifier": "jailbreak"
},
{
"identifier": "personaDescription",
"name": "Persona Description",
"system_prompt": true,
"marker": true
}
],
"prompt_order": [
{
"character_id": 100000,
"order": [
{
"identifier": "main",
"enabled": true
},
{
"identifier": "worldInfoBefore",
"enabled": true
},
{
"identifier": "charDescription",
"enabled": true
},
{
"identifier": "charPersonality",
"enabled": true
},
{
"identifier": "scenario",
"enabled": true
},
{
"identifier": "enhanceDefinitions",
"enabled": false
},
{
"identifier": "nsfw",
"enabled": true
},
{
"identifier": "worldInfoAfter",
"enabled": true
},
{
"identifier": "dialogueExamples",
"enabled": true
},
{
"identifier": "chatHistory",
"enabled": true
},
{
"identifier": "jailbreak",
"enabled": true
}
]
},
{
"character_id": 100001,
"order": [
{
"identifier": "main",
"enabled": true
},
{
"identifier": "worldInfoBefore",
"enabled": true
},
{
"identifier": "personaDescription",
"enabled": true
},
{
"identifier": "charDescription",
"enabled": true
},
{
"identifier": "charPersonality",
"enabled": true
},
{
"identifier": "scenario",
"enabled": true
},
{
"identifier": "enhanceDefinitions",
"enabled": false
},
{
"identifier": "nsfw",
"enabled": true
},
{
"identifier": "worldInfoAfter",
"enabled": true
},
{
"identifier": "dialogueExamples",
"enabled": true
},
{
"identifier": "chatHistory",
"enabled": true
},
{
"identifier": "jailbreak",
"enabled": true
}
]
}
],
"assistant_prefill": "",
"assistant_impersonation": "",
"use_sysprompt": false,
"squash_system_messages": false,
"media_inlining": true,
"continue_prefill": false,
"continue_postfix": " ",
"seed": -1,
"n": 1,
"updatedAt": 1777857993,
"name": "Default",
"createdAt": 1777977985
}

View File

@@ -0,0 +1,28 @@
{
"id": "ruleset-global-default",
"name": "默认全局规则集",
"description": "系统默认的全局正则规则",
"rules": [
{
"id": "rule-hide-thinking-001",
"scriptName": "隐藏思考标签",
"findRegex": "<thinking>[\\s\\S]*?<\\/thinking>",
"replaceString": "",
"trimStrings": [],
"placement": [2],
"substituteRegex": 0,
"markdownOnly": false,
"promptOnly": false,
"runOnEdit": true,
"minDepth": 0,
"maxDepth": null,
"scope": "global",
"characterName": null,
"presetName": null,
"disabled": false,
"order": 0,
"description": "隐藏 AI 回复中的 <thinking> 标签及其内容"
}
],
"isSillyTavernFormat": false
}

View File

@@ -0,0 +1,35 @@
{
"id": "rule-hide-thinking-001",
"name": "隐藏思考标签",
"description": null,
"rules": [
{
"id": "rule-hide-thinking-001",
"scriptName": "隐藏思考标签",
"findRegex": "<thinking>[\\s\\S]*?<\\/thinking>",
"replaceString": "",
"trimStrings": [],
"placement": [
2
],
"substituteRegex": 0,
"markdownOnly": false,
"promptOnly": false,
"runOnEdit": true,
"minDepth": 0,
"maxDepth": null,
"scope": "global",
"characterName": null,
"presetName": null,
"disabled": false,
"order": 0,
"createdAt": 1777997833,
"updatedAt": 1777997833,
"description": "隐藏 AI 回复中的 <thinking> 标签及其内容"
}
],
"createdAt": 1777997834,
"updatedAt": 1777997834,
"version": 1,
"isSillyTavernFormat": false
}

View File

@@ -0,0 +1,10 @@
{
"id": "ruleset-global-default",
"name": "默认全局规则集",
"description": "系统默认的全局正则规则",
"rules": [],
"createdAt": 1777998120,
"updatedAt": 1777998120,
"version": 1,
"isSillyTavernFormat": false
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,35 @@
{
"id": "6acc2ea6-0dd2-4542-b9f5-0bcefe2a5947",
"name": "test 规则集",
"description": "从 SillyTavern 导入的规则",
"rules": [
{
"id": "af24f3c2-9ded-4593-a6db-98449c022696",
"scriptName": "test",
"findRegex": "test",
"replaceString": "",
"trimStrings": [],
"placement": [
2
],
"substituteRegex": 0,
"markdownOnly": false,
"promptOnly": false,
"runOnEdit": true,
"minDepth": 0,
"maxDepth": null,
"scope": "preset",
"characterName": null,
"presetName": "test",
"disabled": false,
"order": 0,
"createdAt": 1777995980,
"updatedAt": 1777995980,
"description": null
}
],
"createdAt": 1777995980,
"updatedAt": 1777995980,
"version": 1,
"isSillyTavernFormat": true
}

View File

@@ -0,0 +1,7 @@
{
"thinkingTagPrefix": "<thinking>",
"thinkingTagSuffix": "</thinking>",
"currentPresetName": null,
"updatedAt": 1777798988,
"version": 1
}

View File

@@ -0,0 +1,14 @@
{"id": "08267c9f-a53c-40cc-b47a-b24c54309d83", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 2, "promptTokens": 73, "completionTokens": 4, "totalTokens": 77, "status": "completed", "errorMessage": null, "timestamp": 1777984056, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
{"id": "dd88534a-2947-4ad2-be81-ffedfb9dfe02", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 3, "promptTokens": 133, "completionTokens": 213, "totalTokens": 346, "status": "completed", "errorMessage": null, "timestamp": 1777984358, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
{"id": "5d3980d1-6c9d-4f6c-ae89-ec1b93b4d4c5", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 5, "promptTokens": 448, "completionTokens": 156, "totalTokens": 604, "status": "completed", "errorMessage": null, "timestamp": 1777984695, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
{"id": "c939e2d6-9bce-4484-8a07-b49dfc51ca46", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 6, "promptTokens": 703, "completionTokens": 104, "totalTokens": 807, "status": "completed", "errorMessage": null, "timestamp": 1777986964, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
{"id": "6cf8bfc1-73aa-4c52-92b6-184d40a57fea", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 6, "promptTokens": 703, "completionTokens": 42, "totalTokens": 745, "status": "completed", "errorMessage": null, "timestamp": 1777987419, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
{"id": "e702de1c-3fed-45cc-87cc-fa42745cdeb6", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 8, "promptTokens": 841, "completionTokens": 72, "totalTokens": 913, "status": "completed", "errorMessage": null, "timestamp": 1777989342, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
{"id": "320c1ed6-bbd7-487c-b61c-148a76605fcc", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 10, "promptTokens": 1009, "completionTokens": 43, "totalTokens": 1052, "status": "completed", "errorMessage": null, "timestamp": 1777989675, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
{"id": "2bd0cf29-4451-462a-9b4a-c643ceeca3d0", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 12, "promptTokens": 1146, "completionTokens": 19, "totalTokens": 1165, "status": "completed", "errorMessage": null, "timestamp": 1777990077, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
{"id": "4ff6d3c6-c986-470b-bd63-6d811fd38651", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 14, "promptTokens": 1258, "completionTokens": 26, "totalTokens": 1284, "status": "completed", "errorMessage": null, "timestamp": 1777990303, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
{"id": "b62eb2a9-e7fe-497f-a9a2-86d523ac1087", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 5, "promptTokens": 448, "completionTokens": 246, "totalTokens": 694, "status": "completed", "errorMessage": null, "timestamp": 1777990887, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
{"id": "1458daed-02b6-403f-b153-71a3fcca411f", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 7, "promptTokens": 694, "completionTokens": 266, "totalTokens": 960, "status": "completed", "errorMessage": null, "timestamp": 1777990994, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
{"id": "e98598a6-18ba-4d9b-98aa-d2b6f2fbf519", "chatId": "写卡机/chat_1777998326762", "roleName": "写卡机", "chatName": "chat_1777998326762", "messageId": null, "floor": 2, "promptTokens": 2019, "completionTokens": 868, "totalTokens": 2887, "status": "completed", "errorMessage": null, "timestamp": 1777998427, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
{"id": "85e7d5ff-4bf7-409f-8fbf-9687722a5a6d", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 5, "promptTokens": 1977, "completionTokens": 1104, "totalTokens": 3081, "status": "completed", "errorMessage": null, "timestamp": 1778068763, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
{"id": "1bd77edf-1b40-4222-a6b9-2091e27fafae", "chatId": "神国之主/chat_1778166975812", "roleName": "神国之主", "chatName": "chat_1778166975812", "messageId": null, "floor": 3, "promptTokens": 2438, "completionTokens": 1272, "totalTokens": 3710, "status": "completed", "errorMessage": null, "timestamp": 1778167133, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}

View File

@@ -0,0 +1,10 @@
{
"https://api.deepseek.com/v1": {
"totalPromptTokens": 13890,
"totalCompletionTokens": 4435,
"totalTokens": 18325,
"count": 14,
"firstUsed": 1777984056,
"lastUsed": 1778167133
}
}

View File

@@ -0,0 +1,20 @@
{
"2026-05-05": {
"promptTokens": 9475,
"completionTokens": 2059,
"totalTokens": 11534,
"count": 12
},
"2026-05-06": {
"promptTokens": 1977,
"completionTokens": 1104,
"totalTokens": 3081,
"count": 1
},
"2026-05-07": {
"promptTokens": 2438,
"completionTokens": 1272,
"totalTokens": 3710,
"count": 1
}
}

Some files were not shown because too many files have changed in this diff Show More