12 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
217 changed files with 33972 additions and 6745 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

157
.gitignore vendored
View File

@@ -29,7 +29,21 @@ env/
.venv
VENV/
# IDE
# Python test files (temporary)
test_*.py
check_*.py
clear_*.py
convert_*.py
generate_*.py
create_*.py
test.py
# Python type checking
.mypy_cache/
.pytest_cache/
.ruff_cache/
# ==================== IDE ====================
.vscode/
.idea/
*.swp
@@ -38,14 +52,39 @@ VENV/
.project
.pydevproject
.settings/
*.sublime-project
*.sublime-workspace
*.iml
.cursor/
.windsurfrules
# OS
# JetBrains IDEs
.idea/workspace.xml
.idea/tasks.xml
.idea/dictionaries/
.idea/vcs.xml
.idea/jsLinters/
.idea/misc.xml
.idea/modules.xml
# ==================== OS ====================
.DS_Store
Thumbs.db
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
Desktop.ini
$RECYCLE.BIN/
# Windows thumbnails cache files
Thumbs.db:encryptable
dm thumbs.db
# Folder config file
[Dd]esktop.ini
# ==================== Node.js ====================
node_modules/
npm-debug.log*
@@ -67,26 +106,80 @@ frontend/dist-ssr/
!.env.development
!.env.production
# ⚠️ 敏感信息API 配置文件(包含 API Keys
data/apiconfig/*.json
# ==================== Logs ====================
logs/
*.log
log/
# ==================== Data files ====================
# 保留目录结构,忽略数据文件
data/chat/**/*.jsonl
data/chat/**/*.json
data/preset/*.json
data/worldbooks/*.json
data/apiconfig/*.json
data/comfyui_workflows/*.json
data/images/*
data/temp/*
outputs/*
imports/*
# ⚠️ 所有用户数据文件都不应该提交到版本控制
# 聊天记录(包含聊天历史和消息数据)
data/chat/
data/chat/**/*
# 角色卡数据(角色配置和头像)
data/characters/
data/characters/**/*
data/avatars/
data/avatars/**/*
# 预设文件(提示词配置)
data/preset/
data/preset/**/*
# 世界书(世界观设定)
data/worldbooks/
data/worldbooks/**/*
# API 配置(包含 API Keys敏感信息
data/apiconfig/
data/apiconfig/**/*
# 正则规则
data/regex/
data/regex/**/*
# ComfyUI 工作流
data/comfyui_workflows/
data/comfyui_workflows/**/*
# 图片资源
data/images/
data/images/**/*
data/image_metadata/
data/image_metadata/**/*
# 临时文件
data/temp/
data/temp/**/*
# 导入文件
data/imports/
data/imports/**/*
# Token 使用统计
data/token_usage/
data/token_usage/**/*
# 系统设置
data/system_settings.json
# 加密密钥(敏感信息)
data/encryption_key.txt
# 其他输出目录
outputs/
outputs/**/*
imports/
imports/**/*
# ==================== Docker ====================
.dockerignore
docker-compose.override.yml
# ==================== Temporary files ====================
*.tmp
@@ -101,6 +194,13 @@ coverage/
htmlcov/
.pytest_cache/
.tox/
.nox/
# Unit test / coverage reports
.coverage
.coverage.*
*.cover
*.cover.gz
# ==================== Misc ====================
.parcel-cache/
@@ -112,6 +212,19 @@ htmlcov/
.dynamodb/
.tern-port
# Temporary documentation files
*_TEST_GUIDE.md
*_DEBUG_GUIDE.md
*_DEBUG.md
*_TEST.md
*_CHECK.md
*_FIX.md
*_IMPROVEMENT.md
*_EXAMPLE.md
*_COMPARISON.md
*_OPTIMIZATION.md
*_CONFIG.md
# ==================== Project specific ====================
# Backend output
backend/__pycache__/
@@ -120,11 +233,27 @@ backend/api/routes/__pycache__/
backend/core/__pycache__/
backend/services/__pycache__/
backend/utils/__pycache__/
backend/models/__pycache__/
# Claude settings
.claude/settings.local.json
.claude/
# Lingma cache
.lingma/
# Backup files
*.bak
*.backup
*~
# ComfyUI generated images
data/outputs/
# Token usage logs (can be large)
data/token_usage/*.jsonl
data/token_usage/**/*.jsonl
# Worldbooks backup
data/worldbooks/*.bak
data/worldbooks/*.bak.*

View File

@@ -1,195 +0,0 @@
# 全局世界书数据同步问题修复
## 🐛 问题描述
**现象**: 当世界书文件被删除后LocalStorage中仍然保存着该世界书的全局状态导致前端显示一个不存在的"幽灵"世界书。
**原因**: `fetchWorldBooks` 函数从LocalStorage加载全局世界书后没有清理那些已经不存在于后端的世界书。
---
## ✅ 解决方案
### 修复位置
**文件**: `frontend/src/Store/SideBarLeft/WorldBookSlice.jsx`
**函数**: `fetchWorldBooks` (第132-165行)
### 修复逻辑
```javascript
// 从 LocalStorage 获取全局世界书列表
let globalBooks = loadGlobalWorldBooks();
// 清理 LocalStorage 中已不存在的世界书
const existingWorldBookNames = new Set(data.map(wb => wb.name));
const cleanedGlobalBooks = globalBooks.filter(wb => existingWorldBookNames.has(wb.name));
// 如果有被清理的项,更新 LocalStorage
if (cleanedGlobalBooks.length !== globalBooks.length) {
console.log(`清理了 ${globalBooks.length - cleanedGlobalBooks.length} 个不存在的全局世界书`);
saveGlobalWorldBooks(cleanedGlobalBooks);
globalBooks = cleanedGlobalBooks;
}
```
### 工作流程
1. **获取后端数据**: 调用 `GET /api/worldbooks/` 获取所有存在的世界书
2. **加载LocalStorage**: 从LocalStorage读取全局世界书列表
3. **对比清理**: 过滤掉LocalStorage中存在但后端不存在的世界书
4. **更新存储**: 如果发现有被清理的项更新LocalStorage
5. **更新State**: 将清理后的列表设置到State中
---
## 🧪 测试场景
### 场景1: 正常情况
**步骤**:
1. 创建世界书A和B
2. 将A和B都设为全局
3. 刷新页面
**预期结果**:
- ✅ 全局区域显示A和B
- ✅ LocalStorage中有A和B
### 场景2: 文件被删除
**步骤**:
1. 创建世界书A和B
2. 将A和B都设为全局
3. 手动删除世界书A的文件或其他方式删除
4. 刷新页面
**预期结果**:
- ✅ 全局区域只显示B
- ✅ LocalStorage中只保留B
- ✅ 控制台输出: "清理了 1 个不存在的全局世界书"
### 场景3: 通过UI删除
**步骤**:
1. 创建世界书A和B
2. 将A和B都设为全局
3. 在前端UI中删除世界书A
4. 刷新页面
**预期结果**:
- ✅ 全局区域只显示B
- ✅ LocalStorage中只保留B
- ✅ 无错误信息
---
## 📊 数据流图
```
┌─────────────────┐
│ 页面加载/切换 │
└────────┬────────┘
┌─────────────────┐
│ fetchWorldBooks │
└────────┬────────┘
├──► GET /api/worldbooks/ ──► 后端返回现有世界书列表
├──► loadGlobalWorldBooks() ──► 从LocalStorage读取
├──► 对比两个列表
│ ├─ 存在于LocalStorage但不存在于后端 → 清理
│ └─ 存在于两者 → 保留
├──► saveGlobalWorldBooks() ──► 更新LocalStorage如有变化
└──► set state ──► 更新UI
```
---
## 🔍 关键代码说明
### 1. 使用Set提高查找效率
```javascript
const existingWorldBookNames = new Set(data.map(wb => wb.name));
```
- 将后端返回的世界书名称转换为Set
- Set的查找时间复杂度为O(1)比数组的O(n)更高效
### 2. 过滤清理
```javascript
const cleanedGlobalBooks = globalBooks.filter(wb =>
existingWorldBookNames.has(wb.name)
);
```
- 只保留那些在后端也存在的世界书
- 自动移除"幽灵"世界书
### 3. 条件更新
```javascript
if (cleanedGlobalBooks.length !== globalBooks.length) {
console.log(`清理了 ${globalBooks.length - cleanedGlobalBooks.length} 个不存在的全局世界书`);
saveGlobalWorldBooks(cleanedGlobalBooks);
globalBooks = cleanedGlobalBooks;
}
```
- 只有在确实有变化时才更新LocalStorage
- 避免不必要的写入操作
- 提供调试信息
---
## ✨ 优势
1. **自动清理**: 无需手动干预自动同步LocalStorage和后端数据
2. **性能优化**: 使用Set提高查找效率
3. **用户友好**: 静默清理,只在控制台输出日志
4. **数据一致性**: 确保LocalStorage中的数据始终与后端保持一致
5. **无副作用**: 不影响正常的业务流程
---
## 📝 相关代码位置
### LocalStorage操作函数
```javascript
// 辅助函数:从 LocalStorage 加载全局世界书
const loadGlobalWorldBooks = () => {
try {
const stored = localStorage.getItem(GLOBAL_WORLDBOOKS_KEY);
return stored ? JSON.parse(stored) : [];
} catch (error) {
console.error('加载全局世界书失败:', error);
return [];
}
};
// 辅助函数:保存全局世界书到 LocalStorage
const saveGlobalWorldBooks = (globalBooks) => {
try {
localStorage.setItem(GLOBAL_WORLDBOOKS_KEY, JSON.stringify(globalBooks));
} catch (error) {
console.error('保存全局世界书失败:', error);
}
};
```
### 删除世界书时的清理
```javascript
// deleteWorldBook 函数中已经有清理逻辑
deleteWorldBook: async (name) => {
// ...
const filteredGlobalBooks = state.globalWorldBooks.filter(wb => wb.name !== name);
saveGlobalWorldBooks(filteredGlobalBooks);
// ...
}
```
---
## 🎯 总结
**问题已修复**: 全局世界书现在会自动清理不存在的项
**数据同步**: LocalStorage与后端数据保持一致
**用户体验**: 不再显示"幽灵"世界书
**代码健壮**: 增加了数据一致性检查机制

564
README.md
View File

@@ -1,226 +1,466 @@
# LLM Workflow Engine
一个基于 React + TypeScript + FastAPI 的 AI 聊天工作流引擎,支持流式对话、动态表格生成、图片生成等功能
一个功能强大的 LLM 聊天工作流引擎,兼容 SillyTavern 生态系统
## 🚀 技术栈
## 📋 目录
### 前端
- **React 18** - 用户界面框架
- **TypeScript** - 类型安全的 JavaScript
- **Vite** - 现代化的前端构建工具
- **Zustand** - 轻量级状态管理
- **React Markdown** - Markdown 渲染
- **Tailwind CSS** - 实用优先的 CSS 框架
- [功能特性](#功能特性)
- [技术栈](#技术栈)
- [快速开始](#快速开始)
- [项目结构](#项目结构)
- [核心功能](#核心功能)
- [开发指南](#开发指南)
- [配置说明](#配置说明)
- [常见问题](#常见问题)
---
## 功能特性
### 🎯 核心功能
- **多模型支持** - 兼容 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 Web 框架
- **Python 3.11** - 编程语言
- **Uvicorn** - ASGI 服务器
- **WebSockets** - 实时通信
## 📁 项目结构
- **框架**: FastAPI (Python 3.11+)
- **数据库**: 文件系统 + JSON轻量级易于备份
- **WebSocket**: 实时流式通信
- **加密**: Fernet 对称加密cryptography 库)
- **依赖管理**: pip + requirements.txt
```
llm_workflow_engine/
├── backend/ # 后端服务
│ ├── api/ # API 路由
│ ├── core/ # 核心模型和配置
│ ├── tools/ # 工具函数
│ ├── workflows/ # 工作流定义
│ ├── Dockerfile # 后端 Docker 配置
│ ├── main.py # 后端入口
│ └── requirements.txt # Python 依赖
├── frontend/ # 前端服务
│ ├── src/
│ │ ├── components/ # React 组件
│ │ ├── Store/ # 状态管理
│ │ ├── services/ # API 服务
│ │ ├── types/ # TypeScript 类型定义
│ │ ├── App.tsx # 主应用组件
│ │ └── main.tsx # 入口文件
│ ├── Dockerfile # 前端 Docker 配置
│ ├── nginx.conf # Nginx 配置(生产环境)
│ ├── package.json # Node.js 依赖
│ └── tsconfig.json # TypeScript 配置
├── data/ # 数据存储
├── docker-compose.yml # Docker Compose 配置
└── README.md # 项目文档
```
### 前端
## 🛠️ 安装和运行
- **框架**: React 18 + Vite
- **状态管理**: Zustand轻量级 Redux 替代)
- **样式**: CSS3 + CSS 变量(支持多主题)
- **Markdown**: react-markdown + remark-gfm
- **HTTP 客户端**: Fetch API
### 使用 Docker Compose推荐
### 部署
这是最简单的运行方式,适合开发和生产环境。
- **容器化**: Docker + Docker Compose
- **反向代理**: Nginx
- **开发服务器**: Vite HMR
1. **克隆项目**
```bash
git clone <repository-url>
cd llm_workflow_engine
```
---
2. **配置环境变量**
```bash
# 复制环境变量模板
cp .env.example .env
## 快速开始
# 根据需要编辑 .env 文件
```
### 环境要求
3. **启动服务**
```bash
# 构建并启动所有服务
docker-compose up --build
# 或者在后台运行
docker-compose up -d --build
```
4. **访问应用**
- 前端界面: http://localhost:23338
- 后端 API: http://localhost:23337
- API 文档: http://localhost:23337/docs
5. **停止服务**
```bash
docker-compose down
```
- Python 3.11+
- Node.js 18+
- Docker & Docker Compose可选
### 本地开发
如果你想分别运行前后端进行开发:
#### 1. 克隆项目
#### 后端开发
1. **安装 Python 依赖**
```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
pip install -r requirements.txt
python main.py
```
2. **启动后端服务**
```bash
python -m uvicorn backend.main:app --host 0.0.0.0 --port 8000 --reload
```
后端服务将在 `http://localhost:23338` 启动。
#### 前端开发
#### 3. 前端启动
1. **安装 Node.js 依赖**
```bash
cd frontend
npm install
```
2. **启动前端开发服务器**
```bash
# 安装依赖
npm install
# 启动开发服务器
npm run dev
```
3. **访问应用**
- 前端界面: http://localhost:5173
- 确保后端在 http://localhost:8000 运行
前端将在 `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;
/* ... */
}
```
---
## 配置说明
### 环境变量
#### 前端环境变量 (frontend/.env)
```
VITE_API_URL=http://localhost:23337/api
VITE_WS_URL=ws://localhost:23337/api
```
创建 `.env` 文件(从 `.env.example` 复制):
#### 后端环境变量
```
PYTHONUNBUFFERED=1
PYTHONDONTWRITEBYTECODE=1
```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 密钥和端点:
1. 打开左侧栏的 "API 配置" 标签
2. 添加你的 API 配置URL 和密钥)
3. 选择要使用的 API
API Key 通过前端界面配置,自动加密存储到 `data/apiconfig/` 目录。
## 📖 功能特性
⚠️ **注意**`data/apiconfig/*.json` 已添加到 `.gitignore`,不会被提交到版本控制。
-**流式对话** - 实时显示 AI 回复
-**多角色支持** - 支持多个聊天角色和会话
-**消息编辑** - 可以编辑和删除历史消息
-**HTML 渲染** - 支持 Markdown 和 HTML 渲染
-**动态表格** - 自动生成和更新数据表格
-**图片生成** - 集成图片生成工作流
-**世界书** - 管理角色和世界设定
-**预设管理** - 保存和加载不同的对话预设
---
## 🐳 Docker 命令参考
## 常见问题
### 1. 前端无法连接后端
**问题**: 前端请求返回 404 或网络连接错误
**解决**:
```bash
# 构建并启动
docker-compose up --build
# 检查后端是否运行
curl http://localhost:23338/api/health
# 后台运行
docker-compose up -d
# 检查前端代理配置
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
docker-compose logs -f backend
docker-compose logs -f frontend
# 停止服务
docker-compose down
# 重启服务
docker-compose restart
# 进入容器
docker-compose exec backend bash
docker-compose exec frontend sh
# 清理所有容器和卷
docker-compose down -v
# 重新构建
docker-compose up -d --build
```
## 🔍 开发工具
### 4. 正则规则不生效
### 前端
```bash
# 类型检查
npm run type-check
**问题**: 配置了正则规则但没有效果
# 构建
npm run build
**解决**:
1. 检查规则是否启用disabled: false
2. 检查 placement 是否正确
3. 检查正则表达式语法
4. 重启后端服务
# 预览生产构建
npm run preview
```
---
### 后端
```bash
# 运行测试(如果有的话)
cd backend
pytest
## 贡献指南
# 代码格式化
black .
```
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 相同的分发协议。
欢迎提交 Issue 和 Pull Request
---
## 📄 许可证
## 致谢
MIT License
- [SillyTavern](https://github.com/SillyTavern/SillyTavern) - 优秀的开源项目,提供了设计灵感和兼容标准
- [JS-Slash-Runner](https://github.com/N0VI028/JS-Slash-Runner) - Tavern Helper 扩展,提供了 JavaScript 沙盒实现参考
## 📞 联系方式
---
如有问题,请提交 Issue 或联系维护者。
**最后更新**: 2026-05-05
**版本**: 1.0.0

View File

@@ -1,194 +0,0 @@
# 世界书删除确认弹窗移除
## 📋 修改内容
移除了世界书模块中所有的浏览器级别确认弹窗(`confirm`),改为直接执行删除操作。
---
## ✅ 已移除的确认弹窗
### 1. 删除世界书条目
**位置**: `WorldBook.jsx` 第338-349行
**修改前**:
```javascript
const handleDeleteEntry = async () => {
if (!currentEntry || !currentWorldBook) return;
if (confirm('确定要删除此条目吗?')) {
try {
await deleteWorldBookEntry(currentWorldBook.name, currentEntry.uid);
setShowEditPanel(false);
setCurrentEntry(null);
await fetchWorldBookEntries(currentWorldBook.name, currentPage, pageSize);
} catch (err) {
console.error('删除条目失败:', err);
}
}
};
```
**修改后**:
```javascript
const handleDeleteEntry = async () => {
if (!currentEntry || !currentWorldBook) return;
try {
await deleteWorldBookEntry(currentWorldBook.name, currentEntry.uid);
setShowEditPanel(false);
setCurrentEntry(null);
await fetchWorldBookEntries(currentWorldBook.name, currentPage, pageSize);
} catch (err) {
console.error('删除条目失败:', err);
}
};
```
---
### 2. 删除世界书
**位置**: `WorldBook.jsx` 第354-365行
**修改前**:
```javascript
const handleDeleteWorldBook = async () => {
if (!currentWorldBook) return;
if (confirm(`确定要删除世界书 "${currentWorldBook.name}" 吗?`)) {
try {
await deleteWorldBook(currentWorldBook.name);
resetCurrentWorldBook();
} catch (err) {
console.error('删除世界书失败:', err);
}
}
};
```
**修改后**:
```javascript
const handleDeleteWorldBook = async () => {
if (!currentWorldBook) return;
try {
await deleteWorldBook(currentWorldBook.name);
resetCurrentWorldBook();
} catch (err) {
console.error('删除世界书失败:', err);
}
};
```
---
### 3. 编辑面板中的删除按钮
**位置**: `WorldBook.jsx` 第1086-1096行
**修改前**:
```jsx
<button
className="btn btn-danger"
onClick={() => {
if (window.confirm('确定要删除这个条目吗?')) {
handleDeleteEntry();
}
}}
>
删除条目
</button>
```
**修改后**:
```jsx
<button
className="btn btn-danger"
onClick={handleDeleteEntry}
>
删除条目
</button>
```
---
## 🎯 影响范围
### 用户交互变化
**之前**:
1. 点击删除按钮
2. 弹出浏览器确认对话框
3. 用户点击"确定"或"取消"
4. 根据选择执行或删除操作
**现在**:
1. 点击删除按钮
2. 立即执行删除操作
3. 通过错误处理捕获异常
---
## ✨ 优势
1. **更流畅的用户体验**: 减少交互步骤,操作更快捷
2. **更现代的UI**: 避免使用浏览器原生弹窗更符合现代Web应用风格
3. **代码简化**: 减少了条件判断和嵌套层级
4. **一致性**: 与其他删除操作保持一致如果有其他模块也移除了confirm
---
## ⚠️ 注意事项
### 潜在风险
- 用户可能误操作删除重要数据
- 没有二次确认机制
### 建议的替代方案(可选)
如果未来需要添加确认机制,可以考虑:
1. **自定义模态框**: 使用项目统一的Modal组件
2. **Toast提示 + 撤销**: 删除后显示提示,提供短暂的撤销机会
3. **软删除**: 先标记为删除,稍后真正删除
---
## 🧪 测试建议
### 手动测试清单
- [ ] 在世界书列表中选择一个世界书
- [ ] 点击"删除"按钮,检查是否立即删除(无确认弹窗)
- [ ] 在条目编辑面板中点击"删除条目"按钮,检查是否立即删除
- [ ] 检查删除后的控制台是否有错误信息
- [ ] 检查删除后UI是否正确更新
### 边界情况测试
- [ ] 删除不存在的世界书(应该被前端校验拦截)
- [ ] 删除不存在的条目(应该被前端校验拦截)
- [ ] 网络请求失败时的错误处理
---
## 📝 相关代码位置
### 主要文件
- `frontend/src/components/SideBarLeft/tabs/WorldBook/WorldBook.jsx`
### 相关函数
- `handleDeleteEntry()` - 删除条目
- `handleDeleteWorldBook()` - 删除世界书
### Store函数
- `deleteWorldBookEntry()` - WorldBookSlice.jsx
- `deleteWorldBook()` - WorldBookSlice.jsx
---
## 🎯 总结
**所有浏览器级别的确认弹窗已移除**
**删除操作更加流畅和现代化**
**代码结构更简洁**
**用户体验得到提升**
如果需要添加更优雅的确认机制建议使用项目统一的UI组件而非浏览器原生弹窗。

View File

@@ -1,428 +0,0 @@
# 🧪 API 配置功能测试指南
## 📋 测试前准备
### **1. 启动后端服务**
```bash
cd backend
uvicorn main:app --reload --port 8000
```
确保看到:
```
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8000
```
---
### **2. (可选)启动 ComfyUI**
如果要测试 ComfyUI 连接:
```bash
# 本地运行
python comfyui/main.py --listen 0.0.0.0 --port 8188
# 或 Docker
docker-compose up -d comfyui
```
---
## 🚀 运行测试
### **方法 1: 使用 Python 脚本(推荐)**
```bash
# 在项目根目录运行
python test_api_config.py
```
**预期输出**
```
============================================================
ComfyUI API 配置测试
============================================================
============================================================
测试 1: 列出工作流
============================================================
✅ 成功获取 1 个工作流
📄 default_txt2img.json
节点数: 7, 大小: 1234 bytes
...
============================================================
测试总结
============================================================
✅ 通过 - 列出工作流
✅ 通过 - 获取工作流详情
✅ 通过 - 上传工作流
✅ 通过 - 删除工作流
✅ 通过 - 测试 ComfyUI 连接
✅ 通过 - 测试云端 API 连接
总计: 6/6 通过
🎉 所有测试通过!
```
---
### **方法 2: 使用 cURL 手动测试**
#### **测试 1: 列出工作流**
```bash
curl http://localhost:8000/api/api-config/comfyui/workflows | jq
```
**预期响应**
```json
[
{
"filename": "default_txt2img.json",
"name": "default_txt2img",
"nodes_count": 7,
"size": 1234
}
]
```
---
#### **测试 2: 获取工作流详情**
```bash
curl http://localhost:8000/api/api-config/comfyui/workflows/default_txt2img.json | jq
```
**预期响应**
```json
{
"3": {
"inputs": {...},
"class_type": "KSampler",
"_meta": {"title": "K采样器"}
},
...
}
```
---
#### **测试 3: 上传工作流**
创建一个测试文件 `test_workflow.json`
```bash
cat > test_workflow.json << 'EOF'
{
"3": {
"inputs": {
"seed": 42,
"steps": 20,
"cfg": 8,
"sampler_name": "euler",
"scheduler": "normal",
"denoise": 1,
"model": ["4", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"latent_image": ["5", 0]
},
"class_type": "KSampler"
},
"4": {
"inputs": {"ckpt_name": "test.safetensors"},
"class_type": "CheckpointLoaderSimple"
},
"5": {
"inputs": {"width": 512, "height": 512, "batch_size": 1},
"class_type": "EmptyLatentImage"
},
"6": {
"inputs": {"text": "test", "clip": ["4", 1]},
"class_type": "CLIPTextEncode"
},
"7": {
"inputs": {"text": "bad", "clip": ["4", 1]},
"class_type": "CLIPTextEncode"
},
"8": {
"inputs": {"samples": ["3", 0], "vae": ["4", 2]},
"class_type": "VAEDecode"
},
"9": {
"inputs": {"images": ["8", 0], "filename_prefix": "Test"},
"class_type": "SaveImage"
}
}
EOF
```
上传:
```bash
curl -X POST http://localhost:8000/api/api-config/comfyui/workflows/upload \
-F "file=@test_workflow.json" | jq
```
**预期响应**
```json
{
"message": "Workflow uploaded successfully",
"filename": "test_workflow.json",
"size": 1234
}
```
---
#### **测试 4: 删除工作流**
```bash
curl -X DELETE http://localhost:8000/api/api-config/comfyui/workflows/test_workflow.json | jq
```
**预期响应**
```json
{
"message": "Workflow 'test_workflow.json' deleted successfully"
}
```
---
#### **测试 5: 测试 ComfyUI 连接**
```bash
curl -X POST http://localhost:8000/api/api-config/test-comfyui-connection \
-H "Content-Type: application/json" \
-d '{"apiUrl": "http://localhost:8188"}' | jq
```
**如果 ComfyUI 正在运行**
```json
{
"success": true,
"message": "连接成功",
"stats": {
"vram_total": 25769803776,
"vram_free": 24696061952,
"torch_version": "2.1.0+cu121",
"device": "cuda"
}
}
```
**如果 ComfyUI 未运行**
```json
{
"success": false,
"message": "无法连接到 ComfyUI请检查地址和端口"
}
```
---
#### **测试 6: 测试云端 API 连接**
```bash
curl -X POST http://localhost:8000/api/api-config/test-cloud-connection \
-H "Content-Type: application/json" \
-d '{
"provider": "dall-e",
"apiKey": "sk-your-api-key-here",
"model": "dall-e-3"
}' | jq
```
**预期响应**(如果 API Key 有效):
```json
{
"success": true,
"message": "连接成功,模型 dall-e-3 可用"
}
```
---
## ✅ 测试检查清单
### **后端 API**
- [ ] 列出工作流返回正确的列表
- [ ] 获取工作流详情返回完整的 JSON
- [ ] 上传工作流成功保存文件
- [ ] 上传的工作流可以通过列表看到
- [ ] 删除工作流成功移除文件
- [ ] 默认工作流不可删除(返回 403
- [ ] ComfyUI 连接测试正确检测状态
- [ ] 云端 API 连接测试验证 Key
### **前端 UI**
- [ ] 可以切换到"🎨 生图"标签
- [ ] 模式切换卡片正常显示
- [ ] 点击"本地 ComfyUI"显示本地配置
- [ ] 点击"在线 API"显示云端配置
- [ ] 表单输入正常工作
- [ ] 工作流管理器显示默认工作流
- [ ] 可以上传工作流文件
- [ ] 可以删除工作流(非默认)
- [ ] 测试连接按钮正常工作
- [ ] 保存配置功能正常
### **响应式设计**
- [ ] 大屏幕(>768px双列布局
- [ ] 小屏幕(<768px单列布局
- [ ] 无页面级滚动条
- [ ] 侧边栏可独立滚动
- [ ] 无横向滚动
---
## 🐛 常见问题
### **Q1: 测试脚本提示"Connection refused"**
**原因**:后端服务未启动
**解决**
```bash
cd backend
uvicorn main:app --reload --port 8000
```
---
### **Q2: 上传工作流提示"Invalid ComfyUI workflow"**
**原因**JSON 格式不正确或缺少必要节点
**解决**
- 确保包含 `KSampler` 节点
- 使用 ComfyUI 的 "Save (API Format)" 导出
- 检查 JSON 语法是否正确
---
### **Q3: 删除工作流提示"Cannot delete default workflow"**
**这是正常的**!默认工作流受保护,不可删除。
要测试删除功能,请先上传一个自定义工作流,然后删除它。
---
### **Q4: ComfyUI 连接测试失败**
**可能原因**
1. ComfyUI 未启动
2. 地址或端口错误
3. Docker 网络问题
**解决**
```bash
# 检查 ComfyUI 是否运行
curl http://localhost:8188/system_stats
# Docker 环境下
docker ps | grep comfyui
docker logs comfyui
```
---
## 📊 测试结果解读
### **全部通过** ✅
```
总计: 6/6 通过
🎉 所有测试通过!
```
→ API 配置功能完全正常,可以开始使用
### **部分失败** ⚠️
```
总计: 4/6 通过
⚠️ 2 个测试失败,请检查日志
```
→ 查看失败的测试项,根据错误信息排查
### **全部失败** ❌
```
总计: 0/6 通过
```
→ 检查后端服务是否正常运行
→ 检查端口是否正确8000
→ 查看后端日志
---
## 🎯 下一步
测试通过后,你可以:
1. **启动前端**
```bash
cd frontend
npm run dev
```
2. **访问应用**
- 打开浏览器访问 `http://localhost:5173`
- 进入 API 配置页面
- 配置你的生图服务
3. **开始生图**
- 配置完成后
- 在聊天界面输入生图请求
- 等待图片生成
---
## 📝 附录
### **工作流文件格式**
必须是 ComfyUI API 格式的 JSON
```json
{
"node_id": {
"inputs": {...},
"class_type": "NodeType",
"_meta": {"title": "Display Name"}
}
}
```
### **必需的节点类型**
- `KSampler` - 采样器(必需)
- `CheckpointLoaderSimple` - 模型加载器
- `EmptyLatentImage` - 潜变量图像
- `CLIPTextEncode` - 文本编码器(正向和负向)
- `VAEDecode` - VAE 解码器
- `SaveImage` - 保存图像
### **API 端点列表**
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/api-config/comfyui/workflows` | 列出工作流 |
| POST | `/api/api-config/comfyui/workflows/upload` | 上传工作流 |
| DELETE | `/api/api-config/comfyui/workflows/{filename}` | 删除工作流 |
| GET | `/api/api-config/comfyui/workflows/{filename}` | 获取工作流详情 |
| POST | `/api/api-config/test-comfyui-connection` | 测试 ComfyUI |
| POST | `/api/api-config/test-cloud-connection` | 测试云端 API |
---
**祝测试顺利!** 🎉

View File

@@ -1,217 +0,0 @@
# 世界书分页功能 - 前端API调用检查报告
## ✅ 检查结果
**所有前端世界书分页功能都能正确发出请求到后端API**
---
## 📋 完整API映射表
### 1. 世界书管理
| 功能 | 前端Store函数 | HTTP方法 | API路径 | 后端路由函数 | 状态 |
|------|--------------|----------|---------|-------------|------|
| 获取世界书列表 | `fetchWorldBooks` | GET | `/api/worldbooks/` | `list_worldbooks` | ✅ |
| 获取指定世界书 | `fetchWorldBook` | GET | `/api/worldbooks/{name}` | `get_worldbook` | ✅ |
| 创建世界书 | `createWorldBook` | POST | `/api/worldbooks/` | `create_worldbook` | ✅ |
| 更新世界书 | `updateWorldBook` | PUT | `/api/worldbooks/{name}` | `update_worldbook` | ✅ |
| 删除世界书 | `deleteWorldBook` | DELETE | `/api/worldbooks/{name}` | `delete_worldbook` | ✅ |
### 2. 条目管理(含分页)
| 功能 | 前端Store函数 | HTTP方法 | API路径 | 后端路由函数 | 状态 |
|------|--------------|----------|---------|-------------|------|
| 获取条目列表(分页) | `fetchWorldBookEntries` | GET | `/api/worldbooks/{name}/entries?page={page}&page_size={page_size}` | `list_worldbook_entries` | ✅ |
| 获取指定条目 | `fetchWorldBookEntry` | GET | `/api/worldbooks/{name}/entries/{uid}` | `get_worldbook_entry` | ✅ |
| 创建条目 | `createWorldBookEntry` | POST | `/api/worldbooks/{name}/entries` | `create_worldbook_entry` | ✅ |
| 更新条目 | `updateWorldBookEntry` | PUT | `/api/worldbooks/{name}/entries/{uid}` | `update_worldbook_entry` | ✅ |
| 删除条目 | `deleteWorldBookEntry` | DELETE | `/api/worldbooks/{name}/entries/{uid}` | `delete_worldbook_entry` | ✅ |
### 3. 导入导出
| 功能 | 前端Store函数 | HTTP方法 | API路径 | 后端路由函数 | 状态 |
|------|--------------|----------|---------|-------------|------|
| 导入世界书 | `importWorldBook` | POST | `/api/worldbooks/{name}/import` | `import_worldbook` | ✅ |
| 导出世界书 | `exportWorldBook` | GET | `/api/worldbooks/{name}/export?format={format}` | `export_worldbook` | ✅ |
---
## 🔍 关键功能验证
### ✅ 下拉框读取所有世界书
**位置**: `WorldBook.jsx` 第547行
```jsx
{worldBooks.map(book => (
<div key={book.name} className="dropdown-item">
...
</div>
))}
```
**数据来源**:
- `worldBooks` 来自 Store (第8行)
- 通过 `fetchWorldBooks()` 加载 (第85行和第95行)
**加载时机**:
1. ✅ 组件挂载时立即加载(如果列表为空)
2. ✅ 切换到世界书标签页时重新加载
**API调用**:
```javascript
// WorldBookSlice.jsx 第135行
const response = await fetch(`/api/worldbooks/`);
```
**后端路由**:
```python
# worldbooksRoute.py 第28行
@router.get("/", response_model=List[Dict[str, Any]])
async def list_worldbooks():
```
---
## 🎯 分页功能验证
### 前端实现
**Store函数** (`WorldBookSlice.jsx` 第343-374行):
```javascript
fetchWorldBookEntries: async (name, page = 1, page_size = 20) => {
const response = await fetch(
`/api/worldbooks/${name}/entries?page=${page}&page_size=${page_size}`
);
// 解析响应并更新 state
}
```
**组件调用**:
- 选择世界书时: `fetchWorldBookEntries(book.name, 1, pageSize)` (第141行)
- 切换页码时: `fetchWorldBookEntries(currentWorldBook.name, newPage, pageSize)` (第247行)
- 改变每页数量: `fetchWorldBookEntries(currentWorldBook.name, 1, newPageSize)` (第259行)
**分页状态**:
```javascript
entriesPagination: {
total: data.total || 0,
page: data.page || 1,
page_size: data.page_size || 20,
total_pages: data.total_pages || 0
}
```
### 后端实现
**API路由** (`worldbooksRoute.py` 第108-128行):
```python
@router.get("/{name}/entries", response_model=Dict[str, Any])
async def list_worldbook_entries(
name: str,
page: int = 1,
page_size: int = 20
):
return worldbook_service.list_entries(name, page, page_size)
```
**服务层** (`worldbook_service.py` 第168-198行):
```python
def list_entries(name: str, page: int = 1, page_size: int = 20) -> Dict[str, Any]:
all_entries = data.get("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
}
```
---
## 📊 数据格式
### 后端返回格式(分页)
```json
{
"entries": [...],
"total": 30,
"page": 1,
"page_size": 20,
"total_pages": 2
}
```
### 前端State结构
```javascript
{
worldBooks: [], // 世界书列表
currentWorldBook: null, // 当前选中的世界书
currentEntries: [], // 当前页的条目列表
entriesPagination: { // 分页信息
total: 0,
page: 1,
page_size: 20,
total_pages: 0
}
}
```
---
## ✨ 优化记录
### 已完成的优化
1.**组件初始化加载**: 添加 `useEffect` 在组件挂载时立即加载世界书列表
2.**格式统一**: 所有世界书文件转换为内部格式存储
3.**代码简化**: 移除服务层的SillyTavern格式运行时兼容逻辑
4.**分页控件**: 添加完整的分页UI上一页、下一页、每页数量选择
5.**自动刷新**: 添加/更新/删除条目后自动刷新当前页
---
## 🧪 测试建议
### 手动测试清单
- [ ] 打开世界书页面,检查下拉框是否显示所有世界书
- [ ] 选择一个世界书,检查是否正确加载第一页条目
- [ ] 点击"下一页",检查是否加载第二页
- [ ] 改变每页数量10/20/50/100检查是否正确刷新
- [ ] 创建新条目,检查是否刷新当前页
- [ ] 更新条目,检查是否刷新当前页
- [ ] 删除条目,检查是否刷新当前页
- [ ] 切换到其他世界书,检查是否重置到第一页
### API测试
```bash
# 获取世界书列表
curl http://localhost:8000/api/worldbooks/
# 获取条目(分页)
curl "http://localhost:8000/api/worldbooks/卡立创-v5/entries?page=1&page_size=5"
```
---
## 📝 结论
**前端世界书分页的所有功能都能正确发出请求到后端API**
- 下拉框正确读取所有世界书
- 分页参数正确传递
- 数据格式匹配
- 错误处理完善
- 用户体验流畅

View File

@@ -1,18 +1,28 @@
from fastapi import APIRouter
from .routes import presetsRoute, chatsRoute, worldbooksRoute, apiConfigRoute, charactersRoute
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)
# 保留原有的其他路由
@router.get("/tool_bar/get_all_role_and_chat")

View File

@@ -2,24 +2,28 @@ from fastapi import APIRouter, HTTPException, UploadFile, File
from pydantic import BaseModel, Field
from typing import Dict, Optional, List, Any
import json
import os
import sys
from pathlib import Path
from core.config import settings
from cryptography.fernet import Fernet
import base64
from services.comfyui_workflow_manager import workflow_manager
from services.llm_model_service import LLMModelService
router = APIRouter(prefix="/api-config", tags=["API Configuration"])
# 加密密钥(实际项目中应该从环境变量读取)
ENCRYPTION_KEY = os.getenv('API_ENCRYPTION_KEY', Fernet.generate_key().decode())
fernet = Fernet(ENCRYPTION_KEY.encode() if isinstance(ENCRYPTION_KEY, str) else ENCRYPTION_KEY)
# 配置文件路径
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 配置项"""
@@ -50,32 +54,8 @@ class ProfileResponse(BaseModel):
apis: Dict[str, dict] # apiKey 字段会被移除或脱敏
def encrypt_api_key(api_key: str) -> str:
"""加密 API Key"""
if not api_key:
return ""
encrypted = fernet.encrypt(api_key.encode())
return base64.urlsafe_b64encode(encrypted).decode()
def decrypt_api_key(encrypted_key: str) -> str:
"""解密 API Key仅在后端内部使用"""
if not encrypted_key:
return ""
try:
decoded = base64.urlsafe_b64decode(encrypted_key.encode())
decrypted = fernet.decrypt(decoded)
return decrypted.decode()
except Exception:
return ""
def mask_api_key(api_key: str) -> str:
"""脱敏 API Key返回给前端"""
if not api_key or len(api_key) < 8:
return "****"
return api_key[:4] + "****" + api_key[-4:]
def load_profile(profile_id: str) -> Optional[dict]:
"""加载配置文件"""
@@ -119,29 +99,28 @@ def get_all_profiles():
@router.get("/profiles/{profile_id}", response_model=ProfileResponse)
def get_profile(profile_id: str):
"""获取单个配置文件API Key 已脱敏"""
"""获取单个配置文件(明文存储,不返回 API Key"""
profile = load_profile(profile_id)
if not profile:
raise HTTPException(status_code=404, detail="配置文件不存在")
# 脱敏所有 API Key
masked_apis = {}
# 移除 API Key 字段,不返回给前端
safe_apis = {}
for category, api_config in profile.get("apis", {}).items():
masked_config = api_config.copy()
if "apiKey" in masked_config and masked_config["apiKey"]:
masked_config["apiKey"] = mask_api_key(masked_config["apiKey"])
masked_apis[category] = masked_config
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": masked_apis
"apis": safe_apis
}
@router.post("/profiles", response_model=ProfileResponse)
def create_or_update_profile(request: ProfileSaveRequest):
"""创建或更新配置文件(增量更新)"""
"""创建或更新配置文件(增量更新,明文存储 API Key"""
# 加载现有配置
existing_profile = load_profile(request.profileId)
@@ -150,16 +129,11 @@ def create_or_update_profile(request: ProfileSaveRequest):
for category, api_config in request.apis.items():
api_config_dict = api_config.dict(exclude_none=True)
# 处理 API Key 加密
if api_config.apiKey and api_config.apiKey != "****":
# 如果是新的明文 key加密它
api_config_dict["apiKey"] = encrypt_api_key(api_config.apiKey)
elif api_config.apiKey == "****":
# 如果是脱敏的 key保留原有的加密 key
if category in existing_profile.get("apis", {}):
api_config_dict["apiKey"] = existing_profile["apis"][category].get("apiKey", "")
else:
api_config_dict.pop("apiKey", None)
# 如果前端传入了空的 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:
@@ -177,28 +151,25 @@ def create_or_update_profile(request: ProfileSaveRequest):
"apis": {}
}
# 添加所有 API 配置
# 添加所有 API 配置(明文存储)
for category, api_config in request.apis.items():
api_config_dict = api_config.dict(exclude_none=True)
if api_config_dict.get("apiKey"):
api_config_dict["apiKey"] = encrypt_api_key(api_config_dict["apiKey"])
profile_data["apis"][category] = api_config_dict
# 保存配置文件
save_profile(request.profileId, profile_data)
# 返回脱敏后的数据
masked_apis = {}
# 返回不包含 API Key 的数据
safe_apis = {}
for category, api_config in profile_data.get("apis", {}).items():
masked_config = api_config.copy()
if "apiKey" in masked_config and masked_config["apiKey"]:
masked_config["apiKey"] = mask_api_key(masked_config["apiKey"])
masked_apis[category] = masked_config
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": masked_apis
"apis": safe_apis
}
@@ -217,13 +188,31 @@ def delete_profile(profile_id: str):
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_config.apiKey or "",
api_key=api_key_to_use,
api_url=api_config.apiUrl
)

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,12 +1,13 @@
from fastapi import APIRouter, HTTPException, status
from pathlib import Path
try:
from backend.services.chat_service import ChatService
from backend.core.config import settings
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
# Docker环境直接从当前目录导入
from services.chat_service import ChatService
from core.config import settings
router = APIRouter(prefix="/chat", tags=["chat"])
@@ -14,11 +15,23 @@ 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 chat_service.list_all_chats()
# 注意:路由定义顺序很重要!更具体的路由(更多参数)必须放在前面
@router.get("/{role_name}/{chat_name}")
async def get_chat(role_name: str, chat_name: str):
"""获取指定聊天的完整内容"""
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):
"""获取指定角色的所有聊天列表"""
@@ -30,13 +43,6 @@ async def list_role_chats(role_name: str):
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{role_name}/{chat_name}")
async def get_chat(role_name: str, chat_name: str):
"""获取指定聊天的完整内容"""
try:
return chat_service.get_chat(role_name, chat_name)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.post("/{role_name}", status_code=status.HTTP_201_CREATED)
async def create_chat(role_name: str, chat_data: dict):
@@ -48,18 +54,21 @@ async def create_chat(role_name: str, chat_data: dict):
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):
"""获取聊天的所有消息"""
@@ -69,6 +78,7 @@ async def list_messages(role_name: str, chat_name: str):
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):
"""获取指定楼层的消息"""
@@ -81,6 +91,7 @@ async def get_message(role_name: str, chat_name: str, floor: int):
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):
"""向聊天添加新消息"""
@@ -91,6 +102,7 @@ async def add_message(role_name: str, chat_name: str, message_data: dict):
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):
"""更新指定楼层的消息"""
@@ -101,6 +113,7 @@ async def update_message(role_name: str, chat_name: str, floor: int, update_data
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):
"""删除指定楼层的消息"""
@@ -110,3 +123,56 @@ async def delete_message(role_name: str, chat_name: str, floor: int):
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

@@ -38,6 +38,80 @@ async def list_worldbooks():
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):
"""
@@ -213,41 +287,3 @@ async def import_worldbook(name: str, file: UploadFile = File(...)):
except Exception as e:
logger.error(f"Failed to import 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))

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,21 +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"
# 角色卡目录
CHARACTERS_PATH = DATA_PATH / "characters"
# 角色卡目录(已合并到 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 = [
@@ -72,10 +81,18 @@ class Settings:
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()

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

@@ -136,11 +136,23 @@ class CharacterCard(BaseModel):
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="替代问候语数组")
tags: 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="最后聊天时间戳")
@@ -150,29 +162,69 @@ class CharacterCard(BaseModel):
# ==================== 聊天记录 (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 角色名称")
tableData: Optional[Dict[str, Any]] = Field(None, description="表格数据 (对应 outputSchema)")
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 统计等功能。
单条对话消息支持多版本 (swipes)、token 统计、历史记录总结等功能。
"""
id: str = Field(..., description="消息唯一标识符 (UUID)")
name: str = Field(..., description="发送者名称")
@@ -186,6 +238,11 @@ class ChatMessage(BaseModel):
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):
"""
@@ -299,3 +356,84 @@ class ChatRAGConfig(BaseModel):
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

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

View File

@@ -50,6 +50,7 @@ class CharacterCardConverter:
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,

View File

@@ -95,11 +95,15 @@ class CharacterService:
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', []),
tags=data.get('tags', []),
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,
@@ -177,8 +181,8 @@ class CharacterService:
更新角色卡
Args:
name: 角色名
updates: 更新的字段
name: 角色名(旧名称,用于定位文件夹)
updates: 更新的字段(可以包含 name 字段来重命名)
Returns:
更新后的 CharacterCard 对象
@@ -193,6 +197,29 @@ class CharacterService:
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())

View File

@@ -10,6 +10,8 @@ import logging
from datetime import datetime
import uuid
from core.config import settings
logger = logging.getLogger(__name__)
@@ -57,7 +59,7 @@ class ChatService:
logger.error(f"处理角色目录 {role_dir.name} 时出错: {str(e)}")
continue
return {"chat": [{"role_name": role, **chat} for role, chats in result.items() for chat in chats]}
return result
def _get_chat_summary(self, role_name: str, chat_name: str) -> Optional[Dict]:
"""
@@ -156,6 +158,7 @@ class ChatService:
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", ""),
@@ -169,6 +172,41 @@ class ChatService:
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:
"""
创建新聊天
@@ -192,6 +230,21 @@ class ChatService:
# 创建角色目录
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",
@@ -207,7 +260,8 @@ class ChatService:
"timedWorldInfo": {},
"variables": {},
"tainted": False,
"lastInContextMessageId": -1
"lastInContextMessageId": -1,
"tags": tags # ✅ 使用标签数组替代 tableHeaders/tableDefaults/tableData
}
# 写入header
@@ -381,3 +435,225 @@ class ChatService:
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,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

@@ -27,18 +27,35 @@ class LLMModelService:
if not base_url:
base_url = "https://api.openai.com/v1"
# 确保 base_url 以 /v1 结尾
if not base_url.endswith('/v1'):
base_url = base_url.rstrip('/') + '/v1'
# 规范化 base_url:确保有协议前缀
base_url = base_url.strip()
if not base_url.startswith(('http://', 'https://')):
base_url = 'https://' + base_url
response = requests.get(
f"{base_url}/models",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
},
timeout=10
)
# 移除末尾的斜杠和常见 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}")
@@ -46,14 +63,8 @@ class LLMModelService:
data = response.json()
models = [model['id'] for model in data.get('data', [])]
# 过滤出聊天模型(可选)
chat_models = [
m for m in models
if any(keyword in m.lower() for keyword in ['gpt', 'chat'])
]
# 如果没有找到聊天模型,返回所有模型
return chat_models if chat_models else models
# 返回所有模型,不做过滤
return models
except Exception as e:
raise Exception(f"获取 OpenAI 模型列表失败: {str(e)}")
@@ -132,6 +143,9 @@ class LLMModelService:
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'

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

@@ -132,14 +132,14 @@ class PromptAssembler:
# Pos 4: AN Top
for entry in grouped.get(self.POS_AN_TOP, []):
parts.append(entry.content)
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(entry.content)
parts.append(str(entry.content) if entry.content else "")
return "\n".join(parts)
@@ -147,10 +147,15 @@ class PromptAssembler:
"""
在聊天历史的指定深度插入条目 (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 分组插入

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

@@ -232,6 +232,32 @@ class WorldBookService:
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]:
"""

View File

@@ -4,9 +4,12 @@ LLM 客户端工具
提供统一的 LLM 接口,支持多种模型提供商。
使用 LangChain 的 ChatModel 抽象,简化不同厂商 API 的调用。
"""
from typing import Optional
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(
@@ -86,3 +89,249 @@ def get_creative_llm(provider: str = "openai") -> BaseChatModel:
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

View File

@@ -1,201 +0,0 @@
"""
检查前端世界书功能与后端API的匹配情况
"""
print("=" * 80)
print("前端世界书功能与后端API路由匹配检查")
print("=" * 80)
# 前端Store中的API调用
frontend_calls = [
{
"功能": "获取世界书列表",
"方法": "GET",
"路径": "/api/worldbooks/",
"Store函数": "fetchWorldBooks"
},
{
"功能": "获取指定世界书",
"方法": "GET",
"路径": "/api/worldbooks/{name}",
"Store函数": "fetchWorldBook"
},
{
"功能": "创建世界书",
"方法": "POST",
"路径": "/api/worldbooks/",
"Store函数": "createWorldBook",
"备注": "FormData: name, is_global, file"
},
{
"功能": "更新世界书",
"方法": "PUT",
"路径": "/api/worldbooks/{name}",
"Store函数": "updateWorldBook",
"备注": "FormData: is_global, file"
},
{
"功能": "删除世界书",
"方法": "DELETE",
"路径": "/api/worldbooks/{name}",
"Store函数": "deleteWorldBook"
},
{
"功能": "获取世界书条目(分页)",
"方法": "GET",
"路径": "/api/worldbooks/{name}/entries?page={page}&page_size={page_size}",
"Store函数": "fetchWorldBookEntries",
"参数": "page=1, page_size=20 (默认)"
},
{
"功能": "获取指定条目",
"方法": "GET",
"路径": "/api/worldbooks/{name}/entries/{uid}",
"Store函数": "fetchWorldBookEntry"
},
{
"功能": "创建条目",
"方法": "POST",
"路径": "/api/worldbooks/{name}/entries",
"Store函数": "createWorldBookEntry",
"备注": "JSON body: entryData (包含trigger_config)"
},
{
"功能": "更新条目",
"方法": "PUT",
"路径": "/api/worldbooks/{name}/entries/{uid}",
"Store函数": "updateWorldBookEntry",
"备注": "JSON body: entryData (包含trigger_config)"
},
{
"功能": "删除条目",
"方法": "DELETE",
"路径": "/api/worldbooks/{name}/entries/{uid}",
"Store函数": "deleteWorldBookEntry"
},
{
"功能": "导入世界书",
"方法": "POST",
"路径": "/api/worldbooks/{name}/import",
"Store函数": "importWorldBook",
"备注": "FormData: file"
},
{
"功能": "导出世界书",
"方法": "GET",
"路径": "/api/worldbooks/{name}/export?format={format}",
"Store函数": "exportWorldBook",
"参数": "format='internal''sillytavern'"
}
]
# 后端API路由
backend_routes = [
{"方法": "GET", "路径": "/worldbooks/", "函数": "list_worldbooks"},
{"方法": "GET", "路径": "/worldbooks/{name}", "函数": "get_worldbook"},
{"方法": "POST", "路径": "/worldbooks/", "函数": "create_worldbook"},
{"方法": "PUT", "路径": "/worldbooks/{name}", "函数": "update_worldbook"},
{"方法": "DELETE", "路径": "/worldbooks/{name}", "函数": "delete_worldbook"},
{"方法": "GET", "路径": "/worldbooks/{name}/entries", "函数": "list_worldbook_entries", "参数": "page, page_size"},
{"方法": "GET", "路径": "/worldbooks/{name}/entries/{uid}", "函数": "get_worldbook_entry"},
{"方法": "POST", "路径": "/worldbooks/{name}/entries", "函数": "create_worldbook_entry"},
{"方法": "PUT", "路径": "/worldbooks/{name}/entries/{uid}", "函数": "update_worldbook_entry"},
{"方法": "DELETE", "路径": "/worldbooks/{name}/entries/{uid}", "函数": "delete_worldbook_entry"},
{"方法": "POST", "路径": "/worldbooks/{name}/import", "函数": "import_worldbook"},
{"方法": "GET", "路径": "/worldbooks/{name}/export", "函数": "export_worldbook", "参数": "format"}
]
print("\n✅ 前端API调用清单:\n")
for i, call in enumerate(frontend_calls, 1):
print(f"{i}. {call['功能']}")
print(f" {call['方法']} {call['路径']}")
print(f" Store: {call['Store函数']}")
if '备注' in call:
print(f" 备注: {call['备注']}")
if '参数' in call:
print(f" 参数: {call['参数']}")
print()
print("\n✅ 后端API路由清单:\n")
for i, route in enumerate(backend_routes, 1):
params = f" (参数: {route['参数']})" if '参数' in route else ""
print(f"{i}. {route['方法']} /api{route['路径']}{params}")
print(f" 函数: {route['函数']}")
print()
# 检查匹配情况
print("\n" + "=" * 80)
print("匹配检查结果:")
print("=" * 80)
all_matched = True
for call in frontend_calls:
# 提取前端路径模板(去掉参数部分)
frontend_path = call['路径'].split('?')[0].replace('/api', '')
frontend_method = call['方法']
# 在后端路由中查找匹配
matched = False
for route in backend_routes:
backend_path_template = route['路径']
backend_method = route['方法']
# 简单匹配:比较方法和路径模式
if frontend_method == backend_method:
# 检查路径是否匹配(考虑参数占位符)
frontend_parts = frontend_path.strip('/').split('/')
backend_parts = backend_path_template.strip('/').split('/')
if len(frontend_parts) == len(backend_parts):
match = True
for fp, bp in zip(frontend_parts, backend_parts):
# 如果后端是占位符(以{开头),则匹配
if bp.startswith('{') and bp.endswith('}'):
continue
# 否则必须完全匹配
if fp != bp:
match = False
break
if match:
matched = True
break
status = "" if matched else ""
print(f"{status} {call['功能']}: {frontend_method} {frontend_path}")
if not matched:
all_matched = False
print(f" ⚠️ 未找到匹配的后端路由!")
print("\n" + "=" * 80)
if all_matched:
print("✅ 所有前端API调用都有对应的后端路由")
else:
print("❌ 存在不匹配的API调用请检查")
print("=" * 80)
# 检查关键功能
print("\n📋 关键功能检查:")
print("=" * 80)
key_features = [
("世界书列表加载", "GET /api/worldbooks/"),
("选择世界书并加载条目", "GET /api/worldbooks/{name}/entries?page=1&page_size=20"),
("创建世界书", "POST /api/worldbooks/"),
("删除世界书", "DELETE /api/worldbooks/{name}"),
("创建条目", "POST /api/worldbooks/{name}/entries"),
("更新条目", "PUT /api/worldbooks/{name}/entries/{uid}"),
("删除条目", "DELETE /api/worldbooks/{name}/entries/{uid}"),
("分页切换", "GET /api/worldbooks/{name}/entries?page=N&page_size=M"),
("导入世界书", "POST /api/worldbooks/{name}/import"),
("导出世界书", "GET /api/worldbooks/{name}/export?format=internal")
]
for feature, api_call in key_features:
print(f"{feature}")
print(f" API: {api_call}")
print("\n" + "=" * 80)
print("结论: 前端世界书分页的所有功能都能正确发出请求到后端API")
print("=" * 80)

View File

@@ -1,49 +0,0 @@
"""
检查世界书路径和文件
"""
from pathlib import Path
# 项目根目录
PROJECT_ROOT = Path(r'D:\progarm\python\llm_workflow_engine')
DATA_PATH = PROJECT_ROOT / 'data'
WORLDBOOKS_PATH = DATA_PATH / 'worldbooks'
print("=" * 60)
print("世界书路径检查")
print("=" * 60)
print(f"\n1. 项目根目录: {PROJECT_ROOT}")
print(f" 存在: {PROJECT_ROOT.exists()}")
print(f"\n2. 数据目录: {DATA_PATH}")
print(f" 存在: {DATA_PATH.exists()}")
print(f"\n3. 世界书目录: {WORLDBOOKS_PATH}")
print(f" 存在: {WORLDBOOKS_PATH.exists()}")
if WORLDBOOKS_PATH.exists():
json_files = list(WORLDBOOKS_PATH.glob("*.json"))
print(f" JSON文件数量: {len(json_files)}")
if json_files:
print(f" 文件列表:")
for f in json_files:
print(f" - {f.name} ({f.stat().st_size} bytes)")
else:
print(f" ⚠️ 世界书目录为空没有JSON文件")
# 检查目录下是否有子目录
subdirs = list(WORLDBOOKS_PATH.iterdir())
if subdirs:
print(f" 子目录/文件:")
for item in subdirs:
print(f" - {item.name} ({'目录' if item.is_dir() else '文件'})")
else:
print(f" ❌ 世界书目录不存在!")
# 检查data目录下有什么
if DATA_PATH.exists():
print(f"\n4. data目录内容:")
for item in DATA_PATH.iterdir():
print(f" - {item.name} ({'目录' if item.is_dir() else '文件'})")
print("\n" + "=" * 60)

View File

@@ -1,53 +0,0 @@
"""
清空默认头像图片中的嵌入JSON数据
"""
from PIL import Image
import io
def clear_png_text_data(png_path):
"""
清空PNG文件中的tEXt文本数据包括嵌入的JSON
Args:
png_path: PNG文件路径
"""
try:
# 打开图片
img = Image.open(png_path)
print(f"原始PNG文本数据键: {list(img.text.keys()) if img.text else ''}")
# 创建一个新的图片对象,复制像素数据但不复制文本数据
if img.mode == 'RGBA':
new_img = Image.new('RGBA', img.size)
else:
new_img = Image.new('RGB', img.size)
# 复制像素数据
new_img.paste(img)
# 确保新图片没有文本数据
new_img.text = {}
# 保存回原文件
buffer = io.BytesIO()
new_img.save(buffer, format='PNG')
buffer.seek(0)
with open(png_path, 'wb') as f:
f.write(buffer.read())
# 验证是否已清空
verify_img = Image.open(png_path)
print(f"清空后PNG文本数据键: {list(verify_img.text.keys()) if verify_img.text else ''}")
print(f"✓ 成功清空 {png_path} 中的JSON数据")
except Exception as e:
print(f"✗ 处理失败: {e}")
import traceback
traceback.print_exc()
if __name__ == '__main__':
png_file = 'data/characters/defult.png'
print(f"正在处理: {png_file}")
clear_png_text_data(png_file)

View File

@@ -1,87 +0,0 @@
"""
将现有的 SillyTavern 格式世界书转换为内部格式
"""
import json
from pathlib import Path
import sys
# 添加backend目录到Python路径
backend_dir = Path(__file__).parent / "backend"
sys.path.insert(0, str(backend_dir))
from models.converters import WorldBookConverter
WORLDBOOKS_PATH = Path(r'D:\progarm\python\llm_workflow_engine\data\worldbooks')
print("=" * 60)
print("世界书格式转换工具")
print("=" * 60)
# 查找所有JSON文件
json_files = list(WORLDBOOKS_PATH.glob("*.json"))
print(f"\n找到 {len(json_files)} 个世界书文件\n")
converted_count = 0
skipped_count = 0
error_count = 0
for file_path in json_files:
print(f"处理: {file_path.name}")
try:
# 读取文件
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
# 检测格式
format_type = WorldBookConverter.detect_format(data)
print(f" 当前格式: {format_type}")
if format_type == "sillytavern":
# 需要转换
name = data.get("name", file_path.stem)
print(f" 世界书名称: {name}")
print(f" 条目数量: {len(data.get('entries', {}))}")
# 转换为内部格式
internal_data = WorldBookConverter.st_to_internal(data, name)
# 备份原文件
backup_path = file_path.with_suffix('.json.bak')
file_path.rename(backup_path)
print(f" ✓ 已备份原文件为: {backup_path.name}")
# 保存转换后的文件
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(internal_data, f, ensure_ascii=False, indent=2)
print(f" ✓ 转换完成并保存")
print(f" - 新格式: internal")
print(f" - 条目数量: {len(internal_data.get('entries', []))}")
converted_count += 1
elif format_type == "internal":
print(f" 已是内部格式,跳过")
skipped_count += 1
else:
print(f" ⚠️ 未知格式,跳过")
skipped_count += 1
except Exception as e:
print(f" ❌ 错误: {e}")
import traceback
traceback.print_exc()
error_count += 1
print()
print("=" * 60)
print("转换完成统计:")
print(f" ✓ 转换成功: {converted_count} 个文件")
print(f" - 跳过(已是内部格式): {skipped_count} 个文件")
print(f" ✗ 转换失败: {error_count} 个文件")
print("=" * 60)
if converted_count > 0:
print("\n提示: 原文件已备份为 .bak 后缀,确认无误后可删除")

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 评价维度及准则,用于模型自检与优化表述。"
}
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -1,23 +0,0 @@
{
"id": "test-character-1",
"name": "测试角色1",
"description": "这是一个测试角色,用于验证角色卡功能",
"personality": "友好、乐于助人、幽默",
"scenario": "日常对话场景",
"first_mes": "你好我是测试角色1很高兴见到你",
"mes_example": "",
"categories": ["测试", "示例"],
"tags": ["test", "demo", "friendly"],
"worldInfoId": null,
"outputSchema": null,
"avatarPath": null,
"alternate_greetings": [
"嗨!有什么我可以帮你的吗?",
"欢迎来到测试世界!"
],
"createdAt": 1700000000,
"updatedAt": 1700000000,
"lastChatAt": null,
"isFavorite": false,
"version": 1
}

View File

@@ -1,4 +0,0 @@
{"user_name": "User", "character_name": "测试角色1", "create_date": "2026-04-30T15:00:00Z"}
{"name": "测试角色1", "is_user": false, "send_date": "2026-04-30T15:00:01Z", "mes": "你好我是测试角色1很高兴见到你"}
{"name": "User", "is_user": true, "send_date": "2026-04-30T15:00:10Z", "mes": "你好!今天过得怎么样?"}
{"name": "测试角色1", "is_user": false, "send_date": "2026-04-30T15:00:15Z", "mes": "我很好,谢谢关心!你呢?"}

View File

@@ -1,20 +0,0 @@
{
"id": "test-character-2",
"name": "测试角色2",
"description": "第二个测试角色,有不同的标签",
"personality": "严肃、专业、认真",
"scenario": "工作场景",
"first_mes": "您好我是测试角色2请问有什么工作需要处理",
"mes_example": "",
"categories": ["测试", "工作"],
"tags": ["test", "professional", "work"],
"worldInfoId": null,
"outputSchema": null,
"avatarPath": null,
"alternate_greetings": [],
"createdAt": 1700000100,
"updatedAt": 1700000100,
"lastChatAt": null,
"isFavorite": true,
"version": 1
}

View File

@@ -1,4 +0,0 @@
{"user_name": "User", "character_name": "测试角色2", "create_date": "2026-04-30T16:00:00Z"}
{"name": "测试角色2", "is_user": false, "send_date": "2026-04-30T16:00:01Z", "mes": "您好我是测试角色2请问有什么工作需要处理"}
{"name": "User", "is_user": true, "send_date": "2026-04-30T16:00:10Z", "mes": "帮我分析一下这个数据"}
{"name": "测试角色2", "is_user": false, "send_date": "2026-04-30T16:00:20Z", "mes": "好的,请提供数据,我会进行专业分析。"}

View File

@@ -1,2 +0,0 @@
{"user_name": "User", "character_name": "测试角色2", "integrity": "644e9983-2102-4608-aeb5-64016c1ba92a", "chat_id_hash": "3f37a950-dda3-4341-97a8-50e58da796ce", "note_prompt": "", "note_interval": 0, "note_position": 0, "note_depth": 0, "note_role": 0, "extensions": {}, "timedWorldInfo": {}, "variables": {}, "tainted": false, "lastInContextMessageId": -1}
{"name": "测试角色2", "is_user": false, "is_system": false, "floor": 1, "send_date": "1777569143941", "mes": "您好我是测试角色2请问有什么工作需要处理", "extra": {}, "swipes": [], "swipe_id": 0}

View File

@@ -1,22 +0,0 @@
{
"id": "test-character-3",
"name": "测试角色3",
"description": "第三个测试角色与角色1有相同的tag",
"personality": "活泼、开朗、爱开玩笑",
"scenario": "娱乐场景",
"first_mes": "嘿嘿我是测试角色3让我们来玩吧",
"mes_example": "",
"categories": ["测试", "娱乐"],
"tags": ["test", "demo", "fun"],
"worldInfoId": null,
"outputSchema": null,
"avatarPath": null,
"alternate_greetings": [
"哟呼!"
],
"createdAt": 1700000200,
"updatedAt": 1700000200,
"lastChatAt": null,
"isFavorite": false,
"version": 1
}

View File

@@ -1,2 +0,0 @@
{"user_name": "User", "character_name": "测试角色3", "integrity": "dc677e4e-dd79-43ad-bbf9-ca886d176a0d", "chat_id_hash": "2fa6e72e-dce1-4f70-9e2d-0bc0ebce3bc1", "note_prompt": "", "note_interval": 0, "note_position": 0, "note_depth": 0, "note_role": 0, "extensions": {}, "timedWorldInfo": {}, "variables": {}, "tainted": false, "lastInContextMessageId": -1}
{"name": "测试角色3", "is_user": false, "is_system": false, "floor": 1, "send_date": "1777569143944", "mes": "嘿嘿我是测试角色3让我们来玩吧", "extra": {}, "swipes": [], "swipe_id": 0}

BIN
data/chat/default.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

1
data/encryption_key.txt Normal file
View File

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

View File

@@ -1,218 +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,
"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": [
"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": [
{
"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": "main",
"enabled": true
},
{
"name": "Auxiliary Prompt",
"system_prompt": true,
"role": "system",
"content": "",
"identifier": "nsfw"
"identifier": "worldInfoBefore",
"enabled": true
},
{
"identifier": "dialogueExamples",
"name": "Chat Examples",
"system_prompt": true,
"marker": true
"identifier": "charDescription",
"enabled": true
},
{
"name": "Post-History Instructions",
"system_prompt": true,
"role": "system",
"content": "",
"identifier": "jailbreak"
"identifier": "charPersonality",
"enabled": true
},
{
"identifier": "chatHistory",
"name": "Chat History",
"system_prompt": true,
"marker": true
"identifier": "scenario",
"enabled": true
},
{
"identifier": "worldInfoAfter",
"name": "World Info (after)",
"system_prompt": true,
"marker": true
"identifier": "enhanceDefinitions",
"enabled": false
},
{
"identifier": "worldInfoBefore",
"name": "World Info (before)",
"system_prompt": true,
"marker": true
"identifier": "nsfw",
"enabled": 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
"identifier": "worldInfoAfter",
"enabled": true
},
{
"identifier": "charDescription",
"name": "Char Description",
"system_prompt": true,
"marker": true
"identifier": "dialogueExamples",
"enabled": true
},
{
"identifier": "charPersonality",
"name": "Char Personality",
"system_prompt": true,
"marker": true
"identifier": "chatHistory",
"enabled": true
},
{
"identifier": "scenario",
"name": "Scenario",
"system_prompt": true,
"marker": true
},
{
"identifier": "personaDescription",
"name": "Persona Description",
"system_prompt": true,
"marker": true
"identifier": "jailbreak",
"enabled": true
}
],
"prompt_order": [
]
},
{
"character_id": 100001,
"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
}
]
"identifier": "main",
"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
}
]
"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
]
}
],
"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
}
}

10
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,10 @@
# 开发环境 override可选
# 用法: docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
#
# 与主 compose 策略一致:源码 volume 挂载 + HMR/reload启动时不跑 npm install。
# 依赖变更: scripts/docker-rebuild.ps1 -Service frontend
# 或 docker compose exec frontend npm install && docker compose restart frontend
services:
frontend:
command: npm run dev -- --host 0.0.0.0

View File

@@ -36,12 +36,11 @@ services:
- "23338:5173"
volumes:
- ./frontend:/app
- /app/node_modules
- node_modules:/app/node_modules
environment:
- NODE_ENV=development
- VITE_API_URL=http://backend:8000
- VITE_WS_URL=ws://backend:8000
command: sh -c "npm install && npm run dev -- --host 0.0.0.0"
# 依赖在镜像构建时写入 node_modules volumepackage.json 变更见 docs/DOCKER_DEV.md
command: npm run dev -- --host 0.0.0.0
depends_on:
backend:
condition: service_healthy

227
docs/DOCKER_DEV.md Normal file
View File

@@ -0,0 +1,227 @@
# Docker 开发指南Windows / Docker Desktop
本文说明如何在 **不重启 Docker Desktop** 的前提下进行日常开发。绝大多数代码改动无需任何容器操作;需要时只需重启**单个容器**。
---
## 核心原则
| 场景 | 需要做什么 | 是否需要重启 Docker Desktop |
|------|-----------|---------------------------|
| 修改 Python 后端代码 | **什么都不做**uvicorn `--reload` 自动重载) | ❌ 不需要 |
| 修改 React 前端代码 | **什么都不做**Vite HMR 热更新) | ❌ 不需要 |
| 容器异常 / 需要刷新进程 | `docker compose restart backend``frontend` | ❌ 不需要 |
| 修改 `Dockerfile` 或依赖文件 | `docker compose up -d --build <service>` | ❌ 不需要 |
| Docker 引擎崩溃 / 端口被占用且无法释放 | 见下文「极少需要重启 Docker Desktop」 | ⚠️ 极少需要 |
**日常开发不要重启 Docker Desktop。** 那是 Windows + WSL2 下最慢、最打断节奏的操作。
---
## 端口对照表
`docker-compose.yml` 一致:
| 服务 | 容器内端口 | 宿主机端口 | 访问地址 |
|------|-----------|-----------|---------|
| backend | 8000 | **23337** | http://localhost:23337 |
| frontend | 5173 | **23338** | http://localhost:23338 |
健康检查:`http://localhost:23337/health`
---
## 代码改动:自动生效
### 后端FastAPI + uvicorn
- 启动命令:`uvicorn main:app --host 0.0.0.0 --port 8000 --reload`
- 源码通过 volume 挂载:`./backend``/app`
- 保存 `.py` 文件后uvicorn 自动检测并重载,**无需重启容器**
### 前端Vite + React
- 启动命令:`npm run dev -- --host 0.0.0.0`
- 源码通过 volume 挂载:`./frontend``/app`
- `node_modules` 保存在独立 volume 中,不随宿主机目录覆盖
- 保存 `.jsx` / `.css` 等文件后Vite HMR 自动更新浏览器,**无需重启容器**
---
## 何时只需重启容器(不是 Docker Desktop
以下情况用 `scripts/docker-restart.ps1``docker compose restart` 即可:
- 修改了环境变量(`docker-compose.yml` 中的 `environment`)并已 `docker compose up -d`
- 容器内进程卡死、内存泄漏
- 前端 HMR 断开、WebSocket 连接异常
- 后端 reload 失败(极少数语法错误导致 worker 无法恢复)
```powershell
# 重启单个服务
.\scripts\docker-restart.ps1 -Service backend
.\scripts\docker-restart.ps1 -Service frontend
# 重启全部
.\scripts\docker-restart.ps1 -Service all
# 或直接
docker compose restart backend
docker compose restart frontend
```
---
## 何时需要重新构建镜像
以下变更需要 **rebuild**,仍然 **不需要重启 Docker Desktop**
| 变更内容 | 命令 |
|---------|------|
| `backend/requirements.txt` | `.\scripts\docker-rebuild.ps1 -Service backend` |
| `backend/Dockerfile` | 同上 |
| `frontend/package.json` / `package-lock.json` | `.\scripts\docker-rebuild.ps1 -Service frontend` |
| `frontend/Dockerfile` | 同上 |
```powershell
# 等价命令
docker compose up -d --build backend
docker compose up -d --build frontend
```
### 前端依赖变更package.json 改了但不想 rebuild
若只新增了 npm 包、尚未 rebuild 镜像,可在运行中的容器内安装一次:
```powershell
docker compose exec frontend npm install
docker compose restart frontend
```
首次 `docker compose up` 时,镜像构建阶段会执行 `npm install`,依赖写入 `node_modules` volume之后日常启动**不再**每次 `npm install`
---
## 极少需要重启 Docker Desktop 的情况
仅在以下情况才考虑重启 Docker Desktop 或 WSL
1. **Docker 引擎无响应**`docker ps` 一直挂起或报错 `Cannot connect to the Docker daemon`
2. **端口被占用且 compose down 无法释放** — 例如 23337/23338 被僵尸进程占用
3. **WSL2 后端异常** — 内存耗尽、磁盘满、网络栈故障
**优先尝试的替代方案(由轻到重):**
```powershell
# 1. 停止并重新启动 compose 栈(不碰 Docker Desktop
docker compose down
docker compose up -d
# 2. 查看日志定位问题
.\scripts\docker-logs.ps1
.\scripts\docker-logs.ps1 -Service backend
# 3. 仅当 Docker 完全无响应时,关闭 WSL会连带重启 Docker 引擎)
wsl --shutdown
# 然后重新打开 Docker Desktop
```
---
## 更快的日常开发方式(推荐)
Docker 适合「全栈联调 / 验收环境」。纯改代码时,**本地直接跑**通常更快:
### 后端(本地)
```powershell
python -m venv venv
.\venv\Scripts\Activate.ps1
pip install -r backend\requirements.txt
cd backend
python main.py
```
### 前端(本地)
```powershell
cd frontend
npm install
npm run dev
```
本地开发时前端默认代理到本地后端Docker 栈仅在需要容器化联调时使用。
---
## 辅助脚本速查
所有脚本位于 `scripts/`,在项目根目录执行:
| 脚本 | 用途 |
|------|------|
| `docker-up.ps1` | 后台启动全部服务 |
| `docker-restart.ps1` | 重启 backend / frontend / all**不**重启 Docker Desktop |
| `docker-logs.ps1` | 跟踪日志(可选 `-Service backend` |
| `docker-rebuild.ps1` | 重新构建并启动指定服务 |
### 典型一日工作流
```powershell
# 早上第一次
.\scripts\docker-up.ps1
# 白天改代码 — 保存即可backend/frontend 自动更新
# 偶尔 HMR 或 reload 异常
.\scripts\docker-restart.ps1 -Service frontend
# 改了 requirements.txt
.\scripts\docker-rebuild.ps1 -Service backend
# 下班
docker compose down
```
---
## 使用 docker-compose.dev.yml可选
开发环境可使用 override 文件,与主 compose 合并:
```powershell
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
```
内容与主文件优化策略一致;便于将来追加仅开发用的配置而不改动默认 compose。
---
## 常见问题
### Q: 改了前端代码但页面没更新?
1. 确认保存了文件
2. 浏览器硬刷新Ctrl+Shift+R
3. `.\scripts\docker-restart.ps1 -Service frontend`
4. 查看日志:`.\scripts\docker-logs.ps1 -Service frontend`
### Q: 改了后端代码但 API 行为没变?
1. 查看 backend 日志是否有 reload 报错
2. `.\scripts\docker-restart.ps1 -Service backend`
3. 若改了依赖,需 rebuild
### Q: 每次启动 frontend 都很慢?
旧版 compose 在每次容器启动时执行 `npm install`。当前配置已改为直接 `npm run dev`;依赖在**镜像构建时**或**手动 exec npm install** 时装入 volume。若仍慢检查是否误删了 `node_modules` volume
```powershell
docker volume ls | Select-String node_modules
```
### Q: 必须重启 Docker Desktop 吗?
**正常代码编辑:不需要。**
**依赖 / Dockerfile 变更rebuild 容器即可。**
**只有 Docker 引擎本身故障时才考虑重启 Docker Desktop 或 `wsl --shutdown`。**

218
frontend/Z_INDEX_GUIDE.md Normal file
View File

@@ -0,0 +1,218 @@
# Z-Index 层级规范文档
## 📋 概述
本文档定义了项目中所有 z-index 的使用规范,确保层级关系清晰、一致、可维护。
## 🎯 设计原则
1. **分层管理**:将 z-index 划分为 5 个主要层级,每层预留充足空间
2. **语义化命名**:使用有意义的变量名,而非魔法数字
3. **统一来源**:所有 z-index 值统一定义在 `z-index.css`
4. **易于扩展**:每层之间至少预留 100 的空间,方便插入新层级
## 📊 层级划分
### 1⃣ 基础层 (0-99)
用于页面背景、基础布局等底层元素
| 变量名 | 值 | 用途 |
|--------|-----|------|
| `--z-background` | 0 | 最底层 - 背景装饰 |
| `--z-base-content` | 1 | 基础内容层 - 普通文本、图片 |
| `--z-divider` | 10 | 分割线、边框装饰 |
**使用场景:**
- 页面背景渐变
- 基础卡片容器
- 列表项默认状态
---
### 2⃣ 组件层 (100-999)
用于常规 UI 组件,如下拉菜单、悬浮提示等
| 变量名 | 值 | 用途 |
|--------|-----|------|
| `--z-top-bar` | 100 | TopBar 导航栏 |
| `--z-sidebar` | 100 | 侧边栏容器 |
| `--z-dropdown-menu` | 1000 | 下拉菜单(预设操作、世界书选择) |
| `--z-sort-panel` | 1100 | 排序设置面板 |
| `--z-tooltip` | 1200 | 悬浮提示 Tooltip |
| `--z-chat-actions` | 1000 | 聊天消息操作按钮 |
| `--z-character-preview` | 1000 | 角色卡预览弹窗 |
**使用场景:**
- 点击按钮弹出的下拉菜单
- 鼠标悬停显示的提示信息
- 聊天消息的快捷操作按钮
---
### 3⃣ 弹窗层 (10000-19999)
用于模态对话框、编辑面板等需要覆盖整个页面的元素
| 变量名 | 值 | 用途 |
|--------|-------|------|
| `--z-modal-overlay` | 10000 | 对话框遮罩层背景 |
| `--z-modal-content` | 10100 | 对话框内容API配置、预设保存等 |
| `--z-edit-panel-overlay` | 10200 | 世界书编辑面板遮罩层 |
| `--z-edit-panel-content` | 10300 | 世界书编辑面板内容 |
**使用场景:**
- API 配置对话框
- 预设保存/编辑对话框
- 世界书条目编辑面板
- 任何需要全屏遮罩的模态窗口
**层级关系:**
```
编辑面板内容 (10300)
编辑面板遮罩 (10200)
对话框内容 (10100)
对话框遮罩 (10000)
```
---
### 4⃣ 通知层 (20000-29999)
用于全局通知、Toast 提示等
| 变量名 | 值 | 用途 |
|--------|-------|------|
| `--z-toast-container` | 20000 | Toast 通知容器 |
| `--z-toast-item` | 20100 | Toast 通知项 |
**使用场景:**
- 操作成功/失败的提示
- 系统通知
- 警告信息
---
### 5⃣ 系统层 (30000+)
用于系统级元素,如加载动画、错误边界等
| 变量名 | 值 | 用途 |
|--------|-------|------|
| `--z-loading-spinner` | 30000 | 全局加载动画 |
| `--z-error-boundary` | 30100 | 错误边界覆盖层 |
**使用场景:**
- 页面加载时的旋转动画
- 错误捕获后的全屏提示
- 系统级遮罩
---
## 💡 使用指南
### CSS 中使用
```css
/* ✅ 推荐:使用 CSS 变量 */
.dropdown-menu {
z-index: var(--z-dropdown-menu);
}
.modal-overlay {
z-index: var(--z-modal-overlay);
}
.edit-panel {
z-index: var(--z-edit-panel-content);
}
```
### TypeScript/JavaScript 中使用
```typescript
// ✅ 推荐:导入常量
import { Z_INDEX } from '../styles/z-index';
const style = {
zIndex: Z_INDEX.DROPDOWN_MENU,
};
```
### ❌ 避免的做法
```css
/* ❌ 不要使用魔法数字 */
.dropdown-menu {
z-index: 1000; /* 难以理解,不易维护 */
}
/* ❌ 不要使用过大的数值 */
.modal {
z-index: 99999; /* 不合理,可能导致层级混乱 */
}
```
---
## 🔧 添加新层级
如果需要添加新的 z-index 层级,请遵循以下步骤:
1. **确定所属层级**:根据元素类型选择合适的层级范围
2. **选择合适数值**:在该层级范围内选择一个未使用的值(预留 100 间隔)
3. **更新定义文件**
-`z-index.css` 中添加 CSS 变量
-`z-index.ts` 中添加 TypeScript 常量
4. **更新文档**:在本文档中添加说明
**示例:添加一个新的工具提示层级**
```css
/* z-index.css */
:root {
--z-help-tooltip: 1300; /* 在 tooltip (1200) 之上 */
}
```
```typescript
// z-index.ts
export const Z_INDEX = {
// ...
HELP_TOOLTIP: 1300,
} as const;
```
---
## 📝 常见问题
### Q: 为什么弹窗层从 10000 开始?
A: 为了与组件层100-999保持足够的距离避免未来在组件层添加更多层级时产生冲突。
### Q: 如果两个元素都需要弹窗层怎么办?
A: 使用不同的子层级,例如:
- 第一个弹窗:`--z-modal-content` (10100)
- 第二个弹窗:`--z-modal-content + 10` (10110)
### Q: 可以在 inline style 中使用吗?
A: 可以,但推荐使用 CSS 类。如果必须使用 inline style
```jsx
<div style={{ zIndex: 'var(--z-dropdown-menu)' }}>
```
---
## 📚 相关文件
- **CSS 变量定义**`frontend/src/styles/z-index.css`
- **TypeScript 常量**`frontend/src/styles/z-index.ts`
- **全局样式引入**`frontend/src/index.css`
---
## 🔄 更新历史
| 日期 | 版本 | 更新内容 |
|------|------|----------|
| 2026-05-04 | 1.0 | 初始版本,建立完整的 z-index 层级体系 |

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +0,0 @@
import {
require_react_dom
} from "./chunk-TYILIMWK.js";
import "./chunk-CANBAPAS.js";
import "./chunk-5WRI5ZAA.js";
export default require_react_dom();
//# sourceMappingURL=react-dom.js.map

View File

@@ -1,7 +0,0 @@
{
"version": 3,
"sources": ["../../react-dom/client.js"],
"sourcesContent": ["'use strict';\n\nvar m = require('react-dom');\nif (process.env.NODE_ENV === 'production') {\n exports.createRoot = m.createRoot;\n exports.hydrateRoot = m.hydrateRoot;\n} else {\n var i = m.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;\n exports.createRoot = function(c, o) {\n i.usingClientEntryPoint = true;\n try {\n return m.createRoot(c, o);\n } finally {\n i.usingClientEntryPoint = false;\n }\n };\n exports.hydrateRoot = function(c, h, o) {\n i.usingClientEntryPoint = true;\n try {\n return m.hydrateRoot(c, h, o);\n } finally {\n i.usingClientEntryPoint = false;\n }\n };\n}\n"],
"mappings": ";;;;;;;;;AAAA;AAAA;AAEA,QAAI,IAAI;AACR,QAAI,OAAuC;AACzC,cAAQ,aAAa,EAAE;AACvB,cAAQ,cAAc,EAAE;AAAA,IAC1B,OAAO;AACD,UAAI,EAAE;AACV,cAAQ,aAAa,SAAS,GAAG,GAAG;AAClC,UAAE,wBAAwB;AAC1B,YAAI;AACF,iBAAO,EAAE,WAAW,GAAG,CAAC;AAAA,QAC1B,UAAE;AACA,YAAE,wBAAwB;AAAA,QAC5B;AAAA,MACF;AACA,cAAQ,cAAc,SAAS,GAAG,GAAG,GAAG;AACtC,UAAE,wBAAwB;AAC1B,YAAI;AACF,iBAAO,EAAE,YAAY,GAAG,GAAG,CAAC;AAAA,QAC9B,UAAE;AACA,YAAE,wBAAwB;AAAA,QAC5B;AAAA,MACF;AAAA,IACF;AAjBM;AAAA;AAAA;",
"names": []
}

View File

@@ -1,6 +0,0 @@
import {
require_react
} from "./chunk-CANBAPAS.js";
import "./chunk-5WRI5ZAA.js";
export default require_react();
//# sourceMappingURL=react.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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