6 Commits

Author SHA1 Message Date
f0e7e75ffb 完成大量美化 2026-05-01 15:44:14 +08:00
6b65b24b0f 完成大量美化 2026-05-01 14:44:18 +08:00
1d0f0ae0ef feat: sync latest local code 2026-04-30 01:38:53 +08:00
ba9b925c32 完成世界书、骰子、apiconfig页面处理 2026-04-30 01:35:10 +08:00
a3e3711b2b 重构前端完成 2026-04-29 00:19:46 +08:00
8b10ef5828 重构后端成功 2026-04-28 00:45:18 +08:00
4687 changed files with 774749 additions and 9709 deletions

5
.env
View File

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

BIN
.gitignore vendored

Binary file not shown.

View File

@@ -3,6 +3,8 @@
<component name="NewModuleRootManager"> <component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$"> <content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" /> <sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/backend" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/backend/api" isTestSource="false" />
</content> </content>
<orderEntry type="jdk" jdkName="Python 3.9 (pythonProject1)" jdkType="Python SDK" /> <orderEntry type="jdk" jdkName="Python 3.9 (pythonProject1)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />

View File

@@ -0,0 +1,195 @@
# 全局世界书数据同步问题修复
## 🐛 问题描述
**现象**: 当世界书文件被删除后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与后端数据保持一致
**用户体验**: 不再显示"幽灵"世界书
**代码健壮**: 增加了数据一致性检查机制

226
README.md Normal file
View File

@@ -0,0 +1,226 @@
# LLM Workflow Engine
一个基于 React + TypeScript + FastAPI 的 AI 聊天工作流引擎,支持流式对话、动态表格生成、图片生成等功能。
## 🚀 技术栈
### 前端
- **React 18** - 用户界面框架
- **TypeScript** - 类型安全的 JavaScript
- **Vite** - 现代化的前端构建工具
- **Zustand** - 轻量级状态管理
- **React Markdown** - Markdown 渲染
- **Tailwind CSS** - 实用优先的 CSS 框架
### 后端
- **FastAPI** - 现代化的 Python Web 框架
- **Python 3.11** - 编程语言
- **Uvicorn** - ASGI 服务器
- **WebSockets** - 实时通信
## 📁 项目结构
```
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 # 项目文档
```
## 🛠️ 安装和运行
### 使用 Docker Compose推荐
这是最简单的运行方式,适合开发和生产环境。
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
```
### 本地开发
如果你想分别运行前后端进行开发:
#### 后端开发
1. **安装 Python 依赖**
```bash
cd backend
pip install -r requirements.txt
```
2. **启动后端服务**
```bash
python -m uvicorn backend.main:app --host 0.0.0.0 --port 8000 --reload
```
#### 前端开发
1. **安装 Node.js 依赖**
```bash
cd frontend
npm install
```
2. **启动前端开发服务器**
```bash
npm run dev
```
3. **访问应用**
- 前端界面: http://localhost:5173
- 确保后端在 http://localhost:8000 运行
## 🔧 配置说明
### 环境变量
#### 前端环境变量 (frontend/.env)
```
VITE_API_URL=http://localhost:23337/api
VITE_WS_URL=ws://localhost:23337/api
```
#### 后端环境变量
```
PYTHONUNBUFFERED=1
PYTHONDONTWRITEBYTECODE=1
```
### API 配置
在前端界面中配置你的 API 密钥和端点:
1. 打开左侧栏的 "API 配置" 标签
2. 添加你的 API 配置URL 和密钥)
3. 选择要使用的 API
## 📖 功能特性
-**流式对话** - 实时显示 AI 回复
-**多角色支持** - 支持多个聊天角色和会话
-**消息编辑** - 可以编辑和删除历史消息
-**HTML 渲染** - 支持 Markdown 和 HTML 渲染
-**动态表格** - 自动生成和更新数据表格
-**图片生成** - 集成图片生成工作流
-**世界书** - 管理角色和世界设定
-**预设管理** - 保存和加载不同的对话预设
## 🐳 Docker 命令参考
```bash
# 构建并启动
docker-compose up --build
# 后台运行
docker-compose up -d
# 查看日志
docker-compose logs -f
# 停止服务
docker-compose down
# 重启服务
docker-compose restart
# 进入容器
docker-compose exec backend bash
docker-compose exec frontend sh
# 清理所有容器和卷
docker-compose down -v
```
## 🔍 开发工具
### 前端
```bash
# 类型检查
npm run type-check
# 构建
npm run build
# 预览生产构建
npm run preview
```
### 后端
```bash
# 运行测试(如果有的话)
cd backend
pytest
# 代码格式化
black .
```
## 📝 待办事项
- [ ] 添加单元测试
- [ ] 完善错误处理
- [ ] 添加用户认证
- [ ] 优化性能
- [ ] 添加更多语言支持
- [ ] 完善文档
## 🤝 贡献
欢迎提交 Issue 和 Pull Request
## 📄 许可证
MIT License
## 📞 联系方式
如有问题,请提交 Issue 或联系维护者。

194
REMOVE_CONFIRM_DIALOG.md Normal file
View File

@@ -0,0 +1,194 @@
# 世界书删除确认弹窗移除
## 📋 修改内容
移除了世界书模块中所有的浏览器级别确认弹窗(`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组件而非浏览器原生弹窗。

428
TEST_API_CONFIG.md Normal file
View File

@@ -0,0 +1,428 @@
# 🧪 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 |
---
**祝测试顺利!** 🎉

217
WORLDBOOK_API_CHECK.md Normal file
View File

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

View File

@@ -4,20 +4,24 @@ FROM python:3.11-slim
# 设置工作目录 # 设置工作目录
WORKDIR /app WORKDIR /app
# 设置环境变量
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
# 复制依赖文件 # 复制依赖文件
COPY requirements.txt . COPY requirements.txt .
# 安装依赖 # 安装依赖
RUN pip install --no-cache-dir -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
# 安装 Pillow使用阿里云镜像源
RUN pip install --no-cache-dir Pillow -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com || echo "Pillow installation failed, will install manually"
# 复制所有代码 # 复制所有代码
# 修改点:把当前目录(即 backend/)的内容复制到 /app/backend/ 下 COPY . .
# 这样镜像内的结构就是 /app/backend/api/route.py
COPY . /app/backend/
# 暴露端口 # 暴露端口
EXPOSE 8000 EXPOSE 8000
# 启动命令 # 启动命令
# 修改点:路径改为 backend.api.route (对应 /app/backend/api/route.py) CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
CMD ["uvicorn", "backend.api.route:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -1,5 +1,8 @@
from fastapi import APIRouter from fastapi import APIRouter
from .routes import presetsRoute, chatsRoute, worldbooksRoute from .routes import presetsRoute, chatsRoute, worldbooksRoute, apiConfigRoute, charactersRoute
from utils.file_utils import get_all_roles_and_chats
from core.config import settings
from pathlib import Path
router = APIRouter() router = APIRouter()
@@ -7,10 +10,11 @@ router = APIRouter()
router.include_router(presetsRoute.router) router.include_router(presetsRoute.router)
router.include_router(chatsRoute.router) router.include_router(chatsRoute.router)
router.include_router(worldbooksRoute.router) router.include_router(worldbooksRoute.router)
router.include_router(apiConfigRoute.router)
router.include_router(charactersRoute.router)
# 保留原有的其他路由 # 保留原有的其他路由
@router.get("/tool_bar/get_all_role_and_chat") @router.get("/tool_bar/get_all_role_and_chat")
def get_all_role_and_chat_endpoint(): def get_all_role_and_chat_endpoint():
from ..tools.get_all_role_and_chat import get_all_role_and_chat return get_all_roles_and_chats(Path(settings.DATA_PATH))
return get_all_role_and_chat()

View File

@@ -0,0 +1,389 @@
from fastapi import APIRouter, HTTPException, UploadFile, File
from pydantic import BaseModel, Field
from typing import Dict, Optional, List, Any
import json
import os
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)
class ApiConfigItem(BaseModel):
"""单个 API 配置项"""
id: Optional[str] = None
name: Optional[str] = ""
category: Optional[str] = None # mainLLM, imageModel, secondaryLLM, ragEmbedding
apiUrl: Optional[str] = ""
apiKey: Optional[str] = None # 前端传入的可能是明文或空
model: Optional[str] = ""
# 生图模型的特殊字段
mode: Optional[str] = None # 'local' | 'cloud'
local: Optional[dict] = None
cloud: Optional[dict] = None
class ProfileSaveRequest(BaseModel):
"""保存配置文件的请求"""
profileId: str
name: Optional[str] = None
apis: Dict[str, ApiConfigItem] # key 是 categoryvalue 是配置
class ProfileResponse(BaseModel):
"""配置文件响应(不包含明文 API Key"""
id: str
name: str
apis: Dict[str, dict] # apiKey 字段会被移除或脱敏
def 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]:
"""加载配置文件"""
config_file = CONFIG_DIR / f"{profile_id}.json"
if not config_file.exists():
return None
with open(config_file, 'r', encoding='utf-8') as f:
return json.load(f)
def save_profile(profile_id: str, profile_data: dict):
"""保存配置文件"""
config_file = CONFIG_DIR / f"{profile_id}.json"
with open(config_file, 'w', encoding='utf-8') as f:
json.dump(profile_data, f, ensure_ascii=False, indent=2)
def list_profiles() -> List[dict]:
"""列出所有配置文件"""
profiles = []
for config_file in CONFIG_DIR.glob("*.json"):
try:
with open(config_file, 'r', encoding='utf-8') as f:
profile = json.load(f)
profiles.append({
"id": profile.get("id", config_file.stem),
"name": profile.get("name", config_file.stem),
"createdAt": profile.get("createdAt", "")
})
except Exception:
continue
return profiles
@router.get("/profiles", response_model=List[dict])
def get_all_profiles():
"""获取所有配置文件列表"""
return list_profiles()
@router.get("/profiles/{profile_id}", response_model=ProfileResponse)
def get_profile(profile_id: str):
"""获取单个配置文件API Key 已脱敏)"""
profile = load_profile(profile_id)
if not profile:
raise HTTPException(status_code=404, detail="配置文件不存在")
# 脱敏所有 API Key
masked_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
return {
"id": profile.get("id", profile_id),
"name": profile.get("name", profile_id),
"apis": masked_apis
}
@router.post("/profiles", response_model=ProfileResponse)
def create_or_update_profile(request: ProfileSaveRequest):
"""创建或更新配置文件(增量更新)"""
# 加载现有配置
existing_profile = load_profile(request.profileId)
if existing_profile:
# 更新现有配置:只更新提供的 API 配置
for category, api_config in request.apis.items():
api_config_dict = api_config.dict(exclude_none=True)
# 处理 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)
# 更新配置
if "apis" not in existing_profile:
existing_profile["apis"] = {}
existing_profile["apis"][category] = api_config_dict
profile_data = existing_profile
else:
# 新建配置文件
from datetime import datetime
profile_data = {
"id": request.profileId,
"name": request.name or request.profileId,
"createdAt": datetime.now().isoformat(),
"apis": {}
}
# 添加所有 API 配置
for category, api_config in request.apis.items():
api_config_dict = api_config.dict(exclude_none=True)
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 = {}
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
return {
"id": profile_data.get("id", request.profileId),
"name": profile_data.get("name", request.profileId),
"apis": masked_apis
}
@router.delete("/profiles/{profile_id}")
def delete_profile(profile_id: str):
"""删除配置文件"""
config_file = CONFIG_DIR / f"{profile_id}.json"
if not config_file.exists():
raise HTTPException(status_code=404, detail="配置文件不存在")
config_file.unlink()
return {"message": "配置文件已删除"}
@router.post("/test-connection")
def test_connection(api_config: ApiConfigItem):
"""测试 API 连接并获取模型列表"""
try:
# 检测提供商类型
provider = LLMModelService.detect_provider(api_config.apiUrl)
# 获取模型列表
models = LLMModelService.get_models_by_provider(
provider=provider,
api_key=api_config.apiKey or "",
api_url=api_config.apiUrl
)
return {
"success": True,
"models": models,
"provider": provider,
"message": f"成功获取 {len(models)} 个模型"
}
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"获取模型列表失败: {str(e)}"
)
# ==================== ComfyUI Workflow Management ====================
@router.get("/comfyui/workflows", response_model=List[Dict[str, Any]])
def get_comfyui_workflows():
"""获取所有可用的 ComfyUI 工作流列表"""
return workflow_manager.list_workflows()
@router.post("/comfyui/workflows/upload")
async def upload_comfyui_workflow(file: UploadFile = File(...)):
"""上传 ComfyUI 工作流 JSON 文件"""
return await workflow_manager.upload_workflow(file)
@router.delete("/comfyui/workflows/{filename}")
def delete_comfyui_workflow(filename: str):
"""删除 ComfyUI 工作流文件"""
return workflow_manager.delete_workflow(filename)
@router.get("/comfyui/workflows/{filename}")
def get_comfyui_workflow(filename: str):
"""获取指定工作流的详细内容"""
return workflow_manager.load_workflow(filename)
# ==================== Connection Testing ====================
@router.post("/test-comfyui-connection")
def test_comfyui_connection(request: dict):
"""测试 ComfyUI 连接"""
import requests as req
api_url = request.get("apiUrl", "http://comfyui:8188")
try:
# 测试基本连通性
response = req.get(f"{api_url}/system_stats", timeout=5)
if response.status_code != 200:
return {
"success": False,
"message": f"HTTP {response.status_code}"
}
stats = response.json()
return {
"success": True,
"message": "连接成功",
"stats": {
"vram_total": stats.get("vram_total", 0),
"vram_free": stats.get("vram_free", 0),
"torch_version": stats.get("torch_version", ""),
"device": stats.get("device", "")
}
}
except req.exceptions.ConnectionError:
return {
"success": False,
"message": "无法连接到 ComfyUI请检查地址和端口"
}
except req.exceptions.Timeout:
return {
"success": False,
"message": "连接超时,请检查 ComfyUI 是否正常运行"
}
except Exception as e:
return {
"success": False,
"message": f"错误: {str(e)}"
}
@router.post("/test-cloud-connection")
def test_cloud_connection(request: dict):
"""测试云端 API 连接"""
import openai
provider = request.get("provider", "dall-e")
api_key = request.get("apiKey", "")
model = request.get("model", "dall-e-3")
if not api_key:
return {
"success": False,
"message": "API Key 不能为空"
}
try:
if provider == "dall-e":
# 测试 DALL-E
client = openai.OpenAI(api_key=api_key)
# 尝试获取模型列表(轻量级测试)
models = client.models.list()
# 检查指定的模型是否存在
model_exists = any(m.id == model for m in models.data)
if model_exists:
return {
"success": True,
"message": f"连接成功,模型 {model} 可用"
}
else:
return {
"success": False,
"message": f"模型 {model} 不可用"
}
elif provider == "stability":
# 测试 Stability AI
import requests as req
response = req.get(
"https://api.stability.ai/v1/engines/list",
headers={
"Authorization": f"Bearer {api_key}"
},
timeout=5
)
if response.status_code == 200:
return {
"success": True,
"message": "连接成功"
}
else:
return {
"success": False,
"message": f"HTTP {response.status_code}: {response.text}"
}
else:
return {
"success": False,
"message": f"不支持的提供商: {provider}"
}
except Exception as e:
return {
"success": False,
"message": f"连接失败: {str(e)}"
}

View File

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

View File

@@ -1,97 +1,112 @@
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
from backend.core.models.chat_history import ChatHistory, Message from pathlib import Path
try:
from backend.services.chat_service import ChatService
from backend.core.config import settings
except ImportError:
# Docker环境直接从当前目录导入
from services.chat_service import ChatService
from core.config import settings
router = APIRouter(prefix="/chat", tags=["chat"]) 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) @router.get("", response_model=dict)
async def list_all_chats(): async def list_all_chats():
"""获取所有角色的所有聊天列表""" """获取所有角色的所有聊天列表"""
return await ChatHistory.list_all_chats() return chat_service.list_all_chats()
@router.get("/{role_name}")
async def list_role_chats(role_name: str):
"""获取指定角色的所有聊天列表"""
try:
all_chats = chat_service.list_all_chats()
# 从所有聊天中筛选出该角色的聊天
role_chats = all_chats.get(role_name, [])
return role_chats
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{role_name}/{chat_name}") @router.get("/{role_name}/{chat_name}")
async def get_chat(role_name: str, chat_name: str): async def get_chat(role_name: str, chat_name: str):
"""获取指定聊天的完整内容""" """获取指定聊天的完整内容"""
try: try:
return await ChatHistory.get_chat(role_name, chat_name) return chat_service.get_chat(role_name, chat_name)
except FileNotFoundError as e: except FileNotFoundError as e:
raise HTTPException(status_code=404, detail="Chat not found") raise HTTPException(status_code=404, detail=str(e))
@router.post("/{role_name}", status_code=status.HTTP_201_CREATED) @router.post("/{role_name}", status_code=status.HTTP_201_CREATED)
async def create_chat(role_name: str, chat_name: str, metadata: dict = None): async def create_chat(role_name: str, chat_data: dict):
"""创建新聊天""" """创建新聊天"""
try: try:
return await ChatHistory.create_chat(role_name, chat_name, metadata) chat_name = chat_data.get("chat_name", "新聊天")
except FileExistsError: metadata = chat_data.get("metadata", {})
raise HTTPException(status_code=400, detail="Chat already exists") return chat_service.create_chat(role_name, chat_name, metadata)
except FileExistsError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.put("/{role_name}/{chat_name}") @router.put("/{role_name}/{chat_name}")
async def update_chat(role_name: str, chat_name: str, update_data: dict): async def update_chat(role_name: str, chat_name: str, update_data: dict):
"""更新聊天元数据""" """更新聊天元数据"""
try: # TODO: 实现更新聊天元数据功能
return await ChatHistory.update_chat(role_name, chat_name, update_data) raise HTTPException(status_code=501, detail="Not Implemented")
except FileNotFoundError:
raise HTTPException(status_code=404, detail="Chat not found")
@router.delete("/{role_name}/{chat_name}") @router.delete("/{role_name}/{chat_name}")
async def delete_chat(role_name: str, chat_name: str): async def delete_chat(role_name: str, chat_name: str):
"""删除指定聊天""" """删除指定聊天"""
try: # TODO: 实现删除聊天功能
return await ChatHistory.delete_chat(role_name, chat_name) raise HTTPException(status_code=501, detail="Not Implemented")
except FileNotFoundError:
raise HTTPException(status_code=404, detail="Chat not found")
# ========== 聊天消息路由 ==========
@router.get("/{role_name}/{chat_name}/messages") @router.get("/{role_name}/{chat_name}/messages")
async def list_messages(role_name: str, chat_name: str): async def list_messages(role_name: str, chat_name: str):
"""获取聊天的所有消息""" """获取聊天的所有消息"""
try: try:
return await ChatHistory.list_messages(role_name, chat_name) chat_data = chat_service.get_chat(role_name, chat_name)
except FileNotFoundError: return {"messages": chat_data["messages"]}
raise HTTPException(status_code=404, detail="Chat not found") except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.get("/{role_name}/{chat_name}/messages/{floor}") @router.get("/{role_name}/{chat_name}/messages/{floor}")
async def get_message(role_name: str, chat_name: str, floor: int): async def get_message(role_name: str, chat_name: str, floor: int):
"""获取指定楼层的消息""" """获取指定楼层的消息"""
try: try:
return await ChatHistory.get_message(role_name, chat_name, floor) chat_data = chat_service.get_chat(role_name, chat_name)
except FileNotFoundError: for msg in chat_data["messages"]:
raise HTTPException(status_code=404, detail="Chat not found") if msg.get("floor") == floor:
return msg
raise HTTPException(status_code=404, detail=f"Message at floor {floor} not found")
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.post("/{role_name}/{chat_name}/messages", status_code=status.HTTP_201_CREATED) @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): async def add_message(role_name: str, chat_name: str, message_data: dict):
"""向聊天添加新消息""" """向聊天添加新消息"""
try: try:
return await ChatHistory.add_message(role_name, chat_name, message_data) return chat_service.add_message(role_name, chat_name, message_data)
except FileNotFoundError: except FileNotFoundError as e:
raise HTTPException(status_code=404, detail="Chat not found") raise HTTPException(status_code=404, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@router.put("/{role_name}/{chat_name}/messages/{floor}") @router.put("/{role_name}/{chat_name}/messages/{floor}")
async def update_message(role_name: str, chat_name: str, floor: int, update_data: dict): async def update_message(role_name: str, chat_name: str, floor: int, update_data: dict):
"""更新指定楼层的消息""" """更新指定楼层的消息"""
try: try:
return await ChatHistory.update_message(role_name, chat_name, floor, update_data) return chat_service.update_message(role_name, chat_name, floor, update_data)
except FileNotFoundError: except FileNotFoundError as e:
raise HTTPException(status_code=404, detail="Chat not found") raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.delete("/{role_name}/{chat_name}/messages/{floor}") @router.delete("/{role_name}/{chat_name}/messages/{floor}")
async def delete_message(role_name: str, chat_name: str, floor: int): async def delete_message(role_name: str, chat_name: str, floor: int):
"""删除指定楼层的消息""" """删除指定楼层的消息"""
try: try:
return await ChatHistory.delete_message(role_name, chat_name, floor) return chat_service.delete_message(role_name, chat_name, floor)
except FileNotFoundError: except FileNotFoundError as e:
raise HTTPException(status_code=404, detail="Chat not found") raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))

View File

@@ -1,98 +1,60 @@
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
from backend.core.models.PromptList import AIDesignSpec # TODO: 实现 PresetService 来替代旧的 AIDesignSpec 逻辑
from backend.core.models.PromptComponent import PromptComponent # from services.preset_service import PresetService
router = APIRouter(prefix="/presets", tags=["presets"]) router = APIRouter(prefix="/presets", tags=["presets"])
# ========== 预设基础路由 ==========
@router.get("", response_model=dict) @router.get("", response_model=dict)
async def list_presets(): async def list_presets():
"""获取所有预设列表及其基本信息""" """获取所有预设列表及其基本信息"""
return await AIDesignSpec.list_all_presets() # return await PresetService.list_all_presets()
return {"presets": []}
@router.get("/{preset_name}") @router.get("/{preset_name}")
async def get_preset(preset_name: str): async def get_preset(preset_name: str):
"""获取指定预设的完整内容""" """获取指定预设的完整内容"""
try: # try:
return await AIDesignSpec.get_preset(preset_name) # return await PresetService.get_preset(preset_name)
except FileNotFoundError: # except FileNotFoundError:
raise HTTPException(status_code=404, detail="Preset not found") # 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) @router.post("", status_code=status.HTTP_201_CREATED)
async def create_preset(preset_name: str, preset_data: dict): async def create_preset(preset_name: str, preset_data: dict):
"""创建新预设""" """创建新预设"""
try: raise HTTPException(status_code=501, detail="Not Implemented")
return await AIDesignSpec.create_preset(preset_name, preset_data)
except FileExistsError:
raise HTTPException(status_code=400, detail="Preset already exists")
@router.put("/{preset_name}") @router.put("/{preset_name}")
async def update_preset(preset_name: str, update_data: dict): async def update_preset(preset_name: str, update_data: dict):
"""更新预设配置""" """更新预设配置"""
try: raise HTTPException(status_code=501, detail="Not Implemented")
return await AIDesignSpec.update_preset(preset_name, update_data)
except FileNotFoundError:
raise HTTPException(status_code=404, detail="Preset not found")
@router.delete("/{preset_name}") @router.delete("/{preset_name}")
async def delete_preset(preset_name: str): async def delete_preset(preset_name: str):
"""删除指定预设""" """删除指定预设"""
try: raise HTTPException(status_code=501, detail="Not Implemented")
return await AIDesignSpec.delete_preset(preset_name)
except FileNotFoundError:
raise HTTPException(status_code=404, detail="Preset not found")
# ========== 预设组件路由 ==========
@router.get("/{preset_name}/components") @router.get("/{preset_name}/components")
async def list_preset_components(preset_name: str): async def list_preset_components(preset_name: str):
"""获取预设中的所有组件""" """获取预设中的所有组件"""
try: raise HTTPException(status_code=501, detail="Not Implemented")
return await AIDesignSpec.list_components(preset_name)
except FileNotFoundError:
raise HTTPException(status_code=404, detail="Preset not found")
@router.get("/{preset_name}/components/{component_id}") @router.get("/{preset_name}/components/{component_id}")
async def get_preset_component(preset_name: str, component_id: str): async def get_preset_component(preset_name: str, component_id: str):
"""获取指定组件的详情""" """获取指定组件的详情"""
try: raise HTTPException(status_code=501, detail="Not Implemented")
return await AIDesignSpec.get_component(preset_name, component_id)
except FileNotFoundError:
raise HTTPException(status_code=404, detail="Preset not found")
@router.post("/{preset_name}/components", status_code=status.HTTP_201_CREATED) @router.post("/{preset_name}/components", status_code=status.HTTP_201_CREATED)
async def add_preset_component(preset_name: str, component_data: dict): async def add_preset_component(preset_name: str, component_data: dict):
"""向预设添加新组件""" """向预设添加新组件"""
try: raise HTTPException(status_code=501, detail="Not Implemented")
return await AIDesignSpec.add_component_to_preset(preset_name, component_data)
except FileNotFoundError:
raise HTTPException(status_code=404, detail="Preset not found")
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.put("/{preset_name}/components/{component_id}") @router.put("/{preset_name}/components/{component_id}")
async def update_preset_component(preset_name: str, component_id: str, update_data: dict): async def update_preset_component(preset_name: str, component_id: str, update_data: dict):
"""更新指定组件""" """更新指定组件"""
try: raise HTTPException(status_code=501, detail="Not Implemented")
return await AIDesignSpec.update_component_in_preset(preset_name, component_id, update_data)
except FileNotFoundError:
raise HTTPException(status_code=404, detail="Preset not found")
@router.delete("/{preset_name}/components/{component_id}") @router.delete("/{preset_name}/components/{component_id}")
async def delete_preset_component(preset_name: str, component_id: str): async def delete_preset_component(preset_name: str, component_id: str):
"""从预设中删除指定组件""" """从预设中删除指定组件"""
try: raise HTTPException(status_code=501, detail="Not Implemented")
return await AIDesignSpec.delete_component_from_preset(preset_name, component_id)
except FileNotFoundError:
raise HTTPException(status_code=404, detail="Preset not found")

View File

@@ -1,5 +1,6 @@
# 标准库导入 # 标准库导入
import os import os
import json
import shutil import shutil
import logging import logging
from pathlib import Path from pathlib import Path
@@ -10,18 +11,9 @@ from fastapi import APIRouter, HTTPException, UploadFile, File, Form
from fastapi.responses import JSONResponse, FileResponse from fastapi.responses import JSONResponse, FileResponse
# 本地模块导入 # 本地模块导入
# 本地模块导入 from models.internal import WorldInfo, WorldInfoEntry
from backend.core.models.WorldBook import WorldBook from core.config import settings
from backend.core.models.WorldItem import ( from services.worldbook_service import worldbook_service
WorldItem,
TriggerConfig,
KeywordTriggerConfig,
RAGTriggerConfig,
ConditionTriggerConfig,
TriggerStrategy
)
from backend.core.config import settings
# 配置日志 # 配置日志
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -41,525 +33,221 @@ async def list_worldbooks():
List[Dict[str, Any]]: 世界书列表 List[Dict[str, Any]]: 世界书列表
""" """
try: try:
worldbooks = [] return worldbook_service.list_worldbooks()
search_dir = settings.WORLDBOOKS_PATH
# 检查目录是否存在
if not os.path.exists(search_dir):
logger.warning(f"目录不存在: {search_dir}")
return []
for filename in os.listdir(search_dir):
if filename.endswith(".json"):
file_path = os.path.join(search_dir, filename)
try:
# 加载世界书基本信息
# 传入文件名(不带扩展名)
world_book = WorldBook.load(Path(file_path).stem)
worldbooks.append(world_book.to_summary_dict())
except Exception as e:
logger.warning(f"加载世界书 {filename} 失败: {str(e)}")
continue
logger.info(f"获取世界书列表: 共 {len(worldbooks)}")
return worldbooks
except Exception as e: except Exception as e:
logger.error(f"获取世界书列表失败: {str(e)}") logger.error(f"Failed to list worldbooks: {str(e)}")
raise HTTPException(status_code=500, detail=f"获取世界书列表失败: {str(e)}") raise HTTPException(status_code=500, detail=str(e))
@router.get("/{name}", response_model=Dict[str, Any]) @router.get("/{name}", response_model=Dict[str, Any])
async def get_worldbook(name: str): async def get_worldbook(name: str):
""" """
获取指定名称的世界书 获取指定名称的世界书
Args:
name: 世界书名称
Returns:
Dict[str, Any]: 世界书数据
""" """
try: try:
if not WorldBook.exists(name): return worldbook_service.get_worldbook(name)
raise HTTPException(status_code=404, detail=f"世界书 '{name}' 不存在") except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
world_book = WorldBook.load(name)
logger.info(f"获取世界书: {name}")
return world_book.to_dict()
except HTTPException:
raise
except Exception as e: except Exception as e:
logger.error(f"获取世界书 {name} 失败: {str(e)}") logger.error(f"Failed to get worldbook '{name}': {str(e)}")
raise HTTPException(status_code=500, detail=f"获取世界书失败: {str(e)}") raise HTTPException(status_code=500, detail=str(e))
@router.post("/", response_model=Dict[str, Any]) @router.post("/", response_model=Dict[str, Any])
async def create_worldbook( async def create_worldbook(
name: str = Form(...), name: str = Form(...),
description: str = Form(""),
file: Optional[UploadFile] = File(None) file: Optional[UploadFile] = File(None)
): ):
""" """
创建新世界书 创建新世界书(可选择导入文件)
Args:
name: 世界书名称
description: 世界书描述
file: 可选的上传文件SillyTavern 格式)
Returns:
Dict[str, Any]: 创建的世界书数据
""" """
try: try:
# 如果上传了文件,从文件导入 # 如果提供了文件,从 SillyTavern 格式导入
if file: if file:
# 保存临时文件 content = await file.read()
temp_path = os.path.join(settings.WORLDBOOKS_PATH, f"temp_{file.filename}") st_data = json.loads(content.decode('utf-8'))
with open(temp_path, "wb") as buffer: return worldbook_service.import_from_sillytavern(name, st_data)
shutil.copyfileobj(file.file, buffer)
try:
# 从文件加载世界书
world_book = WorldBook.load(Path(temp_path).stem)
# 更新名称和描述
world_book.name = name
# 保存世界书
world_book.save()
logger.info(f"从文件创建世界书: {name}")
finally:
# 删除临时文件
if os.path.exists(temp_path):
os.remove(temp_path)
else: else:
# 创建空世界书 # 创建空世界书
world_book = WorldBook.create_empty(name) return worldbook_service.create_worldbook(name, description)
logger.info(f"创建空世界书: {name}") except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return world_book.to_dict()
except HTTPException:
raise
except Exception as e: except Exception as e:
logger.error(f"创建世界书 {name} 失败: {str(e)}") logger.error(f"Failed to create worldbook '{name}': {str(e)}")
raise HTTPException(status_code=500, detail=f"创建世界书失败: {str(e)}") raise HTTPException(status_code=500, detail=str(e))
@router.put("/{name}", response_model=Dict[str, Any]) @router.put("/{name}", response_model=Dict[str, Any])
async def update_worldbook( async def update_worldbook(
name: str, name: str,
file: Optional[UploadFile] = File(None) description: Optional[str] = Form(None)
): ):
""" """
更新世界书 更新世界书基本信息
Args:
name: 世界书名称
description: 世界书描述(可选)
file: 可选的上传文件SillyTavern 格式)
Returns:
Dict[str, Any]: 更新后的世界书数据
""" """
try: try:
# 检查世界书是否存在 return worldbook_service.update_worldbook(name, description)
if not WorldBook.exists(name): except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=f"世界书 '{name}' 不存在") raise HTTPException(status_code=404, detail=str(e))
# 加载世界书
world_book = WorldBook.load(name)
# 如果上传了文件,从文件导入并合并
if file:
# 保存临时文件
temp_path = os.path.join(settings.WORLDBOOKS_PATH, f"temp_{file.filename}")
with open(temp_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
try:
# 从文件加载世界书
imported_book = WorldBook.load(Path(temp_path).stem)
# 合并条目
world_book.merge_from_book(imported_book)
logger.info(f"从文件更新世界书: {name}")
finally:
# 删除临时文件
if os.path.exists(temp_path):
os.remove(temp_path)
# 保存世界书
world_book.save()
logger.info(f"更新世界书: {name}")
return world_book.to_dict()
except HTTPException:
raise
except Exception as e: except Exception as e:
logger.error(f"更新世界书 {name} 失败: {str(e)}") logger.error(f"Failed to update worldbook '{name}': {str(e)}")
raise HTTPException(status_code=500, detail=f"更新世界书失败: {str(e)}") raise HTTPException(status_code=500, detail=str(e))
@router.delete("/{name}") @router.delete("/{name}")
async def delete_worldbook(name: str): async def delete_worldbook(name: str):
""" """
删除世界书 删除世界书
"""
try:
worldbook_service.delete_worldbook(name)
return {"message": f"Worldbook '{name}' deleted successfully"}
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Failed to delete worldbook '{name}': {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{name}/entries", response_model=Dict[str, Any])
async def list_worldbook_entries(
name: str,
page: int = 1,
page_size: int = 20
):
"""
获取世界书的条目列表(支持分页)
Args: Args:
name: 世界书名称 name: 世界书名称
page: 页码从1开始
Returns: page_size: 每页数量默认20
Dict[str, Any]: 删除结果
""" """
try: try:
# 检查世界书是否存在 return worldbook_service.list_entries(name, page, page_size)
if not WorldBook.exists(name): except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=f"世界书 '{name}' 不存在") raise HTTPException(status_code=404, detail=str(e))
# 获取文件路径
file_path = WorldBook.get_file_path(name)
# 删除文件
os.remove(file_path)
logger.info(f"删除世界书: {name}")
return {"success": True, "message": f"世界书 '{name}' 已删除"}
except HTTPException:
raise
except Exception as e: except Exception as e:
logger.error(f"删除世界书 {name} 失败: {str(e)}") logger.error(f"Failed to list entries for worldbook '{name}': {str(e)}")
raise HTTPException(status_code=500, detail=f"删除世界书失败: {str(e)}") raise HTTPException(status_code=500, detail=str(e))
@router.get("/{name}/entries", response_model=List[Dict[str, Any]])
async def list_worldbook_entries(name: str):
"""
获取世界书的所有条目(包括已禁用的条目)
Args:
name: 世界书名称
Returns:
List[Dict[str, Any]]: 条目列表
"""
try:
# 检查世界书是否存在
if not WorldBook.exists(name):
raise HTTPException(status_code=404, detail=f"世界书 '{name}' 不存在")
# 加载世界书
world_book = WorldBook.load(name)
# 获取所有条目的核心信息
entries = world_book.get_all_entries()
logger.info(f"获取世界书 {name} 的所有条目: 共 {len(entries)}")
return entries
except HTTPException:
raise
except Exception as e:
logger.error(f"获取世界书 {name} 的条目失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"获取世界书条目失败: {str(e)}")
@router.get("/{name}/entries/{uid}", response_model=Dict[str, Any]) @router.get("/{name}/entries/{uid}", response_model=Dict[str, Any])
async def get_worldbook_entry(name: str, uid: int): async def get_worldbook_entry(name: str, uid: str):
""" """
获取世界书的指定条目 获取世界书的指定条目
Args:
name: 世界书名称
uid: 条目 UID
Returns:
Dict[str, Any]: 条目数据
""" """
try: try:
# 检查世界书是否存在 return worldbook_service.get_entry(name, uid)
if not WorldBook.exists(name): except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=f"世界书 '{name}' 不存在") raise HTTPException(status_code=404, detail=str(e))
# 加载世界书
world_book = WorldBook.load(name)
# 获取条目
entry = world_book.get_entry(uid)
if entry is None:
raise HTTPException(status_code=404, detail=f"条目 UID {uid} 不存在")
logger.info(f"获取世界书 {name} 的条目: UID={uid}")
return entry.to_dict()
except HTTPException:
raise
except Exception as e: except Exception as e:
logger.error(f"获取世界书 {name} 的条目 {uid} 失败: {str(e)}") logger.error(f"Failed to get entry '{uid}' from worldbook '{name}': {str(e)}")
raise HTTPException(status_code=500, detail=f"获取世界书条目失败: {str(e)}") raise HTTPException(status_code=500, detail=str(e))
@router.post("/{name}/entries", response_model=Dict[str, Any]) @router.post("/{name}/entries", response_model=Dict[str, Any])
async def create_worldbook_entry(name: str, entry_data: Dict[str, Any]): async def create_worldbook_entry(name: str, entry_data: Dict[str, Any]):
""" """
在世界书中创建新条目 在世界书中创建新条目
Args:
name: 世界书名称
entry_data: 条目数据
Returns:
Dict[str, Any]: 创建的条目数据
""" """
try: try:
# 检查世界书是否存在 return worldbook_service.create_entry(name, entry_data)
if not WorldBook.exists(name): except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=f"世界书 '{name}' 不存在") raise HTTPException(status_code=404, detail=str(e))
# 加载世界书
world_book = WorldBook.load(name)
# 处理触发配置数据
trigger_data = entry_data.pop("trigger_config", None)
if trigger_data and "triggers" in trigger_data:
# 创建新的触发配置对象
trigger_config = TriggerConfig()
# 处理每个触发策略
for strategy_str, trigger_info in trigger_data["triggers"].items():
try:
strategy = TriggerStrategy(strategy_str)
enabled = trigger_info[0] if isinstance(trigger_info, list) and len(trigger_info) > 0 else False
config_data = trigger_info[1] if isinstance(trigger_info, list) and len(trigger_info) > 1 else None
# 根据触发策略创建对应的配置对象
if strategy == TriggerStrategy.KEYWORD and config_data:
config = KeywordTriggerConfig(**config_data)
elif strategy == TriggerStrategy.RAG and config_data:
config = RAGTriggerConfig(**config_data)
elif strategy == TriggerStrategy.CONDITION and config_data:
config = ConditionTriggerConfig(**config_data)
else:
config = None
# 设置触发策略
trigger_config.set_trigger(strategy, enabled, config)
except Exception as e:
logger.warning(f"处理触发策略 {strategy_str} 失败: {str(e)}")
continue
# 设置触发配置
entry_data["trigger_config"] = trigger_config
# 创建条目
entry = WorldItem.Entry(**entry_data)
# 添加条目
world_book.add_entry(entry)
# 保存世界书
world_book.save()
logger.info(f"在世界书 {name} 中创建条目: UID={entry.uid}")
return entry.to_dict()
except HTTPException:
raise
except Exception as e: except Exception as e:
logger.error(f"在世界书 {name} 中创建条目失败: {str(e)}") logger.error(f"Failed to create entry in worldbook '{name}': {str(e)}")
raise HTTPException(status_code=500, detail=f"创建世界书条目失败: {str(e)}") raise HTTPException(status_code=500, detail=str(e))
@router.put("/{name}/entries/{uid}", response_model=Dict[str, Any]) @router.put("/{name}/entries/{uid}", response_model=Dict[str, Any])
async def update_worldbook_entry(name: str, uid: int, entry_data: Dict[str, Any]): async def update_worldbook_entry(name: str, uid: str, entry_data: Dict[str, Any]):
""" """
更新世界书的指定条目 更新世界书的指定条目
Args:
name: 世界书名称
uid: 条目 UID
entry_data: 条目数据
Returns:
Dict[str, Any]: 更新后的条目数据
""" """
try: try:
# 检查世界书是否存在 return worldbook_service.update_entry(name, uid, entry_data)
if not WorldBook.exists(name): except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=f"世界书 '{name}' 不存在") raise HTTPException(status_code=404, detail=str(e))
# 加载世界书
world_book = WorldBook.load(name)
# 检查条目是否存在
if world_book.get_entry(uid) is None:
raise HTTPException(status_code=404, detail=f"条目 UID {uid} 不存在")
# 处理触发配置数据
trigger_data = entry_data.pop("trigger_config", None)
if trigger_data and "triggers" in trigger_data:
# 创建新的触发配置对象
trigger_config = TriggerConfig()
# 处理每个触发策略
for strategy_str, trigger_info in trigger_data["triggers"].items():
try:
strategy = TriggerStrategy(strategy_str)
enabled = trigger_info[0] if isinstance(trigger_info, list) and len(trigger_info) > 0 else False
config_data = trigger_info[1] if isinstance(trigger_info, list) and len(trigger_info) > 1 else None
# 根据触发策略创建对应的配置对象
if strategy == TriggerStrategy.KEYWORD and config_data:
config = KeywordTriggerConfig(**config_data)
elif strategy == TriggerStrategy.RAG and config_data:
config = RAGTriggerConfig(**config_data)
elif strategy == TriggerStrategy.CONDITION and config_data:
config = ConditionTriggerConfig(**config_data)
else:
config = None
# 设置触发策略
trigger_config.set_trigger(strategy, enabled, config)
except Exception as e:
logger.warning(f"处理触发策略 {strategy_str} 失败: {str(e)}")
continue
# 设置触发配置
entry_data["trigger_config"] = trigger_config
valid_fields = WorldItem.Entry.model_fields.keys()
filtered_data = {k: v for k, v in entry_data.items() if k in valid_fields}
# 更新条目
success = world_book.update_entry(uid, **filtered_data)
if not success:
raise HTTPException(status_code=500, detail="更新条目失败")
# 保存世界书
world_book.save()
# 获取更新后的条目
entry = world_book.get_entry(uid)
logger.info(f"更新世界书 {name} 的条目: UID={uid}")
return entry.to_dict()
except HTTPException:
raise
except Exception as e: except Exception as e:
logger.error(f"更新世界书 {name} 的条目 {uid} 失败: {str(e)}") logger.error(f"Failed to update entry '{uid}' in worldbook '{name}': {str(e)}")
raise HTTPException(status_code=500, detail=f"更新世界书条目失败: {str(e)}") raise HTTPException(status_code=500, detail=str(e))
@router.delete("/{name}/entries/{uid}") @router.delete("/{name}/entries/{uid}")
async def delete_worldbook_entry(name: str, uid: int): async def delete_worldbook_entry(name: str, uid: str):
""" """
删除世界书的指定条目 删除世界书的指定条目
Args:
name: 世界书名称
uid: 条目 UID
Returns:
Dict[str, Any]: 删除结果
""" """
try: try:
# 检查世界书是否存在 worldbook_service.delete_entry(name, uid)
if not WorldBook.exists(name): return {"message": f"Entry '{uid}' deleted successfully"}
raise HTTPException(status_code=404, detail=f"世界书 '{name}' 不存在") except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
# 加载世界书
world_book = WorldBook.load(name)
# 删除条目
success = world_book.remove_entry(uid)
if not success:
raise HTTPException(status_code=404, detail=f"条目 UID {uid} 不存在")
# 保存世界书
world_book.save()
logger.info(f"删除世界书 {name} 的条目: UID={uid}")
return {"success": True, "message": f"条目 UID {uid} 已删除"}
except HTTPException:
raise
except Exception as e: except Exception as e:
logger.error(f"删除世界书 {name} 的条目 {uid} 失败: {str(e)}") logger.error(f"Failed to delete entry '{uid}' from worldbook '{name}': {str(e)}")
raise HTTPException(status_code=500, detail=f"删除世界书条目失败: {str(e)}") raise HTTPException(status_code=500, detail=str(e))
@router.post("/{name}/import", response_model=Dict[str, Any]) @router.post("/{name}/import", response_model=Dict[str, Any])
async def import_worldbook(name: str, file: UploadFile = File(...)): async def import_worldbook(name: str, file: UploadFile = File(...)):
""" """
从文件导入世界书 从文件导入世界书(自动检测 SillyTavern 或内部格式)
Args:
name: 世界书名称
file: 上传的文件SillyTavern 格式)
Returns:
Dict[str, Any]: 导入的世界书数据
""" """
try: try:
# 保存临时文件 content = await file.read()
temp_path = os.path.join(settings.WORLDBOOKS_PATH, f"temp_{file.filename}") data = json.loads(content.decode('utf-8'))
with open(temp_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
try: # 智能检测格式
# 从文件加载世界书 from models.converters import WorldBookConverter
world_book = WorldBook.load(Path(temp_path).stem) format_type = WorldBookConverter.detect_format(data)
# 如果世界书已存在,合并条目 logger.info(f"检测到世界书格式: {format_type}")
if WorldBook.exists(name):
existing_book = WorldBook.load(name)
existing_book.merge_from_book(world_book)
# 保存合并后的世界书
existing_book.save()
world_book = existing_book
logger.info(f"导入并合并世界书: {name}")
else:
# 设置名称并保存
world_book.name = name
world_book.save()
logger.info(f"导入新世界书: {name}")
return world_book.to_dict() if format_type == "sillytavern":
finally: # SillyTavern 格式,需要转换
# 删除临时文件 logger.info(f"正在转换 SillyTavern 格式为内部格式")
if os.path.exists(temp_path): return worldbook_service.import_from_sillytavern(name, data)
os.remove(temp_path) elif format_type == "internal":
# 已经是内部格式,直接保存
logger.info(f"检测到内部格式,直接保存")
return worldbook_service.import_internal_format(name, data)
else:
raise HTTPException(status_code=400, detail="无法识别的世界书格式")
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="Invalid JSON format")
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f"导入世界书 {name} 失败: {str(e)}") logger.error(f"Failed to import worldbook '{name}': {str(e)}")
raise HTTPException(status_code=500, detail=f"导入世界书失败: {str(e)}") raise HTTPException(status_code=500, detail=str(e))
@router.get("/{name}/export") @router.get("/{name}/export")
async def export_worldbook(name: str): async def export_worldbook(name: str, format: str = "internal"):
""" """
导出世界书为 SillyTavern 格式 导出世界书(支持 internal 和 sillytavern 两种格式)
Args: Args:
name: 世界书名称 name: 世界书名称
format: 导出格式 ('internal''sillytavern'),默认 internal
Returns:
FileResponse: 导出的文件
""" """
try: try:
# 检查世界书是否存在 if format.lower() == "sillytavern":
if not WorldBook.exists(name): # 导出为 SillyTavern 格式(可能丢失特殊设置)
raise HTTPException(status_code=404, detail=f"世界书 '{name}' 不存在") logger.info(f"导出世界书 '{name}' 为 SillyTavern 格式")
st_data = worldbook_service.export_to_sillytavern(name)
# 加载世界书 return JSONResponse(
world_book = WorldBook.load(name) 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(
export_path = os.path.join(settings.WORLDBOOKS_PATH, f"export_{name}.json") content=internal_data,
headers={
# 导出为 SillyTavern 格式 "Content-Disposition": f"attachment; filename={name}.json"
world_book.to_sillytavern_json(export_path) }
)
logger.info(f"导出世界书: {name}") except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
# 返回文件
return FileResponse(
path=export_path,
filename=f"{name}.json",
media_type="application/json"
)
except HTTPException:
raise
except Exception as e: except Exception as e:
logger.error(f"导出世界书 {name} 失败: {str(e)}") logger.error(f"Failed to export worldbook '{name}': {str(e)}")
raise HTTPException(status_code=500, detail=f"导出世界书失败: {str(e)}") raise HTTPException(status_code=500, detail=str(e))

View File

@@ -52,6 +52,15 @@ class Settings:
# 临时文件目录 # 临时文件目录
TEMP_PATH = DATA_PATH / "temp" TEMP_PATH = DATA_PATH / "temp"
# ComfyUI 工作流目录
COMFYUI_WORKFLOWS_PATH = DATA_PATH / "comfyui_workflows"
# 角色卡目录
CHARACTERS_PATH = DATA_PATH / "characters"
# 图片资源目录
IMAGES_PATH = DATA_PATH / "images"
def ensure_directories(self): def ensure_directories(self):
"""确保所有配置的目录存在,如果不存在则创建""" """确保所有配置的目录存在,如果不存在则创建"""
directories = [ directories = [
@@ -60,6 +69,9 @@ class Settings:
self.PRESET_PATH, self.PRESET_PATH,
self.CHAT_PATH, self.CHAT_PATH,
self.TEMP_PATH, self.TEMP_PATH,
self.COMFYUI_WORKFLOWS_PATH,
self.CHARACTERS_PATH,
self.IMAGES_PATH,
] ]
for directory in directories: for directory in directories:
directory.mkdir(parents=True, exist_ok=True) directory.mkdir(parents=True, exist_ok=True)

View File

@@ -1,24 +0,0 @@
from pydantic import BaseModel, Field
from typing import Optional, List
# 1. 定义请求体模型
class ChatRequest(BaseModel):
# --- 基础信息 ---
mes: str = Field(..., description="用户输入的消息内容")
is_user: bool = Field(..., description="标识发送者是否为用户True为用户False为AI")
floor_number: int = Field(..., description="当前对话的楼层号,用于判断是否为重试(Regenerate)请求")
# --- 身份与会话 ---
name: str = Field("default", description="发送者的显示名称,默认为'default'")
role_name: Optional[str] = Field(None, description="当前绑定的角色名称")
chat_name: Optional[str] = Field(None, description="当前会话的标识名称")
preset: Optional[str] = Field(None, description="预设的提示词或系统指令")
# --- 功能开关 ---
stream: bool = Field(False, description="是否开启流式输出")
img_switch: bool = Field(False, description="是否开启图片生成功能")
table_switch: bool = Field(False, description="是否开启表格生成功能")
# 其他可能需要的参数,比如历史记录,可以在这里加
# history: Optional[List[Dict]] = None

View File

@@ -1,65 +0,0 @@
from pydantic import BaseModel, Field, validator
from typing import Dict, Any
class PromptComponent(BaseModel):
"""预设组件类,代表一个独立的提示词模块"""
identifier: str = Field(..., description="唯一标识符,用于引用和定位组件")
name: str = Field(..., description="组件显示名称")
content: str = Field("", description="组件内容文本")
# 0System1User2Assistant
role: int = Field(0, description="角色身份(0System1User2Assistant)")
system_prompt: bool = Field(False, description="是否强制作为系统提示词处理")
marker: bool = Field(False, description="是否为动态插入点占位符")
@validator('role')
def validate_role(cls, v):
"""验证角色值是否在有效范围内"""
if not isinstance(v, int) or v not in [0, 1, 2]:
raise ValueError("角色值必须是0(System)、1(User)或2(Assistant)")
return v
def update(self, **kwargs) -> None:
"""
更新组件属性
参数:
**kwargs: 要更新的字段和值
异常:
ValueError: 当尝试更新identifier时抛出
"""
if 'identifier' in kwargs:
raise ValueError("组件标识符不可修改")
for key, value in kwargs.items():
if hasattr(self, key):
setattr(self, key, value)
def to_dict(self) -> Dict[str, Any]:
"""
将组件转换为字典
返回:
Dict[str, Any]: 组件的字典表示
"""
return self.dict()
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'PromptComponent':
"""
从字典创建组件实例自动处理role字段的类型转换
参数:
data: 包含组件数据的字典
返回:
PromptComponent: 组件实例
"""
# 处理role字段将字符串转换为整数
if 'role' in data and isinstance(data['role'], str):
role_map = {'system': 0, 'user': 1, 'assistant': 2}
data['role'] = role_map.get(data['role'].lower(), 0)
return cls(**data)

View File

@@ -1,591 +0,0 @@
from pydantic import BaseModel, Field, validator
from typing import List, Dict, Any, Optional
from pathlib import Path
import json
from .PromptComponent import PromptComponent
class AIDesignSpec(BaseModel):
"""AI设计规范类包含模型生成的核心参数和动态结构配置"""
# [Base] 基础核心参数
temperature: float = Field(1.0, description="生成温度,控制随机性(0-2)")
frequency_penalty: float = Field(0.0, description="频率惩罚降低重复token概率")
presence_penalty: float = Field(0.0, description="存在惩罚,鼓励谈论新话题")
top_p: float = Field(1.0, description="核采样,控制词汇选择范围")
top_k: int = Field(0, description="随机采样范围从概率最高的K个词中选择")
top_a: float = Field(0.0, description="基于平方概率分布的采样")
min_p: float = Field(0.0, description="最小概率阈值")
repetition_penalty: float = Field(1.0, description="重复惩罚系数(1.0-1.2)")
max_context: int = Field(2048, description="上下文窗口大小(Token上限)")
max_tokens: int = Field(250, description="单次回复的最大长度")
max_context_unlocked: bool = Field(False, description="是否允许超出限制的上下文")
names_behavior: int = Field(0, description="名字处理行为(0=默认,1=始终包含,2=仅角色)")
send_if_empty: str = Field("", description="用户发送空消息时自动填充的内容")
impersonation_prompt: str = Field("", description="模仿模式下使用的提示词")
new_chat_prompt: str = Field("", description="开启新聊天时自动发送的系统提示")
new_group_chat_prompt: str = Field("", description="开启新群组聊天时的提示")
new_example_chat_prompt: str = Field("", description="新示例聊天的提示")
continue_nudge_prompt: str = Field("", description="续写功能触发的提示词")
bias_preset_selected: str = Field("", description="选用的偏见预设")
wi_format: str = Field("{0}", description="世界书条目的格式化字符串")
scenario_format: str = Field("{{scenario}}", description="场景描述的格式化字符串")
personality_format: str = Field("", description="角色性格的格式化字符串")
group_nudge_prompt: str = Field("", description="群组聊天中提示AI仅以特定角色回复的提示词")
stream: bool = Field(True, description="是否使用流式输出")
assistant_prefill: str = Field("", description="强制AI回复的开头内容")
assistant_impersonation: str = Field("", description="模仿模式下强制AI回复的开头内容")
use_sysprompt: bool = Field(True, description="是否强制将提示词注入系统层")
squash_system_messages: bool = Field(False, description="是否压缩系统消息")
media_inlining: bool = Field(False, description="是否内联媒体描述")
continue_prefill: bool = Field(True, description="续写时是否预填充内容")
continue_postfix: str = Field(" ", description="续写时添加的后缀")
seed: int = Field(-1, description="随机种子(-1为随机)")
n: int = Field(1, description="生成回复的数量")
# [Dynamic] 动态结构
prompts: List[PromptComponent] = Field(
default_factory=list,
description="组件库,定义所有可用的积木块"
)
prompt_order: List[str] = Field(
default_factory=list,
description="组装说明书,定义构建最终提示词的顺序"
)
@validator('prompts')
def validate_prompts_unique_identifier(cls, v):
"""验证组件标识符唯一性"""
identifiers = [comp.identifier for comp in v]
if len(identifiers) != len(set(identifiers)):
raise ValueError("组件标识符必须唯一")
return v
@validator('prompt_order')
def validate_prompt_order_exists(cls, v, values):
"""验证prompt_order中的组件ID是否存在于prompts中"""
if 'prompts' in values:
prompt_ids = {comp.identifier for comp in values['prompts']}
invalid_ids = set(v) - prompt_ids
if invalid_ids:
raise ValueError(f"prompt_order中包含不存在的组件ID: {invalid_ids}")
return v
@classmethod
def get_preset_dir(cls) -> Path:
"""获取预设目录路径"""
try:
from backend.core.config import settings
preset_dir = settings.DATA_PATH / "preset"
# 如果路径不存在,尝试使用相对路径
if not preset_dir.exists():
# 尝试从当前工作目录构建路径
cwd_preset_dir = Path.cwd() / "data" / "preset"
if cwd_preset_dir.exists():
return cwd_preset_dir
# 尝试从脚本所在目录构建路径
script_dir = Path(__file__).resolve().parent.parent.parent
script_preset_dir = script_dir / "data" / "preset"
if script_preset_dir.exists():
return script_preset_dir
# 如果都不存在,返回默认路径
return Path("data/preset")
return preset_dir
except ImportError:
# 如果无法导入settings尝试使用相对路径
cwd_preset_dir = Path.cwd() / "data" / "preset"
if cwd_preset_dir.exists():
return cwd_preset_dir
# 尝试从脚本所在目录构建路径
script_dir = Path(__file__).resolve().parent.parent.parent
script_preset_dir = script_dir / "data" / "preset"
if script_preset_dir.exists():
return script_preset_dir
# 如果都不存在,返回默认路径
return Path("data/preset")
@classmethod
async def list_all_presets(cls) -> Dict[str, List[Dict]]:
"""获取所有预设列表及其基本信息"""
preset_dir = cls.get_preset_dir()
if not preset_dir.exists():
return {"presets": []}
presets = []
for preset_file in preset_dir.glob("*.json"):
try:
with open(preset_file, 'r', encoding='utf-8') as f:
preset_data = json.load(f)
presets.append({
"name": preset_file.stem,
"description": preset_data.get("description", ""),
"component_count": len(preset_data.get("prompts", [])),
"temperature": preset_data.get("temperature", 1.0)
})
except Exception:
continue # 跳过损坏的预设文件
return {"presets": presets}
@classmethod
async def get_preset(cls, preset_name: str) -> Dict[str, Any]:
"""获取指定预设的完整内容"""
preset_path = cls.get_preset_dir() / f"{preset_name}.json"
if not preset_path.exists():
raise FileNotFoundError(f"Preset not found: {preset_name}")
try:
with open(preset_path, 'r', encoding='utf-8') as f:
preset_data = json.load(f)
# 处理prompt_order简化为单角色配置
if 'prompt_order' in preset_data and isinstance(preset_data['prompt_order'], list) and len(
preset_data['prompt_order']) > 0:
# 检查第一个元素是否为字典(多角色配置)
first_item = preset_data['prompt_order'][0]
if isinstance(first_item, dict) and 'order' in first_item:
# 提取第一个角色的order配置
first_role_order = first_item
if isinstance(first_role_order['order'], list):
# 简化为只包含enabled为True的identifier列表
simplified_order = [
item.get('identifier')
for item in first_role_order['order']
if item.get('enabled', True)
]
preset_data['prompt_order'] = simplified_order
# 转换为AIDesignSpec对象进行验证
ai_design_spec = cls.from_dict(preset_data)
# 构建返回数据,确保格式与前端期望的一致
result = {
# 基础参数
"temperature": ai_design_spec.temperature,
"frequency_penalty": ai_design_spec.frequency_penalty,
"presence_penalty": ai_design_spec.presence_penalty,
"top_p": ai_design_spec.top_p,
"top_k": ai_design_spec.top_k,
"max_context": ai_design_spec.max_context,
"max_tokens": ai_design_spec.max_tokens,
"max_context_unlocked": ai_design_spec.max_context_unlocked,
"stream_openai": ai_design_spec.stream,
"seed": ai_design_spec.seed,
"n": ai_design_spec.n,
# 兼容旧格式
"openai_max_context": ai_design_spec.max_context,
"openai_max_tokens": ai_design_spec.max_tokens,
# 其他参数
"top_a": ai_design_spec.top_a,
"min_p": ai_design_spec.min_p,
"repetition_penalty": ai_design_spec.repetition_penalty,
"names_behavior": ai_design_spec.names_behavior,
"send_if_empty": ai_design_spec.send_if_empty,
"impersonation_prompt": ai_design_spec.impersonation_prompt,
"new_chat_prompt": ai_design_spec.new_chat_prompt,
"new_group_chat_prompt": ai_design_spec.new_group_chat_prompt,
"new_example_chat_prompt": ai_design_spec.new_example_chat_prompt,
"continue_nudge_prompt": ai_design_spec.continue_nudge_prompt,
"bias_preset_selected": ai_design_spec.bias_preset_selected,
"wi_format": ai_design_spec.wi_format,
"scenario_format": ai_design_spec.scenario_format,
"personality_format": ai_design_spec.personality_format,
"group_nudge_prompt": ai_design_spec.group_nudge_prompt,
"assistant_prefill": ai_design_spec.assistant_prefill,
"assistant_impersonation": ai_design_spec.assistant_impersonation,
"use_sysprompt": ai_design_spec.use_sysprompt,
"squash_system_messages": ai_design_spec.squash_system_messages,
"media_inlining": ai_design_spec.media_inlining,
"continue_prefill": ai_design_spec.continue_prefill,
"continue_postfix": ai_design_spec.continue_postfix,
# 处理组件
"prompts": []
}
# 处理组件列表
if ai_design_spec.prompts:
# 获取当前角色的prompt_order简化后的字符串列表
current_order = ai_design_spec.prompt_order if ai_design_spec.prompt_order else []
# 构建组件列表
for prompt in ai_design_spec.prompts:
# 检查组件是否在order中
is_in_order = prompt.identifier in current_order
# 构建组件对象
component = {
"identifier": prompt.identifier,
"name": prompt.name,
"content": prompt.content if hasattr(prompt, 'content') else "",
"role": prompt.role if hasattr(prompt, 'role') else (0 if prompt.system_prompt else 1),
"system_prompt": prompt.system_prompt,
"marker": prompt.marker,
"enabled": is_in_order if current_order else True
}
result["prompts"].append(component)
# 按照order排序组件
if current_order:
result["prompts"].sort(
key=lambda x: current_order.index(x["identifier"]) if x[
"identifier"] in current_order else len(
current_order))
# 添加prompt_order
result["prompt_order"] = ai_design_spec.prompt_order if ai_design_spec.prompt_order else []
return result
except Exception as e:
raise Exception(f"Failed to load preset: {str(e)}")
@classmethod
async def create_preset(cls, preset_name: str, preset_data: Dict) -> Dict[str, str]:
"""创建新预设"""
preset_dir = cls.get_preset_dir()
preset_dir.mkdir(parents=True, exist_ok=True)
preset_path = preset_dir / f"{preset_name}.json"
if preset_path.exists():
raise FileExistsError(f"Preset already exists: {preset_name}")
try:
# 验证并转换为AIDesignSpec对象
ai_design_spec = cls.from_dict(preset_data)
# 保存到文件
with open(preset_path, 'w', encoding='utf-8') as f:
json.dump(ai_design_spec.dict(), f, ensure_ascii=False, indent=2)
return {"message": "Preset created successfully", "name": preset_name}
except Exception as e:
raise Exception(f"Failed to create preset: {str(e)}")
@classmethod
async def update_preset(cls, preset_name: str, update_data: Dict) -> Dict[str, str]:
"""更新预设配置"""
preset_path = cls.get_preset_dir() / f"{preset_name}.json"
if not preset_path.exists():
raise FileNotFoundError(f"Preset not found: {preset_name}")
try:
# 加载现有预设
with open(preset_path, 'r', encoding='utf-8') as f:
preset_data = json.load(f)
# 更新字段
for key, value in update_data.items():
preset_data[key] = value
# 验证并转换为AIDesignSpec对象
ai_design_spec = cls.from_dict(preset_data)
# 保存更新
with open(preset_path, 'w', encoding='utf-8') as f:
json.dump(ai_design_spec.dict(), f, ensure_ascii=False, indent=2)
return {"message": "Preset updated successfully"}
except Exception as e:
raise Exception(f"Failed to update preset: {str(e)}")
@classmethod
async def delete_preset(cls, preset_name: str) -> Dict[str, str]:
"""删除指定预设"""
preset_path = cls.get_preset_dir() / f"{preset_name}.json"
if not preset_path.exists():
raise FileNotFoundError(f"Preset not found: {preset_name}")
try:
preset_path.unlink()
return {"message": "Preset deleted successfully"}
except Exception as e:
raise Exception(f"Failed to delete preset: {str(e)}")
@classmethod
async def list_components(cls, preset_name: str) -> Dict[str, List[Dict]]:
"""获取预设中的所有组件"""
preset_path = cls.get_preset_dir() / f"{preset_name}.json"
if not preset_path.exists():
raise FileNotFoundError(f"Preset not found: {preset_name}")
try:
with open(preset_path, 'r', encoding='utf-8') as f:
preset_data = json.load(f)
# 获取组件列表
components = preset_data.get("prompts", [])
return {"components": components}
except Exception as e:
raise Exception(f"Failed to load components: {str(e)}")
@classmethod
async def get_component(cls, preset_name: str, component_id: str) -> Dict[str, Any]:
"""获取指定组件的详情"""
preset_path = cls.get_preset_dir() / f"{preset_name}.json"
if not preset_path.exists():
raise FileNotFoundError(f"Preset not found: {preset_name}")
try:
with open(preset_path, 'r', encoding='utf-8') as f:
preset_data = json.load(f)
# 查找组件
components = preset_data.get("prompts", [])
component = next((c for c in components if c.get("identifier") == component_id), None)
if not component:
raise FileNotFoundError(f"Component not found: {component_id}")
return component
except FileNotFoundError:
raise
except Exception as e:
raise Exception(f"Failed to load component: {str(e)}")
@classmethod
async def add_component_to_preset(cls, preset_name: str, component_data: Dict) -> Dict[str, str]:
"""向预设添加新组件"""
preset_path = cls.get_preset_dir() / f"{preset_name}.json"
if not preset_path.exists():
raise FileNotFoundError(f"Preset not found: {preset_name}")
try:
# 加载预设数据
with open(preset_path, 'r', encoding='utf-8') as f:
preset_data = json.load(f)
# 验证组件数据
component = PromptComponent(**component_data)
# 检查组件ID是否已存在
components = preset_data.get("prompts", [])
if any(c.get("identifier") == component.identifier for c in components):
raise ValueError(f"Component identifier already exists: {component.identifier}")
# 添加组件
components.append(component.dict())
preset_data["prompts"] = components
# 保存更新
with open(preset_path, 'w', encoding='utf-8') as f:
json.dump(preset_data, f, ensure_ascii=False, indent=2)
return {"message": "Component added successfully", "identifier": component.identifier}
except (FileNotFoundError, ValueError):
raise
except Exception as e:
raise Exception(f"Failed to add component: {str(e)}")
@classmethod
async def update_component_in_preset(cls, preset_name: str, component_id: str, update_data: Dict) -> Dict[str, str]:
"""更新指定组件"""
preset_path = cls.get_preset_dir() / f"{preset_name}.json"
if not preset_path.exists():
raise FileNotFoundError(f"Preset not found: {preset_name}")
try:
# 加载预设数据
with open(preset_path, 'r', encoding='utf-8') as f:
preset_data = json.load(f)
# 查找并更新组件
components = preset_data.get("prompts", [])
component_index = next((i for i, c in enumerate(components) if c.get("identifier") == component_id), None)
if component_index is None:
raise FileNotFoundError(f"Component not found: {component_id}")
# 更新组件字段
for key, value in update_data.items():
components[component_index][key] = value
# 保存更新
with open(preset_path, 'w', encoding='utf-8') as f:
json.dump(preset_data, f, ensure_ascii=False, indent=2)
return {"message": "Component updated successfully"}
except FileNotFoundError:
raise
except Exception as e:
raise Exception(f"Failed to update component: {str(e)}")
@classmethod
async def delete_component_from_preset(cls, preset_name: str, component_id: str) -> Dict[str, str]:
"""从预设中删除指定组件"""
preset_path = cls.get_preset_dir() / f"{preset_name}.json"
if not preset_path.exists():
raise FileNotFoundError(f"Preset not found: {preset_name}")
try:
# 加载预设数据
with open(preset_path, 'r', encoding='utf-8') as f:
preset_data = json.load(f)
# 查找并删除组件
components = preset_data.get("prompts", [])
original_length = len(components)
components = [c for c in components if c.get("identifier") != component_id]
if len(components) == original_length:
raise FileNotFoundError(f"Component not found: {component_id}")
# 更新预设数据
preset_data["prompts"] = components
# 保存更新
with open(preset_path, 'w', encoding='utf-8') as f:
json.dump(preset_data, f, ensure_ascii=False, indent=2)
return {"message": "Component deleted successfully"}
except FileNotFoundError:
raise
except Exception as e:
raise Exception(f"Failed to delete component: {str(e)}")
# ========== 组件管理方法 ==========
def add_component(self, component: PromptComponent) -> None:
"""
添加新组件
参数:
component: 要添加的组件
异常:
ValueError: 当组件标识符已存在时抛出
"""
if any(c.identifier == component.identifier for c in self.prompts):
raise ValueError(f"组件标识符 {component.identifier} 已存在")
self.prompts.append(component)
def remove_component(self, identifier: str) -> bool:
"""
移除指定组件
参数:
identifier: 组件标识符
返回:
bool: 是否成功移除
"""
original_length = len(self.prompts)
self.prompts = [c for c in self.prompts if c.identifier != identifier]
# 同时从prompt_order中移除
self.prompt_order = [id for id in self.prompt_order if id != identifier]
return len(self.prompts) < original_length
def get_component(self, identifier: str) -> Optional[PromptComponent]:
"""
获取指定组件
参数:
identifier: 组件标识符
返回:
Optional[PromptComponent]: 找到的组件未找到返回None
"""
for component in self.prompts:
if component.identifier == identifier:
return component
return None
def update_component(self, identifier: str, **kwargs) -> bool:
"""
更新指定组件
参数:
identifier: 组件标识符
**kwargs: 要更新的字段
返回:
bool: 是否成功更新
"""
component = self.get_component(identifier)
if component is None:
return False
component.update(**kwargs)
return True
def list_components(self) -> List[Dict[str, Any]]:
"""
列出所有组件
返回:
List[Dict[str, Any]]: 组件字典列表
"""
return [component.to_dict() for component in self.prompts]
def reorder_components(self, new_order: List[str]) -> None:
"""
重新排序组件
参数:
new_order: 新的组件标识符顺序
异常:
ValueError: 当包含不存在的组件ID时抛出
"""
# 验证所有ID都存在
existing_ids = {c.identifier for c in self.prompts}
invalid_ids = set(new_order) - existing_ids
if invalid_ids:
raise ValueError(f"包含不存在的组件ID: {invalid_ids}")
self.prompt_order = new_order
def get_ordered_components(self) -> List[PromptComponent]:
"""
获取按prompt_order排序的组件列表
返回:
List[PromptComponent]: 排序后的组件列表
"""
component_map = {c.identifier: c for c in self.prompts}
ordered_components = []
for identifier in self.prompt_order:
if identifier in component_map:
ordered_components.append(component_map[identifier])
# 添加未在prompt_order中的组件
ordered_components.extend([
c for c in self.prompts
if c.identifier not in self.prompt_order
])
return ordered_components
def to_dict(self) -> Dict[str, Any]:
"""
将设计规范转换为字典
返回:
Dict[str, Any]: 设计规范的字典表示
"""
return self.dict()
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'AIDesignSpec':
"""
从字典创建设计规范实例
参数:
data: 包含设计规范数据的字典
返回:
AIDesignSpec: 设计规范实例
"""
# 处理prompts字段
if 'prompts' in data:
data['prompts'] = [
PromptComponent.from_dict(comp) if isinstance(comp, dict) else comp
for comp in data['prompts']
]
return cls(**data)

View File

@@ -1,438 +0,0 @@
import json
import os
import logging
from typing import Dict, List, Optional, Any
from pathlib import Path
from pydantic import BaseModel, Field, field_validator
from .WorldItem import WorldItem, TriggerStrategy
from backend.core.config import settings
# 配置日志
logger = logging.getLogger(__name__)
class WorldBook(BaseModel):
"""
世界书集合模型
管理多个世界书条目,支持导入导出 SillyTavern 格式
"""
# 世界书基本信息
name: str = Field(..., description="世界书名称")
# 条目集合
entries: Dict[str, WorldItem.Entry] = Field(
default_factory=dict,
description="世界书条目字典对象 (Key-Value Map)"
)
@field_validator('entries')
@classmethod
def validate_entries_unique_uid(cls, v):
"""验证条目 UID 的唯一性"""
uids = [entry.uid for entry in v.values()]
if len(uids) != len(set(uids)):
logger.error("验证失败: 条目 UID 必须唯一")
raise ValueError("条目 UID 必须唯一")
return v
@classmethod
def get_file_path(cls, name: str) -> str:
"""
根据世界书名称获取文件路径
Args:
name: 世界书名称
Returns:
str: 完整的文件路径
"""
# 使用配置中的 WORLDBOOKS_PATH
return str(settings.WORLDBOOKS_PATH / f"{name}.json")
@classmethod
def exists(cls, name: str) -> bool:
"""
检查指定名称的世界书文件是否存在
Args:
name: 世界书名称
Returns:
bool: 文件是否存在
"""
file_path = cls.get_file_path(name)
return os.path.exists(file_path)
@classmethod
def create_empty(cls, name: str) -> 'WorldBook':
"""
创建并保存一个空白的世界书
Args:
name: 世界书名称
Returns:
WorldBook: 创建的世界书对象
Raises:
ValueError: 世界书已存在
IOError: 文件写入失败
"""
# 检查世界书是否已存在
if cls.exists(name):
raise ValueError(f"世界书 '{name}' 已存在")
# 创建空白世界书对象
world_book = cls(
name=name,
)
# 保存世界书
world_book.save()
logger.info(f"创建空白世界书: {name}")
return world_book
def add_entry(self, entry: WorldItem.Entry) -> None:
"""
添加世界书条目
Args:
entry: 世界书条目对象
Raises:
ValueError: 条目 UID 已存在
"""
entry_key = str(entry.uid)
if entry_key in self.entries:
error_msg = f"添加条目失败: 条目 UID {entry.uid} 已存在于世界书 {self.name}"
logger.error(error_msg)
raise ValueError(error_msg)
self.entries[entry_key] = entry
logger.debug(f"已添加条目: UID={entry.uid}, 世界书={self.name}")
def remove_entry(self, uid: int) -> bool:
"""
移除世界书条目
Args:
uid: 条目 UID
Returns:
bool: 是否成功移除
"""
entry_key = str(uid)
if entry_key in self.entries:
del self.entries[entry_key]
logger.info(f"已从世界书 {self.name} 移除条目: UID={uid}")
return True
logger.warning(f"尝试移除不存在的条目: 世界书 {self.name} 中未找到 UID={uid}")
return False
def get_entry(self, uid: int) -> Optional[WorldItem.Entry]:
"""
获取指定 UID 的世界书条目
Args:
uid: 条目 UID
Returns:
Optional[WorldItem.Entry]: 找到的条目,未找到返回 None
"""
entry_key = str(uid)
entry = self.entries.get(entry_key)
if entry:
logger.debug(f"从世界书 {self.name} 获取条目: UID={uid}")
else:
logger.debug(f"在世界书 {self.name} 中未找到条目: UID={uid}")
return entry
def update_entry(self, uid: int, **kwargs) -> bool:
"""
更新世界书条目
Args:
uid: 条目 UID
**kwargs: 要更新的字段
Returns:
bool: 是否成功更新
"""
entry = self.get_entry(uid)
if entry is None:
logger.warning(f"更新条目失败: 在世界书 {self.name} 中未找到 UID={uid}")
return False
for key, value in kwargs.items():
if hasattr(entry, key):
setattr(entry, key, value)
logger.info(f"已更新世界书 {self.name} 中的条目: UID={uid}, 更新字段={list(kwargs.keys())}")
return True
def filter_by_position(self, position: int) -> List[WorldItem.Entry]:
"""
根据位置筛选条目
Args:
position: 位置值
Returns:
List[WorldItem.Entry]: 筛选后的条目列表
"""
filtered_entries = [
entry for entry in self.entries.values()
if entry.position == position
]
logger.debug(
f"在世界书 {self.name} 中按位置筛选: 值={position}, 结果数量={len(filtered_entries)}")
return filtered_entries
def get_summary(self) -> Dict[str, Any]:
"""
获取世界书概要信息
Returns:
Dict[str, Any]: 概要信息字典
"""
summary = {
"name": self.name,
"entry_count": len(self.entries),
"trigger_strategies": {
strategy.value: sum(1 for e in self.entries.values()
if strategy in e.trigger_config.get_enabled_triggers())
for strategy in TriggerStrategy
}
}
logger.debug(f"获取世界书 {self.name} 的概要信息")
return summary
def to_summary_dict(self) -> Dict[str, Any]:
"""
生成世界书摘要信息,用于列表显示
Returns:
Dict[str, Any]: 包含基本信息的字典
"""
summary = {
"name": self.name,
"entry_count": len(self.entries),
}
logger.debug(f"生成世界书 {self.name} 的摘要信息")
return summary
def to_dict(self) -> Dict:
"""
将 WorldBook 转换为字典
Returns:
Dict: 世界书数据字典
"""
result = {
'name': self.name,
'entries': {uid: entry.model_dump() for uid, entry in self.entries.items()}
}
logger.debug(f"将世界书 {self.name} 转换为字典")
return result
@classmethod
def load(cls, name: str) -> 'WorldBook':
"""
从文件加载世界书(只有 entries 字段的格式)
Args:
name: 世界书名称
Returns:
WorldBook: 世界书对象
Raises:
FileNotFoundError: 文件不存在
ValueError: 格式不符合标准
json.JSONDecodeError: JSON 解析错误
"""
file_path = cls.get_file_path(name)
if not os.path.exists(file_path):
error_msg = f"世界书文件未找到: {file_path}"
logger.error(error_msg)
raise FileNotFoundError(error_msg)
try:
with open(file_path, 'r', encoding='utf-8') as f:
raw_data = json.load(f)
# 世界书名称始终使用文件名(不包括后缀名)
world_name = name
# 直接使用 entries 字段
entries_dict = raw_data.get("entries", {})
if not isinstance(entries_dict, dict):
error_msg = "无效的世界书格式:'entries' 字段必须是一个字典。"
logger.error(error_msg)
raise ValueError(error_msg)
# 创建世界书对象
world_book = cls(
name=world_name,
)
# 转换标准格式的条目
for uid, entry_data in entries_dict.items():
try:
# 先使用 WorldItem 解析数据
world_item = WorldItem.from_sillytavern_data(entry_data)
# 然后转换为 Entry
world_entry = world_item.to_entry()
world_book.add_entry(world_entry)
except Exception as e:
logger.warning(f"跳过条目 {uid},解析失败: {e}")
logger.info(
f"从文件加载世界书: 文件={file_path}, 名称={world_name}, 条目数={len(world_book.entries)}")
return world_book
except json.JSONDecodeError as e:
error_msg = f"JSON 解析错误: {str(e)}"
logger.error(error_msg)
raise ValueError(error_msg)
except Exception as e:
error_msg = f"从文件加载世界书失败: {str(e)}"
logger.error(error_msg)
raise ValueError(error_msg)
def save(self) -> None:
"""
保存世界书到文件(只有 entries 字段的格式)
如果文件不存在,会创建新文件;如果文件存在,会更新现有文件
Raises:
IOError: 文件写入失败
"""
file_path = self.get_file_path(self.name)
# 确保目录存在
os.makedirs(Path(file_path).parent, exist_ok=True)
try:
# 转换为标准格式
entries_dict = {}
for uid, entry in self.entries.items():
entries_dict[uid] = entry.to_sillytavern_dict()
output_data = {
"entries": entries_dict
}
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(output_data, f, ensure_ascii=False, indent=2)
logger.info(
f"世界书已保存: 文件={file_path}, 名称={self.name}, 条目数={len(self.entries)}")
except Exception as e:
error_msg = f"保存世界书失败: {str(e)}"
logger.error(error_msg)
raise IOError(error_msg)
def list_triggers_and_content(self) -> List[Dict[str, Any]]:
"""
提取所有条目的触发关键词和内容,用于快速构建向量数据库或索引
Returns:
List[Dict[str, Any]]: 包含 trigger (key) 和 content 的列表
"""
result = []
for entry in self.entries.values():
entry_dict = entry.to_dict()
# 添加额外的触发相关信息
enabled_triggers = entry.trigger_config.get_enabled_triggers()
keyword_enabled, keyword_config = entry.trigger_config.get_trigger(TriggerStrategy.KEYWORD)
constant_enabled, _ = entry.trigger_config.get_trigger(TriggerStrategy.CONSTANT)
entry_dict.update({
"triggers": keyword_config.key if keyword_enabled and keyword_config else [],
"constant": constant_enabled,
"trigger_strategies": [strategy.value for strategy in enabled_triggers]
})
result.append(entry_dict)
logger.debug(f"列出世界书 {self.name} 的触发词和内容: 条目数={len(result)}")
return result
def get_all_entries(self) -> List[Dict[str, Any]]:
"""
获取所有条目的核心信息(包括已禁用的条目)
Returns:
List[Dict[str, Any]]: 包含核心信息的条目列表
"""
result = [entry.to_dict() for entry in self.entries.values()]
logger.debug(f"获取世界书 {self.name} 的所有条目: 条目数={len(result)}")
return result
def merge_from_book(self, other_book: 'WorldBook') -> None:
"""
从另一个世界书合并条目
Args:
other_book: 要合并的世界书对象
"""
for uid, entry in other_book.entries.items():
if uid in self.entries:
# 更新现有条目
for key, value in entry.dict().items():
if key != 'uid': # 不更新 UID
setattr(self.entries[uid], key, value)
else:
# 添加新条目
self.add_entry(entry)
logger.info(f"合并世界书: 从 {other_book.name} 合并到 {self.name}")
def to_sillytavern_json(self, file_path: str) -> None:
"""
导出为 SillyTavern 格式的 JSON 文件
Args:
file_path: 导出文件路径
"""
# 转换为 SillyTavern 格式
entries_dict = {}
for uid, entry in self.entries.items():
entries_dict[uid] = entry.to_sillytavern_dict()
output_data = {
"entries": entries_dict,
"name": self.name
}
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(output_data, f, ensure_ascii=False, indent=2)
logger.info(f"导出世界书为 SillyTavern 格式: 文件={file_path}")
# --- 使用示例 ---
if __name__ == "__main__":
try:
# 创建空白世界书
world_book = WorldBook.create_empty("test_worldbook")
# 加载世界书
world_book = WorldBook.load("test_worldbook")
# 打印概要
summary = world_book.get_summary()
print(f"世界书名称: {summary['name']}")
print(f"条目数量: {summary['entry_count']}")
print(f"触发策略分布: {summary['trigger_strategies']}")
# 列出所有条目的触发词和内容预览
print("\n--- 条目预览 ---")
for item in world_book.list_triggers_and_content():
triggers = item['triggers'] if item['triggers'] else ['(无关键词 - 常驻)']
content_preview = item['content'][:50].replace('\n', ' ') + "..."
print(f"[{item['position']}] TRIGGERS: {triggers} -> CONTENT: {content_preview}")
# 保存世界书
world_book.save()
print(f"\n✅ 世界书已保存")
except Exception as e:
print(f"❌ 错误: {e}")

View File

@@ -1,826 +0,0 @@
import logging
from enum import Enum
from typing import List, Optional, Dict, Any, Union
from pydantic import BaseModel, Field, field_validator, ConfigDict
# 配置日志
logger = logging.getLogger(__name__)
class WorldInfoPosition(Enum):
"""
SillyTavern 世界书条目插入位置枚举
注意枚举值的顺序0-4并不完全代表物理顺序
以下是按照 Prompt 从上到下的真实物理顺序排列的:
"""
# --- 1. 顶部区域 ---
# (System Prompt 在这里,不可插入)
# --- 2. 核心指令区 (Position 4 实际上在这里) ---
SYSTEM_PROMPT = 4
"""
物理位置:紧跟在系统提示词之后,角色定义之前。
语境:最高优先级的规则。
用途作者注释、核心系统规则。AI 在读人设前就会先读到这个。
"""
# --- 3. 角色人设区 (Position 0 实际上在这里) ---
# (Character Definition 在这里)
CHAR_AFTER = 0
"""
物理位置:紧跟在角色定义之后。
语境:角色固有属性。
用途:性格、外貌、长期设定。
"""
# --- 4. 示例对话区 ---
EXAMPLE_BEFORE = 2
"""
物理位置:在示例对话块之前。
"""
EXAMPLE_AFTER = 3
"""
物理位置:在示例对话块之后。
"""
# --- 5. 底部区域 ---
# (Chat History 在这里)
# (User Input 在这里 - 最新输入)
# --- 6. 动态深度区 (Depth / d0-d99) ---
# 这是你强调的"第 6 个插入区"
# 它不是一个固定的物理点,而是一个动态区域
DEPTH_HISTORY = 4
"""
物理位置:
- d0: 在 [用户最新输入] 之前,[AI 回复] 之前。
- d0~d99: 在 [Chat History] 内部,倒数第 N 条消息之前。
语境:
- d0: 即时状态("现在正在发生")。
- d1+: 历史背景("当时就在那里")。
用途:
这是最灵活的插入区,利用 Depth 字段来精确控制条目在对话流中的位置。
"""
@classmethod
def get_description(cls, position: int) -> str:
"""
获取位置描述
Args:
position: 位置值
Returns:
str: 位置描述
"""
position_map = {
0: "角色定义之后",
1: "角色定义之后 (最常用)",
2: "示例对话之前",
3: "示例对话之后",
4: "系统提示 / 作者注释 (底部) 或 历史记录深度插入"
}
return position_map.get(position, "未知位置")
@classmethod
def is_depth_position(cls, position: int) -> bool:
"""
判断是否为深度插入位置
Args:
position: 位置值
Returns:
bool: 是否为深度插入位置
"""
return position == cls.DEPTH_HISTORY.value
class TriggerStrategy(str, Enum):
"""
触发策略枚举
"""
CONSTANT = "constant" # 永久触发
KEYWORD = "keyword" # 关键词匹配触发
RAG = "rag" # 向量检索触发
CONDITION = "condition" # 逻辑条件触发
class RAGTriggerConfig(BaseModel):
"""
RAG触发配置
"""
threshold: float = Field(0.75, description="RAG 相似度阈值")
top_k: int = Field(5, description="返回的匹配条目数")
query_template: Optional[str] = Field(None, description="检索用的查询模板")
class KeywordTriggerConfig(BaseModel):
"""
关键词触发配置
"""
key: List[str] = Field(default_factory=list, description="主关键词数组")
keysecondary: List[str] = Field(default_factory=list, description="次要关键词数组")
selective: bool = Field(True, description="是否开启选择性匹配")
selectiveLogic: int = Field(0, description="逻辑模式 (0=OR, 1=AND)")
matchWholeWords: bool = Field(False, description="是否全词匹配")
caseSensitive: bool = Field(False, description="是否区分大小写")
class ConditionTriggerConfig(BaseModel):
"""
条件触发配置
"""
variable_a: str = Field(..., description="变量a")
operator: str = Field(..., description="运算符 (>, <, =, >=, <=, !=)")
variable_b: str = Field(..., description="变量b")
class TriggerConfig(BaseModel):
"""
触发配置
使用字典结构,键为触发策略,值为[是否启用, 对应配置]的列表
"""
triggers: Dict[TriggerStrategy, List[
Union[bool, Optional[Union[KeywordTriggerConfig, RAGTriggerConfig, ConditionTriggerConfig]]]]] = Field(
default_factory=lambda: {
TriggerStrategy.CONSTANT: [True, None],
TriggerStrategy.KEYWORD: [False, None],
TriggerStrategy.RAG: [False, None],
TriggerStrategy.CONDITION: [False, None]
},
description="触发配置字典,键为触发策略,值为[是否启用, 对应配置]"
)
model_config = ConfigDict(extra='forbid')
def set_trigger(self, strategy: TriggerStrategy, enabled: bool,
config: Optional[Union[KeywordTriggerConfig, RAGTriggerConfig, ConditionTriggerConfig]] = None
):
"""
设置触发策略
Args:
strategy: 触发策略
enabled: 是否启用
config: 对应的配置对象
"""
self.triggers[strategy] = [enabled, config]
def get_trigger(self, strategy: TriggerStrategy) -> List[
Union[bool, Optional[Union[KeywordTriggerConfig, RAGTriggerConfig, ConditionTriggerConfig]]]]:
"""
获取触发策略
Args:
strategy: 触发策略
Returns:
List: [是否启用, 对应配置]
"""
return self.triggers.get(strategy, [False, None])
def get_enabled_triggers(self) -> List[TriggerStrategy]:
"""
获取所有启用的触发策略
Returns:
List[TriggerStrategy]: 启用的触发策略列表
"""
return [strategy for strategy, (enabled, _) in self.triggers.items() if enabled]
class WorldItem(BaseModel):
"""
世界书条目完整模型
包含所有 SillyTavern 世界书条目属性,用于导入导出
"""
class Entry(BaseModel):
"""
世界书条目模型
精简版,只包含必要字段,用于实际使用
"""
# 基础定义
uid: int = Field(..., description="唯一标识符")
content: str = Field(..., description="注入到 Prompt 的实际文本内容")
comment: str = Field("", description="条目名、备注")
# 注入与排序
position: int = Field(0,
description="插入位置 (0=角色定义之前, 1=角色定义之后, 2=示例对话之前, 3=示例对话之后, 4=系统提示/作者注释)")
order: int = Field(100, description="注入顺序权重,数字越小优先级越高")
depth: int = Field(4, description="扫描深度0为最深/最高4为标准")
# 触发配置
trigger_config: Optional[TriggerConfig] = Field(
default_factory=TriggerConfig,
description="触发配置,为空表示无需触发配置"
)
# 角色匹配
role: int = Field(0, description="角色匹配 (0=Both, 1=User, 2=Assistant)")
# 条目启用状态
enabled: bool = Field(True, description="条目是否启用启用才会被插入到LLM")
@field_validator('position')
@classmethod
def validate_position(cls, v):
"""验证 position 值是否在有效范围内"""
if v not in [0, 1, 2, 3, 4]:
logger.warning(f"无效的 position 值: {v},将使用默认值 1")
return 1
return v
def to_dict(self) -> Dict[str, Any]:
"""
转换为字典
Returns:
Dict[str, Any]: 字典数据
"""
return self.dict()
def get_trigger_params(self) -> Dict[str, Any]:
"""
获取触发策略所需的参数
Returns:
Dict[str, Any]: 触发参数字典
"""
params = {}
try:
# 获取所有启用的触发策略
enabled_triggers = self.trigger_config.get_enabled_triggers()
# 处理 RAG 触发
if TriggerStrategy.RAG in enabled_triggers:
_, rag_config = self.trigger_config.get_trigger(TriggerStrategy.RAG)
if rag_config:
params["threshold"] = rag_config.threshold
params["top_k"] = rag_config.top_k
params["query_template"] = rag_config.query_template
params["vectorized"] = True
# 处理关键词触发
if TriggerStrategy.KEYWORD in enabled_triggers:
_, keyword_config = self.trigger_config.get_trigger(TriggerStrategy.KEYWORD)
if keyword_config:
params["key"] = keyword_config.key
params["keysecondary"] = keyword_config.keysecondary
params["selective"] = keyword_config.selective
params["selectiveLogic"] = keyword_config.selectiveLogic
params["matchWholeWords"] = keyword_config.matchWholeWords
params["caseSensitive"] = keyword_config.caseSensitive
# 处理条件触发
if TriggerStrategy.CONDITION in enabled_triggers:
_, condition_config = self.trigger_config.get_trigger(TriggerStrategy.CONDITION)
if condition_config:
params["variable_a"] = condition_config.variable_a
params["operator"] = condition_config.operator
params["variable_b"] = condition_config.variable_b
except Exception as e:
# 如果获取触发参数失败,返回空字典,表示使用默认的永久触发
logger.warning(f"条目 {self.uid} 的触发参数获取失败: {str(e)},使用默认的永久触发")
return params
# 基础定义
uid: int = Field(..., description="唯一标识符")
content: str = Field(..., description="注入到 Prompt 的实际文本内容")
comment: str = Field("", description="条目名、备注")
# 注入与排序
position: int = Field(0,
description="插入位置 (0=角色定义之前, 1=角色定义之后, 2=示例对话之前, 3=示例对话之后, 4=系统提示/作者注释)")
order: int = Field(100, description="注入顺序权重,数字越小优先级越高")
depth: int = Field(4, description="扫描深度0为最深/最高4为标准")
# 触发配置
trigger_config: TriggerConfig = Field(
default_factory=TriggerConfig,
description="触发配置"
)
# 角色匹配
role: int = Field(0, description="角色匹配 (0=Both, 1=User, 2=Assistant)")
# 条目启用状态
enabled: bool = Field(True, description="条目是否启用启用才会被插入到LLM")
# 触发相关属性
vectorized: bool = Field(False, description="是否使用向量检索RAG触发")
selective: bool = Field(True, description="是否开启选择性匹配(关键词触发)")
selectiveLogic: int = Field(0, description="逻辑模式 (0=OR, 1=AND)")
constant: bool = Field(False, description="是否永久触发")
# 关键词相关
key: List[str] = Field(default_factory=list, description="主关键词数组")
keysecondary: List[str] = Field(default_factory=list, description="次要关键词数组")
matchWholeWords: Optional[bool] = Field(None, description="是否全词匹配")
caseSensitive: Optional[bool] = Field(None, description="是否区分大小写")
# RAG相关
rag_threshold: Optional[float] = Field(None, description="RAG 相似度阈值")
top_k: Optional[int] = Field(None, description="返回的匹配条目数")
query_template: Optional[str] = Field(None, description="检索用的查询模板")
# 条目控制
addMemo: bool = Field(True, description="是否添加备忘")
disable: bool = Field(False, description="是否禁用")
ignoreBudget: bool = Field(False, description="是否忽略预算")
excludeRecursion: bool = Field(True, description="是否排除递归")
preventRecursion: bool = Field(True, description="是否阻止递归")
matchPersonaDescription: bool = Field(False, description="是否匹配人设描述")
matchCharacterDescription: bool = Field(False, description="是否匹配角色描述")
matchCharacterPersonality: bool = Field(False, description="是否匹配角色性格")
matchCharacterDepthPrompt: bool = Field(False, description="是否匹配深度提示")
matchScenario: bool = Field(False, description="是否匹配场景")
matchCreatorNotes: bool = Field(False, description="是否匹配作者笔记")
delayUntilRecursion: bool = Field(False, description="是否延迟递归")
# 概率相关
probability: int = Field(100, description="触发概率 (0-100)")
useProbability: bool = Field(True, description="是否使用概率")
# 分组相关
group: str = Field("", description="分组名称")
groupOverride: bool = Field(False, description="是否覆盖分组")
groupWeight: int = Field(100, description="分组权重")
useGroupScoring: bool = Field(False, description="是否使用分组评分")
# 其他属性
scanDepth: Optional[int] = Field(None, description="扫描深度")
automationId: str = Field("", description="自动化ID")
sticky: int = Field(0, description="粘性")
cooldown: int = Field(0, description="冷却时间(秒)")
delay: int = Field(0, description="延迟时间(秒)")
displayIndex: int = Field(0, description="显示索引")
# 角色过滤器
characterFilter: Dict[str, Any] = Field(
default_factory=lambda: {"isExclude": False, "names": [], "tags": []},
description="角色过滤器"
)
# 验证器
@field_validator('position')
@classmethod
def validate_position(cls, v):
"""验证 position 值是否在有效范围内"""
if v not in [0, 1, 2, 3, 4]:
logger.warning(f"无效的 position 值: {v},将使用默认值 1")
return 1
return v
@field_validator('role')
@classmethod
def validate_role(cls, v):
"""验证 role 值是否在有效范围内"""
if v not in [0, 1, 2]:
logger.warning(f"无效的 role 值: {v},将使用默认值 2")
return 2
return v
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'WorldItem':
"""
从字典创建 WorldItem 对象
Args:
data: 字典数据
Returns:
WorldItem: WorldItem 对象
"""
return cls(**data)
def to_dict(self) -> Dict[str, Any]:
"""
转换为字典
Returns:
Dict[str, Any]: 字典数据
"""
return self.dict()
def to_entry(self) -> Entry:
"""
转换为 Entry 对象
Returns:
Entry: Entry 对象
"""
# 转换为 SillyTavern 格式的字典
sillytavern_dict = self.to_sillytavern_dict()
# 创建 Entry 对象
return self.Entry(**sillytavern_dict)
def to_sillytavern_dict(self) -> Dict[str, Any]:
"""
转换为 SillyTavern 格式的字典
Returns:
Dict[str, Any]: SillyTavern 格式的条目数据
"""
result = {
"uid": self.uid,
"content": self.content,
"comment": self.comment,
"position": self.position,
"order": self.order,
"depth": self.depth,
"role": self.role,
"enabled": self.enabled,
"vectorized": self.vectorized,
"selective": self.selective,
"selectiveLogic": self.selectiveLogic,
"constant": self.constant,
"key": self.key,
"keysecondary": self.keysecondary,
"matchWholeWords": self.matchWholeWords,
"caseSensitive": self.caseSensitive,
"addMemo": self.addMemo,
"disable": self.disable,
"ignoreBudget": self.ignoreBudget,
"excludeRecursion": self.excludeRecursion,
"preventRecursion": self.preventRecursion,
"matchPersonaDescription": self.matchPersonaDescription,
"matchCharacterDescription": self.matchCharacterDescription,
"matchCharacterPersonality": self.matchCharacterPersonality,
"matchCharacterDepthPrompt": self.matchCharacterDepthPrompt,
"matchScenario": self.matchScenario,
"matchCreatorNotes": self.matchCreatorNotes,
"delayUntilRecursion": self.delayUntilRecursion,
"probability": self.probability,
"useProbability": self.useProbability,
"group": self.group,
"groupOverride": self.groupOverride,
"groupWeight": self.groupWeight,
"scanDepth": self.scanDepth,
"automationId": self.automationId,
"sticky": self.sticky,
"cooldown": self.cooldown,
"delay": self.delay,
"displayIndex": self.displayIndex,
"characterFilter": self.characterFilter
}
# 添加 RAG 相关字段
if self.vectorized:
result["rag_threshold"] = self.rag_threshold
result["top_k"] = self.top_k
result["query_template"] = self.query_template
# 添加条件触发相关字段
if TriggerStrategy.CONDITION in self.trigger_config.get_enabled_triggers():
condition_config = self.trigger_config.get_trigger(TriggerStrategy.CONDITION)[1]
if condition_config:
result["variable_a"] = condition_config.variable_a
result["operator"] = condition_config.operator
result["variable_b"] = condition_config.variable_b
return result
@classmethod
def from_sillytavern_data(cls, data: Dict[str, Any]) -> 'WorldItem':
"""
从 SillyTavern 格式的数据创建 WorldItem 对象
Args:
data: SillyTavern 格式的条目数据
Returns:
WorldItem: WorldItem 对象
"""
constant = data.get("constant", False)
if isinstance(constant, str):
constant = constant.lower() in ('true', '1', 'yes')
enabled = data.get("enabled", True)
if isinstance(enabled, str):
enabled = enabled.lower() in ('true', '1', 'yes')
try:
# 提取必要字段
uid = int(data.get("uid", data.get("id", 0)))
content = data.get("content", "")
comment = data.get("comment", "")
position = data.get("position", 0)
order = data.get("order", 100)
depth = data.get("depth", 4)
role = data.get("role", 0)
enabled = data.get("enabled", True)
# 处理 position 字段,确保为整数类型
if isinstance(position, str):
try:
position = int(position)
except ValueError:
logger.warning(f"条目 {uid} 的 position 字段值 '{position}' 无法转换为整数,使用默认值 0")
position = 0
# 初始化触发配置
trigger_config = TriggerConfig()
# 读取触发相关字段,并进行类型转换
vectorized = data.get("vectorized", False)
if isinstance(vectorized, str):
vectorized = vectorized.lower() in ('true', '1', 'yes')
selective = data.get("selective", True)
if isinstance(selective, str):
selective = selective.lower() in ('true', '1', 'yes')
constant = data.get("constant", False)
if isinstance(constant, str):
constant = constant.lower() in ('true', '1', 'yes')
# 初始化变量,确保它们始终有值
key = []
keysecondary = []
selectiveLogic = 0
matchWholeWords = False
caseSensitive = False
# 判断触发策略并设置对应的触发配置
# 优先级vectorized > constant > selective
if vectorized:
# RAG 触发
rag_config = RAGTriggerConfig(
threshold=float(data.get("rag_threshold", 0.75)),
top_k=int(data.get("top_k", 5)),
query_template=data.get("query_template", None)
)
trigger_config.set_trigger(TriggerStrategy.RAG, True, rag_config)
elif constant:
# 永久触发
trigger_config.set_trigger(TriggerStrategy.CONSTANT, True)
elif selective:
# 关键词触发
key = data.get("key", [])
keysecondary = data.get("keysecondary", data.get("secondary_keys", []))
selectiveLogic = int(data.get("selectiveLogic", 0))
# 处理 matchWholeWords 字段
matchWholeWords = data.get("matchWholeWords", False)
if matchWholeWords is None:
matchWholeWords = False
elif isinstance(matchWholeWords, str):
matchWholeWords = matchWholeWords.lower() in ('true', '1', 'yes')
# 处理 caseSensitive 字段
caseSensitive = data.get("caseSensitive", False)
if caseSensitive is None:
caseSensitive = False
elif isinstance(caseSensitive, str):
caseSensitive = caseSensitive.lower() in ('true', '1', 'yes')
keyword_config = KeywordTriggerConfig(
key=key,
keysecondary=keysecondary,
selective=selective,
selectiveLogic=selectiveLogic,
matchWholeWords=matchWholeWords,
caseSensitive=caseSensitive
)
trigger_config.set_trigger(TriggerStrategy.KEYWORD, True, keyword_config)
else:
# 默认使用永久触发
trigger_config.set_trigger(TriggerStrategy.CONSTANT, True)
# 检查是否有条件触发(虽然 JSON 中没有对应字段,但需要保留兼容性)
if "variable_a" in data and "operator" in data and "variable_b" in data:
condition_config = ConditionTriggerConfig(
variable_a=data.get("variable_a", ""),
operator=data.get("operator", "="),
variable_b=data.get("variable_b", "")
)
trigger_config.set_trigger(TriggerStrategy.CONDITION, True, condition_config)
# 创建 WorldItem 对象
return cls(
uid=uid,
content=content,
comment=comment,
position=position,
order=order,
depth=depth,
trigger_config=trigger_config,
role=role,
enabled=enabled,
vectorized=vectorized,
selective=selective,
selectiveLogic=selectiveLogic,
constant=constant,
key=key,
keysecondary=keysecondary,
matchWholeWords=matchWholeWords,
caseSensitive=caseSensitive,
rag_threshold=float(data.get("rag_threshold", None)) if vectorized else None,
top_k=int(data.get("top_k", None)) if vectorized else None,
query_template=data.get("query_template", None),
addMemo=data.get("addMemo", True),
disable=data.get("disable", False),
ignoreBudget=data.get("ignoreBudget", False),
excludeRecursion=data.get("excludeRecursion", True),
preventRecursion=data.get("preventRecursion", True),
matchPersonaDescription=data.get("matchPersonaDescription", False),
matchCharacterDescription=data.get("matchCharacterDescription", False),
matchCharacterPersonality=data.get("matchCharacterPersonality", False),
matchCharacterDepthPrompt=data.get("matchCharacterDepthPrompt", False),
matchScenario=data.get("matchScenario", False),
matchCreatorNotes=data.get("matchCreatorNotes", False),
delayUntilRecursion=data.get("delayUntilRecursion", False),
probability=data.get("probability", 100),
useProbability=data.get("useProbability", True),
group=data.get("group", ""),
groupOverride=data.get("groupOverride", False),
groupWeight=data.get("groupWeight", 100),
useGroupScoring=data.get("useGroupScoring", False),
scanDepth=data.get("scanDepth", None),
automationId=data.get("automationId", ""),
sticky=data.get("sticky", 0),
cooldown=data.get("cooldown", 0),
delay=data.get("delay", 0),
displayIndex=data.get("displayIndex", 0),
characterFilter=data.get("characterFilter", {"isExclude": False, "names": [], "tags": []})
)
except Exception as e:
logger.error(f"从 SillyTavern 数据创建 WorldItem 失败: {str(e)}")
raise ValueError(f"从 SillyTavern 数据创建 WorldItem 失败: {str(e)}")
if __name__ == "__main__":
"""
测试入口:用于调试 WorldItem 的解析和转换功能
可以像断点调试一样查看内部执行过程
"""
import json
import sys
from pathlib import Path
# 测试用例1基本条目
test_data_1 = {
"uid": 0,
"content": "测试内容",
"comment": "测试条目",
"position": 0,
"order": 100,
"depth": 4,
"role": 0,
"vectorized": False,
"selective": True,
"selectiveLogic": 0,
"constant": False,
"key": ["测试关键词"],
"keysecondary": [],
"matchWholeWords": False,
"caseSensitive": False,
"addMemo": True,
"disable": False,
"ignoreBudget": False,
"excludeRecursion": True,
"preventRecursion": True
}
# 测试用例2RAG触发
test_data_2 = {
"uid": 1,
"content": "RAG测试内容",
"comment": "RAG测试条目",
"position": 4,
"order": 50,
"depth": 0,
"role": 0,
"vectorized": True,
"rag_threshold": 0.8,
"top_k": 10,
"query_template": "测试模板"
}
# 测试用例3条件触发
test_data_3 = {
"uid": 2,
"content": "条件触发测试",
"comment": "条件触发条目",
"position": 1,
"order": 75,
"depth": 2,
"role": 0,
"variable_a": "好感度",
"operator": ">",
"variable_b": "50"
}
print("=" * 60)
print("开始测试 WorldItem 解析功能")
print("=" * 60)
try:
# 测试1解析基本条目
print("\n【测试1】解析基本条目...")
item1 = WorldItem.from_sillytavern_data(test_data_1)
print(f"✓ 解析成功: {item1.comment}")
print(f" - UID: {item1.uid}")
print(f" - Position: {item1.position}")
print(f" - 触发策略和配置:")
for strategy in item1.trigger_config.get_enabled_triggers():
enabled, config = item1.trigger_config.get_trigger(strategy)
print(f" * {strategy.value}: 启用={enabled}, 配置={config}")
# 测试2解析RAG触发条目
print("\n【测试2】解析RAG触发条目...")
item2 = WorldItem.from_sillytavern_data(test_data_2)
print(f"✓ 解析成功: {item2.comment}")
print(f" - UID: {item2.uid}")
print(f" - Position: {item2.position}")
print(f" - 触发策略和配置:")
for strategy in item2.trigger_config.get_enabled_triggers():
enabled, config = item2.trigger_config.get_trigger(strategy)
print(f" * {strategy.value}: 启用={enabled}, 配置={config}")
if item2.rag_threshold:
print(f" - RAG阈值: {item2.rag_threshold}")
# 测试3解析条件触发条目
print("\n【测试3】解析条件触发条目...")
item3 = WorldItem.from_sillytavern_data(test_data_3)
print(f"✓ 解析成功: {item3.comment}")
print(f" - UID: {item3.uid}")
print(f" - Position: {item3.position}")
print(f" - 触发策略和配置:")
for strategy in item3.trigger_config.get_enabled_triggers():
enabled, config = item3.trigger_config.get_trigger(strategy)
print(f" * {strategy.value}: 启用={enabled}, 配置={config}")
# 测试4从文件读取实际数据
print("\n【测试4】从实际JSON文件读取...")
# 从当前文件位置向上查找项目根目录
current_file = Path(__file__).resolve()
project_root = current_file
while project_root.name != "llm_workflow_engine" and project_root.parent != project_root:
project_root = project_root.parent
# 构建正确的文件路径
json_path = project_root / "data" / "worldbooks" / "卡立创-v5.json"
print(f"查找文件路径: {json_path}")
if json_path.exists():
with open(json_path, 'r', encoding='utf-8') as f:
worldbook_data = json.load(f)
entries = worldbook_data.get('entries', {})
print(f"找到 {len(entries)} 个条目")
# 只测试前3个条目
for uid, entry_data in list(entries.items())[:3]:
try:
item = WorldItem.from_sillytavern_data(entry_data)
print(f"\n✓ 条目 {uid} 解析成功:")
print(f" - 备注: {item.comment}")
print(f" - UID: {item.uid}")
print(f" - Position: {item.position}")
print(f" - 触发策略和配置:")
for strategy in item.trigger_config.get_enabled_triggers():
enabled, config = item.trigger_config.get_trigger(strategy)
print(f" * {strategy.value}: 启用={enabled}, 配置={config}")
except Exception as e:
print(f"\n✗ 条目 {uid} 解析失败: {str(e)}")
import traceback
traceback.print_exc()
else:
print(f"⚠ 文件不存在: {json_path}")
print(f"请确认文件路径是否正确")
# 列出可能的文件位置
possible_paths = [
project_root / "data" / "worldbooks",
project_root / "backend" / "data" / "worldbooks",
current_file.parent.parent.parent / "data" / "worldbooks"
]
print("\n可能的文件位置:")
for path in possible_paths:
if path.exists():
print(f"{path}")
for file in path.glob("*.json"):
print(f" - {file.name}")
print("\n" + "=" * 60)
print("所有测试完成!")
print("=" * 60)
except Exception as e:
print(f"\n✗ 测试失败: {str(e)}")
import traceback
traceback.print_exc()
sys.exit(1)

View File

@@ -1,443 +0,0 @@
from typing import List, Dict, Any, Optional
from datetime import datetime
from pathlib import Path
from pydantic import BaseModel, Field
import json
class Message(BaseModel):
"""消息类代表JSONL文件中的一行消息内容"""
name: str = Field(..., description="发送者名称")
is_user: bool = Field(..., description="是否为用户消息")
is_system: bool = Field(False, description="是否为系统消息")
send_date: str = Field(
default_factory=lambda: str(int(datetime.now().timestamp() * 1000)),
description="消息发送时间戳"
)
floor: int = Field(0, description="对话楼层数")
swipes: List[str] = Field(
default_factory=list,
description="历史版本列表。用户消息存编辑过的不同版本。AI消息存重roll生成的不同版本"
)
swipe_id: int = Field(
0,
description="当前指针。指示当前显示的是 swipes 数组中的第几个(从 0 开始)"
)
mes: str = Field(..., description="消息内容文本")
extra: Dict[str, Any] = Field(
default_factory=dict,
description="额外信息包含推理内容、API、模型等"
)
force_avatar: Optional[str] = Field(None, description="强制头像URL")
variables: List[Any] = Field(default_factory=list, description="消息变量列表")
variables_initialized: List[bool] = Field(default_factory=list, description="变量初始化状态数组")
is_ejs_processed: List[bool] = Field(default_factory=list, description="EJS处理状态数组")
# 以下属性仅在is_user为False时有值
api: Optional[str] = Field(None, description="使用的API提供商")
model: Optional[str] = Field(None, description="使用的AI模型")
reasoning: Optional[str] = Field(None, description="推理内容")
reasoning_duration: Optional[float] = Field(None, description="推理耗时")
reasoning_signature: Optional[str] = Field(None, description="推理签名")
time_to_first_token: Optional[float] = Field(None, description="首Token响应时间")
bias: Optional[float] = Field(None, description="偏差值")
class ChatMetadata(BaseModel):
"""聊天元数据类,包含整个聊天的共享属性"""
user_name: str = Field("User", description="用户名称")
character_name: str = Field("Assistant", description="角色名称")
# 完整性校验相关
integrity: str = Field("", description="完整性校验值")
chat_id_hash: str = Field("", description="聊天ID哈希值")
# 笔记相关
note_prompt: str = Field("", description="作者笔记提示词")
note_interval: int = Field(0, description="笔记插入间隔数")
note_position: int = Field(0, description="笔记插入位置")
note_depth: int = Field(0, description="笔记插入深度")
# 0System1User2Assistant
note_role: int = Field("", description="笔记使用角色类型")
# 扩展信息
extensions: Dict[str, Any] = Field(
default_factory=dict,
description="扩展信息如LittleWhiteBox等"
)
# 世界信息
timedWorldInfo: Dict[str, Any] = Field(
default_factory=dict,
description="定时世界信息"
)
# 变量
variables: Dict[str, Any] = Field(
default_factory=dict,
description="变量字典"
)
# 状态标记
tainted: bool = Field(False, description="是否被修改标记")
lastInContextMessageId: int = Field(-1, description="最后上下文消息ID")
class ChatHistory(BaseModel):
"""聊天文件类,包含完整的聊天记录"""
chat_metadata: ChatMetadata = Field(..., description="聊天元数据,包含基本信息和配置")
messages: List[Message] = Field(default_factory=list, description="消息列表")
class Config:
arbitrary_types_allowed = True
@classmethod
def get_data_path(cls) -> Path:
"""获取数据目录路径"""
try:
from backend.core.config import settings
return settings.DATA_PATH / "chat"
except ImportError:
return Path("data")
@classmethod
async def list_all_chats(cls) -> Dict[str, List[Dict]]:
"""获取所有角色的所有聊天列表"""
data_dir = cls.get_data_path()
if not data_dir.exists():
return {"chat": []}
chats = []
for role_dir in data_dir.iterdir():
if role_dir.is_dir():
for chat_file in role_dir.glob("*.jsonl"):
try:
with open(chat_file, 'r', encoding='utf-8') as f:
# 读取第一行获取元数据
first_line = f.readline()
metadata = json.loads(first_line)
chats.append({
"role_name": role_dir.name,
"chat_name": chat_file.stem,
"user_name": metadata.get("user_name", "User"),
"character_name": metadata.get("character_name", "Assistant"),
"last_modified": metadata.get("last_modified", ""),
"message_count": sum(1 for _ in f) # 统计剩余行数(消息数)
})
except Exception:
continue # 跳过损坏的聊天文件
return {"chat": chats}
@classmethod
async def get_chat(cls, role_name: str, chat_name: str) -> Dict[str, Any]:
"""获取指定聊天的完整内容"""
chat_history = cls.load_from_file(role_name, chat_name)
return {
"metadata": chat_history.chat_metadata.dict(),
"messages": chat_history.to_chatbox_format()
}
@classmethod
async def create_chat(cls, role_name: str, chat_name: str, metadata: Optional[Dict] = None) -> Dict[str, str]:
"""创建新聊天"""
base_path = cls.get_data_path()
role_dir = base_path / role_name
role_dir.mkdir(parents=True, exist_ok=True)
chat_path = role_dir / f"{chat_name}.jsonl"
if chat_path.exists():
raise FileExistsError(f"Chat already exists: {chat_path}")
# 创建聊天历史对象
chat_history = cls(
chat_metadata=ChatMetadata(**(metadata or {})),
messages=[]
)
# 保存到文件
chat_history.save_to_file(role_name, chat_name, base_path)
return {"message": "Chat created successfully"}
@classmethod
async def update_chat(cls, role_name: str, chat_name: str, update_data: Dict) -> Dict[str, str]:
"""更新聊天元数据"""
chat_history = cls.load_from_file(role_name, chat_name)
# 更新元数据
if "metadata" in update_data:
for key, value in update_data["metadata"].items():
if hasattr(chat_history.chat_metadata, key):
setattr(chat_history.chat_metadata, key, value)
# 保存更改
base_path = cls.get_data_path()
chat_history.save_to_file(role_name, chat_name, base_path)
return {"message": "Chat metadata updated successfully"}
@classmethod
async def delete_chat(cls, role_name: str, chat_name: str) -> Dict[str, str]:
"""删除指定聊天"""
base_path = cls.get_data_path()
chat_path = base_path / role_name / f"{chat_name}.jsonl"
if not chat_path.exists():
raise FileNotFoundError(f"Chat not found: {chat_path}")
chat_path.unlink()
return {"message": "Chat deleted successfully"}
@classmethod
async def list_messages(cls, role_name: str, chat_name: str) -> Dict[str, List[Dict]]:
"""获取聊天的所有消息"""
chat_history = cls.load_from_file(role_name, chat_name)
return {"messages": chat_history.to_chatbox_format()}
@classmethod
async def get_message(cls, role_name: str, chat_name: str, floor: int) -> Dict[str, Any]:
"""获取指定楼层的消息"""
chat_history = cls.load_from_file(role_name, chat_name)
message = next((msg for msg in chat_history.messages if msg.floor == floor), None)
if not message:
raise FileNotFoundError(f"Message not found: floor {floor}")
return message.dict()
@classmethod
async def add_message(cls, role_name: str, chat_name: str, message_data: Dict) -> Dict[str, Any]:
"""向聊天添加新消息"""
chat_history = cls.load_from_file(role_name, chat_name)
# 创建消息对象
message = Message(**message_data)
# 检查楼层是否已存在
if any(msg.floor == message.floor for msg in chat_history.messages):
raise ValueError(f"Message floor already exists: {message.floor}")
# 添加消息
chat_history.messages.append(message)
# 保存更改
base_path = cls.get_data_path()
chat_history.save_to_file(role_name, chat_name, base_path)
return {"message": "Message added successfully", "floor": message.floor}
@classmethod
async def update_message(cls, role_name: str, chat_name: str, floor: int, update_data: Dict) -> Dict[str, str]:
"""更新指定楼层的消息"""
chat_history = cls.load_from_file(role_name, chat_name)
message = next((msg for msg in chat_history.messages if msg.floor == floor), None)
if not message:
raise FileNotFoundError(f"Message not found: floor {floor}")
# 更新消息字段
for key, value in update_data.items():
if hasattr(message, key):
setattr(message, key, value)
# 保存更改
base_path = cls.get_data_path()
chat_history.save_to_file(role_name, chat_name, base_path)
return {"message": "Message updated successfully"}
@classmethod
async def delete_message(cls, role_name: str, chat_name: str, floor: int) -> Dict[str, str]:
"""删除指定楼层的消息"""
chat_history = cls.load_from_file(role_name, chat_name)
# 查找并删除消息
original_length = len(chat_history.messages)
chat_history.messages = [msg for msg in chat_history.messages if msg.floor != floor]
if len(chat_history.messages) == original_length:
raise FileNotFoundError(f"Message not found: floor {floor}")
# 保存更改
base_path = cls.get_data_path()
chat_history.save_to_file(role_name, chat_name, base_path)
return {"message": "Message deleted successfully"}
@classmethod
def load_from_file(cls, role_name: str, chat_name: str, base_path: Path = None) -> 'ChatHistory':
"""
从JSONL文件加载聊天历史
参数:
role_name: 角色名称(文件夹名)
chat_name: 聊天名称(文件名,不含扩展名)
base_path: 基础路径默认为配置中的DATA_PATH/chat
返回:
ChatHistory: 加载的聊天历史对象
异常:
FileNotFoundError: 当文件不存在时抛出
json.JSONDecodeError: 当JSON解析失败时抛出
"""
# 设置默认基础路径
if base_path is None:
base_path = cls.get_data_path()
# 构建文件路径
file_path = base_path / role_name / f"{chat_name}.jsonl"
# 检查文件是否存在
if not file_path.exists():
raise FileNotFoundError(f"聊天文件不存在: {file_path}")
# 初始化结果数据
messages = []
metadata = None
# 读取文件内容
with open(file_path, 'r', encoding='utf-8') as f:
for line_num, line in enumerate(f):
try:
line_data = json.loads(line.strip())
# 第一行是元数据
if line_num == 0:
metadata = ChatMetadata(**line_data)
else:
# 后续行是消息
messages.append(Message(**line_data))
except json.JSONDecodeError:
continue
# 创建并返回ChatHistory对象
return cls(
chat_metadata=metadata or ChatMetadata(),
messages=messages
)
@classmethod
def load_from_jsonl(cls, file_path: Path) -> 'ChatHistory':
"""
从JSONL文件加载聊天历史
参数:
file_path: JSONL文件路径
返回:
ChatHistory: 加载的聊天历史对象
异常:
FileNotFoundError: 当文件不存在时抛出
json.JSONDecodeError: 当JSON解析失败时抛出
"""
# 检查文件是否存在
if not file_path.exists():
raise FileNotFoundError(f"聊天文件不存在: {file_path}")
# 初始化结果数据
messages = []
metadata = None
# 读取文件内容
with open(file_path, 'r', encoding='utf-8') as f:
for line_num, line in enumerate(f):
try:
line_data = json.loads(line.strip())
# 第一行是元数据
if line_num == 0:
# 处理元数据中的嵌套结构
if 'chat_metadata' in line_data:
metadata_dict = line_data['chat_metadata']
# 合并顶层字段和chat_metadata中的字段
metadata_dict.update(line_data)
metadata = ChatMetadata(**metadata_dict)
else:
metadata = ChatMetadata(**line_data)
else:
# 后续行是消息
# 处理extra字段中的内容
extra_data = line_data.get('extra', {})
# 如果是AI消息(is_user=False)将extra中的某些字段提升到顶层
if not line_data.get('is_user', True):
ai_fields = ['api', 'model', 'reasoning', 'reasoning_duration',
'reasoning_signature', 'time_to_first_token', 'bias']
for field in ai_fields:
if field in extra_data:
line_data[field] = extra_data.pop(field)
# 创建Message实例
message = Message(**line_data)
# 将剩余的extra数据保存回extra字段
message.extra = extra_data
messages.append(message)
except json.JSONDecodeError:
continue
# 创建并返回ChatHistory对象
return cls(
chat_metadata=metadata or ChatMetadata(),
messages=messages
)
def to_chatbox_format(self) -> List[Dict[str, Any]]:
"""
将聊天历史转换为适合前端chatbox显示的格式
返回:
List[Dict[str, Any]]: 按floor排序的消息字典列表每个字典包含:
{
"name": str,
"is_user": bool,
"floor": int,
"mes": str,
"swipes": List[str],
"swipe_id": int
}
"""
# 创建消息字典列表
messages_list = []
for msg in self.messages:
# 获取当前消息内容优先从swipes数组中获取如果不存在则使用mes
current_mes = msg.mes
if msg.swipes and 0 <= msg.swipe_id < len(msg.swipes):
current_mes = msg.swipes[msg.swipe_id]
msg_dict = {
"name": msg.name,
"is_user": msg.is_user,
"floor": msg.floor,
"mes": current_mes,
"swipes": msg.swipes,
"swipe_id": msg.swipe_id
}
messages_list.append(msg_dict)
# 按floor排序
messages_list.sort(key=lambda x: x["floor"])
return messages_list
def save_to_file(self, role_name: str, chat_name: str, base_path: Path = None) -> None:
"""
将聊天历史保存到JSONL文件
参数:
role_name: 角色名称(文件夹名)
chat_name: 聊天名称(文件名,不含扩展名)
base_path: 基础路径默认为data/chat
"""
# 设置默认基础路径
if base_path is None:
base_path = self.get_data_path()
# 构建文件路径
file_path = base_path / role_name / f"{chat_name}.jsonl"
# 确保目录存在
file_path.parent.mkdir(parents=True, exist_ok=True)
# 写入文件
with open(file_path, 'w', encoding='utf-8') as f:
# 写入元数据
f.write(json.dumps(self.chat_metadata.dict(), ensure_ascii=False) + '\n')
# 写入消息
for message in self.messages:
f.write(json.dumps(message.dict(), ensure_ascii=False) + '\n')

View File

@@ -17,12 +17,35 @@ for logger_name in ['uvicorn', 'uvicorn.access', 'fastapi']:
# backend/app/main.py # backend/app/main.py
from fastapi import FastAPI from fastapi import FastAPI
from .api.route import router from fastapi.middleware.cors import CORSMiddleware
try:
from backend.api.route import router
except ImportError:
from api.route import router
app = FastAPI(title="LLM Workflow Engine") app = FastAPI(title="LLM Workflow Engine")
# 配置CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 开发环境允许所有来源,生产环境应该指定具体域名
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 注册路由 # 注册路由
app.include_router(router, prefix="/api") app.include_router(router, prefix="/api")
# 添加健康检查端点
@app.get("/health")
async def health_check():
return {"status": "healthy"}
# 添加根路径
@app.get("/")
async def root():
return {"message": "LLM Workflow Engine", "status": "running"}
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000) uvicorn.run(app, host="0.0.0.0", port=8000)

231
backend/models/README.md Normal file
View File

@@ -0,0 +1,231 @@
# Backend Models 数据模型说明
## 目录结构
```
models/
├── __init__.py # 包初始化,导出所有模型
├── sillytavern.py # SillyTavern 兼容模型 (仅用于导入/导出)
├── internal.py # 内部业务模型 (项目核心使用)
└── README.md # 本文件
```
## 模型分类
### 1. SillyTavern 兼容模型 (`sillytavern.py`)
**用途**: 仅用于与 SillyTavern 格式的数据进行导入/导出兼容
**特点**:
- 严格遵循 SillyTavern 官方规范
- 不参与内部业务逻辑
- 所有字段名、结构与 SillyTavern 保持一致
- 前缀 `ST` 表示 SillyTavern
**主要模型**:
- `STWorldInfo` - SillyTavern 世界书
- `STCharacterCard` - SillyTavern 角色卡
- `STChatHeader` / `STChatMessage` - SillyTavern 聊天记录
- `STGenerationPreset` - SillyTavern 采样预设
- `STPromptPreset` - SillyTavern 提示词预设
**使用场景**:
```python
# 从 SillyTavern 导入时
st_data = json.load(file)
st_character = STCharacterCard(**st_data)
# 转换为内部模型
internal_character = converter.st_to_internal(st_character)
# 导出到 SillyTavern 时
st_data = converter.internal_to_st(internal_character)
json.dump(st_data.dict(), file)
```
### 2. 内部业务模型 (`internal.py`)
**用途**: 项目内部真正使用的数据结构,所有业务逻辑都基于这些模型
**特点**:
- 继承并扩展了 SillyTavern 的功能
- 添加了项目特色功能 (如 LOGIC 激活、RAG 配置、outputSchema 等)
- 所有 API 响应、数据存储、工作流交换都使用这些模型
- 无前缀,直接使用语义化名称
**主要模型**:
#### 世界书相关
- `ActivationType` - 激活方式枚举 (PERMANENT/KEYWORD/RAG/LOGIC)
- `LogicExpression` - 逻辑表达式
- `RAGConfig` - RAG 检索配置
- `WorldInfoEntry` - 世界书条目
- `WorldInfo` - 世界书
#### 角色卡相关
- `OutputSchemaField` - 结构化输出 schema
- `CharacterCard` - 角色卡
#### 聊天记录相关
- `ChatHeader` - 聊天头
- `ChatMessage` - 聊天消息
- `ChatLog` - 完整聊天记录
#### 预设相关
- `GenerationPreset` - 采样参数预设
- `PromptRole` - Prompt 角色枚举
- `PromptEntry` - Prompt 条目
- `PromptPresetView` - Prompt 预设视图
#### RAG 配置
- `RAGSearchConfig` - RAG 搜索配置
- `CharacterRAGConfig` - 角色卡 RAG 配置
- `ChatRAGConfig` - 聊天 RAG 配置
**使用场景**:
```python
# 业务逻辑中直接使用
from models import CharacterCard, WorldInfo
character = CharacterCard(
id="uuid-123",
name="Alice",
description="...",
...
)
# API 响应
@app.get("/characters/{id}")
async def get_character(id: str):
character = service.get_character(id)
return character # 返回 internal 模型
```
## 数据转换流程
```
SillyTavern 文件
↓ (导入)
STCharacterCard (sillytavern.py)
↓ (转换器)
CharacterCard (internal.py)
↓ (业务处理)
CharacterCard (internal.py)
↓ (转换器)
STCharacterCard (sillytavern.py)
↓ (导出)
SillyTavern 文件
```
## 开发规范
### ✅ 正确做法
1. **业务逻辑使用 internal 模型**
```python
from models import CharacterCard
def create_character(data: dict) -> CharacterCard:
return CharacterCard(**data)
```
2. **导入时使用转换器**
```python
from models import STCharacterCard, CharacterCard
from models.converters import CharacterConverter
def import_character(file_path: str) -> CharacterCard:
st_data = load_json(file_path)
st_char = STCharacterCard(**st_data)
return CharacterConverter.st_to_internal(st_char)
```
3. **API 响应使用 internal 模型**
```python
@app.get("/characters")
async def list_characters() -> List[CharacterCard]:
return service.list_characters()
```
### ❌ 错误做法
1. **不要在业务逻辑中直接使用 ST 模型**
```python
# 错误!
from models import STCharacterCard
def process_character(char: STCharacterCard):
...
```
2. **不要混合使用两种模型**
```python
# 错误!
character = CharacterCard(...)
character.name = st_character.data.name # 不要混用
```
3. **不要在 API 中暴露 ST 模型**
```python
# 错误!
@app.get("/characters")
async def list_characters() -> List[STCharacterCard]:
...
```
## 添加新模型
当需要添加新的数据类型时:
1. **判断用途**:
- 如果是为了 SillyTavern 兼容 → 添加到 `sillytavern.py`
- 如果是项目内部使用 → 添加到 `internal.py`
2. **遵循命名规范**:
- SillyTavern 模型: 前缀 `ST`
- 内部模型: 无前缀,使用清晰的语义化名称
3. **添加详细注释**:
```python
class MyModel(BaseModel):
"""
模型用途说明
详细描述该模型的作用、使用场景等
"""
field1: str = Field(..., description="字段说明")
```
4. **在 `__init__.py` 中导出**:
```python
from .internal import MyModel
__all__ = [
...,
'MyModel',
]
```
## 转换器 (待实现)
`models/converters.py` 将提供双向转换功能:
```python
class CharacterConverter:
@staticmethod
def st_to_internal(st_char: STCharacterCard) -> CharacterCard:
"""SillyTavern → Internal"""
...
@staticmethod
def internal_to_st(int_char: CharacterCard) -> STCharacterCard:
"""Internal → SillyTavern"""
...
```
## 总结
- **sillytavern.py** = 外部兼容层 (Import/Export Only)
- **internal.py** = 内部业务层 (Core Business Logic)
- **永远在业务逻辑中使用 internal 模型**
- **通过转换器进行格式转换**

View File

@@ -0,0 +1,61 @@
"""
数据模型包
导出项目内部真正使用的数据结构 (Internal Models)。
SillyTavern 兼容模型将在需要导入/导出时单独引用。
"""
# 内部业务模型 (项目核心使用)
from .internal import (
# 世界书
ActivationType,
LogicOperator,
LogicExpression,
RAGConfig,
WorldInfoEntry,
WorldInfo,
# 角色卡
OutputSchemaField,
CharacterCard,
# 聊天记录
ChatHeader,
ChatMessage,
ChatLog,
# 预设
GenerationPreset,
# 提示词预设
PromptRole,
PromptEntry,
PromptPresetView,
# RAG 配置
RAGSearchConfig,
CharacterRAGConfig,
ChatRAGConfig,
)
__all__ = [
# 内部模型
'ActivationType',
'LogicOperator',
'LogicExpression',
'RAGConfig',
'WorldInfoEntry',
'WorldInfo',
'OutputSchemaField',
'CharacterCard',
'ChatHeader',
'ChatMessage',
'ChatLog',
'GenerationPreset',
'PromptRole',
'PromptEntry',
'PromptPresetView',
'RAGSearchConfig',
'CharacterRAGConfig',
'ChatRAGConfig',
]

View File

@@ -0,0 +1,379 @@
"""
数据模型转换器
提供 SillyTavern 格式与内部格式之间的双向转换功能。
所有导入/导出操作都应该通过转换器进行,确保数据格式的一致性。
"""
import uuid
from typing import Dict, Any, List, Optional
from datetime import datetime
from models.internal import (
WorldInfo,
WorldInfoEntry,
ActivationType,
)
class WorldBookConverter:
"""世界书数据转换器
负责 SillyTavern 格式和项目内部格式之间的转换。
SillyTavern 格式特点:
- entries 是 dict (key 为 uid)
- 使用 constant 字段表示常驻激活
- position 是字符串 (如 "after_char")
项目内部格式特点:
- entries 是 list
- 使用 activationType 枚举
- position 是数字 (0-5)
- 包含 trigger_config 结构(前端需要)
"""
@staticmethod
def detect_format(data: Dict[str, Any]) -> str:
"""
智能检测世界书数据格式
Args:
data: 世界书数据
Returns:
'sillytavern' | 'internal' | 'unknown'
"""
# 检查 entries 类型
entries = data.get("entries")
if not entries:
return "unknown"
# SillyTavern 特征: entries 是 dict
if isinstance(entries, dict):
return "sillytavern"
# 内部格式特征: entries 是 list
if isinstance(entries, list):
# 进一步检查是否有 trigger_config
if len(entries) > 0 and isinstance(entries[0], dict):
first_entry = entries[0]
if "trigger_config" in first_entry:
return "internal"
# 也可能是简化的内部格式
if "activationType" in first_entry or "position" in first_entry:
return "internal"
return "unknown"
# 位置映射: SillyTavern 字符串 -> 内部数字
POSITION_MAP_ST_TO_INTERNAL = {
"after_char": 0,
"before_char": 1,
"before_example": 2,
"after_example": 3,
"author_note": 4,
"system_prompt": 5,
}
# 位置映射: 内部数字 -> SillyTavern 字符串
POSITION_MAP_INTERNAL_TO_ST = {
0: "after_char",
1: "before_char",
2: "before_example",
3: "after_example",
4: "author_note",
5: "system_prompt",
}
@staticmethod
def st_to_internal(st_data: Dict[str, Any], name: str = None) -> Dict[str, Any]:
"""
将 SillyTavern 格式的世界书转换为内部格式
Args:
st_data: SillyTavern 格式的世界书数据
name: 世界书名称(可选,优先使用 st_data 中的 name)
Returns:
内部格式的世界书字典(包含 trigger_config)
"""
now = int(datetime.now().timestamp())
# 转换条目
entries = []
st_entries = st_data.get("entries", {})
# SillyTavern 的 entries 可能是 dict 或 list
if isinstance(st_entries, dict):
entries_list = list(st_entries.values())
elif isinstance(st_entries, list):
entries_list = st_entries
else:
entries_list = []
for st_entry in entries_list:
if not isinstance(st_entry, dict):
continue
# 判断激活类型
is_constant = st_entry.get("constant", False)
activation_type = ActivationType.PERMANENT if is_constant else ActivationType.KEYWORD
# 转换位置
st_position = st_entry.get("position", "after_char")
internal_position = WorldBookConverter.POSITION_MAP_ST_TO_INTERNAL.get(st_position, 0)
# 构建 trigger_config (前端期望的格式)
trigger_config = WorldBookConverter._build_trigger_config(
is_constant=is_constant,
key=st_entry.get("key", []),
keysecondary=st_entry.get("keysecondary", []),
selective=st_entry.get("selective", True)
)
# 创建内部格式的条目
entry_dict = {
"uid": st_entry.get("uid", str(uuid.uuid4())),
"key": st_entry.get("key", []),
"keysecondary": st_entry.get("keysecondary", []),
"content": st_entry.get("content", ""),
"comment": st_entry.get("comment", ""),
"activationType": activation_type.value,
"trigger_config": trigger_config,
"order": st_entry.get("order", 100),
"position": internal_position,
"depth": st_entry.get("depth", 4),
"role": st_entry.get("role", 0),
"probability": st_entry.get("probability", 100),
"group": st_entry.get("group", []),
"disable": st_entry.get("disable", False),
"createdAt": now,
"updatedAt": now
}
entries.append(entry_dict)
# 创建内部格式的世界书
worldbook_data = {
"id": str(uuid.uuid4()),
"name": name or st_data.get("name", "Unnamed"),
"description": st_data.get("description", ""),
"entries": entries,
"createdAt": now,
"updatedAt": now,
"version": 1
}
return worldbook_data
@staticmethod
def internal_to_st(worldbook_data: Dict[str, Any]) -> Dict[str, Any]:
"""
将内部格式的世界书转换为 SillyTavern 格式
Args:
worldbook_data: 内部格式的世界书字典
Returns:
SillyTavern 格式的世界书数据
"""
# 转换条目
st_entries = {}
for entry_data in worldbook_data.get("entries", []):
if not isinstance(entry_data, dict):
continue
uid = entry_data.get("uid", str(uuid.uuid4()))
# 从 trigger_config 或 activationType 判断是否常驻
is_constant = WorldBookConverter._is_constant_entry(entry_data)
# 提取关键词
key, keysecondary = WorldBookConverter._extract_keywords(entry_data)
# 转换位置
internal_position = entry_data.get("position", 0)
st_position = WorldBookConverter.POSITION_MAP_INTERNAL_TO_ST.get(internal_position, "after_char")
# 创建 SillyTavern 格式的条目
st_entry = {
"uid": uid,
"key": key,
"keysecondary": keysecondary,
"content": entry_data.get("content", ""),
"comment": entry_data.get("comment", ""),
"constant": is_constant,
"selective": not is_constant,
"order": entry_data.get("order", 100),
"position": st_position,
"depth": entry_data.get("depth", 4),
"probability": entry_data.get("probability", 100),
"group": entry_data.get("group", []),
"disable": entry_data.get("disable", False)
}
st_entries[uid] = st_entry
# 创建 SillyTavern 格式的世界书
st_data = {
"name": worldbook_data.get("name", ""),
"description": worldbook_data.get("description", ""),
"entries": st_entries
}
return st_data
@staticmethod
def normalize_entry(entry_data: Dict[str, Any]) -> Dict[str, Any]:
"""
规范化条目数据,确保包含所有必需字段和 trigger_config
Args:
entry_data: 条目数据(可能来自不同来源)
Returns:
规范化后的条目数据
"""
now = int(datetime.now().timestamp())
# 如果已经有 trigger_config,直接返回
if "trigger_config" in entry_data and entry_data["trigger_config"]:
return entry_data
# 否则从其他字段构建 trigger_config
is_constant = WorldBookConverter._is_constant_entry(entry_data)
key, keysecondary = WorldBookConverter._extract_keywords(entry_data)
trigger_config = WorldBookConverter._build_trigger_config(
is_constant=is_constant,
key=key,
keysecondary=keysecondary,
selective=entry_data.get("selective", True)
)
# 添加缺失的字段
normalized = {
"uid": entry_data.get("uid", str(uuid.uuid4())),
"key": key,
"keysecondary": keysecondary,
"content": entry_data.get("content", ""),
"comment": entry_data.get("comment", ""),
"activationType": entry_data.get("activationType",
ActivationType.PERMANENT.value if is_constant
else ActivationType.KEYWORD.value),
"trigger_config": trigger_config,
"order": entry_data.get("order", 100),
"position": entry_data.get("position", 0),
"depth": entry_data.get("depth", 4),
"role": entry_data.get("role", 0),
"probability": entry_data.get("probability", 100),
"group": entry_data.get("group", []),
"disable": entry_data.get("disable", False),
"createdAt": entry_data.get("createdAt", now),
"updatedAt": entry_data.get("updatedAt", now)
}
return normalized
@staticmethod
def _build_trigger_config(
is_constant: bool,
key: List[str],
keysecondary: List[str],
selective: bool = True
) -> Dict[str, Any]:
"""
构建 trigger_config 结构
Args:
is_constant: 是否常驻激活
key: 主关键词列表
keysecondary: 次要关键词列表
selective: 是否选择性匹配
Returns:
trigger_config 字典
"""
return {
"triggers": {
"constant": [is_constant, None],
"keyword": [
not is_constant,
{
"key": key,
"keysecondary": keysecondary,
"selective": selective,
"selectiveLogic": 0,
"matchWholeWords": False,
"caseSensitive": False
}
],
"rag": [False, {
"threshold": 0.75,
"top_k": 5,
"query_template": None
}],
"condition": [False, {
"variable_a": "",
"operator": "=",
"variable_b": ""
}]
}
}
@staticmethod
def _is_constant_entry(entry_data: Dict[str, Any]) -> bool:
"""
判断条目是否为常驻激活
Args:
entry_data: 条目数据
Returns:
是否常驻激活
"""
# 优先从 trigger_config 判断
if "trigger_config" in entry_data and entry_data["trigger_config"]:
try:
return entry_data["trigger_config"]["triggers"]["constant"][0]
except (KeyError, IndexError, TypeError):
pass
# 其次从 activationType 判断
if "activationType" in entry_data:
return entry_data["activationType"] == ActivationType.PERMANENT.value
# 最后从 constant 字段判断
if "constant" in entry_data:
return entry_data["constant"]
return False
@staticmethod
def _extract_keywords(entry_data: Dict[str, Any]) -> tuple:
"""
从条目数据中提取关键词
Args:
entry_data: 条目数据
Returns:
(key, keysecondary) 元组
"""
# 优先从 trigger_config 提取
if "trigger_config" in entry_data and entry_data["trigger_config"]:
try:
keyword_config = entry_data["trigger_config"]["triggers"]["keyword"][1]
if keyword_config:
key = keyword_config.get("key", [])
keysecondary = keyword_config.get("keysecondary", [])
return key, keysecondary
except (KeyError, IndexError, TypeError):
pass
# 否则从顶层字段提取
key = entry_data.get("key", [])
keysecondary = entry_data.get("keysecondary", [])
return key, keysecondary

301
backend/models/internal.py Normal file
View File

@@ -0,0 +1,301 @@
"""
项目内部数据结构定义
这是本项目真正使用的核心数据模型,所有业务逻辑都基于这些类型。
与 sillytavern.py 不同,这里的模型不参与导入导出兼容,而是专注于:
- 内部业务逻辑处理
- API 响应数据结构
- 数据存储格式
- 工作流引擎数据交换
所有从 SillyTavern 导入的数据都会转换为这些内部模型进行处理,
导出时再从内部模型转换回 SillyTavern 格式。
"""
from enum import Enum
from typing import List, Optional, Dict, Any
from pydantic import BaseModel, Field
from datetime import datetime
# ==================== 世界书 (World Info) ====================
class ActivationType(str, Enum):
"""
自定义激活方式类型4种枚举
这是项目的核心创新点之一,相比 SillyTavern 的简单 constant/selective 标志,
我们提供了更灵活的激活机制。
"""
PERMANENT = 'permanent' # 永久激活 - 始终包含在上下文中
KEYWORD = 'keyword' # 关键词触发 - 匹配关键词时激活
RAG = 'rag' # RAG 检索激活 - 基于向量相似度检索
LOGIC = 'logic' # 逻辑表达式激活 - 基于变量条件判断
class LogicOperator(str, Enum):
"""逻辑运算符(用于 LOGIC 激活类型)"""
EQUALS = 'equals' # 等于
NOT_EQUALS = 'not_equals' # 不等于
CONTAINS = 'contains' # 包含
NOT_CONTAINS = 'not_contains' # 不包含
GREATER = 'greater' # 大于
LESS = 'less' # 小于
class LogicExpression(BaseModel):
"""
逻辑表达式结构(用于 LOGIC 激活类型)
示例: variable1="mood", operator="equals", variable2="happy"
表示当 mood 变量等于 happy 时激活该条目
"""
variable1: str = Field(..., description="第一个变量名")
operator: LogicOperator = Field(..., description="比较运算符")
variable2: str = Field(..., description="第二个变量名或值")
class RAGConfig(BaseModel):
"""
RAG 配置(用于 RAG 激活类型)
控制如何从向量数据库中检索相关内容
"""
libraryId: str = Field(..., description="绑定的 RAG 库 ID")
threshold: Optional[float] = Field(0.7, ge=0, le=1, description="相似度阈值 (0-1)")
maxEntries: Optional[int] = Field(5, gt=0, description="最大返回条目数")
class WorldInfoEntry(BaseModel):
"""
项目内部世界书条目结构
这是世界书的核心单元,每个条目代表一段可以被动态注入到对话上下文中的知识。
相比 SillyTavern,我们添加了 activationType、logicExpression、ragConfig 等高级功能。
"""
uid: str = Field(..., description="条目唯一标识符 (UUID)")
key: Optional[List[str]] = Field(None, description="主关键词列表 (用于 KEYWORD 激活)")
keysecondary: Optional[List[str]] = Field(None, description="次要关键词列表 (可选过滤)")
content: str = Field(..., description="条目内容 - 激活时注入的文本")
activationType: ActivationType = Field(..., description="激活方式")
logicExpression: Optional[LogicExpression] = Field(None, description="逻辑表达式 (LOGIC 类型使用)")
ragConfig: Optional[RAGConfig] = Field(None, description="RAG 配置 (RAG 类型使用)")
order: int = Field(0, description="插入顺序 - 数值越大越靠近末尾")
position: Optional[str] = Field('after_char', description="插入位置")
depth: Optional[int] = Field(None, description="插入深度 (当 position='at_depth' 时使用)")
probability: Optional[float] = Field(100, ge=0, le=100, description="激活概率 (0-100)")
group: Optional[List[str]] = Field(None, description="所属组标签")
disable: bool = Field(False, description="是否禁用")
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
class WorldInfo(BaseModel):
"""
项目内部世界书结构
世界书是角色知识的集合,可以绑定到角色卡上,在对话中动态提供背景信息。
"""
id: str = Field(..., description="世界书唯一标识符 (UUID)")
name: str = Field(..., description="世界书名称")
description: Optional[str] = Field(None, description="世界书描述")
entries: List[WorldInfoEntry] = Field(default_factory=list, description="条目数组")
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
version: int = Field(1, description="版本号 (用于数据迁移)")
# ==================== 角色卡 (Character Card) ====================
class OutputSchemaField(BaseModel):
"""
Vercel AI SDK Output.object() 的表头定义
用于结构化输出,让 LLM 按照指定格式返回数据。
这是项目的特色功能,支持动态表格生成。
"""
name: str = Field(..., description="字段名称")
type: str = Field(..., description="字段类型 (string/number/boolean/array/object)")
description: str = Field(..., description="字段描述")
required: Optional[bool] = Field(None, description="是否必需")
enum: Optional[List[str]] = Field(None, description="枚举值 (字符串固定选项)")
fields: Optional[List['OutputSchemaField']] = Field(None, description="嵌套字段 (object 类型)")
class CharacterCard(BaseModel):
"""
项目内部角色卡结构
角色卡是对话 AI 的核心定义,包含人设、场景、开场白等。
相比 SillyTavern,我们添加了 categories、outputSchema、worldInfoId 等功能。
"""
id: str = Field(..., description="角色唯一标识符 (UUID)")
name: str = Field(..., description="角色名称")
description: str = Field(..., description="角色详细描述")
personality: str = Field(..., description="角色性格特征")
scenario: str = Field(..., description="场景设定")
first_mes: str = Field(..., description="首条开场消息")
mes_example: str = Field(..., description="对话示例")
categories: List[str] = Field(default_factory=list, description="分类标签 (用于前端筛选)")
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="标签数组")
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
lastChatAt: Optional[int] = Field(None, description="最后聊天时间戳")
isFavorite: bool = Field(False, description="收藏状态")
version: int = Field(1, description="版本号")
# ==================== 聊天记录 (Chat Log) ====================
class 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)")
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")
class ChatMessage(BaseModel):
"""
项目内部聊天消息
单条对话消息,支持多版本 (swipes)、token 统计等功能。
"""
id: str = Field(..., description="消息唯一标识符 (UUID)")
name: str = Field(..., description="发送者名称")
is_user: bool = Field(..., description="是否为用户消息")
is_system: Optional[bool] = Field(None, description="是否为系统消息")
sendDate: str = Field(..., description="发送日期 ISO 字符串")
mes: str = Field(..., description="消息内容文本")
chatId: str = Field(..., description="关联的聊天 ID")
swipes: Optional[List[str]] = Field(None, description="替换回答数组 (多版本)")
swipe_id: Optional[int] = Field(0, description="当前选择的版本索引")
tokenCount: Optional[int] = Field(None, description="Token 数量 (用于统计)")
isTemporary: Optional[bool] = Field(None, description="是否为临时消息 (未保存)")
class ChatLog(BaseModel):
"""
项目内部完整聊天记录
包含聊天头和所有消息,是完整的对话历史。
"""
header: ChatHeader = Field(..., description="聊天头")
messages: List[ChatMessage] = Field(default_factory=list, description="消息列表")
# ==================== 预设 (Preset) ====================
class GenerationPreset(BaseModel):
"""
项目内部采样参数预设
控制 LLM 生成的参数配置,如温度、top_p 等。
"""
id: str = Field(..., description="预设唯一标识符 (UUID)")
name: str = Field(..., description="预设名称")
temperature: float = Field(1.0, ge=0, le=2, description="温度 (控制随机性)")
topP: float = Field(1.0, ge=0, le=1, description="Top P (核采样)")
topK: int = Field(0, ge=0, description="Top K")
repetitionPenalty: float = Field(1.0, ge=0, description="重复惩罚")
frequencyPenalty: Optional[float] = Field(None, description="频率惩罚")
presencePenalty: Optional[float] = Field(None, description="存在惩罚")
maxLength: Optional[int] = Field(None, gt=0, description="最大生成长度")
isDefault: bool = Field(False, description="是否为默认预设")
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
# ==================== 提示词预设 (Prompt Preset) ====================
class PromptRole(str, Enum):
"""
Prompt 角色类型
内部业务层只保留三种角色,简化了 SillyTavern 的复杂角色系统。
"""
SYSTEM = 'system' # 系统指令
AI = 'ai' # AI 助手
USER = 'user' # 用户
class PromptEntry(BaseModel):
"""
内部业务层 - Prompt 条目
提示词模板的基本单元,可以组合成完整的提示词预设。
这是基于某个 character_id 生成的"当前视图"
"""
identifier: str = Field(..., description="稳定关联键 (用于回写)")
name: str = Field(..., description="条目名 (前端显示)")
enabled: bool = Field(True, description="是否启用 (当前作用域下的业务状态)")
content: str = Field(..., description="条目内容 (静态内容视图)")
order: int = Field(..., description="条目顺序 (前端展示和拖拽排序)")
role: PromptRole = Field(..., description="角色类型")
tokenCount: int = Field(0, description="总 token 数 (派生显示字段)")
isSystemNode: bool = Field(False, description="是否固有节点 (不可删除)")
class PromptPresetView(BaseModel):
"""
内部业务层 - Prompt 预设视图
基于某个 character_id 的"当前视图",包含已排序、已过滤的条目列表。
"""
characterId: str = Field(..., description="关联的角色 ID")
entries: List[PromptEntry] = Field(default_factory=list, description="当前视图的条目列表")
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
version: int = Field(1, description="版本号")
# ==================== RAG 配置 ====================
class RAGSearchConfig(BaseModel):
"""RAG 搜索配置"""
topK: int = Field(5, gt=0, description="每次检索返回的结果数")
threshold: float = Field(0.7, ge=0, le=1, description="相似度阈值 (0-1)")
maxContextLength: int = Field(2000, gt=0, description="最大上下文长度 (字符数)")
class CharacterRAGConfig(BaseModel):
"""
角色卡 RAG 世界书库配置
记录角色卡关联的 RAG 知识库,用于动态检索相关知识。
"""
characterId: str = Field(..., description="角色卡ID")
ragLibraryIds: List[str] = Field(default_factory=list, description="关联的RAG库ID列表")
enabled: bool = Field(True, description="是否启用")
searchConfig: Optional[RAGSearchConfig] = Field(None, description="搜索配置")
position: str = Field('after_char', description="RAG内容插入位置")
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
class ChatRAGConfig(BaseModel):
"""
聊天会话 RAG 历史消息配置
记录聊天会话关联的 RAG 历史消息库,用于智能检索历史对话。
"""
chatId: str = Field(..., description="聊天会话ID")
ragLibraryId: Optional[str] = Field(None, description="关联的RAG历史消息库ID")
enabled: bool = Field(True, description="是否启用")
searchConfig: Optional[Dict[str, Any]] = Field(None, description="搜索配置")
autoIndex: bool = Field(True, description="是否自动索引新消息")
indexConfig: Optional[Dict[str, Any]] = Field(None, description="索引配置")
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")

View File

@@ -1,231 +0,0 @@
from typing import List, Dict, Any, Optional
from pydantic import BaseModel, Field
from backend.core.models.PromptList import AIDesignSpec
from backend.core.models.PromptComponent import PromptComponent
from enum import Enum
from enum import Enum
class SpecialIdentifier(str, Enum):
"""
特殊组件标识符枚举
定义所有提示词组件的类型及其在最终 Prompt 中的默认物理流向
顺序大致遵循:系统层 -> 角色层 -> 动态层 -> 历史层 -> 尾部指令
"""
WORLD_INFO_BEFORE = "worldInfoBefore"
"""前置世界书:通常用于全局设定(如物理法则),紧接在 Main Prompt 之后,拥有最高优先级"""
PERSONA_DESCRIPTION = "personaDescription"
"""用户设定:告诉 AI {{user}} 是谁,通常放在场景之后,完成“谁在对谁说话”的闭环"""
ENHANCE_DEFINITIONS = "enhanceDefinitions"
"""增强定义:通常是 "If you have more knowledge...",用于补充 AI 的知识库这里用rag获取"""
WORLD_INFO_AFTER = "worldInfoAfter"
"""后置世界书:通常用于特定场景规则,位于中间层底部,用于覆盖或补充前面的全局设定"""
CHAT_HISTORY = "chatHistory"
"""聊天历史:包含用户与 AI 的过往对话,占据提示词的下半部分"""
JAILBREAK = "jailbreak"
"""后置指令/注释也即d0层通常位于聊天记录之后、AI 生成之前,用于最后时刻的强调(如“不要重复”)"""
class PresetAssemblyNode(BaseModel):
"""预设组装节点类,负责根据组装指令动态组装提示词内容"""
# 输入数据
design_spec: AIDesignSpec = Field(
...,
description="AI设计规范包含组件库和组装顺序"
)
target_character_id: int = Field(
...,
description="目标角色ID用于选择对应的组装指令"
)
# 内部状态(不参与序列化)
_component_map: Dict[str, PromptComponent] = Field(
default_factory=dict,
description="组件标识符到组件对象的映射"
)
def __init__(self, **data):
"""初始化方法,构建组件映射"""
super().__init__(**data)
# 构建组件映射字典,提高查找效率
self._component_map = {
comp.identifier: comp
for comp in self.design_spec.prompts
}
def _process_special_component(self, component: PromptComponent) -> Optional[Dict[str, Any]]:
"""
处理特殊组件marker为True的组件
参数:
component: 要处理的组件
返回:
Optional[Dict[str, Any]]: 处理后的消息如果组件无法处理则返回None
"""
try:
# 尝试将标识符转换为枚举
special_id = SpecialIdentifier(component.identifier)
# 根据不同标识符执行不同处理逻辑
if special_id == SpecialIdentifier.CHAT_HISTORY:
return self._handle_chat_history(component)
elif special_id == SpecialIdentifier.WORLD_INFO_BEFORE:
return self._handle_world_info_before(component)
elif special_id == SpecialIdentifier.WORLD_INFO_AFTER:
return self._handle_world_info_after(component)
elif special_id == SpecialIdentifier.CHAR_DESCRIPTION:
return self._handle_char_description(component)
else:
# 未知特殊组件,使用默认处理
return self._process_regular_component(component)
except ValueError:
# 不是特殊标识符,使用默认处理
return self._process_regular_component(component)
def _process_special_component(self, component: PromptComponent) -> Optional[Dict[str, Any]]:
"""
处理特殊组件marker为True的组件
参数:
component: 要处理的组件
返回:
Optional[Dict[str, Any]]: 处理后的消息如果组件无法处理则返回None
"""
try:
# 尝试将标识符转换为枚举
special_id = SpecialIdentifier(component.identifier)
# 根据不同标识符执行不同处理逻辑
if special_id == SpecialIdentifier.CHAT_HISTORY:
return self._handle_chat_history(component)
elif special_id == SpecialIdentifier.WORLD_INFO_BEFORE:
return self._handle_world_info_before(component)
elif special_id == SpecialIdentifier.WORLD_INFO_AFTER:
return self._handle_world_info_after(component)
elif special_id == SpecialIdentifier.DIALOGUE_EXAMPLES:
return self._handle_dialogue_examples(component)
elif special_id == SpecialIdentifier.CHAR_DESCRIPTION:
return self._handle_char_description(component)
elif special_id == SpecialIdentifier.CHAR_PERSONALITY:
return self._handle_char_personality(component)
elif special_id == SpecialIdentifier.SCENARIO:
return self._handle_scenario(component)
elif special_id == SpecialIdentifier.PERSONA_DESCRIPTION:
return self._handle_persona_description(component)
else:
# 未知特殊组件,使用默认处理
return self._process_regular_component(component)
except ValueError:
# 不是特殊标识符,使用默认处理
return self._process_regular_component(component)
def _process_regular_component(self, component: PromptComponent) -> Dict[str, Any]:
"""
处理普通组件marker为False的组件
参数:
component: 要处理的组件
返回:
Dict[str, Any]: 处理后的消息
"""
# 角色映射表
role_map = {0: "system", 1: "user", 2: "assistant"}
# 构建消息
message = {
"role": role_map.get(component.role, "system"),
"content": component.content
}
# 添加系统提示词标记
if component.system_prompt:
message["system_prompt"] = True
return message
# 以下为特殊组件处理方法
def _handle_chat_history(self, component: PromptComponent) -> Dict[str, Any]:
"""
处理聊天历史组件
参数:
component: 聊天历史组件
返回:
Dict[str, Any]: 处理后的消息
"""
# 这里应该从外部获取实际的聊天历史
# 示例实现,实际需要根据业务逻辑调整
return {
"role": "system",
"content": "聊天历史内容...",
"marker": True,
"type": "chat_history"
}
def _handle_world_info_before(self, component: PromptComponent) -> Dict[str, Any]:
"""
处理前置世界信息组件
参数:
component: 世界信息组件
返回:
Dict[str, Any]: 处理后的消息
"""
# 这里应该从外部获取实际的世界信息
return {
"role": "system",
"content": "前置世界信息...",
"marker": True,
"type": "world_info_before"
}
def _handle_world_info_after(self, component: PromptComponent) -> Dict[str, Any]:
"""
处理后置世界信息组件
参数:
component: 世界信息组件
返回:
Dict[str, Any]: 处理后的消息
"""
# 这里应该从外部获取实际的世界信息
return {
"role": "system",
"content": "后置世界信息...",
"marker": True,
"type": "world_info_after"
}
def _handle_char_description(self, component: PromptComponent) -> Dict[str, Any]:
"""
处理角色描述组件
参数:
component: 角色描述组件
返回:
Dict[str, Any]: 处理后的消息
"""
# 这里应该从外部获取实际的角色描述
return {
"role": "system",
"content": "角色描述内容...",
"marker": True,
"type": "char_description"
}

View File

@@ -1,56 +0,0 @@
import re
from core.node_base import BaseNode
from typing import List, Dict, Any
class TextSplitterNode(BaseNode):
name = "文本分割节点"
inputs = {"text": "string"}
outputs = {
"outline": "list", # 大纲部分列表
"requirement": "list", # 要求部分列表
"dialogue": "list", # 对话部分列表
"weak_guidance": "list" # 弱指引部分列表
}
async def run(self, text: str) -> Dict[str, List[str]]:
# 正则匹配三种括号内的内容
# 注意:此正则假设括号不嵌套,且没有转义字符
pattern = r'\{([^{}]*)\}|\(([^()]*)\)|“([^”]*)”'
outline = []
requirement = []
dialogue = []
weak_guidance = []
pos = 0
for match in re.finditer(pattern, text):
start, end = match.span()
# 处理匹配前的普通文本(弱指引)
if start > pos:
weak_part = text[pos:start].strip()
if weak_part:
weak_guidance.append(weak_part)
# 根据捕获组确定类型
if match.group(1) is not None: # 大括号
outline.append(match.group(1).strip())
elif match.group(2) is not None: # 小括号
requirement.append(match.group(2).strip())
elif match.group(3) is not None: # 中文引号
dialogue.append(match.group(3).strip())
pos = end
# 处理剩余的普通文本
if pos < len(text):
weak_part = text[pos:].strip()
if weak_part:
weak_guidance.append(weak_part)
return {
"outline": outline,
"requirement": requirement,
"dialogue": dialogue,
"weak_guidance": weak_guidance
}

View File

@@ -1,3 +1,12 @@
fastapi==0.104.1 fastapi==0.104.1
uvicorn[standard]==0.24.0 uvicorn[standard]==0.24.0
python-multipart==0.0.6 python-multipart==0.0.6
cryptography>=41.0.0
requests>=2.31.0
# LangChain for LLM integration (让 pip 自动解析兼容版本)
langchain>=0.1.0
langchain-openai>=0.0.5
langchain-anthropic>=0.1.1
openai>=1.12.0
anthropic>=0.23.0

View File

@@ -0,0 +1,11 @@
"""
业务服务层
包含项目的核心业务逻辑,协调 Models、Utils 和 LLM 组件。
"""
from .prompt_assembler import PromptAssembler, PromptConfig
__all__ = [
'PromptAssembler',
'PromptConfig',
]

View File

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

View File

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

View File

@@ -0,0 +1,383 @@
"""
聊天服务 - 处理聊天记录的读写操作
基于 SillyTavern JSONL 格式的聊天记录管理
"""
from pathlib import Path
from typing import Dict, List, Optional, Any
import json
import logging
from datetime import datetime
import uuid
logger = logging.getLogger(__name__)
class ChatService:
"""聊天服务类处理聊天记录的CRUD操作"""
def __init__(self, data_path: Path):
"""
初始化聊天服务
Args:
data_path: 数据目录路径
"""
self.data_path = data_path
self.chat_dir = data_path / "chat"
self.chat_dir.mkdir(parents=True, exist_ok=True)
def list_all_chats(self) -> Dict[str, List[Dict]]:
"""
获取所有角色和聊天列表
Returns:
Dict[str, List[Dict]]: 字典结构,键是角色名称,值是该角色的聊天信息列表
"""
result = {}
if not self.chat_dir.exists():
logger.warning(f"聊天目录不存在: {self.chat_dir}")
return result
for role_dir in self.chat_dir.iterdir():
try:
if role_dir.is_dir():
chats = []
for chat_file in role_dir.glob("*.jsonl"):
chat_info = self._get_chat_summary(role_dir.name, chat_file.stem)
if chat_info:
chats.append(chat_info)
if chats:
result[role_dir.name] = chats
except Exception as e:
logger.error(f"处理角色目录 {role_dir.name} 时出错: {str(e)}")
continue
return {"chat": [{"role_name": role, **chat} for role, chats in result.items() for chat in chats]}
def _get_chat_summary(self, role_name: str, chat_name: str) -> Optional[Dict]:
"""
获取聊天摘要信息
Args:
role_name: 角色名称
chat_name: 聊天名称
Returns:
Dict: 聊天摘要信息如果文件不存在则返回None
"""
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
if not chat_file.exists():
return None
try:
with open(chat_file, 'r', encoding='utf-8') as f:
lines = f.readlines()
if not lines:
return None
# 第一行是header
header = json.loads(lines[0])
# 计算消息数量排除header
message_count = len(lines) - 1
# 获取最后修改时间
last_modified = datetime.fromtimestamp(
chat_file.stat().st_mtime
).isoformat()
# 获取最后一条消息预览
last_message = ""
if message_count > 0:
try:
last_msg_data = json.loads(lines[-1])
last_message = last_msg_data.get("mes", "")
except:
pass
return {
"chat_name": chat_name,
"user_name": header.get("user_name", "User"),
"character_name": header.get("character_name", ""),
"last_modified": last_modified,
"message_count": message_count,
"last_message": last_message
}
except Exception as e:
logger.error(f"读取聊天摘要失败 {role_name}/{chat_name}: {str(e)}")
return None
def get_chat(self, role_name: str, chat_name: str) -> Dict[str, Any]:
"""
获取指定聊天的完整内容
Args:
role_name: 角色名称
chat_name: 聊天名称
Returns:
Dict: 包含metadata和messages的字典
Raises:
FileNotFoundError: 聊天文件不存在
"""
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
if not chat_file.exists():
raise FileNotFoundError(f"Chat not found: {role_name}/{chat_name}")
try:
with open(chat_file, 'r', encoding='utf-8') as f:
lines = f.readlines()
if not lines:
raise ValueError(f"Empty chat file: {role_name}/{chat_name}")
# 第一行是header
header = json.loads(lines[0])
# 解析消息
messages = []
for i, line in enumerate(lines[1:], start=1):
if line.strip(): # 跳过空行
msg_data = json.loads(line)
# 确保有floor字段
if "floor" not in msg_data:
msg_data["floor"] = i
messages.append(msg_data)
return {
"metadata": {
"user_name": header.get("user_name", "User"),
"character_name": header.get("character_name", ""),
"chat_id": header.get("chat_id_hash", ""),
"integrity": header.get("integrity", "")
},
"messages": messages
}
except Exception as e:
logger.error(f"读取聊天失败 {role_name}/{chat_name}: {str(e)}")
raise
def create_chat(self, role_name: str, chat_name: str, metadata: Dict = None) -> Dict:
"""
创建新聊天
Args:
role_name: 角色名称
chat_name: 聊天名称
metadata: 聊天元数据
Returns:
Dict: 创建的聊天信息
Raises:
FileExistsError: 聊天已存在
"""
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
if chat_file.exists():
raise FileExistsError(f"Chat already exists: {role_name}/{chat_name}")
# 创建角色目录
chat_file.parent.mkdir(parents=True, exist_ok=True)
# 构建header
header = {
"user_name": metadata.get("user_name", "User") if metadata else "User",
"character_name": metadata.get("character_name", role_name) if metadata else role_name,
"integrity": str(uuid.uuid4()),
"chat_id_hash": str(uuid.uuid4()),
"note_prompt": "",
"note_interval": 0,
"note_position": 0,
"note_depth": 0,
"note_role": 0,
"extensions": {},
"timedWorldInfo": {},
"variables": {},
"tainted": False,
"lastInContextMessageId": -1
}
# 写入header
with open(chat_file, 'w', encoding='utf-8') as f:
f.write(json.dumps(header, ensure_ascii=False) + '\n')
return {
"role_name": role_name,
"chat_name": chat_name,
"metadata": {
"user_name": header["user_name"],
"character_name": header["character_name"]
}
}
def add_message(self, role_name: str, chat_name: str, message_data: Dict) -> Dict:
"""
向聊天添加新消息
Args:
role_name: 角色名称
chat_name: 聊天名称
message_data: 消息数据
Returns:
Dict: 添加的消息
Raises:
FileNotFoundError: 聊天不存在
"""
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
if not chat_file.exists():
raise FileNotFoundError(f"Chat not found: {role_name}/{chat_name}")
try:
# 读取现有消息以确定floor
with open(chat_file, 'r', encoding='utf-8') as f:
lines = f.readlines()
# 计算下一个floor号
next_floor = len(lines) - 1 # 减去header行
# 构建完整的消息数据
full_message = {
"name": message_data.get("name", "User"),
"is_user": message_data.get("is_user", True),
"is_system": message_data.get("is_system", False),
"floor": next_floor,
"send_date": message_data.get("send_date", str(int(datetime.now().timestamp() * 1000))),
"mes": message_data.get("mes", ""),
"extra": message_data.get("extra", {}),
"swipes": message_data.get("swipes", []),
"swipe_id": message_data.get("swipe_id", 0),
"force_avatar": None,
"variables": [],
"variables_initialized": [],
"is_ejs_processed": []
}
# 追加消息到文件
with open(chat_file, 'a', encoding='utf-8') as f:
f.write(json.dumps(full_message, ensure_ascii=False) + '\n')
return full_message
except Exception as e:
logger.error(f"添加消息失败 {role_name}/{chat_name}: {str(e)}")
raise
def update_message(self, role_name: str, chat_name: str, floor: int, update_data: Dict) -> Dict:
"""
更新指定楼层的消息
Args:
role_name: 角色名称
chat_name: 聊天名称
floor: 楼层号
update_data: 更新的数据
Returns:
Dict: 更新后的消息
Raises:
FileNotFoundError: 聊天不存在
ValueError: 楼层不存在
"""
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
if not chat_file.exists():
raise FileNotFoundError(f"Chat not found: {role_name}/{chat_name}")
try:
# 读取所有行
with open(chat_file, 'r', encoding='utf-8') as f:
lines = f.readlines()
# 找到对应的消息行floor + 1因为第0行是header
message_line_index = floor + 1
if message_line_index >= len(lines):
raise ValueError(f"Floor {floor} not found in chat")
# 解析并更新消息
msg_data = json.loads(lines[message_line_index])
msg_data.update(update_data)
# 写回文件
lines[message_line_index] = json.dumps(msg_data, ensure_ascii=False) + '\n'
with open(chat_file, 'w', encoding='utf-8') as f:
f.writelines(lines)
return msg_data
except Exception as e:
logger.error(f"更新消息失败 {role_name}/{chat_name}/{floor}: {str(e)}")
raise
def delete_message(self, role_name: str, chat_name: str, floor: int) -> Dict:
"""
删除指定楼层的消息
Args:
role_name: 角色名称
chat_name: 聊天名称
floor: 楼层号
Returns:
Dict: 被删除的消息
Raises:
FileNotFoundError: 聊天不存在
ValueError: 楼层不存在
"""
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
if not chat_file.exists():
raise FileNotFoundError(f"Chat not found: {role_name}/{chat_name}")
try:
# 读取所有行
with open(chat_file, 'r', encoding='utf-8') as f:
lines = f.readlines()
# 找到对应的消息行
message_line_index = floor + 1
if message_line_index >= len(lines):
raise ValueError(f"Floor {floor} not found in chat")
# 保存被删除的消息
deleted_msg = json.loads(lines[message_line_index])
# 删除该行
del lines[message_line_index]
# 重新编号后续消息的floor
for i in range(message_line_index, len(lines)):
if lines[i].strip(): # 跳过空行
msg_data = json.loads(lines[i])
msg_data["floor"] = i - 1 # 重新计算floor
lines[i] = json.dumps(msg_data, ensure_ascii=False) + '\n'
# 写回文件
with open(chat_file, 'w', encoding='utf-8') as f:
f.writelines(lines)
return deleted_msg
except Exception as e:
logger.error(f"删除消息失败 {role_name}/{chat_name}/{floor}: {str(e)}")
raise

View File

@@ -0,0 +1,173 @@
"""
ComfyUI Workflow Manager
管理工作流 JSON 文件的上传、删除和加载
"""
import json
import os
from pathlib import Path
from typing import List, Dict, Optional
from fastapi import UploadFile, HTTPException
import shutil
from core.config import settings
# 工作流目录 - 使用统一的数据目录
WORKFLOW_DIR = settings.COMFYUI_WORKFLOWS_PATH
WORKFLOW_DIR.mkdir(parents=True, exist_ok=True)
class WorkflowManager:
"""ComfyUI 工作流管理器"""
@staticmethod
def list_workflows() -> List[Dict[str, str]]:
"""列出所有可用的工作流"""
workflows = []
for json_file in WORKFLOW_DIR.glob("*.json"):
try:
with open(json_file, 'r', encoding='utf-8') as f:
workflow_data = json.load(f)
workflows.append({
"filename": json_file.name,
"name": json_file.stem,
"nodes_count": len(workflow_data),
"size": json_file.stat().st_size
})
except Exception as e:
print(f"Error loading workflow {json_file.name}: {e}")
continue
return workflows
@staticmethod
def load_workflow(filename: str) -> Dict:
"""加载指定工作流"""
filepath = WORKFLOW_DIR / filename
if not filepath.exists():
raise HTTPException(status_code=404, detail=f"Workflow '{filename}' not found")
if not filepath.suffix == '.json':
raise HTTPException(status_code=400, detail="Invalid file type")
try:
with open(filepath, 'r', encoding='utf-8') as f:
return json.load(f)
except json.JSONDecodeError as e:
raise HTTPException(status_code=500, detail=f"Invalid JSON: {str(e)}")
@staticmethod
async def upload_workflow(file: UploadFile) -> Dict[str, str]:
"""上传工作流文件"""
# 验证文件名
if not file.filename or not file.filename.endswith('.json'):
raise HTTPException(status_code=400, detail="File must be a JSON file")
# 安全检查:防止路径遍历攻击
safe_filename = os.path.basename(file.filename)
if not safe_filename:
raise HTTPException(status_code=400, detail="Invalid filename")
filepath = WORKFLOW_DIR / safe_filename
# 如果文件已存在,先备份
if filepath.exists():
backup_path = WORKFLOW_DIR / f"{safe_filename}.bak"
shutil.copy2(filepath, backup_path)
# 保存文件
try:
content = await file.read()
# 验证 JSON 格式
try:
workflow_data = json.loads(content)
# 基本验证:检查是否是 ComfyUI 工作流
if not isinstance(workflow_data, dict):
raise ValueError("Workflow must be a JSON object")
# 检查是否包含必要的节点类型
has_sampler = any(
node.get("class_type") == "KSampler"
for node in workflow_data.values()
if isinstance(node, dict)
)
if not has_sampler:
raise ValueError("Invalid ComfyUI workflow: missing KSampler node")
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="Invalid JSON format")
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
# 写入文件
with open(filepath, 'w', encoding='utf-8') as f:
f.write(content.decode('utf-8'))
return {
"message": "Workflow uploaded successfully",
"filename": safe_filename,
"size": len(content)
}
except HTTPException:
raise
except Exception as e:
# 如果出错,恢复备份
backup_path = WORKFLOW_DIR / f"{safe_filename}.bak"
if backup_path.exists():
shutil.move(backup_path, filepath)
raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}")
@staticmethod
def delete_workflow(filename: str) -> Dict[str, str]:
"""删除工作流文件"""
# 安全检查
safe_filename = os.path.basename(filename)
if not safe_filename or not safe_filename.endswith('.json'):
raise HTTPException(status_code=400, detail="Invalid filename")
filepath = WORKFLOW_DIR / safe_filename
if not filepath.exists():
raise HTTPException(status_code=404, detail=f"Workflow '{filename}' not found")
# 不允许删除默认工作流
if safe_filename == "default_txt2img.json":
raise HTTPException(
status_code=403,
detail="Cannot delete default workflow"
)
try:
filepath.unlink()
return {"message": f"Workflow '{safe_filename}' deleted successfully"}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Delete failed: {str(e)}")
@staticmethod
def replace_prompt_in_workflow(workflow: Dict, prompt: str) -> Dict:
"""
在工作流中替换提示词
找到第一个 CLIPTextEncode 节点,替换其 text 字段
"""
import copy
workflow_copy = copy.deepcopy(workflow)
# 查找 CLIPTextEncode 节点(通常是正向提示词)
for node_id, node in workflow_copy.items():
if isinstance(node, dict) and node.get("class_type") == "CLIPTextEncode":
if "text" in node.get("inputs", {}):
# 替换提示词
node["inputs"]["text"] = prompt
return workflow_copy
# 如果没有找到 CLIPTextEncode 节点,抛出错误
raise ValueError("No CLIPTextEncode node found in workflow")
# 全局实例
workflow_manager = WorkflowManager()

View File

@@ -0,0 +1,172 @@
"""
LLM 模型管理服务
提供获取不同 LLM 提供商可用模型列表的功能
"""
from typing import List, Dict, Any, Optional
import requests
class LLMModelService:
"""LLM 模型管理服务"""
@staticmethod
def get_openai_models(api_key: str, base_url: Optional[str] = None) -> List[str]:
"""
获取 OpenAI 兼容 API 的模型列表
Args:
api_key: API Key
base_url: API 基础 URL默认为 OpenAI 官方 API
Returns:
模型名称列表
"""
try:
# 默认使用 OpenAI 官方 API
if not base_url:
base_url = "https://api.openai.com/v1"
# 确保 base_url 以 /v1 结尾
if not base_url.endswith('/v1'):
base_url = base_url.rstrip('/') + '/v1'
response = requests.get(
f"{base_url}/models",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
},
timeout=10
)
if response.status_code != 200:
raise Exception(f"HTTP {response.status_code}: {response.text}")
data = response.json()
models = [model['id'] for model in data.get('data', [])]
# 过滤出聊天模型(可选)
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
except Exception as e:
raise Exception(f"获取 OpenAI 模型列表失败: {str(e)}")
@staticmethod
def get_anthropic_models(api_key: str) -> List[str]:
"""
获取 Anthropic Claude 模型列表
Args:
api_key: API Key
Returns:
模型名称列表
"""
try:
# Anthropic 没有公开的模型列表 API返回已知模型
return [
"claude-3-5-sonnet-20241022",
"claude-3-5-haiku-20241022",
"claude-3-opus-20240229",
"claude-3-sonnet-20240229",
"claude-3-haiku-20240307",
"claude-2.1",
"claude-2.0",
"claude-instant-1.2"
]
except Exception as e:
raise Exception(f"获取 Anthropic 模型列表失败: {str(e)}")
@staticmethod
def get_ollama_models(base_url: str = "http://localhost:11434") -> List[str]:
"""
获取 Ollama 本地模型列表
Args:
base_url: Ollama API 地址
Returns:
模型名称列表
"""
try:
response = requests.get(
f"{base_url}/api/tags",
timeout=10
)
if response.status_code != 200:
raise Exception(f"HTTP {response.status_code}: {response.text}")
data = response.json()
models = [model['name'] for model in data.get('models', [])]
return models
except Exception as e:
raise Exception(f"获取 Ollama 模型列表失败: {str(e)}")
@staticmethod
def detect_provider(api_url: str) -> str:
"""
根据 API URL 检测提供商类型
Args:
api_url: API 地址
Returns:
提供商类型: 'openai', 'anthropic', 'ollama', 'unknown'
"""
api_url_lower = api_url.lower()
if 'openai' in api_url_lower or 'api.openai.com' in api_url_lower:
return 'openai'
elif 'anthropic' in api_url_lower or 'api.anthropic.com' in api_url_lower:
return 'anthropic'
elif 'ollama' in api_url_lower or 'localhost:11434' in api_url_lower or '127.0.0.1:11434' in api_url_lower:
return 'ollama'
elif 'siliconflow' in api_url_lower or 'silicon.cloud' in api_url_lower:
# SiliconFlow 等兼容 OpenAI API 的服务
return 'openai'
elif 'deepseek' in api_url_lower:
# DeepSeek 等兼容 OpenAI API 的服务
return 'openai'
else:
# 默认尝试 OpenAI 兼容 API
return 'openai'
@staticmethod
def get_models_by_provider(
provider: str,
api_key: str,
api_url: Optional[str] = None
) -> List[str]:
"""
根据提供商类型获取模型列表
Args:
provider: 提供商类型 ('openai', 'anthropic', 'ollama')
api_key: API Key
api_url: API 地址(可选)
Returns:
模型名称列表
"""
if provider == 'openai':
return LLMModelService.get_openai_models(api_key, api_url)
elif provider == 'anthropic':
return LLMModelService.get_anthropic_models(api_key)
elif provider == 'ollama':
base_url = api_url or "http://localhost:11434"
# 移除 /v1 后缀(如果有)
base_url = base_url.replace('/v1', '').replace('/v1/', '')
return LLMModelService.get_ollama_models(base_url)
else:
raise Exception(f"不支持的提供商: {provider}")

View File

@@ -0,0 +1,221 @@
"""
提示词组装器 (Prompt Assembler)
负责根据 SillyTavern 规范将角色卡、世界书、聊天历史等组件
拼装成最终的 LLM 消息列表。
"""
import re
from typing import List, Dict, Optional
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage, BaseMessage
from models.internal import CharacterCard, ChatMessage, WorldInfoEntry
class PromptConfig:
"""提示词组装配置"""
def __init__(
self,
an_position: str = "after_history", # "before_history" or "after_history"
an_depth: int = 4,
post_history_instructions: Optional[str] = None
):
self.an_position = an_position
self.an_depth = an_depth
self.post_history_instructions = post_history_instructions
class PromptAssembler:
"""
轻量级提示词组装核心
不依赖复杂的框架,只负责纯粹的文本拼接和位置插入。
"""
# SillyTavern 的位置枚举映射
POS_WI_BEFORE = 0
POS_WI_AFTER = 1
POS_EXAMPLES_BEFORE = 2
POS_EXAMPLES_AFTER = 3
POS_AN_TOP = 4
POS_AN_BOTTOM = 5
POS_DEPTH = 6
POS_OUTLET = 7
def assemble(
self,
character: CharacterCard,
chat_history: List[ChatMessage],
user_input: str,
active_entries: List[WorldInfoEntry],
config: PromptConfig = PromptConfig()
) -> List[BaseMessage]:
"""
执行完整的提示词组装流程
Returns:
List[BaseMessage]: 准备好发送给 LLM 的消息列表
"""
# 1. 按位置分组世界书条目
grouped_entries = self._group_entries_by_position(active_entries)
# 2. 组装 Story String (包含 Pos 0-3)
story_string = self._build_story_string(character, grouped_entries)
# 3. 组装 Author's Note (包含 Pos 4-5)
authors_note_content = self._build_authors_note(grouped_entries, config.an_depth)
# 4. 处理 Chat History 并注入 Depth 条目 (Pos 6)
processed_history = self._inject_depth_entries(chat_history, grouped_entries.get(self.POS_DEPTH, []))
# 5. 准备 Outlet 替换字典 (Pos 7)
outlet_map = {entry.uid: entry.content for entry in grouped_entries.get(self.POS_OUTLET, [])}
# 6. 最终封装为 Messages
return self._wrap_to_messages(
story_string,
authors_note_content,
processed_history,
user_input,
outlet_map,
config
)
def _group_entries_by_position(self, entries: List[WorldInfoEntry]) -> Dict[int, List[WorldInfoEntry]]:
"""将激活的条目按 position 分组"""
grouped = {}
for entry in entries:
# 这里假设 entry.position 存储的是我们定义的 0-7 整数
pos = entry.position if isinstance(entry.position, int) else 1 # 默认为 wiAfter
if pos not in grouped:
grouped[pos] = []
grouped[pos].append(entry)
# 对每个组内的条目按 order 排序
for pos in grouped:
grouped[pos].sort(key=lambda x: x.order)
return grouped
def _build_story_string(self, character: CharacterCard, grouped: Dict) -> str:
"""组装故事字符串 (Story String)"""
parts = []
# Pos 0: wiBefore
for entry in grouped.get(self.POS_WI_BEFORE, []):
parts.append(entry.content)
# 角色核心信息
parts.append(f"[Character('{character.name}')]\n{character.description}\n")
parts.append(f"Personality: {character.personality}\n")
parts.append(f"Scenario: {character.scenario}\n")
# Pos 1: wiAfter
for entry in grouped.get(self.POS_WI_AFTER, []):
parts.append(entry.content)
# Pos 2: Examples Before
for entry in grouped.get(self.POS_EXAMPLES_BEFORE, []):
parts.append(entry.content)
# 示例对话
if character.mes_example:
parts.append(f"<START>\n{character.mes_example}")
# Pos 3: Examples After
for entry in grouped.get(self.POS_EXAMPLES_AFTER, []):
parts.append(entry.content)
return "\n".join(parts)
def _build_authors_note(self, grouped: Dict, depth: int) -> str:
"""组装作者笔记 (Author's Note)"""
parts = []
# Pos 4: AN Top
for entry in grouped.get(self.POS_AN_TOP, []):
parts.append(entry.content)
# 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)
return "\n".join(parts)
def _inject_depth_entries(self, history: List[ChatMessage], depth_entries: List[WorldInfoEntry]) -> List[Dict]:
"""
在聊天历史的指定深度插入条目 (Pos 6)
返回一个包含 role 和 content 的字典列表,方便后续转换
"""
# 先将历史转换为中间格式
msg_list = []
for msg in history:
msg_list.append({"role": "user" if msg.is_user else "assistant", "content": msg.mes})
# 按 depth 分组插入
# d0 通常指最新用户输入之前,即列表末尾
for entry in depth_entries:
depth = entry.depth if entry.depth is not None else 0
# 计算插入索引 (从后往前数)
insert_index = max(0, len(msg_list) - depth)
# 确定角色
role_map = {"system": "system", "user": "user", "assistant": "assistant"}
role = role_map.get(str(entry.position).split('_')[-1] if '_' in str(entry.position) else "system", "system")
msg_list.insert(insert_index, {"role": "system", "content": entry.content})
return msg_list
def _replace_outlets(self, text: str, outlet_map: Dict[str, str]) -> str:
"""执行 Outlet 宏替换 (Pos 7)"""
def replacer(match):
uid = match.group(1)
return outlet_map.get(uid, "")
# 匹配 {{outlet::UID}}
return re.sub(r"\{\{outlet::([^}]+)\}\}", replacer, text)
def _wrap_to_messages(
self,
story_string: str,
an_content: str,
history: List[Dict],
user_input: str,
outlet_map: Dict[str, str],
config: PromptConfig
) -> List[BaseMessage]:
"""将组装好的文本块封装为 LangChain Messages"""
messages = []
# 1. System Message (Story String + Outlet 替换)
final_story = self._replace_outlets(story_string, outlet_map)
if final_story:
messages.append(SystemMessage(content=final_story))
# 2. Author's Note (根据配置位置插入)
if an_content and config.an_position == "before_history":
messages.append(SystemMessage(content=self._replace_outlets(an_content, outlet_map)))
# 3. Chat History
for msg_data in history:
if msg_data["role"] == "user":
messages.append(HumanMessage(content=msg_data["content"]))
elif msg_data["role"] == "assistant":
messages.append(AIMessage(content=msg_data["content"]))
else:
messages.append(SystemMessage(content=msg_data["content"]))
# 4. Author's Note (如果在 History 之后)
if an_content and config.an_position == "after_history":
messages.append(SystemMessage(content=self._replace_outlets(an_content, outlet_map)))
# 5. Post-History Instructions & User Input
final_input = user_input
if config.post_history_instructions:
final_input = f"{config.post_history_instructions}\n\n{user_input}"
messages.append(HumanMessage(content=final_input))
return messages

View File

@@ -0,0 +1,410 @@
"""
World Book Service
世界书服务层 - 处理世界书及条目的 CRUD 操作
"""
import json
import os
import uuid
from pathlib import Path
from typing import List, Dict, Any, Optional
from datetime import datetime
from models.internal import WorldInfo, WorldInfoEntry, ActivationType
from models.converters import WorldBookConverter
from core.config import settings
class WorldBookService:
"""世界书服务类"""
@staticmethod
def _get_worldbook_path(name: str) -> Path:
"""获取世界书文件路径"""
return settings.WORLDBOOKS_PATH / f"{name}.json"
@staticmethod
def _load_worldbook(name: str) -> Optional[Dict[str, Any]]:
"""加载世界书 JSON 文件"""
path = WorldBookService._get_worldbook_path(name)
if not path.exists():
return None
try:
with open(path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
raise ValueError(f"Failed to load worldbook '{name}': {str(e)}")
@staticmethod
def _save_worldbook(name: str, data: Dict[str, Any]):
"""保存世界书到 JSON 文件"""
path = WorldBookService._get_worldbook_path(name)
try:
with open(path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
except Exception as e:
raise ValueError(f"Failed to save worldbook '{name}': {str(e)}")
@staticmethod
def list_worldbooks() -> List[Dict[str, Any]]:
"""
获取所有世界书的列表(仅基本信息)
Returns:
世界书列表,每个包含 name, description, entries_count 等
"""
worldbooks = []
for json_file in settings.WORLDBOOKS_PATH.glob("*.json"):
try:
with open(json_file, 'r', encoding='utf-8') as f:
data = json.load(f)
# 内部格式entries 是列表
entries = data.get("entries", [])
entries_count = len(entries) if isinstance(entries, list) else 0
worldbooks.append({
"name": data.get("name", json_file.stem),
"description": data.get("description", ""),
"entries_count": entries_count,
"createdAt": data.get("createdAt", 0),
"updatedAt": data.get("updatedAt", 0)
})
except Exception as e:
print(f"Error loading worldbook {json_file.name}: {e}")
continue
# 按更新时间排序
worldbooks.sort(key=lambda x: x.get("updatedAt", 0), reverse=True)
return worldbooks
@staticmethod
def get_worldbook(name: str) -> Dict[str, Any]:
"""
获取指定世界书的完整数据
Args:
name: 世界书名称
Returns:
世界书完整数据
"""
data = WorldBookService._load_worldbook(name)
if not data:
raise FileNotFoundError(f"Worldbook '{name}' not found")
return data
@staticmethod
def create_worldbook(name: str, description: str = "") -> Dict[str, Any]:
"""
创建新世界书
Args:
name: 世界书名称
description: 世界书描述
Returns:
创建的世界书数据
"""
# 检查是否已存在
if WorldBookService._get_worldbook_path(name).exists():
raise ValueError(f"Worldbook '{name}' already exists")
now = int(datetime.now().timestamp())
worldbook_data = {
"id": str(uuid.uuid4()),
"name": name,
"description": description,
"entries": [],
"createdAt": now,
"updatedAt": now,
"version": 1
}
WorldBookService._save_worldbook(name, worldbook_data)
return worldbook_data
@staticmethod
def update_worldbook(name: str, description: Optional[str] = None) -> Dict[str, Any]:
"""
更新世界书基本信息
Args:
name: 世界书名称
description: 新的描述(可选)
Returns:
更新后的世界书数据
"""
data = WorldBookService._load_worldbook(name)
if not data:
raise FileNotFoundError(f"Worldbook '{name}' not found")
if description is not None:
data["description"] = description
data["updatedAt"] = int(datetime.now().timestamp())
WorldBookService._save_worldbook(name, data)
return data
@staticmethod
def delete_worldbook(name: str) -> bool:
"""
删除世界书
Args:
name: 世界书名称
Returns:
是否删除成功
"""
path = WorldBookService._get_worldbook_path(name)
if not path.exists():
raise FileNotFoundError(f"Worldbook '{name}' not found")
path.unlink()
return True
@staticmethod
def list_entries(name: str, page: int = 1, page_size: int = 20) -> Dict[str, Any]:
"""
获取世界书的条目列表(支持分页)
Args:
name: 世界书名称
page: 页码从1开始
page_size: 每页数量默认20
Returns:
包含条目列表和分页信息的字典
"""
data = WorldBookService._load_worldbook(name)
if not data:
raise FileNotFoundError(f"Worldbook '{name}' not found")
# 内部格式entries 是列表
all_entries = data.get("entries", [])
if not isinstance(all_entries, list):
all_entries = []
total = len(all_entries)
# 计算分页
start_idx = (page - 1) * page_size
end_idx = start_idx + page_size
paginated_entries = all_entries[start_idx:end_idx]
return {
"entries": paginated_entries,
"total": total,
"page": page,
"page_size": page_size,
"total_pages": (total + page_size - 1) // page_size # 向上取整
}
@staticmethod
def get_entry(name: str, uid: str) -> Dict[str, Any]:
"""
获取世界书的指定条目
Args:
name: 世界书名称
uid: 条目 UID
Returns:
条目数据
"""
data = WorldBookService._load_worldbook(name)
if not data:
raise FileNotFoundError(f"Worldbook '{name}' not found")
# 内部格式entries 是列表
entries = data.get("entries", [])
if not isinstance(entries, list):
entries = []
for entry in entries:
if entry.get("uid") == uid or str(entry.get("uid")) == uid:
return entry
raise FileNotFoundError(f"Entry '{uid}' not found in worldbook '{name}'")
@staticmethod
def create_entry(name: str, entry_data: Dict[str, Any]) -> Dict[str, Any]:
"""
在世界书中创建新条目
Args:
name: 世界书名称
entry_data: 条目数据(不包含 uid, createdAt, updatedAt
Returns:
创建的条目数据
"""
data = WorldBookService._load_worldbook(name)
if not data:
raise FileNotFoundError(f"Worldbook '{name}' not found")
# 生成 UID 和时间戳
now = int(datetime.now().timestamp())
new_entry = {
"uid": str(uuid.uuid4()),
"key": entry_data.get("key", []),
"keysecondary": entry_data.get("keysecondary", []),
"content": entry_data.get("content", ""),
"activationType": entry_data.get("activationType", ActivationType.KEYWORD.value),
"logicExpression": entry_data.get("logicExpression"),
"ragConfig": entry_data.get("ragConfig"),
"order": entry_data.get("order", 0),
"position": entry_data.get("position", "after_char"),
"depth": entry_data.get("depth"),
"probability": entry_data.get("probability", 100),
"group": entry_data.get("group", []),
"disable": entry_data.get("disable", False),
"createdAt": now,
"updatedAt": now
}
data["entries"].append(new_entry)
data["updatedAt"] = now
WorldBookService._save_worldbook(name, data)
return new_entry
@staticmethod
def update_entry(name: str, uid: str, entry_data: Dict[str, Any]) -> Dict[str, Any]:
"""
更新世界书的指定条目
Args:
name: 世界书名称
uid: 条目 UID
entry_data: 更新的字段
Returns:
更新后的条目数据
"""
data = WorldBookService._load_worldbook(name)
if not data:
raise FileNotFoundError(f"Worldbook '{name}' not found")
for i, entry in enumerate(data.get("entries", [])):
if entry.get("uid") == uid:
# 更新字段
for key, value in entry_data.items():
if key not in ["uid", "createdAt"]: # 不修改 UID 和创建时间
entry[key] = value
# 更新时间戳
entry["updatedAt"] = int(datetime.now().timestamp())
data["entries"][i] = entry
data["updatedAt"] = entry["updatedAt"]
WorldBookService._save_worldbook(name, data)
return entry
raise FileNotFoundError(f"Entry '{uid}' not found in worldbook '{name}'")
@staticmethod
def delete_entry(name: str, uid: str) -> bool:
"""
删除世界书的指定条目
Args:
name: 世界书名称
uid: 条目 UID
Returns:
是否删除成功
"""
data = WorldBookService._load_worldbook(name)
if not data:
raise FileNotFoundError(f"Worldbook '{name}' not found")
original_length = len(data.get("entries", []))
data["entries"] = [e for e in data.get("entries", []) if e.get("uid") != uid]
if len(data["entries"]) == original_length:
raise FileNotFoundError(f"Entry '{uid}' not found in worldbook '{name}'")
data["updatedAt"] = int(datetime.now().timestamp())
WorldBookService._save_worldbook(name, data)
return True
@staticmethod
def import_from_sillytavern(name: str, st_data: Dict[str, Any]) -> Dict[str, Any]:
"""
从 SillyTavern 格式导入世界书
Args:
name: 世界书名称
st_data: SillyTavern 格式的世界书数据
Returns:
转换后的内部格式世界书数据
"""
# 使用转换器进行转换
worldbook_data = WorldBookConverter.st_to_internal(st_data, name)
# 保存到文件
WorldBookService._save_worldbook(name, worldbook_data)
return worldbook_data
@staticmethod
def import_internal_format(name: str, internal_data: Dict[str, Any]) -> Dict[str, Any]:
"""
直接导入内部格式的世界书(无需转换)
Args:
name: 世界书名称
internal_data: 内部格式的世界书数据
Returns:
内部格式世界书数据
"""
# 确保包含必要的字段
if "name" not in internal_data:
internal_data["name"] = name
# 规范化所有条目,确保有 trigger_config
if "entries" in internal_data and isinstance(internal_data["entries"], list):
normalized_entries = []
for entry in internal_data["entries"]:
if isinstance(entry, dict):
normalized_entry = WorldBookConverter.normalize_entry(entry)
normalized_entries.append(normalized_entry)
internal_data["entries"] = normalized_entries
# 保存文件
WorldBookService._save_worldbook(name, internal_data)
return internal_data
@staticmethod
def export_to_sillytavern(name: str) -> Dict[str, Any]:
"""
导出为 SillyTavern 格式
Args:
name: 世界书名称
Returns:
SillyTavern 格式的世界书数据
"""
data = WorldBookService._load_worldbook(name)
if not data:
raise FileNotFoundError(f"Worldbook '{name}' not found")
# 使用转换器进行转换
st_data = WorldBookConverter.internal_to_st(data)
return st_data
# 全局实例
worldbook_service = WorldBookService()

View File

@@ -1,47 +0,0 @@
from ..core import config
from typing import Dict, List
# 使用配置中的 DATA_PATH 并添加 "chat" 子目录
ROOT_DIR = config.settings.DATA_PATH / "chat"
def get_all_role_and_chat() -> Dict[str, List[str]]:
"""
读取配置目录下的所有子文件夹,并收集每个子文件夹中的 JSONL 文件
返回:
dict: 字典结构,键是文件夹名称,值是该文件夹中的 JSONL 文件列表(仅文件名,无路径和后缀)
"""
result = {}
# 确保目标目录存在
if not ROOT_DIR.exists():
print(f"警告: 目录 {ROOT_DIR} 不存在")
return result
# 打印根目录路径和内容(调试用)
print(f"正在扫描目录: {ROOT_DIR}")
print(f"根目录内容: {list(ROOT_DIR.iterdir())}")
# 遍历根目录下的所有条目
for entry in ROOT_DIR.iterdir():
try:
# 只处理文件夹
if entry.is_dir():
print(f"处理文件夹: {entry.name}") # 调试信息
jsonl_files = []
# 遍历子文件夹中的所有文件
for file in entry.iterdir():
if file.is_file() and file.suffix == '.jsonl':
# 使用 file.stem 获取不带后缀的文件名
jsonl_files.append(file.stem)
print(f" 找到文件: {file.name}") # 调试信息
# 如果该文件夹中有 JSONL 文件,则添加到结果中
if jsonl_files:
result[entry.name] = jsonl_files
except Exception as e:
print(f"处理文件夹 {entry.name} 时出错: {str(e)}")
continue
return result

View File

@@ -1,152 +0,0 @@
import json
from datetime import datetime
from backend.core import config as cfg
from pathlib import Path
from ..core.items import ChatRequest
# 假设 ChatRequest 定义在这里或者从其他地方导入
# from backend.app.core.items import ChatRequest
async def save_input_to_json(chat_request: ChatRequest):
"""
保存消息到JSONL文件或处理重roll请求
参数:
chat_request: 包含消息详情的请求对象
"""
# 1. 从对象中提取属性
mes = chat_request.mes
role_name = chat_request.role_name
chat_name = chat_request.chat_name
name = chat_request.name
is_user = chat_request.is_user
floor_number = chat_request.floor_number
# stream, img_switch, table_switch 等虽然在这个函数逻辑中没用到,
# 但如果 ChatRequest 中有,也可以提取出来备用
# stream = chat_request.stream
# ...
config = cfg.settings
# 注意:这里要确保 role_name 和 chat_name 不为 None否则路径拼接会报错
# 建议在函数入口处增加校验,或者在 Pydantic 模型中设置为必填项
if not role_name or not chat_name:
raise ValueError("role_name and chat_name cannot be empty")
file_path = config.BASE_PATH / "data" / "chat" / role_name / f"{chat_name}.jsonl"
# 确保目录存在
Path(file_path).parent.mkdir(parents=True, exist_ok=True)
# 读取文件内容
try:
with open(file_path, 'r', encoding='utf-8') as f:
lines = f.readlines()
except FileNotFoundError:
lines = []
# 判断是否为重roll请求
is_regenerate = False
target_index = -1
if lines and floor_number > 0:
# 计算当前楼层号
current_floor = len(lines)
# 如果floor_number与当前楼层号相同则为重roll请求
if floor_number == current_floor:
# 找到最后一条非用户消息
for i in range(len(lines) - 1, -1, -1):
try:
line_data = json.loads(lines[i])
if not line_data.get('is_user', False):
is_regenerate = True
target_index = i
break
except json.JSONDecodeError:
continue
# 处理重roll逻辑
if is_regenerate:
# 解析目标消息
try:
target_message = json.loads(lines[target_index])
except json.JSONDecodeError:
raise ValueError(f"无法解析楼层 {floor_number} 的JSON数据")
# 初始化swipes数组
if target_message.get('swipes') is None:
target_message['swipes'] = []
# 将新回复添加到swipes数组
target_message['swipes'].append(mes)
# 更新swipe_id和content
target_message['swipes_id'] = len(target_message['swipes']) - 1
target_message['content'] = mes
# 更新文件内容
lines[target_index] = json.dumps(target_message, ensure_ascii=False) + '\n'
# 写回文件
with open(file_path, 'w', encoding='utf-8') as f:
f.writelines(lines)
return target_message
# 处理普通消息保存逻辑
else:
# 获取当前时间
current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# 构建消息对象
message = {
"role": role_name,
"chat": chat_name,
"content": mes,
"name": name,
"is_user": is_user,
"send_date": current_time,
"floor_number": len(lines) + 1, # 记录楼层号
"swipes": [],
"swipes_id": 0
}
# 追加到文件
with open(file_path, 'a', encoding='utf-8') as f:
f.write(json.dumps(message, ensure_ascii=False) + '\n')
return message
if __name__ == '__main__':
# 注意:为了在本地运行测试,你需要手动构造一个 ChatRequest 对象
# 或者临时修改函数签名以便直接传参测试
# 示例:假设 ChatRequest 是一个简单的类或 Pydantic 模型
class MockChatRequest:
def __init__(self, **kwargs):
self.mes = kwargs.get('mes')
self.role_name = kwargs.get('role_name')
self.chat_name = kwargs.get('chat_name')
self.name = kwargs.get('name')
self.is_user = kwargs.get('is_user')
self.floor_number = kwargs.get('floor_number')
# 测试重roll最后一条AI消息
import asyncio
async def test():
req = MockChatRequest(
mes="这是重roll后的新回复2",
role_name="testRole1",
chat_name="111",
name="AI",
is_user=False,
floor_number=2
)
await save_input_to_json(req)
asyncio.run(test())

17
backend/utils/__init__.py Normal file
View File

@@ -0,0 +1,17 @@
"""
工具类包
提供通用的工具函数和辅助类如文件操作、LLM 调用封装等。
"""
from .file_utils import get_all_roles_and_chats, read_jsonl_file, write_jsonl_file
from .llm_client import get_llm, get_fast_llm, get_creative_llm, get_streaming_llm
__all__ = [
'get_all_roles_and_chats',
'read_jsonl_file',
'write_jsonl_file',
'get_llm',
'get_fast_llm',
'get_creative_llm',
'get_streaming_llm',
]

130
backend/utils/file_utils.py Normal file
View File

@@ -0,0 +1,130 @@
"""
文件操作工具函数
提供文件和目录操作的通用工具
"""
from pathlib import Path
from typing import Dict, List
import json
import logging
logger = logging.getLogger(__name__)
def get_all_roles_and_chats(data_path: Path) -> Dict[str, List[str]]:
"""
获取所有角色和聊天列表
Args:
data_path: 数据目录路径
Returns:
Dict[str, List[str]]: 字典结构,键是角色名称,值是该角色的聊天列表
"""
chat_dir = data_path / "chat"
result = {}
if not chat_dir.exists():
logger.warning(f"聊天目录不存在: {chat_dir}")
return result
for entry in chat_dir.iterdir():
try:
if entry.is_dir():
jsonl_files = []
for file in entry.iterdir():
if file.is_file() and file.suffix == '.jsonl':
jsonl_files.append(file.stem)
if jsonl_files:
result[entry.name] = jsonl_files
except Exception as e:
logger.error(f"处理文件夹 {entry.name} 时出错: {str(e)}")
continue
return result
def ensure_directory_exists(path: Path) -> None:
"""
确保目录存在,如果不存在则创建
Args:
path: 目录路径
"""
path.mkdir(parents=True, exist_ok=True)
def read_json_file(file_path: Path) -> dict:
"""
读取 JSON 文件
Args:
file_path: 文件路径
Returns:
dict: JSON 数据
"""
with open(file_path, 'r', encoding='utf-8') as f:
return json.load(f)
def write_json_file(file_path: Path, data: dict) -> None:
"""
写入 JSON 文件
Args:
file_path: 文件路径
data: 要写入的数据
"""
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def read_jsonl_file(file_path: Path) -> List[dict]:
"""
读取 JSONL 文件
Args:
file_path: 文件路径
Returns:
List[dict]: JSONL 数据列表
"""
lines = []
with open(file_path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
if line:
try:
lines.append(json.loads(line))
except json.JSONDecodeError as e:
logger.warning(f"解析 JSONL 行失败: {e}")
return lines
def append_to_jsonl_file(file_path: Path, data: dict) -> None:
"""
追加数据到 JSONL 文件
Args:
file_path: 文件路径
data: 要追加的数据
"""
with open(file_path, 'a', encoding='utf-8') as f:
f.write(json.dumps(data, ensure_ascii=False) + '\n')
def write_jsonl_file(file_path: Path, data_list: List[dict]) -> None:
"""
写入 JSONL 文件 (覆盖模式)
Args:
file_path: 文件路径
data_list: 数据列表
"""
with open(file_path, 'w', encoding='utf-8') as f:
for data in data_list:
f.write(json.dumps(data, ensure_ascii=False) + '\n')

View File

@@ -0,0 +1,88 @@
"""
LLM 客户端工具
提供统一的 LLM 接口,支持多种模型提供商。
使用 LangChain 的 ChatModel 抽象,简化不同厂商 API 的调用。
"""
from typing import Optional
from langchain_core.language_models.chat_models import BaseChatModel
from core.config import settings
def get_llm(
provider: str = "openai",
model: Optional[str] = None,
temperature: float = 0.7,
streaming: bool = False,
**kwargs
) -> BaseChatModel:
"""
获取 LLM 实例
Args:
provider: 模型提供商 ("openai", "anthropic", "ollama")
model: 模型名称 (如果不指定则使用配置中的默认值)
temperature: 温度参数 (0-2)
streaming: 是否启用流式输出
**kwargs: 其他参数传递给模型
Returns:
BaseChatModel: LangChain 的聊天模型实例
"""
if provider == "openai":
from langchain_openai import ChatOpenAI
return ChatOpenAI(
model=model or settings.OPENAI_MODEL or "gpt-4",
temperature=temperature,
api_key=settings.OPENAI_API_KEY,
streaming=streaming,
**kwargs
)
elif provider == "anthropic":
from langchain_anthropic import ChatAnthropic
return ChatAnthropic(
model=model or settings.ANTHROPIC_MODEL or "claude-3-opus-20240229",
temperature=temperature,
api_key=settings.ANTHROPIC_API_KEY,
max_tokens=kwargs.pop("max_tokens", 4096),
streaming=streaming,
**kwargs
)
elif provider == "ollama":
try:
from langchain_ollama import ChatOllama
return ChatOllama(
model=model or settings.OLLAMA_MODEL or "llama3",
base_url=settings.OLLAMA_BASE_URL or "http://localhost:11434",
temperature=temperature,
**kwargs
)
except ImportError:
raise ImportError(
"langchain-ollama not installed. Run: pip install langchain-ollama"
)
else:
raise ValueError(f"Unsupported provider: {provider}. Use 'openai', 'anthropic', or 'ollama'")
# 便捷函数 - 常用配置
def get_fast_llm(provider: str = "openai") -> BaseChatModel:
"""获取快速响应的 LLM (低温度,适合事实性问题)"""
return get_llm(provider, temperature=0.3)
def get_creative_llm(provider: str = "openai") -> BaseChatModel:
"""获取创造性 LLM (高温度,适合创意写作)"""
return get_llm(provider, temperature=0.9)
def get_streaming_llm(provider: str = "openai") -> BaseChatModel:
"""获取支持流式输出的 LLM"""
return get_llm(provider, streaming=True)

View File

@@ -1,201 +0,0 @@
# backend/app/workflows/llm_workflow.py
from typing import Dict, Any, List, Callable
from dataclasses import dataclass
from enum import Enum
class WorkflowStatus(Enum):
"""工作流状态枚举"""
INITIALIZED = "initialized"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
PAUSED = "paused"
@dataclass
class WorkflowContext:
"""工作流上下文"""
data: Dict[str, Any]
status: WorkflowStatus = WorkflowStatus.INITIALIZED
metadata: Dict[str, Any] = None
def __post_init__(self):
if self.metadata is None:
self.metadata = {}
class WorkflowNode:
"""工作流节点声明"""
def __init__(
self,
name: str,
handler: Callable,
enabled: bool = True,
config: Dict[str, Any] = None
):
self.name = name # 节点的唯一标识符,用于区分不同的节点。
self.handler = handler # 一个可调用对象(函数或方法),这是节点实际执行的处理逻辑。
self.enabled = enabled # 布尔值,控制节点是否启用。默认为 True如果设置为 False节点将被跳过。
self.config = config or {} # 一个字典,用于存储节点的配置信息。默认为空字典。
self.next_nodes: List['WorkflowNode'] = [] # 一个节点列表,用于指定当前节点执行完成后应跳转到的下一个节点。默认为空列表,可能指向多分支。
def execute(self, context: WorkflowContext) -> WorkflowContext:
"""执行节点处理"""
if not self.enabled:
return context
try:
context = self.handler(context, self.config)
return context
except Exception as e:
context.status = WorkflowStatus.FAILED
context.metadata["error"] = str(e)
raise
class LLMWorkflow:
"""LLM工作流声明"""
def __init__(self):
self.nodes: List[WorkflowNode] = []
self._initialize_workflow()
def _initialize_workflow(self):
"""初始化工作流节点(仅声明,不实现)"""
# 输入节点
input_node = WorkflowNode(
name="input",
handler=self._input_handler
)
# 输入预处理节点(可开关)
preprocessing_node = WorkflowNode(
name="preprocessing",
handler=self._preprocessing_handler,
enabled=False
)
# RAG处理节点
rag_node = WorkflowNode(
name="rag",
handler=self._rag_handler
)
# 提示词组装节点
prompt_assembly_node = WorkflowNode(
name="prompt_assembly",
handler=self._prompt_assembly_handler
)
# LLM请求节点
llm_request_node = WorkflowNode(
name="llm_request",
handler=self._llm_request_handler
)
# 图像生成节点(可开关)
image_generation_node = WorkflowNode(
name="image_generation",
handler=self._image_generation_handler,
enabled=False
)
# 动态表格更新节点(可开关)
dynamic_table_node = WorkflowNode(
name="dynamic_table",
handler=self._dynamic_table_handler,
enabled=False
)
# 输出过滤节点
output_filter_node = WorkflowNode(
name="output_filter",
handler=self._output_filter_handler
)
# 输出节点
output_node = WorkflowNode(
name="output",
handler=self._output_handler
)
# 设置节点顺序(构建工作流)
self.nodes = [
input_node,
preprocessing_node,
rag_node,
prompt_assembly_node,
llm_request_node,
image_generation_node,
dynamic_table_node,
output_filter_node,
output_node
]
def execute(self, context: WorkflowContext) -> WorkflowContext:
"""执行工作流"""
context.status = WorkflowStatus.RUNNING
for node in self.nodes:
try:
context = node.execute(context)
# 如果工作流失败,停止执行
if context.status == WorkflowStatus.FAILED:
break
except Exception as e:
context.status = WorkflowStatus.FAILED
context.metadata["error"] = str(e)
break
if context.status != WorkflowStatus.FAILED:
context.status = WorkflowStatus.COMPLETED
return context
def enable_node(self, node_name: str, enabled: bool = True):
"""启用或禁用特定节点"""
for node in self.nodes:
if node.name == node_name:
node.enabled = enabled
return True
return False
# 以下是节点处理函数声明(仅声明,不实现)
def _input_handler(self, context: WorkflowContext, config: Dict[str, Any]) -> WorkflowContext:
"""输入节点处理函数"""
pass
def _preprocessing_handler(self, context: WorkflowContext, config: Dict[str, Any]) -> WorkflowContext:
"""输入预处理节点处理函数"""
pass
def _rag_handler(self, context: WorkflowContext, config: Dict[str, Any]) -> WorkflowContext:
"""RAG处理节点处理函数"""
pass
def _prompt_assembly_handler(self, context: WorkflowContext, config: Dict[str, Any]) -> WorkflowContext:
"""提示词组装节点处理函数"""
pass
def _llm_request_handler(self, context: WorkflowContext, config: Dict[str, Any]) -> WorkflowContext:
"""LLM请求节点处理函数"""
pass
def _image_generation_handler(self, context: WorkflowContext, config: Dict[str, Any]) -> WorkflowContext:
"""图像生成节点处理函数"""
pass
def _dynamic_table_handler(self, context: WorkflowContext, config: Dict[str, Any]) -> WorkflowContext:
"""动态表格更新节点处理函数"""
pass
def _output_filter_handler(self, context: WorkflowContext, config: Dict[str, Any]) -> WorkflowContext:
"""输出过滤节点处理函数"""
pass
def _output_handler(self, context: WorkflowContext, config: Dict[str, Any]) -> WorkflowContext:
"""输出节点处理函数"""
pass

201
check_frontend_api.py Normal file
View File

@@ -0,0 +1,201 @@
"""
检查前端世界书功能与后端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)

49
check_worldbook_path.py Normal file
View File

@@ -0,0 +1,49 @@
"""
检查世界书路径和文件
"""
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)

53
clear_default_avatar.py Normal file
View File

@@ -0,0 +1,53 @@
"""
清空默认头像图片中的嵌入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)

87
convert_worldbooks.py Normal file
View File

@@ -0,0 +1,87 @@
"""
将现有的 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 后缀,确认无误后可删除")

BIN
data/characters/defult.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
{"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}

View File

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

View File

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

View File

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

View File

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

View File

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

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

@@ -0,0 +1,218 @@
{
"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": [
{
"name": "Main Prompt",
"system_prompt": true,
"role": "system",
"content": "Write {{char}}'s next reply in a fictional chat between {{char}} and {{user}}.",
"identifier": "main"
},
{
"name": "Auxiliary Prompt",
"system_prompt": true,
"role": "system",
"content": "",
"identifier": "nsfw"
},
{
"identifier": "dialogueExamples",
"name": "Chat Examples",
"system_prompt": true,
"marker": true
},
{
"name": "Post-History Instructions",
"system_prompt": true,
"role": "system",
"content": "",
"identifier": "jailbreak"
},
{
"identifier": "chatHistory",
"name": "Chat History",
"system_prompt": true,
"marker": true
},
{
"identifier": "worldInfoAfter",
"name": "World Info (after)",
"system_prompt": true,
"marker": true
},
{
"identifier": "worldInfoBefore",
"name": "World Info (before)",
"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
},
{
"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": "personaDescription",
"name": "Persona Description",
"system_prompt": true,
"marker": true
}
],
"prompt_order": [
{
"character_id": 100000,
"order": [
{
"identifier": "main",
"enabled": true
},
{
"identifier": "worldInfoBefore",
"enabled": true
},
{
"identifier": "charDescription",
"enabled": true
},
{
"identifier": "charPersonality",
"enabled": true
},
{
"identifier": "scenario",
"enabled": true
},
{
"identifier": "enhanceDefinitions",
"enabled": false
},
{
"identifier": "nsfw",
"enabled": true
},
{
"identifier": "worldInfoAfter",
"enabled": true
},
{
"identifier": "dialogueExamples",
"enabled": true
},
{
"identifier": "chatHistory",
"enabled": true
},
{
"identifier": "jailbreak",
"enabled": true
}
]
},
{
"character_id": 100001,
"order": [
{
"identifier": "main",
"enabled": true
},
{
"identifier": "worldInfoBefore",
"enabled": true
},
{
"identifier": "personaDescription",
"enabled": true
},
{
"identifier": "charDescription",
"enabled": true
},
{
"identifier": "charPersonality",
"enabled": true
},
{
"identifier": "scenario",
"enabled": true
},
{
"identifier": "enhanceDefinitions",
"enabled": false
},
{
"identifier": "nsfw",
"enabled": true
},
{
"identifier": "worldInfoAfter",
"enabled": true
},
{
"identifier": "dialogueExamples",
"enabled": true
},
{
"identifier": "chatHistory",
"enabled": true
},
{
"identifier": "jailbreak",
"enabled": true
}
]
}
],
"assistant_prefill": "",
"assistant_impersonation": "",
"use_sysprompt": false,
"squash_system_messages": false,
"media_inlining": true,
"continue_prefill": false,
"continue_postfix": " ",
"seed": -1,
"n": 1
}

File diff suppressed because one or more lines are too long

View File

@@ -2,33 +2,56 @@ version: '3.8'
services: services:
backend: backend:
build: ./backend build:
command: uvicorn backend.main:app --host 0.0.0.0 --port 8000 --reload context: ./backend
dockerfile: Dockerfile
container_name: llm-backend
command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload
ports: ports:
- "23337:8000" - "23337:8000"
volumes: volumes:
- ./backend:/app/backend - ./backend:/app
- ./data:/app/data - ./data:/app/data
- ./outputs:/outputs - ./outputs:/app/outputs
environment: environment:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- PYTHONDONTWRITEBYTECODE=1
restart: unless-stopped restart: unless-stopped
networks:
- llm-network
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
frontend: frontend:
build: build:
context: ./frontend-react context: ./frontend
dockerfile: Dockerfile dockerfile: Dockerfile
target: development
container_name: llm-frontend
ports: ports:
- "23338:5173" - "23338:5173"
volumes: volumes:
- ./frontend-react:/app - ./frontend:/app
- /app/node_modules - /app/node_modules
environment: environment:
# 如果不需要特定环境变量,可以完全移除 environment 部分
# 或者添加有效的环境变量,例如:
- NODE_ENV=development - NODE_ENV=development
- VITE_BACKEND_URL=http://backend:8000 - 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" command: sh -c "npm install && npm run dev -- --host 0.0.0.0"
depends_on: depends_on:
- backend backend:
condition: service_healthy
restart: unless-stopped restart: unless-stopped
networks:
- llm-network
networks:
llm-network:
driver: bridge
volumes:
node_modules:

View File

@@ -1,26 +0,0 @@
# 使用 Node.js 18 Alpine 镜像作为基础
# 必须明确指定 node 版本,否则可能默认为空或 python 镜像
FROM node:20-alpine
# 设置工作目录
WORKDIR /app
# 设置 npm 镜像源(可选,国内推荐使用,加速依赖下载)
RUN npm config set registry https://registry.npmmirror.com/
# 复制 package.json 和 package-lock.json
# 利用 Docker 缓存层,只有依赖变更时才重新安装
COPY package.json package-lock.json* ./
# 安装依赖
RUN npm install
# 复制源代码到容器
COPY . .
# 暴露 Vite 默认端口 5173
EXPOSE 5173
# 启动 Vite 开发服务器
# --host 0.0.0.0 允许外部访问Docker 容器外)
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

View File

@@ -1,31 +0,0 @@
// frontend-react/src/App.jsx
import React from 'react';
import Toolbar from './components/ToolBar/ToolBar';
import ChatBox from './components/ChatBox/ChatBox';
import SideBarLeft from './components/SideBarLeft/SideBarLeft';
import SideBarRight from './components/SideBarRight/SideBarRight';
import './index.css';
function App() {
return (
<div className="app">
<Toolbar />
{/* 主内容容器 */}
<div className="main-container">
{/* 左侧栏 - 预设面板 */}
<SideBarLeft />
{/* 中间栏:聊天框 */}
<div className="chat-area">
<ChatBox />
</div>
{/* 右侧栏 */}
<SideBarRight />
</div>
</div>
);
}
export default App;

View File

@@ -1,275 +0,0 @@
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
const useChatBoxStore = create(
subscribeWithSelector((set, get) => ({
// 聊天历史消息列表
messages: [],
// 用户名称
userName: '',
// 角色名称
characterName: '',
// 当前选中的角色
currentRole: null,
// 当前选中的聊天
currentChat: null,
// 是否正在加载
isLoading: false,
// 是否正在生成
isGenerating: false,
// 错误信息
error: null,
// 设置消息列表
setMessages: (messages) => set({ messages }),
// 设置用户名称
setUserName: (userName) => set({ userName }),
// 设置角色名称
setCharacterName: (characterName) => set({ characterName }),
// 设置当前角色
setCurrentRole: (role) => set({ currentRole: role }),
// 设置当前聊天
setCurrentChat: (chat) => set({ currentChat: chat }),
// 同时设置角色和聊天
setChatBoxRoleAndChat: (role, chat) => {
console.log('[ChatBoxStore] setChatBoxRoleAndChat called with:', { role, chat });
set({
currentRole: role,
currentChat: chat
});
},
// 设置生成状态
setIsGenerating: (status) => set({ isGenerating: status }),
// 发送消息
sendMessage: async (content) => {
const { messages, userName, characterName, currentRole, currentChat } = get();
set({
isGenerating: true,
messages: [...messages, {
id: Date.now(),
floor: messages.length + 1,
mes: content,
is_user: true
}]
});
try {
const response = await fetch(`/api/chats/${encodeURIComponent(currentRole)}/${encodeURIComponent(currentChat)}/messages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
floor: messages.length + 1,
mes: content,
is_user: true
})
});
if (!response.ok) {
throw new Error('Failed to send message');
}
const data = await response.json();
set((state) => ({
messages: [...state.messages, {
id: Date.now(),
floor: state.messages.length + 1,
mes: data.response,
is_user: false
}],
isGenerating: false
}));
} catch (error) {
set({
error: error.message,
isGenerating: false
});
}
},
// 终止生成
stopGeneration: () => set({ isGenerating: false }),
// 加载聊天历史
fetchChatHistory: async (roleName, chatName) => {
set({ isLoading: true, error: null });
try {
const response = await fetch(`/api/chats/${encodeURIComponent(roleName)}/${encodeURIComponent(chatName)}`);
if (!response.ok) {
throw new Error('Failed to fetch chat history');
}
const data = await response.json();
set({
messages: data.messages || [],
userName: data.metadata?.user_name || 'User',
characterName: data.metadata?.character_name || roleName || 'Assistant',
isLoading: false
});
} catch (error) {
set({
error: error.message,
isLoading: false
});
}
},
// 清空聊天历史
clearChatHistory: () => set({
messages: [],
userName: '',
characterName: '',
error: null
}),
// 更新特定消息的内容
updateMessage: async (floor, content) => {
const { currentRole, currentChat } = get();
try {
const response = await fetch(`/api/chats/${encodeURIComponent(currentRole)}/${encodeURIComponent(currentChat)}/messages/${floor}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ mes: content })
});
if (!response.ok) {
throw new Error('Failed to update message');
}
set((state) => ({
messages: state.messages.map((msg) =>
msg.floor === floor ? { ...msg, mes: content } : msg
)
}));
} catch (error) {
set({ error: error.message });
}
},
// 删除特定消息
deleteMessage: async (floor) => {
const { currentRole, currentChat } = get();
try {
const response = await fetch(`/api/chats/${encodeURIComponent(currentRole)}/${encodeURIComponent(currentChat)}/messages/${floor}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Failed to delete message');
}
set((state) => ({
messages: state.messages.filter((msg) => msg.floor !== floor)
}));
} catch (error) {
set({ error: error.message });
}
},
// 创建新聊天
createChat: async (roleName, chatName, metadata = {}) => {
try {
const response = await fetch(`/api/chats/${encodeURIComponent(roleName)}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
chat_name: chatName,
metadata: {
user_name: 'User',
character_name: roleName,
...metadata
}
})
});
if (!response.ok) {
throw new Error('Failed to create chat');
}
return await response.json();
} catch (error) {
set({ error: error.message });
throw error;
}
},
// 更新聊天元数据
updateChatMetadata: async (roleName, chatName, metadata) => {
try {
const response = await fetch(`/api/chats/${encodeURIComponent(roleName)}/${encodeURIComponent(chatName)}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ metadata })
});
if (!response.ok) {
throw new Error('Failed to update chat metadata');
}
return await response.json();
} catch (error) {
set({ error: error.message });
throw error;
}
},
// 删除聊天
deleteChat: async (roleName, chatName) => {
try {
const response = await fetch(`/api/chats/${encodeURIComponent(roleName)}/${encodeURIComponent(chatName)}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Failed to delete chat');
}
return await response.json();
} catch (error) {
set({ error: error.message });
throw error;
}
}
}))
);
// 监听角色和聊天变化,自动加载聊天历史
useChatBoxStore.subscribe(
(state) => ({ role: state.currentRole, chat: state.currentChat }),
({ role, chat }, prev) => {
// 只有当角色或聊天发生变化时才处理
if (role !== prev.role || chat !== prev.chat) {
// 确保角色和聊天都存在且不为null
if (role && chat) {
useChatBoxStore.getState().fetchChatHistory(role, chat);
} else {
useChatBoxStore.getState().clearChatHistory();
}
}
},
{ equalityFn: (a, b) => a.role === b.role && a.chat === b.chat }
);
export default useChatBoxStore;

View File

@@ -1,16 +0,0 @@
import { create } from 'zustand';
const useSideBarLeftStore = create((set) => ({
activeTab: 'gallery',
tabs: [
{ id: 'gallery', label: '🖼️ 画廊' },
{ id: 'api', label: '🔌 API' },
{ id: 'presets', label: '📋 预设' },
{ id: 'worldbook', label: '🌍 世界书' }
],
setActiveTab: (tab) => set({ activeTab: tab })
}));
export default useSideBarLeftStore;

View File

@@ -1,34 +0,0 @@
import { create } from 'zustand';
const useSideBarRightStore = create((set) => ({
selectedTabs: ['dice', 'macros'],
allTabs: [
{ id: 'dice', label: '🎲 骰子与工具', component: null },
{ id: 'debug', label: '🔍 上下文调试', component: null },
{ id: 'macros', label: '🔧 快捷宏', component: null },
{ id: 'table', label: '📊 动态表格', component: null }
],
handleTabClick: (tabId) => set((state) => {
if (state.selectedTabs.includes(tabId)) {
// 如果已选中,则取消选中
return { selectedTabs: state.selectedTabs.filter(id => id !== tabId) };
} else if (state.selectedTabs.length < 2) {
// 如果未选中且少于2个则添加
return { selectedTabs: [...state.selectedTabs, tabId] };
} else {
// 如果已有2个则替换最早选中的
return { selectedTabs: [...state.selectedTabs.slice(1), tabId] };
}
}),
// 设置特定标签的组件
setTabComponent: (tabId, component) => set((state) => ({
allTabs: state.allTabs.map(tab =>
tab.id === tabId ? { ...tab, component } : tab
)
}))
}));
export default useSideBarRightStore;

View File

@@ -1,5 +0,0 @@
// frontend-react/src/store/index.js
export { default as useRoleSelectorStore } from './Slices/RoleSelectorSlice';
export { default as useSideBarLeftStore } from './Slices/LeftTabsSlices/SideBarLeftSlice';
export { default as useSideBarRightStore } from './Slices/RightTabsSlices/SideBarRightSlice';
export { default as useChatBoxStore } from './Slices/ChatBoxSlice';

View File

@@ -1,583 +0,0 @@
/* ==================== 聊天框区域 ==================== */
/* React 组件根容器 */
.chat-box {
height: 100%; /* 修改为100%,填满父容器 */
width: 100%;
display: flex;
flex-direction: column;
background-color: #fafafa;
overflow: hidden; /* 防止内容溢出 */
}
/* 消息列表容器 */
.chat-messages {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 20px;
padding-top: 60px;
display: flex;
flex-direction: column;
gap: 15px;
}
/* 设置面板 */
.settings-panel {
position: absolute;
top: 0;
right: 0;
z-index: 20;
background-color: #fff;
border-bottom-left-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
overflow: hidden;
transition: all 0.3s ease;
max-width: 200px;
}
.settings-panel.collapsed {
width: 40px;
height: 40px;
cursor: pointer;
}
.settings-panel.expanded {
width: auto;
min-width: 150px;
padding: 10px;
border: 1px solid #eee;
border-top: none;
border-right: none;
}
.settings-header {
height: 40px;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: #f0f0f0;
cursor: pointer;
font-size: 18px;
color: #666;
}
.settings-panel.collapsed .settings-header:hover {
background-color: #e0e0e0;
}
.settings-options {
display: none;
flex-direction: column;
gap: 10px;
padding-top: 10px;
}
.settings-panel.expanded .settings-options {
display: flex;
}
.setting-item {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
color: #333;
}
.setting-item input[type="checkbox"] {
margin-right: 8px;
cursor: pointer;
}
/* ==================== 聊天气泡样式 ==================== */
/* 消息行容器 */
.message {
display: flex;
width: 100%;
margin-bottom: 10px;
}
/* 气泡本体 */
.message .bubble {
max-width: 100%;
padding: 10px 15px;
border-radius: 12px;
position: relative;
font-size: 14px;
line-height: 1.6;
word-wrap: break-word;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
/* 新增确保内容区域可以容纳swipe控件 */
display: flex;
flex-direction: column;
}
/* 区分左右布局 */
.message.user {
justify-content: flex-end; /* 用户消息靠右 */
}
.message.ai {
justify-content: flex-start; /* AI 消息靠左 */
}
/* 消息容器 - 调整为垂直排列 */
.message-container {
display: flex;
flex-direction: column;
max-width: 70%;
}
/* 消息头部 - 包含名称和工具栏 */
.message-header {
display: flex;
align-items: center;
margin-bottom: 5px;
order: 1; /* 确保头部显示在第一个位置 */
}
/* 消息名称 */
.message-name {
font-size: 14px;
font-weight: 500;
color: #333;
padding: 2px 8px;
border-radius: 12px;
background-color: rgba(0, 0, 0, 0.05);
display: inline-block;
}
/* 用户消息名称 */
.message.user .message-name {
background-color: rgba(24, 144, 255, 0.1);
color: #1890ff;
}
/* AI消息名称 */
.message.ai .message-name {
background-color: rgba(0, 0, 0, 0.05);
color: #333;
}
/* 消息工具栏 - 优化布局使图标更紧凑 */
.message-toolbar {
display: flex;
align-items: center;
padding: 0 5px;
opacity: 0;
transition: opacity 0.2s ease;
}
.message:hover .message-toolbar {
opacity: 1;
}
.message-id {
font-size: 12px;
color: #888;
padding: 2px 6px;
border-radius: 4px;
background-color: rgba(0, 0, 0, 0.05);
display: flex;
align-items: center;
}
.toolbar-buttons {
display: flex;
gap: 3px; /* 减少图标之间的间距 */
margin: 0 5px;
}
.toolbar-button {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background-color: rgba(0, 0, 0, 0.05);
border-radius: 4px;
cursor: pointer;
color: #555;
transition: all 0.2s ease;
}
.toolbar-button:hover {
background-color: rgba(0, 0, 0, 0.1);
color: #333;
}
/* AI消息头部布局AI助手 - ID - 编辑 - 更多 */
.message.ai .message-header {
flex-direction: row;
justify-content: flex-start;
}
.message.ai .message-name {
order: 1;
}
.message.ai .message-id {
order: 2;
margin-left: 5px;
}
.message.ai .toolbar-buttons {
order: 3;
margin-left: 5px;
}
/* 用户消息头部布局:更多 - 编辑 - ID - 我 */
.message.user .message-header {
flex-direction: row;
justify-content: flex-end;
}
.message.user .message-name {
order: 4;
}
.message.user .message-id {
order: 3;
margin-right: 5px;
}
.message.user .toolbar-buttons {
order: 1;
margin-right: 5px;
}
/* 消息内容 - 确保显示在名称下方 */
.message-content {
display: flex;
flex-direction: column;
max-width: 100%;
order: 2; /* 确保内容显示在第二个位置 */
}
/* 气泡本体 */
.message .bubble {
max-width: 100%;
padding: 10px 15px;
border-radius: 12px;
position: relative;
font-size: 14px;
line-height: 1.6;
word-wrap: break-word;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
/* AI 气泡样式 */
.message.ai .bubble {
background-color: #fff;
color: #333;
border-top-left-radius: 2px;
border-bottom-left-radius: 2px;
border: 1px solid #eee;
}
/* 用户气泡样式 */
.message.user .bubble {
background-color: #1890ff;
color: #fff;
border-top-right-radius: 2px;
border-bottom-right-radius: 2px;
}
/* 针对 AI 消息中的 HTML 内容进行简单的样式重置 */
.message.ai .bubble p {
margin: 0 0 8px 0;
}
.message.ai .bubble p:last-child {
margin-bottom: 0;
}
.message.ai .bubble ul, .message.ai .bubble ol {
margin: 0 0 8px 0;
padding-left: 20px;
}
.message.ai .bubble b {
font-weight: 600;
color: #000;
}
/* 编辑模式 */
.edit-container {
display: flex;
flex-direction: column;
gap: 10px;
}
.edit-textarea {
width: 100%;
min-height: 80px;
padding: 10px;
border: 1px solid #ddd;
border-radius: 8px;
resize: vertical;
font-family: inherit;
font-size: 14px;
line-height: 1.5;
}
.edit-textarea:focus {
outline: none;
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
.edit-buttons {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.save-button, .cancel-button {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
border: none;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
}
.save-button {
background-color: #1890ff;
color: white;
}
.save-button:hover {
background-color: #40a9ff;
}
.cancel-button {
background-color: #f0f0f0;
color: #333;
}
.cancel-button:hover {
background-color: #e6e6e6;
}
/* ==================== 输入区域 ==================== */
.chat-input-wrapper {
background-color: #f9f9f9;
border-top: 1px solid #ddd;
padding: 10px 20px;
height: 62px; /* 明确设置高度 */
flex-shrink: 0; /* 防止被压缩 */
display: flex;
align-items: flex-end;
gap: 10px;
width: 100%;
position: relative;
z-index: 10;
}
.chat-input-area {
flex: 1;
display: flex;
}
.chat-input-area textarea {
width: 100%;
height: 42px;
min-height: 42px;
max-height: 300px;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
resize: none;
outline: none;
font-family: inherit;
line-height: 1.5;
overflow-y: auto;
display: block;
}
.send-button {
height: 42px;
padding: 0 20px;
background-color: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
}
.send-button:hover {
background-color: #40a9ff;
}
/* 加载状态样式 */
.loading {
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
color: #888;
font-size: 14px;
}
/* 错误信息样式 */
.error {
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
color: #f5222d;
font-size: 14px;
background-color: rgba(245, 34, 45, 0.05);
border-radius: 4px;
margin: 10px 0;
}
/* 确保消息名称和工具栏在移动设备上也能正常显示 */
@media (max-width: 768px) {
.message-container {
max-width: 85%;
}
.message-header {
flex-wrap: wrap;
}
.message-toolbar {
opacity: 1; /* 在移动设备上始终显示工具栏 */
}
}
/* 添加滚动条样式,使其更美观 */
.chat-messages::-webkit-scrollbar {
width: 6px;
}
.chat-messages::-webkit-scrollbar-track {
background: #f1f1f1;
}
.chat-messages::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.chat-messages::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* 确保消息内容中的链接样式 */
.bubble a {
color: inherit;
text-decoration: underline;
}
/* 代码块样式 */
.bubble pre {
background-color: #f6f8fa;
padding: 10px;
border-radius: 6px;
overflow-x: auto;
margin: 8px 0;
}
.bubble code {
font-family: 'Courier New', Courier, monospace;
font-size: 0.9em;
}
.options-button {
height: 42px;
width: 42px;
background-color: transparent;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
font-size: 20px;
color: #666;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.options-button:hover {
background-color: #f0f0f0;
color: #333;
}
.send-button {
height: 42px;
width: 42px; /* 添加固定宽度,使按钮为正方形 */
padding: 0;
background-color: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
display: flex; /* 添加 flex 布局 */
align-items: center; /* 垂直居中 */
justify-content: center; /* 水平居中 */
flex-shrink: 0;
font-size: 18px; /* 设置图标大小 */
}
.send-button:hover {
background-color: #40a9ff;
}
.send-button.stopping {
background-color: #ff4d4f; /* 终止状态下的背景色 */
}
.send-button.stopping:hover {
background-color: #ff7875;
}
/* Swipe控制按钮样式 */
.swipe-controls {
display: flex;
align-items: center;
justify-content: center;
margin-top: 8px;
gap: 8px;
}
.swipe-button {
background-color: rgba(0, 0, 0, 0.05);
border: none;
border-radius: 4px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #555;
transition: all 0.2s ease;
font-size: 12px;
}
.swipe-button:hover:not(:disabled) {
background-color: rgba(0, 0, 0, 0.1);
color: #333;
}
.swipe-button:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.swipe-counter {
font-size: 12px;
color: #888;
padding: 0 4px;
}

View File

@@ -1,254 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import useChatBoxStore from '../../Store/Slices/ChatBoxSlice';
import './ChatBox.css';
const ChatBox = () => {
const [editingId, setEditingId] = useState(null);
const [editContent, setEditContent] = useState('');
const messagesEndRef = useRef(null);
const [inputValue, setInputValue] = useState('');
// 新增管理每条消息的当前显示的swipe版本
const [currentSwipeId, setCurrentSwipeId] = useState({});
// 从 ChatBoxStore 获取状态和方法
const {
messages,
isLoading,
error,
userName,
characterName,
updateMessage,
isGenerating,
sendMessage,
stopGeneration
} = useChatBoxStore();
const [inputHeight, setInputHeight] = useState(42);
// 添加输入框高度自适应处理
const handleInputHeight = (e) => {
const textarea = e.target;
const newHeight = Math.min(Math.max(textarea.scrollHeight, 42), 300);
setInputHeight(newHeight);
};
// 处理发送或终止
const handleSendOrStop = () => {
if (isGenerating) {
stopGeneration();
} else {
sendMessage(inputValue);
setInputValue('');
setInputHeight(42);
}
};
// 处理键盘事件
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendOrStop();
}
};
// 自动滚动到底部
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
// 处理编辑消息
const handleEdit = (message) => {
setEditingId(message.floor);
setEditContent(message.mes);
};
// 保存编辑
const handleSaveEdit = (messageId) => {
// 调用 store 中的 updateMessage 方法
updateMessage(messageId, editContent);
setEditingId(null);
setEditContent('');
};
// 取消编辑
const handleCancelEdit = () => {
setEditingId(null);
setEditContent('');
};
// 新增处理swipe切换
const handleSwipeChange = (messageId, direction) => {
const message = messages.find(m => m.floor === messageId);
if (message && message.swipes && message.swipes.length > 0) {
const currentIndex = currentSwipeId[messageId] !== undefined
? currentSwipeId[messageId]
: message.swipe_id;
const newIndex = currentIndex + direction;
if (newIndex >= 0 && newIndex < message.swipes.length) {
setCurrentSwipeId(prev => ({
...prev,
[messageId]: newIndex
}));
}
}
};
// 渲染单条消息
const renderMessage = (message) => {
const isUser = message.is_user;
const isEditing = editingId === message.floor;
// 判断是否为最新消息
const isLatestMessage = messages.length > 0 && message.floor === messages[messages.length - 1].floor;
// 根据消息类型设置显示名称
const displayName = isUser ? userName : characterName;
// 确定当前显示的消息内容
let currentMes = message.mes;
let hasSwipes = message.swipes && message.swipes.length > 0;
let currentSwipeIndex = message.swipe_id;
if (hasSwipes) {
// 如果有swipes数组
if (currentSwipeId[message.floor] !== undefined) {
// 如果用户已经切换过版本,使用用户选择的版本
currentSwipeIndex = currentSwipeId[message.floor];
} else {
// 否则使用默认的swipe_id
currentSwipeIndex = message.swipe_id;
}
if (currentSwipeIndex >= 0 && currentSwipeIndex < message.swipes.length) {
currentMes = message.swipes[currentSwipeIndex];
}
}
return (
<div key={message.floor} className={`message ${isUser ? 'user' : 'ai'}`}>
<div className="message-container">
<div className="message-header">
<span className="message-name">{displayName}</span>
<span className="message-id">#{message.floor}</span>
<div className="message-toolbar">
<div className="toolbar-buttons">
<button
className="toolbar-button"
onClick={() => handleEdit(message)}
title="编辑"
>
</button>
<button
className="toolbar-button"
title="更多"
>
</button>
</div>
</div>
</div>
<div className="message-content">
{isEditing ? (
<div className="edit-container">
<textarea
className="edit-textarea"
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
/>
<div className="edit-buttons">
<button
className="cancel-button"
onClick={handleCancelEdit}
>
取消
</button>
<button
className="save-button"
onClick={() => handleSaveEdit(message.floor)}
>
保存
</button>
</div>
</div>
) : (
<div className="bubble">
{currentMes}
{hasSwipes && isLatestMessage && !isUser && (
<div className="swipe-controls">
<button
className="swipe-button"
onClick={() => handleSwipeChange(message.floor, -1)}
disabled={currentSwipeIndex === 0}
>
</button>
<span className="swipe-counter">
{currentSwipeIndex + 1}/{message.swipes.length}
</span>
<button
className="swipe-button"
onClick={() => handleSwipeChange(message.floor, 1)}
disabled={currentSwipeIndex === message.swipes.length - 1}
>
</button>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
};
return (
<div className="chat-box">
<div className="chat-messages">
{isLoading ? (
<div className="loading">加载中...</div>
) : error ? (
<div className="error">{error}</div>
) : messages.length === 0 ? (
<div className="loading">暂无消息</div>
) : (
messages.map(renderMessage)
)}
<div ref={messagesEndRef} />
</div>
<div className="chat-input-wrapper">
<button className="options-button" title="展开选项">
</button>
<div className="chat-input-area">
<textarea
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
handleInputHeight(e);
}}
onKeyDown={handleKeyDown}
style={{ height: `${inputHeight}px` }}
placeholder="输入消息..."
/>
</div>
<button
className={`send-button ${isGenerating ? 'stopping' : ''}`}
onClick={handleSendOrStop}
disabled={!inputValue.trim() && !isGenerating}
>
{isGenerating ? '■' : '➤'}
</button>
</div>
</div>
);
};
export default ChatBox;

View File

@@ -1,544 +0,0 @@
/* ==================== 角色选择器容器 ==================== */
.role-selector {
width: 100%;
height: 100%;
padding: 24px;
box-sizing: border-box;
display: flex;
flex-direction: column;
background-color: #f8fafc;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
/* 添加微妙的背景纹理 */
background-image: radial-gradient(#e5e7eb 1px, transparent 1px);
background-size: 20px 20px;
/* 添加平滑滚动 */
scroll-behavior: smooth;
/* 优化移动端体验 */
-webkit-overflow-scrolling: touch;
}
/* ==================== 当前选中角色显示 ==================== */
.selected-role-display {
padding: 16px 24px;
border-radius: 12px;
/* 更丰富的渐变效果 */
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
margin-bottom: 24px;
/* 增强阴影效果 */
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.25), 0 0 0 1px rgba(255, 255, 255, 0.1) inset;
display: flex;
align-items: center;
justify-content: space-between;
/* 添加微妙的动画效果 */
transition: all 0.3s ease;
}
/* 悬停效果 */
.selected-role-display:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(102, 126, 234, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.2) inset;
}
.role-badge {
display: flex;
align-items: center;
font-size: 15px;
}
.role-badge.empty {
color: rgba(255, 255, 255, 0.8);
}
.role-label {
font-weight: 600;
margin-right: 8px;
color: rgba(255, 255, 255, 0.9);
}
.role-name {
color: #fff;
font-weight: 600;
}
.chat-name {
color: rgba(255, 255, 255, 0.85);
margin-left: 8px;
}
/* ==================== 搜索栏样式 ==================== */
.search-bar {
margin-bottom: 20px;
position: relative;
}
.search-bar input {
width: 100%;
padding: 12px 16px 12px 40px; /* 为搜索图标留出空间 */
border: 2px solid transparent;
border-radius: 10px;
font-size: 14px;
transition: all 0.3s ease;
box-sizing: border-box;
background-color: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
/* 添加搜索图标 */
.search-bar::before {
content: '🔍';
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
font-size: 16px;
opacity: 0.5;
}
.search-bar input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
}
.search-bar input::placeholder {
color: #a0a0a0;
}
/* ==================== 角色列表样式 ==================== */
.role-list {
flex: 1;
overflow-y: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); /* 响应式网格布局 */
gap: 16px;
padding-right: 8px;
padding-bottom: 8px;
/* 自定义滚动条样式 */
scrollbar-width: thin;
scrollbar-color: #cbd5e1 transparent;
}
/* Webkit浏览器滚动条样式 */
.role-list::-webkit-scrollbar {
width: 6px;
}
.role-list::-webkit-scrollbar-track {
background: transparent;
}
.role-list::-webkit-scrollbar-thumb {
background-color: #cbd5e1;
border-radius: 3px;
}
.role-item {
border: none;
border-radius: 14px;
background-color: #fff;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
flex-direction: column;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
overflow: hidden;
height: 200px; /* 设置固定高度 */
position: relative;
/* 添加微妙的边框效果 */
border: 1px solid rgba(0, 0, 0, 0.05);
}
.role-item:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(102, 126, 234, 0.15);
}
.role-item.active {
border: 2px solid #667eea;
background-color: #f5f7ff;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
}
.role-header {
padding: 16px;
display: flex;
justify-content: space-between;
align-items: center;
flex: 0 0 auto; /* 不再自动伸缩,使用固定高度 */
height: 60px; /* 设置固定高度 */
}
.role-header .role-name {
font-size: 15px;
font-weight: 600;
color: #1f2937;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 80%; /* 限制名称最大宽度 */
}
.role-item.active .role-header .role-name {
color: #667eea;
font-size: 16px; /* 激活状态时字体稍大 */
}
.role-actions {
display: flex;
gap: 4px;
position: absolute;
top: 12px;
right: 12px;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 10; /* 确保操作按钮在最上层 */
}
.role-item:hover .role-actions {
opacity: 1;
}
.icon-btn {
background: rgba(255, 255, 255, 0.95);
border: none;
cursor: pointer;
font-size: 14px;
padding: 6px;
border-radius: 8px;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}
.icon-btn:hover {
background-color: #fff;
transform: scale(1.1);
}
.icon-btn.delete:hover {
background-color: #fff1f0;
color: #ff4d4f;
}
/* ==================== 聊天列表样式 ==================== */
.chat-list {
border-top: 1px solid #f0f0f0;
padding: 8px 0;
background-color: #fafbfc;
height: 120px; /* 设置固定高度 */
overflow-y: auto;
/* 自定义滚动条样式 */
scrollbar-width: thin;
scrollbar-color: #cbd5e1 transparent;
flex: 1; /* 让聊天列表占据剩余空间 */
display: flex;
flex-direction: column;
}
/* Webkit浏览器滚动条样式 */
.chat-list::-webkit-scrollbar {
width: 4px;
}
.chat-list::-webkit-scrollbar-track {
background: transparent;
}
.chat-list::-webkit-scrollbar-thumb {
background-color: #cbd5e1;
border-radius: 2px;
}
.chat-item {
padding: 10px 16px 10px 20px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.2s ease;
/* 添加微妙的左边框指示器 */
border-left: 3px solid transparent;
}
.chat-item:hover {
background-color: #f0f2f5;
}
.chat-item.active {
background-color: #eef1ff;
color: #667eea;
/* 激活状态添加左边框指示器 */
border-left-color: #667eea;
}
.chat-item .chat-name {
font-size: 13px;
font-weight: 500;
color: #4b5563;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-item.active .chat-name {
font-weight: 600;
color: #667eea;
}
/* ==================== 加载和空状态 ==================== */
.loading,
.empty-state {
display: flex;
align-items: center;
justify-content: center;
padding: 48px;
color: #9ca3af;
font-size: 14px;
grid-column: 1 / -1;
flex-direction: column;
gap: 16px;
}
/* 添加加载动画 */
.loading::before {
content: '';
width: 32px;
height: 32px;
border: 3px solid #e5e7eb;
border-top-color: #667eea;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* 添加空状态图标 */
.empty-state::before {
content: '📭';
font-size: 48px;
opacity: 0.5;
}
/* ==================== 模态框样式 ==================== */
.delete-confirm-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.2s ease-out;
/* 添加微妙的背景动画 */
background-image: radial-gradient(circle at center, rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%);
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal-content {
background-color: #fff;
border-radius: 16px;
padding: 28px;
width: 90%;
max-width: 420px;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.15);
animation: slideUp 0.3s cubic-bezier(0.4, 0, 0.2, 1);
/* 添加微妙的边框效果 */
border: 1px solid rgba(255, 255, 255, 0.2);
}
@keyframes slideUp {
from {
transform: translateY(24px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.modal-content h3 {
margin: 0 0 12px 0;
font-size: 18px;
font-weight: 600;
color: #1f2937;
}
.modal-content p {
margin-bottom: 28px;
color: #6b7280;
font-size: 15px;
line-height: 1.6;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
}
.modal-actions button {
padding: 11px 24px;
border-radius: 10px;
border: none;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s ease;
/* 添加微妙的阴影效果 */
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.modal-actions button:first-child {
background-color: #f3f4f6;
color: #4b5563;
}
.modal-actions button:first-child:hover {
background-color: #e5e7eb;
transform: translateY(-1px);
}
.modal-actions button.danger {
background-color: #ef4444;
color: white;
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.3);
}
.modal-actions button.danger:hover {
background-color: #f87171;
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
transform: translateY(-1px);
}
/* ==================== 响应式设计 ==================== */
@media (max-width: 768px) {
.role-selector {
padding: 16px;
}
.role-list {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 12px;
}
.role-item {
border: none;
border-radius: 14px;
background-color: #fff;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
flex-direction: column;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
overflow: hidden;
min-height: 160px; /* 修改:增加最小高度,使其更适配宽度 */
position: relative;
/* 添加微妙的边框效果 */
border: 1px solid rgba(0, 0, 0, 0.05);
}
.modal-content {
width: 95%;
padding: 20px;
}
}
@media (max-width: 480px) {
.role-list {
grid-template-columns: 1fr;
}
.selected-role-display {
padding: 12px 16px;
}
.modal-actions {
flex-direction: column;
}
.modal-actions button {
width: 100%;
}
}
/* ==================== 暗色模式支持 ==================== */
@media (prefers-color-scheme: dark) {
.role-selector {
background-color: #1f2937;
background-image: radial-gradient(#374151 1px, transparent 1px);
}
.role-item {
background-color: #374151;
border-color: rgba(255, 255, 255, 0.1);
}
.role-item:hover {
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
}
.role-header .role-name {
color: #f9fafb;
}
.chat-list {
background-color: #374151;
border-top-color: #4b5563;
}
.chat-item:hover {
background-color: #4b5563;
}
.chat-item .chat-name {
color: #d1d5db;
}
.search-bar input {
background-color: #374151;
color: #f9fafb;
}
.modal-content {
background-color: #374151;
border-color: rgba(255, 255, 255, 0.1);
}
.modal-content h3 {
color: #f9fafb;
}
.modal-content p {
color: #d1d5db;
}
}

View File

@@ -1,333 +0,0 @@
import React, { useEffect, useRef } from 'react';
import useRoleSelectorStore from '../../Store/Slices/RoleSelectorSlice';
import useChatBoxStore from '../../Store/Slices/ChatBoxSlice';
import './RoleSelector.css';
const RoleSelector = () => {
const panelRef = useRef(null);
// 从 Zustand store 中获取状态和操作
const {
roleData,
selectedRole,
selectedChat,
hoveredRole,
clickedRole,
isLoading,
searchTerm,
editingRole,
editingChat,
showDeleteConfirm,
deleteType,
fetchRoleData,
setSelectedRole,
setSelectedChat,
setSelectedRoleAndChat,
setHoveredRole,
setClickedRole,
setSearchTerm,
setEditingRole,
setEditingChat,
setShowDeleteConfirm,
setDeleteType,
handleRenameRole,
handleRenameChat,
confirmDelete,
cancelDelete,
handleAddRole,
handleAddChat,
resetPanel
} = useRoleSelectorStore();
// 从 ChatBoxStore 获取状态更新方法
const chatBoxStore = useChatBoxStore();
const { setCurrentRole, setCurrentChat } = chatBoxStore;
const setChatBoxRoleAndChat = chatBoxStore.setChatBoxRoleAndChat;
// 组件挂载时获取数据
useEffect(() => {
fetchRoleData();
}, [fetchRoleData]);
// 点击外部关闭面板
useEffect(() => {
const handleClickOutside = (event) => {
if (panelRef.current && !panelRef.current.contains(event.target)) {
resetPanel();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [resetPanel]);
// 处理角色选择
const handleRoleSelect = (role) => {
// 如果该角色有聊天记录,默认选择第一个
if (roleData[role] && roleData[role].length > 0) {
const firstChat = roleData[role][0];
// 使用新的原子操作方法同时更新角色和聊天
setSelectedRoleAndChat(role, firstChat);
// 同步更新 ChatBoxStore 中的状态
setChatBoxRoleAndChat(role, firstChat);
} else {
// 清空角色和聊天
setSelectedRoleAndChat(null, null);
// 同步更新 ChatBoxStore 中的状态
setChatBoxRoleAndChat(null, null);
}
};
// 处理聊天选择
const handleChatSelect = (chat) => {
// 获取当前展开的角色(聊天所属的角色)
const currentRole = hoveredRole || clickedRole;
// 使用新的原子操作方法同时更新角色和聊天
setSelectedRoleAndChat(currentRole, chat);
// 同步更新 ChatBoxStore 中的状态
setChatBoxRoleAndChat(currentRole, chat);
setHoveredRole(null);
setClickedRole(null);
};
// 处理角色卡片点击
const handleRoleCardClick = (role) => {
if (clickedRole === role) {
setClickedRole(null);
// 取消选择角色时,更新 selectedRole 和 selectedChat
setSelectedRoleAndChat(null, null);
// 同步更新 ChatBoxStore 中的状态
setChatBoxRoleAndChat(null, null);
} else {
setClickedRole(role);
handleRoleSelect(role);
}
};
// 处理搜索
const handleSearchChange = (e) => {
setSearchTerm(e.target.value);
};
// 处理角色编辑
const handleEditRole = (e, role) => {
e.stopPropagation();
setEditingRole(role);
};
// 处理角色重命名
const handleRenameRoleWrapper = (e, oldName) => {
e.stopPropagation();
const newName = e.target.value;
handleRenameRole(oldName, newName);
if (selectedRole === oldName && newName && newName !== oldName) {
// 更新 ChatBoxStore 中的当前角色
setCurrentRole(newName);
}
};
// 处理聊天编辑
const handleEditChat = (e, chat) => {
e.stopPropagation();
setEditingChat(chat);
};
// 处理聊天重命名
const handleRenameChatWrapper = (e, oldName) => {
e.stopPropagation();
const newName = e.target.value;
handleRenameChat(oldName, newName);
if (selectedChat === oldName && newName && newName !== oldName) {
// 更新 ChatBoxStore 中的当前聊天
setCurrentChat(selectedRole, newName);
}
};
// 处理删除确认
const handleDeleteClick = (e, type, name) => {
e.stopPropagation();
setDeleteType(type);
setShowDeleteConfirm(name);
};
// 确认删除
const confirmDeleteWrapper = () => {
confirmDelete();
if (deleteType === 'role' && selectedRole === showDeleteConfirm) {
// 清除 ChatBoxStore 中的当前角色和聊天
setChatBoxRoleAndChat(null, null);
} else if (deleteType === 'chat' && selectedChat === showDeleteConfirm) {
// 清除 ChatBoxStore 中的当前聊天
setChatBoxRoleAndChat(selectedRole, null);
}
};
// 过滤角色
const filteredRoles = Object.keys(roleData).filter(role =>
role.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div className="role-selector" ref={panelRef}>
<div className="selected-role-display">
{selectedRole ? (
<div className="role-badge">
<span className="role-label">当前角色/聊天:</span>
<span className="role-name">{selectedRole}</span>
{selectedChat && <span className="chat-name">/ {selectedChat}</span>}
</div>
) : (
<div className="role-badge empty">
<span>未选择角色</span>
</div>
)}
</div>
<div className="search-bar">
<input
type="text"
placeholder="搜索角色..."
value={searchTerm}
onChange={handleSearchChange}
/>
</div>
<div className="role-list">
{isLoading ? (
<div className="loading">加载中...</div>
) : filteredRoles.length === 0 ? (
<div className="empty-state">
<p>没有找到匹配的角色</p>
</div>
) : (
filteredRoles.map(role => (
<div
key={role}
className={`role-item ${selectedRole === role ? 'active' : ''}`}
onClick={() => handleRoleCardClick(role)}
onMouseEnter={() => setHoveredRole(role)}
onMouseLeave={() => setHoveredRole(null)}
>
<div className="role-header">
{editingRole === role ? (
<input
type="text"
defaultValue={role}
autoFocus
onBlur={(e) => handleRenameRoleWrapper(e, role)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleRenameRoleWrapper(e, role);
}
}}
onClick={(e) => e.stopPropagation()}
/>
) : (
<div className="role-name">{role}</div>
)}
<div className="role-actions">
<button
className="icon-btn"
title="编辑"
onClick={(e) => handleEditRole(e, role)}
>
</button>
<button
className="icon-btn delete"
title="删除"
onClick={(e) => handleDeleteClick(e, 'role', role)}
>
🗑
</button>
</div>
</div>
{/* 聊天列表 */}
{(hoveredRole === role || clickedRole === role) && roleData[role] && roleData[role].length > 0 && (
<div className="chat-list">
{roleData[role].map(chat => (
<div
key={chat}
className={`chat-item ${selectedChat === chat ? 'active' : ''}`}
onClick={(e) => {
e.stopPropagation();
handleChatSelect(chat);
}}
>
<div className="chat-content">
{editingChat === chat ? (
<input
type="text"
defaultValue={chat}
autoFocus
onBlur={(e) => handleRenameChatWrapper(e, chat)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleRenameChatWrapper(e, chat);
}
}}
onClick={(e) => e.stopPropagation()}
/>
) : (
<div className="chat-name">{chat}</div>
)}
</div>
<div className="chat-actions">
{editingChat !== chat && (
<>
<button
className="icon-btn"
title="编辑"
onClick={(e) => handleEditChat(e, chat)}
>
</button>
<button
className="icon-btn delete"
title="删除"
onClick={(e) => handleDeleteClick(e, 'chat', chat)}
>
🗑
</button>
</>
)}
</div>
</div>
))}
</div>
)}
</div>
))
)}
</div>
{/* 删除确认对话框 */}
{showDeleteConfirm && (
<div className="delete-confirm-modal">
<div className="modal-content">
<h3>确认删除</h3>
<p>确定要删除{deleteType === 'role' ? '角色' : '聊天'} "{showDeleteConfirm}" </p>
<div className="modal-actions">
<button className="modal-button cancel-button" onClick={cancelDelete}>
取消
</button>
<button className="modal-button danger" onClick={confirmDeleteWrapper}>
确认删除
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default RoleSelector;

View File

@@ -1,91 +0,0 @@
.sidebar-left {
width: 250px;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background-color: #ffffff;
box-shadow: 2px 0 5px rgba(0, 0, 0, 0.05);
border-right: 1px solid #e8e8e8;
transition: width 0.3s ease;
}
.sidebar-tabs {
display: flex;
border-bottom: 1px solid #e8e8e8;
background-color: #fafafa;
padding: 0 10px;
}
.tab-button {
flex: 1;
padding: 12px 5px;
background: none;
border: none;
cursor: pointer;
transition: all 0.3s ease;
font-size: 14px;
color: #555;
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.tab-button:hover {
background-color: #f0f0f0;
color: #333;
}
.tab-button.active {
color: #4a90e2;
font-weight: 600;
}
.tab-button.active::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 3px;
background-color: #4a90e2;
border-radius: 3px 3px 0 0;
}
.sidebar-content {
flex: 1;
overflow-y: auto;
padding: 15px;
}
.tab-content {
flex: 1;
overflow-y: auto;
padding: 15px;
border-bottom: 1px solid #f0f0f0;
}
/* 自定义滚动条样式 */
.sidebar-content::-webkit-scrollbar,
.tab-content::-webkit-scrollbar {
width: 6px;
}
.sidebar-content::-webkit-scrollbar-track,
.tab-content::-webkit-scrollbar-track {
background: #f1f1f1;
}
.sidebar-content::-webkit-scrollbar-thumb,
.tab-content::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.sidebar-content::-webkit-scrollbar-thumb:hover,
.tab-content::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}

View File

@@ -1,13 +0,0 @@
import React from 'react';
import '../tabcss/ApiConfig.css';
const ApiConfig = () => {
return (
<div className="api-config-content">
<h2>API配置</h2>
{/* 在这里实现API配置的具体内容 */}
</div>
);
};
export default ApiConfig;

View File

@@ -1,939 +0,0 @@
import React, { useEffect, useState } from 'react';
import '../tabcss/WorldBook.css';
import useWorldBookStore from '../../../Store/Slices/LeftTabsSlices/WorldBookSlice';
const WorldBook = () => {
const {
worldBooks,
globalWorldBooks,
currentWorldBook,
currentEntries,
currentEntry,
loading,
error,
success,
message,
fetchWorldBooks,
fetchWorldBook,
createWorldBook,
deleteWorldBook,
fetchWorldBookEntries,
createWorldBookEntry,
updateWorldBookEntry,
deleteWorldBookEntry,
toggleGlobalWorldBook,
setCurrentWorldBook,
setCurrentEntry,
resetCurrentWorldBook,
clearError,
clearSuccess,
} = useWorldBookStore();
const [newEntry, setNewEntry] = useState({
uid: 0,
content: '',
comment: '',
position: 0,
order: 100,
depth: 4,
role: 0,
trigger_config: {
triggers: {
constant: [true, null],
keyword: [false, {
key: [],
keysecondary: [],
selective: true,
selectiveLogic: 0,
matchWholeWords: false,
caseSensitive: false
}],
rag: [false, {
threshold: 0.75,
top_k: 5,
query_template: null
}],
condition: [false, {
variable_a: '',
operator: '=',
variable_b: ''
}]
}
}
});
const [showEditPanel, setShowEditPanel] = useState(false);
const [showWorldBookDropdown, setShowWorldBookDropdown] = useState(false);
const [activeTriggerStrategy, setActiveTriggerStrategy] = useState('constant');
useEffect(() => {
fetchWorldBooks();
}, [fetchWorldBooks]);
useEffect(() => {
if (success && message) {
alert(message);
clearSuccess();
}
}, [success, message, clearSuccess]);
useEffect(() => {
if (error) {
alert(error);
clearError();
}
}, [error, clearError]);
useEffect(() => {
if (currentEntry && currentEntry.trigger_config) {
const triggers = currentEntry.trigger_config.triggers;
if (triggers.constant[0]) {
setActiveTriggerStrategy('constant');
} else if (triggers.keyword[0]) {
setActiveTriggerStrategy('keyword');
} else if (triggers.rag[0]) {
setActiveTriggerStrategy('rag');
} else if (triggers.condition[0]) {
setActiveTriggerStrategy('condition');
}
}
}, [currentEntry]);
const handleCreateWorldBook = async () => {
const name = prompt('请输入世界书名称:');
if (name) {
try {
await createWorldBook({ name });
const newBook = worldBooks.find(wb => wb.name === name);
if (newBook) {
handleSelectWorldBook(newBook);
}
} catch (err) {
console.error('创建世界书失败:', err);
}
}
};
const handleSelectWorldBook = async (book) => {
setCurrentWorldBook(book);
setShowWorldBookDropdown(false);
try {
await fetchWorldBookEntries(book.name);
} catch (err) {
console.error('加载世界书条目失败:', err);
}
};
const handleToggleGlobal = async (name, isGlobal) => {
try {
await toggleGlobalWorldBook(name, isGlobal);
} catch (err) {
console.error('切换全局世界书状态失败:', err);
}
};
const handleAddEntry = async () => {
if (!currentWorldBook) return;
const maxUid = currentEntries.reduce((max, entry) => Math.max(max, entry.uid), 0);
const newUid = maxUid + 1;
const triggerConfig = {
triggers: {
constant: [newEntry.trigger_config.triggers.constant[0], null],
keyword: [!newEntry.trigger_config.triggers.constant[0] && newEntry.trigger_config.triggers.keyword[1].key.length > 0, {
key: newEntry.trigger_config.triggers.keyword[1].key,
keysecondary: newEntry.trigger_config.triggers.keyword[1].keysecondary,
selective: newEntry.trigger_config.triggers.keyword[1].selective,
selectiveLogic: newEntry.trigger_config.triggers.keyword[1].selectiveLogic,
matchWholeWords: newEntry.trigger_config.triggers.keyword[1].matchWholeWords,
caseSensitive: newEntry.trigger_config.triggers.keyword[1].caseSensitive
}],
rag: [false, {
threshold: 0.75,
top_k: 5,
query_template: null
}],
condition: [false, {
variable_a: '',
operator: '=',
variable_b: ''
}]
}
};
const entryData = {
uid: newUid,
content: newEntry.content,
comment: newEntry.comment,
position: newEntry.position,
order: newEntry.order,
depth: newEntry.depth,
role: newEntry.role,
trigger_config: triggerConfig
};
try {
await createWorldBookEntry(currentWorldBook.name, entryData);
setNewEntry({
uid: 0,
content: '',
comment: '',
position: 0,
order: 100,
depth: 4,
role: 0,
trigger_config: {
triggers: {
constant: [true, null],
keyword: [false, {
key: [],
keysecondary: [],
selective: true,
selectiveLogic: 0,
matchWholeWords: false,
caseSensitive: false
}],
rag: [false, {
threshold: 0.75,
top_k: 5,
query_template: null
}],
condition: [false, {
variable_a: '',
operator: '=',
variable_b: ''
}]
}
}
});
} catch (err) {
console.error('添加条目失败:', err);
}
};
const handleEntryClick = (entry) => {
setCurrentEntry(entry);
setShowEditPanel(true);
};
const handleEntryUpdate = async (field, value) => {
if (!currentEntry || !currentWorldBook) return;
const updatedEntry = { ...currentEntry, [field]: value };
try {
await updateWorldBookEntry(currentWorldBook.name, currentEntry.uid, updatedEntry);
setCurrentEntry(updatedEntry);
} catch (err) {
console.error('更新条目失败:', err);
}
};
const handleTriggerStrategyChange = async (strategy) => {
if (!currentEntry || !currentWorldBook) return;
setActiveTriggerStrategy(strategy);
const updatedTriggers = {
constant: [false, null],
keyword: [false, {
key: [],
keysecondary: [],
selective: true,
selectiveLogic: 0,
matchWholeWords: false,
caseSensitive: false
}],
rag: [false, {
threshold: 0.75,
top_k: 5,
query_template: null
}],
condition: [false, {
variable_a: '',
operator: '=',
variable_b: ''
}]
};
if (strategy !== 'constant') {
updatedTriggers[strategy][0] = true;
} else {
updatedTriggers.constant[0] = true;
}
const updatedTriggerConfig = {
...currentEntry.trigger_config,
triggers: updatedTriggers
};
try {
await updateWorldBookEntry(currentWorldBook.name, currentEntry.uid, {
...currentEntry,
trigger_config: updatedTriggerConfig
});
setCurrentEntry({
...currentEntry,
trigger_config: updatedTriggerConfig
});
} catch (err) {
console.error('更新触发策略失败:', err);
}
};
const handleDeleteEntry = async () => {
if (!currentEntry || !currentWorldBook) return;
if (confirm('确定要删除此条目吗?')) {
try {
await deleteWorldBookEntry(currentWorldBook.name, currentEntry.uid);
setShowEditPanel(false);
setCurrentEntry(null);
} catch (err) {
console.error('删除条目失败:', err);
}
}
};
const handleDeleteWorldBook = async () => {
if (!currentWorldBook) return;
if (confirm(`确定要删除世界书 "${currentWorldBook.name}" 吗?`)) {
try {
await deleteWorldBook(currentWorldBook.name);
resetCurrentWorldBook();
} catch (err) {
console.error('删除世界书失败:', err);
}
}
};
const handleImportWorldBook = async () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
const name = prompt('请输入世界书名称:', file.name.replace('.json', ''));
if (!name) return;
try {
await useWorldBookStore.getState().importWorldBook(name, file);
await fetchWorldBooks();
const importedBook = worldBooks.find(wb => wb.name === name);
if (importedBook) {
handleSelectWorldBook(importedBook);
}
} catch (err) {
console.error('导入世界书失败:', err);
}
};
input.click();
};
const handleExportWorldBook = async () => {
if (!currentWorldBook) return;
try {
await useWorldBookStore.getState().exportWorldBook(currentWorldBook.name);
} catch (err) {
console.error('导出世界书失败:', err);
}
};
const getPositionInfo = (position) => {
const positions = {
0: { label: '角色定义之后', weight: '高', desc: 'AI读完人设紧接着就读到这里非常适合补充角色的详细设定、性格细节或特殊规则' },
1: { label: '角色定义之前', weight: '中', desc: '在角色卡内容的最上方,通常用于定义角色的基础背景,让人设部分来解释这些背景' },
2: { label: '示例对话之前', weight: '低', desc: '在对话示例的最上方' },
3: { label: '示例对话之后', weight: '低', desc: '用于在对话开始前提供最后的上下文补充' },
4: { label: '系统提示/作者注释', weight: '极高', desc: 'AI对最近看到的信息记忆最清晰适合动态信息、当前场景描述或临时规则' },
5: { label: '作为系统消息', weight: '最高', desc: '强制作为System Prompt插入通常用于强制指令' }
};
return positions[position] || positions[0];
};
return (
<div className="worldbook-content">
{/* 全局世界书区域 */}
<div className="worldbook-selector-section">
<div className="global-books-display">
<div className="global-books-header">
<span className="title-text">全局世界书</span>
</div>
{globalWorldBooks.length > 0 ? (
<div className="global-books-list">
{globalWorldBooks.map(book => (
<div key={book.name} className="global-book-item">
<span className="global-book-name">{book.name}</span>
<button
className="btn-icon"
onClick={(e) => {
e.stopPropagation();
handleToggleGlobal(book.name, false);
}}
title="取消全局"
>
</button>
</div>
))}
</div>
) : (
<div className="no-global-books">暂无全局世界书</div>
)}
</div>
{/* 世界书管理区域 */}
<div className="worldbook-management">
<div className="worldbook-header">
<span className="title-text">世界书管理</span>
</div>
{/* 操作按钮组 */}
<div className="worldbook-actions">
<button className="action-btn" onClick={handleCreateWorldBook}>
+ 新建
</button>
<button className="action-btn" onClick={handleImportWorldBook}>
📥 导入
</button>
{currentWorldBook && (
<button className="action-btn" onClick={handleExportWorldBook}>
📤 导出
</button>
)}
</div>
{/* 世界书选择区域 */}
<div className="worldbook-selector">
<div className="dropdown" style={{ flex: 1 }}>
<button className="dropdown-btn" onClick={() => setShowWorldBookDropdown(!showWorldBookDropdown)}>
{currentWorldBook ? currentWorldBook.name : '选择世界书'}
<span></span>
</button>
{showWorldBookDropdown && (
<div className="dropdown-menu">
{worldBooks.map(book => (
<div
key={book.name}
className={`dropdown-item ${currentWorldBook?.name === book.name ? 'active' : ''}`}
onClick={(e) => {
if (e.target.type !== 'checkbox') {
handleSelectWorldBook(book);
}
}}
>
<label className="checkbox-label">
<input
type="checkbox"
checked={globalWorldBooks.some(wb => wb.name === book.name)}
onChange={(e) => {
e.stopPropagation();
handleToggleGlobal(book.name, e.target.checked);
}}
/>
<span className="book-name">{book.name}</span>
</label>
</div>
))}
</div>
)}
</div>
{currentWorldBook && (
<button
className="btn btn-danger"
onClick={handleDeleteWorldBook}
>
删除
</button>
)}
</div>
{/* 条目列表区域 */}
{loading ? (
<div className="loading">加载中...</div>
) : error ? (
<div className="error">{error}</div>
) : currentWorldBook ? (
<div className="entries-container">
{currentEntries.length > 0 ? (
currentEntries.map(entry => (
<div
key={entry.uid}
className={`entry-item ${currentEntry?.uid === entry.uid ? 'active' : ''}`}
onClick={() => handleEntryClick(entry)}
>
<div className="entry-header">
<span className="entry-name">
{entry.comment || `条目 #${entry.uid}`}
</span>
<span className="entry-status">
{entry.trigger_config.triggers.constant[0] ? '常驻' : '触发'}
</span>
</div>
<div className="compact-params">
<div className="param-item">
<span className="param-label">位置:</span>
<span className="param-value">{getPositionInfo(entry.position).label}</span>
</div>
<div className="param-item">
<span className="param-label">权重:</span>
<span className="param-value">{getPositionInfo(entry.position).weight}</span>
</div>
<div className="param-item">
<span className="param-label">顺序:</span>
<span className="param-value">{entry.order}</span>
</div>
<div className="param-item">
<span className="param-label">深度:</span>
<span className="param-value">{entry.depth}</span>
</div>
</div>
</div>
))
) : (
<div className="loading">暂无条目</div>
)}
<button className="btn btn-primary" onClick={handleAddEntry}>
+ 添加条目
</button>
</div>
) : (
<div className="loading">请选择一个世界书</div>
)}
</div>
</div>
{/* 编辑面板 */}
{showEditPanel && currentEntry && (
<div className={`edit-panel ${showEditPanel ? 'open' : ''}`}>
<div className="edit-panel-header">
<h2>编辑条目</h2>
<button className="close-btn" onClick={() => setShowEditPanel(false)}>
</button>
</div>
<div className="form-group">
<label className="form-label">条目名称</label>
<input
type="text"
className="form-input"
value={currentEntry.comment || ''}
onChange={(e) => handleEntryUpdate('comment', e.target.value)}
/>
</div>
<div className="form-group">
<label className="form-label">内容</label>
<textarea
className="form-input"
value={currentEntry.content || ''}
onChange={(e) => handleEntryUpdate('content', e.target.value)}
rows={10}
/>
</div>
<div className="form-group">
<label className="form-label">插入位置</label>
<div className="position-selector">
{[0, 1, 2, 3, 4, 5].map(pos => {
const info = getPositionInfo(pos);
return (
<div
key={pos}
className={`position-option ${currentEntry.position === pos ? 'active' : ''}`}
onClick={() => handleEntryUpdate('position', pos)}
>
<div className="position-tooltip" data-tooltip={info.desc}>
<span className="position-label">{info.label}</span>
<span className="position-weight">{info.weight}</span>
</div>
</div>
);
})}
</div>
</div>
<div className="form-group">
<label className="form-label">顺序权重</label>
<input
type="number"
className="form-input"
value={currentEntry.order || 100}
onChange={(e) => handleEntryUpdate('order', parseInt(e.target.value))}
/>
</div>
<div className="form-group">
<label className="form-label">扫描深度</label>
<input
type="number"
className="form-input"
value={currentEntry.depth || 4}
onChange={(e) => handleEntryUpdate('depth', parseInt(e.target.value))}
/>
</div>
<div className="form-group">
<label className="form-label">角色匹配</label>
<select
className="form-input"
value={currentEntry.role || 0}
onChange={(e) => handleEntryUpdate('role', parseInt(e.target.value))}
>
<option value={0}>Both</option>
<option value={1}>User</option>
<option value={2}>Assistant</option>
</select>
</div>
{/* 触发策略选择器 */}
<div className="form-group">
<label className="form-label">触发策略</label>
<div className="trigger-strategy-selector">
<button
className={`trigger-strategy-btn ${activeTriggerStrategy === 'constant' ? 'active' : ''}`}
onClick={() => handleTriggerStrategyChange('constant')}
>
常驻触发
</button>
<button
className={`trigger-strategy-btn ${activeTriggerStrategy === 'keyword' ? 'active' : ''}`}
onClick={() => handleTriggerStrategyChange('keyword')}
>
关键词触发
</button>
<button
className={`trigger-strategy-btn ${activeTriggerStrategy === 'rag' ? 'active' : ''}`}
onClick={() => handleTriggerStrategyChange('rag')}
>
RAG触发
</button>
<button
className={`trigger-strategy-btn ${activeTriggerStrategy === 'condition' ? 'active' : ''}`}
onClick={() => handleTriggerStrategyChange('condition')}
>
条件触发
</button>
</div>
</div>
{/* 根据选择的触发策略显示对应的配置表单 */}
{activeTriggerStrategy === 'keyword' && (
<>
<div className="form-group">
<label className="form-label">主关键词</label>
<input
type="text"
className="form-input"
value={currentEntry.trigger_config?.triggers?.keyword?.[1]?.key?.join(', ') || ''}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
triggers: {
...currentEntry.trigger_config?.triggers,
keyword: [
true,
{
...currentEntry.trigger_config?.triggers?.keyword?.[1],
key: e.target.value.split(',').map(k => k.trim())
}
]
}
};
handleEntryUpdate('trigger_config', updatedTriggerConfig);
}}
/>
</div>
<div className="form-group">
<label className="form-label">次要关键词</label>
<input
type="text"
className="form-input"
value={currentEntry.trigger_config?.triggers?.keyword?.[1]?.keysecondary?.join(', ') || ''}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
triggers: {
...currentEntry.trigger_config?.triggers,
keyword: [
true,
{
...currentEntry.trigger_config?.triggers?.keyword?.[1],
keysecondary: e.target.value.split(',').map(k => k.trim())
}
]
}
};
handleEntryUpdate('trigger_config', updatedTriggerConfig);
}}
/>
</div>
<div className="form-group">
<label className="form-label">
<input
type="checkbox"
checked={currentEntry.trigger_config?.triggers?.keyword?.[1]?.selective || false}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
triggers: {
...currentEntry.trigger_config?.triggers,
keyword: [
true,
{
...currentEntry.trigger_config?.triggers?.keyword?.[1],
selective: e.target.checked
}
]
}
};
handleEntryUpdate('trigger_config', updatedTriggerConfig);
}}
/>
选择性匹配
</label>
</div>
<div className="form-group">
<label className="form-label">
<input
type="checkbox"
checked={currentEntry.trigger_config?.triggers?.keyword?.[1]?.matchWholeWords || false}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
triggers: {
...currentEntry.trigger_config?.triggers,
keyword: [
true,
{
...currentEntry.trigger_config?.triggers?.keyword?.[1],
matchWholeWords: e.target.checked
}
]
}
};
handleEntryUpdate('trigger_config', updatedTriggerConfig);
}}
/>
全词匹配
</label>
</div>
<div className="form-group">
<label className="form-label">
<input
type="checkbox"
checked={currentEntry.trigger_config?.triggers?.keyword?.[1]?.caseSensitive || false}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
triggers: {
...currentEntry.trigger_config?.triggers,
keyword: [
true,
{
...currentEntry.trigger_config?.triggers?.keyword?.[1],
caseSensitive: e.target.checked
}
]
}
};
handleEntryUpdate('trigger_config', updatedTriggerConfig);
}}
/>
区分大小写
</label>
</div>
</>
)}
{activeTriggerStrategy === 'rag' && (
<>
<div className="form-group">
<label className="form-label">相似度阈值</label>
<input
type="number"
step="0.01"
min="0"
max="1"
className="form-input"
value={currentEntry.trigger_config?.triggers?.rag?.[1]?.threshold || 0.75}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
triggers: {
...currentEntry.trigger_config?.triggers,
rag: [
true,
{
...currentEntry.trigger_config?.triggers?.rag?.[1],
threshold: parseFloat(e.target.value)
}
]
}
};
handleEntryUpdate('trigger_config', updatedTriggerConfig);
}}
/>
</div>
<div className="form-group">
<label className="form-label">返回条目数</label>
<input
type="number"
min="1"
className="form-input"
value={currentEntry.trigger_config?.triggers?.rag?.[1]?.top_k || 5}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
triggers: {
...currentEntry.trigger_config?.triggers,
rag: [
true,
{
...currentEntry.trigger_config?.triggers?.rag?.[1],
top_k: parseInt(e.target.value)
}
]
}
};
handleEntryUpdate('trigger_config', updatedTriggerConfig);
}}
/>
</div>
<div className="form-group">
<label className="form-label">查询模板</label>
<textarea
className="form-input"
value={currentEntry.trigger_config?.triggers?.rag?.[1]?.query_template || ''}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
triggers: {
...currentEntry.trigger_config?.triggers,
rag: [
true,
{
...currentEntry.trigger_config?.triggers?.rag?.[1],
query_template: e.target.value
}
]
}
};
handleEntryUpdate('trigger_config', updatedTriggerConfig);
}}
rows={3}
/>
</div>
</>
)}
{activeTriggerStrategy === 'condition' && (
<>
<div className="form-group">
<label className="form-label">变量A</label>
<input
type="text"
className="form-input"
value={currentEntry.trigger_config?.triggers?.condition?.[1]?.variable_a || ''}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
triggers: {
...currentEntry.trigger_config?.triggers,
condition: [
true,
{
...currentEntry.trigger_config?.triggers?.condition?.[1],
variable_a: e.target.value
}
]
}
};
handleEntryUpdate('trigger_config', updatedTriggerConfig);
}}
/>
</div>
<div className="form-group">
<label className="form-label">运算符</label>
<select
className="form-input"
value={currentEntry.trigger_config?.triggers?.condition?.[1]?.operator || '='}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
triggers: {
...currentEntry.trigger_config?.triggers,
condition: [
true,
{
...currentEntry.trigger_config?.triggers?.condition?.[1],
operator: e.target.value
}
]
}
};
handleEntryUpdate('trigger_config', updatedTriggerConfig);
}}
>
<option value="等于">等于</option>
<option value="大于">大于</option>
<option value="小于">小于</option>
<option value="不小于">不小于</option>
<option value="不大于">不大于</option>
<option value="不等于">不等于</option>
<option value="包括">包括</option>
</select>
</div>
<div className="form-group">
<label className="form-label">变量B</label>
<input
type="text"
className="form-input"
value={currentEntry.trigger_config?.triggers?.condition?.[1]?.variable_b || ''}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
triggers: {
...currentEntry.trigger_config?.triggers,
condition: [
true,
{
...currentEntry.trigger_config?.triggers?.condition?.[1],
variable_b: e.target.value
}
]
}
};
handleEntryUpdate('trigger_config', updatedTriggerConfig);
}}
/>
</div>
</>
)}
<button
className="btn btn-danger"
onClick={handleDeleteEntry}
>
删除条目
</button>
</div>
)}
</div>
);
};
export default WorldBook;

View File

@@ -1 +0,0 @@
WorldBook.css

View File

@@ -1,642 +0,0 @@
.worldbook-content {
display: flex;
flex-direction: column;
height: 100%;
padding: 8px;
gap: 8px;
background: #f8f9fa;
overflow-y: auto;
}
/* 全局世界书区域 */
.global-worldbooks-section {
display: flex;
flex-direction: column;
gap: 6px;
}
.global-worldbooks-slot {
background: white;
border-radius: 4px;
border: 1px solid #e9ecef;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
overflow: hidden;
}
.global-books-header {
display: flex;
flex-direction: column;
gap: 3px;
padding: 8px 10px;
background: #f8f9fa;
border-bottom: 1px solid #e9ecef;
}
.title-text {
font-size: 11px;
font-weight: 600;
color: #2c3e50;
}
.active-books-list {
display: flex;
flex-wrap: wrap;
gap: 3px;
}
.active-book-item {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 11px;
color: #4a6cf7;
font-weight: 500;
padding: 3px 6px;
background: rgba(74, 108, 247, 0.1);
border-radius: 3px;
cursor: pointer;
transition: all 0.15s ease;
}
.active-book-item:hover {
background: rgba(74, 108, 247, 0.2);
transform: translateY(-1px);
box-shadow: 0 1px 2px rgba(74, 108, 247, 0.15);
}
.active-book-item .remove-btn {
opacity: 0.7;
cursor: pointer;
transition: opacity 0.15s ease;
}
.active-book-item .remove-btn:hover {
opacity: 1;
color: #e74c3c;
}
/* 操作按钮组 */
.worldbook-actions {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.action-btn {
padding: 4px 8px;
background: white;
border: 1px solid #e9ecef;
border-radius: 3px;
color: #495057;
cursor: pointer;
font-size: 11px;
transition: all 0.15s ease;
font-weight: 500;
flex: 1;
min-width: 50px;
}
.action-btn:hover {
background: #f8f9fa;
border-color: #4a6cf7;
color: #4a6cf7;
transform: translateY(-1px);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.action-btn:active {
transform: translateY(0);
}
/* 世界书选择区域 */
.worldbook-selector {
display: flex;
gap: 6px;
align-items: center;
}
.dropdown {
position: relative;
}
.dropdown-btn {
width: 100%;
padding: 5px 8px;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 3px;
color: #495057;
cursor: pointer;
text-align: left;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11px;
transition: all 0.15s ease;
font-weight: 500;
}
.dropdown-btn:hover {
background: white;
border-color: #ced4da;
}
.dropdown-btn:focus {
outline: none;
border-color: #4a6cf7;
box-shadow: 0 0 0 2px rgba(74, 108, 247, 0.1);
}
.dropdown-menu {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: white;
border: 1px solid #e9ecef;
border-radius: 3px;
margin-top: 3px;
max-height: 180px;
overflow-y: auto;
z-index: 1000;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.dropdown-item {
padding: 5px 8px;
cursor: pointer;
transition: all 0.15s ease;
font-size: 11px;
color: #495057;
font-weight: 500;
}
.dropdown-item:hover {
background: #f8f9fa;
}
.dropdown-item.active {
background: rgba(74, 108, 247, 0.1);
color: #4a6cf7;
}
/* 条目列表区域 */
.entries-container {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 4px;
}
.entry-item {
padding: 8px;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 3px;
cursor: pointer;
transition: all 0.15s ease;
}
.entry-item:hover {
background: white;
border-color: #ced4da;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.entry-item.active {
background: rgba(74, 108, 247, 0.05);
border-color: rgba(74, 108, 247, 0.2);
}
.entry-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 3px;
}
.entry-name {
font-weight: 600;
font-size: 12px;
color: #2c3e50;
}
.entry-status {
font-size: 10px;
color: #6c757d;
padding: 2px 5px;
border-radius: 2px;
background: #f1f3f5;
font-weight: 500;
}
.entry-status.enabled {
color: #4a6cf7;
background: rgba(74, 108, 247, 0.1);
}
.entry-meta {
display: flex;
gap: 8px;
font-size: 10px;
color: #6c757d;
font-weight: 500;
}
/* 编辑面板 */
.edit-panel {
position: fixed;
top: 60px;
right: 0;
bottom: 0;
left: 280px;
background: white;
z-index: 1000;
padding: 12px;
overflow-y: auto;
transform: translateX(100%);
transition: transform 0.2s ease-out;
border-left: 1px solid #e9ecef;
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
}
.edit-panel.open {
transform: translateX(0);
}
.edit-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #e9ecef;
}
.edit-panel-header h2 {
font-size: 13px;
color: #2c3e50;
margin: 0;
font-weight: 600;
}
.close-btn {
background: none;
border: none;
color: #adb5bd;
font-size: 18px;
cursor: pointer;
padding: 0;
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.15s ease;
}
.close-btn:hover {
color: #495057;
}
.form-group {
margin-bottom: 10px;
}
.form-label {
display: block;
margin-bottom: 4px;
font-size: 11px;
color: #495057;
font-weight: 500;
}
.form-input,
.form-textarea,
.form-select {
width: 100%;
padding: 5px 8px;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 3px;
color: #495057;
font-size: 11px;
transition: all 0.15s ease;
}
.form-input:focus,
.form-textarea:focus,
.form-select:focus {
outline: none;
border-color: #4a6cf7;
box-shadow: 0 0 0 2px rgba(74, 108, 247, 0.1);
}
.form-textarea {
min-height: 80px;
resize: vertical;
font-family: inherit;
line-height: 1.4;
}
.form-checkbox {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 11px;
color: #495057;
font-weight: 500;
}
.form-checkbox input {
cursor: pointer;
}
/* 按钮样式 */
.btn {
padding: 5px 10px;
border: none;
border-radius: 3px;
cursor: pointer;
font-size: 11px;
transition: all 0.15s ease;
font-weight: 500;
}
.btn-primary {
background: #4a6cf7;
color: white;
}
.btn-primary:hover {
background: #3a5ce5;
transform: translateY(-1px);
box-shadow: 0 1px 2px rgba(74, 108, 247, 0.15);
}
.btn-danger {
background: #e74c3c;
color: white;
}
.btn-danger:hover {
background: #c0392b;
transform: translateY(-1px);
box-shadow: 0 1px 2px rgba(231, 76, 60, 0.15);
}
/* 加载和错误状态 */
.loading,
.error {
text-align: center;
padding: 16px;
font-size: 11px;
font-weight: 500;
}
.loading {
color: #6c757d;
}
.error {
color: #e74c3c;
background: rgba(231, 76, 60, 0.1);
border-radius: 3px;
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 5px;
height: 5px;
}
::-webkit-scrollbar-track {
background: #f1f3f5;
border-radius: 2px;
}
::-webkit-scrollbar-thumb {
background: #ced4da;
border-radius: 2px;
}
::-webkit-scrollbar-thumb:hover {
background: #adb5bd;
}
/* 插入位置权重提示 */
.position-tooltip {
position: relative;
display: inline-block;
cursor: help;
}
.position-tooltip::after {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
padding: 6px 10px;
background: rgba(0, 0, 0, 0.85);
color: white;
font-size: 11px;
font-weight: 500;
border-radius: 4px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
z-index: 1000;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
margin-bottom: 6px;
}
.position-tooltip:hover::after {
opacity: 1;
visibility: visible;
}
/* 紧凑的参数显示 */
.compact-params {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.param-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 10px;
color: #6c757d;
}
.param-label {
font-weight: 500;
}
.param-value {
color: #495057;
}
/* 响应式设计 */
@media (max-width: 768px) {
.worldbook-content {
padding: 6px;
gap: 6px;
}
.edit-panel {
left: 0;
top: 50px;
}
.compact-params {
gap: 6px;
}
}
/* 编辑面板打开状态优化 */
.edit-panel.open {
transform: translateX(0);
box-shadow: -4px 0 12px rgba(0, 0, 0, 0.15);
}
/* 位置选择器样式 */
.position-selector {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6px;
}
.position-option {
padding: 6px 8px;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 3px;
cursor: pointer;
transition: all 0.15s ease;
}
.position-option:hover {
background: white;
border-color: #ced4da;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.position-option.active {
background: rgba(74, 108, 247, 0.05);
border-color: rgba(74, 108, 247, 0.2);
}
.position-label {
display: block;
font-size: 11px;
font-weight: 500;
color: #2c3e50;
margin-bottom: 2px;
}
.position-weight {
display: inline-block;
padding: 1px 4px;
font-size: 10px;
font-weight: 600;
border-radius: 2px;
background: #f1f3f5;
color: #6c757d;
}
.position-option.active .position-weight {
background: rgba(74, 108, 247, 0.1);
color: #4a6cf7;
}
/* 触发策略选择器样式 */
.trigger-strategy-selector {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.trigger-strategy-btn {
flex: 1;
min-width: 80px;
padding: 5px 8px;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 3px;
cursor: pointer;
font-size: 11px;
font-weight: 500;
color: #495057;
transition: all 0.15s ease;
}
.trigger-strategy-btn:hover {
background: white;
border-color: #ced4da;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.trigger-strategy-btn.active {
background: rgba(74, 108, 247, 0.05);
border-color: rgba(74, 108, 247, 0.2);
color: #4a6cf7;
}
/* 编辑面板内容区域优化 */
.edit-panel .form-group {
margin-bottom: 8px;
}
.edit-panel .form-label {
margin-bottom: 3px;
}
.edit-panel .form-input,
.edit-panel .form-textarea,
.edit-panel .form-select {
padding: 4px 7px;
}
.edit-panel .form-textarea {
min-height: 60px;
line-height: 1.3;
}
/* 编辑面板按钮优化 */
.edit-panel .btn {
width: 100%;
margin-top: 8px;
}
/* 权重标签颜色区分 */
.weight-high {
background: rgba(231, 76, 60, 0.1);
color: #e74c3c;
}
.weight-medium {
background: rgba(241, 196, 15, 0.1);
color: #f39c12;
}
.weight-low {
background: rgba(52, 152, 219, 0.1);
color: #3498db;
}
.weight-extreme {
background: rgba(192, 57, 43, 0.1);
color: #c0392b;
}
.weight-maximum {
background: rgba(142, 68, 173, 0.1);
color: #8e44ad;
}

View File

@@ -1,100 +0,0 @@
.sidebar-right {
width: 300px;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background-color: #ffffff;
box-shadow: -2px 0 5px rgba(0, 0, 0, 0.05);
border-left: 1px solid #e8e8e8;
transition: width 0.3s ease;
}
.sidebar-tabs {
display: flex;
border-bottom: 1px solid #e8e8e8;
flex-shrink: 0;
background-color: #fafafa;
padding: 0 10px;
}
.tab-button {
flex: 1;
padding: 12px 5px;
background: none;
border: none;
cursor: pointer;
transition: all 0.3s ease;
font-size: 14px;
color: #555;
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.tab-button:hover {
background-color: #f0f0f0;
color: #333;
}
.tab-button.active {
color: #4a90e2;
font-weight: 600;
}
.tab-button.active::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 3px;
background-color: #4a90e2;
border-radius: 3px 3px 0 0;
}
.sidebar-content {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.tab-content {
flex: 1;
overflow-y: auto;
padding: 15px;
border-bottom: 1px solid #f0f0f0;
}
.tab-content:last-child {
border-bottom: none;
}
.tab-content.full-height {
flex: 1;
}
/* 自定义滚动条样式 */
.sidebar-content::-webkit-scrollbar,
.tab-content::-webkit-scrollbar {
width: 6px;
}
.sidebar-content::-webkit-scrollbar-track,
.tab-content::-webkit-scrollbar-track {
background: #f1f1f1;
}
.sidebar-content::-webkit-scrollbar-thumb,
.tab-content::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.sidebar-content::-webkit-scrollbar-thumb:hover,
.tab-content::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}

View File

@@ -1,48 +0,0 @@
import React, { useEffect } from 'react';
import './SideBarRight.css';
import Dice from './tab/Dice';
import Debug from './tab/Debug';
import Macros from './tab/Macros';
import Table from './tab/Table';
import useSideBarRightStore from '../../Store/Slices/RightTabsSlices/SideBarRightSlice';
const SideBarRight = () => {
const { selectedTabs, allTabs, handleTabClick, setTabComponent } = useSideBarRightStore();
// 设置标签组件
useEffect(() => {
setTabComponent('dice', Dice);
setTabComponent('debug', Debug);
setTabComponent('macros', Macros);
setTabComponent('table', Table);
}, [setTabComponent]);
return (
<div className="sidebar-right">
<div className="sidebar-tabs">
{allTabs.map(tab => (
<button
key={tab.id}
className={`tab-button ${selectedTabs.includes(tab.id) ? 'active' : ''}`}
onClick={() => handleTabClick(tab.id)}
>
{tab.label}
</button>
))}
</div>
<div className="sidebar-content">
{selectedTabs.map(tabId => {
const tab = allTabs.find(t => t.id === tabId);
return (
<div key={tabId} className={`tab-content ${selectedTabs.length === 1 ? 'full-height' : ''}`}>
{tab.component ? <tab.component /> : <div className="tab-content">{tab.label}内容</div>}
</div>
);
})}
</div>
</div>
);
};
export default SideBarRight;

View File

@@ -1,12 +0,0 @@
import React from 'react';
const Dice = () => {
return (
<div className="dice-panel">
<h2>骰子面板</h2>
<p>这是骰子面板的占位页面</p>
</div>
);
};
export default Dice;

View File

@@ -1,164 +0,0 @@
/* ==================== 顶部工具栏区域 ==================== */
.toolbar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 50px;
background-color: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 1000;
display: flex;
align-items: center;
padding: 0 20px;
justify-content: space-between;
transition: all 0.3s ease;
}
/* 工具栏主要部分容器 */
.toolbar-section {
display: flex;
align-items: center;
gap: 15px;
flex: 1;
}
/* 工具栏图标容器 */
.toolbar-icons {
display: flex;
align-items: center;
gap: 15px;
flex: 1;
}
/* 工具栏图标 */
.toolbar-icon {
height: 36px;
padding: 0 12px;
border-radius: 6px;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
transition: all 0.2s ease;
font-size: 18px;
color: #555;
background-color: #f5f5f5;
white-space: nowrap;
}
.toolbar-icon:hover {
background-color: #e6f7ff;
color: #1890ff;
transform: translateY(-2px);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}
.toolbar-icon.active {
background-color: #1890ff;
color: #fff;
}
/* 图标标签文本 */
.icon-label {
font-size: 14px;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ==================== 弹出面板通用样式 ==================== */
.close-panel-button {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: #666;
padding: 5px 10px;
margin-left: auto;
transition: color 0.2s;
}
.close-panel-button:hover {
color: #333;
}
.panel-overlay {
position: fixed;
top: 50px;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.3);
z-index: 999;
display: flex;
justify-content: center;
padding-top: 20px;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.panel-content {
background-color: #fff;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
width: 90%;
max-width: 1200px;
max-height: calc(100vh - 80px);
overflow: hidden;
display: flex;
flex-direction: column;
animation: slideDown 0.3s ease;
}
@keyframes slideDown {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.panel-header {
padding: 16px 20px;
border-bottom: 1px solid #e0e0e0;
display: flex;
justify-content: space-between;
align-items: center;
background-color: #fafafa;
}
.panel-header h3 {
margin: 0;
font-size: 16px;
font-weight: 500;
color: #333;
}
.panel-body {
padding: 20px;
overflow-y: auto;
flex: 1;
}
/* 主内容区域 */
.main-container {
margin-top: 50px;
height: calc(100vh - 50px);
display: flex;
overflow: hidden;
}

View File

@@ -1,223 +0,0 @@
// frontend-react/src/components/ToolBar/ToolBar.jsx
import React, { useState, useRef, useEffect } from 'react';
import RoleSelector from '../RoleSelector/RoleSelector';
import useRoleSelectorStore from '../../Store/Slices/RoleSelectorSlice';
import './ToolBar.css';
const Toolbar = () => {
const [activePanel, setActivePanel] = useState(null);
const panelRef = useRef(null);
const selectedRole = useRoleSelectorStore((state) => state.selectedRole);
const selectedChat = useRoleSelectorStore((state) => state.selectedChat);
// 点击外部关闭面板
React.useEffect(() => {
const handleClickOutside = (event) => {
if (panelRef.current && !panelRef.current.contains(event.target)) {
setActivePanel(null);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
// 监听selectedRole和selectedChat的变化
useEffect(() => {
console.log('当前选中的角色:', selectedRole);
console.log('当前选中的聊天:', selectedChat);
// 这里可以添加其他需要响应角色变化的逻辑
}, [selectedRole, selectedChat]);
// 处理面板切换
const handlePanelToggle = (panelName) => {
if (activePanel === panelName) {
setActivePanel(null);
} else {
setActivePanel(panelName);
}
};
// 关闭面板
const handleClosePanel = () => {
setActivePanel(null);
};
// 截断文本
const truncateText = (text, maxLength = 20) => {
if (!text) return '未选择';
return text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
};
// 构建显示文本
const getDisplayText = () => {
if (!selectedRole) return '未选择';
return selectedChat ? `${selectedRole} / ${selectedChat}` : selectedRole;
};
return (
<>
<div className="toolbar">
{/* 左侧:当前角色 */}
<div className="toolbar-section">
<div className="toolbar-icons">
<div
className="toolbar-icon"
title="玩家角色"
onClick={() => handlePanelToggle('currentRole')}
>
👤
<span className="icon-label">当前角色</span>
</div>
<div className="toolbar-display-box">
{truncateText(getDisplayText())}
</div>
</div>
</div>
{/* 中间:角色管理 */}
<div className="toolbar-section">
<div className="toolbar-icons">
<div
className={`toolbar-icon ${activePanel === 'role' ? 'active' : ''}`}
title="ai角色"
onClick={() => handlePanelToggle('role')}
>
🎭
<span className="icon-label">角色管理</span>
</div>
<div className="toolbar-display-box">
{truncateText(getDisplayText())}
</div>
</div>
</div>
{/* 全局世界书 */}
<div className="toolbar-section">
<div className="toolbar-icons">
<div
className="toolbar-icon"
title="全局世界书"
onClick={() => handlePanelToggle('worldBook')}
>
📚
<span className="icon-label">全局世界书</span>
</div>
<div className="toolbar-display-box">
全局世界书
</div>
</div>
</div>
{/* 右侧:设置和拓展 */}
<div className="toolbar-section">
<div className="toolbar-icons" style={{ justifyContent: 'flex-end' }}>
<div
className="toolbar-icon"
title="设置"
onClick={() => handlePanelToggle('settings')}
>
</div>
<div
className="toolbar-icon"
title="拓展"
onClick={() => handlePanelToggle('extensions')}
>
</div>
</div>
</div>
</div>
{/* 角色管理面板 */}
{activePanel === 'role' && (
<div className="panel-overlay" ref={panelRef}>
<div className="panel-content">
<div className="panel-header">
<h3>用户角色管理</h3>
<button className="close-panel-button" onClick={handleClosePanel} title="关闭">
</button>
</div>
<div className="panel-body">
<RoleSelector />
</div>
</div>
</div>
)}
{/* 当前角色面板(暂时留空) */}
{activePanel === 'currentRole' && (
<div className="panel-overlay" ref={panelRef}>
<div className="panel-content">
<div className="panel-header">
<h3>当前ai角色</h3>
<button className="close-panel-button" onClick={handleClosePanel} title="关闭">
</button>
</div>
<div className="panel-body">
<p>当前角色详情...</p>
</div>
</div>
</div>
)}
{/* 全局世界书面板 */}
{activePanel === 'worldBook' && (
<div className="panel-overlay" ref={panelRef}>
<div className="panel-content">
<div className="panel-header">
<h3>全局世界书</h3>
<button className="close-panel-button" onClick={handleClosePanel} title="关闭">
</button>
</div>
<div className="panel-body">
<p>全局世界书内容...</p>
</div>
</div>
</div>
)}
{/* 设置面板 */}
{activePanel === 'settings' && (
<div className="panel-overlay" ref={panelRef}>
<div className="panel-content">
<div className="panel-header">
<h3>系统设置</h3>
<button className="close-panel-button" onClick={handleClosePanel} title="关闭">
</button>
</div>
<div className="panel-body">
<p>系统设置内容...</p>
</div>
</div>
</div>
)}
{/* 拓展面板 */}
{activePanel === 'extensions' && (
<div className="panel-overlay" ref={panelRef}>
<div className="panel-content">
<div className="panel-header">
<h3>功能拓展</h3>
<button className="close-panel-button" onClick={handleClosePanel} title="关闭">
</button>
</div>
<div className="panel-body">
<p>功能拓展内容...</p>
</div>
</div>
</div>
)}
</>
);
};
export default Toolbar;

View File

@@ -1,21 +0,0 @@
/* 左侧栏 */
.sidebar-left {
width: 22.5%; /* 修改为30% */
height: 100%;
overflow: hidden;
}
/* 中间聊天区域 */
.chat-area {
flex: 55%; /* 修改为0.4 */
height: 100%;
display: flex;
overflow: hidden;
}
/* 右侧栏 */
.sidebar-right {
width: 22.5%; /* 修改为30% */
height: 100%;
overflow: hidden;
}

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