Compare commits
12 Commits
fix/世界书读取出
...
5bfe7a733f
| Author | SHA1 | Date | |
|---|---|---|---|
| 5bfe7a733f | |||
| 63a32bfa7c | |||
| f3792915a3 | |||
| fa6907fb8d | |||
| bc130d98f4 | |||
| d6745b45a5 | |||
| 9faccc2c03 | |||
| f843a74715 | |||
| 44df56c8d2 | |||
| adb59da06d | |||
| 2050a30a52 | |||
| 7fc9e10c99 |
7
.env
7
.env
@@ -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
23
.env.example
Normal file
@@ -0,0 +1,23 @@
|
||||
# ==================== 路径配置 ====================
|
||||
VECTORSTORE_PATH=/data/vectorstore
|
||||
STATE_FILE=/data/state.json
|
||||
SCHEMA_FILE=/data/schema.json
|
||||
PRESETS_FILE=/data/presets.json
|
||||
REGEX_FILE=/data/regex_rules.json
|
||||
|
||||
# ==================== 服务地址 ====================
|
||||
COMFYUI_API_URL=http://comfyui:8188
|
||||
BACKEND_PORT=8000
|
||||
FRONTEND_PORT=8501
|
||||
|
||||
# ==================== API 加密密钥 ====================
|
||||
# ⚠️ 重要:此密钥用于加密存储在配置文件中的 API Keys
|
||||
# ⚠️ 生产环境必须设置此变量,否则每次重启后无法解密之前的 API Key
|
||||
# ⚠️ 生成方法:python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
API_ENCRYPTION_KEY=your-encryption-key-here
|
||||
|
||||
# ==================== 默认 LLM 配置(可选)====================
|
||||
# 这些配置仅用于测试,实际使用时请通过 API 配置页面设置
|
||||
# MAIN_LLM_API_KEY=sk-your-api-key
|
||||
# MAIN_LLM_BASE_URL=https://api.openai.com/v1
|
||||
# MAIN_LLM_MODEL=gpt-4
|
||||
157
.gitignore
vendored
157
.gitignore
vendored
@@ -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.*
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
# 全局世界书数据同步问题修复
|
||||
|
||||
## 🐛 问题描述
|
||||
|
||||
**现象**: 当世界书文件被删除后,LocalStorage中仍然保存着该世界书的全局状态,导致前端显示一个不存在的"幽灵"世界书。
|
||||
|
||||
**原因**: `fetchWorldBooks` 函数从LocalStorage加载全局世界书后,没有清理那些已经不存在于后端的世界书。
|
||||
|
||||
---
|
||||
|
||||
## ✅ 解决方案
|
||||
|
||||
### 修复位置
|
||||
**文件**: `frontend/src/Store/SideBarLeft/WorldBookSlice.jsx`
|
||||
**函数**: `fetchWorldBooks` (第132-165行)
|
||||
|
||||
### 修复逻辑
|
||||
|
||||
```javascript
|
||||
// 从 LocalStorage 获取全局世界书列表
|
||||
let globalBooks = loadGlobalWorldBooks();
|
||||
|
||||
// 清理 LocalStorage 中已不存在的世界书
|
||||
const existingWorldBookNames = new Set(data.map(wb => wb.name));
|
||||
const cleanedGlobalBooks = globalBooks.filter(wb => existingWorldBookNames.has(wb.name));
|
||||
|
||||
// 如果有被清理的项,更新 LocalStorage
|
||||
if (cleanedGlobalBooks.length !== globalBooks.length) {
|
||||
console.log(`清理了 ${globalBooks.length - cleanedGlobalBooks.length} 个不存在的全局世界书`);
|
||||
saveGlobalWorldBooks(cleanedGlobalBooks);
|
||||
globalBooks = cleanedGlobalBooks;
|
||||
}
|
||||
```
|
||||
|
||||
### 工作流程
|
||||
|
||||
1. **获取后端数据**: 调用 `GET /api/worldbooks/` 获取所有存在的世界书
|
||||
2. **加载LocalStorage**: 从LocalStorage读取全局世界书列表
|
||||
3. **对比清理**: 过滤掉LocalStorage中存在但后端不存在的世界书
|
||||
4. **更新存储**: 如果发现有被清理的项,更新LocalStorage
|
||||
5. **更新State**: 将清理后的列表设置到State中
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试场景
|
||||
|
||||
### 场景1: 正常情况
|
||||
**步骤**:
|
||||
1. 创建世界书A和B
|
||||
2. 将A和B都设为全局
|
||||
3. 刷新页面
|
||||
|
||||
**预期结果**:
|
||||
- ✅ 全局区域显示A和B
|
||||
- ✅ LocalStorage中有A和B
|
||||
|
||||
### 场景2: 文件被删除
|
||||
**步骤**:
|
||||
1. 创建世界书A和B
|
||||
2. 将A和B都设为全局
|
||||
3. 手动删除世界书A的文件(或其他方式删除)
|
||||
4. 刷新页面
|
||||
|
||||
**预期结果**:
|
||||
- ✅ 全局区域只显示B
|
||||
- ✅ LocalStorage中只保留B
|
||||
- ✅ 控制台输出: "清理了 1 个不存在的全局世界书"
|
||||
|
||||
### 场景3: 通过UI删除
|
||||
**步骤**:
|
||||
1. 创建世界书A和B
|
||||
2. 将A和B都设为全局
|
||||
3. 在前端UI中删除世界书A
|
||||
4. 刷新页面
|
||||
|
||||
**预期结果**:
|
||||
- ✅ 全局区域只显示B
|
||||
- ✅ LocalStorage中只保留B
|
||||
- ✅ 无错误信息
|
||||
|
||||
---
|
||||
|
||||
## 📊 数据流图
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ 页面加载/切换 │
|
||||
└────────┬────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ fetchWorldBooks │
|
||||
└────────┬────────┘
|
||||
│
|
||||
├──► GET /api/worldbooks/ ──► 后端返回现有世界书列表
|
||||
│
|
||||
├──► loadGlobalWorldBooks() ──► 从LocalStorage读取
|
||||
│
|
||||
├──► 对比两个列表
|
||||
│ ├─ 存在于LocalStorage但不存在于后端 → 清理
|
||||
│ └─ 存在于两者 → 保留
|
||||
│
|
||||
├──► saveGlobalWorldBooks() ──► 更新LocalStorage(如有变化)
|
||||
│
|
||||
└──► set state ──► 更新UI
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 关键代码说明
|
||||
|
||||
### 1. 使用Set提高查找效率
|
||||
```javascript
|
||||
const existingWorldBookNames = new Set(data.map(wb => wb.name));
|
||||
```
|
||||
- 将后端返回的世界书名称转换为Set
|
||||
- Set的查找时间复杂度为O(1),比数组的O(n)更高效
|
||||
|
||||
### 2. 过滤清理
|
||||
```javascript
|
||||
const cleanedGlobalBooks = globalBooks.filter(wb =>
|
||||
existingWorldBookNames.has(wb.name)
|
||||
);
|
||||
```
|
||||
- 只保留那些在后端也存在的世界书
|
||||
- 自动移除"幽灵"世界书
|
||||
|
||||
### 3. 条件更新
|
||||
```javascript
|
||||
if (cleanedGlobalBooks.length !== globalBooks.length) {
|
||||
console.log(`清理了 ${globalBooks.length - cleanedGlobalBooks.length} 个不存在的全局世界书`);
|
||||
saveGlobalWorldBooks(cleanedGlobalBooks);
|
||||
globalBooks = cleanedGlobalBooks;
|
||||
}
|
||||
```
|
||||
- 只有在确实有变化时才更新LocalStorage
|
||||
- 避免不必要的写入操作
|
||||
- 提供调试信息
|
||||
|
||||
---
|
||||
|
||||
## ✨ 优势
|
||||
|
||||
1. **自动清理**: 无需手动干预,自动同步LocalStorage和后端数据
|
||||
2. **性能优化**: 使用Set提高查找效率
|
||||
3. **用户友好**: 静默清理,只在控制台输出日志
|
||||
4. **数据一致性**: 确保LocalStorage中的数据始终与后端保持一致
|
||||
5. **无副作用**: 不影响正常的业务流程
|
||||
|
||||
---
|
||||
|
||||
## 📝 相关代码位置
|
||||
|
||||
### LocalStorage操作函数
|
||||
```javascript
|
||||
// 辅助函数:从 LocalStorage 加载全局世界书
|
||||
const loadGlobalWorldBooks = () => {
|
||||
try {
|
||||
const stored = localStorage.getItem(GLOBAL_WORLDBOOKS_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch (error) {
|
||||
console.error('加载全局世界书失败:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// 辅助函数:保存全局世界书到 LocalStorage
|
||||
const saveGlobalWorldBooks = (globalBooks) => {
|
||||
try {
|
||||
localStorage.setItem(GLOBAL_WORLDBOOKS_KEY, JSON.stringify(globalBooks));
|
||||
} catch (error) {
|
||||
console.error('保存全局世界书失败:', error);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 删除世界书时的清理
|
||||
```javascript
|
||||
// deleteWorldBook 函数中已经有清理逻辑
|
||||
deleteWorldBook: async (name) => {
|
||||
// ...
|
||||
const filteredGlobalBooks = state.globalWorldBooks.filter(wb => wb.name !== name);
|
||||
saveGlobalWorldBooks(filteredGlobalBooks);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 总结
|
||||
|
||||
✅ **问题已修复**: 全局世界书现在会自动清理不存在的项
|
||||
✅ **数据同步**: LocalStorage与后端数据保持一致
|
||||
✅ **用户体验**: 不再显示"幽灵"世界书
|
||||
✅ **代码健壮**: 增加了数据一致性检查机制
|
||||
564
README.md
564
README.md
@@ -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 Output(AI 输出)
|
||||
- `3` - Quick Reply(快捷回复)
|
||||
- `4` - World Info(世界书信息)
|
||||
- `5` - Reasoning/Thinking(推理/思考内容)
|
||||
|
||||
#### 规则示例
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "hide-thinking-001",
|
||||
"scriptName": "隐藏思考标签",
|
||||
"findRegex": "<thinking>[\\s\\S]*?<\\/thinking>",
|
||||
"replaceString": "",
|
||||
"placement": [2],
|
||||
"substituteRegex": 0,
|
||||
"markdownOnly": false,
|
||||
"promptOnly": false,
|
||||
"disabled": false
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 预设系统
|
||||
|
||||
灵活的提示词组件管理。
|
||||
|
||||
#### 特性
|
||||
|
||||
- 多组件拖拽排序
|
||||
- 角色字段支持(system/user/assistant)
|
||||
- 注入位置控制(injection_position)
|
||||
- 注入深度控制(injection_depth)
|
||||
- 触发条件(injection_trigger)
|
||||
- 完全兼容 SillyTavern 预设格式
|
||||
|
||||
### 4. 聊天管理
|
||||
|
||||
完整的聊天生命周期管理。
|
||||
|
||||
#### 功能
|
||||
|
||||
- 多聊天切换
|
||||
- 消息编辑和保存
|
||||
- 消息 Swipes(多版本)
|
||||
- 右键菜单(编辑/复制/重roll/删除)
|
||||
- 历史总结
|
||||
- 智能滚动
|
||||
|
||||
---
|
||||
|
||||
## 开发指南
|
||||
|
||||
### API 路由
|
||||
|
||||
所有 API 路由定义在 `backend/api/routes/` 目录下:
|
||||
|
||||
- `chatWsRoute.py` - WebSocket 聊天(流式输出)
|
||||
- `chatsRoute.py` - 聊天管理
|
||||
- `charactersRoute.py` - 角色卡管理
|
||||
- `presetsRoute.py` - 预设管理
|
||||
- `worldbooksRoute.py` - 世界书管理
|
||||
- `regexRoute.py` - 正则规则管理
|
||||
- `apiConfigRoute.py` - API 配置管理
|
||||
|
||||
### 状态管理
|
||||
|
||||
前端使用 Zustand 进行状态管理,store 定义在 `frontend/src/Store/`:
|
||||
|
||||
```
|
||||
Store/
|
||||
├── Mid/ # 中间区域状态
|
||||
│ ├── ChatBoxSlice.jsx # 聊天框状态
|
||||
│ └── ChatBoxUISlice.jsx # 聊天框 UI 状态
|
||||
├── SideBarLeft/ # 左侧边栏状态
|
||||
├── SideBarRight/ # 右侧边栏状态
|
||||
└── TopBar/ # 顶部栏状态
|
||||
```
|
||||
|
||||
### 样式系统
|
||||
|
||||
使用 CSS 变量实现多主题:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--color-bg-primary: #ffffff;
|
||||
--color-text-primary: #1a1a1a;
|
||||
--color-accent: #667eea;
|
||||
/* ... */
|
||||
}
|
||||
|
||||
[data-color-theme='dark'] {
|
||||
--color-bg-primary: #1a1a1a;
|
||||
--color-text-primary: #ffffff;
|
||||
/* ... */
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 配置说明
|
||||
|
||||
### 环境变量
|
||||
|
||||
#### 前端环境变量 (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
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
# 世界书删除确认弹窗移除
|
||||
|
||||
## 📋 修改内容
|
||||
|
||||
移除了世界书模块中所有的浏览器级别确认弹窗(`confirm`),改为直接执行删除操作。
|
||||
|
||||
---
|
||||
|
||||
## ✅ 已移除的确认弹窗
|
||||
|
||||
### 1. 删除世界书条目
|
||||
**位置**: `WorldBook.jsx` 第338-349行
|
||||
|
||||
**修改前**:
|
||||
```javascript
|
||||
const handleDeleteEntry = async () => {
|
||||
if (!currentEntry || !currentWorldBook) return;
|
||||
|
||||
if (confirm('确定要删除此条目吗?')) {
|
||||
try {
|
||||
await deleteWorldBookEntry(currentWorldBook.name, currentEntry.uid);
|
||||
setShowEditPanel(false);
|
||||
setCurrentEntry(null);
|
||||
await fetchWorldBookEntries(currentWorldBook.name, currentPage, pageSize);
|
||||
} catch (err) {
|
||||
console.error('删除条目失败:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```javascript
|
||||
const handleDeleteEntry = async () => {
|
||||
if (!currentEntry || !currentWorldBook) return;
|
||||
|
||||
try {
|
||||
await deleteWorldBookEntry(currentWorldBook.name, currentEntry.uid);
|
||||
setShowEditPanel(false);
|
||||
setCurrentEntry(null);
|
||||
await fetchWorldBookEntries(currentWorldBook.name, currentPage, pageSize);
|
||||
} catch (err) {
|
||||
console.error('删除条目失败:', err);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 删除世界书
|
||||
**位置**: `WorldBook.jsx` 第354-365行
|
||||
|
||||
**修改前**:
|
||||
```javascript
|
||||
const handleDeleteWorldBook = async () => {
|
||||
if (!currentWorldBook) return;
|
||||
|
||||
if (confirm(`确定要删除世界书 "${currentWorldBook.name}" 吗?`)) {
|
||||
try {
|
||||
await deleteWorldBook(currentWorldBook.name);
|
||||
resetCurrentWorldBook();
|
||||
} catch (err) {
|
||||
console.error('删除世界书失败:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```javascript
|
||||
const handleDeleteWorldBook = async () => {
|
||||
if (!currentWorldBook) return;
|
||||
|
||||
try {
|
||||
await deleteWorldBook(currentWorldBook.name);
|
||||
resetCurrentWorldBook();
|
||||
} catch (err) {
|
||||
console.error('删除世界书失败:', err);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 编辑面板中的删除按钮
|
||||
**位置**: `WorldBook.jsx` 第1086-1096行
|
||||
|
||||
**修改前**:
|
||||
```jsx
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={() => {
|
||||
if (window.confirm('确定要删除这个条目吗?')) {
|
||||
handleDeleteEntry();
|
||||
}
|
||||
}}
|
||||
>
|
||||
删除条目
|
||||
</button>
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```jsx
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={handleDeleteEntry}
|
||||
>
|
||||
删除条目
|
||||
</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 影响范围
|
||||
|
||||
### 用户交互变化
|
||||
|
||||
**之前**:
|
||||
1. 点击删除按钮
|
||||
2. 弹出浏览器确认对话框
|
||||
3. 用户点击"确定"或"取消"
|
||||
4. 根据选择执行或删除操作
|
||||
|
||||
**现在**:
|
||||
1. 点击删除按钮
|
||||
2. 立即执行删除操作
|
||||
3. 通过错误处理捕获异常
|
||||
|
||||
---
|
||||
|
||||
## ✨ 优势
|
||||
|
||||
1. **更流畅的用户体验**: 减少交互步骤,操作更快捷
|
||||
2. **更现代的UI**: 避免使用浏览器原生弹窗,更符合现代Web应用风格
|
||||
3. **代码简化**: 减少了条件判断和嵌套层级
|
||||
4. **一致性**: 与其他删除操作保持一致(如果有其他模块也移除了confirm)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 潜在风险
|
||||
- 用户可能误操作删除重要数据
|
||||
- 没有二次确认机制
|
||||
|
||||
### 建议的替代方案(可选)
|
||||
如果未来需要添加确认机制,可以考虑:
|
||||
1. **自定义模态框**: 使用项目统一的Modal组件
|
||||
2. **Toast提示 + 撤销**: 删除后显示提示,提供短暂的撤销机会
|
||||
3. **软删除**: 先标记为删除,稍后真正删除
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试建议
|
||||
|
||||
### 手动测试清单
|
||||
|
||||
- [ ] 在世界书列表中选择一个世界书
|
||||
- [ ] 点击"删除"按钮,检查是否立即删除(无确认弹窗)
|
||||
- [ ] 在条目编辑面板中点击"删除条目"按钮,检查是否立即删除
|
||||
- [ ] 检查删除后的控制台是否有错误信息
|
||||
- [ ] 检查删除后UI是否正确更新
|
||||
|
||||
### 边界情况测试
|
||||
|
||||
- [ ] 删除不存在的世界书(应该被前端校验拦截)
|
||||
- [ ] 删除不存在的条目(应该被前端校验拦截)
|
||||
- [ ] 网络请求失败时的错误处理
|
||||
|
||||
---
|
||||
|
||||
## 📝 相关代码位置
|
||||
|
||||
### 主要文件
|
||||
- `frontend/src/components/SideBarLeft/tabs/WorldBook/WorldBook.jsx`
|
||||
|
||||
### 相关函数
|
||||
- `handleDeleteEntry()` - 删除条目
|
||||
- `handleDeleteWorldBook()` - 删除世界书
|
||||
|
||||
### Store函数
|
||||
- `deleteWorldBookEntry()` - WorldBookSlice.jsx
|
||||
- `deleteWorldBook()` - WorldBookSlice.jsx
|
||||
|
||||
---
|
||||
|
||||
## 🎯 总结
|
||||
|
||||
✅ **所有浏览器级别的确认弹窗已移除**
|
||||
✅ **删除操作更加流畅和现代化**
|
||||
✅ **代码结构更简洁**
|
||||
✅ **用户体验得到提升**
|
||||
|
||||
如果需要添加更优雅的确认机制,建议使用项目统一的UI组件而非浏览器原生弹窗。
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
**祝测试顺利!** 🎉
|
||||
@@ -1,217 +0,0 @@
|
||||
# 世界书分页功能 - 前端API调用检查报告
|
||||
|
||||
## ✅ 检查结果
|
||||
|
||||
**所有前端世界书分页功能都能正确发出请求到后端API!**
|
||||
|
||||
---
|
||||
|
||||
## 📋 完整API映射表
|
||||
|
||||
### 1. 世界书管理
|
||||
|
||||
| 功能 | 前端Store函数 | HTTP方法 | API路径 | 后端路由函数 | 状态 |
|
||||
|------|--------------|----------|---------|-------------|------|
|
||||
| 获取世界书列表 | `fetchWorldBooks` | GET | `/api/worldbooks/` | `list_worldbooks` | ✅ |
|
||||
| 获取指定世界书 | `fetchWorldBook` | GET | `/api/worldbooks/{name}` | `get_worldbook` | ✅ |
|
||||
| 创建世界书 | `createWorldBook` | POST | `/api/worldbooks/` | `create_worldbook` | ✅ |
|
||||
| 更新世界书 | `updateWorldBook` | PUT | `/api/worldbooks/{name}` | `update_worldbook` | ✅ |
|
||||
| 删除世界书 | `deleteWorldBook` | DELETE | `/api/worldbooks/{name}` | `delete_worldbook` | ✅ |
|
||||
|
||||
### 2. 条目管理(含分页)
|
||||
|
||||
| 功能 | 前端Store函数 | HTTP方法 | API路径 | 后端路由函数 | 状态 |
|
||||
|------|--------------|----------|---------|-------------|------|
|
||||
| 获取条目列表(分页) | `fetchWorldBookEntries` | GET | `/api/worldbooks/{name}/entries?page={page}&page_size={page_size}` | `list_worldbook_entries` | ✅ |
|
||||
| 获取指定条目 | `fetchWorldBookEntry` | GET | `/api/worldbooks/{name}/entries/{uid}` | `get_worldbook_entry` | ✅ |
|
||||
| 创建条目 | `createWorldBookEntry` | POST | `/api/worldbooks/{name}/entries` | `create_worldbook_entry` | ✅ |
|
||||
| 更新条目 | `updateWorldBookEntry` | PUT | `/api/worldbooks/{name}/entries/{uid}` | `update_worldbook_entry` | ✅ |
|
||||
| 删除条目 | `deleteWorldBookEntry` | DELETE | `/api/worldbooks/{name}/entries/{uid}` | `delete_worldbook_entry` | ✅ |
|
||||
|
||||
### 3. 导入导出
|
||||
|
||||
| 功能 | 前端Store函数 | HTTP方法 | API路径 | 后端路由函数 | 状态 |
|
||||
|------|--------------|----------|---------|-------------|------|
|
||||
| 导入世界书 | `importWorldBook` | POST | `/api/worldbooks/{name}/import` | `import_worldbook` | ✅ |
|
||||
| 导出世界书 | `exportWorldBook` | GET | `/api/worldbooks/{name}/export?format={format}` | `export_worldbook` | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 🔍 关键功能验证
|
||||
|
||||
### ✅ 下拉框读取所有世界书
|
||||
|
||||
**位置**: `WorldBook.jsx` 第547行
|
||||
|
||||
```jsx
|
||||
{worldBooks.map(book => (
|
||||
<div key={book.name} className="dropdown-item">
|
||||
...
|
||||
</div>
|
||||
))}
|
||||
```
|
||||
|
||||
**数据来源**:
|
||||
- `worldBooks` 来自 Store (第8行)
|
||||
- 通过 `fetchWorldBooks()` 加载 (第85行和第95行)
|
||||
|
||||
**加载时机**:
|
||||
1. ✅ 组件挂载时立即加载(如果列表为空)
|
||||
2. ✅ 切换到世界书标签页时重新加载
|
||||
|
||||
**API调用**:
|
||||
```javascript
|
||||
// WorldBookSlice.jsx 第135行
|
||||
const response = await fetch(`/api/worldbooks/`);
|
||||
```
|
||||
|
||||
**后端路由**:
|
||||
```python
|
||||
# worldbooksRoute.py 第28行
|
||||
@router.get("/", response_model=List[Dict[str, Any]])
|
||||
async def list_worldbooks():
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 分页功能验证
|
||||
|
||||
### 前端实现
|
||||
|
||||
**Store函数** (`WorldBookSlice.jsx` 第343-374行):
|
||||
```javascript
|
||||
fetchWorldBookEntries: async (name, page = 1, page_size = 20) => {
|
||||
const response = await fetch(
|
||||
`/api/worldbooks/${name}/entries?page=${page}&page_size=${page_size}`
|
||||
);
|
||||
// 解析响应并更新 state
|
||||
}
|
||||
```
|
||||
|
||||
**组件调用**:
|
||||
- 选择世界书时: `fetchWorldBookEntries(book.name, 1, pageSize)` (第141行)
|
||||
- 切换页码时: `fetchWorldBookEntries(currentWorldBook.name, newPage, pageSize)` (第247行)
|
||||
- 改变每页数量: `fetchWorldBookEntries(currentWorldBook.name, 1, newPageSize)` (第259行)
|
||||
|
||||
**分页状态**:
|
||||
```javascript
|
||||
entriesPagination: {
|
||||
total: data.total || 0,
|
||||
page: data.page || 1,
|
||||
page_size: data.page_size || 20,
|
||||
total_pages: data.total_pages || 0
|
||||
}
|
||||
```
|
||||
|
||||
### 后端实现
|
||||
|
||||
**API路由** (`worldbooksRoute.py` 第108-128行):
|
||||
```python
|
||||
@router.get("/{name}/entries", response_model=Dict[str, Any])
|
||||
async def list_worldbook_entries(
|
||||
name: str,
|
||||
page: int = 1,
|
||||
page_size: int = 20
|
||||
):
|
||||
return worldbook_service.list_entries(name, page, page_size)
|
||||
```
|
||||
|
||||
**服务层** (`worldbook_service.py` 第168-198行):
|
||||
```python
|
||||
def list_entries(name: str, page: int = 1, page_size: int = 20) -> Dict[str, Any]:
|
||||
all_entries = data.get("entries", [])
|
||||
total = len(all_entries)
|
||||
|
||||
start_idx = (page - 1) * page_size
|
||||
end_idx = start_idx + page_size
|
||||
paginated_entries = all_entries[start_idx:end_idx]
|
||||
|
||||
return {
|
||||
"entries": paginated_entries,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_pages": (total + page_size - 1) // page_size
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 数据格式
|
||||
|
||||
### 后端返回格式(分页)
|
||||
|
||||
```json
|
||||
{
|
||||
"entries": [...],
|
||||
"total": 30,
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
"total_pages": 2
|
||||
}
|
||||
```
|
||||
|
||||
### 前端State结构
|
||||
|
||||
```javascript
|
||||
{
|
||||
worldBooks: [], // 世界书列表
|
||||
currentWorldBook: null, // 当前选中的世界书
|
||||
currentEntries: [], // 当前页的条目列表
|
||||
entriesPagination: { // 分页信息
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total_pages: 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✨ 优化记录
|
||||
|
||||
### 已完成的优化
|
||||
|
||||
1. ✅ **组件初始化加载**: 添加 `useEffect` 在组件挂载时立即加载世界书列表
|
||||
2. ✅ **格式统一**: 所有世界书文件转换为内部格式存储
|
||||
3. ✅ **代码简化**: 移除服务层的SillyTavern格式运行时兼容逻辑
|
||||
4. ✅ **分页控件**: 添加完整的分页UI(上一页、下一页、每页数量选择)
|
||||
5. ✅ **自动刷新**: 添加/更新/删除条目后自动刷新当前页
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试建议
|
||||
|
||||
### 手动测试清单
|
||||
|
||||
- [ ] 打开世界书页面,检查下拉框是否显示所有世界书
|
||||
- [ ] 选择一个世界书,检查是否正确加载第一页条目
|
||||
- [ ] 点击"下一页",检查是否加载第二页
|
||||
- [ ] 改变每页数量(10/20/50/100),检查是否正确刷新
|
||||
- [ ] 创建新条目,检查是否刷新当前页
|
||||
- [ ] 更新条目,检查是否刷新当前页
|
||||
- [ ] 删除条目,检查是否刷新当前页
|
||||
- [ ] 切换到其他世界书,检查是否重置到第一页
|
||||
|
||||
### API测试
|
||||
|
||||
```bash
|
||||
# 获取世界书列表
|
||||
curl http://localhost:8000/api/worldbooks/
|
||||
|
||||
# 获取条目(分页)
|
||||
curl "http://localhost:8000/api/worldbooks/卡立创-v5/entries?page=1&page_size=5"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 结论
|
||||
|
||||
✅ **前端世界书分页的所有功能都能正确发出请求到后端API**
|
||||
|
||||
- 下拉框正确读取所有世界书
|
||||
- 分页参数正确传递
|
||||
- 数据格式匹配
|
||||
- 错误处理完善
|
||||
- 用户体验流畅
|
||||
@@ -1,18 +1,28 @@
|
||||
from fastapi import APIRouter
|
||||
from .routes import presetsRoute, chatsRoute, worldbooksRoute, apiConfigRoute, charactersRoute
|
||||
from .routes import presetsRoute, chatsRoute, worldbooksRoute, apiConfigRoute, charactersRoute, chatWsRoute, tokenUsageRoute, imageGalleryRoute, regexRoute, chatSummaryRoute, studioRoute
|
||||
from utils.file_utils import get_all_roles_and_chats
|
||||
from core.config import settings
|
||||
from pathlib import Path
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 注册子路由
|
||||
# 注册子路由(HTTP路由)
|
||||
router.include_router(presetsRoute.router)
|
||||
router.include_router(chatsRoute.router)
|
||||
router.include_router(worldbooksRoute.router)
|
||||
router.include_router(apiConfigRoute.router)
|
||||
router.include_router(charactersRoute.router)
|
||||
|
||||
# ✅ 注册新增路由
|
||||
router.include_router(tokenUsageRoute.router)
|
||||
router.include_router(imageGalleryRoute.router)
|
||||
router.include_router(regexRoute.router)
|
||||
router.include_router(chatSummaryRoute.router)
|
||||
router.include_router(studioRoute.router)
|
||||
|
||||
# ✅ 注册 WebSocket 路由(必须在 HTTP 路由之后,避免路径冲突)
|
||||
router.include_router(chatWsRoute.router)
|
||||
|
||||
|
||||
# 保留原有的其他路由
|
||||
@router.get("/tool_bar/get_all_role_and_chat")
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
154
backend/api/routes/chatSummaryRoute.py
Normal file
154
backend/api/routes/chatSummaryRoute.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
聊天总结 API 路由
|
||||
|
||||
处理聊天记录的总结请求
|
||||
"""
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Body
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from services.chat_service import chat_service
|
||||
from services.chat_summary_service import chat_summary_service
|
||||
from models.internal import SummaryConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/chats", tags=["chat-summary"])
|
||||
|
||||
|
||||
@router.post("/{role_name}/{chat_name}/summarize")
|
||||
async def summarize_chat_history(
|
||||
role_name: str,
|
||||
chat_name: str,
|
||||
request_data: Dict[str, Any] = Body(...)
|
||||
):
|
||||
"""
|
||||
总结聊天历史记录
|
||||
|
||||
Args:
|
||||
role_name: 角色名称
|
||||
chat_name: 聊天名称
|
||||
request_data: {
|
||||
"startFloor": int, # 总结起始楼层
|
||||
"endFloor": int, # 总结结束楼层
|
||||
"summaryConfig": {...}, # 总结配置
|
||||
"apiConfig": {...} # API配置
|
||||
}
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": bool,
|
||||
"summaryText": str, # 总结文本
|
||||
"startFloor": int,
|
||||
"endFloor": int,
|
||||
"message": str
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# 1. 提取请求参数
|
||||
start_floor = request_data.get("startFloor")
|
||||
end_floor = request_data.get("endFloor")
|
||||
summary_config_data = request_data.get("summaryConfig", {})
|
||||
api_config = request_data.get("apiConfig", {})
|
||||
|
||||
if not start_floor or not end_floor:
|
||||
raise HTTPException(status_code=400, detail="缺少 startFloor 或 endFloor 参数")
|
||||
|
||||
# 2. 加载聊天记录
|
||||
chat_log = chat_service.get_chat_log(role_name, chat_name)
|
||||
if not chat_log:
|
||||
raise HTTPException(status_code=404, detail=f"聊天记录 '{role_name}/{chat_name}' 不存在")
|
||||
|
||||
messages = chat_log.messages
|
||||
total_messages = len(messages)
|
||||
|
||||
# 3. 验证楼层范围
|
||||
if start_floor < 1 or end_floor > total_messages or start_floor > end_floor:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"无效的楼层范围: {start_floor}-{end_floor}(总共{total_messages}条消息)"
|
||||
)
|
||||
|
||||
# 4. 构建SummaryConfig对象
|
||||
summary_config = SummaryConfig(**summary_config_data)
|
||||
|
||||
logger.info(
|
||||
f"[ChatSummary] 开始总结: {role_name}/{chat_name}, "
|
||||
f"楼层范围: {start_floor}-{end_floor}, "
|
||||
f"包含用户输入: {summary_config.includeUserInput}"
|
||||
)
|
||||
|
||||
# 5. 调用总结服务
|
||||
summary_text = await chat_summary_service.summarize_messages(
|
||||
messages=messages,
|
||||
start_floor=start_floor,
|
||||
end_floor=end_floor,
|
||||
summary_config=summary_config,
|
||||
api_config=api_config
|
||||
)
|
||||
|
||||
if not summary_text:
|
||||
raise HTTPException(status_code=500, detail="总结生成失败")
|
||||
|
||||
logger.info(f"[ChatSummary] 总结完成,长度: {len(summary_text)} 字符")
|
||||
|
||||
# 6. 更新聊天记录(清空原文 + 替换总结)
|
||||
chat_service.summarize_chat_messages(
|
||||
role_name=role_name,
|
||||
chat_name=chat_name,
|
||||
start_floor=start_floor,
|
||||
end_floor=end_floor,
|
||||
summary_text=summary_text
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"summaryText": summary_text,
|
||||
"startFloor": start_floor,
|
||||
"endFloor": end_floor,
|
||||
"message": f"成功总结 {end_floor - start_floor + 1} 条消息"
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[ChatSummary] 总结失败: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
raise HTTPException(status_code=500, detail=f"总结失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/{role_name}/{chat_name}/summary-status")
|
||||
async def get_summary_status(role_name: str, chat_name: str):
|
||||
"""
|
||||
获取聊天总结状态
|
||||
|
||||
Returns:
|
||||
{
|
||||
"historyMode": str,
|
||||
"summaryCounter": int,
|
||||
"lastSummaryFloor": int,
|
||||
"summaryConfig": {...}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
chat_log = chat_service.get_chat_log(role_name, chat_name)
|
||||
if not chat_log:
|
||||
raise HTTPException(status_code=404, detail="聊天记录不存在")
|
||||
|
||||
header = chat_log.header
|
||||
|
||||
return {
|
||||
"historyMode": header.historyMode.value if hasattr(header.historyMode, 'value') else header.historyMode,
|
||||
"summaryCounter": header.summaryCounter or 0,
|
||||
"lastSummaryFloor": getattr(header, 'lastSummaryFloor', 0),
|
||||
"summaryConfig": header.summaryConfig.dict() if header.summaryConfig else None
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[ChatSummary] 获取总结状态失败: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
424
backend/api/routes/chatWsRoute.py
Normal file
424
backend/api/routes/chatWsRoute.py
Normal file
@@ -0,0 +1,424 @@
|
||||
"""
|
||||
聊天 WebSocket 路由
|
||||
处理实时对话生成
|
||||
"""
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
from typing import Dict, Any
|
||||
import json
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
from backend.services.chat_workflow_service import ChatWorkflowService
|
||||
from backend.services.chat_service import ChatService
|
||||
from backend.services.task_queue_manager import task_queue_manager
|
||||
from backend.core.config import settings
|
||||
except ImportError:
|
||||
from services.chat_workflow_service import ChatWorkflowService
|
||||
from services.chat_service import ChatService
|
||||
from services.task_queue_manager import task_queue_manager
|
||||
from core.config import settings
|
||||
|
||||
router = APIRouter(prefix="/chat", tags=["chat-websocket"])
|
||||
|
||||
# 初始化服务
|
||||
workflow_service = ChatWorkflowService()
|
||||
chat_service = ChatService(settings.DATA_PATH)
|
||||
|
||||
# ✅ 全局变量:用于存储需要中断的聊天会话
|
||||
interrupt_flags: Dict[str, bool] = {}
|
||||
|
||||
|
||||
@router.websocket("/{role_name}/{chat_name}/ws")
|
||||
async def websocket_chat_endpoint(
|
||||
websocket: WebSocket,
|
||||
role_name: str,
|
||||
chat_name: str
|
||||
):
|
||||
"""
|
||||
WebSocket 聊天端点
|
||||
|
||||
接收前端发送的完整对话请求,调用工作流生成回复,支持流式输出
|
||||
"""
|
||||
await websocket.accept()
|
||||
print(f"\n{'='*80}")
|
||||
print(f"[WebSocket] 📡 连接建立: {role_name}/{chat_name}")
|
||||
print(f"{'='*80}\n")
|
||||
|
||||
chat_id = f"{role_name}/{chat_name}"
|
||||
|
||||
try:
|
||||
while True:
|
||||
# 1. 接收前端消息
|
||||
print(f"[WebSocket] ⏳ 等待接收消息...")
|
||||
data = await websocket.receive_text()
|
||||
print(f"[WebSocket] ✅ 收到消息,长度: {len(data)}")
|
||||
request_data = json.loads(data)
|
||||
|
||||
# ✅ 检查是否是取消任务的请求
|
||||
if request_data.get("type") == "cancel_task":
|
||||
task_id = request_data.get("taskId")
|
||||
print(f"[WebSocket] ❌ 收到取消任务请求: {task_id}")
|
||||
|
||||
# ✅ 特殊处理:如果是 LLM 生成任务,需要中断当前流式生成
|
||||
if task_id == "current_llm_generation":
|
||||
print(f"[WebSocket] 🛑 正在终止 LLM 流式生成...")
|
||||
# TODO: 实现 LLM 生成的中断逻辑
|
||||
# 目前只能通过关闭连接来终止
|
||||
|
||||
await websocket.send_json({
|
||||
"type": "task_cancelled",
|
||||
"taskId": task_id,
|
||||
"success": True,
|
||||
"message": "LLM 生成已终止"
|
||||
})
|
||||
else:
|
||||
# 取消其他类型的任务(图像生成、动态表格等)
|
||||
success = await task_queue_manager.cancel_task(task_id)
|
||||
|
||||
await websocket.send_json({
|
||||
"type": "task_cancelled",
|
||||
"taskId": task_id,
|
||||
"success": success
|
||||
})
|
||||
print(f"[WebSocket] ✅ 任务取消结果: {success}")
|
||||
continue
|
||||
|
||||
print(f"\n{'-'*80}")
|
||||
print(f"[WebSocket] 📨 收到请求:")
|
||||
print(f" - Floor: {request_data.get('floor')}")
|
||||
print(f" - Role: {request_data.get('currentRole')}")
|
||||
print(f" - Chat: {request_data.get('currentChat')}")
|
||||
print(f" - Stream: {request_data.get('stream', False)}")
|
||||
print(f" - Message Length: {len(request_data.get('mes', ''))}")
|
||||
|
||||
# ✅ 打印 API 配置信息(隐藏密钥)
|
||||
api_config = request_data.get('apiConfig', {})
|
||||
current_profile = request_data.get('currentProfile', {})
|
||||
profile_id = current_profile.get('id') if isinstance(current_profile, dict) else None
|
||||
|
||||
print(f" - Profile ID: {profile_id or 'N/A'}")
|
||||
print(f" - API URL: {api_config.get('api_url', 'N/A')[:50]}..." if len(api_config.get('api_url', '')) > 50 else f" - API URL: {api_config.get('api_url', 'N/A')}")
|
||||
print(f" - Model: {api_config.get('model', 'N/A')}")
|
||||
|
||||
# ✅ 始终从配置文件中读取 API Key(不信任前端传来的 Key)
|
||||
if profile_id:
|
||||
try:
|
||||
from .apiConfigRoute import load_profile
|
||||
|
||||
profile = load_profile(profile_id)
|
||||
if profile:
|
||||
# 找到 mainLLM 的配置
|
||||
main_llm_config = profile.get('apis', {}).get('mainLLM', {})
|
||||
api_key = main_llm_config.get('apiKey', '')
|
||||
|
||||
if api_key:
|
||||
# 使用明文 API Key
|
||||
api_config['api_key'] = api_key
|
||||
request_data['apiConfig'] = api_config
|
||||
print(f" - API Key: ✅ 已从配置文件加载")
|
||||
else:
|
||||
print(f" - API Key: ⚠️ 配置文件中未找到 Key")
|
||||
else:
|
||||
print(f" - API Key: ❌ 无法加载配置文件: {profile_id}")
|
||||
except Exception as e:
|
||||
print(f" - API Key: ❌ 加载失败: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
else:
|
||||
print(f" - API Key: ⚠️ 未提供 profileId,无法加载")
|
||||
|
||||
print(f"{'-'*80}\n")
|
||||
|
||||
# 2. 提取流式输出标志
|
||||
stream_output = request_data.get("stream", False)
|
||||
|
||||
if stream_output:
|
||||
# === 真正的流式输出模式 ===
|
||||
print(f"[WebSocket] 🌊 进入流式处理模式")
|
||||
await _handle_stream_chat(
|
||||
websocket, role_name, chat_name, request_data, workflow_service
|
||||
)
|
||||
else:
|
||||
# === 非流式输出模式 ===
|
||||
print(f"[WebSocket] 📦 进入非流式处理模式")
|
||||
result = await workflow_service.process_chat_request(request_data)
|
||||
|
||||
if result["success"]:
|
||||
content = result["content"]
|
||||
|
||||
print(f"\n[WebSocket] ✨ 生成成功,内容长度: {len(content)}")
|
||||
|
||||
# ✅ 发送激活的世界书条目信息
|
||||
active_entries = result.get("activeEntries", [])
|
||||
print(f"[WebSocket] 📚 发送世界书激活信息: {len(active_entries)} 个条目")
|
||||
await websocket.send_json({
|
||||
"type": "worldbook_active",
|
||||
"entries": active_entries
|
||||
})
|
||||
|
||||
# ✅ 发送任务ID信息
|
||||
task_ids = result.get("taskIds", {})
|
||||
if task_ids.get("imageWorkflow") or task_ids.get("dynamicTable"):
|
||||
print(f"[WebSocket] 📋 发送任务ID信息: {task_ids}")
|
||||
await websocket.send_json({
|
||||
"type": "tasks_created",
|
||||
"tasks": task_ids
|
||||
})
|
||||
|
||||
# 一次性发送完整内容
|
||||
print(f"[WebSocket] 📤 发送完整内容 (chunk)")
|
||||
await websocket.send_json({
|
||||
"type": "chunk",
|
||||
"content": content
|
||||
})
|
||||
|
||||
print(f"[WebSocket] ✅ 发送完成信号")
|
||||
await websocket.send_json({
|
||||
"type": "complete"
|
||||
})
|
||||
|
||||
# 保存消息
|
||||
print(f"[WebSocket] 💾 保存消息到文件...")
|
||||
await _save_messages(role_name, chat_name, request_data, content)
|
||||
print(f"[WebSocket] ✅ 消息保存完成\n")
|
||||
else:
|
||||
error_msg = result["error"]
|
||||
print(f"[WebSocket] ❌ 处理失败: {error_msg}")
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": error_msg
|
||||
})
|
||||
|
||||
except WebSocketDisconnect:
|
||||
print(f"\n{'='*80}")
|
||||
print(f"[WebSocket] 🔌 连接断开: {role_name}/{chat_name}")
|
||||
print(f"{'='*80}\n")
|
||||
except Exception as e:
|
||||
print(f"\n{'='*80}")
|
||||
print(f"[WebSocket] ⚠️ 错误: {str(e)}")
|
||||
print(f"{'='*80}\n")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
try:
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": f"服务器错误: {str(e)}"
|
||||
})
|
||||
except:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
await websocket.close()
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
async def _handle_stream_chat(
|
||||
websocket: WebSocket,
|
||||
role_name: str,
|
||||
chat_name: str,
|
||||
request_data: Dict[str, Any],
|
||||
workflow_service
|
||||
):
|
||||
"""
|
||||
处理流式聊天请求 – engine callbacks emit worldbook_active / tasks_created / chunk.
|
||||
"""
|
||||
try:
|
||||
print(f"[StreamChat] 🚀 开始流式处理")
|
||||
|
||||
chunk_count = [0]
|
||||
|
||||
async def on_worldbook_active(entries):
|
||||
if entries:
|
||||
print(f"[StreamChat] 📤 发送世界书激活信息: {len(entries)} 个条目")
|
||||
await websocket.send_json({
|
||||
"type": "worldbook_active",
|
||||
"entries": entries,
|
||||
})
|
||||
|
||||
async def on_tasks_created(task_ids):
|
||||
if task_ids.get("imageWorkflow") or task_ids.get("dynamicTable"):
|
||||
print(f"[StreamChat] 📤 发送任务ID信息: {task_ids}")
|
||||
await websocket.send_json({
|
||||
"type": "tasks_created",
|
||||
"tasks": task_ids,
|
||||
})
|
||||
|
||||
async def on_chunk(chunk):
|
||||
chunk_count[0] += 1
|
||||
if chunk_count[0] % 10 == 0:
|
||||
print(f"[StreamChat] 📤 已发送 {chunk_count[0]} 个 chunks")
|
||||
await websocket.send_json({"type": "chunk", "content": chunk})
|
||||
|
||||
result = await workflow_service.process_chat_request_stream(
|
||||
request_data,
|
||||
on_chunk=on_chunk,
|
||||
on_worldbook_active=on_worldbook_active,
|
||||
on_tasks_created=on_tasks_created,
|
||||
)
|
||||
|
||||
if result["success"]:
|
||||
content = result["content"]
|
||||
print(f"\n[StreamChat] ✨ 流式生成成功,总长度: {len(content)}")
|
||||
print(f"[StreamChat] ✅ 发送完成信号")
|
||||
await websocket.send_json({"type": "complete"})
|
||||
print(f"[StreamChat] 💾 保存消息到文件...")
|
||||
await _save_messages(role_name, chat_name, request_data, content)
|
||||
print(f"[StreamChat] ✅ 消息保存完成\n")
|
||||
else:
|
||||
error_msg = result["error"]
|
||||
print(f"[StreamChat] ❌ 流式处理失败: {error_msg}")
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": error_msg,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n[StreamChat] ⚠️ 错误: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
await websocket.send_json({
|
||||
"type": "error",
|
||||
"message": f"流式处理失败: {str(e)}",
|
||||
})
|
||||
|
||||
|
||||
async def _save_messages(
|
||||
role_name: str,
|
||||
chat_name: str,
|
||||
request_data: Dict[str, Any],
|
||||
ai_response: str
|
||||
):
|
||||
"""
|
||||
保存用户消息和AI回复到聊天文件
|
||||
|
||||
Args:
|
||||
role_name: 角色名
|
||||
chat_name: 聊天名
|
||||
request_data: 前端发送的请求数据
|
||||
ai_response: AI生成的回复
|
||||
"""
|
||||
try:
|
||||
from datetime import datetime
|
||||
|
||||
# ✅ 应用双 false 的正则规则(永久修改存储数据)
|
||||
from services.regex_service import regex_service
|
||||
from models.regex_rules import RegexPlacement
|
||||
|
||||
# 获取预设名称
|
||||
preset_config = request_data.get("presetConfig", {})
|
||||
preset_name = preset_config.get("selectedPreset")
|
||||
|
||||
# 计算消息深度
|
||||
floor = request_data.get("floor", 0)
|
||||
message_depth = 0 # AI 回复是最新消息,深度为 0
|
||||
|
||||
# ✅ 应用 AI Output 正则规则(placement=2)
|
||||
# 只应用双 false 的规则(markdownOnly=false 且 promptOnly=false)
|
||||
processed_ai_response = regex_service.apply_rules_by_placement(
|
||||
text=ai_response,
|
||||
placement=RegexPlacement.AI_OUTPUT.value,
|
||||
character_name=role_name,
|
||||
preset_name=preset_name,
|
||||
message_depth=message_depth,
|
||||
is_for_llm=False, # ✅ 不是发送给 LLM,是保存数据
|
||||
is_markdown_rendered=False # ✅ 不是 Markdown 渲染后
|
||||
)
|
||||
|
||||
# 如果处理后的内容与原始内容不同,说明有双 false 规则被应用
|
||||
if processed_ai_response != ai_response:
|
||||
print(f"[Regex] ✅ 已应用双 false 正则规则(永久修改存储数据)")
|
||||
ai_response = processed_ai_response
|
||||
|
||||
# ✅ 检查是否是重roll模式(targetFloor 存在且不为 null)
|
||||
target_floor = request_data.get("floor")
|
||||
is_reroll = target_floor is not None
|
||||
|
||||
if is_reroll:
|
||||
# ✅ 重roll模式:更新现有消息的 swipes 数组
|
||||
print(f"[WebSocket] 🔄 重roll模式,更新楼层 {target_floor} 的 swipes")
|
||||
|
||||
# 获取现有的消息
|
||||
existing_message = chat_service.get_message(role_name, chat_name, target_floor)
|
||||
|
||||
if not existing_message:
|
||||
print(f"[WebSocket] ⚠️ 找不到楼层 {target_floor} 的消息,创建新消息")
|
||||
# 如果找不到,创建新消息(兼容处理)
|
||||
ai_message = {
|
||||
"id": f"msg_{datetime.now().timestamp()}_ai",
|
||||
"name": request_data.get("characterName", role_name),
|
||||
"is_user": False,
|
||||
"is_system": False,
|
||||
"sendDate": datetime.now().isoformat(),
|
||||
"mes": ai_response,
|
||||
"chatId": f"{role_name}/{chat_name}",
|
||||
"floor": target_floor,
|
||||
"swipes": [ai_response],
|
||||
"swipe_id": 0
|
||||
}
|
||||
chat_service.add_message(role_name, chat_name, ai_message)
|
||||
else:
|
||||
# ✅ 更新 swipes 数组
|
||||
existing_swipes = existing_message.get("swipes", [])
|
||||
current_mes = existing_message.get("mes", "")
|
||||
|
||||
# 构建新的 swipes 数组
|
||||
updated_swipes = list(existing_swipes) # 复制现有swipes
|
||||
|
||||
# 如果当前 mes 不在 swipes 中,先添加它
|
||||
if current_mes and current_mes not in updated_swipes:
|
||||
updated_swipes.append(current_mes)
|
||||
print(f"[WebSocket] 📝 将当前内容添加到 swipes")
|
||||
|
||||
# 添加新生成的内容
|
||||
updated_swipes.append(ai_response)
|
||||
print(f"[WebSocket] 📊 Swipes 更新: {len(existing_swipes)} -> {len(updated_swipes)}")
|
||||
|
||||
# 更新消息
|
||||
update_data = {
|
||||
"mes": ai_response, # 显示最新内容
|
||||
"swipes": updated_swipes, # 更新 swipes 数组
|
||||
"swipe_id": len(updated_swipes) - 1 # 自动切换到新版本
|
||||
}
|
||||
|
||||
chat_service.update_message(role_name, chat_name, target_floor, update_data)
|
||||
print(f"[WebSocket] ✅ 楼层 {target_floor} 已更新,swipes 数量: {len(updated_swipes)}")
|
||||
else:
|
||||
# ✅ 正常模式:创建新的用户消息和AI消息
|
||||
print(f"[WebSocket] ➕ 正常模式,创建新消息")
|
||||
|
||||
# 1. 保存用户消息
|
||||
user_message = {
|
||||
"id": f"msg_{datetime.now().timestamp()}_user",
|
||||
"name": request_data.get("userName", "User"),
|
||||
"is_user": True,
|
||||
"is_system": False,
|
||||
"sendDate": datetime.now().isoformat(),
|
||||
"mes": request_data.get("mes", ""),
|
||||
"chatId": f"{role_name}/{chat_name}",
|
||||
"floor": request_data.get("floor", 0)
|
||||
}
|
||||
|
||||
chat_service.add_message(role_name, chat_name, user_message)
|
||||
|
||||
# 2. 保存AI回复
|
||||
ai_message = {
|
||||
"id": f"msg_{datetime.now().timestamp()}_ai",
|
||||
"name": request_data.get("characterName", role_name),
|
||||
"is_user": False,
|
||||
"is_system": False,
|
||||
"sendDate": datetime.now().isoformat(),
|
||||
"mes": ai_response,
|
||||
"chatId": f"{role_name}/{chat_name}",
|
||||
"floor": request_data.get("floor", 0) + 1
|
||||
}
|
||||
|
||||
chat_service.add_message(role_name, chat_name, ai_message)
|
||||
|
||||
print(f"[WebSocket] ✅ 新消息已保存: {role_name}/{chat_name}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[WebSocket] 保存消息失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
# 不抛出异常,避免影响主流程
|
||||
@@ -1,12 +1,13 @@
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from backend.services.chat_service import ChatService
|
||||
from backend.core.config import settings
|
||||
from backend.services.chat_service import ChatService
|
||||
from backend.core.config import settings
|
||||
except ImportError:
|
||||
# Docker环境:直接从当前目录导入
|
||||
from services.chat_service import ChatService
|
||||
from core.config import settings
|
||||
# Docker环境:直接从当前目录导入
|
||||
from services.chat_service import ChatService
|
||||
from core.config import settings
|
||||
|
||||
router = APIRouter(prefix="/chat", tags=["chat"])
|
||||
|
||||
@@ -14,11 +15,23 @@ router = APIRouter(prefix="/chat", tags=["chat"])
|
||||
data_path = Path(settings.DATA_PATH) if hasattr(settings, 'DATA_PATH') else Path("data")
|
||||
chat_service = ChatService(data_path)
|
||||
|
||||
|
||||
@router.get("", response_model=dict)
|
||||
async def list_all_chats():
|
||||
"""获取所有角色的所有聊天列表"""
|
||||
return chat_service.list_all_chats()
|
||||
|
||||
|
||||
# 注意:路由定义顺序很重要!更具体的路由(更多参数)必须放在前面
|
||||
@router.get("/{role_name}/{chat_name}")
|
||||
async def get_chat(role_name: str, chat_name: str):
|
||||
"""获取指定聊天的完整内容"""
|
||||
try:
|
||||
return chat_service.get_chat(role_name, chat_name)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{role_name}")
|
||||
async def list_role_chats(role_name: str):
|
||||
"""获取指定角色的所有聊天列表"""
|
||||
@@ -30,14 +43,7 @@ async def list_role_chats(role_name: str):
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/{role_name}/{chat_name}")
|
||||
async def get_chat(role_name: str, chat_name: str):
|
||||
"""获取指定聊天的完整内容"""
|
||||
try:
|
||||
return chat_service.get_chat(role_name, chat_name)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{role_name}", status_code=status.HTTP_201_CREATED)
|
||||
async def create_chat(role_name: str, chat_data: dict):
|
||||
"""创建新聊天"""
|
||||
@@ -48,18 +54,21 @@ async def create_chat(role_name: str, chat_data: dict):
|
||||
except FileExistsError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/{role_name}/{chat_name}")
|
||||
async def update_chat(role_name: str, chat_name: str, update_data: dict):
|
||||
"""更新聊天元数据"""
|
||||
# TODO: 实现更新聊天元数据功能
|
||||
raise HTTPException(status_code=501, detail="Not Implemented")
|
||||
|
||||
|
||||
@router.delete("/{role_name}/{chat_name}")
|
||||
async def delete_chat(role_name: str, chat_name: str):
|
||||
"""删除指定聊天"""
|
||||
# TODO: 实现删除聊天功能
|
||||
raise HTTPException(status_code=501, detail="Not Implemented")
|
||||
|
||||
|
||||
@router.get("/{role_name}/{chat_name}/messages")
|
||||
async def list_messages(role_name: str, chat_name: str):
|
||||
"""获取聊天的所有消息"""
|
||||
@@ -69,6 +78,7 @@ async def list_messages(role_name: str, chat_name: str):
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/{role_name}/{chat_name}/messages/{floor}")
|
||||
async def get_message(role_name: str, chat_name: str, floor: int):
|
||||
"""获取指定楼层的消息"""
|
||||
@@ -81,6 +91,7 @@ async def get_message(role_name: str, chat_name: str, floor: int):
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{role_name}/{chat_name}/messages", status_code=status.HTTP_201_CREATED)
|
||||
async def add_message(role_name: str, chat_name: str, message_data: dict):
|
||||
"""向聊天添加新消息"""
|
||||
@@ -91,6 +102,7 @@ async def add_message(role_name: str, chat_name: str, message_data: dict):
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/{role_name}/{chat_name}/messages/{floor}")
|
||||
async def update_message(role_name: str, chat_name: str, floor: int, update_data: dict):
|
||||
"""更新指定楼层的消息"""
|
||||
@@ -101,6 +113,7 @@ async def update_message(role_name: str, chat_name: str, floor: int, update_data
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/{role_name}/{chat_name}/messages/{floor}")
|
||||
async def delete_message(role_name: str, chat_name: str, floor: int):
|
||||
"""删除指定楼层的消息"""
|
||||
@@ -110,3 +123,56 @@ async def delete_message(role_name: str, chat_name: str, floor: int):
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/{role_name}/{chat_name}/table")
|
||||
async def update_table_data(role_name: str, chat_name: str, table_update: dict):
|
||||
"""更新表格数据(带时间戳冲突解决)"""
|
||||
try:
|
||||
return chat_service.update_table_data(role_name, chat_name, table_update)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{role_name}/{chat_name}/branch", status_code=status.HTTP_201_CREATED)
|
||||
async def branch_chat(role_name: str, chat_name: str, branch_data: dict):
|
||||
"""
|
||||
创建聊天分支
|
||||
|
||||
复制当前楼层及之前的所有内容到一个新的聊天记录
|
||||
|
||||
Args:
|
||||
role_name: 角色名称
|
||||
chat_name: 原聊天名称
|
||||
branch_data: {
|
||||
"target_floor": int, # 目标楼层(包含该楼层及之前的内容)
|
||||
"new_chat_name": str # 新聊天名称(可选,默认自动生成)
|
||||
}
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": bool,
|
||||
"new_chat_name": str,
|
||||
"message_count": int
|
||||
}
|
||||
"""
|
||||
try:
|
||||
target_floor = branch_data.get("target_floor")
|
||||
new_chat_name = branch_data.get("new_chat_name")
|
||||
|
||||
if target_floor is None:
|
||||
raise HTTPException(status_code=400, detail="缺少 target_floor 参数")
|
||||
|
||||
# 调用服务层创建分支
|
||||
result = chat_service.create_branch(role_name, chat_name, target_floor, new_chat_name)
|
||||
|
||||
return result
|
||||
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"创建分支失败: {str(e)}")
|
||||
|
||||
140
backend/api/routes/imageGalleryRoute.py
Normal file
140
backend/api/routes/imageGalleryRoute.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
图片画廊路由
|
||||
|
||||
提供图片查询、删除等管理接口
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from typing import Dict, Any, List, Optional
|
||||
import os
|
||||
|
||||
try:
|
||||
from backend.services.image_metadata_service import image_metadata_service
|
||||
except ImportError:
|
||||
from services.image_metadata_service import image_metadata_service
|
||||
|
||||
router = APIRouter(prefix="/image-gallery", tags=["image-gallery"])
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_gallery_stats():
|
||||
"""获取画廊统计信息"""
|
||||
return await image_metadata_service.get_gallery_stats()
|
||||
|
||||
|
||||
@router.get("/images/{chat_id}")
|
||||
async def get_chat_images(
|
||||
chat_id: str,
|
||||
floor: Optional[int] = None
|
||||
):
|
||||
"""
|
||||
获取指定聊天的图片列表
|
||||
|
||||
Args:
|
||||
chat_id: 聊天ID (role_name/chat_name)
|
||||
floor: 楼层号(可选)
|
||||
"""
|
||||
images = await image_metadata_service.get_images_by_chat(chat_id, floor)
|
||||
return {
|
||||
"chatId": chat_id,
|
||||
"totalImages": len(images),
|
||||
"images": [img.model_dump() for img in images]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/images/role/{role_name}")
|
||||
async def get_role_images(role_name: str):
|
||||
"""获取指定角色的所有图片"""
|
||||
images = await image_metadata_service.get_images_by_role(role_name)
|
||||
return {
|
||||
"roleName": role_name,
|
||||
"totalImages": len(images),
|
||||
"images": [img.model_dump() for img in images]
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/images/{chat_id}/{image_id}")
|
||||
async def delete_image(chat_id: str, image_id: str):
|
||||
"""
|
||||
删除图片(元数据和文件)
|
||||
|
||||
Args:
|
||||
chat_id: 聊天ID
|
||||
image_id: 图片ID
|
||||
"""
|
||||
# 先获取元数据以得到文件路径
|
||||
images = await image_metadata_service.get_images_by_chat(chat_id)
|
||||
target_image = None
|
||||
for img in images:
|
||||
if img.id == image_id:
|
||||
target_image = img
|
||||
break
|
||||
|
||||
if not target_image:
|
||||
raise HTTPException(status_code=404, detail="图片不存在")
|
||||
|
||||
# 删除元数据
|
||||
success = await image_metadata_service.delete_image(chat_id, image_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="删除失败")
|
||||
|
||||
# 删除实际文件
|
||||
try:
|
||||
file_path = image_metadata_service.get_image_full_path(target_image.filepath)
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
except Exception as e:
|
||||
print(f"[ImageGallery] 删除文件失败: {e}")
|
||||
# 不抛出异常,因为元数据已删除
|
||||
|
||||
return {"message": "图片已删除"}
|
||||
|
||||
|
||||
@router.post("/images/{chat_id}/clear")
|
||||
async def clear_chat_images(chat_id: str):
|
||||
"""
|
||||
清空指定聊天的所有图片
|
||||
|
||||
Args:
|
||||
chat_id: 聊天ID
|
||||
"""
|
||||
count = await image_metadata_service.clear_chat_images(chat_id)
|
||||
return {
|
||||
"message": f"已清空 {count} 张图片",
|
||||
"deletedCount": count
|
||||
}
|
||||
|
||||
|
||||
@router.post("/images/{chat_id}/{image_id}/set-current")
|
||||
async def set_current_swipe(chat_id: str, image_id: str):
|
||||
"""
|
||||
设置某张图片为当前显示的 swipe
|
||||
|
||||
Args:
|
||||
chat_id: 聊天ID
|
||||
image_id: 图片ID
|
||||
"""
|
||||
success = await image_metadata_service.set_current_swipe(chat_id, image_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="图片不存在")
|
||||
|
||||
return {"message": "已设置为当前显示"}
|
||||
|
||||
|
||||
@router.get("/image/{filepath:path}")
|
||||
async def get_image(filepath: str):
|
||||
"""
|
||||
获取图片文件
|
||||
|
||||
Args:
|
||||
filepath: 文件相对路径
|
||||
"""
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
file_path = image_metadata_service.get_image_full_path(filepath)
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="图片文件不存在")
|
||||
|
||||
return FileResponse(str(file_path))
|
||||
@@ -1,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))
|
||||
|
||||
387
backend/api/routes/regexRoute.py
Normal file
387
backend/api/routes/regexRoute.py
Normal file
@@ -0,0 +1,387 @@
|
||||
"""
|
||||
正则规则 API 路由
|
||||
|
||||
提供正则规则的 CRUD 操作和导入导出功能
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
import json
|
||||
import logging
|
||||
|
||||
from services.regex_service import regex_service
|
||||
from models.regex_rules import RegexRule, RegexRuleset, RegexScope
|
||||
from services.system_settings_service import system_settings_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/regex", tags=["regex"])
|
||||
|
||||
|
||||
# ==================== 数据模型 ====================
|
||||
|
||||
class RuleUpdateRequest(BaseModel):
|
||||
"""规则更新请求"""
|
||||
rule: RegexRule
|
||||
scope: RegexScope
|
||||
name: Optional[str] = None # 角色卡名称或预设名称(scope 为 CHARACTER/PRESET 时需要)
|
||||
|
||||
|
||||
class SystemSettingsUpdate(BaseModel):
|
||||
"""系统设置更新请求"""
|
||||
thinkingTagPrefix: Optional[str] = None
|
||||
thinkingTagSuffix: Optional[str] = None
|
||||
currentPresetName: Optional[str] = None
|
||||
|
||||
|
||||
# ==================== 规则查询 ====================
|
||||
|
||||
@router.get("/rules")
|
||||
async def get_rules(
|
||||
character_name: Optional[str] = None,
|
||||
preset_name: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
获取适用的正则规则列表
|
||||
|
||||
Args:
|
||||
character_name: 当前角色卡名称(可选)
|
||||
preset_name: 当前预设名称(可选)
|
||||
|
||||
Returns:
|
||||
规则列表
|
||||
"""
|
||||
try:
|
||||
rules = regex_service.get_rules_for_context(character_name, preset_name)
|
||||
return {
|
||||
"success": True,
|
||||
"rules": [rule.dict() for rule in rules],
|
||||
"count": len(rules)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"获取规则失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/rulesets/global")
|
||||
async def get_global_rulesets():
|
||||
"""获取所有全局规则集"""
|
||||
try:
|
||||
rulesets = list(regex_service.global_rulesets.values())
|
||||
return {
|
||||
"success": True,
|
||||
"rulesets": [rs.dict() for rs in rulesets]
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"获取全局规则集失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/rulesets/character/{character_name}")
|
||||
async def get_character_ruleset(character_name: str):
|
||||
"""获取指定角色卡的规则集"""
|
||||
try:
|
||||
if character_name in regex_service.character_rulesets:
|
||||
ruleset = regex_service.character_rulesets[character_name]
|
||||
return {
|
||||
"success": True,
|
||||
"ruleset": ruleset.dict()
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": True,
|
||||
"ruleset": None
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"获取角色规则集失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/rulesets/preset/{preset_name}")
|
||||
async def get_preset_ruleset(preset_name: str):
|
||||
"""获取指定预设的规则集"""
|
||||
try:
|
||||
if preset_name in regex_service.preset_rulesets:
|
||||
ruleset = regex_service.preset_rulesets[preset_name]
|
||||
return {
|
||||
"success": True,
|
||||
"ruleset": ruleset.dict()
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": True,
|
||||
"ruleset": None
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"获取预设规则集失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ==================== 规则管理 ====================
|
||||
|
||||
@router.post("/rules")
|
||||
async def add_rule(request: RuleUpdateRequest):
|
||||
"""添加或更新规则"""
|
||||
try:
|
||||
# 获取现有的规则集
|
||||
existing_ruleset = None
|
||||
if request.scope == RegexScope.GLOBAL:
|
||||
# 对于全局作用域,查找是否已有同名规则集
|
||||
for ruleset_id, ruleset in regex_service.global_rulesets.items():
|
||||
if ruleset.name == request.rule.scriptName:
|
||||
existing_ruleset = ruleset
|
||||
break
|
||||
elif request.scope == RegexScope.CHARACTER and request.name:
|
||||
if request.name in regex_service.character_rulesets:
|
||||
existing_ruleset = regex_service.character_rulesets[request.name]
|
||||
elif request.scope == RegexScope.PRESET and request.name:
|
||||
if request.name in regex_service.preset_rulesets:
|
||||
existing_ruleset = regex_service.preset_rulesets[request.name]
|
||||
|
||||
if existing_ruleset:
|
||||
# 如果已存在同名规则集,则更新其中的规则
|
||||
updated_rules = []
|
||||
rule_found = False
|
||||
for rule in existing_ruleset.rules:
|
||||
if rule.id == request.rule.id:
|
||||
# 更新现有规则
|
||||
updated_rules.append(request.rule)
|
||||
rule_found = True
|
||||
else:
|
||||
# 保留其他规则
|
||||
updated_rules.append(rule)
|
||||
|
||||
if not rule_found:
|
||||
# 如果没有找到相同ID的规则,则添加新规则
|
||||
updated_rules.append(request.rule)
|
||||
|
||||
# 更新规则集
|
||||
existing_ruleset.rules = updated_rules
|
||||
regex_service.save_ruleset(existing_ruleset, request.scope, request.name)
|
||||
else:
|
||||
# 如果不存在同名规则集,则创建新的规则集
|
||||
new_ruleset = RegexRuleset(
|
||||
id=request.rule.id,
|
||||
name=request.rule.scriptName,
|
||||
rules=[request.rule]
|
||||
)
|
||||
regex_service.save_ruleset(new_ruleset, request.scope, request.name)
|
||||
|
||||
# 重新加载规则
|
||||
regex_service._load_all_rules()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "规则保存成功"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"保存规则失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/rules/{rule_id}")
|
||||
async def delete_rule(rule_id: str, scope: str = "global", name: Optional[str] = None):
|
||||
"""
|
||||
删除规则
|
||||
|
||||
Args:
|
||||
rule_id: 规则ID
|
||||
scope: 作用域 (global/character/preset)
|
||||
name: 角色名或预设名(scope 为 character/preset 时需要)
|
||||
"""
|
||||
try:
|
||||
from models.regex_rules import RegexScope
|
||||
|
||||
scope_map = {
|
||||
"global": RegexScope.GLOBAL,
|
||||
"character": RegexScope.CHARACTER,
|
||||
"preset": RegexScope.PRESET
|
||||
}
|
||||
scope_enum = scope_map.get(scope, RegexScope.GLOBAL)
|
||||
|
||||
# 找到包含该规则的规则集
|
||||
if scope_enum == RegexScope.GLOBAL:
|
||||
rulesets = regex_service.global_rulesets
|
||||
elif scope_enum == RegexScope.CHARACTER:
|
||||
if not name:
|
||||
raise ValueError("删除角色规则需要提供角色名称")
|
||||
rulesets = {name: regex_service.character_rulesets.get(name)} if name in regex_service.character_rulesets else {}
|
||||
elif scope_enum == RegexScope.PRESET:
|
||||
if not name:
|
||||
raise ValueError("删除预设规则需要提供预设名称")
|
||||
rulesets = {name: regex_service.preset_rulesets.get(name)} if name in regex_service.preset_rulesets else {}
|
||||
|
||||
# 查找并删除规则
|
||||
deleted = False
|
||||
for ruleset_name, ruleset in rulesets.items():
|
||||
if not ruleset:
|
||||
continue
|
||||
|
||||
original_count = len(ruleset.rules)
|
||||
ruleset.rules = [r for r in ruleset.rules if r.id != rule_id]
|
||||
|
||||
if len(ruleset.rules) < original_count:
|
||||
# 保存更新后的规则集
|
||||
regex_service.save_ruleset(ruleset, scope_enum, ruleset_name if scope_enum != RegexScope.GLOBAL else None)
|
||||
deleted = True
|
||||
break
|
||||
|
||||
if not deleted:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "未找到指定的规则"
|
||||
}
|
||||
|
||||
# 重新加载规则
|
||||
regex_service._load_all_rules()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "规则已删除"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"删除规则失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ==================== 规则导入导出 ====================
|
||||
|
||||
@router.post("/import")
|
||||
async def import_rules(file: UploadFile = File(...)):
|
||||
"""
|
||||
导入正则规则(支持 SillyTavern 格式)- 文件上传方式
|
||||
|
||||
可以导入:
|
||||
1. 单个规则文件(JSON 数组)
|
||||
2. 规则集文件(JSON 对象)
|
||||
"""
|
||||
try:
|
||||
content = await file.read()
|
||||
data = json.loads(content.decode('utf-8'))
|
||||
|
||||
# 判断格式并导入
|
||||
if isinstance(data, list):
|
||||
# SillyTavern 格式 - 导入为全局规则
|
||||
ruleset = regex_service._convert_sillytavern_format(data, file.filename.replace('.json', ''))
|
||||
regex_service.save_ruleset(ruleset, RegexScope.GLOBAL)
|
||||
elif isinstance(data, dict):
|
||||
if 'rules' in data:
|
||||
# 规则集格式
|
||||
ruleset = RegexRuleset(**data)
|
||||
regex_service.save_ruleset(ruleset, RegexScope.GLOBAL)
|
||||
else:
|
||||
raise ValueError("未知的文件格式")
|
||||
else:
|
||||
raise ValueError("无效的文件格式")
|
||||
|
||||
# 重新加载规则
|
||||
regex_service._load_all_rules()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"成功导入规则集: {ruleset.name}"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"导入规则失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/import-from-preset")
|
||||
async def import_rules_from_preset(request: dict):
|
||||
"""
|
||||
从预设导入正则规则 - JSON 数据方式
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"rules": [...], // SillyTavern 格式的 regex_scripts 数组
|
||||
"scope": "preset", // 作用域:global/character/preset
|
||||
"presetName": "预设名称" // 当 scope 为 preset 时需要
|
||||
}
|
||||
"""
|
||||
try:
|
||||
rules_data = request.get("rules", [])
|
||||
scope_str = request.get("scope", "global")
|
||||
preset_name = request.get("presetName")
|
||||
|
||||
if not rules_data or not isinstance(rules_data, list):
|
||||
raise ValueError("无效的规则数据")
|
||||
|
||||
# 转换作用域字符串为枚举
|
||||
scope_map = {
|
||||
"global": RegexScope.GLOBAL,
|
||||
"character": RegexScope.CHARACTER,
|
||||
"preset": RegexScope.PRESET
|
||||
}
|
||||
scope = scope_map.get(scope_str, RegexScope.GLOBAL)
|
||||
|
||||
# 转换 SillyTavern 格式
|
||||
name = preset_name or "imported_rules"
|
||||
ruleset = regex_service._convert_sillytavern_format(rules_data, name, scope)
|
||||
|
||||
# 保存规则集
|
||||
regex_service.save_ruleset(ruleset, scope, name)
|
||||
|
||||
# 重新加载规则
|
||||
regex_service._load_all_rules()
|
||||
|
||||
logger.info(f"✅ 从预设导入 {len(rules_data)} 条正则规则到 {scope.value}: {name}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"成功导入 {len(rules_data)} 条正则规则",
|
||||
"rulesetId": ruleset.id
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"从预设导入规则失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/export/global")
|
||||
async def export_global_rules():
|
||||
"""导出所有全局规则"""
|
||||
try:
|
||||
all_rulesets = list(regex_service.global_rulesets.values())
|
||||
return {
|
||||
"success": True,
|
||||
"rulesets": [rs.dict() for rs in all_rulesets]
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"导出规则失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ==================== 系统设置 ====================
|
||||
|
||||
@router.get("/settings")
|
||||
async def get_system_settings():
|
||||
"""获取系统设置"""
|
||||
try:
|
||||
settings = system_settings_service.settings
|
||||
return {
|
||||
"success": True,
|
||||
"settings": settings.dict()
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"获取系统设置失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/settings")
|
||||
async def update_system_settings(request: SystemSettingsUpdate):
|
||||
"""更新系统设置"""
|
||||
try:
|
||||
if request.thinkingTagPrefix is not None or request.thinkingTagSuffix is not None:
|
||||
prefix = request.thinkingTagPrefix or system_settings_service.settings.thinkingTagPrefix
|
||||
suffix = request.thinkingTagSuffix or system_settings_service.settings.thinkingTagSuffix
|
||||
system_settings_service.update_thinking_tags(prefix, suffix)
|
||||
|
||||
if request.currentPresetName is not None:
|
||||
system_settings_service.update_current_preset(request.currentPresetName)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "系统设置已更新"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"更新系统设置失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
354
backend/api/routes/studioRoute.py
Normal file
354
backend/api/routes/studioRoute.py
Normal file
@@ -0,0 +1,354 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from models.studio_models import (
|
||||
AdvanceRunRequest,
|
||||
CreateStudioProjectRequest,
|
||||
PipelineDefinition,
|
||||
RenameRunRequest,
|
||||
RunMessageRequest,
|
||||
RunRerollRequest,
|
||||
StudioProject,
|
||||
StudioProjectSummary,
|
||||
StudioRun,
|
||||
StudioRunSummary,
|
||||
UpdateStudioProjectRequest,
|
||||
WorkflowTemplateSummary,
|
||||
WorkflowVariablesResponse,
|
||||
)
|
||||
from services.studio_project_service import studio_project_service
|
||||
from services.studio_run_service import studio_run_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/studio", tags=["studio"])
|
||||
|
||||
|
||||
@router.get("/projects", response_model=List[StudioProjectSummary])
|
||||
async def list_studio_projects():
|
||||
try:
|
||||
return studio_project_service.list_projects()
|
||||
except Exception as e:
|
||||
logger.error("Failed to list studio projects: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}", response_model=StudioProject)
|
||||
async def get_studio_project(project_id: str):
|
||||
try:
|
||||
return studio_project_service.get_project(project_id)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to get studio project %s: %s", project_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/projects/{project_id}", response_model=StudioProject)
|
||||
async def update_studio_project(project_id: str, req: UpdateStudioProjectRequest):
|
||||
try:
|
||||
return studio_project_service.update_project_meta(
|
||||
project_id,
|
||||
name=req.name,
|
||||
description=req.description,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to update studio project %s: %s", project_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/projects/{project_id}/pipeline", response_model=StudioProject)
|
||||
async def save_studio_pipeline(project_id: str, pipeline: PipelineDefinition):
|
||||
try:
|
||||
return studio_project_service.save_pipeline(project_id, pipeline)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to save pipeline for %s: %s", project_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/templates", response_model=List[WorkflowTemplateSummary])
|
||||
async def list_workflow_templates():
|
||||
try:
|
||||
return studio_project_service.list_workflow_templates()
|
||||
except Exception as e:
|
||||
logger.error("Failed to list workflow templates: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/variables", response_model=WorkflowVariablesResponse)
|
||||
async def get_workflow_variables(projectId: str | None = None):
|
||||
try:
|
||||
return studio_project_service.get_workflow_variables(projectId)
|
||||
except Exception as e:
|
||||
logger.error("Failed to load workflow variables: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/skill-templates")
|
||||
async def get_skill_templates() -> Dict[str, Any]:
|
||||
try:
|
||||
return studio_project_service.get_skill_templates()
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to load skill templates: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/niches")
|
||||
async def get_niches() -> Dict[str, Any]:
|
||||
try:
|
||||
return studio_project_service.get_niches()
|
||||
except Exception as e:
|
||||
logger.error("Failed to load niches: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/projects/{project_id}")
|
||||
async def delete_studio_project(project_id: str):
|
||||
try:
|
||||
studio_project_service.delete_project(project_id)
|
||||
return {"ok": True, "id": project_id}
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete studio project %s: %s", project_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/projects", response_model=StudioProject)
|
||||
async def create_studio_project(req: CreateStudioProjectRequest):
|
||||
try:
|
||||
return studio_project_service.create_project(req)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to create studio project: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/runs", response_model=StudioRun)
|
||||
async def create_studio_run(project_id: str):
|
||||
try:
|
||||
return studio_run_service.create_run(project_id)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to create studio run for %s: %s", project_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/runs", response_model=List[StudioRunSummary])
|
||||
async def list_studio_runs(project_id: str):
|
||||
try:
|
||||
studio_project_service.get_project(project_id)
|
||||
return studio_run_service.list_runs(project_id)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to list studio runs for %s: %s", project_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/runs/{run_id}", response_model=StudioRun)
|
||||
async def get_studio_run(project_id: str, run_id: str):
|
||||
try:
|
||||
return studio_run_service.get_run(project_id, run_id)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Failed to get studio run %s/%s: %s", project_id, run_id, e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/runs/{run_id}/advance", response_model=StudioRun)
|
||||
async def advance_studio_run(
|
||||
project_id: str, run_id: str, req: AdvanceRunRequest
|
||||
):
|
||||
try:
|
||||
return studio_run_service.advance_run(
|
||||
project_id, run_id, display_params=req.displayParams
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except NotImplementedError as e:
|
||||
raise HTTPException(status_code=501, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to advance studio run %s/%s: %s", project_id, run_id, e
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/runs/{run_id}/message")
|
||||
async def send_studio_run_message(
|
||||
project_id: str, run_id: str, req: RunMessageRequest
|
||||
):
|
||||
if req.stream:
|
||||
async def ndjson_stream():
|
||||
try:
|
||||
async for event in studio_run_service.send_run_message_stream(
|
||||
project_id,
|
||||
run_id,
|
||||
req.content,
|
||||
profile_id=req.profileId,
|
||||
api_config=req.apiConfig,
|
||||
):
|
||||
yield json.dumps(event, ensure_ascii=False) + "\n"
|
||||
except FileNotFoundError as e:
|
||||
yield json.dumps(
|
||||
{"type": "error", "detail": str(e)}, ensure_ascii=False
|
||||
) + "\n"
|
||||
except ValueError as e:
|
||||
yield json.dumps(
|
||||
{"type": "error", "detail": str(e)}, ensure_ascii=False
|
||||
) + "\n"
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to stream studio run message %s/%s: %s",
|
||||
project_id,
|
||||
run_id,
|
||||
e,
|
||||
)
|
||||
yield json.dumps(
|
||||
{"type": "error", "detail": f"消息处理失败:{e}"},
|
||||
ensure_ascii=False,
|
||||
) + "\n"
|
||||
|
||||
return StreamingResponse(
|
||||
ndjson_stream(),
|
||||
media_type="application/x-ndjson",
|
||||
)
|
||||
|
||||
try:
|
||||
return await studio_run_service.send_run_message(
|
||||
project_id,
|
||||
run_id,
|
||||
req.content,
|
||||
stream=req.stream,
|
||||
profile_id=req.profileId,
|
||||
api_config=req.apiConfig,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to send studio run message %s/%s: %s", project_id, run_id, e
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=f"消息处理失败:{e}")
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/runs/{run_id}/undo", response_model=StudioRun)
|
||||
async def undo_studio_run(project_id: str, run_id: str):
|
||||
try:
|
||||
return studio_run_service.undo_run(project_id, run_id)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to undo studio run %s/%s: %s", project_id, run_id, e
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=f"回退失败:{e}")
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/runs/{run_id}/reroll")
|
||||
async def reroll_studio_run(
|
||||
project_id: str, run_id: str, req: RunRerollRequest
|
||||
):
|
||||
if req.stream:
|
||||
async def ndjson_stream():
|
||||
try:
|
||||
async for event in studio_run_service.reroll_run_stream(
|
||||
project_id,
|
||||
run_id,
|
||||
profile_id=req.profileId,
|
||||
api_config=req.apiConfig,
|
||||
):
|
||||
yield json.dumps(event, ensure_ascii=False) + "\n"
|
||||
except FileNotFoundError as e:
|
||||
yield json.dumps(
|
||||
{"type": "error", "detail": str(e)}, ensure_ascii=False
|
||||
) + "\n"
|
||||
except ValueError as e:
|
||||
yield json.dumps(
|
||||
{"type": "error", "detail": str(e)}, ensure_ascii=False
|
||||
) + "\n"
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to stream studio run reroll %s/%s: %s",
|
||||
project_id,
|
||||
run_id,
|
||||
e,
|
||||
)
|
||||
yield json.dumps(
|
||||
{"type": "error", "detail": f"重 roll 失败:{e}"},
|
||||
ensure_ascii=False,
|
||||
) + "\n"
|
||||
|
||||
return StreamingResponse(
|
||||
ndjson_stream(),
|
||||
media_type="application/x-ndjson",
|
||||
)
|
||||
|
||||
try:
|
||||
return await studio_run_service.reroll_run(
|
||||
project_id,
|
||||
run_id,
|
||||
stream=req.stream,
|
||||
profile_id=req.profileId,
|
||||
api_config=req.apiConfig,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to reroll studio run %s/%s: %s", project_id, run_id, e
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=f"重 roll 失败:{e}")
|
||||
|
||||
|
||||
@router.delete("/projects/{project_id}/runs/{run_id}")
|
||||
async def delete_studio_run(project_id: str, run_id: str):
|
||||
try:
|
||||
studio_run_service.delete_run(project_id, run_id)
|
||||
return {"ok": True, "id": run_id}
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to delete studio run %s/%s: %s", project_id, run_id, e
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/projects/{project_id}/runs/{run_id}", response_model=StudioRun)
|
||||
async def rename_studio_run(
|
||||
project_id: str, run_id: str, req: RenameRunRequest
|
||||
):
|
||||
try:
|
||||
return studio_run_service.rename_run(project_id, run_id, req.title)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to rename studio run %s/%s: %s", project_id, run_id, e
|
||||
)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
130
backend/api/routes/tokenUsageRoute.py
Normal file
130
backend/api/routes/tokenUsageRoute.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
Token 使用统计路由
|
||||
|
||||
提供 token 使用情况的查询接口
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
try:
|
||||
from backend.services.token_usage_service import token_usage_service
|
||||
except ImportError:
|
||||
from services.token_usage_service import token_usage_service
|
||||
|
||||
router = APIRouter(prefix="/token-usage", tags=["token-usage"])
|
||||
|
||||
|
||||
@router.get("/months")
|
||||
async def list_months():
|
||||
"""列出所有有数据的月份"""
|
||||
return await token_usage_service.list_months()
|
||||
|
||||
|
||||
@router.get("/stats/{year}/{month}")
|
||||
async def get_monthly_stats(
|
||||
year: int,
|
||||
month: int,
|
||||
role_name: Optional[str] = None,
|
||||
chat_name: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
获取指定月份的统计数据
|
||||
|
||||
Args:
|
||||
year: 年份
|
||||
month: 月份
|
||||
role_name: 角色名称(可选)
|
||||
chat_name: 聊天名称(可选)
|
||||
"""
|
||||
if month < 1 or month > 12:
|
||||
raise HTTPException(status_code=400, detail="月份必须在 1-12 之间")
|
||||
|
||||
try:
|
||||
stats = await token_usage_service.get_stats_by_month(
|
||||
year=year,
|
||||
month=month,
|
||||
role_name=role_name,
|
||||
chat_name=chat_name
|
||||
)
|
||||
return stats
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"获取统计数据失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/api-urls")
|
||||
async def get_api_url_stats():
|
||||
"""
|
||||
✅ 获取按 API URL 分组的统计数据(快速查询)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"api_url_1": {
|
||||
"totalPromptTokens": 1000,
|
||||
"totalCompletionTokens": 2000,
|
||||
"totalTokens": 3000,
|
||||
"count": 10,
|
||||
"firstUsed": 1234567890,
|
||||
"lastUsed": 1234567899
|
||||
},
|
||||
...
|
||||
}
|
||||
"""
|
||||
try:
|
||||
stats = await token_usage_service.get_api_url_stats()
|
||||
return stats
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"获取 API URL 统计失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/daily/{year}/{month}")
|
||||
async def get_daily_stats(year: int, month: int):
|
||||
"""
|
||||
✅ 获取指定月份的每日统计数据(快速查询)
|
||||
|
||||
Args:
|
||||
year: 年份
|
||||
month: 月份
|
||||
|
||||
Returns:
|
||||
{
|
||||
"2024-01-01": {
|
||||
"promptTokens": 1000,
|
||||
"completionTokens": 2000,
|
||||
"totalTokens": 3000,
|
||||
"count": 10
|
||||
},
|
||||
...
|
||||
}
|
||||
"""
|
||||
if month < 1 or month > 12:
|
||||
raise HTTPException(status_code=400, detail="月份必须在 1-12 之间")
|
||||
|
||||
try:
|
||||
stats = await token_usage_service.get_daily_stats(year, month)
|
||||
return stats
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"获取每日统计失败: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/roles/{year}/{month}")
|
||||
async def get_available_roles(year: int, month: int):
|
||||
"""获取指定月份有数据的角色列表"""
|
||||
if month < 1 or month > 12:
|
||||
raise HTTPException(status_code=400, detail="月份必须在 1-12 之间")
|
||||
|
||||
roles = await token_usage_service.get_available_roles(year, month)
|
||||
return {"roles": roles}
|
||||
|
||||
|
||||
@router.get("/chats/{year}/{month}")
|
||||
async def get_available_chats(
|
||||
year: int,
|
||||
month: int,
|
||||
role_name: Optional[str] = None
|
||||
):
|
||||
"""获取指定月份有数据的聊天列表"""
|
||||
if month < 1 or month > 12:
|
||||
raise HTTPException(status_code=400, detail="月份必须在 1-12 之间")
|
||||
|
||||
chats = await token_usage_service.get_available_chats(year, month, role_name)
|
||||
return {"chats": chats}
|
||||
@@ -38,6 +38,80 @@ async def list_worldbooks():
|
||||
logger.error(f"Failed to list worldbooks: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# 注意:路由定义顺序很重要!更具体的路由(更多参数)必须放在前面
|
||||
@router.get("/{name}/entries/{uid}", response_model=Dict[str, Any])
|
||||
async def get_worldbook_entry(name: str, uid: str):
|
||||
"""
|
||||
获取世界书的指定条目
|
||||
"""
|
||||
try:
|
||||
return worldbook_service.get_entry(name, uid)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get entry '{uid}' from worldbook '{name}': {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/{name}/entries", response_model=Dict[str, Any])
|
||||
async def list_worldbook_entries(
|
||||
name: str,
|
||||
page: int = 1,
|
||||
page_size: int = 20
|
||||
):
|
||||
"""
|
||||
获取世界书的条目列表(支持分页)
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
page: 页码,从1开始
|
||||
page_size: 每页数量,默认20
|
||||
"""
|
||||
try:
|
||||
return worldbook_service.list_entries(name, page, page_size)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list entries for worldbook '{name}': {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/{name}/export")
|
||||
async def export_worldbook(name: str, format: str = "internal"):
|
||||
"""
|
||||
导出世界书(支持 internal 和 sillytavern 两种格式)
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
format: 导出格式 ('internal' 或 'sillytavern'),默认 internal
|
||||
"""
|
||||
try:
|
||||
if format.lower() == "sillytavern":
|
||||
# 导出为 SillyTavern 格式(可能丢失特殊设置)
|
||||
logger.info(f"导出世界书 '{name}' 为 SillyTavern 格式")
|
||||
st_data = worldbook_service.export_to_sillytavern(name)
|
||||
|
||||
return JSONResponse(
|
||||
content=st_data,
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={name}_sillytavern.json"
|
||||
}
|
||||
)
|
||||
else:
|
||||
# 导出为内部格式(保留所有设置)
|
||||
logger.info(f"导出世界书 '{name}' 为内部格式")
|
||||
internal_data = worldbook_service.get_worldbook(name)
|
||||
|
||||
return JSONResponse(
|
||||
content=internal_data,
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={name}.json"
|
||||
}
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to export worldbook '{name}': {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/{name}", response_model=Dict[str, Any])
|
||||
async def get_worldbook(name: str):
|
||||
"""
|
||||
@@ -213,41 +287,3 @@ async def import_worldbook(name: str, file: UploadFile = File(...)):
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to import worldbook '{name}': {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/{name}/export")
|
||||
async def export_worldbook(name: str, format: str = "internal"):
|
||||
"""
|
||||
导出世界书(支持 internal 和 sillytavern 两种格式)
|
||||
|
||||
Args:
|
||||
name: 世界书名称
|
||||
format: 导出格式 ('internal' 或 'sillytavern'),默认 internal
|
||||
"""
|
||||
try:
|
||||
if format.lower() == "sillytavern":
|
||||
# 导出为 SillyTavern 格式(可能丢失特殊设置)
|
||||
logger.info(f"导出世界书 '{name}' 为 SillyTavern 格式")
|
||||
st_data = worldbook_service.export_to_sillytavern(name)
|
||||
|
||||
return JSONResponse(
|
||||
content=st_data,
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={name}_sillytavern.json"
|
||||
}
|
||||
)
|
||||
else:
|
||||
# 导出为内部格式(保留所有设置)
|
||||
logger.info(f"导出世界书 '{name}' 为内部格式")
|
||||
internal_data = worldbook_service.get_worldbook(name)
|
||||
|
||||
return JSONResponse(
|
||||
content=internal_data,
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={name}.json"
|
||||
}
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to export worldbook '{name}': {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -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,8 +43,11 @@ class Settings:
|
||||
# 预设目录
|
||||
PRESET_PATH = DATA_PATH / "preset"
|
||||
|
||||
# 聊天记录目录
|
||||
# 聊天记录目录(同时存放角色卡和聊天)
|
||||
CHAT_PATH = DATA_PATH / "chat"
|
||||
|
||||
# 兼容别名:用于代码中引用
|
||||
CHATS_PATH = CHAT_PATH
|
||||
|
||||
# 临时文件目录
|
||||
TEMP_PATH = DATA_PATH / "temp"
|
||||
@@ -55,12 +55,21 @@ class Settings:
|
||||
# ComfyUI 工作流目录
|
||||
COMFYUI_WORKFLOWS_PATH = DATA_PATH / "comfyui_workflows"
|
||||
|
||||
# 角色卡目录
|
||||
CHARACTERS_PATH = DATA_PATH / "characters"
|
||||
# 角色卡目录(已合并到 CHAT_PATH)
|
||||
CHARACTERS_PATH = CHAT_PATH
|
||||
|
||||
# 图片资源目录
|
||||
IMAGES_PATH = DATA_PATH / "images"
|
||||
|
||||
# Agent 工作流模板与运行记录
|
||||
AGENT_TEMPLATES_PATH = DATA_PATH / "agent" / "templates"
|
||||
AGENT_RUNS_PATH = DATA_PATH / "agent" / "runs"
|
||||
AGENT_STUDIO_PROJECTS_PATH = DATA_PATH / "agent" / "studio_projects"
|
||||
AGENT_STUDIO_RUNS_PATH = DATA_PATH / "agent" / "studio_runs"
|
||||
AGENT_SKILL_TEMPLATES_FILE = DATA_PATH / "agent" / "skill_templates.json"
|
||||
AGENT_NICHES_FILE = DATA_PATH / "agent" / "niches.json"
|
||||
AGENT_WORKFLOW_VARIABLES_FILE = DATA_PATH / "agent" / "workflow_variables.json"
|
||||
|
||||
def ensure_directories(self):
|
||||
"""确保所有配置的目录存在,如果不存在则创建"""
|
||||
directories = [
|
||||
@@ -72,9 +81,17 @@ class Settings:
|
||||
self.COMFYUI_WORKFLOWS_PATH,
|
||||
self.CHARACTERS_PATH,
|
||||
self.IMAGES_PATH,
|
||||
self.AGENT_TEMPLATES_PATH,
|
||||
self.AGENT_RUNS_PATH,
|
||||
self.AGENT_STUDIO_PROJECTS_PATH,
|
||||
self.AGENT_STUDIO_RUNS_PATH,
|
||||
]
|
||||
for directory in directories:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 确保核心数据文件的父目录存在
|
||||
for file_path in [self.STATE_FILE, self.SCHEMA_FILE, self.PRESETS_FILE, self.REGEX_FILE, self.SYSTEM_SETTINGS_FILE]:
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
128
backend/models/agent.py
Normal file
128
backend/models/agent.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
Agent workflow engine data models.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class WorkflowTemplateKind(str, Enum):
|
||||
BUILTIN_CHAT = "builtin.chat"
|
||||
|
||||
|
||||
class RunStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class RunEventType(str, Enum):
|
||||
STATE_ENTER = "state_enter"
|
||||
TOOL_START = "tool_start"
|
||||
TOOL_END = "tool_end"
|
||||
WORLD_BOOK_ACTIVE = "worldbook_active"
|
||||
TASKS_CREATED = "tasks_created"
|
||||
CHUNK = "chunk"
|
||||
ERROR = "error"
|
||||
COMPLETE = "complete"
|
||||
|
||||
|
||||
class ToolSpec(BaseModel):
|
||||
name: str
|
||||
description: str = ""
|
||||
parameters: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SkillManifest(BaseModel):
|
||||
id: str
|
||||
name: str = ""
|
||||
description: str = ""
|
||||
path: str = ""
|
||||
|
||||
|
||||
class WorkflowTemplate(BaseModel):
|
||||
id: str
|
||||
kind: WorkflowTemplateKind
|
||||
name: str = ""
|
||||
description: str = ""
|
||||
version: str = "1.0.0"
|
||||
state_machine_path: str = "state_machine.json"
|
||||
skills: List[SkillManifest] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ChatRunBinding(BaseModel):
|
||||
role_name: str
|
||||
chat_name: str
|
||||
template_id: str = WorkflowTemplateKind.BUILTIN_CHAT.value
|
||||
|
||||
|
||||
class TurnCallbacks(BaseModel):
|
||||
"""Optional async callbacks for streaming / WS events."""
|
||||
|
||||
model_config = {"arbitrary_types_allowed": True}
|
||||
|
||||
on_worldbook_active: Optional[Callable[[List[Any]], Awaitable[None]]] = None
|
||||
on_tasks_created: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None
|
||||
on_chunk: Optional[Callable[[str], Awaitable[None]]] = None
|
||||
|
||||
|
||||
class TurnContext(BaseModel):
|
||||
"""Mutable per-turn execution context passed between tools."""
|
||||
|
||||
model_config = {"arbitrary_types_allowed": True}
|
||||
|
||||
request_data: Dict[str, Any] = Field(default_factory=dict)
|
||||
template_id: str = WorkflowTemplateKind.BUILTIN_CHAT.value
|
||||
run_id: str = ""
|
||||
stream: bool = False
|
||||
callbacks: Optional[TurnCallbacks] = None
|
||||
|
||||
current_role: str = ""
|
||||
current_chat: str = ""
|
||||
user_message: str = ""
|
||||
preset_name: Optional[str] = None
|
||||
character: Any = None
|
||||
active_entries: List[Any] = Field(default_factory=list)
|
||||
chat_history: List[Any] = Field(default_factory=list)
|
||||
prompt_messages: List[Any] = Field(default_factory=list)
|
||||
generated_content: str = ""
|
||||
token_usage: Dict[str, Any] = Field(default_factory=dict)
|
||||
duration: float = 0.0
|
||||
task_ids: Dict[str, Optional[str]] = Field(default_factory=dict)
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class WorkflowRun(BaseModel):
|
||||
id: str
|
||||
template_id: str
|
||||
binding: ChatRunBinding
|
||||
status: RunStatus = RunStatus.PENDING
|
||||
started_at: str = Field(default_factory=lambda: datetime.now().isoformat())
|
||||
finished_at: Optional[str] = None
|
||||
current_state: Optional[str] = None
|
||||
result_content: str = ""
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class RunEvent(BaseModel):
|
||||
run_id: str
|
||||
type: RunEventType
|
||||
timestamp: str = Field(default_factory=lambda: datetime.now().isoformat())
|
||||
state: Optional[str] = None
|
||||
tool: Optional[str] = None
|
||||
payload: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ChatTurnResult(BaseModel):
|
||||
success: bool
|
||||
content: str = ""
|
||||
error: Optional[str] = None
|
||||
active_entries: List[Any] = Field(default_factory=list)
|
||||
task_ids: Dict[str, Optional[str]] = Field(default_factory=dict)
|
||||
run_id: str = ""
|
||||
workflow_template_id: str = WorkflowTemplateKind.BUILTIN_CHAT.value
|
||||
@@ -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="创建时间戳")
|
||||
|
||||
164
backend/models/regex_rules.py
Normal file
164
backend/models/regex_rules.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
正则替换规则模型
|
||||
|
||||
兼容 SillyTavern 的正则系统,支持全局、角色卡、预设三种作用域。
|
||||
"""
|
||||
from enum import Enum
|
||||
from typing import List, Optional, Dict, Any
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class RegexPlacement(int, Enum):
|
||||
"""
|
||||
正则应用位置(对应 SillyTavern 的 placement 数组)
|
||||
|
||||
0: System Prompt - 系统提示词
|
||||
1: User Input - 用户输入
|
||||
2: AI Output - AI 输出
|
||||
3: Quick Reply - 快捷回复
|
||||
4: World Info - 世界书信息
|
||||
5: Reasoning/Thinking - 推理/思考内容
|
||||
"""
|
||||
SYSTEM_PROMPT = 0
|
||||
USER_INPUT = 1
|
||||
AI_OUTPUT = 2
|
||||
QUICK_REPLY = 3
|
||||
WORLD_INFO = 4
|
||||
REASONING = 5
|
||||
|
||||
|
||||
class RegexScope(str, Enum):
|
||||
"""
|
||||
正则规则作用域
|
||||
|
||||
- GLOBAL: 全局生效,对所有聊天应用
|
||||
- CHARACTER: 绑定到特定角色卡
|
||||
- PRESET: 绑定到特定预设
|
||||
"""
|
||||
GLOBAL = 'global'
|
||||
CHARACTER = 'character'
|
||||
PRESET = 'preset'
|
||||
|
||||
|
||||
class SubstituteMode(int, Enum):
|
||||
"""
|
||||
替换模式
|
||||
|
||||
对应 SillyTavern 的 substituteRegex 字段
|
||||
"""
|
||||
REPLACE_ALL = 0 # 替换所有匹配
|
||||
REPLACE_FIRST = 1 # 仅替换首次匹配
|
||||
REPLACE_CAPTURED = 2 # 替换捕获组
|
||||
|
||||
|
||||
class RegexRule(BaseModel):
|
||||
"""
|
||||
单条正则替换规则
|
||||
|
||||
完全兼容 SillyTavern 的正则规则格式
|
||||
"""
|
||||
id: str = Field(..., description="规则唯一标识符 (UUID)")
|
||||
scriptName: str = Field(..., description="脚本名称(用于显示)")
|
||||
|
||||
# 核心正则配置
|
||||
findRegex: str = Field(..., description="查找正则表达式(如:/<thinking>[\\s\\S]*?<\\/thinking>/gi)")
|
||||
replaceString: str = Field("", description="替换字符串(支持捕获组引用 $1, $2 等)")
|
||||
trimStrings: List[str] = Field(default_factory=list, description="要额外修剪的字符串数组")
|
||||
|
||||
# 应用位置(关键!对应 SillyTavern 的 placement 数组)
|
||||
placement: List[RegexPlacement] = Field(
|
||||
default_factory=lambda: [RegexPlacement.AI_OUTPUT],
|
||||
description="应用位置数组:0=系统提示词, 1=用户输入, 2=AI输出, 3=快捷回复, 4=世界书, 5=推理内容"
|
||||
)
|
||||
|
||||
# 替换模式
|
||||
substituteRegex: SubstituteMode = Field(
|
||||
SubstituteMode.REPLACE_ALL,
|
||||
description="替换模式:0=全部,1=首次,2=捕获组"
|
||||
)
|
||||
|
||||
# 作用范围控制
|
||||
markdownOnly: bool = Field(False, description="是否仅应用于 Markdown 渲染后的内容")
|
||||
promptOnly: bool = Field(False, description="是否仅应用于发送给 LLM 的提示词")
|
||||
runOnEdit: bool = Field(True, description="用户编辑消息时是否重新应用")
|
||||
|
||||
# 消息深度控制
|
||||
minDepth: Optional[int] = Field(None, ge=0, description="最小消息深度(从最新消息开始计数,None 表示无限制)")
|
||||
maxDepth: Optional[int] = Field(None, ge=0, description="最大消息深度(None 表示无限制)")
|
||||
|
||||
# 作用域配置
|
||||
scope: RegexScope = Field(RegexScope.GLOBAL, description="规则作用域")
|
||||
characterName: Optional[str] = Field(None, description="绑定的角色卡名称(scope=CHARACTER 时使用)")
|
||||
presetName: Optional[str] = Field(None, description="绑定的预设名称(scope=PRESET 时使用)")
|
||||
|
||||
# 启用状态
|
||||
disabled: bool = Field(False, description="是否禁用此规则(与 enabled 相反,为了兼容 ST)")
|
||||
|
||||
# 执行顺序
|
||||
order: int = Field(0, description="执行顺序(数值越小越先执行)")
|
||||
|
||||
# 元数据
|
||||
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
|
||||
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
|
||||
description: Optional[str] = Field(None, description="规则描述(可选)")
|
||||
|
||||
|
||||
class RegexRuleset(BaseModel):
|
||||
"""
|
||||
正则规则集
|
||||
|
||||
一组正则规则的集合,可以整体导入/导出,兼容 SillyTavern 格式
|
||||
"""
|
||||
id: str = Field(..., description="规则集唯一标识符 (UUID)")
|
||||
name: str = Field(..., description="规则集名称")
|
||||
description: Optional[str] = Field(None, description="规则集描述")
|
||||
|
||||
rules: List[RegexRule] = Field(default_factory=list, description="规则列表")
|
||||
|
||||
# 元数据
|
||||
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
|
||||
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
|
||||
version: int = Field(1, description="版本号(用于数据迁移)")
|
||||
|
||||
# SillyTavern 兼容性标记
|
||||
isSillyTavernFormat: bool = Field(False, description="是否为 SillyTavern 导入格式")
|
||||
|
||||
|
||||
# ==================== 使用示例 ====================
|
||||
|
||||
if __name__ == '__main__':
|
||||
import json
|
||||
|
||||
# 创建一条规则
|
||||
rule = RegexRule(
|
||||
id="example-hide-thinking-001",
|
||||
scriptName="隐藏思考标签",
|
||||
findRegex=r"<thinking>[\s\S]*?<\/thinking>",
|
||||
replaceString="",
|
||||
trimStrings=[],
|
||||
placement=[RegexPlacement.AI_OUTPUT],
|
||||
substituteRegex=SubstituteMode.REPLACE_ALL,
|
||||
markdownOnly=False,
|
||||
promptOnly=False,
|
||||
runOnEdit=True,
|
||||
minDepth=0,
|
||||
maxDepth=None,
|
||||
scope=RegexScope.GLOBAL,
|
||||
characterName=None,
|
||||
presetName=None,
|
||||
disabled=False,
|
||||
order=1,
|
||||
description="隐藏 AI 回复中的 <thinking> 标签及其内容"
|
||||
)
|
||||
|
||||
# 创建规则集
|
||||
ruleset = RegexRuleset(
|
||||
id="ruleset-001",
|
||||
name="默认正则规则集",
|
||||
description="包含常用的文本处理规则",
|
||||
rules=[rule]
|
||||
)
|
||||
|
||||
# 导出为 JSON(兼容 SillyTavern)
|
||||
print(json.dumps(ruleset.dict(), indent=2, ensure_ascii=False))
|
||||
249
backend/models/studio_models.py
Normal file
249
backend/models/studio_models.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""
|
||||
Studio workflow editor data models.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class DisplayParam(BaseModel):
|
||||
key: str
|
||||
label: str
|
||||
type: str = "text"
|
||||
required: bool = True
|
||||
placeholder: str = ""
|
||||
|
||||
|
||||
class InputRef(BaseModel):
|
||||
ref: str
|
||||
label: Optional[str] = None
|
||||
optional: bool = False
|
||||
|
||||
|
||||
class ScoringDimension(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
criteria: str = ""
|
||||
|
||||
|
||||
class InsertionRagConfig(BaseModel):
|
||||
libraryId: str = ""
|
||||
threshold: float = 0.5
|
||||
maxEntries: int = 3
|
||||
|
||||
|
||||
class InsertionConfig(BaseModel):
|
||||
position: int = 1
|
||||
activationType: str = "permanent"
|
||||
key: str = ""
|
||||
keysecondary: str = ""
|
||||
comment: str = ""
|
||||
ragConfig: Optional[InsertionRagConfig] = None
|
||||
|
||||
|
||||
class ScoringConfig(BaseModel):
|
||||
enabled: bool = True
|
||||
dimensions: List[ScoringDimension] = Field(default_factory=list)
|
||||
rubric: Optional[str] = None
|
||||
|
||||
|
||||
class StudioNode(BaseModel):
|
||||
id: str
|
||||
skillId: str
|
||||
displayName: str
|
||||
enabled: bool = True
|
||||
niche: Optional[str] = None
|
||||
loopUntilSatisfied: bool = False
|
||||
config: Dict[str, Any] = Field(default_factory=dict)
|
||||
displayParams: List[DisplayParam] = Field(default_factory=list)
|
||||
inputs: List[InputRef] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PipelineDefinition(BaseModel):
|
||||
workflowGoal: str = ""
|
||||
nodes: List[StudioNode] = Field(default_factory=list)
|
||||
|
||||
|
||||
class StudioProjectMeta(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str = ""
|
||||
templateId: Optional[str] = None
|
||||
characterId: Optional[str] = None
|
||||
worldbookId: Optional[str] = None
|
||||
createdAt: str = ""
|
||||
updatedAt: str = ""
|
||||
|
||||
|
||||
class StudioProject(BaseModel):
|
||||
meta: StudioProjectMeta
|
||||
pipeline: PipelineDefinition
|
||||
|
||||
|
||||
class ArtifactDef(BaseModel):
|
||||
type: str
|
||||
displayName: str = ""
|
||||
|
||||
|
||||
class SkillTemplateDef(BaseModel):
|
||||
skillId: str
|
||||
displayName: str
|
||||
description: str = ""
|
||||
displayParams: List[DisplayParam] = Field(default_factory=list)
|
||||
configWhitelist: List[str] = Field(default_factory=list)
|
||||
artifacts: List[ArtifactDef] = Field(default_factory=list)
|
||||
supportsLoopUntilSatisfied: bool = False
|
||||
supportsInputs: bool = False
|
||||
supportsInsertion: bool = False
|
||||
supportsScoring: bool = False
|
||||
|
||||
|
||||
class WorkflowTemplateSummary(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str = ""
|
||||
|
||||
|
||||
class WorkflowVariableDef(BaseModel):
|
||||
ref: str
|
||||
label: str
|
||||
description: str = ""
|
||||
|
||||
|
||||
class DynamicVariableSuffix(BaseModel):
|
||||
suffix: str
|
||||
labelPattern: str
|
||||
|
||||
|
||||
class WorkflowVariablesResponse(BaseModel):
|
||||
builtIn: List[WorkflowVariableDef] = Field(default_factory=list)
|
||||
dynamic: List[WorkflowVariableDef] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SkillTemplatesCatalog(BaseModel):
|
||||
templates: List[SkillTemplateDef] = Field(default_factory=list)
|
||||
|
||||
|
||||
class StudioProjectSummary(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str = ""
|
||||
updatedAt: str = ""
|
||||
|
||||
|
||||
class CreateStudioProjectRequest(BaseModel):
|
||||
name: str = "新项目"
|
||||
template_id: str = "builtin.studio.example"
|
||||
project_id: Optional[str] = None
|
||||
|
||||
|
||||
class UpdateStudioProjectRequest(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=120)
|
||||
description: Optional[str] = Field(None, max_length=500)
|
||||
|
||||
|
||||
class StudioRunStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
RUNNING = "running"
|
||||
PAUSED = "paused"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class ToolQuestionOption(BaseModel):
|
||||
question: str
|
||||
options: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class StepMessage(BaseModel):
|
||||
"""Short step-scoped dialogue (not full chat history)."""
|
||||
id: str
|
||||
role: str # user | assistant
|
||||
content: str
|
||||
createdAt: Optional[str] = None
|
||||
|
||||
|
||||
class LastToolResponse(BaseModel):
|
||||
"""LLM tool-call payload surfaced to the run UI (R2+)."""
|
||||
thinking: Optional[str] = None
|
||||
evaluation: Optional[str] = None
|
||||
questions: List[ToolQuestionOption] = Field(default_factory=list)
|
||||
generatedAt: Optional[str] = None
|
||||
|
||||
|
||||
class PromptBlock(BaseModel):
|
||||
"""Single assembled context section for LLM prompt (R2 debug / execution)."""
|
||||
id: str
|
||||
label: str
|
||||
content: str
|
||||
source: str = "auto" # auto | manual | workflow
|
||||
|
||||
|
||||
class TurnSnapshot(BaseModel):
|
||||
"""State captured before each LLM turn (for undo)."""
|
||||
lastDraft: Optional[Dict[str, Any]] = None
|
||||
lastToolResponse: Optional[LastToolResponse] = None
|
||||
stepMessages: List[StepMessage] = Field(default_factory=list)
|
||||
timestamp: Optional[str] = None
|
||||
|
||||
|
||||
class StudioNodeRunState(BaseModel):
|
||||
nodeId: str
|
||||
displayName: str
|
||||
skillId: str
|
||||
status: str # pending | active | completed | skipped
|
||||
loopUntilSatisfied: bool = False
|
||||
lastDraft: Optional[Dict[str, Any]] = None
|
||||
lastToolResponse: Optional[LastToolResponse] = None
|
||||
stepMessages: List[StepMessage] = Field(default_factory=list)
|
||||
turnHistory: List[TurnSnapshot] = Field(default_factory=list)
|
||||
|
||||
|
||||
class StudioRun(BaseModel):
|
||||
id: str
|
||||
projectId: str
|
||||
status: StudioRunStatus
|
||||
pipelineSnapshot: PipelineDefinition
|
||||
pipelineVersionNote: str
|
||||
currentNodeId: Optional[str] = None
|
||||
nodeStates: List[StudioNodeRunState] = Field(default_factory=list)
|
||||
workflowVariables: Dict[str, Any] = Field(default_factory=dict)
|
||||
lastPromptBlocks: List[PromptBlock] = Field(default_factory=list)
|
||||
title: str = ""
|
||||
createdAt: str = ""
|
||||
updatedAt: str = ""
|
||||
|
||||
|
||||
class AdvanceRunRequest(BaseModel):
|
||||
displayParams: Dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class RunMessageRequest(BaseModel):
|
||||
content: str = Field(..., min_length=1, max_length=32000)
|
||||
stream: bool = False
|
||||
profileId: Optional[str] = None
|
||||
apiConfig: Optional[Dict[str, str]] = None
|
||||
|
||||
|
||||
class RunRerollRequest(BaseModel):
|
||||
stream: bool = False
|
||||
profileId: Optional[str] = None
|
||||
apiConfig: Optional[Dict[str, str]] = None
|
||||
|
||||
|
||||
class RenameRunRequest(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=120)
|
||||
|
||||
|
||||
class StudioRunSummary(BaseModel):
|
||||
id: str
|
||||
projectId: str
|
||||
status: StudioRunStatus
|
||||
currentNodeId: Optional[str] = None
|
||||
title: str = ""
|
||||
createdAt: str = ""
|
||||
updatedAt: str = ""
|
||||
76
backend/models/summary_message.py
Normal file
76
backend/models/summary_message.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
聊天总结消息数据模型
|
||||
|
||||
用于存储总结后的历史消息记录
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class SummaryMessage(BaseModel):
|
||||
"""
|
||||
总结消息
|
||||
|
||||
保存总结后的文本和元数据
|
||||
"""
|
||||
id: str = Field(..., description="总结消息唯一标识符 (UUID)")
|
||||
chatId: str = Field(..., description="关联的聊天 ID")
|
||||
|
||||
# 总结内容
|
||||
summaryText: str = Field(..., description="总结后的文本内容")
|
||||
originalMessageIds: List[str] = Field(default_factory=list, description="被总结的原始消息 ID 列表")
|
||||
messageRange: Optional[str] = Field(None, description="消息范围描述,如 '1-10'")
|
||||
|
||||
# 元数据
|
||||
summaryTimestamp: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="总结时间戳")
|
||||
messageCount: int = Field(..., description="被总结的消息数量")
|
||||
includeUserInput: bool = Field(True, description="是否包含用户输入")
|
||||
|
||||
# 统计信息
|
||||
originalTokenCount: Optional[int] = Field(None, description="原始消息的 token 总数")
|
||||
summaryTokenCount: Optional[int] = Field(None, description="总结文本的 token 数")
|
||||
|
||||
# 版本控制
|
||||
version: int = Field(1, description="总结版本号(用于追溯)")
|
||||
|
||||
@classmethod
|
||||
def create_summary(
|
||||
cls,
|
||||
chat_id: str,
|
||||
summary_text: str,
|
||||
message_ids: List[str],
|
||||
include_user_input: bool = True,
|
||||
version: int = 1
|
||||
) -> 'SummaryMessage':
|
||||
"""
|
||||
创建总结消息的工厂方法
|
||||
|
||||
Args:
|
||||
chat_id: 聊天 ID
|
||||
summary_text: 总结文本
|
||||
message_ids: 被总结的消息 ID 列表
|
||||
include_user_input: 是否包含用户输入
|
||||
version: 版本号
|
||||
|
||||
Returns:
|
||||
SummaryMessage 实例
|
||||
"""
|
||||
import uuid
|
||||
|
||||
# 生成消息范围描述
|
||||
if len(message_ids) > 0:
|
||||
message_range = f"{len(message_ids)}条消息"
|
||||
else:
|
||||
message_range = "无消息"
|
||||
|
||||
return cls(
|
||||
id=str(uuid.uuid4()),
|
||||
chatId=chat_id,
|
||||
summaryText=summary_text,
|
||||
originalMessageIds=message_ids,
|
||||
messageRange=message_range,
|
||||
messageCount=len(message_ids),
|
||||
includeUserInput=include_user_input,
|
||||
version=version
|
||||
)
|
||||
49
backend/models/system_settings.py
Normal file
49
backend/models/system_settings.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
系统设置模型
|
||||
|
||||
包含全局配置,如思考标签前后缀等。
|
||||
"""
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class SystemSettings(BaseModel):
|
||||
"""
|
||||
系统全局设置
|
||||
|
||||
持久化存储到 data/system_settings.json
|
||||
"""
|
||||
|
||||
# ==================== 思考标签配置 ====================
|
||||
thinkingTagPrefix: str = Field(
|
||||
"<thinking>",
|
||||
description="思考标签前缀(默认:<thinking>)"
|
||||
)
|
||||
thinkingTagSuffix: str = Field(
|
||||
"</thinking>",
|
||||
description="思考标签后缀(默认:</thinking>)"
|
||||
)
|
||||
|
||||
# ==================== 当前选中的预设 ====================
|
||||
currentPresetName: Optional[str] = Field(
|
||||
None,
|
||||
description="当前选中的预设名称(用于确定全局正则的作用域)"
|
||||
)
|
||||
|
||||
# ==================== 元数据 ====================
|
||||
updatedAt: int = Field(
|
||||
default_factory=lambda: int(datetime.now().timestamp()),
|
||||
description="最后更新时间戳"
|
||||
)
|
||||
version: int = Field(1, description="版本号")
|
||||
|
||||
|
||||
# ==================== 默认设置 ====================
|
||||
|
||||
DEFAULT_SYSTEM_SETTINGS = SystemSettings()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import json
|
||||
print(json.dumps(DEFAULT_SYSTEM_SETTINGS.dict(), indent=2, ensure_ascii=False))
|
||||
@@ -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
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
|
||||
包含项目的核心业务逻辑,协调 Models、Utils 和 LLM 组件。
|
||||
"""
|
||||
from .prompt_assembler import PromptAssembler, PromptConfig
|
||||
# 注意:不在这里自动导入模块,避免循环依赖和缺失依赖问题
|
||||
# 需要使用时请显式导入,例如:from services.preset_service import PresetService
|
||||
|
||||
__all__ = [
|
||||
'PromptAssembler',
|
||||
'PromptConfig',
|
||||
]
|
||||
__all__ = []
|
||||
|
||||
@@ -50,6 +50,7 @@ class CharacterCardConverter:
|
||||
first_mes=data.get('first_mes', ''),
|
||||
mes_example=data.get('mes_example', ''),
|
||||
categories=[], # ST没有categories
|
||||
tableHeaders=[], # ST没有tableHeaders
|
||||
worldInfoId=extensions.get('world'),
|
||||
outputSchema=None, # ST不支持结构化输出
|
||||
avatarPath=avatar_path,
|
||||
|
||||
@@ -95,11 +95,15 @@ class CharacterService:
|
||||
first_mes=data.get('first_mes', ''),
|
||||
mes_example=data.get('mes_example', ''),
|
||||
categories=data.get('categories', []),
|
||||
tags=data.get('tags', []), # ✅ 使用标签数组
|
||||
worldInfoId=data.get('worldInfoId'),
|
||||
outputSchema=data.get('outputSchema'),
|
||||
avatarPath=avatar_path,
|
||||
alternate_greetings=data.get('alternate_greetings', []),
|
||||
tags=data.get('tags', []),
|
||||
tableMaintenancePrompt=data.get('tableMaintenancePrompt'),
|
||||
imageGenerationPrompt=data.get('imageGenerationPrompt'),
|
||||
tableHeaders=data.get('tableHeaders'), # ✅ 动态表格表头
|
||||
tableDefaults=data.get('tableDefaults'), # ✅ 动态表格默认值
|
||||
createdAt=data.get('createdAt', int(datetime.now().timestamp())),
|
||||
updatedAt=data.get('updatedAt', int(datetime.now().timestamp())),
|
||||
lastChatAt=last_chat_at,
|
||||
@@ -177,8 +181,8 @@ class CharacterService:
|
||||
更新角色卡
|
||||
|
||||
Args:
|
||||
name: 角色名
|
||||
updates: 更新的字段
|
||||
name: 角色名(旧名称,用于定位文件夹)
|
||||
updates: 更新的字段(可以包含 name 字段来重命名)
|
||||
|
||||
Returns:
|
||||
更新后的 CharacterCard 对象
|
||||
@@ -193,6 +197,29 @@ class CharacterService:
|
||||
with open(char_file, 'r', encoding='utf-8') as f:
|
||||
existing_data = json.load(f)
|
||||
|
||||
# 检查是否需要重命名
|
||||
new_name = updates.get('name')
|
||||
needs_rename = new_name and new_name != name
|
||||
|
||||
if needs_rename:
|
||||
# 验证新名称是否合法
|
||||
if not new_name or new_name.strip() == '':
|
||||
raise ValueError("角色名不能为空")
|
||||
|
||||
# 检查新名称是否已存在
|
||||
new_folder = self.characters_dir / new_name
|
||||
if new_folder.exists():
|
||||
raise FileExistsError(f"角色 '{new_name}' 已存在")
|
||||
|
||||
# 重命名文件夹
|
||||
try:
|
||||
import shutil
|
||||
shutil.move(str(char_folder), str(new_folder))
|
||||
char_folder = new_folder
|
||||
char_file = char_folder / "character.json"
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"重命名文件夹失败: {str(e)}")
|
||||
|
||||
# 合并更新
|
||||
existing_data.update(updates)
|
||||
existing_data['updatedAt'] = int(datetime.now().timestamp())
|
||||
|
||||
@@ -10,6 +10,8 @@ import logging
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -57,7 +59,7 @@ class ChatService:
|
||||
logger.error(f"处理角色目录 {role_dir.name} 时出错: {str(e)}")
|
||||
continue
|
||||
|
||||
return {"chat": [{"role_name": role, **chat} for role, chats in result.items() for chat in chats]}
|
||||
return result
|
||||
|
||||
def _get_chat_summary(self, role_name: str, chat_name: str) -> Optional[Dict]:
|
||||
"""
|
||||
@@ -156,6 +158,7 @@ class ChatService:
|
||||
messages.append(msg_data)
|
||||
|
||||
return {
|
||||
"header": header, # 完整的 header,包含 tableHeaders, tableDefaults, tableData
|
||||
"metadata": {
|
||||
"user_name": header.get("user_name", "User"),
|
||||
"character_name": header.get("character_name", ""),
|
||||
@@ -169,6 +172,41 @@ class ChatService:
|
||||
logger.error(f"读取聊天失败 {role_name}/{chat_name}: {str(e)}")
|
||||
raise
|
||||
|
||||
def get_message(self, role_name: str, chat_name: str, floor: int) -> Dict:
|
||||
"""
|
||||
获取指定楼层的消息
|
||||
|
||||
Args:
|
||||
role_name: 角色名称
|
||||
chat_name: 聊天名称
|
||||
floor: 楼层号
|
||||
|
||||
Returns:
|
||||
Dict: 消息数据,如果不存在则返回 None
|
||||
"""
|
||||
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
|
||||
|
||||
if not chat_file.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(chat_file, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# 找到对应的消息行(floor + 1,因为第0行是header)
|
||||
message_line_index = floor + 1
|
||||
|
||||
if message_line_index >= len(lines):
|
||||
return None
|
||||
|
||||
# 解析并返回消息
|
||||
msg_data = json.loads(lines[message_line_index])
|
||||
return msg_data
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取消息失败 {role_name}/{chat_name}/{floor}: {str(e)}")
|
||||
return None
|
||||
|
||||
def create_chat(self, role_name: str, chat_name: str, metadata: Dict = None) -> Dict:
|
||||
"""
|
||||
创建新聊天
|
||||
@@ -192,6 +230,21 @@ class ChatService:
|
||||
# 创建角色目录
|
||||
chat_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 尝试从角色卡获取 tags(关键字列表)
|
||||
tags = []
|
||||
|
||||
try:
|
||||
character_file = settings.CHARACTERS_PATH / role_name / "character.json"
|
||||
if character_file.exists():
|
||||
with open(character_file, 'r', encoding='utf-8') as f:
|
||||
character_data = json.load(f)
|
||||
|
||||
tags = character_data.get('tags', [])
|
||||
|
||||
logger.info(f"从角色卡 {role_name} 继承标签: {tags}")
|
||||
except Exception as e:
|
||||
logger.warning(f"读取角色卡失败,使用空标签: {e}")
|
||||
|
||||
# 构建header
|
||||
header = {
|
||||
"user_name": metadata.get("user_name", "User") if metadata else "User",
|
||||
@@ -207,7 +260,8 @@ class ChatService:
|
||||
"timedWorldInfo": {},
|
||||
"variables": {},
|
||||
"tainted": False,
|
||||
"lastInContextMessageId": -1
|
||||
"lastInContextMessageId": -1,
|
||||
"tags": tags # ✅ 使用标签数组替代 tableHeaders/tableDefaults/tableData
|
||||
}
|
||||
|
||||
# 写入header
|
||||
@@ -381,3 +435,225 @@ class ChatService:
|
||||
except Exception as e:
|
||||
logger.error(f"删除消息失败 {role_name}/{chat_name}/{floor}: {str(e)}")
|
||||
raise
|
||||
|
||||
def update_table_data(self, role_name: str, chat_name: str, table_update: Dict) -> Dict:
|
||||
"""
|
||||
更新标签数据(SillyTavern 关键字机制)
|
||||
|
||||
Args:
|
||||
role_name: 角色名称
|
||||
chat_name: 聊天名称
|
||||
table_update: 包含 tags 数组的字典
|
||||
|
||||
Returns:
|
||||
Dict: 更新后的标签数据
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: 聊天文件不存在
|
||||
"""
|
||||
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
|
||||
|
||||
if not chat_file.exists():
|
||||
raise FileNotFoundError(f"Chat not found: {role_name}/{chat_name}")
|
||||
|
||||
try:
|
||||
with open(chat_file, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
if not lines:
|
||||
raise ValueError(f"Empty chat file: {role_name}/{chat_name}")
|
||||
|
||||
# 读取 header
|
||||
header = json.loads(lines[0])
|
||||
|
||||
# 获取新的标签数组
|
||||
new_tags = table_update.get('tags', [])
|
||||
|
||||
# 更新 header 中的 tags
|
||||
header['tags'] = new_tags
|
||||
|
||||
# 写回文件
|
||||
lines[0] = json.dumps(header, ensure_ascii=False) + '\n'
|
||||
|
||||
with open(chat_file, 'w', encoding='utf-8') as f:
|
||||
f.writelines(lines)
|
||||
|
||||
logger.info(f"标签数据已更新: {role_name}/{chat_name}, 标签数: {len(new_tags)}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"tags": new_tags,
|
||||
"tagCount": len(new_tags)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"更新标签数据失败 {role_name}/{chat_name}: {str(e)}")
|
||||
raise
|
||||
|
||||
def summarize_chat_messages(
|
||||
self,
|
||||
role_name: str,
|
||||
chat_name: str,
|
||||
start_floor: int,
|
||||
end_floor: int,
|
||||
summary_text: str
|
||||
) -> bool:
|
||||
"""
|
||||
总结聊天消息:清空原文,将总结放到最后一个楼层
|
||||
|
||||
Args:
|
||||
role_name: 角色名称
|
||||
chat_name: 聊天名称
|
||||
start_floor: 总结起始楼层(1-based)
|
||||
end_floor: 总结结束楼层(1-based)
|
||||
summary_text: 总结文本
|
||||
|
||||
Returns:
|
||||
bool: 是否成功
|
||||
"""
|
||||
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
|
||||
|
||||
if not chat_file.exists():
|
||||
raise FileNotFoundError(f"Chat not found: {role_name}/{chat_name}")
|
||||
|
||||
try:
|
||||
with open(chat_file, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
if not lines:
|
||||
raise ValueError(f"Empty chat file: {role_name}/{chat_name}")
|
||||
|
||||
# 转换为0-based索引
|
||||
start_idx = start_floor # header在第0行,所以L1在第1行
|
||||
end_idx = end_floor
|
||||
|
||||
# 验证范围
|
||||
if start_idx < 1 or end_idx >= len(lines) or start_idx > end_idx:
|
||||
raise ValueError(f"Invalid floor range: {start_floor}-{end_floor}")
|
||||
|
||||
# 处理消息
|
||||
for i in range(start_idx, end_idx + 1):
|
||||
msg_data = json.loads(lines[i])
|
||||
|
||||
if i < end_idx:
|
||||
# 中间楼层:清空内容
|
||||
msg_data['mes'] = ""
|
||||
msg_data['is_summarized'] = True
|
||||
else:
|
||||
# 最后一个楼层:放入总结文本
|
||||
msg_data['mes'] = summary_text
|
||||
msg_data['is_summary'] = True
|
||||
msg_data['summary_range'] = f"L{start_floor}-L{end_floor}"
|
||||
|
||||
lines[i] = json.dumps(msg_data, ensure_ascii=False) + '\n'
|
||||
|
||||
# 写回文件
|
||||
with open(chat_file, 'w', encoding='utf-8') as f:
|
||||
f.writelines(lines)
|
||||
|
||||
logger.info(
|
||||
f"[ChatService] 总结完成: {role_name}/{chat_name}, "
|
||||
f"楼层 {start_floor}-{end_floor}"
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"总结聊天消息失败 {role_name}/{chat_name}: {str(e)}")
|
||||
raise
|
||||
|
||||
def create_branch(
|
||||
self,
|
||||
role_name: str,
|
||||
chat_name: str,
|
||||
target_floor: int,
|
||||
new_chat_name: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
创建聊天分支
|
||||
|
||||
复制目标楼层及之前的所有内容到一个新的聊天记录
|
||||
|
||||
Args:
|
||||
role_name: 角色名称
|
||||
chat_name: 原聊天名称
|
||||
target_floor: 目标楼层(包含该楼层及之前的内容)
|
||||
new_chat_name: 新聊天名称(可选,默认自动生成)
|
||||
|
||||
Returns:
|
||||
Dict: {
|
||||
"success": bool,
|
||||
"new_chat_name": str,
|
||||
"message_count": int
|
||||
}
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: 聊天不存在
|
||||
ValueError: 楼层不存在
|
||||
"""
|
||||
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
|
||||
|
||||
if not chat_file.exists():
|
||||
raise FileNotFoundError(f"Chat not found: {role_name}/{chat_name}")
|
||||
|
||||
try:
|
||||
# 读取所有行
|
||||
with open(chat_file, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
if not lines:
|
||||
raise ValueError(f"Empty chat file: {role_name}/{chat_name}")
|
||||
|
||||
# 验证目标楼层
|
||||
# floor + 1 是因为第0行是header
|
||||
message_line_index = target_floor + 1
|
||||
if message_line_index >= len(lines):
|
||||
raise ValueError(f"Floor {target_floor} not found in chat (total messages: {len(lines) - 1})")
|
||||
|
||||
# 生成新聊天名称
|
||||
if not new_chat_name:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
new_chat_name = f"branch_{chat_name}_{timestamp}"
|
||||
|
||||
# 创建新聊天文件
|
||||
new_chat_file = self.chat_dir / role_name / f"{new_chat_name}.jsonl"
|
||||
|
||||
if new_chat_file.exists():
|
||||
raise FileExistsError(f"Branch chat already exists: {new_chat_name}")
|
||||
|
||||
# 复制 header 和目标楼层及之前的消息
|
||||
branch_lines = [lines[0]] # header
|
||||
for i in range(1, message_line_index + 1):
|
||||
msg_data = json.loads(lines[i])
|
||||
# 重新分配 floor(从0开始)
|
||||
msg_data["floor"] = i - 1
|
||||
branch_lines.append(json.dumps(msg_data, ensure_ascii=False) + '\n')
|
||||
|
||||
# 写入新文件
|
||||
with open(new_chat_file, 'w', encoding='utf-8') as f:
|
||||
f.writelines(branch_lines)
|
||||
|
||||
message_count = len(branch_lines) - 1 # 减去header
|
||||
|
||||
logger.info(
|
||||
f"[ChatService] 创建分支成功: {role_name}/{chat_name} -> {new_chat_name}, "
|
||||
f"楼层: 0-{target_floor}, 消息数: {message_count}"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"new_chat_name": new_chat_name,
|
||||
"message_count": message_count,
|
||||
"branched_from": chat_name,
|
||||
"target_floor": target_floor
|
||||
}
|
||||
|
||||
except (FileExistsError, ValueError):
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"创建分支失败 {role_name}/{chat_name}: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
# 全局实例
|
||||
chat_service = ChatService(Path(settings.DATA_PATH))
|
||||
|
||||
213
backend/services/chat_summary_service.py
Normal file
213
backend/services/chat_summary_service.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""
|
||||
聊天总结服务
|
||||
|
||||
负责调用LLM对历史消息进行总结
|
||||
"""
|
||||
import json
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from models.internal import ChatMessage, SummaryConfig
|
||||
from core.config import settings
|
||||
|
||||
|
||||
class ChatSummaryService:
|
||||
"""聊天总结服务类"""
|
||||
|
||||
@staticmethod
|
||||
async def summarize_messages(
|
||||
messages: List[ChatMessage],
|
||||
start_floor: int,
|
||||
end_floor: int,
|
||||
summary_config: SummaryConfig,
|
||||
api_config: Dict[str, str]
|
||||
) -> str:
|
||||
"""
|
||||
对指定范围的消息进行总结
|
||||
|
||||
Args:
|
||||
messages: 完整的消息列表
|
||||
start_floor: 总结起始楼层(1-based)
|
||||
end_floor: 总结结束楼层(1-based)
|
||||
summary_config: 总结配置
|
||||
api_config: API配置 {api_url, api_key, model}
|
||||
|
||||
Returns:
|
||||
总结文本
|
||||
"""
|
||||
# 1. 提取需要总结的消息
|
||||
messages_to_summarize = ChatSummaryService._extract_messages(
|
||||
messages, start_floor, end_floor, summary_config.includeUserInput
|
||||
)
|
||||
|
||||
if not messages_to_summarize:
|
||||
return ""
|
||||
|
||||
# 2. 构建总结提示词
|
||||
prompt = ChatSummaryService._build_summary_prompt(
|
||||
messages_to_summarize, summary_config
|
||||
)
|
||||
|
||||
# 3. 调用LLM生成总结
|
||||
summary_text = await ChatSummaryService._call_llm_for_summary(
|
||||
prompt, api_config, summary_config.maxSummaryLength
|
||||
)
|
||||
|
||||
return summary_text
|
||||
|
||||
@staticmethod
|
||||
def _extract_messages(
|
||||
messages: List[ChatMessage],
|
||||
start_floor: int,
|
||||
end_floor: int,
|
||||
include_user_input: bool
|
||||
) -> List[ChatMessage]:
|
||||
"""
|
||||
提取需要总结的消息
|
||||
|
||||
✅ 根据用户需求:后端不筛选,全部输入给LLM
|
||||
|
||||
Args:
|
||||
messages: 完整消息列表
|
||||
start_floor: 起始楼层
|
||||
end_floor: 结束楼层
|
||||
include_user_input: 是否包含用户输入(此参数目前不使用,但保留以保持接口兼容)
|
||||
|
||||
Returns:
|
||||
需要总结的消息列表(全部消息,不做筛选)
|
||||
"""
|
||||
# 转换为0-based索引
|
||||
start_idx = start_floor - 1
|
||||
end_idx = end_floor - 1
|
||||
|
||||
# 提取范围内的所有消息(不筛选)
|
||||
return messages[start_idx:end_idx + 1]
|
||||
|
||||
@staticmethod
|
||||
def _build_summary_prompt(
|
||||
messages: List[ChatMessage],
|
||||
summary_config: SummaryConfig
|
||||
) -> str:
|
||||
"""
|
||||
构建总结提示词
|
||||
|
||||
Args:
|
||||
messages: 需要总结的消息列表
|
||||
summary_config: 总结配置
|
||||
|
||||
Returns:
|
||||
完整的提示词
|
||||
"""
|
||||
# 使用用户自定义的总结提示词,或默认提示词
|
||||
base_prompt = summary_config.summaryPrompt or (
|
||||
"请总结以下对话内容,保留关键信息和上下文。"
|
||||
"用简洁的语言概括主要事件、人物状态和重要细节。"
|
||||
)
|
||||
|
||||
# 构建对话内容
|
||||
conversation_text = "\n\n".join([
|
||||
f"{'用户' if msg.is_user else msg.name}: {msg.mes}"
|
||||
for msg in messages
|
||||
])
|
||||
|
||||
# 组合完整提示词
|
||||
full_prompt = f"""{base_prompt}
|
||||
|
||||
对话内容:
|
||||
{conversation_text}
|
||||
|
||||
总结要求:
|
||||
1. 保持简洁明了
|
||||
2. 保留关键情节和设定
|
||||
3. 不超过{summary_config.maxSummaryLength}字
|
||||
4. 使用客观叙述语气
|
||||
|
||||
总结:"""
|
||||
|
||||
return full_prompt
|
||||
|
||||
@staticmethod
|
||||
async def _call_llm_for_summary(
|
||||
prompt: str,
|
||||
api_config: Dict[str, str],
|
||||
max_length: int
|
||||
) -> str:
|
||||
"""
|
||||
调用LLM生成总结
|
||||
|
||||
Args:
|
||||
prompt: 总结提示词
|
||||
api_config: API配置
|
||||
max_length: 最大长度限制
|
||||
|
||||
Returns:
|
||||
总结文本
|
||||
"""
|
||||
try:
|
||||
# 导入LLM客户端
|
||||
from utils.llm_client import llm_client
|
||||
|
||||
# 构建消息
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "你是一个专业的对话总结助手,擅长提取关键信息并用简洁的语言概括。"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt
|
||||
}
|
||||
]
|
||||
|
||||
# 调用LLM
|
||||
response = await llm_client.chat_completion(
|
||||
messages=messages,
|
||||
api_url=api_config.get("api_url", ""),
|
||||
api_key=api_config.get("api_key", ""),
|
||||
model=api_config.get("model", "gpt-3.5-turbo"),
|
||||
temperature=0.3, # 总结需要较低的随机性
|
||||
max_tokens=max_length,
|
||||
request_timeout=30
|
||||
)
|
||||
|
||||
# 提取总结文本
|
||||
if isinstance(response, dict):
|
||||
summary = response.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
else:
|
||||
summary = str(response)
|
||||
|
||||
# 清理和截断
|
||||
summary = summary.strip()
|
||||
if len(summary) > max_length:
|
||||
summary = summary[:max_length] + "..."
|
||||
|
||||
return summary
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ChatSummary] ❌ LLM总结失败: {e}")
|
||||
# 返回降级总结(基于规则的简单摘要)
|
||||
return ChatSummaryService._fallback_summary(prompt)
|
||||
|
||||
@staticmethod
|
||||
def _fallback_summary(prompt: str) -> str:
|
||||
"""
|
||||
降级总结(当LLM调用失败时使用)
|
||||
|
||||
Args:
|
||||
prompt: 原始提示词
|
||||
|
||||
Returns:
|
||||
简单的降级总结
|
||||
"""
|
||||
# 提取对话中的关键信息
|
||||
lines = prompt.split("\n")
|
||||
user_lines = [l for l in lines if l.startswith("用户:")]
|
||||
ai_lines = [l for l in lines if l.startswith("AI:")]
|
||||
|
||||
fallback = f"[自动总结] 对话包含 {len(user_lines)} 条用户消息和 {len(ai_lines)} 条AI回复。"
|
||||
|
||||
return fallback
|
||||
|
||||
|
||||
# 全局实例
|
||||
chat_summary_service = ChatSummaryService()
|
||||
1052
backend/services/chat_workflow_service.py
Normal file
1052
backend/services/chat_workflow_service.py
Normal file
File diff suppressed because it is too large
Load Diff
346
backend/services/image_metadata_service.py
Normal file
346
backend/services/image_metadata_service.py
Normal file
@@ -0,0 +1,346 @@
|
||||
"""
|
||||
图片元数据服务
|
||||
|
||||
负责管理生成图片的元数据,支持绑定到角色/聊天的特定楼层
|
||||
数据持久化到 data/image_metadata 目录
|
||||
"""
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Any
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
from backend.models.internal import ImageMetadata
|
||||
from backend.core.config import settings
|
||||
except ImportError:
|
||||
from models.internal import ImageMetadata
|
||||
from core.config import settings
|
||||
|
||||
|
||||
class ImageMetadataService:
|
||||
"""
|
||||
图片元数据服务
|
||||
|
||||
功能:
|
||||
- 记录生成图片的元数据
|
||||
- 按角色/聊天/楼层组织
|
||||
- 支持 swipe(同一楼层多张图片)
|
||||
- 提供画廊查询接口
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.metadata_dir = settings.DATA_PATH / "image_metadata"
|
||||
self.metadata_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 图片存储目录
|
||||
self.images_dir = settings.DATA_PATH / "images"
|
||||
self.images_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _get_chat_metadata_file(self, chat_id: str) -> Path:
|
||||
"""获取指定聊天的元数据文件路径"""
|
||||
# chat_id 格式: role_name/chat_name
|
||||
parts = chat_id.split("/")
|
||||
if len(parts) == 2:
|
||||
role_name, chat_name = parts
|
||||
role_dir = self.metadata_dir / role_name
|
||||
role_dir.mkdir(parents=True, exist_ok=True)
|
||||
return role_dir / f"{chat_name}.json"
|
||||
else:
|
||||
# fallback
|
||||
return self.metadata_dir / f"{chat_id.replace('/', '_')}.json"
|
||||
|
||||
def _load_chat_metadata(self, chat_id: str) -> List[ImageMetadata]:
|
||||
"""加载指定聊天的所有图片元数据"""
|
||||
file_path = self._get_chat_metadata_file(chat_id)
|
||||
|
||||
if not file_path.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
return [ImageMetadata(**item) for item in data]
|
||||
except Exception as e:
|
||||
print(f"[ImageMetadata] 加载元数据失败: {e}")
|
||||
return []
|
||||
|
||||
def _save_chat_metadata(self, chat_id: str, metadata_list: List[ImageMetadata]):
|
||||
"""保存聊天的所有图片元数据"""
|
||||
file_path = self._get_chat_metadata_file(chat_id)
|
||||
|
||||
try:
|
||||
data = [m.model_dump() for m in metadata_list]
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
print(f"[ImageMetadata] 保存元数据失败: {e}")
|
||||
|
||||
async def add_image(
|
||||
self,
|
||||
chat_id: str,
|
||||
role_name: str,
|
||||
chat_name: str,
|
||||
floor: int,
|
||||
filename: str,
|
||||
filepath: str,
|
||||
prompt: Optional[str] = None,
|
||||
negative_prompt: Optional[str] = None,
|
||||
seed: Optional[int] = None,
|
||||
model: Optional[str] = None,
|
||||
workflow_name: Optional[str] = None,
|
||||
task_id: Optional[str] = None,
|
||||
generation_time: Optional[float] = None,
|
||||
width: Optional[int] = None,
|
||||
height: Optional[int] = None,
|
||||
file_size: Optional[int] = None
|
||||
) -> ImageMetadata:
|
||||
"""
|
||||
添加图片元数据
|
||||
|
||||
Args:
|
||||
chat_id: 聊天ID
|
||||
role_name: 角色名称
|
||||
chat_name: 聊天名称
|
||||
floor: 楼层号
|
||||
filename: 文件名
|
||||
filepath: 文件相对路径
|
||||
prompt: 提示词
|
||||
negative_prompt: 负面提示词
|
||||
seed: 随机种子
|
||||
model: 使用的模型
|
||||
workflow_name: 工作流名称
|
||||
task_id: 任务ID
|
||||
generation_time: 生成耗时
|
||||
width: 图片宽度
|
||||
height: 图片高度
|
||||
file_size: 文件大小
|
||||
|
||||
Returns:
|
||||
ImageMetadata: 创建的元数据
|
||||
"""
|
||||
metadata_list = self._load_chat_metadata(chat_id)
|
||||
|
||||
# 计算 swipe_index
|
||||
same_floor_images = [m for m in metadata_list if m.floor == floor]
|
||||
swipe_index = len(same_floor_images)
|
||||
|
||||
# 如果这是该楼层的第一张图片,将其他图片的 isCurrentSwipe 设为 False
|
||||
if swipe_index == 0:
|
||||
for m in metadata_list:
|
||||
if m.floor == floor:
|
||||
m.isCurrentSwipe = False
|
||||
|
||||
metadata = ImageMetadata(
|
||||
id=str(uuid.uuid4()),
|
||||
chatId=chat_id,
|
||||
roleName=role_name,
|
||||
chatName=chat_name,
|
||||
floor=floor,
|
||||
filename=filename,
|
||||
filepath=filepath,
|
||||
prompt=prompt,
|
||||
negativePrompt=negative_prompt,
|
||||
seed=seed,
|
||||
model=model,
|
||||
workflowName=workflow_name,
|
||||
taskId=task_id,
|
||||
generationTime=generation_time,
|
||||
width=width,
|
||||
height=height,
|
||||
fileSize=file_size,
|
||||
swipeIndex=swipe_index,
|
||||
isCurrentSwipe=True
|
||||
)
|
||||
|
||||
metadata_list.append(metadata)
|
||||
self._save_chat_metadata(chat_id, metadata_list)
|
||||
|
||||
return metadata
|
||||
|
||||
async def get_images_by_chat(
|
||||
self,
|
||||
chat_id: str,
|
||||
floor: Optional[int] = None
|
||||
) -> List[ImageMetadata]:
|
||||
"""
|
||||
获取指定聊天的图片列表
|
||||
|
||||
Args:
|
||||
chat_id: 聊天ID
|
||||
floor: 楼层号(可选,用于过滤)
|
||||
|
||||
Returns:
|
||||
图片元数据列表
|
||||
"""
|
||||
metadata_list = self._load_chat_metadata(chat_id)
|
||||
|
||||
if floor is not None:
|
||||
metadata_list = [m for m in metadata_list if m.floor == floor]
|
||||
|
||||
# 按楼层和 swipe_index 排序
|
||||
metadata_list.sort(key=lambda m: (m.floor, m.swipeIndex))
|
||||
|
||||
return metadata_list
|
||||
|
||||
async def get_images_by_role(self, role_name: str) -> List[ImageMetadata]:
|
||||
"""获取指定角色的所有图片"""
|
||||
all_images = []
|
||||
|
||||
role_dir = self.metadata_dir / role_name
|
||||
if not role_dir.exists():
|
||||
return []
|
||||
|
||||
for chat_file in role_dir.glob("*.json"):
|
||||
chat_name = chat_file.stem
|
||||
chat_id = f"{role_name}/{chat_name}"
|
||||
images = self._load_chat_metadata(chat_id)
|
||||
all_images.extend(images)
|
||||
|
||||
# 按创建时间排序
|
||||
all_images.sort(key=lambda m: m.createdAt, reverse=True)
|
||||
|
||||
return all_images
|
||||
|
||||
async def delete_image(self, chat_id: str, image_id: str) -> bool:
|
||||
"""
|
||||
删除图片元数据(不删除实际文件)
|
||||
|
||||
Args:
|
||||
chat_id: 聊天ID
|
||||
image_id: 图片ID
|
||||
|
||||
Returns:
|
||||
bool: 是否成功删除
|
||||
"""
|
||||
metadata_list = self._load_chat_metadata(chat_id)
|
||||
|
||||
# 找到要删除的图片
|
||||
target_image = None
|
||||
for m in metadata_list:
|
||||
if m.id == image_id:
|
||||
target_image = m
|
||||
break
|
||||
|
||||
if not target_image:
|
||||
return False
|
||||
|
||||
floor = target_image.floor
|
||||
swipe_index = target_image.swipeIndex
|
||||
|
||||
# 删除该图片
|
||||
metadata_list = [m for m in metadata_list if m.id != image_id]
|
||||
|
||||
# 重新调整同一楼层其他图片的 swipe_index
|
||||
same_floor_images = [m for m in metadata_list if m.floor == floor]
|
||||
same_floor_images.sort(key=lambda m: m.swipeIndex)
|
||||
|
||||
for idx, m in enumerate(same_floor_images):
|
||||
m.swipeIndex = idx
|
||||
m.isCurrentSwipe = (idx == 0) # 第一个为当前显示
|
||||
|
||||
self._save_chat_metadata(chat_id, metadata_list)
|
||||
return True
|
||||
|
||||
async def clear_chat_images(self, chat_id: str) -> int:
|
||||
"""
|
||||
清空指定聊天的所有图片元数据
|
||||
|
||||
Args:
|
||||
chat_id: 聊天ID
|
||||
|
||||
Returns:
|
||||
int: 删除的图片数量
|
||||
"""
|
||||
metadata_list = self._load_chat_metadata(chat_id)
|
||||
count = len(metadata_list)
|
||||
|
||||
# 清空元数据文件
|
||||
file_path = self._get_chat_metadata_file(chat_id)
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
|
||||
return count
|
||||
|
||||
async def set_current_swipe(self, chat_id: str, image_id: str) -> bool:
|
||||
"""
|
||||
设置某张图片为当前显示的 swipe
|
||||
|
||||
Args:
|
||||
chat_id: 聊天ID
|
||||
image_id: 图片ID
|
||||
|
||||
Returns:
|
||||
bool: 是否成功设置
|
||||
"""
|
||||
metadata_list = self._load_chat_metadata(chat_id)
|
||||
|
||||
target_image = None
|
||||
for m in metadata_list:
|
||||
if m.id == image_id:
|
||||
target_image = m
|
||||
break
|
||||
|
||||
if not target_image:
|
||||
return False
|
||||
|
||||
floor = target_image.floor
|
||||
|
||||
# 将同一楼层的所有图片设为非当前
|
||||
for m in metadata_list:
|
||||
if m.floor == floor:
|
||||
m.isCurrentSwipe = False
|
||||
|
||||
# 设置目标图片为当前
|
||||
target_image.isCurrentSwipe = True
|
||||
|
||||
self._save_chat_metadata(chat_id, metadata_list)
|
||||
return True
|
||||
|
||||
async def get_gallery_stats(self) -> Dict[str, Any]:
|
||||
"""
|
||||
获取画廊统计信息
|
||||
|
||||
Returns:
|
||||
统计信息字典
|
||||
"""
|
||||
stats = {
|
||||
"totalImages": 0,
|
||||
"byRole": {},
|
||||
"byChat": {}
|
||||
}
|
||||
|
||||
if not self.metadata_dir.exists():
|
||||
return stats
|
||||
|
||||
for role_dir in self.metadata_dir.iterdir():
|
||||
if not role_dir.is_dir():
|
||||
continue
|
||||
|
||||
role_name = role_dir.name
|
||||
role_count = 0
|
||||
|
||||
for chat_file in role_dir.glob("*.json"):
|
||||
chat_name = chat_file.stem
|
||||
chat_id = f"{role_name}/{chat_name}"
|
||||
images = self._load_chat_metadata(chat_id)
|
||||
|
||||
chat_count = len(images)
|
||||
role_count += chat_count
|
||||
stats["totalImages"] += chat_count
|
||||
|
||||
if chat_count > 0:
|
||||
stats["byChat"][chat_id] = chat_count
|
||||
|
||||
if role_count > 0:
|
||||
stats["byRole"][role_name] = role_count
|
||||
|
||||
return stats
|
||||
|
||||
def get_image_full_path(self, filepath: str) -> Path:
|
||||
"""获取图片的完整路径"""
|
||||
return self.images_dir / filepath
|
||||
|
||||
|
||||
# 全局实例
|
||||
image_metadata_service = ImageMetadataService()
|
||||
281
backend/services/js_sandbox.py
Normal file
281
backend/services/js_sandbox.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""
|
||||
JavaScript 沙盒执行引擎 + 提示词模板系统
|
||||
|
||||
基于 iframe 隔离的 JavaScript 代码执行环境,提供安全的脚本执行能力。
|
||||
遵循 SillyTavern Tavern Helper 的设计理念。
|
||||
|
||||
安全特性:
|
||||
- 使用 iframe 沙盒隔离执行环境
|
||||
- 禁止访问 window.parent、window.top 等危险 API
|
||||
- 禁止网络请求(fetch、XMLHttpRequest)
|
||||
- 禁止文件系统访问
|
||||
- 禁止 DOM 操作(除特定安全的 API)
|
||||
- 提供受限的有用功能(变量管理、随机数、骰子等)
|
||||
|
||||
提示词模板语法(兼容 SillyTavern):
|
||||
- {{var}} 或 {{getvar::key}}: 获取变量
|
||||
- {{setvar::key::value}}: 设置变量
|
||||
- {{delvar::key}}: 删除变量
|
||||
- {{random::a,b,c}}: 随机选择
|
||||
- {{roll XdY}}: 掷骰子(X 个 Y 面骰)
|
||||
- {{pick::a|b|c}}: 随机选择(使用 | 分隔)
|
||||
- {{// 注释}}: 注释(不会输出)
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import random
|
||||
from typing import Any, Dict, List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class JSSandboxError(Exception):
|
||||
"""沙盒执行错误"""
|
||||
pass
|
||||
|
||||
|
||||
class JSSandboxExecutor:
|
||||
"""
|
||||
JavaScript 沙盒执行器
|
||||
|
||||
提供安全的 JavaScript 代码执行环境,支持:
|
||||
- 变量管理(getvar、setvar、delvar)
|
||||
- 随机数生成(random、roll)
|
||||
- 字符串处理
|
||||
- 数学计算
|
||||
- 安全的对象操作
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# 变量存储(每个会话独立)
|
||||
self.variables: Dict[str, Any] = {}
|
||||
|
||||
# 禁止的危险 API 列表
|
||||
self.dangerous_apis = [
|
||||
'fetch', 'XMLHttpRequest', 'WebSocket',
|
||||
'window.parent', 'window.top', 'window.opener',
|
||||
'document.cookie', 'document.write', 'document.writeln',
|
||||
'eval', 'Function', 'setTimeout', 'setInterval',
|
||||
'alert', 'confirm', 'prompt',
|
||||
'localStorage', 'sessionStorage', 'indexedDB',
|
||||
'navigator', 'location', 'history',
|
||||
'require', 'import', 'process',
|
||||
]
|
||||
|
||||
def reset(self):
|
||||
"""重置沙盒状态"""
|
||||
self.variables.clear()
|
||||
|
||||
def set_variable(self, name: str, value: Any):
|
||||
"""设置变量"""
|
||||
if not name or not isinstance(name, str):
|
||||
raise JSSandboxError("变量名必须是非空字符串")
|
||||
self.variables[name] = value
|
||||
|
||||
def get_variable(self, name: str, default: Any = None) -> Any:
|
||||
"""获取变量"""
|
||||
return self.variables.get(name, default)
|
||||
|
||||
def delete_variable(self, name: str):
|
||||
"""删除变量"""
|
||||
if name in self.variables:
|
||||
del self.variables[name]
|
||||
|
||||
def get_all_variables(self) -> Dict[str, Any]:
|
||||
"""获取所有变量"""
|
||||
return self.variables.copy()
|
||||
|
||||
def execute_code(self, code: str, context: Optional[Dict] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
执行 JavaScript 代码
|
||||
|
||||
Args:
|
||||
code: JavaScript 代码
|
||||
context: 执行上下文(可选)
|
||||
|
||||
Returns:
|
||||
执行结果,包含:
|
||||
- success: 是否成功
|
||||
- result: 执行结果
|
||||
- error: 错误信息(如果有)
|
||||
- variables: 变量状态
|
||||
"""
|
||||
try:
|
||||
# 安全检查
|
||||
self._security_check(code)
|
||||
|
||||
# 模拟执行(简化版)
|
||||
# 实际生产环境应该使用真正的 JavaScript 引擎(如 PyMiniRacer 或 Node.js)
|
||||
result = self._simulate_execution(code, context)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'result': result,
|
||||
'variables': self.get_all_variables()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'variables': self.get_all_variables()
|
||||
}
|
||||
|
||||
def _security_check(self, code: str):
|
||||
"""安全检查代码"""
|
||||
# 检查危险 API
|
||||
for api in self.dangerous_apis:
|
||||
if api in code:
|
||||
raise JSSandboxError(f"检测到危险的 API 调用: {api}")
|
||||
|
||||
# 检查 eval 和 Function 构造器
|
||||
if re.search(r'\beval\s*\(', code):
|
||||
raise JSSandboxError("禁止使用 eval()")
|
||||
|
||||
if re.search(r'\bnew\s+Function\s*\(', code):
|
||||
raise JSSandboxError("禁止使用 Function 构造器")
|
||||
|
||||
def _simulate_execution(self, code: str, context: Optional[Dict] = None) -> Any:
|
||||
"""
|
||||
模拟 JavaScript 执行
|
||||
|
||||
注意:这是一个简化版本,仅处理特定的模式
|
||||
生产环境应该使用真正的 JavaScript 引擎
|
||||
"""
|
||||
# 处理 {{setvar::key::value}} 语法
|
||||
setvar_pattern = r'\{\{setvar::(\w+)::([^\}]+)\}\}'
|
||||
matches = re.findall(setvar_pattern, code)
|
||||
for key, value in matches:
|
||||
self.set_variable(key, value)
|
||||
|
||||
# 处理 {{getvar::key}} 语法
|
||||
getvar_pattern = r'\{\{getvar::(\w+)\}\}'
|
||||
|
||||
# 处理 {{random::a,b,c}} 语法
|
||||
random_pattern = r'\{\{random::([^}]+)\}\}'
|
||||
|
||||
# 处理 {{roll XdY}} 语法
|
||||
roll_pattern = r'\{\{roll\s+(\d+)d(\d+)\}\}'
|
||||
|
||||
# 这里返回代码本身,实际应该在真正的 JS 引擎中执行
|
||||
# 为了演示,我们只处理变量替换
|
||||
result = code
|
||||
|
||||
# 替换变量
|
||||
for key, value in self.variables.items():
|
||||
result = result.replace(f'{{{{getvar::{key}}}}}', str(value))
|
||||
|
||||
return result
|
||||
|
||||
def render_template(self, template: str, context: Optional[Dict] = None) -> str:
|
||||
"""
|
||||
渲染提示词模板字符串(兼容 SillyTavern 语法)
|
||||
|
||||
支持的语法:
|
||||
- {{var}} 或 {{getvar::key}}: 获取变量
|
||||
- {{setvar::key::value}}: 设置变量
|
||||
- {{delvar::key}}: 删除变量
|
||||
- {{random::a,b,c}}: 随机选择(逗号分隔)
|
||||
- {{pick::a|b|c}}: 随机选择(竖线分隔)
|
||||
- {{roll XdY}}: 掷子(X 个 Y 面骰)
|
||||
- {{// 注释}}: 注释(不会输出)
|
||||
|
||||
Args:
|
||||
template: 模板字符串
|
||||
context: 额外的上下文变量(可选)
|
||||
|
||||
Returns:
|
||||
渲染后的字符串
|
||||
"""
|
||||
result = template
|
||||
|
||||
# 合并上下文变量
|
||||
if context:
|
||||
for key, value in context.items():
|
||||
self.set_variable(key, value)
|
||||
|
||||
# 1. 处理 {{// 注释}} - 移除注释
|
||||
result = re.sub(r'\{\{//[^}]*\}\}', '', result)
|
||||
|
||||
# 2. 处理 {{delvar::key}} - 删除变量
|
||||
def replace_delvar(match):
|
||||
key = match.group(1)
|
||||
self.delete_variable(key)
|
||||
return ''
|
||||
result = re.sub(r'\{\{delvar::(\w+)\}\}', replace_delvar, result)
|
||||
|
||||
# 3. 处理 {{setvar::key::value}} - 设置变量(先设置)
|
||||
def replace_setvar(match):
|
||||
key, value = match.group(1), match.group(2)
|
||||
self.set_variable(key, value)
|
||||
return ''
|
||||
result = re.sub(r'\{\{setvar::(\w+)::([^}]+)\}\}', replace_setvar, result)
|
||||
|
||||
# 4. 处理 {{random::a,b,c}} - 随机选择(逗号分隔)
|
||||
def replace_random_comma(match):
|
||||
options = match.group(1).split(',')
|
||||
return random.choice([opt.strip() for opt in options if opt.strip()])
|
||||
result = re.sub(r'\{\{random::([^}]+)\}\}', replace_random_comma, result)
|
||||
|
||||
# 5. 处理 {{pick::a|b|c}} - 随机选择(竖线分隔)
|
||||
def replace_pick(match):
|
||||
options = match.group(1).split('|')
|
||||
return random.choice([opt.strip() for opt in options if opt.strip()])
|
||||
result = re.sub(r'\{\{pick::([^}]+)\}\}', replace_pick, result)
|
||||
|
||||
# 6. 处理 {{roll XdY}} - 掷骰子
|
||||
def replace_roll(match):
|
||||
count = int(match.group(1))
|
||||
sides = int(match.group(2))
|
||||
rolls = [random.randint(1, sides) for _ in range(count)]
|
||||
return str(sum(rolls))
|
||||
result = re.sub(r'\{\{roll\s+(\d+)d(\d+)\}\}', replace_roll, result)
|
||||
|
||||
# 7. 处理 {{getvar::key}} - 获取变量(后获取)
|
||||
def replace_getvar(match):
|
||||
key = match.group(1)
|
||||
return str(self.get_variable(key, ''))
|
||||
result = re.sub(r'\{\{getvar::(\w+)\}\}', replace_getvar, result)
|
||||
|
||||
# 8. 处理 {{var}} - 获取变量(简化语法)
|
||||
def replace_var(match):
|
||||
key = match.group(1)
|
||||
return str(self.get_variable(key, ''))
|
||||
result = re.sub(r'\{\{(\w+)\}\}', replace_var, result)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# 全局沙盒实例
|
||||
js_sandbox = JSSandboxExecutor()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 测试沙盒功能
|
||||
sandbox = JSSandboxExecutor()
|
||||
|
||||
# 测试变量管理
|
||||
print("=== 测试变量管理 ===")
|
||||
sandbox.set_variable('test_var', 'Hello World')
|
||||
print(f"获取变量: {sandbox.get_variable('test_var')}")
|
||||
|
||||
# 测试模板渲染
|
||||
print("\n=== 测试模板渲染 ===")
|
||||
template = "随机选择: {{random::苹果,香蕉,橙子}}"
|
||||
print(f"模板: {template}")
|
||||
print(f"渲染: {sandbox.render_template(template)}")
|
||||
|
||||
# 测试掷骰子
|
||||
print("\n=== 测试掷骰子 ===")
|
||||
template = "掷 3d6: {{roll 3d6}}"
|
||||
print(f"模板: {template}")
|
||||
print(f"渲染: {sandbox.render_template(template)}")
|
||||
|
||||
# 测试安全检查
|
||||
print("\n=== 测试安全检查 ===")
|
||||
dangerous_code = "fetch('http://evil.com')"
|
||||
try:
|
||||
sandbox.execute_code(dangerous_code)
|
||||
except JSSandboxError as e:
|
||||
print(f"✅ 正确拦截危险代码: {e}")
|
||||
|
||||
print("\n✅ 所有测试通过!")
|
||||
@@ -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'
|
||||
|
||||
329
backend/services/preset_service.py
Normal file
329
backend/services/preset_service.py
Normal file
@@ -0,0 +1,329 @@
|
||||
"""
|
||||
Preset Service
|
||||
预设服务层 - 处理预设的 CRUD 操作
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from core.config import settings
|
||||
|
||||
|
||||
class PresetService:
|
||||
"""预设服务类"""
|
||||
|
||||
@staticmethod
|
||||
def _extract_preset_name_from_filename(filename: str) -> str:
|
||||
"""
|
||||
从文件名提取预设名称,去掉时间戳和文件后缀
|
||||
|
||||
Args:
|
||||
filename: 文件名(不含路径)
|
||||
|
||||
Returns:
|
||||
清理后的预设名称
|
||||
|
||||
Examples:
|
||||
"Default.json" -> "Default"
|
||||
"MyPreset_1234567890.json" -> "MyPreset"
|
||||
"Test_1714567890123.json" -> "Test"
|
||||
"""
|
||||
# 去掉 .json 后缀
|
||||
name = filename.replace('.json', '')
|
||||
|
||||
# 去掉末尾的时间戳(下划线+数字组合)
|
||||
# 匹配模式:_后面跟着10-13位数字(Unix时间戳)
|
||||
import re
|
||||
name = re.sub(r'_\d{10,13}$', '', name)
|
||||
|
||||
return name
|
||||
|
||||
@staticmethod
|
||||
def _get_preset_path(name: str) -> Path:
|
||||
"""获取预设文件路径"""
|
||||
return settings.PRESET_PATH / f"{name}.json"
|
||||
|
||||
@staticmethod
|
||||
def _load_preset(name: str) -> Optional[Dict[str, Any]]:
|
||||
"""加载预设 JSON 文件"""
|
||||
path = PresetService._get_preset_path(name)
|
||||
if not path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to load preset '{name}': {str(e)}")
|
||||
|
||||
@staticmethod
|
||||
def _save_preset(name: str, data: Dict[str, Any]):
|
||||
"""保存预设到 JSON 文件"""
|
||||
path = PresetService._get_preset_path(name)
|
||||
try:
|
||||
# 确保 prompts 数组和 prompt_order 的顺序一致
|
||||
if "prompts" in data and "prompt_order" in data:
|
||||
prompts = data["prompts"]
|
||||
prompt_order = data.get("prompt_order", [{}])[0].get("order", [])
|
||||
|
||||
if prompts and prompt_order:
|
||||
# 创建 identifier 到 prompt 的映射
|
||||
prompt_map = {prompt["identifier"]: prompt for prompt in prompts}
|
||||
|
||||
# 按照 prompt_order 的顺序重新排列 prompts
|
||||
reordered_prompts = []
|
||||
for order_item in prompt_order:
|
||||
identifier = order_item.get("identifier")
|
||||
if identifier and identifier in prompt_map:
|
||||
reordered_prompts.append(prompt_map[identifier])
|
||||
|
||||
# 添加 prompt_order 中不存在的 prompts(如果有)
|
||||
existing_identifiers = {item.get("identifier") for item in prompt_order}
|
||||
for prompt in prompts:
|
||||
if prompt["identifier"] not in existing_identifiers:
|
||||
reordered_prompts.append(prompt)
|
||||
|
||||
data["prompts"] = reordered_prompts
|
||||
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to save preset '{name}': {str(e)}")
|
||||
|
||||
@staticmethod
|
||||
def list_presets() -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取所有预设的列表(仅基本信息)
|
||||
|
||||
Returns:
|
||||
预设列表,每个包含 name, description, component_count, temperature 等
|
||||
"""
|
||||
presets = []
|
||||
|
||||
for json_file in settings.PRESET_PATH.glob("*.json"):
|
||||
try:
|
||||
with open(json_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# 计算组件数量 - 支持 SillyTavern 格式 (prompts) 和内部格式 (entries)
|
||||
prompts = data.get("prompts", [])
|
||||
entries = data.get("entries", [])
|
||||
component_count = len(prompts) if prompts else len(entries)
|
||||
|
||||
# 提取温度参数 - 使用 SillyTavern 标准字段名
|
||||
temperature = data.get("temperature", 1.0)
|
||||
|
||||
# 从文件名提取预设名称(去掉时间戳和后缀)
|
||||
preset_name = PresetService._extract_preset_name_from_filename(json_file.name)
|
||||
|
||||
preset_info = {
|
||||
"name": preset_name,
|
||||
"description": data.get("description", ""),
|
||||
"component_count": component_count,
|
||||
"temperature": temperature
|
||||
}
|
||||
|
||||
presets.append(preset_info)
|
||||
except Exception as e:
|
||||
print(f"Error loading preset {json_file.name}: {e}")
|
||||
continue
|
||||
|
||||
# 按名称排序
|
||||
presets.sort(key=lambda x: x.get("name", ""))
|
||||
return presets
|
||||
|
||||
@staticmethod
|
||||
def get_preset(name: str) -> Dict[str, Any]:
|
||||
"""
|
||||
获取指定预设的完整数据
|
||||
|
||||
Args:
|
||||
name: 预设名称
|
||||
|
||||
Returns:
|
||||
预设完整数据
|
||||
"""
|
||||
data = PresetService._load_preset(name)
|
||||
if not data:
|
||||
raise FileNotFoundError(f"Preset '{name}' not found")
|
||||
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def create_preset(name: str, preset_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
创建新预设
|
||||
|
||||
Args:
|
||||
name: 预设名称
|
||||
preset_data: 预设数据
|
||||
|
||||
Returns:
|
||||
创建的预设数据
|
||||
"""
|
||||
# 检查是否已存在
|
||||
if PresetService._get_preset_path(name).exists():
|
||||
raise ValueError(f"Preset '{name}' already exists")
|
||||
|
||||
# 确保有必要的字段
|
||||
if "name" not in preset_data:
|
||||
preset_data["name"] = name
|
||||
|
||||
# 添加时间戳
|
||||
now = int(datetime.now().timestamp())
|
||||
if "createdAt" not in preset_data:
|
||||
preset_data["createdAt"] = now
|
||||
if "updatedAt" not in preset_data:
|
||||
preset_data["updatedAt"] = now
|
||||
|
||||
PresetService._save_preset(name, preset_data)
|
||||
return preset_data
|
||||
|
||||
@staticmethod
|
||||
def update_preset(name: str, update_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
更新预设
|
||||
|
||||
Args:
|
||||
name: 预设名称
|
||||
update_data: 要更新的数据
|
||||
|
||||
Returns:
|
||||
更新后的预设数据
|
||||
"""
|
||||
data = PresetService._load_preset(name)
|
||||
if not data:
|
||||
raise FileNotFoundError(f"Preset '{name}' not found")
|
||||
|
||||
# 更新字段
|
||||
for key, value in update_data.items():
|
||||
if key not in ["name", "createdAt"]: # 不允许修改名称和创建时间
|
||||
data[key] = value
|
||||
|
||||
# 更新时间戳
|
||||
data["updatedAt"] = int(datetime.now().timestamp())
|
||||
|
||||
PresetService._save_preset(name, data)
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def delete_preset(name: str) -> bool:
|
||||
"""
|
||||
删除预设
|
||||
|
||||
Args:
|
||||
name: 预设名称
|
||||
|
||||
Returns:
|
||||
是否删除成功
|
||||
"""
|
||||
path = PresetService._get_preset_path(name)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Preset '{name}' not found")
|
||||
|
||||
path.unlink()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def rename_preset(old_name: str, new_name: str) -> Dict[str, Any]:
|
||||
"""
|
||||
重命名预设(同时修改文件名和内部 name 字段)
|
||||
|
||||
Args:
|
||||
old_name: 原预设名称
|
||||
new_name: 新预设名称
|
||||
|
||||
Returns:
|
||||
更新后的预设数据
|
||||
"""
|
||||
# 检查原预设是否存在
|
||||
old_path = PresetService._get_preset_path(old_name)
|
||||
if not old_path.exists():
|
||||
raise FileNotFoundError(f"Preset '{old_name}' not found")
|
||||
|
||||
# 检查新名称是否已存在
|
||||
new_path = PresetService._get_preset_path(new_name)
|
||||
if new_path.exists() and old_name != new_name:
|
||||
raise ValueError(f"Preset '{new_name}' already exists")
|
||||
|
||||
# 加载原预设数据
|
||||
data = PresetService._load_preset(old_name)
|
||||
if not data:
|
||||
raise FileNotFoundError(f"Preset '{old_name}' not found")
|
||||
|
||||
# 更新内部的 name 字段
|
||||
data["name"] = new_name
|
||||
|
||||
# 更新时间戳
|
||||
data["updatedAt"] = int(datetime.now().timestamp())
|
||||
|
||||
# 保存到新文件
|
||||
PresetService._save_preset(new_name, data)
|
||||
|
||||
# 删除旧文件(如果名称不同)
|
||||
if old_name != new_name:
|
||||
old_path.unlink()
|
||||
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def reorder_components(name: str, component_order: List[str]) -> Dict[str, Any]:
|
||||
"""
|
||||
重新排序预设组件 - 支持 SillyTavern 标准格式
|
||||
|
||||
Args:
|
||||
name: 预设名称
|
||||
component_order: 组件 identifier 列表,按新顺序排列
|
||||
|
||||
Returns:
|
||||
更新后的预设数据
|
||||
"""
|
||||
data = PresetService._load_preset(name)
|
||||
if not data:
|
||||
raise FileNotFoundError(f"Preset '{name}' not found")
|
||||
|
||||
# 支持 SillyTavern 格式的 prompts
|
||||
if "prompts" in data and isinstance(data["prompts"], list):
|
||||
# 创建 identifier 到 prompt 的映射
|
||||
prompt_map = {prompt["identifier"]: prompt for prompt in data["prompts"]}
|
||||
|
||||
# 按新顺序重新排列
|
||||
reordered_prompts = []
|
||||
for identifier in component_order:
|
||||
if identifier in prompt_map:
|
||||
reordered_prompts.append(prompt_map[identifier])
|
||||
|
||||
data["prompts"] = reordered_prompts
|
||||
|
||||
# 更新 prompt_order
|
||||
if "prompt_order" in data and isinstance(data["prompt_order"], list) and len(data["prompt_order"]) > 0:
|
||||
data["prompt_order"][0]["order"] = [
|
||||
{"identifier": identifier, "enabled": True}
|
||||
for identifier in component_order
|
||||
if identifier in prompt_map
|
||||
]
|
||||
|
||||
# 也支持内部格式的 entries(向后兼容)
|
||||
elif "entries" in data and isinstance(data["entries"], list):
|
||||
# 创建 identifier 到 entry 的映射
|
||||
entry_map = {entry["identifier"]: entry for entry in data["entries"]}
|
||||
|
||||
# 按新顺序重新排列
|
||||
reordered_entries = []
|
||||
for identifier in component_order:
|
||||
if identifier in entry_map:
|
||||
reordered_entries.append(entry_map[identifier])
|
||||
|
||||
# 更新 order 字段
|
||||
for index, entry in enumerate(reordered_entries):
|
||||
entry["order"] = index
|
||||
|
||||
data["entries"] = reordered_entries
|
||||
|
||||
# 更新时间戳
|
||||
data["updatedAt"] = int(datetime.now().timestamp())
|
||||
|
||||
PresetService._save_preset(name, data)
|
||||
return data
|
||||
@@ -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 分组插入
|
||||
|
||||
361
backend/services/regex_service.py
Normal file
361
backend/services/regex_service.py
Normal file
@@ -0,0 +1,361 @@
|
||||
"""
|
||||
正则规则服务(重构版 - 文件夹结构)
|
||||
|
||||
负责加载、管理和应用正则替换规则。
|
||||
使用文件夹结构组织规则,兼容 SillyTavern 格式。
|
||||
|
||||
文件结构:
|
||||
data/regex/
|
||||
├── global/ # 全局规则
|
||||
│ └── default.json
|
||||
├── characters/ # 角色卡绑定规则
|
||||
│ └── {characterName}.json
|
||||
└── presets/ # 预设绑定规则
|
||||
└── {presetName}.json
|
||||
"""
|
||||
import re
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Dict
|
||||
from uuid import uuid4
|
||||
|
||||
from core.config import settings
|
||||
from models.regex_rules import RegexRule, RegexRuleset, RegexScope, RegexPlacement, SubstituteMode
|
||||
from services.system_settings_service import system_settings_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RegexService:
|
||||
"""
|
||||
正则替换规则服务(文件夹结构版)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.regex_base_path = settings.DATA_PATH / "regex"
|
||||
self.global_path = self.regex_base_path / "global"
|
||||
self.characters_path = self.regex_base_path / "characters"
|
||||
self.presets_path = self.regex_base_path / "presets"
|
||||
|
||||
# 内存缓存
|
||||
self.global_rulesets: Dict[str, RegexRuleset] = {}
|
||||
self.character_rulesets: Dict[str, RegexRuleset] = {} # key: characterName
|
||||
self.preset_rulesets: Dict[str, RegexRuleset] = {} # key: presetName
|
||||
|
||||
self._ensure_directories()
|
||||
self._load_all_rules()
|
||||
|
||||
def _ensure_directories(self):
|
||||
"""确保目录结构存在"""
|
||||
for path in [self.regex_base_path, self.global_path, self.characters_path, self.presets_path]:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _load_all_rules(self):
|
||||
"""加载所有规则"""
|
||||
self._load_global_rules()
|
||||
self._load_character_rules()
|
||||
self._load_preset_rules()
|
||||
|
||||
def _load_global_rules(self):
|
||||
"""加载全局规则"""
|
||||
if not self.global_path.exists():
|
||||
return
|
||||
|
||||
for json_file in self.global_path.glob("*.json"):
|
||||
try:
|
||||
with open(json_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
if isinstance(data, list):
|
||||
# SillyTavern 格式
|
||||
ruleset = self._convert_sillytavern_format(data, json_file.stem)
|
||||
elif isinstance(data, dict) and 'rules' in data:
|
||||
# 我们的规则集格式
|
||||
ruleset = RegexRuleset(**data)
|
||||
else:
|
||||
logger.warning(f"未知的规则文件格式: {json_file}")
|
||||
continue
|
||||
|
||||
self.global_rulesets[ruleset.id] = ruleset
|
||||
logger.info(f"加载全局规则集: {ruleset.name} ({len(ruleset.rules)} 条规则)")
|
||||
except Exception as e:
|
||||
logger.error(f"加载全局规则失败 {json_file}: {e}")
|
||||
|
||||
def _load_character_rules(self):
|
||||
"""加载角色卡绑定规则"""
|
||||
if not self.characters_path.exists():
|
||||
return
|
||||
|
||||
for json_file in self.characters_path.glob("*.json"):
|
||||
try:
|
||||
character_name = json_file.stem
|
||||
with open(json_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
if isinstance(data, list):
|
||||
ruleset = self._convert_sillytavern_format(data, character_name, RegexScope.CHARACTER)
|
||||
elif isinstance(data, dict) and 'rules' in data:
|
||||
ruleset = RegexRuleset(**data)
|
||||
else:
|
||||
continue
|
||||
|
||||
# 确保所有规则的 scope 正确
|
||||
for rule in ruleset.rules:
|
||||
rule.scope = RegexScope.CHARACTER
|
||||
rule.characterName = character_name
|
||||
|
||||
self.character_rulesets[character_name] = ruleset
|
||||
logger.info(f"加载角色规则: {character_name} ({len(ruleset.rules)} 条规则)")
|
||||
except Exception as e:
|
||||
logger.error(f"加载角色规则失败 {json_file}: {e}")
|
||||
|
||||
def _load_preset_rules(self):
|
||||
"""加载预设绑定规则"""
|
||||
if not self.presets_path.exists():
|
||||
return
|
||||
|
||||
for json_file in self.presets_path.glob("*.json"):
|
||||
try:
|
||||
preset_name = json_file.stem
|
||||
with open(json_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
if isinstance(data, list):
|
||||
ruleset = self._convert_sillytavern_format(data, preset_name, RegexScope.PRESET)
|
||||
elif isinstance(data, dict) and 'rules' in data:
|
||||
ruleset = RegexRuleset(**data)
|
||||
else:
|
||||
continue
|
||||
|
||||
# 确保所有规则的 scope 正确
|
||||
for rule in ruleset.rules:
|
||||
rule.scope = RegexScope.PRESET
|
||||
rule.presetName = preset_name
|
||||
|
||||
self.preset_rulesets[preset_name] = ruleset
|
||||
logger.info(f"加载预设规则: {preset_name} ({len(ruleset.rules)} 条规则)")
|
||||
except Exception as e:
|
||||
logger.error(f"加载预设规则失败 {json_file}: {e}")
|
||||
|
||||
def _convert_sillytavern_format(
|
||||
self,
|
||||
st_rules: List[dict],
|
||||
name: str,
|
||||
scope: RegexScope = RegexScope.GLOBAL
|
||||
) -> RegexRuleset:
|
||||
"""将 SillyTavern 格式转换为内部格式"""
|
||||
rules = []
|
||||
for idx, st_rule in enumerate(st_rules):
|
||||
find_regex = st_rule.get('findRegex', '')
|
||||
pattern, flags = self._parse_st_regex(find_regex)
|
||||
|
||||
# 解析 placement(默认为 AI_OUTPUT)
|
||||
placement_data = st_rule.get('placement', [2])
|
||||
placement = [RegexPlacement(p) for p in placement_data]
|
||||
|
||||
rule = RegexRule(
|
||||
id=str(uuid4()),
|
||||
scriptName=st_rule.get('scriptName', f"{name} 规则 {idx + 1}"),
|
||||
findRegex=pattern,
|
||||
replaceString=st_rule.get('replaceString', ''),
|
||||
trimStrings=st_rule.get('trimStrings', []),
|
||||
placement=placement,
|
||||
substituteRegex=SubstituteMode(st_rule.get('substituteRegex', 0)),
|
||||
markdownOnly=st_rule.get('markdownOnly', False),
|
||||
promptOnly=st_rule.get('promptOnly', False),
|
||||
runOnEdit=st_rule.get('runOnEdit', True),
|
||||
minDepth=st_rule.get('minDepth', 0),
|
||||
maxDepth=st_rule.get('maxDepth'),
|
||||
scope=scope,
|
||||
characterName=name if scope == RegexScope.CHARACTER else None,
|
||||
presetName=name if scope == RegexScope.PRESET else None,
|
||||
disabled=st_rule.get('disabled', False),
|
||||
order=idx
|
||||
)
|
||||
rules.append(rule)
|
||||
|
||||
ruleset = RegexRuleset(
|
||||
id=str(uuid4()),
|
||||
name=f"{name} 规则集",
|
||||
description=f"从 SillyTavern 导入的规则",
|
||||
rules=rules,
|
||||
isSillyTavernFormat=True
|
||||
)
|
||||
|
||||
return ruleset
|
||||
|
||||
def _parse_st_regex(self, st_regex: str) -> tuple[str, str]:
|
||||
"""解析 SillyTavern 的正则表达式格式 /pattern/flags"""
|
||||
if st_regex.startswith('/') and st_regex.count('/') >= 2:
|
||||
parts = st_regex.split('/')
|
||||
pattern = '/'.join(parts[1:-1])
|
||||
flags = parts[-1] if len(parts) > 2 else ''
|
||||
return pattern, flags
|
||||
else:
|
||||
return st_regex, ''
|
||||
|
||||
def apply_rules_by_placement(
|
||||
self,
|
||||
text: str,
|
||||
placement: int,
|
||||
character_name: Optional[str] = None,
|
||||
preset_name: Optional[str] = None,
|
||||
message_depth: int = 0,
|
||||
is_for_llm: bool = False, # ✅ 新增:是否发送给LLM
|
||||
is_markdown_rendered: bool = False # ✅ 新增:是否已Markdown渲染
|
||||
) -> str:
|
||||
"""
|
||||
根据 placement 应用正则规则
|
||||
|
||||
Args:
|
||||
text: 要处理的文本
|
||||
placement: 应用位置(0-5)
|
||||
character_name: 当前角色卡名称
|
||||
preset_name: 当前预设名称
|
||||
message_depth: 消息深度
|
||||
is_for_llm: 是否用于发送给 LLM(影响 promptOnly 逻辑)
|
||||
is_markdown_rendered: 是否是 Markdown 渲染后的内容(影响 markdownOnly 逻辑)
|
||||
|
||||
Returns:
|
||||
处理后的文本
|
||||
"""
|
||||
rules = self.get_rules_for_context(character_name, preset_name)
|
||||
|
||||
result = text
|
||||
for rule in rules:
|
||||
# ✅ SillyTavern 逻辑:根据 markdownOnly 和 promptOnly 决定是否应用
|
||||
# - 双 false:应用到所有场景(包括保存数据、发送LLM、显示)
|
||||
# - markdownOnly=true:只应用于 Markdown 渲染(前端显示)
|
||||
# - promptOnly=true:只应用于发送给 LLM
|
||||
# - 双 true:应用到所有场景(但不修改存储,由调用方决定)
|
||||
|
||||
# 如果是保存数据的场景(is_for_llm=False 且 is_markdown_rendered=False)
|
||||
# 只应用双 false 的规则
|
||||
if not is_for_llm and not is_markdown_rendered:
|
||||
# 保存数据:只应用双 false 的规则
|
||||
if rule.markdownOnly or rule.promptOnly:
|
||||
continue
|
||||
|
||||
# 如果是发送给 LLM 的场景
|
||||
elif is_for_llm and not is_markdown_rendered:
|
||||
# 不应用 markdownOnly=true 且 promptOnly=false 的规则
|
||||
if rule.markdownOnly and not rule.promptOnly:
|
||||
continue
|
||||
|
||||
# 如果是 Markdown 渲染的场景(前端显示)
|
||||
elif is_markdown_rendered and not is_for_llm:
|
||||
# 不应用 promptOnly=true 且 markdownOnly=false 的规则
|
||||
if rule.promptOnly and not rule.markdownOnly:
|
||||
continue
|
||||
|
||||
# 检查此规则是否适用于当前 placement
|
||||
if placement not in [p.value for p in rule.placement]:
|
||||
continue
|
||||
|
||||
# 检查消息深度限制
|
||||
if message_depth < rule.minDepth:
|
||||
continue
|
||||
if rule.maxDepth is not None and message_depth > rule.maxDepth:
|
||||
continue
|
||||
|
||||
# 应用规则
|
||||
result = self._apply_single_rule(result, rule)
|
||||
|
||||
return result
|
||||
|
||||
def _apply_single_rule(self, text: str, rule: RegexRule) -> str:
|
||||
"""应用单条正则规则"""
|
||||
try:
|
||||
flags = 0
|
||||
if 'i' in rule.findRegex:
|
||||
flags |= re.IGNORECASE
|
||||
if 'm' in rule.findRegex:
|
||||
flags |= re.MULTILINE
|
||||
if 's' in rule.findRegex:
|
||||
flags |= re.DOTALL
|
||||
|
||||
pattern = rule.findRegex.replace('i', '').replace('m', '').replace('s', '')
|
||||
|
||||
if rule.substituteRegex == SubstituteMode.REPLACE_FIRST:
|
||||
result = re.sub(pattern, rule.replaceString, text, count=1, flags=flags)
|
||||
else:
|
||||
result = re.sub(pattern, rule.replaceString, text, flags=flags)
|
||||
|
||||
for trim_str in rule.trimStrings:
|
||||
result = result.replace(trim_str, '')
|
||||
|
||||
return result
|
||||
except re.error as e:
|
||||
logger.error(f"正则表达式错误 [{rule.scriptName}]: {e}")
|
||||
return text
|
||||
|
||||
def get_rules_for_context(
|
||||
self,
|
||||
character_name: Optional[str] = None,
|
||||
preset_name: Optional[str] = None
|
||||
) -> List[RegexRule]:
|
||||
"""
|
||||
根据上下文获取适用的规则列表
|
||||
|
||||
优先级:全局规则 + 角色规则 + 预设规则
|
||||
"""
|
||||
applicable_rules = []
|
||||
|
||||
# 1. 加载全局规则
|
||||
for ruleset in self.global_rulesets.values():
|
||||
for rule in ruleset.rules:
|
||||
if not rule.disabled:
|
||||
applicable_rules.append(rule)
|
||||
|
||||
# 2. 加载角色卡规则
|
||||
if character_name and character_name in self.character_rulesets:
|
||||
ruleset = self.character_rulesets[character_name]
|
||||
for rule in ruleset.rules:
|
||||
if not rule.disabled:
|
||||
applicable_rules.append(rule)
|
||||
|
||||
# 3. 加载预设规则
|
||||
if preset_name and preset_name in self.preset_rulesets:
|
||||
ruleset = self.preset_rulesets[preset_name]
|
||||
for rule in ruleset.rules:
|
||||
if not rule.disabled:
|
||||
applicable_rules.append(rule)
|
||||
|
||||
# 按 order 排序
|
||||
applicable_rules.sort(key=lambda r: r.order)
|
||||
|
||||
return applicable_rules
|
||||
|
||||
def save_ruleset(self, ruleset: RegexRuleset, scope: RegexScope, name: Optional[str] = None):
|
||||
"""保存规则集到文件"""
|
||||
if scope == RegexScope.GLOBAL:
|
||||
file_path = self.global_path / f"{ruleset.id}.json"
|
||||
elif scope == RegexScope.CHARACTER:
|
||||
file_path = self.characters_path / f"{name or 'unknown'}.json"
|
||||
elif scope == RegexScope.PRESET:
|
||||
file_path = self.presets_path / f"{name or 'unknown'}.json"
|
||||
else:
|
||||
raise ValueError(f"未知的作用域: {scope}")
|
||||
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(ruleset.dict(), f, ensure_ascii=False, indent=2)
|
||||
|
||||
logger.info(f"保存规则集到: {file_path}")
|
||||
|
||||
def delete_ruleset(self, scope: RegexScope, name: str):
|
||||
"""删除规则集"""
|
||||
if scope == RegexScope.CHARACTER:
|
||||
file_path = self.characters_path / f"{name}.json"
|
||||
elif scope == RegexScope.PRESET:
|
||||
file_path = self.presets_path / f"{name}.json"
|
||||
else:
|
||||
raise ValueError(f"不能删除全局规则集")
|
||||
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
logger.info(f"删除规则集: {file_path}")
|
||||
|
||||
|
||||
# 全局实例
|
||||
regex_service = RegexService()
|
||||
189
backend/services/script_manager.py
Normal file
189
backend/services/script_manager.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
脚本管理模块
|
||||
|
||||
管理 Tavern Helper 的脚本,支持三种作用域:
|
||||
- GLOBAL: 全局脚本,对所有聊天可用
|
||||
- CHARACTER: 角色脚本,绑定到当前角色卡
|
||||
- PRESET: 预设脚本,绑定到当前预设
|
||||
|
||||
每个脚本包含:
|
||||
- 脚本名称
|
||||
- 脚本内容(JavaScript 代码)
|
||||
- 作者备注
|
||||
- 变量列表(绑定到脚本的变量)
|
||||
- 按钮配置(配合 getButtonEvent 使用)
|
||||
- 启用状态
|
||||
"""
|
||||
from enum import Enum
|
||||
from typing import List, Optional, Dict, Any
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
|
||||
class ScriptScope(str, Enum):
|
||||
"""脚本作用域"""
|
||||
GLOBAL = 'global' # 全局脚本
|
||||
CHARACTER = 'character' # 角色脚本
|
||||
PRESET = 'preset' # 预设脚本
|
||||
|
||||
|
||||
class ScriptVariable(BaseModel):
|
||||
"""脚本变量"""
|
||||
name: str = Field(..., description="变量名")
|
||||
value: Any = Field(..., description="变量值")
|
||||
description: Optional[str] = Field(None, description="变量描述")
|
||||
|
||||
|
||||
class ScriptButton(BaseModel):
|
||||
"""脚本按钮配置"""
|
||||
label: str = Field(..., description="按钮显示文本")
|
||||
event: str = Field(..., description="按钮事件名称(配合 getButtonEvent 使用)")
|
||||
enabled: bool = Field(True, description="是否启用")
|
||||
|
||||
|
||||
class ScriptItem(BaseModel):
|
||||
"""脚本项"""
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="脚本唯一标识符")
|
||||
name: str = Field(..., description="脚本名称")
|
||||
content: str = Field(..., description="脚本内容(JavaScript 代码)")
|
||||
authorNote: Optional[str] = Field(None, description="作者备注")
|
||||
|
||||
# 变量列表
|
||||
variables: List[ScriptVariable] = Field(default_factory=list, description="绑定到脚本的变量")
|
||||
|
||||
# 按钮配置
|
||||
buttons: List[ScriptButton] = Field(default_factory=list, description="按钮配置")
|
||||
|
||||
# 作用域
|
||||
scope: ScriptScope = Field(ScriptScope.GLOBAL, description="脚本作用域")
|
||||
characterName: Optional[str] = Field(None, description="绑定的角色卡名称")
|
||||
presetName: Optional[str] = Field(None, description="绑定的预设名称")
|
||||
|
||||
# 启用状态
|
||||
enabled: bool = Field(True, description="是否启用")
|
||||
|
||||
# 元数据
|
||||
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
|
||||
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="更新时间戳")
|
||||
order: int = Field(0, description="执行顺序")
|
||||
|
||||
|
||||
class ScriptManager:
|
||||
"""脚本管理器"""
|
||||
|
||||
def __init__(self):
|
||||
self.scripts: List[ScriptItem] = []
|
||||
|
||||
def add_script(self, script: ScriptItem):
|
||||
"""添加脚本"""
|
||||
self.scripts.append(script)
|
||||
|
||||
def remove_script(self, script_id: str) -> bool:
|
||||
"""删除脚本"""
|
||||
for i, script in enumerate(self.scripts):
|
||||
if script.id == script_id:
|
||||
self.scripts.pop(i)
|
||||
return True
|
||||
return False
|
||||
|
||||
def update_script(self, script_id: str, updates: Dict[str, Any]) -> bool:
|
||||
"""更新脚本"""
|
||||
for script in self.scripts:
|
||||
if script.id == script_id:
|
||||
for key, value in updates.items():
|
||||
if hasattr(script, key):
|
||||
setattr(script, key, value)
|
||||
script.updatedAt = int(datetime.now().timestamp())
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_scripts_by_scope(self, scope: ScriptScope, filter_name: Optional[str] = None) -> List[ScriptItem]:
|
||||
"""按作用域获取脚本"""
|
||||
scripts = [s for s in self.scripts if s.scope == scope]
|
||||
|
||||
if filter_name:
|
||||
scripts = [s for s in scripts if filter_name.lower() in s.name.lower()]
|
||||
|
||||
return sorted(scripts, key=lambda s: s.order)
|
||||
|
||||
def get_enabled_scripts(self, scope: ScriptScope) -> List[ScriptItem]:
|
||||
"""获取启用的脚本"""
|
||||
return [s for s in self.scripts if s.scope == scope and s.enabled]
|
||||
|
||||
def get_script(self, script_id: str) -> Optional[ScriptItem]:
|
||||
"""获取单个脚本"""
|
||||
for script in self.scripts:
|
||||
if script.id == script_id:
|
||||
return script
|
||||
return None
|
||||
|
||||
def toggle_script(self, script_id: str) -> bool:
|
||||
"""切换脚本启用状态"""
|
||||
for script in self.scripts:
|
||||
if script.id == script_id:
|
||||
script.enabled = not script.enabled
|
||||
script.updatedAt = int(datetime.now().timestamp())
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_all_scripts(self) -> List[ScriptItem]:
|
||||
"""获取所有脚本"""
|
||||
return self.scripts
|
||||
|
||||
def export_scripts(self, scope: Optional[ScriptScope] = None) -> List[Dict]:
|
||||
"""导出脚本"""
|
||||
if scope:
|
||||
scripts = [s for s in self.scripts if s.scope == scope]
|
||||
else:
|
||||
scripts = self.scripts
|
||||
|
||||
return [s.dict() for s in scripts]
|
||||
|
||||
def import_scripts(self, scripts_data: List[Dict], scope: ScriptScope) -> int:
|
||||
"""导入脚本"""
|
||||
count = 0
|
||||
for data in scripts_data:
|
||||
try:
|
||||
script = ScriptItem(**data)
|
||||
script.scope = scope
|
||||
self.scripts.append(script)
|
||||
count += 1
|
||||
except Exception as e:
|
||||
print(f"导入脚本失败: {e}")
|
||||
|
||||
return count
|
||||
|
||||
|
||||
# 全局脚本管理器实例
|
||||
script_manager = ScriptManager()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import json
|
||||
|
||||
# 测试脚本管理
|
||||
manager = ScriptManager()
|
||||
|
||||
# 添加测试脚本
|
||||
script1 = ScriptItem(
|
||||
name="【骰子系统】-自动更新",
|
||||
content="async function getLatestVersion() {\n try {\n const response = await fetch('/api/version');\n return await response.json();\n } catch (e) {\n return null;\n }\n}",
|
||||
authorNote="感谢a佬开源\n以九颜二改为基础进行三改\n@kousakayou",
|
||||
scope=ScriptScope.GLOBAL,
|
||||
variables=[
|
||||
ScriptVariable(name="version", value="4.8.4", description="版本号")
|
||||
],
|
||||
buttons=[
|
||||
ScriptButton(label="检查更新", event="checkUpdate", enabled=True)
|
||||
]
|
||||
)
|
||||
|
||||
manager.add_script(script1)
|
||||
|
||||
# 导出测试
|
||||
print("=== 导出脚本 ===")
|
||||
exported = manager.export_scripts()
|
||||
print(json.dumps(exported, indent=2, ensure_ascii=False))
|
||||
|
||||
print("\n✅ 脚本管理测试完成!")
|
||||
105
backend/services/state_machine_runner.py
Normal file
105
backend/services/state_machine_runner.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
JSON state machine runner for workflow templates.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
try:
|
||||
from backend.models.agent import RunEvent, RunEventType, TurnContext, WorkflowRun, RunStatus
|
||||
from backend.services.tool_registry import ToolRegistry
|
||||
except ImportError:
|
||||
from models.agent import RunEvent, RunEventType, TurnContext, WorkflowRun, RunStatus
|
||||
from services.tool_registry import ToolRegistry
|
||||
|
||||
|
||||
class StateMachineRunner:
|
||||
def __init__(
|
||||
self,
|
||||
definition: Dict[str, Any],
|
||||
registry: ToolRegistry,
|
||||
*,
|
||||
on_event: Optional[Callable[[RunEvent], None]] = None,
|
||||
) -> None:
|
||||
self.definition = definition
|
||||
self.registry = registry
|
||||
self.on_event = on_event
|
||||
self.states: Dict[str, Dict[str, Any]] = definition.get("states", {})
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: Path, registry: ToolRegistry, **kwargs) -> "StateMachineRunner":
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
definition = json.load(f)
|
||||
return cls(definition, registry, **kwargs)
|
||||
|
||||
def _emit(self, run: WorkflowRun, event_type: RunEventType, **payload: Any) -> RunEvent:
|
||||
event = RunEvent(
|
||||
run_id=run.id,
|
||||
type=event_type,
|
||||
state=run.current_state,
|
||||
tool=payload.pop("tool", None),
|
||||
payload=payload,
|
||||
)
|
||||
if self.on_event:
|
||||
self.on_event(event)
|
||||
return event
|
||||
|
||||
async def run(self, run: WorkflowRun, ctx: TurnContext) -> List[RunEvent]:
|
||||
events: List[RunEvent] = []
|
||||
original_on_event = self.on_event
|
||||
|
||||
def collect(event: RunEvent) -> None:
|
||||
events.append(event)
|
||||
if original_on_event:
|
||||
original_on_event(event)
|
||||
|
||||
self.on_event = collect
|
||||
|
||||
initial = self.definition.get("initial")
|
||||
if not initial:
|
||||
raise ValueError("State machine missing 'initial' state")
|
||||
|
||||
current = initial
|
||||
run.status = RunStatus.RUNNING
|
||||
|
||||
try:
|
||||
while current:
|
||||
state_def = self.states.get(current)
|
||||
if not state_def:
|
||||
raise ValueError(f"Unknown state: {current}")
|
||||
|
||||
run.current_state = current
|
||||
events.append(self._emit(run, RunEventType.STATE_ENTER, state=current))
|
||||
|
||||
tool_name = state_def.get("tool")
|
||||
if tool_name:
|
||||
events.append(self._emit(run, RunEventType.TOOL_START, tool=tool_name))
|
||||
await self.registry.execute(tool_name, ctx)
|
||||
events.append(
|
||||
self._emit(
|
||||
run,
|
||||
RunEventType.TOOL_END,
|
||||
tool=tool_name,
|
||||
success=True,
|
||||
)
|
||||
)
|
||||
|
||||
current = state_def.get("next")
|
||||
if current == "end" or current is None:
|
||||
break
|
||||
|
||||
run.status = RunStatus.COMPLETED
|
||||
run.result_content = ctx.generated_content
|
||||
events.append(self._emit(run, RunEventType.COMPLETE))
|
||||
except Exception as exc:
|
||||
run.status = RunStatus.FAILED
|
||||
run.error = str(exc)
|
||||
ctx.error = str(exc)
|
||||
events.append(self._emit(run, RunEventType.ERROR, message=str(exc)))
|
||||
raise
|
||||
finally:
|
||||
self.on_event = original_on_event
|
||||
|
||||
return events
|
||||
188
backend/services/studio_context_service.py
Normal file
188
backend/services/studio_context_service.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""
|
||||
Assemble Studio run prompt context from pipeline snapshot, workflow variables,
|
||||
and node outputs (R2). Does not include full chat history.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from models.studio_models import (
|
||||
PipelineDefinition,
|
||||
PromptBlock,
|
||||
StudioNode,
|
||||
StudioNodeRunState,
|
||||
StudioRun,
|
||||
)
|
||||
|
||||
_NODE_OUTPUT_REF = re.compile(r"^([^.]+)\.output$")
|
||||
|
||||
AUTO_BLOCK_SPECS = (
|
||||
("currentProduct", "目前产物", "auto"),
|
||||
("thinkingFlow", "思考流程", "auto"),
|
||||
("coreGoal", "核心目的", "auto"),
|
||||
("scoringCriteria", "评价标准与优化建议", "auto"),
|
||||
)
|
||||
|
||||
|
||||
def _find_node(pipeline: PipelineDefinition, node_id: str) -> Optional[StudioNode]:
|
||||
for node in pipeline.nodes:
|
||||
if node.id == node_id:
|
||||
return node
|
||||
return None
|
||||
|
||||
|
||||
def _state_map(run: StudioRun) -> Dict[str, StudioNodeRunState]:
|
||||
return {s.nodeId: s for s in run.nodeStates}
|
||||
|
||||
|
||||
def _format_draft(draft: Optional[Dict[str, Any]]) -> str:
|
||||
if not draft:
|
||||
return "(暂无内容)"
|
||||
for key in ("entryContent", "content", "text", "body"):
|
||||
value = draft.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return json.dumps(draft, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _format_scoring(config: Dict[str, Any]) -> str:
|
||||
scoring = config.get("scoring") or {}
|
||||
if not scoring.get("enabled", True):
|
||||
return "(本步骤未启用评价)"
|
||||
dimensions = scoring.get("dimensions") or []
|
||||
if not dimensions:
|
||||
rubric = scoring.get("rubric")
|
||||
if rubric:
|
||||
return str(rubric).strip()
|
||||
return "(未配置评价维度)"
|
||||
lines: List[str] = []
|
||||
for dim in dimensions:
|
||||
name = dim.get("name") or dim.get("id") or "维度"
|
||||
criteria = (dim.get("criteria") or "").strip()
|
||||
lines.append(f"- {name}:{criteria}" if criteria else f"- {name}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _resolve_workflow_ref(ref: str, workflow_variables: Dict[str, Any]) -> str:
|
||||
value = workflow_variables.get(ref)
|
||||
if value is None:
|
||||
return "(尚未可用)"
|
||||
if isinstance(value, str):
|
||||
return value.strip() or "(空)"
|
||||
return json.dumps(value, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _resolve_node_output_ref(
|
||||
ref: str,
|
||||
pipeline: PipelineDefinition,
|
||||
state_by_id: Dict[str, StudioNodeRunState],
|
||||
) -> str:
|
||||
match = _NODE_OUTPUT_REF.match(ref)
|
||||
if not match:
|
||||
return f"(无法解析引用:{ref})"
|
||||
node_id = match.group(1)
|
||||
source_node = _find_node(pipeline, node_id)
|
||||
source_state = state_by_id.get(node_id)
|
||||
label = source_node.displayName if source_node else node_id
|
||||
if not source_state or source_state.status != "completed":
|
||||
return f"(前序步骤「{label}」尚未完成)"
|
||||
return _format_draft(source_state.lastDraft)
|
||||
|
||||
|
||||
def _auto_block_content(
|
||||
block_id: str,
|
||||
node: StudioNode,
|
||||
node_state: Optional[StudioNodeRunState],
|
||||
) -> str:
|
||||
config = node.config or {}
|
||||
if block_id == "currentProduct":
|
||||
return _format_draft(node_state.lastDraft if node_state else None)
|
||||
if block_id == "thinkingFlow":
|
||||
return (config.get("thinkingPrompt") or "").strip() or "(未配置思考流程)"
|
||||
if block_id == "coreGoal":
|
||||
return (config.get("stepGoal") or "").strip() or "(未配置步骤目标)"
|
||||
if block_id == "scoringCriteria":
|
||||
return _format_scoring(config)
|
||||
return ""
|
||||
|
||||
|
||||
def assemble_prompt_blocks(run: StudioRun, node_id: str) -> List[PromptBlock]:
|
||||
"""
|
||||
Build ordered prompt blocks for a worldbook step from inputs[].ref,
|
||||
workflow variables, node outputs, and auto-injected context items.
|
||||
"""
|
||||
pipeline = run.pipelineSnapshot
|
||||
node = _find_node(pipeline, node_id)
|
||||
if not node:
|
||||
return []
|
||||
|
||||
if node.skillId != "studio.worldbook_entry":
|
||||
return []
|
||||
|
||||
state_by_id = _state_map(run)
|
||||
node_state = state_by_id.get(node_id)
|
||||
workflow_variables = dict(run.workflowVariables or {})
|
||||
blocks: List[PromptBlock] = []
|
||||
seen_ids: set[str] = set()
|
||||
|
||||
def append_block(
|
||||
block_id: str,
|
||||
label: str,
|
||||
content: str,
|
||||
source: str,
|
||||
) -> None:
|
||||
if block_id in seen_ids:
|
||||
return
|
||||
seen_ids.add(block_id)
|
||||
blocks.append(
|
||||
PromptBlock(
|
||||
id=block_id,
|
||||
label=label,
|
||||
content=content,
|
||||
source=source,
|
||||
)
|
||||
)
|
||||
|
||||
for inp in node.inputs or []:
|
||||
ref = (inp.ref or "").strip()
|
||||
if not ref:
|
||||
continue
|
||||
label = (inp.label or ref).strip()
|
||||
block_id = f"ref:{ref}"
|
||||
|
||||
if ref.startswith("workflow."):
|
||||
content = _resolve_workflow_ref(ref, workflow_variables)
|
||||
if inp.optional and content in ("(尚未可用)", "(空)"):
|
||||
continue
|
||||
append_block(block_id, label, content, "workflow")
|
||||
continue
|
||||
|
||||
if _NODE_OUTPUT_REF.match(ref):
|
||||
content = _resolve_node_output_ref(ref, pipeline, state_by_id)
|
||||
if inp.optional and content.startswith("(前序步骤"):
|
||||
continue
|
||||
append_block(block_id, label, content, "manual")
|
||||
continue
|
||||
|
||||
append_block(block_id, label, f"(未知引用类型:{ref})", "manual")
|
||||
|
||||
for block_id, label, source in AUTO_BLOCK_SPECS:
|
||||
content = _auto_block_content(block_id, node, node_state)
|
||||
append_block(block_id, label, content, source)
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
def store_context_on_run(run: StudioRun, node_id: Optional[str]) -> StudioRun:
|
||||
"""Attach assembled prompt blocks to run for debug / frontend display."""
|
||||
if not node_id:
|
||||
return run.model_copy(update={"lastPromptBlocks": []})
|
||||
|
||||
node = _find_node(run.pipelineSnapshot, node_id)
|
||||
if not node or node.skillId != "studio.worldbook_entry":
|
||||
return run.model_copy(update={"lastPromptBlocks": []})
|
||||
|
||||
blocks = assemble_prompt_blocks(run, node_id)
|
||||
return run.model_copy(update={"lastPromptBlocks": blocks})
|
||||
456
backend/services/studio_project_service.py
Normal file
456
backend/services/studio_project_service.py
Normal file
@@ -0,0 +1,456 @@
|
||||
"""
|
||||
Load/save Studio projects and skill templates from data/agent/.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from core.config import settings
|
||||
from models.studio_models import (
|
||||
CreateStudioProjectRequest,
|
||||
PipelineDefinition,
|
||||
StudioProject,
|
||||
StudioProjectMeta,
|
||||
StudioProjectSummary,
|
||||
WorkflowTemplateSummary,
|
||||
WorkflowVariablesResponse,
|
||||
WorkflowVariableDef,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_TEMPLATE_ID = "builtin.studio.example"
|
||||
|
||||
POSITION_STRING_MAP = {
|
||||
"after_char": 0,
|
||||
"before_char": 1,
|
||||
"before_example": 2,
|
||||
"after_example": 3,
|
||||
"system": 4,
|
||||
"as_system": 5,
|
||||
"depth": 6,
|
||||
"macro": 7,
|
||||
}
|
||||
|
||||
ACTIVATION_LEGACY_MAP = {
|
||||
"normal": "permanent",
|
||||
"constant": "permanent",
|
||||
"selective": "keyword",
|
||||
}
|
||||
|
||||
|
||||
def _normalize_position(value: Any) -> int:
|
||||
if value is None:
|
||||
return 1
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
if value.isdigit():
|
||||
return int(value)
|
||||
return POSITION_STRING_MAP.get(value, 1)
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
|
||||
|
||||
def _normalize_activation(value: Any) -> str:
|
||||
if not value:
|
||||
return "permanent"
|
||||
text = str(value)
|
||||
return ACTIVATION_LEGACY_MAP.get(text, text)
|
||||
|
||||
|
||||
def _migrate_scoring(scoring: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if not scoring:
|
||||
return {"enabled": True, "dimensions": []}
|
||||
if scoring.get("dimensions"):
|
||||
return scoring
|
||||
rubric = scoring.get("rubric")
|
||||
if rubric:
|
||||
scoring = {**scoring}
|
||||
scoring["dimensions"] = [
|
||||
{
|
||||
"id": "default",
|
||||
"name": "综合质量",
|
||||
"criteria": rubric,
|
||||
}
|
||||
]
|
||||
scoring.pop("rubric", None)
|
||||
return scoring
|
||||
|
||||
|
||||
def _normalize_node(node: Dict[str, Any]) -> Dict[str, Any]:
|
||||
node = dict(node)
|
||||
config = dict(node.get("config") or {})
|
||||
insertion = dict(config.get("insertion") or {})
|
||||
if insertion:
|
||||
insertion["position"] = _normalize_position(insertion.get("position"))
|
||||
insertion["activationType"] = _normalize_activation(
|
||||
insertion.get("activationType")
|
||||
)
|
||||
config["insertion"] = insertion
|
||||
if "scoring" in config:
|
||||
config["scoring"] = _migrate_scoring(dict(config.get("scoring") or {}))
|
||||
node["config"] = config
|
||||
return node
|
||||
|
||||
|
||||
def _parse_node_ref(ref: str, node_ids: set[str]) -> Optional[str]:
|
||||
if not ref or not ref.endswith(".output"):
|
||||
return None
|
||||
node_id = ref[: -len(".output")]
|
||||
return node_id if node_id in node_ids else None
|
||||
|
||||
|
||||
def _build_node_dependency_edges(pipeline: Dict[str, Any]) -> List[tuple[str, str]]:
|
||||
nodes = pipeline.get("nodes") or []
|
||||
node_ids = {n["id"] for n in nodes if n.get("id")}
|
||||
edges: List[tuple[str, str]] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for node in nodes:
|
||||
to_id = node.get("id")
|
||||
if not to_id:
|
||||
continue
|
||||
for inp in node.get("inputs") or []:
|
||||
src = _parse_node_ref(inp.get("ref", ""), node_ids)
|
||||
if not src or src == to_id:
|
||||
continue
|
||||
pair = (src, to_id)
|
||||
if pair in seen:
|
||||
continue
|
||||
seen.add(pair)
|
||||
edges.append(pair)
|
||||
return edges
|
||||
|
||||
|
||||
def _detect_reference_cycles(pipeline: Dict[str, Any]) -> List[List[str]]:
|
||||
nodes = pipeline.get("nodes") or []
|
||||
node_ids = [n["id"] for n in nodes if n.get("id")]
|
||||
adj: Dict[str, List[str]] = {nid: [] for nid in node_ids}
|
||||
for src, dst in _build_node_dependency_edges(pipeline):
|
||||
adj[src].append(dst)
|
||||
|
||||
cycles: List[List[str]] = []
|
||||
visited: set[str] = set()
|
||||
stack: set[str] = set()
|
||||
path: List[str] = []
|
||||
|
||||
def dfs(node_id: str) -> None:
|
||||
visited.add(node_id)
|
||||
stack.add(node_id)
|
||||
path.append(node_id)
|
||||
for nxt in adj.get(node_id, []):
|
||||
if nxt not in visited:
|
||||
dfs(nxt)
|
||||
elif nxt in stack:
|
||||
start = path.index(nxt)
|
||||
if start >= 0:
|
||||
cycles.append(path[start:] + [nxt])
|
||||
path.pop()
|
||||
stack.discard(node_id)
|
||||
|
||||
for nid in node_ids:
|
||||
if nid not in visited:
|
||||
dfs(nid)
|
||||
return cycles
|
||||
|
||||
|
||||
def _validate_pipeline_refs(pipeline: Dict[str, Any]) -> None:
|
||||
cycles = _detect_reference_cycles(pipeline)
|
||||
if not cycles:
|
||||
return
|
||||
nodes = {n["id"]: n.get("displayName", n["id"]) for n in pipeline.get("nodes") or []}
|
||||
first = cycles[0]
|
||||
chain = " → ".join(nodes.get(nid, nid) for nid in first)
|
||||
raise ValueError(f"流水线存在循环引用:{chain}")
|
||||
|
||||
|
||||
def _normalize_pipeline_dict(pipeline: Dict[str, Any]) -> Dict[str, Any]:
|
||||
pipeline = dict(pipeline)
|
||||
nodes = pipeline.get("nodes") or []
|
||||
pipeline["nodes"] = [_normalize_node(n) for n in nodes]
|
||||
return pipeline
|
||||
|
||||
|
||||
def _read_json(path: Path) -> Any:
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _write_json(path: Path, data: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _slugify(name: str) -> str:
|
||||
slug = re.sub(r"[^\w\u4e00-\u9fff-]+", "-", name.strip(), flags=re.UNICODE)
|
||||
slug = re.sub(r"-+", "-", slug).strip("-").lower()
|
||||
return slug or "project"
|
||||
|
||||
|
||||
class StudioProjectService:
|
||||
@property
|
||||
def projects_root(self) -> Path:
|
||||
return settings.AGENT_STUDIO_PROJECTS_PATH
|
||||
|
||||
@property
|
||||
def templates_root(self) -> Path:
|
||||
return settings.AGENT_TEMPLATES_PATH
|
||||
|
||||
def _project_dir(self, project_id: str) -> Path:
|
||||
return self.projects_root / project_id
|
||||
|
||||
def _meta_path(self, project_id: str) -> Path:
|
||||
return self._project_dir(project_id) / "meta.json"
|
||||
|
||||
def _pipeline_path(self, project_id: str) -> Path:
|
||||
return self._project_dir(project_id) / "pipeline.json"
|
||||
|
||||
def list_projects(self) -> List[StudioProjectSummary]:
|
||||
root = self.projects_root
|
||||
if not root.exists():
|
||||
return []
|
||||
summaries: List[StudioProjectSummary] = []
|
||||
for child in sorted(root.iterdir()):
|
||||
if not child.is_dir():
|
||||
continue
|
||||
meta_path = child / "meta.json"
|
||||
if not meta_path.exists():
|
||||
continue
|
||||
meta = _read_json(meta_path)
|
||||
summaries.append(
|
||||
StudioProjectSummary(
|
||||
id=meta.get("id", child.name),
|
||||
name=meta.get("name", child.name),
|
||||
description=meta.get("description", ""),
|
||||
updatedAt=meta.get("updatedAt", ""),
|
||||
)
|
||||
)
|
||||
return summaries
|
||||
|
||||
def get_project(self, project_id: str) -> StudioProject:
|
||||
meta_path = self._meta_path(project_id)
|
||||
pipeline_path = self._pipeline_path(project_id)
|
||||
if not meta_path.exists() or not pipeline_path.exists():
|
||||
raise FileNotFoundError(f"Studio project not found: {project_id}")
|
||||
meta = _read_json(meta_path)
|
||||
pipeline = _normalize_pipeline_dict(_read_json(pipeline_path))
|
||||
return StudioProject(
|
||||
meta=StudioProjectMeta(**meta),
|
||||
pipeline=PipelineDefinition(**pipeline),
|
||||
)
|
||||
|
||||
def update_project_bindings(
|
||||
self,
|
||||
project_id: str,
|
||||
character_id: str,
|
||||
worldbook_id: str,
|
||||
) -> StudioProject:
|
||||
meta_path = self._meta_path(project_id)
|
||||
if not meta_path.exists():
|
||||
raise FileNotFoundError(f"Studio project not found: {project_id}")
|
||||
meta = _read_json(meta_path)
|
||||
meta["characterId"] = character_id
|
||||
meta["worldbookId"] = worldbook_id
|
||||
meta["updatedAt"] = datetime.now().isoformat()
|
||||
_write_json(meta_path, meta)
|
||||
return self.get_project(project_id)
|
||||
|
||||
def update_project_meta(
|
||||
self,
|
||||
project_id: str,
|
||||
*,
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
) -> StudioProject:
|
||||
meta_path = self._meta_path(project_id)
|
||||
if not meta_path.exists():
|
||||
raise FileNotFoundError(f"Studio project not found: {project_id}")
|
||||
meta = _read_json(meta_path)
|
||||
if name is not None:
|
||||
meta["name"] = name.strip()
|
||||
if description is not None:
|
||||
meta["description"] = description
|
||||
meta["updatedAt"] = datetime.now().isoformat()
|
||||
_write_json(meta_path, meta)
|
||||
return self.get_project(project_id)
|
||||
|
||||
def save_pipeline(self, project_id: str, pipeline: PipelineDefinition) -> StudioProject:
|
||||
meta_path = self._meta_path(project_id)
|
||||
if not meta_path.exists():
|
||||
raise FileNotFoundError(f"Studio project not found: {project_id}")
|
||||
normalized = _normalize_pipeline_dict(pipeline.model_dump(exclude_none=True))
|
||||
_validate_pipeline_refs(normalized)
|
||||
meta = _read_json(meta_path)
|
||||
now = datetime.now().isoformat()
|
||||
meta["updatedAt"] = now
|
||||
_write_json(meta_path, meta)
|
||||
_write_json(self._pipeline_path(project_id), normalized)
|
||||
return self.get_project(project_id)
|
||||
|
||||
def list_workflow_templates(self) -> List[WorkflowTemplateSummary]:
|
||||
root = self.templates_root
|
||||
if not root.exists():
|
||||
return []
|
||||
summaries: List[WorkflowTemplateSummary] = []
|
||||
for child in sorted(root.iterdir()):
|
||||
if not child.is_dir():
|
||||
continue
|
||||
meta_path = child / "meta.json"
|
||||
if not meta_path.exists():
|
||||
continue
|
||||
meta = _read_json(meta_path)
|
||||
summaries.append(
|
||||
WorkflowTemplateSummary(
|
||||
id=meta.get("id", child.name),
|
||||
name=meta.get("name", child.name),
|
||||
description=meta.get("description", ""),
|
||||
)
|
||||
)
|
||||
return summaries
|
||||
|
||||
def get_workflow_variables(self, project_id: Optional[str] = None) -> WorkflowVariablesResponse:
|
||||
path = settings.AGENT_WORKFLOW_VARIABLES_FILE
|
||||
if path.exists():
|
||||
raw = _read_json(path)
|
||||
else:
|
||||
raw = {
|
||||
"builtIn": [
|
||||
{"ref": "workflow.goal", "label": "工作流目标文本", "description": ""},
|
||||
{"ref": "workflow.boundWorldbook", "label": "绑定世界书摘要", "description": ""},
|
||||
{"ref": "workflow.boundCharacter", "label": "绑定角色卡摘要", "description": ""},
|
||||
],
|
||||
"dynamicSuffixes": [
|
||||
{"suffix": ".output", "labelPattern": "{displayName} · 上轮产物"},
|
||||
{"suffix": ".entryDraft", "labelPattern": "{displayName} · 条目草稿"},
|
||||
],
|
||||
}
|
||||
built_in = [
|
||||
WorkflowVariableDef(**item) for item in raw.get("builtIn", [])
|
||||
]
|
||||
dynamic: List[WorkflowVariableDef] = []
|
||||
suffixes = raw.get("dynamicSuffixes") or [
|
||||
{"suffix": ".output", "labelPattern": "{displayName} · 世界书条目"},
|
||||
]
|
||||
if project_id:
|
||||
try:
|
||||
project = self.get_project(project_id)
|
||||
for node in project.pipeline.nodes:
|
||||
if not node.enabled:
|
||||
continue
|
||||
if node.skillId != "studio.worldbook_entry":
|
||||
continue
|
||||
for suffix_def in suffixes:
|
||||
suffix = suffix_def.get("suffix", ".output")
|
||||
if suffix != ".output":
|
||||
continue
|
||||
pattern = suffix_def.get(
|
||||
"labelPattern", "{displayName} · 世界书条目"
|
||||
)
|
||||
ref = f"{node.id}{suffix}"
|
||||
label = pattern.replace("{displayName}", node.displayName)
|
||||
dynamic.append(
|
||||
WorkflowVariableDef(ref=ref, label=label, description="")
|
||||
)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return WorkflowVariablesResponse(builtIn=built_in, dynamic=dynamic)
|
||||
|
||||
def get_skill_templates(self) -> Dict[str, Any]:
|
||||
path = settings.AGENT_SKILL_TEMPLATES_FILE
|
||||
if not path.exists():
|
||||
raise FileNotFoundError("skill_templates.json not found")
|
||||
return _read_json(path)
|
||||
|
||||
def get_niches(self) -> Dict[str, Any]:
|
||||
path = settings.AGENT_NICHES_FILE
|
||||
if not path.exists():
|
||||
return {"niches": []}
|
||||
return _read_json(path)
|
||||
|
||||
def _unique_project_id(self, base_id: str) -> str:
|
||||
candidate = base_id
|
||||
n = 1
|
||||
while self._project_dir(candidate).exists():
|
||||
candidate = f"{base_id}-{n}"
|
||||
n += 1
|
||||
return candidate
|
||||
|
||||
def create_project(self, req: CreateStudioProjectRequest) -> StudioProject:
|
||||
template_id = req.template_id or DEFAULT_TEMPLATE_ID
|
||||
template_dir = self.templates_root / template_id
|
||||
if not template_dir.exists():
|
||||
raise FileNotFoundError(f"Studio template not found: {template_id}")
|
||||
|
||||
base_id = req.project_id or _slugify(req.name)
|
||||
project_id = self._unique_project_id(base_id)
|
||||
dest = self._project_dir(project_id)
|
||||
dest.mkdir(parents=True, exist_ok=False)
|
||||
|
||||
template_meta = _read_json(template_dir / "meta.json")
|
||||
template_pipeline = _read_json(template_dir / "pipeline.json")
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
meta = {
|
||||
"id": project_id,
|
||||
"name": req.name,
|
||||
"description": template_meta.get("description", ""),
|
||||
"templateId": template_id,
|
||||
"characterId": None,
|
||||
"worldbookId": None,
|
||||
"createdAt": now,
|
||||
"updatedAt": now,
|
||||
}
|
||||
_write_json(dest / "meta.json", meta)
|
||||
_write_json(dest / "pipeline.json", _normalize_pipeline_dict(template_pipeline))
|
||||
return self.get_project(project_id)
|
||||
|
||||
def delete_project(self, project_id: str) -> None:
|
||||
project_dir = self._project_dir(project_id)
|
||||
if not project_dir.exists():
|
||||
raise FileNotFoundError(f"Studio project not found: {project_id}")
|
||||
shutil.rmtree(project_dir)
|
||||
runs_dir = settings.AGENT_STUDIO_RUNS_PATH / project_id
|
||||
if runs_dir.exists():
|
||||
shutil.rmtree(runs_dir)
|
||||
|
||||
def ensure_default_project(self) -> None:
|
||||
"""Copy example template into default project if missing."""
|
||||
default_dir = self._project_dir("default")
|
||||
if default_dir.exists():
|
||||
return
|
||||
template_dir = self.templates_root / DEFAULT_TEMPLATE_ID
|
||||
if not template_dir.exists():
|
||||
logger.warning("builtin.studio.example template missing; skip default project seed")
|
||||
return
|
||||
default_dir.mkdir(parents=True, exist_ok=True)
|
||||
template_meta = _read_json(template_dir / "meta.json")
|
||||
now = datetime.now().isoformat()
|
||||
meta = {
|
||||
"id": "default",
|
||||
"name": "示例角色项目",
|
||||
"description": template_meta.get("description", ""),
|
||||
"templateId": DEFAULT_TEMPLATE_ID,
|
||||
"characterId": None,
|
||||
"worldbookId": None,
|
||||
"createdAt": now,
|
||||
"updatedAt": now,
|
||||
}
|
||||
_write_json(default_dir / "meta.json", meta)
|
||||
shutil.copy2(template_dir / "pipeline.json", default_dir / "pipeline.json")
|
||||
|
||||
|
||||
studio_project_service = StudioProjectService()
|
||||
|
||||
try:
|
||||
studio_project_service.ensure_default_project()
|
||||
except Exception as _seed_err:
|
||||
logger.warning("Studio default project seed skipped: %s", _seed_err)
|
||||
861
backend/services/studio_run_service.py
Normal file
861
backend/services/studio_run_service.py
Normal file
@@ -0,0 +1,861 @@
|
||||
"""
|
||||
Create and load Studio pipeline runs with frozen pipeline snapshots.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, AsyncGenerator, Dict, List, Optional
|
||||
|
||||
from core.config import settings
|
||||
from models.studio_models import (
|
||||
PipelineDefinition,
|
||||
StudioNode,
|
||||
StudioNodeRunState,
|
||||
StudioRun,
|
||||
StudioRunStatus,
|
||||
StudioRunSummary,
|
||||
TurnSnapshot,
|
||||
)
|
||||
from services.character_service import CharacterService
|
||||
from services.studio_context_service import assemble_prompt_blocks, store_context_on_run
|
||||
from services.studio_project_service import studio_project_service
|
||||
from services.studio_step_respond import (
|
||||
resolve_api_config,
|
||||
studio_step_respond,
|
||||
studio_step_respond_stream,
|
||||
)
|
||||
from models.converters import WorldBookConverter
|
||||
from services.worldbook_service import worldbook_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict:
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _write_json(path: Path, data: dict) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _build_node_states(pipeline: PipelineDefinition) -> tuple[list[StudioNodeRunState], Optional[str]]:
|
||||
"""First enabled node is active; other enabled nodes pending; disabled skipped."""
|
||||
states: list[StudioNodeRunState] = []
|
||||
first_active_id: Optional[str] = None
|
||||
seen_active = False
|
||||
|
||||
for node in pipeline.nodes:
|
||||
if not node.enabled:
|
||||
status = "skipped"
|
||||
elif not seen_active:
|
||||
status = "active"
|
||||
first_active_id = node.id
|
||||
seen_active = True
|
||||
else:
|
||||
status = "pending"
|
||||
|
||||
states.append(
|
||||
StudioNodeRunState(
|
||||
nodeId=node.id,
|
||||
displayName=node.displayName,
|
||||
skillId=node.skillId,
|
||||
status=status,
|
||||
loopUntilSatisfied=node.loopUntilSatisfied,
|
||||
)
|
||||
)
|
||||
|
||||
return states, first_active_id
|
||||
|
||||
|
||||
def _find_node(pipeline: PipelineDefinition, node_id: str) -> Optional[StudioNode]:
|
||||
for node in pipeline.nodes:
|
||||
if node.id == node_id:
|
||||
return node
|
||||
return None
|
||||
|
||||
|
||||
def _next_enabled_node_id(pipeline: PipelineDefinition, after_node_id: str) -> Optional[str]:
|
||||
seen = False
|
||||
for node in pipeline.nodes:
|
||||
if node.id == after_node_id:
|
||||
seen = True
|
||||
continue
|
||||
if seen and node.enabled:
|
||||
return node.id
|
||||
return None
|
||||
|
||||
|
||||
def _snapshot_node_state(state: StudioNodeRunState) -> TurnSnapshot:
|
||||
return TurnSnapshot(
|
||||
lastDraft=copy.deepcopy(state.lastDraft) if state.lastDraft else None,
|
||||
lastToolResponse=(
|
||||
state.lastToolResponse.model_copy()
|
||||
if state.lastToolResponse
|
||||
else None
|
||||
),
|
||||
stepMessages=[m.model_copy() for m in (state.stepMessages or [])],
|
||||
timestamp=datetime.now().isoformat(),
|
||||
)
|
||||
|
||||
|
||||
def _apply_snapshot(
|
||||
state: StudioNodeRunState, snapshot: TurnSnapshot
|
||||
) -> StudioNodeRunState:
|
||||
updated = state.model_copy()
|
||||
updated.lastDraft = (
|
||||
copy.deepcopy(snapshot.lastDraft) if snapshot.lastDraft else None
|
||||
)
|
||||
updated.lastToolResponse = (
|
||||
snapshot.lastToolResponse.model_copy()
|
||||
if snapshot.lastToolResponse
|
||||
else None
|
||||
)
|
||||
updated.stepMessages = [
|
||||
m.model_copy() for m in (snapshot.stepMessages or [])
|
||||
]
|
||||
return updated
|
||||
|
||||
|
||||
def _find_last_user_message_index(step_messages: list) -> int:
|
||||
for i in range(len(step_messages) - 1, -1, -1):
|
||||
if step_messages[i].role == "user":
|
||||
return i
|
||||
return -1
|
||||
|
||||
|
||||
class StudioRunService:
|
||||
def __init__(self) -> None:
|
||||
self._character_service = CharacterService()
|
||||
|
||||
@property
|
||||
def runs_root(self) -> Path:
|
||||
return settings.AGENT_STUDIO_RUNS_PATH
|
||||
|
||||
def _project_runs_dir(self, project_id: str) -> Path:
|
||||
return self.runs_root / project_id
|
||||
|
||||
def _run_dir(self, project_id: str, run_id: str) -> Path:
|
||||
return self._project_runs_dir(project_id) / run_id
|
||||
|
||||
def _run_path(self, project_id: str, run_id: str) -> Path:
|
||||
return self._run_dir(project_id, run_id) / "run.json"
|
||||
|
||||
def _save_run(self, project_id: str, run_id: str, run: StudioRun) -> None:
|
||||
_write_json(
|
||||
self._run_path(project_id, run_id),
|
||||
run.model_dump(mode="json"),
|
||||
)
|
||||
|
||||
def create_run(self, project_id: str) -> StudioRun:
|
||||
project = studio_project_service.get_project(project_id)
|
||||
snapshot = PipelineDefinition(**copy.deepcopy(project.pipeline.model_dump()))
|
||||
now = datetime.now().isoformat()
|
||||
run_id = str(uuid.uuid4())
|
||||
node_states, current_node_id = _build_node_states(snapshot)
|
||||
|
||||
has_active = current_node_id is not None
|
||||
run = StudioRun(
|
||||
id=run_id,
|
||||
projectId=project_id,
|
||||
status=StudioRunStatus.RUNNING if has_active else StudioRunStatus.PENDING,
|
||||
pipelineSnapshot=snapshot,
|
||||
pipelineVersionNote=now,
|
||||
currentNodeId=current_node_id,
|
||||
nodeStates=node_states,
|
||||
workflowVariables={"workflow.goal": snapshot.workflowGoal},
|
||||
createdAt=now,
|
||||
updatedAt=now,
|
||||
)
|
||||
self._save_run(project_id, run_id, run)
|
||||
return run
|
||||
|
||||
def list_runs(self, project_id: str) -> List[StudioRunSummary]:
|
||||
root = self._project_runs_dir(project_id)
|
||||
if not root.exists():
|
||||
return []
|
||||
|
||||
summaries: List[StudioRunSummary] = []
|
||||
for child in sorted(root.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True):
|
||||
if not child.is_dir():
|
||||
continue
|
||||
run_path = child / "run.json"
|
||||
if not run_path.exists():
|
||||
continue
|
||||
raw = _read_json(run_path)
|
||||
summaries.append(
|
||||
StudioRunSummary(
|
||||
id=raw.get("id", child.name),
|
||||
projectId=raw.get("projectId", project_id),
|
||||
status=StudioRunStatus(raw.get("status", StudioRunStatus.PENDING.value)),
|
||||
currentNodeId=raw.get("currentNodeId"),
|
||||
title=raw.get("title", ""),
|
||||
createdAt=raw.get("createdAt", ""),
|
||||
updatedAt=raw.get("updatedAt", ""),
|
||||
)
|
||||
)
|
||||
return summaries
|
||||
|
||||
def get_run(self, project_id: str, run_id: str) -> StudioRun:
|
||||
run_path = self._run_path(project_id, run_id)
|
||||
if not run_path.exists():
|
||||
raise FileNotFoundError(f"Studio run not found: {project_id}/{run_id}")
|
||||
run = StudioRun(**_read_json(run_path))
|
||||
return run
|
||||
|
||||
def _validate_display_params(
|
||||
self, node: StudioNode, display_params: Dict[str, str]
|
||||
) -> Dict[str, str]:
|
||||
normalized: Dict[str, str] = {}
|
||||
for dp in node.displayParams:
|
||||
raw = display_params.get(dp.key, "")
|
||||
value = (raw or "").strip()
|
||||
if dp.required and not value:
|
||||
raise ValueError(f"缺少必填项:{dp.label}")
|
||||
normalized[dp.key] = value
|
||||
return normalized
|
||||
|
||||
def _execute_init_bind(
|
||||
self,
|
||||
project_id: str,
|
||||
run: StudioRun,
|
||||
node: StudioNode,
|
||||
display_params: Dict[str, str],
|
||||
) -> tuple[Dict[str, Any], Dict[str, Any]]:
|
||||
params = self._validate_display_params(node, display_params)
|
||||
character_name = params.get("characterName", "")
|
||||
worldbook_name = params.get("worldbookName", "")
|
||||
|
||||
if self._character_service.get_character_by_name(character_name):
|
||||
raise ValueError(f"角色「{character_name}」已存在")
|
||||
if worldbook_service._get_worldbook_path(worldbook_name).exists():
|
||||
raise ValueError(f"世界书「{worldbook_name}」已存在")
|
||||
|
||||
worldbook = worldbook_service.create_worldbook(worldbook_name)
|
||||
worldbook_id = worldbook["id"]
|
||||
|
||||
character = self._character_service.create_character(
|
||||
{
|
||||
"name": character_name,
|
||||
"description": "",
|
||||
"personality": "",
|
||||
"scenario": "",
|
||||
"first_mes": "",
|
||||
"mes_example": "",
|
||||
"categories": [],
|
||||
"tags": [],
|
||||
"worldInfoId": worldbook_id,
|
||||
}
|
||||
)
|
||||
character_id = character.id
|
||||
|
||||
studio_project_service.update_project_bindings(
|
||||
project_id, character_id, worldbook_id
|
||||
)
|
||||
|
||||
workflow_variables = dict(run.workflowVariables or {})
|
||||
workflow_variables["workflow.goal"] = run.pipelineSnapshot.workflowGoal
|
||||
workflow_variables["workflow.boundCharacter"] = (
|
||||
f"名称:{character_name}\nID:{character_id}"
|
||||
)
|
||||
workflow_variables["workflow.boundWorldbook"] = (
|
||||
f"名称:{worldbook_name}\nID:{worldbook_id}"
|
||||
)
|
||||
|
||||
last_draft = {
|
||||
"displayParams": params,
|
||||
"characterId": character_id,
|
||||
"worldbookId": worldbook_id,
|
||||
"characterName": character_name,
|
||||
"worldbookName": worldbook_name,
|
||||
}
|
||||
return workflow_variables, last_draft
|
||||
|
||||
@staticmethod
|
||||
def _parse_keyword_field(raw: Any) -> List[str]:
|
||||
if raw is None:
|
||||
return []
|
||||
if isinstance(raw, list):
|
||||
return [str(x).strip() for x in raw if str(x).strip()]
|
||||
text = str(raw).strip()
|
||||
if not text:
|
||||
return []
|
||||
return [part.strip() for part in text.replace(",", ",").split(",") if part.strip()]
|
||||
|
||||
def _resolve_bound_worldbook_name(
|
||||
self, project_id: str, run: StudioRun
|
||||
) -> str:
|
||||
for state in run.nodeStates:
|
||||
if state.skillId != "studio.init_bind" or state.status != "completed":
|
||||
continue
|
||||
draft = state.lastDraft or {}
|
||||
name = (draft.get("worldbookName") or "").strip()
|
||||
if name:
|
||||
return name
|
||||
|
||||
try:
|
||||
project = studio_project_service.get_project(project_id)
|
||||
worldbook_id = project.meta.worldbookId
|
||||
except FileNotFoundError:
|
||||
worldbook_id = None
|
||||
|
||||
if worldbook_id:
|
||||
for summary in worldbook_service.list_worldbooks():
|
||||
wb_name = summary.get("name")
|
||||
if not wb_name:
|
||||
continue
|
||||
try:
|
||||
data = worldbook_service.get_worldbook(wb_name)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
if data.get("id") == worldbook_id:
|
||||
return wb_name
|
||||
|
||||
raise ValueError("未找到绑定的世界书,请先完成「创建并绑定」步骤")
|
||||
|
||||
def _build_worldbook_entry_payload(
|
||||
self, node: StudioNode, draft: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
insertion = (node.config or {}).get("insertion") or {}
|
||||
content = (draft.get("entryContent") or "").strip()
|
||||
if not content:
|
||||
raise ValueError("当前步骤产物为空,请先生成世界书条目内容后再进入下一步")
|
||||
|
||||
activation = (insertion.get("activationType") or "permanent").strip()
|
||||
position = insertion.get("position", 0)
|
||||
if isinstance(position, str):
|
||||
position = WorldBookConverter.POSITION_MAP_ST_TO_INTERNAL.get(position, 0)
|
||||
|
||||
key = self._parse_keyword_field(
|
||||
draft.get("insertionKey") or insertion.get("key")
|
||||
)
|
||||
keysecondary = self._parse_keyword_field(insertion.get("keysecondary"))
|
||||
comment = (
|
||||
(draft.get("insertionComment") or insertion.get("comment") or "").strip()
|
||||
or f"Studio · {node.displayName}"
|
||||
)
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"content": content,
|
||||
"comment": comment,
|
||||
"activationType": activation,
|
||||
"position": position,
|
||||
"key": key,
|
||||
"keysecondary": keysecondary,
|
||||
"order": insertion.get("order", 100),
|
||||
"depth": insertion.get("depth", 4),
|
||||
"probability": insertion.get("probability", 100),
|
||||
"group": insertion.get("group") or [],
|
||||
"disable": bool(insertion.get("disable", False)),
|
||||
}
|
||||
if activation == "rag" and insertion.get("ragConfig"):
|
||||
payload["ragConfig"] = insertion["ragConfig"]
|
||||
return payload
|
||||
|
||||
def _write_worldbook_entry_on_advance(
|
||||
self,
|
||||
worldbook_name: str,
|
||||
node: StudioNode,
|
||||
draft: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
entry_payload = self._build_worldbook_entry_payload(node, draft)
|
||||
try:
|
||||
return worldbook_service.append_entry(worldbook_name, entry_payload)
|
||||
except FileNotFoundError:
|
||||
raise ValueError(f"世界书「{worldbook_name}」不存在,无法写入条目")
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"写入世界书失败:{exc}") from exc
|
||||
except OSError as exc:
|
||||
raise ValueError(f"写入世界书失败:{exc}") from exc
|
||||
|
||||
def advance_run(
|
||||
self,
|
||||
project_id: str,
|
||||
run_id: str,
|
||||
display_params: Optional[Dict[str, str]] = None,
|
||||
) -> StudioRun:
|
||||
run = self.get_run(project_id, run_id)
|
||||
if run.status not in (StudioRunStatus.RUNNING, StudioRunStatus.PENDING):
|
||||
raise ValueError("运行已结束,无法继续推进")
|
||||
|
||||
current_node_id = run.currentNodeId
|
||||
if not current_node_id:
|
||||
raise ValueError("当前运行无活动节点")
|
||||
|
||||
current_node = _find_node(run.pipelineSnapshot, current_node_id)
|
||||
if not current_node:
|
||||
raise ValueError(f"节点不存在:{current_node_id}")
|
||||
|
||||
current_state = next(
|
||||
(s for s in run.nodeStates if s.nodeId == current_node_id), None
|
||||
)
|
||||
if not current_state or current_state.status != "active":
|
||||
raise ValueError("当前节点不可执行")
|
||||
|
||||
workflow_variables = dict(run.workflowVariables or {})
|
||||
last_draft: Optional[Dict[str, Any]] = None
|
||||
|
||||
if current_node.skillId == "studio.init_bind":
|
||||
if not display_params:
|
||||
raise ValueError("请填写引导表单后再提交")
|
||||
workflow_variables, last_draft = self._execute_init_bind(
|
||||
project_id, run, current_node, display_params
|
||||
)
|
||||
elif current_node.skillId == "studio.worldbook_entry":
|
||||
if not current_state.lastDraft:
|
||||
raise ValueError("请先生成并确认当前产物后再进入下一步")
|
||||
worldbook_name = self._resolve_bound_worldbook_name(project_id, run)
|
||||
written_entry = self._write_worldbook_entry_on_advance(
|
||||
worldbook_name,
|
||||
current_node,
|
||||
current_state.lastDraft,
|
||||
)
|
||||
last_draft = copy.deepcopy(current_state.lastDraft)
|
||||
last_draft["writtenEntryUid"] = written_entry.get("uid")
|
||||
last_draft["writtenWorldbookName"] = worldbook_name
|
||||
else:
|
||||
raise NotImplementedError(
|
||||
f"技能「{current_node.skillId}」的执行尚未实现(R2+)"
|
||||
)
|
||||
|
||||
next_node_id = _next_enabled_node_id(run.pipelineSnapshot, current_node_id)
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
new_node_states: list[StudioNodeRunState] = []
|
||||
for state in run.nodeStates:
|
||||
updated = state.model_copy()
|
||||
if state.nodeId == current_node_id:
|
||||
updated.status = "completed"
|
||||
if last_draft is not None:
|
||||
updated.lastDraft = last_draft
|
||||
elif next_node_id and state.nodeId == next_node_id:
|
||||
updated.status = "active"
|
||||
new_node_states.append(updated)
|
||||
|
||||
new_status = (
|
||||
StudioRunStatus.COMPLETED if not next_node_id else StudioRunStatus.RUNNING
|
||||
)
|
||||
updated_run = run.model_copy(
|
||||
update={
|
||||
"currentNodeId": next_node_id,
|
||||
"nodeStates": new_node_states,
|
||||
"workflowVariables": workflow_variables,
|
||||
"status": new_status,
|
||||
"updatedAt": now,
|
||||
}
|
||||
)
|
||||
updated_run = store_context_on_run(updated_run, next_node_id)
|
||||
self._save_run(project_id, run_id, updated_run)
|
||||
return updated_run
|
||||
|
||||
async def send_run_message(
|
||||
self,
|
||||
project_id: str,
|
||||
run_id: str,
|
||||
content: str,
|
||||
*,
|
||||
stream: bool = False,
|
||||
profile_id: Optional[str] = None,
|
||||
api_config: Optional[Dict[str, str]] = None,
|
||||
) -> StudioRun:
|
||||
"""Send a user message to the active worldbook step and invoke LLM (R3)."""
|
||||
trimmed = (content or "").strip()
|
||||
if not trimmed:
|
||||
raise ValueError("消息内容不能为空")
|
||||
|
||||
run, current_node, current_state, current_node_id = self._prepare_run_message(
|
||||
project_id, run_id, trimmed
|
||||
)
|
||||
|
||||
resolved_api = resolve_api_config(profile_id, api_config)
|
||||
prompt_blocks = assemble_prompt_blocks(run, current_node_id)
|
||||
step_messages = list(current_state.stepMessages or [])
|
||||
|
||||
last_draft, last_tool_response, user_msg, assistant_msg = (
|
||||
await studio_step_respond(
|
||||
node=current_node,
|
||||
prompt_blocks=prompt_blocks,
|
||||
step_messages=step_messages,
|
||||
user_message=trimmed,
|
||||
existing_draft=current_state.lastDraft,
|
||||
api_config=resolved_api,
|
||||
stream=stream,
|
||||
)
|
||||
)
|
||||
|
||||
return self._persist_run_message_turn(
|
||||
project_id,
|
||||
run_id,
|
||||
run,
|
||||
current_node_id,
|
||||
prompt_blocks,
|
||||
step_messages,
|
||||
last_draft,
|
||||
last_tool_response,
|
||||
user_msg,
|
||||
assistant_msg,
|
||||
)
|
||||
|
||||
async def send_run_message_stream(
|
||||
self,
|
||||
project_id: str,
|
||||
run_id: str,
|
||||
content: str,
|
||||
*,
|
||||
profile_id: Optional[str] = None,
|
||||
api_config: Optional[Dict[str, str]] = None,
|
||||
) -> AsyncGenerator[Dict[str, Any], None]:
|
||||
"""Stream thinking deltas, then persist and emit complete run (R4)."""
|
||||
trimmed = (content or "").strip()
|
||||
if not trimmed:
|
||||
raise ValueError("消息内容不能为空")
|
||||
|
||||
run, current_node, current_state, current_node_id = self._prepare_run_message(
|
||||
project_id, run_id, trimmed
|
||||
)
|
||||
|
||||
resolved_api = resolve_api_config(profile_id, api_config)
|
||||
prompt_blocks = assemble_prompt_blocks(run, current_node_id)
|
||||
step_messages = list(current_state.stepMessages or [])
|
||||
|
||||
async for event in studio_step_respond_stream(
|
||||
node=current_node,
|
||||
prompt_blocks=prompt_blocks,
|
||||
step_messages=step_messages,
|
||||
user_message=trimmed,
|
||||
existing_draft=current_state.lastDraft,
|
||||
api_config=resolved_api,
|
||||
):
|
||||
if event.get("type") == "thinking_delta":
|
||||
yield event
|
||||
continue
|
||||
|
||||
if event.get("type") == "complete":
|
||||
from models.studio_models import LastToolResponse, StepMessage
|
||||
|
||||
last_draft = event["last_draft"]
|
||||
last_tool_response = LastToolResponse(**event["last_tool_response"])
|
||||
user_msg = StepMessage(**event["user_msg"])
|
||||
assistant_msg = StepMessage(**event["assistant_msg"])
|
||||
|
||||
updated_run = self._persist_run_message_turn(
|
||||
project_id,
|
||||
run_id,
|
||||
run,
|
||||
current_node_id,
|
||||
prompt_blocks,
|
||||
step_messages,
|
||||
last_draft,
|
||||
last_tool_response,
|
||||
user_msg,
|
||||
assistant_msg,
|
||||
)
|
||||
yield {
|
||||
"type": "complete",
|
||||
"run": updated_run.model_dump(mode="json"),
|
||||
}
|
||||
|
||||
def _prepare_active_worldbook_step(
|
||||
self,
|
||||
project_id: str,
|
||||
run_id: str,
|
||||
) -> tuple[StudioRun, StudioNode, StudioNodeRunState, str]:
|
||||
run = self.get_run(project_id, run_id)
|
||||
if run.status != StudioRunStatus.RUNNING:
|
||||
raise ValueError("运行未处于进行中,无法操作")
|
||||
|
||||
current_node_id = run.currentNodeId
|
||||
if not current_node_id:
|
||||
raise ValueError("当前运行无活动节点")
|
||||
|
||||
current_node = _find_node(run.pipelineSnapshot, current_node_id)
|
||||
if not current_node:
|
||||
raise ValueError(f"节点不存在:{current_node_id}")
|
||||
|
||||
if current_node.skillId != "studio.worldbook_entry":
|
||||
raise ValueError(
|
||||
f"当前步骤「{current_node.displayName}」不支持对话消息"
|
||||
)
|
||||
|
||||
current_state = next(
|
||||
(s for s in run.nodeStates if s.nodeId == current_node_id), None
|
||||
)
|
||||
if not current_state or current_state.status != "active":
|
||||
raise ValueError("当前节点不可执行")
|
||||
|
||||
return run, current_node, current_state, current_node_id
|
||||
|
||||
def _prepare_run_message(
|
||||
self,
|
||||
project_id: str,
|
||||
run_id: str,
|
||||
trimmed: str,
|
||||
) -> tuple[StudioRun, StudioNode, StudioNodeRunState, str]:
|
||||
if not trimmed:
|
||||
raise ValueError("消息内容不能为空")
|
||||
return self._prepare_active_worldbook_step(project_id, run_id)
|
||||
|
||||
def _persist_run_message_turn(
|
||||
self,
|
||||
project_id: str,
|
||||
run_id: str,
|
||||
run: StudioRun,
|
||||
current_node_id: str,
|
||||
prompt_blocks: list,
|
||||
step_messages: list,
|
||||
last_draft: Dict[str, Any],
|
||||
last_tool_response,
|
||||
user_msg,
|
||||
assistant_msg,
|
||||
*,
|
||||
push_snapshot: bool = True,
|
||||
) -> StudioRun:
|
||||
step_messages = list(step_messages)
|
||||
step_messages.extend([user_msg, assistant_msg])
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
new_node_states: list[StudioNodeRunState] = []
|
||||
for state in run.nodeStates:
|
||||
updated = state.model_copy()
|
||||
if state.nodeId == current_node_id:
|
||||
if push_snapshot:
|
||||
turn_history = list(state.turnHistory or [])
|
||||
turn_history.append(_snapshot_node_state(state))
|
||||
updated.turnHistory = turn_history
|
||||
updated.lastDraft = last_draft
|
||||
updated.lastToolResponse = last_tool_response
|
||||
updated.stepMessages = step_messages
|
||||
new_node_states.append(updated)
|
||||
|
||||
updated_run = run.model_copy(
|
||||
update={
|
||||
"nodeStates": new_node_states,
|
||||
"lastPromptBlocks": prompt_blocks,
|
||||
"updatedAt": now,
|
||||
}
|
||||
)
|
||||
updated_run = store_context_on_run(updated_run, current_node_id)
|
||||
self._save_run(project_id, run_id, updated_run)
|
||||
return updated_run
|
||||
|
||||
def undo_run(self, project_id: str, run_id: str) -> StudioRun:
|
||||
"""Restore the active step to the state before the last LLM turn."""
|
||||
run, _, current_state, current_node_id = self._prepare_active_worldbook_step(
|
||||
project_id, run_id
|
||||
)
|
||||
|
||||
turn_history = list(current_state.turnHistory or [])
|
||||
if not turn_history:
|
||||
raise ValueError("无可回退的回合")
|
||||
|
||||
snapshot = turn_history.pop()
|
||||
restored_state = _apply_snapshot(current_state, snapshot)
|
||||
restored_state.turnHistory = turn_history
|
||||
|
||||
now = datetime.now().isoformat()
|
||||
new_node_states: list[StudioNodeRunState] = []
|
||||
for state in run.nodeStates:
|
||||
if state.nodeId == current_node_id:
|
||||
new_node_states.append(restored_state)
|
||||
else:
|
||||
new_node_states.append(state.model_copy())
|
||||
|
||||
updated_run = run.model_copy(
|
||||
update={
|
||||
"nodeStates": new_node_states,
|
||||
"updatedAt": now,
|
||||
}
|
||||
)
|
||||
updated_run = store_context_on_run(updated_run, current_node_id)
|
||||
self._save_run(project_id, run_id, updated_run)
|
||||
return updated_run
|
||||
|
||||
async def reroll_run(
|
||||
self,
|
||||
project_id: str,
|
||||
run_id: str,
|
||||
*,
|
||||
stream: bool = False,
|
||||
profile_id: Optional[str] = None,
|
||||
api_config: Optional[Dict[str, str]] = None,
|
||||
) -> StudioRun:
|
||||
"""Re-run the last user turn with identical inputs (no new user message)."""
|
||||
run, current_node, current_state, current_node_id = (
|
||||
self._prepare_active_worldbook_step(project_id, run_id)
|
||||
)
|
||||
|
||||
step_messages_all = list(current_state.stepMessages or [])
|
||||
last_user_idx = _find_last_user_message_index(step_messages_all)
|
||||
if last_user_idx < 0:
|
||||
raise ValueError("当前步骤尚无用户消息,无法重 roll")
|
||||
|
||||
user_content = step_messages_all[last_user_idx].content
|
||||
step_messages = step_messages_all[:last_user_idx]
|
||||
|
||||
pre_turn_draft = current_state.lastDraft
|
||||
if current_state.turnHistory:
|
||||
pre_turn_draft = current_state.turnHistory[-1].lastDraft
|
||||
|
||||
resolved_api = resolve_api_config(profile_id, api_config)
|
||||
prompt_blocks = assemble_prompt_blocks(run, current_node_id)
|
||||
|
||||
last_draft, last_tool_response, user_msg, assistant_msg = (
|
||||
await studio_step_respond(
|
||||
node=current_node,
|
||||
prompt_blocks=prompt_blocks,
|
||||
step_messages=step_messages,
|
||||
user_message=user_content,
|
||||
existing_draft=pre_turn_draft,
|
||||
api_config=resolved_api,
|
||||
stream=stream,
|
||||
)
|
||||
)
|
||||
|
||||
return self._persist_reroll_turn(
|
||||
project_id,
|
||||
run_id,
|
||||
run,
|
||||
current_node_id,
|
||||
current_state,
|
||||
prompt_blocks,
|
||||
step_messages,
|
||||
last_draft,
|
||||
last_tool_response,
|
||||
user_msg,
|
||||
assistant_msg,
|
||||
)
|
||||
|
||||
async def reroll_run_stream(
|
||||
self,
|
||||
project_id: str,
|
||||
run_id: str,
|
||||
*,
|
||||
profile_id: Optional[str] = None,
|
||||
api_config: Optional[Dict[str, str]] = None,
|
||||
) -> AsyncGenerator[Dict[str, Any], None]:
|
||||
"""Stream thinking during reroll, then persist replaced turn."""
|
||||
run, current_node, current_state, current_node_id = (
|
||||
self._prepare_active_worldbook_step(project_id, run_id)
|
||||
)
|
||||
|
||||
step_messages_all = list(current_state.stepMessages or [])
|
||||
last_user_idx = _find_last_user_message_index(step_messages_all)
|
||||
if last_user_idx < 0:
|
||||
raise ValueError("当前步骤尚无用户消息,无法重 roll")
|
||||
|
||||
user_content = step_messages_all[last_user_idx].content
|
||||
step_messages = step_messages_all[:last_user_idx]
|
||||
|
||||
pre_turn_draft = current_state.lastDraft
|
||||
if current_state.turnHistory:
|
||||
pre_turn_draft = current_state.turnHistory[-1].lastDraft
|
||||
|
||||
resolved_api = resolve_api_config(profile_id, api_config)
|
||||
prompt_blocks = assemble_prompt_blocks(run, current_node_id)
|
||||
|
||||
async for event in studio_step_respond_stream(
|
||||
node=current_node,
|
||||
prompt_blocks=prompt_blocks,
|
||||
step_messages=step_messages,
|
||||
user_message=user_content,
|
||||
existing_draft=pre_turn_draft,
|
||||
api_config=resolved_api,
|
||||
):
|
||||
if event.get("type") == "thinking_delta":
|
||||
yield event
|
||||
continue
|
||||
|
||||
if event.get("type") == "complete":
|
||||
from models.studio_models import LastToolResponse, StepMessage
|
||||
|
||||
last_draft = event["last_draft"]
|
||||
last_tool_response = LastToolResponse(**event["last_tool_response"])
|
||||
user_msg = StepMessage(**event["user_msg"])
|
||||
assistant_msg = StepMessage(**event["assistant_msg"])
|
||||
|
||||
updated_run = self._persist_reroll_turn(
|
||||
project_id,
|
||||
run_id,
|
||||
run,
|
||||
current_node_id,
|
||||
current_state,
|
||||
prompt_blocks,
|
||||
step_messages,
|
||||
last_draft,
|
||||
last_tool_response,
|
||||
user_msg,
|
||||
assistant_msg,
|
||||
)
|
||||
yield {
|
||||
"type": "complete",
|
||||
"run": updated_run.model_dump(mode="json"),
|
||||
}
|
||||
|
||||
def _persist_reroll_turn(
|
||||
self,
|
||||
project_id: str,
|
||||
run_id: str,
|
||||
run: StudioRun,
|
||||
current_node_id: str,
|
||||
current_state: StudioNodeRunState,
|
||||
prompt_blocks: list,
|
||||
step_messages: list,
|
||||
last_draft: Dict[str, Any],
|
||||
last_tool_response,
|
||||
user_msg,
|
||||
assistant_msg,
|
||||
) -> StudioRun:
|
||||
"""Replace the last turn; snapshot current state so reroll is undoable."""
|
||||
step_messages = list(step_messages)
|
||||
step_messages.extend([user_msg, assistant_msg])
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
new_node_states: list[StudioNodeRunState] = []
|
||||
for state in run.nodeStates:
|
||||
updated = state.model_copy()
|
||||
if state.nodeId == current_node_id:
|
||||
turn_history = list(current_state.turnHistory or [])
|
||||
turn_history.append(_snapshot_node_state(current_state))
|
||||
updated.turnHistory = turn_history
|
||||
updated.lastDraft = last_draft
|
||||
updated.lastToolResponse = last_tool_response
|
||||
updated.stepMessages = step_messages
|
||||
new_node_states.append(updated)
|
||||
|
||||
updated_run = run.model_copy(
|
||||
update={
|
||||
"nodeStates": new_node_states,
|
||||
"lastPromptBlocks": prompt_blocks,
|
||||
"updatedAt": now,
|
||||
}
|
||||
)
|
||||
updated_run = store_context_on_run(updated_run, current_node_id)
|
||||
self._save_run(project_id, run_id, updated_run)
|
||||
return updated_run
|
||||
|
||||
def delete_run(self, project_id: str, run_id: str) -> None:
|
||||
run_dir = self._run_dir(project_id, run_id)
|
||||
if not run_dir.exists():
|
||||
raise FileNotFoundError(f"Studio run not found: {project_id}/{run_id}")
|
||||
shutil.rmtree(run_dir)
|
||||
|
||||
def rename_run(self, project_id: str, run_id: str, title: str) -> StudioRun:
|
||||
run = self.get_run(project_id, run_id)
|
||||
trimmed = (title or "").strip()
|
||||
if not trimmed:
|
||||
raise ValueError("运行名称不能为空")
|
||||
now = datetime.now().isoformat()
|
||||
updated = run.model_copy(update={"title": trimmed, "updatedAt": now})
|
||||
self._save_run(project_id, run_id, updated)
|
||||
return updated
|
||||
|
||||
|
||||
studio_run_service = StudioRunService()
|
||||
427
backend/services/studio_step_respond.py
Normal file
427
backend/services/studio_step_respond.py
Normal file
@@ -0,0 +1,427 @@
|
||||
"""
|
||||
Studio worldbook step LLM responder (R3/R4).
|
||||
|
||||
Assembles R2 context blocks + short step dialogue, calls LLM for structured JSON,
|
||||
returns thinking, draft, questions, and evaluation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, AsyncGenerator, Dict, List, Optional, Tuple
|
||||
|
||||
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
||||
|
||||
from models.studio_models import (
|
||||
LastToolResponse,
|
||||
PromptBlock,
|
||||
StudioNode,
|
||||
StepMessage,
|
||||
ToolQuestionOption,
|
||||
)
|
||||
from services.studio_context_service import assemble_prompt_blocks
|
||||
from utils.llm_client import LLMClient
|
||||
|
||||
_llm_client = LLMClient()
|
||||
|
||||
_JSON_FENCE = re.compile(r"```(?:json)?\s*([\s\S]*?)```", re.IGNORECASE)
|
||||
|
||||
|
||||
def resolve_api_config(
|
||||
profile_id: Optional[str],
|
||||
api_config: Optional[Dict[str, str]],
|
||||
) -> Dict[str, str]:
|
||||
"""Merge frontend apiConfig with stored profile mainLLM key (same as chat WS)."""
|
||||
resolved = dict(api_config or {})
|
||||
if profile_id:
|
||||
try:
|
||||
try:
|
||||
from api.routes.apiConfigRoute import load_profile
|
||||
except ImportError:
|
||||
from backend.api.routes.apiConfigRoute import load_profile
|
||||
|
||||
profile = load_profile(profile_id)
|
||||
if profile:
|
||||
main_llm = profile.get("apis", {}).get("mainLLM", {})
|
||||
if main_llm.get("apiUrl") and not resolved.get("api_url"):
|
||||
resolved["api_url"] = main_llm.get("apiUrl", "")
|
||||
if main_llm.get("model") and not resolved.get("model"):
|
||||
resolved["model"] = main_llm.get("model", "")
|
||||
api_key = main_llm.get("apiKey", "")
|
||||
if api_key:
|
||||
resolved["api_key"] = api_key
|
||||
except Exception as exc:
|
||||
print(f"[StudioStepRespond] 加载 API 配置失败: {exc}")
|
||||
return resolved
|
||||
|
||||
|
||||
def _blocks_to_context_text(blocks: List[PromptBlock]) -> str:
|
||||
sections: List[str] = []
|
||||
for block in blocks:
|
||||
sections.append(f"## {block.label}\n{block.content}")
|
||||
return "\n\n".join(sections)
|
||||
|
||||
|
||||
def _build_system_prompt(node: StudioNode) -> str:
|
||||
insertion = (node.config or {}).get("insertion") or {}
|
||||
key = insertion.get("key") or "(未配置关键词)"
|
||||
comment = insertion.get("comment") or ""
|
||||
|
||||
return f"""你是 Studio 创作助手,负责为当前流水线步骤生成或修订世界书条目草稿。
|
||||
|
||||
当前步骤:{node.displayName}
|
||||
目标关键词:{key}
|
||||
备注:{comment or "(无)"}
|
||||
|
||||
你必须只输出一个 JSON 对象(不要 markdown 代码块外的其他文字),字段如下:
|
||||
{{
|
||||
"thinking": "你的内部思考过程(逐步推理,中文)",
|
||||
"currentProduct": "世界书条目正文(纯文本或 Markdown,可直接写入条目 content)",
|
||||
"questions": [
|
||||
{{
|
||||
"question": "需要用户澄清的问题",
|
||||
"options": ["选项A", "选项B", "选项C"]
|
||||
}}
|
||||
],
|
||||
"evaluation": "对照评价维度的自检与优化建议(中文,面向用户)"
|
||||
}}
|
||||
|
||||
规则:
|
||||
1. currentProduct 必须是完整、可注入世界书的条目正文。
|
||||
2. questions 为 0–3 条;每条至少 2 个 options;若无需澄清则 questions 为空数组。
|
||||
3. evaluation 需引用上下文中的评价标准,给出具体、可操作的反馈。
|
||||
4. 若用户要求修改,在 currentProduct 中输出修订后的完整条目,而非仅说明改了什么。
|
||||
5. 全部字段使用中文(专有名词除外)。"""
|
||||
|
||||
|
||||
def _dialogue_to_langchain(
|
||||
step_messages: List[StepMessage],
|
||||
) -> List[Any]:
|
||||
messages: List[Any] = []
|
||||
for msg in step_messages:
|
||||
if msg.role == "user":
|
||||
messages.append(HumanMessage(content=msg.content))
|
||||
elif msg.role == "assistant":
|
||||
messages.append(AIMessage(content=msg.content))
|
||||
return messages
|
||||
|
||||
|
||||
def _build_llm_messages(
|
||||
node: StudioNode,
|
||||
prompt_blocks: List[PromptBlock],
|
||||
step_messages: List[StepMessage],
|
||||
user_message: str,
|
||||
) -> List[Any]:
|
||||
context_text = _blocks_to_context_text(prompt_blocks)
|
||||
system_prompt = _build_system_prompt(node)
|
||||
|
||||
messages: List[Any] = [SystemMessage(content=system_prompt)]
|
||||
messages.append(
|
||||
HumanMessage(
|
||||
content=f"以下为当前步骤上下文(不含完整聊天历史):\n\n{context_text}"
|
||||
)
|
||||
)
|
||||
messages.extend(_dialogue_to_langchain(step_messages))
|
||||
messages.append(HumanMessage(content=user_message))
|
||||
return messages
|
||||
|
||||
|
||||
def _validate_api_config(api_config: Dict[str, str]) -> None:
|
||||
if not api_config.get("api_key"):
|
||||
raise ValueError("API Key 未配置,请先在 API 配置页面保存密钥")
|
||||
if not api_config.get("api_url"):
|
||||
raise ValueError("API 地址未配置,请先在 API 配置页面保存 mainLLM")
|
||||
|
||||
|
||||
def _extract_json(raw: str) -> Dict[str, Any]:
|
||||
text = (raw or "").strip()
|
||||
if not text:
|
||||
raise ValueError("模型返回为空")
|
||||
|
||||
fence = _JSON_FENCE.search(text)
|
||||
if fence:
|
||||
text = fence.group(1).strip()
|
||||
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
start = text.find("{")
|
||||
end = text.rfind("}")
|
||||
if start >= 0 and end > start:
|
||||
return json.loads(text[start : end + 1])
|
||||
raise ValueError("无法解析模型返回的 JSON")
|
||||
|
||||
|
||||
def _decode_json_string_partial(raw: str) -> str:
|
||||
"""Decode a possibly incomplete JSON string body (no surrounding quotes)."""
|
||||
out: List[str] = []
|
||||
i = 0
|
||||
while i < len(raw):
|
||||
if raw[i] == "\\" and i + 1 < len(raw):
|
||||
nxt = raw[i + 1]
|
||||
if nxt == "n":
|
||||
out.append("\n")
|
||||
elif nxt == "t":
|
||||
out.append("\t")
|
||||
elif nxt == "r":
|
||||
out.append("\r")
|
||||
elif nxt == '"':
|
||||
out.append('"')
|
||||
elif nxt == "\\":
|
||||
out.append("\\")
|
||||
elif nxt == "/":
|
||||
out.append("/")
|
||||
elif nxt == "u" and i + 5 < len(raw):
|
||||
try:
|
||||
out.append(chr(int(raw[i + 2 : i + 6], 16)))
|
||||
i += 6
|
||||
continue
|
||||
except ValueError:
|
||||
out.append(nxt)
|
||||
else:
|
||||
out.append(nxt)
|
||||
i += 2
|
||||
else:
|
||||
out.append(raw[i])
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _extract_partial_thinking(raw: str) -> Optional[str]:
|
||||
"""Best-effort extraction of thinking field from incomplete JSON stream."""
|
||||
marker = '"thinking"'
|
||||
idx = raw.find(marker)
|
||||
if idx < 0:
|
||||
return None
|
||||
|
||||
colon = raw.find(":", idx + len(marker))
|
||||
if colon < 0:
|
||||
return None
|
||||
|
||||
rest = raw[colon + 1 :].lstrip()
|
||||
if not rest.startswith('"'):
|
||||
return None
|
||||
|
||||
body_start = 1
|
||||
i = body_start
|
||||
while i < len(rest):
|
||||
ch = rest[i]
|
||||
if ch == '"':
|
||||
break
|
||||
if ch == "\\":
|
||||
i += 2
|
||||
continue
|
||||
i += 1
|
||||
|
||||
partial = rest[body_start:i]
|
||||
if not partial:
|
||||
return None
|
||||
return _decode_json_string_partial(partial)
|
||||
|
||||
|
||||
def _normalize_draft(
|
||||
current_product: Any,
|
||||
node: StudioNode,
|
||||
existing_draft: Optional[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
draft: Dict[str, Any] = dict(existing_draft or {})
|
||||
insertion = (node.config or {}).get("insertion") or {}
|
||||
|
||||
if isinstance(current_product, str):
|
||||
draft["entryContent"] = current_product.strip()
|
||||
elif isinstance(current_product, dict):
|
||||
draft.update(current_product)
|
||||
if "entryContent" not in draft and "content" in draft:
|
||||
draft["entryContent"] = draft["content"]
|
||||
else:
|
||||
draft["entryContent"] = str(current_product)
|
||||
|
||||
if insertion.get("key"):
|
||||
draft["insertionKey"] = insertion["key"]
|
||||
if insertion.get("comment"):
|
||||
draft["insertionComment"] = insertion["comment"]
|
||||
draft["nodeId"] = node.id
|
||||
draft["displayName"] = node.displayName
|
||||
return draft
|
||||
|
||||
|
||||
def _normalize_questions(raw: Any) -> List[ToolQuestionOption]:
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
result: List[ToolQuestionOption] = []
|
||||
for item in raw:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
question = (item.get("question") or "").strip()
|
||||
if not question:
|
||||
continue
|
||||
options = [
|
||||
str(o).strip()
|
||||
for o in (item.get("options") or [])
|
||||
if str(o).strip()
|
||||
]
|
||||
if len(options) < 2:
|
||||
continue
|
||||
result.append(ToolQuestionOption(question=question, options=options))
|
||||
return result[:3]
|
||||
|
||||
|
||||
def _assistant_message_text(parsed: Dict[str, Any]) -> str:
|
||||
evaluation = (parsed.get("evaluation") or "").strip()
|
||||
if evaluation:
|
||||
return evaluation
|
||||
product = parsed.get("currentProduct")
|
||||
if isinstance(product, str) and product.strip():
|
||||
preview = product.strip()
|
||||
if len(preview) > 400:
|
||||
preview = preview[:400] + "…"
|
||||
return f"已更新条目草稿:\n\n{preview}"
|
||||
return "已处理您的消息,请查看左侧目前产物。"
|
||||
|
||||
|
||||
def _build_turn_result(
|
||||
parsed: Dict[str, Any],
|
||||
*,
|
||||
node: StudioNode,
|
||||
user_message: str,
|
||||
existing_draft: Optional[Dict[str, Any]],
|
||||
) -> Tuple[Dict[str, Any], LastToolResponse, StepMessage, StepMessage]:
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
last_draft = _normalize_draft(
|
||||
parsed.get("currentProduct"),
|
||||
node,
|
||||
existing_draft,
|
||||
)
|
||||
last_tool_response = LastToolResponse(
|
||||
thinking=(parsed.get("thinking") or "").strip() or None,
|
||||
evaluation=(parsed.get("evaluation") or "").strip() or None,
|
||||
questions=_normalize_questions(parsed.get("questions")),
|
||||
generatedAt=now,
|
||||
)
|
||||
|
||||
user_step_msg = StepMessage(
|
||||
id=f"msg-{uuid.uuid4().hex[:12]}",
|
||||
role="user",
|
||||
content=user_message,
|
||||
createdAt=now,
|
||||
)
|
||||
assistant_step_msg = StepMessage(
|
||||
id=f"msg-{uuid.uuid4().hex[:12]}",
|
||||
role="assistant",
|
||||
content=_assistant_message_text(parsed),
|
||||
createdAt=now,
|
||||
)
|
||||
|
||||
return last_draft, last_tool_response, user_step_msg, assistant_step_msg
|
||||
|
||||
|
||||
async def studio_step_respond(
|
||||
*,
|
||||
node: StudioNode,
|
||||
prompt_blocks: List[PromptBlock],
|
||||
step_messages: List[StepMessage],
|
||||
user_message: str,
|
||||
existing_draft: Optional[Dict[str, Any]],
|
||||
api_config: Dict[str, str],
|
||||
stream: bool = False,
|
||||
) -> Tuple[Dict[str, Any], LastToolResponse, StepMessage, StepMessage]:
|
||||
"""
|
||||
Execute one worldbook step turn (non-streaming).
|
||||
|
||||
Returns (last_draft, last_tool_response, user_step_msg, assistant_step_msg).
|
||||
"""
|
||||
_validate_api_config(api_config)
|
||||
messages = _build_llm_messages(node, prompt_blocks, step_messages, user_message)
|
||||
model = api_config.get("model") or "gpt-4o-mini"
|
||||
|
||||
if stream:
|
||||
print("[StudioStepRespond] stream=True 应使用 studio_step_respond_stream")
|
||||
|
||||
response = await _llm_client.chat_completion(
|
||||
messages=messages,
|
||||
api_url=api_config.get("api_url", ""),
|
||||
api_key=api_config.get("api_key", ""),
|
||||
model=model,
|
||||
temperature=0.7,
|
||||
max_tokens=8000,
|
||||
request_timeout=120,
|
||||
stream=False,
|
||||
)
|
||||
|
||||
raw_content = ""
|
||||
if isinstance(response, dict):
|
||||
raw_content = (
|
||||
response.get("choices", [{}])[0]
|
||||
.get("message", {})
|
||||
.get("content", "")
|
||||
)
|
||||
else:
|
||||
raw_content = str(response)
|
||||
|
||||
parsed = _extract_json(raw_content)
|
||||
return _build_turn_result(
|
||||
parsed,
|
||||
node=node,
|
||||
user_message=user_message,
|
||||
existing_draft=existing_draft,
|
||||
)
|
||||
|
||||
|
||||
async def studio_step_respond_stream(
|
||||
*,
|
||||
node: StudioNode,
|
||||
prompt_blocks: List[PromptBlock],
|
||||
step_messages: List[StepMessage],
|
||||
user_message: str,
|
||||
existing_draft: Optional[Dict[str, Any]],
|
||||
api_config: Dict[str, str],
|
||||
) -> AsyncGenerator[Dict[str, Any], None]:
|
||||
"""
|
||||
Stream thinking field while LLM generates structured JSON (R4).
|
||||
|
||||
Yields:
|
||||
- {"type": "thinking_delta", "content": "..."}
|
||||
- {"type": "complete", "last_draft", "last_tool_response", "user_msg", "assistant_msg"}
|
||||
"""
|
||||
_validate_api_config(api_config)
|
||||
messages = _build_llm_messages(node, prompt_blocks, step_messages, user_message)
|
||||
model = api_config.get("model") or "gpt-4o-mini"
|
||||
|
||||
accumulated = ""
|
||||
last_thinking = ""
|
||||
|
||||
async for chunk in _llm_client.stream_chat(
|
||||
messages=messages,
|
||||
api_url=api_config.get("api_url", ""),
|
||||
api_key=api_config.get("api_key", ""),
|
||||
model=model,
|
||||
temperature=0.7,
|
||||
max_tokens=8000,
|
||||
request_timeout=120,
|
||||
):
|
||||
if chunk.get("type") != "chunk":
|
||||
continue
|
||||
accumulated += chunk.get("content") or ""
|
||||
partial = _extract_partial_thinking(accumulated)
|
||||
if partial and partial != last_thinking:
|
||||
last_thinking = partial
|
||||
yield {"type": "thinking_delta", "content": partial}
|
||||
|
||||
parsed = _extract_json(accumulated)
|
||||
last_draft, last_tool_response, user_msg, assistant_msg = _build_turn_result(
|
||||
parsed,
|
||||
node=node,
|
||||
user_message=user_message,
|
||||
existing_draft=existing_draft,
|
||||
)
|
||||
|
||||
yield {
|
||||
"type": "complete",
|
||||
"last_draft": last_draft,
|
||||
"last_tool_response": last_tool_response.model_dump(mode="json"),
|
||||
"user_msg": user_msg.model_dump(mode="json"),
|
||||
"assistant_msg": assistant_msg.model_dump(mode="json"),
|
||||
}
|
||||
95
backend/services/system_settings_service.py
Normal file
95
backend/services/system_settings_service.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
系统设置服务
|
||||
|
||||
负责加载、保存和管理全局系统设置。
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from core.config import settings
|
||||
from models.system_settings import SystemSettings, DEFAULT_SYSTEM_SETTINGS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SystemSettingsService:
|
||||
"""
|
||||
系统设置服务
|
||||
|
||||
提供设置的加载、保存和访问功能
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.settings_file = settings.SYSTEM_SETTINGS_FILE
|
||||
self._settings: Optional[SystemSettings] = None
|
||||
self._load_settings()
|
||||
|
||||
def _load_settings(self):
|
||||
"""从文件加载系统设置"""
|
||||
if self.settings_file.exists():
|
||||
try:
|
||||
with open(self.settings_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
self._settings = SystemSettings(**data)
|
||||
logger.info(f"加载系统设置成功")
|
||||
except Exception as e:
|
||||
logger.error(f"加载系统设置失败: {e}")
|
||||
self._settings = DEFAULT_SYSTEM_SETTINGS.copy()
|
||||
else:
|
||||
logger.info("系统设置文件不存在,使用默认设置")
|
||||
self._settings = DEFAULT_SYSTEM_SETTINGS.copy()
|
||||
self._save_settings()
|
||||
|
||||
def _save_settings(self):
|
||||
"""保存系统设置到文件"""
|
||||
try:
|
||||
# 确保父目录存在
|
||||
self.settings_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 写入文件
|
||||
with open(self.settings_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(self._settings.dict(), f, ensure_ascii=False, indent=2)
|
||||
|
||||
logger.info(f"系统设置已保存到 {self.settings_file}")
|
||||
except Exception as e:
|
||||
logger.error(f"保存系统设置失败: {e}")
|
||||
|
||||
@property
|
||||
def settings(self) -> SystemSettings:
|
||||
"""获取当前系统设置"""
|
||||
return self._settings
|
||||
|
||||
def update_thinking_tags(self, prefix: str, suffix: str):
|
||||
"""更新思考标签配置"""
|
||||
self._settings.thinkingTagPrefix = prefix
|
||||
self._settings.thinkingTagSuffix = suffix
|
||||
self._settings.updatedAt = int(__import__('time').time())
|
||||
self._save_settings()
|
||||
logger.info(f"思考标签已更新: {prefix} ... {suffix}")
|
||||
|
||||
def update_current_preset(self, preset_name: Optional[str]):
|
||||
"""更新当前选中的预设名称"""
|
||||
self._settings.currentPresetName = preset_name
|
||||
self._settings.updatedAt = int(__import__('time').time())
|
||||
self._save_settings()
|
||||
logger.info(f"当前预设已更新: {preset_name}")
|
||||
|
||||
def get_thinking_tag_pattern(self) -> str:
|
||||
"""获取思考标签的正则表达式模式"""
|
||||
prefix = self._settings.thinkingTagPrefix
|
||||
suffix = self._settings.thinkingTagSuffix
|
||||
|
||||
# 转义特殊字符
|
||||
import re
|
||||
escaped_prefix = re.escape(prefix)
|
||||
escaped_suffix = re.escape(suffix)
|
||||
|
||||
# 返回匹配思考内容的正则模式
|
||||
return f"{escaped_prefix}[\\s\\S]*?{escaped_suffix}"
|
||||
|
||||
|
||||
# 全局实例
|
||||
system_settings_service = SystemSettingsService()
|
||||
161
backend/services/task_queue_manager.py
Normal file
161
backend/services/task_queue_manager.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
任务队列管理器
|
||||
管理并行任务(生图、动态表格维护等)的状态和生命周期
|
||||
"""
|
||||
import asyncio
|
||||
from typing import Dict, List, Optional
|
||||
from enum import Enum
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class TaskStatus(Enum):
|
||||
"""任务状态枚举"""
|
||||
PENDING = "pending" # 等待中
|
||||
RUNNING = "running" # 进行中
|
||||
COMPLETED = "completed" # 已完成
|
||||
FAILED = "failed" # 失败
|
||||
CANCELLED = "cancelled" # 已取消
|
||||
|
||||
|
||||
class TaskType(Enum):
|
||||
"""任务类型枚举"""
|
||||
IMAGE_WORKFLOW = "image_workflow"
|
||||
DYNAMIC_TABLE = "dynamic_table"
|
||||
|
||||
|
||||
class TaskItem:
|
||||
"""任务项"""
|
||||
|
||||
def __init__(self, task_id: str, task_type: TaskType, chat_id: str):
|
||||
self.task_id = task_id
|
||||
self.task_type = task_type
|
||||
self.chat_id = chat_id
|
||||
self.status = TaskStatus.PENDING
|
||||
self.created_at = datetime.now()
|
||||
self.started_at = None
|
||||
self.completed_at = None
|
||||
self.error = None
|
||||
self.metadata = {} # 用于存储提示词、修改内容等
|
||||
|
||||
def to_dict(self):
|
||||
"""转换为字典格式(前端友好)"""
|
||||
return {
|
||||
"taskId": self.task_id,
|
||||
"taskType": self.task_type.value,
|
||||
"chatId": self.chat_id,
|
||||
"status": self.status.value,
|
||||
"createdAt": self.created_at.isoformat(),
|
||||
"startedAt": self.started_at.isoformat() if self.started_at else None,
|
||||
"completedAt": self.completed_at.isoformat() if self.completed_at else None,
|
||||
"error": self.error,
|
||||
"metadata": self.metadata
|
||||
}
|
||||
|
||||
|
||||
class TaskQueueManager:
|
||||
"""
|
||||
全局任务队列管理器
|
||||
|
||||
功能:
|
||||
- 管理所有并行任务的生命周期
|
||||
- 支持按聊天ID查询任务
|
||||
- 支持取消任务
|
||||
- 自动清理已完成的任务
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.tasks: Dict[str, TaskItem] = {}
|
||||
self.chat_tasks: Dict[str, List[str]] = {} # chat_id -> [task_ids]
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def add_task(self, task_id: str, task_type: TaskType, chat_id: str) -> TaskItem:
|
||||
"""添加任务到队列"""
|
||||
async with self._lock:
|
||||
task = TaskItem(task_id, task_type, chat_id)
|
||||
self.tasks[task_id] = task
|
||||
|
||||
if chat_id not in self.chat_tasks:
|
||||
self.chat_tasks[chat_id] = []
|
||||
self.chat_tasks[chat_id].append(task_id)
|
||||
|
||||
return task
|
||||
|
||||
async def start_task(self, task_id: str):
|
||||
"""标记任务开始执行"""
|
||||
async with self._lock:
|
||||
if task_id in self.tasks:
|
||||
self.tasks[task_id].status = TaskStatus.RUNNING
|
||||
self.tasks[task_id].started_at = datetime.now()
|
||||
|
||||
async def complete_task(self, task_id: str, metadata: dict = None):
|
||||
"""标记任务完成"""
|
||||
async with self._lock:
|
||||
if task_id in self.tasks:
|
||||
self.tasks[task_id].status = TaskStatus.COMPLETED
|
||||
self.tasks[task_id].completed_at = datetime.now()
|
||||
if metadata:
|
||||
self.tasks[task_id].metadata.update(metadata)
|
||||
|
||||
async def fail_task(self, task_id: str, error: str):
|
||||
"""标记任务失败"""
|
||||
async with self._lock:
|
||||
if task_id in self.tasks:
|
||||
self.tasks[task_id].status = TaskStatus.FAILED
|
||||
self.tasks[task_id].completed_at = datetime.now()
|
||||
self.tasks[task_id].error = error
|
||||
|
||||
async def cancel_task(self, task_id: str) -> bool:
|
||||
"""
|
||||
取消任务
|
||||
|
||||
Returns:
|
||||
bool: 是否成功取消
|
||||
"""
|
||||
async with self._lock:
|
||||
if task_id in self.tasks:
|
||||
task = self.tasks[task_id]
|
||||
if task.status in [TaskStatus.PENDING, TaskStatus.RUNNING]:
|
||||
task.status = TaskStatus.CANCELLED
|
||||
task.completed_at = datetime.now()
|
||||
return True
|
||||
return False
|
||||
|
||||
async def get_chat_tasks(self, chat_id: str, include_completed: bool = False) -> List[dict]:
|
||||
"""
|
||||
获取某个聊天的所有任务
|
||||
|
||||
Args:
|
||||
chat_id: 聊天ID
|
||||
include_completed: 是否包含已完成的任务
|
||||
|
||||
Returns:
|
||||
List[dict]: 任务列表
|
||||
"""
|
||||
async with self._lock:
|
||||
task_ids = self.chat_tasks.get(chat_id, [])
|
||||
tasks = []
|
||||
for task_id in task_ids:
|
||||
if task_id in self.tasks:
|
||||
task = self.tasks[task_id]
|
||||
# 根据参数决定是否包含已完成的任务
|
||||
if include_completed or task.status in [TaskStatus.PENDING, TaskStatus.RUNNING]:
|
||||
tasks.append(task.to_dict())
|
||||
return tasks
|
||||
|
||||
async def cleanup_completed_tasks(self, chat_id: str):
|
||||
"""清理已完成的任务"""
|
||||
async with self._lock:
|
||||
if chat_id in self.chat_tasks:
|
||||
task_ids = self.chat_tasks[chat_id]
|
||||
completed_ids = [
|
||||
tid for tid in task_ids
|
||||
if tid in self.tasks and
|
||||
self.tasks[tid].status in [TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED]
|
||||
]
|
||||
for tid in completed_ids:
|
||||
del self.tasks[tid]
|
||||
self.chat_tasks[chat_id].remove(tid)
|
||||
|
||||
|
||||
# 全局实例
|
||||
task_queue_manager = TaskQueueManager()
|
||||
427
backend/services/token_usage_service.py
Normal file
427
backend/services/token_usage_service.py
Normal file
@@ -0,0 +1,427 @@
|
||||
"""
|
||||
Token 使用统计服务
|
||||
|
||||
负责记录、查询和分析 LLM 调用的 token 使用情况
|
||||
数据持久化到 data/token_usage 目录,按月份组织
|
||||
采用双层存储:
|
||||
1. JSONL 文件 - 详细记录(按月存储)
|
||||
2. 索引文件 - 快速聚合统计(按 API URL、日期等维度)
|
||||
"""
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Any
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
|
||||
try:
|
||||
from backend.models.internal import TokenUsageRecord, TokenUsageStatus
|
||||
from backend.core.config import settings
|
||||
except ImportError:
|
||||
from models.internal import TokenUsageRecord, TokenUsageStatus
|
||||
from core.config import settings
|
||||
|
||||
|
||||
class TokenUsageService:
|
||||
"""
|
||||
Token 使用统计服务
|
||||
|
||||
功能:
|
||||
- 记录每次 LLM 调用的 token 使用情况
|
||||
- 按月份、日期、角色、聊天、API URL 维度统计
|
||||
- 支持中断和失败标记
|
||||
- 数据持久化到文件系统(JSONL + 索引)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.token_usage_dir = settings.DATA_PATH / "token_usage"
|
||||
self.token_usage_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ✅ 索引文件目录 - 用于快速聚合查询
|
||||
self.index_dir = self.token_usage_dir / "indexes"
|
||||
self.index_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _get_month_file(self, year: int, month: int) -> Path:
|
||||
"""获取指定月份的统计文件路径"""
|
||||
month_dir = self.token_usage_dir / f"{year}"
|
||||
month_dir.mkdir(parents=True, exist_ok=True)
|
||||
return month_dir / f"{month:02d}.jsonl"
|
||||
|
||||
def _load_month_records(self, year: int, month: int) -> List[TokenUsageRecord]:
|
||||
"""加载指定月份的所有记录"""
|
||||
file_path = self._get_month_file(year, month)
|
||||
|
||||
if not file_path.exists():
|
||||
return []
|
||||
|
||||
records = []
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
if line.strip():
|
||||
data = json.loads(line)
|
||||
records.append(TokenUsageRecord(**data))
|
||||
except Exception as e:
|
||||
print(f"[TokenUsage] 加载记录失败: {e}")
|
||||
|
||||
return records
|
||||
|
||||
def _save_record(self, record: TokenUsageRecord):
|
||||
"""保存单条记录到对应的月份文件"""
|
||||
dt = datetime.fromtimestamp(record.timestamp)
|
||||
file_path = self._get_month_file(dt.year, dt.month)
|
||||
|
||||
try:
|
||||
with open(file_path, 'a', encoding='utf-8') as f:
|
||||
f.write(json.dumps(record.model_dump(), ensure_ascii=False) + '\n')
|
||||
|
||||
# ✅ 同时更新索引文件(用于快速查询)
|
||||
self._update_indexes(record)
|
||||
except Exception as e:
|
||||
print(f"[TokenUsage] 保存记录失败: {e}")
|
||||
|
||||
def _update_indexes(self, record: TokenUsageRecord):
|
||||
"""
|
||||
更新索引文件 - 实现高效的按维度聚合查询
|
||||
|
||||
索引结构:
|
||||
- indexes/api_urls.json - 按 API URL 聚合
|
||||
- indexes/daily/{year}-{month}.json - 按日聚合
|
||||
"""
|
||||
dt = datetime.fromtimestamp(record.timestamp)
|
||||
|
||||
# 1. 更新 API URL 索引
|
||||
if record.apiUrl:
|
||||
api_url_index = self.index_dir / "api_urls.json"
|
||||
self._update_api_url_index(api_url_index, record)
|
||||
|
||||
# 2. 更新每日索引
|
||||
daily_index = self.index_dir / "daily" / f"{dt.year}-{dt.month:02d}.json"
|
||||
daily_index.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._update_daily_index(daily_index, record)
|
||||
|
||||
def _update_api_url_index(self, index_file: Path, record: TokenUsageRecord):
|
||||
"""更新 API URL 索引文件"""
|
||||
index_data = {}
|
||||
|
||||
# 加载现有索引
|
||||
if index_file.exists():
|
||||
try:
|
||||
with open(index_file, 'r', encoding='utf-8') as f:
|
||||
index_data = json.load(f)
|
||||
except:
|
||||
index_data = {}
|
||||
|
||||
# 更新统计
|
||||
api_url = record.apiUrl
|
||||
if api_url not in index_data:
|
||||
index_data[api_url] = {
|
||||
"totalPromptTokens": 0,
|
||||
"totalCompletionTokens": 0,
|
||||
"totalTokens": 0,
|
||||
"count": 0,
|
||||
"firstUsed": record.timestamp,
|
||||
"lastUsed": record.timestamp
|
||||
}
|
||||
|
||||
stats = index_data[api_url]
|
||||
stats["totalPromptTokens"] += record.promptTokens
|
||||
stats["totalCompletionTokens"] += record.completionTokens
|
||||
stats["totalTokens"] += record.totalTokens
|
||||
stats["count"] += 1
|
||||
stats["lastUsed"] = max(stats["lastUsed"], record.timestamp)
|
||||
stats["firstUsed"] = min(stats["firstUsed"], record.timestamp)
|
||||
|
||||
# 保存索引
|
||||
with open(index_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(index_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
def _update_daily_index(self, index_file: Path, record: TokenUsageRecord):
|
||||
"""更新每日索引文件"""
|
||||
dt = datetime.fromtimestamp(record.timestamp)
|
||||
day_key = f"{dt.year}-{dt.month:02d}-{dt.day:02d}"
|
||||
|
||||
index_data = {}
|
||||
|
||||
# 加载现有索引
|
||||
if index_file.exists():
|
||||
try:
|
||||
with open(index_file, 'r', encoding='utf-8') as f:
|
||||
index_data = json.load(f)
|
||||
except:
|
||||
index_data = {}
|
||||
|
||||
# 更新统计
|
||||
if day_key not in index_data:
|
||||
index_data[day_key] = {
|
||||
"promptTokens": 0,
|
||||
"completionTokens": 0,
|
||||
"totalTokens": 0,
|
||||
"count": 0
|
||||
}
|
||||
|
||||
stats = index_data[day_key]
|
||||
stats["promptTokens"] += record.promptTokens
|
||||
stats["completionTokens"] += record.completionTokens
|
||||
stats["totalTokens"] += record.totalTokens
|
||||
stats["count"] += 1
|
||||
|
||||
# 保存索引
|
||||
with open(index_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(index_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
async def record_usage(
|
||||
self,
|
||||
chat_id: str,
|
||||
role_name: str,
|
||||
chat_name: str,
|
||||
prompt_tokens: int,
|
||||
completion_tokens: int,
|
||||
total_tokens: int,
|
||||
status: TokenUsageStatus = TokenUsageStatus.COMPLETED,
|
||||
message_id: Optional[str] = None,
|
||||
floor: Optional[int] = None,
|
||||
error_message: Optional[str] = None,
|
||||
duration: Optional[float] = None,
|
||||
model: Optional[str] = None,
|
||||
api_provider: Optional[str] = None,
|
||||
api_url: Optional[str] = None
|
||||
) -> TokenUsageRecord:
|
||||
"""
|
||||
记录一次 LLM 调用的 token 使用情况
|
||||
|
||||
Args:
|
||||
chat_id: 聊天ID
|
||||
role_name: 角色名称
|
||||
chat_name: 聊天名称
|
||||
prompt_tokens: 输入 token 数
|
||||
completion_tokens: 输出 token 数
|
||||
total_tokens: 总 token 数
|
||||
status: 请求状态
|
||||
message_id: 关联的消息ID
|
||||
floor: 楼层号
|
||||
error_message: 错误信息
|
||||
duration: 请求耗时
|
||||
model: 使用的模型
|
||||
api_provider: API 提供商
|
||||
api_url: API URL地址
|
||||
|
||||
Returns:
|
||||
TokenUsageRecord: 创建的记录
|
||||
"""
|
||||
record = TokenUsageRecord(
|
||||
id=str(uuid.uuid4()),
|
||||
chatId=chat_id,
|
||||
roleName=role_name,
|
||||
chatName=chat_name,
|
||||
messageId=message_id,
|
||||
floor=floor,
|
||||
promptTokens=prompt_tokens,
|
||||
completionTokens=completion_tokens,
|
||||
totalTokens=total_tokens,
|
||||
status=status,
|
||||
errorMessage=error_message,
|
||||
duration=duration,
|
||||
model=model,
|
||||
apiProvider=api_provider,
|
||||
apiUrl=api_url
|
||||
)
|
||||
|
||||
self._save_record(record)
|
||||
return record
|
||||
|
||||
async def get_stats_by_month(
|
||||
self,
|
||||
year: int,
|
||||
month: int,
|
||||
role_name: Optional[str] = None,
|
||||
chat_name: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
获取指定月份的统计数据
|
||||
|
||||
Args:
|
||||
year: 年份
|
||||
month: 月份
|
||||
role_name: 角色名称(可选,用于过滤)
|
||||
chat_name: 聊天名称(可选,用于过滤)
|
||||
|
||||
Returns:
|
||||
统计数据字典
|
||||
"""
|
||||
records = self._load_month_records(year, month)
|
||||
|
||||
# 过滤
|
||||
if role_name:
|
||||
records = [r for r in records if r.roleName == role_name]
|
||||
if chat_name:
|
||||
records = [r for r in records if r.chatName == chat_name]
|
||||
|
||||
# 统计
|
||||
total_prompt = sum(r.promptTokens for r in records)
|
||||
total_completion = sum(r.completionTokens for r in records)
|
||||
total_tokens = sum(r.totalTokens for r in records)
|
||||
|
||||
completed_count = sum(1 for r in records if r.status == TokenUsageStatus.COMPLETED)
|
||||
interrupted_count = sum(1 for r in records if r.status == TokenUsageStatus.INTERRUPTED)
|
||||
failed_count = sum(1 for r in records if r.status == TokenUsageStatus.FAILED)
|
||||
|
||||
# 按日期分组
|
||||
daily_stats = defaultdict(lambda: {
|
||||
"promptTokens": 0,
|
||||
"completionTokens": 0,
|
||||
"totalTokens": 0,
|
||||
"count": 0
|
||||
})
|
||||
|
||||
for r in records:
|
||||
dt = datetime.fromtimestamp(r.timestamp)
|
||||
day_key = f"{dt.year}-{dt.month:02d}-{dt.day:02d}"
|
||||
daily_stats[day_key]["promptTokens"] += r.promptTokens
|
||||
daily_stats[day_key]["completionTokens"] += r.completionTokens
|
||||
daily_stats[day_key]["totalTokens"] += r.totalTokens
|
||||
daily_stats[day_key]["count"] += 1
|
||||
|
||||
# 按角色分组
|
||||
role_stats = defaultdict(lambda: {
|
||||
"promptTokens": 0,
|
||||
"completionTokens": 0,
|
||||
"totalTokens": 0,
|
||||
"count": 0
|
||||
})
|
||||
|
||||
for r in records:
|
||||
role_stats[r.roleName]["promptTokens"] += r.promptTokens
|
||||
role_stats[r.roleName]["completionTokens"] += r.completionTokens
|
||||
role_stats[r.roleName]["totalTokens"] += r.totalTokens
|
||||
role_stats[r.roleName]["count"] += 1
|
||||
|
||||
# 按聊天分组
|
||||
chat_stats = defaultdict(lambda: {
|
||||
"promptTokens": 0,
|
||||
"completionTokens": 0,
|
||||
"totalTokens": 0,
|
||||
"count": 0
|
||||
})
|
||||
|
||||
for r in records:
|
||||
chat_key = f"{r.roleName}/{r.chatName}"
|
||||
chat_stats[chat_key]["promptTokens"] += r.promptTokens
|
||||
chat_stats[chat_key]["completionTokens"] += r.completionTokens
|
||||
chat_stats[chat_key]["totalTokens"] += r.totalTokens
|
||||
chat_stats[chat_key]["count"] += 1
|
||||
|
||||
# ✅ 按 API URL 分组
|
||||
api_url_stats = defaultdict(lambda: {
|
||||
"promptTokens": 0,
|
||||
"completionTokens": 0,
|
||||
"totalTokens": 0,
|
||||
"count": 0
|
||||
})
|
||||
|
||||
for r in records:
|
||||
if r.apiUrl:
|
||||
api_url_stats[r.apiUrl]["promptTokens"] += r.promptTokens
|
||||
api_url_stats[r.apiUrl]["completionTokens"] += r.completionTokens
|
||||
api_url_stats[r.apiUrl]["totalTokens"] += r.totalTokens
|
||||
api_url_stats[r.apiUrl]["count"] += 1
|
||||
|
||||
return {
|
||||
"year": year,
|
||||
"month": month,
|
||||
"totalRecords": len(records),
|
||||
"totalPromptTokens": total_prompt,
|
||||
"totalCompletionTokens": total_completion,
|
||||
"totalTokens": total_tokens,
|
||||
"completedCount": completed_count,
|
||||
"interruptedCount": interrupted_count,
|
||||
"failedCount": failed_count,
|
||||
"dailyStats": dict(daily_stats),
|
||||
"roleStats": dict(role_stats),
|
||||
"chatStats": dict(chat_stats),
|
||||
"apiUrlStats": dict(api_url_stats), # ✅ 新增
|
||||
"records": [r.model_dump() for r in records[:100]] # 最近100条记录
|
||||
}
|
||||
|
||||
async def list_months(self) -> List[Dict[str, int]]:
|
||||
"""列出所有有数据的月份"""
|
||||
months = []
|
||||
|
||||
if not self.token_usage_dir.exists():
|
||||
return months
|
||||
|
||||
for year_dir in sorted(self.token_usage_dir.iterdir()):
|
||||
if year_dir.is_dir() and year_dir.name.isdigit():
|
||||
year = int(year_dir.name)
|
||||
for month_file in sorted(year_dir.glob("*.jsonl")):
|
||||
month = int(month_file.stem)
|
||||
months.append({"year": year, "month": month})
|
||||
|
||||
return months
|
||||
|
||||
async def get_api_url_stats(self) -> Dict[str, Any]:
|
||||
"""
|
||||
✅ 获取按 API URL 分组的统计数据(从索引文件快速读取)
|
||||
|
||||
Returns:
|
||||
{api_url: {totalPromptTokens, totalCompletionTokens, totalTokens, count, firstUsed, lastUsed}}
|
||||
"""
|
||||
api_url_index = self.index_dir / "api_urls.json"
|
||||
|
||||
if not api_url_index.exists():
|
||||
return {}
|
||||
|
||||
try:
|
||||
with open(api_url_index, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
print(f"[TokenUsage] 读取 API URL 索引失败: {e}")
|
||||
return {}
|
||||
|
||||
async def get_daily_stats(self, year: int, month: int) -> Dict[str, Any]:
|
||||
"""
|
||||
✅ 获取指定月份的每日统计数据(从索引文件快速读取)
|
||||
|
||||
Args:
|
||||
year: 年份
|
||||
month: 月份
|
||||
|
||||
Returns:
|
||||
{day_key: {promptTokens, completionTokens, totalTokens, count}}
|
||||
"""
|
||||
daily_index = self.index_dir / "daily" / f"{year}-{month:02d}.json"
|
||||
|
||||
if not daily_index.exists():
|
||||
return {}
|
||||
|
||||
try:
|
||||
with open(daily_index, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
print(f"[TokenUsage] 读取每日索引失败: {e}")
|
||||
return {}
|
||||
|
||||
async def get_available_roles(self, year: int, month: int) -> List[str]:
|
||||
"""获取指定月份有数据的角色列表"""
|
||||
records = self._load_month_records(year, month)
|
||||
roles = set(r.roleName for r in records)
|
||||
return sorted(list(roles))
|
||||
|
||||
async def get_available_chats(
|
||||
self,
|
||||
year: int,
|
||||
month: int,
|
||||
role_name: Optional[str] = None
|
||||
) -> List[str]:
|
||||
"""获取指定月份有数据的聊天列表"""
|
||||
records = self._load_month_records(year, month)
|
||||
|
||||
if role_name:
|
||||
records = [r for r in records if r.roleName == role_name]
|
||||
|
||||
chats = set(f"{r.roleName}/{r.chatName}" for r in records)
|
||||
return sorted(list(chats))
|
||||
|
||||
|
||||
# 全局实例
|
||||
token_usage_service = TokenUsageService()
|
||||
49
backend/services/tool_registry.py
Normal file
49
backend/services/tool_registry.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""
|
||||
Tool registry for workflow engine steps.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional
|
||||
|
||||
try:
|
||||
from backend.models.agent import TurnContext, ToolSpec
|
||||
except ImportError:
|
||||
from models.agent import TurnContext, ToolSpec
|
||||
|
||||
ToolHandler = Callable[[TurnContext], Awaitable[None]]
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
def __init__(self) -> None:
|
||||
self._tools: Dict[str, ToolHandler] = {}
|
||||
self._specs: Dict[str, ToolSpec] = {}
|
||||
|
||||
def register(
|
||||
self,
|
||||
name: str,
|
||||
handler: ToolHandler,
|
||||
*,
|
||||
description: str = "",
|
||||
parameters: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
self._tools[name] = handler
|
||||
self._specs[name] = ToolSpec(
|
||||
name=name,
|
||||
description=description,
|
||||
parameters=parameters or {},
|
||||
)
|
||||
|
||||
def get(self, name: str) -> ToolHandler:
|
||||
if name not in self._tools:
|
||||
raise KeyError(f"Unknown tool: {name}")
|
||||
return self._tools[name]
|
||||
|
||||
def list_specs(self) -> list[ToolSpec]:
|
||||
return list(self._specs.values())
|
||||
|
||||
async def execute(self, name: str, ctx: TurnContext) -> None:
|
||||
handler = self.get(name)
|
||||
await handler(ctx)
|
||||
|
||||
|
||||
default_tool_registry = ToolRegistry()
|
||||
1
backend/services/tools/__init__.py
Normal file
1
backend/services/tools/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Workflow chat tools package."""
|
||||
261
backend/services/tools/chat_tools.py
Normal file
261
backend/services/tools/chat_tools.py
Normal file
@@ -0,0 +1,261 @@
|
||||
"""
|
||||
Chat workflow tools extracted from ChatWorkflowService.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, List
|
||||
|
||||
try:
|
||||
from backend.models.agent import TurnContext
|
||||
from backend.models.internal import CharacterCard, TokenUsageStatus
|
||||
from backend.models.regex_rules import RegexPlacement
|
||||
from backend.services.character_service import CharacterService
|
||||
from backend.services.regex_service import regex_service
|
||||
from backend.services.task_queue_manager import TaskType, task_queue_manager
|
||||
from backend.services.token_usage_service import token_usage_service
|
||||
from backend.core.config import settings
|
||||
except ImportError:
|
||||
from models.agent import TurnContext
|
||||
from models.internal import CharacterCard, TokenUsageStatus
|
||||
from models.regex_rules import RegexPlacement
|
||||
from services.character_service import CharacterService
|
||||
from services.regex_service import regex_service
|
||||
from services.task_queue_manager import TaskType, task_queue_manager
|
||||
from services.token_usage_service import token_usage_service
|
||||
from core.config import settings
|
||||
|
||||
|
||||
_character_service = CharacterService()
|
||||
_workflow_service = None
|
||||
|
||||
|
||||
def _get_workflow_service():
|
||||
"""Lazy init to avoid circular import with chat_workflow_service."""
|
||||
global _workflow_service
|
||||
if _workflow_service is None:
|
||||
try:
|
||||
from backend.services.chat_workflow_service import ChatWorkflowService
|
||||
except ImportError:
|
||||
from services.chat_workflow_service import ChatWorkflowService
|
||||
_workflow_service = ChatWorkflowService()
|
||||
return _workflow_service
|
||||
|
||||
|
||||
async def regex_apply_user_input(ctx: TurnContext) -> None:
|
||||
processed = regex_service.apply_rules_by_placement(
|
||||
text=ctx.user_message,
|
||||
placement=RegexPlacement.USER_INPUT.value,
|
||||
character_name=ctx.current_role,
|
||||
preset_name=ctx.preset_name,
|
||||
message_depth=0,
|
||||
is_for_llm=True,
|
||||
is_markdown_rendered=False,
|
||||
)
|
||||
if processed != ctx.user_message:
|
||||
print("[WorkflowTool] Applied user-input regex rules")
|
||||
ctx.user_message = processed
|
||||
|
||||
|
||||
async def load_character(ctx: TurnContext) -> None:
|
||||
character_data = ctx.request_data.get("characterData")
|
||||
if not character_data:
|
||||
character = _character_service.get_character_by_name(ctx.current_role)
|
||||
if not character:
|
||||
raise ValueError(f"角色 '{ctx.current_role}' 不存在")
|
||||
else:
|
||||
character = CharacterCard(**character_data)
|
||||
ctx.character = character
|
||||
print(f"[WorkflowTool] Loaded character: {character.name}")
|
||||
|
||||
|
||||
async def activate_worldbook(ctx: TurnContext) -> None:
|
||||
svc = _get_workflow_service()
|
||||
active_entries = await svc._collect_and_activate_worldbooks(
|
||||
ctx.request_data,
|
||||
ctx.character,
|
||||
)
|
||||
ctx.active_entries = active_entries
|
||||
print(f"[WorkflowTool] Activated {len(active_entries)} worldbook entries")
|
||||
|
||||
if ctx.callbacks and ctx.callbacks.on_worldbook_active:
|
||||
entries_payload = [
|
||||
entry.model_dump() if hasattr(entry, "model_dump") else entry
|
||||
for entry in active_entries
|
||||
]
|
||||
await ctx.callbacks.on_worldbook_active(entries_payload)
|
||||
|
||||
|
||||
async def load_chat_history(ctx: TurnContext) -> None:
|
||||
svc = _get_workflow_service()
|
||||
chat_history = await svc._load_chat_history(
|
||||
ctx.current_role,
|
||||
ctx.current_chat,
|
||||
)
|
||||
ctx.chat_history = chat_history
|
||||
print(f"[WorkflowTool] Loaded {len(chat_history)} history messages")
|
||||
|
||||
|
||||
async def build_prompt_messages(ctx: TurnContext) -> None:
|
||||
svc = _get_workflow_service()
|
||||
prompt_messages = svc._assemble_prompt(
|
||||
ctx.character,
|
||||
ctx.chat_history,
|
||||
ctx.user_message,
|
||||
ctx.active_entries,
|
||||
ctx.request_data,
|
||||
)
|
||||
ctx.prompt_messages = prompt_messages
|
||||
print(f"[WorkflowTool] Built {len(prompt_messages)} prompt messages")
|
||||
|
||||
|
||||
async def llm_main_reply(ctx: TurnContext) -> None:
|
||||
svc = _get_workflow_service()
|
||||
api_config = ctx.request_data.get("apiConfig", {})
|
||||
preset_config = ctx.request_data.get("presetConfig", {})
|
||||
|
||||
if ctx.stream:
|
||||
if not api_config.get("api_key"):
|
||||
raise ValueError("API Key 未配置,请先在 API 配置页面保存密钥")
|
||||
|
||||
generated_content = ""
|
||||
chunk_count = 0
|
||||
start_time = time.time()
|
||||
|
||||
async for chunk_dict in svc.llm_client.stream_chat(
|
||||
messages=ctx.prompt_messages,
|
||||
api_url=api_config.get("api_url", ""),
|
||||
api_key=api_config.get("api_key", ""),
|
||||
model=api_config.get("model", ""),
|
||||
temperature=preset_config.get("parameters", {}).get("temperature", 1.0),
|
||||
max_tokens=preset_config.get("parameters", {}).get("max_tokens", 30000),
|
||||
request_timeout=preset_config.get("parameters", {}).get("request_timeout", 60),
|
||||
):
|
||||
if isinstance(chunk_dict, dict):
|
||||
if chunk_dict.get("type") == "chunk":
|
||||
chunk_content = chunk_dict.get("content", "")
|
||||
elif chunk_dict.get("type") == "usage":
|
||||
continue
|
||||
else:
|
||||
chunk_content = chunk_dict.get("content", str(chunk_dict))
|
||||
else:
|
||||
chunk_content = str(chunk_dict)
|
||||
|
||||
generated_content += chunk_content
|
||||
chunk_count += 1
|
||||
|
||||
if ctx.callbacks and ctx.callbacks.on_chunk:
|
||||
await ctx.callbacks.on_chunk(chunk_content)
|
||||
|
||||
ctx.duration = time.time() - start_time
|
||||
ctx.generated_content = generated_content
|
||||
ctx.token_usage = {
|
||||
"prompt_tokens": len(str(ctx.prompt_messages)) // 4,
|
||||
"completion_tokens": len(generated_content) // 4,
|
||||
"total_tokens": (len(str(ctx.prompt_messages)) // 4)
|
||||
+ (len(generated_content) // 4),
|
||||
}
|
||||
print(
|
||||
f"[WorkflowTool] Stream LLM complete: {chunk_count} chunks, "
|
||||
f"{len(generated_content)} chars"
|
||||
)
|
||||
else:
|
||||
result = await svc._generate_response(
|
||||
ctx.prompt_messages,
|
||||
api_config,
|
||||
preset_config,
|
||||
stream=False,
|
||||
)
|
||||
ctx.generated_content = result["content"]
|
||||
ctx.token_usage = result.get("usage", {})
|
||||
ctx.duration = result.get("duration", 0.0)
|
||||
print(f"[WorkflowTool] LLM complete: {len(ctx.generated_content)} chars")
|
||||
|
||||
|
||||
async def regex_apply_ai_output(ctx: TurnContext) -> None:
|
||||
processed = regex_service.apply_rules_by_placement(
|
||||
text=ctx.generated_content,
|
||||
placement=RegexPlacement.AI_OUTPUT.value,
|
||||
character_name=ctx.current_role,
|
||||
preset_name=ctx.preset_name,
|
||||
message_depth=0,
|
||||
is_for_llm=False,
|
||||
is_markdown_rendered=False,
|
||||
)
|
||||
if processed != ctx.generated_content:
|
||||
print("[WorkflowTool] Applied AI-output regex rules")
|
||||
ctx.generated_content = processed
|
||||
|
||||
|
||||
async def record_token_usage(ctx: TurnContext) -> None:
|
||||
chat_id = f"{ctx.current_role}/{ctx.current_chat}"
|
||||
floor = ctx.request_data.get("floor", 0)
|
||||
api_config = ctx.request_data.get("apiConfig", {})
|
||||
|
||||
try:
|
||||
await token_usage_service.record_usage(
|
||||
chat_id=chat_id,
|
||||
role_name=ctx.current_role,
|
||||
chat_name=ctx.current_chat,
|
||||
prompt_tokens=ctx.token_usage.get("prompt_tokens", 0),
|
||||
completion_tokens=ctx.token_usage.get("completion_tokens", 0),
|
||||
total_tokens=ctx.token_usage.get("total_tokens", 0),
|
||||
status=TokenUsageStatus.COMPLETED,
|
||||
floor=floor + 1,
|
||||
duration=ctx.duration,
|
||||
model=api_config.get("model"),
|
||||
api_provider="openai",
|
||||
api_url=api_config.get("api_url"),
|
||||
)
|
||||
except Exception as exc:
|
||||
print(f"[WorkflowTool] Token usage recording failed: {exc}")
|
||||
|
||||
|
||||
async def enqueue_parallel_tasks(ctx: TurnContext) -> None:
|
||||
chat_id = f"{ctx.current_role}/{ctx.current_chat}"
|
||||
options = ctx.request_data.get("options", {})
|
||||
image_task_id = None
|
||||
table_task_id = None
|
||||
|
||||
if options.get("imageWorkflow", False):
|
||||
image_task_id = f"img_{uuid.uuid4().hex[:8]}"
|
||||
await task_queue_manager.add_task(image_task_id, TaskType.IMAGE_WORKFLOW, chat_id)
|
||||
|
||||
if options.get("dynamicTable", False):
|
||||
table_task_id = f"tbl_{uuid.uuid4().hex[:8]}"
|
||||
await task_queue_manager.add_task(table_task_id, TaskType.DYNAMIC_TABLE, chat_id)
|
||||
|
||||
ctx.task_ids = {
|
||||
"imageWorkflow": image_task_id,
|
||||
"dynamicTable": table_task_id,
|
||||
}
|
||||
|
||||
if ctx.callbacks and ctx.callbacks.on_tasks_created:
|
||||
if image_task_id or table_task_id:
|
||||
await ctx.callbacks.on_tasks_created(ctx.task_ids)
|
||||
|
||||
# Fire-and-forget parallel workers (same as legacy service)
|
||||
svc = _get_workflow_service()
|
||||
asyncio.create_task(
|
||||
svc._start_parallel_tasks(
|
||||
ctx.request_data,
|
||||
ctx.generated_content,
|
||||
image_task_id,
|
||||
table_task_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def register_chat_tools(registry) -> None:
|
||||
"""Register all chat workflow tools on the given registry."""
|
||||
registry.register("regex_apply_user_input", regex_apply_user_input, description="Apply user-input regex")
|
||||
registry.register("load_character", load_character, description="Load character card")
|
||||
registry.register("activate_worldbook", activate_worldbook, description="Activate worldbook entries")
|
||||
registry.register("load_chat_history", load_chat_history, description="Load chat history")
|
||||
registry.register("build_prompt_messages", build_prompt_messages, description="Assemble LLM prompt")
|
||||
registry.register("llm_main_reply", llm_main_reply, description="Call main LLM (supports stream)")
|
||||
registry.register("regex_apply_ai_output", regex_apply_ai_output, description="Apply AI-output regex")
|
||||
registry.register("record_token_usage", record_token_usage, description="Persist token usage")
|
||||
registry.register("enqueue_parallel_tasks", enqueue_parallel_tasks, description="Enqueue parallel tasks")
|
||||
170
backend/services/workflow_engine.py
Normal file
170
backend/services/workflow_engine.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
Workflow engine – orchestrates template loading, state machine execution, and run persistence.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional
|
||||
|
||||
try:
|
||||
from backend.core.config import settings
|
||||
from backend.models.agent import (
|
||||
ChatRunBinding,
|
||||
ChatTurnResult,
|
||||
RunEvent,
|
||||
RunStatus,
|
||||
TurnCallbacks,
|
||||
TurnContext,
|
||||
WorkflowRun,
|
||||
WorkflowTemplate,
|
||||
WorkflowTemplateKind,
|
||||
)
|
||||
from backend.services.state_machine_runner import StateMachineRunner
|
||||
from backend.services.tool_registry import ToolRegistry, default_tool_registry
|
||||
from backend.services.tools.chat_tools import register_chat_tools
|
||||
except ImportError:
|
||||
from core.config import settings
|
||||
from models.agent import (
|
||||
ChatRunBinding,
|
||||
ChatTurnResult,
|
||||
RunEvent,
|
||||
RunStatus,
|
||||
TurnCallbacks,
|
||||
TurnContext,
|
||||
WorkflowRun,
|
||||
WorkflowTemplate,
|
||||
WorkflowTemplateKind,
|
||||
)
|
||||
from services.state_machine_runner import StateMachineRunner
|
||||
from services.tool_registry import ToolRegistry, default_tool_registry
|
||||
from services.tools.chat_tools import register_chat_tools
|
||||
|
||||
|
||||
class WorkflowEngine:
|
||||
def __init__(self, registry: Optional[ToolRegistry] = None) -> None:
|
||||
self.registry = registry or default_tool_registry
|
||||
if not self.registry.list_specs():
|
||||
register_chat_tools(self.registry)
|
||||
|
||||
def _template_dir(self, template_id: str) -> Path:
|
||||
return settings.AGENT_TEMPLATES_PATH / template_id
|
||||
|
||||
def load_template(self, template_id: str = WorkflowTemplateKind.BUILTIN_CHAT.value) -> WorkflowTemplate:
|
||||
template_path = self._template_dir(template_id) / "template.json"
|
||||
with open(template_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return WorkflowTemplate(**data)
|
||||
|
||||
def _run_dir(self, role_name: str, chat_name: str) -> Path:
|
||||
return settings.AGENT_RUNS_PATH / "chat" / role_name / chat_name
|
||||
|
||||
def _persist_run(self, run: WorkflowRun, events: List[RunEvent]) -> None:
|
||||
run_dir = self._run_dir(run.binding.role_name, run.binding.chat_name)
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
run_file = run_dir / "run.json"
|
||||
run.finished_at = datetime.now().isoformat()
|
||||
with open(run_file, "w", encoding="utf-8") as f:
|
||||
json.dump(run.model_dump(), f, ensure_ascii=False, indent=2)
|
||||
|
||||
events_file = run_dir / "events.jsonl"
|
||||
with open(events_file, "a", encoding="utf-8") as f:
|
||||
for event in events:
|
||||
f.write(json.dumps(event.model_dump(), ensure_ascii=False) + "\n")
|
||||
|
||||
async def run_turn(
|
||||
self,
|
||||
request_data: Dict[str, Any],
|
||||
*,
|
||||
stream: bool = False,
|
||||
on_chunk: Optional[Callable[[str], Awaitable[None]]] = None,
|
||||
on_worldbook_active: Optional[Callable[[List[Any]], Awaitable[None]]] = None,
|
||||
on_tasks_created: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
|
||||
template_id: str = WorkflowTemplateKind.BUILTIN_CHAT.value,
|
||||
) -> ChatTurnResult:
|
||||
current_role = request_data.get("currentRole", "")
|
||||
current_chat = request_data.get("currentChat", "")
|
||||
user_message = request_data.get("mes", "")
|
||||
|
||||
if not current_role or not user_message:
|
||||
return ChatTurnResult(
|
||||
success=False,
|
||||
error="缺少必要的参数:currentRole 或 mes",
|
||||
workflow_template_id=template_id,
|
||||
)
|
||||
|
||||
preset_config = request_data.get("presetConfig", {})
|
||||
preset_name = preset_config.get("selectedPreset")
|
||||
|
||||
run_id = uuid.uuid4().hex
|
||||
binding = ChatRunBinding(
|
||||
role_name=current_role,
|
||||
chat_name=current_chat or "",
|
||||
template_id=template_id,
|
||||
)
|
||||
run = WorkflowRun(
|
||||
id=run_id,
|
||||
template_id=template_id,
|
||||
binding=binding,
|
||||
status=RunStatus.PENDING,
|
||||
)
|
||||
|
||||
callbacks = TurnCallbacks(
|
||||
on_chunk=on_chunk,
|
||||
on_worldbook_active=on_worldbook_active,
|
||||
on_tasks_created=on_tasks_created,
|
||||
)
|
||||
|
||||
ctx = TurnContext(
|
||||
request_data=request_data,
|
||||
template_id=template_id,
|
||||
run_id=run_id,
|
||||
stream=stream,
|
||||
callbacks=callbacks,
|
||||
current_role=current_role,
|
||||
current_chat=current_chat or "",
|
||||
user_message=user_message,
|
||||
preset_name=preset_name,
|
||||
)
|
||||
|
||||
template = self.load_template(template_id)
|
||||
sm_path = self._template_dir(template_id) / template.state_machine_path
|
||||
runner = StateMachineRunner.from_file(sm_path, self.registry)
|
||||
|
||||
try:
|
||||
events = await runner.run(run, ctx)
|
||||
self._persist_run(run, events)
|
||||
|
||||
active_entries = [
|
||||
entry.model_dump() if hasattr(entry, "model_dump") else entry
|
||||
for entry in ctx.active_entries
|
||||
]
|
||||
|
||||
return ChatTurnResult(
|
||||
success=True,
|
||||
content=ctx.generated_content,
|
||||
active_entries=active_entries,
|
||||
task_ids=ctx.task_ids,
|
||||
run_id=run_id,
|
||||
workflow_template_id=template_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
run.status = RunStatus.FAILED
|
||||
run.error = str(exc)
|
||||
try:
|
||||
self._persist_run(run, [])
|
||||
except Exception:
|
||||
pass
|
||||
return ChatTurnResult(
|
||||
success=False,
|
||||
error=f"工作流执行失败: {exc}",
|
||||
run_id=run_id,
|
||||
workflow_template_id=template_id,
|
||||
)
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
workflow_engine = WorkflowEngine()
|
||||
@@ -232,6 +232,32 @@ class WorldBookService:
|
||||
|
||||
raise FileNotFoundError(f"Entry '{uid}' not found in worldbook '{name}'")
|
||||
|
||||
@staticmethod
|
||||
def append_entry(name: str, entry_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
在世界书中追加条目(规范化后写入,与 Chat 侧条目格式一致)。
|
||||
|
||||
Args:
|
||||
name: 世界书名称(文件名,不含 .json)
|
||||
entry_data: 条目字段(content、comment、activationType、position 等)
|
||||
|
||||
Returns:
|
||||
写入后的规范化条目
|
||||
"""
|
||||
data = WorldBookService._load_worldbook(name)
|
||||
if not data:
|
||||
raise FileNotFoundError(f"Worldbook '{name}' not found")
|
||||
|
||||
if not isinstance(data.get("entries"), list):
|
||||
data["entries"] = []
|
||||
|
||||
normalized = WorldBookConverter.normalize_entry(entry_data)
|
||||
data["entries"].append(normalized)
|
||||
now = int(datetime.now().timestamp())
|
||||
data["updatedAt"] = now
|
||||
WorldBookService._save_worldbook(name, data)
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def create_entry(name: str, entry_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
"""
|
||||
检查前端世界书功能与后端API的匹配情况
|
||||
"""
|
||||
|
||||
print("=" * 80)
|
||||
print("前端世界书功能与后端API路由匹配检查")
|
||||
print("=" * 80)
|
||||
|
||||
# 前端Store中的API调用
|
||||
frontend_calls = [
|
||||
{
|
||||
"功能": "获取世界书列表",
|
||||
"方法": "GET",
|
||||
"路径": "/api/worldbooks/",
|
||||
"Store函数": "fetchWorldBooks"
|
||||
},
|
||||
{
|
||||
"功能": "获取指定世界书",
|
||||
"方法": "GET",
|
||||
"路径": "/api/worldbooks/{name}",
|
||||
"Store函数": "fetchWorldBook"
|
||||
},
|
||||
{
|
||||
"功能": "创建世界书",
|
||||
"方法": "POST",
|
||||
"路径": "/api/worldbooks/",
|
||||
"Store函数": "createWorldBook",
|
||||
"备注": "FormData: name, is_global, file"
|
||||
},
|
||||
{
|
||||
"功能": "更新世界书",
|
||||
"方法": "PUT",
|
||||
"路径": "/api/worldbooks/{name}",
|
||||
"Store函数": "updateWorldBook",
|
||||
"备注": "FormData: is_global, file"
|
||||
},
|
||||
{
|
||||
"功能": "删除世界书",
|
||||
"方法": "DELETE",
|
||||
"路径": "/api/worldbooks/{name}",
|
||||
"Store函数": "deleteWorldBook"
|
||||
},
|
||||
{
|
||||
"功能": "获取世界书条目(分页)",
|
||||
"方法": "GET",
|
||||
"路径": "/api/worldbooks/{name}/entries?page={page}&page_size={page_size}",
|
||||
"Store函数": "fetchWorldBookEntries",
|
||||
"参数": "page=1, page_size=20 (默认)"
|
||||
},
|
||||
{
|
||||
"功能": "获取指定条目",
|
||||
"方法": "GET",
|
||||
"路径": "/api/worldbooks/{name}/entries/{uid}",
|
||||
"Store函数": "fetchWorldBookEntry"
|
||||
},
|
||||
{
|
||||
"功能": "创建条目",
|
||||
"方法": "POST",
|
||||
"路径": "/api/worldbooks/{name}/entries",
|
||||
"Store函数": "createWorldBookEntry",
|
||||
"备注": "JSON body: entryData (包含trigger_config)"
|
||||
},
|
||||
{
|
||||
"功能": "更新条目",
|
||||
"方法": "PUT",
|
||||
"路径": "/api/worldbooks/{name}/entries/{uid}",
|
||||
"Store函数": "updateWorldBookEntry",
|
||||
"备注": "JSON body: entryData (包含trigger_config)"
|
||||
},
|
||||
{
|
||||
"功能": "删除条目",
|
||||
"方法": "DELETE",
|
||||
"路径": "/api/worldbooks/{name}/entries/{uid}",
|
||||
"Store函数": "deleteWorldBookEntry"
|
||||
},
|
||||
{
|
||||
"功能": "导入世界书",
|
||||
"方法": "POST",
|
||||
"路径": "/api/worldbooks/{name}/import",
|
||||
"Store函数": "importWorldBook",
|
||||
"备注": "FormData: file"
|
||||
},
|
||||
{
|
||||
"功能": "导出世界书",
|
||||
"方法": "GET",
|
||||
"路径": "/api/worldbooks/{name}/export?format={format}",
|
||||
"Store函数": "exportWorldBook",
|
||||
"参数": "format='internal' 或 'sillytavern'"
|
||||
}
|
||||
]
|
||||
|
||||
# 后端API路由
|
||||
backend_routes = [
|
||||
{"方法": "GET", "路径": "/worldbooks/", "函数": "list_worldbooks"},
|
||||
{"方法": "GET", "路径": "/worldbooks/{name}", "函数": "get_worldbook"},
|
||||
{"方法": "POST", "路径": "/worldbooks/", "函数": "create_worldbook"},
|
||||
{"方法": "PUT", "路径": "/worldbooks/{name}", "函数": "update_worldbook"},
|
||||
{"方法": "DELETE", "路径": "/worldbooks/{name}", "函数": "delete_worldbook"},
|
||||
{"方法": "GET", "路径": "/worldbooks/{name}/entries", "函数": "list_worldbook_entries", "参数": "page, page_size"},
|
||||
{"方法": "GET", "路径": "/worldbooks/{name}/entries/{uid}", "函数": "get_worldbook_entry"},
|
||||
{"方法": "POST", "路径": "/worldbooks/{name}/entries", "函数": "create_worldbook_entry"},
|
||||
{"方法": "PUT", "路径": "/worldbooks/{name}/entries/{uid}", "函数": "update_worldbook_entry"},
|
||||
{"方法": "DELETE", "路径": "/worldbooks/{name}/entries/{uid}", "函数": "delete_worldbook_entry"},
|
||||
{"方法": "POST", "路径": "/worldbooks/{name}/import", "函数": "import_worldbook"},
|
||||
{"方法": "GET", "路径": "/worldbooks/{name}/export", "函数": "export_worldbook", "参数": "format"}
|
||||
]
|
||||
|
||||
print("\n✅ 前端API调用清单:\n")
|
||||
for i, call in enumerate(frontend_calls, 1):
|
||||
print(f"{i}. {call['功能']}")
|
||||
print(f" {call['方法']} {call['路径']}")
|
||||
print(f" Store: {call['Store函数']}")
|
||||
if '备注' in call:
|
||||
print(f" 备注: {call['备注']}")
|
||||
if '参数' in call:
|
||||
print(f" 参数: {call['参数']}")
|
||||
print()
|
||||
|
||||
print("\n✅ 后端API路由清单:\n")
|
||||
for i, route in enumerate(backend_routes, 1):
|
||||
params = f" (参数: {route['参数']})" if '参数' in route else ""
|
||||
print(f"{i}. {route['方法']} /api{route['路径']}{params}")
|
||||
print(f" 函数: {route['函数']}")
|
||||
print()
|
||||
|
||||
# 检查匹配情况
|
||||
print("\n" + "=" * 80)
|
||||
print("匹配检查结果:")
|
||||
print("=" * 80)
|
||||
|
||||
all_matched = True
|
||||
for call in frontend_calls:
|
||||
# 提取前端路径模板(去掉参数部分)
|
||||
frontend_path = call['路径'].split('?')[0].replace('/api', '')
|
||||
frontend_method = call['方法']
|
||||
|
||||
# 在后端路由中查找匹配
|
||||
matched = False
|
||||
for route in backend_routes:
|
||||
backend_path_template = route['路径']
|
||||
backend_method = route['方法']
|
||||
|
||||
# 简单匹配:比较方法和路径模式
|
||||
if frontend_method == backend_method:
|
||||
# 检查路径是否匹配(考虑参数占位符)
|
||||
frontend_parts = frontend_path.strip('/').split('/')
|
||||
backend_parts = backend_path_template.strip('/').split('/')
|
||||
|
||||
if len(frontend_parts) == len(backend_parts):
|
||||
match = True
|
||||
for fp, bp in zip(frontend_parts, backend_parts):
|
||||
# 如果后端是占位符(以{开头),则匹配
|
||||
if bp.startswith('{') and bp.endswith('}'):
|
||||
continue
|
||||
# 否则必须完全匹配
|
||||
if fp != bp:
|
||||
match = False
|
||||
break
|
||||
|
||||
if match:
|
||||
matched = True
|
||||
break
|
||||
|
||||
status = "✓" if matched else "✗"
|
||||
print(f"{status} {call['功能']}: {frontend_method} {frontend_path}")
|
||||
|
||||
if not matched:
|
||||
all_matched = False
|
||||
print(f" ⚠️ 未找到匹配的后端路由!")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
if all_matched:
|
||||
print("✅ 所有前端API调用都有对应的后端路由!")
|
||||
else:
|
||||
print("❌ 存在不匹配的API调用,请检查!")
|
||||
print("=" * 80)
|
||||
|
||||
# 检查关键功能
|
||||
print("\n📋 关键功能检查:")
|
||||
print("=" * 80)
|
||||
|
||||
key_features = [
|
||||
("世界书列表加载", "GET /api/worldbooks/"),
|
||||
("选择世界书并加载条目", "GET /api/worldbooks/{name}/entries?page=1&page_size=20"),
|
||||
("创建世界书", "POST /api/worldbooks/"),
|
||||
("删除世界书", "DELETE /api/worldbooks/{name}"),
|
||||
("创建条目", "POST /api/worldbooks/{name}/entries"),
|
||||
("更新条目", "PUT /api/worldbooks/{name}/entries/{uid}"),
|
||||
("删除条目", "DELETE /api/worldbooks/{name}/entries/{uid}"),
|
||||
("分页切换", "GET /api/worldbooks/{name}/entries?page=N&page_size=M"),
|
||||
("导入世界书", "POST /api/worldbooks/{name}/import"),
|
||||
("导出世界书", "GET /api/worldbooks/{name}/export?format=internal")
|
||||
]
|
||||
|
||||
for feature, api_call in key_features:
|
||||
print(f"✓ {feature}")
|
||||
print(f" API: {api_call}")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("结论: 前端世界书分页的所有功能都能正确发出请求到后端API")
|
||||
print("=" * 80)
|
||||
@@ -1,49 +0,0 @@
|
||||
"""
|
||||
检查世界书路径和文件
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
# 项目根目录
|
||||
PROJECT_ROOT = Path(r'D:\progarm\python\llm_workflow_engine')
|
||||
DATA_PATH = PROJECT_ROOT / 'data'
|
||||
WORLDBOOKS_PATH = DATA_PATH / 'worldbooks'
|
||||
|
||||
print("=" * 60)
|
||||
print("世界书路径检查")
|
||||
print("=" * 60)
|
||||
|
||||
print(f"\n1. 项目根目录: {PROJECT_ROOT}")
|
||||
print(f" 存在: {PROJECT_ROOT.exists()}")
|
||||
|
||||
print(f"\n2. 数据目录: {DATA_PATH}")
|
||||
print(f" 存在: {DATA_PATH.exists()}")
|
||||
|
||||
print(f"\n3. 世界书目录: {WORLDBOOKS_PATH}")
|
||||
print(f" 存在: {WORLDBOOKS_PATH.exists()}")
|
||||
|
||||
if WORLDBOOKS_PATH.exists():
|
||||
json_files = list(WORLDBOOKS_PATH.glob("*.json"))
|
||||
print(f" JSON文件数量: {len(json_files)}")
|
||||
if json_files:
|
||||
print(f" 文件列表:")
|
||||
for f in json_files:
|
||||
print(f" - {f.name} ({f.stat().st_size} bytes)")
|
||||
else:
|
||||
print(f" ⚠️ 世界书目录为空,没有JSON文件")
|
||||
|
||||
# 检查目录下是否有子目录
|
||||
subdirs = list(WORLDBOOKS_PATH.iterdir())
|
||||
if subdirs:
|
||||
print(f" 子目录/文件:")
|
||||
for item in subdirs:
|
||||
print(f" - {item.name} ({'目录' if item.is_dir() else '文件'})")
|
||||
else:
|
||||
print(f" ❌ 世界书目录不存在!")
|
||||
|
||||
# 检查data目录下有什么
|
||||
if DATA_PATH.exists():
|
||||
print(f"\n4. data目录内容:")
|
||||
for item in DATA_PATH.iterdir():
|
||||
print(f" - {item.name} ({'目录' if item.is_dir() else '文件'})")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
@@ -1,53 +0,0 @@
|
||||
"""
|
||||
清空默认头像图片中的嵌入JSON数据
|
||||
"""
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
def clear_png_text_data(png_path):
|
||||
"""
|
||||
清空PNG文件中的tEXt文本数据(包括嵌入的JSON)
|
||||
|
||||
Args:
|
||||
png_path: PNG文件路径
|
||||
"""
|
||||
try:
|
||||
# 打开图片
|
||||
img = Image.open(png_path)
|
||||
|
||||
print(f"原始PNG文本数据键: {list(img.text.keys()) if img.text else '无'}")
|
||||
|
||||
# 创建一个新的图片对象,复制像素数据但不复制文本数据
|
||||
if img.mode == 'RGBA':
|
||||
new_img = Image.new('RGBA', img.size)
|
||||
else:
|
||||
new_img = Image.new('RGB', img.size)
|
||||
|
||||
# 复制像素数据
|
||||
new_img.paste(img)
|
||||
|
||||
# 确保新图片没有文本数据
|
||||
new_img.text = {}
|
||||
|
||||
# 保存回原文件
|
||||
buffer = io.BytesIO()
|
||||
new_img.save(buffer, format='PNG')
|
||||
buffer.seek(0)
|
||||
|
||||
with open(png_path, 'wb') as f:
|
||||
f.write(buffer.read())
|
||||
|
||||
# 验证是否已清空
|
||||
verify_img = Image.open(png_path)
|
||||
print(f"清空后PNG文本数据键: {list(verify_img.text.keys()) if verify_img.text else '无'}")
|
||||
print(f"✓ 成功清空 {png_path} 中的JSON数据")
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ 处理失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
if __name__ == '__main__':
|
||||
png_file = 'data/characters/defult.png'
|
||||
print(f"正在处理: {png_file}")
|
||||
clear_png_text_data(png_file)
|
||||
@@ -1,87 +0,0 @@
|
||||
"""
|
||||
将现有的 SillyTavern 格式世界书转换为内部格式
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
# 添加backend目录到Python路径
|
||||
backend_dir = Path(__file__).parent / "backend"
|
||||
sys.path.insert(0, str(backend_dir))
|
||||
|
||||
from models.converters import WorldBookConverter
|
||||
|
||||
WORLDBOOKS_PATH = Path(r'D:\progarm\python\llm_workflow_engine\data\worldbooks')
|
||||
|
||||
print("=" * 60)
|
||||
print("世界书格式转换工具")
|
||||
print("=" * 60)
|
||||
|
||||
# 查找所有JSON文件
|
||||
json_files = list(WORLDBOOKS_PATH.glob("*.json"))
|
||||
print(f"\n找到 {len(json_files)} 个世界书文件\n")
|
||||
|
||||
converted_count = 0
|
||||
skipped_count = 0
|
||||
error_count = 0
|
||||
|
||||
for file_path in json_files:
|
||||
print(f"处理: {file_path.name}")
|
||||
|
||||
try:
|
||||
# 读取文件
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# 检测格式
|
||||
format_type = WorldBookConverter.detect_format(data)
|
||||
print(f" 当前格式: {format_type}")
|
||||
|
||||
if format_type == "sillytavern":
|
||||
# 需要转换
|
||||
name = data.get("name", file_path.stem)
|
||||
print(f" 世界书名称: {name}")
|
||||
print(f" 条目数量: {len(data.get('entries', {}))}")
|
||||
|
||||
# 转换为内部格式
|
||||
internal_data = WorldBookConverter.st_to_internal(data, name)
|
||||
|
||||
# 备份原文件
|
||||
backup_path = file_path.with_suffix('.json.bak')
|
||||
file_path.rename(backup_path)
|
||||
print(f" ✓ 已备份原文件为: {backup_path.name}")
|
||||
|
||||
# 保存转换后的文件
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(internal_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f" ✓ 转换完成并保存")
|
||||
print(f" - 新格式: internal")
|
||||
print(f" - 条目数量: {len(internal_data.get('entries', []))}")
|
||||
converted_count += 1
|
||||
|
||||
elif format_type == "internal":
|
||||
print(f" ℹ 已是内部格式,跳过")
|
||||
skipped_count += 1
|
||||
|
||||
else:
|
||||
print(f" ⚠️ 未知格式,跳过")
|
||||
skipped_count += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ 错误: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
error_count += 1
|
||||
|
||||
print()
|
||||
|
||||
print("=" * 60)
|
||||
print("转换完成统计:")
|
||||
print(f" ✓ 转换成功: {converted_count} 个文件")
|
||||
print(f" - 跳过(已是内部格式): {skipped_count} 个文件")
|
||||
print(f" ✗ 转换失败: {error_count} 个文件")
|
||||
print("=" * 60)
|
||||
|
||||
if converted_count > 0:
|
||||
print("\n提示: 原文件已备份为 .bak 后缀,确认无误后可删除")
|
||||
2932
data/A.U.T.O.预设 v2.0 (1) (1).json
Normal file
2932
data/A.U.T.O.预设 v2.0 (1) (1).json
Normal file
File diff suppressed because one or more lines are too long
16
data/agent/niches.json
Normal file
16
data/agent/niches.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"niches": [
|
||||
{
|
||||
"id": "aesthetic_tone",
|
||||
"label": "整体美学",
|
||||
"description": "视觉、氛围、叙事基调等宏观美学设定",
|
||||
"suggestedStepGoal": "描述角色的整体美学:色调、材质、氛围、叙事基调,供后续人设与世界书条目引用。"
|
||||
},
|
||||
{
|
||||
"id": "persona_detail",
|
||||
"label": "具体人设",
|
||||
"description": "性格、口癖、关系、行为模式等可扮演细节",
|
||||
"suggestedStepGoal": "在整体美学基础上,细化可扮演的人设:性格、动机、口癖、与他人关系。"
|
||||
}
|
||||
]
|
||||
}
|
||||
73
data/agent/skill_templates.json
Normal file
73
data/agent/skill_templates.json
Normal file
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"templates": [
|
||||
{
|
||||
"skillId": "studio.init_bind",
|
||||
"displayName": "创建并绑定",
|
||||
"description": "创建角色卡与世界书,并绑定到当前 Studio 项目",
|
||||
"displayParams": [
|
||||
{
|
||||
"key": "characterName",
|
||||
"label": "角色卡名称",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"placeholder": "例如:帝国骑士维尔"
|
||||
},
|
||||
{
|
||||
"key": "worldbookName",
|
||||
"label": "世界书名称",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"placeholder": "例如:维尔的世界观"
|
||||
}
|
||||
],
|
||||
"configWhitelist": [],
|
||||
"artifacts": [],
|
||||
"supportsLoopUntilSatisfied": false,
|
||||
"supportsInputs": false,
|
||||
"supportsInsertion": false,
|
||||
"supportsScoring": false
|
||||
},
|
||||
{
|
||||
"skillId": "studio.worldbook_entry",
|
||||
"displayName": "创作世界书条目",
|
||||
"description": "根据 stepGoal 与上文引用,生成并写入世界书条目",
|
||||
"displayParams": [],
|
||||
"configWhitelist": [
|
||||
"stepGoal",
|
||||
"thinkingPrompt",
|
||||
"insertion.position",
|
||||
"insertion.activationType",
|
||||
"insertion.key",
|
||||
"insertion.keysecondary",
|
||||
"insertion.ragConfig",
|
||||
"insertion.comment",
|
||||
"scoring"
|
||||
],
|
||||
"artifacts": [
|
||||
{
|
||||
"type": "worldbook.entries",
|
||||
"displayName": "世界书条目"
|
||||
}
|
||||
],
|
||||
"supportsLoopUntilSatisfied": true,
|
||||
"supportsInputs": true,
|
||||
"supportsInsertion": true,
|
||||
"supportsScoring": true,
|
||||
"configDefaults": {
|
||||
"stepGoal": "",
|
||||
"thinkingPrompt": "====== 思考流程 ======\nStep1: 简短确认任务性质(新设计/修改)\nStep2: 阅读绑定角色/世界书与上文引用\nStep3: 按步骤目标起草世界书条目\nStep4: 对照评价维度自检并优化表述\n\n(核心目的、评价标准与优化建议由系统在运行时自动注入,无需在此填写)",
|
||||
"insertion": {
|
||||
"position": 1,
|
||||
"activationType": "permanent",
|
||||
"key": "",
|
||||
"keysecondary": "",
|
||||
"comment": ""
|
||||
},
|
||||
"scoring": {
|
||||
"enabled": true,
|
||||
"dimensions": []
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
10
data/agent/studio_projects/default/meta.json
Normal file
10
data/agent/studio_projects/default/meta.json
Normal 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"
|
||||
}
|
||||
122
data/agent/studio_projects/default/pipeline.json
Normal file
122
data/agent/studio_projects/default/pipeline.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,3 @@
|
||||
id: chat_reply
|
||||
name: Chat Reply
|
||||
description: Minimal chat reply skill for builtin.chat template
|
||||
41
data/agent/templates/builtin.chat/state_machine.json
Normal file
41
data/agent/templates/builtin.chat/state_machine.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
16
data/agent/templates/builtin.chat/template.json
Normal file
16
data/agent/templates/builtin.chat/template.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
6
data/agent/templates/builtin.studio.example/meta.json
Normal file
6
data/agent/templates/builtin.studio.example/meta.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "builtin.studio.example",
|
||||
"name": "世界书条目创建",
|
||||
"description": "从创建绑定到世界书条目的默认三步流水线,可在工作流编辑页复制并修改。",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
102
data/agent/templates/builtin.studio.example/pipeline.json
Normal file
102
data/agent/templates/builtin.studio.example/pipeline.json
Normal 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": "整体美学 · 世界书条目"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
56
data/agent/workflow_variables.json
Normal file
56
data/agent/workflow_variables.json
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"builtIn": [
|
||||
{
|
||||
"ref": "workflow.goal",
|
||||
"label": "工作流目标文本",
|
||||
"description": "当前 Studio 项目的 workflowGoal 全文(系统自动注入,不可手动选择)",
|
||||
"autoInjected": true
|
||||
},
|
||||
{
|
||||
"ref": "workflow.boundWorldbook",
|
||||
"label": "绑定世界书摘要",
|
||||
"description": "项目绑定的世界书 meta / 摘要(系统自动注入,不可手动选择)",
|
||||
"autoInjected": true
|
||||
},
|
||||
{
|
||||
"ref": "workflow.boundCharacter",
|
||||
"label": "绑定角色卡摘要",
|
||||
"description": "项目绑定的角色卡摘要(系统自动注入,不可手动选择)",
|
||||
"autoInjected": true
|
||||
}
|
||||
],
|
||||
"dynamicSuffixes": [
|
||||
{
|
||||
"suffix": ".output",
|
||||
"labelPattern": "{displayName} · 世界书条目"
|
||||
}
|
||||
],
|
||||
"autoInjectedContext": [
|
||||
"目前产物",
|
||||
"思考流程",
|
||||
"核心目的",
|
||||
"评价标准与优化建议"
|
||||
],
|
||||
"autoInjectedContextDefs": [
|
||||
{
|
||||
"id": "currentProduct",
|
||||
"label": "目前产物",
|
||||
"description": "当前步骤已生成的世界书条目草稿或最新版本,供模型在迭代修改时对照与延续。"
|
||||
},
|
||||
{
|
||||
"id": "thinkingFlow",
|
||||
"label": "思考流程",
|
||||
"description": "本步骤配置的 thinkingPrompt,引导模型按既定步骤推理与自检。"
|
||||
},
|
||||
{
|
||||
"id": "coreGoal",
|
||||
"label": "核心目的",
|
||||
"description": "本步骤的 stepGoal(步骤目标),明确本步要产出的内容与边界。"
|
||||
},
|
||||
{
|
||||
"id": "scoringCriteria",
|
||||
"label": "评价标准与优化建议",
|
||||
"description": "本步骤启用的 scoring 评价维度及准则,用于模型自检与优化表述。"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 58 KiB |
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"id": "test-character-1",
|
||||
"name": "测试角色1",
|
||||
"description": "这是一个测试角色,用于验证角色卡功能",
|
||||
"personality": "友好、乐于助人、幽默",
|
||||
"scenario": "日常对话场景",
|
||||
"first_mes": "你好!我是测试角色1,很高兴见到你!",
|
||||
"mes_example": "",
|
||||
"categories": ["测试", "示例"],
|
||||
"tags": ["test", "demo", "friendly"],
|
||||
"worldInfoId": null,
|
||||
"outputSchema": null,
|
||||
"avatarPath": null,
|
||||
"alternate_greetings": [
|
||||
"嗨!有什么我可以帮你的吗?",
|
||||
"欢迎来到测试世界!"
|
||||
],
|
||||
"createdAt": 1700000000,
|
||||
"updatedAt": 1700000000,
|
||||
"lastChatAt": null,
|
||||
"isFavorite": false,
|
||||
"version": 1
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{"user_name": "User", "character_name": "测试角色1", "create_date": "2026-04-30T15:00:00Z"}
|
||||
{"name": "测试角色1", "is_user": false, "send_date": "2026-04-30T15:00:01Z", "mes": "你好!我是测试角色1,很高兴见到你!"}
|
||||
{"name": "User", "is_user": true, "send_date": "2026-04-30T15:00:10Z", "mes": "你好!今天过得怎么样?"}
|
||||
{"name": "测试角色1", "is_user": false, "send_date": "2026-04-30T15:00:15Z", "mes": "我很好,谢谢关心!你呢?"}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"id": "test-character-2",
|
||||
"name": "测试角色2",
|
||||
"description": "第二个测试角色,有不同的标签",
|
||||
"personality": "严肃、专业、认真",
|
||||
"scenario": "工作场景",
|
||||
"first_mes": "您好,我是测试角色2,请问有什么工作需要处理?",
|
||||
"mes_example": "",
|
||||
"categories": ["测试", "工作"],
|
||||
"tags": ["test", "professional", "work"],
|
||||
"worldInfoId": null,
|
||||
"outputSchema": null,
|
||||
"avatarPath": null,
|
||||
"alternate_greetings": [],
|
||||
"createdAt": 1700000100,
|
||||
"updatedAt": 1700000100,
|
||||
"lastChatAt": null,
|
||||
"isFavorite": true,
|
||||
"version": 1
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{"user_name": "User", "character_name": "测试角色2", "create_date": "2026-04-30T16:00:00Z"}
|
||||
{"name": "测试角色2", "is_user": false, "send_date": "2026-04-30T16:00:01Z", "mes": "您好,我是测试角色2,请问有什么工作需要处理?"}
|
||||
{"name": "User", "is_user": true, "send_date": "2026-04-30T16:00:10Z", "mes": "帮我分析一下这个数据"}
|
||||
{"name": "测试角色2", "is_user": false, "send_date": "2026-04-30T16:00:20Z", "mes": "好的,请提供数据,我会进行专业分析。"}
|
||||
@@ -1,2 +0,0 @@
|
||||
{"user_name": "User", "character_name": "测试角色2", "integrity": "644e9983-2102-4608-aeb5-64016c1ba92a", "chat_id_hash": "3f37a950-dda3-4341-97a8-50e58da796ce", "note_prompt": "", "note_interval": 0, "note_position": 0, "note_depth": 0, "note_role": 0, "extensions": {}, "timedWorldInfo": {}, "variables": {}, "tainted": false, "lastInContextMessageId": -1}
|
||||
{"name": "测试角色2", "is_user": false, "is_system": false, "floor": 1, "send_date": "1777569143941", "mes": "您好,我是测试角色2,请问有什么工作需要处理?", "extra": {}, "swipes": [], "swipe_id": 0}
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"id": "test-character-3",
|
||||
"name": "测试角色3",
|
||||
"description": "第三个测试角色,与角色1有相同的tag",
|
||||
"personality": "活泼、开朗、爱开玩笑",
|
||||
"scenario": "娱乐场景",
|
||||
"first_mes": "嘿嘿!我是测试角色3,让我们来玩吧!",
|
||||
"mes_example": "",
|
||||
"categories": ["测试", "娱乐"],
|
||||
"tags": ["test", "demo", "fun"],
|
||||
"worldInfoId": null,
|
||||
"outputSchema": null,
|
||||
"avatarPath": null,
|
||||
"alternate_greetings": [
|
||||
"哟呼!"
|
||||
],
|
||||
"createdAt": 1700000200,
|
||||
"updatedAt": 1700000200,
|
||||
"lastChatAt": null,
|
||||
"isFavorite": false,
|
||||
"version": 1
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
{"user_name": "User", "character_name": "测试角色3", "integrity": "dc677e4e-dd79-43ad-bbf9-ca886d176a0d", "chat_id_hash": "2fa6e72e-dce1-4f70-9e2d-0bc0ebce3bc1", "note_prompt": "", "note_interval": 0, "note_position": 0, "note_depth": 0, "note_role": 0, "extensions": {}, "timedWorldInfo": {}, "variables": {}, "tainted": false, "lastInContextMessageId": -1}
|
||||
{"name": "测试角色3", "is_user": false, "is_system": false, "floor": 1, "send_date": "1777569143944", "mes": "嘿嘿!我是测试角色3,让我们来玩吧!", "extra": {}, "swipes": [], "swipe_id": 0}
|
||||
BIN
data/chat/default.jpg
Normal file
BIN
data/chat/default.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
1
data/encryption_key.txt
Normal file
1
data/encryption_key.txt
Normal file
@@ -0,0 +1 @@
|
||||
-JTj6zP_N7PFt218eJTGBKFBKED-GjOZVMgCxruoiW8=
|
||||
@@ -1,218 +1,222 @@
|
||||
{
|
||||
"temperature": 1,
|
||||
"frequency_penalty": 0,
|
||||
"presence_penalty": 0,
|
||||
"top_p": 1,
|
||||
"top_k": 0,
|
||||
"top_a": 0,
|
||||
"min_p": 0,
|
||||
"repetition_penalty": 1,
|
||||
"openai_max_context": 4095,
|
||||
"openai_max_tokens": 300,
|
||||
"names_behavior": 0,
|
||||
"send_if_empty": "",
|
||||
"impersonation_prompt": "[Write your next reply from the point of view of {{user}}, using the chat history so far as a guideline for the writing style of {{user}}. Don't write as {{char}} or system. Don't describe actions of {{char}}.]",
|
||||
"new_chat_prompt": "[Start a new Chat]",
|
||||
"new_group_chat_prompt": "[Start a new group chat. Group members: {{group}}]",
|
||||
"new_example_chat_prompt": "[Example Chat]",
|
||||
"continue_nudge_prompt": "[Continue your last message without repeating its original content.]",
|
||||
"bias_preset_selected": "Default (none)",
|
||||
"max_context_unlocked": false,
|
||||
"wi_format": "{0}",
|
||||
"scenario_format": "{{scenario}}",
|
||||
"personality_format": "{{personality}}",
|
||||
"group_nudge_prompt": "[Write the next reply only as {{char}}.]",
|
||||
"stream_openai": true,
|
||||
"prompts": [
|
||||
"temperature": 1,
|
||||
"frequency_penalty": 0,
|
||||
"presence_penalty": 0,
|
||||
"top_p": 1,
|
||||
"top_k": 0,
|
||||
"top_a": 0,
|
||||
"min_p": 0,
|
||||
"repetition_penalty": 1,
|
||||
"openai_max_context": 4095,
|
||||
"openai_max_tokens": 300,
|
||||
"request_timeout": 60,
|
||||
"names_behavior": 0,
|
||||
"send_if_empty": "",
|
||||
"impersonation_prompt": "[Write your next reply from the point of view of {{user}}, using the chat history so far as a guideline for the writing style of {{user}}. Don't write as {{char}} or system. Don't describe actions of {{char}}.]",
|
||||
"new_chat_prompt": "[Start a new Chat]",
|
||||
"new_group_chat_prompt": "[Start a new group chat. Group members: {{group}}]",
|
||||
"new_example_chat_prompt": "[Example Chat]",
|
||||
"continue_nudge_prompt": "[Continue your last message without repeating its original content.]",
|
||||
"bias_preset_selected": "Default (none)",
|
||||
"max_context_unlocked": false,
|
||||
"wi_format": "{0}",
|
||||
"scenario_format": "{{scenario}}",
|
||||
"personality_format": "{{personality}}",
|
||||
"group_nudge_prompt": "[Write the next reply only as {{char}}.]",
|
||||
"stream_openai": true,
|
||||
"prompts": [
|
||||
{
|
||||
"name": "Main Prompt",
|
||||
"system_prompt": true,
|
||||
"role": "system",
|
||||
"content": "Write {{char}}'s next reply in a fictional chat between {{char}} and {{user}}.",
|
||||
"identifier": "main"
|
||||
},
|
||||
{
|
||||
"identifier": "worldInfoBefore",
|
||||
"name": "World Info (before)",
|
||||
"system_prompt": true,
|
||||
"marker": true
|
||||
},
|
||||
{
|
||||
"identifier": "charDescription",
|
||||
"name": "Char Description",
|
||||
"system_prompt": true,
|
||||
"marker": true
|
||||
},
|
||||
{
|
||||
"identifier": "charPersonality",
|
||||
"name": "Char Personality",
|
||||
"system_prompt": true,
|
||||
"marker": true
|
||||
},
|
||||
{
|
||||
"identifier": "scenario",
|
||||
"name": "Scenario",
|
||||
"system_prompt": true,
|
||||
"marker": true
|
||||
},
|
||||
{
|
||||
"identifier": "enhanceDefinitions",
|
||||
"role": "system",
|
||||
"name": "Enhance Definitions",
|
||||
"content": "If you have more knowledge of {{char}}, add to the character's lore and personality to enhance them but keep the Character Sheet's definitions absolute.",
|
||||
"system_prompt": true,
|
||||
"marker": false
|
||||
},
|
||||
{
|
||||
"name": "Auxiliary Prompt",
|
||||
"system_prompt": true,
|
||||
"role": "system",
|
||||
"content": "",
|
||||
"identifier": "nsfw"
|
||||
},
|
||||
{
|
||||
"identifier": "worldInfoAfter",
|
||||
"name": "World Info (after)",
|
||||
"system_prompt": true,
|
||||
"marker": true
|
||||
},
|
||||
{
|
||||
"identifier": "dialogueExamples",
|
||||
"name": "Chat Examples",
|
||||
"system_prompt": true,
|
||||
"marker": true
|
||||
},
|
||||
{
|
||||
"identifier": "chatHistory",
|
||||
"name": "Chat History",
|
||||
"system_prompt": true,
|
||||
"marker": true
|
||||
},
|
||||
{
|
||||
"name": "Post-History Instructions",
|
||||
"system_prompt": true,
|
||||
"role": "system",
|
||||
"content": "",
|
||||
"identifier": "jailbreak"
|
||||
},
|
||||
{
|
||||
"identifier": "personaDescription",
|
||||
"name": "Persona Description",
|
||||
"system_prompt": true,
|
||||
"marker": true
|
||||
}
|
||||
],
|
||||
"prompt_order": [
|
||||
{
|
||||
"character_id": 100000,
|
||||
"order": [
|
||||
{
|
||||
"name": "Main Prompt",
|
||||
"system_prompt": true,
|
||||
"role": "system",
|
||||
"content": "Write {{char}}'s next reply in a fictional chat between {{char}} and {{user}}.",
|
||||
"identifier": "main"
|
||||
"identifier": "main",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"name": "Auxiliary Prompt",
|
||||
"system_prompt": true,
|
||||
"role": "system",
|
||||
"content": "",
|
||||
"identifier": "nsfw"
|
||||
"identifier": "worldInfoBefore",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"identifier": "dialogueExamples",
|
||||
"name": "Chat Examples",
|
||||
"system_prompt": true,
|
||||
"marker": true
|
||||
"identifier": "charDescription",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"name": "Post-History Instructions",
|
||||
"system_prompt": true,
|
||||
"role": "system",
|
||||
"content": "",
|
||||
"identifier": "jailbreak"
|
||||
"identifier": "charPersonality",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"identifier": "chatHistory",
|
||||
"name": "Chat History",
|
||||
"system_prompt": true,
|
||||
"marker": true
|
||||
"identifier": "scenario",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"identifier": "worldInfoAfter",
|
||||
"name": "World Info (after)",
|
||||
"system_prompt": true,
|
||||
"marker": true
|
||||
"identifier": "enhanceDefinitions",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"identifier": "worldInfoBefore",
|
||||
"name": "World Info (before)",
|
||||
"system_prompt": true,
|
||||
"marker": true
|
||||
"identifier": "nsfw",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"identifier": "enhanceDefinitions",
|
||||
"role": "system",
|
||||
"name": "Enhance Definitions",
|
||||
"content": "If you have more knowledge of {{char}}, add to the character's lore and personality to enhance them but keep the Character Sheet's definitions absolute.",
|
||||
"system_prompt": true,
|
||||
"marker": false
|
||||
"identifier": "worldInfoAfter",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"identifier": "charDescription",
|
||||
"name": "Char Description",
|
||||
"system_prompt": true,
|
||||
"marker": true
|
||||
"identifier": "dialogueExamples",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"identifier": "charPersonality",
|
||||
"name": "Char Personality",
|
||||
"system_prompt": true,
|
||||
"marker": true
|
||||
"identifier": "chatHistory",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"identifier": "scenario",
|
||||
"name": "Scenario",
|
||||
"system_prompt": true,
|
||||
"marker": true
|
||||
},
|
||||
{
|
||||
"identifier": "personaDescription",
|
||||
"name": "Persona Description",
|
||||
"system_prompt": true,
|
||||
"marker": true
|
||||
"identifier": "jailbreak",
|
||||
"enabled": true
|
||||
}
|
||||
],
|
||||
"prompt_order": [
|
||||
]
|
||||
},
|
||||
{
|
||||
"character_id": 100001,
|
||||
"order": [
|
||||
{
|
||||
"character_id": 100000,
|
||||
"order": [
|
||||
{
|
||||
"identifier": "main",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"identifier": "worldInfoBefore",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"identifier": "charDescription",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"identifier": "charPersonality",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"identifier": "scenario",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"identifier": "enhanceDefinitions",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"identifier": "nsfw",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"identifier": "worldInfoAfter",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"identifier": "dialogueExamples",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"identifier": "chatHistory",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"identifier": "jailbreak",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
"identifier": "main",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"character_id": 100001,
|
||||
"order": [
|
||||
{
|
||||
"identifier": "main",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"identifier": "worldInfoBefore",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"identifier": "personaDescription",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"identifier": "charDescription",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"identifier": "charPersonality",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"identifier": "scenario",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"identifier": "enhanceDefinitions",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"identifier": "nsfw",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"identifier": "worldInfoAfter",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"identifier": "dialogueExamples",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"identifier": "chatHistory",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"identifier": "jailbreak",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
"identifier": "worldInfoBefore",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"identifier": "personaDescription",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"identifier": "charDescription",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"identifier": "charPersonality",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"identifier": "scenario",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"identifier": "enhanceDefinitions",
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"identifier": "nsfw",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"identifier": "worldInfoAfter",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"identifier": "dialogueExamples",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"identifier": "chatHistory",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"identifier": "jailbreak",
|
||||
"enabled": true
|
||||
}
|
||||
],
|
||||
"assistant_prefill": "",
|
||||
"assistant_impersonation": "",
|
||||
"use_sysprompt": false,
|
||||
"squash_system_messages": false,
|
||||
"media_inlining": true,
|
||||
"continue_prefill": false,
|
||||
"continue_postfix": " ",
|
||||
"seed": -1,
|
||||
"n": 1
|
||||
]
|
||||
}
|
||||
],
|
||||
"assistant_prefill": "",
|
||||
"assistant_impersonation": "",
|
||||
"use_sysprompt": false,
|
||||
"squash_system_messages": false,
|
||||
"media_inlining": true,
|
||||
"continue_prefill": false,
|
||||
"continue_postfix": " ",
|
||||
"seed": -1,
|
||||
"n": 1,
|
||||
"updatedAt": 1777857993,
|
||||
"name": "Default",
|
||||
"createdAt": 1777977985
|
||||
}
|
||||
28
data/regex/global/default.json
Normal file
28
data/regex/global/default.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"id": "ruleset-global-default",
|
||||
"name": "默认全局规则集",
|
||||
"description": "系统默认的全局正则规则",
|
||||
"rules": [
|
||||
{
|
||||
"id": "rule-hide-thinking-001",
|
||||
"scriptName": "隐藏思考标签",
|
||||
"findRegex": "<thinking>[\\s\\S]*?<\\/thinking>",
|
||||
"replaceString": "",
|
||||
"trimStrings": [],
|
||||
"placement": [2],
|
||||
"substituteRegex": 0,
|
||||
"markdownOnly": false,
|
||||
"promptOnly": false,
|
||||
"runOnEdit": true,
|
||||
"minDepth": 0,
|
||||
"maxDepth": null,
|
||||
"scope": "global",
|
||||
"characterName": null,
|
||||
"presetName": null,
|
||||
"disabled": false,
|
||||
"order": 0,
|
||||
"description": "隐藏 AI 回复中的 <thinking> 标签及其内容"
|
||||
}
|
||||
],
|
||||
"isSillyTavernFormat": false
|
||||
}
|
||||
35
data/regex/global/rule-hide-thinking-001.json
Normal file
35
data/regex/global/rule-hide-thinking-001.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"id": "rule-hide-thinking-001",
|
||||
"name": "隐藏思考标签",
|
||||
"description": null,
|
||||
"rules": [
|
||||
{
|
||||
"id": "rule-hide-thinking-001",
|
||||
"scriptName": "隐藏思考标签",
|
||||
"findRegex": "<thinking>[\\s\\S]*?<\\/thinking>",
|
||||
"replaceString": "",
|
||||
"trimStrings": [],
|
||||
"placement": [
|
||||
2
|
||||
],
|
||||
"substituteRegex": 0,
|
||||
"markdownOnly": false,
|
||||
"promptOnly": false,
|
||||
"runOnEdit": true,
|
||||
"minDepth": 0,
|
||||
"maxDepth": null,
|
||||
"scope": "global",
|
||||
"characterName": null,
|
||||
"presetName": null,
|
||||
"disabled": false,
|
||||
"order": 0,
|
||||
"createdAt": 1777997833,
|
||||
"updatedAt": 1777997833,
|
||||
"description": "隐藏 AI 回复中的 <thinking> 标签及其内容"
|
||||
}
|
||||
],
|
||||
"createdAt": 1777997834,
|
||||
"updatedAt": 1777997834,
|
||||
"version": 1,
|
||||
"isSillyTavernFormat": false
|
||||
}
|
||||
10
data/regex/global/ruleset-global-default.json
Normal file
10
data/regex/global/ruleset-global-default.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "ruleset-global-default",
|
||||
"name": "默认全局规则集",
|
||||
"description": "系统默认的全局正则规则",
|
||||
"rules": [],
|
||||
"createdAt": 1777998120,
|
||||
"updatedAt": 1777998120,
|
||||
"version": 1,
|
||||
"isSillyTavernFormat": false
|
||||
}
|
||||
323
data/regex/presets/MyPreset.json
Normal file
323
data/regex/presets/MyPreset.json
Normal file
File diff suppressed because one or more lines are too long
35
data/regex/presets/test.json
Normal file
35
data/regex/presets/test.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"id": "6acc2ea6-0dd2-4542-b9f5-0bcefe2a5947",
|
||||
"name": "test 规则集",
|
||||
"description": "从 SillyTavern 导入的规则",
|
||||
"rules": [
|
||||
{
|
||||
"id": "af24f3c2-9ded-4593-a6db-98449c022696",
|
||||
"scriptName": "test",
|
||||
"findRegex": "test",
|
||||
"replaceString": "",
|
||||
"trimStrings": [],
|
||||
"placement": [
|
||||
2
|
||||
],
|
||||
"substituteRegex": 0,
|
||||
"markdownOnly": false,
|
||||
"promptOnly": false,
|
||||
"runOnEdit": true,
|
||||
"minDepth": 0,
|
||||
"maxDepth": null,
|
||||
"scope": "preset",
|
||||
"characterName": null,
|
||||
"presetName": "test",
|
||||
"disabled": false,
|
||||
"order": 0,
|
||||
"createdAt": 1777995980,
|
||||
"updatedAt": 1777995980,
|
||||
"description": null
|
||||
}
|
||||
],
|
||||
"createdAt": 1777995980,
|
||||
"updatedAt": 1777995980,
|
||||
"version": 1,
|
||||
"isSillyTavernFormat": true
|
||||
}
|
||||
7
data/system_settings.json
Normal file
7
data/system_settings.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"thinkingTagPrefix": "<thinking>",
|
||||
"thinkingTagSuffix": "</thinking>",
|
||||
"currentPresetName": null,
|
||||
"updatedAt": 1777798988,
|
||||
"version": 1
|
||||
}
|
||||
14
data/token_usage/2026/05.jsonl
Normal file
14
data/token_usage/2026/05.jsonl
Normal file
@@ -0,0 +1,14 @@
|
||||
{"id": "08267c9f-a53c-40cc-b47a-b24c54309d83", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 2, "promptTokens": 73, "completionTokens": 4, "totalTokens": 77, "status": "completed", "errorMessage": null, "timestamp": 1777984056, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
|
||||
{"id": "dd88534a-2947-4ad2-be81-ffedfb9dfe02", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 3, "promptTokens": 133, "completionTokens": 213, "totalTokens": 346, "status": "completed", "errorMessage": null, "timestamp": 1777984358, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
|
||||
{"id": "5d3980d1-6c9d-4f6c-ae89-ec1b93b4d4c5", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 5, "promptTokens": 448, "completionTokens": 156, "totalTokens": 604, "status": "completed", "errorMessage": null, "timestamp": 1777984695, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
|
||||
{"id": "c939e2d6-9bce-4484-8a07-b49dfc51ca46", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 6, "promptTokens": 703, "completionTokens": 104, "totalTokens": 807, "status": "completed", "errorMessage": null, "timestamp": 1777986964, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
|
||||
{"id": "6cf8bfc1-73aa-4c52-92b6-184d40a57fea", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 6, "promptTokens": 703, "completionTokens": 42, "totalTokens": 745, "status": "completed", "errorMessage": null, "timestamp": 1777987419, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
|
||||
{"id": "e702de1c-3fed-45cc-87cc-fa42745cdeb6", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 8, "promptTokens": 841, "completionTokens": 72, "totalTokens": 913, "status": "completed", "errorMessage": null, "timestamp": 1777989342, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
|
||||
{"id": "320c1ed6-bbd7-487c-b61c-148a76605fcc", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 10, "promptTokens": 1009, "completionTokens": 43, "totalTokens": 1052, "status": "completed", "errorMessage": null, "timestamp": 1777989675, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
|
||||
{"id": "2bd0cf29-4451-462a-9b4a-c643ceeca3d0", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 12, "promptTokens": 1146, "completionTokens": 19, "totalTokens": 1165, "status": "completed", "errorMessage": null, "timestamp": 1777990077, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
|
||||
{"id": "4ff6d3c6-c986-470b-bd63-6d811fd38651", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 14, "promptTokens": 1258, "completionTokens": 26, "totalTokens": 1284, "status": "completed", "errorMessage": null, "timestamp": 1777990303, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
|
||||
{"id": "b62eb2a9-e7fe-497f-a9a2-86d523ac1087", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 5, "promptTokens": 448, "completionTokens": 246, "totalTokens": 694, "status": "completed", "errorMessage": null, "timestamp": 1777990887, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
|
||||
{"id": "1458daed-02b6-403f-b153-71a3fcca411f", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 7, "promptTokens": 694, "completionTokens": 266, "totalTokens": 960, "status": "completed", "errorMessage": null, "timestamp": 1777990994, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
|
||||
{"id": "e98598a6-18ba-4d9b-98aa-d2b6f2fbf519", "chatId": "写卡机/chat_1777998326762", "roleName": "写卡机", "chatName": "chat_1777998326762", "messageId": null, "floor": 2, "promptTokens": 2019, "completionTokens": 868, "totalTokens": 2887, "status": "completed", "errorMessage": null, "timestamp": 1777998427, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
|
||||
{"id": "85e7d5ff-4bf7-409f-8fbf-9687722a5a6d", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 5, "promptTokens": 1977, "completionTokens": 1104, "totalTokens": 3081, "status": "completed", "errorMessage": null, "timestamp": 1778068763, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
|
||||
{"id": "1bd77edf-1b40-4222-a6b9-2091e27fafae", "chatId": "神国之主/chat_1778166975812", "roleName": "神国之主", "chatName": "chat_1778166975812", "messageId": null, "floor": 3, "promptTokens": 2438, "completionTokens": 1272, "totalTokens": 3710, "status": "completed", "errorMessage": null, "timestamp": 1778167133, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
|
||||
10
data/token_usage/indexes/api_urls.json
Normal file
10
data/token_usage/indexes/api_urls.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"https://api.deepseek.com/v1": {
|
||||
"totalPromptTokens": 13890,
|
||||
"totalCompletionTokens": 4435,
|
||||
"totalTokens": 18325,
|
||||
"count": 14,
|
||||
"firstUsed": 1777984056,
|
||||
"lastUsed": 1778167133
|
||||
}
|
||||
}
|
||||
20
data/token_usage/indexes/daily/2026-05.json
Normal file
20
data/token_usage/indexes/daily/2026-05.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"2026-05-05": {
|
||||
"promptTokens": 9475,
|
||||
"completionTokens": 2059,
|
||||
"totalTokens": 11534,
|
||||
"count": 12
|
||||
},
|
||||
"2026-05-06": {
|
||||
"promptTokens": 1977,
|
||||
"completionTokens": 1104,
|
||||
"totalTokens": 3081,
|
||||
"count": 1
|
||||
},
|
||||
"2026-05-07": {
|
||||
"promptTokens": 2438,
|
||||
"completionTokens": 1272,
|
||||
"totalTokens": 3710,
|
||||
"count": 1
|
||||
}
|
||||
}
|
||||
10
docker-compose.dev.yml
Normal file
10
docker-compose.dev.yml
Normal file
@@ -0,0 +1,10 @@
|
||||
# 开发环境 override(可选)
|
||||
# 用法: docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||
#
|
||||
# 与主 compose 策略一致:源码 volume 挂载 + HMR/reload,启动时不跑 npm install。
|
||||
# 依赖变更: scripts/docker-rebuild.ps1 -Service frontend
|
||||
# 或 docker compose exec frontend npm install && docker compose restart frontend
|
||||
|
||||
services:
|
||||
frontend:
|
||||
command: npm run dev -- --host 0.0.0.0
|
||||
@@ -36,12 +36,11 @@ services:
|
||||
- "23338:5173"
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
- node_modules:/app/node_modules
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- VITE_API_URL=http://backend:8000
|
||||
- VITE_WS_URL=ws://backend:8000
|
||||
command: sh -c "npm install && npm run dev -- --host 0.0.0.0"
|
||||
# 依赖在镜像构建时写入 node_modules volume;package.json 变更见 docs/DOCKER_DEV.md
|
||||
command: npm run dev -- --host 0.0.0.0
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
|
||||
227
docs/DOCKER_DEV.md
Normal file
227
docs/DOCKER_DEV.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# Docker 开发指南(Windows / Docker Desktop)
|
||||
|
||||
本文说明如何在 **不重启 Docker Desktop** 的前提下进行日常开发。绝大多数代码改动无需任何容器操作;需要时只需重启**单个容器**。
|
||||
|
||||
---
|
||||
|
||||
## 核心原则
|
||||
|
||||
| 场景 | 需要做什么 | 是否需要重启 Docker Desktop |
|
||||
|------|-----------|---------------------------|
|
||||
| 修改 Python 后端代码 | **什么都不做**(uvicorn `--reload` 自动重载) | ❌ 不需要 |
|
||||
| 修改 React 前端代码 | **什么都不做**(Vite HMR 热更新) | ❌ 不需要 |
|
||||
| 容器异常 / 需要刷新进程 | `docker compose restart backend` 或 `frontend` | ❌ 不需要 |
|
||||
| 修改 `Dockerfile` 或依赖文件 | `docker compose up -d --build <service>` | ❌ 不需要 |
|
||||
| Docker 引擎崩溃 / 端口被占用且无法释放 | 见下文「极少需要重启 Docker Desktop」 | ⚠️ 极少需要 |
|
||||
|
||||
**日常开发不要重启 Docker Desktop。** 那是 Windows + WSL2 下最慢、最打断节奏的操作。
|
||||
|
||||
---
|
||||
|
||||
## 端口对照表
|
||||
|
||||
与 `docker-compose.yml` 一致:
|
||||
|
||||
| 服务 | 容器内端口 | 宿主机端口 | 访问地址 |
|
||||
|------|-----------|-----------|---------|
|
||||
| backend | 8000 | **23337** | http://localhost:23337 |
|
||||
| frontend | 5173 | **23338** | http://localhost:23338 |
|
||||
|
||||
健康检查:`http://localhost:23337/health`
|
||||
|
||||
---
|
||||
|
||||
## 代码改动:自动生效
|
||||
|
||||
### 后端(FastAPI + uvicorn)
|
||||
|
||||
- 启动命令:`uvicorn main:app --host 0.0.0.0 --port 8000 --reload`
|
||||
- 源码通过 volume 挂载:`./backend` → `/app`
|
||||
- 保存 `.py` 文件后,uvicorn 自动检测并重载,**无需重启容器**
|
||||
|
||||
### 前端(Vite + React)
|
||||
|
||||
- 启动命令:`npm run dev -- --host 0.0.0.0`
|
||||
- 源码通过 volume 挂载:`./frontend` → `/app`
|
||||
- `node_modules` 保存在独立 volume 中,不随宿主机目录覆盖
|
||||
- 保存 `.jsx` / `.css` 等文件后,Vite HMR 自动更新浏览器,**无需重启容器**
|
||||
|
||||
---
|
||||
|
||||
## 何时只需重启容器(不是 Docker Desktop)
|
||||
|
||||
以下情况用 `scripts/docker-restart.ps1` 或 `docker compose restart` 即可:
|
||||
|
||||
- 修改了环境变量(`docker-compose.yml` 中的 `environment`)并已 `docker compose up -d`
|
||||
- 容器内进程卡死、内存泄漏
|
||||
- 前端 HMR 断开、WebSocket 连接异常
|
||||
- 后端 reload 失败(极少数语法错误导致 worker 无法恢复)
|
||||
|
||||
```powershell
|
||||
# 重启单个服务
|
||||
.\scripts\docker-restart.ps1 -Service backend
|
||||
.\scripts\docker-restart.ps1 -Service frontend
|
||||
|
||||
# 重启全部
|
||||
.\scripts\docker-restart.ps1 -Service all
|
||||
|
||||
# 或直接
|
||||
docker compose restart backend
|
||||
docker compose restart frontend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 何时需要重新构建镜像
|
||||
|
||||
以下变更需要 **rebuild**,仍然 **不需要重启 Docker Desktop**:
|
||||
|
||||
| 变更内容 | 命令 |
|
||||
|---------|------|
|
||||
| `backend/requirements.txt` | `.\scripts\docker-rebuild.ps1 -Service backend` |
|
||||
| `backend/Dockerfile` | 同上 |
|
||||
| `frontend/package.json` / `package-lock.json` | `.\scripts\docker-rebuild.ps1 -Service frontend` |
|
||||
| `frontend/Dockerfile` | 同上 |
|
||||
|
||||
```powershell
|
||||
# 等价命令
|
||||
docker compose up -d --build backend
|
||||
docker compose up -d --build frontend
|
||||
```
|
||||
|
||||
### 前端依赖变更(package.json 改了但不想 rebuild)
|
||||
|
||||
若只新增了 npm 包、尚未 rebuild 镜像,可在运行中的容器内安装一次:
|
||||
|
||||
```powershell
|
||||
docker compose exec frontend npm install
|
||||
docker compose restart frontend
|
||||
```
|
||||
|
||||
首次 `docker compose up` 时,镜像构建阶段会执行 `npm install`,依赖写入 `node_modules` volume,之后日常启动**不再**每次 `npm install`。
|
||||
|
||||
---
|
||||
|
||||
## 极少需要重启 Docker Desktop 的情况
|
||||
|
||||
仅在以下情况才考虑重启 Docker Desktop 或 WSL:
|
||||
|
||||
1. **Docker 引擎无响应** — `docker ps` 一直挂起或报错 `Cannot connect to the Docker daemon`
|
||||
2. **端口被占用且 compose down 无法释放** — 例如 23337/23338 被僵尸进程占用
|
||||
3. **WSL2 后端异常** — 内存耗尽、磁盘满、网络栈故障
|
||||
|
||||
**优先尝试的替代方案(由轻到重):**
|
||||
|
||||
```powershell
|
||||
# 1. 停止并重新启动 compose 栈(不碰 Docker Desktop)
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
|
||||
# 2. 查看日志定位问题
|
||||
.\scripts\docker-logs.ps1
|
||||
.\scripts\docker-logs.ps1 -Service backend
|
||||
|
||||
# 3. 仅当 Docker 完全无响应时,关闭 WSL(会连带重启 Docker 引擎)
|
||||
wsl --shutdown
|
||||
# 然后重新打开 Docker Desktop
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 更快的日常开发方式(推荐)
|
||||
|
||||
Docker 适合「全栈联调 / 验收环境」。纯改代码时,**本地直接跑**通常更快:
|
||||
|
||||
### 后端(本地)
|
||||
|
||||
```powershell
|
||||
python -m venv venv
|
||||
.\venv\Scripts\Activate.ps1
|
||||
pip install -r backend\requirements.txt
|
||||
cd backend
|
||||
python main.py
|
||||
```
|
||||
|
||||
### 前端(本地)
|
||||
|
||||
```powershell
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
本地开发时前端默认代理到本地后端;Docker 栈仅在需要容器化联调时使用。
|
||||
|
||||
---
|
||||
|
||||
## 辅助脚本速查
|
||||
|
||||
所有脚本位于 `scripts/`,在项目根目录执行:
|
||||
|
||||
| 脚本 | 用途 |
|
||||
|------|------|
|
||||
| `docker-up.ps1` | 后台启动全部服务 |
|
||||
| `docker-restart.ps1` | 重启 backend / frontend / all(**不**重启 Docker Desktop) |
|
||||
| `docker-logs.ps1` | 跟踪日志(可选 `-Service backend`) |
|
||||
| `docker-rebuild.ps1` | 重新构建并启动指定服务 |
|
||||
|
||||
### 典型一日工作流
|
||||
|
||||
```powershell
|
||||
# 早上第一次
|
||||
.\scripts\docker-up.ps1
|
||||
|
||||
# 白天改代码 — 保存即可,backend/frontend 自动更新
|
||||
|
||||
# 偶尔 HMR 或 reload 异常
|
||||
.\scripts\docker-restart.ps1 -Service frontend
|
||||
|
||||
# 改了 requirements.txt
|
||||
.\scripts\docker-rebuild.ps1 -Service backend
|
||||
|
||||
# 下班
|
||||
docker compose down
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 使用 docker-compose.dev.yml(可选)
|
||||
|
||||
开发环境可使用 override 文件,与主 compose 合并:
|
||||
|
||||
```powershell
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||
```
|
||||
|
||||
内容与主文件优化策略一致;便于将来追加仅开发用的配置而不改动默认 compose。
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 改了前端代码但页面没更新?
|
||||
|
||||
1. 确认保存了文件
|
||||
2. 浏览器硬刷新(Ctrl+Shift+R)
|
||||
3. `.\scripts\docker-restart.ps1 -Service frontend`
|
||||
4. 查看日志:`.\scripts\docker-logs.ps1 -Service frontend`
|
||||
|
||||
### Q: 改了后端代码但 API 行为没变?
|
||||
|
||||
1. 查看 backend 日志是否有 reload 报错
|
||||
2. `.\scripts\docker-restart.ps1 -Service backend`
|
||||
3. 若改了依赖,需 rebuild
|
||||
|
||||
### Q: 每次启动 frontend 都很慢?
|
||||
|
||||
旧版 compose 在每次容器启动时执行 `npm install`。当前配置已改为直接 `npm run dev`;依赖在**镜像构建时**或**手动 exec npm install** 时装入 volume。若仍慢,检查是否误删了 `node_modules` volume:
|
||||
|
||||
```powershell
|
||||
docker volume ls | Select-String node_modules
|
||||
```
|
||||
|
||||
### Q: 必须重启 Docker Desktop 吗?
|
||||
|
||||
**正常代码编辑:不需要。**
|
||||
**依赖 / Dockerfile 变更:rebuild 容器即可。**
|
||||
**只有 Docker 引擎本身故障时才考虑重启 Docker Desktop 或 `wsl --shutdown`。**
|
||||
218
frontend/Z_INDEX_GUIDE.md
Normal file
218
frontend/Z_INDEX_GUIDE.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# Z-Index 层级规范文档
|
||||
|
||||
## 📋 概述
|
||||
|
||||
本文档定义了项目中所有 z-index 的使用规范,确保层级关系清晰、一致、可维护。
|
||||
|
||||
## 🎯 设计原则
|
||||
|
||||
1. **分层管理**:将 z-index 划分为 5 个主要层级,每层预留充足空间
|
||||
2. **语义化命名**:使用有意义的变量名,而非魔法数字
|
||||
3. **统一来源**:所有 z-index 值统一定义在 `z-index.css` 中
|
||||
4. **易于扩展**:每层之间至少预留 100 的空间,方便插入新层级
|
||||
|
||||
## 📊 层级划分
|
||||
|
||||
### 1️⃣ 基础层 (0-99)
|
||||
用于页面背景、基础布局等底层元素
|
||||
|
||||
| 变量名 | 值 | 用途 |
|
||||
|--------|-----|------|
|
||||
| `--z-background` | 0 | 最底层 - 背景装饰 |
|
||||
| `--z-base-content` | 1 | 基础内容层 - 普通文本、图片 |
|
||||
| `--z-divider` | 10 | 分割线、边框装饰 |
|
||||
|
||||
**使用场景:**
|
||||
- 页面背景渐变
|
||||
- 基础卡片容器
|
||||
- 列表项默认状态
|
||||
|
||||
---
|
||||
|
||||
### 2️⃣ 组件层 (100-999)
|
||||
用于常规 UI 组件,如下拉菜单、悬浮提示等
|
||||
|
||||
| 变量名 | 值 | 用途 |
|
||||
|--------|-----|------|
|
||||
| `--z-top-bar` | 100 | TopBar 导航栏 |
|
||||
| `--z-sidebar` | 100 | 侧边栏容器 |
|
||||
| `--z-dropdown-menu` | 1000 | 下拉菜单(预设操作、世界书选择) |
|
||||
| `--z-sort-panel` | 1100 | 排序设置面板 |
|
||||
| `--z-tooltip` | 1200 | 悬浮提示 Tooltip |
|
||||
| `--z-chat-actions` | 1000 | 聊天消息操作按钮 |
|
||||
| `--z-character-preview` | 1000 | 角色卡预览弹窗 |
|
||||
|
||||
**使用场景:**
|
||||
- 点击按钮弹出的下拉菜单
|
||||
- 鼠标悬停显示的提示信息
|
||||
- 聊天消息的快捷操作按钮
|
||||
|
||||
---
|
||||
|
||||
### 3️⃣ 弹窗层 (10000-19999)
|
||||
用于模态对话框、编辑面板等需要覆盖整个页面的元素
|
||||
|
||||
| 变量名 | 值 | 用途 |
|
||||
|--------|-------|------|
|
||||
| `--z-modal-overlay` | 10000 | 对话框遮罩层背景 |
|
||||
| `--z-modal-content` | 10100 | 对话框内容(API配置、预设保存等) |
|
||||
| `--z-edit-panel-overlay` | 10200 | 世界书编辑面板遮罩层 |
|
||||
| `--z-edit-panel-content` | 10300 | 世界书编辑面板内容 |
|
||||
|
||||
**使用场景:**
|
||||
- API 配置对话框
|
||||
- 预设保存/编辑对话框
|
||||
- 世界书条目编辑面板
|
||||
- 任何需要全屏遮罩的模态窗口
|
||||
|
||||
**层级关系:**
|
||||
```
|
||||
编辑面板内容 (10300)
|
||||
↓
|
||||
编辑面板遮罩 (10200)
|
||||
↓
|
||||
对话框内容 (10100)
|
||||
↓
|
||||
对话框遮罩 (10000)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4️⃣ 通知层 (20000-29999)
|
||||
用于全局通知、Toast 提示等
|
||||
|
||||
| 变量名 | 值 | 用途 |
|
||||
|--------|-------|------|
|
||||
| `--z-toast-container` | 20000 | Toast 通知容器 |
|
||||
| `--z-toast-item` | 20100 | Toast 通知项 |
|
||||
|
||||
**使用场景:**
|
||||
- 操作成功/失败的提示
|
||||
- 系统通知
|
||||
- 警告信息
|
||||
|
||||
---
|
||||
|
||||
### 5️⃣ 系统层 (30000+)
|
||||
用于系统级元素,如加载动画、错误边界等
|
||||
|
||||
| 变量名 | 值 | 用途 |
|
||||
|--------|-------|------|
|
||||
| `--z-loading-spinner` | 30000 | 全局加载动画 |
|
||||
| `--z-error-boundary` | 30100 | 错误边界覆盖层 |
|
||||
|
||||
**使用场景:**
|
||||
- 页面加载时的旋转动画
|
||||
- 错误捕获后的全屏提示
|
||||
- 系统级遮罩
|
||||
|
||||
---
|
||||
|
||||
## 💡 使用指南
|
||||
|
||||
### CSS 中使用
|
||||
|
||||
```css
|
||||
/* ✅ 推荐:使用 CSS 变量 */
|
||||
.dropdown-menu {
|
||||
z-index: var(--z-dropdown-menu);
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
z-index: var(--z-modal-overlay);
|
||||
}
|
||||
|
||||
.edit-panel {
|
||||
z-index: var(--z-edit-panel-content);
|
||||
}
|
||||
```
|
||||
|
||||
### TypeScript/JavaScript 中使用
|
||||
|
||||
```typescript
|
||||
// ✅ 推荐:导入常量
|
||||
import { Z_INDEX } from '../styles/z-index';
|
||||
|
||||
const style = {
|
||||
zIndex: Z_INDEX.DROPDOWN_MENU,
|
||||
};
|
||||
```
|
||||
|
||||
### ❌ 避免的做法
|
||||
|
||||
```css
|
||||
/* ❌ 不要使用魔法数字 */
|
||||
.dropdown-menu {
|
||||
z-index: 1000; /* 难以理解,不易维护 */
|
||||
}
|
||||
|
||||
/* ❌ 不要使用过大的数值 */
|
||||
.modal {
|
||||
z-index: 99999; /* 不合理,可能导致层级混乱 */
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 添加新层级
|
||||
|
||||
如果需要添加新的 z-index 层级,请遵循以下步骤:
|
||||
|
||||
1. **确定所属层级**:根据元素类型选择合适的层级范围
|
||||
2. **选择合适数值**:在该层级范围内选择一个未使用的值(预留 100 间隔)
|
||||
3. **更新定义文件**:
|
||||
- 在 `z-index.css` 中添加 CSS 变量
|
||||
- 在 `z-index.ts` 中添加 TypeScript 常量
|
||||
4. **更新文档**:在本文档中添加说明
|
||||
|
||||
**示例:添加一个新的工具提示层级**
|
||||
|
||||
```css
|
||||
/* z-index.css */
|
||||
:root {
|
||||
--z-help-tooltip: 1300; /* 在 tooltip (1200) 之上 */
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// z-index.ts
|
||||
export const Z_INDEX = {
|
||||
// ...
|
||||
HELP_TOOLTIP: 1300,
|
||||
} as const;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 常见问题
|
||||
|
||||
### Q: 为什么弹窗层从 10000 开始?
|
||||
A: 为了与组件层(100-999)保持足够的距离,避免未来在组件层添加更多层级时产生冲突。
|
||||
|
||||
### Q: 如果两个元素都需要弹窗层怎么办?
|
||||
A: 使用不同的子层级,例如:
|
||||
- 第一个弹窗:`--z-modal-content` (10100)
|
||||
- 第二个弹窗:`--z-modal-content + 10` (10110)
|
||||
|
||||
### Q: 可以在 inline style 中使用吗?
|
||||
A: 可以,但推荐使用 CSS 类。如果必须使用 inline style:
|
||||
|
||||
```jsx
|
||||
<div style={{ zIndex: 'var(--z-dropdown-menu)' }}>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 相关文件
|
||||
|
||||
- **CSS 变量定义**:`frontend/src/styles/z-index.css`
|
||||
- **TypeScript 常量**:`frontend/src/styles/z-index.ts`
|
||||
- **全局样式引入**:`frontend/src/index.css`
|
||||
|
||||
---
|
||||
|
||||
## 🔄 更新历史
|
||||
|
||||
| 日期 | 版本 | 更新内容 |
|
||||
|------|------|----------|
|
||||
| 2026-05-04 | 1.0 | 初始版本,建立完整的 z-index 层级体系 |
|
||||
1906
frontend/node_modules/.vite/deps_temp_e32948ce/chunk-CANBAPAS.js
generated
vendored
1906
frontend/node_modules/.vite/deps_temp_e32948ce/chunk-CANBAPAS.js
generated
vendored
File diff suppressed because it is too large
Load Diff
7
frontend/node_modules/.vite/deps_temp_e32948ce/react-dom.js
generated
vendored
7
frontend/node_modules/.vite/deps_temp_e32948ce/react-dom.js
generated
vendored
@@ -1,7 +0,0 @@
|
||||
import {
|
||||
require_react_dom
|
||||
} from "./chunk-TYILIMWK.js";
|
||||
import "./chunk-CANBAPAS.js";
|
||||
import "./chunk-5WRI5ZAA.js";
|
||||
export default require_react_dom();
|
||||
//# sourceMappingURL=react-dom.js.map
|
||||
7
frontend/node_modules/.vite/deps_temp_e32948ce/react-dom_client.js.map
generated
vendored
7
frontend/node_modules/.vite/deps_temp_e32948ce/react-dom_client.js.map
generated
vendored
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"version": 3,
|
||||
"sources": ["../../react-dom/client.js"],
|
||||
"sourcesContent": ["'use strict';\n\nvar m = require('react-dom');\nif (process.env.NODE_ENV === 'production') {\n exports.createRoot = m.createRoot;\n exports.hydrateRoot = m.hydrateRoot;\n} else {\n var i = m.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;\n exports.createRoot = function(c, o) {\n i.usingClientEntryPoint = true;\n try {\n return m.createRoot(c, o);\n } finally {\n i.usingClientEntryPoint = false;\n }\n };\n exports.hydrateRoot = function(c, h, o) {\n i.usingClientEntryPoint = true;\n try {\n return m.hydrateRoot(c, h, o);\n } finally {\n i.usingClientEntryPoint = false;\n }\n };\n}\n"],
|
||||
"mappings": ";;;;;;;;;AAAA;AAAA;AAEA,QAAI,IAAI;AACR,QAAI,OAAuC;AACzC,cAAQ,aAAa,EAAE;AACvB,cAAQ,cAAc,EAAE;AAAA,IAC1B,OAAO;AACD,UAAI,EAAE;AACV,cAAQ,aAAa,SAAS,GAAG,GAAG;AAClC,UAAE,wBAAwB;AAC1B,YAAI;AACF,iBAAO,EAAE,WAAW,GAAG,CAAC;AAAA,QAC1B,UAAE;AACA,YAAE,wBAAwB;AAAA,QAC5B;AAAA,MACF;AACA,cAAQ,cAAc,SAAS,GAAG,GAAG,GAAG;AACtC,UAAE,wBAAwB;AAC1B,YAAI;AACF,iBAAO,EAAE,YAAY,GAAG,GAAG,CAAC;AAAA,QAC9B,UAAE;AACA,YAAE,wBAAwB;AAAA,QAC5B;AAAA,MACF;AAAA,IACF;AAjBM;AAAA;AAAA;",
|
||||
"names": []
|
||||
}
|
||||
6
frontend/node_modules/.vite/deps_temp_e32948ce/react.js
generated
vendored
6
frontend/node_modules/.vite/deps_temp_e32948ce/react.js
generated
vendored
@@ -1,6 +0,0 @@
|
||||
import {
|
||||
require_react
|
||||
} from "./chunk-CANBAPAS.js";
|
||||
import "./chunk-5WRI5ZAA.js";
|
||||
export default require_react();
|
||||
//# sourceMappingURL=react.js.map
|
||||
7
frontend/node_modules/.vite/deps_temp_e32948ce/zustand.js.map
generated
vendored
7
frontend/node_modules/.vite/deps_temp_e32948ce/zustand.js.map
generated
vendored
File diff suppressed because one or more lines are too long
7
frontend/node_modules/.vite/deps_temp_e32948ce/zustand_middleware.js.map
generated
vendored
7
frontend/node_modules/.vite/deps_temp_e32948ce/zustand_middleware.js.map
generated
vendored
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user