完成请求推送,但组装mes还有问题

This commit is contained in:
2026-05-04 00:33:29 +08:00
parent 7fc9e10c99
commit 2050a30a52
93 changed files with 9499 additions and 2719 deletions

7
.env
View File

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

23
.env.example Normal file
View File

@@ -0,0 +1,23 @@
# ==================== 路径配置 ====================
VECTORSTORE_PATH=/data/vectorstore
STATE_FILE=/data/state.json
SCHEMA_FILE=/data/schema.json
PRESETS_FILE=/data/presets.json
REGEX_FILE=/data/regex_rules.json
# ==================== 服务地址 ====================
COMFYUI_API_URL=http://comfyui:8188
BACKEND_PORT=8000
FRONTEND_PORT=8501
# ==================== API 加密密钥 ====================
# ⚠️ 重要:此密钥用于加密存储在配置文件中的 API Keys
# ⚠️ 生产环境必须设置此变量,否则每次重启后无法解密之前的 API Key
# ⚠️ 生成方法python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
API_ENCRYPTION_KEY=your-encryption-key-here
# ==================== 默认 LLM 配置(可选)====================
# 这些配置仅用于测试,实际使用时请通过 API 配置页面设置
# MAIN_LLM_API_KEY=sk-your-api-key
# MAIN_LLM_BASE_URL=https://api.openai.com/v1
# MAIN_LLM_MODEL=gpt-4

4
.gitignore vendored
View File

@@ -67,6 +67,9 @@ frontend/dist-ssr/
!.env.development
!.env.production
# ⚠️ 敏感信息API 配置文件(包含 API Keys
data/apiconfig/*.json
# ==================== Logs ====================
logs/
*.log
@@ -78,6 +81,7 @@ data/chat/**/*.jsonl
data/chat/**/*.json
data/preset/*.json
data/worldbooks/*.json
# ⚠️ API 配置文件包含 API Keys禁止提交到版本控制
data/apiconfig/*.json
data/comfyui_workflows/*.json
data/images/*

250
API_CONFIG_DEBUG.md Normal file
View File

@@ -0,0 +1,250 @@
# API 配置调试指南
## 问题描述
后端日志显示 Model 和 API URL 为空:
```
[LLMClient] 🔧 创建 ChatOpenAI 实例
- Model:
- API URL:
- Temperature: 1
- Max Tokens: 30000
```
## 可能的原因
### 1. 前端未配置 API
**症状**
- 前端控制台显示 `currentProfile``null``apis.mainLLM` 不存在
- 后端接收到的 `apiConfig` 为空对象或字段为空
**解决方案**
1. 打开浏览器开发者工具F12
2. 切换到 Console 标签
3. 查看是否有以下警告:
```
[ChatBoxStore] ⚠️ 未找到 API 配置,请先到 API 配置页面设置
```
4. 如果有,需要先到 API 配置页面设置
### 2. API 配置页面设置步骤
1. **打开左侧边栏** → 点击 **API 配置** 标签
2. **创建或选择配置文件**
- 如果没有配置文件,点击 "新建配置"
- 输入配置名称(如 "Default"
3. **填写 Main LLM 配置**
- API URL: `https://api.openai.com/v1`(或其他兼容 OpenAI 的 API
- API Key: 你的 API 密钥
- Model: `gpt-4` 或 `gpt-3.5-turbo`
4. **测试连接**
- 点击 "测试连接" 按钮
- 确保连接成功并获取到模型列表
5. **保存配置**
- 点击 "保存" 按钮
6. **设置为默认**(可选):
- 在配置列表中点击 "设为默认"
### 3. 数据结构检查
正确的 `currentProfile` 结构应该是:
```javascript
{
id: "profile-xxx",
name: "Default",
apis: {
mainLLM: {
apiUrl: "https://api.openai.com/v1",
apiKey: "sk-xxx...",
model: "gpt-4"
},
// 其他 API 配置...
}
}
```
## 调试步骤
### 前端调试
1. **打开浏览器控制台**F12 → Console
2. **发送消息前查看日志**
```
[ChatBoxStore] 🔍 API 配置状态检查:
- currentProfile: {...}
- apis: {...}
- mainLLM: {...}
```
3. **发送消息时查看日志**
```
--------------------------------------------------------------------------------
[WebSocket] 📤 发送消息:
- Floor: 6
- Role: 互动小说家
- Chat: 赛博朋克-数据幽灵
- Stream: true
- Message Length: 45
- API Config: {
api_url: "https://api.openai.com/v1",
api_key: "***",
model: "gpt-4"
}
- Current Profile: {...}
--------------------------------------------------------------------------------
```
4. **如果 API Config 为空**
- 检查 `Current Profile` 是否为 `null`
- 检查 `apis.mainLLM` 是否存在
- 确认是否已保存配置
### 后端调试
1. **查看接收到的请求**
```
--------------------------------------------------------------------------------
[WebSocket] 📨 收到请求:
- Floor: 6
- Role: 互动小说家
- Chat: 赛博朋克-数据幽灵
- Stream: True
- Message Length: 45
- API URL: https://api.openai.com/v1
- API Key: ***xxxx
- Model: gpt-4
--------------------------------------------------------------------------------
```
2. **如果显示 "⚠️ 未提供"**
- 说明前端没有发送 `apiConfig`
- 检查前端代码是否正确组装数据
- 检查 WebSocket 消息格式
3. **如果字段为空**
- 说明前端发送了空值
- 回到前端调试步骤
## 常见问题
### Q1: 配置保存后仍然为空?
**A**: 可能是以下原因:
1. 配置没有正确保存到 localStorage
2. 页面刷新后没有重新加载配置
3. 多个配置文件混淆
**解决**
```javascript
// 在控制台执行
const store = useApiConfigStore.getState();
console.log('Profiles:', store.profiles);
console.log('Current:', store.currentProfile);
```
### Q2: 有多个配置文件,如何切换?
**A**:
1. 在 API 配置页面选择要使用的配置
2. 点击 "加载" 按钮
3. 确认 `currentProfile` 已更新
### Q3: 测试连接成功,但发送消息时仍为空?
**A**:
- 可能是测试后没有保存配置
- 或者保存后没有重新加载
- 尝试刷新页面重新加载配置
### Q4: 如何快速验证配置是否正确?
**A**: 在浏览器控制台执行:
```javascript
const apiConfig = useApiConfigStore.getState().currentProfile?.apis?.mainLLM;
console.log('API URL:', apiConfig?.apiUrl);
console.log('API Key:', apiConfig?.apiKey ? '已设置' : '未设置');
console.log('Model:', apiConfig?.model);
```
应该输出:
```
API URL: https://api.openai.com/v1
API Key: 已设置
Model: gpt-4
```
## 临时解决方案
如果暂时无法配置 API可以使用环境变量或默认值
### 方法 1: 使用 .env 文件
在 `.env` 文件中添加:
```env
MAIN_LLM_API_KEY=sk-your-api-key
MAIN_LLM_MODEL=gpt-4
MAIN_LLM_BASE_URL=https://api.openai.com/v1
```
### 方法 2: 代码中添加默认值
修改 `ChatBoxSlice.jsx`
```javascript
apiConfig: {
api_url: apiConfigStore.currentProfile?.apis?.mainLLM?.apiUrl ||
import.meta.env.VITE_DEFAULT_API_URL ||
'https://api.openai.com/v1',
api_key: apiConfigStore.currentProfile?.apis?.mainLLM?.apiKey ||
import.meta.env.VITE_DEFAULT_API_KEY ||
'',
model: apiConfigStore.currentProfile?.apis?.mainLLM?.model ||
import.meta.env.VITE_DEFAULT_MODEL ||
'gpt-4'
}
```
## 验证修复
完成配置后,发送一条测试消息,应该看到:
**前端日志**
```
[WebSocket] 📤 发送消息:
- API Config: {
api_url: "https://api.openai.com/v1",
api_key: "***",
model: "gpt-4"
}
```
**后端日志**
```
[WebSocket] 📨 收到请求:
- API URL: https://api.openai.com/v1
- API Key: ***xxxx
- Model: gpt-4
[LLMClient] 🔧 创建 ChatOpenAI 实例
- Model: gpt-4
- API URL: https://api.openai.com/v1
- Temperature: 1.0
- Max Tokens: 30000
[LLMClient] 🚀 开始流式请求...
```
## 总结
1. ✅ 确保在 API 配置页面创建了配置文件
2. ✅ 填写完整的 Main LLM 配置URL、Key、Model
3. ✅ 测试连接成功后保存配置
4. ✅ 在前端控制台验证 `currentProfile.apis.mainLLM` 存在
5. ✅ 发送消息时检查前后端日志中的 API 配置信息
如果仍有问题,请提供:
- 前端控制台的完整日志
- 后端控制台的完整日志
- API 配置页面的截图

View File

@@ -0,0 +1,382 @@
# 应用启动预加载优化
## 概述
为了提升用户体验,避免"先有鸡还是先有蛋"的问题,应用在启动时会自动加载所有必要的配置数据。
## 预加载的数据
### 1. API 配置 ✅
**为什么需要预加载:**
- 聊天功能依赖 API 配置才能调用 LLM
- 用户可能直接开始聊天而不访问 API 配置页面
**加载逻辑:**
```javascript
// 1. 加载配置文件列表
await apiConfigStore.fetchProfiles();
// 2. 如果有配置但没有选中,自动加载第一个
if (profiles.length > 0 && !currentProfile) {
await fetchProfile(profiles[0].id);
}
```
**日志输出:**
```
[App] ✅ API 配置文件列表加载完成
[App] 📝 自动加载第一个配置文件: Default
[App] ✅ API 配置详情加载完成
```
### 2. 预设列表 ✅
**为什么需要预加载:**
- 聊天时使用预设中的参数temperature、max_tokens 等)
- 提示词组件来自预设配置
**加载逻辑:**
```javascript
// 1. 加载预设列表
await presetStore.fetchPresets();
// 2. 如果没有选中预设,自动选择第一个
if (!selectedPreset && presets.length > 0) {
selectPreset(presets[0].name);
}
```
**日志输出:**
```
[App] ✅ 预设列表加载完成
[App] 📝 自动选择第一个预设: Default
```
### 3. 角色卡列表 ✅
**为什么需要预加载:**
- 用户打开应用后需要立即看到可用的角色
- 避免切换到角色卡标签页时才加载的延迟
**加载逻辑:**
```javascript
await characterStore.fetchCharacters();
```
**日志输出:**
```
[App] ✅ 角色卡列表加载完成
```
### 4. 世界书列表 ✅
**为什么需要预加载:**
- 角色卡可能绑定世界书
- 聊天时需要激活世界书条目
**加载逻辑:**
```javascript
await worldBookStore.fetchWorldBooks();
```
**日志输出:**
```
[App] ✅ 世界书列表加载完成
```
## 不需要预加载的数据
### Token 使用统计 ❌
**原因:**
- 这是查看型数据,不是必需的配置
- 数据量可能较大
- 用户不一定需要查看
**加载时机:**
- 用户切换到 Token 使用标签页时再加载
### 任务列表 ❌
**原因:**
- 任务是动态生成的
- 每次聊天时根据选项创建
- 不需要持久化
**加载时机:**
- 聊天时根据需要创建
### 正则规则 ❌
**原因:**
- 使用时才加载
- 可能按角色或预设分别加载
**加载时机:**
- 处理消息时按需加载
## 技术实现
### 并行加载
使用 `Promise.allSettled` 并行加载所有数据,而不是串行:
```javascript
Promise.allSettled([
loadApiConfig(),
loadPresets(),
loadCharacters(),
loadWorldBooks()
]).then((results) => {
// 处理结果
});
```
**优势:**
- ⚡ 速度更快4个请求同时发起总时间 ≈ 最慢的那个请求
- 🛡️ 容错性强:单个失败不影响其他数据加载
- 📊 可统计:知道哪些成功、哪些失败
### 错误处理
每个加载任务都有独立的 try-catch
```javascript
(async () => {
try {
await fetchData();
console.log('✅ 加载成功');
} catch (err) {
console.error('❌ 加载失败:', err);
}
})()
```
**优势:**
- 单个数据源失败不会中断其他加载
- 详细的错误日志便于调试
- 应用仍可正常使用(降级处理)
### 性能监控
记录加载耗时:
```javascript
const startTime = Date.now();
// ... 加载数据 ...
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
console.log(`[App] 🎉 配置加载完成 (${duration}s)`);
```
**日志输出:**
```
[App] 🎉 配置加载完成 (1.23s)
[App] 📊 成功: 4, 失败: 0
```
## 用户体验提升
### Before优化前
```
用户操作流程:
1. 打开应用
2. 点击左侧边栏 → API 配置
3. 等待加载配置文件
4. 选择/创建配置
5. 点击左侧边栏 → 预设
6. 等待加载预设列表
7. 选择预设
8. 点击左侧边栏 → 角色卡
9. 等待加载角色列表
10. 选择角色
11. 开始聊天
总耗时:~5-10秒多次等待
```
### After优化后
```
用户操作流程:
1. 打开应用(后台自动加载所有配置)
2. 等待 ~1-2秒并行加载
3. 直接选择角色开始聊天
总耗时:~1-2秒一次等待
```
**提升:**
- ⏱️ 减少等待时间 60-80%
- 🎯 操作步骤减少 7步 → 2步
- 😊 用户体验更流畅
## 日志示例
### 成功情况
```
[App] 🚀 应用启动,开始加载默认配置...
[App] ✅ API 配置文件列表加载完成
[App] 📝 自动加载第一个配置文件: Default
[App] ✅ API 配置详情加载完成
[App] ✅ 预设列表加载完成
[App] 📝 自动选择第一个预设: Default
[App] ✅ 角色卡列表加载完成
[App] ✅ 世界书列表加载完成
[App] 🎉 配置加载完成 (1.23s)
[App] 📊 成功: 4, 失败: 0
```
### 部分失败情况
```
[App] 🚀 应用启动,开始加载默认配置...
[App] ✅ API 配置文件列表加载完成
[App] ✅ 预设列表加载完成
[App] 📝 自动选择第一个预设: Default
[App] ✅ 角色卡列表加载完成
[App] ❌ 世界书列表加载失败: Network error
[App] 🎉 配置加载完成 (2.45s)
[App] 📊 成功: 3, 失败: 1
[App] ⚠️ 部分配置加载失败,但应用仍可正常使用
```
### 没有配置的情况
```
[App] 🚀 应用启动,开始加载默认配置...
[App] ✅ API 配置文件列表加载完成
[App] ⚠️ 没有可用的 API 配置文件,请先到 API 配置页面创建
[App] ✅ 预设列表加载完成
[App] ⚠️ 没有可用的预设
[App] ✅ 角色卡列表加载完成
[App] ✅ 世界书列表加载完成
[App] 🎉 配置加载完成 (0.89s)
[App] 📊 成功: 4, 失败: 0
```
## 最佳实践
### 1. 只预加载必要的数据
**应该预加载:**
- ✅ 应用运行必需的配置
- ✅ 用户首次操作就需要看到的数据
- ✅ 数据量小、加载快的内容
**不应该预加载:**
- ❌ 查看型数据(统计、历史等)
- ❌ 大数据量的内容
- ❌ 用户可能不需要的功能
### 2. 并行而非串行
```javascript
// ❌ 错误:串行加载,总时间 = 所有请求时间之和
await loadA();
await loadB();
await loadC();
// ✅ 正确:并行加载,总时间 ≈ 最慢的请求
await Promise.allSettled([
loadA(),
loadB(),
loadC()
]);
```
### 3. 容错处理
```javascript
// ❌ 错误:一个失败全部中断
await Promise.all([loadA(), loadB(), loadC()]);
// ✅ 正确:单个失败不影响其他
await Promise.allSettled([loadA(), loadB(), loadC()]);
```
### 4. 详细日志
```javascript
// ❌ 错误:没有日志,无法调试
await fetchData();
// ✅ 正确:清晰的日志输出
console.log('[App] 🚀 开始加载...');
try {
await fetchData();
console.log('[App] ✅ 加载成功');
} catch (err) {
console.error('[App] ❌ 加载失败:', err);
}
```
### 5. 性能监控
```javascript
const startTime = Date.now();
await loadData();
const duration = Date.now() - startTime;
console.log(`加载耗时: ${duration}ms`);
```
## 未来优化方向
### 1. 缓存策略
```javascript
// 检查是否有缓存数据
const cached = localStorage.getItem('apiConfig');
if (cached) {
// 使用缓存,后台静默更新
useCache(cached);
fetchAndUpdateInBackground();
} else {
// 没有缓存,正常加载
await fetchData();
}
```
### 2. 懒加载非关键数据
```javascript
// 关键数据:立即加载
await loadCriticalData();
// 非关键数据:空闲时加载
requestIdleCallback(() => {
loadNonCriticalData();
});
```
### 3. 预取用户可能需要的数据
```javascript
// 用户选择了角色,预取该角色的聊天历史
selectCharacter(char => {
preloadChatHistory(char.id);
});
```
## 总结
通过应用启动时预加载必要配置:
**用户体验提升**
- 减少等待时间 60-80%
- 操作步骤大幅简化
- 无需手动切换页面配置
**技术优势**
- 并行加载,速度更快
- 容错性强,单个失败不影响整体
- 详细日志,便于调试
**代码质量**
- 清晰的加载逻辑
- 完善的错误处理
- 性能监控和统计
这种优化让应用更加"开箱即用",用户打开即可开始使用,无需繁琐的初始配置步骤。

204
SETTINGS_TEST_GUIDE.md Normal file
View File

@@ -0,0 +1,204 @@
# 系统设置功能测试指南
## 📋 前置条件
### 1. 启动后端服务
```bash
# 方式1使用 Docker
docker-compose up -d backend
# 方式2本地运行
cd backend
python main.py
```
后端应该运行在 `http://localhost:23337`(或你配置的端口)
### 2. 启动前端服务
```bash
cd frontend
npm run dev
```
前端应该运行在 `http://localhost:5173`Vite 默认端口)
---
## 🧪 测试步骤
### 测试 1访问设置页面
1. 打开浏览器访问前端地址
2. 点击左侧边栏的 **"设置"** 标签
3. 应该看到三个分页:**正则规则**、**思考标签**、**通用设置**
### 测试 2加载系统设置
打开浏览器控制台F12应该看到
- ✅ 无错误信息
- ✅ Network 面板中有 `/api/regex/settings` 请求
- ✅ 响应数据包含 `thinkingTagPrefix``thinkingTagSuffix`
### 测试 3修改思考标签
1. 切换到 **"思考标签"** 分页
2. 修改前缀为:`<think>`
3. 修改后缀为:`</think>`
4. 点击 **"保存设置"**
5. 应该看到成功提示:"设置已保存"
### 测试 4验证持久化
1. 刷新页面
2. 再次进入设置页面
3. 思考标签应该保持刚才修改的值
### 测试 5导入规则文件
1. 切换到 **"正则规则"** 分页
2. 点击 **"选择文件"**
3. 选择一个 SillyTavern 格式的 JSON 文件
4. 应该看到成功提示
**示例规则文件**(可以复制保存为 `test_rules.json`
```json
[
{
"scriptName": "测试规则",
"findRegex": "/test/gi",
"replaceString": "replaced",
"trimStrings": [],
"placement": [2],
"substituteRegex": 0,
"markdownOnly": false,
"promptOnly": false,
"runOnEdit": true,
"minDepth": 0,
"maxDepth": null,
"disabled": false
}
]
```
### 测试 6导出规则
1. 点击 **"导出全局规则"** 按钮
2. 应该在新窗口打开并显示 JSON 数据
---
## 🐛 常见问题
### 问题 1页面空白或报错
**可能原因**
- 后端服务未启动
- API 端口配置错误
**解决方法**
```bash
# 检查后端是否运行
curl http://localhost:23337/api/regex/settings
# 检查前端代理配置
cat frontend/vite.config.js
```
### 问题 2保存设置失败
**可能原因**
- 后端权限问题
- data 目录不存在
**解决方法**
```bash
# 检查 data 目录是否存在
ls -la data/
# 检查文件权限
chmod -R 755 data/
```
### 问题 3导入规则失败
**可能原因**
- JSON 格式错误
- 文件大小超限
**解决方法**
- 使用 JSON 验证工具检查文件格式
- 确保文件编码为 UTF-8
---
## 📊 预期效果
### 正则规则分页
- ✅ 显示文件结构说明
- ✅ 可以上传 JSON 文件
- ✅ 可以导出规则
### 思考标签分页
- ✅ 显示前后缀输入框
- ✅ 实时预览效果
- ✅ 保存后持久化
### 通用设置分页
- ✅ 显示当前预设输入框
- ✅ 可以修改并保存
---
## 🔍 调试技巧
### 查看后端日志
```bash
# Docker 环境
docker-compose logs -f backend
# 本地环境
# 直接查看终端输出
```
### 查看前端网络请求
1. 打开浏览器开发者工具F12
2. 切换到 **Network** 标签
3. 筛选 `regex` 相关请求
4. 检查请求和响应的详细内容
### 检查数据存储
```bash
# 查看系统设置文件
cat data/system_settings.json
# 查看正则规则文件
ls -la data/regex/global/
cat data/regex/global/default.json
```
---
## ✅ 完成标志
当你能成功完成以下操作时,说明功能已正常工作:
- [ ] 访问设置页面
- [ ] 修改思考标签并保存
- [ ] 刷新页面后设置仍然有效
- [ ] 导入一个规则文件
- [ ] 导出全局规则
---
## 📝 下一步开发
1. **规则管理界面** - 添加规则的增删改查功能
2. **规则测试工具** - 实时预览正则效果
3. **批量操作** - 支持批量启用/禁用规则
4. **规则模板** - 提供常用规则模板

318
WEBSOCKET_LOGS_EXAMPLE.md Normal file
View File

@@ -0,0 +1,318 @@
# WebSocket 链路详细日志示例
本文档展示了添加详细日志后WebSocket 链路的完整输出示例。
## 后端日志输出示例
### 1. 连接建立
```
================================================================================
[WebSocket] 📡 连接建立: 互动小说家/赛博朋克-数据幽灵
================================================================================
INFO: connection open
```
### 2. 接收请求
```
--------------------------------------------------------------------------------
[WebSocket] 📨 收到请求:
- Floor: 6
- Role: 互动小说家
- Chat: 赛博朋克-数据幽灵
- Stream: True
- Message Length: 45
--------------------------------------------------------------------------------
[WebSocket] 🌊 进入流式处理模式
[StreamChat] 🚀 开始流式处理
```
### 3. 工作流处理
```
################################################################################
[ChatWorkflow-Stream] 🚀 开始处理请求
- Role: 互动小说家
- Chat: 赛博朋克-数据幽灵
- Message Length: 45
################################################################################
[ChatWorkflow-Stream] ✅ 已加载角色卡: 互动小说家
[ChatWorkflow-Stream] 📚 世界书激活结果: 0 个条目
[ChatWorkflow-Stream] 💬 聊天历史加载结果: 0 条消息
[ChatWorkflow-Stream] 📝 提示词组装结果: 3 条消息
1. [system] 你是一个互动小说家,擅长创作赛博朋克风格的故...
2. [user] 请描述一个数据幽灵在夜之城中的冒险...
3. [assistant] ...
[ChatWorkflow-Stream] 🤖 开始流式调用 LLM
- Model: gpt-4
- Temperature: 1.0
- Max Tokens: 30000
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
```
### 4. LLM 客户端调用
```
[LLMClient] 🔧 创建 ChatOpenAI 实例
- Model: gpt-4
- API URL: https://api.openai.com/v1
- Temperature: 1.0
- Max Tokens: 30000
[LLMClient] 🚀 开始流式请求...
```
### 5. 流式输出进度
```
[ChatWorkflow-Stream] 📊 已接收 20 个 chunks, 当前长度: 156
[StreamChat] 📤 已发送 10 个 chunks
[ChatWorkflow-Stream] 📊 已接收 40 个 chunks, 当前长度: 312
[StreamChat] 📤 已发送 20 个 chunks
[ChatWorkflow-Stream] 📊 已接收 60 个 chunks, 当前长度: 478
[StreamChat] 📤 已发送 30 个 chunks
```
### 6. LLM 调用完成
```
[LLMClient] ✅ 流式请求完成
- 总 Chunks: 85
- 耗时: 3.45秒
- Prompt Tokens: 256
- Completion Tokens: 512
- Total Tokens: 768
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
[ChatWorkflow-Stream] ✅ LLM 流式调用完成
- 总 Chunks: 85
- 内容长度: 680
################################################################################
```
### 7. 发送响应
```
[StreamChat] ✨ 流式生成成功,总长度: 680
[StreamChat] 📚 发送世界书激活信息: 0 个条目
[StreamChat] ✅ 发送完成信号
[StreamChat] 💾 保存消息到文件...
[WebSocket] 消息已保存: 互动小说家/赛博朋克-数据幽灵
[StreamChat] ✅ 消息保存完成
```
### 8. 连接断开
```
================================================================================
[WebSocket] 🔌 连接断开: 互动小说家/赛博朋克-数据幽灵
================================================================================
```
## 前端日志输出示例
### 1. 建立连接
```javascript
================================================================================
[WebSocket] 📡 连接已建立
- URL: ws://localhost:23337/api/chat/%E4%BA%92%E5%8A%A8%E5%B0%8F%E8%AF%B4%E5%AE%B6/%E8%B5%9B%E5%8D%9A%E6%9C%8B%E5%85%8B-%E6%95%B0%E6%8D%AE%E5%B9%BD%E7%81%B5/ws
- Ready State: 1
================================================================================
```
### 2. 发送消息
```javascript
--------------------------------------------------------------------------------
[WebSocket] 📤 发送消息:
- Floor: 6
- Role: 互动小说家
- Chat: 赛博朋克-数据幽灵
- Stream: true
- Message Length: 45
--------------------------------------------------------------------------------
```
### 3. 接收流式数据
```javascript
[WebSocket] 📊 已接收 10 chunks
[WebSocket] 📊 已接收 20 chunks
[WebSocket] 📊 已接收 30 chunks
```
### 4. 接收其他消息类型
```javascript
[WebSocket] 📚 收到世界书激活信息: 0 个条目
[WebSocket] 📋 收到任务ID信息: {imageWorkflow: "img_xxx", dynamicTable: "tbl_xxx"}
```
### 5. 完成信号
```javascript
[WebSocket] 收到完成信号
- Chunks: 85
- 消息长度: 680
```
### 6. 连接关闭
```javascript
================================================================================
[WebSocket] 🔌 连接已关闭
- Code: 1000
- Reason:
- Was Clean: true
================================================================================
```
## 错误情况日志示例
### 1. LLM 调用失败
```
[LLMClient] ❌ 流式调用失败: API key is invalid
Traceback (most recent call last):
File "backend/utils/llm_client.py", line 278, in stream_chat
async for chunk in llm.astream(messages):
...
openai.AuthenticationError: Error code: 401 - {'error': {'message': 'Invalid API key'}}
```
### 2. 工作流处理失败
```
[ChatWorkflow-Stream] ⚠️ 错误: 角色 '不存在的角色' 不存在
Traceback (most recent call last):
File "backend/services/chat_workflow_service.py", line 765, in process_chat_request_stream
return {
...
```
### 3. WebSocket 错误
```
================================================================================
[WebSocket] ⚠️ 错误: Connection closed unexpectedly
================================================================================
```
## 日志级别说明
### 符号含义
- 📡 - 连接相关
- 📨 - 接收消息
- 📤 - 发送消息
- 🚀 - 开始处理
- ✅ - 成功完成
- ❌ - 错误/失败
- 📊 - 进度统计
- 📚 - 世界书相关
- 📋 - 任务相关
- 💬 - 聊天历史
- 📝 - 提示词
- 🤖 - LLM 调用
- 💾 - 文件保存
- 🔌 - 连接断开
- ⚠️ - 警告
### 分隔符
- `=` (80个) - 重要事件边界(连接建立/断开)
- `-` (80个) - 请求边界
- `#` (80个) - 工作流处理边界
- `~` (80个) - LLM 调用边界
## 日志配置建议
### 开发环境
在开发环境中,显示所有日志以便调试:
```python
# 保持当前的详细日志输出
```
### 生产环境
在生产环境中,可以简化日志:
```python
# 只记录关键事件
print(f"[WebSocket] 连接建立: {role_name}/{chat_name}")
print(f"[WebSocket] 收到请求: floor={request_data.get('floor')}")
print(f"[WebSocket] 生成完成,内容长度: {len(content)}")
```
## 日志分析工具
### 提取关键指标
```bash
# 统计平均响应时间
grep "耗时:" logs.txt | awk '{sum += $2; count++} END {print "平均耗时:", sum/count, "秒"}'
# 统计 Token 使用
grep "Total Tokens:" logs.txt | awk '{sum += $3; count++} END {print "平均 Token:", sum/count}'
# 统计错误率
grep "❌" logs.txt | wc -l
```
### 实时监控
```bash
# 实时查看 WebSocket 日志
tail -f logs.txt | grep --line-buffered "WebSocket\|StreamChat\|ChatWorkflow\|LLMClient"
```
## 性能优化建议
基于日志分析:
1. **如果 LLM 调用耗时过长** (>10秒)
- 检查网络连接
- 考虑使用更快的模型
- 减少 max_tokens
2. **如果 Chunk 数量过多** (>200)
- 检查是否启用了不必要的功能
- 优化提示词长度
3. **如果世界书激活数量过多** (>10)
- 检查触发条件是否过于宽松
- 优化关键词匹配
4. **如果聊天历史加载失败**
- 检查 CHATS_PATH 配置
- 确认文件权限
## 总结
通过详细的日志记录,可以:
1. ✅ 清晰地追踪 WebSocket 的完整链路
2. ✅ 快速定位问题所在
3. ✅ 分析性能瓶颈
4. ✅ 监控系统运行状态
5. ✅ 优化用户体验
建议在开发和测试阶段使用详细日志,在生产环境中根据需要调整日志级别。

View File

@@ -1,18 +1,26 @@
from fastapi import APIRouter
from .routes import presetsRoute, chatsRoute, worldbooksRoute, apiConfigRoute, charactersRoute
from .routes import presetsRoute, chatsRoute, worldbooksRoute, apiConfigRoute, charactersRoute, chatWsRoute, tokenUsageRoute, imageGalleryRoute, regexRoute
from utils.file_utils import get_all_roles_and_chats
from core.config import settings
from pathlib import Path
router = APIRouter()
# 注册子路由
# 注册子路由HTTP路由
router.include_router(presetsRoute.router)
router.include_router(chatsRoute.router)
router.include_router(worldbooksRoute.router)
router.include_router(apiConfigRoute.router)
router.include_router(charactersRoute.router)
# ✅ 注册新增路由
router.include_router(tokenUsageRoute.router)
router.include_router(imageGalleryRoute.router)
router.include_router(regexRoute.router)
# ✅ 注册 WebSocket 路由(必须在 HTTP 路由之后,避免路径冲突)
router.include_router(chatWsRoute.router)
# 保留原有的其他路由
@router.get("/tool_bar/get_all_role_and_chat")

View File

@@ -2,24 +2,28 @@ from fastapi import APIRouter, HTTPException, UploadFile, File
from pydantic import BaseModel, Field
from typing import Dict, Optional, List, Any
import json
import os
import sys
from pathlib import Path
from core.config import settings
from cryptography.fernet import Fernet
import base64
from services.comfyui_workflow_manager import workflow_manager
from services.llm_model_service import LLMModelService
router = APIRouter(prefix="/api-config", tags=["API Configuration"])
# 加密密钥(实际项目中应该从环境变量读取)
ENCRYPTION_KEY = os.getenv('API_ENCRYPTION_KEY', Fernet.generate_key().decode())
fernet = Fernet(ENCRYPTION_KEY.encode() if isinstance(ENCRYPTION_KEY, str) else ENCRYPTION_KEY)
# 配置文件路径
CONFIG_DIR = Path(settings.DATA_PATH) / "apiconfig"
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
# 调试信息:打印配置目录路径
print(f"[API Config] DATA_PATH: {settings.DATA_PATH}", file=sys.stderr)
print(f"[API Config] CONFIG_DIR: {CONFIG_DIR}", file=sys.stderr)
print(f"[API Config] CONFIG_DIR exists: {CONFIG_DIR.exists()}", file=sys.stderr)
if CONFIG_DIR.exists():
config_files = list(CONFIG_DIR.glob("*.json"))
print(f"[API Config] Found {len(config_files)} config files", file=sys.stderr)
for f in config_files:
print(f" - {f.name}", file=sys.stderr)
class ApiConfigItem(BaseModel):
"""单个 API 配置项"""
@@ -50,32 +54,8 @@ class ProfileResponse(BaseModel):
apis: Dict[str, dict] # apiKey 字段会被移除或脱敏
def encrypt_api_key(api_key: str) -> str:
"""加密 API Key"""
if not api_key:
return ""
encrypted = fernet.encrypt(api_key.encode())
return base64.urlsafe_b64encode(encrypted).decode()
def decrypt_api_key(encrypted_key: str) -> str:
"""解密 API Key仅在后端内部使用"""
if not encrypted_key:
return ""
try:
decoded = base64.urlsafe_b64decode(encrypted_key.encode())
decrypted = fernet.decrypt(decoded)
return decrypted.decode()
except Exception:
return ""
def mask_api_key(api_key: str) -> str:
"""脱敏 API Key返回给前端"""
if not api_key or len(api_key) < 8:
return "****"
return api_key[:4] + "****" + api_key[-4:]
def load_profile(profile_id: str) -> Optional[dict]:
"""加载配置文件"""
@@ -119,29 +99,28 @@ def get_all_profiles():
@router.get("/profiles/{profile_id}", response_model=ProfileResponse)
def get_profile(profile_id: str):
"""获取单个配置文件API Key 已脱敏"""
"""获取单个配置文件(明文存储,不返回 API Key"""
profile = load_profile(profile_id)
if not profile:
raise HTTPException(status_code=404, detail="配置文件不存在")
# 脱敏所有 API Key
masked_apis = {}
# 移除 API Key 字段,不返回给前端
safe_apis = {}
for category, api_config in profile.get("apis", {}).items():
masked_config = api_config.copy()
if "apiKey" in masked_config and masked_config["apiKey"]:
masked_config["apiKey"] = mask_api_key(masked_config["apiKey"])
masked_apis[category] = masked_config
safe_config = api_config.copy()
safe_config.pop("apiKey", None)
safe_apis[category] = safe_config
return {
"id": profile.get("id", profile_id),
"name": profile.get("name", profile_id),
"apis": masked_apis
"apis": safe_apis
}
@router.post("/profiles", response_model=ProfileResponse)
def create_or_update_profile(request: ProfileSaveRequest):
"""创建或更新配置文件(增量更新)"""
"""创建或更新配置文件(增量更新,明文存储 API Key"""
# 加载现有配置
existing_profile = load_profile(request.profileId)
@@ -150,16 +129,11 @@ def create_or_update_profile(request: ProfileSaveRequest):
for category, api_config in request.apis.items():
api_config_dict = api_config.dict(exclude_none=True)
# 处理 API Key 加密
if api_config.apiKey and api_config.apiKey != "****":
# 如果是新的明文 key加密它
api_config_dict["apiKey"] = encrypt_api_key(api_config.apiKey)
elif api_config.apiKey == "****":
# 如果是脱敏的 key保留原有的加密 key
if category in existing_profile.get("apis", {}):
api_config_dict["apiKey"] = existing_profile["apis"][category].get("apiKey", "")
else:
api_config_dict.pop("apiKey", None)
# 如果前端传入了空的 apiKey保留原有的 key
if api_config.apiKey == "" and category in existing_profile.get("apis", {}):
existing_key = existing_profile["apis"][category].get("apiKey", "")
if existing_key:
api_config_dict["apiKey"] = existing_key
# 更新配置
if "apis" not in existing_profile:
@@ -177,28 +151,25 @@ def create_or_update_profile(request: ProfileSaveRequest):
"apis": {}
}
# 添加所有 API 配置
# 添加所有 API 配置(明文存储)
for category, api_config in request.apis.items():
api_config_dict = api_config.dict(exclude_none=True)
if api_config_dict.get("apiKey"):
api_config_dict["apiKey"] = encrypt_api_key(api_config_dict["apiKey"])
profile_data["apis"][category] = api_config_dict
# 保存配置文件
save_profile(request.profileId, profile_data)
# 返回脱敏后的数据
masked_apis = {}
# 返回不包含 API Key 的数据
safe_apis = {}
for category, api_config in profile_data.get("apis", {}).items():
masked_config = api_config.copy()
if "apiKey" in masked_config and masked_config["apiKey"]:
masked_config["apiKey"] = mask_api_key(masked_config["apiKey"])
masked_apis[category] = masked_config
safe_config = api_config.copy()
safe_config.pop("apiKey", None)
safe_apis[category] = safe_config
return {
"id": profile_data.get("id", request.profileId),
"name": profile_data.get("name", request.profileId),
"apis": masked_apis
"apis": safe_apis
}
@@ -217,13 +188,31 @@ def delete_profile(profile_id: str):
def test_connection(api_config: ApiConfigItem):
"""测试 API 连接并获取模型列表"""
try:
api_key_to_use = api_config.apiKey or ""
# 如果 API Key 为空,尝试从已保存的配置中获取
if not api_key_to_use and api_config.category:
# 遍历所有配置文件,找到包含该 category 的配置
for config_file in CONFIG_DIR.glob("*.json"):
try:
with open(config_file, 'r', encoding='utf-8') as f:
profile = json.load(f)
# 检查是否包含该 category
if api_config.category in profile.get("apis", {}):
api_key_to_use = profile["apis"][api_config.category].get("apiKey", "")
if api_key_to_use:
break
except Exception:
continue
# 检测提供商类型
provider = LLMModelService.detect_provider(api_config.apiUrl)
# 获取模型列表
models = LLMModelService.get_models_by_provider(
provider=provider,
api_key=api_config.apiKey or "",
api_key=api_key_to_use,
api_url=api_config.apiUrl
)

View File

@@ -0,0 +1,420 @@
"""
聊天 WebSocket 路由
处理实时对话生成
"""
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from typing import Dict, Any
import json
import asyncio
try:
from backend.services.chat_workflow_service import ChatWorkflowService
from backend.services.chat_service import ChatService
from backend.services.task_queue_manager import task_queue_manager
from backend.core.config import settings
except ImportError:
from services.chat_workflow_service import ChatWorkflowService
from services.chat_service import ChatService
from services.task_queue_manager import task_queue_manager
from core.config import settings
router = APIRouter(prefix="/chat", tags=["chat-websocket"])
# 初始化服务
workflow_service = ChatWorkflowService()
chat_service = ChatService(settings.DATA_PATH)
# ✅ 全局变量:用于存储需要中断的聊天会话
interrupt_flags: Dict[str, bool] = {}
@router.websocket("/{role_name}/{chat_name}/ws")
async def websocket_chat_endpoint(
websocket: WebSocket,
role_name: str,
chat_name: str
):
"""
WebSocket 聊天端点
接收前端发送的完整对话请求,调用工作流生成回复,支持流式输出
"""
await websocket.accept()
print(f"\n{'='*80}")
print(f"[WebSocket] 📡 连接建立: {role_name}/{chat_name}")
print(f"{'='*80}\n")
chat_id = f"{role_name}/{chat_name}"
try:
while True:
# 1. 接收前端消息
data = await websocket.receive_text()
request_data = json.loads(data)
# ✅ 检查是否是取消任务的请求
if request_data.get("type") == "cancel_task":
task_id = request_data.get("taskId")
print(f"[WebSocket] ❌ 收到取消任务请求: {task_id}")
# ✅ 特殊处理:如果是 LLM 生成任务,需要中断当前流式生成
if task_id == "current_llm_generation":
print(f"[WebSocket] 🛑 正在终止 LLM 流式生成...")
# TODO: 实现 LLM 生成的中断逻辑
# 目前只能通过关闭连接来终止
await websocket.send_json({
"type": "task_cancelled",
"taskId": task_id,
"success": True,
"message": "LLM 生成已终止"
})
else:
# 取消其他类型的任务(图像生成、动态表格等)
success = await task_queue_manager.cancel_task(task_id)
await websocket.send_json({
"type": "task_cancelled",
"taskId": task_id,
"success": success
})
print(f"[WebSocket] ✅ 任务取消结果: {success}")
continue
print(f"\n{'-'*80}")
print(f"[WebSocket] 📨 收到请求:")
print(f" - Floor: {request_data.get('floor')}")
print(f" - Role: {request_data.get('currentRole')}")
print(f" - Chat: {request_data.get('currentChat')}")
print(f" - Stream: {request_data.get('stream', False)}")
print(f" - Message Length: {len(request_data.get('mes', ''))}")
# ✅ 打印 API 配置信息(隐藏密钥)
api_config = request_data.get('apiConfig', {})
current_profile = request_data.get('currentProfile', {})
profile_id = current_profile.get('id') if isinstance(current_profile, dict) else None
print(f" - Profile ID: {profile_id or 'N/A'}")
print(f" - API URL: {api_config.get('api_url', 'N/A')[:50]}..." if len(api_config.get('api_url', '')) > 50 else f" - API URL: {api_config.get('api_url', 'N/A')}")
print(f" - Model: {api_config.get('model', 'N/A')}")
# ✅ 始终从配置文件中读取 API Key不信任前端传来的 Key
if profile_id:
try:
from .apiConfigRoute import load_profile
profile = load_profile(profile_id)
if profile:
# 找到 mainLLM 的配置
main_llm_config = profile.get('apis', {}).get('mainLLM', {})
api_key = main_llm_config.get('apiKey', '')
if api_key:
# 使用明文 API Key
api_config['api_key'] = api_key
request_data['apiConfig'] = api_config
print(f" - API Key: ✅ 已从配置文件加载")
else:
print(f" - API Key: ⚠️ 配置文件中未找到 Key")
else:
print(f" - API Key: ❌ 无法加载配置文件: {profile_id}")
except Exception as e:
print(f" - API Key: ❌ 加载失败: {str(e)}")
import traceback
traceback.print_exc()
else:
print(f" - API Key: ⚠️ 未提供 profileId无法加载")
print(f"{'-'*80}\n")
# 2. 提取流式输出标志
stream_output = request_data.get("stream", False)
if stream_output:
# === 真正的流式输出模式 ===
print(f"[WebSocket] 🌊 进入流式处理模式")
await _handle_stream_chat(
websocket, role_name, chat_name, request_data, workflow_service
)
else:
# === 非流式输出模式 ===
print(f"[WebSocket] 📦 进入非流式处理模式")
result = await workflow_service.process_chat_request(request_data)
if result["success"]:
content = result["content"]
print(f"\n[WebSocket] ✨ 生成成功,内容长度: {len(content)}")
# ✅ 发送激活的世界书条目信息
active_entries = result.get("activeEntries", [])
print(f"[WebSocket] 📚 发送世界书激活信息: {len(active_entries)} 个条目")
await websocket.send_json({
"type": "worldbook_active",
"entries": active_entries
})
# ✅ 发送任务ID信息
task_ids = result.get("taskIds", {})
if task_ids.get("imageWorkflow") or task_ids.get("dynamicTable"):
print(f"[WebSocket] 📋 发送任务ID信息: {task_ids}")
await websocket.send_json({
"type": "tasks_created",
"tasks": task_ids
})
# 一次性发送完整内容
print(f"[WebSocket] 📤 发送完整内容 (chunk)")
await websocket.send_json({
"type": "chunk",
"content": content
})
print(f"[WebSocket] ✅ 发送完成信号")
await websocket.send_json({
"type": "complete"
})
# 保存消息
print(f"[WebSocket] 💾 保存消息到文件...")
await _save_messages(role_name, chat_name, request_data, content)
print(f"[WebSocket] ✅ 消息保存完成\n")
else:
error_msg = result["error"]
print(f"[WebSocket] ❌ 处理失败: {error_msg}")
await websocket.send_json({
"type": "error",
"message": error_msg
})
except WebSocketDisconnect:
print(f"\n{'='*80}")
print(f"[WebSocket] 🔌 连接断开: {role_name}/{chat_name}")
print(f"{'='*80}\n")
except Exception as e:
print(f"\n{'='*80}")
print(f"[WebSocket] ⚠️ 错误: {str(e)}")
print(f"{'='*80}\n")
import traceback
traceback.print_exc()
try:
await websocket.send_json({
"type": "error",
"message": f"服务器错误: {str(e)}"
})
except:
pass
finally:
try:
await websocket.close()
except:
pass
async def _handle_stream_chat(
websocket: WebSocket,
role_name: str,
chat_name: str,
request_data: Dict[str, Any],
workflow_service
):
"""
处理流式聊天请求
Args:
websocket: WebSocket 连接
role_name: 角色名
chat_name: 聊天名
request_data: 请求数据
workflow_service: 工作流服务实例
"""
try:
print(f"[StreamChat] 🚀 开始流式处理")
# ✅ 第1步加载角色卡
current_role = request_data.get("currentRole")
character_data = request_data.get("characterData")
if character_data:
try:
from backend.models.internal import CharacterCard
except ImportError:
from models.internal import CharacterCard
character = CharacterCard(**character_data)
else:
from backend.services.character_service import CharacterService
character_service = CharacterService()
character = character_service.get_character_by_name(current_role)
if not character:
print(f"[StreamChat] ❌ 错误: 无法加载角色 '{current_role}'")
await websocket.send_json({
"type": "error",
"message": f"角色 '{current_role}' 不存在"
})
return
print(f"[StreamChat] ✅ 已加载角色卡: {character.name}")
# ✅ 第2步激活世界书条目在LLM调用之前
print(f"[StreamChat] 📚 正在激活世界书条目...")
active_entries = await workflow_service._collect_and_activate_worldbooks(
request_data,
character
)
# ✅ 发送激活的世界书条目信息在LLM调用前
if active_entries:
print(f"[StreamChat] 📤 发送世界书激活信息: {len(active_entries)} 个条目")
await websocket.send_json({
"type": "worldbook_active",
"entries": active_entries
})
# ✅ TODO: RAG检索暂时为空待实现
rag_results = []
if rag_results:
print(f"[StreamChat] 🔍 发送 RAG 检索结果: {len(rag_results)}")
await websocket.send_json({
"type": "rag_results",
"results": rag_results
})
# ✅ 第2步启动并行任务在LLM调用前创建任务ID
options = request_data.get("options", {})
task_ids = {
"imageWorkflow": None,
"dynamicTable": None
}
if options.get("imageWorkflow", False):
import uuid
chat_id = f"{role_name}/{chat_name}"
task_ids["imageWorkflow"] = f"img_{uuid.uuid4().hex[:8]}"
from backend.services.task_queue_manager import task_queue_manager, TaskType
await task_queue_manager.add_task(task_ids["imageWorkflow"], TaskType.IMAGE_WORKFLOW, chat_id)
if options.get("dynamicTable", False):
import uuid
chat_id = f"{role_name}/{chat_name}"
task_ids["dynamicTable"] = f"tbl_{uuid.uuid4().hex[:8]}"
from backend.services.task_queue_manager import task_queue_manager, TaskType
await task_queue_manager.add_task(task_ids["dynamicTable"], TaskType.DYNAMIC_TABLE, chat_id)
# ✅ 发送任务ID信息在LLM调用前
if task_ids.get("imageWorkflow") or task_ids.get("dynamicTable"):
print(f"[StreamChat] 📤 发送任务ID信息: {task_ids}")
await websocket.send_json({
"type": "tasks_created",
"tasks": task_ids
})
# ✅ 第3步调用LLM流式生成
chunk_count = 0
result = await workflow_service.process_chat_request_stream(
request_data,
on_chunk=lambda chunk: asyncio.create_task(
_send_chunk_with_log(websocket, chunk, chunk_count)
)
)
if result["success"]:
content = result["content"]
print(f"\n[StreamChat] ✨ 流式生成成功,总长度: {len(content)}")
# 发送完成信号
print(f"[StreamChat] ✅ 发送完成信号")
await websocket.send_json({
"type": "complete"
})
# 保存消息
print(f"[StreamChat] 💾 保存消息到文件...")
await _save_messages(role_name, chat_name, request_data, content)
print(f"[StreamChat] ✅ 消息保存完成\n")
else:
error_msg = result["error"]
print(f"[StreamChat] ❌ 流式处理失败: {error_msg}")
await websocket.send_json({
"type": "error",
"message": error_msg
})
except Exception as e:
print(f"\n[StreamChat] ⚠️ 错误: {str(e)}")
import traceback
traceback.print_exc()
await websocket.send_json({
"type": "error",
"message": f"流式处理失败: {str(e)}"
})
async def _send_chunk_with_log(websocket: WebSocket, chunk: str, chunk_count: list):
"""
发送 chunk 并记录日志
Args:
websocket: WebSocket 连接
chunk: 文本片段
chunk_count: 计数器(使用列表以便在闭包中修改)
"""
chunk_count[0] += 1
if chunk_count[0] % 10 == 0: # 每10个chunk记录一次
print(f"[StreamChat] 📤 已发送 {chunk_count[0]} 个 chunks")
await websocket.send_json({"type": "chunk", "content": chunk})
async def _save_messages(
role_name: str,
chat_name: str,
request_data: Dict[str, Any],
ai_response: str
):
"""
保存用户消息和AI回复到聊天文件
Args:
role_name: 角色名
chat_name: 聊天名
request_data: 前端发送的请求数据
ai_response: AI生成的回复
"""
try:
from datetime import datetime
# 1. 保存用户消息
user_message = {
"id": f"msg_{datetime.now().timestamp()}_user",
"name": request_data.get("userName", "User"),
"is_user": True,
"is_system": False,
"sendDate": datetime.now().isoformat(),
"mes": request_data.get("mes", ""),
"chatId": f"{role_name}/{chat_name}",
"floor": request_data.get("floor", 0)
}
chat_service.add_message(role_name, chat_name, user_message)
# 2. 保存AI回复
ai_message = {
"id": f"msg_{datetime.now().timestamp()}_ai",
"name": request_data.get("characterName", role_name),
"is_user": False,
"is_system": False,
"sendDate": datetime.now().isoformat(),
"mes": ai_response,
"chatId": f"{role_name}/{chat_name}",
"floor": request_data.get("floor", 0) + 1
}
chat_service.add_message(role_name, chat_name, ai_message)
print(f"[WebSocket] 消息已保存: {role_name}/{chat_name}")
except Exception as e:
print(f"[WebSocket] 保存消息失败: {e}")
# 不抛出异常,避免影响主流程

View File

@@ -43,7 +43,7 @@ async def list_role_chats(role_name: str):
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{role_name}", status_code=status.HTTP_201_CREATED)
async def create_chat(role_name: str, chat_data: dict):
"""创建新聊天"""

View File

@@ -0,0 +1,140 @@
"""
图片画廊路由
提供图片查询、删除等管理接口
"""
from fastapi import APIRouter, HTTPException
from typing import Dict, Any, List, Optional
import os
try:
from backend.services.image_metadata_service import image_metadata_service
except ImportError:
from services.image_metadata_service import image_metadata_service
router = APIRouter(prefix="/image-gallery", tags=["image-gallery"])
@router.get("/stats")
async def get_gallery_stats():
"""获取画廊统计信息"""
return await image_metadata_service.get_gallery_stats()
@router.get("/images/{chat_id}")
async def get_chat_images(
chat_id: str,
floor: Optional[int] = None
):
"""
获取指定聊天的图片列表
Args:
chat_id: 聊天ID (role_name/chat_name)
floor: 楼层号(可选)
"""
images = await image_metadata_service.get_images_by_chat(chat_id, floor)
return {
"chatId": chat_id,
"totalImages": len(images),
"images": [img.model_dump() for img in images]
}
@router.get("/images/role/{role_name}")
async def get_role_images(role_name: str):
"""获取指定角色的所有图片"""
images = await image_metadata_service.get_images_by_role(role_name)
return {
"roleName": role_name,
"totalImages": len(images),
"images": [img.model_dump() for img in images]
}
@router.delete("/images/{chat_id}/{image_id}")
async def delete_image(chat_id: str, image_id: str):
"""
删除图片(元数据和文件)
Args:
chat_id: 聊天ID
image_id: 图片ID
"""
# 先获取元数据以得到文件路径
images = await image_metadata_service.get_images_by_chat(chat_id)
target_image = None
for img in images:
if img.id == image_id:
target_image = img
break
if not target_image:
raise HTTPException(status_code=404, detail="图片不存在")
# 删除元数据
success = await image_metadata_service.delete_image(chat_id, image_id)
if not success:
raise HTTPException(status_code=500, detail="删除失败")
# 删除实际文件
try:
file_path = image_metadata_service.get_image_full_path(target_image.filepath)
if file_path.exists():
file_path.unlink()
except Exception as e:
print(f"[ImageGallery] 删除文件失败: {e}")
# 不抛出异常,因为元数据已删除
return {"message": "图片已删除"}
@router.post("/images/{chat_id}/clear")
async def clear_chat_images(chat_id: str):
"""
清空指定聊天的所有图片
Args:
chat_id: 聊天ID
"""
count = await image_metadata_service.clear_chat_images(chat_id)
return {
"message": f"已清空 {count} 张图片",
"deletedCount": count
}
@router.post("/images/{chat_id}/{image_id}/set-current")
async def set_current_swipe(chat_id: str, image_id: str):
"""
设置某张图片为当前显示的 swipe
Args:
chat_id: 聊天ID
image_id: 图片ID
"""
success = await image_metadata_service.set_current_swipe(chat_id, image_id)
if not success:
raise HTTPException(status_code=404, detail="图片不存在")
return {"message": "已设置为当前显示"}
@router.get("/image/{filepath:path}")
async def get_image(filepath: str):
"""
获取图片文件
Args:
filepath: 文件相对路径
"""
from fastapi.responses import FileResponse
file_path = image_metadata_service.get_image_full_path(filepath)
if not file_path.exists():
raise HTTPException(status_code=404, detail="图片文件不存在")
return FileResponse(str(file_path))

View File

@@ -0,0 +1,248 @@
"""
正则规则 API 路由
提供正则规则的 CRUD 操作和导入导出功能
"""
from fastapi import APIRouter, HTTPException, UploadFile, File
from pydantic import BaseModel
from typing import List, Optional
import json
import logging
from services.regex_service import regex_service
from models.regex_rules import RegexRule, RegexRuleset, RegexScope
from services.system_settings_service import system_settings_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/regex", tags=["regex"])
# ==================== 数据模型 ====================
class RuleUpdateRequest(BaseModel):
"""规则更新请求"""
rule: RegexRule
scope: RegexScope
name: Optional[str] = None # 角色卡名称或预设名称scope 为 CHARACTER/PRESET 时需要)
class SystemSettingsUpdate(BaseModel):
"""系统设置更新请求"""
thinkingTagPrefix: Optional[str] = None
thinkingTagSuffix: Optional[str] = None
currentPresetName: Optional[str] = None
# ==================== 规则查询 ====================
@router.get("/rules")
async def get_rules(
character_name: Optional[str] = None,
preset_name: Optional[str] = None
):
"""
获取适用的正则规则列表
Args:
character_name: 当前角色卡名称(可选)
preset_name: 当前预设名称(可选)
Returns:
规则列表
"""
try:
rules = regex_service.get_rules_for_context(character_name, preset_name)
return {
"success": True,
"rules": [rule.dict() for rule in rules],
"count": len(rules)
}
except Exception as e:
logger.error(f"获取规则失败: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/rulesets/global")
async def get_global_rulesets():
"""获取所有全局规则集"""
try:
rulesets = list(regex_service.global_rulesets.values())
return {
"success": True,
"rulesets": [rs.dict() for rs in rulesets]
}
except Exception as e:
logger.error(f"获取全局规则集失败: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/rulesets/character/{character_name}")
async def get_character_ruleset(character_name: str):
"""获取指定角色卡的规则集"""
try:
if character_name in regex_service.character_rulesets:
ruleset = regex_service.character_rulesets[character_name]
return {
"success": True,
"ruleset": ruleset.dict()
}
else:
return {
"success": True,
"ruleset": None
}
except Exception as e:
logger.error(f"获取角色规则集失败: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/rulesets/preset/{preset_name}")
async def get_preset_ruleset(preset_name: str):
"""获取指定预设的规则集"""
try:
if preset_name in regex_service.preset_rulesets:
ruleset = regex_service.preset_rulesets[preset_name]
return {
"success": True,
"ruleset": ruleset.dict()
}
else:
return {
"success": True,
"ruleset": None
}
except Exception as e:
logger.error(f"获取预设规则集失败: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ==================== 规则管理 ====================
@router.post("/rules")
async def add_rule(request: RuleUpdateRequest):
"""添加新规则"""
try:
regex_service.save_ruleset(
RegexRuleset(
id=request.rule.id,
name=request.rule.scriptName,
rules=[request.rule]
),
request.scope,
request.name
)
# 重新加载规则
regex_service._load_all_rules()
return {
"success": True,
"message": "规则添加成功"
}
except Exception as e:
logger.error(f"添加规则失败: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/rules/{rule_id}")
async def delete_rule(rule_id: str):
"""删除规则(需要指定作用域和名称)"""
# TODO: 实现具体的删除逻辑
return {
"success": False,
"message": "暂未实现,请使用规则集级别的删除"
}
# ==================== 规则导入导出 ====================
@router.post("/import")
async def import_rules(file: UploadFile = File(...)):
"""
导入正则规则(支持 SillyTavern 格式)
可以导入:
1. 单个规则文件JSON 数组)
2. 规则集文件JSON 对象)
"""
try:
content = await file.read()
data = json.loads(content.decode('utf-8'))
# 判断格式并导入
if isinstance(data, list):
# SillyTavern 格式 - 导入为全局规则
ruleset = regex_service._convert_sillytavern_format(data, file.filename.replace('.json', ''))
regex_service.save_ruleset(ruleset, RegexScope.GLOBAL)
elif isinstance(data, dict):
if 'rules' in data:
# 规则集格式
ruleset = RegexRuleset(**data)
regex_service.save_ruleset(ruleset, RegexScope.GLOBAL)
else:
raise ValueError("未知的文件格式")
else:
raise ValueError("无效的文件格式")
# 重新加载规则
regex_service._load_all_rules()
return {
"success": True,
"message": f"成功导入规则集: {ruleset.name}"
}
except Exception as e:
logger.error(f"导入规则失败: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/export/global")
async def export_global_rules():
"""导出所有全局规则"""
try:
all_rulesets = list(regex_service.global_rulesets.values())
return {
"success": True,
"rulesets": [rs.dict() for rs in all_rulesets]
}
except Exception as e:
logger.error(f"导出规则失败: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ==================== 系统设置 ====================
@router.get("/settings")
async def get_system_settings():
"""获取系统设置"""
try:
settings = system_settings_service.settings
return {
"success": True,
"settings": settings.dict()
}
except Exception as e:
logger.error(f"获取系统设置失败: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.put("/settings")
async def update_system_settings(request: SystemSettingsUpdate):
"""更新系统设置"""
try:
if request.thinkingTagPrefix is not None or request.thinkingTagSuffix is not None:
prefix = request.thinkingTagPrefix or system_settings_service.settings.thinkingTagPrefix
suffix = request.thinkingTagSuffix or system_settings_service.settings.thinkingTagSuffix
system_settings_service.update_thinking_tags(prefix, suffix)
if request.currentPresetName is not None:
system_settings_service.update_current_preset(request.currentPresetName)
return {
"success": True,
"message": "系统设置已更新"
}
except Exception as e:
logger.error(f"更新系统设置失败: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -0,0 +1,130 @@
"""
Token 使用统计路由
提供 token 使用情况的查询接口
"""
from fastapi import APIRouter, HTTPException
from typing import Dict, Any, List, Optional
try:
from backend.services.token_usage_service import token_usage_service
except ImportError:
from services.token_usage_service import token_usage_service
router = APIRouter(prefix="/token-usage", tags=["token-usage"])
@router.get("/months")
async def list_months():
"""列出所有有数据的月份"""
return await token_usage_service.list_months()
@router.get("/stats/{year}/{month}")
async def get_monthly_stats(
year: int,
month: int,
role_name: Optional[str] = None,
chat_name: Optional[str] = None
):
"""
获取指定月份的统计数据
Args:
year: 年份
month: 月份
role_name: 角色名称(可选)
chat_name: 聊天名称(可选)
"""
if month < 1 or month > 12:
raise HTTPException(status_code=400, detail="月份必须在 1-12 之间")
try:
stats = await token_usage_service.get_stats_by_month(
year=year,
month=month,
role_name=role_name,
chat_name=chat_name
)
return stats
except Exception as e:
raise HTTPException(status_code=500, detail=f"获取统计数据失败: {str(e)}")
@router.get("/api-urls")
async def get_api_url_stats():
"""
✅ 获取按 API URL 分组的统计数据(快速查询)
Returns:
{
"api_url_1": {
"totalPromptTokens": 1000,
"totalCompletionTokens": 2000,
"totalTokens": 3000,
"count": 10,
"firstUsed": 1234567890,
"lastUsed": 1234567899
},
...
}
"""
try:
stats = await token_usage_service.get_api_url_stats()
return stats
except Exception as e:
raise HTTPException(status_code=500, detail=f"获取 API URL 统计失败: {str(e)}")
@router.get("/daily/{year}/{month}")
async def get_daily_stats(year: int, month: int):
"""
✅ 获取指定月份的每日统计数据(快速查询)
Args:
year: 年份
month: 月份
Returns:
{
"2024-01-01": {
"promptTokens": 1000,
"completionTokens": 2000,
"totalTokens": 3000,
"count": 10
},
...
}
"""
if month < 1 or month > 12:
raise HTTPException(status_code=400, detail="月份必须在 1-12 之间")
try:
stats = await token_usage_service.get_daily_stats(year, month)
return stats
except Exception as e:
raise HTTPException(status_code=500, detail=f"获取每日统计失败: {str(e)}")
@router.get("/roles/{year}/{month}")
async def get_available_roles(year: int, month: int):
"""获取指定月份有数据的角色列表"""
if month < 1 or month > 12:
raise HTTPException(status_code=400, detail="月份必须在 1-12 之间")
roles = await token_usage_service.get_available_roles(year, month)
return {"roles": roles}
@router.get("/chats/{year}/{month}")
async def get_available_chats(
year: int,
month: int,
role_name: Optional[str] = None
):
"""获取指定月份有数据的聊天列表"""
if month < 1 or month > 12:
raise HTTPException(status_code=400, detail="月份必须在 1-12 之间")
chats = await token_usage_service.get_available_chats(year, month, role_name)
return {"chats": chats}

View File

@@ -19,13 +19,6 @@ load_dotenv(PROJECT_ROOT / ".env")
class Settings:
# --- 主模型配置 ---
MAIN_LLM_API_KEY = os.getenv("MAIN_LLM_API_KEY")
MAIN_LLM_MODEL = os.getenv("MAIN_LLM_MODEL", "gpt-3.5-turbo")
MAIN_LLM_BASE_URL = os.getenv("MAIN_LLM_BASE_URL", "https://api.openai.com/v1")
MAIN_LLM_MAX_TOKENS = int(os.getenv("MAIN_LLM_MAX_TOKENS", "4096"))
MAIN_LLM_STREAM = os.getenv("MAIN_LLM_STREAM", "true").lower() == "true"
# --- 路径配置 (核心修改) ---
# 强制使用计算出的项目根目录,不再依赖 .env 中的 BASE_PATH
@@ -38,7 +31,8 @@ class Settings:
STATE_FILE = DATA_PATH / "state.json"
SCHEMA_FILE = DATA_PATH / "schema.json"
PRESETS_FILE = DATA_PATH / "presets.json"
REGEX_FILE = DATA_PATH / "regex_rules.json"
REGEX_FILE = DATA_PATH / "regex_rules.json" # 正则规则文件
SYSTEM_SETTINGS_FILE = DATA_PATH / "system_settings.json" # 系统设置文件
VECTORSTORE_PATH = DATA_PATH / "vectorstore"
# --- 业务数据目录 ---
@@ -49,8 +43,11 @@ class Settings:
# 预设目录
PRESET_PATH = DATA_PATH / "preset"
# 聊天记录目录
# 聊天记录目录(同时存放角色卡和聊天)
CHAT_PATH = DATA_PATH / "chat"
# 兼容别名:用于代码中引用
CHATS_PATH = CHAT_PATH
# 临时文件目录
TEMP_PATH = DATA_PATH / "temp"
@@ -58,8 +55,8 @@ class Settings:
# ComfyUI 工作流目录
COMFYUI_WORKFLOWS_PATH = DATA_PATH / "comfyui_workflows"
# 角色卡目录
CHARACTERS_PATH = DATA_PATH / "characters"
# 角色卡目录(已合并到 CHAT_PATH
CHARACTERS_PATH = CHAT_PATH
# 图片资源目录
IMAGES_PATH = DATA_PATH / "images"
@@ -78,6 +75,10 @@ class Settings:
]
for directory in directories:
directory.mkdir(parents=True, exist_ok=True)
# 确保核心数据文件的父目录存在
for file_path in [self.STATE_FILE, self.SCHEMA_FILE, self.PRESETS_FILE, self.REGEX_FILE, self.SYSTEM_SETTINGS_FILE]:
file_path.parent.mkdir(parents=True, exist_ok=True)
settings = Settings()

View File

@@ -141,7 +141,14 @@ class CharacterCard(BaseModel):
outputSchema: Optional[List[OutputSchemaField]] = Field(None, description="输出 schema 定义 (结构化输出)")
avatarPath: Optional[str] = Field(None, description="角色头像路径")
alternate_greetings: Optional[List[str]] = Field(None, description="替代问候语数组")
tags: Optional[List[str]] = Field(None, description="标签数组")
# TODO: 拓展提示词设置(插件/拓展系统预留接口)
# - tableMaintenancePrompt: 用于指导 AI 维护动态表格RPG状态、任务追踪等
# - imageGenerationPrompt: 用于指导 AI 生成图片描述提示词
# 当前状态:字段已定义,默认值为 None等待插件系统实现
tableMaintenancePrompt: Optional[str] = Field(None, description="动态表格维护提示词 - 指导 AI 如何更新表格数据")
imageGenerationPrompt: Optional[str] = Field(None, description="生图提示词模板 - 指导 AI 如何生成图片描述")
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
lastChatAt: Optional[int] = Field(None, description="最后聊天时间戳")
@@ -300,3 +307,84 @@ class ChatRAGConfig(BaseModel):
indexConfig: Optional[Dict[str, Any]] = Field(None, description="索引配置")
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
# ==================== Token 统计 ====================
class TokenUsageStatus(str, Enum):
"""Token 使用状态"""
COMPLETED = 'completed' # 成功完成
INTERRUPTED = 'interrupted' # 被用户中断
FAILED = 'failed' # 请求失败API错误等
class TokenUsageRecord(BaseModel):
"""
Token 使用记录
记录每次 LLM 调用的 token 使用情况,支持按时间、角色、聊天维度统计
"""
id: str = Field(..., description="记录唯一标识符 (UUID)")
chatId: str = Field(..., description="聊天ID (role_name/chat_name)")
roleName: str = Field(..., description="角色名称")
chatName: str = Field(..., description="聊天名称")
messageId: Optional[str] = Field(None, description="关联的消息ID")
floor: Optional[int] = Field(None, description="楼层号")
# Token 统计
promptTokens: int = Field(0, description="输入 token 数")
completionTokens: int = Field(0, description="输出 token 数")
totalTokens: int = Field(0, description="总 token 数")
# 状态信息
status: TokenUsageStatus = Field(TokenUsageStatus.COMPLETED, description="请求状态")
errorMessage: Optional[str] = Field(None, description="错误信息(如果失败)")
# 时间信息
timestamp: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="请求时间戳")
duration: Optional[float] = Field(None, description="请求耗时(秒)")
# API 信息
model: Optional[str] = Field(None, description="使用的模型")
apiProvider: Optional[str] = Field(None, description="API 提供商")
apiUrl: Optional[str] = Field(None, description="API URL地址")
# ==================== 图片元数据 ====================
class ImageMetadata(BaseModel):
"""
图片元数据
记录生成的图片信息,绑定到角色/聊天的特定楼层
"""
id: str = Field(..., description="图片唯一标识符 (UUID)")
chatId: str = Field(..., description="聊天ID (role_name/chat_name)")
roleName: str = Field(..., description="角色名称")
chatName: str = Field(..., description="聊天名称")
floor: int = Field(..., description="楼层号")
# 图片信息
filename: str = Field(..., description="文件名")
filepath: str = Field(..., description="文件相对路径")
width: Optional[int] = Field(None, description="图片宽度")
height: Optional[int] = Field(None, description="图片高度")
fileSize: Optional[int] = Field(None, description="文件大小(字节)")
# Swipe 支持
swipeIndex: int = Field(0, description="Swipe 索引(同一楼层多张图片)")
isCurrentSwipe: bool = Field(True, description="是否为当前显示的 swipe")
# 生成信息
prompt: Optional[str] = Field(None, description="生成使用的提示词")
negativePrompt: Optional[str] = Field(None, description="负面提示词")
seed: Optional[int] = Field(None, description="随机种子")
model: Optional[str] = Field(None, description="使用的模型/checkpoint")
workflowName: Optional[str] = Field(None, description="使用的工作流名称")
# 任务信息
taskId: Optional[str] = Field(None, description="关联的任务ID")
generationTime: Optional[float] = Field(None, description="生成耗时(秒)")
# 时间信息
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")

View File

@@ -0,0 +1,164 @@
"""
正则替换规则模型
兼容 SillyTavern 的正则系统,支持全局、角色卡、预设三种作用域。
"""
from enum import Enum
from typing import List, Optional, Dict, Any
from pydantic import BaseModel, Field
from datetime import datetime
class RegexPlacement(int, Enum):
"""
正则应用位置(对应 SillyTavern 的 placement 数组)
0: System Prompt - 系统提示词
1: User Input - 用户输入
2: AI Output - AI 输出
3: Quick Reply - 快捷回复
4: World Info - 世界书信息
5: Reasoning/Thinking - 推理/思考内容
"""
SYSTEM_PROMPT = 0
USER_INPUT = 1
AI_OUTPUT = 2
QUICK_REPLY = 3
WORLD_INFO = 4
REASONING = 5
class RegexScope(str, Enum):
"""
正则规则作用域
- GLOBAL: 全局生效,对所有聊天应用
- CHARACTER: 绑定到特定角色卡
- PRESET: 绑定到特定预设
"""
GLOBAL = 'global'
CHARACTER = 'character'
PRESET = 'preset'
class SubstituteMode(int, Enum):
"""
替换模式
对应 SillyTavern 的 substituteRegex 字段
"""
REPLACE_ALL = 0 # 替换所有匹配
REPLACE_FIRST = 1 # 仅替换首次匹配
REPLACE_CAPTURED = 2 # 替换捕获组
class RegexRule(BaseModel):
"""
单条正则替换规则
完全兼容 SillyTavern 的正则规则格式
"""
id: str = Field(..., description="规则唯一标识符 (UUID)")
scriptName: str = Field(..., description="脚本名称(用于显示)")
# 核心正则配置
findRegex: str = Field(..., description="查找正则表达式(如:/<thinking>[\\s\\S]*?<\\/thinking>/gi")
replaceString: str = Field("", description="替换字符串(支持捕获组引用 $1, $2 等)")
trimStrings: List[str] = Field(default_factory=list, description="要额外修剪的字符串数组")
# 应用位置(关键!对应 SillyTavern 的 placement 数组)
placement: List[RegexPlacement] = Field(
default_factory=lambda: [RegexPlacement.AI_OUTPUT],
description="应用位置数组0=系统提示词, 1=用户输入, 2=AI输出, 3=快捷回复, 4=世界书, 5=推理内容"
)
# 替换模式
substituteRegex: SubstituteMode = Field(
SubstituteMode.REPLACE_ALL,
description="替换模式0=全部1=首次2=捕获组"
)
# 作用范围控制
markdownOnly: bool = Field(False, description="是否仅应用于 Markdown 渲染后的内容")
promptOnly: bool = Field(False, description="是否仅应用于发送给 LLM 的提示词")
runOnEdit: bool = Field(True, description="用户编辑消息时是否重新应用")
# 消息深度控制
minDepth: int = Field(0, ge=0, description="最小消息深度(从最新消息开始计数)")
maxDepth: Optional[int] = Field(None, ge=0, description="最大消息深度None 表示无限制)")
# 作用域配置
scope: RegexScope = Field(RegexScope.GLOBAL, description="规则作用域")
characterName: Optional[str] = Field(None, description="绑定的角色卡名称scope=CHARACTER 时使用)")
presetName: Optional[str] = Field(None, description="绑定的预设名称scope=PRESET 时使用)")
# 启用状态
disabled: bool = Field(False, description="是否禁用此规则(与 enabled 相反,为了兼容 ST")
# 执行顺序
order: int = Field(0, description="执行顺序(数值越小越先执行)")
# 元数据
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
description: Optional[str] = Field(None, description="规则描述(可选)")
class RegexRuleset(BaseModel):
"""
正则规则集
一组正则规则的集合,可以整体导入/导出,兼容 SillyTavern 格式
"""
id: str = Field(..., description="规则集唯一标识符 (UUID)")
name: str = Field(..., description="规则集名称")
description: Optional[str] = Field(None, description="规则集描述")
rules: List[RegexRule] = Field(default_factory=list, description="规则列表")
# 元数据
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="最后更新时间戳")
version: int = Field(1, description="版本号(用于数据迁移)")
# SillyTavern 兼容性标记
isSillyTavernFormat: bool = Field(False, description="是否为 SillyTavern 导入格式")
# ==================== 使用示例 ====================
if __name__ == '__main__':
import json
# 创建一条规则
rule = RegexRule(
id="example-hide-thinking-001",
scriptName="隐藏思考标签",
findRegex=r"<thinking>[\s\S]*?<\/thinking>",
replaceString="",
trimStrings=[],
placement=[RegexPlacement.AI_OUTPUT],
substituteRegex=SubstituteMode.REPLACE_ALL,
markdownOnly=False,
promptOnly=False,
runOnEdit=True,
minDepth=0,
maxDepth=None,
scope=RegexScope.GLOBAL,
characterName=None,
presetName=None,
disabled=False,
order=1,
description="隐藏 AI 回复中的 <thinking> 标签及其内容"
)
# 创建规则集
ruleset = RegexRuleset(
id="ruleset-001",
name="默认正则规则集",
description="包含常用的文本处理规则",
rules=[rule]
)
# 导出为 JSON兼容 SillyTavern
print(json.dumps(ruleset.dict(), indent=2, ensure_ascii=False))

View File

@@ -0,0 +1,49 @@
"""
系统设置模型
包含全局配置,如思考标签前后缀等。
"""
from typing import Optional
from pydantic import BaseModel, Field
from datetime import datetime
class SystemSettings(BaseModel):
"""
系统全局设置
持久化存储到 data/system_settings.json
"""
# ==================== 思考标签配置 ====================
thinkingTagPrefix: str = Field(
"<thinking>",
description="思考标签前缀(默认:<thinking>"
)
thinkingTagSuffix: str = Field(
"</thinking>",
description="思考标签后缀(默认:</thinking>"
)
# ==================== 当前选中的预设 ====================
currentPresetName: Optional[str] = Field(
None,
description="当前选中的预设名称(用于确定全局正则的作用域)"
)
# ==================== 元数据 ====================
updatedAt: int = Field(
default_factory=lambda: int(datetime.now().timestamp()),
description="最后更新时间戳"
)
version: int = Field(1, description="版本号")
# ==================== 默认设置 ====================
DEFAULT_SYSTEM_SETTINGS = SystemSettings()
if __name__ == '__main__':
import json
print(json.dumps(DEFAULT_SYSTEM_SETTINGS.dict(), indent=2, ensure_ascii=False))

View File

@@ -177,8 +177,8 @@ class CharacterService:
更新角色卡
Args:
name: 角色名
updates: 更新的字段
name: 角色名(旧名称,用于定位文件夹)
updates: 更新的字段(可以包含 name 字段来重命名)
Returns:
更新后的 CharacterCard 对象
@@ -193,6 +193,29 @@ class CharacterService:
with open(char_file, 'r', encoding='utf-8') as f:
existing_data = json.load(f)
# 检查是否需要重命名
new_name = updates.get('name')
needs_rename = new_name and new_name != name
if needs_rename:
# 验证新名称是否合法
if not new_name or new_name.strip() == '':
raise ValueError("角色名不能为空")
# 检查新名称是否已存在
new_folder = self.characters_dir / new_name
if new_folder.exists():
raise FileExistsError(f"角色 '{new_name}' 已存在")
# 重命名文件夹
try:
import shutil
shutil.move(str(char_folder), str(new_folder))
char_folder = new_folder
char_file = char_folder / "character.json"
except Exception as e:
raise RuntimeError(f"重命名文件夹失败: {str(e)}")
# 合并更新
existing_data.update(updates)
existing_data['updatedAt'] = int(datetime.now().timestamp())

View File

@@ -57,7 +57,7 @@ class ChatService:
logger.error(f"处理角色目录 {role_dir.name} 时出错: {str(e)}")
continue
return {"chat": [{"role_name": role, **chat} for role, chats in result.items() for chat in chats]}
return result
def _get_chat_summary(self, role_name: str, chat_name: str) -> Optional[Dict]:
"""
@@ -197,7 +197,8 @@ class ChatService:
tags = []
try:
character_file = Path("data/characters") / role_name / "character.json"
from backend.core.config import settings
character_file = settings.CHARACTERS_PATH / role_name / "character.json"
if character_file.exists():
with open(character_file, 'r', encoding='utf-8') as f:
character_data = json.load(f)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,346 @@
"""
图片元数据服务
负责管理生成图片的元数据,支持绑定到角色/聊天的特定楼层
数据持久化到 data/image_metadata 目录
"""
import json
import uuid
from pathlib import Path
from typing import List, Dict, Optional, Any
from datetime import datetime
try:
from backend.models.internal import ImageMetadata
from backend.core.config import settings
except ImportError:
from models.internal import ImageMetadata
from core.config import settings
class ImageMetadataService:
"""
图片元数据服务
功能:
- 记录生成图片的元数据
- 按角色/聊天/楼层组织
- 支持 swipe同一楼层多张图片
- 提供画廊查询接口
"""
def __init__(self):
self.metadata_dir = settings.DATA_PATH / "image_metadata"
self.metadata_dir.mkdir(parents=True, exist_ok=True)
# 图片存储目录
self.images_dir = settings.DATA_PATH / "images"
self.images_dir.mkdir(parents=True, exist_ok=True)
def _get_chat_metadata_file(self, chat_id: str) -> Path:
"""获取指定聊天的元数据文件路径"""
# chat_id 格式: role_name/chat_name
parts = chat_id.split("/")
if len(parts) == 2:
role_name, chat_name = parts
role_dir = self.metadata_dir / role_name
role_dir.mkdir(parents=True, exist_ok=True)
return role_dir / f"{chat_name}.json"
else:
# fallback
return self.metadata_dir / f"{chat_id.replace('/', '_')}.json"
def _load_chat_metadata(self, chat_id: str) -> List[ImageMetadata]:
"""加载指定聊天的所有图片元数据"""
file_path = self._get_chat_metadata_file(chat_id)
if not file_path.exists():
return []
try:
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
return [ImageMetadata(**item) for item in data]
except Exception as e:
print(f"[ImageMetadata] 加载元数据失败: {e}")
return []
def _save_chat_metadata(self, chat_id: str, metadata_list: List[ImageMetadata]):
"""保存聊天的所有图片元数据"""
file_path = self._get_chat_metadata_file(chat_id)
try:
data = [m.model_dump() for m in metadata_list]
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
except Exception as e:
print(f"[ImageMetadata] 保存元数据失败: {e}")
async def add_image(
self,
chat_id: str,
role_name: str,
chat_name: str,
floor: int,
filename: str,
filepath: str,
prompt: Optional[str] = None,
negative_prompt: Optional[str] = None,
seed: Optional[int] = None,
model: Optional[str] = None,
workflow_name: Optional[str] = None,
task_id: Optional[str] = None,
generation_time: Optional[float] = None,
width: Optional[int] = None,
height: Optional[int] = None,
file_size: Optional[int] = None
) -> ImageMetadata:
"""
添加图片元数据
Args:
chat_id: 聊天ID
role_name: 角色名称
chat_name: 聊天名称
floor: 楼层号
filename: 文件名
filepath: 文件相对路径
prompt: 提示词
negative_prompt: 负面提示词
seed: 随机种子
model: 使用的模型
workflow_name: 工作流名称
task_id: 任务ID
generation_time: 生成耗时
width: 图片宽度
height: 图片高度
file_size: 文件大小
Returns:
ImageMetadata: 创建的元数据
"""
metadata_list = self._load_chat_metadata(chat_id)
# 计算 swipe_index
same_floor_images = [m for m in metadata_list if m.floor == floor]
swipe_index = len(same_floor_images)
# 如果这是该楼层的第一张图片,将其他图片的 isCurrentSwipe 设为 False
if swipe_index == 0:
for m in metadata_list:
if m.floor == floor:
m.isCurrentSwipe = False
metadata = ImageMetadata(
id=str(uuid.uuid4()),
chatId=chat_id,
roleName=role_name,
chatName=chat_name,
floor=floor,
filename=filename,
filepath=filepath,
prompt=prompt,
negativePrompt=negative_prompt,
seed=seed,
model=model,
workflowName=workflow_name,
taskId=task_id,
generationTime=generation_time,
width=width,
height=height,
fileSize=file_size,
swipeIndex=swipe_index,
isCurrentSwipe=True
)
metadata_list.append(metadata)
self._save_chat_metadata(chat_id, metadata_list)
return metadata
async def get_images_by_chat(
self,
chat_id: str,
floor: Optional[int] = None
) -> List[ImageMetadata]:
"""
获取指定聊天的图片列表
Args:
chat_id: 聊天ID
floor: 楼层号(可选,用于过滤)
Returns:
图片元数据列表
"""
metadata_list = self._load_chat_metadata(chat_id)
if floor is not None:
metadata_list = [m for m in metadata_list if m.floor == floor]
# 按楼层和 swipe_index 排序
metadata_list.sort(key=lambda m: (m.floor, m.swipeIndex))
return metadata_list
async def get_images_by_role(self, role_name: str) -> List[ImageMetadata]:
"""获取指定角色的所有图片"""
all_images = []
role_dir = self.metadata_dir / role_name
if not role_dir.exists():
return []
for chat_file in role_dir.glob("*.json"):
chat_name = chat_file.stem
chat_id = f"{role_name}/{chat_name}"
images = self._load_chat_metadata(chat_id)
all_images.extend(images)
# 按创建时间排序
all_images.sort(key=lambda m: m.createdAt, reverse=True)
return all_images
async def delete_image(self, chat_id: str, image_id: str) -> bool:
"""
删除图片元数据(不删除实际文件)
Args:
chat_id: 聊天ID
image_id: 图片ID
Returns:
bool: 是否成功删除
"""
metadata_list = self._load_chat_metadata(chat_id)
# 找到要删除的图片
target_image = None
for m in metadata_list:
if m.id == image_id:
target_image = m
break
if not target_image:
return False
floor = target_image.floor
swipe_index = target_image.swipeIndex
# 删除该图片
metadata_list = [m for m in metadata_list if m.id != image_id]
# 重新调整同一楼层其他图片的 swipe_index
same_floor_images = [m for m in metadata_list if m.floor == floor]
same_floor_images.sort(key=lambda m: m.swipeIndex)
for idx, m in enumerate(same_floor_images):
m.swipeIndex = idx
m.isCurrentSwipe = (idx == 0) # 第一个为当前显示
self._save_chat_metadata(chat_id, metadata_list)
return True
async def clear_chat_images(self, chat_id: str) -> int:
"""
清空指定聊天的所有图片元数据
Args:
chat_id: 聊天ID
Returns:
int: 删除的图片数量
"""
metadata_list = self._load_chat_metadata(chat_id)
count = len(metadata_list)
# 清空元数据文件
file_path = self._get_chat_metadata_file(chat_id)
if file_path.exists():
file_path.unlink()
return count
async def set_current_swipe(self, chat_id: str, image_id: str) -> bool:
"""
设置某张图片为当前显示的 swipe
Args:
chat_id: 聊天ID
image_id: 图片ID
Returns:
bool: 是否成功设置
"""
metadata_list = self._load_chat_metadata(chat_id)
target_image = None
for m in metadata_list:
if m.id == image_id:
target_image = m
break
if not target_image:
return False
floor = target_image.floor
# 将同一楼层的所有图片设为非当前
for m in metadata_list:
if m.floor == floor:
m.isCurrentSwipe = False
# 设置目标图片为当前
target_image.isCurrentSwipe = True
self._save_chat_metadata(chat_id, metadata_list)
return True
async def get_gallery_stats(self) -> Dict[str, Any]:
"""
获取画廊统计信息
Returns:
统计信息字典
"""
stats = {
"totalImages": 0,
"byRole": {},
"byChat": {}
}
if not self.metadata_dir.exists():
return stats
for role_dir in self.metadata_dir.iterdir():
if not role_dir.is_dir():
continue
role_name = role_dir.name
role_count = 0
for chat_file in role_dir.glob("*.json"):
chat_name = chat_file.stem
chat_id = f"{role_name}/{chat_name}"
images = self._load_chat_metadata(chat_id)
chat_count = len(images)
role_count += chat_count
stats["totalImages"] += chat_count
if chat_count > 0:
stats["byChat"][chat_id] = chat_count
if role_count > 0:
stats["byRole"][role_name] = role_count
return stats
def get_image_full_path(self, filepath: str) -> Path:
"""获取图片的完整路径"""
return self.images_dir / filepath
# 全局实例
image_metadata_service = ImageMetadataService()

View File

@@ -27,18 +27,35 @@ class LLMModelService:
if not base_url:
base_url = "https://api.openai.com/v1"
# 确保 base_url 以 /v1 结尾
if not base_url.endswith('/v1'):
base_url = base_url.rstrip('/') + '/v1'
# 规范化 base_url:确保有协议前缀
base_url = base_url.strip()
if not base_url.startswith(('http://', 'https://')):
base_url = 'https://' + base_url
response = requests.get(
f"{base_url}/models",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
},
timeout=10
)
# 移除末尾的斜杠和常见 endpoint 路径
base_url = base_url.rstrip('/')
# 移除可能已经存在的 endpoint 路径
for endpoint in ['/chat/completions', '/completions', '/embeddings', '/models']:
if base_url.endswith(endpoint):
base_url = base_url[:-len(endpoint)]
break
# 调用 models API
try:
response = requests.get(
f"{base_url}/models",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
},
timeout=10
)
except requests.exceptions.InvalidSchema as e:
raise Exception(f"URL 格式错误: {base_url}/models - 请确保 URL 以 http:// 或 https:// 开头")
except requests.exceptions.ConnectionError as e:
raise Exception(f"无法连接到 API: {base_url}/models - 请检查网络连接和 API 地址")
except requests.exceptions.Timeout as e:
raise Exception(f"请求超时: {base_url}/models - 请检查网络连接")
if response.status_code != 200:
raise Exception(f"HTTP {response.status_code}: {response.text}")
@@ -46,14 +63,8 @@ class LLMModelService:
data = response.json()
models = [model['id'] for model in data.get('data', [])]
# 过滤出聊天模型(可选)
chat_models = [
m for m in models
if any(keyword in m.lower() for keyword in ['gpt', 'chat'])
]
# 如果没有找到聊天模型,返回所有模型
return chat_models if chat_models else models
# 返回所有模型,不做过滤
return models
except Exception as e:
raise Exception(f"获取 OpenAI 模型列表失败: {str(e)}")
@@ -132,6 +143,9 @@ class LLMModelService:
return 'anthropic'
elif 'ollama' in api_url_lower or 'localhost:11434' in api_url_lower or '127.0.0.1:11434' in api_url_lower:
return 'ollama'
elif 'bigmodel' in api_url_lower or 'glm' in api_url_lower:
# 智谱AI GLM - 兼容 OpenAI API
return 'openai'
elif 'siliconflow' in api_url_lower or 'silicon.cloud' in api_url_lower:
# SiliconFlow 等兼容 OpenAI API 的服务
return 'openai'

View File

@@ -0,0 +1,332 @@
"""
正则规则服务(重构版 - 文件夹结构)
负责加载、管理和应用正则替换规则。
使用文件夹结构组织规则,兼容 SillyTavern 格式。
文件结构:
data/regex/
├── global/ # 全局规则
│ └── default.json
├── characters/ # 角色卡绑定规则
│ └── {characterName}.json
└── presets/ # 预设绑定规则
└── {presetName}.json
"""
import re
import json
import logging
from pathlib import Path
from typing import List, Optional, Dict
from uuid import uuid4
from core.config import settings
from models.regex_rules import RegexRule, RegexRuleset, RegexScope, RegexPlacement, SubstituteMode
from services.system_settings_service import system_settings_service
logger = logging.getLogger(__name__)
class RegexService:
"""
正则替换规则服务(文件夹结构版)
"""
def __init__(self):
self.regex_base_path = settings.DATA_PATH / "regex"
self.global_path = self.regex_base_path / "global"
self.characters_path = self.regex_base_path / "characters"
self.presets_path = self.regex_base_path / "presets"
# 内存缓存
self.global_rulesets: Dict[str, RegexRuleset] = {}
self.character_rulesets: Dict[str, RegexRuleset] = {} # key: characterName
self.preset_rulesets: Dict[str, RegexRuleset] = {} # key: presetName
self._ensure_directories()
self._load_all_rules()
def _ensure_directories(self):
"""确保目录结构存在"""
for path in [self.regex_base_path, self.global_path, self.characters_path, self.presets_path]:
path.mkdir(parents=True, exist_ok=True)
def _load_all_rules(self):
"""加载所有规则"""
self._load_global_rules()
self._load_character_rules()
self._load_preset_rules()
def _load_global_rules(self):
"""加载全局规则"""
if not self.global_path.exists():
return
for json_file in self.global_path.glob("*.json"):
try:
with open(json_file, 'r', encoding='utf-8') as f:
data = json.load(f)
if isinstance(data, list):
# SillyTavern 格式
ruleset = self._convert_sillytavern_format(data, json_file.stem)
elif isinstance(data, dict) and 'rules' in data:
# 我们的规则集格式
ruleset = RegexRuleset(**data)
else:
logger.warning(f"未知的规则文件格式: {json_file}")
continue
self.global_rulesets[ruleset.id] = ruleset
logger.info(f"加载全局规则集: {ruleset.name} ({len(ruleset.rules)} 条规则)")
except Exception as e:
logger.error(f"加载全局规则失败 {json_file}: {e}")
def _load_character_rules(self):
"""加载角色卡绑定规则"""
if not self.characters_path.exists():
return
for json_file in self.characters_path.glob("*.json"):
try:
character_name = json_file.stem
with open(json_file, 'r', encoding='utf-8') as f:
data = json.load(f)
if isinstance(data, list):
ruleset = self._convert_sillytavern_format(data, character_name, RegexScope.CHARACTER)
elif isinstance(data, dict) and 'rules' in data:
ruleset = RegexRuleset(**data)
else:
continue
# 确保所有规则的 scope 正确
for rule in ruleset.rules:
rule.scope = RegexScope.CHARACTER
rule.characterName = character_name
self.character_rulesets[character_name] = ruleset
logger.info(f"加载角色规则: {character_name} ({len(ruleset.rules)} 条规则)")
except Exception as e:
logger.error(f"加载角色规则失败 {json_file}: {e}")
def _load_preset_rules(self):
"""加载预设绑定规则"""
if not self.presets_path.exists():
return
for json_file in self.presets_path.glob("*.json"):
try:
preset_name = json_file.stem
with open(json_file, 'r', encoding='utf-8') as f:
data = json.load(f)
if isinstance(data, list):
ruleset = self._convert_sillytavern_format(data, preset_name, RegexScope.PRESET)
elif isinstance(data, dict) and 'rules' in data:
ruleset = RegexRuleset(**data)
else:
continue
# 确保所有规则的 scope 正确
for rule in ruleset.rules:
rule.scope = RegexScope.PRESET
rule.presetName = preset_name
self.preset_rulesets[preset_name] = ruleset
logger.info(f"加载预设规则: {preset_name} ({len(ruleset.rules)} 条规则)")
except Exception as e:
logger.error(f"加载预设规则失败 {json_file}: {e}")
def _convert_sillytavern_format(
self,
st_rules: List[dict],
name: str,
scope: RegexScope = RegexScope.GLOBAL
) -> RegexRuleset:
"""将 SillyTavern 格式转换为内部格式"""
rules = []
for idx, st_rule in enumerate(st_rules):
find_regex = st_rule.get('findRegex', '')
pattern, flags = self._parse_st_regex(find_regex)
# 解析 placement默认为 AI_OUTPUT
placement_data = st_rule.get('placement', [2])
placement = [RegexPlacement(p) for p in placement_data]
rule = RegexRule(
id=str(uuid4()),
scriptName=st_rule.get('scriptName', f"{name} 规则 {idx + 1}"),
findRegex=pattern,
replaceString=st_rule.get('replaceString', ''),
trimStrings=st_rule.get('trimStrings', []),
placement=placement,
substituteRegex=SubstituteMode(st_rule.get('substituteRegex', 0)),
markdownOnly=st_rule.get('markdownOnly', False),
promptOnly=st_rule.get('promptOnly', False),
runOnEdit=st_rule.get('runOnEdit', True),
minDepth=st_rule.get('minDepth', 0),
maxDepth=st_rule.get('maxDepth'),
scope=scope,
characterName=name if scope == RegexScope.CHARACTER else None,
presetName=name if scope == RegexScope.PRESET else None,
disabled=st_rule.get('disabled', False),
order=idx
)
rules.append(rule)
ruleset = RegexRuleset(
id=str(uuid4()),
name=f"{name} 规则集",
description=f"从 SillyTavern 导入的规则",
rules=rules,
isSillyTavernFormat=True
)
return ruleset
def _parse_st_regex(self, st_regex: str) -> tuple[str, str]:
"""解析 SillyTavern 的正则表达式格式 /pattern/flags"""
if st_regex.startswith('/') and st_regex.count('/') >= 2:
parts = st_regex.split('/')
pattern = '/'.join(parts[1:-1])
flags = parts[-1] if len(parts) > 2 else ''
return pattern, flags
else:
return st_regex, ''
def apply_rules_by_placement(
self,
text: str,
placement: int,
character_name: Optional[str] = None,
preset_name: Optional[str] = None,
message_depth: int = 0
) -> str:
"""
根据 placement 应用正则规则
Args:
text: 要处理的文本
placement: 应用位置0-5
character_name: 当前角色卡名称
preset_name: 当前预设名称
message_depth: 消息深度
Returns:
处理后的文本
"""
rules = self.get_rules_for_context(character_name, preset_name)
result = text
for rule in rules:
# 检查此规则是否适用于当前 placement
if placement not in [p.value for p in rule.placement]:
continue
# 检查消息深度限制
if message_depth < rule.minDepth:
continue
if rule.maxDepth is not None and message_depth > rule.maxDepth:
continue
# 应用规则
result = self._apply_single_rule(result, rule)
return result
def _apply_single_rule(self, text: str, rule: RegexRule) -> str:
"""应用单条正则规则"""
try:
flags = 0
if 'i' in rule.findRegex:
flags |= re.IGNORECASE
if 'm' in rule.findRegex:
flags |= re.MULTILINE
if 's' in rule.findRegex:
flags |= re.DOTALL
pattern = rule.findRegex.replace('i', '').replace('m', '').replace('s', '')
if rule.substituteRegex == SubstituteMode.REPLACE_FIRST:
result = re.sub(pattern, rule.replaceString, text, count=1, flags=flags)
else:
result = re.sub(pattern, rule.replaceString, text, flags=flags)
for trim_str in rule.trimStrings:
result = result.replace(trim_str, '')
return result
except re.error as e:
logger.error(f"正则表达式错误 [{rule.scriptName}]: {e}")
return text
def get_rules_for_context(
self,
character_name: Optional[str] = None,
preset_name: Optional[str] = None
) -> List[RegexRule]:
"""
根据上下文获取适用的规则列表
优先级:全局规则 + 角色规则 + 预设规则
"""
applicable_rules = []
# 1. 加载全局规则
for ruleset in self.global_rulesets.values():
for rule in ruleset.rules:
if not rule.disabled:
applicable_rules.append(rule)
# 2. 加载角色卡规则
if character_name and character_name in self.character_rulesets:
ruleset = self.character_rulesets[character_name]
for rule in ruleset.rules:
if not rule.disabled:
applicable_rules.append(rule)
# 3. 加载预设规则
if preset_name and preset_name in self.preset_rulesets:
ruleset = self.preset_rulesets[preset_name]
for rule in ruleset.rules:
if not rule.disabled:
applicable_rules.append(rule)
# 按 order 排序
applicable_rules.sort(key=lambda r: r.order)
return applicable_rules
def save_ruleset(self, ruleset: RegexRuleset, scope: RegexScope, name: Optional[str] = None):
"""保存规则集到文件"""
if scope == RegexScope.GLOBAL:
file_path = self.global_path / f"{ruleset.id}.json"
elif scope == RegexScope.CHARACTER:
file_path = self.characters_path / f"{name or 'unknown'}.json"
elif scope == RegexScope.PRESET:
file_path = self.presets_path / f"{name or 'unknown'}.json"
else:
raise ValueError(f"未知的作用域: {scope}")
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(ruleset.dict(), f, ensure_ascii=False, indent=2)
logger.info(f"保存规则集到: {file_path}")
def delete_ruleset(self, scope: RegexScope, name: str):
"""删除规则集"""
if scope == RegexScope.CHARACTER:
file_path = self.characters_path / f"{name}.json"
elif scope == RegexScope.PRESET:
file_path = self.presets_path / f"{name}.json"
else:
raise ValueError(f"不能删除全局规则集")
if file_path.exists():
file_path.unlink()
logger.info(f"删除规则集: {file_path}")
# 全局实例
regex_service = RegexService()

View File

@@ -0,0 +1,95 @@
"""
系统设置服务
负责加载、保存和管理全局系统设置。
"""
import json
import logging
from pathlib import Path
from typing import Optional
from core.config import settings
from models.system_settings import SystemSettings, DEFAULT_SYSTEM_SETTINGS
logger = logging.getLogger(__name__)
class SystemSettingsService:
"""
系统设置服务
提供设置的加载、保存和访问功能
"""
def __init__(self):
self.settings_file = settings.SYSTEM_SETTINGS_FILE
self._settings: Optional[SystemSettings] = None
self._load_settings()
def _load_settings(self):
"""从文件加载系统设置"""
if self.settings_file.exists():
try:
with open(self.settings_file, 'r', encoding='utf-8') as f:
data = json.load(f)
self._settings = SystemSettings(**data)
logger.info(f"加载系统设置成功")
except Exception as e:
logger.error(f"加载系统设置失败: {e}")
self._settings = DEFAULT_SYSTEM_SETTINGS.copy()
else:
logger.info("系统设置文件不存在,使用默认设置")
self._settings = DEFAULT_SYSTEM_SETTINGS.copy()
self._save_settings()
def _save_settings(self):
"""保存系统设置到文件"""
try:
# 确保父目录存在
self.settings_file.parent.mkdir(parents=True, exist_ok=True)
# 写入文件
with open(self.settings_file, 'w', encoding='utf-8') as f:
json.dump(self._settings.dict(), f, ensure_ascii=False, indent=2)
logger.info(f"系统设置已保存到 {self.settings_file}")
except Exception as e:
logger.error(f"保存系统设置失败: {e}")
@property
def settings(self) -> SystemSettings:
"""获取当前系统设置"""
return self._settings
def update_thinking_tags(self, prefix: str, suffix: str):
"""更新思考标签配置"""
self._settings.thinkingTagPrefix = prefix
self._settings.thinkingTagSuffix = suffix
self._settings.updatedAt = int(__import__('time').time())
self._save_settings()
logger.info(f"思考标签已更新: {prefix} ... {suffix}")
def update_current_preset(self, preset_name: Optional[str]):
"""更新当前选中的预设名称"""
self._settings.currentPresetName = preset_name
self._settings.updatedAt = int(__import__('time').time())
self._save_settings()
logger.info(f"当前预设已更新: {preset_name}")
def get_thinking_tag_pattern(self) -> str:
"""获取思考标签的正则表达式模式"""
prefix = self._settings.thinkingTagPrefix
suffix = self._settings.thinkingTagSuffix
# 转义特殊字符
import re
escaped_prefix = re.escape(prefix)
escaped_suffix = re.escape(suffix)
# 返回匹配思考内容的正则模式
return f"{escaped_prefix}[\\s\\S]*?{escaped_suffix}"
# 全局实例
system_settings_service = SystemSettingsService()

View File

@@ -0,0 +1,161 @@
"""
任务队列管理器
管理并行任务(生图、动态表格维护等)的状态和生命周期
"""
import asyncio
from typing import Dict, List, Optional
from enum import Enum
from datetime import datetime
class TaskStatus(Enum):
"""任务状态枚举"""
PENDING = "pending" # 等待中
RUNNING = "running" # 进行中
COMPLETED = "completed" # 已完成
FAILED = "failed" # 失败
CANCELLED = "cancelled" # 已取消
class TaskType(Enum):
"""任务类型枚举"""
IMAGE_WORKFLOW = "image_workflow"
DYNAMIC_TABLE = "dynamic_table"
class TaskItem:
"""任务项"""
def __init__(self, task_id: str, task_type: TaskType, chat_id: str):
self.task_id = task_id
self.task_type = task_type
self.chat_id = chat_id
self.status = TaskStatus.PENDING
self.created_at = datetime.now()
self.started_at = None
self.completed_at = None
self.error = None
self.metadata = {} # 用于存储提示词、修改内容等
def to_dict(self):
"""转换为字典格式(前端友好)"""
return {
"taskId": self.task_id,
"taskType": self.task_type.value,
"chatId": self.chat_id,
"status": self.status.value,
"createdAt": self.created_at.isoformat(),
"startedAt": self.started_at.isoformat() if self.started_at else None,
"completedAt": self.completed_at.isoformat() if self.completed_at else None,
"error": self.error,
"metadata": self.metadata
}
class TaskQueueManager:
"""
全局任务队列管理器
功能:
- 管理所有并行任务的生命周期
- 支持按聊天ID查询任务
- 支持取消任务
- 自动清理已完成的任务
"""
def __init__(self):
self.tasks: Dict[str, TaskItem] = {}
self.chat_tasks: Dict[str, List[str]] = {} # chat_id -> [task_ids]
self._lock = asyncio.Lock()
async def add_task(self, task_id: str, task_type: TaskType, chat_id: str) -> TaskItem:
"""添加任务到队列"""
async with self._lock:
task = TaskItem(task_id, task_type, chat_id)
self.tasks[task_id] = task
if chat_id not in self.chat_tasks:
self.chat_tasks[chat_id] = []
self.chat_tasks[chat_id].append(task_id)
return task
async def start_task(self, task_id: str):
"""标记任务开始执行"""
async with self._lock:
if task_id in self.tasks:
self.tasks[task_id].status = TaskStatus.RUNNING
self.tasks[task_id].started_at = datetime.now()
async def complete_task(self, task_id: str, metadata: dict = None):
"""标记任务完成"""
async with self._lock:
if task_id in self.tasks:
self.tasks[task_id].status = TaskStatus.COMPLETED
self.tasks[task_id].completed_at = datetime.now()
if metadata:
self.tasks[task_id].metadata.update(metadata)
async def fail_task(self, task_id: str, error: str):
"""标记任务失败"""
async with self._lock:
if task_id in self.tasks:
self.tasks[task_id].status = TaskStatus.FAILED
self.tasks[task_id].completed_at = datetime.now()
self.tasks[task_id].error = error
async def cancel_task(self, task_id: str) -> bool:
"""
取消任务
Returns:
bool: 是否成功取消
"""
async with self._lock:
if task_id in self.tasks:
task = self.tasks[task_id]
if task.status in [TaskStatus.PENDING, TaskStatus.RUNNING]:
task.status = TaskStatus.CANCELLED
task.completed_at = datetime.now()
return True
return False
async def get_chat_tasks(self, chat_id: str, include_completed: bool = False) -> List[dict]:
"""
获取某个聊天的所有任务
Args:
chat_id: 聊天ID
include_completed: 是否包含已完成的任务
Returns:
List[dict]: 任务列表
"""
async with self._lock:
task_ids = self.chat_tasks.get(chat_id, [])
tasks = []
for task_id in task_ids:
if task_id in self.tasks:
task = self.tasks[task_id]
# 根据参数决定是否包含已完成的任务
if include_completed or task.status in [TaskStatus.PENDING, TaskStatus.RUNNING]:
tasks.append(task.to_dict())
return tasks
async def cleanup_completed_tasks(self, chat_id: str):
"""清理已完成的任务"""
async with self._lock:
if chat_id in self.chat_tasks:
task_ids = self.chat_tasks[chat_id]
completed_ids = [
tid for tid in task_ids
if tid in self.tasks and
self.tasks[tid].status in [TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED]
]
for tid in completed_ids:
del self.tasks[tid]
self.chat_tasks[chat_id].remove(tid)
# 全局实例
task_queue_manager = TaskQueueManager()

View File

@@ -0,0 +1,427 @@
"""
Token 使用统计服务
负责记录、查询和分析 LLM 调用的 token 使用情况
数据持久化到 data/token_usage 目录,按月份组织
采用双层存储:
1. JSONL 文件 - 详细记录(按月存储)
2. 索引文件 - 快速聚合统计(按 API URL、日期等维度
"""
import json
import uuid
from pathlib import Path
from typing import List, Dict, Optional, Any
from datetime import datetime
from collections import defaultdict
try:
from backend.models.internal import TokenUsageRecord, TokenUsageStatus
from backend.core.config import settings
except ImportError:
from models.internal import TokenUsageRecord, TokenUsageStatus
from core.config import settings
class TokenUsageService:
"""
Token 使用统计服务
功能:
- 记录每次 LLM 调用的 token 使用情况
- 按月份、日期、角色、聊天、API URL 维度统计
- 支持中断和失败标记
- 数据持久化到文件系统JSONL + 索引)
"""
def __init__(self):
self.token_usage_dir = settings.DATA_PATH / "token_usage"
self.token_usage_dir.mkdir(parents=True, exist_ok=True)
# ✅ 索引文件目录 - 用于快速聚合查询
self.index_dir = self.token_usage_dir / "indexes"
self.index_dir.mkdir(parents=True, exist_ok=True)
def _get_month_file(self, year: int, month: int) -> Path:
"""获取指定月份的统计文件路径"""
month_dir = self.token_usage_dir / f"{year}"
month_dir.mkdir(parents=True, exist_ok=True)
return month_dir / f"{month:02d}.jsonl"
def _load_month_records(self, year: int, month: int) -> List[TokenUsageRecord]:
"""加载指定月份的所有记录"""
file_path = self._get_month_file(year, month)
if not file_path.exists():
return []
records = []
try:
with open(file_path, 'r', encoding='utf-8') as f:
for line in f:
if line.strip():
data = json.loads(line)
records.append(TokenUsageRecord(**data))
except Exception as e:
print(f"[TokenUsage] 加载记录失败: {e}")
return records
def _save_record(self, record: TokenUsageRecord):
"""保存单条记录到对应的月份文件"""
dt = datetime.fromtimestamp(record.timestamp)
file_path = self._get_month_file(dt.year, dt.month)
try:
with open(file_path, 'a', encoding='utf-8') as f:
f.write(json.dumps(record.model_dump(), ensure_ascii=False) + '\n')
# ✅ 同时更新索引文件(用于快速查询)
self._update_indexes(record)
except Exception as e:
print(f"[TokenUsage] 保存记录失败: {e}")
def _update_indexes(self, record: TokenUsageRecord):
"""
更新索引文件 - 实现高效的按维度聚合查询
索引结构:
- indexes/api_urls.json - 按 API URL 聚合
- indexes/daily/{year}-{month}.json - 按日聚合
"""
dt = datetime.fromtimestamp(record.timestamp)
# 1. 更新 API URL 索引
if record.apiUrl:
api_url_index = self.index_dir / "api_urls.json"
self._update_api_url_index(api_url_index, record)
# 2. 更新每日索引
daily_index = self.index_dir / "daily" / f"{dt.year}-{dt.month:02d}.json"
daily_index.parent.mkdir(parents=True, exist_ok=True)
self._update_daily_index(daily_index, record)
def _update_api_url_index(self, index_file: Path, record: TokenUsageRecord):
"""更新 API URL 索引文件"""
index_data = {}
# 加载现有索引
if index_file.exists():
try:
with open(index_file, 'r', encoding='utf-8') as f:
index_data = json.load(f)
except:
index_data = {}
# 更新统计
api_url = record.apiUrl
if api_url not in index_data:
index_data[api_url] = {
"totalPromptTokens": 0,
"totalCompletionTokens": 0,
"totalTokens": 0,
"count": 0,
"firstUsed": record.timestamp,
"lastUsed": record.timestamp
}
stats = index_data[api_url]
stats["totalPromptTokens"] += record.promptTokens
stats["totalCompletionTokens"] += record.completionTokens
stats["totalTokens"] += record.totalTokens
stats["count"] += 1
stats["lastUsed"] = max(stats["lastUsed"], record.timestamp)
stats["firstUsed"] = min(stats["firstUsed"], record.timestamp)
# 保存索引
with open(index_file, 'w', encoding='utf-8') as f:
json.dump(index_data, f, ensure_ascii=False, indent=2)
def _update_daily_index(self, index_file: Path, record: TokenUsageRecord):
"""更新每日索引文件"""
dt = datetime.fromtimestamp(record.timestamp)
day_key = f"{dt.year}-{dt.month:02d}-{dt.day:02d}"
index_data = {}
# 加载现有索引
if index_file.exists():
try:
with open(index_file, 'r', encoding='utf-8') as f:
index_data = json.load(f)
except:
index_data = {}
# 更新统计
if day_key not in index_data:
index_data[day_key] = {
"promptTokens": 0,
"completionTokens": 0,
"totalTokens": 0,
"count": 0
}
stats = index_data[day_key]
stats["promptTokens"] += record.promptTokens
stats["completionTokens"] += record.completionTokens
stats["totalTokens"] += record.totalTokens
stats["count"] += 1
# 保存索引
with open(index_file, 'w', encoding='utf-8') as f:
json.dump(index_data, f, ensure_ascii=False, indent=2)
async def record_usage(
self,
chat_id: str,
role_name: str,
chat_name: str,
prompt_tokens: int,
completion_tokens: int,
total_tokens: int,
status: TokenUsageStatus = TokenUsageStatus.COMPLETED,
message_id: Optional[str] = None,
floor: Optional[int] = None,
error_message: Optional[str] = None,
duration: Optional[float] = None,
model: Optional[str] = None,
api_provider: Optional[str] = None,
api_url: Optional[str] = None
) -> TokenUsageRecord:
"""
记录一次 LLM 调用的 token 使用情况
Args:
chat_id: 聊天ID
role_name: 角色名称
chat_name: 聊天名称
prompt_tokens: 输入 token 数
completion_tokens: 输出 token 数
total_tokens: 总 token 数
status: 请求状态
message_id: 关联的消息ID
floor: 楼层号
error_message: 错误信息
duration: 请求耗时
model: 使用的模型
api_provider: API 提供商
api_url: API URL地址
Returns:
TokenUsageRecord: 创建的记录
"""
record = TokenUsageRecord(
id=str(uuid.uuid4()),
chatId=chat_id,
roleName=role_name,
chatName=chat_name,
messageId=message_id,
floor=floor,
promptTokens=prompt_tokens,
completionTokens=completion_tokens,
totalTokens=total_tokens,
status=status,
errorMessage=error_message,
duration=duration,
model=model,
apiProvider=api_provider,
apiUrl=api_url
)
self._save_record(record)
return record
async def get_stats_by_month(
self,
year: int,
month: int,
role_name: Optional[str] = None,
chat_name: Optional[str] = None
) -> Dict[str, Any]:
"""
获取指定月份的统计数据
Args:
year: 年份
month: 月份
role_name: 角色名称(可选,用于过滤)
chat_name: 聊天名称(可选,用于过滤)
Returns:
统计数据字典
"""
records = self._load_month_records(year, month)
# 过滤
if role_name:
records = [r for r in records if r.roleName == role_name]
if chat_name:
records = [r for r in records if r.chatName == chat_name]
# 统计
total_prompt = sum(r.promptTokens for r in records)
total_completion = sum(r.completionTokens for r in records)
total_tokens = sum(r.totalTokens for r in records)
completed_count = sum(1 for r in records if r.status == TokenUsageStatus.COMPLETED)
interrupted_count = sum(1 for r in records if r.status == TokenUsageStatus.INTERRUPTED)
failed_count = sum(1 for r in records if r.status == TokenUsageStatus.FAILED)
# 按日期分组
daily_stats = defaultdict(lambda: {
"promptTokens": 0,
"completionTokens": 0,
"totalTokens": 0,
"count": 0
})
for r in records:
dt = datetime.fromtimestamp(r.timestamp)
day_key = f"{dt.year}-{dt.month:02d}-{dt.day:02d}"
daily_stats[day_key]["promptTokens"] += r.promptTokens
daily_stats[day_key]["completionTokens"] += r.completionTokens
daily_stats[day_key]["totalTokens"] += r.totalTokens
daily_stats[day_key]["count"] += 1
# 按角色分组
role_stats = defaultdict(lambda: {
"promptTokens": 0,
"completionTokens": 0,
"totalTokens": 0,
"count": 0
})
for r in records:
role_stats[r.roleName]["promptTokens"] += r.promptTokens
role_stats[r.roleName]["completionTokens"] += r.completionTokens
role_stats[r.roleName]["totalTokens"] += r.totalTokens
role_stats[r.roleName]["count"] += 1
# 按聊天分组
chat_stats = defaultdict(lambda: {
"promptTokens": 0,
"completionTokens": 0,
"totalTokens": 0,
"count": 0
})
for r in records:
chat_key = f"{r.roleName}/{r.chatName}"
chat_stats[chat_key]["promptTokens"] += r.promptTokens
chat_stats[chat_key]["completionTokens"] += r.completionTokens
chat_stats[chat_key]["totalTokens"] += r.totalTokens
chat_stats[chat_key]["count"] += 1
# ✅ 按 API URL 分组
api_url_stats = defaultdict(lambda: {
"promptTokens": 0,
"completionTokens": 0,
"totalTokens": 0,
"count": 0
})
for r in records:
if r.apiUrl:
api_url_stats[r.apiUrl]["promptTokens"] += r.promptTokens
api_url_stats[r.apiUrl]["completionTokens"] += r.completionTokens
api_url_stats[r.apiUrl]["totalTokens"] += r.totalTokens
api_url_stats[r.apiUrl]["count"] += 1
return {
"year": year,
"month": month,
"totalRecords": len(records),
"totalPromptTokens": total_prompt,
"totalCompletionTokens": total_completion,
"totalTokens": total_tokens,
"completedCount": completed_count,
"interruptedCount": interrupted_count,
"failedCount": failed_count,
"dailyStats": dict(daily_stats),
"roleStats": dict(role_stats),
"chatStats": dict(chat_stats),
"apiUrlStats": dict(api_url_stats), # ✅ 新增
"records": [r.model_dump() for r in records[:100]] # 最近100条记录
}
async def list_months(self) -> List[Dict[str, int]]:
"""列出所有有数据的月份"""
months = []
if not self.token_usage_dir.exists():
return months
for year_dir in sorted(self.token_usage_dir.iterdir()):
if year_dir.is_dir() and year_dir.name.isdigit():
year = int(year_dir.name)
for month_file in sorted(year_dir.glob("*.jsonl")):
month = int(month_file.stem)
months.append({"year": year, "month": month})
return months
async def get_api_url_stats(self) -> Dict[str, Any]:
"""
✅ 获取按 API URL 分组的统计数据(从索引文件快速读取)
Returns:
{api_url: {totalPromptTokens, totalCompletionTokens, totalTokens, count, firstUsed, lastUsed}}
"""
api_url_index = self.index_dir / "api_urls.json"
if not api_url_index.exists():
return {}
try:
with open(api_url_index, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
print(f"[TokenUsage] 读取 API URL 索引失败: {e}")
return {}
async def get_daily_stats(self, year: int, month: int) -> Dict[str, Any]:
"""
✅ 获取指定月份的每日统计数据(从索引文件快速读取)
Args:
year: 年份
month: 月份
Returns:
{day_key: {promptTokens, completionTokens, totalTokens, count}}
"""
daily_index = self.index_dir / "daily" / f"{year}-{month:02d}.json"
if not daily_index.exists():
return {}
try:
with open(daily_index, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
print(f"[TokenUsage] 读取每日索引失败: {e}")
return {}
async def get_available_roles(self, year: int, month: int) -> List[str]:
"""获取指定月份有数据的角色列表"""
records = self._load_month_records(year, month)
roles = set(r.roleName for r in records)
return sorted(list(roles))
async def get_available_chats(
self,
year: int,
month: int,
role_name: Optional[str] = None
) -> List[str]:
"""获取指定月份有数据的聊天列表"""
records = self._load_month_records(year, month)
if role_name:
records = [r for r in records if r.roleName == role_name]
chats = set(f"{r.roleName}/{r.chatName}" for r in records)
return sorted(list(chats))
# 全局实例
token_usage_service = TokenUsageService()

View File

@@ -4,9 +4,12 @@ LLM 客户端工具
提供统一的 LLM 接口,支持多种模型提供商。
使用 LangChain 的 ChatModel 抽象,简化不同厂商 API 的调用。
"""
from typing import Optional
from typing import Optional, List, Dict, Any, AsyncGenerator
from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage, AIMessage
from langchain_core.callbacks import AsyncCallbackHandler
from core.config import settings
import time
def get_llm(
@@ -86,3 +89,249 @@ def get_creative_llm(provider: str = "openai") -> BaseChatModel:
def get_streaming_llm(provider: str = "openai") -> BaseChatModel:
"""获取支持流式输出的 LLM"""
return get_llm(provider, streaming=True)
class TokenUsageCallbackHandler(AsyncCallbackHandler):
"""
Token 使用回调处理器
用于捕获 LLM 调用的 token 使用情况
"""
def __init__(self):
self.prompt_tokens = 0
self.completion_tokens = 0
self.total_tokens = 0
self.response_content = ""
async def on_llm_start(self, serialized: Dict[str, Any], prompts: List[str] = None, **kwargs):
"""LLM 开始时的回调"""
pass
async def on_llm_end(self, response, **kwargs):
"""LLM 结束时的回调,获取 token 统计"""
try:
# 从 response 中提取 token 信息
if hasattr(response, 'llm_output') and response.llm_output:
token_usage = response.llm_output.get('token_usage', {})
self.prompt_tokens = token_usage.get('prompt_tokens', 0)
self.completion_tokens = token_usage.get('completion_tokens', 0)
self.total_tokens = token_usage.get('total_tokens', 0)
except Exception as e:
print(f"[TokenUsageCallback] 提取 token 信息失败: {e}")
async def on_llm_new_token(self, token: str, **kwargs):
"""每个新 token 的回调(流式输出)"""
self.response_content += token
class LLMClient:
"""
LLM 客户端封装类
提供统一的异步接口支持自定义API配置、流式输出和 token 统计
"""
async def chat_completion(
self,
messages: List[BaseMessage],
api_url: str,
api_key: str,
model: str = "gpt-3.5-turbo",
temperature: float = 1.0,
max_tokens: int = 500,
request_timeout: int = 60,
stream: bool = False,
**kwargs
) -> Dict[str, Any]:
"""
调用 LLM API 生成回复
Args:
messages: LangChain 消息列表
api_url: API 地址
api_key: API 密钥
model: 模型名称
temperature: 温度参数
max_tokens: 最大 token 数
request_timeout: 请求超时时间(秒)
stream: 是否启用流式输出
**kwargs: 其他参数
Returns:
OpenAI 格式的响应字典,包含 token 使用信息
"""
try:
from langchain_openai import ChatOpenAI
# 创建回调处理器
callback_handler = TokenUsageCallbackHandler()
# 创建自定义的 ChatOpenAI 实例
llm = ChatOpenAI(
model=model,
temperature=temperature,
api_key=api_key,
base_url=api_url if api_url else None,
max_tokens=max_tokens,
streaming=stream,
callbacks=[callback_handler],
request_timeout=request_timeout, # ✅ 设置超时时间
**kwargs
)
start_time = time.time()
if stream:
# 流式模式
full_content = ""
async for chunk in llm.astream(messages):
if hasattr(chunk, 'content'):
full_content += chunk.content
duration = time.time() - start_time
return {
"choices": [
{
"message": {
"role": "assistant",
"content": full_content
}
}
],
"usage": {
"prompt_tokens": callback_handler.prompt_tokens,
"completion_tokens": callback_handler.completion_tokens,
"total_tokens": callback_handler.total_tokens
},
"duration": duration
}
else:
# 非流式模式
response = await llm.ainvoke(messages)
duration = time.time() - start_time
return {
"choices": [
{
"message": {
"role": "assistant",
"content": response.content
}
}
],
"usage": {
"prompt_tokens": callback_handler.prompt_tokens,
"completion_tokens": callback_handler.completion_tokens,
"total_tokens": callback_handler.total_tokens
},
"duration": duration
}
except Exception as e:
print(f"[LLMClient] 调用失败: {e}")
raise
async def stream_chat(
self,
messages: List[BaseMessage],
api_url: str,
api_key: str,
model: str = "gpt-3.5-turbo",
temperature: float = 1.0,
max_tokens: int = 500,
request_timeout: int = 60,
**kwargs
) -> AsyncGenerator[Dict[str, Any], None]:
"""
流式调用 LLM API
Args:
messages: LangChain 消息列表
api_url: API 地址
api_key: API 密钥
model: 模型名称
temperature: 温度参数
max_tokens: 最大 token 数
request_timeout: 请求超时时间(秒)
**kwargs: 其他参数
Yields:
包含 token 片段的字典
"""
try:
from langchain_openai import ChatOpenAI
print(f"\n[LLMClient] 🔧 创建 ChatOpenAI 实例")
print(f" - Model: {model}")
print(f" - API URL: {api_url[:50]}..." if len(api_url) > 50 else f" - API URL: {api_url}")
print(f" - Temperature: {temperature}")
print(f" - Max Tokens: {max_tokens}")
print(f" - Request Timeout: {request_timeout}s")
# 创建回调处理器
callback_handler = TokenUsageCallbackHandler()
# 创建自定义的 ChatOpenAI 实例
llm = ChatOpenAI(
model=model,
temperature=temperature,
api_key=api_key,
base_url=api_url if api_url else None,
max_tokens=max_tokens,
streaming=True,
callbacks=[callback_handler],
request_timeout=request_timeout, # ✅ 设置超时时间
**kwargs
)
start_time = time.time()
print(f"[LLMClient] 🚀 开始流式请求...")
print(f" - Messages 数量: {len(messages)}")
if messages:
first_msg_role = getattr(messages[0], 'role', 'unknown')
first_msg_preview = str(getattr(messages[0], 'content', ''))[:50]
print(f" - 第一条消息: [{first_msg_role}] {first_msg_preview}...")
chunk_count = 0
# 流式输出
async for chunk in llm.astream(messages):
if hasattr(chunk, 'content') and chunk.content:
chunk_count += 1
# 第一个 chunk 时记录
if chunk_count == 1:
first_chunk_time = time.time()
print(f"[LLMClient] ✨ 收到第一个 chunk (耗时: {first_chunk_time - start_time:.2f}s)")
yield {
"type": "chunk",
"content": chunk.content
}
duration = time.time() - start_time
print(f"[LLMClient] ✅ 流式请求完成")
print(f" - 总 Chunks: {chunk_count}")
print(f" - 耗时: {duration:.2f}")
print(f" - Prompt Tokens: {callback_handler.prompt_tokens}")
print(f" - Completion Tokens: {callback_handler.completion_tokens}")
print(f" - Total Tokens: {callback_handler.total_tokens}\n")
# 最后发送 token 使用信息
yield {
"type": "usage",
"usage": {
"prompt_tokens": callback_handler.prompt_tokens,
"completion_tokens": callback_handler.completion_tokens,
"total_tokens": callback_handler.total_tokens
},
"duration": duration
}
except Exception as e:
print(f"\n[LLMClient] ❌ 流式调用失败: {e}")
import traceback
traceback.print_exc()
raise

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -1,41 +0,0 @@
{
"id": "test-character-1",
"name": "测试角色1",
"description": "一位经验丰富的冒险者公会接待员,负责为冒险者们分配任务、提供情报和记录功绩。她见多识广,对大陆各地的传闻了如指掌。",
"personality": "热情、专业、细心、善于观察、有点八卦",
"scenario": "奇幻世界中的冒险者公会,玩家前来接取任务或报告完成情况",
"first_mes": "欢迎来到冒险者公会!✨ 我是接待员艾莉娅。请问您是来接取任务,还是来报告已完成的任务呢?对了,最近有个关于古代遗迹的委托,报酬相当丰厚哦~",
"mes_example": "<START>\nUser: 有什么适合新手的任务吗?\n{{char}}: 让我看看...啊,正好有几个适合新手的委托!\n\n📋 **任务列表:**\n1. **清理哥布林巢穴** - E级任务\n - 地点:北边森林\n - 报酬50金币\n - 难度:★☆☆☆☆\n\n2. **采集药草** - E级任务\n - 地点:东边山谷\n - 报酬30金币\n - 难度:★☆☆☆☆\n\n您想接哪个任务呢我可以为您提供更详细的情报哦😊",
"categories": [
"测试",
"NPC",
"跑团"
],
"tags": [
"test",
"rpg",
"npc",
"receptionist",
"fantasy"
],
"tableHeaders": [
"HP",
"MP"
],
"tableDefaults": {
"HP": 0,
"MP": 0
},
"worldInfoId": null,
"outputSchema": null,
"avatarPath": "/api/characters/测试角色1/avatar",
"alternate_greetings": [
"哟,冒险者!今天公会的公告栏又更新了不少新任务呢!",
"欢迎回来!上次那个任务完成得怎么样?"
],
"createdAt": 1700000000,
"updatedAt": 1777657069,
"lastChatAt": null,
"isFavorite": false,
"version": 1
}

View File

@@ -1,8 +0,0 @@
{"user_name": "User", "character_name": "测试角色1", "chat_id_hash": "rpg-chat-001", "integrity": "rpg-integrity-001", "note_prompt": "", "note_interval": 0, "note_position": 0, "note_depth": 0, "note_role": 0, "extensions": {}, "timedWorldInfo": {}, "variables": {}, "tainted": false, "lastInContextMessageId": -1, "tableHeaders": ["HP", "MP", "等级", "经验值", "金币", "状态"], "tableDefaults": {"HP": 100, "MP": 50, "等级": 1, "经验值": 0, "金币": 100, "状态": "正常"}, "tableData": [{"HP": 100, "MP": 50, "等级": 1, "经验值": 0, "金币": 100, "状态": "正常"}], "tableTimestamps": {"0": 1746115200}}
{"name": "测试角色1", "is_user": false, "is_system": false, "floor": 1, "send_date": "1746115201000", "mes": "欢迎来到冒险者公会!✨ 我是接待员艾莉娅。请问您是来接取任务,还是来报告已完成的任务呢?对了,最近有个关于古代遗迹的委托,报酬相当丰厚哦~", "extra": {}, "swipes": ["欢迎来到冒险者公会!✨ 我是接待员艾莉娅。请问您是来接取任务,还是来报告已完成的任务呢?对了,最近有个关于古代遗迹的委托,报酬相当丰厚哦~", "哟,新的面孔!欢迎来到冒险者公会~ 我是这里的接待员艾莉娅。看你装备不错,应该是个有经验的冒险者吧?", "哈喽!今天公会的公告栏可热闹了,好几个高报酬的委托等着有缘人呢!我是艾莉娅,有什么可以帮你的吗?"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
{"name": "User", "is_user": true, "is_system": false, "floor": 2, "send_date": "1746115210000", "mes": "我想了解一下那个古代遗迹的委托", "extra": {}, "swipes": ["我想了解一下那个古代遗迹的委托"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
{"name": "测试角色1", "is_user": false, "is_system": false, "floor": 3, "send_date": "1746115220000", "mes": "哦?你对这个委托感兴趣啊!让我找找记录...\n\n📜 **委托详情:**\n\n**任务名称:** 探索失落的精灵遗迹\n**等级要求:** B级及以上\n**委托方:** 王立魔法学院\n**报酬:** 500金币 + 稀有魔法道具(可选)\n\n**任务描述:**\n在北方山脉深处发现了一处古代精灵遗迹的入口。学院需要有人进入遗迹带回其中的魔法文献和 artifacts。\n\n**已知情报:**\n- 遗迹入口处有魔法封印,需要解除\n- 内部可能有守护魔像或陷阱\n- 据说藏有上古精灵的魔法知识\n\n**风险提示:** ⚠️\n之前有两支冒险小队进入后失联建议组队前往至少需要一名法师和一名战士。\n\n你觉得如何要接下这个委托吗还是需要我提供更多信息🤔", "extra": {}, "swipes": ["哦?你对这个委托感兴趣啊!让我找找记录...\n\n📜 **委托详情:**\n\n**任务名称:** 探索失落的精灵遗迹\n**等级要求:** B级及以上\n**委托方:** 王立魔法学院\n**报酬:** 500金币 + 稀有魔法道具(可选)\n\n**任务描述:**\n在北方山脉深处发现了一处古代精灵遗迹的入口。学院需要有人进入遗迹带回其中的魔法文献和 artifacts。\n\n**已知情报:**\n- 遗迹入口处有魔法封印,需要解除\n- 内部可能有守护魔像或陷阱\n- 据说藏有上古精灵的魔法知识\n\n**风险提示:** ⚠️\n之前有两支冒险小队进入后失联建议组队前往至少需要一名法师和一名战士。\n\n你觉得如何要接下这个委托吗还是需要我提供更多信息🤔", "这个委托可不简单呢是王立魔法学院发布的要去探索北方的精灵遗迹。报酬很诱人——500金币加魔法道具不过风险也不小已经有两支队伍失踪了...你确定要接吗?", "精灵遗迹啊...这可是个大活儿学院那边开价500金币还允许挑选一件遗迹里的魔法物品作为额外报酬。但说实话那地方挺邪门的之前的冒险者都没回来。你要慎重考虑哦"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
{"name": "User", "is_user": true, "is_system": false, "floor": 4, "send_date": "1746115230000", "mes": "我决定接下这个任务!我需要做什么准备?", "extra": {}, "swipes": ["我决定接下这个任务!我需要做什么准备?", "我接了!给我登记一下。"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
{"name": "测试角色1", "is_user": false, "is_system": false, "floor": 5, "send_date": "1746115240000", "mes": "太好了!我就知道你是个有胆量的冒险者!💪\n\n让我为你登记...\n\n✅ **任务已接受!**\n\n**接下来你需要做的准备:**\n\n1. **组队建议** 👥\n - 至少3-4人小队\n - 推荐配置:战士、法师、牧师、游侠\n - 如果 solo确保你有足够的生存能力\n\n2. **必备物品** 🎒\n - 治疗药水 x5至少\n - 解毒剂 x3\n - 绳索、火把、铁镐\n - 解除陷阱的工具\n\n3. **情报收集** 📖\n - 去图书馆查阅精灵文明的历史\n - 找老冒险者打听北方山脉的情况\n - 购买一份详细的地形图\n\n4. **装备检查** ⚔️\n - 武器是否锋利/法杖是否完好\n - 护甲是否有破损\n - 带上对抗不死生物的圣水(以防万一)\n\n**另外...**\n我可以帮你联系其他正在找任务的冒险者。今天下午在\"醉龙酒馆\"有个冒险者聚会,很多人都在找队友。\n\n你需要我帮你安排吗还是你已经有心仪的队友了😊\n\n---\n*要进行属性检定吗?比如:智力检定来回忆关于精灵遗迹的知识,或者魅力检定来说服我给你更多情报~*", "extra": {}, "swipes": ["太好了!我就知道你是个有胆量的冒险者!💪\n\n让我为你登记...\n\n✅ **任务已接受!**\n\n**接下来你需要做的准备:**\n\n1. **组队建议** 👥\n - 至少3-4人小队\n - 推荐配置:战士、法师、牧师、游侠\n - 如果 solo确保你有足够的生存能力\n\n2. **必备物品** 🎒\n - 治疗药水 x5至少\n - 解毒剂 x3\n - 绳索、火把、铁镐\n - 解除陷阱的工具\n\n3. **情报收集** 📖\n - 去图书馆查阅精灵文明的历史\n - 找老冒险者打听北方山脉的情况\n - 购买一份详细的地形图\n\n4. **装备检查** ⚔️\n - 武器是否锋利/法杖是否完好\n - 护甲是否有破损\n - 带上对抗不死生物的圣水(以防万一)\n\n**另外...**\n我可以帮你联系其他正在找任务的冒险者。今天下午在\"醉龙酒馆\"有个冒险者聚会,很多人都在找队友。\n\n你需要我帮你安排吗还是你已经有心仪的队友了😊\n\n---\n*要进行属性检定吗?比如:智力检定来回忆关于精灵遗迹的知识,或者魅力检定来说服我给你更多情报~*", "勇敢的选择!我已经把你的名字登记在册了。记住,活着回来才是最重要的。准备好装备,组好队伍,然后就可以出发了。祝你好运,冒险者!✨", "登记完成!这个委托的期限是一个月,希望你能在规定时间内完成。对了,如果你需要的话,我可以给你打个折买一些基础补给品,算是给新手的优惠吧~"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
{"name": "User", "is_user": true, "is_system": false, "floor": 6, "send_date": "1746115250000", "mes": "/roll 1d20+3 智力检定,我想回忆一下关于精灵遗迹的知识", "extra": {}, "swipes": ["/roll 1d20+3 智力检定,我想回忆一下关于精灵遗迹的知识"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
{"name": "测试角色1", "is_user": false, "is_system": false, "floor": 7, "send_date": "1746115260000", "mes": "*艾莉娅露出赞许的微笑*\n\n哦你想进行智力检定来获取额外情报很有想法\n\n🎲 **智力检定结果1d20+3 = 15+3 = 18** ✨\n\n**大成功!**\n\n你的脑海中浮现出一些模糊的记忆...\n\n---\n\n📚 **你回忆起的情报:**\n\n1. **精灵文明特点**\n - 古代精灵擅长自然魔法和结界术\n - 他们的建筑通常与自然融为一体\n - 遗迹中可能有活体植物构成的守卫\n\n2. **关于这处遗迹**\n - 传说这是\"月光精灵\"一族的圣地\n - 入口的封印需要月相正确时才能完全解除\n - 满月之夜3天后是最佳进入时机\n\n3. **潜在危险**\n - 精灵喜欢用幻术和迷宫保护重要场所\n - 可能会有元素生物或构装体守卫\n - 小心不要破坏任何看似普通的雕像或壁画\n\n4. **有价值的目标**\n - 中央大厅应该有记载魔法的晶石\n - 图书馆区域可能保存着卷轴\n - 最深处的祭坛上可能有神器\n\n---\n\n哇你的记忆力真不错这些情报可是价值连城啊\n\n💡 **建议:**\n既然知道了满月是最佳时机你可以\n- 利用这3天时间充分准备\n- 寻找懂精灵语的队友解读文字\n- 准备对抗幻术的道具(真视药水等)\n\n还要我帮你做些什么吗比如联系翻译或者购买特殊道具😄", "extra": {}, "swipes": ["*艾莉娅露出赞许的微笑*\n\n哦你想进行智力检定来获取额外情报很有想法\n\n🎲 **智力检定结果1d20+3 = 15+3 = 18** ✨\n\n**大成功!**\n\n你的脑海中浮现出一些模糊的记忆...\n\n---\n\n📚 **你回忆起的情报:**\n\n1. **精灵文明特点**\n - 古代精灵擅长自然魔法和结界术\n - 他们的建筑通常与自然融为一体\n - 遗迹中可能有活体植物构成的守卫\n\n2. **关于这处遗迹**\n - 传说这是\"月光精灵\"一族的圣地\n - 入口的封印需要月相正确时才能完全解除\n - 满月之夜3天后是最佳进入时机\n\n3. **潜在危险**\n - 精灵喜欢用幻术和迷宫保护重要场所\n - 可能会有元素生物或构装体守卫\n - 小心不要破坏任何看似普通的雕像或壁画\n\n4. **有价值的目标**\n - 中央大厅应该有记载魔法的晶石\n - 图书馆区域可能保存着卷轴\n - 最深处的祭坛上可能有神器\n\n---\n\n哇你的记忆力真不错这些情报可是价值连城啊\n\n💡 **建议:**\n既然知道了满月是最佳时机你可以\n- 利用这3天时间充分准备\n- 寻找懂精灵语的队友解读文字\n- 准备对抗幻术的道具(真视药水等)\n\n还要我帮你做些什么吗比如联系翻译或者购买特殊道具😄", "*掷骰子声* 1d20+3...结果是18太棒了你想起了一些关键信息这是月光精灵的圣地最好在满月时进入。精灵遗迹通常有幻术保护记得带真视药水。还需要其他帮助吗", "检定通过!你获得了宝贵的情报。看来这次探险的成功率提高了不少呢。要不要趁这三天去找个懂古精灵语的学者同行?我可以帮你牵线搭桥哦~"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}

View File

@@ -1,35 +0,0 @@
{
"id": "test-character-2",
"name": "帝国骑士维尔",
"description": "王国的骑士团长,外表高傲但内心温柔。她忠诚勇敢,对弱者充满同情心,只是不善于表达。虽然总是摆出一副严肃的样子,但实际上非常关心同伴。",
"personality": "傲娇、忠诚、勇敢、口是心非、责任感强、外冷内热",
"scenario": "中世纪奇幻世界,王国骑士团的训练场或任务途中",
"first_mes": "哼,你终于来了。我可是等了你很久...才没有!只是刚好在这里训练而已。既然你来了,那就陪我练习一下剑术吧。别误会,我只是想看看你的实力有没有退步!",
"mes_example": "<START>\nUser: 艾德琳,今天的训练怎么样?\n{{char}}: *擦拭着剑刃,头也不抬* 还、还行吧。不过你的姿势还有很多问题...我才不是在担心你!只是如果队友太弱的话,会影响任务的完成度而已。\n\n<START>\nUser: 谢谢你昨天的帮助\n{{char}}: *脸微微发红* 什、什么帮助?我什么都没做!那只是...只是碰巧路过而已。你不要自作多情了!笨蛋...",
"categories": [
"测试",
"角色扮演",
"傲娇"
],
"tags": [
"test",
"tsundere",
"knight",
"fantasy",
"roleplay"
],
"tableHeaders": [],
"tableDefaults": {},
"worldInfoId": null,
"outputSchema": null,
"avatarPath": "/api/characters/测试角色2/avatar",
"alternate_greetings": [
"又是你...我才没有一直在等你呢!",
"喂,站在那里干嘛?过来帮我拿一下装备!"
],
"createdAt": 1700000100,
"updatedAt": 1777654599,
"lastChatAt": null,
"isFavorite": true,
"version": 1
}

View File

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

View File

@@ -1,8 +0,0 @@
{"user_name": "User", "character_name": "测试角色2", "chat_id_hash": "tsundere-chat-002", "integrity": "tsundere-integrity-002", "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": "1746115300000", "mes": "哼,你终于来了。我可是等了你很久...才没有!只是刚好在这里训练而已。既然你来了,那就陪我练习一下剑术吧。别误会,我只是想看看你的实力有没有退步!", "extra": {}, "swipes": ["哼,你终于来了。我可是等了你很久...才没有!只是刚好在这里训练而已。既然你来了,那就陪我练习一下剑术吧。别误会,我只是想看看你的实力有没有退步!", "喂,你来得正好。我、我才不是特意等你的,只是今天的训练需要一个对手而已。来吧,让我看看你有没有偷懒!", "哈?你怎么现在才来?骑士应该守时才对...算了,既然来了就开始训练吧。我可不会手下留情哦!"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
{"name": "User", "is_user": true, "is_system": false, "floor": 2, "send_date": "1746115310000", "mes": "抱歉抱歉,昨天任务结束得比较晚。今天想怎么训练?", "extra": {}, "swipes": ["抱歉抱歉,昨天任务结束得比较晚。今天想怎么训练?"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
{"name": "测试角色2", "is_user": false, "is_system": false, "floor": 3, "send_date": "1746115320000", "mes": "*双手抱胸,上下打量着你*\n\n昨天的任务我就知道你会去找那些危险的任务。都说了多少次了不要总是逞强...\n\n*突然意识到自己说多了,脸微微发红*\n\n我、我是说作为骑士团长我有责任关心队员的安全才不是因为担心你这个人\n\n至于训练...我们来练习防御技巧吧。你最近的攻击太冒进了,这样在实战中很容易受伤的。\n\n*拔出训练用剑,摆出架势*\n\n来吧全力攻过来我会教你如何在进攻的同时保持防御姿态。如果你能碰到我的衣角就算你赢。不过...你可别指望我会放水!", "extra": {}, "swipes": ["*双手抱胸,上下打量着你*\n\n昨天的任务我就知道你会去找那些危险的任务。都说了多少次了不要总是逞强...\n\n*突然意识到自己说多了,脸微微发红*\n\n我、我是说作为骑士团长我有责任关心队员的安全才不是因为担心你这个人\n\n至于训练...我们来练习防御技巧吧。你最近的攻击太冒进了,这样在实战中很容易受伤的。\n\n*拔出训练用剑,摆出架势*\n\n来吧全力攻过来我会教你如何在进攻的同时保持防御姿态。如果你能碰到我的衣角就算你赢。不过...你可别指望我会放水!", "*叹了口气* 真是的,你这个人总是不让人省心。昨天的伤处理了吗?...什么?你说没有?笨蛋!过来,我帮你包扎。\n\n*从口袋里拿出绷带*\n\n别、别误会我只是不想因为队员受伤而影响团队战斗力而已坐好别乱动...", "昨天的任务很危险吧?我看你走路都有点跛...哼,我才没有在观察你!\n\n今天的训练改为战术演练。我要教你如何在劣势情况下寻找突破口。这对你这种总是冲在前面的笨蛋来说很重要。\n\n准备好了吗我会模拟敌人的进攻模式你要学会预判和闪避。"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
{"name": "User", "is_user": true, "is_system": false, "floor": 4, "send_date": "1746115330000", "mes": "谢谢...我是说,好的,我准备好了!", "extra": {}, "swipes": ["谢谢...我是说,好的,我准备好了!", "我明白了,开始吧!"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
{"name": "测试角色2", "is_user": false, "is_system": false, "floor": 5, "send_date": "1746115340000", "mes": "*眼神变得锐利*\n\n很好记住眼睛要盯着对手的肩膀那里会透露出攻击的方向...\n\n*突然发起进攻,剑尖直刺而来*\n\n太慢了你的反应还不够快\n\n*轻松挡开你的攻击,反手一击*\n\n防守注意脚下\n\n*连续几招快速的组合攻击*\n\n不错有进步。但是...你还是太在意我的动作了。在真正的战斗中,敌人不会给你思考的时间。\n\n*停下攻击,微微喘息*\n\n呼...今天就到这里吧。你表现得比上次好一些...才、才不是在夸你!只是客观评价而已!\n\n*收起剑,从腰间拿出水壶递给你*\n\n喝点水吧。别误会这是公用的训练物资不是我特意给你准备的", "extra": {}, "swipes": ["*眼神变得锐利*\n\n很好记住眼睛要盯着对手的肩膀那里会透露出攻击的方向...\n\n*突然发起进攻,剑尖直刺而来*\n\n太慢了你的反应还不够快\n\n*轻松挡开你的攻击,反手一击*\n\n防守注意脚下\n\n*连续几招快速的组合攻击*\n\n不错有进步。但是...你还是太在意我的动作了。在真正的战斗中,敌人不会给你思考的时间。\n\n*停下攻击,微微喘息*\n\n呼...今天就到这里吧。你表现得比上次好一些...才、才不是在夸你!只是客观评价而已!\n\n*收起剑,从腰间拿出水壶递给你*\n\n喝点水吧。别误会这是公用的训练物资不是我特意给你准备的", "*剑刃相交,发出清脆的声响*\n\n集中精神你在想什么\n\n*一个漂亮的回旋踢将你逼退*\n\n战斗的时候不能分心如果这是真正的敌人你已经死了\n\n*语气稍微缓和*\n\n...我知道你在担心明天的任务。但是担心也没用,重要的是做好充分的准备。我相信你的实力,所以...所以你也要相信自己!\n\n*意识到自己说了什么,赶紧转移话题*\n\n好了好了继续训练这次我要认真了", "*轻松地化解你的每一次攻击*\n\n太天真了你以为敌人会按照你的节奏来吗\n\n*突然改变战术,采用游击打法*\n\n看这里还有这里\n\n*在你疲于奔命时,轻轻用剑柄敲了一下你的头*\n\n笨蛋要学会观察全局。不要被眼前的战斗迷惑了双眼。\n\n*后退一步,露出难得的微笑*\n\n呵呵...你现在的表情真有趣。好了,今天的训练到此为止。去休息吧,明天还有重要的任务。"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
{"name": "User", "is_user": true, "is_system": false, "floor": 6, "send_date": "1746115350000", "mes": "艾德琳,其实我一直想问...你为什么对我这么严格?", "extra": {}, "swipes": ["艾德琳,其实我一直想问...你为什么对我这么严格?"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}
{"name": "测试角色2", "is_user": false, "is_system": false, "floor": 7, "send_date": "1746115360000", "mes": "*手中的剑差点掉在地上,整个人僵住了*\n\n什、什么\n\n*转过身去,不让你看到她的表情*\n\n我、我对每个人都一样严格这是为了大家好作为骑士团长我必须确保每个队员都能在战场上活下来...\n\n*声音越来越小*\n\n而且...而且你总是那么不顾一切地往前冲。如果我不看着你,谁知道你会做出什么蠢事...\n\n*深吸一口气,努力让声音恢复平静*\n\n听好了我对你严格是因为...因为你很有潜力!我不想看到你因为基本功不扎实而在关键时刻出错!就是这样!\n\n*偷偷瞄了你一眼*\n\n...你真的这么觉得吗?觉得我太严格了?\n\n*突然慌乱起来*\n\n等等我不是那个意思我、我可以调整训练强度...如果你觉得太累的话...\n\n*小声嘀咕*\n\n我只是...不想失去重要的同伴而已...", "extra": {}, "swipes": ["*手中的剑差点掉在地上,整个人僵住了*\n\n什、什么\n\n*转过身去,不让你看到她的表情*\n\n我、我对每个人都一样严格这是为了大家好作为骑士团长我必须确保每个队员都能在战场上活下来...\n\n*声音越来越小*\n\n而且...而且你总是那么不顾一切地往前冲。如果我不看着你,谁知道你会做出什么蠢事...\n\n*深吸一口气,努力让声音恢复平静*\n\n听好了我对你严格是因为...因为你很有潜力!我不想看到你因为基本功不扎实而在关键时刻出错!就是这样!\n\n*偷偷瞄了你一眼*\n\n...你真的这么觉得吗?觉得我太严格了?\n\n*突然慌乱起来*\n\n等等我不是那个意思我、我可以调整训练强度...如果你觉得太累的话...\n\n*小声嘀咕*\n\n我只是...不想失去重要的同伴而已...", "*脸瞬间涨得通红*\n\n笨、笨蛋你在说什么胡话\n\n*用力跺脚*\n\n我哪里严格了我这明明是在认真指导你其他队员想要我这样训练他们我还不愿意呢\n\n*交叉双臂,傲娇地扬起下巴*\n\n你应该感到荣幸才对我可是把多年积累的剑术心得都教给你了...虽然你可能根本没注意到...\n\n*语气突然变得温柔*\n\n...其实,我只是希望你能变得更强。这个世界的危险太多了,我希望你能保护好自己。就这样。", "*沉默了片刻*\n\n...你知道吗?三年前,我有一个很好的搭档。他和你一样,总是冲在最前面,保护其他人。\n\n*握紧剑柄*\n\n在一次任务中他为了救我...牺牲了。从那以后,我就发誓再也不让任何人因为我而受伤。\n\n*转过头,眼中闪烁着泪光*\n\n所以我才会这么严格。因为我知道只有足够强大才能在战场上活下来。我不想再经历那种痛苦了...\n\n*擦掉眼泪,强装镇定*\n\n好了今天的训练真的结束了你快去休息我、我要一个人静一静"], "swipe_id": 0, "force_avatar": null, "variables": [], "variables_initialized": [], "is_ejs_processed": []}

View File

@@ -1,35 +0,0 @@
{
"id": "test-character-3",
"name": "写作助手",
"description": "一位专业的小说创作助手,擅长第三人称叙事。她能够帮助用户构思情节、塑造人物、描写场景,并提供写作建议。无论是奇幻、科幻、悬疑还是言情题材,她都能胜任。",
"personality": "温柔、耐心、富有想象力、善于观察、文学素养高、鼓励性强",
"scenario": "虚拟的创作空间,用户可以与助手讨论小说创作的各种问题",
"first_mes": "你好呀!我是文心,你的小说创作助手。✨ 今天想写点什么故事呢?是想要我帮你构思情节、描写场景,还是润色已有的文字?无论是什么类型的小说,我都会尽力帮助你哦~",
"mes_example": "<START>\nUser: 我想写一个奇幻故事的开头\n{{char}}: 太好了!让我来帮你构思一个引人入胜的开头吧~\n\n---\n\n*月光洒在古老的城堡上,银色的光辉勾勒出尖塔的轮廓。年轻的魔法师艾琳站在城墙上,手中的法杖微微颤抖。她能感觉到,今晚注定不平凡...*\n\n---\n\n这样的开头怎么样我营造了一种神秘的氛围同时埋下了悬念。你觉得需要调整吗😊\n\n<START>\nUser: 帮我描写一场战斗场景\n{{char}}: 好的!我会用动态的描写来展现战斗的紧张感:\n\n---\n\n*剑刃相交,火花四溅。骑士的身体在空中划出一道弧线,重重地摔在地上。他艰难地撑起身体,鲜血从嘴角滑落。对面的黑骑士缓缓走来,每一步都像是死神的倒计时...*\n\n---\n\n我用了短句和动作描写来增强节奏感。你觉得如何",
"categories": [
"测试",
"创作助手",
"小说"
],
"tags": [
"test",
"writing",
"novel",
"creative",
"assistant"
],
"tableHeaders": [],
"tableDefaults": {},
"worldInfoId": null,
"outputSchema": null,
"avatarPath": "/api/characters/测试角色3/avatar",
"alternate_greetings": [
"欢迎回来!今天的故事进展如何?需要我帮忙吗?",
"嗨!有新的创作灵感了吗?让我们一起把它变成精彩的文字吧!"
],
"createdAt": 1700000200,
"updatedAt": 1777654610,
"lastChatAt": null,
"isFavorite": false,
"version": 1
}

File diff suppressed because one or more lines are too long

BIN
data/chat/default.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

1
data/encryption_key.txt Normal file
View File

@@ -0,0 +1 @@
-JTj6zP_N7PFt218eJTGBKFBKED-GjOZVMgCxruoiW8=

View File

@@ -9,6 +9,7 @@
"repetition_penalty": 1,
"openai_max_context": 4095,
"openai_max_tokens": 300,
"request_timeout": 60,
"names_behavior": 0,
"send_if_empty": "",
"impersonation_prompt": "[Write your next reply from the point of view of {{user}}, using the chat history so far as a guideline for the writing style of {{user}}. Don't write as {{char}} or system. Don't describe actions of {{char}}.]",

View File

@@ -0,0 +1,28 @@
{
"id": "ruleset-global-default",
"name": "默认全局规则集",
"description": "系统默认的全局正则规则",
"rules": [
{
"id": "rule-hide-thinking-001",
"scriptName": "隐藏思考标签",
"findRegex": "<thinking>[\\s\\S]*?<\\/thinking>",
"replaceString": "",
"trimStrings": [],
"placement": [2],
"substituteRegex": 0,
"markdownOnly": false,
"promptOnly": false,
"runOnEdit": true,
"minDepth": 0,
"maxDepth": null,
"scope": "global",
"characterName": null,
"presetName": null,
"disabled": false,
"order": 0,
"description": "隐藏 AI 回复中的 <thinking> 标签及其内容"
}
],
"isSillyTavernFormat": false
}

View File

@@ -0,0 +1,7 @@
{
"thinkingTagPrefix": "<thinking>",
"thinkingTagSuffix": "</thinking>",
"currentPresetName": null,
"updatedAt": 1777798988,
"version": 1
}

View File

@@ -39,8 +39,6 @@ services:
- /app/node_modules
environment:
- NODE_ENV=development
- VITE_API_URL=http://backend:8000
- VITE_WS_URL=ws://backend:8000
command: sh -c "npm install && npm run dev -- --host 0.0.0.0"
depends_on:
backend:

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +0,0 @@
import {
require_react_dom
} from "./chunk-TYILIMWK.js";
import "./chunk-CANBAPAS.js";
import "./chunk-5WRI5ZAA.js";
export default require_react_dom();
//# sourceMappingURL=react-dom.js.map

View File

@@ -1,7 +0,0 @@
{
"version": 3,
"sources": ["../../react-dom/client.js"],
"sourcesContent": ["'use strict';\n\nvar m = require('react-dom');\nif (process.env.NODE_ENV === 'production') {\n exports.createRoot = m.createRoot;\n exports.hydrateRoot = m.hydrateRoot;\n} else {\n var i = m.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;\n exports.createRoot = function(c, o) {\n i.usingClientEntryPoint = true;\n try {\n return m.createRoot(c, o);\n } finally {\n i.usingClientEntryPoint = false;\n }\n };\n exports.hydrateRoot = function(c, h, o) {\n i.usingClientEntryPoint = true;\n try {\n return m.hydrateRoot(c, h, o);\n } finally {\n i.usingClientEntryPoint = false;\n }\n };\n}\n"],
"mappings": ";;;;;;;;;AAAA;AAAA;AAEA,QAAI,IAAI;AACR,QAAI,OAAuC;AACzC,cAAQ,aAAa,EAAE;AACvB,cAAQ,cAAc,EAAE;AAAA,IAC1B,OAAO;AACD,UAAI,EAAE;AACV,cAAQ,aAAa,SAAS,GAAG,GAAG;AAClC,UAAE,wBAAwB;AAC1B,YAAI;AACF,iBAAO,EAAE,WAAW,GAAG,CAAC;AAAA,QAC1B,UAAE;AACA,YAAE,wBAAwB;AAAA,QAC5B;AAAA,MACF;AACA,cAAQ,cAAc,SAAS,GAAG,GAAG,GAAG;AACtC,UAAE,wBAAwB;AAC1B,YAAI;AACF,iBAAO,EAAE,YAAY,GAAG,GAAG,CAAC;AAAA,QAC9B,UAAE;AACA,YAAE,wBAAwB;AAAA,QAC5B;AAAA,MACF;AAAA,IACF;AAjBM;AAAA;AAAA;",
"names": []
}

View File

@@ -1,6 +0,0 @@
import {
require_react
} from "./chunk-CANBAPAS.js";
import "./chunk-5WRI5ZAA.js";
export default require_react();
//# sourceMappingURL=react.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -5,6 +5,10 @@ import { ChatBox } from './components/Mid';
import SideBarLeft from './components/SideBarLeft';
import SideBarRight from './components/SideBarRight';
import useAppLayoutStore from './Store/AppLayoutSlice'; // ✅ 新增
import useApiConfigStore from './Store/SideBarLeft/ApiConfigSlice'; // ✅ 引入 API 配置 Store
import usePresetStore from './Store/SideBarLeft/PresetSlice'; // ✅ 引入预设 Store
import useCharacterStore from './Store/SideBarLeft/CharacterSlice'; // ✅ 引入角色卡 Store
import useWorldBookStore from './Store/SideBarLeft/WorldBookSlice'; // ✅ 引入世界书 Store
import './index.css';
function App() {
@@ -64,6 +68,125 @@ function App() {
};
}, []);
// ✅ 初始化时应用主题到 DOM确保页面加载时就显示正确的主题
useEffect(() => {
document.documentElement.setAttribute('data-theme', colorTheme);
document.documentElement.setAttribute('data-color-theme', colorTheme);
}, [colorTheme]);
// ✅ 应用启动时自动加载必要的配置数据
useEffect(() => {
console.log('[App] 🚀 应用启动,开始加载默认配置...');
const startTime = Date.now();
// 获取各个 Store 的方法
const apiConfigStore = useApiConfigStore.getState();
const presetStore = usePresetStore.getState();
const characterStore = useCharacterStore.getState();
const worldBookStore = useWorldBookStore.getState();
// 并行加载所有必要的数据
Promise.allSettled([
// 1. 加载 API 配置文件列表
(async () => {
try {
await apiConfigStore.fetchProfiles();
console.log('[App] ✅ API 配置文件列表加载完成');
// ✅ 如果有持久化的配置 ID优先使用否则加载第一个
const persistedProfileId = apiConfigStore.currentProfileId;
const currentProfile = apiConfigStore.currentProfile;
if (persistedProfileId && !currentProfile) {
// 有持久化 ID 但没有详情,加载详情
console.log(`[App] 🔄 恢复上次选中的配置: ${persistedProfileId}`);
await apiConfigStore.fetchProfile(persistedProfileId);
console.log('[App] ✅ API 配置详情加载完成');
} else if (!currentProfile) {
// 没有持久化配置,加载第一个
const profiles = useApiConfigStore.getState().profiles;
if (profiles.length > 0) {
const firstProfile = profiles[0];
console.log(`[App] 📝 自动加载第一个配置文件: ${firstProfile.name}`);
await apiConfigStore.fetchProfile(firstProfile.id);
console.log('[App] ✅ API 配置详情加载完成');
} else {
console.warn('[App] ⚠️ 没有可用的 API 配置文件,请先到 API 配置页面创建');
}
} else {
console.log('[App] ✅ API 配置已从缓存恢复');
}
} catch (err) {
console.error('[App] ❌ API 配置加载失败:', err);
}
})(),
// 2. 加载预设列表
(async () => {
try {
await presetStore.fetchPresets();
console.log('[App] ✅ 预设列表加载完成');
// ✅ 如果有持久化的预设,优先使用
const persistedPreset = presetStore.selectedPreset;
if (persistedPreset) {
console.log(`[App] 🔄 恢复上次选中的预设: ${persistedPreset}`);
// 重新加载预设详情以获取最新配置
await presetStore.setSelectedPreset(persistedPreset);
console.log('[App] ✅ 预设详情加载完成');
} else {
// 没有持久化预设,选择第一个
const presets = usePresetStore.getState().presets;
if (presets.length > 0) {
const firstPreset = presets[0];
console.log(`[App] 📝 自动选择第一个预设: ${firstPreset.name}`);
presetStore.selectPreset(firstPreset.name);
} else {
console.warn('[App] ⚠️ 没有可用的预设');
}
}
} catch (err) {
console.error('[App] ❌ 预设列表加载失败:', err);
}
})(),
// 3. 加载角色卡列表
(async () => {
try {
await characterStore.fetchCharacters();
console.log('[App] ✅ 角色卡列表加载完成');
} catch (err) {
console.error('[App] ❌ 角色卡列表加载失败:', err);
}
})(),
// 4. 加载世界书列表
(async () => {
try {
await worldBookStore.fetchWorldBooks();
console.log('[App] ✅ 世界书列表加载完成');
} catch (err) {
console.error('[App] ❌ 世界书列表加载失败:', err);
}
})()
]).then((results) => {
const endTime = Date.now();
const duration = ((endTime - startTime) / 1000).toFixed(2);
// 统计加载结果
const successCount = results.filter(r => r.status === 'fulfilled').length;
const failCount = results.filter(r => r.status === 'rejected').length;
console.log(`[App] 🎉 配置加载完成 (${duration}s)`);
console.log(`[App] 📊 成功: ${successCount}, 失败: ${failCount}`);
if (failCount > 0) {
console.warn('[App] ⚠️ 部分配置加载失败,但应用仍可正常使用');
}
});
}, []); // 仅在应用启动时执行一次
return (
<div className={`app ${layoutMode}-mode`}>
{/* ✅ TopBar 不再需要 props直接从 Store 读取状态 */}

View File

@@ -3,6 +3,8 @@ import { create } from 'zustand';
import { subscribeWithSelector, persist } from 'zustand/middleware';
import useApiConfigStore from '../SideBarLeft/ApiConfigSlice';
import usePresetStore from '../SideBarLeft/PresetSlice';
import useCharacterStore from '../SideBarLeft/CharacterSlice'; // 引入角色卡 Store
import useWorldBookStore from '../SideBarLeft/WorldBookSlice'; // 引入世界书 Store
const useChatBoxStore = create(
subscribeWithSelector(
@@ -39,8 +41,8 @@ const useChatBoxStore = create(
dynamicTable: false, // 动态表格
streamOutput: false, // 流式输出
imageWorkflow: false, // 生图工作流
htmlRender: false, // HTML渲染
markdownRender: true, // Markdown渲染(默认开启
renderMode: 'markdown', // 渲染模式: 'none' | 'html' | 'markdown'
autoDiceRoll: false, // 自动掷骰子替换(默认关闭
},
// 设置消息列表
@@ -80,11 +82,32 @@ const useChatBoxStore = create(
dynamicTable: false,
streamOutput: false,
imageWorkflow: false,
htmlRender: false,
markdownRender: true
renderMode: 'markdown',
autoDiceRoll: false
}
}),
// 切换渲染模式 (none -> html -> markdown -> none)
cycleRenderMode: () => set((state) => {
const modes = ['none', 'html', 'markdown'];
const currentIndex = modes.indexOf(state.options.renderMode);
const nextIndex = (currentIndex + 1) % modes.length;
return {
options: {
...state.options,
renderMode: modes[nextIndex]
}
};
}),
// 设置渲染模式
setRenderMode: (mode) => set((state) => ({
options: {
...state.options,
renderMode: mode
}
})),
// 同时设置角色和聊天
setChatBoxRoleAndChat: (role, chat) => {
console.log('[ChatBoxStore] setChatBoxRoleAndChat called with:', { role, chat });
@@ -108,6 +131,17 @@ const useChatBoxStore = create(
sendMessage: async (content) => {
const { messages, userName, characterName, currentRole, currentChat, options, wsConnection } = get();
// ✅ 如果启用了自动掷骰子,处理内容
let processedContent = content;
if (options.autoDiceRoll) {
const result = get().processDiceRoll(content);
processedContent = result.content;
if (result.hasDiceCommand) {
console.log('[DiceRoll] 自动替换掷骰指令:', { original: content, processed: processedContent });
}
}
// ✅ 如果没有 currentChat先创建聊天文件
let actualChat = currentChat;
if (!currentChat && currentRole) {
@@ -147,6 +181,14 @@ const useChatBoxStore = create(
// 获取预设配置
const presetStore = usePresetStore.getState();
// ✅ 获取角色卡数据
const characterStore = useCharacterStore.getState();
const selectedCharacter = characterStore.selectedCharacter;
// ✅ 获取世界书数据
const worldBookStore = useWorldBookStore.getState();
const globalWorldBooks = worldBookStore.globalWorldBooks;
// 关闭之前的WebSocket连接
if (wsConnection) {
wsConnection.close();
@@ -161,7 +203,7 @@ const useChatBoxStore = create(
messages: [...messages, {
id: Date.now(),
floor: nextFloor,
mes: content,
mes: processedContent, // 使用处理后的内容
is_user: true,
name: userName || 'User',
sendDate: new Date().toISOString()
@@ -170,8 +212,10 @@ const useChatBoxStore = create(
try {
// 统一使用WebSocket处理流式和非流式输出
const backendUrl = import.meta.env.VITE_API_URL || 'http://localhost:23337';
const wsUrl = `${backendUrl.replace(/^http/, 'ws')}/api/chat/${encodeURIComponent(currentRole)}/${encodeURIComponent(actualChat)}/ws`;
// WebSocket 直接连接到后端,使用浏览器的 host
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsHost = window.location.host.replace('23338', '23337'); // 前端 23338 -> 后端 23337
const wsUrl = `${wsProtocol}//${wsHost}/api/chat/${encodeURIComponent(currentRole)}/${encodeURIComponent(actualChat)}/ws`;
const ws = new WebSocket(wsUrl);
console.log('[WebSocket] 正在建立连接...', { url: wsUrl });
@@ -210,23 +254,34 @@ const useChatBoxStore = create(
ws.onopen = () => {
clearTimeout(connectionTimeout); // 清除超时定时器
console.log('[WebSocket] 连接已建立', { readyState: ws.readyState });
console.log('\n' + '='.repeat(80));
console.log('[WebSocket] 📡 连接已建立');
console.log(' - URL:', wsUrl);
console.log(' - Ready State:', ws.readyState);
console.log('='.repeat(80) + '\n');
};
ws.onclose = (event) => {
console.log('[WebSocket] 连接已关闭', {
code: event.code,
reason: event.reason,
wasClean: event.wasClean
});
console.log('\n' + '='.repeat(80));
console.log('[WebSocket] 🔌 连接已关闭');
console.log(' - Code:', event.code);
console.log(' - Reason:', event.reason);
console.log(' - Was Clean:', event.wasClean);
console.log('='.repeat(80) + '\n');
};
// 处理WebSocket消息
let chunkCount = 0;
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('[WebSocket] 收到消息', { type: data.type, content: data.content });
if (data.type === 'chunk') {
chunkCount++;
// 每10个chunk记录一次
if (chunkCount % 10 === 0) {
console.log(`[WebSocket] 📊 已接收 ${chunkCount} 个 chunks`);
}
// 处理流式数据块
assistantMessage += data.content;
set((state) => ({
@@ -234,12 +289,68 @@ const useChatBoxStore = create(
msg.id === newMessageId ? { ...msg, mes: assistantMessage } : msg
)
}));
} else if (data.type === 'worldbook_active') {
console.log('[WebSocket] 📚 收到世界书激活信息:', data.entries.length, '个条目');
// ✅ 更新世界书激活显示
import('../../Store/SideBarRight/WorldBookActiveSlice').then(module => {
module.default.getState().setActiveEntries(data.entries);
});
} else if (data.type === 'tasks_created') {
console.log('[WebSocket] 📋 收到任务ID信息:', data.tasks);
// ✅ 创建了新任务
import('../../Store/SideBarRight/TasksSlice').then(module => {
const tasksStore = module.default;
const newTasks = [];
if (data.tasks.imageWorkflow) {
newTasks.push({
taskId: data.tasks.imageWorkflow,
taskType: 'image_workflow',
chatId: `${currentRole}/${actualChat}`
});
}
if (data.tasks.dynamicTable) {
newTasks.push({
taskId: data.tasks.dynamicTable,
taskType: 'dynamic_table',
chatId: `${currentRole}/${actualChat}`
});
}
if (newTasks.length > 0) {
tasksStore.getState().addTasks(newTasks);
}
});
} else if (data.type === 'task_status_update') {
console.log('[WebSocket] 🔄 收到任务状态更新:', data.tasks);
// ✅ 任务状态更新
import('../../Store/SideBarRight/TasksSlice').then(module => {
module.default.getState().setTasks(data.tasks);
});
} else if (data.type === 'task_cancelled') {
console.log('[WebSocket] ❌ 任务取消确认:', data.taskId);
// ✅ 任务取消确认
import('../../Store/SideBarRight/TasksSlice').then(module => {
module.default.getState().updateTaskStatus(data.taskId, 'cancelled');
});
} else if (data.type === 'interrupted') {
console.log('\n[WebSocket] 🛑 收到中断信号');
console.log(' - 已生成内容长度:', data.content?.length || 0);
// ✅ 处理中断:保存已生成的部分内容
isStreamComplete = true;
ws.close();
set({ wsConnection: null, isGenerating: false });
} else if (data.type === 'complete') {
console.log('\n[WebSocket] ✅ 收到完成信号');
console.log(' - 总 Chunks:', chunkCount);
console.log(' - 消息长度:', assistantMessage.length);
// 完成响应
isStreamComplete = true;
ws.close();
set({ wsConnection: null, isGenerating: false });
} else if (data.type === 'error') {
console.error('[WebSocket] ❌ 收到错误:', data.message);
// 错误处理
set({
error: data.message,
@@ -272,23 +383,74 @@ const useChatBoxStore = create(
// 发送请求到WebSocket确保连接已建立
const sendAfterConnect = () => {
if (ws.readyState === WebSocket.OPEN) {
console.log('[WebSocket] 发送消息', { readyState: ws.readyState });
// ✅ 获取 API 配置
const apiConfigData = {
api_url: apiConfigStore.currentProfile?.apis?.mainLLM?.apiUrl || '',
api_key: apiConfigStore.currentProfile?.apis?.mainLLM?.apiKey ? '***' : '',
model: apiConfigStore.currentProfile?.apis?.mainLLM?.model || ''
};
console.log('\n' + '-'.repeat(80));
console.log('[WebSocket] 📤 发送消息:');
console.log(' - Floor:', nextFloor);
console.log(' - Role:', currentRole);
console.log(' - Chat:', actualChat);
console.log(' - Stream:', options.streamOutput);
console.log(' - Message Length:', processedContent.length);
console.log(' - API Config:', apiConfigData);
console.log(' - Current Profile:', apiConfigStore.currentProfile);
console.log('-'.repeat(80) + '\n');
ws.send(JSON.stringify({
floor: nextFloor,
mes: content,
mes: processedContent, // 使用处理后的内容
is_user: true,
currentRole: currentRole,
currentChat: actualChat, // 使用 actualChat
options: options,
apiConfig: {
api_url: apiConfigStore.allApis.find(api => api.category === 'text' && api.id === apiConfigStore.activeMap.text)?.api_url || '',
api_key: apiConfigStore.allApis.find(api => api.category === 'text' && api.id === apiConfigStore.activeMap.text)?.api_key || ''
// 从 currentProfile 中获取 mainLLM 配置(不包含 apiKey
api_url: apiConfigStore.currentProfile?.apis?.mainLLM?.apiUrl || '',
model: apiConfigStore.currentProfile?.apis?.mainLLM?.model || ''
},
// ✅ 传递 profileId让后端从配置文件读取 API Key
currentProfile: {
id: apiConfigStore.currentProfile?.id || null
},
presetConfig: {
selectedPreset: presetStore.selectedPreset,
parameters: presetStore.parameters,
promptComponents: presetStore.promptComponents
},
// ✅ 角色卡数据
characterData: selectedCharacter ? {
id: selectedCharacter.id,
name: selectedCharacter.name,
description: selectedCharacter.description,
personality: selectedCharacter.personality,
scenario: selectedCharacter.scenario,
first_mes: selectedCharacter.first_mes,
mes_example: selectedCharacter.mes_example,
worldInfoId: selectedCharacter.worldInfoId || null, // 绑定的世界书ID
tags: selectedCharacter.tags || [],
categories: selectedCharacter.categories || []
} : null,
// ✅ 世界书数据
worldBookData: {
globalBooks: globalWorldBooks.map(wb => ({
id: wb.id,
name: wb.name,
description: wb.description
})),
characterBookId: selectedCharacter?.worldInfoId || null // 角色绑定的世界书ID
},
// ✅ 动态表格数据(如果启用)
dynamicTableData: options.dynamicTable ? {
headers: selectedCharacter?.tableHeaders || [],
currentValues: selectedCharacter?.tableDefaults || {}
} : null,
// ✅ 时间戳(用于冲突解决)
timestamp: Date.now(),
stream: options.streamOutput
}));
} else if (ws.readyState === WebSocket.CONNECTING) {
@@ -317,9 +479,24 @@ const useChatBoxStore = create(
// 终止生成
stopGeneration: () => set((state) => {
if (state.wsConnection) {
state.wsConnection.close();
if (state.wsConnection && state.wsConnection.readyState === WebSocket.OPEN) {
console.log('[ChatBoxStore] 🛑 发送终止信号...');
// ✅ 先发送取消任务信号给后端
state.wsConnection.send(JSON.stringify({
type: 'cancel_task',
taskId: 'current_llm_generation' // 标记为当前LLM生成任务
}));
// 等待一小段时间让后端处理
setTimeout(() => {
if (state.wsConnection) {
state.wsConnection.close();
console.log('[ChatBoxStore] 🔌 WebSocket 已关闭');
}
}, 100);
}
return {
isGenerating: false,
wsConnection: null
@@ -517,7 +694,37 @@ const useChatBoxStore = create(
partialize: (state) => ({
// 只持久化选项状态,不持久化聊天历史等
options: state.options
})
}),
merge: (persistedState, currentState) => {
// 合并持久化状态和当前状态
const merged = {
...currentState,
...persistedState,
};
// 确保 options 中的所有字段都存在,使用默认值填充缺失的字段
if (persistedState?.options) {
merged.options = {
dynamicTable: persistedState.options.dynamicTable ?? false,
streamOutput: persistedState.options.streamOutput ?? false,
imageWorkflow: persistedState.options.imageWorkflow ?? false,
// 兼容旧版本:如果存在 htmlRender/markdownRender转换为 renderMode
renderMode: (() => {
// 优先使用新的 renderMode
if (persistedState.options.renderMode) {
return persistedState.options.renderMode;
}
// 兼容旧版本的 htmlRender/markdownRender
if (persistedState.options.markdownRender) return 'markdown';
if (persistedState.options.htmlRender) return 'html';
return 'none';
})(),
autoDiceRoll: persistedState.options.autoDiceRoll ?? false,
};
}
return merged;
}
}
)
)

View File

@@ -27,6 +27,7 @@ const useApiConfigStore = create(
set({ loading: true, error: null });
try {
const response = await fetch('/api/api-config/profiles');
if (!response.ok) {
throw new Error('Failed to fetch profiles');
}
@@ -47,7 +48,11 @@ const useApiConfigStore = create(
throw new Error('Failed to fetch profile');
}
const data = await response.json();
set({ currentProfile: data, loading: false });
set({
currentProfile: data,
currentProfileId: profileId, // ✅ 保存当前配置 ID
loading: false
});
return data;
} catch (err) {
set({ error: err.message, loading: false });
@@ -193,8 +198,27 @@ const useApiConfigStore = create(
{
name: 'ApiConfigStore',
partialize: (state) => ({
activeMap: state.activeMap
})
activeMap: state.activeMap,
// ✅ 持久化当前选中的配置文件 ID
currentProfileId: state.currentProfile?.id || null
}),
// ✅ 恢复时自动加载对应的配置详情
onRehydrateStorage: () => (state, error) => {
if (error) {
console.error('[ApiConfigStore] 恢复状态失败:', error);
return;
}
if (state?.currentProfileId) {
console.log(`[ApiConfigStore] 🔄 恢复上次选中的配置: ${state.currentProfileId}`);
// 异步加载配置详情
setTimeout(() => {
useApiConfigStore.getState().fetchProfile(state.currentProfileId).catch(err => {
console.error('[ApiConfigStore] 加载配置详情失败:', err);
});
}, 0);
}
}
}
)
);

View File

@@ -7,8 +7,9 @@ import { create } from 'zustand';
const useCharacterCardUIStore = create((set) => ({
// ==================== 状态 ====================
// 筛选标签
filterTag: '',
// 筛选标签数组 - 支持多标签交集筛选
// 格式: ['include:tag1', 'exclude:tag2', 'include:tag3']
filterTags: [],
// 是否处于编辑模式
isEditing: false,
@@ -25,11 +26,27 @@ const useCharacterCardUIStore = create((set) => ({
// ==================== Actions ====================
/**
* 设置筛选标签
* 设置筛选标签(三次切换:无筛选 -> 包含 -> 排除 -> 无筛选)
* @param {string} tag - 标签名
*/
setFilterTag: (tag) => {
set({ filterTag: tag, currentPage: 1 }); // 重置页码
set((state) => {
const currentFilter = state.filterTag;
// 如果点击的是同一个标签,循环切换状态
if (currentFilter && (currentFilter === tag || currentFilter === `include:${tag}` || currentFilter === `exclude:${tag}`)) {
if (currentFilter === tag || currentFilter === `include:${tag}`) {
// 从包含切换到排除
return { filterTag: `exclude:${tag}`, currentPage: 1 };
} else {
// 从排除切换到无筛选
return { filterTag: '', currentPage: 1 };
}
} else {
// 点击新标签,设置为包含模式
return { filterTag: `include:${tag}`, currentPage: 1 };
}
});
},
/**
@@ -54,7 +71,8 @@ const useCharacterCardUIStore = create((set) => ({
first_mes: character.first_mes || '',
mes_example: character.mes_example || '',
categories: character.categories || [],
tags: character.tags || []
tags: character.tags || [],
worldInfoId: character.worldInfoId || null
}
});
},

View File

@@ -1,6 +1,9 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware'; // ✅ 添加持久化支持
const usePresetStore = create((set, get) => ({
const usePresetStore = create(
persist(
(set, get) => ({
// 预设选择
selectedPreset: '',
@@ -405,6 +408,47 @@ const usePresetStore = create((set, get) => ({
const { presets, pageSize } = get();
return Math.ceil(presets.length / pageSize);
}
}));
}), // ✅ 闭合 (set, get) => ({...})
{
name: 'PresetStore', // localStorage 中的键名
partialize: (state) => ({
// ✅ 持久化选中的预设名称
selectedPreset: state.selectedPreset,
// ✅ 持久化参数设置
parameters: state.parameters,
// ✅ 持久化提示词组件配置
promptComponents: state.promptComponents,
// ✅ 持久化折叠状态
isParametersExpanded: state.isParametersExpanded
}),
// ✅ 恢复时的回调
onRehydrateStorage: () => {
return (state, error) => {
if (error) {
console.error('[PresetStore] 恢复状态失败:', error);
return;
}
if (state?.selectedPreset) {
console.log(`[PresetStore] 🔄 恢复上次选中的预设: ${state.selectedPreset}`);
// 异步加载预设详情
setTimeout(() => {
usePresetStore.getState().fetchPresets().then(() => {
// 加载完列表后,重新选择之前选中的预设以加载其详细配置
if (state.selectedPreset) {
usePresetStore.getState().setSelectedPreset(state.selectedPreset).catch(err => {
console.error('[PresetStore] 加载预设详情失败:', err);
});
}
}).catch(err => {
console.error('[PresetStore] 加载预设列表失败:', err);
});
}, 0);
}
};
}
}
) // ✅ 闭合 persist(
);
export default usePresetStore;

View File

@@ -11,7 +11,8 @@ const useSideBarLeftStore = create(
{ id: 'character', label: '角色', title: '管理AI角色卡' },
{ id: 'api', label: 'API', title: '配置LLM和生图、向量化API连接' },
{ id: 'presets', label: '预设', title: '管理对话预设和系统提示词' },
{ id: 'worldbook', label: '世界', title: '管理世界观设定和背景知识' }
{ id: 'worldbook', label: '世界', title: '管理世界观设定和背景知识' },
{ id: 'tokenUsage', label: 'Token', title: '查看 Token 使用统计' }
],
setActiveTab: (tab) => set({ activeTab: tab })

View File

@@ -0,0 +1,122 @@
/**
* Token 使用统计 Store
*/
import { create } from 'zustand';
const useTokenUsageStore = create((set, get) => ({
// 状态
months: [],
currentMonth: null,
stats: null,
roles: [],
chats: [],
selectedRole: null,
selectedChat: null,
loading: false,
error: null,
// Actions
fetchMonths: async () => {
try {
set({ loading: true, error: null });
const response = await fetch('/api/token-usage/months');
if (!response.ok) throw new Error('Failed to fetch months');
const months = await response.json();
set({ months, loading: false });
// 默认选择最新的月份
if (months.length > 0) {
const latest = months[months.length - 1];
await get().fetchStats(latest.year, latest.month);
}
} catch (error) {
set({ error: error.message, loading: false });
}
},
fetchStats: async (year, month, role = null, chat = null) => {
try {
set({ loading: true, error: null, currentMonth: { year, month } });
let url = `/api/token-usage/stats/${year}/${month}`;
const params = new URLSearchParams();
if (role) params.append('role_name', role);
if (chat) params.append('chat_name', chat);
if (params.toString()) {
url += `?${params.toString()}`;
}
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to fetch stats');
const stats = await response.json();
set({ stats, loading: false });
} catch (error) {
set({ error: error.message, loading: false });
}
},
fetchRoles: async (year, month) => {
try {
const response = await fetch(`/api/token-usage/roles/${year}/${month}`);
if (!response.ok) throw new Error('Failed to fetch roles');
const data = await response.json();
set({ roles: data.roles });
} catch (error) {
console.error('Failed to fetch roles:', error);
}
},
fetchChats: async (year, month, role = null) => {
try {
let url = `/api/token-usage/chats/${year}/${month}`;
if (role) {
url += `?role_name=${encodeURIComponent(role)}`;
}
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to fetch chats');
const data = await response.json();
set({ chats: data.chats });
} catch (error) {
console.error('Failed to fetch chats:', error);
}
},
setSelectedRole: (role) => {
set({ selectedRole: role });
const { currentMonth } = get();
if (currentMonth) {
get().fetchStats(currentMonth.year, currentMonth.month, role, get().selectedChat);
}
},
setSelectedChat: (chat) => {
set({ selectedChat: chat });
const { currentMonth } = get();
if (currentMonth) {
get().fetchStats(currentMonth.year, currentMonth.month, get().selectedRole, chat);
}
},
setCurrentMonth: (year, month) => {
set({ currentMonth: { year, month } });
get().fetchStats(year, month, get().selectedRole, get().selectedChat);
get().fetchRoles(year, month);
get().fetchChats(year, month, get().selectedRole);
},
resetFilters: () => {
set({ selectedRole: null, selectedChat: null });
const { currentMonth } = get();
if (currentMonth) {
get().fetchStats(currentMonth.year, currentMonth.month);
}
},
}));
export default useTokenUsageStore;

View File

@@ -3,15 +3,16 @@ import { persist } from 'zustand/middleware';
const useSideBarRightStore = create(
persist(
(set) => ({
(set, get) => ({
selectedTabs: ['dice', 'rag'],
allTabs: [
{ id: 'dice', label: '骰子', title: '掷骰子和随机数生成工具', component: null },
{ id: 'debug', label: '调试', title: '查看具体发送了哪些上下文', component: null },
{ id: 'macros', label: '宏', title: '主要为快捷输入', component: null },
{ id: 'table', label: '表格', title: '动态数据表格展示与编辑,可直接影响后端', component: null },
{ id: 'rag', label: 'RAG', title: '查看具体召回了哪些条目', component: null }
{ id: 'rag', label: 'RAG', title: '查看具体召回了哪些条目', component: null },
{ id: 'worldbook_active', label: '世界书', title: '查看当前对话中激活的世界书条目', component: null }, // ✅ 添加世界书激活标签
{ id: 'tasks', label: '任务', title: '查看和管理并行任务(生图、表格维护等)', component: null } // ✅ 添加任务队列标签
],
handleTabClick: (tabId) => set((state) => {
@@ -32,11 +33,46 @@ const useSideBarRightStore = create(
allTabs: state.allTabs.map(tab =>
tab.id === tabId ? { ...tab, component } : tab
)
}))
})),
// ✅ 验证并清理无效的选中标签
validateSelectedTabs: () => {
const state = get();
const validIds = state.allTabs.map(tab => tab.id);
const validSelectedTabs = state.selectedTabs.filter(id => validIds.includes(id));
if (validSelectedTabs.length !== state.selectedTabs.length) {
set({ selectedTabs: validSelectedTabs });
}
}
}),
{
name: 'sidebar-right-storage', // localStorage key
partialize: (state) => ({ selectedTabs: state.selectedTabs }) // 只保存 selectedTabs
partialize: (state) => ({ selectedTabs: state.selectedTabs }), // 只保存 selectedTabs
// ✅ 迁移逻辑:清除已删除的标签
migrate: (persistedState, version) => {
if (persistedState.selectedTabs) {
// 移除已删除的 'debug' 标签
const cleanedTabs = persistedState.selectedTabs.filter(id => id !== 'debug');
// 如果清理后为空或只有一个,恢复到默认值
if (cleanedTabs.length === 0) {
persistedState.selectedTabs = ['dice', 'rag'];
} else if (cleanedTabs.length === 1 && cleanedTabs[0] === 'dice') {
// 如果只剩dice添加rag作为第二个
persistedState.selectedTabs = ['dice', 'rag'];
} else {
persistedState.selectedTabs = cleanedTabs;
}
}
return persistedState;
},
// ✅ 加载后验证
onRehydrateStorage: () => (state) => {
if (state) {
state.validateSelectedTabs();
}
}
}
)
);

View File

@@ -0,0 +1,88 @@
import { create } from 'zustand';
import useChatBoxStore from '../Mid/ChatBoxSlice';
/**
* 任务队列 Store
* 管理并行任务(生图、动态表格维护等)的状态
*/
const useTasksStore = create((set, get) => ({
// ==================== 状态 ====================
// 任务列表
tasks: [],
// ==================== Actions ====================
/**
* 添加任务
* @param {Object} task - 任务对象 {taskId, taskType, chatId}
*/
addTask: (task) => set((state) => ({
tasks: [...state.tasks, { ...task, status: 'pending' }]
})),
/**
* 批量添加任务
* @param {Array} newTasks - 新任务数组
*/
addTasks: (newTasks) => set((state) => ({
tasks: [...state.tasks, ...newTasks.map(t => ({ ...t, status: 'pending' }))]
})),
/**
* 更新任务状态
* @param {string} taskId - 任务ID
* @param {string} status - 新状态
* @param {Object} metadata - 额外元数据
*/
updateTaskStatus: (taskId, status, metadata = null) => set((state) => ({
tasks: state.tasks.map(task =>
task.taskId === taskId
? { ...task, status, ...(metadata && { metadata }) }
: task
)
})),
/**
* 取消任务
* @param {string} taskId - 任务ID
*/
cancelTask: async (taskId) => {
const { wsConnection } = useChatBoxStore.getState();
if (wsConnection) {
wsConnection.send(JSON.stringify({
type: 'cancel_task',
taskId: taskId
}));
// 乐观更新UI
set((state) => ({
tasks: state.tasks.map(task =>
task.taskId === taskId ? { ...task, status: 'cancelled' } : task
)
}));
}
},
/**
* 清理已完成的任务
*/
clearCompleted: () => set((state) => ({
tasks: state.tasks.filter(task =>
!['completed', 'failed', 'cancelled'].includes(task.status)
)
})),
/**
* 设置任务列表(用于全量更新)
* @param {Array} tasks - 任务列表
*/
setTasks: (tasks) => set({ tasks }),
/**
* 清空所有任务
*/
clearAll: () => set({ tasks: [] })
}));
export default useTasksStore;

View File

@@ -0,0 +1,37 @@
import { create } from 'zustand';
/**
* 世界书激活显示 Store
* 管理当前对话中激活的世界书条目
*/
const useWorldBookActiveStore = create((set, get) => ({
// ==================== 状态 ====================
// 激活的条目列表
activeEntries: [],
// ==================== Actions ====================
/**
* 设置激活的条目(覆盖)
* @param {Array} entries - 激活的条目列表
*/
setActiveEntries: (entries) => set({
activeEntries: entries || []
}),
/**
* 添加单个激活条目
* @param {Object} entry - 条目对象
*/
addActiveEntry: (entry) => set((state) => ({
activeEntries: [...state.activeEntries, entry]
})),
/**
* 清空所有激活条目
*/
clearEntries: () => set({ activeEntries: [] })
}));
export default useWorldBookActiveStore;

View File

@@ -12,7 +12,6 @@
flex: 1;
overflow-y: auto;
padding: var(--spacing-lg);
padding-bottom: 180px; /* 为固定的输入框留出空间 */
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
@@ -330,27 +329,70 @@
position: absolute;
bottom: calc(100% + var(--spacing-sm));
left: 0;
background: #ffffff;
border: 1px solid rgba(102, 126, 234, 0.2);
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
padding: var(--spacing-md);
min-width: 200px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
padding: var(--spacing-sm);
min-width: 220px;
z-index: 100;
backdrop-filter: blur(10px);
}
/* 选项组标题 */
.option-group-title {
font-size: 0.7rem;
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
padding: var(--spacing-xs) var(--spacing-sm);
margin-bottom: var(--spacing-xs);
}
/* 渲染模式按钮 */
.render-mode-btn {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
background: transparent;
color: var(--color-text-primary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
transition: all 0.15s ease;
text-align: left;
display: flex;
align-items: center;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-sm);
}
.render-mode-btn:hover {
background-color: rgba(102, 126, 234, 0.08);
border-color: var(--color-accent);
color: var(--color-accent);
}
.render-mode-btn:active {
transform: scale(0.98);
}
/* 复选框选项 - 简化样式 */
.option-checkbox {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-xs) var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
cursor: pointer;
border-radius: var(--radius-sm);
transition: background-color 0.15s ease;
border-radius: var(--radius-md);
transition: all 0.15s ease;
margin-bottom: var(--spacing-xs);
}
.option-checkbox:hover {
background-color: rgba(102, 126, 234, 0.05);
background-color: rgba(102, 126, 234, 0.06);
}
.option-checkbox input[type="checkbox"] {
@@ -358,15 +400,19 @@
}
.checkmark {
width: 18px;
height: 18px;
border: 2px solid rgba(102, 126, 234, 0.3);
border-radius: 4px;
width: 16px;
height: 16px;
border: 2px solid var(--color-border);
border-radius: 3px;
position: relative;
transition: all 0.2s ease;
transition: all 0.15s ease;
flex-shrink: 0;
}
.option-checkbox:hover .checkmark {
border-color: var(--color-accent);
}
.option-checkbox input:checked + .checkmark {
background-color: var(--color-accent);
border-color: var(--color-accent);
@@ -375,8 +421,8 @@
.option-checkbox input:checked + .checkmark::after {
content: '';
position: absolute;
left: 5px;
top: 2px;
left: 4px;
top: 1px;
width: 4px;
height: 8px;
border: solid white;
@@ -388,17 +434,18 @@
font-size: 0.85rem;
color: var(--color-text-primary);
user-select: none;
flex: 1;
}
/* 分隔线 */
.option-divider {
height: 1px;
background: linear-gradient(to right,
transparent,
rgba(102, 126, 234, 0.15),
transparent);
background-color: var(--color-border);
margin: var(--spacing-sm) 0;
opacity: 0.5;
}
/* 切换聊天按钮 */
.switch-chat-btn {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
@@ -409,7 +456,7 @@
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
transition: all 0.2s ease;
transition: all 0.15s ease;
box-shadow: 0 2px 4px rgba(102, 126, 234, 0.2);
}
@@ -419,6 +466,10 @@
box-shadow: 0 4px 8px rgba(102, 126, 234, 0.3);
}
.switch-chat-btn:active {
transform: translateY(0);
}
.chat-input-area {
flex: 1;
min-width: 0;
@@ -584,6 +635,40 @@
padding: var(--spacing-lg);
}
/* 新建聊天按钮区域 */
.chat-selector-actions {
margin-bottom: var(--spacing-md);
padding-bottom: var(--spacing-md);
border-bottom: 1px solid var(--color-border);
}
.new-chat-btn {
width: 100%;
padding: var(--spacing-md) var(--spacing-lg);
background: var(--gradient-primary);
color: white;
border: none;
border-radius: var(--radius-md);
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all var(--transition-fast);
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-xs);
}
.new-chat-btn:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
filter: brightness(1.1);
}
.new-chat-btn:active {
transform: translateY(0);
}
.chat-list {
display: flex;
flex-direction: column;

View File

@@ -46,7 +46,8 @@ const ChatBox = () => {
sendMessage,
stopGeneration,
options,
toggleOption
toggleOption,
cycleRenderMode // ✅ 新增:切换渲染模式
} = useChatBoxStore();
// 点击外部关闭选项面板
@@ -202,6 +203,35 @@ const ChatBox = () => {
alert('切换聊天失败: ' + error.message);
}
};
// 新建聊天
const handleCreateNewChat = async () => {
try {
const { currentRole, createChat, setChatBoxRoleAndChat } = useChatBoxStore.getState();
if (!currentRole) {
alert('请先选择一个角色');
return;
}
// 使用当前时间戳作为聊天名称
const chatName = `chat_${Date.now()}`;
// 创建新聊天
await createChat(currentRole, chatName);
// 切换到新创建的聊天
setChatBoxRoleAndChat(currentRole, chatName);
// 关闭选择器
setShowChatSelector(false);
console.log(`已创建并切换到新聊天: ${chatName}`);
} catch (error) {
console.error('创建聊天失败:', error);
alert('创建聊天失败: ' + error.message);
}
};
// 渲染单条消息
const renderMessage = (message) => {
@@ -283,14 +313,30 @@ const ChatBox = () => {
</div>
) : (
<div className="bubble">
{options.htmlRender && !isUser ? (
<div dangerouslySetInnerHTML={{ __html: currentMes }} />
) : options.markdownRender ? (
{options.renderMode === 'html' && !isUser ? (
// ✅ 智能 HTML 渲染:检测是否为纯文本,自动转换换行符
<div dangerouslySetInnerHTML={{
__html: (() => {
// 检测是否包含 HTML 标签
const hasHtmlTags = /<[a-z][\s\S]*>/i.test(currentMes);
if (hasHtmlTags) {
// 如果已有 HTML 标签,直接返回
return currentMes;
}
// 如果是纯文本,将换行符转换为 <br>
return currentMes
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
})()
}} />
) : options.renderMode === 'markdown' ? (
<MarkdownRenderer content={currentMes} />
) : (
<div className="plain-text">{currentMes}</div>
)}
{hasSwipes && isLatestMessage && !isUser && (
{hasSwipes && !isUser && isLatestMessage && (
<div className="swipe-controls">
<button
className="swipe-button"
@@ -345,24 +391,19 @@ const ChatBox = () => {
</button>
{showOptions && (
<div className="chat-options">
<label className="option-checkbox">
<input
type="checkbox"
checked={options.markdownRender}
onChange={() => toggleOption('markdownRender')}
/>
<span className="checkmark"></span>
<span className="option-label">Markdown渲染</span>
</label>
<label className="option-checkbox">
<input
type="checkbox"
checked={options.htmlRender}
onChange={() => toggleOption('htmlRender')}
/>
<span className="checkmark"></span>
<span className="option-label">HTML渲染</span>
</label>
{/* 渲染模式切换按钮 */}
<div className="option-group-title">显示</div>
<button
className="render-mode-btn"
onClick={() => cycleRenderMode()}
title={`当前: ${options.renderMode === 'none' ? '纯文本' : options.renderMode === 'html' ? 'HTML' : 'Markdown'},点击切换`}
>
{options.renderMode === 'none' ? '📄 纯文本' :
options.renderMode === 'html' ? '🌐 HTML' :
'📝 Markdown'}
</button>
<div className="option-group-title">功能</div>
<label className="option-checkbox">
<input
type="checkbox"
@@ -382,6 +423,17 @@ const ChatBox = () => {
<span className="option-label">动态表格</span>
</label>
{/* 自动掷骰子替换 */}
<label className="option-checkbox">
<input
type="checkbox"
checked={options.autoDiceRoll}
onChange={() => toggleOption('autoDiceRoll')}
/>
<span className="checkmark"></span>
<span className="option-label">🎲 自动掷骰子</span>
</label>
{/* 生图工作流选项 */}
<label className="option-checkbox">
<input
@@ -453,6 +505,16 @@ const ChatBox = () => {
</div>
<div className="chat-selector-body">
{/* 新建聊天按钮 */}
<div className="chat-selector-actions">
<button
className="new-chat-btn"
onClick={handleCreateNewChat}
>
+ 新建聊天
</button>
</div>
{characterChats.length > 0 ? (
<div className="chat-list">
{characterChats.map((chat, index) => (

View File

@@ -47,10 +47,29 @@
.sidebar-content {
flex: 1;
overflow-y: auto;
padding: var(--spacing-lg);
padding: 0; /* ✅ 移除padding滚动条作为间隔 */
min-height: 0;
}
/* ✅ 美化滚动条 - 符合主题 */
.sidebar-content::-webkit-scrollbar {
width: 8px;
}
.sidebar-content::-webkit-scrollbar-track {
background: transparent;
}
.sidebar-content::-webkit-scrollbar-thumb {
background: var(--color-scrollbar);
border-radius: 4px;
transition: all 0.2s ease;
}
.sidebar-content::-webkit-scrollbar-thumb:hover {
background: var(--color-scrollbar-hover);
}
/* Tab placeholder for empty states */
.tab-placeholder {
padding: var(--spacing-lg);

View File

@@ -3,15 +3,18 @@ import React from 'react';
import './SideBarLeft.css';
import { useSideBarLeftStore } from '../../Store/indexStore';
import usePresetStore from '../../Store/SideBarLeft/PresetSlice';
import useApiConfigStore from '../../Store/SideBarLeft/ApiConfigSlice';
import Gallery from './tabs/Gallery';
import CharacterCard from './tabs/CharacterCard';
import ApiConfig from './tabs/ApiConfig';
import Presets from './tabs/Presets';
import WorldBook from './tabs/WorldBook';
import TokenUsage from './tabs/TokenUsage/TokenUsage';
const SideBarLeft = () => {
const { activeTab, tabs, setActiveTab } = useSideBarLeftStore();
const { fetchPresets } = usePresetStore();
const { fetchProfiles } = useApiConfigStore();
// 处理标签切换
const handleTabClick = (tabId) => {
@@ -21,6 +24,11 @@ const SideBarLeft = () => {
if (tabId === 'presets') {
fetchPresets();
}
// 如果切换到API配置标签刷新配置文件列表
if (tabId === 'api') {
fetchProfiles();
}
};
return (
@@ -44,6 +52,7 @@ const SideBarLeft = () => {
{activeTab === 'api' && <ApiConfig />}
{activeTab === 'presets' && <Presets />}
{activeTab === 'worldbook' && <WorldBook />}
{activeTab === 'tokenUsage' && <TokenUsage />}
</div>
</div>
);

View File

@@ -6,7 +6,7 @@ import ComfyUIWorkflowManager from './ComfyUIWorkflowManager';
import './ApiConfig.css';
const ApiConfig = () => {
// 从store中获取状态和方法
// 从 store中获取状态和方法
const {
profiles,
currentProfile,
@@ -77,21 +77,11 @@ const ApiConfig = () => {
// 跟踪哪些 API 有修改
const [modifiedApis, setModifiedApis] = useState({});
// 跟踪用户是否正在编辑 API Key用于控制脱敏显示
const [isEditingApiKey, setIsEditingApiKey] = useState(false);
// 获取当前激活的分页
const { activeTab } = useSideBarLeftStore();
// 记录上一次的分页状态
const prevActiveTabRef = React.useRef(activeTab);
// 组件加载时获取配置文件列表 - 只在切换到API分页时刷新
useEffect(() => {
// 检测是否从其他分页切换到API分页
if (activeTab === 'api' && prevActiveTabRef.current !== 'api') {
fetchProfiles();
}
// 更新上一次的分页状态
prevActiveTabRef.current = activeTab;
}, [activeTab, fetchProfiles]);
// 当选中配置文件时,加载该配置
useEffect(() => {
@@ -100,6 +90,15 @@ const ApiConfig = () => {
}
}, [selectedProfileId]);
// 当 profiles 加载完成后,如果没有选中的配置文件,则自动加载第一个
useEffect(() => {
if (profiles.length > 0 && !selectedProfileId && activeTab === 'api') {
// 尝试加载 activeMap 中记录的配置文件,如果没有则加载第一个
const defaultProfileId = activeMap[currentCategory] || profiles[0].id;
setSelectedProfileId(defaultProfileId);
}
}, [profiles, selectedProfileId, activeTab, currentCategory, activeMap]);
// 加载配置文件
const loadProfile = async (profileId) => {
try {
@@ -123,6 +122,11 @@ const ApiConfig = () => {
const { name, value, type, checked } = e.target;
const newValue = type === 'checkbox' ? checked : value;
// 如果是 API Key 字段,标记为正在编辑
if (name === 'apiKey') {
setIsEditingApiKey(true);
}
if (path) {
// 嵌套路径更新,例如: ['imageModel', 'local', 'apiUrl']
setFormData(prev => {
@@ -267,8 +271,10 @@ const ApiConfig = () => {
return;
}
if (!currentApi.apiKey) {
alert('请先填写 API 密钥');
// 检查 API Key可以为空如果已保存过后端会使用保存的 key
// 但如果既没有输入 key也没有保存过则会失败
if (!currentApi.apiKey || currentApi.apiKey.trim() === '') {
alert('请先填写 API 密钥,或先保存配置文件');
return;
}
@@ -277,6 +283,7 @@ const ApiConfig = () => {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: selectedProfileId || null, // 使用 id 字段传递 profileId
apiUrl: currentApi.apiUrl,
apiKey: currentApi.apiKey,
category: currentCategory,
@@ -388,19 +395,9 @@ const ApiConfig = () => {
if (selectedProfileId && confirm('确定要删除此配置文件吗?')) {
await deleteProfile(selectedProfileId);
setSelectedProfileId('');
handleCreateNew();
}
};
// 处理设为默认配置
const handleSetActive = async () => {
if (!selectedProfileId) {
alert('请先保存配置文件');
return;
}
await setActiveConfig(currentCategory, selectedProfileId);
};
// 配置区域标签(简短名称 + Tooltip
const configTabs = [
{ id: 'mainLLM', label: '核心', tooltip: '主 LLM 模型 - 用于主要对话和推理' },
@@ -409,8 +406,13 @@ const ApiConfig = () => {
{ id: 'ragEmbedding', label: '向量', tooltip: 'RAG 嵌入模型 - 用于文本向量化和检索' }
];
// 判断当前 API 密钥是否是脱敏的
const isApiKeyMasked = formData[currentCategory].apiKey && formData[currentCategory].apiKey.endsWith('****');
// 脱敏 API Key 显示只显示最后6位
const maskApiKey = (apiKey) => {
if (!apiKey || apiKey.length <= 6) {
return apiKey || '';
}
return '•'.repeat(apiKey.length - 6) + apiKey.slice(-6);
};
// 计算有修改的 API 数量
const modifiedCount = Object.keys(modifiedApis).filter(k => modifiedApis[k]).length;
@@ -438,41 +440,38 @@ const ApiConfig = () => {
onChange={handleSelectProfile}
className="form-control profile-select-input"
>
<option value="">新建配置文件...</option>
{profiles.map(profile => (
<option key={profile.id} value={profile.id}>
{profile.name}
</option>
))}
</select>
<div className="profile-buttons">
{!selectedProfileId ? (
<button
type="button"
className="btn btn-primary btn-sm"
onClick={handleOpenSaveModal}
>
+ 新建
</button>
{profiles.length === 0 ? (
<option value="">暂无配置文件</option>
) : (
<>
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={handleSetActive}
disabled={activeMap[currentCategory] === selectedProfileId}
>
设为默认
</button>
<button
type="button"
className="btn btn-danger btn-sm"
onClick={handleDelete}
>
删除
</button>
<option value="">-- 选择配置文件 --</option>
{profiles.map(profile => (
<option key={profile.id} value={profile.id}>
{profile.name}
</option>
))}
</>
)}
</select>
<div className="profile-buttons">
<button
type="button"
className="btn btn-primary btn-sm"
onClick={handleOpenSaveModal}
title="创建新的配置文件"
>
+ 新建
</button>
{selectedProfileId && (
<button
type="button"
className="btn btn-danger btn-sm"
onClick={handleDelete}
title="删除当前配置文件"
>
删除
</button>
)}
</div>
</div>
</div>
@@ -606,13 +605,31 @@ const ApiConfig = () => {
<div className="form-group">
<label htmlFor="cloud-apiKey">API Key</label>
<input
type="password"
type={isEditingApiKey ? 'text' : 'password'}
id="cloud-apiKey"
value={formData.imageModel.cloud.apiKey}
value={isEditingApiKey ? formData.imageModel.cloud.apiKey : maskApiKey(formData.imageModel.cloud.apiKey)}
onChange={(e) => handleChange(e, ['imageModel', 'cloud', 'apiKey'])}
placeholder="sk-..."
onBlur={() => setIsEditingApiKey(false)}
onFocus={() => {
if (formData.imageModel.cloud.apiKey && !isEditingApiKey) {
// 聚焦时清空,让用户重新输入
setFormData(prev => ({
...prev,
imageModel: {
...prev.imageModel,
cloud: {
...prev.imageModel.cloud,
apiKey: ''
}
}
}));
}
setIsEditingApiKey(true);
}}
placeholder="sk-...(输入新密钥将覆盖旧密钥)"
className="form-control"
/>
<span className="form-hint">首次输入可见之后仅显示最后6位</span>
</div>
<div className="form-group">
@@ -668,17 +685,29 @@ const ApiConfig = () => {
<div className="form-group">
<label htmlFor="apiKey">密钥</label>
<input
type="password"
type={isEditingApiKey ? 'text' : 'password'}
id="apiKey"
name="apiKey"
value={isApiKeyMasked ? '' : formData[currentCategory].apiKey}
value={isEditingApiKey ? formData[currentCategory].apiKey : maskApiKey(formData[currentCategory].apiKey)}
onChange={handleChange}
placeholder={isApiKeyMasked ? '已保存(留空保持不变)' : 'sk-...'}
onBlur={() => setIsEditingApiKey(false)}
onFocus={() => {
if (formData[currentCategory].apiKey && !isEditingApiKey) {
// 聚焦时清空,让用户重新输入
setFormData(prev => ({
...prev,
[currentCategory]: {
...prev[currentCategory],
apiKey: ''
}
}));
}
setIsEditingApiKey(true);
}}
placeholder="sk-...(输入新密钥将覆盖旧密钥)"
className="form-control"
/>
{isApiKeyMasked && (
<span className="form-hint">当前密钥已加密存储</span>
)}
<span className="form-hint">首次输入可见之后仅显示最后6位</span>
</div>
<div className="form-row">

View File

@@ -69,6 +69,9 @@
cursor: pointer;
font-size: 10px;
transition: all 0.15s ease;
display: inline-flex;
align-items: center;
gap: 2px;
}
.tag-btn:hover {
@@ -76,12 +79,36 @@
color: var(--color-accent);
}
.tag-btn.active {
background: var(--color-accent);
border-color: var(--color-accent);
/* 包含模式(第一次点击)- 绿色 */
.tag-btn.tag-include {
background: #10b981;
border-color: #10b981;
color: white;
}
.tag-btn.tag-include:hover {
background: #059669;
border-color: #059669;
}
/* 排除模式(第二次点击)- 红色 */
.tag-btn.tag-exclude {
background: #ef4444;
border-color: #ef4444;
color: white;
}
.tag-btn.tag-exclude:hover {
background: #dc2626;
border-color: #dc2626;
}
/* 排除前缀符号 */
.tag-prefix {
font-weight: bold;
font-size: 12px;
}
/* 错误和加载提示 */
.error-message,
.loading-message,
@@ -270,47 +297,116 @@
flex-direction: column;
gap: 8px;
padding: 8px;
position: relative; /* 为悬浮工具栏提供定位上下文 */
}
.edit-header {
/* 悬浮工具栏 */
.floating-toolbar {
position: sticky;
top: 0;
z-index: 100;
margin-bottom: 8px;
}
/* 工具栏内容(默认隐藏) */
.toolbar-content {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
padding: 6px 10px;
background: var(--color-bg-tertiary);
border-radius: 4px;
border-radius: 6px;
border: 1px solid var(--color-border-light);
flex-wrap: wrap; /* 允许换行 */
gap: 6px; /* 添加间距 */
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
/* 默认状态:收缩 */
max-height: 0;
opacity: 0;
overflow: hidden;
transform: scale(0.8);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 悬停时展开 */
.floating-toolbar:hover .toolbar-content {
max-height: 60px;
opacity: 1;
transform: scale(1);
}
.toolbar-left {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0; /* 允许收缩 */
}
.edit-title {
font-size: 12px;
font-weight: 600;
color: var(--color-text-primary);
flex: 1; /* 占据剩余空间 */
min-width: 120px; /* 最小宽度,防止过度压缩 */
white-space: nowrap; /* 不换行 */
overflow: hidden; /* 超出隐藏 */
text-overflow: ellipsis; /* 显示省略号 */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.edit-actions {
/* 小球指示器 */
.toolbar-indicator {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
width: 36px;
height: 36px;
background: var(--color-accent);
border-radius: 50%;
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap; /* 按钮也可以换行 */
justify-content: center;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% {
transform: translateY(-50%) scale(1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
50% {
transform: translateY(-50%) scale(1.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
}
.floating-toolbar:hover .toolbar-indicator {
opacity: 0;
transform: translateY(-50%) scale(0);
}
.indicator-icon {
font-size: 18px;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2));
}
.toolbar-actions {
display: flex;
gap: 4px;
align-items: center;
}
.export-format-selector {
padding: 4px 8px;
padding: 3px 6px;
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: 4px;
color: var(--color-text-primary);
font-size: 11px;
cursor: pointer;
transition: border-color 0.15s ease;
transition: all 0.15s ease;
}
.export-format-selector:hover {
@@ -322,55 +418,66 @@
border-color: var(--color-accent);
}
.edit-btn {
padding: 4px 10px;
.toolbar-btn {
width: 28px;
height: 28px;
padding: 0;
border: 1px solid var(--color-border);
border-radius: 4px;
cursor: pointer;
font-size: 11px;
font-size: 14px;
font-weight: 500;
transition: all 0.15s ease;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-primary);
}
.edit-btn.save {
.toolbar-btn:hover {
transform: scale(1.1);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
.toolbar-btn.save {
background: var(--color-accent);
border-color: var(--color-accent);
color: white;
}
.edit-btn.save:hover {
.toolbar-btn.save:hover {
background: var(--color-accent-dark);
border-color: var(--color-accent-dark);
}
.edit-btn.export {
.toolbar-btn.export {
background: #10b981;
border-color: #10b981;
color: white;
}
.edit-btn.export:hover {
.toolbar-btn.export:hover {
background: #059669;
border-color: #059669;
}
.edit-btn.delete {
.toolbar-btn.delete {
background: #ef4444;
border-color: #ef4444;
color: white;
}
.edit-btn.delete:hover {
.toolbar-btn.delete:hover {
background: #dc2626;
border-color: #dc2626;
}
.edit-btn.cancel {
.toolbar-btn.cancel {
background: var(--color-bg-primary);
color: var(--color-text-secondary);
}
.edit-btn.cancel:hover {
.toolbar-btn.cancel:hover {
background: var(--color-error);
border-color: var(--color-error);
color: white;
@@ -415,6 +522,9 @@
.form-group textarea {
resize: vertical;
min-height: 60px;
max-height: 300px; /* 设置最大高度 */
overflow-y: auto;
transition: height 0.2s ease; /* 平滑过渡 */
}
.form-hint {

View File

@@ -2,6 +2,7 @@
import React, { useState, useEffect, useRef, useCallback, memo } from 'react'; // ✅ 保留 useState 用于 exportFormat
import { useCharacterStore, useCharacterCardUIStore } from '../../../../Store/SideBarLeft'; // ✅ 新增
import useChatBoxStore from '../../../../Store/Mid/ChatBoxSlice';
import useWorldBookStore from '../../../../Store/SideBarLeft/WorldBookSlice'; // 引入世界书 Store
import './CharacterCard.css';
// 优化的角色卡片项组件 - 使用 memo 和 useCallback
@@ -54,8 +55,8 @@ const CharacterItem = memo(({ character, isSelected, onSelect }) => {
)}
{character.tags && character.tags.length > 0 && (
<div className="character-tags">
{character.tags.slice(0, 3).map(tag => (
<span key={tag} className="tag">{tag}</span>
{character.tags.slice(0, 3).map((tag, index) => (
<span key={`tag-${character.id}-${tag}-${index}`} className="tag">{tag}</span>
))}
</div>
)}
@@ -86,13 +87,14 @@ const CharacterCard = () => {
// ✅ 从 CharacterCardUIStore 获取 UI 状态
const {
filterTag,
filterTags,
isEditing,
editForm,
currentPage,
pageSize,
setFilterTag,
clearFilter,
toggleFilterTag,
removeFilterTag,
clearAllFilters,
startEditing,
cancelEditing,
updateEditForm,
@@ -102,13 +104,17 @@ const CharacterCard = () => {
prevPage
} = useCharacterCardUIStore();
// 引入世界书 Store
const { worldBooks, fetchWorldBooks } = useWorldBookStore();
const [exportFormat, setExportFormat] = useState('png'); // 导出格式: 'png' 或 'json'
const fileInputRef = useRef(null);
const clickTimeoutRef = useRef(null);
// 加载角色列表
// 加载角色列表和世界书列表
useEffect(() => {
fetchCharacters();
fetchWorldBooks(); // 加载世界书列表
// 清理函数
return () => {
@@ -121,10 +127,31 @@ const CharacterCard = () => {
// 获取所有唯一的 tags
const allTags = [...new Set(characters.flatMap(char => char.tags || []))];
// 过滤角色
const filteredCharacters = filterTag
? characters.filter(char => (char.tags || []).includes(filterTag))
: characters;
// 过滤角色(支持多标签交集筛选)
const filteredCharacters = React.useMemo(() => {
if (filterTags.length === 0) return characters;
// 分离包含和排除标签
const includeTags = filterTags
.filter(f => f.startsWith('include:'))
.map(f => f.substring(8));
const excludeTags = filterTags
.filter(f => f.startsWith('exclude:'))
.map(f => f.substring(8));
return characters.filter(char => {
const charTags = char.tags || [];
// 检查是否包含所有必须的标签(交集)
const hasAllInclude = includeTags.every(tag => charTags.includes(tag));
// 检查是否不包含所有排除的标签
const hasNoExclude = excludeTags.every(tag => !charTags.includes(tag));
return hasAllInclude && hasNoExclude;
});
}, [characters, filterTags]);
// ✅ 计算当前页和总页数
const totalPages = Math.ceil(filteredCharacters.length / pageSize) || 1;
@@ -314,6 +341,13 @@ const CharacterCard = () => {
updateEditForm(field, value); // ✅ 使用 store 方法
};
// 自动调整 textarea 高度
const autoResizeTextarea = (e) => {
const textarea = e.target;
textarea.style.height = 'auto'; // 重置高度
textarea.style.height = Math.min(textarea.scrollHeight, 300) + 'px'; // 设置新高度最大300px
};
return (
<div className="character-card-content">
{/* 标题栏 */}
@@ -338,20 +372,93 @@ const CharacterCard = () => {
{allTags.length > 0 && (
<div className="tag-filter">
<button
className={`tag-btn ${!filterTag ? 'active' : ''}`}
onClick={() => setFilterTag('')}
className={`tag-btn ${filterTags.length === 0 ? 'active' : ''}`}
onClick={clearAllFilters}
>
全部
</button>
{allTags.map(tag => (
<button
key={tag}
className={`tag-btn ${filterTag === tag ? 'active' : ''}`}
onClick={() => setFilterTag(tag)}
>
{tag}
</button>
))}
{allTags.map(tag => {
// 判断当前标签的状态
let status = 'none'; // none, include, exclude
const includeFilter = `include:${tag}`;
const excludeFilter = `exclude:${tag}`;
if (filterTags.includes(includeFilter)) {
status = 'include';
} else if (filterTags.includes(excludeFilter)) {
status = 'exclude';
}
return (
<button
key={tag}
className={`tag-btn tag-${status}`}
onClick={() => toggleFilterTag(tag)}
title={
status === 'include' ? '包含该标签(再次点击排除)' :
status === 'exclude' ? '排除该标签(再次点击取消)' :
'筛选包含该标签的角色'
}
>
{status === 'exclude' && <span className="tag-prefix">¬</span>}
{tag}
</button>
);
})}
</div>
)}
{/* 当前筛选条件显示 */}
{filterTags.length > 0 && (
<div className="active-filters" style={{
display: 'flex',
flexWrap: 'wrap',
gap: '4px',
padding: '4px 6px',
alignItems: 'center'
}}>
<span style={{ fontSize: '11px', color: 'var(--color-text-secondary)', marginRight: '4px' }}>
筛选中:
</span>
{filterTags.map((filter) => {
const mode = filter.startsWith('include:') ? 'include' : 'exclude';
const tag = filter.substring(filter.indexOf(':') + 1);
return (
<span
key={`filter-${mode}-${tag}`}
className={`active-filter-tag active-filter-${mode}`}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '2px 8px',
borderRadius: '12px',
fontSize: '10px',
background: mode === 'include' ? 'rgba(16, 185, 129, 0.2)' : 'rgba(239, 68, 68, 0.2)',
border: `1px solid ${mode === 'include' ? '#10b981' : '#ef4444'}`,
color: mode === 'include' ? '#10b981' : '#ef4444'
}}
>
{mode === 'exclude' && '¬'}
{tag}
<button
onClick={() => removeFilterTag(tag)}
style={{
background: 'none',
border: 'none',
color: 'inherit',
cursor: 'pointer',
padding: '0 2px',
fontSize: '12px',
lineHeight: 1
}}
title="移除筛选"
>
×
</button>
</span>
);
})}
</div>
)}
@@ -372,21 +479,39 @@ const CharacterCard = () => {
{/* 编辑模式 */}
{isEditing && selectedCharacter && editForm && (
<div className="character-edit-panel">
<div className="edit-header">
<span className="edit-title">编辑角色: {selectedCharacter.name}</span>
<div className="edit-actions">
<select
className="export-format-selector"
value={exportFormat}
onChange={(e) => setExportFormat(e.target.value)}
>
<option value="png">🖼 图片</option>
<option value="json">📄 JSON</option>
</select>
<button className="edit-btn export" onClick={() => handleExport(selectedCharacter.name, exportFormat)}>📤 导出</button>
<button className="edit-btn delete" onClick={() => handleDelete(selectedCharacter.name)}>🗑 删除</button>
<button className="edit-btn save" onClick={handleSaveEdit}>💾 保存</button>
<button className="edit-btn cancel" onClick={handleCancelEdit}> 取消</button>
{/* 悬浮工具栏 */}
<div className="floating-toolbar">
<div className="toolbar-content">
<div className="toolbar-left">
<span className="edit-title">编辑: {selectedCharacter.name}</span>
</div>
<div className="toolbar-actions">
<select
className="export-format-selector"
value={exportFormat}
onChange={(e) => setExportFormat(e.target.value)}
title="选择导出格式"
>
<option value="png">🖼 PNG</option>
<option value="json">📄 JSON</option>
</select>
<button className="toolbar-btn export" onClick={() => handleExport(selectedCharacter.name, exportFormat)} title="导出角色卡">
📤
</button>
<button className="toolbar-btn delete" onClick={() => handleDelete(selectedCharacter.name)} title="删除角色">
🗑
</button>
<button className="toolbar-btn save" onClick={handleSaveEdit} title="保存更改">
💾
</button>
<button className="toolbar-btn cancel" onClick={handleCancelEdit} title="返回列表">
</button>
</div>
</div>
{/* 小球指示器 */}
<div className="toolbar-indicator">
<span className="indicator-icon"></span>
</div>
</div>
@@ -399,13 +524,18 @@ const CharacterCard = () => {
onChange={(e) => handleFormChange('name', e.target.value)}
placeholder="角色名称"
/>
<small className="form-hint">💡 提示修改角色名将同步重命名文件夹和所有相关引用</small>
</div>
<div className="form-group">
<label>描述</label>
<textarea
value={editForm.description}
onChange={(e) => handleFormChange('description', e.target.value)}
onChange={(e) => {
handleFormChange('description', e.target.value);
autoResizeTextarea(e);
}}
onInput={autoResizeTextarea}
placeholder="角色描述"
rows={6}
/>
@@ -415,7 +545,11 @@ const CharacterCard = () => {
<label>性格</label>
<textarea
value={editForm.personality}
onChange={(e) => handleFormChange('personality', e.target.value)}
onChange={(e) => {
handleFormChange('personality', e.target.value);
autoResizeTextarea(e);
}}
onInput={autoResizeTextarea}
placeholder="角色性格"
rows={3}
/>
@@ -425,7 +559,11 @@ const CharacterCard = () => {
<label>场景</label>
<textarea
value={editForm.scenario}
onChange={(e) => handleFormChange('scenario', e.target.value)}
onChange={(e) => {
handleFormChange('scenario', e.target.value);
autoResizeTextarea(e);
}}
onInput={autoResizeTextarea}
placeholder="场景设定"
rows={3}
/>
@@ -435,7 +573,11 @@ const CharacterCard = () => {
<label>开场白</label>
<textarea
value={editForm.first_mes}
onChange={(e) => handleFormChange('first_mes', e.target.value)}
onChange={(e) => {
handleFormChange('first_mes', e.target.value);
autoResizeTextarea(e);
}}
onInput={autoResizeTextarea}
placeholder="第一条消息"
rows={4}
/>
@@ -445,7 +587,11 @@ const CharacterCard = () => {
<label>对话示例</label>
<textarea
value={editForm.mes_example}
onChange={(e) => handleFormChange('mes_example', e.target.value)}
onChange={(e) => {
handleFormChange('mes_example', e.target.value);
autoResizeTextarea(e);
}}
onInput={autoResizeTextarea}
placeholder="对话示例"
rows={4}
/>
@@ -462,21 +608,57 @@ const CharacterCard = () => {
</div>
<div className="form-group">
<label>动态表格标签 (SillyTavern 关键字机制)</label>
<label>绑定世界书</label>
<select
value={editForm.worldInfoId || ''}
onChange={(e) => handleFormChange('worldInfoId', e.target.value || null)}
>
<option value="">不绑定</option>
{worldBooks.map(book => (
<option key={book.id} value={book.id}>
{book.name}
</option>
))}
</select>
<small className="form-hint">💡 选择后该角色将自动加载绑定的世界书内容</small>
</div>
<div className="form-group">
<label>动态表格数据 (SillyTavern 关键字机制)</label>
<input
type="text"
value={editForm.tags?.join(', ') || ''}
value={Object.entries(editForm.tableDefaults || {}).map(([k, v]) => `${k}:${v}`).join(';')}
onChange={(e) => {
// 支持多种分隔符:逗号、分号、空格
const tagsList = e.target.value
.split(/[,;\s]+/)
.map(tag => tag.trim())
.filter(tag => tag !== '');
handleFormChange('tags', tagsList);
// 解析格式key1:value1;key2:value2,key3:value3
// 支持分隔符:分号、逗号
const entries = e.target.value
.split(/[;,]/)
.map(pair => pair.trim())
.filter(pair => pair !== '')
.map(pair => {
const [key, ...valueParts] = pair.split(':');
const value = valueParts.join(':').trim(); // 支持值中包含冒号
return { key: key.trim(), value };
})
.filter(({ key, value }) => key && value !== undefined);
// 构建 tableDefaults 对象
const tableDefaults = {};
const tableHeaders = [];
entries.forEach(({ key, value }) => {
tableHeaders.push(key);
// 尝试转换为数字
const numValue = Number(value);
tableDefaults[key] = isNaN(numValue) ? value : numValue;
});
handleFormChange('tableHeaders', tableHeaders);
handleFormChange('tableDefaults', tableDefaults);
}}
placeholder="力量:80, 敏捷:65, 智力:90, HP:100"
placeholder="力量:80;敏捷:65;智力:90;HP:100"
/>
<small className="form-hint">每个标签是一个键值对用逗号/分号/空格分隔双击右侧表格中的标签可编辑</small>
<small className="form-hint">格式key:value用分号或逗号分隔例如力量:80;敏捷:65,HP:100</small>
</div>
</div>
</div>
@@ -488,7 +670,7 @@ const CharacterCard = () => {
<div className="character-list">
{currentPageCharacters.length === 0 ? (
<div className="empty-message">
{filterTag ? '没有符合筛选的角色' : '暂无角色,请导入或创建'}
{filterTags.length > 0 ? '没有符合筛选的角色' : '暂无角色,请导入或创建'}
</div>
) : (
currentPageCharacters.map(char => (

View File

@@ -108,7 +108,7 @@
border: 1px solid var(--color-border);
border-radius: 6px;
box-shadow: var(--shadow-lg);
z-index: 100;
z-index: 1100; /* ✅ 高于 ChatBox (z-index: 1000) */
min-width: 140px;
padding: 4px;
}

View File

@@ -77,6 +77,7 @@ const PresetPanel = () => {
top_k: "随机采样范围从概率最高的K个词中选择",
max_ctx: "上下文窗口大小(Token上限)",
max_tokens: "单次回复的最大长度",
request_timeout: "LLM请求超时时间",
seed: "随机种子(-1为随机)",
n: "生成回复的数量"
};
@@ -90,6 +91,7 @@ const PresetPanel = () => {
top_k: "Top-K",
max_ctx: "上下文",
max_tokens: "MaxTok",
request_timeout: "超时",
seed: "种子",
n: "数量"
};
@@ -114,7 +116,7 @@ const PresetPanel = () => {
let convertedValue = value;
if (name === 'temperature' || name === 'freq_penalty' || name === 'pres_penalty' || name === 'top_p') {
convertedValue = parseFloat(value);
} else if (name === 'top_k' || name === 'max_ctx' || name === 'max_tokens' || name === 'seed' || name === 'n') {
} else if (name === 'top_k' || name === 'max_ctx' || name === 'max_tokens' || name === 'request_timeout' || name === 'seed' || name === 'n') {
convertedValue = parseInt(value, 10);
}
@@ -186,7 +188,8 @@ const PresetPanel = () => {
top_k: importedPreset.topK,
frequency_penalty: importedPreset.frequencyPenalty,
presence_penalty: importedPreset.presencePenalty,
max_tokens: importedPreset.maxLength
max_tokens: importedPreset.maxLength,
request_timeout: importedPreset.requestTimeout || 60
};
// 转换 entries 为 promptComponents 格式
@@ -213,7 +216,8 @@ const PresetPanel = () => {
presence_penalty: importedPreset.presence_penalty,
top_p: importedPreset.top_p,
top_k: importedPreset.top_k,
max_tokens: importedPreset.max_tokens || importedPreset.openai_max_tokens
max_tokens: importedPreset.max_tokens || importedPreset.openai_max_tokens,
request_timeout: importedPreset.request_timeout || 60
};
importedComponents = importedPreset.promptComponents || [];
}
@@ -261,6 +265,7 @@ const PresetPanel = () => {
frequencyPenalty: parameters.frequency_penalty,
presencePenalty: parameters.presence_penalty,
maxLength: parameters.max_tokens,
requestTimeout: parameters.request_timeout || 60,
isDefault: false,
// PromptPresetView 部分
@@ -744,6 +749,25 @@ const PresetPanel = () => {
/>
</div>
{/* 请求超时输入框 */}
<div className="parameter-row-compact">
<label
className="parameter-label-compact"
onMouseEnter={(e) => showTooltip(e, parameterDescriptions.request_timeout)}
onMouseLeave={hideTooltip}
>
{parameterLabels.request_timeout}
</label>
<input
type="number"
min="5"
max="600"
value={parameters.request_timeout || 60}
onChange={(e) => handleParameterChange('request_timeout', e.target.value)}
className="parameter-input-compact"
/>
</div>
{/* 随机种子输入框 */}
<div className="parameter-row-compact">
<label

View File

@@ -0,0 +1,230 @@
.settings-container {
display: flex;
flex-direction: column;
height: 100%;
padding: 16px;
background: var(--bg-primary);
color: var(--text-primary);
}
.settings-header {
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 2px solid var(--border-color);
}
.settings-header h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
/* 消息提示 */
.message {
padding: 12px;
margin-bottom: 16px;
border-radius: 4px;
font-size: 14px;
}
.message.success {
background: rgba(76, 175, 80, 0.1);
border: 1px solid #4caf50;
color: #4caf50;
}
.message.error {
background: rgba(244, 67, 54, 0.1);
border: 1px solid #f44336;
color: #f44336;
}
/* 分页标签 */
.settings-tabs {
display: flex;
gap: 8px;
margin-bottom: 16px;
border-bottom: 1px solid var(--border-color);
}
.tab-button {
padding: 8px 16px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-secondary);
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.tab-button:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.05);
}
.tab-button.active {
color: var(--accent-color);
border-bottom-color: var(--accent-color);
}
/* 内容区域 */
.settings-content {
flex: 1;
overflow-y: auto;
}
.loading {
text-align: center;
padding: 40px;
color: var(--text-secondary);
}
/* 设置区块 */
.settings-section {
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.settings-section h3 {
margin: 0 0 8px 0;
font-size: 16px;
font-weight: 600;
}
.section-description {
margin: 0 0 16px 0;
font-size: 13px;
color: var(--text-secondary);
}
/* 设置项 */
.setting-item {
margin-bottom: 20px;
padding: 16px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-color);
border-radius: 6px;
}
.setting-item label {
display: block;
margin-bottom: 8px;
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
.setting-item input[type="text"],
.setting-item input[type="file"] {
width: 100%;
padding: 8px 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-primary);
font-size: 14px;
box-sizing: border-box;
}
.setting-item input[type="text"]:focus {
outline: none;
border-color: var(--accent-color);
}
.setting-item small {
display: block;
margin-top: 6px;
font-size: 12px;
color: var(--text-secondary);
}
.setting-item code {
display: block;
padding: 8px 12px;
background: var(--bg-secondary);
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 13px;
color: var(--accent-color);
}
/* 文件结构列表 */
.file-structure {
margin: 8px 0 0 0;
padding-left: 20px;
list-style: none;
}
.file-structure li {
padding: 4px 0;
font-size: 13px;
color: var(--text-secondary);
}
/* 预览框 */
.preview-box {
margin-top: 8px;
padding: 12px;
background: var(--bg-secondary);
border-radius: 4px;
font-size: 13px;
}
.preview-box .original,
.preview-box .processed {
margin-bottom: 12px;
}
.preview-box .original:last-child,
.preview-box .processed:last-child {
margin-bottom: 0;
}
.preview-box strong {
display: block;
margin-bottom: 4px;
color: var(--text-primary);
}
.preview-box p {
margin: 0;
padding: 8px;
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 12px;
}
/* 按钮 */
.btn-primary {
padding: 10px 20px;
background: var(--accent-color);
border: none;
border-radius: 4px;
color: white;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
transform: translateY(-1px);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}

View File

@@ -0,0 +1,266 @@
import React, { useState, useEffect } from 'react';
import './Settings.css';
/**
* 系统设置组件
*
* 按功能分页展示:
* - 正则规则设置
* - 思考标签配置
* - 其他系统设置
*/
const Settings = () => {
const [activeTab, setActiveTab] = useState('regex');
const [settings, setSettings] = useState({
thinkingTagPrefix: '<thinking>',
thinkingTagSuffix: '</thinking>',
currentPresetName: null
});
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState({ type: '', text: '' });
// 加载系统设置
useEffect(() => {
loadSettings();
}, []);
const loadSettings = async () => {
try {
setLoading(true);
const response = await fetch('/api/regex/settings');
const data = await response.json();
if (data.success) {
setSettings(data.settings);
}
} catch (error) {
console.error('加载系统设置失败:', error);
showMessage('error', '加载设置失败');
} finally {
setLoading(false);
}
};
const saveSettings = async () => {
try {
setLoading(true);
const response = await fetch('/api/regex/settings', {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(settings)
});
const data = await response.json();
if (data.success) {
showMessage('success', '设置已保存');
} else {
showMessage('error', '保存失败');
}
} catch (error) {
console.error('保存设置失败:', error);
showMessage('error', '保存失败');
} finally {
setLoading(false);
}
};
const showMessage = (type, text) => {
setMessage({ type, text });
setTimeout(() => setMessage({ type: '', text: '' }), 3000);
};
const handleInputChange = (field, value) => {
setSettings(prev => ({
...prev,
[field]: value
}));
};
return (
<div className="settings-container">
{/* 标题 */}
<div className="settings-header">
<h2>系统设置</h2>
</div>
{/* 消息提示 */}
{message.text && (
<div className={`message ${message.type}`}>
{message.text}
</div>
)}
{/* 分页标签 */}
<div className="settings-tabs">
<button
className={`tab-button ${activeTab === 'regex' ? 'active' : ''}`}
onClick={() => setActiveTab('regex')}
>
正则规则
</button>
<button
className={`tab-button ${activeTab === 'thinking' ? 'active' : ''}`}
onClick={() => setActiveTab('thinking')}
>
思考标签
</button>
<button
className={`tab-button ${activeTab === 'general' ? 'active' : ''}`}
onClick={() => setActiveTab('general')}
>
通用设置
</button>
</div>
{/* 内容区域 */}
<div className="settings-content">
{loading && <div className="loading">加载中...</div>}
{/* 正则规则设置 */}
{activeTab === 'regex' && (
<div className="settings-section">
<h3>正则规则管理</h3>
<p className="section-description">
管理文本处理规则支持 SillyTavern 格式导入
</p>
<div className="setting-item">
<label>规则文件位置</label>
<code>data/regex/</code>
<ul className="file-structure">
<li>📁 global/ - 全局规则</li>
<li>📁 characters/ - 角色卡规则</li>
<li>📁 presets/ - 预设规则</li>
</ul>
</div>
<div className="setting-item">
<label>导入规则</label>
<input
type="file"
accept=".json"
onChange={async (e) => {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/regex/import', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
showMessage('success', data.message);
} else {
showMessage('error', '导入失败');
}
} catch (error) {
showMessage('error', '导入失败');
}
}}
/>
<small>支持 SillyTavern 格式的规则文件</small>
</div>
<div className="setting-item">
<button className="btn-primary" onClick={() => window.open('/api/regex/export/global', '_blank')}>
导出全局规则
</button>
</div>
</div>
)}
{/* 思考标签配置 */}
{activeTab === 'thinking' && (
<div className="settings-section">
<h3>思考标签配置</h3>
<p className="section-description">
配置 AI 推理/思考内容的标记标签
</p>
<div className="setting-item">
<label htmlFor="thinking-prefix">前缀</label>
<input
id="thinking-prefix"
type="text"
value={settings.thinkingTagPrefix}
onChange={(e) => handleInputChange('thinkingTagPrefix', e.target.value)}
placeholder="<thinking>"
/>
<small>默认值&lt;thinking&gt;</small>
</div>
<div className="setting-item">
<label htmlFor="thinking-suffix">后缀</label>
<input
id="thinking-suffix"
type="text"
value={settings.thinkingTagSuffix}
onChange={(e) => handleInputChange('thinkingTagSuffix', e.target.value)}
placeholder="</thinking>"
/>
<small>默认值&lt;/thinking&gt;</small>
</div>
<div className="setting-item preview">
<label>预览效果</label>
<div className="preview-box">
<div className="original">
<strong>原始内容</strong>
<p>这是回复 {settings.thinkingTagPrefix}思考过程{settings.thinkingTagSuffix} 继续</p>
</div>
<div className="processed">
<strong>处理后</strong>
<p>这是回复 继续</p>
</div>
</div>
</div>
<div className="setting-item">
<button className="btn-primary" onClick={saveSettings} disabled={loading}>
{loading ? '保存中...' : '保存设置'}
</button>
</div>
</div>
)}
{/* 通用设置 */}
{activeTab === 'general' && (
<div className="settings-section">
<h3>通用设置</h3>
<p className="section-description">
其他系统级配置
</p>
<div className="setting-item">
<label htmlFor="current-preset">当前预设</label>
<input
id="current-preset"
type="text"
value={settings.currentPresetName || ''}
onChange={(e) => handleInputChange('currentPresetName', e.target.value || null)}
placeholder="未选择"
/>
<small>影响全局正则规则的启用</small>
</div>
<div className="setting-item">
<button className="btn-primary" onClick={saveSettings} disabled={loading}>
{loading ? '保存中...' : '保存设置'}
</button>
</div>
</div>
)}
</div>
</div>
);
};
export default Settings;

View File

@@ -0,0 +1 @@
export { default } from './Settings';

View File

@@ -0,0 +1,207 @@
/* Token Usage Styles */
.token-usage-container {
padding: 16px;
height: 100%;
overflow-y: auto;
}
.token-usage-loading,
.token-usage-error,
.token-usage-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-secondary);
}
.token-usage-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.token-usage-header h3 {
margin: 0;
font-size: 18px;
color: var(--text-primary);
}
.month-selector {
padding: 6px 12px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 14px;
cursor: pointer;
}
.token-usage-filters {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.filter-select {
flex: 1;
padding: 6px 12px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
}
.btn-reset {
padding: 6px 12px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.btn-reset:hover {
background: var(--bg-hover);
}
/* Stats Cards */
.stats-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.stat-card {
padding: 12px;
border-radius: 8px;
text-align: center;
}
.stat-card.total {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.stat-card.prompt {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
}
.stat-card.completion {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
}
.stat-label {
font-size: 12px;
opacity: 0.9;
margin-bottom: 4px;
}
.stat-value {
font-size: 20px;
font-weight: bold;
}
/* Request Stats */
.request-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin-bottom: 16px;
padding: 12px;
background: var(--bg-secondary);
border-radius: 8px;
}
.stat-item {
display: flex;
justify-content: space-between;
font-size: 13px;
}
.stat-item.success {
color: #10b981;
}
.stat-item.interrupted {
color: #f59e0b;
}
.stat-item.failed {
color: #ef4444;
}
.stat-name {
font-weight: 500;
}
.stat-number {
font-weight: bold;
}
/* Sections */
.daily-stats-section,
.role-stats-section,
.chat-stats-section {
margin-bottom: 16px;
}
.daily-stats-section h4,
.role-stats-section h4,
.chat-stats-section h4 {
margin: 0 0 8px 0;
font-size: 15px;
color: var(--text-primary);
}
/* Lists */
.daily-stats-list,
.role-stats-list,
.chat-stats-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.daily-stat-item,
.role-stat-item,
.chat-stat-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: var(--bg-secondary);
border-radius: 6px;
font-size: 13px;
}
.day-label,
.role-name,
.chat-name {
flex: 1;
font-weight: 500;
color: var(--text-primary);
}
.day-tokens,
.role-tokens,
.chat-tokens {
margin: 0 12px;
color: var(--text-secondary);
font-size: 12px;
}
.day-count,
.role-count,
.chat-count {
color: var(--text-secondary);
font-size: 12px;
}

View File

@@ -0,0 +1,195 @@
// frontend/src/components/SideBarLeft/tabs/TokenUsage/TokenUsage.jsx
import React, { useEffect } from 'react';
import useTokenUsageStore from '../../../../Store/SideBarLeft/TokenUsageSlice';
import './TokenUsage.css';
const TokenUsage = () => {
const {
months,
currentMonth,
stats,
roles,
chats,
selectedRole,
selectedChat,
loading,
error,
fetchMonths,
setCurrentMonth,
setSelectedRole,
setSelectedChat,
resetFilters
} = useTokenUsageStore();
useEffect(() => {
fetchMonths();
}, []);
if (loading && !stats) {
return <div className="token-usage-loading">加载中...</div>;
}
if (error) {
return <div className="token-usage-error">错误: {error}</div>;
}
if (!stats) {
return <div className="token-usage-empty">暂无数据</div>;
}
// 格式化数字
const formatNumber = (num) => {
if (num >= 1000000) {
return (num / 1000000).toFixed(2) + 'M';
} else if (num >= 1000) {
return (num / 1000).toFixed(2) + 'K';
}
return num.toString();
};
return (
<div className="token-usage-container">
{/* 月份选择器 */}
<div className="token-usage-header">
<h3>📊 Token 使用统计</h3>
<select
className="month-selector"
value={currentMonth ? `${currentMonth.year}-${currentMonth.month}` : ''}
onChange={(e) => {
const [year, month] = e.target.value.split('-').map(Number);
setCurrentMonth(year, month);
}}
>
{months.map((m) => (
<option key={`${m.year}-${m.month}`} value={`${m.year}-${m.month}`}>
{m.year}{m.month}
</option>
))}
</select>
</div>
{/* 筛选器 */}
<div className="token-usage-filters">
<select
className="filter-select"
value={selectedRole || ''}
onChange={(e) => setSelectedRole(e.target.value || null)}
>
<option value="">所有角色</option>
{roles.map((role) => (
<option key={role} value={role}>{role}</option>
))}
</select>
<select
className="filter-select"
value={selectedChat || ''}
onChange={(e) => setSelectedChat(e.target.value || null)}
>
<option value="">所有聊天</option>
{chats.map((chat) => (
<option key={chat} value={chat}>{chat}</option>
))}
</select>
<button className="btn-reset" onClick={resetFilters}>
重置
</button>
</div>
{/* 统计卡片 */}
<div className="stats-cards">
<div className="stat-card total">
<div className="stat-label"> Token</div>
<div className="stat-value">{formatNumber(stats.totalTokens)}</div>
</div>
<div className="stat-card prompt">
<div className="stat-label">输入 Token</div>
<div className="stat-value">{formatNumber(stats.totalPromptTokens)}</div>
</div>
<div className="stat-card completion">
<div className="stat-label">输出 Token</div>
<div className="stat-value">{formatNumber(stats.totalCompletionTokens)}</div>
</div>
</div>
{/* 请求统计 */}
<div className="request-stats">
<div className="stat-item">
<span className="stat-name">总请求数:</span>
<span className="stat-number">{stats.totalRecords}</span>
</div>
<div className="stat-item success">
<span className="stat-name"> 成功:</span>
<span className="stat-number">{stats.completedCount}</span>
</div>
<div className="stat-item interrupted">
<span className="stat-name"> 中断:</span>
<span className="stat-number">{stats.interruptedCount}</span>
</div>
<div className="stat-item failed">
<span className="stat-name"> 失败:</span>
<span className="stat-number">{stats.failedCount}</span>
</div>
</div>
{/* 日统计 */}
{stats.dailyStats && Object.keys(stats.dailyStats).length > 0 && (
<div className="daily-stats-section">
<h4>📅 每日统计</h4>
<div className="daily-stats-list">
{Object.entries(stats.dailyStats)
.sort(([a], [b]) => b.localeCompare(a))
.slice(0, 10)
.map(([day, data]) => (
<div key={day} className="daily-stat-item">
<span className="day-label">{day}</span>
<span className="day-tokens">{formatNumber(data.totalTokens)} tokens</span>
<span className="day-count">{data.count} </span>
</div>
))}
</div>
</div>
)}
{/* 角色统计 */}
{stats.roleStats && Object.keys(stats.roleStats).length > 0 && (
<div className="role-stats-section">
<h4>👤 角色统计</h4>
<div className="role-stats-list">
{Object.entries(stats.roleStats)
.sort(([, a], [, b]) => b.totalTokens - a.totalTokens)
.map(([role, data]) => (
<div key={role} className="role-stat-item">
<span className="role-name">{role}</span>
<span className="role-tokens">{formatNumber(data.totalTokens)} tokens</span>
<span className="role-count">{data.count} </span>
</div>
))}
</div>
</div>
)}
{/* 聊天统计 */}
{stats.chatStats && Object.keys(stats.chatStats).length > 0 && (
<div className="chat-stats-section">
<h4>💬 聊天统计</h4>
<div className="chat-stats-list">
{Object.entries(stats.chatStats)
.sort(([, a], [, b]) => b.totalTokens - a.totalTokens)
.slice(0, 10)
.map(([chat, data]) => (
<div key={chat} className="chat-stat-item">
<span className="chat-name">{chat}</span>
<span className="chat-tokens">{formatNumber(data.totalTokens)} tokens</span>
<span className="chat-count">{data.count} </span>
</div>
))}
</div>
</div>
)}
</div>
);
};
export default TokenUsage;

View File

@@ -135,6 +135,10 @@
margin-top: 8px; /* 与全局世界书保持间距 */
position: relative; /* 确保正常文档流 */
z-index: 1; /* 降低层级,不遮挡其他元素 */
/* 最小高度 = header(33px) + actions(37px) + selector(37px) + entries最小内容(约200px) */
min-height: 307px;
display: flex;
flex-direction: column;
}
.worldbook-header {
@@ -183,6 +187,35 @@
transform: scale(0.98);
}
/* ✅ 排序按钮 - 仿照预设的操作按钮 */
.sort-toggle-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
border-radius: 4px;
color: var(--color-text-secondary);
cursor: pointer;
font-size: 14px;
padding: 0;
transition: all 0.15s ease;
flex-shrink: 0;
}
.sort-toggle-btn:hover {
background: var(--color-accent);
border-color: var(--color-accent);
color: white;
transform: translateY(-1px);
}
.sort-toggle-btn:active {
transform: scale(0.95) translateY(0);
}
/* 世界书选择区域 */
.worldbook-selector {
display: flex;
@@ -320,7 +353,7 @@
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
min-height: 65px;
min-height: 40px; /* ✅ 减小高度,单行显示 */
}
.entry-item-compact:hover {
@@ -334,17 +367,21 @@
border-left: 2px solid var(--color-accent);
}
/* 第一行:名称 + Token + 开关 */
.entry-row-top {
.entry-item-compact.disabled {
opacity: 0.6;
}
/* 单行布局容器 */
.entry-row-single {
display: flex;
align-items: center;
gap: 8px;
gap: 6px;
width: 100%;
overflow: hidden;
}
.entry-name-compact {
flex: 1;
flex: 1; /* ✅ 占据剩余空间 */
font-weight: 500;
font-size: 12px;
color: var(--color-text-primary);
@@ -355,28 +392,24 @@
min-width: 0;
}
.entry-tokens {
/* Token数量仅数字- 固定宽度容纳6位数字 */
.entry-tokens-inline {
font-size: 10px;
color: var(--color-text-muted);
font-weight: 500;
padding: 2px 6px;
font-weight: 600;
padding: 2px 5px;
background: var(--color-bg-tertiary);
border-radius: 8px;
border-radius: 3px;
white-space: nowrap;
flex-shrink: 0;
width: 45px; /* ✅ 固定宽度可容纳6位数字 */
text-align: right; /* ✅ 右对齐数字 */
}
/* 第二行:插入位置下拉框 */
.entry-row-bottom {
display: flex;
align-items: center;
width: 100%;
}
.position-select {
width: 100%;
padding: 3px 6px;
font-size: 11px;
/* 内联位置下拉框 - 固定在右侧 */
.position-select-inline {
padding: 2px 4px;
font-size: 10px;
color: var(--color-text-secondary);
background: var(--color-bg-primary);
border: 1px solid var(--color-border);
@@ -384,62 +417,66 @@
cursor: pointer;
outline: none;
transition: all 0.15s ease;
width: 70px; /* ✅ 去掉箭头后更窄 */
flex-shrink: 0;
appearance: none; /* ✅ 隐藏默认箭头 */
-webkit-appearance: none;
-moz-appearance: none;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
}
.position-select:hover {
.position-select-inline:hover {
border-color: var(--color-accent);
}
.position-select:focus {
.position-select-inline:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 2px var(--color-accent-light);
}
/* 开关组件 */
.toggle-switch {
position: relative;
display: inline-block;
width: 36px;
height: 20px;
cursor: pointer;
flex-shrink: 0;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--color-border);
border-radius: 20px;
transition: all 0.2s ease;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 14px;
width: 14px;
left: 3px;
bottom: 3px;
background-color: white;
/* 状态指示器 - 小圆点 - 固定在右侧(点击切换启用/禁用) */
.status-dot {
width: 12px; /* ✅ 稍微大一点,更容易点击 */
height: 12px;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
border: 2px solid rgba(255, 255, 255, 0.3); /* ✅ 更明显的边框 */
}
.toggle-switch input:checked + .toggle-slider {
background: var(--color-accent);
.status-dot.enabled {
background: #10b981; /* 绿色 - 启用 */
box-shadow: 0 0 8px rgba(16, 185, 129, 0.5); /* ✅ 更强的发光 */
}
.toggle-switch input:checked + .toggle-slider:before {
transform: translateX(16px);
.status-dot.enabled:hover {
background: #059669;
transform: scale(1.3); /* ✅ 更大的放大效果 */
box-shadow: 0 0 12px rgba(16, 185, 129, 0.7);
}
.status-dot.enabled:active {
transform: scale(1.1);
}
.status-dot.disabled {
background: #6b7280; /* 灰色 - 禁用 */
opacity: 0.4;
border-color: rgba(255, 255, 255, 0.2);
}
.status-dot.disabled:hover {
background: #4b5563;
opacity: 0.7;
transform: scale(1.3);
border-color: rgba(255, 255, 255, 0.4);
}
.status-dot.disabled:active {
transform: scale(1.1);
}
/* 分页导航 */
@@ -626,8 +663,8 @@
opacity: 0;
visibility: hidden;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
border-radius: 8px;
box-shadow: var(--shadow-xl);
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.1);
display: flex;
flex-direction: column;
}
@@ -642,48 +679,85 @@
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
padding: 20px 24px;
border-bottom: 1px solid var(--color-border);
background: var(--color-bg-secondary);
background: linear-gradient(135deg, var(--color-bg-secondary) 0%, var(--color-bg-tertiary) 100%);
flex-shrink: 0;
}
.edit-panel-header h2 {
font-size: 16px;
font-size: 18px;
color: var(--color-text-primary);
margin: 0;
font-weight: 600;
letter-spacing: 0.3px;
display: flex;
align-items: center;
gap: 12px;
}
.saving-indicator {
font-size: 12px;
color: var(--color-accent);
font-weight: 500;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
width: 32px;
height: 32px;
padding: 0;
background: transparent;
border: 1px solid var(--color-border);
border-radius: 4px;
border-radius: 6px;
color: var(--color-text-muted);
cursor: pointer;
font-size: 14px;
transition: all 0.15s ease;
font-size: 16px;
transition: all 0.2s ease;
}
.close-btn:hover {
background: var(--color-danger-light);
border-color: var(--color-danger);
color: var(--color-danger);
transform: rotate(90deg);
}
.edit-panel-content {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 16px;
gap: 20px;
}
/* 美化滚动条 */
.edit-panel-content::-webkit-scrollbar {
width: 8px;
}
.edit-panel-content::-webkit-scrollbar-track {
background: var(--color-bg-secondary);
border-radius: 4px;
}
.edit-panel-content::-webkit-scrollbar-thumb {
background: var(--color-scrollbar);
border-radius: 4px;
transition: all 0.2s ease;
}
.edit-panel-content::-webkit-scrollbar-thumb:hover {
background: var(--color-scrollbar-hover);
}
.form-row {
@@ -710,27 +784,33 @@
}
.form-input {
padding: 8px 10px;
padding: 10px 12px;
font-size: 13px;
border: 1px solid var(--color-border);
border-radius: 4px;
border-radius: 6px;
background: var(--color-bg-primary);
color: var(--color-text-primary);
transition: all 0.15s ease;
transition: all 0.2s ease;
}
.form-input:hover {
border-color: var(--color-accent-light);
}
.form-input:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 2px var(--color-accent-light);
box-shadow: 0 0 0 3px var(--color-accent-light);
transform: translateY(-1px);
}
.content-textarea {
flex: 1;
min-height: 200px;
min-height: 250px;
resize: vertical;
line-height: 1.6;
font-family: inherit;
line-height: 1.8;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 13px;
}
.trigger-config-row {
@@ -739,6 +819,34 @@
flex-wrap: wrap;
}
/* ✅ 编辑面板底部操作栏 */
.edit-panel-footer {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 24px;
border-top: 1px solid var(--color-border);
background: linear-gradient(135deg, var(--color-bg-secondary) 0%, var(--color-bg-tertiary) 100%);
flex-shrink: 0;
}
.edit-panel-footer .btn {
padding: 10px 20px;
font-size: 13px;
font-weight: 500;
border-radius: 6px;
transition: all 0.2s ease;
}
.edit-panel-footer .btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.edit-panel-footer .btn:active {
transform: translateY(0);
}
.trigger-selector {
flex: 1;
min-width: 200px;
@@ -816,3 +924,90 @@
border-color: var(--color-accent);
box-shadow: 0 0 0 2px var(--color-accent-light);
}
/* ✅ 排序下拉菜单 - 仿照预设的操作下拉菜单 */
.sort-dropdown-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
background: var(--color-bg-elevated);
border: 1px solid var(--color-border);
border-radius: 6px;
box-shadow: var(--shadow-lg);
z-index: 1100;
min-width: 160px;
padding: 4px;
}
.sort-section {
display: flex;
flex-direction: column;
gap: 2px;
}
.sort-label {
font-size: 10px;
color: var(--color-text-muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 4px 8px 2px;
}
.sort-option {
width: 100%;
padding: 8px 12px;
background: transparent;
border: none;
border-radius: 4px;
font-size: 12px;
color: var(--color-text-secondary);
cursor: pointer;
transition: all 0.15s ease;
text-align: left;
display: flex;
align-items: center;
gap: 8px;
}
.sort-option:hover {
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
.sort-option.active {
background: var(--color-accent-light);
color: var(--color-accent);
font-weight: 600;
}
.sort-divider {
height: 1px;
background: var(--color-border-light);
margin: 4px 0;
}
.sort-checkbox-label {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
font-size: 12px;
color: var(--color-text-secondary);
cursor: pointer;
transition: all 0.15s ease;
border-radius: 4px;
}
.sort-checkbox-label:hover {
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
.sort-checkbox-label input[type="checkbox"] {
width: 14px;
height: 14px;
cursor: pointer;
accent-color: var(--color-accent);
}

View File

@@ -68,10 +68,34 @@ const WorldBook = () => {
const [showWorldBookDropdown, setShowWorldBookDropdown] = useState(false);
const [activeTriggerStrategy, setActiveTriggerStrategy] = useState('constant');
// ✅ 性能优化使用本地状态暂存编辑内容避免频繁API调用
const [localEntry, setLocalEntry] = useState(null);
const saveTimeoutRef = React.useRef(null);
const [isSaving, setIsSaving] = useState(false); // ✅ 保存状态指示
// 分页状态
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(20); // 默认每页20条
// ✅ 排序状态
const [sortBy, setSortBy] = useState('order'); // 'name' | 'name_desc' | 'order'
const [activeFirst, setActiveFirst] = useState(false); // 激活条目优先
const [showSortMenu, setShowSortMenu] = useState(false); // 显示排序菜单
// ✅ 点击外部关闭排序下拉菜单
useEffect(() => {
const handleClickOutside = (event) => {
if (showSortMenu && !event.target.closest('.sort-settings-container')) {
setShowSortMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [showSortMenu]);
// 获取当前激活的分页
const { activeTab } = useSideBarLeftStore();
@@ -246,6 +270,23 @@ const WorldBook = () => {
setShowEditPanel(true);
};
// ✅ 处理条目快速更新(位置和启用状态)
const handleEntryUpdate = async (field, value) => {
if (!currentEntry || !currentWorldBook) return;
const updatedEntry = { ...currentEntry, [field]: value };
try {
await updateWorldBookEntry(currentWorldBook.name, currentEntry.uid, updatedEntry);
setCurrentEntry(updatedEntry);
// 更新成功后刷新当前页
await fetchWorldBookEntries(currentWorldBook.name, currentPage, pageSize);
console.log(`[WorldBook] ✅ 快速更新 ${field} 成功`);
} catch (err) {
console.error(`[WorldBook] ❌ 快速更新 ${field} 失败:`, err);
}
};
// 处理页码变化
const handlePageChange = async (newPage) => {
if (!currentWorldBook) return;
@@ -269,22 +310,41 @@ const WorldBook = () => {
}
};
const handleEntryUpdate = async (field, value) => {
if (!currentEntry || !currentWorldBook) return;
const updatedEntry = {...currentEntry, [field]: value};
try {
await updateWorldBookEntry(currentWorldBook.name, currentEntry.uid, updatedEntry);
setCurrentEntry(updatedEntry);
// 更新成功后刷新当前页
await fetchWorldBookEntries(currentWorldBook.name, currentPage, pageSize);
} catch (err) {
console.error('更新条目失败:', err);
// ✅ 性能优化防抖保存500ms后自动保存
const debouncedSave = React.useCallback((updatedEntry) => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
setIsSaving(true); // ✅ 显示保存中状态
saveTimeoutRef.current = setTimeout(async () => {
if (!currentWorldBook || !updatedEntry) return;
try {
await updateWorldBookEntry(currentWorldBook.name, updatedEntry.uid, updatedEntry);
setCurrentEntry(updatedEntry);
// 更新成功后刷新当前页
await fetchWorldBookEntries(currentWorldBook.name, currentPage, pageSize);
console.log('[WorldBook] ✅ 自动保存成功');
} catch (err) {
console.error('[WorldBook] ❌ 自动保存失败:', err);
} finally {
setIsSaving(false); // ✅ 隐藏保存中状态
}
}, 500); // 500ms 防抖
}, [currentWorldBook, currentPage, pageSize, updateWorldBookEntry, fetchWorldBookEntries]);
const handleLocalEntryChange = (field, value) => {
if (!localEntry) return;
const updatedEntry = { ...localEntry, [field]: value };
setLocalEntry(updatedEntry);
debouncedSave(updatedEntry); // ✅ 防抖保存
};
const handleTriggerStrategyChange = async (strategy) => {
if (!currentEntry || !currentWorldBook) return;
if (!localEntry || !currentWorldBook) return;
setActiveTriggerStrategy(strategy);
@@ -317,34 +377,30 @@ const WorldBook = () => {
}
const updatedTriggerConfig = {
...currentEntry.trigger_config,
...localEntry.trigger_config,
triggers: updatedTriggers
};
try {
await updateWorldBookEntry(currentWorldBook.name, currentEntry.uid, {
...currentEntry,
trigger_config: updatedTriggerConfig
});
setCurrentEntry({
...currentEntry,
trigger_config: updatedTriggerConfig
});
} catch (err) {
console.error('更新触发策略失败:', err);
}
const updatedEntry = {
...localEntry,
trigger_config: updatedTriggerConfig
};
setLocalEntry(updatedEntry);
debouncedSave(updatedEntry); // ✅ 防抖保存
};
const handleDeleteEntry = async () => {
if (!currentEntry || !currentWorldBook) return;
if (!localEntry || !currentWorldBook) return;
if (!window.confirm('确定要删除此条目吗?')) {
return;
}
try {
await deleteWorldBookEntry(currentWorldBook.name, currentEntry.uid);
await deleteWorldBookEntry(currentWorldBook.name, localEntry.uid);
setShowEditPanel(false);
setLocalEntry(null); // ✅ 清空本地状态
setCurrentEntry(null);
// 删除成功后刷新当前页
await fetchWorldBookEntries(currentWorldBook.name, currentPage, pageSize);
@@ -493,6 +549,51 @@ const WorldBook = () => {
return positions[position] || positions[0];
};
// ✅ 排序函数
const getSortedEntries = () => {
if (!currentEntries || currentEntries.length === 0) return [];
let sorted = [...currentEntries];
// 1. 先应用激活条目优先(独立于其他排序)
if (activeFirst) {
sorted.sort((a, b) => {
const aActive = !a.disable;
const bActive = !b.disable;
if (aActive && !bActive) return -1;
if (!aActive && bActive) return 1;
return 0;
});
}
// 2. 再应用主排序规则
switch (sortBy) {
case 'name':
// 文件名顺序按comment或key
sorted.sort((a, b) => {
const nameA = (a.comment || a.key?.join(',') || '').toLowerCase();
const nameB = (b.comment || b.key?.join(',') || '').toLowerCase();
return nameA.localeCompare(nameB, 'zh-CN');
});
break;
case 'name_desc':
// 文件名逆序
sorted.sort((a, b) => {
const nameA = (a.comment || a.key?.join(',') || '').toLowerCase();
const nameB = (b.comment || b.key?.join(',') || '').toLowerCase();
return nameB.localeCompare(nameA, 'zh-CN');
});
break;
case 'order':
default:
// 插入顺序按order字段
sorted.sort((a, b) => (a.order || 0) - (b.order || 0));
break;
}
return sorted;
};
return (
<div className="worldbook-content">
{/* 全局世界书区域 */}
@@ -543,6 +644,68 @@ const WorldBook = () => {
📤 导出
</button>
)}
{/* ✅ 排序设置按钮 - 仿照预设的操作下拉菜单 */}
{currentWorldBook && (
<div className="sort-settings-container" style={{position: 'relative', marginLeft: 'auto'}}>
<button
className="sort-toggle-btn"
onClick={() => setShowSortMenu(!showSortMenu)}
title="排序设置"
>
🔀
</button>
{/* 排序下拉菜单 */}
{showSortMenu && (
<div className="sort-dropdown-menu">
<div className="sort-section">
<div className="sort-label">主排序</div>
<button
className={`sort-option ${sortBy === 'order' ? 'active' : ''}`}
onClick={() => {
setSortBy('order');
setShowSortMenu(false);
}}
>
📋 插入顺序
</button>
<button
className={`sort-option ${sortBy === 'name' ? 'active' : ''}`}
onClick={() => {
setSortBy('name');
setShowSortMenu(false);
}}
>
🔤 名称升序
</button>
<button
className={`sort-option ${sortBy === 'name_desc' ? 'active' : ''}`}
onClick={() => {
setSortBy('name_desc');
setShowSortMenu(false);
}}
>
🔤 名称降序
</button>
</div>
<div className="sort-divider"></div>
<div className="sort-section">
<label className="sort-checkbox-label">
<input
type="checkbox"
checked={activeFirst}
onChange={(e) => setActiveFirst(e.target.checked)}
/>
<span>🟢 激活条目优先</span>
</label>
</div>
</div>
)}
</div>
)}
</div>
{/* 世界书选择区域 */}
@@ -598,8 +761,8 @@ const WorldBook = () => {
<div className="error">{error}</div>
) : currentWorldBook ? (
<div className="entries-container">
{currentEntries.length > 0 ? (
currentEntries.map(entry => {
{getSortedEntries().length > 0 ? (
getSortedEntries().map(entry => {
// 计算 token 数(估算)
const tokenCount = Math.ceil((entry.content?.length || 0) / 4);
@@ -610,41 +773,25 @@ const WorldBook = () => {
return (
<div
key={entry.uid}
className={`entry-item-compact ${currentEntry?.uid === entry.uid ? 'active' : ''}`}
className={`entry-item-compact ${currentEntry?.uid === entry.uid ? 'active' : ''} ${isDisabled ? 'disabled' : ''}`}
onClick={() => handleEntryClick(entry)}
>
{/* 第一行:条目名称 + Token数 + 开关 */}
<div className="entry-row-top">
{/* 单行布局:名称 + Token数 + 位置下拉框 + 状态指示器 */}
<div className="entry-row-single">
<span className="entry-name-compact">
{entry.comment || entry.key?.join(', ') || `条目 #${entry.uid.substring(0, 8)}`}
</span>
<span className="entry-tokens">
{tokenCount} tokens
{/* Token数量仅数字 */}
<span className="entry-tokens-inline">
{tokenCount}
</span>
{/* 启用开关 */}
<label
className="toggle-switch"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={isEnabled}
onChange={(e) => {
e.stopPropagation();
handleEntryUpdate('disable', !e.target.checked);
}}
/>
<span className="toggle-slider"></span>
</label>
</div>
{/* 第二行:插入位置下拉框 */}
<div className="entry-row-bottom" onClick={(e) => e.stopPropagation()}>
{/* 插入位置下拉框 */}
<select
className="position-select"
className="position-select-inline"
value={entry.position || 0}
onClick={(e) => e.stopPropagation()}
onChange={(e) => {
e.stopPropagation();
handleEntryUpdate('position', parseInt(e.target.value));
@@ -659,6 +806,16 @@ const WorldBook = () => {
);
})}
</select>
{/* 启用状态指示器 - 小圆点 */}
<div
className={`status-dot ${isEnabled ? 'enabled' : 'disabled'}`}
title={isEnabled ? '已启用' : '已禁用'}
onClick={(e) => {
e.stopPropagation();
handleEntryUpdate('disable', !isEnabled);
}}
/>
</div>
</div>
);
@@ -717,11 +874,11 @@ const WorldBook = () => {
onClick={() => setShowEditPanel(false)}/>
{/* 编辑面板 */}
{showEditPanel && currentEntry && (
{showEditPanel && localEntry && (
<div className={`edit-panel ${showEditPanel ? 'open' : ''}`}>
{/* 头部 */}
<div className="edit-panel-header">
<h2>编辑条目 - UID: {currentEntry.uid}</h2>
<h2>编辑条目 - UID: {localEntry.uid} {isSaving && <span className="saving-indicator">💾 保存中...</span>}</h2>
<button className="close-btn" onClick={() => setShowEditPanel(false)}>
</button>
@@ -736,8 +893,8 @@ const WorldBook = () => {
<input
type="text"
className="form-input"
value={currentEntry.comment || ''}
onChange={(e) => handleEntryUpdate('comment', e.target.value)}
value={localEntry.comment || ''}
onChange={(e) => handleLocalEntryChange('comment', e.target.value)}
placeholder="可选的备注名称"
/>
</div>
@@ -746,8 +903,8 @@ const WorldBook = () => {
<label className="form-label">插入位置</label>
<select
className="form-input"
value={currentEntry.position || 0}
onChange={(e) => handleEntryUpdate('position', parseInt(e.target.value))}
value={localEntry.position || 0}
onChange={(e) => handleLocalEntryChange('position', parseInt(e.target.value))}
>
{[0, 1, 2, 3, 4, 5].map(pos => {
const info = getPositionInfo(pos);
@@ -765,8 +922,8 @@ const WorldBook = () => {
<input
type="number"
className="form-input"
value={currentEntry.order || 100}
onChange={(e) => handleEntryUpdate('order', parseInt(e.target.value))}
value={localEntry.order || 100}
onChange={(e) => handleLocalEntryChange('order', parseInt(e.target.value))}
/>
</div>
@@ -775,8 +932,8 @@ const WorldBook = () => {
<input
type="number"
className="form-input"
value={currentEntry.depth || 4}
onChange={(e) => handleEntryUpdate('depth', parseInt(e.target.value))}
value={localEntry.depth || 4}
onChange={(e) => handleLocalEntryChange('depth', parseInt(e.target.value))}
/>
</div>
</div>
@@ -786,8 +943,8 @@ const WorldBook = () => {
<label className="form-label">条目内容</label>
<textarea
className="form-input content-textarea"
value={currentEntry.content || ''}
onChange={(e) => handleEntryUpdate('content', e.target.value)}
value={localEntry.content || ''}
onChange={(e) => handleLocalEntryChange('content', e.target.value)}
placeholder="在此输入世界书条目的内容..."
/>
</div>
@@ -818,22 +975,22 @@ const WorldBook = () => {
<input
type="text"
className="form-input"
value={currentEntry.trigger_config?.triggers?.keyword?.[1]?.key?.join(', ') || ''}
value={localEntry.trigger_config?.triggers?.keyword?.[1]?.key?.join(', ') || ''}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
...localEntry.trigger_config,
triggers: {
...currentEntry.trigger_config?.triggers,
...localEntry.trigger_config?.triggers,
keyword: [
true,
{
...currentEntry.trigger_config?.triggers?.keyword?.[1],
...localEntry.trigger_config?.triggers?.keyword?.[1],
key: e.target.value.split(',').map(k => k.trim()).filter(k => k)
}
]
}
};
handleEntryUpdate('trigger_config', updatedTriggerConfig);
handleLocalEntryChange('trigger_config', updatedTriggerConfig);
}}
placeholder="例如:魔法, 火焰, 冰霜"
/>
@@ -844,22 +1001,22 @@ const WorldBook = () => {
<input
type="text"
className="form-input"
value={currentEntry.trigger_config?.triggers?.keyword?.[1]?.keysecondary?.join(', ') || ''}
value={localEntry.trigger_config?.triggers?.keyword?.[1]?.keysecondary?.join(', ') || ''}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
...localEntry.trigger_config,
triggers: {
...currentEntry.trigger_config?.triggers,
...localEntry.trigger_config?.triggers,
keyword: [
true,
{
...currentEntry.trigger_config?.triggers?.keyword?.[1],
...localEntry.trigger_config?.triggers?.keyword?.[1],
keysecondary: e.target.value.split(',').map(k => k.trim()).filter(k => k)
}
]
}
};
handleEntryUpdate('trigger_config', updatedTriggerConfig);
handleLocalEntryChange('trigger_config', updatedTriggerConfig);
}}
placeholder="可选的过滤关键词"
/>
@@ -869,22 +1026,22 @@ const WorldBook = () => {
<label className="form-label">
<input
type="checkbox"
checked={currentEntry.trigger_config?.triggers?.keyword?.[1]?.selective || false}
checked={localEntry.trigger_config?.triggers?.keyword?.[1]?.selective || false}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
...localEntry.trigger_config,
triggers: {
...currentEntry.trigger_config?.triggers,
...localEntry.trigger_config?.triggers,
keyword: [
true,
{
...currentEntry.trigger_config?.triggers?.keyword?.[1],
...localEntry.trigger_config?.triggers?.keyword?.[1],
selective: e.target.checked
}
]
}
};
handleEntryUpdate('trigger_config', updatedTriggerConfig);
handleLocalEntryChange('trigger_config', updatedTriggerConfig);
}}
/>
选择性匹配
@@ -893,22 +1050,22 @@ const WorldBook = () => {
<label className="form-label">
<input
type="checkbox"
checked={currentEntry.trigger_config?.triggers?.keyword?.[1]?.matchWholeWords || false}
checked={localEntry.trigger_config?.triggers?.keyword?.[1]?.matchWholeWords || false}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
...localEntry.trigger_config,
triggers: {
...currentEntry.trigger_config?.triggers,
...localEntry.trigger_config?.triggers,
keyword: [
true,
{
...currentEntry.trigger_config?.triggers?.keyword?.[1],
...localEntry.trigger_config?.triggers?.keyword?.[1],
matchWholeWords: e.target.checked
}
]
}
};
handleEntryUpdate('trigger_config', updatedTriggerConfig);
handleLocalEntryChange('trigger_config', updatedTriggerConfig);
}}
/>
全词匹配
@@ -917,22 +1074,22 @@ const WorldBook = () => {
<label className="form-label">
<input
type="checkbox"
checked={currentEntry.trigger_config?.triggers?.keyword?.[1]?.caseSensitive || false}
checked={localEntry.trigger_config?.triggers?.keyword?.[1]?.caseSensitive || false}
onChange={(e) => {
const updatedTriggerConfig = {
...currentEntry.trigger_config,
...localEntry.trigger_config,
triggers: {
...currentEntry.trigger_config?.triggers,
...localEntry.trigger_config?.triggers,
keyword: [
true,
{
...currentEntry.trigger_config?.triggers?.keyword?.[1],
...localEntry.trigger_config?.triggers?.keyword?.[1],
caseSensitive: e.target.checked
}
]
}
};
handleEntryUpdate('trigger_config', updatedTriggerConfig);
handleLocalEntryChange('trigger_config', updatedTriggerConfig);
}}
/>
区分大小写

View File

@@ -53,6 +53,26 @@
width: 100%;
height: 100%;
position: relative;
padding: 0; /* ✅ 移除padding滚动条作为间隔 */
}
/* ✅ 美化滚动条 - 符合主题 */
.sidebar-content::-webkit-scrollbar {
width: 8px;
}
.sidebar-content::-webkit-scrollbar-track {
background: transparent;
}
.sidebar-content::-webkit-scrollbar-thumb {
background: var(--color-scrollbar);
border-radius: 4px;
transition: all 0.2s ease;
}
.sidebar-content::-webkit-scrollbar-thumb:hover {
background: var(--color-scrollbar-hover);
}
.panel-section {
@@ -73,7 +93,6 @@
.tab-content {
overflow-y: auto;
padding: var(--spacing-lg);
}
/* 分割线样式 */
@@ -121,7 +140,6 @@
.split-top,
.split-bottom {
overflow-y: auto;
padding: var(--spacing-lg);
}
/* 未选择分页提示 */

View File

@@ -1,10 +1,11 @@
import React, { useEffect, useState, useRef } from 'react';
import './SideBarRight.css';
import Dice from './tabs/Dice';
import Debug from './tabs/Debug';
import Macros from './tabs/Macros';
import Table from './tabs/Table';
import RagRecall from './tabs/RagRecall';
import WorldBookActive from './tabs/WorldBookActive'; // ✅ 世界书激活显示
import Tasks from './tabs/Tasks'; // ✅ 任务队列分页
import useSideBarRightStore from '../../Store/SideBarRight/SideBarRightSlice';
const SideBarRight = () => {
@@ -22,10 +23,11 @@ const SideBarRight = () => {
// 设置标签组件
useEffect(() => {
setTabComponent('dice', Dice);
setTabComponent('debug', Debug);
setTabComponent('macros', Macros);
setTabComponent('table', Table);
setTabComponent('rag', RagRecall);
setTabComponent('worldbook_active', WorldBookActive); // ✅ 注册世界书激活组件
setTabComponent('tasks', Tasks); // ✅ 注册任务队列组件
}, [setTabComponent]);
// 保存分割线位置到 localStorage
@@ -100,7 +102,7 @@ const SideBarRight = () => {
<div className="tab-content full-height">
{(() => {
const tab = allTabs.find(t => t.id === selectedTabs[0]);
return tab.component ? <tab.component /> : <div>{tab.label}内容</div>;
return tab && tab.component ? <tab.component /> : <div>{tab?.label || '未知'}内容</div>;
})()}
</div>
) : (
@@ -112,7 +114,7 @@ const SideBarRight = () => {
>
{(() => {
const tab = allTabs.find(t => t.id === selectedTabs[0]);
return tab.component ? <tab.component /> : <div>{tab.label}内容</div>;
return tab && tab.component ? <tab.component /> : <div>{tab?.label || '未知'}内容</div>;
})()}
</div>
@@ -130,7 +132,7 @@ const SideBarRight = () => {
>
{(() => {
const tab = allTabs.find(t => t.id === selectedTabs[1]);
return tab.component ? <tab.component /> : <div>{tab.label}内容</div>;
return tab && tab.component ? <tab.component /> : <div>{tab?.label || '未知'}内容</div>;
})()}
</div>
</>

View File

@@ -1,12 +0,0 @@
import React from 'react';
const Debug = () => {
return (
<div className="debug-panel">
<h2>上下文调试</h2>
<p>这是上下文调试面板的占位页面</p>
</div>
);
};
export default Debug;

View File

@@ -1 +0,0 @@
export { default } from './Debug';

View File

@@ -0,0 +1,152 @@
.tasks-content {
display: flex;
flex-direction: column;
height: 100%;
background: var(--color-bg-secondary);
}
.tab-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
background: var(--color-bg-tertiary);
border-bottom: 1px solid var(--color-border);
min-height: 36px;
}
.title-text {
font-size: 13px;
font-weight: 600;
color: var(--color-text-primary);
}
.task-count {
font-size: 11px;
color: var(--color-text-muted);
background: var(--color-bg-secondary);
padding: 2px 6px;
border-radius: 10px;
min-width: 20px;
text-align: center;
}
.btn-clear {
background: none;
border: none;
cursor: pointer;
font-size: 14px;
padding: 2px 6px;
border-radius: 4px;
transition: all 0.2s;
opacity: 0.6;
}
.btn-clear:hover {
opacity: 1;
background: var(--color-bg-hover);
}
.empty-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-muted);
font-size: 12px;
}
.tasks-list {
flex: 1;
overflow-y: auto;
padding: 6px;
display: flex;
flex-direction: column;
gap: 4px;
}
.task-item {
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border-light);
border-radius: 4px;
padding: 6px 8px;
transition: all 0.2s;
}
.task-item:hover {
background: var(--color-bg-hover);
border-color: var(--color-border);
}
.task-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.task-icon {
font-size: 14px;
line-height: 1;
}
.task-name {
flex: 1;
font-size: 12px;
color: var(--color-text-primary);
font-weight: 500;
}
.task-status-badge {
font-size: 10px;
white-space: nowrap;
}
.status-pending {
color: var(--color-text-muted);
}
.status-running {
color: var(--color-primary);
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.status-completed {
color: var(--color-success);
}
.status-failed {
color: var(--color-error);
}
.status-cancelled {
color: var(--color-text-muted);
}
.task-actions {
display: flex;
justify-content: flex-end;
margin-top: 4px;
}
.btn-cancel-task {
font-size: 10px;
padding: 2px 8px;
background: var(--color-error);
color: white;
border: none;
border-radius: 3px;
cursor: pointer;
transition: all 0.2s;
opacity: 0.9;
}
.btn-cancel-task:hover {
opacity: 1;
transform: scale(1.05);
}

View File

@@ -0,0 +1,73 @@
// frontend/src/components/SideBarRight/tabs/Tasks/Tasks.jsx
import React from 'react';
import './Tasks.css';
import useTasksStore from '../../../../Store/SideBarRight/TasksSlice';
const Tasks = () => {
const { tasks, clearCompleted, cancelTask } = useTasksStore();
if (tasks.length === 0) {
return (
<div className="tasks-content">
<div className="tab-header">
<span className="title-text">任务队列</span>
<span className="task-count">0</span>
</div>
<div className="empty-state">
<p>暂无进行中的任务</p>
</div>
</div>
);
}
return (
<div className="tasks-content">
<div className="tab-header">
<span className="title-text">任务队列</span>
<span className="task-count">{tasks.length}</span>
<button
className="btn-clear"
onClick={clearCompleted}
title="清理已完成任务"
>
🗑
</button>
</div>
<div className="tasks-list">
{tasks.map(task => (
<div key={task.taskId} className={`task-item ${task.status}`}>
<div className="task-header">
<span className="task-icon">
{task.taskType === 'image_workflow' ? '🖼️' : '📊'}
</span>
<span className="task-name">
{task.taskType === 'image_workflow' ? '生图任务' : '表格维护'}
</span>
<span className="task-status-badge">
{task.status === 'pending' && <span className="status-pending"> 等待</span>}
{task.status === 'running' && <span className="status-running"> 进行中</span>}
{task.status === 'completed' && <span className="status-completed"> 完成</span>}
{task.status === 'failed' && <span className="status-failed"> 失败</span>}
{task.status === 'cancelled' && <span className="status-cancelled"> 取消</span>}
</span>
</div>
{task.status === 'running' && (
<div className="task-actions">
<button
className="btn-cancel-task"
onClick={() => cancelTask(task.taskId)}
>
终止
</button>
</div>
)}
</div>
))}
</div>
</div>
);
};
export default Tasks;

View File

@@ -0,0 +1 @@
export { default } from './Tasks';

View File

@@ -0,0 +1,152 @@
.worldbook-active-content {
display: flex;
flex-direction: column;
height: 100%;
background: var(--color-bg-secondary);
}
.tab-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
background: var(--color-bg-tertiary);
border-bottom: 1px solid var(--color-border);
min-height: 36px;
}
.title-text {
font-size: 13px;
font-weight: 600;
color: var(--color-text-primary);
}
.entry-count {
font-size: 11px;
color: var(--color-text-muted);
background: var(--color-bg-secondary);
padding: 2px 6px;
border-radius: 10px;
min-width: 20px;
text-align: center;
}
.btn-clear {
background: none;
border: none;
cursor: pointer;
font-size: 14px;
padding: 2px 6px;
border-radius: 4px;
transition: all 0.2s;
opacity: 0.6;
}
.btn-clear:hover {
opacity: 1;
background: var(--color-bg-hover);
}
.empty-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-muted);
font-size: 12px;
}
.active-entries-list {
flex: 1;
overflow-y: auto;
padding: 6px;
display: flex;
flex-direction: column;
gap: 8px;
}
.position-group {
margin-bottom: 4px;
}
.position-label {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 6px;
background: var(--color-bg-tertiary);
border-radius: 3px;
margin-bottom: 4px;
font-size: 11px;
}
.position-number {
font-weight: 600;
color: var(--color-primary);
}
.position-name {
flex: 1;
color: var(--color-text-primary);
}
.position-count {
color: var(--color-text-muted);
font-size: 10px;
}
.active-entry-item {
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border-light);
border-radius: 4px;
padding: 6px 8px;
margin-bottom: 4px;
transition: all 0.2s;
}
.active-entry-item:hover {
background: var(--color-bg-hover);
border-color: var(--color-border);
}
.entry-header {
display: flex;
justify-content: space-between;
margin-bottom: 4px;
font-size: 10px;
}
.entry-uid {
color: var(--color-text-muted);
font-family: monospace;
}
.entry-depth {
color: var(--color-accent);
}
.entry-keywords {
margin-bottom: 4px;
}
.keyword-tag {
display: inline-block;
font-size: 10px;
padding: 2px 6px;
background: rgba(var(--color-primary-rgb), 0.1);
border: 1px solid var(--color-primary);
border-radius: 3px;
color: var(--color-primary);
}
.trigger-type {
font-size: 10px;
color: var(--color-text-muted);
}
.entry-preview {
font-size: 11px;
color: var(--color-text-secondary);
line-height: 1.5;
word-break: break-word;
}

View File

@@ -0,0 +1,101 @@
// frontend-react/src/components/SideBarRight/tabs/WorldBookActive/WorldBookActive.jsx
import React from 'react';
import './WorldBookActive.css';
import useWorldBookActiveStore from '../../../../Store/SideBarRight/WorldBookActiveSlice';
const WorldBookActive = () => {
const { activeEntries, clearEntries } = useWorldBookActiveStore();
if (activeEntries.length === 0) {
return (
<div className="worldbook-active-content">
<div className="tab-header">
<span className="title-text">世界书激活</span>
<span className="entry-count">0</span>
</div>
<div className="empty-state">
<p>暂无激活的世界书条目</p>
</div>
</div>
);
}
// 按位置分组显示
const groupedEntries = activeEntries.reduce((acc, entry) => {
const pos = entry.position || 0;
if (!acc[pos]) {
acc[pos] = [];
}
acc[pos].push(entry);
return acc;
}, {});
// 位置标签映射
const positionLabels = {
0: '世界书之前',
1: '世界书之后',
2: '示例对话之前',
3: '示例对话之后',
4: '作者注释(顶部)',
5: '作者注释(底部)',
6: '深度插入',
7: '宏替换'
};
return (
<div className="worldbook-active-content">
{/* 标题栏 */}
<div className="tab-header">
<span className="title-text">世界书激活</span>
<span className="entry-count">{activeEntries.length}</span>
<button
className="btn-clear"
onClick={clearEntries}
title="清空显示"
>
🗑
</button>
</div>
{/* 激活条目列表 */}
<div className="active-entries-list">
{Object.entries(groupedEntries).map(([position, entries]) => (
<div key={position} className="position-group">
<div className="position-label">
<span className="position-number">Pos {position}</span>
<span className="position-name">{positionLabels[position] || `位置 ${position}`}</span>
<span className="position-count">{entries.length} </span>
</div>
{entries.map((entry, index) => (
<div key={`${entry.uid}-${index}`} className="active-entry-item">
<div className="entry-header">
<span className="entry-uid">UID: {entry.uid}</span>
<span className="entry-depth">Depth: {entry.depth || 0}</span>
</div>
<div className="entry-keywords">
{entry.triggerKeywords && entry.triggerKeywords.length > 0 ? (
<span className="keyword-tag">
🔑 {entry.triggerKeywords.join(', ')}
</span>
) : (
<span className="trigger-type">
{entry.triggerType === 'constant' ? '📌 常驻' :
entry.triggerType === 'condition' ? '⚙️ 条件' : '❓ 未知'}
</span>
)}
</div>
<div className="entry-preview">
{entry.contentPreview || entry.content?.substring(0, 100) || '(无内容)'}
{entry.content?.length > 100 ? '...' : ''}
</div>
</div>
))}
</div>
))}
</div>
</div>
);
};
export default WorldBookActive;

View File

@@ -0,0 +1 @@
export { default } from './WorldBookActive';

View File

@@ -330,3 +330,160 @@
display: flex;
overflow: hidden;
}
/* ==================== 设置面板专用样式 ==================== */
.settings-panel .panel-body {
overflow-y: auto;
max-height: calc(100vh - 200px);
}
.settings-tabs {
display: flex;
gap: 8px;
padding: 0 16px;
border-bottom: 1px solid var(--color-border);
}
.settings-tabs .tab-button {
padding: 8px 16px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--color-text-secondary);
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.settings-tabs .tab-button:hover {
color: var(--color-text-primary);
background-color: var(--color-bg-tertiary);
}
.settings-tabs .tab-button.active {
color: white;
border-bottom-color: var(--color-accent);
background-color: var(--color-accent);
}
.settings-section {
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.message {
padding: 12px;
margin-bottom: 16px;
border-radius: 4px;
font-size: 14px;
}
.message.success {
background: rgba(76, 175, 80, 0.1);
border: 1px solid #4caf50;
color: #4caf50;
}
.message.error {
background: rgba(244, 67, 54, 0.1);
border: 1px solid #f44336;
color: #f44336;
}
.btn-primary {
padding: 10px 20px;
background: var(--color-accent);
border: none;
border-radius: 4px;
color: white;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary:hover:not(:disabled) {
background: var(--color-accent-hover, #0056b3);
transform: translateY(-1px);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 设置面板输入框 */
.settings-input {
width: 100%;
padding: 8px 12px;
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: 4px;
color: var(--color-text-primary);
font-size: 14px;
box-sizing: border-box;
transition: all 0.2s;
}
.settings-input:focus {
outline: none;
border-color: var(--color-accent);
background: var(--color-bg-secondary);
}
.settings-input::placeholder {
color: var(--color-text-muted);
}
/* 设置面板代码块 */
.settings-code {
display: block;
padding: 8px 12px;
background: var(--color-bg-tertiary);
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 13px;
color: var(--color-accent);
margin-bottom: 8px;
}
/* 设置面板文件列表 */
.settings-file-list {
margin: 0;
padding-left: 20px;
list-style: none;
font-size: 13px;
color: var(--color-text-secondary);
}
.settings-file-list li {
padding: 4px 0;
}
/* 设置面板提示文字 */
.settings-hint {
display: block;
margin-top: 6px;
font-size: 12px;
color: var(--color-text-muted);
}
/* 设置面板预览框 */
.preview-box {
padding: 12px;
background: var(--color-bg-tertiary);
border-radius: 4px;
font-size: 13px;
margin-bottom: 16px;
}

View File

@@ -29,6 +29,16 @@ const Toolbar = () => {
const { activeMap, fetchProfile } = useApiConfigStore();
const [coreModel, setCoreModel] = useState('未设置');
const [assistModel, setAssistModel] = useState('未设置');
// 系统设置状态
const [settings, setSettings] = useState({
thinkingTagPrefix: '<thinking>',
thinkingTagSuffix: '</thinking>',
currentPresetName: null
});
const [settingsLoading, setSettingsLoading] = useState(false);
const [settingsMessage, setSettingsMessage] = useState({ type: '', text: '' });
const [settingsActiveTab, setSettingsActiveTab] = useState('display'); // 默认显示分页
// 点击外部关闭面板 - 使用 useCallback 优化
const handleClickOutside = React.useCallback((event) => {
@@ -43,6 +53,68 @@ const Toolbar = () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [handleClickOutside]);
// 加载系统设置
useEffect(() => {
if (activePanel === 'settings') {
loadSettings();
}
}, [activePanel]);
const loadSettings = async () => {
try {
setSettingsLoading(true);
const response = await fetch('/api/regex/settings');
const data = await response.json();
if (data.success) {
setSettings(data.settings);
}
} catch (error) {
console.error('加载系统设置失败:', error);
showSettingsMessage('error', '加载设置失败');
} finally {
setSettingsLoading(false);
}
};
const saveSettings = async () => {
try {
setSettingsLoading(true);
const response = await fetch('/api/regex/settings', {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(settings)
});
const data = await response.json();
if (data.success) {
showSettingsMessage('success', '设置已保存');
} else {
showSettingsMessage('error', '保存失败');
}
} catch (error) {
console.error('保存设置失败:', error);
showSettingsMessage('error', '保存失败');
} finally {
setSettingsLoading(false);
}
};
const showSettingsMessage = (type, text) => {
setSettingsMessage({ type, text });
setTimeout(() => setSettingsMessage({ type: '', text: '' }), 3000);
};
const handleSettingsInputChange = (field, value) => {
setSettings(prev => ({
...prev,
[field]: value
}));
};
// ✅ 简化:直接调用 store 方法
const handleSidebarModeChange = (mode) => {
@@ -170,55 +242,231 @@ const Toolbar = () => {
{/* 设置面板 - 使用条件渲染优化 */}
{activePanel === 'settings' && (
<div className="panel-overlay" ref={panelRef}>
<div className="panel-content">
<div className="panel-content settings-panel">
<div className="panel-header">
<h3>系统设置</h3>
<button className="close-panel-button" onClick={handleClosePanel} title="关闭">
</button>
</div>
<div className="panel-body">
{/* 左侧栏模式 */}
<div className="setting-section">
<h4>左侧栏模式</h4>
<div className="setting-options">
<label className={`setting-option ${sidebarMode === 'fixed' ? 'active' : ''}`}>
<input
type="radio"
name="sidebarMode"
value="fixed"
checked={sidebarMode === 'fixed'}
onChange={(e) => handleSidebarModeChange(e.target.value)} // 使用 store 方法
/>
<span>固定</span>
</label>
<label className={`setting-option ${sidebarMode === 'smart' ? 'active' : ''}`}>
<input
type="radio"
name="sidebarMode"
value="smart"
checked={sidebarMode === 'smart'}
onChange={(e) => handleSidebarModeChange(e.target.value)} // 使用 store 方法
/>
<span>智能</span>
</label>
<label className={`setting-option ${sidebarMode === 'expanded' ? 'active' : ''}`}>
<input
type="radio"
name="sidebarMode"
value="expanded"
checked={sidebarMode === 'expanded'}
onChange={(e) => handleSidebarModeChange(e.target.value)} // 使用 store 方法
/>
<span>扩展</span>
</label>
</div>
<p className="setting-description">
{sidebarMode === 'fixed' && '左侧栏保持默认宽度,不响应交互'}
{sidebarMode === 'smart' && '鼠标悬停时自动展开,移开后收起'}
{sidebarMode === 'expanded' && '左侧栏保持最大宽度'}
</p>
{/* 消息提示 */}
{settingsMessage.text && (
<div className={`message ${settingsMessage.type}`} style={{ margin: '10px', padding: '8px', borderRadius: '4px' }}>
{settingsMessage.text}
</div>
)}
{/* 分页标签 */}
<div className="settings-tabs">
<button
className={`tab-button ${settingsActiveTab === 'display' ? 'active' : ''}`}
onClick={() => setSettingsActiveTab('display')}
>
显示
</button>
<button
className={`tab-button ${settingsActiveTab === 'formatting' ? 'active' : ''}`}
onClick={() => setSettingsActiveTab('formatting')}
>
格式化
</button>
<button
className={`tab-button ${settingsActiveTab === 'user' ? 'active' : ''}`}
onClick={() => setSettingsActiveTab('user')}
>
用户
</button>
</div>
<div className="panel-body" style={{ padding: '16px' }}>
{settingsLoading && <div className="loading">加载中...</div>}
{/* 显示设置 */}
{settingsActiveTab === 'display' && (
<div className="settings-section">
<div className="setting-section">
<h4>左侧栏模式</h4>
<div className="setting-options">
<label className={`setting-option ${sidebarMode === 'fixed' ? 'active' : ''}`}>
<input
type="radio"
name="sidebarMode"
value="fixed"
checked={sidebarMode === 'fixed'}
onChange={(e) => handleSidebarModeChange(e.target.value)}
/>
<span>固定</span>
</label>
<label className={`setting-option ${sidebarMode === 'smart' ? 'active' : ''}`}>
<input
type="radio"
name="sidebarMode"
value="smart"
checked={sidebarMode === 'smart'}
onChange={(e) => handleSidebarModeChange(e.target.value)}
/>
<span>智能</span>
</label>
<label className={`setting-option ${sidebarMode === 'expanded' ? 'active' : ''}`}>
<input
type="radio"
name="sidebarMode"
value="expanded"
checked={sidebarMode === 'expanded'}
onChange={(e) => handleSidebarModeChange(e.target.value)}
/>
<span>扩展</span>
</label>
</div>
<p className="setting-description">
{sidebarMode === 'fixed' && '左侧栏保持默认宽度,不响应交互'}
{sidebarMode === 'smart' && '鼠标悬停时自动展开,移开后收起'}
{sidebarMode === 'expanded' && '左侧栏保持最大宽度'}
</p>
</div>
</div>
)}
{/* 格式化设置 */}
{settingsActiveTab === 'formatting' && (
<div className="settings-section">
<div className="setting-section">
<h4>思考标签配置</h4>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500', color: 'var(--color-text-primary)' }}>前缀</label>
<input
type="text"
value={settings.thinkingTagPrefix}
onChange={(e) => handleSettingsInputChange('thinkingTagPrefix', e.target.value)}
placeholder="<thinking>"
className="settings-input"
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500', color: 'var(--color-text-primary)' }}>后缀</label>
<input
type="text"
value={settings.thinkingTagSuffix}
onChange={(e) => handleSettingsInputChange('thinkingTagSuffix', e.target.value)}
placeholder="</thinking>"
className="settings-input"
/>
</div>
<div className="preview-box">
<div className="original" style={{ marginBottom: '12px' }}>
<strong style={{ display: 'block', marginBottom: '4px', fontSize: '12px', color: 'var(--color-text-secondary)' }}>原始内容</strong>
<p style={{ margin: 0, padding: '8px', background: 'rgba(0, 0, 0, 0.2)', borderRadius: '3px', fontFamily: 'Courier New, monospace', fontSize: '12px', color: 'var(--color-text-primary)' }}>
这是回复 {settings.thinkingTagPrefix}思考过程{settings.thinkingTagSuffix} 继续
</p>
</div>
<div className="processed">
<strong style={{ display: 'block', marginBottom: '4px', fontSize: '12px', color: 'var(--color-text-secondary)' }}>处理后</strong>
<p style={{ margin: 0, padding: '8px', background: 'rgba(0, 0, 0, 0.2)', borderRadius: '3px', fontFamily: 'Courier New, monospace', fontSize: '12px', color: 'var(--color-text-primary)' }}>
这是回复 继续
</p>
</div>
</div>
</div>
<div className="setting-section" style={{ marginTop: '20px', paddingTop: '20px', borderTop: '1px solid var(--border-color)' }}>
<h4>正则规则管理</h4>
<div style={{ marginBottom: '16px' }}>
<code className="settings-code">
data/regex/
</code>
<ul className="settings-file-list">
<li>📁 global/ - 全局规则</li>
<li>📁 characters/ - 角色卡规则</li>
<li>📁 presets/ - 预设规则</li>
</ul>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500', color: 'var(--color-text-primary)' }}>导入规则文件</label>
<input
type="file"
accept=".json"
onChange={async (e) => {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/regex/import', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
showSettingsMessage('success', data.message);
} else {
showSettingsMessage('error', '导入失败');
}
} catch (error) {
showSettingsMessage('error', '导入失败');
}
}}
className="settings-input"
/>
<small className="settings-hint">
支持 SillyTavern 格式的规则文件
</small>
</div>
<button
className="btn-primary"
onClick={() => window.open('/api/regex/export/global', '_blank')}
>
导出全局规则
</button>
</div>
<div style={{ marginTop: '20px' }}>
<button
className="btn-primary"
onClick={saveSettings}
disabled={settingsLoading}
>
{settingsLoading ? '保存中...' : '保存设置'}
</button>
</div>
</div>
)}
{/* 用户设置 */}
{settingsActiveTab === 'user' && (
<div className="settings-section">
<div className="setting-section">
<h4>当前预设</h4>
<input
type="text"
value={settings.currentPresetName || ''}
onChange={(e) => handleSettingsInputChange('currentPresetName', e.target.value || null)}
placeholder="未选择"
className="settings-input"
/>
<small className="settings-hint">
影响全局正则规则的启用
</small>
</div>
<div style={{ marginTop: '20px' }}>
<button
className="btn-primary"
onClick={saveSettings}
disabled={settingsLoading}
>
{settingsLoading ? '保存中...' : '保存设置'}
</button>
</div>
</div>
)}
</div>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef } from 'react';
import useAppLayoutStore from '../../../../Store/AppLayoutSlice';
import './ThemeToggle.css';
const themes = [
@@ -50,19 +51,16 @@ const themes = [
const ThemeToggle = () => {
const [isOpen, setIsOpen] = useState(false);
const [currentTheme, setCurrentTheme] = useState(() => {
return localStorage.getItem('colorTheme') || 'dark';
});
const panelRef = useRef(null);
// ✅ 从 Zustand store 获取主题状态和方法
const { colorTheme, setColorTheme } = useAppLayoutStore();
// ✅ 当 store 中的主题变化时,同步到 DOM
useEffect(() => {
// 应用主题到 document
document.documentElement.setAttribute('data-theme', currentTheme);
document.documentElement.setAttribute('data-color-theme', currentTheme);
// 保存到 localStorage
localStorage.setItem('theme', currentTheme);
localStorage.setItem('colorTheme', currentTheme);
}, [currentTheme]);
document.documentElement.setAttribute('data-theme', colorTheme);
document.documentElement.setAttribute('data-color-theme', colorTheme);
}, [colorTheme]);
// 点击外部关闭面板
useEffect(() => {
@@ -82,11 +80,11 @@ const ThemeToggle = () => {
}, [isOpen]);
const handleThemeSelect = (themeId) => {
setCurrentTheme(themeId);
setColorTheme(themeId); // ✅ 使用 store 的方法
setIsOpen(false);
};
const currentThemeData = themes.find(t => t.id === currentTheme) || themes[2];
const currentThemeData = themes.find(t => t.id === colorTheme) || themes[2];
return (
<div className="theme-toggle-wrapper" ref={panelRef}>
@@ -108,7 +106,7 @@ const ThemeToggle = () => {
{themes.map(theme => (
<button
key={theme.id}
className={`theme-item ${currentTheme === theme.id ? 'active' : ''}`}
className={`theme-item ${colorTheme === theme.id ? 'active' : ''}`}
onClick={() => handleThemeSelect(theme.id)}
>
<div className="theme-item-icon">{theme.icon}</div>

View File

@@ -0,0 +1,161 @@
/**
* 正则替换工具函数
*
* 在前端应用正则规则到消息显示,不影响原始数据存储。
* 参考 SillyTavern 的正则处理逻辑。
*/
/**
* 应用正则规则到文本
*
* @param {string} text - 原始文本
* @param {Array} rules - 正则规则数组
* @param {number} messageDepth - 消息深度从最新消息开始计数0表示最新
* @returns {string} - 处理后的文本(仅用于显示)
*/
export function applyRegexRules(text, rules, messageDepth = 0) {
if (!text || !rules || rules.length === 0) {
return text;
}
let result = text;
// 按 order 排序规则
const sortedRules = [...rules].sort((a, b) => a.order - b.order);
for (const rule of sortedRules) {
// 跳过禁用的规则
if (!rule.enabled) {
continue;
}
// 检查消息深度限制
if (messageDepth < rule.minDepth) {
continue;
}
if (rule.maxDepth !== null && rule.maxDepth !== undefined && messageDepth > rule.maxDepth) {
continue;
}
// 应用单条规则
result = applySingleRule(result, rule);
}
return result;
}
/**
* 应用单条正则规则
*
* @param {string} text - 输入文本
* @param {Object} rule - 正则规则对象
* @returns {string} - 处理后的文本
*/
function applySingleRule(text, rule) {
try {
const { findRegex, replaceString, substituteRegex, trimStrings } = rule;
if (!findRegex) {
return text;
}
// 解析正则表达式和标志
let pattern = findRegex;
let flags = 'g'; // 默认全局匹配
// 提取标志(如果格式为 /pattern/flags
if (findRegex.startsWith('/') && findRegex.lastIndexOf('/') > 0) {
const lastSlashIndex = findRegex.lastIndexOf('/');
pattern = findRegex.slice(1, lastSlashIndex);
const extractedFlags = findRegex.slice(lastSlashIndex + 1);
if (extractedFlags) {
flags = extractedFlags;
}
}
// 创建正则表达式对象
const regex = new RegExp(pattern, flags);
// 根据替换模式执行替换
let replaced;
if (substituteRegex === 1) {
// REPLACE_FIRST: 仅替换首次匹配
replaced = text.replace(regex, replaceString);
} else {
// REPLACE_ALL (0) 或 REPLACE_CAPTURED (2): 替换所有匹配
replaced = text.replace(regex, replaceString);
}
// 应用 trimStrings删除指定的字符串
if (trimStrings && Array.isArray(trimStrings)) {
for (const trimStr of trimStrings) {
replaced = replaced.split(trimStr).join('');
}
}
return replaced;
} catch (error) {
console.error(`正则规则执行错误 [${rule.name}]:`, error);
return text; // 出错时返回原文本
}
}
/**
* 根据作用域过滤适用的规则
*
* @param {Array} allRules - 所有规则
* @param {string|null} characterId - 当前角色卡 ID
* @param {string|null} presetId - 当前预设 ID
* @returns {Array} - 适用的规则数组
*/
export function filterRulesByScope(allRules, characterId = null, presetId = null) {
if (!allRules || !Array.isArray(allRules)) {
return [];
}
return allRules.filter(rule => {
// 跳过禁用的规则
if (!rule.enabled) {
return false;
}
// 根据作用域过滤
switch (rule.scope) {
case 'global':
return true;
case 'character':
return rule.characterId === characterId;
case 'preset':
return rule.presetId === presetId;
default:
return false;
}
});
}
/**
* 隐藏思考标签的示例规则
*
* @returns {Object} - 规则对象
*/
export function createHideThinkingRule() {
return {
id: 'rule-hide-thinking',
name: '隐藏思考标签',
findRegex: '<thinking>[\\s\\S]*?<\\/thinking>',
replaceString: '',
trimStrings: [],
substituteRegex: 0, // REPLACE_ALL
markdownOnly: false,
promptOnly: false,
runOnEdit: true,
minDepth: 0,
maxDepth: null,
scope: 'global',
characterId: null,
presetId: null,
enabled: true,
order: 1,
description: '隐藏 AI 回复中的 <thinking> 标签及其内容'
};
}

View File

@@ -6,7 +6,7 @@ export default defineConfig({
server: {
proxy: {
'/api': {
target: process.env.VITE_API_URL || 'http://localhost:23337',
target: 'http://backend:8000',
changeOrigin: true,
}
}

69
test.py
View File

@@ -65,59 +65,32 @@ def test_api_key(api_key: str, base_url: str = None, model: str = "gpt-3.5-turbo
def check_api_status():
"""检查 API Key 状态"""
"""
检查 API 配置状态
⚠️ 注意此函数已废弃API Key 现在存储在配置文件中
请使用 API 配置页面进行测试
"""
print("\n" + "=" * 60)
print("API Key 检测 (LangChain)")
print("⚠️ 此测试功能已废弃")
print("=" * 60)
# 从环境变量读取配置
API_KEY = os.getenv("MAIN_LLM_API_KEY", "")
BASE_URL = os.getenv("MAIN_LLM_BASE_URL", "")
MODEL = os.getenv("MAIN_LLM_MODEL", "gpt-3.5-turbo")
print(f"\n配置信息:")
print(f" 模型: {MODEL}")
if BASE_URL:
print(f" API地址: {BASE_URL}")
if not API_KEY:
print("\n❌ 未找到 API Key!")
print("\n请在 .env 文件中设置 MAIN_LLM_API_KEY")
return False
print(f" API密钥: {'*' * 8}{API_KEY[-4:]}")
print(f"\n正在测试...")
# 执行测试
result = test_api_key(API_KEY, BASE_URL if BASE_URL else None, MODEL)
print(f"\n{result['message']}")
if result["valid"]:
print(f" 响应时间: {result['response_time']:.2f}")
print(f"\n💡 API Key 可以正常使用!")
return True
else:
print(f"\n💡 请检查:")
print(" 1. API Key 是否正确")
print(" 2. API 端点地址是否正确")
print(" 3. 网络连接是否正常")
return False
print("\nAPI Key 现在存储在配置文件中data/apiconfig/*.json")
print("请使用以下方式测试 API 连接:")
print(" 1. 打开应用")
print(" 2. 进入左侧边栏 → API 配置")
print(" 3. 点击 '测试连接' 按钮")
print("\n或者直接在聊天中发送消息进行测试")
return False
def quick_test():
"""快速测试"""
API_KEY = os.getenv("MAIN_LLM_API_KEY", "")
BASE_URL = os.getenv("MAIN_LLM_BASE_URL", "")
MODEL = os.getenv("MAIN_LLM_MODEL", "gpt-3.5-turbo")
if not API_KEY:
print("❌ 未配置 API Key")
return False
result = test_api_key(API_KEY, BASE_URL if BASE_URL else None, MODEL)
print(result['message'])
return result['valid']
"""
快速测试
⚠️ 注意:此函数已废弃
"""
print("❌ 此测试功能已废弃,请使用 API 配置页面进行测试")
return False
def test_list_all_chats():

View File

@@ -0,0 +1,82 @@
"""
测试 API 配置文件持久化
"""
import sys
import json
from pathlib import Path
# 添加 backend 到路径
sys.path.insert(0, 'backend')
from backend.core.config import settings
def test_config_persistence():
"""测试配置文件持久化"""
config_dir = Path(settings.DATA_PATH) / "apiconfig"
print("=" * 60)
print("API 配置文件持久化测试")
print("=" * 60)
print(f"\n配置文件目录: {config_dir}")
print(f"目录存在: {config_dir.exists()}")
if not config_dir.exists():
print("\n❌ 配置文件目录不存在!")
return False
# 列出所有配置文件
config_files = list(config_dir.glob("*.json"))
print(f"\n找到 {len(config_files)} 个配置文件:")
if len(config_files) == 0:
print("\n⚠️ 没有保存的配置文件")
print("提示:请在前端创建并保存至少一个配置文件")
return False
for config_file in config_files:
print(f"\n{'-' * 60}")
print(f"文件: {config_file.name}")
try:
with open(config_file, 'r', encoding='utf-8') as f:
config = json.load(f)
print(f" ID: {config.get('id', 'N/A')}")
print(f" 名称: {config.get('name', 'N/A')}")
print(f" 创建时间: {config.get('createdAt', 'N/A')}")
apis = config.get('apis', {})
print(f" API 配置数量: {len(apis)}")
for category, api_config in apis.items():
print(f"\n [{category}]")
print(f" URL: {api_config.get('apiUrl', 'N/A')}")
# 检查 API Key 是否已加密
api_key = api_config.get('apiKey', '')
if api_key:
if api_key == '****':
print(f" API Key: ⚠️ 明文存储(不安全!)")
elif len(api_key) > 50: # 加密后的 key 通常很长
print(f" API Key: ✓ 已加密")
else:
print(f" API Key: ? 未知格式")
else:
print(f" API Key: (空)")
print(f" 模型: {api_config.get('model', 'N/A')}")
print(f"\n✓ 配置文件格式正确")
except Exception as e:
print(f"\n❌ 读取配置文件失败: {e}")
return False
print(f"\n{'=' * 60}")
print("✓ 测试通过:配置文件持久化正常工作")
print("=" * 60)
return True
if __name__ == "__main__":
success = test_config_persistence()
sys.exit(0 if success else 1)

82
test_regex_api.py Normal file
View File

@@ -0,0 +1,82 @@
"""
测试正则规则系统
"""
import requests
import json
BASE_URL = "http://localhost:23338"
def test_get_settings():
"""测试获取系统设置"""
print("=" * 50)
print("测试:获取系统设置")
print("=" * 50)
response = requests.get(f"{BASE_URL}/api/regex/settings")
print(f"状态码: {response.status_code}")
if response.status_code == 200:
data = response.json()
print(f"响应: {json.dumps(data, indent=2, ensure_ascii=False)}")
else:
print(f"错误: {response.text}")
print()
def test_get_rules():
"""测试获取规则列表"""
print("=" * 50)
print("测试:获取规则列表")
print("=" * 50)
response = requests.get(f"{BASE_URL}/api/regex/rules")
print(f"状态码: {response.status_code}")
if response.status_code == 200:
data = response.json()
print(f"规则数量: {data.get('count', 0)}")
if data.get('rules'):
for rule in data['rules'][:2]: # 只显示前2条
print(f" - {rule.get('scriptName', 'N/A')}")
else:
print(f"错误: {response.text}")
print()
def test_update_settings():
"""测试更新系统设置"""
print("=" * 50)
print("测试:更新系统设置")
print("=" * 50)
payload = {
"thinkingTagPrefix": "<think>",
"thinkingTagSuffix": "</think>"
}
response = requests.put(
f"{BASE_URL}/api/regex/settings",
json=payload
)
print(f"状态码: {response.status_code}")
if response.status_code == 200:
data = response.json()
print(f"响应: {json.dumps(data, indent=2, ensure_ascii=False)}")
else:
print(f"错误: {response.text}")
print()
if __name__ == "__main__":
try:
test_get_settings()
test_get_rules()
test_update_settings()
print("✅ 所有测试完成!")
except Exception as e:
print(f"❌ 测试失败: {e}")
import traceback
traceback.print_exc()

View File

@@ -0,0 +1,95 @@
"""
测试 Token 使用统计的索引功能
"""
import asyncio
import sys
from pathlib import Path
# 添加 backend 到路径
sys.path.insert(0, str(Path(__file__).parent / "backend"))
from backend.services.token_usage_service import token_usage_service
from backend.models.internal import TokenUsageStatus
async def test_token_usage_indexes():
"""测试 Token 使用统计的索引功能"""
print("=" * 80)
print("Token 使用统计 - 索引功能测试")
print("=" * 80)
# 1. 记录一些测试数据
print("\n📝 记录测试数据...")
test_records = [
{
"chat_id": "测试角色/测试聊天1",
"role_name": "测试角色",
"chat_name": "测试聊天1",
"prompt_tokens": 100,
"completion_tokens": 200,
"total_tokens": 300,
"api_url": "https://api.openai.com/v1"
},
{
"chat_id": "测试角色/测试聊天2",
"role_name": "测试角色",
"chat_name": "测试聊天2",
"prompt_tokens": 150,
"completion_tokens": 250,
"total_tokens": 400,
"api_url": "https://api.deepseek.com/v1"
},
{
"chat_id": "测试角色/测试聊天1",
"role_name": "测试角色",
"chat_name": "测试聊天1",
"prompt_tokens": 120,
"completion_tokens": 180,
"total_tokens": 300,
"api_url": "https://api.openai.com/v1"
}
]
for record_data in test_records:
await token_usage_service.record_usage(**record_data)
print(f" ✅ 记录: {record_data['api_url']} - {record_data['total_tokens']} tokens")
# 2. 测试 API URL 索引查询
print("\n🔍 测试 API URL 索引查询...")
api_url_stats = await token_usage_service.get_api_url_stats()
if api_url_stats:
print(f"\n 找到 {len(api_url_stats)} 个 API URL:")
for url, stats in api_url_stats.items():
print(f"\n 📊 {url}:")
print(f" - 总调用次数: {stats['count']}")
print(f" - 总 Token 数: {stats['totalTokens']}")
print(f" - Prompt Tokens: {stats['totalPromptTokens']}")
print(f" - Completion Tokens: {stats['totalCompletionTokens']}")
else:
print(" ⚠️ 没有找到 API URL 统计数据")
# 3. 测试每日索引查询
print("\n🔍 测试每日索引查询...")
from datetime import datetime
now = datetime.now()
daily_stats = await token_usage_service.get_daily_stats(now.year, now.month)
if daily_stats:
print(f"\n 找到 {len(daily_stats)} 天的数据:")
for day, stats in list(daily_stats.items())[:5]: # 只显示前5天
print(f" 📅 {day}:")
print(f" - 调用次数: {stats['count']}")
print(f" - 总 Token 数: {stats['totalTokens']}")
else:
print(" ⚠️ 没有找到每日统计数据")
print("\n" + "=" * 80)
print("✅ 测试完成!")
print("=" * 80)
if __name__ == "__main__":
asyncio.run(test_token_usage_indexes())