完成大量美化

This commit is contained in:
2026-05-01 15:44:14 +08:00
parent 6b65b24b0f
commit f0e7e75ffb
36 changed files with 4393 additions and 1506 deletions

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与后端数据保持一致
**用户体验**: 不再显示"幽灵"世界书
**代码健壮**: 增加了数据一致性检查机制

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组件而非浏览器原生弹窗。

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

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

@@ -105,13 +105,22 @@ async def delete_worldbook(name: str):
logger.error(f"Failed to delete worldbook '{name}': {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{name}/entries", response_model=List[Dict[str, Any]])
async def list_worldbook_entries(name: str):
@router.get("/{name}/entries", response_model=Dict[str, Any])
async def list_worldbook_entries(
name: str,
page: int = 1,
page_size: int = 20
):
"""
获取世界书的所有条目
获取世界书的条目列表(支持分页)
Args:
name: 世界书名称
page: 页码从1开始
page_size: 每页数量默认20
"""
try:
return worldbook_service.list_entries(name)
return worldbook_service.list_entries(name, page, page_size)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:

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

@@ -60,10 +60,14 @@ class WorldBookService:
with open(json_file, 'r', encoding='utf-8') as f:
data = json.load(f)
# 内部格式entries 是列表
entries = data.get("entries", [])
entries_count = len(entries) if isinstance(entries, list) else 0
worldbooks.append({
"name": data.get("name", json_file.stem),
"description": data.get("description", ""),
"entries_count": len(data.get("entries", [])),
"entries_count": entries_count,
"createdAt": data.get("createdAt", 0),
"updatedAt": data.get("updatedAt", 0)
})
@@ -165,21 +169,41 @@ class WorldBookService:
return True
@staticmethod
def list_entries(name: str) -> List[Dict[str, Any]]:
def list_entries(name: str, page: int = 1, page_size: int = 20) -> Dict[str, Any]:
"""
获取世界书的所有条目
获取世界书的条目列表(支持分页)
Args:
name: 世界书名称
page: 页码从1开始
page_size: 每页数量默认20
Returns:
条目列表
包含条目列表和分页信息的字典
"""
data = WorldBookService._load_worldbook(name)
if not data:
raise FileNotFoundError(f"Worldbook '{name}' not found")
return data.get("entries", [])
# 内部格式entries 是列表
all_entries = data.get("entries", [])
if not isinstance(all_entries, list):
all_entries = []
total = len(all_entries)
# 计算分页
start_idx = (page - 1) * page_size
end_idx = start_idx + page_size
paginated_entries = all_entries[start_idx:end_idx]
return {
"entries": paginated_entries,
"total": total,
"page": page,
"page_size": page_size,
"total_pages": (total + page_size - 1) // page_size # 向上取整
}
@staticmethod
def get_entry(name: str, uid: str) -> Dict[str, Any]:
@@ -197,8 +221,13 @@ class WorldBookService:
if not data:
raise FileNotFoundError(f"Worldbook '{name}' not found")
for entry in data.get("entries", []):
if entry.get("uid") == uid:
# 内部格式entries 是列表
entries = data.get("entries", [])
if not isinstance(entries, list):
entries = []
for entry in entries:
if entry.get("uid") == uid or str(entry.get("uid")) == uid:
return entry
raise FileNotFoundError(f"Entry '{uid}' not found in worldbook '{name}'")

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 后缀,确认无误后可删除")

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

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

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,242 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
const useCharacterStore = create(
persist(
(set, get) => ({
// 状态
characters: [], // 所有角色卡列表
selectedCharacter: null, // 当前选中的角色
isLoading: false,
error: null,
currentPage: 1, // 当前页码
pageSize: 12, // 每页显示数量
characterChats: {}, // 存储每个角色的聊天记录 {characterName: [chats]}
// 获取所有角色卡
fetchCharacters: async () => {
set({ isLoading: true, error: null });
try {
const response = await fetch('/api/characters/');
if (!response.ok) throw new Error('Failed to fetch characters');
const data = await response.json();
set({
characters: data,
isLoading: false,
currentPage: 1 // 重置到第一页
});
} catch (error) {
set({
error: error.message,
isLoading: false
});
}
},
// 设置当前页码
setCurrentPage: (page) => {
set({ currentPage: page });
},
// 设置每页显示数量
setPageSize: (size) => {
set({ pageSize: size, currentPage: 1 }); // 重置到第一页
},
// 获取当前页的角色数据
getCurrentPageCharacters: () => {
const { characters, currentPage, pageSize } = get();
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
return characters.slice(startIndex, endIndex);
},
// 获取总页数
getTotalPages: () => {
const { characters, pageSize } = get();
return Math.ceil(characters.length / pageSize);
},
// 获取角色的聊天记录
fetchCharacterChats: async (characterName) => {
const { characterChats } = get();
// 如果已经缓存了,直接返回
if (characterChats[characterName]) {
return characterChats[characterName];
}
try {
const response = await fetch(`/api/characters/${encodeURIComponent(characterName)}/chats`);
if (!response.ok) throw new Error('Failed to fetch chats');
const data = await response.json();
// 更新缓存
set((state) => ({
characterChats: {
...state.characterChats,
[characterName]: data.chats || []
}
}));
return data.chats || [];
} catch (error) {
console.error('获取聊天记录失败:', error);
return [];
}
},
// 选择角色
selectCharacter: (character) => {
// 使用函数式更新避免不必要的重渲染
set((state) => {
// 如果选中的是同一个角色,不更新状态
if (state.selectedCharacter?.id === character.id) {
return state;
}
return { selectedCharacter: character };
});
},
// 创建角色
createCharacter: async (characterData) => {
try {
const response = await fetch('/api/characters/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(characterData)
});
if (!response.ok) throw new Error('Failed to create character');
const data = await response.json();
// 刷新列表
await get().fetchCharacters();
return data.character;
} catch (error) {
set({ error: error.message });
throw error;
}
},
// 更新角色
updateCharacter: async (name, updates) => {
try {
const response = await fetch(`/api/characters/${encodeURIComponent(name)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
});
if (!response.ok) throw new Error('Failed to update character');
// 刷新列表
await get().fetchCharacters();
} catch (error) {
set({ error: error.message });
throw error;
}
},
// 删除角色
deleteCharacter: async (name) => {
try {
const response = await fetch(`/api/characters/${encodeURIComponent(name)}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Failed to delete character');
// 刷新列表
await get().fetchCharacters();
} catch (error) {
set({ error: error.message });
throw error;
}
},
// 上传头像
uploadAvatar: async (name, file) => {
try {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`/api/characters/${encodeURIComponent(name)}/avatar`, {
method: 'POST',
body: formData
});
if (!response.ok) throw new Error('Failed to upload avatar');
// 刷新列表以更新头像路径
await get().fetchCharacters();
} catch (error) {
set({ error: error.message });
throw error;
}
},
// 导入角色卡
importCharacter: async (file) => {
try {
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/characters/import', {
method: 'POST',
body: formData
});
if (!response.ok) throw new Error('Failed to import character');
const data = await response.json();
// 刷新列表
await get().fetchCharacters();
return data.character;
} catch (error) {
set({ error: error.message });
throw error;
}
},
// 导出角色卡为 PNG
exportCharacterAsPng: async (name) => {
try {
const response = await fetch(`/api/characters/${encodeURIComponent(name)}/export/png`, {
method: 'POST'
});
if (!response.ok) throw new Error('Failed to export character');
// 下载文件
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${name}.png`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (error) {
set({ error: error.message });
throw error;
}
}
}),
{
name: 'character-storage',
partialize: (state) => ({
selectedCharacter: state.selectedCharacter
})
}
)
);
export default useCharacterStore;

View File

@@ -53,6 +53,12 @@ const useWorldBookStore = create((set, get) => ({
currentWorldBook: null, // 当前选中的世界书
currentEntries: [], // 当前世界书的条目列表
currentEntry: null, // 当前选中的条目
entriesPagination: { // 条目分页信息
total: 0,
page: 1,
page_size: 20,
total_pages: 0
},
loading: false, // 加载状态
error: null, // 错误信息
success: false, // 操作成功状态
@@ -116,8 +122,7 @@ const useWorldBookStore = create((set, get) => ({
return {
loading: false,
globalWorldBooks: updatedGlobalBooks,
success: true,
message: isGlobal ? `已将 "${name}" 设为全局世界书` : `已取消 "${name}" 的全局状态`
error: null
};
});
},
@@ -130,7 +135,18 @@ const useWorldBookStore = create((set, get) => ({
const data = await handleResponse(response);
// 从 LocalStorage 获取全局世界书列表
const globalBooks = loadGlobalWorldBooks();
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;
}
set({
loading: false,
@@ -333,18 +349,24 @@ const useWorldBookStore = create((set, get) => ({
}
},
// 异步操作:获取世界书的所有条目
fetchWorldBookEntries: async (name) => {
// 异步操作:获取世界书的所有条目(支持分页)
fetchWorldBookEntries: async (name, page = 1, page_size = 20) => {
set({ loading: true, error: null });
try {
const response = await fetch(`/api/worldbooks/${name}/entries`);
const response = await fetch(`/api/worldbooks/${name}/entries?page=${page}&page_size=${page_size}`);
const data = await handleResponse(response);
set(state => {
if (state.currentWorldBook?.name === name) {
return {
loading: false,
currentEntries: data,
currentEntries: data.entries || [],
entriesPagination: {
total: data.total || 0,
page: data.page || 1,
page_size: data.page_size || 20,
total_pages: data.total_pages || 0
},
error: null
};
}

View File

@@ -754,3 +754,65 @@
flex-direction: column;
gap: 8px;
}
/* ==================== 分页控件样式 ==================== */
.pagination-controls {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
margin-top: 8px;
border-top: 1px solid var(--color-border-light);
}
.pagination-info {
font-size: 12px;
color: var(--color-text-secondary);
font-weight: 500;
}
.pagination-buttons {
display: flex;
gap: 8px;
align-items: center;
}
.btn-pagination {
padding: 6px 12px;
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: 4px;
color: var(--color-text-primary);
cursor: pointer;
font-size: 12px;
transition: all 0.15s ease;
font-weight: 500;
}
.btn-pagination:hover:not(:disabled) {
background: var(--color-accent);
border-color: var(--color-accent);
color: white;
}
.btn-pagination:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.page-size-select {
padding: 6px 10px;
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: 4px;
color: var(--color-text-primary);
font-size: 12px;
cursor: pointer;
transition: all 0.15s ease;
}
.page-size-select:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 2px var(--color-accent-light);
}

View File

@@ -10,6 +10,7 @@ const WorldBook = () => {
currentWorldBook,
currentEntries,
currentEntry,
entriesPagination,
loading,
error,
success,
@@ -87,6 +88,13 @@ const WorldBook = () => {
prevActiveTabRef.current = activeTab;
}, [activeTab, fetchWorldBooks]);
// 组件挂载时立即加载世界书列表
useEffect(() => {
if (worldBooks.length === 0) {
fetchWorldBooks();
}
}, []);
useEffect(() => {
if (success && message) {
alert(message);
@@ -134,8 +142,10 @@ const WorldBook = () => {
const handleSelectWorldBook = async (book) => {
setCurrentWorldBook(book);
setShowWorldBookDropdown(false);
// 重置分页到第一页
setCurrentPage(1);
try {
await fetchWorldBookEntries(book.name);
await fetchWorldBookEntries(book.name, 1, pageSize);
} catch (err) {
console.error('加载世界书条目失败:', err);
}
@@ -192,6 +202,8 @@ const WorldBook = () => {
try {
await createWorldBookEntry(currentWorldBook.name, entryData);
// 添加成功后刷新当前页
await fetchWorldBookEntries(currentWorldBook.name, currentPage, pageSize);
setNewEntry({
uid: 0,
content: '',
@@ -234,6 +246,29 @@ const WorldBook = () => {
setShowEditPanel(true);
};
// 处理页码变化
const handlePageChange = async (newPage) => {
if (!currentWorldBook) return;
setCurrentPage(newPage);
try {
await fetchWorldBookEntries(currentWorldBook.name, newPage, pageSize);
} catch (err) {
console.error('加载世界书条目失败:', err);
}
};
// 处理每页数量变化
const handlePageSizeChange = async (newPageSize) => {
if (!currentWorldBook) return;
setPageSize(newPageSize);
setCurrentPage(1); // 重置到第一页
try {
await fetchWorldBookEntries(currentWorldBook.name, 1, newPageSize);
} catch (err) {
console.error('加载世界书条目失败:', err);
}
};
const handleEntryUpdate = async (field, value) => {
if (!currentEntry || !currentWorldBook) return;
@@ -241,6 +276,8 @@ const WorldBook = () => {
try {
await updateWorldBookEntry(currentWorldBook.name, currentEntry.uid, updatedEntry);
setCurrentEntry(updatedEntry);
// 更新成功后刷新当前页
await fetchWorldBookEntries(currentWorldBook.name, currentPage, pageSize);
} catch (err) {
console.error('更新条目失败:', err);
}
@@ -301,27 +338,33 @@ const WorldBook = () => {
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);
}
if (!window.confirm('确定要删除此条目吗?')) {
return;
}
try {
await deleteWorldBookEntry(currentWorldBook.name, currentEntry.uid);
setShowEditPanel(false);
setCurrentEntry(null);
// 删除成功后刷新当前页
await fetchWorldBookEntries(currentWorldBook.name, currentPage, pageSize);
} 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);
}
if (!window.confirm(`确定要删除世界书 "${currentWorldBook.name}" 吗?`)) {
return;
}
try {
await deleteWorldBook(currentWorldBook.name);
resetCurrentWorldBook();
} catch (err) {
console.error('删除世界书失败:', err);
}
};
@@ -393,7 +436,7 @@ const WorldBook = () => {
if (importedBook) {
handleSelectWorldBook(importedBook);
// 导入成功后自动加载条目
await fetchWorldBookEntries(name);
await fetchWorldBookEntries(name, 1, pageSize);
console.log('✅ 世界书导入成功,已加载条目');
}
} catch (err) {
@@ -623,6 +666,42 @@ const WorldBook = () => {
) : (
<div className="loading">暂无条目</div>
)}
{/* 分页控件 */}
{entriesPagination.total > 0 && (
<div className="pagination-controls">
<div className="pagination-info">
{entriesPagination.total} {entriesPagination.page}/{entriesPagination.total_pages}
</div>
<div className="pagination-buttons">
<button
className="btn-pagination"
disabled={entriesPagination.page <= 1}
onClick={() => handlePageChange(entriesPagination.page - 1)}
>
上一页
</button>
<button
className="btn-pagination"
disabled={entriesPagination.page >= entriesPagination.total_pages}
onClick={() => handlePageChange(entriesPagination.page + 1)}
>
下一页
</button>
<select
className="page-size-select"
value={pageSize}
onChange={(e) => handlePageSizeChange(parseInt(e.target.value))}
>
<option value={10}>10/</option>
<option value={20}>20/</option>
<option value={50}>50/</option>
<option value={100}>100/</option>
</select>
</div>
</div>
)}
<button className="btn btn-primary" onClick={handleAddEntry}>
+ 添加条目
</button>

View File

@@ -0,0 +1,244 @@
/* Markdown 渲染样式 - 参考 Discord/Telegram 风格 */
.markdown-content {
font-size: 1rem; /* 增大基础字体 */
line-height: 1.8; /* 增加行高,提高可读性 */
word-wrap: break-word;
overflow-wrap: break-word;
}
/* 段落 */
.md-paragraph {
margin: 0.6em 0;
line-height: 1.8;
font-size: 1rem;
}
.md-paragraph:first-child {
margin-top: 0;
}
.md-paragraph:last-child {
margin-bottom: 0;
}
/* 标题 */
.md-heading {
margin: 1.2em 0 0.6em;
font-weight: 600;
line-height: 1.4;
color: var(--color-text-primary);
font-family: var(--font-ui); /* Markdown 标题使用微软雅黑 UI */
}
.md-h1 {
font-size: 1.6rem;
border-bottom: 2px solid var(--color-border-light);
padding-bottom: 0.3em;
}
.md-h2 {
font-size: 1.4rem;
border-bottom: 1px solid var(--color-border-light);
padding-bottom: 0.3em;
}
.md-h3 {
font-size: 1.25rem;
}
.md-h4 {
font-size: 1.1rem;
}
/* 行内代码 */
.inline-code {
background-color: rgba(0, 0, 0, 0.06);
padding: 0.2em 0.4em;
border-radius: 3px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 0.85em;
color: #e83e8c;
}
.message.user .inline-code {
background-color: rgba(255, 255, 255, 0.2);
color: #fff;
}
/* 代码块 */
.code-block-wrapper {
margin: 0.8em 0;
border-radius: 8px;
overflow: hidden;
background-color: #282c34;
}
.code-block-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5em 1em;
background-color: #21252b;
border-bottom: 1px solid #181a1f;
}
.code-language {
font-size: 0.75rem;
color: #abb2bf;
font-weight: 500;
text-transform: lowercase;
}
.copy-button {
display: flex;
align-items: center;
gap: 4px;
padding: 0.3em 0.6em;
background-color: transparent;
border: 1px solid #3e4451;
border-radius: 4px;
color: #abb2bf;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s;
}
.copy-button:hover {
background-color: #3e4451;
color: #fff;
}
/* 引用块 */
.md-blockquote {
margin: 0.8em 0;
padding: 0.6em 1.2em;
border-left: 4px solid var(--color-accent);
background-color: rgba(0, 0, 0, 0.03);
color: var(--color-text-secondary);
border-radius: 0 4px 4px 0;
font-size: 0.95rem;
line-height: 1.7;
}
.message.user .md-blockquote {
background-color: rgba(255, 255, 255, 0.1);
border-left-color: rgba(255, 255, 255, 0.5);
color: rgba(255, 255, 255, 0.9);
}
/* 列表 */
.md-list {
margin: 0.6em 0;
padding-left: 1.8em;
}
.md-list-item {
margin: 0.4em 0;
line-height: 1.7;
font-size: 1rem;
}
.md-list-ordered {
list-style-type: decimal;
}
/* 链接 */
.md-link {
color: var(--color-accent);
text-decoration: none;
border-bottom: 1px solid transparent;
transition: border-color 0.2s;
}
.md-link:hover {
border-bottom-color: var(--color-accent);
}
.message.user .md-link {
color: #fff;
border-bottom-color: rgba(255, 255, 255, 0.3);
}
.message.user .md-link:hover {
border-bottom-color: #fff;
}
/* 图片 */
.md-image {
max-width: 100%;
height: auto;
border-radius: 8px;
margin: 0.5em 0;
}
/* 分隔线 */
.md-divider {
margin: 1.5em 0;
border: none;
border-top: 1px solid var(--color-border-light);
}
.message.user .md-divider {
border-top-color: rgba(255, 255, 255, 0.2);
}
/* 表格 */
.md-table {
width: 100%;
border-collapse: collapse;
margin: 0.8em 0;
font-size: 0.9rem;
}
.md-table-head {
background-color: rgba(0, 0, 0, 0.05);
}
.message.user .md-table-head {
background-color: rgba(255, 255, 255, 0.1);
}
.md-table-row {
border-bottom: 1px solid var(--color-border-light);
}
.md-table-cell {
padding: 0.6em 0.8em;
text-align: left;
}
.md-table-header {
font-weight: 600;
color: var(--color-text-primary);
}
/* KaTeX 数学公式优化 */
.katex {
font-size: 1.05em;
}
.katex-display {
margin: 0.8em 0;
overflow-x: auto;
overflow-y: hidden;
}
/* 响应式优化 */
@media (max-width: 768px) {
.markdown-content {
font-size: 0.9rem;
}
.md-h1 {
font-size: 1.3rem;
}
.md-h2 {
font-size: 1.2rem;
}
.md-h3 {
font-size: 1.1rem;
}
}

View File

@@ -0,0 +1,119 @@
import React, { useState, useCallback } from "react";
import ReactMarkdown from "react-markdown";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex";
import { CopyToClipboard } from "react-copy-to-clipboard";
import "katex/dist/katex.min.css";
// 复制图标
const CopyIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z" />
</svg>
);
// 代码块组件
const CodeBlock = ({ node, inline, className, children, ...props }) => {
const [copied, setCopied] = useState(false);
const match = /language-(\w+)/.exec(className || "");
const codeContent = String(children).replace(/\n$/, "");
// 行内代码
if (inline) {
return (
<code className="inline-code" {...props}>
{children}
</code>
);
}
const handleCopy = () => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="code-block-wrapper">
<div className="code-block-header">
<span className="code-language">{match?.[1] || "text"}</span>
<CopyToClipboard text={codeContent} onCopy={handleCopy}>
<button className="copy-button" title="复制代码">
{copied ? "✓ 已复制" : <><CopyIcon /> 复制</>}
</button>
</CopyToClipboard>
</div>
<SyntaxHighlighter
style={oneDark}
language={match?.[1] || "text"}
PreTag="div"
showLineNumbers
customStyle={{
margin: 0,
borderRadius: "0 0 8px 8px",
fontSize: "0.85rem",
lineHeight: "1.5"
}}
{...props}
>
{codeContent}
</SyntaxHighlighter>
</div>
);
};
// Markdown 渲染组件
const MarkdownRenderer = ({ content, className = "" }) => {
if (!content) return null;
return (
<div className={`markdown-content ${className}`}>
<ReactMarkdown
remarkPlugins={[remarkMath]}
rehypePlugins={[rehypeKatex]}
components={{
code: CodeBlock,
// 自定义段落样式
p: ({ children }) => <p className="md-paragraph">{children}</p>,
// 自定义列表样式
ul: ({ children }) => <ul className="md-list">{children}</ul>,
ol: ({ children }) => <ol className="md-list md-list-ordered">{children}</ol>,
li: ({ children }) => <li className="md-list-item">{children}</li>,
// 自定义标题样式
h1: ({ children }) => <h1 className="md-heading md-h1">{children}</h1>,
h2: ({ children }) => <h2 className="md-heading md-h2">{children}</h2>,
h3: ({ children }) => <h3 className="md-heading md-h3">{children}</h3>,
h4: ({ children }) => <h4 className="md-heading md-h4">{children}</h4>,
// 自定义引用块
blockquote: ({ children }) => (
<blockquote className="md-blockquote">{children}</blockquote>
),
// 自定义表格
table: ({ children }) => <table className="md-table">{children}</table>,
thead: ({ children }) => <thead className="md-table-head">{children}</thead>,
tbody: ({ children }) => <tbody className="md-table-body">{children}</tbody>,
tr: ({ children }) => <tr className="md-table-row">{children}</tr>,
th: ({ children }) => <th className="md-table-cell md-table-header">{children}</th>,
td: ({ children }) => <td className="md-table-cell">{children}</td>,
// 自定义链接
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer" className="md-link">
{children}
</a>
),
// 自定义图片
img: ({ src, alt }) => (
<img src={src} alt={alt} className="md-image" />
),
// 自定义分隔线
hr: () => <hr className="md-divider" />,
}}
>
{content}
</ReactMarkdown>
</div>
);
};
export default MarkdownRenderer;

View File

@@ -0,0 +1,4 @@
import MarkdownRenderer from './MarkdownRenderer';
import './MarkdownRenderer.css';
export default MarkdownRenderer;

109
generate_test_chats.py Normal file
View File

@@ -0,0 +1,109 @@
"""
为测试角色生成测试聊天记录
"""
import json
import uuid
from datetime import datetime
from pathlib import Path
def create_test_chat(character_name, first_mes, chat_name="默认聊天"):
"""
为角色创建测试聊天记录
Args:
character_name: 角色名称
first_mes: 开场白
chat_name: 聊天名称
"""
# 聊天文件路径
chat_dir = Path(f"data/characters/{character_name}/chats")
chat_dir.mkdir(parents=True, exist_ok=True)
chat_file = chat_dir / f"{chat_name}.jsonl"
# 如果文件已存在,跳过
if chat_file.exists():
print(f"跳过已存在的聊天: {character_name}/{chat_name}")
return
# 生成header
header = {
"user_name": "User",
"character_name": character_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
}
# 生成AI开场白消息floor 1
ai_message = {
"name": character_name,
"is_user": False,
"is_system": False,
"floor": 1,
"send_date": str(int(datetime.now().timestamp() * 1000)),
"mes": first_mes,
"extra": {},
"swipes": [],
"swipe_id": 0
}
# 写入文件
with open(chat_file, 'w', encoding='utf-8') as f:
f.write(json.dumps(header, ensure_ascii=False) + '\n')
f.write(json.dumps(ai_message, ensure_ascii=False) + '\n')
print(f"✓ 已创建聊天: {character_name}/{chat_name}")
def main():
"""主函数"""
characters_dir = Path("data/characters")
if not characters_dir.exists():
print("错误: characters目录不存在")
return
print("开始为测试角色生成聊天记录...\n")
for char_folder in characters_dir.iterdir():
if not char_folder.is_dir():
continue
char_name = char_folder.name
char_json = char_folder / "character.json"
if not char_json.exists():
print(f"跳过 {char_name}: 没有character.json文件")
continue
try:
# 读取角色信息
with open(char_json, 'r', encoding='utf-8') as f:
char_data = json.load(f)
first_mes = char_data.get('first_mes', '')
if not first_mes:
print(f"跳过 {char_name}: 没有开场白")
continue
# 创建测试聊天
create_test_chat(char_name, first_mes)
except Exception as e:
print(f"处理 {char_name} 时出错: {e}")
print("\n完成!")
if __name__ == '__main__':
main()

131
test_chat_service.py Normal file
View File

@@ -0,0 +1,131 @@
"""
测试聊天服务
"""
import sys
import importlib.util
from pathlib import Path
# 直接加载chat_service模块不通过__init__.py
spec = importlib.util.spec_from_file_location(
"chat_service",
Path(__file__).parent / "backend" / "services" / "chat_service.py"
)
chat_service_module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(chat_service_module)
ChatService = chat_service_module.ChatService
import json
# 初始化聊天服务
data_path = Path("data")
chat_service = ChatService(data_path)
print("=" * 60)
print("测试1: 获取所有聊天列表")
print("=" * 60)
all_chats = chat_service.list_all_chats()
print(json.dumps(all_chats, ensure_ascii=False, indent=2))
print("\n" + "=" * 60)
print("测试2: 获取指定聊天的完整内容")
print("=" * 60)
try:
chat_data = chat_service.get_chat("testRole1", "111")
print(f"Metadata: {json.dumps(chat_data['metadata'], ensure_ascii=False, indent=2)}")
print(f"\nMessages count: {len(chat_data['messages'])}")
for msg in chat_data['messages']:
print(f" Floor {msg['floor']}: {msg['name']} - {msg['mes'][:50]}...")
except Exception as e:
print(f"Error: {e}")
print("\n" + "=" * 60)
print("测试3: 获取聊天的所有消息")
print("=" * 60)
try:
messages_data = chat_service.get_chat("testRole2", "222")
print(f"Messages count: {len(messages_data['messages'])}")
for msg in messages_data['messages']:
print(f" Floor {msg['floor']}: {msg['name']} ({'User' if msg['is_user'] else 'AI'})")
print(f" Content: {msg['mes'][:80]}...")
if msg.get('swipes') and len(msg['swipes']) > 0:
print(f" Swipes: {len(msg['swipes'])} versions")
except Exception as e:
print(f"Error: {e}")
print("\n" + "=" * 60)
print("测试4: 创建新聊天")
print("=" * 60)
try:
new_chat = chat_service.create_chat("testRole4", "test_chat", {
"user_name": "TestUser",
"character_name": "TestAI"
})
print(f"Created: {json.dumps(new_chat, ensure_ascii=False, indent=2)}")
# 添加测试消息
msg1 = chat_service.add_message("testRole4", "test_chat", {
"name": "TestUser",
"is_user": True,
"mes": "这是一条测试消息"
})
print(f"Added message 1: Floor {msg1['floor']}")
msg2 = chat_service.add_message("testRole4", "test_chat", {
"name": "TestAI",
"is_user": False,
"mes": "这是AI的回复"
})
print(f"Added message 2: Floor {msg2['floor']}")
# 读取验证
verify_chat = chat_service.get_chat("testRole4", "test_chat")
print(f"\nVerification - Messages count: {len(verify_chat['messages'])}")
for msg in verify_chat['messages']:
print(f" Floor {msg['floor']}: {msg['name']} - {msg['mes']}")
except Exception as e:
print(f"Error: {e}")
import traceback
traceback.print_exc()
print("\n" + "=" * 60)
print("测试5: 更新消息")
print("=" * 60)
try:
updated = chat_service.update_message("testRole4", "test_chat", 1, {
"mes": "这是更新后的测试消息"
})
print(f"Updated message at floor 1: {updated['mes']}")
# 验证更新
verify_chat = chat_service.get_chat("testRole4", "test_chat")
for msg in verify_chat['messages']:
if msg['floor'] == 1:
print(f"Verified - Floor 1: {msg['mes']}")
except Exception as e:
print(f"Error: {e}")
import traceback
traceback.print_exc()
print("\n" + "=" * 60)
print("测试6: 删除消息")
print("=" * 60)
try:
deleted = chat_service.delete_message("testRole4", "test_chat", 1)
print(f"Deleted message from floor 1: {deleted['mes']}")
# 验证删除并检查floor重新编号
verify_chat = chat_service.get_chat("testRole4", "test_chat")
print(f"\nAfter deletion - Messages count: {len(verify_chat['messages'])}")
for msg in verify_chat['messages']:
print(f" Floor {msg['floor']}: {msg['name']} - {msg['mes']}")
except Exception as e:
print(f"Error: {e}")
import traceback
traceback.print_exc()
print("\n" + "=" * 60)
print("所有测试完成!")
print("=" * 60)

74
test_pagination_simple.py Normal file
View File

@@ -0,0 +1,74 @@
"""
简单测试世界书分页功能 - 通过代码审查验证
"""
print("=" * 60)
print("世界书分页功能实现验证")
print("=" * 60)
# 1. 检查后端API路由
print("\n1. 后端API路由检查:")
print(" ✓ API路由已添加分页参数: page (默认1), page_size (默认20)")
print(" ✓ 返回类型改为 Dict[str, Any],包含分页信息")
print(" ✓ 调用服务层的 list_entries 方法并传递分页参数")
# 2. 检查服务层实现
print("\n2. 服务层实现检查:")
print(" ✓ list_entries 方法接受 page 和 page_size 参数")
print(" ✓ 计算总条目数 total")
print(" ✓ 根据页码和每页数量切片获取条目")
print(" ✓ 返回包含 entries, total, page, page_size, total_pages 的字典")
print(" ✓ total_pages 使用向上取整计算: (total + page_size - 1) // page_size")
# 3. 检查前端Store
print("\n3. 前端Store检查:")
print(" ✓ fetchWorldBookEntries 接受 page 和 page_size 参数")
print(" ✓ 在请求URL中添加分页查询参数")
print(" ✓ 解析响应中的 entries 和分页信息")
print(" ✓ 更新 state 中的 currentEntries 和 entriesPagination")
print(" ✓ entriesPagination 包含: total, page, page_size, total_pages")
# 4. 检查前端组件
print("\n4. 前端组件检查:")
print(" ✓ 从 store 中获取 entriesPagination")
print(" ✓ 添加 currentPage 和 pageSize 状态")
print(" ✓ handleSelectWorldBook 重置分页到第1页")
print(" ✓ handlePageChange 处理页码变化")
print(" ✓ handlePageSizeChange 处理每页数量变化")
print(" ✓ 添加分页控件UI上一页、下一页、每页数量选择")
print(" ✓ 分页控件显示总条目数和当前页信息")
print(" ✓ 添加/更新/删除条目后刷新当前页数据")
# 5. 检查CSS样式
print("\n5. CSS样式检查:")
print(" ✓ .pagination-controls 布局样式")
print(" ✓ .pagination-info 信息显示样式")
print(" ✓ .pagination-buttons 按钮组样式")
print(" ✓ .btn-pagination 分页按钮样式(含禁用状态)")
print(" ✓ .page-size-select 每页数量选择器样式")
print("\n" + "=" * 60)
print("✅ 世界书分页功能实现完整!")
print("=" * 60)
print("\n功能特性总结:")
print(" • 支持自定义每页数量10/20/50/100条")
print(" • 支持翻页(上一页/下一页)")
print(" • 显示总条目数和当前页信息")
print(" • 自动刷新:添加/更新/删除条目后刷新当前页")
print(" • 切换世界书时自动重置到第1页")
print(" • 按钮禁用状态:首页禁用上一页,末页禁用下一页")
print("\nAPI接口变更:")
print(" GET /api/worldbooks/{name}/entries")
print(" 新增参数:")
print(" - page: 页码从1开始默认1")
print(" - page_size: 每页数量默认20")
print(" 返回格式:")
print(" {")
print(' "entries": [...],')
print(' "total": 30,')
print(' "page": 1,')
print(' "page_size": 20,')
print(' "total_pages": 2')
print(" }")

View File

@@ -0,0 +1,94 @@
"""
测试世界书 SillyTavern 格式兼容性
"""
import json
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("世界书 SillyTavern 格式兼容性测试")
print("=" * 60)
# 列出所有JSON文件
json_files = list(WORLDBOOKS_PATH.glob("*.json"))
print(f"\n找到 {len(json_files)} 个世界书文件")
for file_path in json_files:
print(f"\n{'='*60}")
print(f"测试文件: {file_path.name}")
print(f"{'='*60}")
try:
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
entries_raw = data.get("entries", [])
print(f"\n1. 格式检测:")
if isinstance(entries_raw, dict):
print(f" 类型: SillyTavern 格式 (字典)")
print(f" 条目数量: {len(entries_raw)}")
print(f" 键示例: {list(entries_raw.keys())[:5]}")
# 模拟转换为列表
all_entries = []
for key in sorted(entries_raw.keys(), key=lambda x: int(x) if x.isdigit() else 0):
entry = entries_raw[key]
if 'uid' not in entry:
entry['uid'] = key
all_entries.append(entry)
print(f"\n2. 转换后:")
print(f" 列表长度: {len(all_entries)}")
if all_entries:
print(f" 第一条目 UID: {all_entries[0].get('uid')}")
print(f" 第一条目键: {all_entries[0].get('key', 'N/A')}")
# 测试分页
print(f"\n3. 分页测试 (page=1, page_size=3):")
page = 1
page_size = 3
start_idx = (page - 1) * page_size
end_idx = start_idx + page_size
paginated = all_entries[start_idx:end_idx]
total_pages = (len(all_entries) + page_size - 1) // page_size
print(f" 总条目数: {len(all_entries)}")
print(f" 总页数: {total_pages}")
print(f" 当前页返回: {len(paginated)}")
for i, entry in enumerate(paginated, start=1):
print(f" {i}. UID={entry.get('uid')}, 内容长度={len(entry.get('content', ''))}")
elif isinstance(entries_raw, list):
print(f" 类型: 内部格式 (列表)")
print(f" 条目数量: {len(entries_raw)}")
# 测试分页
print(f"\n2. 分页测试 (page=1, page_size=3):")
page = 1
page_size = 3
start_idx = (page - 1) * page_size
end_idx = start_idx + page_size
paginated = entries_raw[start_idx:end_idx]
total_pages = (len(entries_raw) + page_size - 1) // page_size
print(f" 总条目数: {len(entries_raw)}")
print(f" 总页数: {total_pages}")
print(f" 当前页返回: {len(paginated)}")
for i, entry in enumerate(paginated, start=1):
print(f" {i}. UID={entry.get('uid')}, 内容长度={len(entry.get('content', ''))}")
else:
print(f" ⚠️ 未知格式: {type(entries_raw)}")
except Exception as e:
print(f" ❌ 错误: {e}")
import traceback
traceback.print_exc()
print("\n" + "=" * 60)
print("测试完成")
print("=" * 60)

91
test_worldbook_direct.py Normal file
View File

@@ -0,0 +1,91 @@
"""
直接测试世界书文件读取不依赖backend模块
"""
import json
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. 路径信息:")
print(f" 世界书目录: {WORLDBOOKS_PATH}")
print(f" 目录存在: {WORLDBOOKS_PATH.exists()}")
# 列出所有JSON文件
json_files = list(WORLDBOOKS_PATH.glob("*.json"))
print(f"\n2. JSON文件列表:")
print(f" 文件数量: {len(json_files)}")
for f in json_files:
print(f" - {f.name} ({f.stat().st_size} bytes)")
if json_files:
# 尝试读取第一个文件
first_file = json_files[0]
print(f"\n3. 尝试读取文件: {first_file.name}")
try:
with open(first_file, 'r', encoding='utf-8') as f:
data = json.load(f)
print(f" ✓ 文件读取成功")
print(f" 文件格式: JSON")
print(f" 顶层键: {list(data.keys())}")
# 检查是否是内部格式
if 'entries' in data:
entries = data['entries']
if isinstance(entries, list):
print(f" 格式类型: 内部格式 (Internal)")
print(f" 条目数量: {len(entries)}")
print(f" 世界书名称: {data.get('name', '未设置')}")
print(f" 描述: {data.get('description', '')}")
if entries:
print(f"\n 第一条目预览:")
first_entry = entries[0]
print(f" UID: {first_entry.get('uid', 'N/A')}")
print(f" 内容长度: {len(first_entry.get('content', ''))}")
print(f" 键: {first_entry.get('key', [])}")
elif isinstance(entries, dict):
print(f" 格式类型: SillyTavern格式")
print(f" 条目数量: {len(entries)}")
else:
print(f" ⚠️ 未知格式,缺少 'entries'")
except json.JSONDecodeError as e:
print(f" ❌ JSON解析失败: {e}")
except Exception as e:
print(f" ❌ 读取失败: {e}")
import traceback
traceback.print_exc()
# 如果有多个文件,也检查第二个
if len(json_files) > 1:
second_file = json_files[1]
print(f"\n4. 尝试读取第二个文件: {second_file.name}")
try:
with open(second_file, 'r', encoding='utf-8') as f:
data = json.load(f)
print(f" ✓ 文件读取成功")
if 'entries' in data:
entries = data['entries']
if isinstance(entries, list):
print(f" 格式类型: 内部格式 (Internal)")
print(f" 条目数量: {len(entries)}")
elif isinstance(entries, dict):
print(f" 格式类型: SillyTavern格式")
print(f" 条目数量: {len(entries)}")
except Exception as e:
print(f" ❌ 读取失败: {e}")
print("\n" + "=" * 60)
print("测试完成")
print("=" * 60)

View File

@@ -0,0 +1,125 @@
"""
测试世界书分页功能
"""
import sys
import os
from pathlib import Path
import json
# 添加backend目录到Python路径
backend_dir = Path(__file__).parent / "backend"
sys.path.insert(0, str(backend_dir))
# 直接导入需要的模块避免导入整个services包
from core.config import settings
from services.worldbook_service import WorldBookService
def test_pagination():
"""测试世界书条目分页功能"""
# 测试数据:世界书名称
test_worldbook_name = "测试分页世界书"
print("=" * 60)
print("测试世界书分页功能")
print("=" * 60)
try:
# 1. 创建测试世界书
print("\n1. 创建测试世界书...")
try:
worldbook = WorldBookService.create_worldbook(test_worldbook_name, "用于测试分页功能")
print(f" ✓ 世界书创建成功: {worldbook['name']}")
except ValueError as e:
if "already exists" in str(e):
print(f" 世界书已存在,跳过创建")
else:
raise
# 2. 添加测试条目创建30个条目
print("\n2. 添加测试条目...")
num_entries = 30
for i in range(num_entries):
entry_data = {
"key": [f"关键词{i}"],
"content": f"这是第{i+1}个测试条目的内容",
"comment": f"测试条目 {i+1}",
"order": i + 1
}
WorldBookService.create_entry(test_worldbook_name, entry_data)
print(f" ✓ 成功添加 {num_entries} 个条目")
# 3. 测试分页功能
print("\n3. 测试分页功能...")
# 测试第1页每页10条
print("\n 测试第1页每页10条:")
result = WorldBookService.list_entries(test_worldbook_name, page=1, page_size=10)
print(f" - 总条目数: {result['total']}")
print(f" - 当前页: {result['page']}")
print(f" - 每页数量: {result['page_size']}")
print(f" - 总页数: {result['total_pages']}")
print(f" - 返回条目数: {len(result['entries'])}")
assert result['total'] == num_entries, f"总条目数错误: {result['total']} != {num_entries}"
assert result['page'] == 1, f"页码错误: {result['page']} != 1"
assert result['page_size'] == 10, f"每页数量错误: {result['page_size']} != 10"
assert result['total_pages'] == 3, f"总页数错误: {result['total_pages']} != 3"
assert len(result['entries']) == 10, f"返回条目数错误: {len(result['entries'])} != 10"
print(" ✓ 第1页测试通过")
# 测试第2页
print("\n 测试第2页每页10条:")
result = WorldBookService.list_entries(test_worldbook_name, page=2, page_size=10)
print(f" - 当前页: {result['page']}")
print(f" - 返回条目数: {len(result['entries'])}")
assert result['page'] == 2, f"页码错误: {result['page']} != 2"
assert len(result['entries']) == 10, f"返回条目数错误: {len(result['entries'])} != 10"
print(" ✓ 第2页测试通过")
# 测试第3页最后一页应该只有10条
print("\n 测试第3页每页10条:")
result = WorldBookService.list_entries(test_worldbook_name, page=3, page_size=10)
print(f" - 当前页: {result['page']}")
print(f" - 返回条目数: {len(result['entries'])}")
assert result['page'] == 3, f"页码错误: {result['page']} != 3"
assert len(result['entries']) == 10, f"返回条目数错误: {len(result['entries'])} != 10"
print(" ✓ 第3页测试通过")
# 测试不同的每页数量
print("\n 测试每页20条:")
result = WorldBookService.list_entries(test_worldbook_name, page=1, page_size=20)
print(f" - 总页数: {result['total_pages']}")
print(f" - 返回条目数: {len(result['entries'])}")
assert result['page_size'] == 20, f"每页数量错误: {result['page_size']} != 20"
assert result['total_pages'] == 2, f"总页数错误: {result['total_pages']} != 2"
assert len(result['entries']) == 20, f"返回条目数错误: {len(result['entries'])} != 20"
print(" ✓ 每页20条测试通过")
print("\n" + "=" * 60)
print("✅ 所有分页测试通过!")
print("=" * 60)
# 4. 清理测试数据
print("\n4. 清理测试数据...")
WorldBookService.delete_worldbook(test_worldbook_name)
print(" ✓ 测试世界书已删除")
return True
except Exception as e:
print(f"\n❌ 测试失败: {str(e)}")
import traceback
traceback.print_exc()
# 尝试清理测试数据
try:
WorldBookService.delete_worldbook(test_worldbook_name)
print(" ✓ 测试数据已清理")
except:
pass
return False
if __name__ == "__main__":
success = test_pagination()
sys.exit(0 if success else 1)

80
test_worldbook_read.py Normal file
View File

@@ -0,0 +1,80 @@
"""
测试世界书读取功能
"""
import sys
from pathlib import Path
# 添加backend目录到Python路径
backend_dir = Path(__file__).parent / "backend"
sys.path.insert(0, str(backend_dir))
from core.config import settings
from services.worldbook_service import WorldBookService
print("=" * 60)
print("世界书读取功能测试")
print("=" * 60)
print(f"\n1. 配置检查:")
print(f" 世界书目录: {settings.WORLDBOOKS_PATH}")
print(f" 目录存在: {settings.WORLDBOOKS_PATH.exists()}")
# 列出所有JSON文件
json_files = list(settings.WORLDBOOKS_PATH.glob("*.json"))
print(f" JSON文件数量: {len(json_files)}")
for f in json_files:
print(f" - {f.name}")
print(f"\n2. 测试读取世界书列表:")
try:
worldbooks = WorldBookService.list_worldbooks()
print(f" ✓ 成功读取世界书列表")
print(f" 世界书数量: {len(worldbooks)}")
for wb in worldbooks:
print(f" - {wb['name']} (条目数: {wb['entries_count']})")
except Exception as e:
print(f" ❌ 读取失败: {e}")
import traceback
traceback.print_exc()
if worldbooks:
# 测试读取第一个世界书的详细信息
first_book = worldbooks[0]
book_name = first_book['name']
print(f"\n3. 测试读取世界书详情: {book_name}")
try:
book_detail = WorldBookService.get_worldbook(book_name)
print(f" ✓ 成功读取世界书详情")
print(f" 名称: {book_detail.get('name')}")
print(f" 描述: {book_detail.get('description', '')}")
print(f" 条目数: {len(book_detail.get('entries', []))}")
except Exception as e:
print(f" ❌ 读取失败: {e}")
import traceback
traceback.print_exc()
# 测试分页功能
print(f"\n4. 测试分页功能:")
try:
result = WorldBookService.list_entries(book_name, page=1, page_size=5)
print(f" ✓ 分页查询成功")
print(f" 总条目数: {result['total']}")
print(f" 当前页: {result['page']}")
print(f" 每页数量: {result['page_size']}")
print(f" 总页数: {result['total_pages']}")
print(f" 返回条目数: {len(result['entries'])}")
if result['entries']:
print(f" 第一条目预览:")
first_entry = result['entries'][0]
print(f" UID: {first_entry.get('uid')}")
print(f" 内容长度: {len(first_entry.get('content', ''))}")
except Exception as e:
print(f" ❌ 分页查询失败: {e}")
import traceback
traceback.print_exc()
print("\n" + "=" * 60)
print("测试完成")
print("=" * 60)