完成大量美化
This commit is contained in:
195
FIX_GLOBAL_WORLDBOOK_SYNC.md
Normal file
195
FIX_GLOBAL_WORLDBOOK_SYNC.md
Normal 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
194
REMOVE_CONFIRM_DIALOG.md
Normal 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
217
WORLDBOOK_API_CHECK.md
Normal 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**
|
||||
|
||||
- 下拉框正确读取所有世界书
|
||||
- 分页参数正确传递
|
||||
- 数据格式匹配
|
||||
- 错误处理完善
|
||||
- 用户体验流畅
|
||||
292
backend/api/routes/charactersRoute.py
Normal file
292
backend/api/routes/charactersRoute.py
Normal 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)}")
|
||||
@@ -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:
|
||||
|
||||
166
backend/services/character_card_converter.py
Normal file
166
backend/services/character_card_converter.py
Normal 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
|
||||
318
backend/services/character_service.py
Normal file
318
backend/services/character_service.py
Normal 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)
|
||||
383
backend/services/chat_service.py
Normal file
383
backend/services/chat_service.py
Normal 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
|
||||
@@ -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
201
check_frontend_api.py
Normal 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
49
check_worldbook_path.py
Normal 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
53
clear_default_avatar.py
Normal 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
87
convert_worldbooks.py
Normal 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 后缀,确认无误后可删除")
|
||||
23
data/characters/测试角色1/character.json
Normal file
23
data/characters/测试角色1/character.json
Normal 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
|
||||
}
|
||||
4
data/characters/测试角色1/chats/默认聊天.jsonl
Normal file
4
data/characters/测试角色1/chats/默认聊天.jsonl
Normal 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": "我很好,谢谢关心!你呢?"}
|
||||
20
data/characters/测试角色2/character.json
Normal file
20
data/characters/测试角色2/character.json
Normal 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
|
||||
}
|
||||
4
data/characters/测试角色2/chats/工作讨论.jsonl
Normal file
4
data/characters/测试角色2/chats/工作讨论.jsonl
Normal 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": "好的,请提供数据,我会进行专业分析。"}
|
||||
2
data/characters/测试角色2/chats/默认聊天.jsonl
Normal file
2
data/characters/测试角色2/chats/默认聊天.jsonl
Normal 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}
|
||||
22
data/characters/测试角色3/character.json
Normal file
22
data/characters/测试角色3/character.json
Normal 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
|
||||
}
|
||||
2
data/characters/测试角色3/chats/默认聊天.jsonl
Normal file
2
data/characters/测试角色3/chats/默认聊天.jsonl
Normal 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}
|
||||
16
data/imports/导入测试角色.json
Normal file
16
data/imports/导入测试角色.json
Normal 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
242
frontend/src/Store/SideBarLeft/CharacterSlice.jsx
Normal file
242
frontend/src/Store/SideBarLeft/CharacterSlice.jsx
Normal 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;
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
4
frontend/src/components/shared/MarkdownRenderer/index.js
Normal file
4
frontend/src/components/shared/MarkdownRenderer/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import MarkdownRenderer from './MarkdownRenderer';
|
||||
import './MarkdownRenderer.css';
|
||||
|
||||
export default MarkdownRenderer;
|
||||
109
generate_test_chats.py
Normal file
109
generate_test_chats.py
Normal 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
131
test_chat_service.py
Normal 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
74
test_pagination_simple.py
Normal 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(" }")
|
||||
94
test_sillytavern_compat.py
Normal file
94
test_sillytavern_compat.py
Normal 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
91
test_worldbook_direct.py
Normal 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)
|
||||
125
test_worldbook_pagination.py
Normal file
125
test_worldbook_pagination.py
Normal 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
80
test_worldbook_read.py
Normal 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)
|
||||
Reference in New Issue
Block a user