19 Commits

Author SHA1 Message Date
81b0d0ea1f feat: add NovelPage component with bookshelf, open book, and reading views 2026-06-02 08:19:38 +08:00
54e17c9795 Studio workflow engine complete (R0-R6, run page, node switch, binding editor); R4 streaming deferred; R7 unchanged.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 23:59:56 +08:00
2d0f82ee87 Fix Studio manual node switch state transitions so revisitable steps stay accessible after switching away.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 23:46:35 +08:00
71e673a2ed Studio: 增量/覆盖保存、节点切换与绑定编辑弹窗
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-31 23:28:49 +08:00
0f50c98cf3 Add Studio run interrupt, confirm actions, and template-bound controls.
AbortController cancels message/reroll streams; undo/reroll use in-place confirm; runControls in skill templates gate UI by skillId.

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

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

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

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

7
.env
View File

@@ -8,9 +8,4 @@ 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
FRONTEND_PORT=8501

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.*

76
AGENTS.md Normal file
View File

@@ -0,0 +1,76 @@
# AI Agent Guidance for this Repository
## Repository overview
- Backend: `backend/` using FastAPI, Python 3.11+, `uvicorn --reload` for development.
- Frontend: `frontend/` using React 18 + Vite + TypeScript, with Zustand for state.
- Runtime data is stored under `data/` as JSON/files; do not treat it as source code.
- Docker support exists via `docker-compose.yml` and `docs/DOCKER_DEV.md`.
## What an AI coding agent should do first
1. Read `README.md` and `docs/DOCKER_DEV.md` before proposing environment or run commands.
2. Identify whether a change belongs in `backend/` or `frontend/`.
3. Prefer small, incremental edits.
4. When in doubt, ask the user before making large refactors or architectural changes.
## Build / run commands
### Backend local
- `python -m venv venv`
- `venv\Scripts\activate` (Windows)
- `pip install -r backend/requirements.txt`
- `cd backend && python main.py`
### Frontend local
- `cd frontend`
- `npm install`
- `npm run dev`
### Docker development
- `.\scripts\docker-up.ps1`
- `.\scripts\docker-restart.ps1 -Service backend`
- `.\scripts\docker-rebuild.ps1 -Service backend`
- `.\scripts\docker-logs.ps1`
- `docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d`
## Testing and quality checks
- Backend tests live in `backend/tests/`.
- Use `python -m pytest backend/tests` for automated backend test runs.
- Frontend has lint/type-check scripts in `frontend/package.json`:
- `npm run lint`
- `npm run type-check`
- Prefer adding or updating tests for bug fixes, new features, and non-trivial behavior changes.
- Keep changes tidy and consistent with the repository's existing style.
## Code conventions and review preferences
- The user prefers code that is:
- 基本审查过的
- 规范整洁的
- 严谨测试覆盖的
- Do not perform sweeping refactors without explicit user approval.
- If a change affects core logic, clearly explain the reason and the hypothesis for the fix.
- For any change, state whether it is:
- bug fix
- cleanup/refactor
- feature addition
## Important paths and domains
- `backend/main.py` — FastAPI app entrypoint
- `backend/api/` — HTTP route definitions
- `backend/services/` — core business logic and domain services
- `backend/models/` — data models and converters
- `backend/utils/` — shared helper modules
- `frontend/src/` — React source code
- `frontend/package.json` — frontend scripts and dependencies
- `docs/DOCKER_DEV.md` — Docker development guidance
## Practical guidance for AI agents
- Avoid editing generated or runtime content in `data/` unless explicitly asked.
- Prefer changes that are easy to reason about and test.
- Use existing test tools rather than inventing new workflows.
- Link to repository docs instead of duplicating long explanations.
- If a requested change is uncertain, ask for clarification rather than guessing.
## References
- `README.md`
- `docs/DOCKER_DEV.md`
- `frontend/package.json`
- `backend/requirements.txt`

View File

@@ -1,390 +0,0 @@
# ✅ API 配置功能 - 完成清单
## 📦 已完成的功能模块
### **1. 后端实现** ✅
#### **工作流管理服务**
-`backend/services/comfyui_workflow_manager.py` (173行)
- 列出所有工作流
- 上传工作流(带验证)
- 删除工作流(保护默认文件)
- 加载工作流
- 提示词替换功能
#### **API 端点** (6个)
-`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 连接
#### **默认工作流**
-`backend/data/comfyui_workflows/default_txt2img.json`
- 标准 ComfyUI API 格式
- 7个节点KSampler、CheckpointLoader、EmptyLatentImage、CLIPTextEncode x2、VAEDecode、SaveImage
- 包含 `_meta` 元数据
- 中文节点标题
---
### **2. 前端实现** ✅
#### **核心组件**
-`ComfyUIWorkflowManager.jsx` (179行)
- 工作流列表显示
- 上传功能
- 删除功能
- 刷新功能
- 空状态提示
- 使用说明
#### **主配置页面**
-`ApiConfig.jsx` (完整重构)
- 模式切换卡片(本地/云端)
- 本地 ComfyUI 配置表单
- 云端 API 配置表单
- 嵌套路径更新逻辑
- 修改跟踪系统
- 测试连接功能
#### **样式系统**
-`ApiConfig.css` (扩展 300+ 行)
- 模式选择器样式
- Toggle Switch 开关
- 工作流管理器样式
- 响应式设计
- 防横向滚动
---
### **3. 数据结构** ✅
#### **imageModel 新结构**
```javascript
{
mode: 'local', // 'local' | 'cloud'
local: {
apiUrl: 'http://comfyui:8188',
websocketEnabled: true,
queueTimeout: 300,
defaultWorkflow: 'default_txt2img.json'
},
cloud: {
provider: 'dall-e',
apiUrl: 'https://api.openai.com/v1/images/generations',
apiKey: '',
model: 'dall-e-3'
}
}
```
---
### **4. 响应式设计** ✅
#### **SillyTavern 风格布局**
- ✅ 无页面级滚动条 (`overflow: hidden`)
- ✅ 三栏独立滚动 (`overflow-y: auto`)
- ✅ 禁止横向滚动 (`overflow-x: hidden`)
- ✅ 视口高度布局 (`100vh`)
- ✅ 媒体查询适配 (<768px)
---
### **5. 交互逻辑** ✅
#### **核心函数**
-`handleChange(e, path)` - 支持嵌套路径更新
-`handleImageModeChange(mode)` - 模式切换
-`testComfyUIConnection(apiUrl)` - 测试本地连接
-`testCloudConnection(config)` - 测试云端连接
-`handleOpenSaveModal()` - 打开保存对话框
-`handleSave()` - 保存配置
#### **修改跟踪**
- ✅ 自动标记已修改的配置
- ✅ 保存按钮显示修改数量
- ✅ 标签页红点提示
---
## 📁 文件清单
### **新增文件** (7个)
1.`backend/data/comfyui_workflows/default_txt2img.json`
2.`backend/services/comfyui_workflow_manager.py`
3.`frontend/src/components/SideBarLeft/tabs/ApiConfig/ComfyUIWorkflowManager.jsx`
4.`COMFYUI_WORKFLOW_IMPLEMENTATION.md`
5.`API_IMAGE_CONFIG_COMPLETE.md`
6.`COMFYUI_API_CONFIG_GUIDE.md`
7.`API_CONFIG_FINAL_SUMMARY.md` (本文件)
### **修改文件** (3个)
1.`backend/api/routes/apiConfigRoute.py` (+148行)
2.`frontend/src/components/SideBarLeft/tabs/ApiConfig/ApiConfig.jsx` (重构)
3.`frontend/src/components/SideBarLeft/tabs/ApiConfig/ApiConfig.css` (+300行)
---
## 🎯 功能特性
### **工作流管理**
- ✅ 上传自定义工作流 JSON
- ✅ 删除工作流(保护默认文件)
- ✅ 列表显示(文件名、节点数、大小)
- ✅ 实时刷新
- ✅ 默认工作流标记
### **配置管理**
- ✅ 本地/云端模式切换
- ✅ 完整的本地配置表单
- ✅ 完整的云端配置表单
- ✅ 动态模型选择
- ✅ WebSocket 开关
- ✅ 超时设置
### **连接测试**
- ✅ ComfyUI 连接测试
- 检查连通性
- 获取 VRAM 信息
- 获取设备信息
- ✅ 云端 API 连接测试
- DALL-E 验证
- Stability AI 验证
- 模型可用性检查
### **安全性**
- ✅ API Key 加密存储Fernet
- ✅ 路径遍历攻击防护
- ✅ JSON 格式验证
- ✅ 工作流有效性检查
- ✅ 文件备份机制
---
## 🔧 技术栈
### **后端**
- FastAPI
- Python requests
- OpenAI SDK
- cryptography (Fernet 加密)
- JSON 文件存储
### **前端**
- React 18
- Zustand (状态管理)
- CSS3 (Grid + Flexbox)
- Fetch API
- FormData (文件上传)
---
## 📊 代码统计
| 模块 | 文件数 | 代码行数 |
|------|--------|----------|
| 后端服务 | 1 | 173 |
| 后端路由 | 1 | +148 |
| 前端组件 | 1 | 179 |
| 前端主页面 | 1 | ~800 (重构) |
| 样式文件 | 1 | +300 |
| 工作流模板 | 1 | 108 |
| 文档 | 4 | ~1500 |
| **总计** | **10** | **~3200+** |
---
## ✅ 测试清单
### **后端测试**
```bash
# 1. 测试列出工作流
curl http://localhost:8000/api/api-config/comfyui/workflows
# 2. 测试上传工作流
curl -X POST http://localhost:8000/api/api-config/comfyui/workflows/upload \
-F "file=@my_workflow.json"
# 3. 测试删除工作流
curl -X DELETE http://localhost:8000/api/api-config/comfyui/workflows/my_workflow.json
# 4. 测试获取工作流详情
curl http://localhost:8000/api/api-config/comfyui/workflows/default_txt2img.json
# 5. 测试 ComfyUI 连接
curl -X POST http://localhost:8000/api/api-config/test-comfyui-connection \
-H "Content-Type: application/json" \
-d '{"apiUrl": "http://localhost:8188"}'
# 6. 测试云端 API 连接
curl -X POST http://localhost:8000/api/api-config/test-cloud-connection \
-H "Content-Type: application/json" \
-d '{"provider": "dall-e", "apiKey": "sk-xxx", "model": "dall-e-3"}'
```
### **前端测试**
- [ ] 打开 API 配置页面
- [ ] 切换到"🎨 生图"标签
- [ ] 看到模式切换卡片
- [ ] 点击"本地 ComfyUI" → 显示本地配置
- [ ] 点击"在线 API" → 显示云端配置
- [ ] 填写配置并测试连接
- [ ] 上传工作流文件
- [ ] 查看工作流列表
- [ ] 删除工作流(非默认)
- [ ] 保存配置
- [ ] 重新加载配置
- [ ] 测试响应式布局
---
## 🚀 部署说明
### **Docker 环境**
```yaml
# docker-compose.yml
version: '3.8'
services:
llm-workflow-engine:
build: ./backend
ports:
- "23338:8000"
volumes:
- ./backend/data:/app/data
networks:
- ai-network
comfyui:
image: ghcr.io/comfyanonymous/comfyui:latest
ports:
- "8188:8188"
volumes:
- ./comfyui/models:/app/models
- ./comfyui/output:/app/output
networks:
- ai-network
command: --listen 0.0.0.0 --port 8188
networks:
ai-network:
driver: bridge
```
**配置示例**
- API 地址:`http://comfyui:8188`
- 工作流目录:`backend/data/comfyui_workflows/`
---
### **本地环境**
```bash
# 1. 安装依赖
cd backend
pip install -r requirements.txt
# 2. 启动后端
uvicorn main:app --reload --port 8000
# 3. 启动前端
cd frontend
npm run dev
# 4. 启动 ComfyUI
python comfyui/main.py --listen 0.0.0.0 --port 8188
```
**配置示例**
- API 地址:`http://localhost:8188`
---
## 📝 使用流程
### **首次配置**
1. **选择模式**
- 点击"🎨 生图"标签
- 选择"🖥️ 本地 ComfyUI"或"☁️ 在线 API"
2. **填写配置**
- 本地:填写 API 地址、超时等
- 云端:填写 API Key、选择模型
3. **测试连接**
- 点击"测试连接"按钮
- 确认连接成功
4. **管理工作流**(仅本地模式)
- 查看默认工作流
- (可选)上传自定义工作流
5. **保存配置**
- 点击"保存配置"
- 勾选"🎨 生图"
- 确认保存
---
### **运行时生图**
```
用户输入:"画一只猫"
聊天接口检测生图意图
读取 imageModel 配置
调用 ImageGenerator.generate_image()
如果 mode === 'local':
1. 加载工作流 JSON
2. 替换提示词为"画一只猫"
3. 发送到 ComfyUI (/prompt)
4. 等待完成 (/history/{prompt_id})
5. 返回图片 URL (/view?filename=...)
否则:
1. 调用 DALL-E API
2. 返回图片 URL
在聊天界面显示图片
```
---
## 🎊 总结
### **已完成** ✅
- ✅ 完整的工作流管理系统
- ✅ 本地/云端双模式支持
- ✅ 标准的 ComfyUI API 格式
- ✅ 连接测试功能
- ✅ 响应式 UI 设计
- ✅ SillyTavern 风格布局
- ✅ 安全性保障(加密、验证)
- ✅ 完善的文档
### **待完成** ⚠️
- ⚠️ 生图服务实现 (`image_generator.py`)
- ⚠️ 集成到聊天接口
- ⚠️ Store 保存逻辑更新(处理嵌套结构)
### **下一步建议**
1. 测试前端 UI 和后端 API
2. 创建 `image_generator.py` 服务
3. 集成到聊天流程
4. 添加进度显示和错误处理
---
**当前状态**: 🟢 **API 配置功能完成,等待生图服务集成**
**文档版本**: v1.0.0
**最后更新**: 2026-04-28

View File

@@ -1,412 +0,0 @@
# 🎨 API 配置页面 - 生图功能完善总结
## ✅ 已完成的功能
### **1. 数据结构设计**
#### **imageModel 新结构**
```javascript
imageModel: {
mode: 'local', // 'local' | 'cloud'
local: {
apiUrl: 'http://comfyui:8188',
websocketEnabled: true,
queueTimeout: 300,
defaultWorkflow: 'default_txt2img.json'
},
cloud: {
provider: 'dall-e',
apiUrl: 'https://api.openai.com/v1/images/generations',
apiKey: '',
model: 'dall-e-3'
}
}
```
---
### **2. 前端 UI 组件**
#### **模式切换卡片** ✅
- 🖥️ 本地 ComfyUI
- 图标 + 标题 + 描述
- 悬停效果(上浮 + 阴影)
- 选中状态(高亮边框 + 背景色)
- ☁️ 在线 API
- 同样的交互效果
- 清晰的视觉区分
#### **本地 ComfyUI 配置表单** ✅
- API 地址输入框
- 提示Docker vs 本地运行
- WebSocket 开关Toggle Switch
- 队列超时设置(数字输入)
- 默认工作流下拉选择
- 测试连接按钮
#### **云端 API 配置表单** ✅
- 服务提供商选择DALL-E / Stability AI
- API Key 输入(密码框)
- 模型选择(根据提供商动态显示)
- 测试连接按钮
#### **ComfyUI 工作流管理器** ✅
- 工作流列表显示
- 文件名
- 节点数量
- 文件大小
- 默认标记
- 上传按钮(导入 JSON
- 删除按钮(每个工作流)
- 刷新按钮
- 空状态提示
- 使用说明
---
### **3. 响应式设计** ✅
#### **布局策略**
```css
/* 全局禁止页面级滚动 */
html, body {
height: 100%;
overflow: hidden;
}
#root {
height: 100vh;
display: flex;
flex-direction: column;
}
/* 三栏独立滚动 */
.sidebar-left, .chat-area, .sidebar-right {
overflow-y: auto;
overflow-x: hidden;
}
```
#### **媒体查询**
```css
@media (max-width: 768px) {
/* 小屏幕下单列布局 */
.image-mode-selector {
grid-template-columns: 1fr;
}
.form-row {
flex-direction: column;
}
}
```
#### **防横向滚动**
```css
.api-config-container {
max-width: 100%;
overflow-x: hidden;
}
.form-control {
max-width: 100%;
box-sizing: border-box;
}
```
---
### **4. 交互逻辑**
#### **handleChange 支持嵌套路径** ✅
```javascript
// 扁平结构(其他 API
handleChange(e);
// 嵌套结构(生图配置)
handleChange(e, ['imageModel', 'local', 'apiUrl']);
```
#### **模式切换** ✅
```javascript
handleImageModeChange('local'); // 或 'cloud'
```
#### **修改跟踪** ✅
- 自动标记已修改的配置
- 保存按钮显示修改数量
- 标签页红点提示
---
### **5. 样式系统**
#### **模式卡片** ✅
- Grid 布局2列
- 悬停动画transform + shadow
- 选中状态border + background + ring
- Flexbox 垂直居中内容
#### **开关 Toggle** ✅
- CSS-only 实现
- 平滑过渡动画
- Focus 状态(无障碍)
- 自定义颜色主题
#### **工作流列表** ✅
- 卡片式布局
- 悬停高亮
- 徽章样式(默认标记)
- 滚动容器max-height
---
## 📋 **待完成的后端功能**
### **1. 测试连接端点** ⚠️
需要添加两个新的 API 端点:
```python
@router.post("/test-comfyui-connection")
def test_comfyui_connection(config: dict):
"""测试 ComfyUI 连接"""
# 1. 检查连通性
# 2. 获取系统信息VRAM、设备
# 3. 返回结果
@router.post("/test-cloud-connection")
def test_cloud_connection(config: dict):
"""测试云端 API 连接"""
# 1. 验证 API Key
# 2. 测试请求
# 3. 返回结果
```
---
### **2. 生图服务** ⚠️
创建 `backend/services/image_generator.py`:
```python
class ImageGenerator:
def generate_image(self, prompt: str, config: dict):
if config['mode'] == 'local':
return self._call_comfyui(prompt, config['local'])
else:
return self._call_cloud_api(prompt, config['cloud'])
def _call_comfyui(self, prompt: str, local_config: dict):
# 1. 加载工作流
# 2. 替换提示词
# 3. 发送到 ComfyUI
# 4. 等待完成
# 5. 返回图片 URL
def _call_cloud_api(self, prompt: str, cloud_config: dict):
# 1. 调用 OpenAI/Stability API
# 2. 返回图片 URL
```
---
### **3. Store 更新** ⚠️
`ApiConfigSlice.jsx` 需要:
- 更新 `saveProfile` 以正确处理嵌套的 `imageModel` 结构
- 确保加密只应用于 `cloud.apiKey`
---
## 🎯 **SillyTavern 布局参考**
### **核心原则**
1.**无页面级滚动条** - `overflow: hidden` on body
2.**三栏独立滚动** - 每栏 `overflow-y: auto`
3.**无横向滚动** - `overflow-x: hidden` everywhere
4.**Flexbox 布局** - 弹性自适应
5.**视口高度** - `100vh` / `100dvh`
### **实现细节**
```
┌─────────────────────────────────────────┐
│ TopBar (固定高度) │
├──────────┬──────────────┬───────────────┤
│ │ │ │
│ Left │ Center │ Right │
│ Panel │ Panel │ Panel │
│ │ │ │
│ scroll ↓ │ scroll ↓ │ scroll ↓ │
│ │ │ │
└──────────┴──────────────┴───────────────┘
```
**CSS 关键代码**:
```css
/* App 根容器 */
.app {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 主布局 */
.main-container {
flex: 1;
display: flex;
overflow: hidden;
}
/* 每个面板 */
.panel {
overflow-y: auto;
overflow-x: hidden;
}
```
---
## 🔧 **测试清单**
### **前端测试**
- [ ] 打开 API 配置页面
- [ ] 切换到"🎨 生图"标签
- [ ] 看到模式切换卡片
- [ ] 点击"本地 ComfyUI"卡片
- [ ] 显示本地配置表单
- [ ] 显示工作流管理器
- [ ] 点击"在线 API"卡片
- [ ] 显示云端配置表单
- [ ] 隐藏工作流管理器
- [ ] 测试表单输入
- [ ] API 地址输入
- [ ] WebSocket 开关
- [ ] 超时设置
- [ ] 工作流选择
- [ ] 测试上传工作流
- [ ] 点击"+ 导入工作流"
- [ ] 选择 JSON 文件
- [ ] 看到上传成功提示
- [ ] 列表中显示新工作流
- [ ] 测试删除工作流
- [ ] 点击删除按钮
- [ ] 确认删除
- [ ] 看到删除成功提示
- [ ] 测试响应式
- [ ] 缩小浏览器窗口
- [ ] 模式卡片变为单列
- [ ] 表单行变为垂直排列
- [ ] 检查滚动条
- [ ] 页面无滚动条
- [ ] 左侧边栏可垂直滚动
- [ ] 无横向滚动条
### **后端测试**
```bash
# 测试列出工作流
curl http://localhost:8000/api/api-config/comfyui/workflows
# 测试上传
curl -X POST http://localhost:8000/api/api-config/comfyui/workflows/upload \
-F "file=@test_workflow.json"
# 测试删除
curl -X DELETE http://localhost:8000/api/api-config/comfyui/workflows/test.json
```
---
## 📝 **使用流程**
### **用户配置 ComfyUI**
1. **选择模式**
- 点击"🎨 生图"标签
- 点击"🖥️ 本地 ComfyUI"卡片
2. **填写配置**
- API 地址:`http://comfyui:8188`Docker
- 启用 WebSocket
- 队列超时300 秒
- 默认工作流:文生图(默认)
3. **管理工作流**
- 查看默认工作流列表
- (可选)上传自定义工作流
- 在 ComfyUI 中设计工作流
- 导出为 JSONAPI Format
- 点击"+ 导入工作流"上传
4. **测试连接**
- 点击"测试连接"按钮
- 查看 VRAM 和设备信息
5. **保存配置**
- 点击底部"保存配置"按钮
- 勾选"🎨 生图"
- 确认保存
---
### **运行时生图**
```
用户输入:"画一只猫"
聊天接口检测生图意图
读取 imageModel 配置
调用 ImageGenerator.generate_image()
如果 mode === 'local':
- 加载工作流 JSON
- 替换提示词为"画一只猫"
- 发送到 ComfyUI
- 等待完成
- 返回图片 URL
否则:
- 调用 DALL-E API
- 返回图片 URL
在聊天界面显示图片
```
---
## 🎊 **总结**
### **已完成** ✅
- ✅ 数据结构设计(嵌套结构)
- ✅ 模式切换 UIRadio 卡片)
- ✅ 本地配置表单(完整字段)
- ✅ 云端配置表单(完整字段)
- ✅ 工作流管理器CRUD
- ✅ 响应式设计(移动端适配)
- ✅ 无页面级滚动SillyTavern 风格)
- ✅ 嵌套路径更新逻辑
- ✅ 修改跟踪系统
- ✅ 测试连接函数(占位)
### **待完成** ⚠️
- ⚠️ 后端测试连接端点
- ⚠️ 生图服务实现
- ⚠️ Store 保存逻辑更新
- ⚠️ 聊天集成
### **架构优势** ✅
- ✅ 清晰的职责分离(前端配置 vs 后端执行)
- ✅ 灵活的模式切换(本地/云端)
- ✅ 工作流由后端管理(易于维护)
- ✅ 响应式布局(多设备支持)
- ✅ 无滚动冲突SillyTavern 最佳实践)
---
**当前状态**: 🟢 **前端 UI 完成,等待后端服务集成**

View File

@@ -1,485 +0,0 @@
# 🎨 ComfyUI API 配置使用指南
## 📋 目录
- [快速开始](#快速开始)
- [工作流管理](#工作流管理)
- [API 配置](#api-配置)
- [测试连接](#测试连接)
- [常见问题](#常见问题)
---
## 🚀 快速开始
### **1. 准备工作**
确保你已经:
- ✅ 安装了 ComfyUI本地或 Docker
- ✅ ComfyUI 正在运行并监听 `0.0.0.0:8188`
- ✅ 下载了至少一个 checkpoint 模型文件
### **2. 访问 API 配置页面**
1. 打开应用
2. 点击左侧边栏的"⚙️ API配置"
3. 选择"🎨 生图"标签
---
## 📁 工作流管理
### **默认工作流**
系统已预装一个标准的文生图工作流:
- 文件位置:`backend/data/comfyui_workflows/default_txt2img.json`
- 格式ComfyUI API Format标准 JSON
- 节点数7个KSampler、CheckpointLoader、EmptyLatentImage、CLIPTextEncode x2、VAEDecode、SaveImage
### **工作流结构**
```json
{
"3": {
"inputs": {
"seed": 0,
"steps": 20,
"cfg": 8,
"sampler_name": "euler",
...
},
"class_type": "KSampler",
"_meta": {
"title": "K采样器"
}
},
...
}
```
**关键字段**
- `class_type`: 节点类型
- `inputs`: 节点参数
- `_meta.title`: 节点显示名称(可选)
---
### **上传自定义工作流**
#### **步骤 1: 在 ComfyUI 中设计工作流**
1. 打开 ComfyUI Web UI (`http://localhost:8188`)
2. 拖拽节点,搭建你的工作流
3. 连接节点之间的数据流
4. 配置节点参数(模型、提示词、采样器等)
5. 点击 "Queue Prompt" 测试是否能正常生成图像
#### **步骤 2: 导出 API 格式的 JSON**
1. 点击顶部菜单栏的 **"工作流" (Workflow)**
2. 选择 **"导出(API)" (Export API)** 或 **"Save (API Format)"**
3. 浏览器会自动下载 `workflow_api.json` 文件
**重要提示**
- ⚠️ 必须使用 **"Save (API Format)"**,而不是普通的 "Save"
- ⚠️ API 格式的 JSON 包含节点 ID 和连接关系,是 API 调用的核心
#### **步骤 3: 上传到本项目**
1. 在本项目的 API 配置页面
2. 滚动到"ComfyUI 工作流管理"区域
3. 点击 **"+ 导入工作流"** 按钮
4. 选择刚才导出的 JSON 文件
5. 看到"上传成功"提示
#### **验证上传**
上传成功后,你会在工作流列表中看到:
- 文件名(例如:`my_custom_workflow.json`
- 节点数量
- 文件大小
---
### **删除工作流**
1. 在工作流列表中找到要删除的工作流
2. 点击右侧的 🗑️ 删除按钮
3. 确认删除
**注意**
-`default_txt2img.json` 不可删除(受保护)
- ✅ 其他所有工作流都可以删除
---
## ⚙️ API 配置
### **本地 ComfyUI 模式**
#### **配置项**
| 字段 | 说明 | 示例值 |
|------|------|--------|
| API 地址 | ComfyUI 的服务地址 | `http://comfyui:8188` (Docker)<br>`http://localhost:8188` (本地) |
| 启用 WebSocket | 是否使用 WebSocket 监听进度 | ✓ / ✗ |
| 队列超时 | 等待生成的最大时间(秒) | `300` (5分钟) |
| 默认工作流 | 使用的预设工作流文件 | `default_txt2img.json` |
#### **Docker 环境配置**
如果使用 Docker Compose
```yaml
# docker-compose.yml
services:
comfyui:
image: ghcr.io/comfyanonymous/comfyui:latest
ports:
- "8188:8188"
networks:
- ai-network
command: --listen 0.0.0.0 --port 8188
llm-workflow-engine:
# ...
networks:
- ai-network
```
**API 地址填写**`http://comfyui:8188`Docker 内部网络 DNS
#### **本地运行配置**
如果 ComfyUI 运行在宿主机:
```bash
# 启动 ComfyUI
python main.py --listen 0.0.0.0 --port 8188
```
**API 地址填写**`http://localhost:8188`
---
### **在线 API 模式**
#### **支持的提供商**
1. **DALL-E (OpenAI)**
- 模型:`dall-e-3`, `dall-e-2`
- 质量:最高
- 价格:较贵
2. **Stable Diffusion (Stability AI)**
- 模型:`sd-xl-1024`, `sd-2-1`
- 质量:高
- 价格:中等
#### **配置项**
| 字段 | 说明 | 示例值 |
|------|------|--------|
| 服务提供商 | 选择 API 提供商 | DALL-E / Stability AI |
| API Key | 你的 API 密钥 | `sk-...` |
| 模型 | 选择具体模型 | `dall-e-3` |
#### **获取 API Key**
**DALL-E**:
1. 访问 https://platform.openai.com/
2. 注册/登录账号
3. 进入 API Keys 页面
4. 创建新的 Secret Key
5. 复制并粘贴到配置中
**Stability AI**:
1. 访问 https://platform.stability.ai/
2. 注册/登录账号
3. 进入 API Keys 页面
4. 创建新的 Key
5. 复制并粘贴到配置中
---
## 🔌 测试连接
### **测试 ComfyUI 连接**
1. 填写 API 地址
2. 点击"测试连接"按钮
3. 查看结果
**成功响应**
```json
{
"success": true,
"message": "连接成功",
"stats": {
"vram_total": 25769803776,
"vram_free": 24696061952,
"torch_version": "2.1.0+cu121",
"device": "cuda"
}
}
```
**失败响应**
```json
{
"success": false,
"message": "无法连接到 ComfyUI请检查地址和端口"
}
```
---
### **测试云端 API 连接**
1. 填写 API Key
2. 选择模型
3. 点击"测试连接"按钮
4. 查看结果
**成功响应**
```json
{
"success": true,
"message": "连接成功,模型 dall-e-3 可用"
}
```
**失败响应**
```json
{
"success": false,
"message": "连接失败: Invalid API key"
}
```
---
## 💾 保存配置
### **保存流程**
1. 完成所有配置后
2. 点击底部的"保存配置"按钮
3. 在弹出的对话框中勾选要保存的配置
4. 点击"保存选中的配置"
### **配置文件存储**
- 位置:`backend/data/apiconfig/`
- 格式JSON
- 加密API Key 使用 Fernet 加密存储
### **加载配置**
1. 从下拉框选择已保存的配置文件
2. 自动加载所有配置
3. 可以修改后重新保存
---
## ❓ 常见问题
### **Q1: 上传工作流时提示"Invalid ComfyUI workflow"**
**原因**:上传的不是 API 格式的 JSON
**解决**
1. 在 ComfyUI 中使用 "Save (API Format)" 导出
2. 不要使用普通的 "Save" 功能
3. 确保 JSON 包含节点定义(有 `class_type` 字段)
---
### **Q2: 测试连接时提示"Connection refused"**
**可能原因**
1. ComfyUI 未启动
2. 地址或端口错误
3. Docker 网络配置问题
**解决**
```bash
# 检查 ComfyUI 是否运行
curl http://localhost:8188/system_stats
# Docker 环境下
docker ps | grep comfyui
docker logs comfyui
# 确认监听地址
docker exec comfyui netstat -tlnp | grep 8188
# 应该看到: 0.0.0.0:8188
```
---
### **Q3: 工作流中的提示词会被替换吗?**
**是的**!后端会自动:
1. 加载工作流 JSON
2. 找到第一个 `CLIPTextEncode` 节点
3. 将其 `text` 字段替换为用户输入的提示词
4. 发送到 ComfyUI
**示例**
```json
// 工作流中的原始提示词
"6": {
"inputs": {
"text": "beautiful scenery nature glass bottle landscape..."
}
}
// 运行时会被替换为
"6": {
"inputs": {
"text": "用户输入的提示词,例如:画一只猫"
}
}
```
---
### **Q4: 如何添加 LoRA 或 ControlNet**
**方法 1: 在 ComfyUI 中添加节点**
1. 在 ComfyUI Web UI 中加载 LoRA Loader 或 ControlNet 节点
2. 连接到工作流
3. 配置参数
4. 导出为 API 格式
5. 上传到本项目
**方法 2: 手动编辑 JSON**
```json
"10": {
"inputs": {
"lora_name": "cyberpunk_style.safetensors",
"strength_model": 0.7,
"strength_clip": 0.7,
"model": ["4", 0],
"clip": ["4", 1]
},
"class_type": "LoraLoader"
}
```
---
### **Q5: 支持批量生图吗?**
当前版本不支持批量生图,但可以通过以下方式实现:
**方案 A: 多次调用**
```python
for prompt in prompts:
result = generate_image(prompt, config)
save_result(result)
```
**方案 B: ComfyUI 批量节点**
在工作流中使用 Batch Size > 1
```json
"5": {
"inputs": {
"width": 512,
"height": 512,
"batch_size": 4 // 一次生成4张
}
}
```
---
### **Q6: 如何优化生图速度?**
**本地 ComfyUI**
1. 使用更快的采样器(如 `euler_ancestral`
2. 减少步数Steps: 15-20
3. 降低分辨率512x512 而非 1024x1024
4. 使用 GPU 加速
**云端 API**
1. 选择更快的模型DALL-E 2 比 DALL-E 3 快)
2. 使用较小的尺寸
3. 考虑付费套餐(更高的优先级)
---
## 📊 工作流示例
### **基础文生图**
```json
{
"3": {"class_type": "KSampler", ...},
"4": {"class_type": "CheckpointLoaderSimple", ...},
"5": {"class_type": "EmptyLatentImage", ...},
"6": {"class_type": "CLIPTextEncode", ...},
"7": {"class_type": "CLIPTextEncode", ...},
"8": {"class_type": "VAEDecode", ...},
"9": {"class_type": "SaveImage", ...}
}
```
### **带 LoRA 的文生图**
额外添加:
```json
"10": {
"class_type": "LoraLoader",
"inputs": {
"lora_name": "style.safetensors",
"strength_model": 0.7,
"model": ["4", 0],
"clip": ["4", 1]
}
}
```
### **图生图**
需要添加:
```json
"10": {
"class_type": "LoadImage",
"inputs": {
"image": "reference.png"
}
},
"11": {
"class_type": "VAEEncode",
"inputs": {
"pixels": ["10", 0],
"vae": ["4", 2]
}
}
```
---
## 🔗 相关资源
- **ComfyUI 官方文档**: https://github.com/comfyanonymous/ComfyUI
- **ComfyUI API 示例**: https://github.com/zer0Black/ComfyUI-Api-Demo
- **工作流分享社区**: https://comfyworkflows.com/
- **模型下载**: https://civitai.com/
---
## 📝 更新日志
### **v1.0.0** (2026-04-28)
- ✅ 初始版本发布
- ✅ 支持 ComfyUI 本地部署
- ✅ 支持云端 APIDALL-E、Stability AI
- ✅ 工作流管理(上传、删除、列表)
- ✅ 连接测试功能
- ✅ 默认工作流模板
---
**如有问题,请查看日志或联系开发者!**

View File

@@ -1,243 +0,0 @@
# 🎨 ComfyUI 工作流管理功能 - 实现完成
## ✅ 已完成的功能
### **1. 后端实现**
#### **文件结构**
```
backend/
├── data/
│ └── comfyui_workflows/
│ └── default_txt2img.json # 默认文生图工作流
├── services/
│ └── comfyui_workflow_manager.py # 工作流管理服务
└── api/routes/
└── apiConfigRoute.py # 添加了4个新端点
```
#### **API 端点**
1. **GET `/api/api-config/comfyui/workflows`**
- 获取所有可用的工作流列表
- 返回: `[{filename, name, nodes_count, size}, ...]`
2. **POST `/api/api-config/comfyui/workflows/upload`**
- 上传工作流 JSON 文件
- 验证: JSON格式、包含KSampler节点
- 自动备份已存在的文件
3. **DELETE `/api/api-config/comfyui/workflows/{filename}`**
- 删除工作流文件
- 保护: 不允许删除 `default_txt2img.json`
4. **GET `/api/api-config/comfyui/workflows/{filename}`**
- 获取指定工作流的详细内容
#### **核心功能**
- ✅ 工作流文件管理(增删查)
- ✅ JSON 格式验证
- ✅ ComfyUI 工作流有效性检查
- ✅ 自动备份机制
- ✅ 路径安全保护(防止遍历攻击)
- ✅ 提示词替换功能(`replace_prompt_in_workflow`
---
### **2. 前端实现**
#### **新增组件**
```
frontend/src/components/SideBarLeft/tabs/ApiConfig/
├── ComfyUIWorkflowManager.jsx # 工作流管理器组件
└── ApiConfig.css # 添加了工作流管理器样式
```
#### **组件功能**
**ComfyUIWorkflowManager.jsx**:
- ✅ 显示工作流列表(文件名、节点数、大小)
- ✅ 上传按钮(导入 JSON 文件)
- ✅ 删除按钮(每个工作流项)
- ✅ 刷新按钮
- ✅ 默认工作流标记
- ✅ 空状态提示
- ✅ 使用说明
**UI 特性**:
- 紧凑的卡片式布局
- 悬停效果
- 加载状态
- 错误提示
- 响应式设计
---
### **3. 数据结构更新**
#### **前端 formData.imageModel 新结构**
```javascript
imageModel: {
mode: 'local', // 'local' | 'cloud'
local: {
apiUrl: 'http://comfyui:8188',
websocketEnabled: true,
queueTimeout: 300,
defaultWorkflow: 'default_txt2img.json'
},
cloud: {
provider: 'dall-e',
apiUrl: 'https://api.openai.com/v1/images/generations',
apiKey: '',
model: 'dall-e-3'
}
}
```
---
## 📋 **待完成的工作**
### **1. 前端 UI 完善** ⚠️
当前 `ApiConfig.jsx` 中:
- ✅ 已导入 `ComfyUIWorkflowManager` 组件
- ✅ 已在适当位置插入组件
-**需要添加模式切换 UI**(本地/云端 Radio 卡片)
-**需要添加本地配置表单**apiUrl、websocket、timeout
-**需要添加云端配置表单**provider、apiKey、model
-**需要修改 `handleChange` 支持嵌套结构**
### **2. Store 更新** ⚠️
`ApiConfigSlice.jsx` 需要:
- ❌ 更新 `saveProfile` 以支持新的 `imageModel` 结构
- ❌ 添加 `testComfyUIConnection` 方法
- ❌ 添加 `testCloudConnection` 方法
### **3. 后端生图服务** ⚠️
需要创建:
-`backend/services/image_generator.py` - 统一的生图服务
- `generate_image(prompt, config)` - 主函数
- `call_comfyui(prompt, local_config)` - 调用 ComfyUI
- `call_cloud_api(prompt, cloud_config)` - 调用云端 API
- 工作流加载和提示词替换逻辑
### **4. 聊天集成** ⚠️
需要在聊天接口中:
- ❌ 检测用户想要生图的意图
- ❌ 提取提示词
- ❌ 读取 imageModel 配置
- ❌ 调用生图服务
- ❌ 返回图片 URL 或 base64
---
## 🎯 **下一步建议**
### **优先级 1: 完善前端 UI**
1.`ApiConfig.jsx` 中添加模式切换 Radio 卡片
2. 根据模式动态显示不同的配置表单
3. 修改 `handleChange` 支持嵌套路径
4. 测试上传/删除工作流功能
### **优先级 2: 创建生图服务**
1. 创建 `image_generator.py`
2. 实现 ComfyUI 调用逻辑
3. 实现云端 API 调用逻辑
4. 添加错误处理和重试
### **优先级 3: 集成到聊天**
1. 在聊天路由中添加生图端点
2. 实现意图识别(可选,或使用命令如 `/imagine`
3. 测试完整流程
---
## 🔧 **测试清单**
### **后端测试**
```bash
# 1. 测试列出工作流
curl http://localhost:8000/api/api-config/comfyui/workflows
# 2. 测试上传工作流
curl -X POST http://localhost:8000/api/api-config/comfyui/workflows/upload \
-F "file=@my_workflow.json"
# 3. 测试删除工作流
curl -X DELETE http://localhost:8000/api/api-config/comfyui/workflows/my_workflow.json
# 4. 测试获取工作流详情
curl http://localhost:8000/api/api-config/comfyui/workflows/default_txt2img.json
```
### **前端测试**
- [ ] 打开 API 配置页面
- [ ] 切换到"🎨 生图"标签
- [ ] 看到工作流管理器
- [ ] 点击"导入工作流"上传 JSON
- [ ] 看到上传的工作流出现在列表中
- [ ] 点击删除按钮删除工作流
- [ ] 确认默认工作流不可删除
---
## 📝 **使用说明**
### **用户上传工作流**
1. 在 ComfyUI Web UI 中设计工作流
2. 点击菜单 → "Save (API Format)"
3. 保存为 `.json` 文件
4. 在本项目中点击"+ 导入工作流"
5. 选择导出的 JSON 文件
6. 上传成功后可在列表中看到
### **默认工作流**
- 文件: `backend/data/comfyui_workflows/default_txt2img.json`
- 类型: 标准文生图
- 参数: 512x512, 20 steps, CFG 7, Euler sampler
- 不可删除
### **运行时提示词替换**
后端会自动:
1. 加载选定的工作流 JSON
2. 找到第一个 `CLIPTextEncode` 节点
3. 将其 `text` 字段替换为用户输入的提示词
4. 发送到 ComfyUI
---
## 🎊 **总结**
### **已完成**
- ✅ 后端工作流管理服务和 API
- ✅ 默认文生图工作流
- ✅ 前端工作流管理器组件
- ✅ 完整的 CRUD 功能
- ✅ 数据结构设计
### **待完成**
- ⚠️ 前端模式切换 UI
- ⚠️ 生图服务实现
- ⚠️ 聊天集成
### **架构优势**
- ✅ 前后端分离清晰
- ✅ 工作流由后端统一管理
- ✅ 前端只需配置连接信息
- ✅ 易于扩展新的工作流
- ✅ 安全性好(验证、备份、路径保护)
---
**当前状态**: 🟡 **基础框架完成,等待 UI 完善和服务集成**

View File

@@ -1,411 +0,0 @@
# 🎨 Compact Modern Design - API 配置页面精简版
## ✨ 设计理念
**Compact Modern Design = Linear × Vercel**
- **高密度** - 最大化信息展示,减少空白
- **现代化** - Pill 标签、简洁按钮
- **克制优雅** - 无多余装饰,功能优先
- **高效实用** - 快速扫描和操作
---
## 📊 精简对比
### **之前(臃肿)** ❌
```
┌─────────────────────────────┐
│ 🖥️ │
│ 本地 ComfyUI │
│ 使用本地 GPU免费但需要硬件 │ ← 太大!
└─────────────────────────────┘
┌─────────────────────────────┐
│ ☁️ │
│ 在线 API │
│ 使用云端服务,付费但无需硬件 │
└─────────────────────────────┘
工作流管理区域占用 300px+ 高度
- 大标题
- 详细说明
- 节点数和文件大小
- 提示列表
```
### **现在(紧凑)** ✅
```
[🖥️ 本地] [☁️ 云端] ← Pill Toggle仅 28px 高
工作流 📤 🔄
- default_txt2img [默认]
- my_workflow 🗑️
```
---
## 🎯 关键改进
### **1. 模式切换 - Pill Toggle**
**之前**: 2个大卡片每个 120px 高
**现在**: 2个按钮28px 高
```css
.mode-toggle {
display: inline-flex;
gap: 2px;
padding: 2px;
background-color: var(--color-bg-tertiary);
border-radius: 6px;
}
.mode-btn {
padding: 4px 12px;
font-size: 0.8rem;
border-radius: 4px;
}
```
**视觉**:
```
未选中: [🖥️ 本地] [☁️ 云端]
选中: [🖥️ 本地] (☁️ 云端)
↑ 白色背景 + 阴影
```
---
### **2. 工作流管理器 - 极简版**
**之前**:
- 大标题 "ComfyUI 工作流管理"
- 上传按钮 "+ 导入工作流"
- 每个工作流显示:文件名、节点数、大小
- 底部提示列表3条
**现在**:
- 小标签 "工作流"
- 图标按钮 📤 🔄
- 仅显示文件名 + 默认标记
- 无说明文字
```jsx
<div className="workflow-manager-compact">
<div className="workflow-header-compact">
<span className="workflow-label">工作流</span>
<div className="workflow-actions-compact">
<label className="btn-icon">📤</label>
<button className="btn-icon">🔄</button>
</div>
</div>
<div className="workflow-list-compact">
<div className="workflow-item-compact">
<span>
<span className="badge-default">默认</span>
default_txt2img
</span>
</div>
</div>
</div>
```
**高度对比**:
- 之前: ~350px
- 现在: ~150px减少 57%
---
### **3. 表单间距 - 紧凑化**
**之前**:
```css
.form-group {
margin-bottom: var(--spacing-md); /* 16px */
}
.form-control {
padding: 8px 12px;
}
```
**现在**:
```css
.form-group {
margin-bottom: var(--spacing-sm); /* 8px */
}
.form-control {
padding: 6px 10px;
font-size: 0.85rem;
}
```
**节省空间**: 每个字段减少 8px
---
### **4. 删除冗余元素**
#### **移除的装饰**
- ❌ 卡片阴影mode-card box-shadow
- ❌ 悬停动画transform: translateY
- ❌ 渐变背景
- ❌ 大图标2.5rem → 0.9rem
- ❌ 详细说明文字
- ❌ 节点数和文件大小
- ❌ 提示列表
#### **保留的核心**
- ✅ 功能按钮
- ✅ 必要标签
- ✅ 状态指示active badge
- ✅ 基本悬停反馈
---
## 📐 尺寸规范
### **间距系统**
| 元素 | 之前 | 现在 | 减少 |
|------|------|------|------|
| 容器 padding | 16px | 12px | -25% |
| 字段间距 | 16px | 8px | -50% |
| 按钮 padding | 8px 16px | 4px 12px | -40% |
| 卡片间隙 | 16px | 2px | -87% |
### **字体大小**
| 元素 | 之前 | 现在 |
|------|------|------|
| 标题 | 1.1rem | 0.75rem (uppercase) |
| 标签 | 0.85rem | 0.75rem |
| 输入框 | 0.9rem | 0.85rem |
| 按钮 | 0.85rem | 0.8rem |
### **组件高度**
| 组件 | 之前 | 现在 | 减少 |
|------|------|------|------|
| 模式切换 | 120px × 2 | 28px | -88% |
| 工作流管理器 | 350px | 150px | -57% |
| 表单区域 | ~600px | ~450px | -25% |
| **总计** | **~1100px** | **~650px** | **-41%** |
---
## 🎨 视觉风格
### **颜色使用**
```css
/* 背景色层次 */
--color-bg-primary: /* 输入框背景 */
--color-bg-secondary: /* 工作流项背景 */
--color-bg-tertiary: /* Toggle/Manager 背景 */
--color-bg-elevated: /* Active/Hover 状态 */
/* 文字颜色 */
--color-text-primary: /* 主要文字 */
--color-text-secondary: /* 标签/次要 */
--color-text-muted: /* 提示/禁用 */
```
### **圆角规范**
```css
border-radius: 4px; /* 按钮、输入框 */
border-radius: 6px; /* 容器、Toggle */
border-radius: 3px; /* Badge */
```
### **过渡动画**
```css
transition: all 0.15s ease; /* 快速响应 */
```
---
## 💡 设计原则应用
### **1. 高密度**
✅ 减少 padding/margin
✅ 缩小字体
✅ 去除装饰性空白
**结果**: 同屏显示更多信息
---
### **2. 现代化**
✅ Pill Toggle类似 macOS/iOS
✅ 图标按钮(简洁直观)
✅ 扁平化设计(无渐变/阴影)
**参考**: Linear、Vercel Dashboard
---
### **3. 克制优雅**
✅ 只保留必要元素
✅ 统一的设计语言
✅ 克制的色彩使用
**理念**: Less is More
---
### **4. 高效实用**
✅ 一眼看到关键信息
✅ 快速操作(点击即切换)
✅ 减少认知负担
**目标**: 最小化操作步骤
---
## 📱 响应式考虑
虽然侧边栏宽度固定,但仍需保证:
✅ 无横向滚动
✅ 内容自适应宽度
✅ 小屏幕下仍可操作
**实现**:
```css
.api-config-container {
max-width: 100%;
overflow-x: hidden;
}
.form-control {
width: 100%;
box-sizing: border-box;
}
```
---
## 🎯 用户体验提升
### **操作效率**
| 任务 | 之前 | 现在 | 提升 |
|------|------|------|------|
| 切换模式 | 点击大卡片 | 点击按钮 | 更快 |
| 上传工作流 | 找按钮+阅读说明 | 直接点图标 | 更直观 |
| 查看工作流 | 滚动长列表 | 紧凑列表 | 更快 |
| 填写表单 | 大间距需滚动 | 紧凑少滚动 | 更高效 |
### **视觉清晰度**
- ✅ 减少视觉噪音
- ✅ 突出关键操作
- ✅ 统一的设计语言
### **学习成本**
- ✅ 符合常见模式Pill Toggle
- ✅ 图标直观易懂
- ✅ 无需阅读说明
---
## 🔧 技术实现
### **CSS 架构**
```
ApiConfig.css
├── 基础样式(已有)
│ ├── .api-config-container
│ ├── .config-tabs
│ ├── .form-group
│ └── .btn
└── Compact Modern新增
├── .mode-toggle
├── .mode-btn
├── .workflow-manager-compact
├── .btn-icon
└── .workflow-item-compact
```
### **组件结构**
```jsx
ApiConfig.jsx
Config TabsPill 标签
Profile Manager配置管理
Mode Toggle模式切换 新增
Form Section表单
Local Config本地配置
Cloud Config云端配置
Workflow Manager工作流 精简
```
---
## 📊 性能优化
### **渲染性能**
- ✅ 减少 DOM 节点(从 ~80 个 → ~40 个)
- ✅ 简化 CSS去除复杂选择器
- ✅ 减少动画(仅保留必要的 transition
### **加载速度**
- ✅ CSS 文件减小(-155 行)
- ✅ 组件代码简化(-40 行)
---
## ✅ 验收标准
### **视觉检查**
- [ ] 模式切换为 Pill 样式
- [ ] 工作流管理器紧凑(<200px
- [ ] 无多余装饰元素
- [ ] 字体大小统一0.75-0.85rem
### **功能检查**
- [ ] 模式切换正常工作
- [ ] 工作流上传/删除正常
- [ ] 表单输入正常
- [ ] 无横向滚动
### **响应式检查**
- [ ] 不同宽度下无溢出
- [ ] 所有元素可见且可操作
---
## 🎊 总结
### **精简成果**
- ✅ 垂直空间减少 **41%**
- ✅ DOM 节点减少 **50%**
- ✅ CSS 代码减少 **155 行**
- ✅ 视觉复杂度降低 **60%**
### **设计哲学**
> "在有限的空间内,提供最大的价值和最好的体验。"
**关键词**: 紧凑 · 现代 · 高效 · 克制 · 精致 · 专业
---
**当前状态**: 🟢 **Compact Modern Design 已实现**

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

@@ -12,7 +12,10 @@ ENV PYTHONDONTWRITEBYTECODE=1
COPY requirements.txt .
# 安装依赖
RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
# 安装 Pillow使用阿里云镜像源
RUN pip install --no-cache-dir Pillow -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com || echo "Pillow installation failed, will install manually"
# 复制所有代码
COPY . .

View File

@@ -1,16 +1,28 @@
from fastapi import APIRouter
from .routes import presetsRoute, chatsRoute, worldbooksRoute, apiConfigRoute
from .routes import presetsRoute, chatsRoute, worldbooksRoute, apiConfigRoute, charactersRoute, chatWsRoute, tokenUsageRoute, imageGalleryRoute, regexRoute, chatSummaryRoute, studioRoute, fictionRoute
from utils.file_utils import get_all_roles_and_chats
from core.config import settings
from pathlib import Path
router = APIRouter()
# 注册子路由
# 注册子路由HTTP路由
router.include_router(presetsRoute.router)
router.include_router(chatsRoute.router)
router.include_router(worldbooksRoute.router)
router.include_router(apiConfigRoute.router)
router.include_router(charactersRoute.router)
# ✅ 注册新增路由
router.include_router(tokenUsageRoute.router)
router.include_router(imageGalleryRoute.router)
router.include_router(regexRoute.router)
router.include_router(chatSummaryRoute.router)
router.include_router(studioRoute.router)
router.include_router(fictionRoute.router)
# ✅ 注册 WebSocket 路由(必须在 HTTP 路由之后,避免路径冲突)
router.include_router(chatWsRoute.router)
# 保留原有的其他路由

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,292 @@
"""
角色卡 API 路由
"""
from fastapi import APIRouter, HTTPException, UploadFile, File
from fastapi.responses import FileResponse, StreamingResponse
from typing import List
from pathlib import Path
import io
try:
from backend.services.character_service import CharacterService
except ImportError:
from services.character_service import CharacterService
router = APIRouter(prefix="/characters", tags=["characters"])
character_service = CharacterService()
@router.get("/", response_model=List[dict])
async def list_characters():
"""
获取所有角色卡列表
Returns:
按最后聊天时间排序的角色卡列表
"""
characters = character_service.scan_all_characters()
return [c.dict() for c in characters]
@router.get("/{name}", response_model=dict)
async def get_character(name: str):
"""
获取指定角色卡
Args:
name: 角色名URL编码
"""
character = character_service.get_character_by_name(name)
if not character:
raise HTTPException(status_code=404, detail=f"角色 '{name}' 不存在")
return character.dict()
@router.post("/", response_model=dict)
async def create_character(character_data: dict):
"""
创建新角色卡
Request Body:
{
"name": "角色名",
"description": "描述",
"personality": "性格",
"scenario": "场景",
"first_mes": "开场白",
"categories": ["分类1", "分类2"],
"tags": ["tag1", "tag2"]
}
"""
try:
character = character_service.create_character(character_data)
return {
"success": True,
"character": character.dict()
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.put("/{name}", response_model=dict)
async def update_character(name: str, updates: dict):
"""
更新角色卡
Args:
name: 角色名
updates: 要更新的字段
"""
try:
character = character_service.update_character(name, updates)
return {
"success": True,
"character": character.dict()
}
except FileNotFoundError:
raise HTTPException(status_code=404, detail=f"角色 '{name}' 不存在")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/{name}")
async def delete_character(name: str):
"""
删除角色卡及其所有聊天记录
"""
success = character_service.delete_character(name)
if not success:
raise HTTPException(status_code=404, detail=f"角色 '{name}' 不存在")
return {"success": True, "message": f"角色 '{name}' 已删除"}
@router.get("/{name}/avatar")
async def get_avatar(name: str):
"""
获取角色头像
Returns:
PNG 图片文件或 404
"""
char_folder = character_service.characters_dir / name
avatar_file = char_folder / "avatar.png"
if not avatar_file.exists():
# 返回默认头像
default_avatar = Path("data/images/avatars/fallback.png")
if default_avatar.exists():
return FileResponse(default_avatar, media_type="image/png")
raise HTTPException(status_code=404, detail="头像不存在")
return FileResponse(avatar_file, media_type="image/png")
@router.post("/{name}/avatar")
async def upload_avatar(name: str, file: UploadFile = File(...)):
"""
上传角色头像
Args:
name: 角色名
file: PNG 图片文件
"""
# 验证文件类型
if not file.content_type.startswith('image/'):
raise HTTPException(status_code=400, detail="只支持图片文件")
# 检查角色是否存在
character = character_service.get_character_by_name(name)
if not character:
raise HTTPException(status_code=404, detail=f"角色 '{name}' 不存在")
# 保存图片
image_data = await file.read()
avatar_path = character_service.save_avatar(name, image_data)
return {
"success": True,
"avatar_path": avatar_path
}
@router.get("/{name}/chats")
async def list_chats(name: str):
"""
获取角色的所有聊天列表
Returns:
聊天文件列表(包含最后一条消息预览)
"""
char_folder = character_service.characters_dir / name
chats_dir = char_folder / "chats"
if not chats_dir.exists():
return {"chats": []}
import json
from datetime import datetime
chats = []
for chat_file in chats_dir.glob("*.jsonl"):
try:
with open(chat_file, 'r', encoding='utf-8') as f:
lines = f.readlines()
if not lines:
continue
# 第一行是header
header = json.loads(lines[0])
# 计算消息数量排除header
message_count = len(lines) - 1
# 获取最后修改时间
last_modified = datetime.fromtimestamp(
chat_file.stat().st_mtime
).isoformat()
# 获取最后一条消息预览
last_message = ""
if message_count > 0:
try:
last_msg_data = json.loads(lines[-1])
last_message = last_msg_data.get("mes", "")
except:
pass
chats.append({
"chat_name": chat_file.stem,
"user_name": header.get("user_name", "User"),
"character_name": header.get("character_name", ""),
"last_modified": last_modified,
"message_count": message_count,
"last_message": last_message
})
except Exception as e:
# 如果解析失败,使用基本信息
chats.append({
"chat_name": chat_file.stem,
"last_modified": datetime.fromtimestamp(chat_file.stat().st_mtime).isoformat(),
"message_count": 0,
"last_message": ""
})
# 按修改时间排序
chats.sort(key=lambda c: c.get('last_modified', ''), reverse=True)
return {"chats": chats}
@router.post("/import")
async def import_character(file: UploadFile = File(...)):
"""
导入角色卡(支持 PNG 或 JSON
- PNG: 自动提取嵌入数据,创建文件夹
- JSON: 创建文件夹并保存
"""
content = await file.read()
filename = file.filename
if filename.endswith('.png'):
# 导入 PNG
try:
character = character_service.import_from_png(content, filename)
return {
"success": True,
"character": character.dict(),
"format": "png_embedded"
}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"导入失败: {str(e)}")
elif filename.endswith('.json'):
# 导入 JSON
try:
import json
data = json.loads(content.decode('utf-8'))
character = character_service.create_character(data)
return {
"success": True,
"character": character.dict(),
"format": "json"
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"导入失败: {str(e)}")
else:
raise HTTPException(status_code=400, detail="不支持的文件格式")
@router.post("/{name}/export/png")
async def export_character_as_png(name: str):
"""
导出角色为 SillyTavern PNG 格式
Returns:
PNG 文件下载
"""
try:
png_data = character_service.export_as_png(name)
return StreamingResponse(
io.BytesIO(png_data),
media_type="image/png",
headers={
"Content-Disposition": f"attachment; filename={name}.png"
}
)
except FileNotFoundError:
raise HTTPException(status_code=404, detail=f"角色 '{name}' 不存在")
except Exception as e:
raise HTTPException(status_code=500, detail=f"导出失败: {str(e)}")

View File

@@ -0,0 +1,154 @@
"""
聊天总结 API 路由
处理聊天记录的总结请求
"""
import logging
from typing import Dict, Any
from fastapi import APIRouter, HTTPException, Body
from fastapi.responses import JSONResponse
from services.chat_service import chat_service
from services.chat_summary_service import chat_summary_service
from models.internal import SummaryConfig
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/chats", tags=["chat-summary"])
@router.post("/{role_name}/{chat_name}/summarize")
async def summarize_chat_history(
role_name: str,
chat_name: str,
request_data: Dict[str, Any] = Body(...)
):
"""
总结聊天历史记录
Args:
role_name: 角色名称
chat_name: 聊天名称
request_data: {
"startFloor": int, # 总结起始楼层
"endFloor": int, # 总结结束楼层
"summaryConfig": {...}, # 总结配置
"apiConfig": {...} # API配置
}
Returns:
{
"success": bool,
"summaryText": str, # 总结文本
"startFloor": int,
"endFloor": int,
"message": str
}
"""
try:
# 1. 提取请求参数
start_floor = request_data.get("startFloor")
end_floor = request_data.get("endFloor")
summary_config_data = request_data.get("summaryConfig", {})
api_config = request_data.get("apiConfig", {})
if not start_floor or not end_floor:
raise HTTPException(status_code=400, detail="缺少 startFloor 或 endFloor 参数")
# 2. 加载聊天记录
chat_log = chat_service.get_chat_log(role_name, chat_name)
if not chat_log:
raise HTTPException(status_code=404, detail=f"聊天记录 '{role_name}/{chat_name}' 不存在")
messages = chat_log.messages
total_messages = len(messages)
# 3. 验证楼层范围
if start_floor < 1 or end_floor > total_messages or start_floor > end_floor:
raise HTTPException(
status_code=400,
detail=f"无效的楼层范围: {start_floor}-{end_floor}(总共{total_messages}条消息)"
)
# 4. 构建SummaryConfig对象
summary_config = SummaryConfig(**summary_config_data)
logger.info(
f"[ChatSummary] 开始总结: {role_name}/{chat_name}, "
f"楼层范围: {start_floor}-{end_floor}, "
f"包含用户输入: {summary_config.includeUserInput}"
)
# 5. 调用总结服务
summary_text = await chat_summary_service.summarize_messages(
messages=messages,
start_floor=start_floor,
end_floor=end_floor,
summary_config=summary_config,
api_config=api_config
)
if not summary_text:
raise HTTPException(status_code=500, detail="总结生成失败")
logger.info(f"[ChatSummary] 总结完成,长度: {len(summary_text)} 字符")
# 6. 更新聊天记录(清空原文 + 替换总结)
chat_service.summarize_chat_messages(
role_name=role_name,
chat_name=chat_name,
start_floor=start_floor,
end_floor=end_floor,
summary_text=summary_text
)
return {
"success": True,
"summaryText": summary_text,
"startFloor": start_floor,
"endFloor": end_floor,
"message": f"成功总结 {end_floor - start_floor + 1} 条消息"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"[ChatSummary] 总结失败: {str(e)}")
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail=f"总结失败: {str(e)}")
@router.get("/{role_name}/{chat_name}/summary-status")
async def get_summary_status(role_name: str, chat_name: str):
"""
获取聊天总结状态
Returns:
{
"historyMode": str,
"summaryCounter": int,
"lastSummaryFloor": int,
"summaryConfig": {...}
}
"""
try:
chat_log = chat_service.get_chat_log(role_name, chat_name)
if not chat_log:
raise HTTPException(status_code=404, detail="聊天记录不存在")
header = chat_log.header
return {
"historyMode": header.historyMode.value if hasattr(header.historyMode, 'value') else header.historyMode,
"summaryCounter": header.summaryCounter or 0,
"lastSummaryFloor": getattr(header, 'lastSummaryFloor', 0),
"summaryConfig": header.summaryConfig.dict() if header.summaryConfig else None
}
except HTTPException:
raise
except Exception as e:
logger.error(f"[ChatSummary] 获取总结状态失败: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -0,0 +1,424 @@
"""
聊天 WebSocket 路由
处理实时对话生成
"""
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from typing import Dict, Any
import json
import asyncio
try:
from backend.services.chat_workflow_service import ChatWorkflowService
from backend.services.chat_service import ChatService
from backend.services.task_queue_manager import task_queue_manager
from backend.core.config import settings
except ImportError:
from services.chat_workflow_service import ChatWorkflowService
from services.chat_service import ChatService
from services.task_queue_manager import task_queue_manager
from core.config import settings
router = APIRouter(prefix="/chat", tags=["chat-websocket"])
# 初始化服务
workflow_service = ChatWorkflowService()
chat_service = ChatService(settings.DATA_PATH)
# ✅ 全局变量:用于存储需要中断的聊天会话
interrupt_flags: Dict[str, bool] = {}
@router.websocket("/{role_name}/{chat_name}/ws")
async def websocket_chat_endpoint(
websocket: WebSocket,
role_name: str,
chat_name: str
):
"""
WebSocket 聊天端点
接收前端发送的完整对话请求,调用工作流生成回复,支持流式输出
"""
await websocket.accept()
print(f"\n{'='*80}")
print(f"[WebSocket] 📡 连接建立: {role_name}/{chat_name}")
print(f"{'='*80}\n")
chat_id = f"{role_name}/{chat_name}"
try:
while True:
# 1. 接收前端消息
print(f"[WebSocket] ⏳ 等待接收消息...")
data = await websocket.receive_text()
print(f"[WebSocket] ✅ 收到消息,长度: {len(data)}")
request_data = json.loads(data)
# ✅ 检查是否是取消任务的请求
if request_data.get("type") == "cancel_task":
task_id = request_data.get("taskId")
print(f"[WebSocket] ❌ 收到取消任务请求: {task_id}")
# ✅ 特殊处理:如果是 LLM 生成任务,需要中断当前流式生成
if task_id == "current_llm_generation":
print(f"[WebSocket] 🛑 正在终止 LLM 流式生成...")
# TODO: 实现 LLM 生成的中断逻辑
# 目前只能通过关闭连接来终止
await websocket.send_json({
"type": "task_cancelled",
"taskId": task_id,
"success": True,
"message": "LLM 生成已终止"
})
else:
# 取消其他类型的任务(图像生成、动态表格等)
success = await task_queue_manager.cancel_task(task_id)
await websocket.send_json({
"type": "task_cancelled",
"taskId": task_id,
"success": success
})
print(f"[WebSocket] ✅ 任务取消结果: {success}")
continue
print(f"\n{'-'*80}")
print(f"[WebSocket] 📨 收到请求:")
print(f" - Floor: {request_data.get('floor')}")
print(f" - Role: {request_data.get('currentRole')}")
print(f" - Chat: {request_data.get('currentChat')}")
print(f" - Stream: {request_data.get('stream', False)}")
print(f" - Message Length: {len(request_data.get('mes', ''))}")
# ✅ 打印 API 配置信息(隐藏密钥)
api_config = request_data.get('apiConfig', {})
current_profile = request_data.get('currentProfile', {})
profile_id = current_profile.get('id') if isinstance(current_profile, dict) else None
print(f" - Profile ID: {profile_id or 'N/A'}")
print(f" - API URL: {api_config.get('api_url', 'N/A')[:50]}..." if len(api_config.get('api_url', '')) > 50 else f" - API URL: {api_config.get('api_url', 'N/A')}")
print(f" - Model: {api_config.get('model', 'N/A')}")
# ✅ 始终从配置文件中读取 API Key不信任前端传来的 Key
if profile_id:
try:
from .apiConfigRoute import load_profile
profile = load_profile(profile_id)
if profile:
# 找到 mainLLM 的配置
main_llm_config = profile.get('apis', {}).get('mainLLM', {})
api_key = main_llm_config.get('apiKey', '')
if api_key:
# 使用明文 API Key
api_config['api_key'] = api_key
request_data['apiConfig'] = api_config
print(f" - API Key: ✅ 已从配置文件加载")
else:
print(f" - API Key: ⚠️ 配置文件中未找到 Key")
else:
print(f" - API Key: ❌ 无法加载配置文件: {profile_id}")
except Exception as e:
print(f" - API Key: ❌ 加载失败: {str(e)}")
import traceback
traceback.print_exc()
else:
print(f" - API Key: ⚠️ 未提供 profileId无法加载")
print(f"{'-'*80}\n")
# 2. 提取流式输出标志
stream_output = request_data.get("stream", False)
if stream_output:
# === 真正的流式输出模式 ===
print(f"[WebSocket] 🌊 进入流式处理模式")
await _handle_stream_chat(
websocket, role_name, chat_name, request_data, workflow_service
)
else:
# === 非流式输出模式 ===
print(f"[WebSocket] 📦 进入非流式处理模式")
result = await workflow_service.process_chat_request(request_data)
if result["success"]:
content = result["content"]
print(f"\n[WebSocket] ✨ 生成成功,内容长度: {len(content)}")
# ✅ 发送激活的世界书条目信息
active_entries = result.get("activeEntries", [])
print(f"[WebSocket] 📚 发送世界书激活信息: {len(active_entries)} 个条目")
await websocket.send_json({
"type": "worldbook_active",
"entries": active_entries
})
# ✅ 发送任务ID信息
task_ids = result.get("taskIds", {})
if task_ids.get("imageWorkflow") or task_ids.get("dynamicTable"):
print(f"[WebSocket] 📋 发送任务ID信息: {task_ids}")
await websocket.send_json({
"type": "tasks_created",
"tasks": task_ids
})
# 一次性发送完整内容
print(f"[WebSocket] 📤 发送完整内容 (chunk)")
await websocket.send_json({
"type": "chunk",
"content": content
})
print(f"[WebSocket] ✅ 发送完成信号")
await websocket.send_json({
"type": "complete"
})
# 保存消息
print(f"[WebSocket] 💾 保存消息到文件...")
await _save_messages(role_name, chat_name, request_data, content)
print(f"[WebSocket] ✅ 消息保存完成\n")
else:
error_msg = result["error"]
print(f"[WebSocket] ❌ 处理失败: {error_msg}")
await websocket.send_json({
"type": "error",
"message": error_msg
})
except WebSocketDisconnect:
print(f"\n{'='*80}")
print(f"[WebSocket] 🔌 连接断开: {role_name}/{chat_name}")
print(f"{'='*80}\n")
except Exception as e:
print(f"\n{'='*80}")
print(f"[WebSocket] ⚠️ 错误: {str(e)}")
print(f"{'='*80}\n")
import traceback
traceback.print_exc()
try:
await websocket.send_json({
"type": "error",
"message": f"服务器错误: {str(e)}"
})
except:
pass
finally:
try:
await websocket.close()
except:
pass
async def _handle_stream_chat(
websocket: WebSocket,
role_name: str,
chat_name: str,
request_data: Dict[str, Any],
workflow_service
):
"""
处理流式聊天请求 engine callbacks emit worldbook_active / tasks_created / chunk.
"""
try:
print(f"[StreamChat] 🚀 开始流式处理")
chunk_count = [0]
async def on_worldbook_active(entries):
if entries:
print(f"[StreamChat] 📤 发送世界书激活信息: {len(entries)} 个条目")
await websocket.send_json({
"type": "worldbook_active",
"entries": entries,
})
async def on_tasks_created(task_ids):
if task_ids.get("imageWorkflow") or task_ids.get("dynamicTable"):
print(f"[StreamChat] 📤 发送任务ID信息: {task_ids}")
await websocket.send_json({
"type": "tasks_created",
"tasks": task_ids,
})
async def on_chunk(chunk):
chunk_count[0] += 1
if chunk_count[0] % 10 == 0:
print(f"[StreamChat] 📤 已发送 {chunk_count[0]} 个 chunks")
await websocket.send_json({"type": "chunk", "content": chunk})
result = await workflow_service.process_chat_request_stream(
request_data,
on_chunk=on_chunk,
on_worldbook_active=on_worldbook_active,
on_tasks_created=on_tasks_created,
)
if result["success"]:
content = result["content"]
print(f"\n[StreamChat] ✨ 流式生成成功,总长度: {len(content)}")
print(f"[StreamChat] ✅ 发送完成信号")
await websocket.send_json({"type": "complete"})
print(f"[StreamChat] 💾 保存消息到文件...")
await _save_messages(role_name, chat_name, request_data, content)
print(f"[StreamChat] ✅ 消息保存完成\n")
else:
error_msg = result["error"]
print(f"[StreamChat] ❌ 流式处理失败: {error_msg}")
await websocket.send_json({
"type": "error",
"message": error_msg,
})
except Exception as e:
print(f"\n[StreamChat] ⚠️ 错误: {str(e)}")
import traceback
traceback.print_exc()
await websocket.send_json({
"type": "error",
"message": f"流式处理失败: {str(e)}",
})
async def _save_messages(
role_name: str,
chat_name: str,
request_data: Dict[str, Any],
ai_response: str
):
"""
保存用户消息和AI回复到聊天文件
Args:
role_name: 角色名
chat_name: 聊天名
request_data: 前端发送的请求数据
ai_response: AI生成的回复
"""
try:
from datetime import datetime
# ✅ 应用双 false 的正则规则(永久修改存储数据)
from services.regex_service import regex_service
from models.regex_rules import RegexPlacement
# 获取预设名称
preset_config = request_data.get("presetConfig", {})
preset_name = preset_config.get("selectedPreset")
# 计算消息深度
floor = request_data.get("floor", 0)
message_depth = 0 # AI 回复是最新消息,深度为 0
# ✅ 应用 AI Output 正则规则placement=2
# 只应用双 false 的规则markdownOnly=false 且 promptOnly=false
processed_ai_response = regex_service.apply_rules_by_placement(
text=ai_response,
placement=RegexPlacement.AI_OUTPUT.value,
character_name=role_name,
preset_name=preset_name,
message_depth=message_depth,
is_for_llm=False, # ✅ 不是发送给 LLM是保存数据
is_markdown_rendered=False # ✅ 不是 Markdown 渲染后
)
# 如果处理后的内容与原始内容不同,说明有双 false 规则被应用
if processed_ai_response != ai_response:
print(f"[Regex] ✅ 已应用双 false 正则规则(永久修改存储数据)")
ai_response = processed_ai_response
# ✅ 检查是否是重roll模式targetFloor 存在且不为 null
target_floor = request_data.get("floor")
is_reroll = target_floor is not None
if is_reroll:
# ✅ 重roll模式更新现有消息的 swipes 数组
print(f"[WebSocket] 🔄 重roll模式更新楼层 {target_floor} 的 swipes")
# 获取现有的消息
existing_message = chat_service.get_message(role_name, chat_name, target_floor)
if not existing_message:
print(f"[WebSocket] ⚠️ 找不到楼层 {target_floor} 的消息,创建新消息")
# 如果找不到,创建新消息(兼容处理)
ai_message = {
"id": f"msg_{datetime.now().timestamp()}_ai",
"name": request_data.get("characterName", role_name),
"is_user": False,
"is_system": False,
"sendDate": datetime.now().isoformat(),
"mes": ai_response,
"chatId": f"{role_name}/{chat_name}",
"floor": target_floor,
"swipes": [ai_response],
"swipe_id": 0
}
chat_service.add_message(role_name, chat_name, ai_message)
else:
# ✅ 更新 swipes 数组
existing_swipes = existing_message.get("swipes", [])
current_mes = existing_message.get("mes", "")
# 构建新的 swipes 数组
updated_swipes = list(existing_swipes) # 复制现有swipes
# 如果当前 mes 不在 swipes 中,先添加它
if current_mes and current_mes not in updated_swipes:
updated_swipes.append(current_mes)
print(f"[WebSocket] 📝 将当前内容添加到 swipes")
# 添加新生成的内容
updated_swipes.append(ai_response)
print(f"[WebSocket] 📊 Swipes 更新: {len(existing_swipes)} -> {len(updated_swipes)}")
# 更新消息
update_data = {
"mes": ai_response, # 显示最新内容
"swipes": updated_swipes, # 更新 swipes 数组
"swipe_id": len(updated_swipes) - 1 # 自动切换到新版本
}
chat_service.update_message(role_name, chat_name, target_floor, update_data)
print(f"[WebSocket] ✅ 楼层 {target_floor} 已更新swipes 数量: {len(updated_swipes)}")
else:
# ✅ 正常模式创建新的用户消息和AI消息
print(f"[WebSocket] 正常模式,创建新消息")
# 1. 保存用户消息
user_message = {
"id": f"msg_{datetime.now().timestamp()}_user",
"name": request_data.get("userName", "User"),
"is_user": True,
"is_system": False,
"sendDate": datetime.now().isoformat(),
"mes": request_data.get("mes", ""),
"chatId": f"{role_name}/{chat_name}",
"floor": request_data.get("floor", 0)
}
chat_service.add_message(role_name, chat_name, user_message)
# 2. 保存AI回复
ai_message = {
"id": f"msg_{datetime.now().timestamp()}_ai",
"name": request_data.get("characterName", role_name),
"is_user": False,
"is_system": False,
"sendDate": datetime.now().isoformat(),
"mes": ai_response,
"chatId": f"{role_name}/{chat_name}",
"floor": request_data.get("floor", 0) + 1
}
chat_service.add_message(role_name, chat_name, ai_message)
print(f"[WebSocket] ✅ 新消息已保存: {role_name}/{chat_name}")
except Exception as e:
print(f"[WebSocket] 保存消息失败: {e}")
import traceback
traceback.print_exc()
# 不抛出异常,避免影响主流程

View File

@@ -1,56 +1,178 @@
from fastapi import APIRouter, HTTPException, status
# TODO: 实现 ChatService 来替代旧的 ChatHistory 逻辑
# from services.chat_service import ChatService
from pathlib import Path
try:
from backend.services.chat_service import ChatService
from backend.core.config import settings
except ImportError:
# Docker环境直接从当前目录导入
from services.chat_service import ChatService
from core.config import settings
router = APIRouter(prefix="/chat", tags=["chat"])
# 初始化聊天服务
data_path = Path(settings.DATA_PATH) if hasattr(settings, 'DATA_PATH') else Path("data")
chat_service = ChatService(data_path)
@router.get("", response_model=dict)
async def list_all_chats():
"""获取所有角色的所有聊天列表"""
# return await ChatService.list_all_chats()
return {"chats": []}
return chat_service.list_all_chats()
# 注意:路由定义顺序很重要!更具体的路由(更多参数)必须放在前面
@router.get("/{role_name}/{chat_name}")
async def get_chat(role_name: str, chat_name: str):
"""获取指定聊天的完整内容"""
raise HTTPException(status_code=501, detail="Not Implemented")
try:
return chat_service.get_chat(role_name, chat_name)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.get("/{role_name}")
async def list_role_chats(role_name: str):
"""获取指定角色的所有聊天列表"""
try:
all_chats = chat_service.list_all_chats()
# 从所有聊天中筛选出该角色的聊天
role_chats = all_chats.get(role_name, [])
return role_chats
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{role_name}", status_code=status.HTTP_201_CREATED)
async def create_chat(role_name: str, chat_name: str, metadata: dict = None):
async def create_chat(role_name: str, chat_data: dict):
"""创建新聊天"""
raise HTTPException(status_code=501, detail="Not Implemented")
try:
chat_name = chat_data.get("chat_name", "新聊天")
metadata = chat_data.get("metadata", {})
return chat_service.create_chat(role_name, chat_name, metadata)
except FileExistsError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.put("/{role_name}/{chat_name}")
async def update_chat(role_name: str, chat_name: str, update_data: dict):
"""更新聊天元数据"""
# TODO: 实现更新聊天元数据功能
raise HTTPException(status_code=501, detail="Not Implemented")
@router.delete("/{role_name}/{chat_name}")
async def delete_chat(role_name: str, chat_name: str):
"""删除指定聊天"""
# TODO: 实现删除聊天功能
raise HTTPException(status_code=501, detail="Not Implemented")
@router.get("/{role_name}/{chat_name}/messages")
async def list_messages(role_name: str, chat_name: str):
"""获取聊天的所有消息"""
raise HTTPException(status_code=501, detail="Not Implemented")
try:
chat_data = chat_service.get_chat(role_name, chat_name)
return {"messages": chat_data["messages"]}
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.get("/{role_name}/{chat_name}/messages/{floor}")
async def get_message(role_name: str, chat_name: str, floor: int):
"""获取指定楼层的消息"""
raise HTTPException(status_code=501, detail="Not Implemented")
try:
chat_data = chat_service.get_chat(role_name, chat_name)
for msg in chat_data["messages"]:
if msg.get("floor") == floor:
return msg
raise HTTPException(status_code=404, detail=f"Message at floor {floor} not found")
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.post("/{role_name}/{chat_name}/messages", status_code=status.HTTP_201_CREATED)
async def add_message(role_name: str, chat_name: str, message_data: dict):
"""向聊天添加新消息"""
raise HTTPException(status_code=501, detail="Not Implemented")
try:
return chat_service.add_message(role_name, chat_name, message_data)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.put("/{role_name}/{chat_name}/messages/{floor}")
async def update_message(role_name: str, chat_name: str, floor: int, update_data: dict):
"""更新指定楼层的消息"""
raise HTTPException(status_code=501, detail="Not Implemented")
try:
return chat_service.update_message(role_name, chat_name, floor, update_data)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.delete("/{role_name}/{chat_name}/messages/{floor}")
async def delete_message(role_name: str, chat_name: str, floor: int):
"""删除指定楼层的消息"""
raise HTTPException(status_code=501, detail="Not Implemented")
try:
return chat_service.delete_message(role_name, chat_name, floor)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.put("/{role_name}/{chat_name}/table")
async def update_table_data(role_name: str, chat_name: str, table_update: dict):
"""更新表格数据(带时间戳冲突解决)"""
try:
return chat_service.update_table_data(role_name, chat_name, table_update)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{role_name}/{chat_name}/branch", status_code=status.HTTP_201_CREATED)
async def branch_chat(role_name: str, chat_name: str, branch_data: dict):
"""
创建聊天分支
复制当前楼层及之前的所有内容到一个新的聊天记录
Args:
role_name: 角色名称
chat_name: 原聊天名称
branch_data: {
"target_floor": int, # 目标楼层(包含该楼层及之前的内容)
"new_chat_name": str # 新聊天名称(可选,默认自动生成)
}
Returns:
{
"success": bool,
"new_chat_name": str,
"message_count": int
}
"""
try:
target_floor = branch_data.get("target_floor")
new_chat_name = branch_data.get("new_chat_name")
if target_floor is None:
raise HTTPException(status_code=400, detail="缺少 target_floor 参数")
# 调用服务层创建分支
result = chat_service.create_branch(role_name, chat_name, target_floor, new_chat_name)
return result
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"创建分支失败: {str(e)}")

View File

@@ -0,0 +1,439 @@
import json
import logging
from typing import List
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
import services.tools.fiction_tools # noqa: F401 — register fiction tools
from models.fiction_models import (
CreateFictionBookRequest,
EmotionFlowCatalog,
FictionBookMeta,
FictionBookMetadata,
FictionBookSettings,
FictionBookSummary,
FictionChapter,
FictionChapterSummary,
FictionGenerationRequest,
FictionGuideWorldbook,
FictionPipelineTickResult,
FictionRunState,
FictionStartReadingResult,
GuideGlobalEntries,
OpenBookRequest,
OpenBookResult,
UpdateFictionBookSettingsRequest,
UpdateFictionProgressRequest,
)
from services.fiction_chapter_service import ensure_chapter, run_chapter
from services.fiction_coarse_service import run_coarse_outline
from services.fiction_event_plan_service import (
iter_event_plan,
run_event_plan,
stream_event_plan_subscribe,
)
from services.fiction_metadata_service import fiction_metadata_service
from services.fiction_open_book_service import run_open_book
from services.fiction_planning_service import (
ensure_chapter_plan,
ensure_event_chain,
ensure_volume,
)
from services.fiction_orchestrator_service import (
get_pending_stages,
get_pipeline_run,
start_reading_pipeline,
tick_reading_pipeline,
)
from services.fiction_service import fiction_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/fiction", tags=["fiction"])
@router.get("/books", response_model=List[FictionBookSummary])
async def list_fiction_books():
try:
return fiction_service.list_books()
except Exception as e:
logger.error("Failed to list fiction books: %s", e)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/books", response_model=FictionBookMeta)
async def create_fiction_book(req: CreateFictionBookRequest):
try:
return fiction_service.create_book(req)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except FileExistsError as e:
raise HTTPException(status_code=409, detail=str(e))
except Exception as e:
logger.error("Failed to create fiction book: %s", e)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/books/{book_id}", response_model=FictionBookMeta)
async def get_fiction_book_meta(book_id: str):
try:
return fiction_service.get_book_meta(book_id)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to get fiction book %s: %s", book_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/books/{book_id}")
async def delete_fiction_book(book_id: str):
try:
fiction_service.delete_book(book_id)
return {"ok": True}
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to delete fiction book %s: %s", book_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/books/{book_id}/settings", response_model=FictionBookSettings)
async def get_fiction_book_settings(book_id: str):
try:
return fiction_service.get_book_settings(book_id)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to get settings for %s: %s", book_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.put("/books/{book_id}/settings", response_model=FictionBookSettings)
async def update_fiction_book_settings(
book_id: str, req: UpdateFictionBookSettingsRequest
):
try:
return fiction_service.update_book_settings(book_id, req)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to update settings for %s: %s", book_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/books/{book_id}/guide", response_model=FictionGuideWorldbook)
async def get_fiction_book_guide(book_id: str):
try:
return fiction_service.get_book_guide(book_id)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to get guide for %s: %s", book_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/emotion-flows/catalog", response_model=EmotionFlowCatalog)
async def get_emotion_flow_catalog():
try:
return fiction_service.get_emotion_catalog()
except Exception as e:
logger.error("Failed to get emotion catalog: %s", e)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/guide-global/entries", response_model=GuideGlobalEntries)
async def get_guide_global_entries():
try:
return fiction_service.get_guide_global_entries()
except Exception as e:
logger.error("Failed to get guide global entries: %s", e)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/open-book", response_model=OpenBookResult)
async def open_fiction_book(req: OpenBookRequest):
try:
return await run_open_book(
req.inspiration,
profile_id=req.profile_id,
api_config=req.api_config,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error("Failed to open fiction book: %s", e)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/books/{book_id}/metadata", response_model=FictionBookMetadata)
async def get_fiction_book_metadata(book_id: str):
try:
return fiction_metadata_service.get_metadata(book_id)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to get metadata for %s: %s", book_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/books/{book_id}/run", response_model=FictionRunState)
async def get_fiction_book_run(book_id: str):
try:
return fiction_metadata_service.get_run(book_id)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to get run for %s: %s", book_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/books/{book_id}/start", response_model=FictionStartReadingResult)
async def start_fiction_reading(book_id: str, req: FictionGenerationRequest):
"""进入阅读时自动触发粗纲 / 事件纲要流水线(后台异步)。"""
try:
return await start_reading_pipeline(
book_id,
profile_id=req.profile_id,
api_config=req.api_config,
)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to start reading pipeline for %s: %s", book_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/books/{book_id}/pipeline/tick", response_model=FictionPipelineTickResult)
async def tick_fiction_pipeline(book_id: str, req: FictionGenerationRequest):
"""检查并推进流水线(尊重半自动设置)。"""
try:
return await tick_reading_pipeline(
book_id,
profile_id=req.profile_id,
api_config=req.api_config,
)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to tick pipeline for %s: %s", book_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/books/{book_id}/pipeline/status")
async def get_fiction_pipeline_status(book_id: str):
"""流水线 run 状态 + 待手动阶段列表。"""
try:
run = get_pipeline_run(book_id)
pending = get_pending_stages(book_id)
return {"run": run, "pendingStages": pending}
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to get pipeline status for %s: %s", book_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/books/{book_id}/volumes/ensure", response_model=FictionBookMetadata)
async def ensure_fiction_volume(book_id: str, req: FictionGenerationRequest):
try:
return await ensure_volume(
book_id,
profile_id=req.profile_id,
api_config=req.api_config,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to ensure volume for %s: %s", book_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/books/{book_id}/events/ensure", response_model=FictionBookMetadata)
async def ensure_fiction_event_chain(book_id: str, req: FictionGenerationRequest):
try:
return await ensure_event_chain(
book_id,
profile_id=req.profile_id,
api_config=req.api_config,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to ensure event chain for %s: %s", book_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/books/{book_id}/chapter-plans/ensure", response_model=FictionBookMetadata)
async def ensure_fiction_chapter_plan(book_id: str, req: FictionGenerationRequest):
try:
return await ensure_chapter_plan(
book_id,
event_id=req.event_id,
profile_id=req.profile_id,
api_config=req.api_config,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to ensure chapter plan for %s: %s", book_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/books/{book_id}/chapters/ensure", response_model=FictionChapter)
async def ensure_fiction_chapter(book_id: str, req: FictionGenerationRequest):
try:
return await ensure_chapter(
book_id,
profile_id=req.profile_id,
api_config=req.api_config,
seq=req.seq,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to ensure chapter for %s: %s", book_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/books/{book_id}/coarse-outline", response_model=FictionBookMetadata)
async def generate_coarse_outline(book_id: str, req: FictionGenerationRequest):
try:
return await ensure_volume(
book_id,
profile_id=req.profile_id,
api_config=req.api_config,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to ensure volume for %s: %s", book_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/books/{book_id}/event-plan", response_model=FictionBookMetadata)
async def generate_event_plan(book_id: str, req: FictionGenerationRequest):
if req.stream:
async def ndjson_stream():
try:
async for event in iter_event_plan(
book_id,
profile_id=req.profile_id,
api_config=req.api_config,
event_id=req.event_id,
):
yield json.dumps(event, ensure_ascii=False) + "\n"
except FileNotFoundError as e:
yield json.dumps({"type": "error", "message": str(e)}, ensure_ascii=False) + "\n"
except ValueError as e:
yield json.dumps({"type": "error", "message": str(e)}, ensure_ascii=False) + "\n"
except Exception as e:
logger.error("Failed to stream event plan for %s: %s", book_id, e)
yield json.dumps(
{"type": "error", "message": str(e)}, ensure_ascii=False
) + "\n"
return StreamingResponse(ndjson_stream(), media_type="application/x-ndjson")
try:
return await ensure_chapter_plan(
book_id,
event_id=req.event_id,
profile_id=req.profile_id,
api_config=req.api_config,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to generate event plan for %s: %s", book_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/books/{book_id}/event-plan/stream")
async def subscribe_event_plan_stream(book_id: str):
"""订阅事件纲要生成进度NDJSON适用于后台流水线已启动时。"""
try:
fiction_service.get_book_meta(book_id)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
async def ndjson_stream():
try:
async for event in stream_event_plan_subscribe(book_id):
yield json.dumps(event, ensure_ascii=False) + "\n"
except FileNotFoundError as e:
yield json.dumps({"type": "error", "message": str(e)}, ensure_ascii=False) + "\n"
except Exception as e:
logger.error("Failed to subscribe event plan stream for %s: %s", book_id, e)
yield json.dumps({"type": "error", "message": str(e)}, ensure_ascii=False) + "\n"
return StreamingResponse(ndjson_stream(), media_type="application/x-ndjson")
@router.get("/books/{book_id}/chapters", response_model=List[FictionChapterSummary])
async def list_fiction_chapters(book_id: str):
try:
return fiction_service.list_chapter_summaries(book_id)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to list chapters for %s: %s", book_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/books/{book_id}/chapters/{seq}", response_model=FictionChapter)
async def get_fiction_chapter(book_id: str, seq: int):
try:
return fiction_service.get_chapter(book_id, seq)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to get chapter %s for %s: %s", seq, book_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/books/{book_id}/chapter", response_model=FictionChapter)
async def generate_fiction_chapter(book_id: str, req: FictionGenerationRequest):
"""撰写下一章或指定 seq 的章节(本地已存在则直接返回)。"""
try:
return await run_chapter(
book_id,
profile_id=req.profile_id,
api_config=req.api_config,
seq=req.seq,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to generate chapter for %s: %s", book_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/books/{book_id}/progress", response_model=FictionBookMetadata)
async def update_fiction_progress(book_id: str, req: UpdateFictionProgressRequest):
try:
return fiction_metadata_service.update_progress(
book_id,
current_chapter_seq=req.currentChapterSeq,
char_offset=req.charOffset,
)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to update progress for %s: %s", book_id, e)
raise HTTPException(status_code=500, detail=str(e))

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,390 @@
import json
import logging
from typing import Any, Dict, List
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from models.studio_models import (
AdvanceRunRequest,
CreateStudioProjectRequest,
PipelineDefinition,
RenameRunRequest,
RunMessageRequest,
RunRerollRequest,
SaveRunRequest,
StudioProject,
StudioProjectSummary,
StudioRun,
StudioRunSummary,
SwitchRunNodeRequest,
UpdateStudioProjectRequest,
WorkflowTemplateSummary,
WorkflowVariablesResponse,
)
from services.studio_project_service import studio_project_service
from services.studio_run_service import studio_run_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/studio", tags=["studio"])
@router.get("/projects", response_model=List[StudioProjectSummary])
async def list_studio_projects():
try:
return studio_project_service.list_projects()
except Exception as e:
logger.error("Failed to list studio projects: %s", e)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/projects/{project_id}", response_model=StudioProject)
async def get_studio_project(project_id: str):
try:
return studio_project_service.get_project(project_id)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to get studio project %s: %s", project_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/projects/{project_id}", response_model=StudioProject)
async def update_studio_project(project_id: str, req: UpdateStudioProjectRequest):
try:
return studio_project_service.update_project_meta(
project_id,
name=req.name,
description=req.description,
)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to update studio project %s: %s", project_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.put("/projects/{project_id}/pipeline", response_model=StudioProject)
async def save_studio_pipeline(project_id: str, pipeline: PipelineDefinition):
try:
return studio_project_service.save_pipeline(project_id, pipeline)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to save pipeline for %s: %s", project_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/templates", response_model=List[WorkflowTemplateSummary])
async def list_workflow_templates():
try:
return studio_project_service.list_workflow_templates()
except Exception as e:
logger.error("Failed to list workflow templates: %s", e)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/variables", response_model=WorkflowVariablesResponse)
async def get_workflow_variables(projectId: str | None = None):
try:
return studio_project_service.get_workflow_variables(projectId)
except Exception as e:
logger.error("Failed to load workflow variables: %s", e)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/skill-templates")
async def get_skill_templates() -> Dict[str, Any]:
try:
return studio_project_service.get_skill_templates()
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to load skill templates: %s", e)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/niches")
async def get_niches() -> Dict[str, Any]:
try:
return studio_project_service.get_niches()
except Exception as e:
logger.error("Failed to load niches: %s", e)
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/projects/{project_id}")
async def delete_studio_project(project_id: str):
try:
studio_project_service.delete_project(project_id)
return {"ok": True, "id": project_id}
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to delete studio project %s: %s", project_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/projects", response_model=StudioProject)
async def create_studio_project(req: CreateStudioProjectRequest):
try:
return studio_project_service.create_project(req)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to create studio project: %s", e)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/projects/{project_id}/runs", response_model=StudioRun)
async def create_studio_run(project_id: str):
try:
return studio_run_service.create_run(project_id)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to create studio run for %s: %s", project_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/projects/{project_id}/runs", response_model=List[StudioRunSummary])
async def list_studio_runs(project_id: str):
try:
studio_project_service.get_project(project_id)
return studio_run_service.list_runs(project_id)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to list studio runs for %s: %s", project_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/projects/{project_id}/runs/{run_id}", response_model=StudioRun)
async def get_studio_run(project_id: str, run_id: str):
try:
return studio_run_service.get_run(project_id, run_id)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error("Failed to get studio run %s/%s: %s", project_id, run_id, e)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/projects/{project_id}/runs/{run_id}/advance", response_model=StudioRun)
async def advance_studio_run(
project_id: str, run_id: str, req: AdvanceRunRequest
):
try:
return studio_run_service.advance_run(
project_id, run_id, display_params=req.displayParams, save_mode=req.saveMode
)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except NotImplementedError as e:
raise HTTPException(status_code=501, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(
"Failed to advance studio run %s/%s: %s", project_id, run_id, e
)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/projects/{project_id}/runs/{run_id}/save", response_model=StudioRun)
async def save_studio_run(
project_id: str, run_id: str, req: SaveRunRequest
):
try:
return studio_run_service.save_run(project_id, run_id, req.mode)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(
"Failed to save studio run %s/%s: %s", project_id, run_id, e
)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/projects/{project_id}/runs/{run_id}/switch-node", response_model=StudioRun)
async def switch_studio_run_node(
project_id: str, run_id: str, req: SwitchRunNodeRequest
):
try:
return studio_run_service.switch_run_node(project_id, run_id, req.nodeId)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(
"Failed to switch studio run node %s/%s: %s", project_id, run_id, e
)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/projects/{project_id}/runs/{run_id}/message")
async def send_studio_run_message(
project_id: str, run_id: str, req: RunMessageRequest
):
if req.stream:
async def ndjson_stream():
try:
async for event in studio_run_service.send_run_message_stream(
project_id,
run_id,
req.content,
profile_id=req.profileId,
api_config=req.apiConfig,
):
yield json.dumps(event, ensure_ascii=False) + "\n"
except FileNotFoundError as e:
yield json.dumps(
{"type": "error", "detail": str(e)}, ensure_ascii=False
) + "\n"
except ValueError as e:
yield json.dumps(
{"type": "error", "detail": str(e)}, ensure_ascii=False
) + "\n"
except Exception as e:
logger.error(
"Failed to stream studio run message %s/%s: %s",
project_id,
run_id,
e,
)
yield json.dumps(
{"type": "error", "detail": f"消息处理失败:{e}"},
ensure_ascii=False,
) + "\n"
return StreamingResponse(
ndjson_stream(),
media_type="application/x-ndjson",
)
try:
return await studio_run_service.send_run_message(
project_id,
run_id,
req.content,
stream=req.stream,
profile_id=req.profileId,
api_config=req.apiConfig,
)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(
"Failed to send studio run message %s/%s: %s", project_id, run_id, e
)
raise HTTPException(status_code=500, detail=f"消息处理失败:{e}")
@router.post("/projects/{project_id}/runs/{run_id}/undo", response_model=StudioRun)
async def undo_studio_run(project_id: str, run_id: str):
try:
return studio_run_service.undo_run(project_id, run_id)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(
"Failed to undo studio run %s/%s: %s", project_id, run_id, e
)
raise HTTPException(status_code=500, detail=f"回退失败:{e}")
@router.post("/projects/{project_id}/runs/{run_id}/reroll")
async def reroll_studio_run(
project_id: str, run_id: str, req: RunRerollRequest
):
if req.stream:
async def ndjson_stream():
try:
async for event in studio_run_service.reroll_run_stream(
project_id,
run_id,
profile_id=req.profileId,
api_config=req.apiConfig,
):
yield json.dumps(event, ensure_ascii=False) + "\n"
except FileNotFoundError as e:
yield json.dumps(
{"type": "error", "detail": str(e)}, ensure_ascii=False
) + "\n"
except ValueError as e:
yield json.dumps(
{"type": "error", "detail": str(e)}, ensure_ascii=False
) + "\n"
except Exception as e:
logger.error(
"Failed to stream studio run reroll %s/%s: %s",
project_id,
run_id,
e,
)
yield json.dumps(
{"type": "error", "detail": f"重 roll 失败:{e}"},
ensure_ascii=False,
) + "\n"
return StreamingResponse(
ndjson_stream(),
media_type="application/x-ndjson",
)
try:
return await studio_run_service.reroll_run(
project_id,
run_id,
stream=req.stream,
profile_id=req.profileId,
api_config=req.apiConfig,
)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(
"Failed to reroll studio run %s/%s: %s", project_id, run_id, e
)
raise HTTPException(status_code=500, detail=f"重 roll 失败:{e}")
@router.delete("/projects/{project_id}/runs/{run_id}")
async def delete_studio_run(project_id: str, run_id: str):
try:
studio_run_service.delete_run(project_id, run_id)
return {"ok": True, "id": run_id}
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(
"Failed to delete studio run %s/%s: %s", project_id, run_id, e
)
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/projects/{project_id}/runs/{run_id}", response_model=StudioRun)
async def rename_studio_run(
project_id: str, run_id: str, req: RenameRunRequest
):
try:
return studio_run_service.rename_run(project_id, run_id, req.title)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(
"Failed to rename studio run %s/%s: %s", project_id, run_id, e
)
raise HTTPException(status_code=500, detail=str(e))

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):
"""
@@ -105,13 +179,22 @@ async def delete_worldbook(name: str):
logger.error(f"Failed to delete worldbook '{name}': {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{name}/entries", response_model=List[Dict[str, Any]])
async def list_worldbook_entries(name: str):
@router.get("/{name}/entries", response_model=Dict[str, Any])
async def list_worldbook_entries(
name: str,
page: int = 1,
page_size: int = 20
):
"""
获取世界书的所有条目
获取世界书的条目列表(支持分页)
Args:
name: 世界书名称
page: 页码从1开始
page_size: 每页数量默认20
"""
try:
return worldbook_service.list_entries(name)
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:
@@ -204,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,14 +43,40 @@ class Settings:
# 预设目录
PRESET_PATH = DATA_PATH / "preset"
# 聊天记录目录
# 聊天记录目录(同时存放角色卡和聊天)
CHAT_PATH = DATA_PATH / "chat"
# 兼容别名:用于代码中引用
CHATS_PATH = CHAT_PATH
# 临时文件目录
TEMP_PATH = DATA_PATH / "temp"
# ComfyUI 工作流目录
COMFYUI_WORKFLOWS_PATH = DATA_PATH / "comfyui_workflows"
# 角色卡目录(已合并到 CHAT_PATH
CHARACTERS_PATH = CHAT_PATH
# 图片资源目录
IMAGES_PATH = DATA_PATH / "images"
# Agent 工作流模板与运行记录
AGENT_TEMPLATES_PATH = DATA_PATH / "agent" / "templates"
AGENT_RUNS_PATH = DATA_PATH / "agent" / "runs"
AGENT_STUDIO_PROJECTS_PATH = DATA_PATH / "agent" / "studio_projects"
AGENT_STUDIO_RUNS_PATH = DATA_PATH / "agent" / "studio_runs"
AGENT_SKILL_TEMPLATES_FILE = DATA_PATH / "agent" / "skill_templates.json"
AGENT_NICHES_FILE = DATA_PATH / "agent" / "niches.json"
AGENT_WORKFLOW_VARIABLES_FILE = DATA_PATH / "agent" / "workflow_variables.json"
# 爽文Fiction / Novel数据目录
FICTION_PATH = DATA_PATH / "agent" / "fiction"
FICTION_EMOTION_FLOWS_PATH = FICTION_PATH / "emotion_flows"
FICTION_GUIDE_GLOBAL_PATH = FICTION_PATH / "guide_global"
FICTION_BOOKS_PATH = FICTION_PATH / "books"
FICTION_EMOTION_CATALOG_FILE = FICTION_EMOTION_FLOWS_PATH / "catalog.json"
FICTION_GUIDE_GLOBAL_ENTRIES_FILE = FICTION_GUIDE_GLOBAL_PATH / "entries.json"
def ensure_directories(self):
"""确保所有配置的目录存在,如果不存在则创建"""
@@ -64,9 +87,23 @@ class Settings:
self.CHAT_PATH,
self.TEMP_PATH,
self.COMFYUI_WORKFLOWS_PATH,
self.CHARACTERS_PATH,
self.IMAGES_PATH,
self.AGENT_TEMPLATES_PATH,
self.AGENT_RUNS_PATH,
self.AGENT_STUDIO_PROJECTS_PATH,
self.AGENT_STUDIO_RUNS_PATH,
self.FICTION_PATH,
self.FICTION_EMOTION_FLOWS_PATH,
self.FICTION_GUIDE_GLOBAL_PATH,
self.FICTION_BOOKS_PATH,
]
for directory in directories:
directory.mkdir(parents=True, exist_ok=True)
# 确保核心数据文件的父目录存在
for file_path in [self.STATE_FILE, self.SCHEMA_FILE, self.PRESETS_FILE, self.REGEX_FILE, self.SYSTEM_SETTINGS_FILE]:
file_path.parent.mkdir(parents=True, exist_ok=True)
settings = Settings()

View File

@@ -17,12 +17,22 @@ for logger_name in ['uvicorn', 'uvicorn.access', 'fastapi']:
# backend/app/main.py
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
try:
from backend.api.route import router
except ImportError:
from api.route import router
app = FastAPI(title="LLM Workflow Engine")
# 配置CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 开发环境允许所有来源,生产环境应该指定具体域名
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 注册路由
app.include_router(router, prefix="/api")

128
backend/models/agent.py Normal file
View File

@@ -0,0 +1,128 @@
"""
Agent workflow engine data models.
"""
from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import Any, Awaitable, Callable, Dict, List, Optional
from pydantic import BaseModel, Field
class WorkflowTemplateKind(str, Enum):
BUILTIN_CHAT = "builtin.chat"
class RunStatus(str, Enum):
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
class RunEventType(str, Enum):
STATE_ENTER = "state_enter"
TOOL_START = "tool_start"
TOOL_END = "tool_end"
WORLD_BOOK_ACTIVE = "worldbook_active"
TASKS_CREATED = "tasks_created"
CHUNK = "chunk"
ERROR = "error"
COMPLETE = "complete"
class ToolSpec(BaseModel):
name: str
description: str = ""
parameters: Dict[str, Any] = Field(default_factory=dict)
class SkillManifest(BaseModel):
id: str
name: str = ""
description: str = ""
path: str = ""
class WorkflowTemplate(BaseModel):
id: str
kind: WorkflowTemplateKind
name: str = ""
description: str = ""
version: str = "1.0.0"
state_machine_path: str = "state_machine.json"
skills: List[SkillManifest] = Field(default_factory=list)
class ChatRunBinding(BaseModel):
role_name: str
chat_name: str
template_id: str = WorkflowTemplateKind.BUILTIN_CHAT.value
class TurnCallbacks(BaseModel):
"""Optional async callbacks for streaming / WS events."""
model_config = {"arbitrary_types_allowed": True}
on_worldbook_active: Optional[Callable[[List[Any]], Awaitable[None]]] = None
on_tasks_created: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None
on_chunk: Optional[Callable[[str], Awaitable[None]]] = None
class TurnContext(BaseModel):
"""Mutable per-turn execution context passed between tools."""
model_config = {"arbitrary_types_allowed": True}
request_data: Dict[str, Any] = Field(default_factory=dict)
template_id: str = WorkflowTemplateKind.BUILTIN_CHAT.value
run_id: str = ""
stream: bool = False
callbacks: Optional[TurnCallbacks] = None
current_role: str = ""
current_chat: str = ""
user_message: str = ""
preset_name: Optional[str] = None
character: Any = None
active_entries: List[Any] = Field(default_factory=list)
chat_history: List[Any] = Field(default_factory=list)
prompt_messages: List[Any] = Field(default_factory=list)
generated_content: str = ""
token_usage: Dict[str, Any] = Field(default_factory=dict)
duration: float = 0.0
task_ids: Dict[str, Optional[str]] = Field(default_factory=dict)
error: Optional[str] = None
class WorkflowRun(BaseModel):
id: str
template_id: str
binding: ChatRunBinding
status: RunStatus = RunStatus.PENDING
started_at: str = Field(default_factory=lambda: datetime.now().isoformat())
finished_at: Optional[str] = None
current_state: Optional[str] = None
result_content: str = ""
error: Optional[str] = None
class RunEvent(BaseModel):
run_id: str
type: RunEventType
timestamp: str = Field(default_factory=lambda: datetime.now().isoformat())
state: Optional[str] = None
tool: Optional[str] = None
payload: Dict[str, Any] = Field(default_factory=dict)
class ChatTurnResult(BaseModel):
success: bool
content: str = ""
error: Optional[str] = None
active_entries: List[Any] = Field(default_factory=list)
task_ids: Dict[str, Optional[str]] = Field(default_factory=dict)
run_id: str = ""
workflow_template_id: str = WorkflowTemplateKind.BUILTIN_CHAT.value

View File

@@ -0,0 +1,285 @@
"""
爽文Fiction / Novel数据模型。
"""
from __future__ import annotations
from typing import Dict, List, Optional
from pydantic import BaseModel, Field
class EmotionFlowStep(BaseModel):
key: str
text: str
class EmotionFlow(BaseModel):
id: str
intro: str
tags: List[str] = Field(default_factory=list)
steps: List[EmotionFlowStep] = Field(default_factory=list)
class EmotionFlowCatalog(BaseModel):
flows: List[EmotionFlow] = Field(default_factory=list)
class GuideGlobalEntry(BaseModel):
layer: str
title: str
content: str
class GuideGlobalEntries(BaseModel):
entries: List[GuideGlobalEntry] = Field(default_factory=list)
class FictionGuideWorldbook(BaseModel):
"""Book-local guide 世界书:本书具体人设 / 爽点 / 用户体验 / 禁区。"""
persona: str = ""
highlight: str = ""
experience: str = ""
forbiddenZones: str = ""
class FictionBookMeta(BaseModel):
id: str
title: str
allowedFlowIds: List[str] = Field(default_factory=list)
createdAt: str = ""
updatedAt: str = ""
class FictionBookSummary(BaseModel):
id: str
title: str
allowedFlowIds: List[str] = Field(default_factory=list)
updatedAt: str = ""
class FictionPrompts(BaseModel):
openBook: str = ""
coarseOutline: str = ""
eventPlan: str = ""
chapter: str = ""
nudge: str = ""
class FictionReaderSettings(BaseModel):
contextWindowChars: int = 2000
prefetchRemainingWords: int = 300
class FictionPipelineSettings(BaseModel):
"""半自动流水线:各层 ON=自动OFF=需手动触发。"""
semiAuto: bool = False
autoCoarse: bool = True
autoEventPlan: bool = True
autoChapter: bool = True
class FictionBookSettings(BaseModel):
prompts: FictionPrompts = Field(default_factory=FictionPrompts)
reader: FictionReaderSettings = Field(default_factory=FictionReaderSettings)
pipeline: FictionPipelineSettings = Field(default_factory=FictionPipelineSettings)
class CreateFictionBookRequest(BaseModel):
title: str
inspiration: str = ""
guide: FictionGuideWorldbook
allowedFlowIds: List[str] = Field(default_factory=list)
class OpenBookRequest(BaseModel):
inspiration: str
profile_id: Optional[str] = None
api_config: Optional[Dict[str, str]] = None
class OpenBookResult(BaseModel):
title: str
optimizedIntro: str
guide: FictionGuideWorldbook
allowedFlowIds: List[str] = Field(default_factory=list)
class UpdateFictionBookSettingsRequest(BaseModel):
prompts: Optional[FictionPrompts] = None
reader: Optional[FictionReaderSettings] = None
pipeline: Optional[FictionPipelineSettings] = None
class FictionPipelineTickResult(BaseModel):
run: FictionRunState
started: bool = False
pendingStages: List[str] = Field(default_factory=list)
class VolumeOutline(BaseModel):
"""卷纲:当前 10~30 章的阶段规划,不是整本书全局大纲。"""
id: str
order: int = 1
title: str = ""
goal: str = ""
coreConflict: str = ""
powerProgression: str = ""
emotionalPromise: str = ""
endingHook: str = ""
targetChapterCount: int = 20
primaryEmotionFlowId: str = ""
status: str = "active"
class CoarseOutlineEvent(BaseModel):
"""兼容旧 coarseOutline.events同时作为新版事件串条目的轻量视图。"""
id: str
title: str
summary: str = ""
order: int = 1
volumeId: str = ""
class CoarseOutline(BaseModel):
events: List[CoarseOutlineEvent] = Field(default_factory=list)
version: int = 1
class EventChainItem(BaseModel):
"""事件串:卷纲 + 情感链在剧情层的落地。"""
id: str
volumeId: str = ""
order: int = 1
title: str = ""
summary: str = ""
purpose: str = ""
conflict: str = ""
turningPoint: str = ""
expectedPayoff: str = ""
targetChapterCount: int = 3
emotionFlowId: str = ""
emotionStepKey: str = ""
emotionStepText: str = ""
status: str = "planned"
class FlowStepsPlan(BaseModel):
: str = ""
: str = ""
: str = ""
: str = ""
class ChapterPlanItem(BaseModel):
seq: int
# 旧字段:保留以兼容现有 metadata.events[eventId].chapterPlan。
phaseKey: str = ""
phaseSlice: str = ""
brief: str = ""
# 新字段:章纲是写章前最具体的规划层。
eventId: str = ""
title: str = ""
goal: str = ""
opening: str = ""
mainConflict: str = ""
emotionalTurn: str = ""
emotionStepKey: str = ""
emotionGoal: str = ""
payoff: str = ""
endingHook: str = ""
forbidden: str = ""
targetWords: int = 2000
status: str = "planned"
class EventPlanEntry(BaseModel):
emotionFlowId: str = ""
flowStepsPlan: FlowStepsPlan = Field(default_factory=FlowStepsPlan)
chapterPlan: List[ChapterPlanItem] = Field(default_factory=list)
class FictionProgress(BaseModel):
currentChapterSeq: int = 0
charOffset: int = 0
ttsPaused: bool = False
genPaused: bool = False
class FictionChapter(BaseModel):
seq: int
title: str = ""
body: str = ""
charCount: int = 0
status: str = "written"
eventId: str = ""
phaseKey: str = ""
createdAt: str = ""
class FictionChapterSummary(BaseModel):
seq: int
title: str = ""
charCount: int = 0
eventId: str = ""
phaseKey: str = ""
class FictionBookMetadata(BaseModel):
# v2 新规划结构:不再保留旧 coarseOutline/events 作为持久化主结构。
version: int = 2
volumes: List[VolumeOutline] = Field(default_factory=list)
eventChains: Dict[str, List[EventChainItem]] = Field(default_factory=dict)
chapterPlans: Dict[str, List[ChapterPlanItem]] = Field(default_factory=dict)
progress: FictionProgress = Field(default_factory=FictionProgress)
class FictionRunState(BaseModel):
status: str = "idle"
pipelineStage: Optional[str] = None
stage: str = "idle"
message: Optional[str] = None
progress: Optional[Dict[str, int]] = None
updatedAt: str = ""
class FictionStartReadingResult(BaseModel):
run: FictionRunState
started: bool = False
class FictionGenerationRequest(BaseModel):
profile_id: Optional[str] = None
api_config: Optional[Dict[str, str]] = None
event_id: Optional[str] = None
seq: Optional[int] = None
stream: bool = False
class UpdateFictionProgressRequest(BaseModel):
currentChapterSeq: Optional[int] = None
charOffset: Optional[int] = None
ttsPaused: Optional[bool] = None
genPaused: Optional[bool] = None
class FictionPrefetchRequest(BaseModel):
profile_id: Optional[str] = None
api_config: Optional[Dict[str, str]] = None
currentChapterSeq: int
charOffset: int = 0
remainingWords: Optional[int] = None
class FictionPrefetchResult(BaseModel):
run: FictionRunState
started: bool = False
targetSeq: Optional[int] = None
skippedReason: Optional[str] = None

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="发送者名称")
@@ -185,6 +237,11 @@ class ChatMessage(BaseModel):
swipe_id: Optional[int] = Field(0, description="当前选择的版本索引")
tokenCount: Optional[int] = Field(None, description="Token 数量 (用于统计)")
isTemporary: Optional[bool] = Field(None, description="是否为临时消息 (未保存)")
# ✅ 历史记录总结相关字段
is_summarized: bool = Field(False, description="是否已被总结(中间楼层,内容为空)")
is_summary: bool = Field(False, description="是否是总结消息(包含总结文本的楼层)")
summary_range: Optional[str] = Field(None, description="总结范围描述(如 'L1-L8',仅在 is_summary=True 时有值)")
class ChatLog(BaseModel):
@@ -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,258 @@
"""
Studio workflow editor data models.
"""
from __future__ import annotations
from enum import Enum
from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, Field
class DisplayParam(BaseModel):
key: str
label: str
type: str = "text"
required: bool = True
placeholder: str = ""
class InputRef(BaseModel):
ref: str
label: Optional[str] = None
optional: bool = False
class ScoringDimension(BaseModel):
id: str
name: str
criteria: str = ""
class InsertionRagConfig(BaseModel):
libraryId: str = ""
threshold: float = 0.5
maxEntries: int = 3
class InsertionConfig(BaseModel):
position: int = 1
activationType: str = "permanent"
key: str = ""
keysecondary: str = ""
comment: str = ""
ragConfig: Optional[InsertionRagConfig] = None
class ScoringConfig(BaseModel):
enabled: bool = True
dimensions: List[ScoringDimension] = Field(default_factory=list)
rubric: Optional[str] = None
class StudioNode(BaseModel):
id: str
skillId: str
displayName: str
enabled: bool = True
niche: Optional[str] = None
loopUntilSatisfied: bool = False
config: Dict[str, Any] = Field(default_factory=dict)
displayParams: List[DisplayParam] = Field(default_factory=list)
inputs: List[InputRef] = Field(default_factory=list)
class PipelineDefinition(BaseModel):
workflowGoal: str = ""
nodes: List[StudioNode] = Field(default_factory=list)
class StudioProjectMeta(BaseModel):
id: str
name: str
description: str = ""
templateId: Optional[str] = None
characterId: Optional[str] = None
worldbookId: Optional[str] = None
createdAt: str = ""
updatedAt: str = ""
class StudioProject(BaseModel):
meta: StudioProjectMeta
pipeline: PipelineDefinition
class ArtifactDef(BaseModel):
type: str
displayName: str = ""
class SkillTemplateDef(BaseModel):
skillId: str
displayName: str
description: str = ""
displayParams: List[DisplayParam] = Field(default_factory=list)
configWhitelist: List[str] = Field(default_factory=list)
artifacts: List[ArtifactDef] = Field(default_factory=list)
supportsLoopUntilSatisfied: bool = False
supportsInputs: bool = False
supportsInsertion: bool = False
supportsScoring: bool = False
class WorkflowTemplateSummary(BaseModel):
id: str
name: str
description: str = ""
class WorkflowVariableDef(BaseModel):
ref: str
label: str
description: str = ""
class DynamicVariableSuffix(BaseModel):
suffix: str
labelPattern: str
class WorkflowVariablesResponse(BaseModel):
builtIn: List[WorkflowVariableDef] = Field(default_factory=list)
dynamic: List[WorkflowVariableDef] = Field(default_factory=list)
class SkillTemplatesCatalog(BaseModel):
templates: List[SkillTemplateDef] = Field(default_factory=list)
class StudioProjectSummary(BaseModel):
id: str
name: str
description: str = ""
updatedAt: str = ""
class CreateStudioProjectRequest(BaseModel):
name: str = "新项目"
template_id: str = "builtin.studio.example"
project_id: Optional[str] = None
class UpdateStudioProjectRequest(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=120)
description: Optional[str] = Field(None, max_length=500)
class StudioRunStatus(str, Enum):
PENDING = "pending"
RUNNING = "running"
PAUSED = "paused"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
class ToolQuestionOption(BaseModel):
question: str
options: List[str] = Field(default_factory=list)
class StepMessage(BaseModel):
"""Short step-scoped dialogue (not full chat history)."""
id: str
role: str # user | assistant
content: str
createdAt: Optional[str] = None
class LastToolResponse(BaseModel):
"""LLM tool-call payload surfaced to the run UI (R2+)."""
thinking: Optional[str] = None
evaluation: Optional[str] = None
questions: List[ToolQuestionOption] = Field(default_factory=list)
generatedAt: Optional[str] = None
class PromptBlock(BaseModel):
"""Single assembled context section for LLM prompt (R2 debug / execution)."""
id: str
label: str
content: str
source: str = "auto" # auto | manual | workflow
class TurnSnapshot(BaseModel):
"""State captured before each LLM turn (for undo)."""
lastDraft: Optional[Dict[str, Any]] = None
lastToolResponse: Optional[LastToolResponse] = None
stepMessages: List[StepMessage] = Field(default_factory=list)
timestamp: Optional[str] = None
class StudioNodeRunState(BaseModel):
nodeId: str
displayName: str
skillId: str
status: str # pending | active | completed | skipped
loopUntilSatisfied: bool = False
lastDraft: Optional[Dict[str, Any]] = None
lastToolResponse: Optional[LastToolResponse] = None
stepMessages: List[StepMessage] = Field(default_factory=list)
turnHistory: List[TurnSnapshot] = Field(default_factory=list)
class StudioRun(BaseModel):
id: str
projectId: str
status: StudioRunStatus
pipelineSnapshot: PipelineDefinition
pipelineVersionNote: str
currentNodeId: Optional[str] = None
nodeStates: List[StudioNodeRunState] = Field(default_factory=list)
workflowVariables: Dict[str, Any] = Field(default_factory=dict)
lastPromptBlocks: List[PromptBlock] = Field(default_factory=list)
title: str = ""
createdAt: str = ""
updatedAt: str = ""
class AdvanceRunRequest(BaseModel):
displayParams: Dict[str, str] = Field(default_factory=dict)
saveMode: Literal["advance", "append", "overwrite"] = "advance"
class SaveRunRequest(BaseModel):
mode: Literal["incremental", "overwrite"]
class SwitchRunNodeRequest(BaseModel):
nodeId: str = Field(..., min_length=1)
class RunMessageRequest(BaseModel):
content: str = Field(..., min_length=1, max_length=32000)
stream: bool = False
profileId: Optional[str] = None
apiConfig: Optional[Dict[str, str]] = None
class RunRerollRequest(BaseModel):
stream: bool = False
profileId: Optional[str] = None
apiConfig: Optional[Dict[str, str]] = None
class RenameRunRequest(BaseModel):
title: str = Field(..., min_length=1, max_length=120)
class StudioRunSummary(BaseModel):
id: str
projectId: str
status: StudioRunStatus
currentNodeId: Optional[str] = None
title: str = ""
createdAt: str = ""
updatedAt: str = ""

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

@@ -0,0 +1,167 @@
"""
角色卡格式转换器
支持 SillyTavern V2/V3 格式与内部格式的双向转换
"""
import json
import base64
from typing import Optional, Dict, Any
from pathlib import Path
from PIL import Image
import io
try:
from backend.models.internal import CharacterCard
except ImportError:
from models.internal import CharacterCard
class CharacterCardConverter:
"""角色卡格式转换器"""
@staticmethod
def st_to_internal(st_data: dict, avatar_path: Optional[str] = None) -> CharacterCard:
"""
SillyTavern 格式 → 内部格式
Args:
st_data: SillyTavern 角色卡数据V2/V3
avatar_path: 头像路径(可选)
Returns:
CharacterCard 对象
"""
import uuid
from datetime import datetime
# 兼容两种传入方式完整ST格式或直接data
if 'spec' in st_data:
data = st_data.get('data', {})
else:
data = st_data
extensions = data.get('extensions', {})
return CharacterCard(
id=str(uuid.uuid4()),
name=data['name'],
description=data.get('description', ''),
personality=data.get('personality', ''),
scenario=data.get('scenario', ''),
first_mes=data.get('first_mes', ''),
mes_example=data.get('mes_example', ''),
categories=[], # ST没有categories
tableHeaders=[], # ST没有tableHeaders
worldInfoId=extensions.get('world'),
outputSchema=None, # ST不支持结构化输出
avatarPath=avatar_path,
alternate_greetings=data.get('alternate_greetings', []),
tags=data.get('tags', []),
createdAt=int(datetime.now().timestamp()),
updatedAt=int(datetime.now().timestamp()),
lastChatAt=None,
isFavorite=extensions.get('fav', False),
version=1
)
@staticmethod
def internal_to_st(character: CharacterCard) -> dict:
"""
内部格式 → SillyTavern V3 格式
Args:
character: CharacterCard 对象
Returns:
SillyTavern V3 格式字典
"""
return {
"spec": "chara_card_v3",
"spec_version": "3.0",
"data": {
"name": character.name,
"description": character.description,
"personality": character.personality,
"scenario": character.scenario,
"first_mes": character.first_mes,
"mes_example": character.mes_example,
"alternate_greetings": character.alternate_greetings or [],
"tags": character.tags or [],
"creator_notes": "",
"system_prompt": "",
"post_history_instructions": "",
"extensions": {
"world": character.worldInfoId,
"talkativeness": 0.5,
"fav": character.isFavorite
}
}
}
@staticmethod
def export_as_png(character: CharacterCard, avatar_path: Optional[str] = None, use_default_avatar: bool = False) -> bytes:
"""
导出为 SillyTavern PNG 格式
Args:
character: CharacterCard 对象
avatar_path: 头像图片路径(可选)
use_default_avatar: 是否使用默认头像不嵌入JSON数据
Returns:
PNG 文件的二进制数据
"""
# 1. 创建/加载图片
if avatar_path and Path(avatar_path).exists():
img = Image.open(avatar_path)
else:
# 创建默认图片400x600像素灰色背景
img = Image.new('RGB', (400, 600), color=(73, 109, 137))
# 确保是 RGBA 模式
if img.mode != 'RGBA':
img = img.convert('RGBA')
# 2. 如果不是默认头像,才嵌入 JSON 数据
if not use_default_avatar:
st_data = CharacterCardConverter.internal_to_st(character)
json_str = json.dumps(st_data, ensure_ascii=False)
base64_data = base64.b64encode(json_str.encode('utf-8')).decode('ascii')
img.text['ccv3'] = base64_data
# 3. 保存到字节流
buffer = io.BytesIO()
img.save(buffer, format='PNG')
buffer.seek(0)
return buffer.read()
@staticmethod
def extract_from_png(png_data: bytes) -> Optional[dict]:
"""
从 PNG 文件中提取嵌入的角色数据
Args:
png_data: PNG 文件的二进制数据
Returns:
SillyTavern 格式字典,如果没有嵌入数据则返回 None
"""
try:
img = Image.open(io.BytesIO(png_data))
# 尝试 V3 格式 (ccv3)
if 'ccv3' in img.text:
json_str = base64.b64decode(img.text['ccv3']).decode('utf-8')
return json.loads(json_str)
# 尝试 V2 格式 (chara)
elif 'chara' in img.text:
json_str = base64.b64decode(img.text['chara']).decode('utf-8')
return json.loads(json_str)
# 没有嵌入数据
return None
except Exception as e:
print(f"解析PNG失败: {e}")
return None

View File

@@ -0,0 +1,345 @@
"""
角色卡服务 - 严格按照 internal.py 的数据结构
每个角色一个文件夹,包含 character.json、avatar.png 和 chats/
"""
import json
from pathlib import Path
from typing import List, Optional
from datetime import datetime
import uuid
try:
from backend.models.internal import CharacterCard
from backend.core.config import settings
from backend.services.character_card_converter import CharacterCardConverter
except ImportError:
from models.internal import CharacterCard
from core.config import settings
from services.character_card_converter import CharacterCardConverter
class CharacterService:
"""角色卡管理服务"""
def __init__(self):
self.characters_dir = settings.CHARACTERS_PATH
self.converter = CharacterCardConverter()
# 确保目录存在
self.characters_dir.mkdir(parents=True, exist_ok=True)
def scan_all_characters(self) -> List[CharacterCard]:
"""
扫描所有角色卡
Returns:
按 lastChatAt 排序的角色卡列表(最新的在前)
"""
characters = []
for char_folder in self.characters_dir.iterdir():
if not char_folder.is_dir():
continue
try:
character = self._load_character_from_folder(char_folder)
if character:
characters.append(character)
except Exception as e:
print(f"加载角色卡失败 {char_folder.name}: {e}")
continue
# 按最后聊天时间排序None 排最后)
characters.sort(
key=lambda c: c.lastChatAt or 0,
reverse=True
)
return characters
def _load_character_from_folder(self, folder: Path) -> Optional[CharacterCard]:
"""
从文件夹加载角色卡
Args:
folder: 角色文件夹路径
Returns:
CharacterCard 对象或 None
"""
# 1. 读取 character.json必须存在
char_file = folder / "character.json"
if not char_file.exists():
return None
with open(char_file, 'r', encoding='utf-8') as f:
data = json.load(f)
# 2. 检查是否有 avatar.png
avatar_path = None
avatar_file = folder / "avatar.png"
if avatar_file.exists():
# 存储相对路径,用于前端访问
avatar_path = f"/api/characters/{folder.name}/avatar"
# 3. 计算最后聊天时间
last_chat_at = self._get_last_chat_timestamp(folder)
# 4. 构建 CharacterCard 对象(严格按照数据结构)
character = CharacterCard(
id=data.get('id', str(uuid.uuid4())),
name=data['name'],
description=data.get('description', ''),
personality=data.get('personality', ''),
scenario=data.get('scenario', ''),
first_mes=data.get('first_mes', ''),
mes_example=data.get('mes_example', ''),
categories=data.get('categories', []),
tags=data.get('tags', []), # ✅ 使用标签数组
worldInfoId=data.get('worldInfoId'),
outputSchema=data.get('outputSchema'),
avatarPath=avatar_path,
alternate_greetings=data.get('alternate_greetings', []),
tableMaintenancePrompt=data.get('tableMaintenancePrompt'),
imageGenerationPrompt=data.get('imageGenerationPrompt'),
tableHeaders=data.get('tableHeaders'), # ✅ 动态表格表头
tableDefaults=data.get('tableDefaults'), # ✅ 动态表格默认值
createdAt=data.get('createdAt', int(datetime.now().timestamp())),
updatedAt=data.get('updatedAt', int(datetime.now().timestamp())),
lastChatAt=last_chat_at,
isFavorite=data.get('isFavorite', False),
version=data.get('version', 1)
)
return character
def _get_last_chat_timestamp(self, char_folder: Path) -> Optional[int]:
"""
获取角色的最后聊天时间戳
通过扫描 chats 目录下所有 .jsonl 文件的修改时间
"""
chats_dir = char_folder / "chats"
if not chats_dir.exists():
return None
latest_time = None
for chat_file in chats_dir.glob("*.jsonl"):
file_mtime = int(chat_file.stat().st_mtime)
if latest_time is None or file_mtime > latest_time:
latest_time = file_mtime
return latest_time
def get_character_by_name(self, name: str) -> Optional[CharacterCard]:
"""根据角色名获取角色卡"""
char_folder = self.characters_dir / name
if not char_folder.exists():
return None
return self._load_character_from_folder(char_folder)
def create_character(self, character_data: dict) -> CharacterCard:
"""
创建新角色卡
Args:
character_data: 角色数据字典
Returns:
创建的 CharacterCard 对象
"""
# 生成唯一ID
if 'id' not in character_data:
character_data['id'] = str(uuid.uuid4())
# 设置时间戳
now = int(datetime.now().timestamp())
character_data['createdAt'] = now
character_data['updatedAt'] = now
character_data['lastChatAt'] = None
# 创建文件夹
char_name = character_data['name']
char_folder = self.characters_dir / char_name
char_folder.mkdir(parents=True, exist_ok=True)
# 创建 chats 目录
chats_dir = char_folder / "chats"
chats_dir.mkdir(exist_ok=True)
# 保存 character.json
char_file = char_folder / "character.json"
with open(char_file, 'w', encoding='utf-8') as f:
json.dump(character_data, f, ensure_ascii=False, indent=2)
return self._load_character_from_folder(char_folder)
def update_character(self, name: str, updates: dict) -> CharacterCard:
"""
更新角色卡
Args:
name: 角色名(旧名称,用于定位文件夹)
updates: 更新的字段(可以包含 name 字段来重命名)
Returns:
更新后的 CharacterCard 对象
"""
char_folder = self.characters_dir / name
char_file = char_folder / "character.json"
if not char_file.exists():
raise FileNotFoundError(f"角色卡不存在: {name}")
# 读取现有数据
with open(char_file, 'r', encoding='utf-8') as f:
existing_data = json.load(f)
# 检查是否需要重命名
new_name = updates.get('name')
needs_rename = new_name and new_name != name
if needs_rename:
# 验证新名称是否合法
if not new_name or new_name.strip() == '':
raise ValueError("角色名不能为空")
# 检查新名称是否已存在
new_folder = self.characters_dir / new_name
if new_folder.exists():
raise FileExistsError(f"角色 '{new_name}' 已存在")
# 重命名文件夹
try:
import shutil
shutil.move(str(char_folder), str(new_folder))
char_folder = new_folder
char_file = char_folder / "character.json"
except Exception as e:
raise RuntimeError(f"重命名文件夹失败: {str(e)}")
# 合并更新
existing_data.update(updates)
existing_data['updatedAt'] = int(datetime.now().timestamp())
# 保存
with open(char_file, 'w', encoding='utf-8') as f:
json.dump(existing_data, f, ensure_ascii=False, indent=2)
return self._load_character_from_folder(char_folder)
def delete_character(self, name: str) -> bool:
"""
删除角色卡(包括所有聊天记录)
Args:
name: 角色名
Returns:
是否成功删除
"""
char_folder = self.characters_dir / name
if not char_folder.exists():
return False
import shutil
shutil.rmtree(char_folder)
return True
def save_avatar(self, name: str, image_data: bytes) -> str:
"""
保存角色头像
Args:
name: 角色名
image_data: 图片二进制数据
Returns:
头像访问路径
"""
char_folder = self.characters_dir / name
avatar_file = char_folder / "avatar.png"
with open(avatar_file, 'wb') as f:
f.write(image_data)
return f"/api/characters/{name}/avatar"
def import_from_png(self, png_data: bytes, filename: str) -> CharacterCard:
"""
从 SillyTavern PNG 导入角色卡
Args:
png_data: PNG 文件二进制数据
filename: 原始文件名
Returns:
创建的 CharacterCard 对象
"""
# 1. 提取嵌入数据
st_data = self.converter.extract_from_png(png_data)
if not st_data:
raise ValueError("PNG文件中没有嵌入角色数据")
# 2. 转换为内部格式
character = self.converter.st_to_internal(st_data)
# 3. 创建角色文件夹
char_name = character.name
char_folder = self.characters_dir / char_name
char_folder.mkdir(parents=True, exist_ok=True)
# 4. 保存 PNG 作为 avatar.png
avatar_file = char_folder / "avatar.png"
with open(avatar_file, 'wb') as f:
f.write(png_data)
# 5. 保存 character.json
char_file = char_folder / "character.json"
with open(char_file, 'w', encoding='utf-8') as f:
json.dump(character.dict(), f, ensure_ascii=False, indent=2)
# 6. 创建 chats 目录
(char_folder / "chats").mkdir(exist_ok=True)
return character
def export_as_png(self, name: str) -> bytes:
"""
导出角色为 SillyTavern PNG 格式
Args:
name: 角色名
Returns:
PNG 文件二进制数据
"""
character = self.get_character_by_name(name)
if not character:
raise FileNotFoundError(f"角色 '{name}' 不存在")
# 获取头像路径
avatar_path = None
if character.avatarPath:
# 从路径中提取文件名
avatar_filename = character.avatarPath.split('/')[-1].split('?')[0]
char_folder = self.characters_dir / name
avatar_file = char_folder / avatar_filename
if avatar_file.exists():
avatar_path = str(avatar_file)
# 如果没有头像,使用默认图片
use_default = False
if not avatar_path:
default_avatar = self.characters_dir / "defult.png"
if default_avatar.exists():
avatar_path = str(default_avatar)
use_default = True
print(f"使用默认头像: {avatar_path}")
else:
print("警告: 没有找到默认头像")
# 生成 PNG
return self.converter.export_as_png(character, avatar_path, use_default_avatar=use_default)

View File

@@ -0,0 +1,659 @@
"""
聊天服务 - 处理聊天记录的读写操作
基于 SillyTavern JSONL 格式的聊天记录管理
"""
from pathlib import Path
from typing import Dict, List, Optional, Any
import json
import logging
from datetime import datetime
import uuid
from core.config import settings
logger = logging.getLogger(__name__)
class ChatService:
"""聊天服务类处理聊天记录的CRUD操作"""
def __init__(self, data_path: Path):
"""
初始化聊天服务
Args:
data_path: 数据目录路径
"""
self.data_path = data_path
self.chat_dir = data_path / "chat"
self.chat_dir.mkdir(parents=True, exist_ok=True)
def list_all_chats(self) -> Dict[str, List[Dict]]:
"""
获取所有角色和聊天列表
Returns:
Dict[str, List[Dict]]: 字典结构,键是角色名称,值是该角色的聊天信息列表
"""
result = {}
if not self.chat_dir.exists():
logger.warning(f"聊天目录不存在: {self.chat_dir}")
return result
for role_dir in self.chat_dir.iterdir():
try:
if role_dir.is_dir():
chats = []
for chat_file in role_dir.glob("*.jsonl"):
chat_info = self._get_chat_summary(role_dir.name, chat_file.stem)
if chat_info:
chats.append(chat_info)
if chats:
result[role_dir.name] = chats
except Exception as e:
logger.error(f"处理角色目录 {role_dir.name} 时出错: {str(e)}")
continue
return result
def _get_chat_summary(self, role_name: str, chat_name: str) -> Optional[Dict]:
"""
获取聊天摘要信息
Args:
role_name: 角色名称
chat_name: 聊天名称
Returns:
Dict: 聊天摘要信息如果文件不存在则返回None
"""
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
if not chat_file.exists():
return None
try:
with open(chat_file, 'r', encoding='utf-8') as f:
lines = f.readlines()
if not lines:
return None
# 第一行是header
header = json.loads(lines[0])
# 计算消息数量排除header
message_count = len(lines) - 1
# 获取最后修改时间
last_modified = datetime.fromtimestamp(
chat_file.stat().st_mtime
).isoformat()
# 获取最后一条消息预览
last_message = ""
if message_count > 0:
try:
last_msg_data = json.loads(lines[-1])
last_message = last_msg_data.get("mes", "")
except:
pass
return {
"chat_name": chat_name,
"user_name": header.get("user_name", "User"),
"character_name": header.get("character_name", ""),
"last_modified": last_modified,
"message_count": message_count,
"last_message": last_message
}
except Exception as e:
logger.error(f"读取聊天摘要失败 {role_name}/{chat_name}: {str(e)}")
return None
def get_chat(self, role_name: str, chat_name: str) -> Dict[str, Any]:
"""
获取指定聊天的完整内容
Args:
role_name: 角色名称
chat_name: 聊天名称
Returns:
Dict: 包含metadata和messages的字典
Raises:
FileNotFoundError: 聊天文件不存在
"""
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
if not chat_file.exists():
raise FileNotFoundError(f"Chat not found: {role_name}/{chat_name}")
try:
with open(chat_file, 'r', encoding='utf-8') as f:
lines = f.readlines()
if not lines:
raise ValueError(f"Empty chat file: {role_name}/{chat_name}")
# 第一行是header
header = json.loads(lines[0])
# 解析消息
messages = []
for i, line in enumerate(lines[1:], start=1):
if line.strip(): # 跳过空行
msg_data = json.loads(line)
# 确保有floor字段
if "floor" not in msg_data:
msg_data["floor"] = i
messages.append(msg_data)
return {
"header": header, # 完整的 header包含 tableHeaders, tableDefaults, tableData
"metadata": {
"user_name": header.get("user_name", "User"),
"character_name": header.get("character_name", ""),
"chat_id": header.get("chat_id_hash", ""),
"integrity": header.get("integrity", "")
},
"messages": messages
}
except Exception as e:
logger.error(f"读取聊天失败 {role_name}/{chat_name}: {str(e)}")
raise
def get_message(self, role_name: str, chat_name: str, floor: int) -> Dict:
"""
获取指定楼层的消息
Args:
role_name: 角色名称
chat_name: 聊天名称
floor: 楼层号
Returns:
Dict: 消息数据,如果不存在则返回 None
"""
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
if not chat_file.exists():
return None
try:
with open(chat_file, 'r', encoding='utf-8') as f:
lines = f.readlines()
# 找到对应的消息行floor + 1因为第0行是header
message_line_index = floor + 1
if message_line_index >= len(lines):
return None
# 解析并返回消息
msg_data = json.loads(lines[message_line_index])
return msg_data
except Exception as e:
logger.error(f"获取消息失败 {role_name}/{chat_name}/{floor}: {str(e)}")
return None
def create_chat(self, role_name: str, chat_name: str, metadata: Dict = None) -> Dict:
"""
创建新聊天
Args:
role_name: 角色名称
chat_name: 聊天名称
metadata: 聊天元数据
Returns:
Dict: 创建的聊天信息
Raises:
FileExistsError: 聊天已存在
"""
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
if chat_file.exists():
raise FileExistsError(f"Chat already exists: {role_name}/{chat_name}")
# 创建角色目录
chat_file.parent.mkdir(parents=True, exist_ok=True)
# 尝试从角色卡获取 tags关键字列表
tags = []
try:
character_file = settings.CHARACTERS_PATH / role_name / "character.json"
if character_file.exists():
with open(character_file, 'r', encoding='utf-8') as f:
character_data = json.load(f)
tags = character_data.get('tags', [])
logger.info(f"从角色卡 {role_name} 继承标签: {tags}")
except Exception as e:
logger.warning(f"读取角色卡失败,使用空标签: {e}")
# 构建header
header = {
"user_name": metadata.get("user_name", "User") if metadata else "User",
"character_name": metadata.get("character_name", role_name) if metadata else role_name,
"integrity": str(uuid.uuid4()),
"chat_id_hash": str(uuid.uuid4()),
"note_prompt": "",
"note_interval": 0,
"note_position": 0,
"note_depth": 0,
"note_role": 0,
"extensions": {},
"timedWorldInfo": {},
"variables": {},
"tainted": False,
"lastInContextMessageId": -1,
"tags": tags # ✅ 使用标签数组替代 tableHeaders/tableDefaults/tableData
}
# 写入header
with open(chat_file, 'w', encoding='utf-8') as f:
f.write(json.dumps(header, ensure_ascii=False) + '\n')
return {
"role_name": role_name,
"chat_name": chat_name,
"metadata": {
"user_name": header["user_name"],
"character_name": header["character_name"]
}
}
def add_message(self, role_name: str, chat_name: str, message_data: Dict) -> Dict:
"""
向聊天添加新消息
Args:
role_name: 角色名称
chat_name: 聊天名称
message_data: 消息数据
Returns:
Dict: 添加的消息
Raises:
FileNotFoundError: 聊天不存在
"""
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
if not chat_file.exists():
raise FileNotFoundError(f"Chat not found: {role_name}/{chat_name}")
try:
# 读取现有消息以确定floor
with open(chat_file, 'r', encoding='utf-8') as f:
lines = f.readlines()
# 计算下一个floor号
next_floor = len(lines) - 1 # 减去header行
# 构建完整的消息数据
full_message = {
"name": message_data.get("name", "User"),
"is_user": message_data.get("is_user", True),
"is_system": message_data.get("is_system", False),
"floor": next_floor,
"send_date": message_data.get("send_date", str(int(datetime.now().timestamp() * 1000))),
"mes": message_data.get("mes", ""),
"extra": message_data.get("extra", {}),
"swipes": message_data.get("swipes", []),
"swipe_id": message_data.get("swipe_id", 0),
"force_avatar": None,
"variables": [],
"variables_initialized": [],
"is_ejs_processed": []
}
# 追加消息到文件
with open(chat_file, 'a', encoding='utf-8') as f:
f.write(json.dumps(full_message, ensure_ascii=False) + '\n')
return full_message
except Exception as e:
logger.error(f"添加消息失败 {role_name}/{chat_name}: {str(e)}")
raise
def update_message(self, role_name: str, chat_name: str, floor: int, update_data: Dict) -> Dict:
"""
更新指定楼层的消息
Args:
role_name: 角色名称
chat_name: 聊天名称
floor: 楼层号
update_data: 更新的数据
Returns:
Dict: 更新后的消息
Raises:
FileNotFoundError: 聊天不存在
ValueError: 楼层不存在
"""
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
if not chat_file.exists():
raise FileNotFoundError(f"Chat not found: {role_name}/{chat_name}")
try:
# 读取所有行
with open(chat_file, 'r', encoding='utf-8') as f:
lines = f.readlines()
# 找到对应的消息行floor + 1因为第0行是header
message_line_index = floor + 1
if message_line_index >= len(lines):
raise ValueError(f"Floor {floor} not found in chat")
# 解析并更新消息
msg_data = json.loads(lines[message_line_index])
msg_data.update(update_data)
# 写回文件
lines[message_line_index] = json.dumps(msg_data, ensure_ascii=False) + '\n'
with open(chat_file, 'w', encoding='utf-8') as f:
f.writelines(lines)
return msg_data
except Exception as e:
logger.error(f"更新消息失败 {role_name}/{chat_name}/{floor}: {str(e)}")
raise
def delete_message(self, role_name: str, chat_name: str, floor: int) -> Dict:
"""
删除指定楼层的消息
Args:
role_name: 角色名称
chat_name: 聊天名称
floor: 楼层号
Returns:
Dict: 被删除的消息
Raises:
FileNotFoundError: 聊天不存在
ValueError: 楼层不存在
"""
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
if not chat_file.exists():
raise FileNotFoundError(f"Chat not found: {role_name}/{chat_name}")
try:
# 读取所有行
with open(chat_file, 'r', encoding='utf-8') as f:
lines = f.readlines()
# 找到对应的消息行
message_line_index = floor + 1
if message_line_index >= len(lines):
raise ValueError(f"Floor {floor} not found in chat")
# 保存被删除的消息
deleted_msg = json.loads(lines[message_line_index])
# 删除该行
del lines[message_line_index]
# 重新编号后续消息的floor
for i in range(message_line_index, len(lines)):
if lines[i].strip(): # 跳过空行
msg_data = json.loads(lines[i])
msg_data["floor"] = i - 1 # 重新计算floor
lines[i] = json.dumps(msg_data, ensure_ascii=False) + '\n'
# 写回文件
with open(chat_file, 'w', encoding='utf-8') as f:
f.writelines(lines)
return deleted_msg
except Exception as e:
logger.error(f"删除消息失败 {role_name}/{chat_name}/{floor}: {str(e)}")
raise
def update_table_data(self, role_name: str, chat_name: str, table_update: Dict) -> Dict:
"""
更新标签数据SillyTavern 关键字机制)
Args:
role_name: 角色名称
chat_name: 聊天名称
table_update: 包含 tags 数组的字典
Returns:
Dict: 更新后的标签数据
Raises:
FileNotFoundError: 聊天文件不存在
"""
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
if not chat_file.exists():
raise FileNotFoundError(f"Chat not found: {role_name}/{chat_name}")
try:
with open(chat_file, 'r', encoding='utf-8') as f:
lines = f.readlines()
if not lines:
raise ValueError(f"Empty chat file: {role_name}/{chat_name}")
# 读取 header
header = json.loads(lines[0])
# 获取新的标签数组
new_tags = table_update.get('tags', [])
# 更新 header 中的 tags
header['tags'] = new_tags
# 写回文件
lines[0] = json.dumps(header, ensure_ascii=False) + '\n'
with open(chat_file, 'w', encoding='utf-8') as f:
f.writelines(lines)
logger.info(f"标签数据已更新: {role_name}/{chat_name}, 标签数: {len(new_tags)}")
return {
"success": True,
"tags": new_tags,
"tagCount": len(new_tags)
}
except Exception as e:
logger.error(f"更新标签数据失败 {role_name}/{chat_name}: {str(e)}")
raise
def summarize_chat_messages(
self,
role_name: str,
chat_name: str,
start_floor: int,
end_floor: int,
summary_text: str
) -> bool:
"""
总结聊天消息:清空原文,将总结放到最后一个楼层
Args:
role_name: 角色名称
chat_name: 聊天名称
start_floor: 总结起始楼层1-based
end_floor: 总结结束楼层1-based
summary_text: 总结文本
Returns:
bool: 是否成功
"""
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
if not chat_file.exists():
raise FileNotFoundError(f"Chat not found: {role_name}/{chat_name}")
try:
with open(chat_file, 'r', encoding='utf-8') as f:
lines = f.readlines()
if not lines:
raise ValueError(f"Empty chat file: {role_name}/{chat_name}")
# 转换为0-based索引
start_idx = start_floor # header在第0行所以L1在第1行
end_idx = end_floor
# 验证范围
if start_idx < 1 or end_idx >= len(lines) or start_idx > end_idx:
raise ValueError(f"Invalid floor range: {start_floor}-{end_floor}")
# 处理消息
for i in range(start_idx, end_idx + 1):
msg_data = json.loads(lines[i])
if i < end_idx:
# 中间楼层:清空内容
msg_data['mes'] = ""
msg_data['is_summarized'] = True
else:
# 最后一个楼层:放入总结文本
msg_data['mes'] = summary_text
msg_data['is_summary'] = True
msg_data['summary_range'] = f"L{start_floor}-L{end_floor}"
lines[i] = json.dumps(msg_data, ensure_ascii=False) + '\n'
# 写回文件
with open(chat_file, 'w', encoding='utf-8') as f:
f.writelines(lines)
logger.info(
f"[ChatService] 总结完成: {role_name}/{chat_name}, "
f"楼层 {start_floor}-{end_floor}"
)
return True
except Exception as e:
logger.error(f"总结聊天消息失败 {role_name}/{chat_name}: {str(e)}")
raise
def create_branch(
self,
role_name: str,
chat_name: str,
target_floor: int,
new_chat_name: Optional[str] = None
) -> Dict:
"""
创建聊天分支
复制目标楼层及之前的所有内容到一个新的聊天记录
Args:
role_name: 角色名称
chat_name: 原聊天名称
target_floor: 目标楼层(包含该楼层及之前的内容)
new_chat_name: 新聊天名称(可选,默认自动生成)
Returns:
Dict: {
"success": bool,
"new_chat_name": str,
"message_count": int
}
Raises:
FileNotFoundError: 聊天不存在
ValueError: 楼层不存在
"""
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
if not chat_file.exists():
raise FileNotFoundError(f"Chat not found: {role_name}/{chat_name}")
try:
# 读取所有行
with open(chat_file, 'r', encoding='utf-8') as f:
lines = f.readlines()
if not lines:
raise ValueError(f"Empty chat file: {role_name}/{chat_name}")
# 验证目标楼层
# floor + 1 是因为第0行是header
message_line_index = target_floor + 1
if message_line_index >= len(lines):
raise ValueError(f"Floor {target_floor} not found in chat (total messages: {len(lines) - 1})")
# 生成新聊天名称
if not new_chat_name:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
new_chat_name = f"branch_{chat_name}_{timestamp}"
# 创建新聊天文件
new_chat_file = self.chat_dir / role_name / f"{new_chat_name}.jsonl"
if new_chat_file.exists():
raise FileExistsError(f"Branch chat already exists: {new_chat_name}")
# 复制 header 和目标楼层及之前的消息
branch_lines = [lines[0]] # header
for i in range(1, message_line_index + 1):
msg_data = json.loads(lines[i])
# 重新分配 floor从0开始
msg_data["floor"] = i - 1
branch_lines.append(json.dumps(msg_data, ensure_ascii=False) + '\n')
# 写入新文件
with open(new_chat_file, 'w', encoding='utf-8') as f:
f.writelines(branch_lines)
message_count = len(branch_lines) - 1 # 减去header
logger.info(
f"[ChatService] 创建分支成功: {role_name}/{chat_name} -> {new_chat_name}, "
f"楼层: 0-{target_floor}, 消息数: {message_count}"
)
return {
"success": True,
"new_chat_name": new_chat_name,
"message_count": message_count,
"branched_from": chat_name,
"target_floor": target_floor
}
except (FileExistsError, ValueError):
raise
except Exception as e:
logger.error(f"创建分支失败 {role_name}/{chat_name}: {str(e)}")
raise
# 全局实例
chat_service = ChatService(Path(settings.DATA_PATH))

View File

@@ -0,0 +1,213 @@
"""
聊天总结服务
负责调用LLM对历史消息进行总结
"""
import json
from typing import List, Dict, Any, Optional
from datetime import datetime
from models.internal import ChatMessage, SummaryConfig
from core.config import settings
class ChatSummaryService:
"""聊天总结服务类"""
@staticmethod
async def summarize_messages(
messages: List[ChatMessage],
start_floor: int,
end_floor: int,
summary_config: SummaryConfig,
api_config: Dict[str, str]
) -> str:
"""
对指定范围的消息进行总结
Args:
messages: 完整的消息列表
start_floor: 总结起始楼层1-based
end_floor: 总结结束楼层1-based
summary_config: 总结配置
api_config: API配置 {api_url, api_key, model}
Returns:
总结文本
"""
# 1. 提取需要总结的消息
messages_to_summarize = ChatSummaryService._extract_messages(
messages, start_floor, end_floor, summary_config.includeUserInput
)
if not messages_to_summarize:
return ""
# 2. 构建总结提示词
prompt = ChatSummaryService._build_summary_prompt(
messages_to_summarize, summary_config
)
# 3. 调用LLM生成总结
summary_text = await ChatSummaryService._call_llm_for_summary(
prompt, api_config, summary_config.maxSummaryLength
)
return summary_text
@staticmethod
def _extract_messages(
messages: List[ChatMessage],
start_floor: int,
end_floor: int,
include_user_input: bool
) -> List[ChatMessage]:
"""
提取需要总结的消息
✅ 根据用户需求后端不筛选全部输入给LLM
Args:
messages: 完整消息列表
start_floor: 起始楼层
end_floor: 结束楼层
include_user_input: 是否包含用户输入(此参数目前不使用,但保留以保持接口兼容)
Returns:
需要总结的消息列表(全部消息,不做筛选)
"""
# 转换为0-based索引
start_idx = start_floor - 1
end_idx = end_floor - 1
# 提取范围内的所有消息(不筛选)
return messages[start_idx:end_idx + 1]
@staticmethod
def _build_summary_prompt(
messages: List[ChatMessage],
summary_config: SummaryConfig
) -> str:
"""
构建总结提示词
Args:
messages: 需要总结的消息列表
summary_config: 总结配置
Returns:
完整的提示词
"""
# 使用用户自定义的总结提示词,或默认提示词
base_prompt = summary_config.summaryPrompt or (
"请总结以下对话内容,保留关键信息和上下文。"
"用简洁的语言概括主要事件、人物状态和重要细节。"
)
# 构建对话内容
conversation_text = "\n\n".join([
f"{'用户' if msg.is_user else msg.name}: {msg.mes}"
for msg in messages
])
# 组合完整提示词
full_prompt = f"""{base_prompt}
对话内容:
{conversation_text}
总结要求:
1. 保持简洁明了
2. 保留关键情节和设定
3. 不超过{summary_config.maxSummaryLength}
4. 使用客观叙述语气
总结:"""
return full_prompt
@staticmethod
async def _call_llm_for_summary(
prompt: str,
api_config: Dict[str, str],
max_length: int
) -> str:
"""
调用LLM生成总结
Args:
prompt: 总结提示词
api_config: API配置
max_length: 最大长度限制
Returns:
总结文本
"""
try:
# 导入LLM客户端
from utils.llm_client import llm_client
# 构建消息
messages = [
{
"role": "system",
"content": "你是一个专业的对话总结助手,擅长提取关键信息并用简洁的语言概括。"
},
{
"role": "user",
"content": prompt
}
]
# 调用LLM
response = await llm_client.chat_completion(
messages=messages,
api_url=api_config.get("api_url", ""),
api_key=api_config.get("api_key", ""),
model=api_config.get("model", "gpt-3.5-turbo"),
temperature=0.3, # 总结需要较低的随机性
max_tokens=max_length,
request_timeout=30
)
# 提取总结文本
if isinstance(response, dict):
summary = response.get("choices", [{}])[0].get("message", {}).get("content", "")
else:
summary = str(response)
# 清理和截断
summary = summary.strip()
if len(summary) > max_length:
summary = summary[:max_length] + "..."
return summary
except Exception as e:
print(f"[ChatSummary] ❌ LLM总结失败: {e}")
# 返回降级总结(基于规则的简单摘要)
return ChatSummaryService._fallback_summary(prompt)
@staticmethod
def _fallback_summary(prompt: str) -> str:
"""
降级总结当LLM调用失败时使用
Args:
prompt: 原始提示词
Returns:
简单的降级总结
"""
# 提取对话中的关键信息
lines = prompt.split("\n")
user_lines = [l for l in lines if l.startswith("用户:")]
ai_lines = [l for l in lines if l.startswith("AI:")]
fallback = f"[自动总结] 对话包含 {len(user_lines)} 条用户消息和 {len(ai_lines)} 条AI回复。"
return fallback
# 全局实例
chat_summary_service = ChatSummaryService()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,317 @@
"""
爽文章节写作fiction.chapter— 新版 metadata v2 章纲驱动。
"""
from __future__ import annotations
import json
import logging
import re
from datetime import datetime
from typing import Any, Dict, Iterator, List, Optional, Tuple
from langchain_core.messages import HumanMessage, SystemMessage
from models.fiction_models import (
ChapterPlanItem,
EventChainItem,
FictionBookMetadata,
FictionChapter,
VolumeOutline,
)
from services.fiction_metadata_service import fiction_metadata_service
from services.fiction_planning_service import ensure_chapter_plan
from services.fiction_prompt_utils import resolve_prompt
from services.fiction_service import fiction_service
from services.studio_step_respond import resolve_api_config
from utils.llm_client import LLMClient
logger = logging.getLogger(__name__)
_llm_client = LLMClient()
_JSON_FENCE = re.compile(r"```(?:json)?\s*([\s\S]*?)```", re.IGNORECASE)
PlannedChapter = Tuple[int, VolumeOutline, EventChainItem, ChapterPlanItem]
def _extract_json(raw: str) -> Dict[str, Any]:
text = (raw or "").strip()
if not text:
raise ValueError("模型返回为空")
fence = _JSON_FENCE.search(text)
if fence:
text = fence.group(1).strip()
try:
return json.loads(text)
except json.JSONDecodeError:
start = text.find("{")
end = text.rfind("}")
if start >= 0 and end > start:
return json.loads(text[start : end + 1])
raise ValueError("无法解析模型返回的 JSON")
def _validate_api_config(api_config: Dict[str, str]) -> None:
if not api_config.get("api_key"):
raise ValueError("API Key 未配置,请先在 API 配置页面保存密钥")
if not api_config.get("api_url"):
raise ValueError("API 地址未配置,请先在 API 配置页面保存 mainLLM")
if not api_config.get("model"):
raise ValueError("模型未配置,请先在 API 配置页面保存 mainLLM 模型")
def _format_guide_global_layers(layers: List[str]) -> str:
entries = fiction_service.get_guide_global_entries().entries
filtered = [e for e in entries if e.layer in layers]
lines: List[str] = []
for entry in filtered:
lines.append(f"[{entry.layer}] {entry.title}\n{entry.content}")
return "\n\n".join(lines) if lines else "(无全局指南)"
def _format_book_guide(book_id: str) -> str:
guide = fiction_service.get_book_guide(book_id)
return guide.chapter.content or "(无章节层世界书)"
def iter_planned_chapters(metadata: FictionBookMetadata) -> Iterator[PlannedChapter]:
"""按卷纲 → 事件链 → 章纲顺序展开章节计划。"""
volumes = sorted(metadata.volumes or [], key=lambda v: v.order)
for volume in volumes:
events = sorted(metadata.eventChains.get(volume.id, []), key=lambda e: e.order)
for event in events:
plans = sorted(metadata.chapterPlans.get(event.id, []), key=lambda c: c.seq)
for item in plans:
yield item.seq, volume, event, item
def find_next_unwritten_chapter(
book_id: str, metadata: Optional[FictionBookMetadata] = None
) -> Optional[PlannedChapter]:
metadata = metadata or fiction_metadata_service.get_metadata(book_id)
for seq, volume, event, item in iter_planned_chapters(metadata):
if fiction_service.chapter_exists(book_id, seq):
continue
if item.status == "written":
continue
return seq, volume, event, item
return None
def has_written_chapters(book_id: str) -> bool:
return len(fiction_service.list_written_chapter_seqs(book_id)) > 0
def _build_context_tail(book_id: str, before_seq: int, context_chars: int) -> str:
if before_seq <= 1 or context_chars <= 0:
return ""
parts: List[str] = []
for seq in range(1, before_seq):
if not fiction_service.chapter_exists(book_id, seq):
continue
ch = fiction_service.get_chapter(book_id, seq)
if ch.body:
parts.append(ch.body)
combined = "\n\n".join(parts)
if len(combined) <= context_chars:
return combined
return combined[-context_chars:]
def _build_chapter_messages(
book_id: str,
global_seq: int,
volume: VolumeOutline,
event: EventChainItem,
plan_item: ChapterPlanItem,
) -> List[Any]:
settings = fiction_service.get_book_settings(book_id)
user_prompt = settings.prompts.chapter or fiction_service.get_default_settings().prompts.chapter
system_prompt = resolve_prompt("chapter", user_prompt)
context_chars = settings.reader.contextWindowChars if settings.reader else 2000
context_tail = _build_context_tail(book_id, global_seq, context_chars)
guide_l3 = _format_guide_global_layers(["L3"])
book_guide = _format_book_guide(book_id)
context_block = context_tail if context_tail else "(本章为开篇,无上文)"
user_content = f"""## 全局创作指南L3
{guide_l3}
## 本书世界书(章节层)
{book_guide}
## 当前卷纲
- id: {volume.id}
- title: {volume.title}
- goal: {volume.goal}
- coreConflict: {volume.coreConflict}
- emotionalPromise: {volume.emotionalPromise}
## 当前事件
- id: {event.id}
- title: {event.title}
- summary: {event.summary}
- purpose: {event.purpose}
- conflict: {event.conflict}
- turningPoint: {event.turningPoint}
- expectedPayoff: {event.expectedPayoff}
## 本章章纲(第 {global_seq} 章)
- title: {plan_item.title}
- goal: {plan_item.goal}
- opening: {plan_item.opening}
- mainConflict: {plan_item.mainConflict}
- emotionalTurn: {plan_item.emotionalTurn}
- emotionStepKey: {plan_item.emotionStepKey}
- emotionGoal: {plan_item.emotionGoal}
- payoff: {plan_item.payoff}
- endingHook: {plan_item.endingHook}
- forbidden: {plan_item.forbidden}
- targetWords: {plan_item.targetWords}
## 已读上文末尾(最多 {context_chars} 字,供衔接)
{context_block}
## 绝对要求
- 只生成第 {global_seq} 章正文。
- 正文字数目标约 {plan_item.targetWords or 2000} 汉字。
- 必须遵循本章章纲、当前事件、当前卷纲与章节层世界书。
- 不生成下一章章纲,不生成解释说明。"""
return [
SystemMessage(content=system_prompt),
HumanMessage(content=user_content),
]
def _parse_chapter_response(
data: Dict[str, Any],
*,
global_seq: int,
event: EventChainItem,
plan_item: ChapterPlanItem,
) -> FictionChapter:
body = str(data.get("body") or "").strip()
if not body:
raise ValueError("章节正文为空")
title = str(data.get("title") or plan_item.title or f"{global_seq}").strip()
return FictionChapter(
seq=global_seq,
title=title,
body=body,
charCount=len(body),
status="written",
eventId=event.id,
phaseKey=plan_item.emotionStepKey or plan_item.phaseKey,
createdAt=datetime.now().isoformat(),
)
def _mark_chapter_written(
metadata: FictionBookMetadata, event_id: str, plan_seq: int
) -> FictionBookMetadata:
plans = metadata.chapterPlans.get(event_id, [])
updated_plan: List[ChapterPlanItem] = []
for item in plans:
if item.seq == plan_seq:
updated_plan.append(item.model_copy(update={"status": "written"}))
else:
updated_plan.append(item)
metadata.chapterPlans[event_id] = updated_plan
return metadata
async def run_chapter(
book_id: str,
*,
profile_id: Optional[str] = None,
api_config: Optional[Dict[str, str]] = None,
seq: Optional[int] = None,
) -> FictionChapter:
resolved = resolve_api_config(profile_id, api_config)
_validate_api_config(resolved)
metadata = await ensure_chapter_plan(
book_id,
profile_id=profile_id,
api_config=api_config,
)
target: Optional[PlannedChapter] = None
if seq is not None:
for global_seq, volume, event, item in iter_planned_chapters(metadata):
if global_seq == seq:
target = (global_seq, volume, event, item)
break
if not target:
raise ValueError(f"章节规划不存在: seq={seq}")
global_seq, volume, event, item = target
if fiction_service.chapter_exists(book_id, global_seq):
return fiction_service.get_chapter(book_id, global_seq)
else:
found = find_next_unwritten_chapter(book_id, metadata)
if not found:
raise ValueError("没有待撰写的章节")
global_seq, volume, event, item = found
if fiction_service.chapter_exists(book_id, global_seq):
return fiction_service.get_chapter(book_id, global_seq)
run = fiction_metadata_service.get_run(book_id)
if run.status == "error":
fiction_metadata_service.clear_pipeline_error(book_id)
fiction_metadata_service.set_pipeline_stage(
book_id, status="running", pipeline_stage="chapter"
)
try:
messages = _build_chapter_messages(book_id, global_seq, volume, event, item)
response = await _llm_client.chat_completion(
messages=messages,
api_url=resolved["api_url"],
api_key=resolved["api_key"],
model=resolved["model"],
temperature=0.8,
max_tokens=8000,
request_timeout=180,
stream=False,
)
content = response["choices"][0]["message"]["content"]
data = _extract_json(content)
chapter = _parse_chapter_response(
data, global_seq=global_seq, event=event, plan_item=item
)
fiction_service.save_chapter(book_id, chapter)
metadata = fiction_metadata_service.get_metadata(book_id)
metadata = _mark_chapter_written(metadata, event.id, item.seq)
progress = metadata.progress
if progress.currentChapterSeq <= 0:
progress.currentChapterSeq = global_seq
metadata.progress = progress
fiction_metadata_service.save_metadata(book_id, metadata)
fiction_metadata_service.set_pipeline_stage(
book_id, status="idle", pipeline_stage="ready"
)
return chapter
except Exception:
fiction_metadata_service.set_pipeline_stage(
book_id, status="error", pipeline_stage="chapter"
)
raise
async def ensure_chapter(
book_id: str,
*,
profile_id: Optional[str] = None,
api_config: Optional[Dict[str, str]] = None,
seq: Optional[int] = None,
) -> FictionChapter:
return await run_chapter(
book_id,
profile_id=profile_id,
api_config=api_config,
seq=seq,
)

View File

@@ -0,0 +1,178 @@
"""
爽文粗纲生成fiction.coarse— LLM 调用逻辑。
"""
from __future__ import annotations
import json
import logging
import re
from typing import Any, Dict, List, Optional
from langchain_core.messages import HumanMessage, SystemMessage
from models.fiction_models import CoarseOutline, CoarseOutlineEvent, FictionBookMetadata
from services.fiction_metadata_service import fiction_metadata_service
from services.fiction_prompt_utils import resolve_prompt
from services.fiction_service import fiction_service
from services.studio_step_respond import resolve_api_config
from utils.llm_client import LLMClient
logger = logging.getLogger(__name__)
_llm_client = LLMClient()
_JSON_FENCE = re.compile(r"```(?:json)?\s*([\s\S]*?)```", re.IGNORECASE)
def _extract_json(raw: str) -> Dict[str, Any]:
text = (raw or "").strip()
if not text:
raise ValueError("模型返回为空")
fence = _JSON_FENCE.search(text)
if fence:
text = fence.group(1).strip()
try:
return json.loads(text)
except json.JSONDecodeError:
start = text.find("{")
end = text.rfind("}")
if start >= 0 and end > start:
return json.loads(text[start : end + 1])
raise ValueError("无法解析模型返回的 JSON")
def _validate_api_config(api_config: Dict[str, str]) -> None:
if not api_config.get("api_key"):
raise ValueError("API Key 未配置,请先在 API 配置页面保存密钥")
if not api_config.get("api_url"):
raise ValueError("API 地址未配置,请先在 API 配置页面保存 mainLLM")
def _format_guide_global_layers(layers: List[str]) -> str:
entries = fiction_service.get_guide_global_entries().entries
filtered = [e for e in entries if e.layer in layers]
lines: List[str] = []
for entry in filtered:
lines.append(f"[{entry.layer}] {entry.title}\n{entry.content}")
return "\n\n".join(lines) if lines else "(无全局指南)"
def _format_book_guide(book_id: str) -> str:
guide = fiction_service.get_book_guide(book_id)
parts = [
f"主角人设:{guide.persona}",
f"核心爽点:{guide.highlight}",
f"用户体验:{guide.experience}",
f"创作禁区:{guide.forbiddenZones}",
]
return "\n".join(parts)
def _build_coarse_messages(book_id: str) -> List[Any]:
settings = fiction_service.get_book_settings(book_id)
user_prompt = settings.prompts.coarseOutline or fiction_service.get_default_settings().prompts.coarseOutline
system_prompt = resolve_prompt("coarseOutline", user_prompt)
guide_l1 = _format_guide_global_layers(["L1"])
book_guide = _format_book_guide(book_id)
meta = fiction_service.get_book_meta(book_id)
user_content = f"""## 全局创作指南L1仅用于粗纲
{guide_l1}
## 本书 Guide 世界书
{book_guide}
## 书名
{meta.title}
## 已选情绪流 ID
{", ".join(meta.allowedFlowIds or []) or "(未配置)"}
请生成本书的粗纲事件链。"""
return [
SystemMessage(content=system_prompt),
HumanMessage(content=user_content),
]
def _normalize_coarse_events(raw_events: Any) -> List[CoarseOutlineEvent]:
if not isinstance(raw_events, list):
return []
events: List[CoarseOutlineEvent] = []
for idx, item in enumerate(raw_events):
if not isinstance(item, dict):
continue
evt_id = str(item.get("id") or f"evt-{idx + 1}").strip()
title = str(item.get("title") or f"事件 {idx + 1}").strip()
summary = str(item.get("summary") or "").strip()
order = item.get("order")
if not isinstance(order, int):
order = idx + 1
events.append(
CoarseOutlineEvent(id=evt_id, title=title, summary=summary, order=order)
)
events.sort(key=lambda e: e.order)
return events
def _parse_coarse_response(data: Dict[str, Any]) -> CoarseOutline:
events = _normalize_coarse_events(data.get("events"))
if not events:
raise ValueError("粗纲事件列表为空")
version = data.get("version")
if not isinstance(version, int):
version = 1
return CoarseOutline(events=events, version=version)
async def run_coarse_outline(
book_id: str,
*,
profile_id: Optional[str] = None,
api_config: Optional[Dict[str, str]] = None,
) -> FictionBookMetadata:
existing = fiction_metadata_service.get_metadata(book_id)
if existing.coarseOutline.events:
logger.info("Coarse outline already exists for book %s, skipping generation", book_id)
return existing
resolved = resolve_api_config(profile_id, api_config)
_validate_api_config(resolved)
run = fiction_metadata_service.get_run(book_id)
if run.status == "error":
fiction_metadata_service.clear_pipeline_error(book_id)
fiction_metadata_service.set_pipeline_stage(
book_id, status="running", pipeline_stage="coarse"
)
try:
messages = _build_coarse_messages(book_id)
response = await _llm_client.chat_completion(
messages=messages,
api_url=resolved["api_url"],
api_key=resolved["api_key"],
model=resolved.get("model", "gpt-4o-mini"),
temperature=0.7,
max_tokens=8000,
request_timeout=120,
stream=False,
)
content = response["choices"][0]["message"]["content"]
data = _extract_json(content)
coarse = _parse_coarse_response(data)
current = fiction_metadata_service.get_metadata(book_id)
current.coarseOutline = coarse
saved = fiction_metadata_service.save_metadata(book_id, current)
fiction_metadata_service.set_pipeline_stage(
book_id, status="idle", pipeline_stage="coarse_done"
)
return saved
except Exception:
fiction_metadata_service.set_pipeline_stage(
book_id, status="error", pipeline_stage="coarse"
)
raise

View File

@@ -0,0 +1,34 @@
"""
事件纲要生成进度广播 — 供 NDJSON 订阅端与后台流水线共享。
"""
from __future__ import annotations
import asyncio
from typing import Any, AsyncIterator, Dict, List
_subscribers: Dict[str, List[asyncio.Queue]] = {}
def emit(book_id: str, event: Dict[str, Any]) -> None:
for q in list(_subscribers.get(book_id, [])):
try:
q.put_nowait(event)
except asyncio.QueueFull:
pass
async def subscribe(book_id: str) -> AsyncIterator[Dict[str, Any]]:
q: asyncio.Queue = asyncio.Queue(maxsize=128)
_subscribers.setdefault(book_id, []).append(q)
try:
while True:
item = await q.get()
yield item
if item.get("type") in ("done", "error"):
break
finally:
subs = _subscribers.get(book_id, [])
if q in subs:
subs.remove(q)
if not subs:
_subscribers.pop(book_id, None)

View File

@@ -0,0 +1,422 @@
"""
爽文事件规划fiction.event_plan— LLM 调用逻辑。
按事件顺序生成,每完成一个事件即持久化并广播进度。
"""
from __future__ import annotations
import json
import logging
import random
import re
from typing import Any, AsyncIterator, Dict, List, Optional
from langchain_core.messages import HumanMessage, SystemMessage
from models.fiction_models import (
ChapterPlanItem,
CoarseOutlineEvent,
EventPlanEntry,
FictionBookMetadata,
FlowStepsPlan,
)
from services.fiction_event_plan_progress import emit as emit_progress
from services.fiction_event_plan_progress import subscribe as subscribe_progress
from services.fiction_metadata_service import fiction_metadata_service
from services.fiction_prompt_utils import resolve_prompt
from services.fiction_service import fiction_service
from services.studio_step_respond import resolve_api_config
from utils.llm_client import LLMClient
logger = logging.getLogger(__name__)
_llm_client = LLMClient()
_JSON_FENCE = re.compile(r"```(?:json)?\s*([\s\S]*?)```", re.IGNORECASE)
def _extract_json(raw: str) -> Dict[str, Any]:
text = (raw or "").strip()
if not text:
raise ValueError("模型返回为空")
fence = _JSON_FENCE.search(text)
if fence:
text = fence.group(1).strip()
try:
return json.loads(text)
except json.JSONDecodeError:
start = text.find("{")
end = text.rfind("}")
if start >= 0 and end > start:
return json.loads(text[start : end + 1])
raise ValueError("无法解析模型返回的 JSON")
def _validate_api_config(api_config: Dict[str, str]) -> None:
if not api_config.get("api_key"):
raise ValueError("API Key 未配置,请先在 API 配置页面保存密钥")
if not api_config.get("api_url"):
raise ValueError("API 地址未配置,请先在 API 配置页面保存 mainLLM")
def _format_guide_global_layers(layers: List[str]) -> str:
entries = fiction_service.get_guide_global_entries().entries
filtered = [e for e in entries if e.layer in layers]
lines: List[str] = []
for entry in filtered:
lines.append(f"[{entry.layer}] {entry.title}\n{entry.content}")
return "\n\n".join(lines) if lines else "(无全局指南)"
def _format_book_guide(book_id: str) -> str:
guide = fiction_service.get_book_guide(book_id)
parts = [
f"主角人设:{guide.persona}",
f"核心爽点:{guide.highlight}",
f"用户体验:{guide.experience}",
f"创作禁区:{guide.forbiddenZones}",
]
return "\n".join(parts)
def _get_flow_by_id(flow_id: str):
catalog = fiction_service.get_emotion_catalog()
for flow in catalog.flows:
if flow.id == flow_id:
return flow
return None
def _format_flow_steps(flow) -> str:
lines = [f"情绪流:{flow.intro} (id: {flow.id})"]
for step in flow.steps or []:
lines.append(f" [{step.key}] {step.text}")
return "\n".join(lines)
def _build_event_plan_messages(
book_id: str,
event_id: str,
event_title: str,
event_summary: str,
emotion_flow_id: str,
) -> List[Any]:
settings = fiction_service.get_book_settings(book_id)
user_prompt = settings.prompts.eventPlan or fiction_service.get_default_settings().prompts.eventPlan
system_prompt = resolve_prompt("eventPlan", user_prompt)
guide_l2 = _format_guide_global_layers(["L2"])
book_guide = _format_book_guide(book_id)
flow = _get_flow_by_id(emotion_flow_id)
flow_text = _format_flow_steps(flow) if flow else f"情绪流 id: {emotion_flow_id}"
user_content = f"""## 全局创作指南L2仅用于事件规划
{guide_l2}
## 本书 Guide 世界书
{book_guide}
## 当前粗纲事件
- id: {event_id}
- title: {event_title}
- summary: {event_summary}
## 为本事件随机选定的情绪流
{flow_text}
请输出 flowStepsPlan起承转合与 chapterPlan章节级 brief
输出 JSON 示例:
{{
"flowStepsPlan": {{
"": "本阶段规划…",
"": "",
"": "",
"": ""
}},
"chapterPlan": [
{{ "seq": 1, "phaseKey": "", "phaseSlice": "", "brief": "本章要点", "status": "planned" }}
]
}}"""
return [
SystemMessage(content=system_prompt),
HumanMessage(content=user_content),
]
def _normalize_flow_steps_plan(raw: Any) -> FlowStepsPlan:
data = raw if isinstance(raw, dict) else {}
return FlowStepsPlan(
=str(data.get("") or data.get("qi") or ""),
=str(data.get("") or data.get("cheng") or ""),
=str(data.get("") or data.get("zhuan") or ""),
=str(data.get("") or data.get("he") or ""),
)
def _normalize_chapter_plan(raw: Any) -> List[ChapterPlanItem]:
if not isinstance(raw, list):
return []
items: List[ChapterPlanItem] = []
for idx, item in enumerate(raw):
if not isinstance(item, dict):
continue
seq = item.get("seq")
if not isinstance(seq, int):
seq = idx + 1
status = str(item.get("status") or "planned")
items.append(
ChapterPlanItem(
seq=seq,
phaseKey=str(item.get("phaseKey") or item.get("phase_key") or ""),
phaseSlice=str(item.get("phaseSlice") or item.get("phase_slice") or ""),
brief=str(item.get("brief") or ""),
status=status,
)
)
items.sort(key=lambda c: c.seq)
return items
def _parse_event_plan_response(data: Dict[str, Any], emotion_flow_id: str) -> EventPlanEntry:
flow_steps = _normalize_flow_steps_plan(data.get("flowStepsPlan"))
chapter_plan = _normalize_chapter_plan(data.get("chapterPlan"))
if not chapter_plan:
raise ValueError("chapterPlan 为空")
return EventPlanEntry(
emotionFlowId=emotion_flow_id,
flowStepsPlan=flow_steps,
chapterPlan=chapter_plan,
)
def _event_fully_planned(entry: EventPlanEntry) -> bool:
return bool(entry.chapterPlan)
def _resolve_targets(
metadata: FictionBookMetadata,
*,
event_id: Optional[str] = None,
) -> List[CoarseOutlineEvent]:
coarse_events = metadata.coarseOutline.events
if not coarse_events:
raise ValueError("请先生成粗纲")
events_map = dict(metadata.events or {})
if event_id:
targets = [e for e in coarse_events if e.id == event_id]
if not targets:
raise ValueError(f"粗纲中不存在事件: {event_id}")
return targets
return [
e
for e in coarse_events
if e.id not in events_map or not _event_fully_planned(events_map[e.id])
]
def event_planned_payload(evt: CoarseOutlineEvent, entry: EventPlanEntry) -> Dict[str, Any]:
phases: List[str] = []
fsp = entry.flowStepsPlan
for key in ("", "", "", ""):
if getattr(fsp, key, ""):
phases.append(key)
return {
"type": "event_planned",
"eventId": evt.id,
"title": evt.title,
"chapterCount": len(entry.chapterPlan),
"phases": phases,
}
def build_progress_snapshot(book_id: str) -> Dict[str, Any]:
"""已规划事件的目录快照(不含 brief 剧透)。"""
metadata = fiction_metadata_service.get_metadata(book_id)
coarse_events = metadata.coarseOutline.events
events_map = metadata.events or {}
items: List[Dict[str, Any]] = []
for evt in coarse_events:
entry = events_map.get(evt.id)
if entry and _event_fully_planned(entry):
items.append(event_planned_payload(evt, entry))
run = fiction_metadata_service.get_run(book_id)
progress = run.progress or {}
return {
"type": "snapshot",
"items": items,
"done": progress.get("done", len(items)),
"total": progress.get("total", len(coarse_events)),
}
async def _generate_one_event(
book_id: str,
evt: CoarseOutlineEvent,
*,
resolved: Dict[str, str],
allowed: List[str],
) -> EventPlanEntry:
emotion_flow_id = random.choice(allowed)
messages = _build_event_plan_messages(
book_id,
evt.id,
evt.title,
evt.summary,
emotion_flow_id,
)
response = await _llm_client.chat_completion(
messages=messages,
api_url=resolved["api_url"],
api_key=resolved["api_key"],
model=resolved.get("model", "gpt-4o-mini"),
temperature=0.7,
max_tokens=8000,
request_timeout=120,
stream=False,
)
content = response["choices"][0]["message"]["content"]
data = _extract_json(content)
return _parse_event_plan_response(data, emotion_flow_id)
async def iter_event_plan(
book_id: str,
*,
profile_id: Optional[str] = None,
api_config: Optional[Dict[str, str]] = None,
event_id: Optional[str] = None,
) -> AsyncIterator[Dict[str, Any]]:
"""按事件逐个生成,每完成一个即保存并 yield 进度事件。"""
resolved = resolve_api_config(profile_id, api_config)
_validate_api_config(resolved)
metadata = fiction_metadata_service.get_metadata(book_id)
meta = fiction_service.get_book_meta(book_id)
allowed = list(meta.allowedFlowIds or [])
if not allowed:
raise ValueError("本书未配置 allowedFlowIds")
targets = _resolve_targets(metadata, event_id=event_id)
coarse_events = metadata.coarseOutline.events
coarse_total = len(coarse_events)
if not targets:
logger.info("Event plans already exist for book %s, skipping generation", book_id)
fiction_metadata_service.set_pipeline_stage(
book_id, status="idle", pipeline_stage="event_plan_done"
)
fiction_metadata_service.clear_pipeline_progress(book_id)
done_evt: Dict[str, Any] = {"type": "done", "done": coarse_total, "total": coarse_total}
emit_progress(book_id, done_evt)
yield done_evt
return
run = fiction_metadata_service.get_run(book_id)
if run.status == "error":
fiction_metadata_service.clear_pipeline_error(book_id)
fiction_metadata_service.set_pipeline_stage(
book_id, status="running", pipeline_stage="event_plan"
)
events_map = dict(metadata.events or {})
def _planned_count() -> int:
return sum(
1
for e in coarse_events
if e.id in events_map and _event_fully_planned(events_map[e.id])
)
initial_done = _planned_count()
fiction_metadata_service.set_pipeline_progress(
book_id, done=initial_done, total=coarse_total
)
started: Dict[str, Any] = {
"type": "started",
"done": initial_done,
"total": coarse_total,
"pending": len(targets),
}
emit_progress(book_id, started)
yield started
try:
for evt in targets:
entry = await _generate_one_event(
book_id, evt, resolved=resolved, allowed=allowed
)
events_map[evt.id] = entry
metadata.events = events_map
fiction_metadata_service.save_metadata(book_id, metadata)
done_count = _planned_count()
fiction_metadata_service.set_pipeline_progress(
book_id, done=done_count, total=coarse_total
)
payload = event_planned_payload(evt, entry)
emit_progress(book_id, payload)
yield payload
fiction_metadata_service.set_pipeline_stage(
book_id, status="idle", pipeline_stage="event_plan_done"
)
fiction_metadata_service.clear_pipeline_progress(book_id)
done_evt = {"type": "done", "done": _planned_count(), "total": coarse_total}
emit_progress(book_id, done_evt)
yield done_evt
except Exception as exc:
completed = [
eid for eid, ent in events_map.items() if _event_fully_planned(ent)
]
err_evt: Dict[str, Any] = {
"type": "error",
"message": str(exc),
"completedEvents": completed,
"done": _planned_count(),
"total": coarse_total,
}
emit_progress(book_id, err_evt)
fiction_metadata_service.set_pipeline_stage(
book_id, status="error", pipeline_stage="event_plan"
)
yield err_evt
raise
async def run_event_plan(
book_id: str,
*,
profile_id: Optional[str] = None,
api_config: Optional[Dict[str, str]] = None,
event_id: Optional[str] = None,
) -> FictionBookMetadata:
async for _event in iter_event_plan(
book_id,
profile_id=profile_id,
api_config=api_config,
event_id=event_id,
):
pass
return fiction_metadata_service.get_metadata(book_id)
async def stream_event_plan_subscribe(book_id: str) -> AsyncIterator[Dict[str, Any]]:
"""订阅进行中的事件纲要进度(先快照,再实时)。"""
snapshot = build_progress_snapshot(book_id)
yield snapshot
run = fiction_metadata_service.get_run(book_id)
if run.status == "running" and run.pipelineStage == "event_plan":
async for event in subscribe_progress(book_id):
if event.get("type") == "snapshot":
continue
yield event
elif snapshot["items"] or snapshot.get("done", 0) > 0:
yield {
"type": "done",
"done": snapshot["done"],
"total": snapshot["total"],
}

View File

@@ -0,0 +1,168 @@
"""
爽文 metadata.json / run.json 读写服务。
"""
from __future__ import annotations
import json
import logging
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional
from models.fiction_models import FictionBookMetadata, FictionRunState
from services.fiction_service import fiction_service
logger = logging.getLogger(__name__)
_STAGE_MESSAGES: Dict[tuple, tuple] = {
("running", "coarse"): ("coarse_generating", "正在生成粗纲…"),
("running", "event_plan"): ("event_plan_generating", "正在生成事件纲要…"),
("running", "chapter"): ("chapter_generating", "正在撰写正文…"),
("error", "coarse"): ("error", "粗纲生成失败"),
("error", "event_plan"): ("error", "事件纲要生成失败"),
("error", "chapter"): ("error", "章节写作失败"),
}
def _resolve_stage_message(
status: str,
pipeline_stage: Optional[str],
override_message: Optional[str] = None,
) -> tuple:
if override_message is not None:
key = (status, pipeline_stage or "")
stage = _STAGE_MESSAGES.get(key, (status, override_message))[0]
if status == "error":
stage = "error"
elif status == "running" and pipeline_stage == "coarse":
stage = "coarse_generating"
elif status == "running" and pipeline_stage == "event_plan":
stage = "event_plan_generating"
elif status == "running" and pipeline_stage == "chapter":
stage = "chapter_generating"
elif status == "idle":
stage = "idle"
return stage, override_message
matched = _STAGE_MESSAGES.get((status, pipeline_stage or ""))
if matched:
return matched
if status == "idle":
return "idle", None
if status == "error":
return "error", "生成失败"
return status, None
def _read_json(path: Path) -> Any:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def _write_json(path: Path, data: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
class FictionMetadataService:
def _metadata_path(self, book_id: str) -> Path:
return fiction_service._metadata_path(book_id)
def _run_path(self, book_id: str) -> Path:
return fiction_service._run_path(book_id)
def _ensure_book(self, book_id: str) -> None:
if not fiction_service._meta_path(book_id).exists():
raise FileNotFoundError(f"Book not found: {book_id}")
def get_metadata(self, book_id: str) -> FictionBookMetadata:
self._ensure_book(book_id)
path = self._metadata_path(book_id)
if not path.exists():
raise FileNotFoundError(f"metadata.json not found for book: {book_id}")
return FictionBookMetadata(**_read_json(path))
def save_metadata(self, book_id: str, metadata: FictionBookMetadata) -> FictionBookMetadata:
self._ensure_book(book_id)
_write_json(self._metadata_path(book_id), metadata.model_dump())
self._touch_book_meta(book_id)
return metadata
def update_metadata(self, book_id: str, patch: Dict[str, Any]) -> FictionBookMetadata:
current = self.get_metadata(book_id)
data = current.model_dump()
for key, value in patch.items():
data[key] = value
updated = FictionBookMetadata(**data)
return self.save_metadata(book_id, updated)
def get_run(self, book_id: str) -> FictionRunState:
self._ensure_book(book_id)
path = self._run_path(book_id)
if not path.exists():
return FictionRunState()
return FictionRunState(**_read_json(path))
def save_run(self, book_id: str, run: FictionRunState) -> FictionRunState:
self._ensure_book(book_id)
_write_json(self._run_path(book_id), run.model_dump())
return run
def set_pipeline_stage(
self,
book_id: str,
*,
status: str,
pipeline_stage: Optional[str] = None,
message: Optional[str] = None,
) -> FictionRunState:
run = self.get_run(book_id)
run.status = status
run.pipelineStage = pipeline_stage
run.stage, run.message = _resolve_stage_message(status, pipeline_stage, message)
run.updatedAt = datetime.now().isoformat()
return self.save_run(book_id, run)
def clear_pipeline_error(self, book_id: str) -> FictionRunState:
"""清除 error 状态,保留 pipelineStage 供重试参考。"""
run = self.get_run(book_id)
if run.status != "error":
return run
run.status = "idle"
run.stage = "idle"
run.message = None
run.updatedAt = datetime.now().isoformat()
return self.save_run(book_id, run)
def set_pipeline_progress(
self, book_id: str, *, done: int, total: int
) -> FictionRunState:
run = self.get_run(book_id)
run.progress = {"done": done, "total": total}
run.updatedAt = datetime.now().isoformat()
return self.save_run(book_id, run)
def clear_pipeline_progress(self, book_id: str) -> FictionRunState:
run = self.get_run(book_id)
run.progress = None
run.updatedAt = datetime.now().isoformat()
return self.save_run(book_id, run)
def update_progress(
self,
book_id: str,
*,
current_chapter_seq: Optional[int] = None,
char_offset: Optional[int] = None,
):
metadata = self.get_metadata(book_id)
progress = metadata.progress
if current_chapter_seq is not None:
progress.currentChapterSeq = current_chapter_seq
if char_offset is not None:
progress.charOffset = char_offset
metadata.progress = progress
return self.save_metadata(book_id, metadata)
fiction_metadata_service = FictionMetadataService()

View File

@@ -0,0 +1,159 @@
"""
爽文开书优化fiction.open_book— LLM 调用逻辑。
"""
from __future__ import annotations
import json
import logging
import re
from typing import Any, Dict, List, Optional
from langchain_core.messages import HumanMessage, SystemMessage
from models.fiction_models import (
EmotionFlow,
FictionGuideWorldbook,
OpenBookResult,
)
from services.fiction_prompt_utils import resolve_prompt
from services.fiction_service import fiction_service
from services.studio_step_respond import resolve_api_config
from utils.llm_client import LLMClient
logger = logging.getLogger(__name__)
_llm_client = LLMClient()
_JSON_FENCE = re.compile(r"```(?:json)?\s*([\s\S]*?)```", re.IGNORECASE)
def _extract_json(raw: str) -> Dict[str, Any]:
text = (raw or "").strip()
if not text:
raise ValueError("模型返回为空")
fence = _JSON_FENCE.search(text)
if fence:
text = fence.group(1).strip()
try:
return json.loads(text)
except json.JSONDecodeError:
start = text.find("{")
end = text.rfind("}")
if start >= 0 and end > start:
return json.loads(text[start : end + 1])
raise ValueError("无法解析模型返回的 JSON")
def _validate_api_config(api_config: Dict[str, str]) -> None:
if not api_config.get("api_key"):
raise ValueError("API Key 未配置,请先在 API 配置页面保存密钥")
if not api_config.get("api_url"):
raise ValueError("API 地址未配置,请先在 API 配置页面保存 mainLLM")
def _format_catalog_for_prompt(flows: List[EmotionFlow]) -> str:
lines: List[str] = []
for flow in flows:
tags = "".join(flow.tags or [])
lines.append(f"- id: {flow.id}\n intro: {flow.intro}\n tags: {tags}")
return "\n".join(lines) if lines else "(无可用情绪流)"
def _format_guide_global_for_prompt() -> str:
entries = fiction_service.get_guide_global_entries().entries
lines: List[str] = []
for entry in entries:
lines.append(f"[{entry.layer}] {entry.title}\n{entry.content}")
return "\n\n".join(lines) if lines else "(无全局指南)"
def _build_open_book_messages(inspiration: str) -> List[Any]:
default_settings = fiction_service.get_default_settings()
system_prompt = resolve_prompt("openBook", default_settings.prompts.openBook)
catalog = fiction_service.get_emotion_catalog()
catalog_text = _format_catalog_for_prompt(catalog.flows)
guide_global_text = _format_guide_global_for_prompt()
user_content = f"""## 全局创作指南L0L3
{guide_global_text}
## 可选情绪流 catalog
{catalog_text}
## 用户创作灵感
{inspiration.strip()}
请根据以上信息优化开书方案。"""
return [
SystemMessage(content=system_prompt),
HumanMessage(content=user_content),
]
def _normalize_flow_ids(raw_ids: Any, valid_ids: set[str]) -> List[str]:
if not isinstance(raw_ids, list):
return []
result: List[str] = []
for item in raw_ids:
fid = str(item).strip()
if fid in valid_ids and fid not in result:
result.append(fid)
return result
def _parse_open_book_response(
data: Dict[str, Any], valid_flow_ids: set[str]
) -> OpenBookResult:
guide_raw = data.get("guide") or {}
guide = FictionGuideWorldbook(
persona=str(guide_raw.get("persona") or "").strip(),
highlight=str(guide_raw.get("highlight") or "").strip(),
experience=str(guide_raw.get("experience") or "").strip(),
forbiddenZones=str(guide_raw.get("forbiddenZones") or "").strip(),
)
allowed = _normalize_flow_ids(data.get("allowedFlowIds"), valid_flow_ids)
if not allowed and valid_flow_ids:
allowed = [next(iter(valid_flow_ids))]
title = str(data.get("title") or "未命名作品").strip() or "未命名作品"
optimized_intro = str(data.get("optimizedIntro") or "").strip()
return OpenBookResult(
title=title,
optimizedIntro=optimized_intro,
guide=guide,
allowedFlowIds=allowed,
)
async def run_open_book(
inspiration: str,
*,
profile_id: Optional[str] = None,
api_config: Optional[Dict[str, str]] = None,
) -> OpenBookResult:
inspiration = (inspiration or "").strip()
if not inspiration:
raise ValueError("创作灵感不能为空")
resolved = resolve_api_config(profile_id, api_config)
_validate_api_config(resolved)
catalog = fiction_service.get_emotion_catalog()
valid_flow_ids = {f.id for f in catalog.flows}
messages = _build_open_book_messages(inspiration)
response = await _llm_client.chat_completion(
messages=messages,
api_url=resolved["api_url"],
api_key=resolved["api_key"],
model=resolved.get("model", "gpt-4o-mini"),
temperature=0.7,
max_tokens=8000,
request_timeout=120,
stream=False,
)
content = response["choices"][0]["message"]["content"]
data = _extract_json(content)
return _parse_open_book_response(data, valid_flow_ids)

View File

@@ -0,0 +1,290 @@
"""
爽文阅读流水线编排 — 新版 ensure 滚动补齐:卷纲 → 事件链 → 章纲 → 章节。
"""
from __future__ import annotations
import asyncio
import logging
from typing import Dict, List, Optional
from models.fiction_models import (
FictionPipelineSettings,
FictionPipelineTickResult,
FictionRunState,
FictionStartReadingResult,
)
from services.fiction_chapter_service import (
find_next_unwritten_chapter,
has_written_chapters,
run_chapter,
)
from services.fiction_metadata_service import fiction_metadata_service
from services.fiction_planning_service import (
ensure_chapter_plan,
ensure_event_chain,
ensure_volume,
)
from services.fiction_service import fiction_service
logger = logging.getLogger(__name__)
_active_tasks: Dict[str, asyncio.Task] = {}
_lock = asyncio.Lock()
def _get_pipeline_settings(book_id: str) -> FictionPipelineSettings:
settings = fiction_service.get_book_settings(book_id)
return settings.pipeline or FictionPipelineSettings()
def _needs_volume(book_id: str) -> bool:
metadata = fiction_metadata_service.get_metadata(book_id)
return not metadata.volumes
def _needs_event_chain(book_id: str) -> bool:
metadata = fiction_metadata_service.get_metadata(book_id)
if not metadata.volumes:
return False
for volume in metadata.volumes:
if not metadata.eventChains.get(volume.id):
return True
return False
def _needs_chapter_plan(book_id: str) -> bool:
metadata = fiction_metadata_service.get_metadata(book_id)
if not metadata.volumes:
return False
for volume in metadata.volumes:
events = metadata.eventChains.get(volume.id, [])
if not events:
return False
for event in events:
if not metadata.chapterPlans.get(event.id):
return True
return False
def _needs_chapter(book_id: str) -> bool:
if _needs_volume(book_id) or _needs_event_chain(book_id) or _needs_chapter_plan(book_id):
return False
if has_written_chapters(book_id):
return False
metadata = fiction_metadata_service.get_metadata(book_id)
return find_next_unwritten_chapter(book_id, metadata) is not None
def _pipeline_complete(book_id: str) -> bool:
return (
not _needs_volume(book_id)
and not _needs_event_chain(book_id)
and not _needs_chapter_plan(book_id)
and not _needs_chapter(book_id)
)
def get_pending_stages(book_id: str) -> List[str]:
"""返回需手动触发的阶段 id 列表auto 关闭且仍有工作,或上次失败需重试)。"""
pipeline = _get_pipeline_settings(book_id)
pending: List[str] = []
if _needs_volume(book_id) and not pipeline.autoCoarse:
pending.append("volume")
if _needs_event_chain(book_id) and not pipeline.autoEventPlan:
pending.append("event_chain")
if _needs_chapter_plan(book_id) and not pipeline.autoEventPlan:
pending.append("chapter_plan")
if _needs_chapter(book_id) and not pipeline.autoChapter:
pending.append("chapter")
run = fiction_metadata_service.get_run(book_id)
if run.status == "error" and run.pipelineStage:
retry_map = {
"volume": "volume",
"coarse": "volume",
"event_chain": "event_chain",
"event_plan": "chapter_plan",
"chapter_plan": "chapter_plan",
"chapter": "chapter",
}
failed = retry_map.get(run.pipelineStage)
if failed and failed not in pending:
if failed == "volume" and _needs_volume(book_id):
pending.insert(0, failed)
elif failed == "event_chain" and _needs_event_chain(book_id):
pending.insert(0, failed)
elif failed == "chapter_plan" and _needs_chapter_plan(book_id):
pending.insert(0, failed)
elif failed == "chapter" and _needs_chapter(book_id):
pending.insert(0, failed)
return pending
def _next_auto_stage(book_id: str) -> Optional[str]:
pipeline = _get_pipeline_settings(book_id)
if _needs_volume(book_id):
return "volume" if pipeline.autoCoarse else None
if _needs_event_chain(book_id):
return "event_chain" if pipeline.autoEventPlan else None
if _needs_chapter_plan(book_id):
return "chapter_plan" if pipeline.autoEventPlan else None
if _needs_chapter(book_id):
return "chapter" if pipeline.autoChapter else None
return None
async def _run_pipeline(
book_id: str,
*,
profile_id: Optional[str] = None,
api_config: Optional[Dict[str, str]] = None,
) -> None:
pipeline = _get_pipeline_settings(book_id)
try:
if _needs_volume(book_id):
if not pipeline.autoCoarse:
return
await ensure_volume(
book_id, profile_id=profile_id, api_config=api_config
)
if _needs_event_chain(book_id):
if not pipeline.autoEventPlan:
return
await ensure_event_chain(
book_id, profile_id=profile_id, api_config=api_config
)
if _needs_chapter_plan(book_id):
if not pipeline.autoEventPlan:
return
await ensure_chapter_plan(
book_id, profile_id=profile_id, api_config=api_config
)
if _needs_chapter(book_id):
if not pipeline.autoChapter:
return
await run_chapter(
book_id, profile_id=profile_id, api_config=api_config
)
if _pipeline_complete(book_id):
fiction_metadata_service.set_pipeline_stage(
book_id, status="idle", pipeline_stage="ready"
)
except Exception:
logger.exception("Fiction pipeline failed for book %s", book_id)
finally:
async with _lock:
_active_tasks.pop(book_id, None)
async def _task_is_active(book_id: str) -> bool:
async with _lock:
task = _active_tasks.get(book_id)
return task is not None and not task.done()
async def _start_pipeline_task(
book_id: str,
*,
profile_id: Optional[str] = None,
api_config: Optional[Dict[str, str]] = None,
) -> FictionPipelineTickResult:
run = fiction_metadata_service.get_run(book_id)
pending = get_pending_stages(book_id)
if run.status == "running":
if await _task_is_active(book_id):
return FictionPipelineTickResult(run=run, started=False, pendingStages=pending)
logger.warning("Stale running pipeline for book %s, resetting to idle", book_id)
run = fiction_metadata_service.set_pipeline_stage(
book_id, status="idle", pipeline_stage=run.pipelineStage
)
if run.status == "error":
if _pipeline_complete(book_id):
run = fiction_metadata_service.set_pipeline_stage(
book_id, status="idle", pipeline_stage="ready"
)
return FictionPipelineTickResult(
run=run, started=False, pendingStages=pending
)
run = fiction_metadata_service.clear_pipeline_error(book_id)
if _pipeline_complete(book_id):
if run.pipelineStage != "ready":
run = fiction_metadata_service.set_pipeline_stage(
book_id, status="idle", pipeline_stage="ready"
)
return FictionPipelineTickResult(run=run, started=False, pendingStages=pending)
next_stage = _next_auto_stage(book_id)
if not next_stage:
if run.pipelineStage not in (
None,
"ready",
"volume_done",
"event_chain_done",
"chapter_plan_done",
"chapter_done",
):
run = fiction_metadata_service.set_pipeline_stage(
book_id, status="idle", pipeline_stage="ready"
)
return FictionPipelineTickResult(
run=run, started=False, pendingStages=pending
)
async with _lock:
existing = _active_tasks.get(book_id)
if existing and not existing.done():
run = fiction_metadata_service.get_run(book_id)
return FictionPipelineTickResult(
run=run, started=False, pendingStages=pending
)
fiction_metadata_service.set_pipeline_stage(
book_id, status="running", pipeline_stage=next_stage
)
task = asyncio.create_task(
_run_pipeline(
book_id, profile_id=profile_id, api_config=api_config
)
)
_active_tasks[book_id] = task
run = fiction_metadata_service.get_run(book_id)
pending = get_pending_stages(book_id)
return FictionPipelineTickResult(run=run, started=True, pendingStages=pending)
async def tick_reading_pipeline(
book_id: str,
*,
profile_id: Optional[str] = None,
api_config: Optional[Dict[str, str]] = None,
) -> FictionPipelineTickResult:
"""检查 metadata + settings按需启动下一自动阶段。"""
return await _start_pipeline_task(
book_id, profile_id=profile_id, api_config=api_config
)
async def start_reading_pipeline(
book_id: str,
*,
profile_id: Optional[str] = None,
api_config: Optional[Dict[str, str]] = None,
) -> FictionStartReadingResult:
"""进入阅读时的流水线入口(兼容旧接口)。"""
result = await tick_reading_pipeline(
book_id, profile_id=profile_id, api_config=api_config
)
return FictionStartReadingResult(run=result.run, started=result.started)
def get_pipeline_run(book_id: str) -> FictionRunState:
return fiction_metadata_service.get_run(book_id)

View File

@@ -0,0 +1,540 @@
"""
爽文新版规划服务:卷纲 → 情感链事件串 → 章纲。
原则:
- 硬编码提示词只保留规定性约束:输出结构、字数/章节数、必须遵循的上游内容。
- “如何写爽点/如何留钩子”等创作方法交给 book-local 世界书与全局指南。
- book-local 世界书按 volume/event/chapter 三层分别插入,不混用。
"""
from __future__ import annotations
import json
import logging
import random
import re
from typing import Any, Dict, List, Optional
from langchain_core.messages import HumanMessage, SystemMessage
from models.fiction_models import (
ChapterPlanItem,
EventChainItem,
FictionBookMetadata,
VolumeOutline,
)
from services.fiction_metadata_service import fiction_metadata_service
from services.fiction_prompt_utils import resolve_prompt
from services.fiction_service import fiction_service
from services.studio_step_respond import resolve_api_config
from utils.llm_client import LLMClient
logger = logging.getLogger(__name__)
_llm_client = LLMClient()
_JSON_FENCE = re.compile(r"```(?:json)?\s*([\s\S]*?)```", re.IGNORECASE)
def _extract_json(raw: str) -> Dict[str, Any]:
text = (raw or "").strip()
if not text:
raise ValueError("模型返回为空")
fence = _JSON_FENCE.search(text)
if fence:
text = fence.group(1).strip()
try:
return json.loads(text)
except json.JSONDecodeError:
start = text.find("{")
end = text.rfind("}")
if start >= 0 and end > start:
return json.loads(text[start : end + 1])
raise ValueError("无法解析模型返回的 JSON")
def _validate_api_config(api_config: Dict[str, str]) -> None:
if not api_config.get("api_key"):
raise ValueError("API Key 未配置,请先在 API 配置页面保存密钥")
if not api_config.get("api_url"):
raise ValueError("API 地址未配置,请先在 API 配置页面保存 mainLLM")
if not api_config.get("model"):
raise ValueError("模型未配置,请先在 API 配置页面保存 mainLLM 模型")
def _format_guide_global_layers(layers: List[str]) -> str:
entries = fiction_service.get_guide_global_entries().entries
filtered = [e for e in entries if e.layer in layers]
lines: List[str] = []
for entry in filtered:
lines.append(f"[{entry.layer}] {entry.title}\n{entry.content}")
return "\n\n".join(lines) if lines else "(无全局指南)"
def _format_book_guide(book_id: str) -> str:
guide = fiction_service.get_book_guide(book_id)
lines = [
f"主角人设:{guide.persona}",
f"核心爽点:{guide.highlight}",
f"用户体验:{guide.experience}",
f"创作禁区:{guide.forbiddenZones}",
]
text = "\n".join(line for line in lines if line.split("", 1)[1].strip()).strip()
return text or "(无本书 guide"
def _flow_catalog_text() -> str:
catalog = fiction_service.get_emotion_catalog()
lines: List[str] = []
for flow in catalog.flows:
steps = "".join([f"{s.key}:{s.text}" for s in flow.steps])
lines.append(f"- {flow.id}: {flow.intro} | steps={steps}")
return "\n".join(lines) if lines else "(无情感链目录)"
def _get_flow_by_id(flow_id: str):
catalog = fiction_service.get_emotion_catalog()
for flow in catalog.flows:
if flow.id == flow_id:
return flow
return None
def _choose_flow_id(book_id: str) -> str:
meta = fiction_service.get_book_meta(book_id)
allowed = list(meta.allowedFlowIds or [])
catalog = fiction_service.get_emotion_catalog()
catalog_ids = [flow.id for flow in catalog.flows]
candidates = [fid for fid in allowed if fid in catalog_ids] or allowed or catalog_ids
if not candidates:
return ""
return random.choice(candidates)
def _format_flow(flow_id: str) -> str:
flow = _get_flow_by_id(flow_id)
if not flow:
return f"情感链 id: {flow_id or '(未指定)'}"
lines = [f"情感链:{flow.intro} (id: {flow.id})"]
for step in flow.steps or []:
lines.append(f"- {step.key}: {step.text}")
return "\n".join(lines)
def _next_volume_id(metadata: FictionBookMetadata) -> str:
return f"vol_{len(metadata.volumes) + 1:03d}"
def _next_event_id(metadata: FictionBookMetadata, index: int) -> str:
all_events = [event for chain in metadata.eventChains.values() for event in chain]
return f"evt_{len(all_events) + index + 1:04d}"
def _build_volume_messages(book_id: str, metadata: FictionBookMetadata) -> List[Any]:
settings = fiction_service.get_book_settings(book_id)
user_prompt = (
settings.prompts.coarseOutline
or fiction_service.get_default_settings().prompts.coarseOutline
)
system_prompt = resolve_prompt("volumeOutline", user_prompt)
meta = fiction_service.get_book_meta(book_id)
guide_l1 = _format_guide_global_layers(["L1"])
book_guide = _format_book_guide(book_id)
existing = "\n".join([f"- {v.id} {v.title}: {v.goal}" for v in metadata.volumes]) or "(暂无)"
user_content = f"""## 全局创作指南L1
{guide_l1}
## 本书 guide具体人设/爽点/体验/禁区)
{book_guide}
## 书名
{meta.title}
## 已有卷纲
{existing}
## 可用情感链目录
{_flow_catalog_text()}
## 本次任务
生成下一卷卷纲。
## 绝对要求
- 只生成 1 卷。
- 本卷目标章节数 targetChapterCount 必须在 10 到 30 之间。
- primaryEmotionFlowId 必须来自可用情感链目录;如目录为空则留空。
- 不生成事件链、章纲或正文。
"""
return [SystemMessage(content=system_prompt), HumanMessage(content=user_content)]
def _normalize_volume(raw: Dict[str, Any], volume_id: str, order: int) -> VolumeOutline:
target = raw.get("targetChapterCount")
if not isinstance(target, int):
target = 20
return VolumeOutline(
id=str(raw.get("id") or volume_id),
order=order,
title=str(raw.get("title") or f"{order}"),
goal=str(raw.get("goal") or ""),
coreConflict=str(raw.get("coreConflict") or raw.get("core_conflict") or ""),
powerProgression=str(raw.get("powerProgression") or raw.get("power_progression") or ""),
emotionalPromise=str(raw.get("emotionalPromise") or raw.get("emotional_promise") or ""),
endingHook=str(raw.get("endingHook") or raw.get("ending_hook") or ""),
targetChapterCount=max(10, min(30, target)),
primaryEmotionFlowId=str(raw.get("primaryEmotionFlowId") or raw.get("primary_emotion_flow_id") or ""),
status=str(raw.get("status") or "active"),
)
def _build_event_chain_messages(book_id: str, volume: VolumeOutline, flow_id: str) -> List[Any]:
settings = fiction_service.get_book_settings(book_id)
user_prompt = (
settings.prompts.eventPlan
or fiction_service.get_default_settings().prompts.eventPlan
)
system_prompt = resolve_prompt("eventChain", user_prompt)
guide_l2 = _format_guide_global_layers(["L2"])
book_guide = _format_book_guide(book_id)
flow_text = _format_flow(flow_id)
user_content = f"""## 全局创作指南L2
{guide_l2}
## 本书 guide具体人设/爽点/体验/禁区)
{book_guide}
## 当前卷纲
- id: {volume.id}
- title: {volume.title}
- goal: {volume.goal}
- coreConflict: {volume.coreConflict}
- powerProgression: {volume.powerProgression}
- emotionalPromise: {volume.emotionalPromise}
- endingHook: {volume.endingHook}
- targetChapterCount: {volume.targetChapterCount}
## 必须遵循的情感链
{flow_text}
## 本次任务
生成当前卷的事件链。
## 绝对要求
- 事件链总章节数应接近卷纲 targetChapterCount。
- 每个事件 targetChapterCount 必须在 2 到 5 之间。
- 每个事件必须填写 emotionFlowId、emotionStepKey、emotionStepText。
- 事件顺序必须遵循情感链 steps 的顺序,不得倒置。
- 不生成章纲或正文。
"""
return [SystemMessage(content=system_prompt), HumanMessage(content=user_content)]
def _normalize_event_chain(
raw: Any,
metadata: FictionBookMetadata,
volume: VolumeOutline,
flow_id: str,
) -> List[EventChainItem]:
raw_events = raw if isinstance(raw, list) else []
events: List[EventChainItem] = []
flow = _get_flow_by_id(flow_id)
steps = flow.steps if flow else []
for idx, item in enumerate(raw_events):
if not isinstance(item, dict):
continue
target = item.get("targetChapterCount")
if not isinstance(target, int):
target = 3
step = steps[min(idx, len(steps) - 1)] if steps else None
events.append(
EventChainItem(
id=str(item.get("id") or _next_event_id(metadata, idx)),
volumeId=volume.id,
order=int(item.get("order")) if isinstance(item.get("order"), int) else idx + 1,
title=str(item.get("title") or f"事件 {idx + 1}"),
summary=str(item.get("summary") or ""),
purpose=str(item.get("purpose") or ""),
conflict=str(item.get("conflict") or ""),
turningPoint=str(item.get("turningPoint") or item.get("turning_point") or ""),
expectedPayoff=str(item.get("expectedPayoff") or item.get("expected_payoff") or ""),
targetChapterCount=max(2, min(5, target)),
emotionFlowId=str(item.get("emotionFlowId") or item.get("emotion_flow_id") or flow_id),
emotionStepKey=str(item.get("emotionStepKey") or item.get("emotion_step_key") or (step.key if step else "")),
emotionStepText=str(item.get("emotionStepText") or item.get("emotion_step_text") or (step.text if step else "")),
status=str(item.get("status") or "planned"),
)
)
events.sort(key=lambda e: e.order)
if not events:
raise ValueError("事件链为空")
return events
def _build_chapter_plan_messages(
book_id: str,
volume: VolumeOutline,
event: EventChainItem,
) -> List[Any]:
settings = fiction_service.get_book_settings(book_id)
user_prompt = (
settings.prompts.eventPlan
or fiction_service.get_default_settings().prompts.eventPlan
)
system_prompt = resolve_prompt("chapterPlan", user_prompt)
guide_l3 = _format_guide_global_layers(["L3"])
book_guide = _format_book_guide(book_id)
user_content = f"""## 全局创作指南L3
{guide_l3}
## 本书 guide具体人设/爽点/体验/禁区)
{book_guide}
## 当前卷纲
- id: {volume.id}
- title: {volume.title}
- goal: {volume.goal}
- coreConflict: {volume.coreConflict}
- emotionalPromise: {volume.emotionalPromise}
## 当前事件
- id: {event.id}
- title: {event.title}
- summary: {event.summary}
- purpose: {event.purpose}
- conflict: {event.conflict}
- turningPoint: {event.turningPoint}
- expectedPayoff: {event.expectedPayoff}
- targetChapterCount: {event.targetChapterCount}
## 当前事件绑定的情感链步骤
- emotionFlowId: {event.emotionFlowId}
- emotionStepKey: {event.emotionStepKey}
- emotionStepText: {event.emotionStepText}
## 本次任务
为当前事件生成章纲。
## 绝对要求
- 必须生成 {event.targetChapterCount} 章章纲。
- 每章 targetWords 必须为 2000。
- 每章必须继承当前事件 id。
- 每章必须填写 emotionStepKey 与 emotionGoal。
- 不生成正文。
"""
return [SystemMessage(content=system_prompt), HumanMessage(content=user_content)]
def _next_chapter_seq(metadata: FictionBookMetadata) -> int:
max_seq = 0
for plans in metadata.chapterPlans.values():
for item in plans:
max_seq = max(max_seq, item.seq)
return max_seq + 1
def _normalize_chapter_plans(
raw: Any,
metadata: FictionBookMetadata,
event: EventChainItem,
) -> List[ChapterPlanItem]:
raw_items = raw if isinstance(raw, list) else []
start_seq = _next_chapter_seq(metadata)
items: List[ChapterPlanItem] = []
for idx, item in enumerate(raw_items):
if not isinstance(item, dict):
continue
seq = start_seq + idx
title = str(item.get("title") or f"{seq}")
goal = str(item.get("goal") or item.get("brief") or "")
items.append(
ChapterPlanItem(
seq=seq,
phaseKey=str(item.get("phaseKey") or event.emotionStepKey),
phaseSlice=str(item.get("phaseSlice") or ""),
brief=str(item.get("brief") or goal),
eventId=event.id,
title=title,
goal=goal,
opening=str(item.get("opening") or ""),
mainConflict=str(item.get("mainConflict") or item.get("main_conflict") or event.conflict),
emotionalTurn=str(item.get("emotionalTurn") or item.get("emotional_turn") or ""),
emotionStepKey=str(item.get("emotionStepKey") or item.get("emotion_step_key") or event.emotionStepKey),
emotionGoal=str(item.get("emotionGoal") or item.get("emotion_goal") or event.emotionStepText),
payoff=str(item.get("payoff") or event.expectedPayoff),
endingHook=str(item.get("endingHook") or item.get("ending_hook") or ""),
forbidden=str(item.get("forbidden") or ""),
targetWords=2000,
status=str(item.get("status") or "planned"),
)
)
items.sort(key=lambda c: c.seq)
if not items:
raise ValueError("章纲为空")
return items
async def ensure_volume(
book_id: str,
*,
profile_id: Optional[str] = None,
api_config: Optional[Dict[str, str]] = None,
) -> FictionBookMetadata:
metadata = fiction_metadata_service.get_metadata(book_id)
if metadata.volumes:
return metadata
resolved = resolve_api_config(profile_id, api_config)
_validate_api_config(resolved)
fiction_metadata_service.set_pipeline_stage(
book_id, status="running", pipeline_stage="volume"
)
try:
messages = _build_volume_messages(book_id, metadata)
response = await _llm_client.chat_completion(
messages=messages,
api_url=resolved["api_url"],
api_key=resolved["api_key"],
model=resolved["model"],
temperature=0.7,
max_tokens=8000,
request_timeout=120,
stream=False,
)
data = _extract_json(response["choices"][0]["message"]["content"])
volume = _normalize_volume(
data.get("volume") if isinstance(data.get("volume"), dict) else data,
_next_volume_id(metadata),
len(metadata.volumes) + 1,
)
if not volume.primaryEmotionFlowId:
volume.primaryEmotionFlowId = _choose_flow_id(book_id)
metadata.volumes.append(volume)
saved = fiction_metadata_service.save_metadata(book_id, metadata)
fiction_metadata_service.set_pipeline_stage(
book_id, status="idle", pipeline_stage="volume_done"
)
return saved
except Exception:
fiction_metadata_service.set_pipeline_stage(
book_id, status="error", pipeline_stage="volume"
)
raise
async def ensure_event_chain(
book_id: str,
*,
volume_id: Optional[str] = None,
profile_id: Optional[str] = None,
api_config: Optional[Dict[str, str]] = None,
) -> FictionBookMetadata:
metadata = await ensure_volume(book_id, profile_id=profile_id, api_config=api_config)
volume = next((v for v in metadata.volumes if v.id == volume_id), metadata.volumes[-1])
if metadata.eventChains.get(volume.id):
return metadata
resolved = resolve_api_config(profile_id, api_config)
_validate_api_config(resolved)
flow_id = volume.primaryEmotionFlowId or _choose_flow_id(book_id)
volume.primaryEmotionFlowId = flow_id
fiction_metadata_service.set_pipeline_stage(
book_id, status="running", pipeline_stage="event_chain"
)
try:
messages = _build_event_chain_messages(book_id, volume, flow_id)
response = await _llm_client.chat_completion(
messages=messages,
api_url=resolved["api_url"],
api_key=resolved["api_key"],
model=resolved["model"],
temperature=0.7,
max_tokens=8000,
request_timeout=120,
stream=False,
)
data = _extract_json(response["choices"][0]["message"]["content"])
raw_events = data.get("events") or data.get("eventChain") or data.get("event_chain")
events = _normalize_event_chain(raw_events, metadata, volume, flow_id)
metadata.eventChains[volume.id] = events
saved = fiction_metadata_service.save_metadata(book_id, metadata)
fiction_metadata_service.set_pipeline_stage(
book_id, status="idle", pipeline_stage="event_chain_done"
)
return saved
except Exception:
fiction_metadata_service.set_pipeline_stage(
book_id, status="error", pipeline_stage="event_chain"
)
raise
async def ensure_chapter_plan(
book_id: str,
*,
event_id: Optional[str] = None,
profile_id: Optional[str] = None,
api_config: Optional[Dict[str, str]] = None,
) -> FictionBookMetadata:
metadata = await ensure_event_chain(book_id, profile_id=profile_id, api_config=api_config)
target_event: Optional[EventChainItem] = None
target_volume: Optional[VolumeOutline] = None
for volume in metadata.volumes:
for event in metadata.eventChains.get(volume.id, []):
if event_id and event.id != event_id:
continue
if metadata.chapterPlans.get(event.id):
if event_id:
return metadata
continue
target_event = event
target_volume = volume
break
if target_event:
break
if not target_event or not target_volume:
return metadata
resolved = resolve_api_config(profile_id, api_config)
_validate_api_config(resolved)
fiction_metadata_service.set_pipeline_stage(
book_id, status="running", pipeline_stage="chapter_plan"
)
try:
messages = _build_chapter_plan_messages(book_id, target_volume, target_event)
response = await _llm_client.chat_completion(
messages=messages,
api_url=resolved["api_url"],
api_key=resolved["api_key"],
model=resolved["model"],
temperature=0.7,
max_tokens=8000,
request_timeout=120,
stream=False,
)
data = _extract_json(response["choices"][0]["message"]["content"])
raw_items = data.get("chapterPlan") or data.get("chapters") or data.get("chapter_plan")
plans = _normalize_chapter_plans(raw_items, metadata, target_event)
metadata.chapterPlans[target_event.id] = plans
saved = fiction_metadata_service.save_metadata(book_id, metadata)
fiction_metadata_service.set_pipeline_stage(
book_id, status="idle", pipeline_stage="chapter_plan_done"
)
return saved
except Exception:
fiction_metadata_service.set_pipeline_stage(
book_id, status="error", pipeline_stage="chapter_plan"
)
raise

View File

@@ -0,0 +1,125 @@
"""
爽文提示词解析 — 用户自然语言 + 内部 JSON 输出格式(不暴露给前端)。
"""
from __future__ import annotations
from typing import Dict
# 新建书籍时的默认用户向提示(自然语言,不含 JSON 结构)
USER_DEFAULT_PROMPTS: Dict[str, str] = {
"openBook": (
"你是爽文开书优化助手。根据用户创作灵感,提炼书名、优化简介,"
"并生成主角人设、核心爽点、读者体验策略与创作禁区。"
"从情绪流目录中挑选 14 个最匹配的条目。"
),
"coarseOutline": (
"你是爽文大纲助手。根据本书设定与进度,生成事件链级别的粗纲,"
"每个事件包含标题与概要,节奏紧凑、爽点清晰。"
),
"eventPlan": (
"你是爽文事件规划助手。将粗纲中的事件展开为章节级计划,"
"结合情绪流起承转合,为每章规划核心冲突与爽点。"
),
"chapter": (
"你是爽文章节写作助手。根据事件计划、guide 设定与上文撰写正文,"
"节奏明快、对话推动冲突、章末留钩子。"
),
"nudge": (
"你是爽文创作教练。根据当前进度与读者体验目标,"
"给出 13 条简短的下一步写作建议,不直接写正文。"
),
}
# 调用 LLM 时在系统提示末尾追加的输出格式(用户 UI 不可见)
_INTERNAL_FORMAT: Dict[str, str] = {
"openBook": """
【输出格式】只输出 JSON不要 markdown 代码块外的文字:
{
"title": "书名",
"optimizedIntro": "优化后的开书灵感",
"guide": {
"persona": "主角人设:身份、性格、欲望、能力边界与成长方向",
"highlight": "核心爽点:本书最稳定兑现的爽点、打脸方式、升级/获得感",
"experience": "用户体验:视角/人称、听感、节奏、世界感与读者情绪承诺",
"forbiddenZones": "创作禁区:不能写、不能破坏、不能弱化的内容"
},
"allowedFlowIds": ["flow-id"]
}""",
"volumeOutline": """
【输出格式】只输出 JSON不要 markdown 代码块外的文字:
{
"id": "vol_001",
"order": 1,
"title": "卷名",
"goal": "本卷目标",
"coreConflict": "本卷核心冲突",
"powerProgression": "本卷成长/变化",
"emotionalPromise": "本卷情绪承诺",
"endingHook": "本卷结尾钩子",
"targetChapterCount": 20,
"primaryEmotionFlowId": "emotion-flow-id",
"status": "active"
}""",
"eventChain": """
【输出格式】只输出 JSON不要 markdown 代码块外的文字:
{
"events": [
{
"id": "evt_0001",
"volumeId": "vol_001",
"order": 1,
"title": "事件标题",
"summary": "事件概要",
"purpose": "事件作用",
"conflict": "事件冲突",
"turningPoint": "事件转折",
"expectedPayoff": "预期兑现",
"targetChapterCount": 3,
"emotionFlowId": "emotion-flow-id",
"emotionStepKey": "情感链步骤 key",
"emotionStepText": "情感链步骤 text",
"status": "planned"
}
]
}""",
"chapterPlan": """
【输出格式】只输出 JSON不要 markdown 代码块外的文字:
{
"chapterPlan": [
{
"title": "章标题",
"goal": "本章目标",
"opening": "开场内容",
"mainConflict": "本章主要冲突",
"emotionalTurn": "本章情绪变化",
"emotionStepKey": "情感链步骤 key",
"emotionGoal": "本章情绪目标",
"payoff": "本章兑现",
"endingHook": "章末信息",
"forbidden": "本章禁止事项",
"targetWords": 2000,
"status": "planned"
}
]
}""",
"chapter": """
【输出格式】只输出 JSON
{ "title": "章标题", "body": "正文(可分段)" }""",
"nudge": "",
}
def resolve_prompt(prompt_key: str, user_text: str | None) -> str:
"""合并用户自然语言指令与内部 JSON 输出格式,供 LLM 系统提示使用。"""
base = (user_text or "").strip()
if not base:
base = USER_DEFAULT_PROMPTS.get(prompt_key, "")
fmt = _INTERNAL_FORMAT.get(prompt_key, "")
if fmt and fmt.strip() not in base:
return base + fmt
return base

View File

@@ -0,0 +1,302 @@
"""
爽文书籍 CRUD 与全局资源读取。
"""
from __future__ import annotations
import json
import logging
import re
import shutil
import uuid
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
from core.config import settings
from models.fiction_models import (
CreateFictionBookRequest,
EmotionFlowCatalog,
FictionBookMeta,
FictionBookSettings,
FictionBookSummary,
FictionChapter,
FictionChapterSummary,
FictionGuideWorldbook,
FictionPipelineSettings,
FictionPrompts,
FictionReaderSettings,
GuideGlobalEntries,
UpdateFictionBookSettingsRequest,
)
from services.fiction_prompt_utils import USER_DEFAULT_PROMPTS
logger = logging.getLogger(__name__)
DEFAULT_PROMPTS = FictionPrompts(
openBook=USER_DEFAULT_PROMPTS["openBook"],
coarseOutline=USER_DEFAULT_PROMPTS["coarseOutline"],
eventPlan=USER_DEFAULT_PROMPTS["eventPlan"],
chapter=USER_DEFAULT_PROMPTS["chapter"],
nudge=USER_DEFAULT_PROMPTS["nudge"],
)
DEFAULT_READER = FictionReaderSettings()
DEFAULT_PIPELINE = FictionPipelineSettings()
DEFAULT_METADATA: Dict[str, Any] = {
"version": 2,
"volumes": [],
"eventChains": {},
"chapterPlans": {},
"progress": {
"currentChapterSeq": 0,
"charOffset": 0,
"ttsPaused": False,
"genPaused": False,
},
}
DEFAULT_RUN: Dict[str, Any] = {
"status": "idle",
"pipelineStage": None,
"stage": "idle",
"message": None,
"updatedAt": "",
}
def _read_json(path: Path) -> Any:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
def _write_json(path: Path, data: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def _slugify(text: str) -> str:
text = (text or "").strip()
text = re.sub(r"[^\w\u4e00-\u9fff\-]+", "-", text, flags=re.UNICODE)
text = re.sub(r"-+", "-", text).strip("-")
return text[:48] or "book"
class FictionService:
@property
def books_root(self) -> Path:
return settings.FICTION_BOOKS_PATH
def _book_dir(self, book_id: str) -> Path:
return self.books_root / book_id
def _meta_path(self, book_id: str) -> Path:
return self._book_dir(book_id) / "meta.json"
def _settings_path(self, book_id: str) -> Path:
return self._book_dir(book_id) / "settings.json"
def _guide_path(self, book_id: str) -> Path:
return self._book_dir(book_id) / "guide.worldbook.json"
def _metadata_path(self, book_id: str) -> Path:
return self._book_dir(book_id) / "metadata.json"
def _run_path(self, book_id: str) -> Path:
return self._book_dir(book_id) / "run.json"
def _chapters_dir(self, book_id: str) -> Path:
return self._book_dir(book_id) / "chapters"
def _chapter_path(self, book_id: str, seq: int) -> Path:
return self._chapters_dir(book_id) / f"{seq:04d}.json"
def chapter_exists(self, book_id: str, seq: int) -> bool:
return self._chapter_path(book_id, seq).exists()
def get_chapter(self, book_id: str, seq: int) -> FictionChapter:
path = self._chapter_path(book_id, seq)
if not path.exists():
raise FileNotFoundError(f"Chapter not found: {book_id}/{seq}")
return FictionChapter(**_read_json(path))
def save_chapter(self, book_id: str, chapter: FictionChapter) -> FictionChapter:
self._ensure_book(book_id)
_write_json(self._chapter_path(book_id, chapter.seq), chapter.model_dump())
self._touch_book_meta(book_id)
return chapter
def list_written_chapter_seqs(self, book_id: str) -> List[int]:
chapters_dir = self._chapters_dir(book_id)
if not chapters_dir.exists():
return []
seqs: List[int] = []
for path in chapters_dir.glob("*.json"):
try:
seqs.append(int(path.stem))
except ValueError:
continue
return sorted(seqs)
def list_chapter_summaries(self, book_id: str) -> List[FictionChapterSummary]:
summaries: List[FictionChapterSummary] = []
for seq in self.list_written_chapter_seqs(book_id):
ch = self.get_chapter(book_id, seq)
summaries.append(
FictionChapterSummary(
seq=ch.seq,
title=ch.title,
charCount=ch.charCount,
eventId=ch.eventId,
phaseKey=ch.phaseKey,
)
)
return summaries
def _ensure_book(self, book_id: str) -> None:
if not self._meta_path(book_id).exists():
raise FileNotFoundError(f"Book not found: {book_id}")
def _touch_book_meta(self, book_id: str) -> None:
meta_path = self._meta_path(book_id)
meta = _read_json(meta_path)
meta["updatedAt"] = datetime.now().isoformat()
_write_json(meta_path, meta)
def get_default_settings(self) -> FictionBookSettings:
return FictionBookSettings(
prompts=DEFAULT_PROMPTS,
reader=DEFAULT_READER,
pipeline=DEFAULT_PIPELINE,
)
def get_emotion_catalog(self) -> EmotionFlowCatalog:
path = settings.FICTION_EMOTION_CATALOG_FILE
if not path.exists():
return EmotionFlowCatalog(flows=[])
return EmotionFlowCatalog(**_read_json(path))
def get_guide_global_entries(self) -> GuideGlobalEntries:
path = settings.FICTION_GUIDE_GLOBAL_ENTRIES_FILE
if not path.exists():
return GuideGlobalEntries(entries=[])
return GuideGlobalEntries(**_read_json(path))
def list_books(self) -> List[FictionBookSummary]:
root = self.books_root
if not root.exists():
return []
summaries: List[FictionBookSummary] = []
for child in sorted(root.iterdir()):
if not child.is_dir():
continue
meta_path = child / "meta.json"
if not meta_path.exists():
continue
meta = _read_json(meta_path)
summaries.append(
FictionBookSummary(
id=meta.get("id", child.name),
title=meta.get("title", child.name),
allowedFlowIds=meta.get("allowedFlowIds", []),
updatedAt=meta.get("updatedAt", ""),
)
)
summaries.sort(key=lambda x: x.updatedAt or "", reverse=True)
return summaries
def get_book_meta(self, book_id: str) -> FictionBookMeta:
meta_path = self._meta_path(book_id)
if not meta_path.exists():
raise FileNotFoundError(f"Book not found: {book_id}")
return FictionBookMeta(**_read_json(meta_path))
def get_book_settings(self, book_id: str) -> FictionBookSettings:
settings_path = self._settings_path(book_id)
if not settings_path.exists():
raise FileNotFoundError(f"Book not found: {book_id}")
return FictionBookSettings(**_read_json(settings_path))
def update_book_settings(
self, book_id: str, req: UpdateFictionBookSettingsRequest
) -> FictionBookSettings:
meta_path = self._meta_path(book_id)
settings_path = self._settings_path(book_id)
if not meta_path.exists():
raise FileNotFoundError(f"Book not found: {book_id}")
current = FictionBookSettings(**_read_json(settings_path))
data = current.model_dump()
if req.prompts is not None:
data["prompts"] = req.prompts.model_dump()
if req.reader is not None:
data["reader"] = req.reader.model_dump()
if req.pipeline is not None:
data["pipeline"] = req.pipeline.model_dump()
_write_json(settings_path, data)
meta = _read_json(meta_path)
meta["updatedAt"] = datetime.now().isoformat()
_write_json(meta_path, meta)
return FictionBookSettings(**data)
def get_book_guide(self, book_id: str) -> FictionGuideWorldbook:
guide_path = self._guide_path(book_id)
if not guide_path.exists():
raise FileNotFoundError(f"Book not found: {book_id}")
return FictionGuideWorldbook(**_read_json(guide_path))
def _unique_book_id(self, base_id: str) -> str:
candidate = base_id
n = 1
while self._book_dir(candidate).exists():
candidate = f"{base_id}-{n}"
n += 1
return candidate
def create_book(self, req: CreateFictionBookRequest) -> FictionBookMeta:
title = (req.title or "").strip()
if not title:
raise ValueError("书名不能为空")
base_id = _slugify(title)
if not base_id or base_id == "book":
base_id = str(uuid.uuid4())[:8]
book_id = self._unique_book_id(base_id)
dest = self._book_dir(book_id)
dest.mkdir(parents=True, exist_ok=False)
self._chapters_dir(book_id).mkdir(parents=True, exist_ok=True)
now = datetime.now().isoformat()
meta = {
"id": book_id,
"title": title,
"allowedFlowIds": list(req.allowedFlowIds or []),
"createdAt": now,
"updatedAt": now,
}
_write_json(self._meta_path(book_id), meta)
default_settings = self.get_default_settings()
_write_json(self._settings_path(book_id), default_settings.model_dump())
guide = req.guide.model_dump() if req.guide else FictionGuideWorldbook().model_dump()
_write_json(self._guide_path(book_id), guide)
metadata = dict(DEFAULT_METADATA)
_write_json(self._metadata_path(book_id), metadata)
run_data = dict(DEFAULT_RUN)
run_data["updatedAt"] = now
_write_json(self._run_path(book_id), run_data)
return FictionBookMeta(**meta)
def delete_book(self, book_id: str) -> None:
book_dir = self._book_dir(book_id)
if not book_dir.exists():
raise FileNotFoundError(f"Book not found: {book_id}")
shutil.rmtree(book_dir)
fiction_service = FictionService()

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)

File diff suppressed because it is too large Load Diff

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,185 @@
"""
爽文工作流 Tool 注册。
"""
from __future__ import annotations
import json
from typing import Any, Dict, Optional
try:
from backend.models.agent import TurnContext
from backend.services.fiction_chapter_service import run_chapter
from backend.services.fiction_coarse_service import run_coarse_outline
from backend.services.fiction_event_plan_service import run_event_plan
from backend.services.fiction_open_book_service import run_open_book
from backend.services.tool_registry import ToolRegistry
except ImportError:
from models.agent import TurnContext
from services.fiction_chapter_service import run_chapter
from services.fiction_coarse_service import run_coarse_outline
from services.fiction_event_plan_service import run_event_plan
from services.fiction_open_book_service import run_open_book
from services.tool_registry import ToolRegistry
async def fiction_open_book(ctx: TurnContext) -> None:
"""fiction.open_book — 根据用户灵感优化开书方案(不创建书籍目录)。"""
request = ctx.request_data or {}
inspiration = str(request.get("inspiration") or request.get("intro") or "").strip()
profile_id = request.get("profile_id") or request.get("profileId")
api_config = request.get("api_config") or request.get("apiConfig")
result = await run_open_book(
inspiration,
profile_id=profile_id,
api_config=api_config,
)
payload = result.model_dump()
ctx.request_data["fictionOpenBookResult"] = payload
ctx.generated_content = json.dumps(payload, ensure_ascii=False)
async def fiction_coarse(ctx: TurnContext) -> None:
"""fiction.coarse — 生成本书粗纲事件链,写入 metadata.json。"""
request = ctx.request_data or {}
book_id = str(request.get("book_id") or request.get("bookId") or "").strip()
if not book_id:
raise ValueError("book_id 不能为空")
profile_id = request.get("profile_id") or request.get("profileId")
api_config = request.get("api_config") or request.get("apiConfig")
result = await run_coarse_outline(
book_id,
profile_id=profile_id,
api_config=api_config,
)
payload = result.model_dump()
ctx.request_data["fictionCoarseResult"] = payload
ctx.generated_content = json.dumps(payload, ensure_ascii=False)
async def fiction_event_plan(ctx: TurnContext) -> None:
"""fiction.event_plan — 为粗纲事件生成 flowStepsPlan + chapterPlan。"""
request = ctx.request_data or {}
book_id = str(request.get("book_id") or request.get("bookId") or "").strip()
if not book_id:
raise ValueError("book_id 不能为空")
profile_id = request.get("profile_id") or request.get("profileId")
api_config = request.get("api_config") or request.get("apiConfig")
event_id = request.get("event_id") or request.get("eventId")
result = await run_event_plan(
book_id,
profile_id=profile_id,
api_config=api_config,
event_id=event_id,
)
payload = result.model_dump()
ctx.request_data["fictionEventPlanResult"] = payload
ctx.generated_content = json.dumps(payload, ensure_ascii=False)
async def fiction_chapter(ctx: TurnContext) -> None:
"""fiction.chapter — 根据 chapterPlan brief 撰写正文,写入 chapters/{seq}.json。"""
request = ctx.request_data or {}
book_id = str(request.get("book_id") or request.get("bookId") or "").strip()
if not book_id:
raise ValueError("book_id 不能为空")
profile_id = request.get("profile_id") or request.get("profileId")
api_config = request.get("api_config") or request.get("apiConfig")
seq = request.get("seq") or request.get("chapterSeq")
result = await run_chapter(
book_id,
profile_id=profile_id,
api_config=api_config,
seq=int(seq) if seq is not None else None,
)
payload = result.model_dump()
ctx.request_data["fictionChapterResult"] = payload
ctx.generated_content = json.dumps(payload, ensure_ascii=False)
def register_fiction_tools(registry: ToolRegistry) -> None:
registry.register(
"fiction.open_book",
fiction_open_book,
description="根据用户创作灵感优化爽文开书方案,返回 guide 草稿与推荐情绪流",
parameters={
"type": "object",
"properties": {
"inspiration": {"type": "string", "description": "用户创作灵感/简介"},
"profile_id": {"type": "string", "description": "API 配置 profile ID"},
"api_config": {
"type": "object",
"description": "可选 inline API 配置",
},
},
"required": ["inspiration"],
},
)
registry.register(
"fiction.coarse",
fiction_coarse,
description="根据本书 guide 与 L1 全局指南生成粗纲,写入 metadata.coarseOutline",
parameters={
"type": "object",
"properties": {
"book_id": {"type": "string", "description": "书籍 ID"},
"profile_id": {"type": "string", "description": "API 配置 profile ID"},
"api_config": {"type": "object", "description": "可选 inline API 配置"},
},
"required": ["book_id"],
},
)
registry.register(
"fiction.event_plan",
fiction_event_plan,
description="为粗纲事件随机选情绪流并生成 flowStepsPlan + chapterPlan",
parameters={
"type": "object",
"properties": {
"book_id": {"type": "string", "description": "书籍 ID"},
"event_id": {
"type": "string",
"description": "可选,仅规划指定粗纲事件;缺省则规划全部",
},
"profile_id": {"type": "string", "description": "API 配置 profile ID"},
"api_config": {"type": "object", "description": "可选 inline API 配置"},
},
"required": ["book_id"],
},
)
registry.register(
"fiction.chapter",
fiction_chapter,
description="根据 chapterPlan brief 撰写章节正文,写入 chapters 目录",
parameters={
"type": "object",
"properties": {
"book_id": {"type": "string", "description": "书籍 ID"},
"seq": {
"type": "integer",
"description": "可选,指定章节序号;缺省则写下一未写章",
},
"profile_id": {"type": "string", "description": "API 配置 profile ID"},
"api_config": {"type": "object", "description": "可选 inline API 配置"},
},
"required": ["book_id"],
},
)
# 模块加载时注册到默认 registry
try:
from services.tool_registry import default_tool_registry
register_fiction_tools(default_tool_registry)
except Exception:
pass

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

@@ -60,10 +60,14 @@ class WorldBookService:
with open(json_file, 'r', encoding='utf-8') as f:
data = json.load(f)
# 内部格式entries 是列表
entries = data.get("entries", [])
entries_count = len(entries) if isinstance(entries, list) else 0
worldbooks.append({
"name": data.get("name", json_file.stem),
"description": data.get("description", ""),
"entries_count": len(data.get("entries", [])),
"entries_count": entries_count,
"createdAt": data.get("createdAt", 0),
"updatedAt": data.get("updatedAt", 0)
})
@@ -165,21 +169,41 @@ class WorldBookService:
return True
@staticmethod
def list_entries(name: str) -> List[Dict[str, Any]]:
def list_entries(name: str, page: int = 1, page_size: int = 20) -> Dict[str, Any]:
"""
获取世界书的所有条目
获取世界书的条目列表(支持分页)
Args:
name: 世界书名称
page: 页码从1开始
page_size: 每页数量默认20
Returns:
条目列表
包含条目列表和分页信息的字典
"""
data = WorldBookService._load_worldbook(name)
if not data:
raise FileNotFoundError(f"Worldbook '{name}' not found")
return data.get("entries", [])
# 内部格式entries 是列表
all_entries = data.get("entries", [])
if not isinstance(all_entries, list):
all_entries = []
total = len(all_entries)
# 计算分页
start_idx = (page - 1) * page_size
end_idx = start_idx + page_size
paginated_entries = all_entries[start_idx:end_idx]
return {
"entries": paginated_entries,
"total": total,
"page": page,
"page_size": page_size,
"total_pages": (total + page_size - 1) // page_size # 向上取整
}
@staticmethod
def get_entry(name: str, uid: str) -> Dict[str, Any]:
@@ -197,12 +221,43 @@ class WorldBookService:
if not data:
raise FileNotFoundError(f"Worldbook '{name}' not found")
for entry in data.get("entries", []):
if entry.get("uid") == uid:
# 内部格式entries 是列表
entries = data.get("entries", [])
if not isinstance(entries, list):
entries = []
for entry in entries:
if entry.get("uid") == uid or str(entry.get("uid")) == uid:
return entry
raise FileNotFoundError(f"Entry '{uid}' not found in worldbook '{name}'")
@staticmethod
def append_entry(name: str, entry_data: Dict[str, Any]) -> Dict[str, Any]:
"""
在世界书中追加条目(规范化后写入,与 Chat 侧条目格式一致)。
Args:
name: 世界书名称(文件名,不含 .json
entry_data: 条目字段content、comment、activationType、position 等)
Returns:
写入后的规范化条目
"""
data = WorldBookService._load_worldbook(name)
if not data:
raise FileNotFoundError(f"Worldbook '{name}' not found")
if not isinstance(data.get("entries"), list):
data["entries"] = []
normalized = WorldBookConverter.normalize_entry(entry_data)
data["entries"].append(normalized)
now = int(datetime.now().timestamp())
data["updatedAt"] = now
WorldBookService._save_worldbook(name, data)
return normalized
@staticmethod
def create_entry(name: str, entry_data: Dict[str, Any]) -> Dict[str, Any]:
"""

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

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,6 @@
{
"persona": "主角表面温文尔雅、恪守使者礼节,实则杀伐果断、胸怀大格局。受辱时隐忍记账,关键节点一击致命。拥有超越时代的信息差与历史推演金手指,善于借势与布局,从单枪匹马到建立西域都护府权威。",
"highlight": "1. 身份反差所有人都当他是落魄流民直到汉节与国书亮相震惊全场。2. 文明降维打击用冶铁、造纸、兵法碾压西域各方势力。3. 外交爽文舌战群胡、以一人退一国之兵。4. 势力养成:收服三十六国,在异域复刻大汉盛世。",
"experience": "第三人称有限视角跟随主角,初期通过旁观者的鄙夷积蓄压抑,中后期在亮身份、展实力时拉远镜头,放大旁观者的跪服与匈奴使者的恐惧,形成反复打脸爽感。每场外交冲突都按铺垫→加压→以汉威逆转的结构推进。",
"forbiddenZones": "禁止主角长期忍气吞声无所作为、禁止汉使身份被长期误解不开封、禁止面对胡人欺辱时以德报怨挫折控制在1章内且必须立刻给出明确反击预期。"
}

View File

@@ -0,0 +1,11 @@
{
"id": "我是汉使-谁敢不敬",
"title": "我是汉使,谁敢不敬",
"allowedFlowIds": [
"power-reveal",
"face-slap-rise",
"alliance-dominate"
],
"createdAt": "2026-06-01T10:36:45.755451",
"updatedAt": "2026-06-01T10:56:58.330409"
}

View File

@@ -0,0 +1,120 @@
{
"coarseOutline": {
"events": [
{
"id": "evt-1",
"title": "流落楼兰,身份的蛰伏",
"summary": "主角衣衫褴褛抵达楼兰城外,被守城胡兵当成逃难流民肆意驱赶羞辱。主角冷眼观察局势,暗中记录下所有侮辱,等待时机。入城后被安置在最低贱的商贾聚集区。",
"order": 1
},
{
"id": "evt-2",
"title": "匈奴使团嚣张登场,压抑升级",
"summary": "匈奴特使率领百骑使团入城,楼兰王卑躬屈膝迎接。匈奴使者在市集当众嘲笑汉人弱如羔羊,主角被推搡却不动声色,只展露些许冶铁知识换取铁匠铺收留,埋下反击伏笔。",
"order": 2
},
{
"id": "evt-3",
"title": "纸刀初试,小胜立威",
"summary": "匈奴人再次当众侮辱主角,逼其钻胯。主角以“教你们写汉字”为赌约,用刚造出的粗糙纸张和断笔,当众写下檄文,暗讽匈奴无文。贵族们对纸惊为神物,主角赢得楼兰大商人的庇护,匈奴使吃瘪离去。",
"order": 3
},
{
"id": "evt-4",
"title": "王宫夜宴亮汉节,身份反转",
"summary": "楼兰王宴请匈奴使,主角被大商贾带入宴席。匈奴使阁楼内强迫楼兰王交出“汉人奸细”处死,主角缓步出列,手捧封尘汉节,展开黄绫国书,朗声道:“大汉使臣张明,持节出使西域三十六国,谁敢不敬?”全场死寂,匈奴使脸色煞白。",
"order": 4
},
{
"id": "evt-5",
"title": "斩杀匈奴使,楼兰臣服",
"summary": "匈奴使强辩汉节是伪造,命令护卫拿下主角。主角喝破楼兰王昔日杀汉使将招致灭国之祸,并当场宣读大汉讨罪檄文。在楼兰王犹豫之际,主角以迅雷之势拔出随从暗藏环首刀,亲手斩杀匈奴正使,威震大殿。楼兰王跪伏,愿为汉属。",
"order": 5
},
{
"id": "evt-6",
"title": "以寡敌众,冶铁骑兵显威",
"summary": "匈奴副使逃出带领城外百骑反扑,主角早有准备,调遣楼兰城卫,并用改良的冶铁马蹄铁和简易马镫装备了二十人骑兵小队,正面冲垮毫无防备的匈奴骑兵。一战斩首七十,俘虏三十,彻底打垮楼兰境内匈奴势力。",
"order": 6
},
{
"id": "evt-7",
"title": "楼兰盟约,都护雏形",
"summary": "主角与楼兰王歃血为盟,设立汉使常驻衙门,推行简易汉律保护商路。同时放出流言:大汉西域都护府即将设立,归附者可得冶铁、造纸之术。周边小国开始遣使试探。",
"order": 7
},
{
"id": "evt-8",
"title": "车师设局,舌战降服",
"summary": "车师国受匈奴蛊惑扣押汉商队,主角仅带十骑赴会。在王庭之上,车师王列甲士恐吓,主角从容陈说匈奴败亡之势,并以楼兰之变震慑,允诺开放贸易和冶铁秘法。车师贵族分裂,最终斩杀亲匈奴大臣,迎汉使入驻。",
"order": 8
},
{
"id": "evt-9",
"title": "三十六国初盟,匈奴单于震怒",
"summary": "半年间,主角借商业和文明技术输出,接连与十余国结盟。一次盟会上,主角正式打出“大汉西域都护府”旗号,诸国共尊汉使为都护。消息传到漠北,匈奴单于怒极,下令三万铁骑西征,誓要血洗西域汉势力。",
"order": 9
},
{
"id": "evt-10",
"title": "大漠烽烟,以少胜多",
"summary": "主角利用信息差,提前获知匈奴行军路线,以兵法“围点打援”诱敌深入。集结诸国联军八千,在干涸河床设伏,用改良的硬弩和连环马阵正面击溃匈奴前锋,再以火攻断其辎重。匈奴单于亲率的中军溃败,折损过半,仓皇东逃。",
"order": 10
},
{
"id": "evt-11",
"title": "丝路重开,万邦来朝",
"summary": "主角将缴获的匈奴单于金箭与捷报一同传往长安。汉武帝大喜,正式册封主角为西域都护,授权统管西域。丝绸之路全线贯通,大汉商队、工匠、文人涌入西域,诸国争相效仿汉制。主角立于都护府高台,俯瞰一片繁华,当年那些羞辱过他的人早已化为尘埃。",
"order": 11
}
],
"version": 1
},
"events": {
"evt-1": {
"emotionFlowId": "alliance-dominate",
"flowStepsPlan": {
"起": "主角孤身抵达楼兰城外,衣着破烂,人脉与资源为零。被守城胡兵当作流民百般羞辱驱赶,主角隐忍观察,暗中记录所有折辱者面孔与背景,最终被丢到最低贱的商贾聚集区。此时主角处于绝对弱势,积蓄着对胡人傲慢的认识与反击的渴望。",
"承": "在商贾区,主角利用超越时代的知识小露锋芒,比如用古法提纯井盐、鉴别劣质铁器。这些举动引起了几类人的注意:落魄的本地护卫、被排挤的西域小商贩,以及楼兰城中一名失势贵族管家。主角刻意展示出自己‘虽落魄却有秘术’的价值,为后续结盟埋下伏笔。",
"转": "楼兰城中某个小权贵欲吞占商贾区,设局陷害聚集区商户。主角在暗中洞悉阴谋,指使刚结交的护卫提前揭露,并在众人面前以智谋反制,让权贵当众出丑。此事令那失势贵族管家背后的小主子意识到主角不凡,主动邀约,主角以‘各取所需’的姿态赢得其暂时效忠,完成第一次关键人收服。",
"合": "主角以这失势贵族为跳板,在商贾区建立初步的情报网络,收服了第一批追随者——护卫担任贴身力量,小商贩负责打探消息,失势贵族提供身份掩护。虽然依旧隐藏汉使身份,但一个以他为核心的微型势力雏形已在楼兰底层成型,为后续朝堂亮相、收服三十六国埋下暗线。"
},
"chapterPlan": [
{
"seq": 1,
"phaseKey": "起",
"phaseSlice": "全",
"brief": "主角衣衫褴褛到楼兰城下,被胡兵当难民拖拽驱赶、吐口水羞辱,他一言不发冷眼观察。城门口,一名胡商因货物被刁难,主角用简单西域话帮其解围,展露冷静思维。最终被丢入最混乱的商贾区,在破棚里用手刻下第一个仇人名字。爽点:冷静隐忍与记账的伏笔,通过旁人对‘这个乞丐居然会西域话’的诧异铺垫反差。",
"status": "planned"
},
{
"seq": 2,
"phaseKey": "承",
"phaseSlice": "全",
"brief": "主角以替人写书信、鉴别货物真伪在商贾区站稳脚跟,用提纯粗盐的方法让一名小贩获利三倍,引发小轰动。落魄护卫昆图目睹后主动试探,主角故意露一手卸骨擒拿术震慑对方。同时引起失势贵族之管家注意。爽点:文明降维打击——人人当他是流民,他却随手点石成金,周围人从轻蔑转为巴结。",
"status": "planned"
},
{
"seq": 3,
"phaseKey": "转",
"phaseSlice": "全",
"brief": "小权贵古尔贡欲用‘盗窃军马’罪名强占商贾区,抓走两名商贩。主角让昆图护住证人,自己通过管家带话给失势贵族,以三句话点破古尔贡布局的漏洞。在古尔贡带兵来封市时,主角当众拆穿伪造证据,并反手指控其勾结马贼,围观胡人从嘲弄变为惊惧。古尔贡被当街打脸,失势贵族公开表态庇护商圈。爽点:以一介贱民之身,翻手间让权贵灰头土脸,旁观者倒戈,首次展示翻云覆雨的智谋。",
"status": "planned"
},
{
"seq": 4,
"phaseKey": "合",
"phaseSlice": "全",
"brief": "事后失势贵族幼主密邀主角,试探其来历。主角只展露汉节一角却不宣明身份,提出互利之约:贵族借主角之智复起,主角借贵族之便铺开暗棋。昆图宣誓效忠,小贩们主动成为眼线。主角在商贾区租下一处院落,挂上破损卦幡,以此为据点开始收集三十六国情报。爽点:收服班底、势力雏形确立,身份依旧成谜但威势已成,为下一阶段亮明汉使身份蓄满张力。",
"status": "planned"
}
]
}
},
"progress": {
"currentChapterSeq": 0,
"charOffset": 0,
"ttsPaused": false,
"genPaused": false
}
}

View File

@@ -0,0 +1,11 @@
{
"status": "error",
"pipelineStage": "event_plan",
"stage": "error",
"message": "事件纲要生成失败",
"progress": {
"done": 0,
"total": 11
},
"updatedAt": "2026-06-01T11:56:27.977792"
}

View File

@@ -0,0 +1,13 @@
{
"prompts": {
"openBook": "你是爽文开书优化助手。根据用户提供的创作灵感,输出结构化的开书方案。\n\n要求\n1. 提炼并优化用户灵感,使其更适合网文爽文节奏。\n2. 生成 guide 世界书草稿persona主角人设、highlight核心爽点、experience读者体验/视角策略、forbiddenZones创作禁区。\n3. 从提供的情绪流 catalog 中挑选 14 个最匹配的 flow id 写入 allowedFlowIds。\n4. 建议一个简洁有力的书名。\n\n只输出 JSON不要 markdown 代码块外的文字:\n{\n \"title\": \"书名\",\n \"optimizedIntro\": \"优化后的开书灵感\",\n \"guide\": {\n \"persona\": \"...\",\n \"highlight\": \"...\",\n \"experience\": \"...\",\n \"forbiddenZones\": \"...\"\n },\n \"allowedFlowIds\": [\"flow-id\"]\n}",
"coarseOutline": "你是爽文大纲助手。根据当前书籍设定与进度,生成/修订粗纲(事件链级别,非细章)。\n\n输出 JSON\n{\n \"events\": [\n { \"id\": \"evt-1\", \"title\": \"事件标题\", \"summary\": \"事件概要\", \"emotionFlowId\": \"可选\" }\n ],\n \"version\": 1\n}",
"eventPlan": "你是爽文事件规划助手。将粗纲中的某个事件展开为可执行的章节级计划。\n\n输出 JSON包含章节序号建议、每章核心冲突与爽点类型。",
"chapter": "你是爽文章节写作助手。根据当前事件计划、guide 设定与上文,撰写本章正文。\n\n要求节奏明快、对话推动冲突、每章末尾留钩子。输出 JSON\n{ \"title\": \"章标题\", \"body\": \"正文(可分段)\" }",
"nudge": "你是爽文创作教练。根据当前进度与读者体验目标,给出 13 条简短的下一步写作建议(不直接写正文)。"
},
"reader": {
"contextWindowChars": 2000,
"prefetchRemainingWords": 300
}
}

View File

@@ -0,0 +1,59 @@
{
"flows": [
{
"id": "face-slap-rise",
"intro": "经典打脸逆袭:先抑后扬,让读者在主角翻盘点获得强烈爽感。",
"tags": ["打脸", "逆袭", "装逼"],
"steps": [
{ "key": "起", "text": "主角被轻视、被嘲讽,处境处于低谷,埋下伏笔。" },
{ "key": "承", "text": "矛盾升级,对手步步紧逼,读者情绪被压抑到极点。" },
{ "key": "转", "text": "主角展露真实实力或底牌,局势开始逆转。" },
{ "key": "合", "text": "当众打脸,对手颜面尽失,主角收获声望与资源。" }
]
},
{
"id": "treasure-upgrade",
"intro": "奇遇升级流:获得宝物/传承后实力跃迁,节奏明快。",
"tags": ["奇遇", "升级", "宝物"],
"steps": [
{ "key": "起", "text": "主角陷入险境或瓶颈,看似无路可走。" },
{ "key": "承", "text": "意外触发隐藏机缘,获得线索或残缺传承。" },
{ "key": "转", "text": "完成试炼或解开封印,实力/境界突破。" },
{ "key": "合", "text": "以新力量解决眼前危机,并留下更大悬念。" }
]
},
{
"id": "power-reveal",
"intro": "扮猪吃虎:隐藏身份或实力,在关键时刻一鸣惊人。",
"tags": ["扮猪吃虎", "身份", "震惊"],
"steps": [
{ "key": "起", "text": "主角以弱者/凡人形象出现,被各方忽视。" },
{ "key": "承", "text": "敌人或路人持续挑衅,形成对比张力。" },
{ "key": "转", "text": "危机时刻主角不再隐藏,展露真实层次。" },
{ "key": "合", "text": "全场震惊,先前嘲讽者态度一百八十度转变。" }
]
},
{
"id": "alliance-dominate",
"intro": "势力扩张:收服强者、建立势力,格局由小变大。",
"tags": ["势力", "收服", "格局"],
"steps": [
{ "key": "起", "text": "主角孤身或小团队,资源与人脉有限。" },
{ "key": "承", "text": "展现价值或魅力,引起潜在盟友/强者的注意。" },
{ "key": "转", "text": "通过实力或智谋赢得关键人物认可。" },
{ "key": "合", "text": "势力雏形确立,为下一阶段大事件铺垫。" }
]
},
{
"id": "revenge-climax",
"intro": "复仇清算:旧怨新仇一并了结,情绪释放型高潮。",
"tags": ["复仇", "清算", "高潮"],
"steps": [
{ "key": "起", "text": "回忆旧怨,明确复仇对象与动机。" },
{ "key": "承", "text": "对手仍嚣张或以为主角不足为惧。" },
{ "key": "转", "text": "主角布局收网,切断对手退路。" },
{ "key": "合", "text": "当众清算,恩怨了结,读者情绪得到释放。" }
]
}
]
}

View File

@@ -0,0 +1,44 @@
{
"entries": [
{
"layer": "L0",
"title": "爽文基本节奏",
"content": "每章需有明确的情绪推进:铺垫→加压→释放。避免长时间无爽点的水文;小爽点每 8001500 字,大爽点每 35 章。"
},
{
"layer": "L0",
"title": "读者预期管理",
"content": "开书前 3 章必须建立核心卖点(金手指/身份差/仇恨对象)。让读者知道「这本书承诺给我什么爽感」。"
},
{
"layer": "L1",
"title": "主角行为准则",
"content": "主角可以低调但不能窝囊;遇辱必报、有仇必记,但报复需有层次(先小胜再大胜)。避免圣母式原谅削弱爽感。"
},
{
"layer": "L1",
"title": "对手设计",
"content": "反派/对手需有足够嚣张资本与明确动机;被打脸前要让读者足够讨厌他们。避免脸谱化到无法代入。"
},
{
"layer": "L2",
"title": "信息投放",
"content": "世界观与力量体系采用「冰山法则」:每次只揭示与当前冲突相关的信息。悬念优于说明书式设定堆砌。"
},
{
"layer": "L2",
"title": "对话与描写比例",
"content": "冲突场景多用短句对话推进;升级/打脸瞬间可加入 12 句环境或旁观者反应放大爽感,但避免冗长旁白。"
},
{
"layer": "L3",
"title": "用户体验(视角/人称)",
"content": "默认第三人称有限视角跟随主角;关键爽点可短暂拉远至旁观者视角以放大震惊效果。人称切换需有明确叙事目的,避免混乱。"
},
{
"layer": "L3",
"title": "禁区与雷点",
"content": "避免 NTR、主角长期受虐无反击、重要角色无理由降智。若需挫折控制在 12 章内并给出明确反击预期。"
}
]
}

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,15 @@
{
"id": "c529576bd6de40969648cf80a3780db9",
"template_id": "builtin.chat",
"binding": {
"role_name": "帝国骑士维尔",
"chat_name": "默认聊天",
"template_id": "builtin.chat"
},
"status": "failed",
"started_at": "2026-05-30T18:20:55.872092",
"finished_at": "2026-05-30T18:21:31.857209",
"current_state": "regex_apply_ai_output",
"result_content": "",
"error": "'<' not supported between instances of 'int' and 'NoneType'"
}

View File

@@ -0,0 +1,75 @@
{
"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,
"runControls": []
},
{
"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": []
}
},
"runControls": ["undo", "reroll", "interrupt", "incrementalSave", "overwriteSave", "questions"]
}
]
}

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,10 @@
{
"id": "test-r1-72cd5b4b",
"name": "test-r1-72cd5b4b",
"description": "从创建绑定到世界书条目的默认三步流水线,可在工作流编辑页复制并修改。",
"templateId": "builtin.studio.example",
"characterId": "b0992d14-cb7c-4d81-b0af-7f7434710903",
"worldbookId": "e26e5d15-5b87-4693-aa47-f3021fbdc3d7",
"createdAt": "2026-05-31T14:58:54.635290",
"updatedAt": "2026-05-31T14:58:54.656107"
}

View File

@@ -0,0 +1,118 @@
{
"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": "整体美学",
"niche": "aesthetic_tone",
"enabled": true,
"loopUntilSatisfied": true,
"config": {
"stepGoal": "产出角色的整体美学设定:视觉风格、氛围、叙事基调,供后续人设步骤引用。",
"thinkingPrompt": "",
"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": "工作流目标文本"
}
]
},
{
"id": "persona",
"skillId": "studio.worldbook_entry",
"displayName": "具体人设",
"niche": "persona_detail",
"enabled": true,
"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": "工作流目标文本"
},
{
"ref": "aesthetic.output",
"label": "整体美学 · 上轮产物"
},
{
"ref": "persona.output",
"label": "具体人设 · 上轮产物",
"optional": true
}
]
}
]
}

View File

@@ -0,0 +1,10 @@
{
"id": "新项目",
"name": "新项目",
"description": "从创建绑定到世界书条目的默认三步流水线,可在工作流编辑页复制并修改。",
"templateId": "builtin.studio.example",
"characterId": null,
"worldbookId": null,
"createdAt": "2026-05-30T19:57:24.114210",
"updatedAt": "2026-05-30T19:57:24.114210"
}

View File

@@ -0,0 +1,83 @@
{
"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": "整体美学",
"niche": "aesthetic_tone",
"enabled": true,
"loopUntilSatisfied": true,
"config": {
"stepGoal": "产出角色的整体美学设定:视觉风格、氛围、叙事基调,供后续人设步骤引用。",
"insertion": {
"position": 4,
"activationType": "normal",
"key": "整体美学",
"comment": "Studio · 整体美学"
},
"scoring": {
"enabled": true,
"rubric": "是否覆盖色调/材质/氛围/叙事基调;是否可与后续人设衔接;表述是否简洁可注入世界书。"
}
},
"displayParams": [],
"inputs": []
},
{
"id": "persona",
"skillId": "studio.worldbook_entry",
"displayName": "具体人设",
"niche": "persona_detail",
"enabled": true,
"loopUntilSatisfied": true,
"config": {
"stepGoal": "在整体美学基础上,写出可扮演的人设:性格、动机、口癖、关系与行为模式。",
"insertion": {
"position": 4,
"activationType": "normal",
"key": "具体人设",
"comment": "Studio · 具体人设"
},
"scoring": {
"enabled": true,
"rubric": "是否与人设目标一致;是否与整体美学一致;是否具备可扮演细节;是否避免与已有条目冲突。"
}
},
"displayParams": [],
"inputs": [
{
"ref": "aesthetic.output"
},
{
"ref": "persona.output",
"optional": true
}
]
}
]
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,161 @@
{
"id": "6f4d68e5-f2cf-470d-a9f0-740c1403495f",
"projectId": "test-r1-72cd5b4b",
"status": "running",
"pipelineSnapshot": {
"workflowGoal": "设计一个单人角色:先绑定角色卡与世界书,再迭代整体美学与具体人设,写入世界书条目。",
"nodes": [
{
"id": "init",
"skillId": "studio.init_bind",
"displayName": "创建并绑定",
"enabled": true,
"niche": null,
"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": "",
"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
}
]
}
]
},
"pipelineVersionNote": "2026-05-31T11:24:30.222456",
"currentNodeId": "init",
"nodeStates": [
{
"nodeId": "init",
"displayName": "创建并绑定",
"skillId": "studio.init_bind",
"status": "active",
"loopUntilSatisfied": false,
"lastDraft": null
},
{
"nodeId": "aesthetic",
"displayName": "整体美学",
"skillId": "studio.worldbook_entry",
"status": "pending",
"loopUntilSatisfied": true,
"lastDraft": null
},
{
"nodeId": "persona",
"displayName": "具体人设",
"skillId": "studio.worldbook_entry",
"status": "pending",
"loopUntilSatisfied": true,
"lastDraft": null
}
],
"workflowVariables": {
"workflow.goal": "设计一个单人角色:先绑定角色卡与世界书,再迭代整体美学与具体人设,写入世界书条目。"
},
"createdAt": "2026-05-31T11:24:30.222456",
"updatedAt": "2026-05-31T11:24:30.222456"
}

View File

@@ -0,0 +1,172 @@
{
"id": "c70b04fb-f793-4a5c-a687-56466aa95a4e",
"projectId": "test-r1-72cd5b4b",
"status": "running",
"pipelineSnapshot": {
"workflowGoal": "设计一个单人角色:先绑定角色卡与世界书,再迭代整体美学与具体人设,写入世界书条目。",
"nodes": [
{
"id": "init",
"skillId": "studio.init_bind",
"displayName": "创建并绑定",
"enabled": true,
"niche": null,
"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": "",
"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
}
]
}
]
},
"pipelineVersionNote": "2026-05-31T14:58:54.643943",
"currentNodeId": "aesthetic",
"nodeStates": [
{
"nodeId": "init",
"displayName": "创建并绑定",
"skillId": "studio.init_bind",
"status": "completed",
"loopUntilSatisfied": false,
"lastDraft": {
"displayParams": {
"characterName": "测试角色6367da",
"worldbookName": "测试世界书8c09f9"
},
"characterId": "b0992d14-cb7c-4d81-b0af-7f7434710903",
"worldbookId": "e26e5d15-5b87-4693-aa47-f3021fbdc3d7",
"characterName": "测试角色6367da",
"worldbookName": "测试世界书8c09f9"
}
},
{
"nodeId": "aesthetic",
"displayName": "整体美学",
"skillId": "studio.worldbook_entry",
"status": "active",
"loopUntilSatisfied": true,
"lastDraft": null
},
{
"nodeId": "persona",
"displayName": "具体人设",
"skillId": "studio.worldbook_entry",
"status": "pending",
"loopUntilSatisfied": true,
"lastDraft": null
}
],
"workflowVariables": {
"workflow.goal": "设计一个单人角色:先绑定角色卡与世界书,再迭代整体美学与具体人设,写入世界书条目。",
"workflow.boundCharacter": "名称测试角色6367da\nIDb0992d14-cb7c-4d81-b0af-7f7434710903",
"workflow.boundWorldbook": "名称测试世界书8c09f9\nIDe26e5d15-5b87-4693-aa47-f3021fbdc3d7"
},
"createdAt": "2026-05-31T14:58:54.643943",
"updatedAt": "2026-05-31T14:58:54.659103"
}

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": "整体美学 · 世界书条目"
}
]
}
]
}

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