完成大量美化
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)}")
|
logger.error(f"Failed to delete worldbook '{name}': {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
@router.get("/{name}/entries", response_model=List[Dict[str, Any]])
|
@router.get("/{name}/entries", response_model=Dict[str, Any])
|
||||||
async def list_worldbook_entries(name: str):
|
async def list_worldbook_entries(
|
||||||
|
name: str,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
获取世界书的所有条目
|
获取世界书的条目列表(支持分页)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 世界书名称
|
||||||
|
page: 页码,从1开始
|
||||||
|
page_size: 每页数量,默认20
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return worldbook_service.list_entries(name)
|
return worldbook_service.list_entries(name, page, page_size)
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
except Exception as 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:
|
with open(json_file, 'r', encoding='utf-8') as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
|
|
||||||
|
# 内部格式:entries 是列表
|
||||||
|
entries = data.get("entries", [])
|
||||||
|
entries_count = len(entries) if isinstance(entries, list) else 0
|
||||||
|
|
||||||
worldbooks.append({
|
worldbooks.append({
|
||||||
"name": data.get("name", json_file.stem),
|
"name": data.get("name", json_file.stem),
|
||||||
"description": data.get("description", ""),
|
"description": data.get("description", ""),
|
||||||
"entries_count": len(data.get("entries", [])),
|
"entries_count": entries_count,
|
||||||
"createdAt": data.get("createdAt", 0),
|
"createdAt": data.get("createdAt", 0),
|
||||||
"updatedAt": data.get("updatedAt", 0)
|
"updatedAt": data.get("updatedAt", 0)
|
||||||
})
|
})
|
||||||
@@ -165,21 +169,41 @@ class WorldBookService:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
@staticmethod
|
@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:
|
Args:
|
||||||
name: 世界书名称
|
name: 世界书名称
|
||||||
|
page: 页码,从1开始
|
||||||
|
page_size: 每页数量,默认20
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
条目列表
|
包含条目列表和分页信息的字典
|
||||||
"""
|
"""
|
||||||
data = WorldBookService._load_worldbook(name)
|
data = WorldBookService._load_worldbook(name)
|
||||||
if not data:
|
if not data:
|
||||||
raise FileNotFoundError(f"Worldbook '{name}' not found")
|
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
|
@staticmethod
|
||||||
def get_entry(name: str, uid: str) -> Dict[str, Any]:
|
def get_entry(name: str, uid: str) -> Dict[str, Any]:
|
||||||
@@ -197,8 +221,13 @@ class WorldBookService:
|
|||||||
if not data:
|
if not data:
|
||||||
raise FileNotFoundError(f"Worldbook '{name}' not found")
|
raise FileNotFoundError(f"Worldbook '{name}' not found")
|
||||||
|
|
||||||
for entry in data.get("entries", []):
|
# 内部格式:entries 是列表
|
||||||
if entry.get("uid") == uid:
|
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
|
return entry
|
||||||
|
|
||||||
raise FileNotFoundError(f"Entry '{uid}' not found in worldbook '{name}'")
|
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, // 当前选中的世界书
|
currentWorldBook: null, // 当前选中的世界书
|
||||||
currentEntries: [], // 当前世界书的条目列表
|
currentEntries: [], // 当前世界书的条目列表
|
||||||
currentEntry: null, // 当前选中的条目
|
currentEntry: null, // 当前选中的条目
|
||||||
|
entriesPagination: { // 条目分页信息
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
page_size: 20,
|
||||||
|
total_pages: 0
|
||||||
|
},
|
||||||
loading: false, // 加载状态
|
loading: false, // 加载状态
|
||||||
error: null, // 错误信息
|
error: null, // 错误信息
|
||||||
success: false, // 操作成功状态
|
success: false, // 操作成功状态
|
||||||
@@ -116,8 +122,7 @@ const useWorldBookStore = create((set, get) => ({
|
|||||||
return {
|
return {
|
||||||
loading: false,
|
loading: false,
|
||||||
globalWorldBooks: updatedGlobalBooks,
|
globalWorldBooks: updatedGlobalBooks,
|
||||||
success: true,
|
error: null
|
||||||
message: isGlobal ? `已将 "${name}" 设为全局世界书` : `已取消 "${name}" 的全局状态`
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -130,7 +135,18 @@ const useWorldBookStore = create((set, get) => ({
|
|||||||
const data = await handleResponse(response);
|
const data = await handleResponse(response);
|
||||||
|
|
||||||
// 从 LocalStorage 获取全局世界书列表
|
// 从 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({
|
set({
|
||||||
loading: false,
|
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 });
|
set({ loading: true, error: null });
|
||||||
try {
|
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);
|
const data = await handleResponse(response);
|
||||||
|
|
||||||
set(state => {
|
set(state => {
|
||||||
if (state.currentWorldBook?.name === name) {
|
if (state.currentWorldBook?.name === name) {
|
||||||
return {
|
return {
|
||||||
loading: false,
|
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
|
error: null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -754,3 +754,65 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
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,
|
currentWorldBook,
|
||||||
currentEntries,
|
currentEntries,
|
||||||
currentEntry,
|
currentEntry,
|
||||||
|
entriesPagination,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
success,
|
success,
|
||||||
@@ -87,6 +88,13 @@ const WorldBook = () => {
|
|||||||
prevActiveTabRef.current = activeTab;
|
prevActiveTabRef.current = activeTab;
|
||||||
}, [activeTab, fetchWorldBooks]);
|
}, [activeTab, fetchWorldBooks]);
|
||||||
|
|
||||||
|
// 组件挂载时立即加载世界书列表
|
||||||
|
useEffect(() => {
|
||||||
|
if (worldBooks.length === 0) {
|
||||||
|
fetchWorldBooks();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (success && message) {
|
if (success && message) {
|
||||||
alert(message);
|
alert(message);
|
||||||
@@ -134,8 +142,10 @@ const WorldBook = () => {
|
|||||||
const handleSelectWorldBook = async (book) => {
|
const handleSelectWorldBook = async (book) => {
|
||||||
setCurrentWorldBook(book);
|
setCurrentWorldBook(book);
|
||||||
setShowWorldBookDropdown(false);
|
setShowWorldBookDropdown(false);
|
||||||
|
// 重置分页到第一页
|
||||||
|
setCurrentPage(1);
|
||||||
try {
|
try {
|
||||||
await fetchWorldBookEntries(book.name);
|
await fetchWorldBookEntries(book.name, 1, pageSize);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('加载世界书条目失败:', err);
|
console.error('加载世界书条目失败:', err);
|
||||||
}
|
}
|
||||||
@@ -192,6 +202,8 @@ const WorldBook = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await createWorldBookEntry(currentWorldBook.name, entryData);
|
await createWorldBookEntry(currentWorldBook.name, entryData);
|
||||||
|
// 添加成功后刷新当前页
|
||||||
|
await fetchWorldBookEntries(currentWorldBook.name, currentPage, pageSize);
|
||||||
setNewEntry({
|
setNewEntry({
|
||||||
uid: 0,
|
uid: 0,
|
||||||
content: '',
|
content: '',
|
||||||
@@ -234,6 +246,29 @@ const WorldBook = () => {
|
|||||||
setShowEditPanel(true);
|
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) => {
|
const handleEntryUpdate = async (field, value) => {
|
||||||
if (!currentEntry || !currentWorldBook) return;
|
if (!currentEntry || !currentWorldBook) return;
|
||||||
|
|
||||||
@@ -241,6 +276,8 @@ const WorldBook = () => {
|
|||||||
try {
|
try {
|
||||||
await updateWorldBookEntry(currentWorldBook.name, currentEntry.uid, updatedEntry);
|
await updateWorldBookEntry(currentWorldBook.name, currentEntry.uid, updatedEntry);
|
||||||
setCurrentEntry(updatedEntry);
|
setCurrentEntry(updatedEntry);
|
||||||
|
// 更新成功后刷新当前页
|
||||||
|
await fetchWorldBookEntries(currentWorldBook.name, currentPage, pageSize);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('更新条目失败:', err);
|
console.error('更新条目失败:', err);
|
||||||
}
|
}
|
||||||
@@ -301,28 +338,34 @@ const WorldBook = () => {
|
|||||||
const handleDeleteEntry = async () => {
|
const handleDeleteEntry = async () => {
|
||||||
if (!currentEntry || !currentWorldBook) return;
|
if (!currentEntry || !currentWorldBook) return;
|
||||||
|
|
||||||
if (confirm('确定要删除此条目吗?')) {
|
if (!window.confirm('确定要删除此条目吗?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteWorldBookEntry(currentWorldBook.name, currentEntry.uid);
|
await deleteWorldBookEntry(currentWorldBook.name, currentEntry.uid);
|
||||||
setShowEditPanel(false);
|
setShowEditPanel(false);
|
||||||
setCurrentEntry(null);
|
setCurrentEntry(null);
|
||||||
|
// 删除成功后刷新当前页
|
||||||
|
await fetchWorldBookEntries(currentWorldBook.name, currentPage, pageSize);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('删除条目失败:', err);
|
console.error('删除条目失败:', err);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteWorldBook = async () => {
|
const handleDeleteWorldBook = async () => {
|
||||||
if (!currentWorldBook) return;
|
if (!currentWorldBook) return;
|
||||||
|
|
||||||
if (confirm(`确定要删除世界书 "${currentWorldBook.name}" 吗?`)) {
|
if (!window.confirm(`确定要删除世界书 "${currentWorldBook.name}" 吗?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteWorldBook(currentWorldBook.name);
|
await deleteWorldBook(currentWorldBook.name);
|
||||||
resetCurrentWorldBook();
|
resetCurrentWorldBook();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('删除世界书失败:', err);
|
console.error('删除世界书失败:', err);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImportWorldBook = async () => {
|
const handleImportWorldBook = async () => {
|
||||||
@@ -393,7 +436,7 @@ const WorldBook = () => {
|
|||||||
if (importedBook) {
|
if (importedBook) {
|
||||||
handleSelectWorldBook(importedBook);
|
handleSelectWorldBook(importedBook);
|
||||||
// 导入成功后自动加载条目
|
// 导入成功后自动加载条目
|
||||||
await fetchWorldBookEntries(name);
|
await fetchWorldBookEntries(name, 1, pageSize);
|
||||||
console.log('✅ 世界书导入成功,已加载条目');
|
console.log('✅ 世界书导入成功,已加载条目');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -623,6 +666,42 @@ const WorldBook = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="loading">暂无条目</div>
|
<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 className="btn btn-primary" onClick={handleAddEntry}>
|
||||||
+ 添加条目
|
+ 添加条目
|
||||||
</button>
|
</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