完成请求推送,但组装mes还有问题
This commit is contained in:
7
.env
7
.env
@@ -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
23
.env.example
Normal 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
4
.gitignore
vendored
@@ -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
250
API_CONFIG_DEBUG.md
Normal 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 配置页面的截图
|
||||
382
APP_INITIALIZATION_OPTIMIZATION.md
Normal file
382
APP_INITIALIZATION_OPTIMIZATION.md
Normal 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
204
SETTINGS_TEST_GUIDE.md
Normal 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
318
WEBSOCKET_LOGS_EXAMPLE.md
Normal 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. ✅ 优化用户体验
|
||||
|
||||
建议在开发和测试阶段使用详细日志,在生产环境中根据需要调整日志级别。
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
420
backend/api/routes/chatWsRoute.py
Normal file
420
backend/api/routes/chatWsRoute.py
Normal 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}")
|
||||
# 不抛出异常,避免影响主流程
|
||||
@@ -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):
|
||||
"""创建新聊天"""
|
||||
|
||||
140
backend/api/routes/imageGalleryRoute.py
Normal file
140
backend/api/routes/imageGalleryRoute.py
Normal 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))
|
||||
248
backend/api/routes/regexRoute.py
Normal file
248
backend/api/routes/regexRoute.py
Normal 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))
|
||||
130
backend/api/routes/tokenUsageRoute.py
Normal file
130
backend/api/routes/tokenUsageRoute.py
Normal 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}
|
||||
@@ -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()
|
||||
|
||||
@@ -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="创建时间戳")
|
||||
|
||||
164
backend/models/regex_rules.py
Normal file
164
backend/models/regex_rules.py
Normal 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))
|
||||
49
backend/models/system_settings.py
Normal file
49
backend/models/system_settings.py
Normal 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))
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
1009
backend/services/chat_workflow_service.py
Normal file
1009
backend/services/chat_workflow_service.py
Normal file
File diff suppressed because it is too large
Load Diff
346
backend/services/image_metadata_service.py
Normal file
346
backend/services/image_metadata_service.py
Normal 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()
|
||||
@@ -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'
|
||||
|
||||
332
backend/services/regex_service.py
Normal file
332
backend/services/regex_service.py
Normal 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()
|
||||
95
backend/services/system_settings_service.py
Normal file
95
backend/services/system_settings_service.py
Normal 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()
|
||||
161
backend/services/task_queue_manager.py
Normal file
161
backend/services/task_queue_manager.py
Normal 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()
|
||||
427
backend/services/token_usage_service.py
Normal file
427
backend/services/token_usage_service.py
Normal 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()
|
||||
@@ -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 |
@@ -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
|
||||
}
|
||||
@@ -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": []}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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": "好的,请提供数据,我会进行专业分析。"}
|
||||
@@ -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": []}
|
||||
@@ -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
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
1
data/encryption_key.txt
Normal file
@@ -0,0 +1 @@
|
||||
-JTj6zP_N7PFt218eJTGBKFBKED-GjOZVMgCxruoiW8=
|
||||
@@ -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}}.]",
|
||||
|
||||
28
data/regex/global/default.json
Normal file
28
data/regex/global/default.json
Normal 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
|
||||
}
|
||||
7
data/system_settings.json
Normal file
7
data/system_settings.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"thinkingTagPrefix": "<thinking>",
|
||||
"thinkingTagSuffix": "</thinking>",
|
||||
"currentPresetName": null,
|
||||
"updatedAt": 1777798988,
|
||||
"version": 1
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
1906
frontend/node_modules/.vite/deps_temp_e32948ce/chunk-CANBAPAS.js
generated
vendored
1906
frontend/node_modules/.vite/deps_temp_e32948ce/chunk-CANBAPAS.js
generated
vendored
File diff suppressed because it is too large
Load Diff
7
frontend/node_modules/.vite/deps_temp_e32948ce/react-dom.js
generated
vendored
7
frontend/node_modules/.vite/deps_temp_e32948ce/react-dom.js
generated
vendored
@@ -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
|
||||
7
frontend/node_modules/.vite/deps_temp_e32948ce/react-dom_client.js.map
generated
vendored
7
frontend/node_modules/.vite/deps_temp_e32948ce/react-dom_client.js.map
generated
vendored
@@ -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": []
|
||||
}
|
||||
6
frontend/node_modules/.vite/deps_temp_e32948ce/react.js
generated
vendored
6
frontend/node_modules/.vite/deps_temp_e32948ce/react.js
generated
vendored
@@ -1,6 +0,0 @@
|
||||
import {
|
||||
require_react
|
||||
} from "./chunk-CANBAPAS.js";
|
||||
import "./chunk-5WRI5ZAA.js";
|
||||
export default require_react();
|
||||
//# sourceMappingURL=react.js.map
|
||||
7
frontend/node_modules/.vite/deps_temp_e32948ce/zustand.js.map
generated
vendored
7
frontend/node_modules/.vite/deps_temp_e32948ce/zustand.js.map
generated
vendored
File diff suppressed because one or more lines are too long
7
frontend/node_modules/.vite/deps_temp_e32948ce/zustand_middleware.js.map
generated
vendored
7
frontend/node_modules/.vite/deps_temp_e32948ce/zustand_middleware.js.map
generated
vendored
File diff suppressed because one or more lines are too long
@@ -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 读取状态 */}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 })
|
||||
|
||||
122
frontend/src/Store/SideBarLeft/TokenUsageSlice.jsx
Normal file
122
frontend/src/Store/SideBarLeft/TokenUsageSlice.jsx
Normal 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;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
88
frontend/src/Store/SideBarRight/TasksSlice.jsx
Normal file
88
frontend/src/Store/SideBarRight/TasksSlice.jsx
Normal 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;
|
||||
37
frontend/src/Store/SideBarRight/WorldBookActiveSlice.jsx
Normal file
37
frontend/src/Store/SideBarRight/WorldBookActiveSlice.jsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.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) => (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 => (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
230
frontend/src/components/SideBarLeft/tabs/Settings/Settings.css
Normal file
230
frontend/src/components/SideBarLeft/tabs/Settings/Settings.css
Normal 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;
|
||||
}
|
||||
266
frontend/src/components/SideBarLeft/tabs/Settings/Settings.jsx
Normal file
266
frontend/src/components/SideBarLeft/tabs/Settings/Settings.jsx
Normal 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>默认值:<thinking></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>默认值:</thinking></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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './Settings';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
区分大小写
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/* 未选择分页提示 */
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const Debug = () => {
|
||||
return (
|
||||
<div className="debug-panel">
|
||||
<h2>上下文调试</h2>
|
||||
<p>这是上下文调试面板的占位页面</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Debug;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './Debug';
|
||||
152
frontend/src/components/SideBarRight/tabs/Tasks/Tasks.css
Normal file
152
frontend/src/components/SideBarRight/tabs/Tasks/Tasks.css
Normal 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);
|
||||
}
|
||||
73
frontend/src/components/SideBarRight/tabs/Tasks/Tasks.jsx
Normal file
73
frontend/src/components/SideBarRight/tabs/Tasks/Tasks.jsx
Normal 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;
|
||||
1
frontend/src/components/SideBarRight/tabs/Tasks/index.js
Normal file
1
frontend/src/components/SideBarRight/tabs/Tasks/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './Tasks';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './WorldBookActive';
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
161
frontend/src/utils/regexProcessor.js
Normal file
161
frontend/src/utils/regexProcessor.js
Normal 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> 标签及其内容'
|
||||
};
|
||||
}
|
||||
@@ -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
69
test.py
@@ -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():
|
||||
|
||||
82
test_api_config_persistence.py
Normal file
82
test_api_config_persistence.py
Normal 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
82
test_regex_api.py
Normal 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()
|
||||
95
test_token_usage_indexes.py
Normal file
95
test_token_usage_indexes.py
Normal 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())
|
||||
Reference in New Issue
Block a user