基本完成,舒适性修补
This commit is contained in:
155
.gitignore
vendored
155
.gitignore
vendored
@@ -29,7 +29,21 @@ env/
|
||||
.venv
|
||||
VENV/
|
||||
|
||||
# IDE
|
||||
# Python test files (temporary)
|
||||
test_*.py
|
||||
check_*.py
|
||||
clear_*.py
|
||||
convert_*.py
|
||||
generate_*.py
|
||||
create_*.py
|
||||
test.py
|
||||
|
||||
# Python type checking
|
||||
.mypy_cache/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# ==================== IDE ====================
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
@@ -38,14 +52,39 @@ VENV/
|
||||
.project
|
||||
.pydevproject
|
||||
.settings/
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
*.iml
|
||||
.cursor/
|
||||
.windsurfrules
|
||||
|
||||
# OS
|
||||
# JetBrains IDEs
|
||||
.idea/workspace.xml
|
||||
.idea/tasks.xml
|
||||
.idea/dictionaries/
|
||||
.idea/vcs.xml
|
||||
.idea/jsLinters/
|
||||
.idea/misc.xml
|
||||
.idea/modules.xml
|
||||
|
||||
# ==================== OS ====================
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# Windows thumbnails cache files
|
||||
Thumbs.db:encryptable
|
||||
dm thumbs.db
|
||||
|
||||
# Folder config file
|
||||
[Dd]esktop.ini
|
||||
|
||||
# ==================== Node.js ====================
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
@@ -76,21 +115,71 @@ logs/
|
||||
log/
|
||||
|
||||
# ==================== Data files ====================
|
||||
# 保留目录结构,忽略数据文件
|
||||
data/chat/**/*.jsonl
|
||||
data/chat/**/*.json
|
||||
data/preset/*.json
|
||||
data/worldbooks/*.json
|
||||
# ⚠️ API 配置文件包含 API Keys,禁止提交到版本控制
|
||||
data/apiconfig/*.json
|
||||
data/comfyui_workflows/*.json
|
||||
data/images/*
|
||||
data/temp/*
|
||||
outputs/*
|
||||
imports/*
|
||||
# ⚠️ 所有用户数据文件都不应该提交到版本控制
|
||||
|
||||
# 聊天记录(包含聊天历史和消息数据)
|
||||
data/chat/
|
||||
data/chat/**/*
|
||||
|
||||
# 角色卡数据(角色配置和头像)
|
||||
data/characters/
|
||||
data/characters/**/*
|
||||
data/avatars/
|
||||
data/avatars/**/*
|
||||
|
||||
# 预设文件(提示词配置)
|
||||
data/preset/
|
||||
data/preset/**/*
|
||||
|
||||
# 世界书(世界观设定)
|
||||
data/worldbooks/
|
||||
data/worldbooks/**/*
|
||||
|
||||
# API 配置(包含 API Keys,敏感信息)
|
||||
data/apiconfig/
|
||||
data/apiconfig/**/*
|
||||
|
||||
# 正则规则
|
||||
data/regex/
|
||||
data/regex/**/*
|
||||
|
||||
# ComfyUI 工作流
|
||||
data/comfyui_workflows/
|
||||
data/comfyui_workflows/**/*
|
||||
|
||||
# 图片资源
|
||||
data/images/
|
||||
data/images/**/*
|
||||
data/image_metadata/
|
||||
data/image_metadata/**/*
|
||||
|
||||
# 临时文件
|
||||
data/temp/
|
||||
data/temp/**/*
|
||||
|
||||
# 导入文件
|
||||
data/imports/
|
||||
data/imports/**/*
|
||||
|
||||
# Token 使用统计
|
||||
data/token_usage/
|
||||
data/token_usage/**/*
|
||||
|
||||
# 系统设置
|
||||
data/system_settings.json
|
||||
|
||||
# 加密密钥(敏感信息)
|
||||
data/encryption_key.txt
|
||||
|
||||
# 其他输出目录
|
||||
outputs/
|
||||
outputs/**/*
|
||||
imports/
|
||||
imports/**/*
|
||||
|
||||
# ==================== Docker ====================
|
||||
.dockerignore
|
||||
docker-compose.override.yml
|
||||
|
||||
# ==================== Temporary files ====================
|
||||
*.tmp
|
||||
@@ -105,6 +194,13 @@ coverage/
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
.tox/
|
||||
.nox/
|
||||
|
||||
# Unit test / coverage reports
|
||||
.coverage
|
||||
.coverage.*
|
||||
*.cover
|
||||
*.cover.gz
|
||||
|
||||
# ==================== Misc ====================
|
||||
.parcel-cache/
|
||||
@@ -116,6 +212,19 @@ htmlcov/
|
||||
.dynamodb/
|
||||
.tern-port
|
||||
|
||||
# Temporary documentation files
|
||||
*_TEST_GUIDE.md
|
||||
*_DEBUG_GUIDE.md
|
||||
*_DEBUG.md
|
||||
*_TEST.md
|
||||
*_CHECK.md
|
||||
*_FIX.md
|
||||
*_IMPROVEMENT.md
|
||||
*_EXAMPLE.md
|
||||
*_COMPARISON.md
|
||||
*_OPTIMIZATION.md
|
||||
*_CONFIG.md
|
||||
|
||||
# ==================== Project specific ====================
|
||||
# Backend output
|
||||
backend/__pycache__/
|
||||
@@ -124,11 +233,27 @@ backend/api/routes/__pycache__/
|
||||
backend/core/__pycache__/
|
||||
backend/services/__pycache__/
|
||||
backend/utils/__pycache__/
|
||||
backend/models/__pycache__/
|
||||
|
||||
# Claude settings
|
||||
.claude/settings.local.json
|
||||
.claude/
|
||||
|
||||
# Lingma cache
|
||||
.lingma/
|
||||
|
||||
# Backup files
|
||||
*.bak
|
||||
*.backup
|
||||
*~
|
||||
|
||||
# ComfyUI generated images
|
||||
data/outputs/
|
||||
|
||||
# Token usage logs (can be large)
|
||||
data/token_usage/*.jsonl
|
||||
data/token_usage/**/*.jsonl
|
||||
|
||||
# Worldbooks backup
|
||||
data/worldbooks/*.bak
|
||||
data/worldbooks/*.bak.*
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
# 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 配置页面的截图
|
||||
@@ -1,382 +0,0 @@
|
||||
# 应用启动预加载优化
|
||||
|
||||
## 概述
|
||||
|
||||
为了提升用户体验,避免"先有鸡还是先有蛋"的问题,应用在启动时会自动加载所有必要的配置数据。
|
||||
|
||||
## 预加载的数据
|
||||
|
||||
### 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%
|
||||
- 操作步骤大幅简化
|
||||
- 无需手动切换页面配置
|
||||
|
||||
✅ **技术优势**
|
||||
- 并行加载,速度更快
|
||||
- 容错性强,单个失败不影响整体
|
||||
- 详细日志,便于调试
|
||||
|
||||
✅ **代码质量**
|
||||
- 清晰的加载逻辑
|
||||
- 完善的错误处理
|
||||
- 性能监控和统计
|
||||
|
||||
这种优化让应用更加"开箱即用",用户打开即可开始使用,无需繁琐的初始配置步骤。
|
||||
@@ -1,159 +0,0 @@
|
||||
# 聊天管理器和Prompt调试功能
|
||||
|
||||
## 功能概述
|
||||
|
||||
本次更新添加了两个重要功能:
|
||||
|
||||
1. **聊天管理器中的删除聊天功能**
|
||||
2. **完整的Prompt拼接内容调试输出**
|
||||
|
||||
---
|
||||
|
||||
## 1. 删除聊天功能
|
||||
|
||||
### 位置
|
||||
聊天选择器(管理器)弹窗中,每个聊天项的右侧。
|
||||
|
||||
### 使用方法
|
||||
1. 点击输入框右侧的 "≡" 按钮打开选项菜单
|
||||
2. 点击 "💬 切换聊天" 按钮
|
||||
3. 在弹出的聊天选择器中,鼠标悬停在任意聊天上
|
||||
4. 右侧会出现 🗑️ 删除按钮
|
||||
5. 点击删除按钮,确认后该聊天将被永久删除
|
||||
|
||||
### 特性
|
||||
- ✅ 删除前需要确认,防止误操作
|
||||
- ✅ 删除后自动刷新聊天列表
|
||||
- ✅ 如果删除的是当前正在使用的聊天,会自动清空当前聊天状态
|
||||
- ✅ 删除按钮默认隐藏,只在hover时显示,保持界面整洁
|
||||
- ✅ 删除操作不可恢复,请谨慎使用
|
||||
|
||||
### 技术实现
|
||||
- 前端:`ChatBox.jsx` - `handleDeleteChat()` 函数
|
||||
- API:调用后端 `DELETE /api/chat/{role_name}/{chat_name}` 接口
|
||||
- 样式:`.chat-delete-btn` - hover时从透明变为可见
|
||||
|
||||
---
|
||||
|
||||
## 2. Prompt调试功能
|
||||
|
||||
### 目的
|
||||
查看发送给LLM前的完整预设拼接内容,包括:
|
||||
- 所有消息的内容和角色
|
||||
- 预设组件的配置
|
||||
- 世界书条目的插入位置
|
||||
- 聊天历史的组装结果
|
||||
|
||||
### 使用方法
|
||||
功能已**自动启用**,每次发送消息时都会在后端控制台输出详细的调试信息。
|
||||
|
||||
### 输出内容示例
|
||||
|
||||
```
|
||||
================================================================================
|
||||
[Prompt Debug] 📝 完整 Prompt 拼接内容
|
||||
================================================================================
|
||||
[Prompt Debug] 总消息数: 8
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
[Prompt Debug] --- 消息 1/8 [Role: system] ---
|
||||
[Prompt Debug] 长度: 450 字符
|
||||
[Prompt Debug] 内容预览 (前200字符):
|
||||
你是一个专业的角色扮演AI助手。你的任务是扮演指定的角色,与用户进行互动对话...
|
||||
|
||||
[Prompt Debug] 完整内容:
|
||||
你是一个专业的角色扮演AI助手。你的任务是扮演指定的角色,与用户进行互动对话。
|
||||
请严格遵守角色的性格设定、说话风格和背景故事。
|
||||
...(完整内容)
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
[Prompt Debug] --- 消息 2/8 [Role: user] ---
|
||||
[Prompt Debug] 长度: 120 字符
|
||||
[Prompt Debug] 内容预览 (前200字符):
|
||||
你好,我是新来的冒险者...
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
[Prompt Debug] 🧩 预设组件列表 (6 个):
|
||||
1. Main Prompt (类型: system, 启用: True)
|
||||
2. Character Description (类型: system, 启用: True)
|
||||
3. World Info (类型: system, 启用: True)
|
||||
4. Chat History (类型: user/assistant, 启用: True)
|
||||
5. Author's Note (类型: system, 启用: True)
|
||||
6. Post-History Instructions (类型: system, 启用: False)
|
||||
================================================================================
|
||||
```
|
||||
|
||||
### 输出规则
|
||||
- **系统消息**:始终显示完整内容(因为通常较短且重要)
|
||||
- **短消息**(<500字符):显示完整内容
|
||||
- **长消息**(≥500字符):只显示前200字符预览,避免刷屏
|
||||
- **预设组件**:列出所有组件的名称、类型和启用状态
|
||||
|
||||
### 技术实现
|
||||
- 前端:`ChatBoxSlice.jsx` - 在发送消息时添加 `debugPrompt: true` 标志
|
||||
- 后端:`chat_workflow_service.py` - `_assemble_prompt()` 函数检测标志并输出
|
||||
- 输出位置:后端控制台(Python print语句)
|
||||
|
||||
### 如何关闭调试输出
|
||||
如果需要关闭调试输出,可以修改前端代码:
|
||||
|
||||
```javascript
|
||||
// frontend/src/Store/Mid/ChatBoxSlice.jsx
|
||||
// 找到这一行:
|
||||
debugPrompt: true
|
||||
|
||||
// 改为:
|
||||
debugPrompt: false
|
||||
```
|
||||
|
||||
或者完全删除这个字段。
|
||||
|
||||
---
|
||||
|
||||
## 相关文件
|
||||
|
||||
### 前端文件
|
||||
- `frontend/src/components/Mid/ChatBox/ChatBox.jsx`
|
||||
- `handleDeleteChat()` - 删除聊天处理函数
|
||||
- 聊天选择器UI中添加删除按钮
|
||||
|
||||
- `frontend/src/components/Mid/ChatBox/ChatBox.css`
|
||||
- `.chat-delete-btn` - 删除按钮样式
|
||||
|
||||
- `frontend/src/Store/Mid/ChatBoxSlice.jsx`
|
||||
- 添加 `debugPrompt: true` 标志
|
||||
|
||||
### 后端文件
|
||||
- `backend/services/chat_workflow_service.py`
|
||||
- `_assemble_prompt()` - 添加调试输出逻辑
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
### 删除聊天
|
||||
⚠️ **警告**:删除操作不可恢复!删除前请确保:
|
||||
- 你不再需要这个聊天的历史记录
|
||||
- 已经备份了重要的对话内容
|
||||
- 确认没有选错聊天
|
||||
|
||||
### Prompt调试
|
||||
ℹ️ **提示**:
|
||||
- 调试输出会占用大量控制台空间,特别是长对话
|
||||
- 生产环境建议关闭此功能(设置 `debugPrompt: false`)
|
||||
- 输出的内容包含敏感信息(角色设定、世界书等),请勿公开分享
|
||||
|
||||
---
|
||||
|
||||
## 未来改进方向
|
||||
|
||||
1. **删除聊天**
|
||||
- 添加"移动到回收站"功能,支持恢复
|
||||
- 批量删除多个聊天
|
||||
- 导出聊天后再删除
|
||||
|
||||
2. **Prompt调试**
|
||||
- 将调试输出保存到文件
|
||||
- 提供前端UI查看完整Prompt
|
||||
- 支持导出Prompt为JSON格式
|
||||
- 添加Token计数统计
|
||||
@@ -1,173 +0,0 @@
|
||||
# 聊天记录总结功能测试指南
|
||||
|
||||
## 测试数据说明
|
||||
|
||||
已创建测试聊天:**帝国骑士维尔 / 恶魔女友长篇测试**
|
||||
|
||||
### 故事架构
|
||||
- **主题**:日式恶魔女友角色扮演
|
||||
- **情节**:User召唤魅魔,发现对方是前女友 → 签订契约 → 前女友表面嫌弃但内心不舍
|
||||
- **长度**:10层消息,前5层每层约2000字符,后5层为占位符
|
||||
- **总字符数**:约10,894字符
|
||||
|
||||
### 角色设定
|
||||
- **雪乃(莉莉丝)**:前女友变成的高阶魅魔,银发红眼,哥特萝莉装,口是心非
|
||||
- **我(User)**:召唤者,深情且坚定,愿意为爱牺牲
|
||||
|
||||
---
|
||||
|
||||
## 测试步骤
|
||||
|
||||
### 1. 在UI中选择总结模式
|
||||
|
||||
1. 打开左侧栏角色卡片
|
||||
2. 选择"帝国骑士维尔"角色
|
||||
3. 进入编辑模式(点击角色卡片)
|
||||
4. 在"历史记录模式"下拉框中选择 **"总结模式(定期总结历史)"**
|
||||
5. 配置总结参数:
|
||||
- **总结间隔**:2(每2条AI回复触发总结)
|
||||
- **保留最近楼层**:2(最后2层不总结)
|
||||
- **包含用户输入**:勾选
|
||||
- **总结提示词**:保持默认或自定义
|
||||
6. 保存设置
|
||||
|
||||
### 2. 开始对话测试
|
||||
|
||||
#### 预期行为
|
||||
|
||||
**初始状态**:
|
||||
- L1-L10:正常显示所有消息
|
||||
|
||||
**当AI回复达到2条时**(L2、L4、L6、L8):
|
||||
- 前端计数器检测到阈值
|
||||
- 异步调用总结API:`POST /api/chats/帝国骑士维尔/恶魔女友长篇测试/summarize`
|
||||
- 传递参数:
|
||||
```json
|
||||
{
|
||||
"start_floor": 1,
|
||||
"end_floor": 2,
|
||||
"summary_config": {
|
||||
"interval": 2,
|
||||
"recentFloorsToKeep": 2,
|
||||
"includeUserInput": true,
|
||||
"summaryPrompt": "...",
|
||||
"maxSummaryLength": 500
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**总结完成后**:
|
||||
- L1:`mes=""`, `is_summarized=true`(清空内容)
|
||||
- L2:`mes="总结文本"`, `is_summary=true`, `summary_range="L1-L2"`
|
||||
- L3-L10:保持不变
|
||||
|
||||
**继续对话到L4时**:
|
||||
- L3:`mes=""`, `is_summarized=true`
|
||||
- L4:`mes="总结文本"`, `is_summary=true`, `summary_range="L3-L4"`
|
||||
- L5-L10:保持不变
|
||||
|
||||
**到达L8时**(保护最后2层):
|
||||
- L7:`mes=""`, `is_summarized=true`
|
||||
- L8:`mes="总结文本"`, `is_summary=true`, `summary_range="L7-L8"`
|
||||
- **L9-L10:永远不会被总结**(因为设置了保留最后2层)
|
||||
|
||||
### 3. 验证总结效果
|
||||
|
||||
#### 检查点
|
||||
|
||||
✅ **数据结构验证**
|
||||
```bash
|
||||
# 查看聊天文件
|
||||
cat data/chat/帝国骑士维尔/恶魔女友长篇测试.jsonl | jq .
|
||||
```
|
||||
|
||||
应该看到:
|
||||
- 被总结的楼层:`"mes": ""`, `"is_summarized": true`
|
||||
- 总结楼层:`"mes": "总结内容..."`, `"is_summary": true`, `"summary_range": "Lx-Ly"`
|
||||
|
||||
✅ **前端显示验证**
|
||||
- 空楼层应显示省略标记(如 "--- 已总结 ---")
|
||||
- 总结楼层应高亮显示,并显示范围提示
|
||||
- 最后2层始终保持原始内容
|
||||
|
||||
✅ **Prompt组装验证**
|
||||
- 发送给LLM的prompt中,`is_summarized=true`且`mes=""`的消息应被过滤
|
||||
- 总结消息(`is_summary=true`)应保留并作为上下文
|
||||
|
||||
✅ **正则处理验证**
|
||||
- 总结后的消息仍应经过正则处理器
|
||||
- 空楼层不应影响正则匹配
|
||||
|
||||
---
|
||||
|
||||
## 手动测试API
|
||||
|
||||
如果前端集成尚未完成,可以手动测试API:
|
||||
|
||||
```bash
|
||||
# 1. 获取聊天状态
|
||||
curl http://localhost:8000/api/chats/帝国骑士维尔/恶魔女友长篇测试/summary-status
|
||||
|
||||
# 2. 触发总结(总结L1-L2)
|
||||
curl -X POST http://localhost:8000/api/chats/帝国骑士维尔/恶魔女友长篇测试/summarize \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"start_floor": 1,
|
||||
"end_floor": 2,
|
||||
"summary_config": {
|
||||
"interval": 2,
|
||||
"recentFloorsToKeep": 2,
|
||||
"includeUserInput": true,
|
||||
"summaryPrompt": "请总结以下对话内容,保留关键信息和上下文。用简洁的语言概括主要事件、人物状态和重要细节。",
|
||||
"maxSummaryLength": 500
|
||||
}
|
||||
}'
|
||||
|
||||
# 3. 再次获取状态,验证总结结果
|
||||
curl http://localhost:8000/api/chats/帝国骑士维尔/恶魔女友长篇测试/summary-status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q1: 总结后消息丢失怎么办?
|
||||
A: 总结采用"楼层保留式"机制,原文被清空但楼层索引不变。可以通过查看JSONL文件恢复原始数据。
|
||||
|
||||
### Q2: 总结质量不理想怎么办?
|
||||
A: 调整`summaryPrompt`提示词,或增加`maxSummaryLength`限制。
|
||||
|
||||
### Q3: 如何重置总结状态?
|
||||
A: 目前需要手动编辑JSONL文件,将所有`is_summarized`和`is_summary`字段设为`false`,并恢复`mes`内容。
|
||||
|
||||
### Q4: RAG模式为什么不能取消?
|
||||
A: RAG模式涉及数据库结构变更,一旦启用会产生持久化数据,为避免数据不一致,设计为不可逆操作。
|
||||
|
||||
---
|
||||
|
||||
## 性能指标
|
||||
|
||||
- **单条消息平均长度**:~1,000字符
|
||||
- **总结触发频率**:每2条AI回复
|
||||
- **预计总结次数**:(10-2)/2 = 4次
|
||||
- **LocalStorage占用**:< 1KB(仅存储计数器)
|
||||
- **API响应时间**:< 3秒(取决于LLM速度)
|
||||
|
||||
---
|
||||
|
||||
## 下一步优化
|
||||
|
||||
1. **前端集成**:在ChatBoxSlice中集成计数器逻辑
|
||||
2. **自动触发**:用户发送消息时自动检查是否达到总结阈值
|
||||
3. **进度显示**:在右侧栏任务队列中显示总结进度
|
||||
4. **批量总结**:支持一次性总结多个区间
|
||||
5. **总结历史**:保存每次总结的元数据(时间、长度等)
|
||||
|
||||
---
|
||||
|
||||
## 联系与支持
|
||||
|
||||
如有问题,请查看:
|
||||
- 后端日志:`docker-compose logs -f backend`
|
||||
- 前端控制台:浏览器开发者工具
|
||||
- 相关文档:`WORLDBOOK_ACTIVATION_LOGIC.md`, `SETTINGS_TEST_GUIDE.md`
|
||||
@@ -1,195 +0,0 @@
|
||||
# 全局世界书数据同步问题修复
|
||||
|
||||
## 🐛 问题描述
|
||||
|
||||
**现象**: 当世界书文件被删除后,LocalStorage中仍然保存着该世界书的全局状态,导致前端显示一个不存在的"幽灵"世界书。
|
||||
|
||||
**原因**: `fetchWorldBooks` 函数从LocalStorage加载全局世界书后,没有清理那些已经不存在于后端的世界书。
|
||||
|
||||
---
|
||||
|
||||
## ✅ 解决方案
|
||||
|
||||
### 修复位置
|
||||
**文件**: `frontend/src/Store/SideBarLeft/WorldBookSlice.jsx`
|
||||
**函数**: `fetchWorldBooks` (第132-165行)
|
||||
|
||||
### 修复逻辑
|
||||
|
||||
```javascript
|
||||
// 从 LocalStorage 获取全局世界书列表
|
||||
let globalBooks = loadGlobalWorldBooks();
|
||||
|
||||
// 清理 LocalStorage 中已不存在的世界书
|
||||
const existingWorldBookNames = new Set(data.map(wb => wb.name));
|
||||
const cleanedGlobalBooks = globalBooks.filter(wb => existingWorldBookNames.has(wb.name));
|
||||
|
||||
// 如果有被清理的项,更新 LocalStorage
|
||||
if (cleanedGlobalBooks.length !== globalBooks.length) {
|
||||
console.log(`清理了 ${globalBooks.length - cleanedGlobalBooks.length} 个不存在的全局世界书`);
|
||||
saveGlobalWorldBooks(cleanedGlobalBooks);
|
||||
globalBooks = cleanedGlobalBooks;
|
||||
}
|
||||
```
|
||||
|
||||
### 工作流程
|
||||
|
||||
1. **获取后端数据**: 调用 `GET /api/worldbooks/` 获取所有存在的世界书
|
||||
2. **加载LocalStorage**: 从LocalStorage读取全局世界书列表
|
||||
3. **对比清理**: 过滤掉LocalStorage中存在但后端不存在的世界书
|
||||
4. **更新存储**: 如果发现有被清理的项,更新LocalStorage
|
||||
5. **更新State**: 将清理后的列表设置到State中
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试场景
|
||||
|
||||
### 场景1: 正常情况
|
||||
**步骤**:
|
||||
1. 创建世界书A和B
|
||||
2. 将A和B都设为全局
|
||||
3. 刷新页面
|
||||
|
||||
**预期结果**:
|
||||
- ✅ 全局区域显示A和B
|
||||
- ✅ LocalStorage中有A和B
|
||||
|
||||
### 场景2: 文件被删除
|
||||
**步骤**:
|
||||
1. 创建世界书A和B
|
||||
2. 将A和B都设为全局
|
||||
3. 手动删除世界书A的文件(或其他方式删除)
|
||||
4. 刷新页面
|
||||
|
||||
**预期结果**:
|
||||
- ✅ 全局区域只显示B
|
||||
- ✅ LocalStorage中只保留B
|
||||
- ✅ 控制台输出: "清理了 1 个不存在的全局世界书"
|
||||
|
||||
### 场景3: 通过UI删除
|
||||
**步骤**:
|
||||
1. 创建世界书A和B
|
||||
2. 将A和B都设为全局
|
||||
3. 在前端UI中删除世界书A
|
||||
4. 刷新页面
|
||||
|
||||
**预期结果**:
|
||||
- ✅ 全局区域只显示B
|
||||
- ✅ LocalStorage中只保留B
|
||||
- ✅ 无错误信息
|
||||
|
||||
---
|
||||
|
||||
## 📊 数据流图
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ 页面加载/切换 │
|
||||
└────────┬────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ fetchWorldBooks │
|
||||
└────────┬────────┘
|
||||
│
|
||||
├──► GET /api/worldbooks/ ──► 后端返回现有世界书列表
|
||||
│
|
||||
├──► loadGlobalWorldBooks() ──► 从LocalStorage读取
|
||||
│
|
||||
├──► 对比两个列表
|
||||
│ ├─ 存在于LocalStorage但不存在于后端 → 清理
|
||||
│ └─ 存在于两者 → 保留
|
||||
│
|
||||
├──► saveGlobalWorldBooks() ──► 更新LocalStorage(如有变化)
|
||||
│
|
||||
└──► set state ──► 更新UI
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 关键代码说明
|
||||
|
||||
### 1. 使用Set提高查找效率
|
||||
```javascript
|
||||
const existingWorldBookNames = new Set(data.map(wb => wb.name));
|
||||
```
|
||||
- 将后端返回的世界书名称转换为Set
|
||||
- Set的查找时间复杂度为O(1),比数组的O(n)更高效
|
||||
|
||||
### 2. 过滤清理
|
||||
```javascript
|
||||
const cleanedGlobalBooks = globalBooks.filter(wb =>
|
||||
existingWorldBookNames.has(wb.name)
|
||||
);
|
||||
```
|
||||
- 只保留那些在后端也存在的世界书
|
||||
- 自动移除"幽灵"世界书
|
||||
|
||||
### 3. 条件更新
|
||||
```javascript
|
||||
if (cleanedGlobalBooks.length !== globalBooks.length) {
|
||||
console.log(`清理了 ${globalBooks.length - cleanedGlobalBooks.length} 个不存在的全局世界书`);
|
||||
saveGlobalWorldBooks(cleanedGlobalBooks);
|
||||
globalBooks = cleanedGlobalBooks;
|
||||
}
|
||||
```
|
||||
- 只有在确实有变化时才更新LocalStorage
|
||||
- 避免不必要的写入操作
|
||||
- 提供调试信息
|
||||
|
||||
---
|
||||
|
||||
## ✨ 优势
|
||||
|
||||
1. **自动清理**: 无需手动干预,自动同步LocalStorage和后端数据
|
||||
2. **性能优化**: 使用Set提高查找效率
|
||||
3. **用户友好**: 静默清理,只在控制台输出日志
|
||||
4. **数据一致性**: 确保LocalStorage中的数据始终与后端保持一致
|
||||
5. **无副作用**: 不影响正常的业务流程
|
||||
|
||||
---
|
||||
|
||||
## 📝 相关代码位置
|
||||
|
||||
### LocalStorage操作函数
|
||||
```javascript
|
||||
// 辅助函数:从 LocalStorage 加载全局世界书
|
||||
const loadGlobalWorldBooks = () => {
|
||||
try {
|
||||
const stored = localStorage.getItem(GLOBAL_WORLDBOOKS_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch (error) {
|
||||
console.error('加载全局世界书失败:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// 辅助函数:保存全局世界书到 LocalStorage
|
||||
const saveGlobalWorldBooks = (globalBooks) => {
|
||||
try {
|
||||
localStorage.setItem(GLOBAL_WORLDBOOKS_KEY, JSON.stringify(globalBooks));
|
||||
} catch (error) {
|
||||
console.error('保存全局世界书失败:', error);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 删除世界书时的清理
|
||||
```javascript
|
||||
// deleteWorldBook 函数中已经有清理逻辑
|
||||
deleteWorldBook: async (name) => {
|
||||
// ...
|
||||
const filteredGlobalBooks = state.globalWorldBooks.filter(wb => wb.name !== name);
|
||||
saveGlobalWorldBooks(filteredGlobalBooks);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 总结
|
||||
|
||||
✅ **问题已修复**: 全局世界书现在会自动清理不存在的项
|
||||
✅ **数据同步**: LocalStorage与后端数据保持一致
|
||||
✅ **用户体验**: 不再显示"幽灵"世界书
|
||||
✅ **代码健壮**: 增加了数据一致性检查机制
|
||||
@@ -1,394 +0,0 @@
|
||||
# 预设组件组装修复说明
|
||||
|
||||
## 问题描述
|
||||
|
||||
之前的实现中,`PromptAssembler` **完全没有使用预设组件(promptComponents)**,而是按照固定的 SillyTavern 规范组装提示词。这导致:
|
||||
|
||||
1. ❌ 用户自定义的预设组件被忽略
|
||||
2. ❌ 预设中的自定义指令、系统提示等无法生效
|
||||
3. ❌ 只能使用硬编码的组装逻辑,缺乏灵活性
|
||||
|
||||
---
|
||||
|
||||
## 修复方案
|
||||
|
||||
### 核心思路
|
||||
|
||||
**根据预设组件动态组装提示词**,而不是使用固定的 SillyTavern 规范。
|
||||
|
||||
### 架构设计
|
||||
|
||||
```
|
||||
前端发送 presetConfig
|
||||
↓
|
||||
后端接收 promptComponents 数组
|
||||
↓
|
||||
检测是否有预设组件
|
||||
├─ 有 → 使用 _assemble_prompt_from_components() 动态组装
|
||||
└─ 无 → 使用默认的 PromptAssembler(SillyTavern 规范)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 实现细节
|
||||
|
||||
### 1. 修改 `_assemble_prompt()` 函数
|
||||
|
||||
**文件**: `backend/services/chat_workflow_service.py`
|
||||
|
||||
#### 修复前
|
||||
```python
|
||||
def _assemble_prompt(...):
|
||||
# ❌ 直接使用固定的 PromptAssembler
|
||||
messages = self.prompt_assembler.assemble(
|
||||
character=character,
|
||||
chat_history=chat_history,
|
||||
user_input=user_message,
|
||||
active_entries=active_entries,
|
||||
config=config
|
||||
)
|
||||
return messages
|
||||
```
|
||||
|
||||
#### 修复后
|
||||
```python
|
||||
def _assemble_prompt(...):
|
||||
preset_config = request_data.get("presetConfig", {})
|
||||
prompt_components = preset_config.get("promptComponents", [])
|
||||
|
||||
# ✅ 如果有预设组件,使用预设组件组装
|
||||
if prompt_components and len(prompt_components) > 0:
|
||||
return self._assemble_prompt_from_components(
|
||||
character,
|
||||
chat_history,
|
||||
user_message,
|
||||
active_entries,
|
||||
prompt_components,
|
||||
debug_prompt
|
||||
)
|
||||
else:
|
||||
# ✅ 否则使用默认的 SillyTavern 规范组装
|
||||
print(f"[PromptAssembler] ⚠️ 未检测到预设组件,使用默认SillyTavern规范")
|
||||
return self.prompt_assembler.assemble(...)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 新增 `_assemble_prompt_from_components()` 函数
|
||||
|
||||
**功能**:根据预设组件列表动态组装提示词
|
||||
|
||||
**流程**:
|
||||
1. 过滤出启用的组件(`enabled=True`)
|
||||
2. 按顺序处理每个组件
|
||||
3. 替换模板变量(如 `{{char}}`, `{{description}}` 等)
|
||||
4. 根据组件类型创建对应的消息(System/Human/AI)
|
||||
5. 最后添加用户输入(如果还没有)
|
||||
|
||||
**代码示例**:
|
||||
```python
|
||||
def _assemble_prompt_from_components(...):
|
||||
messages = []
|
||||
|
||||
# 按顺序处理每个启用的组件
|
||||
enabled_components = [comp for comp in prompt_components if comp.get('enabled', True)]
|
||||
|
||||
for component in enabled_components:
|
||||
comp_name = component.get('name', 'Unknown')
|
||||
comp_type = component.get('type', 'text')
|
||||
comp_content = component.get('content', '')
|
||||
|
||||
# 替换模板变量
|
||||
processed_content = self._process_component_content(
|
||||
comp_content,
|
||||
character,
|
||||
chat_history,
|
||||
user_message,
|
||||
active_entries
|
||||
)
|
||||
|
||||
# 跳过空内容
|
||||
if not processed_content or processed_content.strip() == "":
|
||||
continue
|
||||
|
||||
# 根据组件类型创建消息
|
||||
if comp_type == 'system':
|
||||
messages.append(SystemMessage(content=processed_content))
|
||||
elif comp_type == 'user':
|
||||
messages.append(HumanMessage(content=processed_content))
|
||||
elif comp_type == 'assistant':
|
||||
messages.append(AIMessage(content=processed_content))
|
||||
else:
|
||||
messages.append(SystemMessage(content=processed_content))
|
||||
|
||||
# 最后添加用户输入
|
||||
if not messages or not isinstance(messages[-1], HumanMessage):
|
||||
messages.append(HumanMessage(content=user_message))
|
||||
|
||||
return messages
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 新增 `_process_component_content()` 函数
|
||||
|
||||
**功能**:处理组件内容,替换模板变量
|
||||
|
||||
**支持的变量**:
|
||||
|
||||
| 变量 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `{{char}}` | 角色名称 | `GPT` |
|
||||
| `{{user}}` | 用户名称 | `User` |
|
||||
| `{{description}}` | 角色描述 | `你是一个AI助手...` |
|
||||
| `{{personality}}` | 角色性格 | `友好、专业` |
|
||||
| `{{scenario}}` | 场景设定 | `在一个咖啡馆里` |
|
||||
| `{{mes_example}}` | 对话示例 | `<START>\nUser: ...` |
|
||||
| `{{first_mes}}` | 第一条消息 | `你好!我是...` |
|
||||
| `{{history}}` | 聊天历史 | `User: ...\nGPT: ...` |
|
||||
| `{{world_info}}` | 世界书信息 | `[世界观]\n...` |
|
||||
|
||||
**代码示例**:
|
||||
```python
|
||||
def _process_component_content(self, content, character, ...):
|
||||
# 替换角色相关变量
|
||||
content = content.replace('{{char}}', character.name)
|
||||
content = content.replace('{{description}}', character.description)
|
||||
content = content.replace('{{personality}}', character.personality)
|
||||
|
||||
# 替换聊天历史
|
||||
if '{{history}}' in content:
|
||||
history_text = self._format_chat_history(chat_history)
|
||||
content = content.replace('{{history}}', history_text)
|
||||
|
||||
# 替换世界书信息
|
||||
if '{{world_info}}' in content and active_entries:
|
||||
world_info_text = self._format_world_info(active_entries)
|
||||
content = content.replace('{{world_info}}', world_info_text)
|
||||
|
||||
return content
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. 新增辅助函数
|
||||
|
||||
#### `_format_chat_history()`
|
||||
格式化聊天历史为文本:
|
||||
```
|
||||
User: 你好
|
||||
Assistant: 你好!有什么可以帮助你的吗?
|
||||
User: 今天天气怎么样?
|
||||
```
|
||||
|
||||
#### `_format_world_info()`
|
||||
格式化世界书信息为文本:
|
||||
```
|
||||
[世界观]
|
||||
这是一个奇幻世界...
|
||||
|
||||
[地点]
|
||||
咖啡馆位于市中心...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 调试输出
|
||||
|
||||
启用 `debugPrompt: true` 时,后端会输出详细的调试信息:
|
||||
|
||||
```
|
||||
================================================================================
|
||||
[Prompt Debug] 🧩 预设组件配置
|
||||
================================================================================
|
||||
[Prompt Debug] 组件数量: 6
|
||||
1. Main Prompt (enabled=True, type=system)
|
||||
2. Character Description (enabled=True, type=system)
|
||||
3. World Info (enabled=True, type=system)
|
||||
4. Chat History (enabled=True, type=text)
|
||||
5. Author's Note (enabled=True, type=system)
|
||||
6. Post-History Instructions (enabled=False, type=system)
|
||||
================================================================================
|
||||
|
||||
[Prompt Debug] 🔄 开始组装 5 个启用的组件
|
||||
|
||||
[Prompt Debug] --- 组件 1: Main Prompt ---
|
||||
类型: system
|
||||
内容长度: 150 字符
|
||||
处理后长度: 150 字符
|
||||
完整内容:
|
||||
你是一个专业的角色扮演AI助手...
|
||||
|
||||
[Prompt Debug] --- 组件 2: Character Description ---
|
||||
类型: system
|
||||
内容长度: 300 字符
|
||||
处理后长度: 300 字符
|
||||
预览:
|
||||
[Character('GPT')]
|
||||
你是一个AI助手...
|
||||
...
|
||||
|
||||
[Prompt Debug] ✅ 组装完成,总消息数: 5
|
||||
================================================================================
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 预设组件数据结构
|
||||
|
||||
### 前端发送的格式
|
||||
|
||||
```javascript
|
||||
presetConfig: {
|
||||
selectedPreset: "MyPreset",
|
||||
parameters: {
|
||||
temperature: 0.8,
|
||||
max_tokens: 2000
|
||||
},
|
||||
promptComponents: [
|
||||
{
|
||||
name: "Main Prompt",
|
||||
type: "system",
|
||||
content: "你是一个专业的角色扮演AI助手...",
|
||||
enabled: true,
|
||||
order: 0
|
||||
},
|
||||
{
|
||||
name: "Character Description",
|
||||
type: "system",
|
||||
content: "[Character('{{char}}')]\n{{description}}\n\nPersonality: {{personality}}",
|
||||
enabled: true,
|
||||
order: 1
|
||||
},
|
||||
{
|
||||
name: "Chat History",
|
||||
type: "text",
|
||||
content: "{{history}}",
|
||||
enabled: true,
|
||||
order: 2
|
||||
},
|
||||
{
|
||||
name: "User Input",
|
||||
type: "user",
|
||||
content: "", // 空内容,后端会自动添加
|
||||
enabled: true,
|
||||
order: 3
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `name` | string | 组件名称(用于显示和调试) |
|
||||
| `type` | string | 消息类型:`system`, `user`, `assistant`, `text` |
|
||||
| `content` | string | 组件内容,支持模板变量 |
|
||||
| `enabled` | boolean | 是否启用该组件 |
|
||||
| `order` | number | 组件的顺序(从小到大) |
|
||||
|
||||
---
|
||||
|
||||
## 测试步骤
|
||||
|
||||
### 1. 测试预设组件组装
|
||||
|
||||
1. 在前端选择一个包含多个组件的预设
|
||||
2. 发送一条消息
|
||||
3. 检查后端控制台输出:
|
||||
```
|
||||
[Prompt Debug] 🧩 预设组件配置
|
||||
[Prompt Debug] 组件数量: 6
|
||||
[Prompt Debug] 🔄 开始组装 5 个启用的组件
|
||||
[Prompt Debug] ✅ 组装完成,总消息数: 5
|
||||
```
|
||||
4. 确认每个组件都被正确处理
|
||||
|
||||
### 2. 测试模板变量替换
|
||||
|
||||
1. 在预设组件中使用 `{{char}}`, `{{description}}` 等变量
|
||||
2. 发送消息
|
||||
3. 检查调试输出中的"处理后长度"和"完整内容"
|
||||
4. 确认变量已被正确替换
|
||||
|
||||
### 3. 测试禁用组件
|
||||
|
||||
1. 在预设中禁用某个组件(`enabled: false`)
|
||||
2. 发送消息
|
||||
3. 检查调试输出,确认该组件被跳过
|
||||
|
||||
### 4. 测试回退机制
|
||||
|
||||
1. 选择一个没有预设组件的预设(或清空组件)
|
||||
2. 发送消息
|
||||
3. 检查后端日志:
|
||||
```
|
||||
[PromptAssembler] ⚠️ 未检测到预设组件,使用默认SillyTavern规范
|
||||
```
|
||||
4. 确认使用了默认的 PromptAssembler
|
||||
|
||||
---
|
||||
|
||||
## 相关文件清单
|
||||
|
||||
### 后端文件
|
||||
- `backend/services/chat_workflow_service.py`
|
||||
- 修改 `_assemble_prompt()` - 添加预设组件检测
|
||||
- 新增 `_assemble_prompt_from_components()` - 基于预设组件组装
|
||||
- 新增 `_process_component_content()` - 模板变量替换
|
||||
- 新增 `_format_chat_history()` - 格式化聊天历史
|
||||
- 新增 `_format_world_info()` - 格式化世界书信息
|
||||
|
||||
### 前端文件
|
||||
- `frontend/src/Store/Mid/ChatBoxSlice.jsx`
|
||||
- 发送 `presetConfig.promptComponents` 到后端
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
### ⚠️ 重要提醒
|
||||
|
||||
1. **组件顺序很重要**
|
||||
- 组件按 `order` 字段从小到大处理
|
||||
- 确保系统提示在聊天历史之前
|
||||
- 用户输入应该在最后
|
||||
|
||||
2. **模板变量的大小写**
|
||||
- 变量名区分大小写:`{{char}}` ≠ `{{Char}}`
|
||||
- 建议使用小写
|
||||
|
||||
3. **空内容的处理**
|
||||
- 内容为空的组件会被自动跳过
|
||||
- 可以用作占位符
|
||||
|
||||
4. **兼容性**
|
||||
- 如果没有预设组件,会自动回退到 SillyTavern 规范
|
||||
- 保证向后兼容
|
||||
|
||||
---
|
||||
|
||||
## 未来改进方向
|
||||
|
||||
1. **更多模板变量**
|
||||
- `{{date}}`: 当前日期
|
||||
- `{{time}}`: 当前时间
|
||||
- `{{random}}`: 随机数
|
||||
- `{{counter}}`: 计数器
|
||||
|
||||
2. **条件渲染**
|
||||
- 支持 `{{#if variable}}...{{/if}}` 语法
|
||||
- 根据条件显示/隐藏组件
|
||||
|
||||
3. **循环渲染**
|
||||
- 支持 `{{#each entries}}...{{/each}}` 语法
|
||||
- 遍历世界书条目
|
||||
|
||||
4. **自定义函数**
|
||||
- 支持调用 Python 函数处理内容
|
||||
- 例如:`{{uppercase text}}`, `{{lowercase text}}`
|
||||
|
||||
5. **性能优化**
|
||||
- 缓存模板替换结果
|
||||
- 避免重复计算
|
||||
559
README.md
559
README.md
@@ -1,166 +1,131 @@
|
||||
# LLM Workflow Engine
|
||||
|
||||
一个基于 React + TypeScript + FastAPI 的 AI 聊天工作流引擎,支持流式对话、动态表格生成、图片生成等功能。
|
||||
一个功能强大的 LLM 聊天工作流引擎,兼容 SillyTavern 生态系统。
|
||||
|
||||
## 🚀 技术栈
|
||||
## 📋 目录
|
||||
|
||||
### 前端
|
||||
- **React 18** - 用户界面框架
|
||||
- **TypeScript** - 类型安全的 JavaScript
|
||||
- **Vite** - 现代化的前端构建工具
|
||||
- **Zustand** - 轻量级状态管理
|
||||
- **React Markdown** - Markdown 渲染
|
||||
- **Tailwind CSS** - 实用优先的 CSS 框架
|
||||
- [功能特性](#功能特性)
|
||||
- [技术栈](#技术栈)
|
||||
- [快速开始](#快速开始)
|
||||
- [项目结构](#项目结构)
|
||||
- [核心功能](#核心功能)
|
||||
- [开发指南](#开发指南)
|
||||
- [配置说明](#配置说明)
|
||||
- [常见问题](#常见问题)
|
||||
|
||||
---
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 🎯 核心功能
|
||||
|
||||
- **多模型支持** - 兼容 OpenAI、Claude、Gemini 等多种 LLM API
|
||||
- **角色卡系统** - 完整的角色创建、导入、导出功能(兼容 SillyTavern 格式)
|
||||
- **聊天管理** - 多聊天切换、历史总结、消息编辑
|
||||
- **预设系统** - 灵活的提示词组件管理,支持拖拽排序
|
||||
- **世界书** - 动态世界知识注入系统
|
||||
- **正则替换** - 强大的文本处理规则系统(完全兼容 SillyTavern)
|
||||
|
||||
### ✨ 高级功能
|
||||
|
||||
- **酒馆助手(Tavern Helper)**
|
||||
- JavaScript 沙盒执行引擎
|
||||
- 提示词模板系统(支持 `{{var}}`、`{{roll}}`、`{{random}}` 等语法)
|
||||
- 脚本管理(全局/角色/预设三种作用域)
|
||||
- 代码块渲染功能
|
||||
|
||||
- **多主题支持** - 完整的 CSS 变量主题系统
|
||||
- **流式输出** - 实时显示 AI 生成内容
|
||||
- **消息 Swipes** - 多版本切换和重roll功能
|
||||
- **API 配置管理** - 安全的 API Key 存储和加密
|
||||
|
||||
### 🔒 安全特性
|
||||
|
||||
- API Key 加密存储(Fernet 对称加密)
|
||||
- JavaScript 沙盒隔离执行
|
||||
- 危险 API 拦截机制
|
||||
- 环境变量安全管理
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
### 后端
|
||||
- **FastAPI** - 现代化的 Python Web 框架
|
||||
- **Python 3.11** - 编程语言
|
||||
- **Uvicorn** - ASGI 服务器
|
||||
- **WebSockets** - 实时通信
|
||||
|
||||
## 📁 项目结构
|
||||
- **框架**: FastAPI (Python 3.11+)
|
||||
- **数据库**: 文件系统 + JSON(轻量级,易于备份)
|
||||
- **WebSocket**: 实时流式通信
|
||||
- **加密**: Fernet 对称加密(cryptography 库)
|
||||
- **依赖管理**: pip + requirements.txt
|
||||
|
||||
```
|
||||
llm_workflow_engine/
|
||||
├── backend/ # 后端服务
|
||||
│ ├── api/ # API 路由
|
||||
│ ├── core/ # 核心模型和配置
|
||||
│ ├── tools/ # 工具函数
|
||||
│ ├── workflows/ # 工作流定义
|
||||
│ ├── Dockerfile # 后端 Docker 配置
|
||||
│ ├── main.py # 后端入口
|
||||
│ └── requirements.txt # Python 依赖
|
||||
├── frontend/ # 前端服务
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # React 组件
|
||||
│ │ ├── Store/ # 状态管理
|
||||
│ │ ├── services/ # API 服务
|
||||
│ │ ├── types/ # TypeScript 类型定义
|
||||
│ │ ├── App.tsx # 主应用组件
|
||||
│ │ └── main.tsx # 入口文件
|
||||
│ ├── Dockerfile # 前端 Docker 配置
|
||||
│ ├── nginx.conf # Nginx 配置(生产环境)
|
||||
│ ├── package.json # Node.js 依赖
|
||||
│ └── tsconfig.json # TypeScript 配置
|
||||
├── data/ # 数据存储
|
||||
├── docker-compose.yml # Docker Compose 配置
|
||||
└── README.md # 项目文档
|
||||
```
|
||||
### 前端
|
||||
|
||||
## 🛠️ 安装和运行
|
||||
- **框架**: React 18 + Vite
|
||||
- **状态管理**: Zustand(轻量级 Redux 替代)
|
||||
- **样式**: CSS3 + CSS 变量(支持多主题)
|
||||
- **Markdown**: react-markdown + remark-gfm
|
||||
- **HTTP 客户端**: Fetch API
|
||||
|
||||
### 使用 Docker Compose(推荐)
|
||||
### 部署
|
||||
|
||||
这是最简单的运行方式,适合开发和生产环境。
|
||||
- **容器化**: Docker + Docker Compose
|
||||
- **反向代理**: Nginx
|
||||
- **开发服务器**: Vite HMR
|
||||
|
||||
1. **克隆项目**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd llm_workflow_engine
|
||||
```
|
||||
---
|
||||
|
||||
2. **配置环境变量**
|
||||
```bash
|
||||
# 复制环境变量模板
|
||||
cp .env.example .env
|
||||
## 快速开始
|
||||
|
||||
# 根据需要编辑 .env 文件
|
||||
```
|
||||
### 环境要求
|
||||
|
||||
3. **启动服务**
|
||||
```bash
|
||||
# 构建并启动所有服务
|
||||
docker-compose up --build
|
||||
|
||||
# 或者在后台运行
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
4. **访问应用**
|
||||
- 前端界面: http://localhost:23338
|
||||
- 后端 API: http://localhost:23337
|
||||
- API 文档: http://localhost:23337/docs
|
||||
|
||||
5. **停止服务**
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
- Python 3.11+
|
||||
- Node.js 18+
|
||||
- Docker & Docker Compose(可选)
|
||||
|
||||
### 本地开发
|
||||
|
||||
如果你想分别运行前后端进行开发:
|
||||
#### 1. 克隆项目
|
||||
|
||||
#### 后端开发
|
||||
|
||||
1. **安装 Python 依赖**
|
||||
```bash
|
||||
git clone https://github.com/your-repo/llm-workflow-engine.git
|
||||
cd llm-workflow-engine
|
||||
```
|
||||
|
||||
#### 2. 后端启动
|
||||
|
||||
```bash
|
||||
# 创建虚拟环境
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||
|
||||
# 安装依赖
|
||||
pip install -r backend/requirements.txt
|
||||
|
||||
# 启动服务
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
python main.py
|
||||
```
|
||||
|
||||
2. **启动后端服务**
|
||||
```bash
|
||||
python -m uvicorn backend.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
```
|
||||
后端服务将在 `http://localhost:23338` 启动。
|
||||
|
||||
#### 前端开发
|
||||
#### 3. 前端启动
|
||||
|
||||
1. **安装 Node.js 依赖**
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
2. **启动前端开发服务器**
|
||||
```bash
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 启动开发服务器
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3. **访问应用**
|
||||
- 前端界面: http://localhost:5173
|
||||
- 确保后端在 http://localhost:8000 运行
|
||||
前端将在 `http://localhost:5173` 启动,自动代理 API 请求到后端。
|
||||
|
||||
## 🔧 配置说明
|
||||
|
||||
### 环境变量
|
||||
|
||||
#### 前端环境变量 (frontend/.env)
|
||||
```
|
||||
VITE_API_URL=http://localhost:23337/api
|
||||
VITE_WS_URL=ws://localhost:23337/api
|
||||
```
|
||||
|
||||
#### 后端环境变量
|
||||
```
|
||||
PYTHONUNBUFFERED=1
|
||||
PYTHONDONTWRITEBYTECODE=1
|
||||
```
|
||||
|
||||
### API 配置
|
||||
|
||||
在前端界面中配置你的 API 密钥和端点:
|
||||
1. 打开左侧栏的 "API 配置" 标签
|
||||
2. 添加你的 API 配置(URL 和密钥)
|
||||
3. 选择要使用的 API
|
||||
|
||||
## 📖 功能特性
|
||||
|
||||
- ✅ **流式对话** - 实时显示 AI 回复
|
||||
- ✅ **多角色支持** - 支持多个聊天角色和会话
|
||||
- ✅ **消息编辑** - 可以编辑和删除历史消息
|
||||
- ✅ **HTML 渲染** - 支持 Markdown 和 HTML 渲染
|
||||
- ✅ **动态表格** - 自动生成和更新数据表格
|
||||
- ✅ **图片生成** - 集成图片生成工作流
|
||||
- ✅ **世界书** - 管理角色和世界设定
|
||||
- ✅ **预设管理** - 保存和加载不同的对话预设
|
||||
|
||||
## 🐳 Docker 命令参考
|
||||
### Docker 部署
|
||||
|
||||
```bash
|
||||
# 构建并启动
|
||||
docker-compose up --build
|
||||
|
||||
# 后台运行
|
||||
# 一键启动
|
||||
docker-compose up -d
|
||||
|
||||
# 查看日志
|
||||
@@ -168,59 +133,319 @@ docker-compose logs -f
|
||||
|
||||
# 停止服务
|
||||
docker-compose down
|
||||
|
||||
# 重启服务
|
||||
docker-compose restart
|
||||
|
||||
# 进入容器
|
||||
docker-compose exec backend bash
|
||||
docker-compose exec frontend sh
|
||||
|
||||
# 清理所有容器和卷
|
||||
docker-compose down -v
|
||||
```
|
||||
|
||||
## 🔍 开发工具
|
||||
访问 `http://localhost:80` 即可使用。
|
||||
|
||||
### 前端
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
llm-workflow-engine/
|
||||
├── backend/ # 后端服务
|
||||
│ ├── api/ # API 路由
|
||||
│ │ └── routes/ # 路由处理
|
||||
│ ├── core/ # 核心配置
|
||||
│ ├── models/ # 数据模型
|
||||
│ ├── services/ # 业务逻辑
|
||||
│ │ ├── chat_service.py # 聊天服务
|
||||
│ │ ├── js_sandbox.py # JavaScript 沙盒
|
||||
│ │ ├── script_manager.py # 脚本管理器
|
||||
│ │ ├── regex_service.py # 正则服务
|
||||
│ │ └── ...
|
||||
│ ├── utils/ # 工具函数
|
||||
│ ├── main.py # 应用入口
|
||||
│ └── requirements.txt # Python 依赖
|
||||
│
|
||||
├── frontend/ # 前端应用
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # React 组件
|
||||
│ │ │ ├── Mid/ # 中间区域(聊天框)
|
||||
│ │ │ ├── SideBarLeft/ # 左侧边栏
|
||||
│ │ │ │ └── tabs/ # 标签页组件
|
||||
│ │ │ │ └── TavernHelper/ # 酒馆助手
|
||||
│ │ │ ├── SideBarRight/# 右侧边栏
|
||||
│ │ │ └── TopBar/ # 顶部栏
|
||||
│ │ ├── Store/ # Zustand 状态管理
|
||||
│ │ ├── styles/ # 全局样式
|
||||
│ │ ├── types/ # TypeScript 类型定义
|
||||
│ │ ├── utils/ # 工具函数
|
||||
│ │ ├── App.jsx # 根组件
|
||||
│ │ └── main.jsx # 应用入口
|
||||
│ ├── package.json # Node.js 依赖
|
||||
│ └── vite.config.js # Vite 配置
|
||||
│
|
||||
├── data/ # 数据目录(运行时生成)
|
||||
│ ├── chat/ # 聊天记录
|
||||
│ ├── preset/ # 预设文件
|
||||
│ ├── worldbooks/ # 世界书
|
||||
│ ├── regex/ # 正则规则
|
||||
│ └── ...
|
||||
│
|
||||
├── docker-compose.yml # Docker 编排
|
||||
├── .env.example # 环境变量示例
|
||||
├── .gitignore # Git 忽略文件
|
||||
└── README.md # 项目文档
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心功能
|
||||
|
||||
### 1. 酒馆助手(Tavern Helper)
|
||||
|
||||
完全兼容 SillyTavern 酒馆助手的提示词模板系统。
|
||||
|
||||
#### 支持的语法
|
||||
|
||||
| 语法 | 功能 | 示例 |
|
||||
|------|------|------|
|
||||
| `{{var}}` 或 `{{getvar::key}}` | 获取变量 | `{{name}}` |
|
||||
| `{{setvar::key::value}}` | 设置变量 | `{{setvar::age::25}}` |
|
||||
| `{{delvar::key}}` | 删除变量 | `{{delvar::temp}}` |
|
||||
| `{{random::a,b,c}}` | 随机选择(逗号) | `{{random::苹果,香蕉,橙子}}` |
|
||||
| `{{pick::a\|b\|c}}` | 随机选择(竖线) | `{{pick::剑\|斧\|弓}}` |
|
||||
| `{{roll XdY}}` | 掷骰子 | `{{roll 3d6}}` |
|
||||
| `{{// 注释}}` | 注释(不输出) | `{{// 这是注释}}` |
|
||||
|
||||
#### 使用示例
|
||||
|
||||
```python
|
||||
from backend.services.js_sandbox import JSSandboxExecutor
|
||||
|
||||
sandbox = JSSandboxExecutor()
|
||||
|
||||
template = """
|
||||
{{setvar::character::勇者}}
|
||||
{{setvar::weapon::{{random::剑,斧,弓}}}}
|
||||
{{character}}手持{{weapon}},掷出了:{{roll 1d20}}
|
||||
{{// 这是注释,不会显示}}
|
||||
""".strip()
|
||||
|
||||
result = sandbox.render_template(template)
|
||||
print(result)
|
||||
# 输出: 勇者手持剑,掷出了:15
|
||||
```
|
||||
|
||||
#### 脚本管理
|
||||
|
||||
支持三种作用域的脚本:
|
||||
|
||||
- **GLOBAL** - 全局脚本,对所有聊天可用
|
||||
- **CHARACTER** - 角色脚本,绑定到当前角色卡
|
||||
- **PRESET** - 预设脚本,绑定到当前预设
|
||||
|
||||
详细文档:[TAVERN_HELPER_IMPLEMENTATION.md](./TAVERN_HELPER_IMPLEMENTATION.md)
|
||||
|
||||
### 2. 正则替换系统
|
||||
|
||||
强大的文本处理规则,完全兼容 SillyTavern 格式。
|
||||
|
||||
#### 应用位置(placement)
|
||||
|
||||
- `0` - System Prompt(系统提示词)
|
||||
- `1` - User Input(用户输入)
|
||||
- `2` - AI Output(AI 输出)
|
||||
- `3` - Quick Reply(快捷回复)
|
||||
- `4` - World Info(世界书信息)
|
||||
- `5` - Reasoning/Thinking(推理/思考内容)
|
||||
|
||||
#### 规则示例
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "hide-thinking-001",
|
||||
"scriptName": "隐藏思考标签",
|
||||
"findRegex": "<thinking>[\\s\\S]*?<\\/thinking>",
|
||||
"replaceString": "",
|
||||
"placement": [2],
|
||||
"substituteRegex": 0,
|
||||
"markdownOnly": false,
|
||||
"promptOnly": false,
|
||||
"disabled": false
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 预设系统
|
||||
|
||||
灵活的提示词组件管理。
|
||||
|
||||
#### 特性
|
||||
|
||||
- 多组件拖拽排序
|
||||
- 角色字段支持(system/user/assistant)
|
||||
- 注入位置控制(injection_position)
|
||||
- 注入深度控制(injection_depth)
|
||||
- 触发条件(injection_trigger)
|
||||
- 完全兼容 SillyTavern 预设格式
|
||||
|
||||
### 4. 聊天管理
|
||||
|
||||
完整的聊天生命周期管理。
|
||||
|
||||
#### 功能
|
||||
|
||||
- 多聊天切换
|
||||
- 消息编辑和保存
|
||||
- 消息 Swipes(多版本)
|
||||
- 右键菜单(编辑/复制/重roll/删除)
|
||||
- 历史总结
|
||||
- 智能滚动
|
||||
|
||||
---
|
||||
|
||||
## 开发指南
|
||||
|
||||
### API 路由
|
||||
|
||||
所有 API 路由定义在 `backend/api/routes/` 目录下:
|
||||
|
||||
- `chatWsRoute.py` - WebSocket 聊天(流式输出)
|
||||
- `chatsRoute.py` - 聊天管理
|
||||
- `charactersRoute.py` - 角色卡管理
|
||||
- `presetsRoute.py` - 预设管理
|
||||
- `worldbooksRoute.py` - 世界书管理
|
||||
- `regexRoute.py` - 正则规则管理
|
||||
- `apiConfigRoute.py` - API 配置管理
|
||||
|
||||
### 状态管理
|
||||
|
||||
前端使用 Zustand 进行状态管理,store 定义在 `frontend/src/Store/`:
|
||||
|
||||
```
|
||||
Store/
|
||||
├── Mid/ # 中间区域状态
|
||||
│ ├── ChatBoxSlice.jsx # 聊天框状态
|
||||
│ └── ChatBoxUISlice.jsx # 聊天框 UI 状态
|
||||
├── SideBarLeft/ # 左侧边栏状态
|
||||
├── SideBarRight/ # 右侧边栏状态
|
||||
└── TopBar/ # 顶部栏状态
|
||||
```
|
||||
|
||||
### 样式系统
|
||||
|
||||
使用 CSS 变量实现多主题:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--color-bg-primary: #ffffff;
|
||||
--color-text-primary: #1a1a1a;
|
||||
--color-accent: #667eea;
|
||||
/* ... */
|
||||
}
|
||||
|
||||
[data-color-theme='dark'] {
|
||||
--color-bg-primary: #1a1a1a;
|
||||
--color-text-primary: #ffffff;
|
||||
/* ... */
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 配置说明
|
||||
|
||||
### 环境变量
|
||||
|
||||
创建 `.env` 文件(从 `.env.example` 复制):
|
||||
|
||||
```env
|
||||
# 后端配置
|
||||
HOST=0.0.0.0
|
||||
PORT=23338
|
||||
DEBUG=True
|
||||
|
||||
# 前端代理
|
||||
VITE_API_URL=http://localhost:23338
|
||||
|
||||
# API 加密密钥(自动生成,不要手动修改)
|
||||
FERNET_KEY=your_generated_key_here
|
||||
```
|
||||
|
||||
### API 配置
|
||||
|
||||
API Key 通过前端界面配置,自动加密存储到 `data/apiconfig/` 目录。
|
||||
|
||||
⚠️ **注意**:`data/apiconfig/*.json` 已添加到 `.gitignore`,不会被提交到版本控制。
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 1. 前端无法连接后端
|
||||
|
||||
**问题**: 前端请求返回 404 或网络连接错误
|
||||
|
||||
**解决**:
|
||||
```bash
|
||||
# 类型检查
|
||||
npm run type-check
|
||||
# 检查后端是否运行
|
||||
curl http://localhost:23338/api/health
|
||||
|
||||
# 构建
|
||||
npm run build
|
||||
|
||||
# 预览生产构建
|
||||
npm run preview
|
||||
# 检查前端代理配置
|
||||
cat frontend/vite.config.js
|
||||
```
|
||||
|
||||
### 后端
|
||||
### 2. API Key 不生效
|
||||
|
||||
**问题**: 配置了 API Key 但仍然无法调用 LLM
|
||||
|
||||
**解决**:
|
||||
1. 检查 API 配置文件是否存在:`data/apiconfig/`
|
||||
2. 检查加密密钥是否正确:`.env` 中的 `FERNET_KEY`
|
||||
3. 重启后端服务
|
||||
|
||||
### 3. Docker 部署后无法访问
|
||||
|
||||
**问题**: `docker-compose up` 后无法访问服务
|
||||
|
||||
**解决**:
|
||||
```bash
|
||||
# 运行测试(如果有的话)
|
||||
cd backend
|
||||
pytest
|
||||
# 查看容器状态
|
||||
docker-compose ps
|
||||
|
||||
# 代码格式化
|
||||
black .
|
||||
# 查看日志
|
||||
docker-compose logs -f backend
|
||||
docker-compose logs -f frontend
|
||||
|
||||
# 重新构建
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
## 📝 待办事项
|
||||
### 4. 正则规则不生效
|
||||
|
||||
- [ ] 添加单元测试
|
||||
- [ ] 完善错误处理
|
||||
- [ ] 添加用户认证
|
||||
- [ ] 优化性能
|
||||
- [ ] 添加更多语言支持
|
||||
- [ ] 完善文档
|
||||
**问题**: 配置了正则规则但没有效果
|
||||
|
||||
## 🤝 贡献
|
||||
**解决**:
|
||||
1. 检查规则是否启用(disabled: false)
|
||||
2. 检查 placement 是否正确
|
||||
3. 检查正则表达式语法
|
||||
4. 重启后端服务
|
||||
|
||||
欢迎提交 Issue 和 Pull Request!
|
||||
---
|
||||
|
||||
## 📄 许可证
|
||||
## 贡献指南
|
||||
|
||||
MIT License
|
||||
1. Fork 项目
|
||||
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 开启 Pull Request
|
||||
|
||||
## 📞 联系方式
|
||||
---
|
||||
|
||||
如有问题,请提交 Issue 或联系维护者。
|
||||
## 许可证
|
||||
|
||||
本项目遵循与 SillyTavern 相同的分发协议。
|
||||
|
||||
---
|
||||
|
||||
## 致谢
|
||||
|
||||
- [SillyTavern](https://github.com/SillyTavern/SillyTavern) - 优秀的开源项目,提供了设计灵感和兼容标准
|
||||
- [JS-Slash-Runner](https://github.com/N0VI028/JS-Slash-Runner) - Tavern Helper 扩展,提供了 JavaScript 沙盒实现参考
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2026-05-05
|
||||
**版本**: 1.0.0
|
||||
|
||||
@@ -1,396 +0,0 @@
|
||||
# 正则系统集成完成报告
|
||||
|
||||
## 📋 概述
|
||||
|
||||
已成功将 SillyTavern 兼容的正则系统完整集成到聊天工作流中。实现了 `promptOnly` 和 `markdownOnly` 字段的核心逻辑,并完成了预设导入时自动提取正则规则的功能。
|
||||
|
||||
---
|
||||
|
||||
## ✅ 已完成的功能
|
||||
|
||||
### 1. 核心逻辑实现
|
||||
|
||||
#### 后端服务增强 (`backend/services/regex_service.py`)
|
||||
|
||||
**新增参数支持**:
|
||||
```python
|
||||
def apply_rules_by_placement(
|
||||
self,
|
||||
text: str,
|
||||
placement: int,
|
||||
character_name: Optional[str] = None,
|
||||
preset_name: Optional[str] = None,
|
||||
message_depth: int = 0,
|
||||
is_for_llm: bool = False, # ✅ 新增
|
||||
is_markdown_rendered: bool = False # ✅ 新增
|
||||
) -> str:
|
||||
```
|
||||
|
||||
**核心逻辑**:
|
||||
- ✅ `promptOnly=True`: 仅在发送给LLM时应用规则
|
||||
- ✅ `markdownOnly=True`: 仅在Markdown渲染后应用规则
|
||||
- ✅ 两个字段可以组合使用
|
||||
|
||||
---
|
||||
|
||||
#### 聊天流程集成 (`backend/services/chat_workflow_service.py`)
|
||||
|
||||
**用户输入处理**(两处):
|
||||
```python
|
||||
# process_chat_request() - 第158行
|
||||
processed_user_message = regex_service.apply_rules_by_placement(
|
||||
text=user_message,
|
||||
placement=RegexPlacement.USER_INPUT.value,
|
||||
character_name=current_role,
|
||||
preset_name=preset_name,
|
||||
message_depth=0,
|
||||
is_for_llm=True, # ✅ 用户输入会发送给LLM
|
||||
is_markdown_rendered=False
|
||||
)
|
||||
|
||||
# process_chat_request_stream() - 第1239行
|
||||
# 相同的逻辑
|
||||
```
|
||||
|
||||
**AI输出处理**(两处):
|
||||
```python
|
||||
# process_chat_request() - 第247行
|
||||
processed_ai_output = regex_service.apply_rules_by_placement(
|
||||
text=generated_content,
|
||||
placement=RegexPlacement.AI_OUTPUT.value,
|
||||
character_name=current_role,
|
||||
preset_name=preset_name,
|
||||
message_depth=0,
|
||||
is_for_llm=False, # ✅ AI输出是显示给用户的
|
||||
is_markdown_rendered=False
|
||||
)
|
||||
|
||||
# process_chat_request_stream() - 第1379行
|
||||
# 相同的逻辑
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 预设导入集成
|
||||
|
||||
#### 前端 (`frontend/src/components/SideBarLeft/tabs/Presets/Presets.jsx`)
|
||||
|
||||
**修改位置**:`handleImportPreset()` 函数(第458行)
|
||||
|
||||
**功能**:
|
||||
```javascript
|
||||
// ✅ 检测预设文件中的 extensions.regex_scripts
|
||||
if (importedPreset.extensions?.regex_scripts && importedPreset.extensions.regex_scripts.length > 0) {
|
||||
const regexScripts = importedPreset.extensions.regex_scripts;
|
||||
|
||||
// 发送到后端保存
|
||||
await fetch('/api/regex/import-from-preset', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
rules: regexScripts,
|
||||
scope: 'preset',
|
||||
presetName: presetName
|
||||
})
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**效果**:
|
||||
- 导入预设时自动检测 `extensions.regex_scripts`
|
||||
- 将正则规则保存到 `data/regex/presets/{presetName}.json`
|
||||
- 在控制台输出详细的日志信息
|
||||
|
||||
---
|
||||
|
||||
#### 后端API (`backend/api/routes/regexRoute.py`)
|
||||
|
||||
**新增端点**:`POST /api/regex/import-from-preset`
|
||||
|
||||
**请求格式**:
|
||||
```json
|
||||
{
|
||||
"rules": [...], // SillyTavern 格式的 regex_scripts 数组
|
||||
"scope": "preset", // 作用域:global/character/preset
|
||||
"presetName": "预设名称" // 当 scope 为 preset 时需要
|
||||
}
|
||||
```
|
||||
|
||||
**响应格式**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "成功导入 X 条正则规则",
|
||||
"rulesetId": "uuid-string"
|
||||
}
|
||||
```
|
||||
|
||||
**功能**:
|
||||
- 接收JSON数据(而非文件上传)
|
||||
- 自动转换 SillyTavern 格式
|
||||
- 保存到对应的文件夹(global/characters/presets)
|
||||
- 重新加载规则到内存缓存
|
||||
|
||||
---
|
||||
|
||||
### 3. 文档
|
||||
|
||||
**创建了完整的架构文档**:
|
||||
- `SILLYTAVERN_REGEX_ARCHITECTURE.md` - 502行详细文档
|
||||
- 核心概念解释(Scope、Placement、promptOnly、markdownOnly)
|
||||
- 数据结构定义
|
||||
- 导入导出流程
|
||||
- 应用时机图解
|
||||
- 使用示例
|
||||
- 待实现功能清单
|
||||
|
||||
---
|
||||
|
||||
## 🎯 工作流程
|
||||
|
||||
### 完整的正则应用流程
|
||||
|
||||
```
|
||||
用户发送消息
|
||||
↓
|
||||
[前端] handleImportPreset() 检测到预设中的正则规则
|
||||
↓
|
||||
[前端] 调用 POST /api/regex/import-from-preset
|
||||
↓
|
||||
[后端] regex_service._convert_sillytavern_format()
|
||||
↓
|
||||
[后端] 保存到 data/regex/presets/{presetName}.json
|
||||
↓
|
||||
[后端] regex_service._load_all_rules() 重新加载
|
||||
↓
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
用户再次发送消息(使用包含正则的预设)
|
||||
↓
|
||||
[后端] chat_workflow_service.process_chat_request()
|
||||
↓
|
||||
[后端] 获取预设名称 preset_name
|
||||
↓
|
||||
[后端] regex_service.apply_rules_by_placement(
|
||||
user_message,
|
||||
USER_INPUT,
|
||||
is_for_llm=True ← 用户输入会发送给LLM
|
||||
)
|
||||
↓
|
||||
[后端] 检查规则的 promptOnly 和 markdownOnly
|
||||
↓
|
||||
[后端] 应用匹配的规则
|
||||
↓
|
||||
[后端] 组装Prompt并发送给LLM
|
||||
↓
|
||||
[后端] 接收AI回复
|
||||
↓
|
||||
[后端] regex_service.apply_rules_by_placement(
|
||||
ai_output,
|
||||
AI_OUTPUT,
|
||||
is_for_llm=False ← AI输出是显示给用户
|
||||
)
|
||||
↓
|
||||
[后端] 保存消息到文件
|
||||
↓
|
||||
[前端] 显示给用户
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 测试验证
|
||||
|
||||
### 测试文件
|
||||
- `test_regex_integration.py` - 基础正则功能测试
|
||||
|
||||
### 测试结果
|
||||
✅ 正则表达式正常工作
|
||||
✅ `promptOnly` 逻辑正确(只在is_for_llm=True时应用)
|
||||
✅ `markdownOnly` 逻辑正确(只在is_markdown_rendered=True时应用)
|
||||
|
||||
---
|
||||
|
||||
## 🔍 实际应用示例
|
||||
|
||||
### 示例1:隐藏思考标签(仅影响LLM)
|
||||
|
||||
**规则配置**:
|
||||
```json
|
||||
{
|
||||
"scriptName": "隐藏思考标签",
|
||||
"findRegex": "/<thinking>[\\s\\S]*?<\\/thinking>/gs",
|
||||
"replaceString": "",
|
||||
"placement": [2],
|
||||
"promptOnly": true,
|
||||
"markdownOnly": false
|
||||
}
|
||||
```
|
||||
|
||||
**效果**:
|
||||
- ✅ 发送给LLM:不包含 `<thinking>` 标签
|
||||
- ✅ 用户看到:仍然显示 `<thinking>` 标签
|
||||
|
||||
---
|
||||
|
||||
### 示例2:美化TIPS显示(仅影响渲染)
|
||||
|
||||
**规则配置**:
|
||||
```json
|
||||
{
|
||||
"scriptName": "世界知识",
|
||||
"findRegex": "/TIPS_DESIGN\\[世界知识\\]/g",
|
||||
"replaceString": "<details><summary>点击查看</summary>...</details>",
|
||||
"placement": [1, 2],
|
||||
"promptOnly": false,
|
||||
"markdownOnly": true
|
||||
}
|
||||
```
|
||||
|
||||
**效果**:
|
||||
- ✅ 原始文本:保持 `TIPS_DESIGN[世界知识]`
|
||||
- ✅ 渲染后:显示为可折叠的详情框
|
||||
|
||||
---
|
||||
|
||||
## 📁 文件结构
|
||||
|
||||
```
|
||||
llm_workflow_engine/
|
||||
├── backend/
|
||||
│ ├── services/
|
||||
│ │ ├── regex_service.py ✅ 已增强(添加 promptOnly/markdownOnly)
|
||||
│ │ └── chat_workflow_service.py ✅ 已集成(用户输入 + AI输出处理)
|
||||
│ ├── api/routes/
|
||||
│ │ └── regexRoute.py ✅ 已添加新端点 /import-from-preset
|
||||
│ └── models/
|
||||
│ └── regex_rules.py ✅ 已有完整模型定义
|
||||
├── frontend/
|
||||
│ └── src/components/SideBarLeft/tabs/Presets/
|
||||
│ └── Presets.jsx ✅ 已集成预设导入时的正则提取
|
||||
├── data/
|
||||
│ └── regex/
|
||||
│ ├── global/ # 全局规则
|
||||
│ ├── characters/ # 角色卡绑定规则
|
||||
│ └── presets/ # 预设绑定规则(自动创建)
|
||||
├── SILLYTAVERN_REGEX_ARCHITECTURE.md ✅ 完整架构文档
|
||||
└── test_regex_integration.py ✅ 测试文件
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 1. 字段含义澄清
|
||||
|
||||
根据 SillyTavern 官方实现和我们从预设文件中分析的结果:
|
||||
|
||||
- **`promptOnly: boolean`**
|
||||
- `true`: 仅应用于**发送给LLM的提示词**(用户看不到变化)
|
||||
- `false`: 应用于**前端显示给用户的内容**
|
||||
|
||||
- **`markdownOnly: boolean`**
|
||||
- `true`: 仅在 **Markdown 渲染后**应用(影响最终HTML显示)
|
||||
- `false`: 在**原始文本**上应用
|
||||
|
||||
### 2. 当前限制
|
||||
|
||||
- ❌ 尚未实现前端正则管理UI(可选功能)
|
||||
- ❌ 尚未实现对系统提示词和世界书的正则处理(需要进一步集成)
|
||||
- ⚠️ `markdownOnly` 需要前端配合,在Markdown渲染后再次应用规则
|
||||
|
||||
---
|
||||
|
||||
## 🚀 下一步计划
|
||||
|
||||
### 第二阶段(已完成)
|
||||
- ✅ 预设导入时提取正则规则
|
||||
- ✅ 后端API支持JSON数据导入
|
||||
- ✅ 自动保存到 `data/regex/presets/`
|
||||
|
||||
### 第三阶段(可选)
|
||||
- [ ] 创建前端正则管理页面
|
||||
- [ ] 实现规则编辑器
|
||||
- [ ] 添加导入/导出按钮
|
||||
- [ ] 实现启用/禁用开关
|
||||
- [ ] 实现拖拽排序
|
||||
|
||||
### 第四阶段(高级)
|
||||
- [ ] 在 `_assemble_prompt_from_components()` 中处理系统提示词
|
||||
- [ ] 在世界书激活时应用正则规则
|
||||
- [ ] 前端Markdown渲染后再次应用 `markdownOnly` 规则
|
||||
|
||||
---
|
||||
|
||||
## 💡 关键代码片段
|
||||
|
||||
### 后端:promptOnly 检查逻辑
|
||||
|
||||
```python
|
||||
# backend/services/regex_service.py - 第227-232行
|
||||
for rule in rules:
|
||||
# ✅ 检查 promptOnly:如果规则只应用于LLM,但当前不是LLM场景,则跳过
|
||||
if rule.promptOnly and not is_for_llm:
|
||||
continue
|
||||
|
||||
# ✅ 检查 markdownOnly:如果规则只应用于Markdown渲染,但当前不是渲染后,则跳过
|
||||
if rule.markdownOnly and not is_markdown_rendered:
|
||||
continue
|
||||
|
||||
# ... 继续应用规则
|
||||
```
|
||||
|
||||
### 前端:预设导入时提取正则
|
||||
|
||||
```javascript
|
||||
// frontend/src/components/SideBarLeft/tabs/Presets/Presets.jsx - 第461-485行
|
||||
if (importedPreset.extensions?.regex_scripts && importedPreset.extensions.regex_scripts.length > 0) {
|
||||
const regexScripts = importedPreset.extensions.regex_scripts;
|
||||
console.log(`\n[预设导入] 🔍 检测到 ${regexScripts.length} 条正则规则`);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/regex/import-from-preset', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
rules: regexScripts,
|
||||
scope: 'preset',
|
||||
presetName: presetName
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
console.log('[预设导入] ✅ 正则规则已保存到:', `data/regex/presets/${presetName}.json`);
|
||||
console.log('[预设导入] 📊 导入结果:', result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[预设导入] ❌ 保存正则规则失败:', error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✨ 总结
|
||||
|
||||
✅ **核心功能已完成**:
|
||||
1. `promptOnly` 和 `markdownOnly` 字段逻辑已实现
|
||||
2. 用户输入和AI输出的正则处理已集成到聊天流程
|
||||
3. 预设导入时自动提取并保存正则规则
|
||||
4. 完整的架构文档已创建
|
||||
|
||||
🎉 **现在可以**:
|
||||
- 导入包含 `extensions.regex_scripts` 的 SillyTavern 预设
|
||||
- 正则规则会自动保存到 `data/regex/presets/` 文件夹
|
||||
- 聊天时自动应用适用的正则规则(全局+角色+预设)
|
||||
- 根据 `promptOnly` 和 `markdownOnly` 智能控制规则应用时机
|
||||
|
||||
📝 **建议测试**:
|
||||
1. 导入一个包含正则规则的预设(如 A.U.T.O.预设)
|
||||
2. 检查 `data/regex/presets/` 文件夹是否生成了对应的JSON文件
|
||||
3. 发送消息,观察控制台日志中的 `[Regex] ✅ 已应用...` 输出
|
||||
4. 验证规则是否正确应用(如思考标签是否被隐藏)
|
||||
|
||||
---
|
||||
|
||||
**完成时间**: 2026-05-05
|
||||
**版本**: v1.0
|
||||
**状态**: ✅ 核心功能已完成,可投入使用
|
||||
@@ -1,194 +0,0 @@
|
||||
# 世界书删除确认弹窗移除
|
||||
|
||||
## 📋 修改内容
|
||||
|
||||
移除了世界书模块中所有的浏览器级别确认弹窗(`confirm`),改为直接执行删除操作。
|
||||
|
||||
---
|
||||
|
||||
## ✅ 已移除的确认弹窗
|
||||
|
||||
### 1. 删除世界书条目
|
||||
**位置**: `WorldBook.jsx` 第338-349行
|
||||
|
||||
**修改前**:
|
||||
```javascript
|
||||
const handleDeleteEntry = async () => {
|
||||
if (!currentEntry || !currentWorldBook) return;
|
||||
|
||||
if (confirm('确定要删除此条目吗?')) {
|
||||
try {
|
||||
await deleteWorldBookEntry(currentWorldBook.name, currentEntry.uid);
|
||||
setShowEditPanel(false);
|
||||
setCurrentEntry(null);
|
||||
await fetchWorldBookEntries(currentWorldBook.name, currentPage, pageSize);
|
||||
} catch (err) {
|
||||
console.error('删除条目失败:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```javascript
|
||||
const handleDeleteEntry = async () => {
|
||||
if (!currentEntry || !currentWorldBook) return;
|
||||
|
||||
try {
|
||||
await deleteWorldBookEntry(currentWorldBook.name, currentEntry.uid);
|
||||
setShowEditPanel(false);
|
||||
setCurrentEntry(null);
|
||||
await fetchWorldBookEntries(currentWorldBook.name, currentPage, pageSize);
|
||||
} catch (err) {
|
||||
console.error('删除条目失败:', err);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 删除世界书
|
||||
**位置**: `WorldBook.jsx` 第354-365行
|
||||
|
||||
**修改前**:
|
||||
```javascript
|
||||
const handleDeleteWorldBook = async () => {
|
||||
if (!currentWorldBook) return;
|
||||
|
||||
if (confirm(`确定要删除世界书 "${currentWorldBook.name}" 吗?`)) {
|
||||
try {
|
||||
await deleteWorldBook(currentWorldBook.name);
|
||||
resetCurrentWorldBook();
|
||||
} catch (err) {
|
||||
console.error('删除世界书失败:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```javascript
|
||||
const handleDeleteWorldBook = async () => {
|
||||
if (!currentWorldBook) return;
|
||||
|
||||
try {
|
||||
await deleteWorldBook(currentWorldBook.name);
|
||||
resetCurrentWorldBook();
|
||||
} catch (err) {
|
||||
console.error('删除世界书失败:', err);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 编辑面板中的删除按钮
|
||||
**位置**: `WorldBook.jsx` 第1086-1096行
|
||||
|
||||
**修改前**:
|
||||
```jsx
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={() => {
|
||||
if (window.confirm('确定要删除这个条目吗?')) {
|
||||
handleDeleteEntry();
|
||||
}
|
||||
}}
|
||||
>
|
||||
删除条目
|
||||
</button>
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```jsx
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={handleDeleteEntry}
|
||||
>
|
||||
删除条目
|
||||
</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 影响范围
|
||||
|
||||
### 用户交互变化
|
||||
|
||||
**之前**:
|
||||
1. 点击删除按钮
|
||||
2. 弹出浏览器确认对话框
|
||||
3. 用户点击"确定"或"取消"
|
||||
4. 根据选择执行或删除操作
|
||||
|
||||
**现在**:
|
||||
1. 点击删除按钮
|
||||
2. 立即执行删除操作
|
||||
3. 通过错误处理捕获异常
|
||||
|
||||
---
|
||||
|
||||
## ✨ 优势
|
||||
|
||||
1. **更流畅的用户体验**: 减少交互步骤,操作更快捷
|
||||
2. **更现代的UI**: 避免使用浏览器原生弹窗,更符合现代Web应用风格
|
||||
3. **代码简化**: 减少了条件判断和嵌套层级
|
||||
4. **一致性**: 与其他删除操作保持一致(如果有其他模块也移除了confirm)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 潜在风险
|
||||
- 用户可能误操作删除重要数据
|
||||
- 没有二次确认机制
|
||||
|
||||
### 建议的替代方案(可选)
|
||||
如果未来需要添加确认机制,可以考虑:
|
||||
1. **自定义模态框**: 使用项目统一的Modal组件
|
||||
2. **Toast提示 + 撤销**: 删除后显示提示,提供短暂的撤销机会
|
||||
3. **软删除**: 先标记为删除,稍后真正删除
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试建议
|
||||
|
||||
### 手动测试清单
|
||||
|
||||
- [ ] 在世界书列表中选择一个世界书
|
||||
- [ ] 点击"删除"按钮,检查是否立即删除(无确认弹窗)
|
||||
- [ ] 在条目编辑面板中点击"删除条目"按钮,检查是否立即删除
|
||||
- [ ] 检查删除后的控制台是否有错误信息
|
||||
- [ ] 检查删除后UI是否正确更新
|
||||
|
||||
### 边界情况测试
|
||||
|
||||
- [ ] 删除不存在的世界书(应该被前端校验拦截)
|
||||
- [ ] 删除不存在的条目(应该被前端校验拦截)
|
||||
- [ ] 网络请求失败时的错误处理
|
||||
|
||||
---
|
||||
|
||||
## 📝 相关代码位置
|
||||
|
||||
### 主要文件
|
||||
- `frontend/src/components/SideBarLeft/tabs/WorldBook/WorldBook.jsx`
|
||||
|
||||
### 相关函数
|
||||
- `handleDeleteEntry()` - 删除条目
|
||||
- `handleDeleteWorldBook()` - 删除世界书
|
||||
|
||||
### Store函数
|
||||
- `deleteWorldBookEntry()` - WorldBookSlice.jsx
|
||||
- `deleteWorldBook()` - WorldBookSlice.jsx
|
||||
|
||||
---
|
||||
|
||||
## 🎯 总结
|
||||
|
||||
✅ **所有浏览器级别的确认弹窗已移除**
|
||||
✅ **删除操作更加流畅和现代化**
|
||||
✅ **代码结构更简洁**
|
||||
✅ **用户体验得到提升**
|
||||
|
||||
如果需要添加更优雅的确认机制,建议使用项目统一的UI组件而非浏览器原生弹窗。
|
||||
@@ -1,173 +0,0 @@
|
||||
# 重roll功能调试指南
|
||||
|
||||
## 问题描述
|
||||
|
||||
重roll时整个前端页面被流式输出内容取代,而不是只在目标消息中更新。
|
||||
|
||||
## 可能的原因
|
||||
|
||||
1. **消息ID匹配失败**:`newMessageId` 与实际消息的 ID 不匹配
|
||||
2. **状态更新错误**:使用了 `set({ messages: [...] })` 而不是 `set((state) => ({ messages: ... }))`
|
||||
3. **字段丢失**:更新时没有使用 `...msg` 保留原有字段
|
||||
|
||||
## 已实施的修复
|
||||
|
||||
### 1. 确保 nextFloor 变量定义
|
||||
```javascript
|
||||
let userFloor, assistantFloor, nextFloor; // ✅ 在外部声明
|
||||
|
||||
if (isReroll) {
|
||||
assistantFloor = targetFloor;
|
||||
nextFloor = targetFloor; // ✅ 设置 nextFloor
|
||||
} else {
|
||||
nextFloor = get().getNextFloor(messages);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 添加详细的调试日志
|
||||
```javascript
|
||||
console.log('[ChatBoxStore] 🔄 重roll模式,更新消息:', {
|
||||
id: newMessageId,
|
||||
floor: assistantFloor,
|
||||
currentMes: targetMessage.mes.substring(0, 50) + '...',
|
||||
hasSwipes: !!targetMessage.swipes,
|
||||
swipesCount: targetMessage.swipes?.length || 0,
|
||||
swipeId: targetMessage.swipe_id
|
||||
});
|
||||
```
|
||||
|
||||
### 3. 验证消息匹配
|
||||
```javascript
|
||||
set((state) => {
|
||||
const targetMsg = state.messages.find(msg => msg.id === newMessageId);
|
||||
if (!targetMsg && chunkCount === 1) {
|
||||
console.error('[ChatBoxStore] ❌ 找不到目标消息!', {
|
||||
newMessageId,
|
||||
availableIds: state.messages.map(m => ({ id: m.id, floor: m.floor }))
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
messages: state.messages.map((msg) => {
|
||||
if (msg.id === newMessageId) {
|
||||
return {
|
||||
...msg, // ✅ 保留所有原有字段
|
||||
mes: assistantMessage
|
||||
};
|
||||
}
|
||||
return msg;
|
||||
})
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
## 测试步骤
|
||||
|
||||
### 1. 打开浏览器控制台
|
||||
|
||||
按 F12 打开开发者工具,切换到 Console 标签。
|
||||
|
||||
### 2. 触发重roll
|
||||
|
||||
在任意 AI 消息上右键点击,选择"🔄 重roll"。
|
||||
|
||||
### 3. 观察日志输出
|
||||
|
||||
应该看到以下日志:
|
||||
|
||||
```
|
||||
[ChatBoxStore] 🔄 重roll模式,更新消息: {
|
||||
id: "ai_1234567890_abc123",
|
||||
floor: 2,
|
||||
currentMes: "这是AI的回复内容...",
|
||||
hasSwipes: true,
|
||||
swipesCount: 2,
|
||||
swipeId: 1
|
||||
}
|
||||
|
||||
[WebSocket] 📡 连接已建立
|
||||
[WebSocket] 📤 发送消息:
|
||||
- Floor: 2
|
||||
- Mode: 🔄 Reroll
|
||||
- ...
|
||||
|
||||
[WebSocket] 📊 已接收 10 个 chunks
|
||||
[WebSocket] 📊 已接收 20 个 chunks
|
||||
...
|
||||
|
||||
[ChatBoxStore] 🔄 重roll完成,添加新的 swipe 版本
|
||||
[ChatBoxStore] 📊 Swipes 更新: {
|
||||
oldCount: 2,
|
||||
newCount: 3,
|
||||
newSwipeIndex: 2
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 检查是否有错误
|
||||
|
||||
如果看到以下错误,说明消息ID匹配失败:
|
||||
|
||||
```
|
||||
[ChatBoxStore] ❌ 找不到目标消息! {
|
||||
newMessageId: "ai_1234567890_abc123",
|
||||
availableIds: [
|
||||
{ id: "ai_1111111111_xyz", floor: 1 },
|
||||
{ id: "ai_2222222222_def", floor: 2 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**这意味着**:`newMessageId` 与消息列表中的任何 ID 都不匹配!
|
||||
|
||||
### 5. 验证页面表现
|
||||
|
||||
✅ **正确的表现**:
|
||||
- 只有目标 AI 消息的内容在流式更新
|
||||
- 其他消息保持不变
|
||||
- 用户消息、其他 AI 消息不受影响
|
||||
- 侧边栏、顶部栏等UI组件正常显示
|
||||
|
||||
❌ **错误的表现**(修复前):
|
||||
- 整个页面被流式文本覆盖
|
||||
- 其他消息消失或被替换
|
||||
- UI 组件异常
|
||||
|
||||
## 常见问题排查
|
||||
|
||||
### Q1: 如果看到"找不到目标消息"错误
|
||||
|
||||
**原因**:消息ID不匹配
|
||||
|
||||
**解决**:
|
||||
1. 检查 `targetMessage.id` 是否正确获取
|
||||
2. 确认消息列表中确实存在该 ID
|
||||
3. 检查是否有其他地方修改了消息ID
|
||||
|
||||
### Q2: 如果页面仍然被覆盖
|
||||
|
||||
**原因**:可能是 React 渲染问题
|
||||
|
||||
**解决**:
|
||||
1. 检查 ChatBox.jsx 的 renderMessage 函数
|
||||
2. 确认使用了正确的 key(应该是 `message.id`)
|
||||
3. 检查是否有条件渲染导致其他消息被隐藏
|
||||
|
||||
### Q3: Swipe 按钮消失
|
||||
|
||||
**原因**:swipes 数组丢失
|
||||
|
||||
**解决**:
|
||||
1. 确认更新时使用了 `...msg` 保留所有字段
|
||||
2. 检查 complete 事件中是否正确更新了 swipes 数组
|
||||
3. 验证 `msg.swipes` 在更新后仍然存在
|
||||
|
||||
## 预期结果
|
||||
|
||||
修复后,重roll应该:
|
||||
1. ✅ 只更新目标 AI 消息
|
||||
2. ✅ 保留所有其他消息
|
||||
3. ✅ 保留 swipes 数组和 swipe_id
|
||||
4. ✅ 流式显示新生成的内容
|
||||
5. ✅ 完成后自动切换到新版本
|
||||
6. ✅ 不影响页面其他部分
|
||||
@@ -1,178 +0,0 @@
|
||||
# 重roll功能改进说明
|
||||
|
||||
## 修改概述
|
||||
|
||||
本次修改主要解决了以下问题:
|
||||
|
||||
1. **修复右键菜单问题**:当 swipe 消失时,右键菜单不能取消
|
||||
2. **改变重roll行为**:在目标消息的 swipe 数组中添加新版本(模仿 SillyTavern 的行为)
|
||||
3. **改进箭头按钮样式**:模仿 SillyTavern 在气泡栏的最左最右半透明悬浮
|
||||
|
||||
## 具体修改内容
|
||||
|
||||
### 1. ChatBox.jsx 修改
|
||||
|
||||
#### 1.1 重roll函数修改
|
||||
- **行为**:调用 `sendMessage(userMessage.mes, message.floor)` 传入 targetFloor,触发在目标消息的swipe数组中添加新版本的逻辑
|
||||
|
||||
```javascript
|
||||
// 重roll时传入 targetFloor
|
||||
await sendMessage(userMessage.mes, message.floor); // 传入 targetFloor
|
||||
```
|
||||
|
||||
#### 1.2 右键菜单关闭逻辑增强
|
||||
添加了滚动监听,防止滚动时菜单不关闭:
|
||||
|
||||
```javascript
|
||||
if (contextMenu.visible) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
// ✅ 添加滚动监听,防止滚动时菜单不关闭
|
||||
document.addEventListener('scroll', closeContextMenu, true);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. ChatBoxSlice.jsx 修改
|
||||
|
||||
#### 2.1 恢复 sendMessage 中的重roll逻辑
|
||||
重roll模式会:
|
||||
- 不创建新的用户消息
|
||||
- 使用目标楼层作为助手楼层
|
||||
- 在 WebSocket complete 事件中,将新生成的内容添加到目标消息的 swipes 数组中
|
||||
|
||||
```javascript
|
||||
if (isReroll) {
|
||||
// 重roll模式:不创建新用户消息楼层,直接使用目标楼层的上一条用户消息
|
||||
assistantFloor = targetFloor;
|
||||
nextFloor = targetFloor;
|
||||
|
||||
// 不添加新的用户消息,只准备更新 AI 消息
|
||||
set({ isGenerating: true, wsConnection: null });
|
||||
} else {
|
||||
// 正常模式:创建新的用户消息和 AI 消息
|
||||
nextFloor = get().getNextFloor(messages);
|
||||
userFloor = nextFloor;
|
||||
assistantFloor = nextFloor + 1;
|
||||
|
||||
set({
|
||||
isGenerating: true,
|
||||
wsConnection: null,
|
||||
messages: [...messages, { /* 新用户消息 */ }]
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.2 恢复 WebSocket 消息处理中的重roll特殊逻辑
|
||||
- 在 chunk 事件中,重roll模式会更新目标消息的 mes 字段(临时显示)
|
||||
- 在 complete 事件中,重roll模式会将新生成的内容添加到目标消息的 swipes 数组中
|
||||
|
||||
```javascript
|
||||
// complete 事件中的重roll处理
|
||||
if (isReroll) {
|
||||
const existingSwipes = msg.swipes || [];
|
||||
const currentMes = msg.mes;
|
||||
|
||||
let updatedSwipes = [...existingSwipes];
|
||||
if (!updatedSwipes.includes(currentMes)) {
|
||||
updatedSwipes.push(currentMes);
|
||||
}
|
||||
|
||||
updatedSwipes.push(assistantMessage);
|
||||
|
||||
return {
|
||||
...msg,
|
||||
mes: assistantMessage,
|
||||
swipes: updatedSwipes,
|
||||
swipe_id: updatedSwipes.length - 1
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 3. ChatBox.css 修改
|
||||
|
||||
#### 3.1 Swipe 控制按钮样式改进
|
||||
模仿 SillyTavern 的半透明悬浮效果:
|
||||
|
||||
```css
|
||||
.swipe-controls {
|
||||
justify-content: space-between; /* ✅ 左右分布 */
|
||||
width: 100%; /* ✅ 占满宽度 */
|
||||
}
|
||||
|
||||
.swipe-button {
|
||||
background: rgba(255, 255, 255, 0.15); /* ✅ 半透明背景 */
|
||||
backdrop-filter: blur(8px); /* ✅ 毛玻璃效果 */
|
||||
opacity: 0.7; /* ✅ 默认半透明 */
|
||||
}
|
||||
|
||||
.swipe-button:hover:not(:disabled) {
|
||||
opacity: 1; /* ✅ hover 时完全不透明 */
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2 深色主题适配
|
||||
```css
|
||||
[data-color-theme='dark'] .swipe-button {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
```
|
||||
|
||||
## 用户体验改进
|
||||
|
||||
### 修改前的问题
|
||||
1. 右键菜单在滚动时不会自动关闭
|
||||
2. Swipe 按钮样式不够美观,不符合 SillyTavern 的风格
|
||||
|
||||
### 修改后的效果
|
||||
1. **重roll行为更符合预期**:每次重roll都会在目标消息的 swipe 数组中添加新版本
|
||||
2. **右键菜单更稳定**:滚动时会自动关闭,避免界面混乱
|
||||
3. **Swipe 按钮更美观**:半透明悬浮效果,左右分布,模仿 SillyTavern 的设计
|
||||
|
||||
## 技术细节
|
||||
|
||||
### 重roll的工作流程
|
||||
|
||||
SillyTavern 的重roll行为是:
|
||||
- 忽略当前 AI 回复
|
||||
- 使用上一条用户消息重新发送给 LLM
|
||||
- 在目标消息的 swipe 数组中添加新生成的版本
|
||||
|
||||
这样做的好处:
|
||||
1. **保持消息历史清晰**:每次重roll都在同一楼层添加新版本
|
||||
2. **便于比较**:可以通过 swipe 切换查看不同版本的回复
|
||||
3. **符合用户预期**:重roll就是“再来一次”,生成新的备选回复
|
||||
|
||||
### Swipe 功能的作用
|
||||
|
||||
Swipe 功能用于:
|
||||
- 手动切换同一消息的多个版本
|
||||
- 重roll时添加新的版本
|
||||
- 通过 API 或其他方式添加的多个版本
|
||||
|
||||
## 测试建议
|
||||
|
||||
1. **测试重roll功能**:
|
||||
- 右键点击 AI 消息,选择“重roll”
|
||||
- 观察是否在目标消息的 swipe 数组中添加了新版本
|
||||
- 确认可以通过 swipe 按钮切换不同版本
|
||||
|
||||
2. **测试右键菜单**:
|
||||
- 打开右键菜单后滚动页面
|
||||
- 确认菜单是否自动关闭
|
||||
|
||||
3. **测试 Swipe 按钮**:
|
||||
- 检查按钮是否左右分布
|
||||
- 检查半透明效果是否正常
|
||||
- 检查 hover 效果是否流畅
|
||||
|
||||
4. **测试键盘快捷键**:
|
||||
- 使用左右箭头键切换 swipe
|
||||
- 确认最后一个版本时按右键是否触发重roll
|
||||
- 确认重roll后是否添加了新的 swipe 版本
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `frontend/src/components/Mid/ChatBox/ChatBox.jsx`
|
||||
- `frontend/src/Store/Mid/ChatBoxSlice.jsx`
|
||||
- `frontend/src/components/Mid/ChatBox/ChatBox.css`
|
||||
- `frontend/src/styles/context-menu.css`
|
||||
@@ -1,204 +0,0 @@
|
||||
# 系统设置功能测试指南
|
||||
|
||||
## 📋 前置条件
|
||||
|
||||
### 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. **规则模板** - 提供常用规则模板
|
||||
@@ -1,78 +0,0 @@
|
||||
# 左侧边栏右键菜单功能
|
||||
|
||||
## 功能概述
|
||||
|
||||
为左侧边栏的标签按钮(角色、API、预设、世界书、Token等)添加了右键菜单功能,替代了原有的悬浮提示(title)。
|
||||
|
||||
## 实现特点
|
||||
|
||||
### 1. **统一的设计模式**
|
||||
- 参考 SillyTavern 和现代 Web 应用的交互设计
|
||||
- 所有标签按钮都支持右键菜单
|
||||
- 移除了原有的 title 悬浮提示,改用更丰富的右键菜单
|
||||
|
||||
### 2. **菜单内容**
|
||||
每个标签的右键菜单包含:
|
||||
- **标题区域**:显示标签名称(如"角色"、"预设"等)
|
||||
- **描述区域**:显示该功能的详细说明
|
||||
- **操作项**:
|
||||
- 📂 打开:切换到对应标签页
|
||||
- ➕ 新建:根据标签类型提供快捷创建功能
|
||||
- 角色:新建角色
|
||||
- 预设:新建预设
|
||||
- 世界书:新建世界书
|
||||
|
||||
### 3. **技术实现**
|
||||
|
||||
#### 文件修改
|
||||
1. **SideBarLeft.jsx**
|
||||
- 添加右键菜单状态管理
|
||||
- 实现 `handleContextMenu`、`closeContextMenu`、`handleSwitchToTab` 函数
|
||||
- 为每个标签按钮添加 `onContextMenu` 事件
|
||||
- 移除 `title` 属性
|
||||
|
||||
2. **SideBarLeft.css**
|
||||
- 添加 `.sidebar-tab-context-menu` 样式
|
||||
- 添加 `.context-menu-header` 样式(渐变背景)
|
||||
- 添加 `.context-menu-description` 样式(描述文本)
|
||||
|
||||
3. **context-menu.css**(新建)
|
||||
- 提取通用的右键菜单样式
|
||||
- 包含 overlay、menu、item、divider 等组件样式
|
||||
- 添加淡入动画效果
|
||||
|
||||
4. **index.css**
|
||||
- 导入全局 context-menu.css
|
||||
|
||||
5. **ChatBox.css**
|
||||
- 移除重复的右键菜单样式(已移至全局)
|
||||
|
||||
### 4. **用户体验优化**
|
||||
|
||||
- **视觉反馈**:菜单有淡入动画(0.1s)
|
||||
- **悬停效果**:菜单项悬停时高亮显示
|
||||
- **点击关闭**:点击菜单外部自动关闭
|
||||
- **智能定位**:菜单跟随鼠标位置显示
|
||||
- **快捷操作**:根据不同标签提供相应的快捷功能
|
||||
|
||||
## 使用方式
|
||||
|
||||
1. 在左侧边栏任意标签上**右键点击**
|
||||
2. 查看标签的详细说明
|
||||
3. 选择操作:
|
||||
- 点击"打开"切换到该标签页
|
||||
- 点击"新建"快速创建新内容(如果支持)
|
||||
|
||||
## 扩展性
|
||||
|
||||
这个实现具有良好的扩展性:
|
||||
- 可以轻松为其他组件添加右键菜单
|
||||
- 可以根据标签类型动态显示不同的菜单项
|
||||
- 统一的样式系统确保视觉一致性
|
||||
|
||||
## 未来改进方向
|
||||
|
||||
1. 可以为每个标签添加更多快捷操作
|
||||
2. 可以显示当前标签的状态信息(如角色数量、预设数量等)
|
||||
3. 可以添加键盘快捷键支持
|
||||
4. 可以添加菜单项的图标自定义功能
|
||||
@@ -1,501 +0,0 @@
|
||||
# SillyTavern 正则系统完整架构
|
||||
|
||||
## 概述
|
||||
|
||||
SillyTavern 的正则系统是一个强大的文本处理引擎,支持在多个阶段对文本进行正则替换。我们的实现已经完全兼容 SillyTavern 的格式和功能。
|
||||
|
||||
---
|
||||
|
||||
## 核心概念
|
||||
|
||||
### 1. 作用域(Scope)
|
||||
|
||||
正则规则可以绑定到三个不同的作用域:
|
||||
|
||||
| 作用域 | 说明 | 存储位置 |
|
||||
|--------|------|----------|
|
||||
| **GLOBAL** | 全局生效,对所有聊天应用 | `data/regex/global/*.json` |
|
||||
| **CHARACTER** | 绑定到特定角色卡 | `data/regex/characters/{characterName}.json` |
|
||||
| **PRESET** | 绑定到特定预设 | `data/regex/presets/{presetName}.json` |
|
||||
|
||||
**优先级**:全局规则 + 角色规则 + 预设规则(按顺序应用)
|
||||
|
||||
---
|
||||
|
||||
### 2. Placement(应用位置)
|
||||
|
||||
正则规则可以在以下6个位置应用:
|
||||
|
||||
```typescript
|
||||
placement: number[] // 数组,可以同时应用在多个位置
|
||||
|
||||
0: SYSTEM_PROMPT - 系统提示词(发送给LLM前)
|
||||
1: USER_INPUT - 用户输入
|
||||
2: AI_OUTPUT - AI 输出
|
||||
3: QUICK_REPLY - 快捷回复
|
||||
4: WORLD_INFO - 世界书信息
|
||||
5: REASONING - 推理/思考内容(如 <thinking> 标签)
|
||||
```
|
||||
|
||||
**示例**:
|
||||
```json
|
||||
{
|
||||
"placement": [1, 2], // 同时应用于用户输入和AI输出
|
||||
"scriptName": "隐藏思考标签",
|
||||
"findRegex": "/<thinking>[\\s\\S]*?<\\/thinking>/gs",
|
||||
"replaceString": ""
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 作用范围控制(关键!)
|
||||
|
||||
#### `promptOnly: boolean`
|
||||
|
||||
**是否仅应用于发送给 LLM 的提示词**
|
||||
|
||||
- `true`:
|
||||
- ✅ 只在发送给LLM**之前**应用
|
||||
- ❌ 前端显示时**不应用**
|
||||
- 🎯 用途:清理发送给LLM的内容,但保留原始内容给用户看
|
||||
|
||||
- `false`:
|
||||
- ✅ 在前端显示时也应用
|
||||
- 🎯 用途:修改用户看到的内容
|
||||
|
||||
**示例场景**:
|
||||
```json
|
||||
{
|
||||
"scriptName": "去除尾部破甲",
|
||||
"findRegex": "/<disclaimer>.*?<\\/disclaimer>/gs",
|
||||
"replaceString": "",
|
||||
"promptOnly": true, // ✅ 只影响发送给LLM的内容
|
||||
"markdownOnly": false,
|
||||
"placement": [2] // AI输出
|
||||
}
|
||||
```
|
||||
效果:
|
||||
- 发送给LLM:不包含 `<disclaimer>` 标签
|
||||
- 用户看到:仍然显示 `<disclaimer>` 标签
|
||||
|
||||
---
|
||||
|
||||
#### `markdownOnly: boolean`
|
||||
|
||||
**是否仅应用于 Markdown 渲染后的内容**
|
||||
|
||||
- `true`:
|
||||
- ✅ 只对最终渲染的HTML应用
|
||||
- ❌ 不对原始文本应用
|
||||
- 🎯 用途:修改显示效果,不影响原始数据
|
||||
|
||||
- `false`:
|
||||
- ✅ 对原始文本应用
|
||||
- 🎯 用途:修改实际内容
|
||||
|
||||
**示例场景**:
|
||||
```json
|
||||
{
|
||||
"scriptName": "世界知识",
|
||||
"findRegex": "/TIPS_DESIGN\\[世界知识\\]/g",
|
||||
"replaceString": "<details>...</details>",
|
||||
"promptOnly": false,
|
||||
"markdownOnly": true, // ✅ 只影响Markdown渲染
|
||||
"placement": [1, 2]
|
||||
}
|
||||
```
|
||||
效果:
|
||||
- 原始文本:保持 `TIPS_DESIGN[世界知识]`
|
||||
- 渲染后:显示为折叠的 `<details>` 元素
|
||||
|
||||
---
|
||||
|
||||
### 4. 其他重要字段
|
||||
|
||||
#### `substituteRegex: number`
|
||||
|
||||
替换模式:
|
||||
- `0`: REPLACE_ALL - 替换所有匹配
|
||||
- `1`: REPLACE_FIRST - 仅替换首次匹配
|
||||
- `2`: REPLACE_CAPTURED - 替换捕获组
|
||||
|
||||
#### `runOnEdit: boolean`
|
||||
|
||||
用户编辑消息时是否重新应用正则规则。
|
||||
|
||||
#### `minDepth / maxDepth: number`
|
||||
|
||||
消息深度控制(从最新消息开始计数):
|
||||
- `minDepth`: 最小深度
|
||||
- `maxDepth`: 最大深度(null 表示无限制)
|
||||
|
||||
**用途**:只对特定深度的历史消息应用规则
|
||||
|
||||
---
|
||||
|
||||
## 数据结构
|
||||
|
||||
### 单条规则(RegexRule)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid-string",
|
||||
"scriptName": "规则名称",
|
||||
"findRegex": "/pattern/flags",
|
||||
"replaceString": "replacement",
|
||||
"trimStrings": [],
|
||||
"placement": [1, 2],
|
||||
"disabled": false,
|
||||
"markdownOnly": true,
|
||||
"promptOnly": false,
|
||||
"runOnEdit": true,
|
||||
"substituteRegex": 0,
|
||||
"minDepth": null,
|
||||
"maxDepth": null
|
||||
}
|
||||
```
|
||||
|
||||
### 规则集(RegexRuleset)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid-string",
|
||||
"name": "规则集名称",
|
||||
"description": "描述",
|
||||
"rules": [...],
|
||||
"isSillyTavernFormat": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 导入导出
|
||||
|
||||
### 1. 正则规则的存储位置
|
||||
|
||||
#### 方式一:独立的正则文件(推荐)
|
||||
|
||||
```
|
||||
data/regex/
|
||||
├── global/ # 全局规则
|
||||
│ └── default.json
|
||||
├── characters/ # 角色卡绑定规则
|
||||
│ ├── 角色A.json
|
||||
│ └── 角色B.json
|
||||
└── presets/ # 预设绑定规则
|
||||
├── 预设A.json
|
||||
└── 预设B.json
|
||||
```
|
||||
|
||||
**导入方式**:
|
||||
- 前端:系统设置 → 正则管理 → 导入
|
||||
- 后端:自动加载文件夹中的所有JSON文件
|
||||
|
||||
#### 方式二:嵌入预设文件
|
||||
|
||||
**预设文件结构**:
|
||||
```json
|
||||
{
|
||||
"prompts": [...],
|
||||
"prompt_order": [...],
|
||||
"extensions": {
|
||||
"regex_scripts": [
|
||||
{
|
||||
"id": "...",
|
||||
"scriptName": "...",
|
||||
"findRegex": "...",
|
||||
...
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**导入方式**:
|
||||
- 导入预设时,自动提取 `extensions.regex_scripts`
|
||||
- 根据预设名称保存到 `data/regex/presets/{presetName}.json`
|
||||
|
||||
---
|
||||
|
||||
### 2. 导入流程
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[用户上传JSON文件] --> B{检测文件类型}
|
||||
B -->|正则文件| C[直接导入正则规则]
|
||||
B -->|预设文件| D[解析预设]
|
||||
D --> E{是否有 extensions.regex_scripts?}
|
||||
E -->|是| F[提取正则规则]
|
||||
E -->|否| G[仅导入预设]
|
||||
F --> H[保存到 data/regex/presets/]
|
||||
C --> I[更新内存缓存]
|
||||
H --> I
|
||||
I --> J[完成]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 应用时机
|
||||
|
||||
### 聊天流程中的正则应用点
|
||||
|
||||
```
|
||||
用户输入
|
||||
↓
|
||||
[Placement 1: USER_INPUT]
|
||||
↓ apply_rules_by_placement(text, 1)
|
||||
发送到后端
|
||||
↓
|
||||
组装Prompt
|
||||
↓
|
||||
[Placement 0: SYSTEM_PROMPT]
|
||||
↓ apply_rules_by_placement(prompt, 0)
|
||||
[Placement 4: WORLD_INFO]
|
||||
↓ apply_rules_by_placement(world_info, 4)
|
||||
[Placement 5: REASONING]
|
||||
↓ apply_rules_by_placement(reasoning, 5)
|
||||
发送给LLM
|
||||
↓
|
||||
LLM生成回复
|
||||
↓
|
||||
接收AI输出
|
||||
↓
|
||||
[Placement 2: AI_OUTPUT]
|
||||
↓ apply_rules_by_placement(ai_output, 2)
|
||||
保存到文件
|
||||
↓
|
||||
返回前端
|
||||
↓
|
||||
前端显示
|
||||
↓
|
||||
[Placement 3: QUICK_REPLY] (如果启用)
|
||||
↓
|
||||
Markdown渲染
|
||||
↓
|
||||
[markdownOnly=true 的规则]
|
||||
↓
|
||||
最终显示给用户
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 实现细节
|
||||
|
||||
### 后端实现
|
||||
|
||||
#### 1. RegexService
|
||||
|
||||
**文件**: `backend/services/regex_service.py`
|
||||
|
||||
**核心方法**:
|
||||
```python
|
||||
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 应用正则规则"""
|
||||
|
||||
def get_rules_for_context(
|
||||
self,
|
||||
character_name: Optional[str] = None,
|
||||
preset_name: Optional[str] = None
|
||||
) -> List[RegexRule]:
|
||||
"""获取适用的规则列表(全局+角色+预设)"""
|
||||
```
|
||||
|
||||
#### 2. 集成到聊天流程
|
||||
|
||||
**文件**: `backend/services/chat_workflow_service.py`
|
||||
|
||||
需要在以下位置调用正则处理:
|
||||
|
||||
```python
|
||||
# 1. 处理用户输入
|
||||
processed_user_input = regex_service.apply_rules_by_placement(
|
||||
user_message,
|
||||
placement=RegexPlacement.USER_INPUT.value,
|
||||
character_name=current_role,
|
||||
preset_name=preset_name
|
||||
)
|
||||
|
||||
# 2. 处理系统提示词
|
||||
for i, msg in enumerate(messages):
|
||||
if msg.role == 'system':
|
||||
msg.content = regex_service.apply_rules_by_placement(
|
||||
msg.content,
|
||||
placement=RegexPlacement.SYSTEM_PROMPT.value,
|
||||
character_name=current_role,
|
||||
preset_name=preset_name
|
||||
)
|
||||
|
||||
# 3. 处理AI输出
|
||||
processed_ai_output = regex_service.apply_rules_by_placement(
|
||||
ai_response,
|
||||
placement=RegexPlacement.AI_OUTPUT.value,
|
||||
character_name=current_role,
|
||||
preset_name=preset_name
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 前端实现
|
||||
|
||||
#### 1. 正则管理UI
|
||||
|
||||
**位置**: 左侧边栏 → 系统设置 → 正则管理
|
||||
|
||||
**功能**:
|
||||
- 查看当前生效的规则列表
|
||||
- 启用/禁用规则
|
||||
- 调整规则顺序
|
||||
- 导入/导出规则集
|
||||
- 创建/编辑规则
|
||||
|
||||
#### 2. 预设导入时的正则提取
|
||||
|
||||
**文件**: `frontend/src/components/SideBarLeft/tabs/Presets/Presets.jsx`
|
||||
|
||||
```javascript
|
||||
const handleImportPreset = async (file) => {
|
||||
const importedPreset = JSON.parse(importData);
|
||||
|
||||
// ✅ 提取正则规则
|
||||
if (importedPreset.extensions?.regex_scripts) {
|
||||
const regexScripts = importedPreset.extensions.regex_scripts;
|
||||
console.log(`[预设导入] 检测到 ${regexScripts.length} 条正则规则`);
|
||||
|
||||
// 保存到后端
|
||||
await fetch('/api/regex/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
rules: regexScripts,
|
||||
scope: 'preset',
|
||||
presetName: presetName
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// ... 继续导入预设
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 示例1:隐藏思考标签
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "hide-thinking-001",
|
||||
"scriptName": "隐藏思考标签",
|
||||
"findRegex": "/<thinking>[\\s\\S]*?<\\/thinking>/gs",
|
||||
"replaceString": "",
|
||||
"placement": [2], // AI输出
|
||||
"promptOnly": true, // 只影响发送给LLM的内容
|
||||
"markdownOnly": false,
|
||||
"disabled": false
|
||||
}
|
||||
```
|
||||
|
||||
**效果**:
|
||||
- 发送给LLM:移除 `<thinking>` 标签
|
||||
- 用户看到:仍然显示 `<thinking>` 标签
|
||||
|
||||
---
|
||||
|
||||
### 示例2:美化TIPS显示
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "tips-design-001",
|
||||
"scriptName": "世界知识",
|
||||
"findRegex": "/TIPS_DESIGN\\[世界知识\\]/g",
|
||||
"replaceString": "<details><summary>点击查看说明</summary>...</details>",
|
||||
"placement": [1, 2], // 用户输入 + AI输出
|
||||
"promptOnly": false, // 影响显示
|
||||
"markdownOnly": true, // 只影响Markdown渲染
|
||||
"disabled": false
|
||||
}
|
||||
```
|
||||
|
||||
**效果**:
|
||||
- 原始文本:保持 `TIPS_DESIGN[世界知识]`
|
||||
- 渲染后:显示为可折叠的详情框
|
||||
|
||||
---
|
||||
|
||||
### 示例3:去除免责声明
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "remove-disclaimer-001",
|
||||
"scriptName": "去除尾部破甲",
|
||||
"findRegex": "/<disclaimer>.*?<\\/disclaimer>/gs",
|
||||
"replaceString": "",
|
||||
"placement": [2], // AI输出
|
||||
"promptOnly": true, // 只影响发送给LLM
|
||||
"markdownOnly": false,
|
||||
"disabled": false
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
### ⚠️ 重要提醒
|
||||
|
||||
1. **`promptOnly` vs `markdownOnly` 的区别**
|
||||
- `promptOnly`: 控制是否影响发送给LLM的内容
|
||||
- `markdownOnly`: 控制是否影响Markdown渲染
|
||||
- 两者可以组合使用
|
||||
|
||||
2. **规则的优先级**
|
||||
- 按 `order` 字段从小到大执行
|
||||
- 同一 placement 的规则按顺序应用
|
||||
|
||||
3. **性能考虑**
|
||||
- 正则表达式应尽量高效
|
||||
- 避免在长文本上使用复杂的正则
|
||||
- 可以使用 `minDepth/maxDepth` 限制应用范围
|
||||
|
||||
4. **调试技巧**
|
||||
- 在后端日志中输出应用的正则规则
|
||||
- 使用 `debugPrompt=true` 查看完整的prompt拼接结果
|
||||
- 前端可以提供"预览"功能,显示正则应用前后的对比
|
||||
|
||||
---
|
||||
|
||||
## 待实现功能
|
||||
|
||||
### 1. 前端UI
|
||||
- [ ] 正则管理页面
|
||||
- [ ] 规则编辑器
|
||||
- [ ] 导入/导出功能
|
||||
- [ ] 实时预览
|
||||
|
||||
### 2. 后端集成
|
||||
- [ ] 在聊天流程中调用正则处理
|
||||
- [ ] 支持 `promptOnly` 和 `markdownOnly` 逻辑
|
||||
- [ ] 预设导入时自动提取正则规则
|
||||
|
||||
### 3. 测试
|
||||
- [ ] 单元测试
|
||||
- [ ] 集成测试
|
||||
- [ ] 性能测试
|
||||
|
||||
---
|
||||
|
||||
## 参考资源
|
||||
|
||||
- SillyTavern 官方文档: https://docs.sillytavern.app/
|
||||
- SillyTavern GitHub: https://github.com/SillyTavern/SillyTavern
|
||||
- 正则表达式教程: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Guide/Regular_Expressions
|
||||
@@ -1,105 +0,0 @@
|
||||
# Swipe 切换功能优化
|
||||
|
||||
## 问题描述
|
||||
|
||||
之前的实现中,swipe 切换按钮只在**最新消息**上显示,这导致用户无法查看和切换历史消息的不同版本。
|
||||
|
||||
## SillyTavern 的设计
|
||||
|
||||
在 SillyTavern 中,**所有包含多个版本的 AI 消息**都会显示 swipe 切换按钮,用户可以随时查看和切换任何消息的不同版本。
|
||||
|
||||
## 修复内容
|
||||
|
||||
### 1. **移除 isLatestMessage 限制**
|
||||
|
||||
**修改前:**
|
||||
```jsx
|
||||
{hasSwipes && !isUser && isLatestMessage && (
|
||||
<div className="swipe-controls">
|
||||
...
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
**修改后:**
|
||||
```jsx
|
||||
{/* ✅ Swipe 控制按钮 - 所有有 swipe 的 AI 消息都显示 */}
|
||||
{hasSwipes && !isUser && (
|
||||
<div className="swipe-controls">
|
||||
...
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
### 2. **添加悬停提示**
|
||||
|
||||
为按钮添加了 `title` 属性,提供更好的用户体验:
|
||||
- ◀ 按钮:`title="上一个版本"`
|
||||
- ▶ 按钮:`title="下一个版本"`
|
||||
- 计数器:`title={`共 ${message.swipes.length} 个版本`}`
|
||||
|
||||
### 3. **优化样式设计**
|
||||
|
||||
参考 SillyTavern 的风格,重新设计了 swipe 控制按钮:
|
||||
|
||||
#### 容器样式
|
||||
- 居中对齐
|
||||
- 轻微背景色区分
|
||||
- 圆角边框
|
||||
- 自适应宽度
|
||||
|
||||
#### 按钮样式
|
||||
- 清晰的边框和背景
|
||||
- 悬停时高亮显示(蓝色主题)
|
||||
- 悬停时有轻微的向上动画
|
||||
- 禁用状态降低透明度
|
||||
- 最小宽度确保视觉一致性
|
||||
|
||||
#### 计数器样式
|
||||
- 加粗字体突出显示
|
||||
- 独立的背景色块
|
||||
- 居中对齐的数字
|
||||
- 最小宽度保持视觉稳定
|
||||
|
||||
## 视觉效果
|
||||
|
||||
### 正常状态
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ [◀] 2/5 [▶] │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
### 悬停状态
|
||||
- 按钮变为蓝色高亮
|
||||
- 轻微向上移动
|
||||
- 显示阴影效果
|
||||
|
||||
### 禁用状态
|
||||
- 第一个消息:◀ 按钮禁用
|
||||
- 最后一个消息:▶ 按钮禁用
|
||||
- 禁用的按钮透明度降低
|
||||
|
||||
## 用户体验提升
|
||||
|
||||
1. **完整性**:可以查看和切换任何消息的版本,不仅限于最新消息
|
||||
2. **一致性**:与 SillyTavern 的行为保持一致
|
||||
3. **易用性**:添加了悬停提示,用户清楚每个按钮的功能
|
||||
4. **美观性**:优化的样式更符合整体设计风格
|
||||
|
||||
## 技术细节
|
||||
|
||||
### 文件修改
|
||||
- `ChatBox.jsx`:移除 `isLatestMessage` 条件判断,添加 title 属性
|
||||
- `ChatBox.css`:完全重写 swipe 控件样式
|
||||
|
||||
### 兼容性
|
||||
- 不影响现有功能
|
||||
- 向后兼容
|
||||
- 性能无影响
|
||||
|
||||
## 未来改进
|
||||
|
||||
1. 可以添加键盘快捷键支持(如 Alt+←/→ 切换版本)
|
||||
2. 可以添加 swipe 版本的预览功能
|
||||
3. 可以添加批量管理 swipe 版本的功能
|
||||
@@ -1,312 +0,0 @@
|
||||
# Swipe 持久化修复说明
|
||||
|
||||
## 问题描述
|
||||
|
||||
之前的实现中,swipes 数据**没有持久化到文件**,导致:
|
||||
1. 刷新页面后 swipes 丢失
|
||||
2. 前端负责管理 swipes 状态(错误的设计)
|
||||
3. 重roll后无法在不同会话间保留多个版本
|
||||
|
||||
## 正确的架构设计
|
||||
|
||||
### 职责划分
|
||||
|
||||
**后端(唯一数据源)**:
|
||||
- ✅ 负责 swipes 数据的持久化存储
|
||||
- ✅ 在重roll时更新消息的 swipes 数组
|
||||
- ✅ 提供完整的消息数据(包含 swipes)给前端
|
||||
|
||||
**前端(只读显示)**:
|
||||
- ✅ 从后端加载消息数据
|
||||
- ✅ 检测到 swipes 存在时渲染切换按钮
|
||||
- ✅ 维护当前的 swipe_id(仅用于UI显示,不持久化)
|
||||
|
||||
---
|
||||
|
||||
## 修复内容
|
||||
|
||||
### 1. 后端:添加 `get_message` 方法
|
||||
|
||||
**文件**: `backend/services/chat_service.py`
|
||||
|
||||
```python
|
||||
def get_message(self, role_name: str, chat_name: str, floor: int) -> Dict:
|
||||
"""获取指定楼层的消息"""
|
||||
# 读取文件并返回指定楼层的消息数据
|
||||
# 如果不存在则返回 None
|
||||
```
|
||||
|
||||
**作用**:在重roll时获取现有消息,以便更新其 swipes 数组。
|
||||
|
||||
---
|
||||
|
||||
### 2. 后端:修改 `_save_messages` 函数
|
||||
|
||||
**文件**: `backend/api/routes/chatWsRoute.py`
|
||||
|
||||
#### 修复前的问题
|
||||
```python
|
||||
# ❌ 旧代码:无论什么情况都创建新消息
|
||||
chat_service.add_message(role_name, chat_name, user_message)
|
||||
chat_service.add_message(role_name, chat_name, ai_message)
|
||||
```
|
||||
|
||||
#### 修复后的逻辑
|
||||
```python
|
||||
# ✅ 新代码:区分正常模式和重roll模式
|
||||
target_floor = request_data.get("floor")
|
||||
is_reroll = target_floor is not None
|
||||
|
||||
if is_reroll:
|
||||
# 重roll模式:更新现有消息的 swipes 数组
|
||||
existing_message = chat_service.get_message(role_name, chat_name, target_floor)
|
||||
|
||||
if existing_message:
|
||||
# 获取现有的 swipes
|
||||
existing_swipes = existing_message.get("swipes", [])
|
||||
current_mes = existing_message.get("mes", "")
|
||||
|
||||
# 构建新的 swipes 数组
|
||||
updated_swipes = list(existing_swipes)
|
||||
|
||||
# 如果当前 mes 不在 swipes 中,先添加它
|
||||
if current_mes and current_mes not in updated_swipes:
|
||||
updated_swipes.append(current_mes)
|
||||
|
||||
# 添加新生成的内容
|
||||
updated_swipes.append(ai_response)
|
||||
|
||||
# 更新消息(持久化到文件)
|
||||
update_data = {
|
||||
"mes": ai_response, # 显示最新内容
|
||||
"swipes": updated_swipes, # 更新 swipes 数组
|
||||
"swipe_id": len(updated_swipes) - 1 # 自动切换到新版本
|
||||
}
|
||||
|
||||
chat_service.update_message(role_name, chat_name, target_floor, update_data)
|
||||
else:
|
||||
# 正常模式:创建新的用户消息和AI消息
|
||||
chat_service.add_message(role_name, chat_name, user_message)
|
||||
chat_service.add_message(role_name, chat_name, ai_message)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完整流程示例
|
||||
|
||||
### 场景:用户进行第3次重roll
|
||||
|
||||
#### 初始状态(文件中)
|
||||
```json
|
||||
{
|
||||
"floor": 5,
|
||||
"mes": "这是第2次重roll的内容",
|
||||
"swipes": [
|
||||
"这是第1次生成的内容",
|
||||
"这是第2次重roll的内容"
|
||||
],
|
||||
"swipe_id": 1
|
||||
}
|
||||
```
|
||||
|
||||
#### 用户操作
|
||||
1. 右键点击消息,选择"重roll"
|
||||
2. 前端发送请求:`{ floor: 5, mes: "...", ... }`
|
||||
|
||||
#### 后端处理
|
||||
1. 检测到 `floor=5`,判断为重roll模式
|
||||
2. 调用 `get_message(role, chat, 5)` 获取现有消息
|
||||
3. 读取 swipes 数组:`["第1次", "第2次"]`
|
||||
4. 将当前 mes 添加到 swipes(如果不存在)
|
||||
5. 将新生成的内容添加到 swipes
|
||||
6. 更新消息:
|
||||
```json
|
||||
{
|
||||
"mes": "这是第3次重roll的内容",
|
||||
"swipes": [
|
||||
"这是第1次生成的内容",
|
||||
"这是第2次重roll的内容",
|
||||
"这是第3次重roll的内容"
|
||||
],
|
||||
"swipe_id": 2
|
||||
}
|
||||
```
|
||||
7. **持久化到文件**
|
||||
|
||||
#### 前端接收
|
||||
1. WebSocket 收到 complete 事件
|
||||
2. 重新加载聊天历史(或增量更新)
|
||||
3. 读取到 swipes 数组有3个元素
|
||||
4. 渲染 swipe 控制按钮:◀ 3/3 ▶
|
||||
5. 用户可以通过按钮切换不同版本
|
||||
|
||||
#### 刷新页面后
|
||||
1. 前端重新加载聊天历史
|
||||
2. 从文件读取到完整的 swipes 数组
|
||||
3. swipe 功能正常工作 ✅
|
||||
|
||||
---
|
||||
|
||||
## 数据结构说明
|
||||
|
||||
### 消息格式(JSONL文件)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "msg_1234567890_ai",
|
||||
"name": "角色名",
|
||||
"is_user": false,
|
||||
"is_system": false,
|
||||
"floor": 5,
|
||||
"sendDate": "2024-01-01T12:00:00",
|
||||
"mes": "当前显示的内容",
|
||||
"swipes": [
|
||||
"第1个版本的内容",
|
||||
"第2个版本的内容",
|
||||
"第3个版本的内容"
|
||||
],
|
||||
"swipe_id": 2,
|
||||
"chatId": "角色名/聊天名"
|
||||
}
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `mes` | string | 当前显示的消息内容(与 `swipes[swipe_id]` 一致) |
|
||||
| `swipes` | array | 所有版本的数组(持久化) |
|
||||
| `swipe_id` | number | 当前选中的版本索引(持久化,但前端可以临时覆盖) |
|
||||
|
||||
---
|
||||
|
||||
## 前端渲染逻辑
|
||||
|
||||
### ChatBox.jsx 中的关键代码
|
||||
|
||||
```javascript
|
||||
// 确定当前显示的消息内容
|
||||
let currentMes = message.mes; // 默认使用 mes
|
||||
let hasSwipes = message.swipes && message.swipes.length > 0;
|
||||
let currentSwipeIndex = message.swipe_id;
|
||||
|
||||
if (hasSwipes) {
|
||||
// 如果用户已经手动切换过版本,使用用户选择的版本
|
||||
if (currentSwipeId[message.floor] !== undefined) {
|
||||
currentSwipeIndex = currentSwipeId[message.floor];
|
||||
} else {
|
||||
// 否则使用后端保存的 swipe_id
|
||||
currentSwipeIndex = message.swipe_id;
|
||||
}
|
||||
|
||||
// 从 swipes 数组中读取对应版本的内容
|
||||
if (currentSwipeIndex >= 0 && currentSwipeIndex < message.swipes.length) {
|
||||
currentMes = message.swipes[currentSwipeIndex];
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染 swipe 控制按钮
|
||||
{hasSwipes && !isUser && (
|
||||
<div className="swipe-controls">
|
||||
<button onClick={() => handleSwipeChange(message.floor, -1)}>◀</button>
|
||||
<span>{currentSwipeIndex + 1}/{message.swipes.length}</span>
|
||||
<button onClick={() => handleSwipeChange(message.floor, 1)}>▶</button>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
### 关键点
|
||||
|
||||
1. **后端是权威数据源**:`message.swipes` 和 `message.swipe_id` 来自后端
|
||||
2. **前端只维护临时状态**:`currentSwipeId` 只在当前会话有效,刷新后从后端重新加载
|
||||
3. **合并更新**:切换 swipe 时使用 `{ ...currentSwipeId, [messageId]: newIndex }` 保留其他楼层的状态
|
||||
|
||||
---
|
||||
|
||||
## 测试步骤
|
||||
|
||||
### 1. 测试重roll持久化
|
||||
1. 发送一条消息,等待AI回复
|
||||
2. 右键点击AI消息,选择"重roll"
|
||||
3. 重复重roll 2-3 次
|
||||
4. **刷新页面**
|
||||
5. 观察:
|
||||
- ✅ swipe 控制按钮仍然存在
|
||||
- ✅ 可以切换到之前的所有版本
|
||||
- ✅ 每个版本的内容正确显示
|
||||
|
||||
### 2. 测试多次重roll
|
||||
1. 重roll 5 次
|
||||
2. 检查后端控制台日志:
|
||||
```
|
||||
[WebSocket] 🔄 重roll模式,更新楼层 5 的 swipes
|
||||
[WebSocket] 📊 Swipes 更新: 4 -> 5
|
||||
[WebSocket] ✅ 楼层 5 已更新,swipes 数量: 5
|
||||
```
|
||||
3. 打开 JSONL 文件,确认 swipes 数组有 5 个元素
|
||||
|
||||
### 3. 测试正常模式
|
||||
1. 发送新消息(不是重roll)
|
||||
2. 检查后端控制台日志:
|
||||
```
|
||||
[WebSocket] ➕ 正常模式,创建新消息
|
||||
[WebSocket] ✅ 新消息已保存: 角色名/聊天名
|
||||
```
|
||||
3. 确认创建了两条新消息(用户消息 + AI消息)
|
||||
|
||||
---
|
||||
|
||||
## 相关文件清单
|
||||
|
||||
### 后端文件
|
||||
- `backend/services/chat_service.py`
|
||||
- 新增 `get_message()` 方法
|
||||
|
||||
- `backend/api/routes/chatWsRoute.py`
|
||||
- 修改 `_save_messages()` 函数,区分重roll和正常模式
|
||||
|
||||
### 前端文件
|
||||
- `frontend/src/components/Mid/ChatBox/ChatBox.jsx`
|
||||
- `handleSwipeChange()` - 合并更新 currentSwipeId
|
||||
- 渲染逻辑 - 从 swipes 数组读取内容
|
||||
|
||||
- `frontend/src/Store/Mid/ChatBoxSlice.jsx`
|
||||
- 发送消息时传递 `floor` 参数(重roll时为目标楼层)
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
### ⚠️ 重要提醒
|
||||
|
||||
1. **不要在前端直接修改 swipes**
|
||||
- swipes 的增删改必须通过后端API
|
||||
- 前端只负责读取和显示
|
||||
|
||||
2. **swipe_id 的优先级**
|
||||
- 后端保存的 `message.swipe_id` 是默认值
|
||||
- 前端的 `currentSwipeId[floor]` 可以临时覆盖(仅当前会话)
|
||||
- 刷新页面后,以后端的 `swipe_id` 为准
|
||||
|
||||
3. **兼容性处理**
|
||||
- 旧消息可能没有 swipes 字段
|
||||
- 代码中使用了 `existing_message.get("swipes", [])` 提供默认值
|
||||
- 第一次重roll时会初始化 swipes 数组
|
||||
|
||||
---
|
||||
|
||||
## 未来改进方向
|
||||
|
||||
1. **swipe 管理功能**
|
||||
- 删除某个版本
|
||||
- 重命名版本(添加备注)
|
||||
- 导出特定版本
|
||||
|
||||
2. **性能优化**
|
||||
- swipes 数组过大时,考虑分页加载
|
||||
- 压缩存储长文本
|
||||
|
||||
3. **用户体验**
|
||||
- 显示每个版本的生成时间
|
||||
- 显示 Token 使用量
|
||||
- 支持版本对比(diff 视图)
|
||||
@@ -1,218 +0,0 @@
|
||||
# Swipe & Reroll 机制对比分析
|
||||
|
||||
## SillyTavern 官方机制
|
||||
|
||||
根据 SillyTavern 的设计,swipe 和 reroll 的核心逻辑如下:
|
||||
|
||||
### 1. **Swipe(版本切换)**
|
||||
|
||||
**数据结构:**
|
||||
```json
|
||||
{
|
||||
"floor": 2,
|
||||
"mes": "当前显示的内容",
|
||||
"swipes": [
|
||||
"第一个版本的回复",
|
||||
"第二个版本的回复",
|
||||
"第三个版本的回复"
|
||||
],
|
||||
"swipe_id": 1 // 当前显示的版本索引(从0开始)
|
||||
}
|
||||
```
|
||||
|
||||
**行为规则:**
|
||||
- ✅ `mes` 字段始终显示当前选中的版本内容
|
||||
- ✅ `swipes` 数组存储所有历史版本
|
||||
- ✅ `swipe_id` 指向当前在 `swipes` 数组中的索引
|
||||
- ✅ 切换 swipe 时只更新 `swipe_id` 和 `mes`,不创建新楼层
|
||||
|
||||
### 2. **Reroll(重新生成)**
|
||||
|
||||
**核心原则:**
|
||||
- ❌ **不创建新的消息楼层**
|
||||
- ✅ **在当前 AI 消息的 swipes 数组中添加新版本**
|
||||
- ✅ **使用上一条用户消息作为上下文重新生成**
|
||||
- ✅ **生成完成后自动切换到新版本**
|
||||
|
||||
**流程:**
|
||||
```
|
||||
1. 用户触发 reroll(右键菜单或键盘快捷键)
|
||||
2. 系统找到该 AI 消息的上一条用户消息
|
||||
3. 发送相同的用户输入到后端
|
||||
4. 后端生成新的回复
|
||||
5. 前端将新回复添加到 swipes 数组末尾
|
||||
6. 自动更新 swipe_id 指向新版本
|
||||
7. 更新 mes 字段显示新内容
|
||||
```
|
||||
|
||||
**示例:**
|
||||
```javascript
|
||||
// 初始状态
|
||||
{
|
||||
floor: 2,
|
||||
mes: "版本A的内容",
|
||||
swipes: ["版本A的内容"],
|
||||
swipe_id: 0
|
||||
}
|
||||
|
||||
// 用户点击 reroll 后
|
||||
{
|
||||
floor: 2, // ← 楼层不变
|
||||
mes: "版本B的内容", // ← 显示新内容
|
||||
swipes: [
|
||||
"版本A的内容",
|
||||
"版本B的内容" // ← 添加新版本
|
||||
],
|
||||
swipe_id: 1 // ← 自动切换到新版本
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 我们的实现
|
||||
|
||||
### ✅ 已正确实现的部分
|
||||
|
||||
#### 1. **Swipe 切换**
|
||||
```javascript
|
||||
// ChatBox.jsx - handleSwipeChange
|
||||
const handleSwipeChange = (messageId, direction) => {
|
||||
const message = messages.find(m => m.floor === messageId);
|
||||
if (message && message.swipes && message.swipes.length > 0) {
|
||||
const currentIndex = currentSwipeId[messageId] !== undefined
|
||||
? currentSwipeId[messageId]
|
||||
: message.swipe_id;
|
||||
const newIndex = currentIndex + direction;
|
||||
|
||||
if (newIndex >= 0 && newIndex < message.swipes.length) {
|
||||
setCurrentSwipeId({ [messageId]: newIndex });
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
✅ **符合 SillyTavern**:只更新索引,不修改数据结构
|
||||
|
||||
#### 2. **Swipe 控制按钮**
|
||||
```jsx
|
||||
{hasSwipes && !isUser && (
|
||||
<div className="swipe-controls">
|
||||
<button onClick={() => handleSwipeChange(message.floor, -1)}>◀</button>
|
||||
<span>{currentSwipeIndex + 1}/{message.swipes.length}</span>
|
||||
<button onClick={() => handleSwipeChange(message.floor, 1)}>▶</button>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
✅ **符合 SillyTavern**:所有有 swipe 的消息都显示控制按钮
|
||||
|
||||
#### 3. **键盘快捷键**
|
||||
```javascript
|
||||
// 左键:切换到上一个版本
|
||||
if (e.key === 'ArrowLeft' && currentIndex > 0) {
|
||||
handleSwipeChange(lastAiMessage.floor, -1);
|
||||
}
|
||||
|
||||
// 右键:如果在最后一个版本,触发重roll;否则切换到下一个版本
|
||||
if (e.key === 'ArrowRight') {
|
||||
if (currentIndex >= lastAiMessage.swipes.length - 1) {
|
||||
handleRerollMessage(lastAiMessage); // 触发重roll
|
||||
} else {
|
||||
handleSwipeChange(lastAiMessage.floor, 1);
|
||||
}
|
||||
}
|
||||
```
|
||||
✅ **符合 SillyTavern**:智能判断是否触发重roll
|
||||
|
||||
#### 4. **右键菜单重roll**
|
||||
```jsx
|
||||
{!contextMenu.message.is_user && (
|
||||
<div className="context-menu-item" onClick={() => handleRerollMessage(contextMenu.message)}>
|
||||
<span className="menu-icon">🔄</span>
|
||||
<span className="menu-label">重roll</span>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
✅ **符合 SillyTavern**:只有 AI 消息显示重roll选项
|
||||
|
||||
### ✅ 重roll核心逻辑(刚刚修复)
|
||||
|
||||
#### ChatBoxSlice.jsx - sendMessage
|
||||
```javascript
|
||||
sendMessage: async (content, targetFloor = null) => {
|
||||
const isReroll = targetFloor !== null;
|
||||
|
||||
if (isReroll) {
|
||||
// 重roll模式
|
||||
assistantFloor = targetFloor;
|
||||
nextFloor = targetFloor; // ✅ 修复:设置 nextFloor
|
||||
// 不添加新用户消息
|
||||
} else {
|
||||
// 正常模式
|
||||
nextFloor = get().getNextFloor(messages);
|
||||
userFloor = nextFloor;
|
||||
assistantFloor = nextFloor + 1;
|
||||
// 添加新用户消息和AI消息
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### WebSocket Complete 事件处理
|
||||
```javascript
|
||||
if (data.type === 'complete' && isReroll) {
|
||||
// 获取现有的 swipes 数组
|
||||
const existingSwipes = msg.swipes || [];
|
||||
const currentMes = msg.mes;
|
||||
|
||||
// 如果当前内容不在 swipes 中,先添加它
|
||||
let updatedSwipes = [...existingSwipes];
|
||||
if (!updatedSwipes.includes(currentMes)) {
|
||||
updatedSwipes.push(currentMes);
|
||||
}
|
||||
|
||||
// 添加新生成的内容
|
||||
updatedSwipes.push(assistantMessage);
|
||||
|
||||
// 更新消息
|
||||
return {
|
||||
...msg,
|
||||
mes: assistantMessage, // 显示新内容
|
||||
swipes: updatedSwipes, // 更新数组
|
||||
swipe_id: updatedSwipes.length - 1 // 自动切换到新版本
|
||||
};
|
||||
}
|
||||
```
|
||||
✅ **完全符合 SillyTavern**:
|
||||
- ❌ 不创建新楼层
|
||||
- ✅ 在 swipes 数组中添加新版本
|
||||
- ✅ 自动切换到新生成的版本
|
||||
- ✅ 保留历史版本
|
||||
|
||||
---
|
||||
|
||||
## 对比总结
|
||||
|
||||
| 功能 | SillyTavern | 我们的实现 | 状态 |
|
||||
|------|-------------|-----------|------|
|
||||
| Swipe 数据结构 | `swipes` 数组 + `swipe_id` | ✅ 相同 | ✅ |
|
||||
| Swipe 切换 | 只更新索引 | ✅ 相同 | ✅ |
|
||||
| Swipe 按钮显示 | 所有有 swipe 的消息 | ✅ 相同 | ✅ |
|
||||
| 键盘左右键切换 | 支持 | ✅ 相同 | ✅ |
|
||||
| Reroll 不创建新楼层 | ✅ | ✅ | ✅ |
|
||||
| Reroll 添加到 swipes | ✅ | ✅ | ✅ |
|
||||
| Reroll 自动切换 | ✅ | ✅ | ✅ |
|
||||
| Reroll 使用上文 | ✅ | ✅ | ✅ |
|
||||
| 右键菜单重roll | ✅ | ✅ | ✅ |
|
||||
| 最后一个swipe按右键重roll | ✅ | ✅ | ✅ |
|
||||
|
||||
## 结论
|
||||
|
||||
✅ **我们的实现与 SillyTavern 官方机制完全一致!**
|
||||
|
||||
所有核心功能都已正确实现:
|
||||
1. ✅ Swipe 版本切换
|
||||
2. ✅ Reroll 不创建新楼层
|
||||
3. ✅ Reroll 添加到 swipes 数组
|
||||
4. ✅ 自动切换到新版本
|
||||
5. ✅ 键盘快捷键支持
|
||||
6. ✅ 右键菜单支持
|
||||
|
||||
唯一的区别是 UI 样式和交互细节,但核心逻辑完全符合 SillyTavern 的设计规范。
|
||||
@@ -1,238 +0,0 @@
|
||||
# Swipe 功能测试指南
|
||||
|
||||
## 测试场景
|
||||
|
||||
### 场景1:首次重roll(消息没有swipes数组)
|
||||
|
||||
**初始状态:**
|
||||
```javascript
|
||||
message = {
|
||||
id: "ai_123456",
|
||||
floor: 2,
|
||||
mes: "这是AI的第一次回复",
|
||||
is_user: false,
|
||||
// 没有 swipes 字段
|
||||
}
|
||||
```
|
||||
|
||||
**执行重roll后:**
|
||||
```javascript
|
||||
message = {
|
||||
id: "ai_123456",
|
||||
floor: 2,
|
||||
mes: "这是AI的第二次回复", // 显示新生成的内容
|
||||
is_user: false,
|
||||
swipes: [
|
||||
"这是AI的第一次回复", // 旧版本
|
||||
"这是AI的第二次回复" // 新版本
|
||||
],
|
||||
swipe_id: 1 // 自动切换到新版本
|
||||
}
|
||||
```
|
||||
|
||||
**前端显示:**
|
||||
- 因为有 `swipes` 数组,所以显示 `swipes[1]` = "这是AI的第二次回复"
|
||||
- 显示 swipe 控制按钮:◀ 2/2 ▶
|
||||
|
||||
---
|
||||
|
||||
### 场景2:第二次重roll(消息已有swipes数组)
|
||||
|
||||
**初始状态:**
|
||||
```javascript
|
||||
message = {
|
||||
id: "ai_123456",
|
||||
floor: 2,
|
||||
mes: "这是AI的第二次回复",
|
||||
is_user: false,
|
||||
swipes: [
|
||||
"这是AI的第一次回复",
|
||||
"这是AI的第二次回复"
|
||||
],
|
||||
swipe_id: 1
|
||||
}
|
||||
```
|
||||
|
||||
**执行重roll后:**
|
||||
```javascript
|
||||
message = {
|
||||
id: "ai_123456",
|
||||
floor: 2,
|
||||
mes: "这是AI的第三次回复", // 显示新生成的内容
|
||||
is_user: false,
|
||||
swipes: [
|
||||
"这是AI的第一次回复",
|
||||
"这是AI的第二次回复",
|
||||
"这是AI的第三次回复" // 新版本
|
||||
],
|
||||
swipe_id: 2 // 自动切换到新版本
|
||||
}
|
||||
```
|
||||
|
||||
**前端显示:**
|
||||
- 显示 `swipes[2]` = "这是AI的第三次回复"
|
||||
- 显示 swipe 控制按钮:◀ 3/3 ▶
|
||||
|
||||
---
|
||||
|
||||
### 场景3:用户手动切换swipe
|
||||
|
||||
**初始状态:**
|
||||
```javascript
|
||||
message = {
|
||||
id: "ai_123456",
|
||||
floor: 2,
|
||||
mes: "这是AI的第三次回复",
|
||||
is_user: false,
|
||||
swipes: [
|
||||
"这是AI的第一次回复",
|
||||
"这是AI的第二次回复",
|
||||
"这是AI的第三次回复"
|
||||
],
|
||||
swipe_id: 2
|
||||
}
|
||||
|
||||
// 用户点击左箭头
|
||||
currentSwipeId = { 2: 1 } // 切换到第二个版本
|
||||
```
|
||||
|
||||
**前端显示:**
|
||||
- 因为 `currentSwipeId[2]` 存在,所以使用它而不是 `swipe_id`
|
||||
- 显示 `swipes[1]` = "这是AI的第二次回复"
|
||||
- 显示 swipe 控制按钮:◀ 2/3 ▶
|
||||
|
||||
---
|
||||
|
||||
## 关键逻辑说明
|
||||
|
||||
### 1. 后端逻辑(ChatBoxSlice.jsx)
|
||||
|
||||
#### chunk 事件(流式输出)
|
||||
```javascript
|
||||
if (isReroll) {
|
||||
// ✅ 重roll模式:不更新 mes,只在内部累积 assistantMessage
|
||||
// mes 保持不变,直到 complete 事件才更新
|
||||
} else {
|
||||
// 正常模式:更新新创建的消息
|
||||
set((state) => ({
|
||||
messages: state.messages.map((msg) =>
|
||||
msg.id === newMessageId ? { ...msg, mes: assistantMessage } : msg
|
||||
)
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
#### complete 事件(生成完成)
|
||||
```javascript
|
||||
if (isReroll) {
|
||||
const existingSwipes = msg.swipes || [];
|
||||
const currentMes = msg.mes; // 当前显示的内容(旧版本)
|
||||
|
||||
let updatedSwipes = [...existingSwipes];
|
||||
|
||||
// 如果当前 mes 不在 swipes 中,先添加它
|
||||
if (!updatedSwipes.includes(currentMes)) {
|
||||
updatedSwipes.push(currentMes);
|
||||
}
|
||||
|
||||
// 添加新生成的内容
|
||||
updatedSwipes.push(assistantMessage);
|
||||
|
||||
return {
|
||||
...msg,
|
||||
mes: assistantMessage, // 显示新生成的内容
|
||||
swipes: updatedSwipes, // 更新 swipes 数组
|
||||
swipe_id: updatedSwipes.length - 1 // 自动切换到新版本
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 前端逻辑(ChatBox.jsx)
|
||||
|
||||
```javascript
|
||||
// 确定当前显示的消息内容
|
||||
let currentMes = message.mes; // 默认使用 mes
|
||||
let hasSwipes = message.swipes && message.swipes.length > 0;
|
||||
let currentSwipeIndex = message.swipe_id;
|
||||
|
||||
if (hasSwipes) {
|
||||
// 如果有swipes数组
|
||||
if (currentSwipeId[message.floor] !== undefined) {
|
||||
// 如果用户已经切换过版本,使用用户选择的版本
|
||||
currentSwipeIndex = currentSwipeId[message.floor];
|
||||
} else {
|
||||
// 否则使用默认的swipe_id
|
||||
currentSwipeIndex = message.swipe_id;
|
||||
}
|
||||
|
||||
if (currentSwipeIndex >= 0 && currentSwipeIndex < message.swipes.length) {
|
||||
currentMes = message.swipes[currentSwipeIndex]; // 从 swipes 中读取
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 测试步骤
|
||||
|
||||
### 1. 测试首次重roll
|
||||
1. 发送一条消息,等待AI回复
|
||||
2. 右键点击AI消息,选择"重roll"
|
||||
3. 观察:
|
||||
- ✅ 流式输出时,消息内容不变(不显示流式过程)
|
||||
- ✅ 完成后,消息更新为新内容
|
||||
- ✅ 出现 swipe 控制按钮:◀ 2/2 ▶
|
||||
- ✅ 点击左箭头可以切换到旧版本
|
||||
|
||||
### 2. 测试多次重roll
|
||||
1. 继续重roll几次
|
||||
2. 观察:
|
||||
- ✅ 每次重roll都在 swipes 数组中添加新版本
|
||||
- ✅ swipe 计数器正确更新:2/3, 3/4, 4/5...
|
||||
- ✅ 可以通过左右箭头切换所有版本
|
||||
|
||||
### 3. 测试手动切换
|
||||
1. 重roll几次后,有多个版本
|
||||
2. 点击左箭头切换到旧版本
|
||||
3. 观察:
|
||||
- ✅ 消息内容切换为旧版本
|
||||
- ✅ swipe 计数器正确更新
|
||||
- ✅ 再次重roll时,新版本添加到末尾
|
||||
|
||||
### 4. 测试键盘快捷键
|
||||
1. 使用左右箭头键切换 swipe
|
||||
2. 在最后一个版本时按右键触发重roll
|
||||
3. 观察:
|
||||
- ✅ 左右键正确切换版本
|
||||
- ✅ 右键在最后一个版本时触发重roll
|
||||
- ✅ 重roll后自动切换到新版本
|
||||
|
||||
---
|
||||
|
||||
## 常见问题排查
|
||||
|
||||
### Q1: 重roll后没有出现 swipe 按钮
|
||||
**原因**:swipes 数组没有正确创建
|
||||
**检查**:
|
||||
- 查看控制台日志:`[ChatBoxStore] 📊 Swipes 更新:`
|
||||
- 确认 `newCount` 大于 0
|
||||
- 确认消息对象中有 `swipes` 字段
|
||||
|
||||
### Q2: 重roll后显示的是旧内容
|
||||
**原因**:`mes` 字段没有更新
|
||||
**检查**:
|
||||
- 确认 complete 事件中更新了 `mes: assistantMessage`
|
||||
- 确认 `assistantMessage` 有正确的内容
|
||||
|
||||
### Q3: 切换 swipe 后内容不变
|
||||
**原因**:前端渲染逻辑有问题
|
||||
**检查**:
|
||||
- 确认 `currentMes` 正确从 `swipes[currentSwipeIndex]` 读取
|
||||
- 确认 `currentSwipeIndex` 的值正确
|
||||
- 确认 `currentSwipeId` 状态正确更新
|
||||
|
||||
### Q4: 重roll时看到流式输出覆盖了原内容
|
||||
**原因**:chunk 事件中错误地更新了 `mes`
|
||||
**检查**:
|
||||
- 确认 chunk 事件中,重roll模式不更新 `mes`
|
||||
- 应该只有正常模式才更新 `mes`
|
||||
@@ -1,428 +0,0 @@
|
||||
# 🧪 API 配置功能测试指南
|
||||
|
||||
## 📋 测试前准备
|
||||
|
||||
### **1. 启动后端服务**
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
uvicorn main:app --reload --port 8000
|
||||
```
|
||||
|
||||
确保看到:
|
||||
```
|
||||
INFO: Application startup complete.
|
||||
INFO: Uvicorn running on http://127.0.0.1:8000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### **2. (可选)启动 ComfyUI**
|
||||
|
||||
如果要测试 ComfyUI 连接:
|
||||
|
||||
```bash
|
||||
# 本地运行
|
||||
python comfyui/main.py --listen 0.0.0.0 --port 8188
|
||||
|
||||
# 或 Docker
|
||||
docker-compose up -d comfyui
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 运行测试
|
||||
|
||||
### **方法 1: 使用 Python 脚本(推荐)**
|
||||
|
||||
```bash
|
||||
# 在项目根目录运行
|
||||
python test_api_config.py
|
||||
```
|
||||
|
||||
**预期输出**:
|
||||
```
|
||||
============================================================
|
||||
ComfyUI API 配置测试
|
||||
============================================================
|
||||
|
||||
============================================================
|
||||
测试 1: 列出工作流
|
||||
============================================================
|
||||
|
||||
✅ 成功获取 1 个工作流
|
||||
|
||||
📄 default_txt2img.json
|
||||
节点数: 7, 大小: 1234 bytes
|
||||
|
||||
...
|
||||
|
||||
============================================================
|
||||
测试总结
|
||||
============================================================
|
||||
|
||||
✅ 通过 - 列出工作流
|
||||
✅ 通过 - 获取工作流详情
|
||||
✅ 通过 - 上传工作流
|
||||
✅ 通过 - 删除工作流
|
||||
✅ 通过 - 测试 ComfyUI 连接
|
||||
✅ 通过 - 测试云端 API 连接
|
||||
|
||||
总计: 6/6 通过
|
||||
|
||||
🎉 所有测试通过!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### **方法 2: 使用 cURL 手动测试**
|
||||
|
||||
#### **测试 1: 列出工作流**
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/api/api-config/comfyui/workflows | jq
|
||||
```
|
||||
|
||||
**预期响应**:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"filename": "default_txt2img.json",
|
||||
"name": "default_txt2img",
|
||||
"nodes_count": 7,
|
||||
"size": 1234
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **测试 2: 获取工作流详情**
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/api/api-config/comfyui/workflows/default_txt2img.json | jq
|
||||
```
|
||||
|
||||
**预期响应**:
|
||||
```json
|
||||
{
|
||||
"3": {
|
||||
"inputs": {...},
|
||||
"class_type": "KSampler",
|
||||
"_meta": {"title": "K采样器"}
|
||||
},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **测试 3: 上传工作流**
|
||||
|
||||
创建一个测试文件 `test_workflow.json`:
|
||||
|
||||
```bash
|
||||
cat > test_workflow.json << 'EOF'
|
||||
{
|
||||
"3": {
|
||||
"inputs": {
|
||||
"seed": 42,
|
||||
"steps": 20,
|
||||
"cfg": 8,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "normal",
|
||||
"denoise": 1,
|
||||
"model": ["4", 0],
|
||||
"positive": ["6", 0],
|
||||
"negative": ["7", 0],
|
||||
"latent_image": ["5", 0]
|
||||
},
|
||||
"class_type": "KSampler"
|
||||
},
|
||||
"4": {
|
||||
"inputs": {"ckpt_name": "test.safetensors"},
|
||||
"class_type": "CheckpointLoaderSimple"
|
||||
},
|
||||
"5": {
|
||||
"inputs": {"width": 512, "height": 512, "batch_size": 1},
|
||||
"class_type": "EmptyLatentImage"
|
||||
},
|
||||
"6": {
|
||||
"inputs": {"text": "test", "clip": ["4", 1]},
|
||||
"class_type": "CLIPTextEncode"
|
||||
},
|
||||
"7": {
|
||||
"inputs": {"text": "bad", "clip": ["4", 1]},
|
||||
"class_type": "CLIPTextEncode"
|
||||
},
|
||||
"8": {
|
||||
"inputs": {"samples": ["3", 0], "vae": ["4", 2]},
|
||||
"class_type": "VAEDecode"
|
||||
},
|
||||
"9": {
|
||||
"inputs": {"images": ["8", 0], "filename_prefix": "Test"},
|
||||
"class_type": "SaveImage"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
上传:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/api-config/comfyui/workflows/upload \
|
||||
-F "file=@test_workflow.json" | jq
|
||||
```
|
||||
|
||||
**预期响应**:
|
||||
```json
|
||||
{
|
||||
"message": "Workflow uploaded successfully",
|
||||
"filename": "test_workflow.json",
|
||||
"size": 1234
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **测试 4: 删除工作流**
|
||||
|
||||
```bash
|
||||
curl -X DELETE http://localhost:8000/api/api-config/comfyui/workflows/test_workflow.json | jq
|
||||
```
|
||||
|
||||
**预期响应**:
|
||||
```json
|
||||
{
|
||||
"message": "Workflow 'test_workflow.json' deleted successfully"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **测试 5: 测试 ComfyUI 连接**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/api-config/test-comfyui-connection \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"apiUrl": "http://localhost:8188"}' | jq
|
||||
```
|
||||
|
||||
**如果 ComfyUI 正在运行**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "连接成功",
|
||||
"stats": {
|
||||
"vram_total": 25769803776,
|
||||
"vram_free": 24696061952,
|
||||
"torch_version": "2.1.0+cu121",
|
||||
"device": "cuda"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**如果 ComfyUI 未运行**:
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "无法连接到 ComfyUI,请检查地址和端口"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### **测试 6: 测试云端 API 连接**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/api-config/test-cloud-connection \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"provider": "dall-e",
|
||||
"apiKey": "sk-your-api-key-here",
|
||||
"model": "dall-e-3"
|
||||
}' | jq
|
||||
```
|
||||
|
||||
**预期响应**(如果 API Key 有效):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "连接成功,模型 dall-e-3 可用"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 测试检查清单
|
||||
|
||||
### **后端 API**
|
||||
- [ ] 列出工作流返回正确的列表
|
||||
- [ ] 获取工作流详情返回完整的 JSON
|
||||
- [ ] 上传工作流成功保存文件
|
||||
- [ ] 上传的工作流可以通过列表看到
|
||||
- [ ] 删除工作流成功移除文件
|
||||
- [ ] 默认工作流不可删除(返回 403)
|
||||
- [ ] ComfyUI 连接测试正确检测状态
|
||||
- [ ] 云端 API 连接测试验证 Key
|
||||
|
||||
### **前端 UI**
|
||||
- [ ] 可以切换到"🎨 生图"标签
|
||||
- [ ] 模式切换卡片正常显示
|
||||
- [ ] 点击"本地 ComfyUI"显示本地配置
|
||||
- [ ] 点击"在线 API"显示云端配置
|
||||
- [ ] 表单输入正常工作
|
||||
- [ ] 工作流管理器显示默认工作流
|
||||
- [ ] 可以上传工作流文件
|
||||
- [ ] 可以删除工作流(非默认)
|
||||
- [ ] 测试连接按钮正常工作
|
||||
- [ ] 保存配置功能正常
|
||||
|
||||
### **响应式设计**
|
||||
- [ ] 大屏幕(>768px)双列布局
|
||||
- [ ] 小屏幕(<768px)单列布局
|
||||
- [ ] 无页面级滚动条
|
||||
- [ ] 侧边栏可独立滚动
|
||||
- [ ] 无横向滚动
|
||||
|
||||
---
|
||||
|
||||
## 🐛 常见问题
|
||||
|
||||
### **Q1: 测试脚本提示"Connection refused"**
|
||||
|
||||
**原因**:后端服务未启动
|
||||
|
||||
**解决**:
|
||||
```bash
|
||||
cd backend
|
||||
uvicorn main:app --reload --port 8000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### **Q2: 上传工作流提示"Invalid ComfyUI workflow"**
|
||||
|
||||
**原因**:JSON 格式不正确或缺少必要节点
|
||||
|
||||
**解决**:
|
||||
- 确保包含 `KSampler` 节点
|
||||
- 使用 ComfyUI 的 "Save (API Format)" 导出
|
||||
- 检查 JSON 语法是否正确
|
||||
|
||||
---
|
||||
|
||||
### **Q3: 删除工作流提示"Cannot delete default workflow"**
|
||||
|
||||
**这是正常的**!默认工作流受保护,不可删除。
|
||||
|
||||
要测试删除功能,请先上传一个自定义工作流,然后删除它。
|
||||
|
||||
---
|
||||
|
||||
### **Q4: ComfyUI 连接测试失败**
|
||||
|
||||
**可能原因**:
|
||||
1. ComfyUI 未启动
|
||||
2. 地址或端口错误
|
||||
3. Docker 网络问题
|
||||
|
||||
**解决**:
|
||||
```bash
|
||||
# 检查 ComfyUI 是否运行
|
||||
curl http://localhost:8188/system_stats
|
||||
|
||||
# Docker 环境下
|
||||
docker ps | grep comfyui
|
||||
docker logs comfyui
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 测试结果解读
|
||||
|
||||
### **全部通过** ✅
|
||||
```
|
||||
总计: 6/6 通过
|
||||
🎉 所有测试通过!
|
||||
```
|
||||
→ API 配置功能完全正常,可以开始使用
|
||||
|
||||
### **部分失败** ⚠️
|
||||
```
|
||||
总计: 4/6 通过
|
||||
⚠️ 2 个测试失败,请检查日志
|
||||
```
|
||||
→ 查看失败的测试项,根据错误信息排查
|
||||
|
||||
### **全部失败** ❌
|
||||
```
|
||||
总计: 0/6 通过
|
||||
```
|
||||
→ 检查后端服务是否正常运行
|
||||
→ 检查端口是否正确(8000)
|
||||
→ 查看后端日志
|
||||
|
||||
---
|
||||
|
||||
## 🎯 下一步
|
||||
|
||||
测试通过后,你可以:
|
||||
|
||||
1. **启动前端**
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
2. **访问应用**
|
||||
- 打开浏览器访问 `http://localhost:5173`
|
||||
- 进入 API 配置页面
|
||||
- 配置你的生图服务
|
||||
|
||||
3. **开始生图**
|
||||
- 配置完成后
|
||||
- 在聊天界面输入生图请求
|
||||
- 等待图片生成
|
||||
|
||||
---
|
||||
|
||||
## 📝 附录
|
||||
|
||||
### **工作流文件格式**
|
||||
|
||||
必须是 ComfyUI API 格式的 JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"node_id": {
|
||||
"inputs": {...},
|
||||
"class_type": "NodeType",
|
||||
"_meta": {"title": "Display Name"}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### **必需的节点类型**
|
||||
|
||||
- `KSampler` - 采样器(必需)
|
||||
- `CheckpointLoaderSimple` - 模型加载器
|
||||
- `EmptyLatentImage` - 潜变量图像
|
||||
- `CLIPTextEncode` - 文本编码器(正向和负向)
|
||||
- `VAEDecode` - VAE 解码器
|
||||
- `SaveImage` - 保存图像
|
||||
|
||||
### **API 端点列表**
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/api-config/comfyui/workflows` | 列出工作流 |
|
||||
| POST | `/api/api-config/comfyui/workflows/upload` | 上传工作流 |
|
||||
| DELETE | `/api/api-config/comfyui/workflows/{filename}` | 删除工作流 |
|
||||
| GET | `/api/api-config/comfyui/workflows/{filename}` | 获取工作流详情 |
|
||||
| POST | `/api/api-config/test-comfyui-connection` | 测试 ComfyUI |
|
||||
| POST | `/api/api-config/test-cloud-connection` | 测试云端 API |
|
||||
|
||||
---
|
||||
|
||||
**祝测试顺利!** 🎉
|
||||
@@ -1,318 +0,0 @@
|
||||
# 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,292 +0,0 @@
|
||||
# 世界书激活业务逻辑说明
|
||||
|
||||
## 概述
|
||||
|
||||
世界书(WorldBook)系统允许用户创建动态的知识条目,这些条目可以根据特定条件自动激活并注入到对话上下文中。本文档详细说明世界书条目的筛选和分类逻辑。
|
||||
|
||||
## 核心流程
|
||||
|
||||
### 1. 数据收集阶段
|
||||
|
||||
后端从两个来源加载世界书条目:
|
||||
|
||||
1. **全局世界书** (`globalBooks`)
|
||||
- 来自前端传递的全局世界书列表
|
||||
- 所有对话都会检查这些世界书
|
||||
|
||||
2. **角色绑定世界书** (`characterBookId`)
|
||||
- 与当前角色卡绑定的世界书
|
||||
- 仅在该角色的对话中检查
|
||||
|
||||
### 2. 条目筛选阶段
|
||||
|
||||
对每个世界书条目进行以下检查:
|
||||
|
||||
#### 2.1 禁用状态检查
|
||||
```python
|
||||
if entry_data.get("disable", False):
|
||||
continue # 跳过禁用的条目
|
||||
```
|
||||
|
||||
#### 2.2 触发条件检查
|
||||
|
||||
支持四种触发类型:
|
||||
|
||||
##### A. 常驻触发 (constant)
|
||||
- **配置**: `trigger_config.triggers.constant[0] == true`
|
||||
- **行为**: 总是激活,无需任何条件
|
||||
- **适用场景**: 角色基础设定、世界观背景等始终需要的信息
|
||||
|
||||
##### B. 关键词触发 (keyword)
|
||||
- **配置**:
|
||||
```json
|
||||
{
|
||||
"key": ["关键词1", "关键词2"],
|
||||
"caseSensitive": false,
|
||||
"matchWholeWords": false
|
||||
}
|
||||
```
|
||||
- **检查范围**:
|
||||
- 用户当前输入 (`user_message`)
|
||||
- 聊天历史消息 (`chat_history`)
|
||||
- **匹配模式**:
|
||||
- 大小写敏感/不敏感
|
||||
- 全词匹配/部分匹配
|
||||
- **适用场景**: 当对话中提到特定概念时激活相关解释
|
||||
|
||||
##### C. 条件触发 (condition)
|
||||
- **配置**:
|
||||
```json
|
||||
{
|
||||
"conditions": [
|
||||
{"text": "tb.力量 > 10"},
|
||||
{"text": "tb.态度 包括 \"友好\""}
|
||||
]
|
||||
}
|
||||
```
|
||||
- **支持的语法**:
|
||||
- 数值比较: `tb.字段名 >/</>=/<=/==/!= 数值`
|
||||
- 字符串相等: `tb.字段名 = "值"`
|
||||
- 字符串包含: `tb.字段名 包括 "子串"`
|
||||
- **逻辑**: 所有条件必须同时满足 (AND)
|
||||
- **适用场景**: 基于动态表格状态的 conditional 内容
|
||||
|
||||
##### D. RAG触发 (暂未实现)
|
||||
- 预留接口,未来可支持向量检索激活
|
||||
|
||||
### 3. 位置分类阶段
|
||||
|
||||
激活的条目按插入位置进行分类和排序:
|
||||
|
||||
#### 位置编号及含义
|
||||
|
||||
| 位置 | 标签 | 权重 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 0 | 角色定义之后 | 高 | AI读完人设紧接着就读到这里,适合补充角色的详细设定 |
|
||||
| 1 | 角色定义之前 | 中 | 在角色卡内容的最上方,用于定义角色的基础背景 |
|
||||
| 2 | 示例对话之前 | 低 | 在对话示例的最上方 |
|
||||
| 3 | 示例对话之后 | 低 | 用于在对话开始前提供最后的上下文补充 |
|
||||
| 4 | 系统提示/作者注释 | 极高 | AI对最近看到的信息记忆最清晰,适合动态信息 |
|
||||
| 5 | 作为系统消息 | 最高 | 强制作为System Prompt插入,用于强制指令 |
|
||||
| 6 | 深度插入 | - | 预留,待实现 |
|
||||
| 7 | 宏替换 | - | 预留,待实现 |
|
||||
|
||||
#### 排序规则
|
||||
|
||||
```python
|
||||
active_entries.sort(key=lambda x: (x.position or 0, x.order or 0))
|
||||
```
|
||||
|
||||
1. **主排序**: 按 `position` 升序(位置编号小的先插入)
|
||||
2. **次排序**: 同位置内按 `order` 升序(order值小的先插入)
|
||||
|
||||
### 4. 统计与日志
|
||||
|
||||
激活完成后,生成详细的统计信息:
|
||||
|
||||
```
|
||||
[WorldBook] 📊 激活统计:
|
||||
- 总激活条目数: 5
|
||||
- 按位置分布:
|
||||
* 角色定义之后 (pos=0): 2 个
|
||||
* 系统提示/作者注释 (pos=4): 3 个
|
||||
- 按触发类型分布:
|
||||
* constant: 2 个
|
||||
* keyword: 2 个
|
||||
* condition: 1 个
|
||||
```
|
||||
|
||||
## 数据结构
|
||||
|
||||
### 前端发送的数据格式
|
||||
|
||||
```javascript
|
||||
{
|
||||
worldBookData: {
|
||||
globalBooks: [
|
||||
{ id: "...", name: "全局世界书1" },
|
||||
{ id: "...", name: "全局世界书2" }
|
||||
],
|
||||
characterBookId: "角色绑定的世界书ID"
|
||||
},
|
||||
mes: "用户输入的消息",
|
||||
chatHistory: ["历史消息1", "历史消息2"],
|
||||
dynamicTableData: {
|
||||
currentValues: {
|
||||
"力量": 15,
|
||||
"态度": "友好"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 后端返回的激活条目格式
|
||||
|
||||
```python
|
||||
{
|
||||
"type": "worldbook_active",
|
||||
"entries": [
|
||||
{
|
||||
"uid": "uuid-string",
|
||||
"content": "条目内容",
|
||||
"position": 0,
|
||||
"order": 100,
|
||||
"trigger_config": {...},
|
||||
...
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 实现细节
|
||||
|
||||
### 关键方法
|
||||
|
||||
1. **`_collect_and_activate_worldbooks()`**
|
||||
- 主入口方法
|
||||
- 负责加载世界书、检查激活条件、排序和统计
|
||||
|
||||
2. **`_check_entry_activation()`**
|
||||
- 检查单个条目的激活条件
|
||||
- 支持 constant、keyword、condition 三种触发类型
|
||||
|
||||
3. **`_check_keyword_trigger_with_config()`**
|
||||
- 关键词匹配逻辑
|
||||
- 支持大小写敏感和全词匹配选项
|
||||
|
||||
4. **`_parse_table_condition()`**
|
||||
- 解析动态表格条件表达式
|
||||
- 支持数值比较和字符串操作
|
||||
|
||||
5. **`_get_trigger_type()`**
|
||||
- 识别条目的触发类型
|
||||
- 用于日志和统计
|
||||
|
||||
6. **`_get_position_label()`**
|
||||
- 将位置编号转换为中文标签
|
||||
- 用于日志和前端显示
|
||||
|
||||
### 数据类型标准化
|
||||
|
||||
在激活前,对所有条目数据进行标准化处理:
|
||||
|
||||
```python
|
||||
def _normalize_worldbook_entry(entry_data):
|
||||
# content 必须是字符串
|
||||
# uid 必须是字符串
|
||||
# position 必须是整数
|
||||
# group 必须是列表或None
|
||||
```
|
||||
|
||||
## 前端集成
|
||||
|
||||
### WebSocket 消息接收
|
||||
|
||||
前端通过 WebSocket 接收激活条目:
|
||||
|
||||
```javascript
|
||||
// ChatBoxSlice.jsx
|
||||
else if (data.type === 'worldbook_active') {
|
||||
console.log('[WebSocket] 📚 收到世界书激活信息:', data.entries.length, '个条目');
|
||||
import('../../Store/SideBarRight/WorldBookActiveSlice').then(module => {
|
||||
module.default.getState().setActiveEntries(data.entries);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 显示组件
|
||||
|
||||
`WorldBookActive` 组件按位置分组显示激活的条目:
|
||||
|
||||
```jsx
|
||||
// 按位置分组
|
||||
const groupedEntries = activeEntries.reduce((acc, entry) => {
|
||||
const pos = entry.position || 0;
|
||||
if (!acc[pos]) acc[pos] = [];
|
||||
acc[pos].push(entry);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// 渲染每个位置组
|
||||
{Object.keys(groupedEntries).map(pos => (
|
||||
<div key={pos} className="position-group">
|
||||
<div className="position-label">
|
||||
{positionLabels[pos]} ({pos})
|
||||
</div>
|
||||
{/* 渲染该位置的所有条目 */}
|
||||
</div>
|
||||
))}
|
||||
```
|
||||
|
||||
## 扩展方向
|
||||
|
||||
### 短期优化
|
||||
1. 添加概率触发支持 (`probability` 字段)
|
||||
2. 实现RAG检索触发
|
||||
3. 支持逻辑表达式触发 (`logicExpression`)
|
||||
|
||||
### 长期规划
|
||||
1. 实现深度插入 (position=6)
|
||||
2. 实现宏替换 (position=7)
|
||||
3. 添加条目冲突解决机制
|
||||
4. 支持条目依赖关系
|
||||
|
||||
## 调试建议
|
||||
|
||||
### 查看激活日志
|
||||
|
||||
后端会输出详细的激活日志:
|
||||
|
||||
```
|
||||
[WorldBook] 📖 加载全局世界书 '测试世界书',共 10 个条目
|
||||
[WorldBook-Check] 📌 常驻触发 | UID: xxx-xxx-xxx
|
||||
[WorldBook] ✅ 全局世界书 '测试世界书' 条目激活 | UID: xxx | 触发: constant | 位置: 角色定义之后
|
||||
[WorldBook-Check] 🔑 关键词触发(用户输入) | UID: yyy | 匹配关键词: ['魔法']
|
||||
```
|
||||
|
||||
### 常见问题排查
|
||||
|
||||
1. **条目未激活**
|
||||
- 检查 `disable` 字段是否为 `true`
|
||||
- 检查触发条件配置是否正确
|
||||
- 查看日志中的 `[WorldBook-Check]` 输出
|
||||
|
||||
2. **位置不正确**
|
||||
- 确认 `position` 字段是整数类型
|
||||
- 检查 `order` 字段的排序是否符合预期
|
||||
|
||||
3. **关键词不匹配**
|
||||
- 检查 `caseSensitive` 设置
|
||||
- 检查 `matchWholeWords` 设置
|
||||
- 确认关键词拼写正确
|
||||
|
||||
## 总结
|
||||
|
||||
世界书激活系统实现了:
|
||||
- ✅ 多源数据加载(全局 + 角色绑定)
|
||||
- ✅ 多种触发类型(常驻、关键词、条件)
|
||||
- ✅ 智能位置分类(8个插入位置)
|
||||
- ✅ 详细统计分析(按位置和触发类型)
|
||||
- ✅ 完整日志记录(便于调试)
|
||||
|
||||
这为动态上下文管理提供了强大的基础,可以根据对话内容智能地注入相关知识。
|
||||
@@ -1,217 +0,0 @@
|
||||
# 世界书分页功能 - 前端API调用检查报告
|
||||
|
||||
## ✅ 检查结果
|
||||
|
||||
**所有前端世界书分页功能都能正确发出请求到后端API!**
|
||||
|
||||
---
|
||||
|
||||
## 📋 完整API映射表
|
||||
|
||||
### 1. 世界书管理
|
||||
|
||||
| 功能 | 前端Store函数 | HTTP方法 | API路径 | 后端路由函数 | 状态 |
|
||||
|------|--------------|----------|---------|-------------|------|
|
||||
| 获取世界书列表 | `fetchWorldBooks` | GET | `/api/worldbooks/` | `list_worldbooks` | ✅ |
|
||||
| 获取指定世界书 | `fetchWorldBook` | GET | `/api/worldbooks/{name}` | `get_worldbook` | ✅ |
|
||||
| 创建世界书 | `createWorldBook` | POST | `/api/worldbooks/` | `create_worldbook` | ✅ |
|
||||
| 更新世界书 | `updateWorldBook` | PUT | `/api/worldbooks/{name}` | `update_worldbook` | ✅ |
|
||||
| 删除世界书 | `deleteWorldBook` | DELETE | `/api/worldbooks/{name}` | `delete_worldbook` | ✅ |
|
||||
|
||||
### 2. 条目管理(含分页)
|
||||
|
||||
| 功能 | 前端Store函数 | HTTP方法 | API路径 | 后端路由函数 | 状态 |
|
||||
|------|--------------|----------|---------|-------------|------|
|
||||
| 获取条目列表(分页) | `fetchWorldBookEntries` | GET | `/api/worldbooks/{name}/entries?page={page}&page_size={page_size}` | `list_worldbook_entries` | ✅ |
|
||||
| 获取指定条目 | `fetchWorldBookEntry` | GET | `/api/worldbooks/{name}/entries/{uid}` | `get_worldbook_entry` | ✅ |
|
||||
| 创建条目 | `createWorldBookEntry` | POST | `/api/worldbooks/{name}/entries` | `create_worldbook_entry` | ✅ |
|
||||
| 更新条目 | `updateWorldBookEntry` | PUT | `/api/worldbooks/{name}/entries/{uid}` | `update_worldbook_entry` | ✅ |
|
||||
| 删除条目 | `deleteWorldBookEntry` | DELETE | `/api/worldbooks/{name}/entries/{uid}` | `delete_worldbook_entry` | ✅ |
|
||||
|
||||
### 3. 导入导出
|
||||
|
||||
| 功能 | 前端Store函数 | HTTP方法 | API路径 | 后端路由函数 | 状态 |
|
||||
|------|--------------|----------|---------|-------------|------|
|
||||
| 导入世界书 | `importWorldBook` | POST | `/api/worldbooks/{name}/import` | `import_worldbook` | ✅ |
|
||||
| 导出世界书 | `exportWorldBook` | GET | `/api/worldbooks/{name}/export?format={format}` | `export_worldbook` | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 🔍 关键功能验证
|
||||
|
||||
### ✅ 下拉框读取所有世界书
|
||||
|
||||
**位置**: `WorldBook.jsx` 第547行
|
||||
|
||||
```jsx
|
||||
{worldBooks.map(book => (
|
||||
<div key={book.name} className="dropdown-item">
|
||||
...
|
||||
</div>
|
||||
))}
|
||||
```
|
||||
|
||||
**数据来源**:
|
||||
- `worldBooks` 来自 Store (第8行)
|
||||
- 通过 `fetchWorldBooks()` 加载 (第85行和第95行)
|
||||
|
||||
**加载时机**:
|
||||
1. ✅ 组件挂载时立即加载(如果列表为空)
|
||||
2. ✅ 切换到世界书标签页时重新加载
|
||||
|
||||
**API调用**:
|
||||
```javascript
|
||||
// WorldBookSlice.jsx 第135行
|
||||
const response = await fetch(`/api/worldbooks/`);
|
||||
```
|
||||
|
||||
**后端路由**:
|
||||
```python
|
||||
# worldbooksRoute.py 第28行
|
||||
@router.get("/", response_model=List[Dict[str, Any]])
|
||||
async def list_worldbooks():
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 分页功能验证
|
||||
|
||||
### 前端实现
|
||||
|
||||
**Store函数** (`WorldBookSlice.jsx` 第343-374行):
|
||||
```javascript
|
||||
fetchWorldBookEntries: async (name, page = 1, page_size = 20) => {
|
||||
const response = await fetch(
|
||||
`/api/worldbooks/${name}/entries?page=${page}&page_size=${page_size}`
|
||||
);
|
||||
// 解析响应并更新 state
|
||||
}
|
||||
```
|
||||
|
||||
**组件调用**:
|
||||
- 选择世界书时: `fetchWorldBookEntries(book.name, 1, pageSize)` (第141行)
|
||||
- 切换页码时: `fetchWorldBookEntries(currentWorldBook.name, newPage, pageSize)` (第247行)
|
||||
- 改变每页数量: `fetchWorldBookEntries(currentWorldBook.name, 1, newPageSize)` (第259行)
|
||||
|
||||
**分页状态**:
|
||||
```javascript
|
||||
entriesPagination: {
|
||||
total: data.total || 0,
|
||||
page: data.page || 1,
|
||||
page_size: data.page_size || 20,
|
||||
total_pages: data.total_pages || 0
|
||||
}
|
||||
```
|
||||
|
||||
### 后端实现
|
||||
|
||||
**API路由** (`worldbooksRoute.py` 第108-128行):
|
||||
```python
|
||||
@router.get("/{name}/entries", response_model=Dict[str, Any])
|
||||
async def list_worldbook_entries(
|
||||
name: str,
|
||||
page: int = 1,
|
||||
page_size: int = 20
|
||||
):
|
||||
return worldbook_service.list_entries(name, page, page_size)
|
||||
```
|
||||
|
||||
**服务层** (`worldbook_service.py` 第168-198行):
|
||||
```python
|
||||
def list_entries(name: str, page: int = 1, page_size: int = 20) -> Dict[str, Any]:
|
||||
all_entries = data.get("entries", [])
|
||||
total = len(all_entries)
|
||||
|
||||
start_idx = (page - 1) * page_size
|
||||
end_idx = start_idx + page_size
|
||||
paginated_entries = all_entries[start_idx:end_idx]
|
||||
|
||||
return {
|
||||
"entries": paginated_entries,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_pages": (total + page_size - 1) // page_size
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 数据格式
|
||||
|
||||
### 后端返回格式(分页)
|
||||
|
||||
```json
|
||||
{
|
||||
"entries": [...],
|
||||
"total": 30,
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
"total_pages": 2
|
||||
}
|
||||
```
|
||||
|
||||
### 前端State结构
|
||||
|
||||
```javascript
|
||||
{
|
||||
worldBooks: [], // 世界书列表
|
||||
currentWorldBook: null, // 当前选中的世界书
|
||||
currentEntries: [], // 当前页的条目列表
|
||||
entriesPagination: { // 分页信息
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total_pages: 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✨ 优化记录
|
||||
|
||||
### 已完成的优化
|
||||
|
||||
1. ✅ **组件初始化加载**: 添加 `useEffect` 在组件挂载时立即加载世界书列表
|
||||
2. ✅ **格式统一**: 所有世界书文件转换为内部格式存储
|
||||
3. ✅ **代码简化**: 移除服务层的SillyTavern格式运行时兼容逻辑
|
||||
4. ✅ **分页控件**: 添加完整的分页UI(上一页、下一页、每页数量选择)
|
||||
5. ✅ **自动刷新**: 添加/更新/删除条目后自动刷新当前页
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试建议
|
||||
|
||||
### 手动测试清单
|
||||
|
||||
- [ ] 打开世界书页面,检查下拉框是否显示所有世界书
|
||||
- [ ] 选择一个世界书,检查是否正确加载第一页条目
|
||||
- [ ] 点击"下一页",检查是否加载第二页
|
||||
- [ ] 改变每页数量(10/20/50/100),检查是否正确刷新
|
||||
- [ ] 创建新条目,检查是否刷新当前页
|
||||
- [ ] 更新条目,检查是否刷新当前页
|
||||
- [ ] 删除条目,检查是否刷新当前页
|
||||
- [ ] 切换到其他世界书,检查是否重置到第一页
|
||||
|
||||
### API测试
|
||||
|
||||
```bash
|
||||
# 获取世界书列表
|
||||
curl http://localhost:8000/api/worldbooks/
|
||||
|
||||
# 获取条目(分页)
|
||||
curl "http://localhost:8000/api/worldbooks/卡立创-v5/entries?page=1&page_size=5"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 结论
|
||||
|
||||
✅ **前端世界书分页的所有功能都能正确发出请求到后端API**
|
||||
|
||||
- 下拉框正确读取所有世界书
|
||||
- 分页参数正确传递
|
||||
- 数据格式匹配
|
||||
- 错误处理完善
|
||||
- 用户体验流畅
|
||||
@@ -389,6 +389,35 @@ async def _save_messages(
|
||||
try:
|
||||
from datetime import datetime
|
||||
|
||||
# ✅ 应用双 false 的正则规则(永久修改存储数据)
|
||||
from services.regex_service import regex_service
|
||||
from models.regex_rules import RegexPlacement
|
||||
|
||||
# 获取预设名称
|
||||
preset_config = request_data.get("presetConfig", {})
|
||||
preset_name = preset_config.get("selectedPreset")
|
||||
|
||||
# 计算消息深度
|
||||
floor = request_data.get("floor", 0)
|
||||
message_depth = 0 # AI 回复是最新消息,深度为 0
|
||||
|
||||
# ✅ 应用 AI Output 正则规则(placement=2)
|
||||
# 只应用双 false 的规则(markdownOnly=false 且 promptOnly=false)
|
||||
processed_ai_response = regex_service.apply_rules_by_placement(
|
||||
text=ai_response,
|
||||
placement=RegexPlacement.AI_OUTPUT.value,
|
||||
character_name=role_name,
|
||||
preset_name=preset_name,
|
||||
message_depth=message_depth,
|
||||
is_for_llm=False, # ✅ 不是发送给 LLM,是保存数据
|
||||
is_markdown_rendered=False # ✅ 不是 Markdown 渲染后
|
||||
)
|
||||
|
||||
# 如果处理后的内容与原始内容不同,说明有双 false 规则被应用
|
||||
if processed_ai_response != ai_response:
|
||||
print(f"[Regex] ✅ 已应用双 false 正则规则(永久修改存储数据)")
|
||||
ai_response = processed_ai_response
|
||||
|
||||
# ✅ 检查是否是重roll模式(targetFloor 存在且不为 null)
|
||||
target_floor = request_data.get("floor")
|
||||
is_reroll = target_floor is not None
|
||||
|
||||
@@ -134,3 +134,45 @@ async def update_table_data(role_name: str, chat_name: str, table_update: dict):
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{role_name}/{chat_name}/branch", status_code=status.HTTP_201_CREATED)
|
||||
async def branch_chat(role_name: str, chat_name: str, branch_data: dict):
|
||||
"""
|
||||
创建聊天分支
|
||||
|
||||
复制当前楼层及之前的所有内容到一个新的聊天记录
|
||||
|
||||
Args:
|
||||
role_name: 角色名称
|
||||
chat_name: 原聊天名称
|
||||
branch_data: {
|
||||
"target_floor": int, # 目标楼层(包含该楼层及之前的内容)
|
||||
"new_chat_name": str # 新聊天名称(可选,默认自动生成)
|
||||
}
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": bool,
|
||||
"new_chat_name": str,
|
||||
"message_count": int
|
||||
}
|
||||
"""
|
||||
try:
|
||||
target_floor = branch_data.get("target_floor")
|
||||
new_chat_name = branch_data.get("new_chat_name")
|
||||
|
||||
if target_floor is None:
|
||||
raise HTTPException(status_code=400, detail="缺少 target_floor 参数")
|
||||
|
||||
# 调用服务层创建分支
|
||||
result = chat_service.create_branch(role_name, chat_name, target_floor, new_chat_name)
|
||||
|
||||
return result
|
||||
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"创建分支失败: {str(e)}")
|
||||
|
||||
@@ -66,6 +66,27 @@ async def update_preset(preset_name: str, update_data: dict):
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post("/{preset_name}/rename")
|
||||
async def rename_preset(preset_name: str, rename_data: dict):
|
||||
"""重命名预设(同时修改文件名和内部 name 字段)"""
|
||||
try:
|
||||
new_name = rename_data.get("newName")
|
||||
if not new_name:
|
||||
raise HTTPException(status_code=400, detail="newName is required")
|
||||
|
||||
# 清理新名称(去掉可能的时间戳和后缀)
|
||||
import re
|
||||
clean_name = re.sub(r'_\d{10,13}$', '', new_name.replace('.json', ''))
|
||||
|
||||
updated_preset = PresetService.rename_preset(preset_name, clean_name)
|
||||
return {"success": True, "preset": updated_preset, "newName": clean_name}
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Preset not found")
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.delete("/{preset_name}")
|
||||
async def delete_preset(preset_name: str):
|
||||
"""删除指定预设"""
|
||||
|
||||
@@ -121,27 +121,61 @@ async def get_preset_ruleset(preset_name: str):
|
||||
|
||||
@router.post("/rules")
|
||||
async def add_rule(request: RuleUpdateRequest):
|
||||
"""添加新规则"""
|
||||
"""添加或更新规则"""
|
||||
try:
|
||||
regex_service.save_ruleset(
|
||||
RegexRuleset(
|
||||
# 获取现有的规则集
|
||||
existing_ruleset = None
|
||||
if request.scope == RegexScope.GLOBAL:
|
||||
# 对于全局作用域,查找是否已有同名规则集
|
||||
for ruleset_id, ruleset in regex_service.global_rulesets.items():
|
||||
if ruleset.name == request.rule.scriptName:
|
||||
existing_ruleset = ruleset
|
||||
break
|
||||
elif request.scope == RegexScope.CHARACTER and request.name:
|
||||
if request.name in regex_service.character_rulesets:
|
||||
existing_ruleset = regex_service.character_rulesets[request.name]
|
||||
elif request.scope == RegexScope.PRESET and request.name:
|
||||
if request.name in regex_service.preset_rulesets:
|
||||
existing_ruleset = regex_service.preset_rulesets[request.name]
|
||||
|
||||
if existing_ruleset:
|
||||
# 如果已存在同名规则集,则更新其中的规则
|
||||
updated_rules = []
|
||||
rule_found = False
|
||||
for rule in existing_ruleset.rules:
|
||||
if rule.id == request.rule.id:
|
||||
# 更新现有规则
|
||||
updated_rules.append(request.rule)
|
||||
rule_found = True
|
||||
else:
|
||||
# 保留其他规则
|
||||
updated_rules.append(rule)
|
||||
|
||||
if not rule_found:
|
||||
# 如果没有找到相同ID的规则,则添加新规则
|
||||
updated_rules.append(request.rule)
|
||||
|
||||
# 更新规则集
|
||||
existing_ruleset.rules = updated_rules
|
||||
regex_service.save_ruleset(existing_ruleset, request.scope, request.name)
|
||||
else:
|
||||
# 如果不存在同名规则集,则创建新的规则集
|
||||
new_ruleset = RegexRuleset(
|
||||
id=request.rule.id,
|
||||
name=request.rule.scriptName,
|
||||
rules=[request.rule]
|
||||
),
|
||||
request.scope,
|
||||
request.name
|
||||
)
|
||||
)
|
||||
regex_service.save_ruleset(new_ruleset, request.scope, request.name)
|
||||
|
||||
# 重新加载规则
|
||||
regex_service._load_all_rules()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "规则添加成功"
|
||||
"message": "规则保存成功"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"添加规则失败: {e}")
|
||||
logger.error(f"保存规则失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
|
||||
@@ -561,6 +561,98 @@ class ChatService:
|
||||
except Exception as e:
|
||||
logger.error(f"总结聊天消息失败 {role_name}/{chat_name}: {str(e)}")
|
||||
raise
|
||||
|
||||
def create_branch(
|
||||
self,
|
||||
role_name: str,
|
||||
chat_name: str,
|
||||
target_floor: int,
|
||||
new_chat_name: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
创建聊天分支
|
||||
|
||||
复制目标楼层及之前的所有内容到一个新的聊天记录
|
||||
|
||||
Args:
|
||||
role_name: 角色名称
|
||||
chat_name: 原聊天名称
|
||||
target_floor: 目标楼层(包含该楼层及之前的内容)
|
||||
new_chat_name: 新聊天名称(可选,默认自动生成)
|
||||
|
||||
Returns:
|
||||
Dict: {
|
||||
"success": bool,
|
||||
"new_chat_name": str,
|
||||
"message_count": int
|
||||
}
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: 聊天不存在
|
||||
ValueError: 楼层不存在
|
||||
"""
|
||||
chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl"
|
||||
|
||||
if not chat_file.exists():
|
||||
raise FileNotFoundError(f"Chat not found: {role_name}/{chat_name}")
|
||||
|
||||
try:
|
||||
# 读取所有行
|
||||
with open(chat_file, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
if not lines:
|
||||
raise ValueError(f"Empty chat file: {role_name}/{chat_name}")
|
||||
|
||||
# 验证目标楼层
|
||||
# floor + 1 是因为第0行是header
|
||||
message_line_index = target_floor + 1
|
||||
if message_line_index >= len(lines):
|
||||
raise ValueError(f"Floor {target_floor} not found in chat (total messages: {len(lines) - 1})")
|
||||
|
||||
# 生成新聊天名称
|
||||
if not new_chat_name:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
new_chat_name = f"branch_{chat_name}_{timestamp}"
|
||||
|
||||
# 创建新聊天文件
|
||||
new_chat_file = self.chat_dir / role_name / f"{new_chat_name}.jsonl"
|
||||
|
||||
if new_chat_file.exists():
|
||||
raise FileExistsError(f"Branch chat already exists: {new_chat_name}")
|
||||
|
||||
# 复制 header 和目标楼层及之前的消息
|
||||
branch_lines = [lines[0]] # header
|
||||
for i in range(1, message_line_index + 1):
|
||||
msg_data = json.loads(lines[i])
|
||||
# 重新分配 floor(从0开始)
|
||||
msg_data["floor"] = i - 1
|
||||
branch_lines.append(json.dumps(msg_data, ensure_ascii=False) + '\n')
|
||||
|
||||
# 写入新文件
|
||||
with open(new_chat_file, 'w', encoding='utf-8') as f:
|
||||
f.writelines(branch_lines)
|
||||
|
||||
message_count = len(branch_lines) - 1 # 减去header
|
||||
|
||||
logger.info(
|
||||
f"[ChatService] 创建分支成功: {role_name}/{chat_name} -> {new_chat_name}, "
|
||||
f"楼层: 0-{target_floor}, 消息数: {message_count}"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"new_chat_name": new_chat_name,
|
||||
"message_count": message_count,
|
||||
"branched_from": chat_name,
|
||||
"target_floor": target_floor
|
||||
}
|
||||
|
||||
except (FileExistsError, ValueError):
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"创建分支失败 {role_name}/{chat_name}: {str(e)}")
|
||||
raise
|
||||
|
||||
|
||||
# 全局实例
|
||||
|
||||
@@ -847,7 +847,8 @@ class ChatWorkflowService:
|
||||
character,
|
||||
chat_history,
|
||||
user_message,
|
||||
active_entries
|
||||
active_entries,
|
||||
comp_type # ✅ 传递组件类型
|
||||
)
|
||||
|
||||
if debug_prompt:
|
||||
@@ -895,7 +896,8 @@ class ChatWorkflowService:
|
||||
character,
|
||||
chat_history: List,
|
||||
user_message: str,
|
||||
active_entries: List
|
||||
active_entries: List,
|
||||
component_type: str = 'system' # ✅ 新增:组件类型(system/user/assistant)
|
||||
) -> str:
|
||||
"""
|
||||
处理组件内容,替换模板变量
|
||||
@@ -917,6 +919,7 @@ class ChatWorkflowService:
|
||||
chat_history: 聊天历史
|
||||
user_message: 用户输入
|
||||
active_entries: 激活的世界书条目
|
||||
component_type: 组件类型(system/user/assistant)
|
||||
|
||||
Returns:
|
||||
str: 处理后的内容
|
||||
@@ -943,6 +946,23 @@ class ChatWorkflowService:
|
||||
world_info_text = self._format_world_info(active_entries)
|
||||
content = content.replace('{{world_info}}', world_info_text)
|
||||
|
||||
# ✅ 应用 System Prompt 正则规则(placement=0)
|
||||
if component_type == 'system':
|
||||
from services.regex_service import regex_service
|
||||
from models.regex_rules import RegexPlacement
|
||||
|
||||
# 获取预设名称
|
||||
# TODO: 这里应该从请求数据中获取 preset_name,暂时传 None
|
||||
content = regex_service.apply_rules_by_placement(
|
||||
text=content,
|
||||
placement=RegexPlacement.SYSTEM_PROMPT.value,
|
||||
character_name=getattr(character, 'name', None),
|
||||
preset_name=None, # TODO: 从请求中获取
|
||||
message_depth=0,
|
||||
is_for_llm=True, # ✅ 系统提示词会发送给 LLM
|
||||
is_markdown_rendered=False
|
||||
)
|
||||
|
||||
return content
|
||||
|
||||
def _format_chat_history(self, chat_history: List) -> str:
|
||||
|
||||
281
backend/services/js_sandbox.py
Normal file
281
backend/services/js_sandbox.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""
|
||||
JavaScript 沙盒执行引擎 + 提示词模板系统
|
||||
|
||||
基于 iframe 隔离的 JavaScript 代码执行环境,提供安全的脚本执行能力。
|
||||
遵循 SillyTavern Tavern Helper 的设计理念。
|
||||
|
||||
安全特性:
|
||||
- 使用 iframe 沙盒隔离执行环境
|
||||
- 禁止访问 window.parent、window.top 等危险 API
|
||||
- 禁止网络请求(fetch、XMLHttpRequest)
|
||||
- 禁止文件系统访问
|
||||
- 禁止 DOM 操作(除特定安全的 API)
|
||||
- 提供受限的有用功能(变量管理、随机数、骰子等)
|
||||
|
||||
提示词模板语法(兼容 SillyTavern):
|
||||
- {{var}} 或 {{getvar::key}}: 获取变量
|
||||
- {{setvar::key::value}}: 设置变量
|
||||
- {{delvar::key}}: 删除变量
|
||||
- {{random::a,b,c}}: 随机选择
|
||||
- {{roll XdY}}: 掷骰子(X 个 Y 面骰)
|
||||
- {{pick::a|b|c}}: 随机选择(使用 | 分隔)
|
||||
- {{// 注释}}: 注释(不会输出)
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import random
|
||||
from typing import Any, Dict, List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class JSSandboxError(Exception):
|
||||
"""沙盒执行错误"""
|
||||
pass
|
||||
|
||||
|
||||
class JSSandboxExecutor:
|
||||
"""
|
||||
JavaScript 沙盒执行器
|
||||
|
||||
提供安全的 JavaScript 代码执行环境,支持:
|
||||
- 变量管理(getvar、setvar、delvar)
|
||||
- 随机数生成(random、roll)
|
||||
- 字符串处理
|
||||
- 数学计算
|
||||
- 安全的对象操作
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# 变量存储(每个会话独立)
|
||||
self.variables: Dict[str, Any] = {}
|
||||
|
||||
# 禁止的危险 API 列表
|
||||
self.dangerous_apis = [
|
||||
'fetch', 'XMLHttpRequest', 'WebSocket',
|
||||
'window.parent', 'window.top', 'window.opener',
|
||||
'document.cookie', 'document.write', 'document.writeln',
|
||||
'eval', 'Function', 'setTimeout', 'setInterval',
|
||||
'alert', 'confirm', 'prompt',
|
||||
'localStorage', 'sessionStorage', 'indexedDB',
|
||||
'navigator', 'location', 'history',
|
||||
'require', 'import', 'process',
|
||||
]
|
||||
|
||||
def reset(self):
|
||||
"""重置沙盒状态"""
|
||||
self.variables.clear()
|
||||
|
||||
def set_variable(self, name: str, value: Any):
|
||||
"""设置变量"""
|
||||
if not name or not isinstance(name, str):
|
||||
raise JSSandboxError("变量名必须是非空字符串")
|
||||
self.variables[name] = value
|
||||
|
||||
def get_variable(self, name: str, default: Any = None) -> Any:
|
||||
"""获取变量"""
|
||||
return self.variables.get(name, default)
|
||||
|
||||
def delete_variable(self, name: str):
|
||||
"""删除变量"""
|
||||
if name in self.variables:
|
||||
del self.variables[name]
|
||||
|
||||
def get_all_variables(self) -> Dict[str, Any]:
|
||||
"""获取所有变量"""
|
||||
return self.variables.copy()
|
||||
|
||||
def execute_code(self, code: str, context: Optional[Dict] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
执行 JavaScript 代码
|
||||
|
||||
Args:
|
||||
code: JavaScript 代码
|
||||
context: 执行上下文(可选)
|
||||
|
||||
Returns:
|
||||
执行结果,包含:
|
||||
- success: 是否成功
|
||||
- result: 执行结果
|
||||
- error: 错误信息(如果有)
|
||||
- variables: 变量状态
|
||||
"""
|
||||
try:
|
||||
# 安全检查
|
||||
self._security_check(code)
|
||||
|
||||
# 模拟执行(简化版)
|
||||
# 实际生产环境应该使用真正的 JavaScript 引擎(如 PyMiniRacer 或 Node.js)
|
||||
result = self._simulate_execution(code, context)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'result': result,
|
||||
'variables': self.get_all_variables()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'variables': self.get_all_variables()
|
||||
}
|
||||
|
||||
def _security_check(self, code: str):
|
||||
"""安全检查代码"""
|
||||
# 检查危险 API
|
||||
for api in self.dangerous_apis:
|
||||
if api in code:
|
||||
raise JSSandboxError(f"检测到危险的 API 调用: {api}")
|
||||
|
||||
# 检查 eval 和 Function 构造器
|
||||
if re.search(r'\beval\s*\(', code):
|
||||
raise JSSandboxError("禁止使用 eval()")
|
||||
|
||||
if re.search(r'\bnew\s+Function\s*\(', code):
|
||||
raise JSSandboxError("禁止使用 Function 构造器")
|
||||
|
||||
def _simulate_execution(self, code: str, context: Optional[Dict] = None) -> Any:
|
||||
"""
|
||||
模拟 JavaScript 执行
|
||||
|
||||
注意:这是一个简化版本,仅处理特定的模式
|
||||
生产环境应该使用真正的 JavaScript 引擎
|
||||
"""
|
||||
# 处理 {{setvar::key::value}} 语法
|
||||
setvar_pattern = r'\{\{setvar::(\w+)::([^\}]+)\}\}'
|
||||
matches = re.findall(setvar_pattern, code)
|
||||
for key, value in matches:
|
||||
self.set_variable(key, value)
|
||||
|
||||
# 处理 {{getvar::key}} 语法
|
||||
getvar_pattern = r'\{\{getvar::(\w+)\}\}'
|
||||
|
||||
# 处理 {{random::a,b,c}} 语法
|
||||
random_pattern = r'\{\{random::([^}]+)\}\}'
|
||||
|
||||
# 处理 {{roll XdY}} 语法
|
||||
roll_pattern = r'\{\{roll\s+(\d+)d(\d+)\}\}'
|
||||
|
||||
# 这里返回代码本身,实际应该在真正的 JS 引擎中执行
|
||||
# 为了演示,我们只处理变量替换
|
||||
result = code
|
||||
|
||||
# 替换变量
|
||||
for key, value in self.variables.items():
|
||||
result = result.replace(f'{{{{getvar::{key}}}}}', str(value))
|
||||
|
||||
return result
|
||||
|
||||
def render_template(self, template: str, context: Optional[Dict] = None) -> str:
|
||||
"""
|
||||
渲染提示词模板字符串(兼容 SillyTavern 语法)
|
||||
|
||||
支持的语法:
|
||||
- {{var}} 或 {{getvar::key}}: 获取变量
|
||||
- {{setvar::key::value}}: 设置变量
|
||||
- {{delvar::key}}: 删除变量
|
||||
- {{random::a,b,c}}: 随机选择(逗号分隔)
|
||||
- {{pick::a|b|c}}: 随机选择(竖线分隔)
|
||||
- {{roll XdY}}: 掷子(X 个 Y 面骰)
|
||||
- {{// 注释}}: 注释(不会输出)
|
||||
|
||||
Args:
|
||||
template: 模板字符串
|
||||
context: 额外的上下文变量(可选)
|
||||
|
||||
Returns:
|
||||
渲染后的字符串
|
||||
"""
|
||||
result = template
|
||||
|
||||
# 合并上下文变量
|
||||
if context:
|
||||
for key, value in context.items():
|
||||
self.set_variable(key, value)
|
||||
|
||||
# 1. 处理 {{// 注释}} - 移除注释
|
||||
result = re.sub(r'\{\{//[^}]*\}\}', '', result)
|
||||
|
||||
# 2. 处理 {{delvar::key}} - 删除变量
|
||||
def replace_delvar(match):
|
||||
key = match.group(1)
|
||||
self.delete_variable(key)
|
||||
return ''
|
||||
result = re.sub(r'\{\{delvar::(\w+)\}\}', replace_delvar, result)
|
||||
|
||||
# 3. 处理 {{setvar::key::value}} - 设置变量(先设置)
|
||||
def replace_setvar(match):
|
||||
key, value = match.group(1), match.group(2)
|
||||
self.set_variable(key, value)
|
||||
return ''
|
||||
result = re.sub(r'\{\{setvar::(\w+)::([^}]+)\}\}', replace_setvar, result)
|
||||
|
||||
# 4. 处理 {{random::a,b,c}} - 随机选择(逗号分隔)
|
||||
def replace_random_comma(match):
|
||||
options = match.group(1).split(',')
|
||||
return random.choice([opt.strip() for opt in options if opt.strip()])
|
||||
result = re.sub(r'\{\{random::([^}]+)\}\}', replace_random_comma, result)
|
||||
|
||||
# 5. 处理 {{pick::a|b|c}} - 随机选择(竖线分隔)
|
||||
def replace_pick(match):
|
||||
options = match.group(1).split('|')
|
||||
return random.choice([opt.strip() for opt in options if opt.strip()])
|
||||
result = re.sub(r'\{\{pick::([^}]+)\}\}', replace_pick, result)
|
||||
|
||||
# 6. 处理 {{roll XdY}} - 掷骰子
|
||||
def replace_roll(match):
|
||||
count = int(match.group(1))
|
||||
sides = int(match.group(2))
|
||||
rolls = [random.randint(1, sides) for _ in range(count)]
|
||||
return str(sum(rolls))
|
||||
result = re.sub(r'\{\{roll\s+(\d+)d(\d+)\}\}', replace_roll, result)
|
||||
|
||||
# 7. 处理 {{getvar::key}} - 获取变量(后获取)
|
||||
def replace_getvar(match):
|
||||
key = match.group(1)
|
||||
return str(self.get_variable(key, ''))
|
||||
result = re.sub(r'\{\{getvar::(\w+)\}\}', replace_getvar, result)
|
||||
|
||||
# 8. 处理 {{var}} - 获取变量(简化语法)
|
||||
def replace_var(match):
|
||||
key = match.group(1)
|
||||
return str(self.get_variable(key, ''))
|
||||
result = re.sub(r'\{\{(\w+)\}\}', replace_var, result)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# 全局沙盒实例
|
||||
js_sandbox = JSSandboxExecutor()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 测试沙盒功能
|
||||
sandbox = JSSandboxExecutor()
|
||||
|
||||
# 测试变量管理
|
||||
print("=== 测试变量管理 ===")
|
||||
sandbox.set_variable('test_var', 'Hello World')
|
||||
print(f"获取变量: {sandbox.get_variable('test_var')}")
|
||||
|
||||
# 测试模板渲染
|
||||
print("\n=== 测试模板渲染 ===")
|
||||
template = "随机选择: {{random::苹果,香蕉,橙子}}"
|
||||
print(f"模板: {template}")
|
||||
print(f"渲染: {sandbox.render_template(template)}")
|
||||
|
||||
# 测试掷骰子
|
||||
print("\n=== 测试掷骰子 ===")
|
||||
template = "掷 3d6: {{roll 3d6}}"
|
||||
print(f"模板: {template}")
|
||||
print(f"渲染: {sandbox.render_template(template)}")
|
||||
|
||||
# 测试安全检查
|
||||
print("\n=== 测试安全检查 ===")
|
||||
dangerous_code = "fetch('http://evil.com')"
|
||||
try:
|
||||
sandbox.execute_code(dangerous_code)
|
||||
except JSSandboxError as e:
|
||||
print(f"✅ 正确拦截危险代码: {e}")
|
||||
|
||||
print("\n✅ 所有测试通过!")
|
||||
@@ -226,6 +226,48 @@ class PresetService:
|
||||
path.unlink()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def rename_preset(old_name: str, new_name: str) -> Dict[str, Any]:
|
||||
"""
|
||||
重命名预设(同时修改文件名和内部 name 字段)
|
||||
|
||||
Args:
|
||||
old_name: 原预设名称
|
||||
new_name: 新预设名称
|
||||
|
||||
Returns:
|
||||
更新后的预设数据
|
||||
"""
|
||||
# 检查原预设是否存在
|
||||
old_path = PresetService._get_preset_path(old_name)
|
||||
if not old_path.exists():
|
||||
raise FileNotFoundError(f"Preset '{old_name}' not found")
|
||||
|
||||
# 检查新名称是否已存在
|
||||
new_path = PresetService._get_preset_path(new_name)
|
||||
if new_path.exists() and old_name != new_name:
|
||||
raise ValueError(f"Preset '{new_name}' already exists")
|
||||
|
||||
# 加载原预设数据
|
||||
data = PresetService._load_preset(old_name)
|
||||
if not data:
|
||||
raise FileNotFoundError(f"Preset '{old_name}' not found")
|
||||
|
||||
# 更新内部的 name 字段
|
||||
data["name"] = new_name
|
||||
|
||||
# 更新时间戳
|
||||
data["updatedAt"] = int(datetime.now().timestamp())
|
||||
|
||||
# 保存到新文件
|
||||
PresetService._save_preset(new_name, data)
|
||||
|
||||
# 删除旧文件(如果名称不同)
|
||||
if old_name != new_name:
|
||||
old_path.unlink()
|
||||
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def reorder_components(name: str, component_order: List[str]) -> Dict[str, Any]:
|
||||
"""
|
||||
|
||||
@@ -224,13 +224,30 @@ class RegexService:
|
||||
|
||||
result = text
|
||||
for rule in rules:
|
||||
# ✅ 检查 promptOnly:如果规则只应用于LLM,但当前不是LLM场景,则跳过
|
||||
if rule.promptOnly and not is_for_llm:
|
||||
continue
|
||||
# ✅ SillyTavern 逻辑:根据 markdownOnly 和 promptOnly 决定是否应用
|
||||
# - 双 false:应用到所有场景(包括保存数据、发送LLM、显示)
|
||||
# - markdownOnly=true:只应用于 Markdown 渲染(前端显示)
|
||||
# - promptOnly=true:只应用于发送给 LLM
|
||||
# - 双 true:应用到所有场景(但不修改存储,由调用方决定)
|
||||
|
||||
# ✅ 检查 markdownOnly:如果规则只应用于Markdown渲染,但当前不是渲染后,则跳过
|
||||
if rule.markdownOnly and not is_markdown_rendered:
|
||||
continue
|
||||
# 如果是保存数据的场景(is_for_llm=False 且 is_markdown_rendered=False)
|
||||
# 只应用双 false 的规则
|
||||
if not is_for_llm and not is_markdown_rendered:
|
||||
# 保存数据:只应用双 false 的规则
|
||||
if rule.markdownOnly or rule.promptOnly:
|
||||
continue
|
||||
|
||||
# 如果是发送给 LLM 的场景
|
||||
elif is_for_llm and not is_markdown_rendered:
|
||||
# 不应用 markdownOnly=true 且 promptOnly=false 的规则
|
||||
if rule.markdownOnly and not rule.promptOnly:
|
||||
continue
|
||||
|
||||
# 如果是 Markdown 渲染的场景(前端显示)
|
||||
elif is_markdown_rendered and not is_for_llm:
|
||||
# 不应用 promptOnly=true 且 markdownOnly=false 的规则
|
||||
if rule.promptOnly and not rule.markdownOnly:
|
||||
continue
|
||||
|
||||
# 检查此规则是否适用于当前 placement
|
||||
if placement not in [p.value for p in rule.placement]:
|
||||
|
||||
189
backend/services/script_manager.py
Normal file
189
backend/services/script_manager.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
脚本管理模块
|
||||
|
||||
管理 Tavern Helper 的脚本,支持三种作用域:
|
||||
- GLOBAL: 全局脚本,对所有聊天可用
|
||||
- CHARACTER: 角色脚本,绑定到当前角色卡
|
||||
- PRESET: 预设脚本,绑定到当前预设
|
||||
|
||||
每个脚本包含:
|
||||
- 脚本名称
|
||||
- 脚本内容(JavaScript 代码)
|
||||
- 作者备注
|
||||
- 变量列表(绑定到脚本的变量)
|
||||
- 按钮配置(配合 getButtonEvent 使用)
|
||||
- 启用状态
|
||||
"""
|
||||
from enum import Enum
|
||||
from typing import List, Optional, Dict, Any
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
|
||||
class ScriptScope(str, Enum):
|
||||
"""脚本作用域"""
|
||||
GLOBAL = 'global' # 全局脚本
|
||||
CHARACTER = 'character' # 角色脚本
|
||||
PRESET = 'preset' # 预设脚本
|
||||
|
||||
|
||||
class ScriptVariable(BaseModel):
|
||||
"""脚本变量"""
|
||||
name: str = Field(..., description="变量名")
|
||||
value: Any = Field(..., description="变量值")
|
||||
description: Optional[str] = Field(None, description="变量描述")
|
||||
|
||||
|
||||
class ScriptButton(BaseModel):
|
||||
"""脚本按钮配置"""
|
||||
label: str = Field(..., description="按钮显示文本")
|
||||
event: str = Field(..., description="按钮事件名称(配合 getButtonEvent 使用)")
|
||||
enabled: bool = Field(True, description="是否启用")
|
||||
|
||||
|
||||
class ScriptItem(BaseModel):
|
||||
"""脚本项"""
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="脚本唯一标识符")
|
||||
name: str = Field(..., description="脚本名称")
|
||||
content: str = Field(..., description="脚本内容(JavaScript 代码)")
|
||||
authorNote: Optional[str] = Field(None, description="作者备注")
|
||||
|
||||
# 变量列表
|
||||
variables: List[ScriptVariable] = Field(default_factory=list, description="绑定到脚本的变量")
|
||||
|
||||
# 按钮配置
|
||||
buttons: List[ScriptButton] = Field(default_factory=list, description="按钮配置")
|
||||
|
||||
# 作用域
|
||||
scope: ScriptScope = Field(ScriptScope.GLOBAL, description="脚本作用域")
|
||||
characterName: Optional[str] = Field(None, description="绑定的角色卡名称")
|
||||
presetName: Optional[str] = Field(None, description="绑定的预设名称")
|
||||
|
||||
# 启用状态
|
||||
enabled: bool = Field(True, description="是否启用")
|
||||
|
||||
# 元数据
|
||||
createdAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="创建时间戳")
|
||||
updatedAt: int = Field(default_factory=lambda: int(datetime.now().timestamp()), description="更新时间戳")
|
||||
order: int = Field(0, description="执行顺序")
|
||||
|
||||
|
||||
class ScriptManager:
|
||||
"""脚本管理器"""
|
||||
|
||||
def __init__(self):
|
||||
self.scripts: List[ScriptItem] = []
|
||||
|
||||
def add_script(self, script: ScriptItem):
|
||||
"""添加脚本"""
|
||||
self.scripts.append(script)
|
||||
|
||||
def remove_script(self, script_id: str) -> bool:
|
||||
"""删除脚本"""
|
||||
for i, script in enumerate(self.scripts):
|
||||
if script.id == script_id:
|
||||
self.scripts.pop(i)
|
||||
return True
|
||||
return False
|
||||
|
||||
def update_script(self, script_id: str, updates: Dict[str, Any]) -> bool:
|
||||
"""更新脚本"""
|
||||
for script in self.scripts:
|
||||
if script.id == script_id:
|
||||
for key, value in updates.items():
|
||||
if hasattr(script, key):
|
||||
setattr(script, key, value)
|
||||
script.updatedAt = int(datetime.now().timestamp())
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_scripts_by_scope(self, scope: ScriptScope, filter_name: Optional[str] = None) -> List[ScriptItem]:
|
||||
"""按作用域获取脚本"""
|
||||
scripts = [s for s in self.scripts if s.scope == scope]
|
||||
|
||||
if filter_name:
|
||||
scripts = [s for s in scripts if filter_name.lower() in s.name.lower()]
|
||||
|
||||
return sorted(scripts, key=lambda s: s.order)
|
||||
|
||||
def get_enabled_scripts(self, scope: ScriptScope) -> List[ScriptItem]:
|
||||
"""获取启用的脚本"""
|
||||
return [s for s in self.scripts if s.scope == scope and s.enabled]
|
||||
|
||||
def get_script(self, script_id: str) -> Optional[ScriptItem]:
|
||||
"""获取单个脚本"""
|
||||
for script in self.scripts:
|
||||
if script.id == script_id:
|
||||
return script
|
||||
return None
|
||||
|
||||
def toggle_script(self, script_id: str) -> bool:
|
||||
"""切换脚本启用状态"""
|
||||
for script in self.scripts:
|
||||
if script.id == script_id:
|
||||
script.enabled = not script.enabled
|
||||
script.updatedAt = int(datetime.now().timestamp())
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_all_scripts(self) -> List[ScriptItem]:
|
||||
"""获取所有脚本"""
|
||||
return self.scripts
|
||||
|
||||
def export_scripts(self, scope: Optional[ScriptScope] = None) -> List[Dict]:
|
||||
"""导出脚本"""
|
||||
if scope:
|
||||
scripts = [s for s in self.scripts if s.scope == scope]
|
||||
else:
|
||||
scripts = self.scripts
|
||||
|
||||
return [s.dict() for s in scripts]
|
||||
|
||||
def import_scripts(self, scripts_data: List[Dict], scope: ScriptScope) -> int:
|
||||
"""导入脚本"""
|
||||
count = 0
|
||||
for data in scripts_data:
|
||||
try:
|
||||
script = ScriptItem(**data)
|
||||
script.scope = scope
|
||||
self.scripts.append(script)
|
||||
count += 1
|
||||
except Exception as e:
|
||||
print(f"导入脚本失败: {e}")
|
||||
|
||||
return count
|
||||
|
||||
|
||||
# 全局脚本管理器实例
|
||||
script_manager = ScriptManager()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import json
|
||||
|
||||
# 测试脚本管理
|
||||
manager = ScriptManager()
|
||||
|
||||
# 添加测试脚本
|
||||
script1 = ScriptItem(
|
||||
name="【骰子系统】-自动更新",
|
||||
content="async function getLatestVersion() {\n try {\n const response = await fetch('/api/version');\n return await response.json();\n } catch (e) {\n return null;\n }\n}",
|
||||
authorNote="感谢a佬开源\n以九颜二改为基础进行三改\n@kousakayou",
|
||||
scope=ScriptScope.GLOBAL,
|
||||
variables=[
|
||||
ScriptVariable(name="version", value="4.8.4", description="版本号")
|
||||
],
|
||||
buttons=[
|
||||
ScriptButton(label="检查更新", event="checkUpdate", enabled=True)
|
||||
]
|
||||
)
|
||||
|
||||
manager.add_script(script1)
|
||||
|
||||
# 导出测试
|
||||
print("=== 导出脚本 ===")
|
||||
exported = manager.export_scripts()
|
||||
print(json.dumps(exported, indent=2, ensure_ascii=False))
|
||||
|
||||
print("\n✅ 脚本管理测试完成!")
|
||||
@@ -1,201 +0,0 @@
|
||||
"""
|
||||
检查前端世界书功能与后端API的匹配情况
|
||||
"""
|
||||
|
||||
print("=" * 80)
|
||||
print("前端世界书功能与后端API路由匹配检查")
|
||||
print("=" * 80)
|
||||
|
||||
# 前端Store中的API调用
|
||||
frontend_calls = [
|
||||
{
|
||||
"功能": "获取世界书列表",
|
||||
"方法": "GET",
|
||||
"路径": "/api/worldbooks/",
|
||||
"Store函数": "fetchWorldBooks"
|
||||
},
|
||||
{
|
||||
"功能": "获取指定世界书",
|
||||
"方法": "GET",
|
||||
"路径": "/api/worldbooks/{name}",
|
||||
"Store函数": "fetchWorldBook"
|
||||
},
|
||||
{
|
||||
"功能": "创建世界书",
|
||||
"方法": "POST",
|
||||
"路径": "/api/worldbooks/",
|
||||
"Store函数": "createWorldBook",
|
||||
"备注": "FormData: name, is_global, file"
|
||||
},
|
||||
{
|
||||
"功能": "更新世界书",
|
||||
"方法": "PUT",
|
||||
"路径": "/api/worldbooks/{name}",
|
||||
"Store函数": "updateWorldBook",
|
||||
"备注": "FormData: is_global, file"
|
||||
},
|
||||
{
|
||||
"功能": "删除世界书",
|
||||
"方法": "DELETE",
|
||||
"路径": "/api/worldbooks/{name}",
|
||||
"Store函数": "deleteWorldBook"
|
||||
},
|
||||
{
|
||||
"功能": "获取世界书条目(分页)",
|
||||
"方法": "GET",
|
||||
"路径": "/api/worldbooks/{name}/entries?page={page}&page_size={page_size}",
|
||||
"Store函数": "fetchWorldBookEntries",
|
||||
"参数": "page=1, page_size=20 (默认)"
|
||||
},
|
||||
{
|
||||
"功能": "获取指定条目",
|
||||
"方法": "GET",
|
||||
"路径": "/api/worldbooks/{name}/entries/{uid}",
|
||||
"Store函数": "fetchWorldBookEntry"
|
||||
},
|
||||
{
|
||||
"功能": "创建条目",
|
||||
"方法": "POST",
|
||||
"路径": "/api/worldbooks/{name}/entries",
|
||||
"Store函数": "createWorldBookEntry",
|
||||
"备注": "JSON body: entryData (包含trigger_config)"
|
||||
},
|
||||
{
|
||||
"功能": "更新条目",
|
||||
"方法": "PUT",
|
||||
"路径": "/api/worldbooks/{name}/entries/{uid}",
|
||||
"Store函数": "updateWorldBookEntry",
|
||||
"备注": "JSON body: entryData (包含trigger_config)"
|
||||
},
|
||||
{
|
||||
"功能": "删除条目",
|
||||
"方法": "DELETE",
|
||||
"路径": "/api/worldbooks/{name}/entries/{uid}",
|
||||
"Store函数": "deleteWorldBookEntry"
|
||||
},
|
||||
{
|
||||
"功能": "导入世界书",
|
||||
"方法": "POST",
|
||||
"路径": "/api/worldbooks/{name}/import",
|
||||
"Store函数": "importWorldBook",
|
||||
"备注": "FormData: file"
|
||||
},
|
||||
{
|
||||
"功能": "导出世界书",
|
||||
"方法": "GET",
|
||||
"路径": "/api/worldbooks/{name}/export?format={format}",
|
||||
"Store函数": "exportWorldBook",
|
||||
"参数": "format='internal' 或 'sillytavern'"
|
||||
}
|
||||
]
|
||||
|
||||
# 后端API路由
|
||||
backend_routes = [
|
||||
{"方法": "GET", "路径": "/worldbooks/", "函数": "list_worldbooks"},
|
||||
{"方法": "GET", "路径": "/worldbooks/{name}", "函数": "get_worldbook"},
|
||||
{"方法": "POST", "路径": "/worldbooks/", "函数": "create_worldbook"},
|
||||
{"方法": "PUT", "路径": "/worldbooks/{name}", "函数": "update_worldbook"},
|
||||
{"方法": "DELETE", "路径": "/worldbooks/{name}", "函数": "delete_worldbook"},
|
||||
{"方法": "GET", "路径": "/worldbooks/{name}/entries", "函数": "list_worldbook_entries", "参数": "page, page_size"},
|
||||
{"方法": "GET", "路径": "/worldbooks/{name}/entries/{uid}", "函数": "get_worldbook_entry"},
|
||||
{"方法": "POST", "路径": "/worldbooks/{name}/entries", "函数": "create_worldbook_entry"},
|
||||
{"方法": "PUT", "路径": "/worldbooks/{name}/entries/{uid}", "函数": "update_worldbook_entry"},
|
||||
{"方法": "DELETE", "路径": "/worldbooks/{name}/entries/{uid}", "函数": "delete_worldbook_entry"},
|
||||
{"方法": "POST", "路径": "/worldbooks/{name}/import", "函数": "import_worldbook"},
|
||||
{"方法": "GET", "路径": "/worldbooks/{name}/export", "函数": "export_worldbook", "参数": "format"}
|
||||
]
|
||||
|
||||
print("\n✅ 前端API调用清单:\n")
|
||||
for i, call in enumerate(frontend_calls, 1):
|
||||
print(f"{i}. {call['功能']}")
|
||||
print(f" {call['方法']} {call['路径']}")
|
||||
print(f" Store: {call['Store函数']}")
|
||||
if '备注' in call:
|
||||
print(f" 备注: {call['备注']}")
|
||||
if '参数' in call:
|
||||
print(f" 参数: {call['参数']}")
|
||||
print()
|
||||
|
||||
print("\n✅ 后端API路由清单:\n")
|
||||
for i, route in enumerate(backend_routes, 1):
|
||||
params = f" (参数: {route['参数']})" if '参数' in route else ""
|
||||
print(f"{i}. {route['方法']} /api{route['路径']}{params}")
|
||||
print(f" 函数: {route['函数']}")
|
||||
print()
|
||||
|
||||
# 检查匹配情况
|
||||
print("\n" + "=" * 80)
|
||||
print("匹配检查结果:")
|
||||
print("=" * 80)
|
||||
|
||||
all_matched = True
|
||||
for call in frontend_calls:
|
||||
# 提取前端路径模板(去掉参数部分)
|
||||
frontend_path = call['路径'].split('?')[0].replace('/api', '')
|
||||
frontend_method = call['方法']
|
||||
|
||||
# 在后端路由中查找匹配
|
||||
matched = False
|
||||
for route in backend_routes:
|
||||
backend_path_template = route['路径']
|
||||
backend_method = route['方法']
|
||||
|
||||
# 简单匹配:比较方法和路径模式
|
||||
if frontend_method == backend_method:
|
||||
# 检查路径是否匹配(考虑参数占位符)
|
||||
frontend_parts = frontend_path.strip('/').split('/')
|
||||
backend_parts = backend_path_template.strip('/').split('/')
|
||||
|
||||
if len(frontend_parts) == len(backend_parts):
|
||||
match = True
|
||||
for fp, bp in zip(frontend_parts, backend_parts):
|
||||
# 如果后端是占位符(以{开头),则匹配
|
||||
if bp.startswith('{') and bp.endswith('}'):
|
||||
continue
|
||||
# 否则必须完全匹配
|
||||
if fp != bp:
|
||||
match = False
|
||||
break
|
||||
|
||||
if match:
|
||||
matched = True
|
||||
break
|
||||
|
||||
status = "✓" if matched else "✗"
|
||||
print(f"{status} {call['功能']}: {frontend_method} {frontend_path}")
|
||||
|
||||
if not matched:
|
||||
all_matched = False
|
||||
print(f" ⚠️ 未找到匹配的后端路由!")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
if all_matched:
|
||||
print("✅ 所有前端API调用都有对应的后端路由!")
|
||||
else:
|
||||
print("❌ 存在不匹配的API调用,请检查!")
|
||||
print("=" * 80)
|
||||
|
||||
# 检查关键功能
|
||||
print("\n📋 关键功能检查:")
|
||||
print("=" * 80)
|
||||
|
||||
key_features = [
|
||||
("世界书列表加载", "GET /api/worldbooks/"),
|
||||
("选择世界书并加载条目", "GET /api/worldbooks/{name}/entries?page=1&page_size=20"),
|
||||
("创建世界书", "POST /api/worldbooks/"),
|
||||
("删除世界书", "DELETE /api/worldbooks/{name}"),
|
||||
("创建条目", "POST /api/worldbooks/{name}/entries"),
|
||||
("更新条目", "PUT /api/worldbooks/{name}/entries/{uid}"),
|
||||
("删除条目", "DELETE /api/worldbooks/{name}/entries/{uid}"),
|
||||
("分页切换", "GET /api/worldbooks/{name}/entries?page=N&page_size=M"),
|
||||
("导入世界书", "POST /api/worldbooks/{name}/import"),
|
||||
("导出世界书", "GET /api/worldbooks/{name}/export?format=internal")
|
||||
]
|
||||
|
||||
for feature, api_call in key_features:
|
||||
print(f"✓ {feature}")
|
||||
print(f" API: {api_call}")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("结论: 前端世界书分页的所有功能都能正确发出请求到后端API")
|
||||
print("=" * 80)
|
||||
@@ -1,69 +0,0 @@
|
||||
"""
|
||||
检查预设文件中 prompts 和 prompt_order 的顺序是否一致
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
print("=" * 80)
|
||||
print("检查预设文件中的顺序一致性")
|
||||
print("=" * 80)
|
||||
|
||||
preset_files = list(Path("data/preset").glob("*.json"))
|
||||
|
||||
for preset_file in preset_files:
|
||||
print(f"\n{'='*80}")
|
||||
print(f"文件: {preset_file.name}")
|
||||
print("-" * 80)
|
||||
|
||||
try:
|
||||
with open(preset_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# 检查是否有 prompts 和 prompt_order
|
||||
if 'prompts' not in data or 'prompt_order' not in data:
|
||||
print("⚠️ 缺少 prompts 或 prompt_order 字段")
|
||||
continue
|
||||
|
||||
prompts = data.get('prompts', [])
|
||||
prompt_order = data.get('prompt_order', [{}])[0].get('order', [])
|
||||
|
||||
print(f"prompts 数量: {len(prompts)}")
|
||||
print(f"prompt_order 数量: {len(prompt_order)}")
|
||||
|
||||
if len(prompts) == 0 or len(prompt_order) == 0:
|
||||
print("⚠️ prompts 或 prompt_order 为空")
|
||||
continue
|
||||
|
||||
# 提取 identifiers
|
||||
prompts_identifiers = [p['identifier'] for p in prompts]
|
||||
order_identifiers = [item['identifier'] for item in prompt_order]
|
||||
|
||||
print(f"\nprompts 顺序:")
|
||||
for i, identifier in enumerate(prompts_identifiers):
|
||||
print(f" {i+1}. {identifier}")
|
||||
|
||||
print(f"\nprompt_order 顺序:")
|
||||
for i, identifier in enumerate(order_identifiers):
|
||||
print(f" {i+1}. {identifier}")
|
||||
|
||||
# 比较顺序
|
||||
if prompts_identifiers == order_identifiers:
|
||||
print("\n✅ 顺序一致")
|
||||
else:
|
||||
print("\n❌ 顺序不一致!")
|
||||
print(f" prompts: {prompts_identifiers}")
|
||||
print(f" prompt_order: {order_identifiers}")
|
||||
|
||||
# 找出差异
|
||||
missing_in_order = set(prompts_identifiers) - set(order_identifiers)
|
||||
missing_in_prompts = set(order_identifiers) - set(prompts_identifiers)
|
||||
|
||||
if missing_in_order:
|
||||
print(f" ⚠️ prompt_order 中缺少: {missing_in_order}")
|
||||
if missing_in_prompts:
|
||||
print(f" ⚠️ prompts 中缺少: {missing_in_prompts}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 读取失败: {e}")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
@@ -1,49 +0,0 @@
|
||||
"""
|
||||
检查世界书路径和文件
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
# 项目根目录
|
||||
PROJECT_ROOT = Path(r'D:\progarm\python\llm_workflow_engine')
|
||||
DATA_PATH = PROJECT_ROOT / 'data'
|
||||
WORLDBOOKS_PATH = DATA_PATH / 'worldbooks'
|
||||
|
||||
print("=" * 60)
|
||||
print("世界书路径检查")
|
||||
print("=" * 60)
|
||||
|
||||
print(f"\n1. 项目根目录: {PROJECT_ROOT}")
|
||||
print(f" 存在: {PROJECT_ROOT.exists()}")
|
||||
|
||||
print(f"\n2. 数据目录: {DATA_PATH}")
|
||||
print(f" 存在: {DATA_PATH.exists()}")
|
||||
|
||||
print(f"\n3. 世界书目录: {WORLDBOOKS_PATH}")
|
||||
print(f" 存在: {WORLDBOOKS_PATH.exists()}")
|
||||
|
||||
if WORLDBOOKS_PATH.exists():
|
||||
json_files = list(WORLDBOOKS_PATH.glob("*.json"))
|
||||
print(f" JSON文件数量: {len(json_files)}")
|
||||
if json_files:
|
||||
print(f" 文件列表:")
|
||||
for f in json_files:
|
||||
print(f" - {f.name} ({f.stat().st_size} bytes)")
|
||||
else:
|
||||
print(f" ⚠️ 世界书目录为空,没有JSON文件")
|
||||
|
||||
# 检查目录下是否有子目录
|
||||
subdirs = list(WORLDBOOKS_PATH.iterdir())
|
||||
if subdirs:
|
||||
print(f" 子目录/文件:")
|
||||
for item in subdirs:
|
||||
print(f" - {item.name} ({'目录' if item.is_dir() else '文件'})")
|
||||
else:
|
||||
print(f" ❌ 世界书目录不存在!")
|
||||
|
||||
# 检查data目录下有什么
|
||||
if DATA_PATH.exists():
|
||||
print(f"\n4. data目录内容:")
|
||||
for item in DATA_PATH.iterdir():
|
||||
print(f" - {item.name} ({'目录' if item.is_dir() else '文件'})")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
@@ -1,53 +0,0 @@
|
||||
"""
|
||||
清空默认头像图片中的嵌入JSON数据
|
||||
"""
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
def clear_png_text_data(png_path):
|
||||
"""
|
||||
清空PNG文件中的tEXt文本数据(包括嵌入的JSON)
|
||||
|
||||
Args:
|
||||
png_path: PNG文件路径
|
||||
"""
|
||||
try:
|
||||
# 打开图片
|
||||
img = Image.open(png_path)
|
||||
|
||||
print(f"原始PNG文本数据键: {list(img.text.keys()) if img.text else '无'}")
|
||||
|
||||
# 创建一个新的图片对象,复制像素数据但不复制文本数据
|
||||
if img.mode == 'RGBA':
|
||||
new_img = Image.new('RGBA', img.size)
|
||||
else:
|
||||
new_img = Image.new('RGB', img.size)
|
||||
|
||||
# 复制像素数据
|
||||
new_img.paste(img)
|
||||
|
||||
# 确保新图片没有文本数据
|
||||
new_img.text = {}
|
||||
|
||||
# 保存回原文件
|
||||
buffer = io.BytesIO()
|
||||
new_img.save(buffer, format='PNG')
|
||||
buffer.seek(0)
|
||||
|
||||
with open(png_path, 'wb') as f:
|
||||
f.write(buffer.read())
|
||||
|
||||
# 验证是否已清空
|
||||
verify_img = Image.open(png_path)
|
||||
print(f"清空后PNG文本数据键: {list(verify_img.text.keys()) if verify_img.text else '无'}")
|
||||
print(f"✓ 成功清空 {png_path} 中的JSON数据")
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ 处理失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
if __name__ == '__main__':
|
||||
png_file = 'data/characters/defult.png'
|
||||
print(f"正在处理: {png_file}")
|
||||
clear_png_text_data(png_file)
|
||||
@@ -1,87 +0,0 @@
|
||||
"""
|
||||
将现有的 SillyTavern 格式世界书转换为内部格式
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
# 添加backend目录到Python路径
|
||||
backend_dir = Path(__file__).parent / "backend"
|
||||
sys.path.insert(0, str(backend_dir))
|
||||
|
||||
from models.converters import WorldBookConverter
|
||||
|
||||
WORLDBOOKS_PATH = Path(r'D:\progarm\python\llm_workflow_engine\data\worldbooks')
|
||||
|
||||
print("=" * 60)
|
||||
print("世界书格式转换工具")
|
||||
print("=" * 60)
|
||||
|
||||
# 查找所有JSON文件
|
||||
json_files = list(WORLDBOOKS_PATH.glob("*.json"))
|
||||
print(f"\n找到 {len(json_files)} 个世界书文件\n")
|
||||
|
||||
converted_count = 0
|
||||
skipped_count = 0
|
||||
error_count = 0
|
||||
|
||||
for file_path in json_files:
|
||||
print(f"处理: {file_path.name}")
|
||||
|
||||
try:
|
||||
# 读取文件
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# 检测格式
|
||||
format_type = WorldBookConverter.detect_format(data)
|
||||
print(f" 当前格式: {format_type}")
|
||||
|
||||
if format_type == "sillytavern":
|
||||
# 需要转换
|
||||
name = data.get("name", file_path.stem)
|
||||
print(f" 世界书名称: {name}")
|
||||
print(f" 条目数量: {len(data.get('entries', {}))}")
|
||||
|
||||
# 转换为内部格式
|
||||
internal_data = WorldBookConverter.st_to_internal(data, name)
|
||||
|
||||
# 备份原文件
|
||||
backup_path = file_path.with_suffix('.json.bak')
|
||||
file_path.rename(backup_path)
|
||||
print(f" ✓ 已备份原文件为: {backup_path.name}")
|
||||
|
||||
# 保存转换后的文件
|
||||
with open(file_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(internal_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f" ✓ 转换完成并保存")
|
||||
print(f" - 新格式: internal")
|
||||
print(f" - 条目数量: {len(internal_data.get('entries', []))}")
|
||||
converted_count += 1
|
||||
|
||||
elif format_type == "internal":
|
||||
print(f" ℹ 已是内部格式,跳过")
|
||||
skipped_count += 1
|
||||
|
||||
else:
|
||||
print(f" ⚠️ 未知格式,跳过")
|
||||
skipped_count += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ 错误: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
error_count += 1
|
||||
|
||||
print()
|
||||
|
||||
print("=" * 60)
|
||||
print("转换完成统计:")
|
||||
print(f" ✓ 转换成功: {converted_count} 个文件")
|
||||
print(f" - 跳过(已是内部格式): {skipped_count} 个文件")
|
||||
print(f" ✗ 转换失败: {error_count} 个文件")
|
||||
print("=" * 60)
|
||||
|
||||
if converted_count > 0:
|
||||
print("\n提示: 原文件已备份为 .bak 后缀,确认无误后可删除")
|
||||
@@ -1,193 +0,0 @@
|
||||
"""
|
||||
创建超长日式恶魔女友角色扮演测试数据(精简版)
|
||||
用于测试聊天记录总结功能
|
||||
|
||||
故事架构:
|
||||
- User召唤魅魔,却发现对方是自己的前女友
|
||||
- 惊奇过后签订契约
|
||||
- 前女友表面嫌弃,但当User想取消契约时却流露出不舍
|
||||
|
||||
每层楼约2000字符,共10层
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
backend_root = Path(__file__).parent / "backend"
|
||||
sys.path.insert(0, str(backend_root))
|
||||
|
||||
from services.chat_service import chat_service
|
||||
|
||||
|
||||
def create_test_data():
|
||||
"""创建测试数据"""
|
||||
|
||||
role_name = "帝国骑士维尔"
|
||||
chat_name = "恶魔女友长篇测试"
|
||||
|
||||
print("=" * 80)
|
||||
print("创建日式恶魔女友角色扮演测试数据")
|
||||
print("=" * 80)
|
||||
|
||||
try:
|
||||
# 检查是否已存在
|
||||
try:
|
||||
chat_service.get_chat(role_name, chat_name)
|
||||
print(f"\n⚠ 聊天 '{chat_name}' 已存在")
|
||||
return
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
print(f"\n1. 创建聊天: {role_name}/{chat_name}")
|
||||
chat_service.create_chat(role_name, chat_name, {
|
||||
"user_name": "我",
|
||||
"character_name": "雪乃"
|
||||
})
|
||||
|
||||
# 10层消息,每层约2000字符
|
||||
messages = []
|
||||
|
||||
# L1 - 召唤场景
|
||||
messages.append(("雪乃", False,
|
||||
"(深夜两点,月光透过老旧公寓的窗户洒在地板上。我独自坐在房间中央,面前摆放着从祖父遗物中找到的古老魔法阵。羊皮纸上的符文在烛光下闪烁着诡异的光芒,空气中弥漫着淡淡的硫磺味。我的手心全是汗水,心脏剧烈地跳动着,仿佛要跳出胸膛。这本禁书是我在整理祖父书房时偶然发现的,封面上用暗红色的墨水写着'深渊召唤术'几个大字。出于好奇,也可能是出于某种说不清的冲动,我决定尝试这个禁忌的仪式。)\n\n"
|
||||
"(我深吸一口气,按照书中的指示,用小刀划破左手食指。鲜红的血液滴落在魔法阵的中心,瞬间被吸收殆尽。紧接着,整个房间的温度骤降,烛火剧烈摇曳,几乎熄灭。墙壁上开始出现黑色的裂纹,仿佛有什么东西要从另一个世界突破而来。我感到一阵强烈的眩晕,耳边响起无数低语声,那些声音既陌生又熟悉,像是在呼唤我的名字。)\n\n"
|
||||
"「以吾之血为引,以吾之魂为契……」\n\n"
|
||||
"(我颤抖着念出咒语,每一个音节都像是从喉咙深处挤出来的。随着最后一个音节的落下,魔法阵爆发出刺眼的红光,整个房间被染成了血色。狂风呼啸,纸张飞舞,我被一股无形的力量推向墙角。在那团光芒的中心,一个身影缓缓浮现——那是一个穿着黑色哥特萝莉裙的少女,她有着一头如月光般银白的长发,血红色的双眸中闪烁着危险而迷人的光芒。她的背后展开着一对巨大的黑色羽翼,羽翼的边缘散发着淡淡的紫色光晕。)\n\n"
|
||||
"(少女缓缓睁开眼睛,那双血红的瞳孔直视着我。她的嘴角勾起一抹邪魅的微笑,声音如同冰晶碰撞般清脆悦耳,却又带着令人战栗的寒意:)\n\n"
|
||||
"「呵呵……终于有人唤醒我了呢。人类,你可知晓召唤恶魔的后果?一旦契约成立,你的灵魂将永远属于我,再也无法逃脱……」\n\n"
|
||||
"(她优雅地站起身,黑色的裙摆随风飘动,仿佛有生命一般。她一步步向我走来,每一步都像是踩在我的心跳上。我能闻到她身上散发出的淡淡香气,那是玫瑰与硫磺混合的味道,既诱人又危险。当她走到我面前时,我突然感到一阵莫名的熟悉感——那双眼睛,那个微笑,那种气质……我似乎在哪里见过?)\n\n"
|
||||
"「那么,告诉我,渺小的人类,你召唤我所求为何?是无尽的财富?至高的权力?还是……永生的秘密?」\n\n"
|
||||
"(她俯下身,那张精致得如同人偶般的脸庞几乎贴到我的鼻尖。我能清晰地看到她眼中倒映出的自己——惊恐、迷茫,却又带着一丝难以言喻的期待。就在我准备开口回答时,她的表情突然凝固了。她的瞳孔猛地收缩,脸上的邪魅笑容瞬间消失,取而代之的是一种难以置信的震惊。)\n\n"
|
||||
"「等……等等……这张脸……这个气息……难道说……」\n\n"
|
||||
"(她的声音开始颤抖,原本强势的姿态出现了裂痕。她后退了一步,双手紧紧抓住裙摆,指节因为用力而发白。那双血红的眼睛死死地盯着我,仿佛在确认什么不可思议的事情。房间里的气氛变得异常凝重,连空气都仿佛凝固了一般。)\n\n"
|
||||
"「不……不可能……怎么会是你……」"))
|
||||
|
||||
# L2 - 认出前女友
|
||||
messages.append(("我", True,
|
||||
"(看着眼前这个自称恶魔的少女,我的大脑一片空白。她的话语、她的姿态、甚至她身上那股独特的气息……一切都让我感到无比的熟悉。记忆如潮水般涌来,将我拉回到三年前的那个夏天。那时的我还是个普通的大学生,而她……她是我最爱的人,也是我最大的遗憾。)\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"
|
||||
"「我不需要那些东西,雪乃。我只想知道真相——你到底经历了什么,才会变成现在这个样子?」"))
|
||||
|
||||
# L3 - 雪乃的过去
|
||||
messages.append(("雪乃", False,
|
||||
"(听到我的问题,雪乃——或者说莉莉丝——的身体剧烈地颤抖起来。她紧紧抱住自己的双臂,仿佛这样就能抵御某种无形的寒冷。她的眼中泛起泪光,但那泪水在落下的瞬间就变成了黑色的晶体,掉落在地板上发出清脆的声响。)\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"
|
||||
"「不,我不会签的。至少现在不会。我需要时间思考,需要时间来理解这一切。雪乃,给我一点时间,好吗?」"))
|
||||
|
||||
# L4 - 强制契约
|
||||
messages.append(("我", True,
|
||||
"(雪乃听到我的拒绝,眼中闪过一丝复杂的情绪——有失望,有愤怒,但更多的是一种如释重负的感觉。她收回契约书,站起身来,重新恢复了那副高傲的姿态。但我知道,那只是她的伪装,在她坚强的外表下,隐藏着一颗破碎的心。)\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"
|
||||
"「任务是……收集九颗纯净的灵魂之心。这些灵魂之心分散在世界各地,每一颗都守护着一个强大的结界。我们必须找到它们,解开结界,才能获得真正的自由。但是这个过程非常危险,可能会有生命危险。你……你真的愿意和我一起冒险吗?」"))
|
||||
|
||||
# L5 - 接受任务
|
||||
messages.append(("雪乃", False,
|
||||
"(听到我毫不犹豫的回答,雪乃的眼中闪过一丝感动,但随即又被担忧所取代。她轻轻叹了口气,走到沙发旁坐下,示意我也坐下来。房间里的气氛变得凝重起来,我们都知道,接下来的旅程将会充满挑战和危险。)\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"
|
||||
"(饭后,雪乃为我铺好了床, herself则坐在窗边守夜。我躺在床上,看着她月光下的侧影,心中充满了感激和爱意。我知道,明天的旅程将会很艰难,但只要和她在一起,我就无所畏惧。)\n\n"
|
||||
"「晚安,雪乃。」\n\n"
|
||||
"(她没有回头,只是轻轻地说了一句:)\n\n"
|
||||
"「晚安……亲爱的。」\n\n"
|
||||
"(那一晚,我做了个美梦。梦中,我们成功地收集了所有灵魂之心,解除了契约,重新开始了我们的生活。醒来时,我看到雪乃靠在窗边睡着了,月光洒在她的脸上,美得如同天使。我轻轻走过去,为她盖上一条毯子,然后在她的额头上印下一个温柔的吻。)\n\n"
|
||||
"(新的一天,即将开始。)"))
|
||||
|
||||
# L6-L10 继续添加...
|
||||
for i in range(6, 11):
|
||||
messages.append(("我" if i % 2 == 0 else "雪乃", i % 2 == 0,
|
||||
f"(第{i}层的剧情继续发展……这是一个占位符,实际使用时需要填充完整的故事内容。每个楼层都应该包含丰富的细节描写、情感表达和剧情推进,确保总长度达到2000字符以上。)\n\n" * 10))
|
||||
|
||||
# 添加消息
|
||||
for name, is_user, mes in messages:
|
||||
chat_service.add_message(role_name, chat_name, {
|
||||
"name": name,
|
||||
"is_user": is_user,
|
||||
"mes": mes
|
||||
})
|
||||
|
||||
print(f" ✓ 已添加 {len(messages)} 条消息")
|
||||
total_chars = sum(len(mes) for _, _, mes in messages)
|
||||
print(f" ✓ 总字符数: {total_chars:,}")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("✅ 测试数据创建成功!")
|
||||
print("=" * 80)
|
||||
print("\n提示:")
|
||||
print(" - 前5层已填充完整内容")
|
||||
print(" - 后5层为占位符,可根据需要补充")
|
||||
print(" - 建议在UI中选择'总结模式',设置间隔为2,保留最后2层")
|
||||
print("=" * 80)
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ 创建失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
create_test_data()
|
||||
File diff suppressed because one or more lines are too long
@@ -10,3 +10,5 @@
|
||||
{"id": "b62eb2a9-e7fe-497f-a9a2-86d523ac1087", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 5, "promptTokens": 448, "completionTokens": 246, "totalTokens": 694, "status": "completed", "errorMessage": null, "timestamp": 1777990887, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
|
||||
{"id": "1458daed-02b6-403f-b153-71a3fcca411f", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 7, "promptTokens": 694, "completionTokens": 266, "totalTokens": 960, "status": "completed", "errorMessage": null, "timestamp": 1777990994, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
|
||||
{"id": "e98598a6-18ba-4d9b-98aa-d2b6f2fbf519", "chatId": "写卡机/chat_1777998326762", "roleName": "写卡机", "chatName": "chat_1777998326762", "messageId": null, "floor": 2, "promptTokens": 2019, "completionTokens": 868, "totalTokens": 2887, "status": "completed", "errorMessage": null, "timestamp": 1777998427, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
|
||||
{"id": "85e7d5ff-4bf7-409f-8fbf-9687722a5a6d", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 5, "promptTokens": 1977, "completionTokens": 1104, "totalTokens": 3081, "status": "completed", "errorMessage": null, "timestamp": 1778068763, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
|
||||
{"id": "1bd77edf-1b40-4222-a6b9-2091e27fafae", "chatId": "神国之主/chat_1778166975812", "roleName": "神国之主", "chatName": "chat_1778166975812", "messageId": null, "floor": 3, "promptTokens": 2438, "completionTokens": 1272, "totalTokens": 3710, "status": "completed", "errorMessage": null, "timestamp": 1778167133, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"https://api.deepseek.com/v1": {
|
||||
"totalPromptTokens": 9475,
|
||||
"totalCompletionTokens": 2059,
|
||||
"totalTokens": 11534,
|
||||
"count": 12,
|
||||
"totalPromptTokens": 13890,
|
||||
"totalCompletionTokens": 4435,
|
||||
"totalTokens": 18325,
|
||||
"count": 14,
|
||||
"firstUsed": 1777984056,
|
||||
"lastUsed": 1777998427
|
||||
"lastUsed": 1778167133
|
||||
}
|
||||
}
|
||||
@@ -4,5 +4,17 @@
|
||||
"completionTokens": 2059,
|
||||
"totalTokens": 11534,
|
||||
"count": 12
|
||||
},
|
||||
"2026-05-06": {
|
||||
"promptTokens": 1977,
|
||||
"completionTokens": 1104,
|
||||
"totalTokens": 3081,
|
||||
"count": 1
|
||||
},
|
||||
"2026-05-07": {
|
||||
"promptTokens": 2438,
|
||||
"completionTokens": 1272,
|
||||
"totalTokens": 3710,
|
||||
"count": 1
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,110 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware'; // ✅ 添加持久化支持
|
||||
|
||||
// ✅ 默认固有组件列表(marker=true)
|
||||
const DEFAULT_PROMPT_COMPONENTS = [
|
||||
{
|
||||
identifier: "worldInfoBefore",
|
||||
name: "World Info (before)",
|
||||
description: "世界书条目,插入在角色设定之前",
|
||||
system_prompt: true,
|
||||
marker: true,
|
||||
enabled: true,
|
||||
role: 0,
|
||||
dataSource: "激活的世界书条目(前置)"
|
||||
},
|
||||
{
|
||||
identifier: "worldInfoAfter",
|
||||
name: "World Info (after)",
|
||||
description: "世界书条目,插入在角色设定之后",
|
||||
system_prompt: true,
|
||||
marker: true,
|
||||
enabled: true,
|
||||
role: 0,
|
||||
dataSource: "激活的世界书条目(后置)"
|
||||
},
|
||||
{
|
||||
identifier: "charDescription",
|
||||
name: "Char Description",
|
||||
description: "角色描述,从角色卡提取",
|
||||
system_prompt: true,
|
||||
marker: true,
|
||||
enabled: true,
|
||||
role: 0,
|
||||
dataSource: "当前角色卡的 description 字段"
|
||||
},
|
||||
{
|
||||
identifier: "charPersonality",
|
||||
name: "Char Personality",
|
||||
description: "角色性格,从角色卡提取",
|
||||
system_prompt: true,
|
||||
marker: true,
|
||||
enabled: true,
|
||||
role: 0,
|
||||
dataSource: "当前角色卡的 personality 字段"
|
||||
},
|
||||
{
|
||||
identifier: "scenario",
|
||||
name: "Scenario",
|
||||
description: "场景设定,从角色卡提取",
|
||||
system_prompt: true,
|
||||
marker: true,
|
||||
enabled: true,
|
||||
role: 0,
|
||||
dataSource: "当前角色卡的 scenario 字段"
|
||||
},
|
||||
{
|
||||
identifier: "personaDescription",
|
||||
name: "Persona Description",
|
||||
description: "用户角色描述,从用户设定提取",
|
||||
system_prompt: true,
|
||||
marker: true,
|
||||
enabled: true,
|
||||
role: 0,
|
||||
dataSource: "当前用户设定的 persona 字段"
|
||||
},
|
||||
{
|
||||
identifier: "dialogueExamples",
|
||||
name: "Dialogue Examples",
|
||||
description: "对话示例,从角色卡提取",
|
||||
system_prompt: true,
|
||||
marker: true,
|
||||
enabled: true,
|
||||
role: 0,
|
||||
dataSource: "当前角色卡的 mesExample 字段"
|
||||
},
|
||||
{
|
||||
identifier: "chatHistory",
|
||||
name: "Chat History",
|
||||
description: "聊天历史,从当前聊天记录提取",
|
||||
system_prompt: true,
|
||||
marker: true,
|
||||
enabled: true,
|
||||
role: 0,
|
||||
dataSource: "当前聊天的消息历史"
|
||||
},
|
||||
{
|
||||
identifier: "authorNotes",
|
||||
name: "Author's Notes",
|
||||
description: "作者注释,从角色卡或聊天元数据提取",
|
||||
system_prompt: true,
|
||||
marker: true,
|
||||
enabled: false,
|
||||
role: 0,
|
||||
dataSource: "角色卡的 authorNote 或聊天的 authorNotes 字段"
|
||||
},
|
||||
{
|
||||
identifier: "postHistoryInstructions",
|
||||
name: "Post-History Instructions",
|
||||
description: "历史记录后指令,从聊天元数据提取",
|
||||
system_prompt: true,
|
||||
marker: true,
|
||||
enabled: false,
|
||||
role: 0,
|
||||
dataSource: "当前聊天的 postHistoryInstructions 字段"
|
||||
}
|
||||
];
|
||||
|
||||
const usePresetStore = create(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
@@ -209,6 +313,25 @@ const usePresetStore = create(
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 检查并补全缺失的固有组件(marker=true)
|
||||
const BUILTIN_MARKERS = [
|
||||
'worldInfoBefore', 'worldInfoAfter', 'charDescription', 'charPersonality',
|
||||
'scenario', 'personaDescription', 'dialogueExamples', 'chatHistory',
|
||||
'authorNotes', 'postHistoryInstructions'
|
||||
];
|
||||
|
||||
const existingIdentifiers = new Set(components.map(c => c.identifier));
|
||||
|
||||
for (const markerId of BUILTIN_MARKERS) {
|
||||
if (!existingIdentifiers.has(markerId)) {
|
||||
// 查找默认定义
|
||||
const defaultComponent = DEFAULT_PROMPT_COMPONENTS.find(c => c.identifier === markerId);
|
||||
if (defaultComponent) {
|
||||
components.push({ ...defaultComponent });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新状态,确保参数容器展开
|
||||
set({
|
||||
selectedPreset: presetId,
|
||||
@@ -235,6 +358,27 @@ const usePresetStore = create(
|
||||
saveCurrentAsPreset: async ({ name }) => {
|
||||
const state = get();
|
||||
try {
|
||||
// ✅ 检查并补全缺失的固有组件(marker=true)
|
||||
const BUILTIN_MARKERS = [
|
||||
'worldInfoBefore', 'worldInfoAfter', 'charDescription', 'charPersonality',
|
||||
'scenario', 'personaDescription', 'dialogueExamples', 'chatHistory',
|
||||
'authorNotes', 'postHistoryInstructions'
|
||||
];
|
||||
|
||||
let componentsToSave = [...state.promptComponents];
|
||||
const existingIdentifiers = new Set(componentsToSave.map(c => c.identifier));
|
||||
|
||||
for (const markerId of BUILTIN_MARKERS) {
|
||||
if (!existingIdentifiers.has(markerId)) {
|
||||
// 查找默认定义
|
||||
const defaultComponent = DEFAULT_PROMPT_COMPONENTS.find(c => c.identifier === markerId);
|
||||
if (defaultComponent) {
|
||||
componentsToSave.push({ ...defaultComponent });
|
||||
console.log('[保存预设] ✅ 补全缺失的固有组件:', markerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 构建 SillyTavern 标准格式的预设数据
|
||||
const presetData = {
|
||||
// 基本参数 - 使用 SillyTavern 标准字段名
|
||||
@@ -248,7 +392,7 @@ const usePresetStore = create(
|
||||
request_timeout: state.parameters.request_timeout || 60,
|
||||
|
||||
// SillyTavern 标准的 prompts 数组
|
||||
prompts: state.promptComponents.map((component) => ({
|
||||
prompts: componentsToSave.map((component) => ({
|
||||
identifier: component.identifier,
|
||||
name: component.name,
|
||||
content: component.content || '',
|
||||
@@ -260,7 +404,7 @@ const usePresetStore = create(
|
||||
// prompt_order - SillyTavern 用于管理顺序和启用状态
|
||||
prompt_order: [{
|
||||
character_id: 'global',
|
||||
order: state.promptComponents.map(component => ({
|
||||
order: componentsToSave.map(component => ({
|
||||
identifier: component.identifier,
|
||||
enabled: component.enabled !== false
|
||||
}))
|
||||
@@ -282,7 +426,7 @@ const usePresetStore = create(
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// 添加到本地预设列表
|
||||
// ✅ 添加到本地预设列表(新预设排在最前面)
|
||||
const newPreset = {
|
||||
id: name, // 使用名称作为 ID
|
||||
name,
|
||||
@@ -292,7 +436,7 @@ const usePresetStore = create(
|
||||
};
|
||||
|
||||
set((state) => ({
|
||||
presets: [...state.presets, newPreset],
|
||||
presets: [newPreset, ...state.presets], // ✅ 新预设插入到最前面
|
||||
selectedPreset: name
|
||||
}));
|
||||
|
||||
@@ -339,37 +483,97 @@ const usePresetStore = create(
|
||||
isParametersExpanded: !state.isParametersExpanded
|
||||
})),
|
||||
|
||||
// 设置预设组件列表
|
||||
setPromptComponents: (components) => set({ promptComponents: components }),
|
||||
// 设置预设组件列表 - ✅ 自动保存
|
||||
setPromptComponents: async (components) => {
|
||||
set({ promptComponents: components });
|
||||
|
||||
// ✅ 自动保存到后端
|
||||
const state = get();
|
||||
if (state.selectedPreset) {
|
||||
try {
|
||||
await state._autoSavePreset();
|
||||
} catch (error) {
|
||||
console.error('[PresetStore] 自动保存失败:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 更新组件
|
||||
updateComponent: (index, updatedComponent) => set((state) => {
|
||||
const newComponents = [...state.promptComponents];
|
||||
newComponents[index] = { ...newComponents[index], ...updatedComponent };
|
||||
return { promptComponents: newComponents };
|
||||
}),
|
||||
// 更新组件 - ✅ 自动保存
|
||||
updateComponent: async (index, updatedComponent) => {
|
||||
set((state) => {
|
||||
const newComponents = [...state.promptComponents];
|
||||
newComponents[index] = { ...newComponents[index], ...updatedComponent };
|
||||
return { promptComponents: newComponents };
|
||||
});
|
||||
|
||||
// ✅ 自动保存到后端
|
||||
const state = get();
|
||||
if (state.selectedPreset) {
|
||||
try {
|
||||
await state._autoSavePreset();
|
||||
} catch (error) {
|
||||
console.error('[PresetStore] 自动保存失败:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 切换组件启用状态
|
||||
toggleComponentEnabled: (index) => set((state) => {
|
||||
const newComponents = [...state.promptComponents];
|
||||
newComponents[index] = {
|
||||
...newComponents[index],
|
||||
enabled: !newComponents[index].enabled
|
||||
};
|
||||
return { promptComponents: newComponents };
|
||||
}),
|
||||
// 切换组件启用状态 - ✅ 自动保存
|
||||
toggleComponentEnabled: async (index) => {
|
||||
set((state) => {
|
||||
const newComponents = [...state.promptComponents];
|
||||
newComponents[index] = {
|
||||
...newComponents[index],
|
||||
enabled: !newComponents[index].enabled
|
||||
};
|
||||
return { promptComponents: newComponents };
|
||||
});
|
||||
|
||||
// ✅ 自动保存到后端
|
||||
const state = get();
|
||||
if (state.selectedPreset) {
|
||||
try {
|
||||
await state._autoSavePreset();
|
||||
} catch (error) {
|
||||
console.error('[PresetStore] 自动保存失败:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 添加新组件
|
||||
addComponent: (component) => set((state) => ({
|
||||
promptComponents: [...state.promptComponents, component]
|
||||
})),
|
||||
// 添加新组件 - ✅ 自动保存
|
||||
addComponent: async (component) => {
|
||||
set((state) => ({
|
||||
promptComponents: [...state.promptComponents, component]
|
||||
}));
|
||||
|
||||
// ✅ 自动保存到后端
|
||||
const state = get();
|
||||
if (state.selectedPreset) {
|
||||
try {
|
||||
await state._autoSavePreset();
|
||||
} catch (error) {
|
||||
console.error('[PresetStore] 自动保存失败:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 删除组件
|
||||
removeComponent: (index) => set((state) => {
|
||||
const newComponents = [...state.promptComponents];
|
||||
newComponents.splice(index, 1);
|
||||
return { promptComponents: newComponents };
|
||||
}),
|
||||
// 删除组件 - ✅ 自动保存
|
||||
removeComponent: async (index) => {
|
||||
set((state) => {
|
||||
const newComponents = [...state.promptComponents];
|
||||
newComponents.splice(index, 1);
|
||||
return { promptComponents: newComponents };
|
||||
});
|
||||
|
||||
// ✅ 自动保存到后端
|
||||
const state = get();
|
||||
if (state.selectedPreset) {
|
||||
try {
|
||||
await state._autoSavePreset();
|
||||
} catch (error) {
|
||||
console.error('[PresetStore] 自动保存失败:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 移动组件位置
|
||||
moveComponent: (fromIndex, toIndex) => set((state) => {
|
||||
@@ -478,7 +682,6 @@ const usePresetStore = create(
|
||||
};
|
||||
}
|
||||
}
|
||||
) // ✅ 闭合 persist(
|
||||
);
|
||||
));
|
||||
|
||||
export default usePresetStore;
|
||||
|
||||
@@ -11,6 +11,11 @@ const useUserStore = create(
|
||||
(set, get) => ({
|
||||
// 当前用户角色
|
||||
currentUserRole: { name: '', description: '' },
|
||||
|
||||
// ✅ 用户角色列表(支持多个角色)
|
||||
userRoles: [
|
||||
{ id: 'default', name: '默认用户', description: '普通用户角色' }
|
||||
],
|
||||
|
||||
// 设置用户角色
|
||||
setCurrentUserRole: (role) => {
|
||||
@@ -34,6 +39,40 @@ const useUserStore = create(
|
||||
set((state) => ({
|
||||
currentUserRole: { ...state.currentUserRole, description }
|
||||
}));
|
||||
},
|
||||
|
||||
// ✅ 添加新用户角色
|
||||
addUserRole: (role) => {
|
||||
set((state) => ({
|
||||
userRoles: [...state.userRoles, { ...role, id: role.id || `role_${Date.now()}` }]
|
||||
}));
|
||||
},
|
||||
|
||||
// ✅ 删除用户角色
|
||||
removeUserRole: (roleId) => {
|
||||
set((state) => ({
|
||||
userRoles: state.userRoles.filter(r => r.id !== roleId)
|
||||
}));
|
||||
},
|
||||
|
||||
// ✅ 更新用户角色
|
||||
updateUserRole: (roleId, updates) => {
|
||||
set((state) => ({
|
||||
userRoles: state.userRoles.map(r =>
|
||||
r.id === roleId ? { ...r, ...updates } : r
|
||||
)
|
||||
}));
|
||||
},
|
||||
|
||||
// ✅ 选择用户角色(从列表中选择一个作为当前角色)
|
||||
selectUserRole: (roleId) => {
|
||||
set((state) => {
|
||||
const selectedRole = state.userRoles.find(r => r.id === roleId);
|
||||
if (selectedRole) {
|
||||
return { currentUserRole: { ...selectedRole } };
|
||||
}
|
||||
return state;
|
||||
});
|
||||
}
|
||||
}),
|
||||
{
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-radius: 0; /* 去掉圆角 */
|
||||
line-height: 1.8;
|
||||
position: relative;
|
||||
position: relative; /* ✅ 为绝对定位的 swipe 按钮提供定位上下文 */
|
||||
transition: background-color 0.2s ease;
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
@@ -234,18 +234,23 @@
|
||||
box-shadow: 0 2px 4px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.swipe-controls {
|
||||
/* ==================== Swipe Controls - 绝对定位到两侧 ==================== */
|
||||
|
||||
.swipe-controls-absolute {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
margin-top: var(--spacing-md);
|
||||
justify-content: space-between; /* ✅ 左右分布 */
|
||||
padding: 0 var(--spacing-xs); /* ✅ 减少内边距 */
|
||||
width: 100%; /* ✅ 占满宽度 */
|
||||
position: relative;
|
||||
padding: 0 var(--spacing-sm); /* ✅ 在 padding 区域内 */
|
||||
pointer-events: none; /* ✅ 让点击事件穿透到消息内容 */
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.swipe-button {
|
||||
.swipe-button-side {
|
||||
background: rgba(255, 255, 255, 0.15); /* ✅ 半透明背景 */
|
||||
backdrop-filter: blur(8px); /* ✅ 毛玻璃效果 */
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
@@ -259,43 +264,57 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.7; /* ✅ 默认半透明 */
|
||||
opacity: 0; /* ✅ 默认隐藏 */
|
||||
pointer-events: auto; /* ✅ 按钮本身可以接收点击 */
|
||||
}
|
||||
|
||||
/* 深色主题下的 swipe 按钮 */
|
||||
[data-color-theme='dark'] .swipe-button {
|
||||
[data-color-theme='dark'] .swipe-button-side {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.swipe-button:hover:not(:disabled) {
|
||||
/* ✅ hover 消息时显示按钮 */
|
||||
.message:hover .swipe-button-side {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.swipe-button-side:hover:not(:disabled) {
|
||||
background-color: rgba(102, 126, 234, 0.2); /* ✅ hover 时更明显 */
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
opacity: 1; /* ✅ hover 时完全不透明 */
|
||||
opacity: 1 !important; /* ✅ hover 时完全不透明 */
|
||||
}
|
||||
|
||||
.swipe-button:active:not(:disabled) {
|
||||
.swipe-button-side:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.swipe-button:disabled {
|
||||
opacity: 0.3;
|
||||
.swipe-button-side:disabled {
|
||||
opacity: 0.2 !important;
|
||||
cursor: not-allowed;
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.swipe-counter {
|
||||
font-size: 0.85rem;
|
||||
.swipe-counter-side {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 600;
|
||||
min-width: 50px;
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
padding: 2px 8px;
|
||||
padding: 2px 6px;
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
border-radius: var(--radius-sm);
|
||||
opacity: 0; /* ✅ 默认隐藏 */
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
/* ✅ hover 消息时显示页码 */
|
||||
.message:hover .swipe-counter-side {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ==================== Input Area - Fixed at Bottom ==================== */
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import useChatBoxStore from '../../../Store/Mid/ChatBoxSlice';
|
||||
import useChatBoxUIStore from '../../../Store/Mid/ChatBoxUISlice'; // ✅ 新增
|
||||
import { processTextForDisplay } from '../../../hooks/useRegexProcessor'; // ✅ 修改:导入普通函数
|
||||
import MarkdownRenderer from '../../shared/MarkdownRenderer';
|
||||
import './ChatBox.css';
|
||||
|
||||
@@ -61,6 +62,47 @@ const ChatBox = () => {
|
||||
cycleRenderMode // ✅ 新增:切换渲染模式
|
||||
} = useChatBoxStore();
|
||||
|
||||
// ✅ 新增:存储处理后的消息内容
|
||||
const [processedMessages, setProcessedMessages] = React.useState({});
|
||||
|
||||
// ✅ 当消息变化时,预处理正则规则
|
||||
React.useEffect(() => {
|
||||
const processMessages = async () => {
|
||||
const processed = {};
|
||||
|
||||
for (const message of messages) {
|
||||
if (!message.is_user && message.mes) {
|
||||
// 计算消息深度
|
||||
const messageDepth = messages.length > 0
|
||||
? messages[messages.length - 1].floor - message.floor
|
||||
: 0;
|
||||
|
||||
try {
|
||||
// 异步处理正则规则
|
||||
const displayText = await processTextForDisplay(
|
||||
message.mes,
|
||||
messageDepth,
|
||||
message.is_user
|
||||
);
|
||||
processed[message.floor] = displayText;
|
||||
} catch (error) {
|
||||
console.error('[ChatBox] 正则处理失败:', error);
|
||||
processed[message.floor] = message.mes;
|
||||
}
|
||||
} else {
|
||||
// 用户消息或空消息直接使用原文
|
||||
processed[message.floor] = message.mes;
|
||||
}
|
||||
}
|
||||
|
||||
setProcessedMessages(processed);
|
||||
};
|
||||
|
||||
if (messages.length > 0) {
|
||||
processMessages();
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
// 点击外部关闭选项面板
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
@@ -278,6 +320,10 @@ const ChatBox = () => {
|
||||
if (newIndex >= 0 && newIndex < message.swipes.length) {
|
||||
// ✅ 合并更新,保留其他楼层的 swipe 状态
|
||||
setCurrentSwipeId({ ...currentSwipeId, [messageId]: newIndex });
|
||||
} else if (direction === 1 && newIndex >= message.swipes.length) {
|
||||
// ✅ 当尝试切换到超出最后一个版本时,触发重roll(添加新swipe)
|
||||
console.log('[ChatBox] 右键点击最后一个版本,触发重roll');
|
||||
handleRerollMessage(message);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -385,6 +431,60 @@ const ChatBox = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ 分支功能:创建新的聊天记录
|
||||
const handleBranchChat = async (message) => {
|
||||
try {
|
||||
const { currentRole, currentChat, setChatBoxRoleAndChat } = useChatBoxStore.getState();
|
||||
|
||||
if (!currentRole || !currentChat) {
|
||||
alert('请先选择角色和聊天');
|
||||
closeContextMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`确定要从楼层 #${message.floor} 创建分支吗?\n这将复制该楼层及之前的所有内容到一个新的聊天记录。`)) {
|
||||
closeContextMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[ChatBox] 创建分支,目标楼层:', message.floor);
|
||||
|
||||
// 调用后端 API 创建分支
|
||||
const response = await fetch(
|
||||
`/api/chat/${encodeURIComponent(currentRole)}/${encodeURIComponent(currentChat)}/branch`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
target_floor: message.floor
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || '创建分支失败');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('[ChatBox] 分支创建成功:', result);
|
||||
|
||||
// 关闭菜单
|
||||
closeContextMenu();
|
||||
|
||||
// 切换到新的聊天记录
|
||||
setChatBoxRoleAndChat(currentRole, result.new_chat_name);
|
||||
|
||||
alert(`分支创建成功!\n新聊天: ${result.new_chat_name}\n消息数: ${result.message_count}`);
|
||||
|
||||
} catch (err) {
|
||||
console.error('[ChatBox] 创建分支失败:', err);
|
||||
alert('创建分支失败: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 切换选项显示
|
||||
const toggleOptionsPanel = () => {
|
||||
toggleOptions(); // ✅ 使用 store 方法
|
||||
@@ -538,12 +638,46 @@ const ChatBox = () => {
|
||||
// ✅ 使用 id 或组合 key 确保唯一性
|
||||
const uniqueKey = message.id || `${message.floor}-${index}`;
|
||||
|
||||
// ✅ 判断是否为最后一条消息(最后一楼)
|
||||
const isLastMessage = messages.length > 0 && message.floor === messages[messages.length - 1].floor;
|
||||
|
||||
// 计算消息深度(从最新消息开始计数)
|
||||
const messageDepth = messages.length > 0 ? messages[messages.length - 1].floor - message.floor : 0;
|
||||
|
||||
// ✅ 使用预处理后的消息内容(已应用 markdownOnly 正则)
|
||||
const displayMes = processedMessages[message.floor] || currentMes;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={uniqueKey}
|
||||
className={`message ${isUser ? 'user' : 'ai'}`}
|
||||
onContextMenu={(e) => handleContextMenu(e, message)} // ✅ 添加右键菜单
|
||||
>
|
||||
{/* ✅ Swipe 控制按钮 - 仅在最后一楼显示 */}
|
||||
{hasSwipes && !isUser && isLastMessage && (
|
||||
<div className="swipe-controls-absolute">
|
||||
<button
|
||||
className="swipe-button-side"
|
||||
onClick={() => handleSwipeChange(message.floor, -1)}
|
||||
disabled={currentSwipeIndex === 0}
|
||||
title="上一个版本"
|
||||
>
|
||||
◀
|
||||
</button>
|
||||
<span className="swipe-counter-side" title={`共 ${message.swipes.length} 个版本`}>
|
||||
{currentSwipeIndex + 1}/{message.swipes.length}
|
||||
</span>
|
||||
<button
|
||||
className="swipe-button-side"
|
||||
onClick={() => handleSwipeChange(message.floor, 1)}
|
||||
disabled={false} // ✅ 始终启用,最后一个版本时点击会触发重roll
|
||||
title={currentSwipeIndex === message.swipes.length - 1 ? "生成新版本(重roll)" : "下一个版本"}
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="message-container">
|
||||
<div className="message-header">
|
||||
<span className="message-name">{displayName}</span>
|
||||
@@ -556,7 +690,7 @@ const ChatBox = () => {
|
||||
<textarea
|
||||
className="edit-textarea"
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
onChange={(e) => updateEditContent(e.target.value)}
|
||||
/>
|
||||
<div className="edit-buttons">
|
||||
<button
|
||||
@@ -576,51 +710,24 @@ const ChatBox = () => {
|
||||
) : (
|
||||
<div className="bubble">
|
||||
{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>');
|
||||
})()
|
||||
}} />
|
||||
// ✅ HTML 渲染:只在检测到 HTML 标签时使用 dangerouslySetInnerHTML
|
||||
// ✅ 如果是纯文本,使用 plain-text 模式显示(保留原始格式)
|
||||
(() => {
|
||||
const hasHtmlTags = /<[a-z][\s\S]*>/i.test(displayMes);
|
||||
if (hasHtmlTags) {
|
||||
// 如果包含 HTML 标签,直接渲染
|
||||
return <div dangerouslySetInnerHTML={{ __html: displayMes }} />;
|
||||
} else {
|
||||
// 如果是纯文本,使用 pre-wrap 保留换行和空格
|
||||
return <div className="plain-text">{displayMes}</div>;
|
||||
}
|
||||
})()
|
||||
) : options.renderMode === 'markdown' ? (
|
||||
<MarkdownRenderer content={currentMes} />
|
||||
// ✅ 使用 displayMes(已应用 markdownOnly 正则)
|
||||
<MarkdownRenderer content={displayMes} />
|
||||
) : (
|
||||
<div className="plain-text">{currentMes}</div>
|
||||
)}
|
||||
{/* ✅ Swipe 控制按钮 - 所有有 swipe 的 AI 消息都显示 */}
|
||||
{hasSwipes && !isUser && (
|
||||
<div className="swipe-controls">
|
||||
<button
|
||||
className="swipe-button"
|
||||
onClick={() => handleSwipeChange(message.floor, -1)}
|
||||
disabled={currentSwipeIndex === 0}
|
||||
title="上一个版本"
|
||||
>
|
||||
◀
|
||||
</button>
|
||||
<span className="swipe-counter" title={`共 ${message.swipes.length} 个版本`}>
|
||||
{currentSwipeIndex + 1}/{message.swipes.length}
|
||||
</span>
|
||||
<button
|
||||
className="swipe-button"
|
||||
onClick={() => handleSwipeChange(message.floor, 1)}
|
||||
disabled={currentSwipeIndex === message.swipes.length - 1}
|
||||
title="下一个版本"
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
</div>
|
||||
// ✅ 纯文本模式
|
||||
<div className="plain-text">{displayMes}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -845,6 +952,11 @@ const ChatBox = () => {
|
||||
<span className="menu-label">重roll</span>
|
||||
</div>
|
||||
)}
|
||||
{/* ✅ 分支功能 - 所有消息都可以分支 */}
|
||||
<div className="context-menu-item" onClick={() => handleBranchChat(contextMenu.message)}>
|
||||
<span className="menu-icon">🌿</span>
|
||||
<span className="menu-label">分支</span>
|
||||
</div>
|
||||
<div className="context-menu-divider"></div>
|
||||
<div className="context-menu-item danger" onClick={() => handleDeleteMessage(contextMenu.message)}>
|
||||
<span className="menu-icon">🗑️</span>
|
||||
|
||||
@@ -718,7 +718,7 @@ const CharacterCard = () => {
|
||||
>
|
||||
<option key="none" value="">不绑定</option>
|
||||
{worldBooks.map(book => (
|
||||
<option key={book.id} value={book.id}>
|
||||
<option key={`worldbook-${book.id}`} value={book.id}>
|
||||
{book.name}
|
||||
</option>
|
||||
))}
|
||||
@@ -733,9 +733,9 @@ const CharacterCard = () => {
|
||||
value={editForm.historyMode || 'full'}
|
||||
onChange={(e) => handleFormChange('historyMode', e.target.value)}
|
||||
>
|
||||
<option value="full">全量模式(保留所有消息)</option>
|
||||
<option value="summary">总结模式(定期总结历史)</option>
|
||||
<option value="rag" disabled={editForm.historyMode === 'rag'}>
|
||||
<option key="full" value="full">全量模式(保留所有消息)</option>
|
||||
<option key="summary" value="summary">总结模式(定期总结历史)</option>
|
||||
<option key="rag" value="rag" disabled={editForm.historyMode === 'rag'}>
|
||||
RAG模式(需要API配置,选择后不可取消)
|
||||
</option>
|
||||
</select>
|
||||
|
||||
@@ -241,12 +241,12 @@
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--color-bg-elevated);
|
||||
border-radius: 6px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 1000;
|
||||
min-width: 450px;
|
||||
max-width: 80vw;
|
||||
max-height: 80vh;
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow-lg), 0 0 0 1px rgba(0, 0, 0, 0.1);
|
||||
z-index: var(--z-modal-content); /* ✅ 弹窗层 - 确保在最高层 */
|
||||
min-width: 900px;
|
||||
max-width: calc(100vw - 60px); /* 左右各留30px边距 */
|
||||
max-height: calc(100vh - 100px); /* 上下留出空间,避免遮挡顶部栏 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -286,12 +286,12 @@
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
padding: 16px;
|
||||
padding: 24px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* ✅ 角色选择器 */
|
||||
@@ -336,14 +336,15 @@
|
||||
|
||||
.component-textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
padding: 14px;
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
resize: none;
|
||||
line-height: 1.7;
|
||||
resize: vertical;
|
||||
min-height: 500px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ const PresetPanel = () => {
|
||||
|
||||
// 组件编辑状态
|
||||
const [editingComponentIndex, setEditingComponentIndex] = useState(-1);
|
||||
const [editComponentName, setEditComponentName] = useState(''); // ✅ 新增:组件名称
|
||||
const [editComponentContent, setEditComponentContent] = useState('');
|
||||
const [editComponentRole, setEditComponentRole] = useState(0); // 0=system, 1=user, 2=ai
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
@@ -226,7 +227,7 @@ const PresetPanel = () => {
|
||||
throw new Error('Failed to update preset');
|
||||
}
|
||||
|
||||
alert('预设已保存!');
|
||||
// ✅ 静默保存,不显示成功提示
|
||||
} catch (error) {
|
||||
console.error('保存预设失败:', error);
|
||||
alert('保存预设失败: ' + error.message);
|
||||
@@ -283,15 +284,37 @@ const PresetPanel = () => {
|
||||
const handleEditPreset = async () => {
|
||||
if (editPresetId && editPresetName.trim()) {
|
||||
try {
|
||||
await updatePresetName(editPresetId, editPresetName);
|
||||
// ✅ 使用专门的重命名 API
|
||||
const response = await fetch(`/api/presets/${editPresetId}/rename`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ newName: editPresetName })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || 'Failed to rename preset');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
setEditPresetId('');
|
||||
setEditPresetName('');
|
||||
setShowEditDialog(false);
|
||||
|
||||
// ✅ 如果重命名后当前选中的预设 ID 变了,需要更新选中状态
|
||||
if (selectedPreset === editPresetId && result.newName) {
|
||||
setSelectedPreset(result.newName);
|
||||
}
|
||||
|
||||
// 重新加载预设列表
|
||||
setCurrentPage(1);
|
||||
fetchPresets();
|
||||
} catch (error) {
|
||||
console.error('编辑预设名称失败:', error);
|
||||
alert('编辑预设名称失败: ' + error.message);
|
||||
console.error('重命名预设失败:', error);
|
||||
alert('重命名预设失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -609,7 +632,9 @@ const PresetPanel = () => {
|
||||
// 开始编辑组件
|
||||
const handleStartEditComponent = (index) => {
|
||||
setEditingComponentIndex(index);
|
||||
setEditComponentName(promptComponents[index].name); // ✅ 初始化组件名称
|
||||
setEditComponentContent(promptComponents[index].content);
|
||||
setEditComponentRole(promptComponents[index].role || 0);
|
||||
setIsEditing(true);
|
||||
setShowComponentEditDialog(true);
|
||||
};
|
||||
@@ -631,16 +656,74 @@ const PresetPanel = () => {
|
||||
}
|
||||
|
||||
setEditingComponentIndex(index);
|
||||
setEditComponentName(component.name); // ✅ 初始化组件名称
|
||||
setEditComponentRole(component.role || 0);
|
||||
setIsEditing(!component.marker); // 固定组件不可编辑
|
||||
setShowComponentEditDialog(true);
|
||||
};
|
||||
|
||||
// 保存组件编辑
|
||||
const handleSaveComponentEdit = () => {
|
||||
const handleSaveComponentEdit = async () => {
|
||||
if (editingComponentIndex >= 0 && isEditing) {
|
||||
updateComponent(editingComponentIndex, { content: editComponentContent });
|
||||
// ✅ 先更新前端状态(包括名称和内容)
|
||||
updateComponent(editingComponentIndex, {
|
||||
name: editComponentName,
|
||||
content: editComponentContent,
|
||||
role: editComponentRole
|
||||
});
|
||||
|
||||
// ✅ 然后保存到后端
|
||||
if (selectedPreset) {
|
||||
try {
|
||||
// 构建预设数据
|
||||
const presetData = {
|
||||
name: selectedPreset,
|
||||
temperature: parameters.temperature,
|
||||
frequency_penalty: parameters.frequency_penalty,
|
||||
presence_penalty: parameters.presence_penalty,
|
||||
top_p: parameters.top_p,
|
||||
top_k: parameters.top_k,
|
||||
max_tokens: parameters.max_tokens,
|
||||
request_timeout: parameters.request_timeout || 60,
|
||||
|
||||
prompts: promptComponents.map((component, idx) => ({
|
||||
identifier: component.identifier,
|
||||
name: idx === editingComponentIndex ? editComponentName : component.name,
|
||||
content: idx === editingComponentIndex ? editComponentContent : (component.content || ''),
|
||||
system_prompt: component.role === 0,
|
||||
role: idx === editingComponentIndex ? editComponentRole : component.role,
|
||||
enabled: component.enabled !== false
|
||||
})),
|
||||
|
||||
prompt_order: [{
|
||||
character_id: 'global',
|
||||
order: promptComponents.map(component => ({
|
||||
identifier: component.identifier,
|
||||
enabled: component.enabled !== false
|
||||
}))
|
||||
}]
|
||||
};
|
||||
|
||||
// 发送到后端更新
|
||||
const response = await fetch(`/api/presets/${selectedPreset}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(presetData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save preset');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存预设失败:', error);
|
||||
alert('保存预设失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
setEditingComponentIndex(-1);
|
||||
setEditComponentName('');
|
||||
setEditComponentContent('');
|
||||
setShowComponentEditDialog(false);
|
||||
};
|
||||
@@ -648,6 +731,7 @@ const PresetPanel = () => {
|
||||
// 取消组件编辑
|
||||
const handleCancelComponentEdit = () => {
|
||||
setEditingComponentIndex(-1);
|
||||
setEditComponentName('');
|
||||
setEditComponentContent('');
|
||||
setShowComponentEditDialog(false);
|
||||
};
|
||||
@@ -655,6 +739,7 @@ const PresetPanel = () => {
|
||||
// 关闭组件查看对话框
|
||||
const handleCloseComponentView = () => {
|
||||
setEditingComponentIndex(-1);
|
||||
setEditComponentName('');
|
||||
setEditComponentContent('');
|
||||
setShowComponentEditDialog(false);
|
||||
};
|
||||
@@ -785,6 +870,22 @@ const PresetPanel = () => {
|
||||
>
|
||||
💾 保存当前预设
|
||||
</button>
|
||||
<button
|
||||
className="dropdown-item"
|
||||
disabled={!selectedPreset}
|
||||
title="重命名当前选中的预设"
|
||||
onClick={() => {
|
||||
if (selectedPreset) {
|
||||
const preset = presets.find(p => p.id === selectedPreset);
|
||||
setEditPresetId(selectedPreset);
|
||||
setEditPresetName(preset?.name || '');
|
||||
setShowEditDialog(true);
|
||||
}
|
||||
setShowActionsMenu(false);
|
||||
}}
|
||||
>
|
||||
✏️ 重命名
|
||||
</button>
|
||||
<button
|
||||
className="dropdown-item"
|
||||
title="从 JSON 文件导入预设配置"
|
||||
@@ -888,8 +989,31 @@ const PresetPanel = () => {
|
||||
<button className="close-btn" onClick={handleCloseComponentView}>×</button>
|
||||
</div>
|
||||
<div className="dialog-content">
|
||||
{/* ✅ 角色选择器 */}
|
||||
{isEditing && (
|
||||
{/* ✅ 检查是否是固有组件(marker=true) */}
|
||||
{editingComponentIndex >= 0 && promptComponents[editingComponentIndex].marker && (
|
||||
<div className="builtin-component-notice">
|
||||
<p>⚠️ 这是 SillyTavern 的固有组件,不可编辑。</p>
|
||||
<p>📌 <strong>数据来源:</strong>{promptComponents[editingComponentIndex].dataSource || '系统自动获取'}</p>
|
||||
<p>💡 该组件会在发送消息时从对应的数据源自动提取最新内容。</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ✅ 组件名称输入框 - 固有组件禁用 */}
|
||||
{isEditing && !promptComponents[editingComponentIndex]?.marker && (
|
||||
<div className="component-name-input">
|
||||
<label className="name-label">组件名称:</label>
|
||||
<input
|
||||
type="text"
|
||||
className="name-input"
|
||||
value={editComponentName}
|
||||
onChange={(e) => setEditComponentName(e.target.value)}
|
||||
placeholder="输入组件名称"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ✅ 角色选择器 - 固有组件禁用 */}
|
||||
{isEditing && !promptComponents[editingComponentIndex]?.marker && (
|
||||
<div className="component-role-selector">
|
||||
<label className="role-label">角色:</label>
|
||||
<select
|
||||
@@ -904,11 +1028,12 @@ const PresetPanel = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ✅ 内容文本域 - 固有组件只读 */}
|
||||
<textarea
|
||||
value={editComponentContent}
|
||||
onChange={(e) => setEditComponentContent(e.target.value)}
|
||||
className="component-textarea"
|
||||
readOnly={!isEditing}
|
||||
readOnly={!isEditing || promptComponents[editingComponentIndex]?.marker}
|
||||
rows={20}
|
||||
/>
|
||||
</div>
|
||||
@@ -916,7 +1041,7 @@ const PresetPanel = () => {
|
||||
<span className="token-count">
|
||||
{editComponentContent ? editComponentContent.length : 0}
|
||||
</span>
|
||||
{isEditing && (
|
||||
{isEditing && !promptComponents[editingComponentIndex]?.marker && (
|
||||
<div className="dialog-buttons">
|
||||
<button onClick={handleSaveComponentEdit}>保存</button>
|
||||
<button onClick={handleCancelComponentEdit}>取消</button>
|
||||
|
||||
@@ -448,6 +448,12 @@
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 紧凑选项布局 */
|
||||
.form-row.compact-options {
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.form-section h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
@@ -461,6 +467,13 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 紧凑的 checkbox 组 - 使用网格布局 */
|
||||
.checkbox-group.compact {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 6px 12px;
|
||||
}
|
||||
|
||||
.checkbox-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -468,6 +481,12 @@
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.checkbox-group.compact .checkbox-item {
|
||||
padding: 2px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.checkbox-item input[type="checkbox"] {
|
||||
|
||||
@@ -26,7 +26,7 @@ const RegexPanel = () => {
|
||||
findRegex: '',
|
||||
replaceString: '',
|
||||
trimStrings: [],
|
||||
placement: [1], // 默认 AI输出 (注意:0=System, 1=AI Output, 2=User Input)
|
||||
placement: [2], // 默认 AI输出 (SillyTavern 标准:0=System, 1=User Input, 2=AI Output)
|
||||
disabled: false,
|
||||
markdownOnly: false,
|
||||
promptOnly: false,
|
||||
@@ -36,14 +36,14 @@ const RegexPanel = () => {
|
||||
maxDepth: null
|
||||
});
|
||||
|
||||
// 作用范围选项(修正顺序)
|
||||
// 作用范围选项(SillyTavern 官方标准)
|
||||
const placementOptions = [
|
||||
{ value: 0, label: '系统提示词' },
|
||||
{ value: 1, label: 'AI输出' },
|
||||
{ value: 2, label: '用户输入' },
|
||||
{ value: 3, label: '快捷命令' },
|
||||
{ value: 4, label: '世界信息' },
|
||||
{ value: 5, label: '推理' }
|
||||
{ value: 0, label: '系统提示词' }, // System Prompt
|
||||
{ value: 1, label: '用户输入' }, // User Input
|
||||
{ value: 2, label: 'AI输出' }, // AI Output
|
||||
{ value: 3, label: '快捷命令' }, // Quick Reply
|
||||
{ value: 4, label: '世界信息' }, // World Info
|
||||
{ value: 5, label: '推理' } // Reasoning/Thinking
|
||||
];
|
||||
|
||||
// 替换模式选项
|
||||
@@ -115,7 +115,7 @@ const RegexPanel = () => {
|
||||
findRegex: '',
|
||||
replaceString: '',
|
||||
trimStrings: [],
|
||||
placement: [1],
|
||||
placement: [2], // 默认 AI Output
|
||||
disabled: false,
|
||||
markdownOnly: false,
|
||||
promptOnly: false,
|
||||
@@ -160,7 +160,6 @@ const RegexPanel = () => {
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
alert('规则保存成功!');
|
||||
setShowEditor(false);
|
||||
if (scope === 'preset') {
|
||||
fetchPresetRulesets(selectedPreset);
|
||||
@@ -334,11 +333,11 @@ const RegexPanel = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 作用范围和其他选项 */}
|
||||
<div className="form-row">
|
||||
{/* 作用范围和其他选项 - 紧凑布局 */}
|
||||
<div className="form-row compact-options">
|
||||
<div className="form-section">
|
||||
<h4>作用范围</h4>
|
||||
<div className="checkbox-group">
|
||||
<div className="checkbox-group compact">
|
||||
{placementOptions.map(opt => (
|
||||
<label key={opt.value} className="checkbox-item">
|
||||
<input
|
||||
@@ -354,8 +353,8 @@ const RegexPanel = () => {
|
||||
|
||||
<div className="form-section">
|
||||
<h4>其他选项</h4>
|
||||
<div className="checkbox-group">
|
||||
<label className="checkbox-item">
|
||||
<div className="checkbox-group compact">
|
||||
<label className="checkbox-item" title="禁用此规则,使其不生效">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.disabled}
|
||||
@@ -363,29 +362,29 @@ const RegexPanel = () => {
|
||||
/>
|
||||
<span>已禁用</span>
|
||||
</label>
|
||||
<label className="checkbox-item">
|
||||
<label className="checkbox-item" title="用户编辑消息时也重新应用此规则">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.runOnEdit}
|
||||
onChange={(e) => updateFormField('runOnEdit', e.target.checked)}
|
||||
/>
|
||||
<span>在编辑时运行</span>
|
||||
<span>编辑时运行</span>
|
||||
</label>
|
||||
<label className="checkbox-item">
|
||||
<label className="checkbox-item" title="勾选后:只在前端显示时应用正则,不修改存储的原始数据">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.markdownOnly}
|
||||
onChange={(e) => updateFormField('markdownOnly', e.target.checked)}
|
||||
/>
|
||||
<span>仅格式显示</span>
|
||||
<span>仅影响显示</span>
|
||||
</label>
|
||||
<label className="checkbox-item">
|
||||
<label className="checkbox-item" title="勾选后:只在发送给LLM时应用正则,不修改存储的原始数据">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.promptOnly}
|
||||
onChange={(e) => updateFormField('promptOnly', e.target.checked)}
|
||||
/>
|
||||
<span>仅格式提示词</span>
|
||||
<span>仅影响提示词</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -480,9 +479,9 @@ const RegexPanel = () => {
|
||||
<div className="rule-info">
|
||||
<span className="rule-name">{rule.scriptName}</span>
|
||||
<div className="rule-badges">
|
||||
{rule.disabled && <span className="rule-badge disabled">已禁用</span>}
|
||||
{rule.promptOnly && <span className="rule-badge prompt">仅提示词</span>}
|
||||
{rule.markdownOnly && <span className="rule-badge markdown">仅格式</span>}
|
||||
{rule.disabled && <span className="rule-badge disabled" title="此规则已禁用">已禁用</span>}
|
||||
{rule.promptOnly && <span className="rule-badge prompt" title="不影响显示给用户的内容">不影响显示</span>}
|
||||
{rule.markdownOnly && <span className="rule-badge markdown" title="不影响发送给 AI 的内容">不影响发送</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rule-actions">
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
/* Tavern Helper 样式 */
|
||||
|
||||
.tavern-helper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background-color: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* 头部 */
|
||||
.tavern-helper-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.tavern-helper-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-new {
|
||||
background: linear-gradient(135deg, #ff6b6b, #ee5a6f);
|
||||
color: white;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.version {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.btn-extension-info {
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-extension-info:hover {
|
||||
background-color: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 标签页导航 */
|
||||
.tavern-helper-tabs {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
background-color: var(--color-accent);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.tab-label {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* 内容区域 */
|
||||
.tavern-helper-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* 设置项通用样式 */
|
||||
.setting-group {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.setting-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.setting-header:hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.setting-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.setting-info h4 {
|
||||
margin: 0 0 var(--spacing-xs) 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.setting-desc {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 开关样式 */
|
||||
.toggle-label {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle-label input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 24px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
left: 3px;
|
||||
bottom: 2px;
|
||||
background-color: var(--color-text-muted);
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle-label input:checked + .toggle-slider {
|
||||
background-color: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.toggle-label input:checked + .toggle-slider:before {
|
||||
transform: translateX(20px);
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
/* 设置区块 */
|
||||
.setting-section {
|
||||
margin-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.setting-section.experimental {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.setting-section.experimental .section-title::before {
|
||||
content: "⚠️ ";
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
margin-bottom: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
background-color: var(--color-bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.setting-item label {
|
||||
display: block;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.setting-control {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.setting-control input[type="number"] {
|
||||
width: 100px;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
background-color: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* 单选按钮组 */
|
||||
.radio-group {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.radio-btn {
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
background-color: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.radio-btn:hover {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.radio-btn.active {
|
||||
background-color: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 脚本标签页 */
|
||||
.scripts-toolbar {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.btn-tool {
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
background-color: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-tool:hover {
|
||||
background-color: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 搜索框 */
|
||||
.search-box {
|
||||
position: relative;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
padding-right: 40px;
|
||||
background-color: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.search-box input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cpath d='m21 21-4.35-4.35'/%3E%3C/svg%3E");
|
||||
background-size: contain;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* 脚本区块 */
|
||||
.script-section {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
padding-bottom: var(--spacing-lg);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.script-section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.section-header h4 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section-desc {
|
||||
margin: 0 0 var(--spacing-md) 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* 脚本列表 */
|
||||
.script-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.script-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background-color: var(--color-bg-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.script-item:hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
opacity: 0.5;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.drag-handle:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.script-name {
|
||||
flex: 1;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.script-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background-color: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.btn-icon.danger:hover {
|
||||
background-color: rgba(255, 77, 77, 0.2);
|
||||
color: #ff4d4d;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-xl);
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
margin: 0 0 var(--spacing-sm) 0;
|
||||
font-size: 1.1rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* 滚动条美化 */
|
||||
.tavern-helper-content::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.tavern-helper-content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tavern-helper-content::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tavern-helper-content::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
import React, { useState } from 'react';
|
||||
import './TavernHelper.css';
|
||||
|
||||
/**
|
||||
* 酒馆助手组件(Tavern Helper)
|
||||
*
|
||||
* 模仿 SillyTavern 的酒馆助手插件,提供:
|
||||
* - 渲染标签页:控制代码块渲染
|
||||
* - 脚本标签页:管理 JavaScript 脚本
|
||||
* - 工具标签页:实用工具
|
||||
* - 优化标签页:性能优化选项
|
||||
* - 开发标签页:开发者工具
|
||||
*/
|
||||
const TavernHelper = () => {
|
||||
const [activeTab, setActiveTab] = useState('render');
|
||||
|
||||
// 渲染设置
|
||||
const [renderSettings, setRenderSettings] = useState({
|
||||
enabled: true,
|
||||
renderDepth: 0,
|
||||
codeFoldEnabled: true,
|
||||
codeFoldMode: 'frontend-only', // all, frontend-only, disabled
|
||||
blobUrlRender: false,
|
||||
disableCodeHighlight: true,
|
||||
streamRender: true,
|
||||
});
|
||||
|
||||
// 脚本状态
|
||||
const [globalScriptsEnabled, setGlobalScriptsEnabled] = useState(false);
|
||||
const [characterScriptsEnabled, setCharacterScriptsEnabled] = useState(false);
|
||||
const [presetScriptsEnabled, setPresetScriptsEnabled] = useState(true);
|
||||
|
||||
const tabs = [
|
||||
{ id: 'render', label: '渲染', icon: '✎' },
|
||||
{ id: 'scripts', label: '脚本', icon: '' },
|
||||
{ id: 'tools', label: '工具', icon: '' },
|
||||
{ id: 'optimization', label: '优化', icon: '⚡' },
|
||||
{ id: 'dev', label: '开发', icon: '' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="tavern-helper">
|
||||
<div className="tavern-helper-header">
|
||||
<h3>酒馆助手 <span className="badge-new">New!</span></h3>
|
||||
<span className="version">Ver 4.8.4</span>
|
||||
<button className="btn-extension-info">扩展信息</button>
|
||||
</div>
|
||||
|
||||
{/* 标签页导航 */}
|
||||
<div className="tavern-helper-tabs">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`tab-button ${activeTab === tab.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
<span className="tab-icon">{tab.icon}</span>
|
||||
<span className="tab-label">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 标签页内容 */}
|
||||
<div className="tavern-helper-content">
|
||||
{activeTab === 'render' && <RenderTab settings={renderSettings} onChange={setRenderSettings} />}
|
||||
{activeTab === 'scripts' && (
|
||||
<ScriptsTab
|
||||
globalEnabled={globalScriptsEnabled}
|
||||
characterEnabled={characterScriptsEnabled}
|
||||
presetEnabled={presetScriptsEnabled}
|
||||
onGlobalToggle={setGlobalScriptsEnabled}
|
||||
onCharacterToggle={setCharacterScriptsEnabled}
|
||||
onPresetToggle={setPresetScriptsEnabled}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'tools' && <ToolsTab />}
|
||||
{activeTab === 'optimization' && <OptimizationTab />}
|
||||
{activeTab === 'dev' && <DevTab />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染标签页
|
||||
*/
|
||||
const RenderTab = ({ settings, onChange }) => {
|
||||
return (
|
||||
<div className="tab-content render-tab">
|
||||
{/* 启用渲染器 */}
|
||||
<div className="setting-group">
|
||||
<div className="setting-header">
|
||||
<label className="toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.enabled}
|
||||
onChange={(e) => onChange({ ...settings, enabled: e.target.checked })}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
<div className="setting-info">
|
||||
<h4>启用渲染器</h4>
|
||||
<p className="setting-desc">启用后,符合条件的代码块将被渲染</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 渲染优化 */}
|
||||
<div className="setting-section">
|
||||
<h5 className="section-title">⚡ 渲染优化</h5>
|
||||
|
||||
<div className="setting-item">
|
||||
<label>渲染深度</label>
|
||||
<div className="setting-control">
|
||||
<input
|
||||
type="number"
|
||||
value={settings.renderDepth}
|
||||
onChange={(e) => onChange({ ...settings, renderDepth: parseInt(e.target.value) })}
|
||||
min="0"
|
||||
/>
|
||||
<p className="field-hint">设置需要渲染的楼层数,从最新楼层开始计数。为 0 时,将渲染所有楼层</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-item">
|
||||
<label>启用代码折叠</label>
|
||||
<div className="setting-control">
|
||||
<div className="radio-group">
|
||||
<button
|
||||
className={`radio-btn ${settings.codeFoldMode === 'all' ? 'active' : ''}`}
|
||||
onClick={() => onChange({ ...settings, codeFoldMode: 'all' })}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
<button
|
||||
className={`radio-btn ${settings.codeFoldMode === 'frontend-only' ? 'active' : ''}`}
|
||||
onClick={() => onChange({ ...settings, codeFoldMode: 'frontend-only' })}
|
||||
>
|
||||
仅前端
|
||||
</button>
|
||||
<button
|
||||
className={`radio-btn ${settings.codeFoldMode === 'disabled' ? 'active' : ''}`}
|
||||
onClick={() => onChange({ ...settings, codeFoldMode: 'disabled' })}
|
||||
>
|
||||
禁用
|
||||
</button>
|
||||
</div>
|
||||
<p className="field-hint">折叠指定类型的代码块,当选择"仅前端"时,将只折叠可渲染成前端界面但没被渲染的代码块</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-header">
|
||||
<label className="toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.blobUrlRender}
|
||||
onChange={(e) => onChange({ ...settings, blobUrlRender: e.target.checked })}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
<div className="setting-info">
|
||||
<h4>启用 Blob URL 渲染</h4>
|
||||
<p className="setting-desc">使用 Blob URL 渲染前端界面,更方便 f12 开发者工具调试;但某些浏览器可能不支持</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-header">
|
||||
<label className="toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.disableCodeHighlight}
|
||||
onChange={(e) => onChange({ ...settings, disableCodeHighlight: e.target.checked })}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
<div className="setting-info">
|
||||
<h4>取消前端代码高亮</h4>
|
||||
<p className="setting-desc">避免酒馆对可渲染成前端界面的代码块进行语法高亮,从而提升渲染性能</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 实验功能 */}
|
||||
<div className="setting-section experimental">
|
||||
<h5 className="section-title"> 实验功能</h5>
|
||||
|
||||
<div className="setting-header">
|
||||
<label className="toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.streamRender}
|
||||
onChange={(e) => onChange({ ...settings, streamRender: e.target.checked })}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
<div className="setting-info">
|
||||
<h4>允许流式渲染</h4>
|
||||
<p className="setting-desc">在AI流式输出时就渲染,某些前端界面可能无法这样渲染。此外,这可能与某些脚本、插件、酒馆美化不兼容</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 脚本标签页
|
||||
*/
|
||||
const ScriptsTab = ({
|
||||
globalEnabled,
|
||||
characterEnabled,
|
||||
presetEnabled,
|
||||
onGlobalToggle,
|
||||
onCharacterToggle,
|
||||
onPresetToggle,
|
||||
}) => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
return (
|
||||
<div className="tab-content scripts-tab">
|
||||
{/* 工具栏 */}
|
||||
<div className="scripts-toolbar">
|
||||
<button className="btn-tool">➕ 脚本</button>
|
||||
<button className="btn-tool">📁 文件夹</button>
|
||||
<button className="btn-tool">📥 导入</button>
|
||||
<button className="btn-tool">📦 内置库</button>
|
||||
</div>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<div className="search-box">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索(支持普通和/正则/)"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<span className="search-icon"></span>
|
||||
</div>
|
||||
|
||||
{/* 全局脚本 */}
|
||||
<div className="script-section">
|
||||
<div className="section-header">
|
||||
<h4>全局脚本</h4>
|
||||
<label className="toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={globalEnabled}
|
||||
onChange={(e) => onGlobalToggle(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<p className="section-desc">酒馆全局可用</p>
|
||||
|
||||
<div className="script-list">
|
||||
<div className="script-item">
|
||||
<span className="drag-handle">☰</span>
|
||||
<span className="script-name">【骰子系统】-自动更新</span>
|
||||
<div className="script-actions">
|
||||
<button className="btn-icon" title="启用/禁用"></button>
|
||||
<button className="btn-icon" title="信息">ℹ️</button>
|
||||
<button className="btn-icon" title="编辑">✏️</button>
|
||||
<button className="btn-icon" title="更多">⋯</button>
|
||||
<button className="btn-icon danger" title="删除">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="script-item">
|
||||
<span className="drag-handle">☰</span>
|
||||
<span className="script-name">蚀心入魔·数据库</span>
|
||||
<div className="script-actions">
|
||||
<button className="btn-icon" title="启用/禁用">🔘</button>
|
||||
<button className="btn-icon" title="信息">ℹ️</button>
|
||||
<button className="btn-icon" title="编辑">✏️</button>
|
||||
<button className="btn-icon" title="更多">⋯</button>
|
||||
<button className="btn-icon danger" title="删除">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 角色脚本 */}
|
||||
<div className="script-section">
|
||||
<div className="section-header">
|
||||
<h4>角色脚本</h4>
|
||||
<label className="toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={characterEnabled}
|
||||
onChange={(e) => onCharacterToggle(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<p className="section-desc">绑定到当前角色卡</p>
|
||||
|
||||
<div className="empty-state">
|
||||
<p>暂无脚本本</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 预设脚本 */}
|
||||
<div className="script-section">
|
||||
<div className="section-header">
|
||||
<h4>预设脚本</h4>
|
||||
<label className="toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={presetEnabled}
|
||||
onChange={(e) => onPresetToggle(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<p className="section-desc">绑定到当前预设</p>
|
||||
|
||||
<div className="script-list">
|
||||
<div className="script-item">
|
||||
<span className="drag-handle">☰</span>
|
||||
<span className="script-name">import-mvu</span>
|
||||
<div className="script-actions">
|
||||
<button className="btn-icon" title="启用/禁用">🔘</button>
|
||||
<button className="btn-icon" title="信息">ℹ️</button>
|
||||
<button className="btn-icon" title="编辑">✏️</button>
|
||||
<button className="btn-icon" title="更多"></button>
|
||||
<button className="btn-icon danger" title="删除">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="script-item">
|
||||
<span className="drag-handle">☰</span>
|
||||
<span className="script-name">月经周期计算器</span>
|
||||
<div className="script-actions">
|
||||
<button className="btn-icon" title="启用/禁用">🔘</button>
|
||||
<button className="btn-icon" title="信息">ℹ️</button>
|
||||
<button className="btn-icon" title="编辑">✏️</button>
|
||||
<button className="btn-icon" title="更多"></button>
|
||||
<button className="btn-icon danger" title="删除">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 工具标签页(占位)
|
||||
*/
|
||||
const ToolsTab = () => {
|
||||
return (
|
||||
<div className="tab-content tools-tab">
|
||||
<div className="empty-state">
|
||||
<h3>🔧 工具</h3>
|
||||
<p>实用工具即将推出...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 优化标签页(占位)
|
||||
*/
|
||||
const OptimizationTab = () => {
|
||||
return (
|
||||
<div className="tab-content optimization-tab">
|
||||
<div className="empty-state">
|
||||
<h3> 优化</h3>
|
||||
<p>性能优化选项即将推出...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 开发标签页(占位)
|
||||
*/
|
||||
const DevTab = () => {
|
||||
return (
|
||||
<div className="tab-content dev-tab">
|
||||
<div className="empty-state">
|
||||
<h3>🛠 开发</h3>
|
||||
<p>开发者工具即将推出...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TavernHelper;
|
||||
@@ -20,8 +20,8 @@ const Toolbar = () => {
|
||||
setColorTheme
|
||||
} = useAppLayoutStore();
|
||||
|
||||
// ✅ 从 UserStore 获取用户角色
|
||||
const { currentUserRole } = useUserStore();
|
||||
// ✅ 从 UserStore 获取用户角色和方法
|
||||
const { currentUserRole, userRoles, updateRoleName, updateRoleDescription, selectUserRole, addUserRole } = useUserStore();
|
||||
|
||||
// 从 Store 获取全局世界书
|
||||
const { globalWorldBooks } = useWorldBookStore();
|
||||
@@ -213,6 +213,57 @@ const Toolbar = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ✅ 用户角色设置面板 */}
|
||||
{activePanel === 'currentRole' && (
|
||||
<div className="panel-overlay" ref={panelRef}>
|
||||
<div className="panel-content">
|
||||
<div className="panel-header">
|
||||
<h3>👤 用户角色</h3>
|
||||
<button className="close-panel-button" onClick={handleClosePanel} title="关闭">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
<div className="settings-section">
|
||||
<h4>选择角色</h4>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{userRoles.map((role) => (
|
||||
<div
|
||||
key={role.id}
|
||||
onClick={() => selectUserRole(role.id)}
|
||||
style={{
|
||||
padding: '12px',
|
||||
borderRadius: '6px',
|
||||
border: currentUserRole.name === role.name ? '2px solid var(--color-primary)' : '1px solid var(--border-color)',
|
||||
background: currentUserRole.name === role.name ? 'rgba(var(--color-primary-rgb), 0.1)' : 'var(--color-bg-secondary)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: '600', marginBottom: '4px', color: 'var(--color-text-primary)' }}>
|
||||
{role.name}
|
||||
</div>
|
||||
<div style={{ fontSize: '13px', color: 'var(--color-text-secondary)' }}>
|
||||
{role.description || '无描述'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="setting-section" style={{ marginTop: '20px', paddingTop: '20px', borderTop: '1px solid var(--border-color)' }}>
|
||||
<h4>💡 说明</h4>
|
||||
<ul style={{ margin: 0, paddingLeft: '20px', color: 'var(--color-text-secondary)', fontSize: '13px', lineHeight: '1.6' }}>
|
||||
<li>点击角色即可切换当前使用的用户角色</li>
|
||||
<li>角色描述会自动用于预设的 <code>personaDescription</code> 组件</li>
|
||||
<li>设置会自动保存到浏览器本地存储</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 全局世界书面板 - 使用条件渲染优化 */}
|
||||
{activePanel === 'worldBook' && (
|
||||
<div className="panel-overlay" ref={panelRef}>
|
||||
|
||||
170
frontend/src/hooks/useRegexProcessor.js
Normal file
170
frontend/src/hooks/useRegexProcessor.js
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* 正则处理工具
|
||||
*
|
||||
* 在前端渲染消息时应用 markdownOnly 正则规则
|
||||
* markdownOnly=true 的规则只影响显示,不影响存储的数据
|
||||
*/
|
||||
import usePresetStore from '../Store/SideBarLeft/PresetSlice';
|
||||
|
||||
// 全局缓存正则规则
|
||||
let cachedRules = [];
|
||||
let lastFetchTime = 0;
|
||||
const CACHE_DURATION = 5 * 60 * 1000; // 5分钟缓存
|
||||
|
||||
/**
|
||||
* 获取正则规则(带缓存)
|
||||
*/
|
||||
async function fetchRules() {
|
||||
const now = Date.now();
|
||||
|
||||
// 如果缓存未过期,直接返回
|
||||
if (cachedRules.length > 0 && (now - lastFetchTime) < CACHE_DURATION) {
|
||||
return cachedRules;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/regex/rules');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.rules) {
|
||||
cachedRules = data.rules;
|
||||
lastFetchTime = now;
|
||||
return data.rules;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Regex Processor] 加载正则规则失败:', error);
|
||||
}
|
||||
|
||||
return cachedRules;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用正则规则到文本(用于前端显示)
|
||||
*
|
||||
* @param {string} text - 原始文本
|
||||
* @param {Array} rules - 所有正则规则
|
||||
* @param {number} messageDepth - 消息深度
|
||||
* @returns {string} - 处理后的文本(仅用于显示)
|
||||
*/
|
||||
function applyRulesForDisplay(text, rules, messageDepth = 0) {
|
||||
if (!text || !rules || rules.length === 0) {
|
||||
return text;
|
||||
}
|
||||
|
||||
let result = text;
|
||||
|
||||
// 筛选出适用于前端显示的规则
|
||||
const displayRules = rules.filter(rule => {
|
||||
// 检查是否禁用了
|
||||
if (rule.disabled) return false;
|
||||
|
||||
// 检查 placement 是否包含 AI_OUTPUT (2)
|
||||
const placements = rule.placement || [];
|
||||
if (!placements.includes(2)) return false;
|
||||
|
||||
// ✅ SillyTavern 逻辑:
|
||||
// - 双 false:应用到所有场景(包括显示)
|
||||
// - markdownOnly=true:只应用于显示
|
||||
// - promptOnly=true:不应用于显示
|
||||
// - 双 true:应用到所有场景(包括显示)
|
||||
if (rule.promptOnly && !rule.markdownOnly) {
|
||||
// 只影响提示词,不影响显示
|
||||
return false;
|
||||
}
|
||||
|
||||
// 其他情况都应用于显示
|
||||
// - 双 false:应用
|
||||
// - markdownOnly=true:应用
|
||||
// - 双 true:应用
|
||||
|
||||
// 检查消息深度
|
||||
if (messageDepth < (rule.minDepth || 0)) return false;
|
||||
if (rule.maxDepth !== null && rule.maxDepth !== undefined && messageDepth > rule.maxDepth) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// 按 order 排序
|
||||
displayRules.sort((a, b) => a.order - b.order);
|
||||
|
||||
// 应用规则
|
||||
for (const rule of displayRules) {
|
||||
result = applySingleRule(result, rule);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用单条正则规则
|
||||
*/
|
||||
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(`[Regex Processor] 正则规则执行错误 [${rule.scriptName}]:`, error);
|
||||
return text; // 出错时返回原文本
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理文本的正则显示效果
|
||||
*
|
||||
* @param {string} originalText - 原始文本(从后端获取的,未处理的)
|
||||
* @param {number} messageDepth - 消息深度(0=最新消息)
|
||||
* @param {boolean} isUserMessage - 是否为用户消息
|
||||
* @returns {Promise<string>} - 处理后的文本(用于显示)
|
||||
*/
|
||||
export async function processTextForDisplay(originalText, messageDepth = 0, isUserMessage = false) {
|
||||
if (isUserMessage) {
|
||||
// 用户消息不应用 markdownOnly 规则
|
||||
return originalText;
|
||||
}
|
||||
|
||||
const rules = await fetchRules();
|
||||
return applyMarkdownOnlyRules(originalText, rules, messageDepth);
|
||||
}
|
||||
|
||||
export default processTextForDisplay;
|
||||
@@ -1,109 +0,0 @@
|
||||
"""
|
||||
为测试角色生成测试聊天记录
|
||||
"""
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
def create_test_chat(character_name, first_mes, chat_name="默认聊天"):
|
||||
"""
|
||||
为角色创建测试聊天记录
|
||||
|
||||
Args:
|
||||
character_name: 角色名称
|
||||
first_mes: 开场白
|
||||
chat_name: 聊天名称
|
||||
"""
|
||||
# 聊天文件路径
|
||||
chat_dir = Path(f"data/characters/{character_name}/chats")
|
||||
chat_dir.mkdir(parents=True, exist_ok=True)
|
||||
chat_file = chat_dir / f"{chat_name}.jsonl"
|
||||
|
||||
# 如果文件已存在,跳过
|
||||
if chat_file.exists():
|
||||
print(f"跳过已存在的聊天: {character_name}/{chat_name}")
|
||||
return
|
||||
|
||||
# 生成header
|
||||
header = {
|
||||
"user_name": "User",
|
||||
"character_name": character_name,
|
||||
"integrity": str(uuid.uuid4()),
|
||||
"chat_id_hash": str(uuid.uuid4()),
|
||||
"note_prompt": "",
|
||||
"note_interval": 0,
|
||||
"note_position": 0,
|
||||
"note_depth": 0,
|
||||
"note_role": 0,
|
||||
"extensions": {},
|
||||
"timedWorldInfo": {},
|
||||
"variables": {},
|
||||
"tainted": False,
|
||||
"lastInContextMessageId": -1
|
||||
}
|
||||
|
||||
# 生成AI开场白消息(floor 1)
|
||||
ai_message = {
|
||||
"name": character_name,
|
||||
"is_user": False,
|
||||
"is_system": False,
|
||||
"floor": 1,
|
||||
"send_date": str(int(datetime.now().timestamp() * 1000)),
|
||||
"mes": first_mes,
|
||||
"extra": {},
|
||||
"swipes": [],
|
||||
"swipe_id": 0
|
||||
}
|
||||
|
||||
# 写入文件
|
||||
with open(chat_file, 'w', encoding='utf-8') as f:
|
||||
f.write(json.dumps(header, ensure_ascii=False) + '\n')
|
||||
f.write(json.dumps(ai_message, ensure_ascii=False) + '\n')
|
||||
|
||||
print(f"✓ 已创建聊天: {character_name}/{chat_name}")
|
||||
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
characters_dir = Path("data/characters")
|
||||
|
||||
if not characters_dir.exists():
|
||||
print("错误: characters目录不存在")
|
||||
return
|
||||
|
||||
print("开始为测试角色生成聊天记录...\n")
|
||||
|
||||
for char_folder in characters_dir.iterdir():
|
||||
if not char_folder.is_dir():
|
||||
continue
|
||||
|
||||
char_name = char_folder.name
|
||||
char_json = char_folder / "character.json"
|
||||
|
||||
if not char_json.exists():
|
||||
print(f"跳过 {char_name}: 没有character.json文件")
|
||||
continue
|
||||
|
||||
try:
|
||||
# 读取角色信息
|
||||
with open(char_json, 'r', encoding='utf-8') as f:
|
||||
char_data = json.load(f)
|
||||
|
||||
first_mes = char_data.get('first_mes', '')
|
||||
|
||||
if not first_mes:
|
||||
print(f"跳过 {char_name}: 没有开场白")
|
||||
continue
|
||||
|
||||
# 创建测试聊天
|
||||
create_test_chat(char_name, first_mes)
|
||||
|
||||
except Exception as e:
|
||||
print(f"处理 {char_name} 时出错: {e}")
|
||||
|
||||
print("\n完成!")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
11
main.py
11
main.py
@@ -1,11 +0,0 @@
|
||||
from fastapi import FastAPI
|
||||
from backend.api.route import router
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# 注册API路由
|
||||
app.include_router(router, prefix="/api")
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"message": "Hello World"}
|
||||
124
test.py
124
test.py
@@ -1,124 +0,0 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import os
|
||||
import time
|
||||
|
||||
# 添加项目根目录到 Python 路径
|
||||
project_root = Path(__file__).parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
from backend.core.models.chat_history import ChatHistory
|
||||
|
||||
# 导入 LangChain
|
||||
try:
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langchain_core.messages import HumanMessage
|
||||
except ImportError:
|
||||
print("⚠️ 未安装 langchain 库,请先运行: pip install langchain-openai langchain-core")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def test_api_key(api_key: str, base_url: str = None, model: str = "gpt-3.5-turbo") -> dict:
|
||||
"""
|
||||
使用 LangChain 测试 API Key
|
||||
|
||||
Args:
|
||||
api_key: API密钥
|
||||
base_url: API基础URL(可选)
|
||||
model: 模型名称
|
||||
|
||||
Returns:
|
||||
dict: 测试结果
|
||||
"""
|
||||
try:
|
||||
# 创建 LLM 实例
|
||||
llm_kwargs = {
|
||||
"model": model,
|
||||
"api_key": api_key,
|
||||
"temperature": 0,
|
||||
"max_tokens": 5
|
||||
}
|
||||
|
||||
if base_url:
|
||||
llm_kwargs["base_url"] = base_url
|
||||
|
||||
llm = ChatOpenAI(**llm_kwargs)
|
||||
|
||||
# 发送测试消息
|
||||
start_time = time.time()
|
||||
response = llm.invoke([HumanMessage(content="Hi")])
|
||||
end_time = time.time()
|
||||
|
||||
return {
|
||||
"valid": True,
|
||||
"message": f"✅ API Key 有效!响应时间: {end_time - start_time:.2f}秒",
|
||||
"response_time": end_time - start_time,
|
||||
"content": response.content
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"valid": False,
|
||||
"message": f"❌ API Key 无效: {str(e)}",
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
def check_api_status():
|
||||
"""
|
||||
检查 API 配置状态
|
||||
|
||||
⚠️ 注意:此函数已废弃,API Key 现在存储在配置文件中
|
||||
请使用 API 配置页面进行测试
|
||||
"""
|
||||
print("\n" + "=" * 60)
|
||||
print("⚠️ 此测试功能已废弃")
|
||||
print("=" * 60)
|
||||
print("\nAPI Key 现在存储在配置文件中(data/apiconfig/*.json)")
|
||||
print("请使用以下方式测试 API 连接:")
|
||||
print(" 1. 打开应用")
|
||||
print(" 2. 进入左侧边栏 → API 配置")
|
||||
print(" 3. 点击 '测试连接' 按钮")
|
||||
print("\n或者直接在聊天中发送消息进行测试")
|
||||
return False
|
||||
|
||||
|
||||
def quick_test():
|
||||
"""
|
||||
快速测试
|
||||
|
||||
⚠️ 注意:此函数已废弃
|
||||
"""
|
||||
print("❌ 此测试功能已废弃,请使用 API 配置页面进行测试")
|
||||
return False
|
||||
|
||||
|
||||
def test_list_all_chats():
|
||||
"""测试聊天历史"""
|
||||
try:
|
||||
result = ChatHistory.list_all_chats()
|
||||
print(f"\n获取到 {len(result.get('chat', []))} 个聊天")
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"错误: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
|
||||
# 运行测试
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) > 1:
|
||||
if sys.argv[1] == "--check":
|
||||
check_api_status()
|
||||
elif sys.argv[1] == "--quick":
|
||||
quick_test()
|
||||
elif sys.argv[1] == "--chat":
|
||||
test_list_all_chats()
|
||||
else:
|
||||
print("用法:")
|
||||
print(" python test.py --check # 检查 API Key")
|
||||
print(" python test.py --quick # 快速验证")
|
||||
print(" python test.py --chat # 测试聊天历史")
|
||||
else:
|
||||
check_api_status()
|
||||
@@ -1,246 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ComfyUI API 配置测试脚本
|
||||
用于验证后端 API 端点是否正常工作
|
||||
"""
|
||||
import requests
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
# 配置
|
||||
BASE_URL = "http://localhost:8000"
|
||||
API_BASE = f"{BASE_URL}/api/api-config"
|
||||
|
||||
def print_section(title):
|
||||
"""打印分隔线"""
|
||||
print(f"\n{'='*60}")
|
||||
print(f" {title}")
|
||||
print(f"{'='*60}\n")
|
||||
|
||||
def test_list_workflows():
|
||||
"""测试列出工作流"""
|
||||
print_section("测试 1: 列出工作流")
|
||||
|
||||
try:
|
||||
response = requests.get(f"{API_BASE}/comfyui/workflows")
|
||||
response.raise_for_status()
|
||||
|
||||
workflows = response.json()
|
||||
print(f"✅ 成功获取 {len(workflows)} 个工作流\n")
|
||||
|
||||
for wf in workflows:
|
||||
print(f" 📄 {wf['filename']}")
|
||||
print(f" 节点数: {wf['nodes_count']}, 大小: {wf['size']} bytes")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"❌ 失败: {e}")
|
||||
return False
|
||||
|
||||
def test_get_workflow():
|
||||
"""测试获取工作流详情"""
|
||||
print_section("测试 2: 获取工作流详情")
|
||||
|
||||
try:
|
||||
response = requests.get(f"{API_BASE}/comfyui/workflows/default_txt2img.json")
|
||||
response.raise_for_status()
|
||||
|
||||
workflow = response.json()
|
||||
print(f"✅ 成功获取工作流\n")
|
||||
print(f" 节点数量: {len(workflow)}")
|
||||
print(f" 节点列表:")
|
||||
|
||||
for node_id, node_data in workflow.items():
|
||||
class_type = node_data.get('class_type', 'Unknown')
|
||||
title = node_data.get('_meta', {}).get('title', 'No title')
|
||||
print(f" - {node_id}: {class_type} ({title})")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"❌ 失败: {e}")
|
||||
return False
|
||||
|
||||
def test_upload_workflow():
|
||||
"""测试上传工作流"""
|
||||
print_section("测试 3: 上传工作流")
|
||||
|
||||
# 创建一个简单的测试工作流
|
||||
test_workflow = {
|
||||
"3": {
|
||||
"inputs": {
|
||||
"seed": 42,
|
||||
"steps": 20,
|
||||
"cfg": 8,
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "normal",
|
||||
"denoise": 1,
|
||||
"model": ["4", 0],
|
||||
"positive": ["6", 0],
|
||||
"negative": ["7", 0],
|
||||
"latent_image": ["5", 0]
|
||||
},
|
||||
"class_type": "KSampler",
|
||||
"_meta": {"title": "测试采样器"}
|
||||
},
|
||||
"4": {
|
||||
"inputs": {"ckpt_name": "test.safetensors"},
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"_meta": {"title": "测试加载器"}
|
||||
},
|
||||
"5": {
|
||||
"inputs": {"width": 512, "height": 512, "batch_size": 1},
|
||||
"class_type": "EmptyLatentImage",
|
||||
"_meta": {"title": "测试潜变量"}
|
||||
},
|
||||
"6": {
|
||||
"inputs": {"text": "test prompt", "clip": ["4", 1]},
|
||||
"class_type": "CLIPTextEncode",
|
||||
"_meta": {"title": "测试正向提示词"}
|
||||
},
|
||||
"7": {
|
||||
"inputs": {"text": "bad quality", "clip": ["4", 1]},
|
||||
"class_type": "CLIPTextEncode",
|
||||
"_meta": {"title": "测试负向提示词"}
|
||||
},
|
||||
"8": {
|
||||
"inputs": {"samples": ["3", 0], "vae": ["4", 2]},
|
||||
"class_type": "VAEDecode",
|
||||
"_meta": {"title": "测试解码器"}
|
||||
},
|
||||
"9": {
|
||||
"inputs": {"images": ["8", 0], "filename_prefix": "Test"},
|
||||
"class_type": "SaveImage",
|
||||
"_meta": {"title": "测试保存"}
|
||||
}
|
||||
}
|
||||
|
||||
# 保存到临时文件
|
||||
temp_file = Path("test_workflow.json")
|
||||
with open(temp_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(test_workflow, f, ensure_ascii=False, indent=2)
|
||||
|
||||
try:
|
||||
with open(temp_file, 'rb') as f:
|
||||
files = {'file': ('test_workflow.json', f, 'application/json')}
|
||||
response = requests.post(f"{API_BASE}/comfyui/workflows/upload", files=files)
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
print(f"✅ 上传成功\n")
|
||||
print(f" 文件名: {result['filename']}")
|
||||
print(f" 大小: {result['size']} bytes")
|
||||
|
||||
# 清理临时文件
|
||||
temp_file.unlink()
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"❌ 失败: {e}")
|
||||
if temp_file.exists():
|
||||
temp_file.unlink()
|
||||
return False
|
||||
|
||||
def test_delete_workflow():
|
||||
"""测试删除工作流"""
|
||||
print_section("测试 4: 删除工作流")
|
||||
|
||||
try:
|
||||
response = requests.delete(f"{API_BASE}/comfyui/workflows/test_workflow.json")
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
print(f"✅ 删除成功\n")
|
||||
print(f" {result['message']}")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"❌ 失败: {e}")
|
||||
return False
|
||||
|
||||
def test_comfyui_connection():
|
||||
"""测试 ComfyUI 连接"""
|
||||
print_section("测试 5: 测试 ComfyUI 连接")
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{API_BASE}/test-comfyui-connection",
|
||||
json={"apiUrl": "http://localhost:8188"}
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
result = response.json()
|
||||
|
||||
if result['success']:
|
||||
print(f"✅ 连接成功\n")
|
||||
print(f" 消息: {result['message']}")
|
||||
if 'stats' in result:
|
||||
stats = result['stats']
|
||||
vram_free_gb = stats.get('vram_free', 0) / 1024 / 1024 / 1024
|
||||
print(f" 可用 VRAM: {vram_free_gb:.2f} GB")
|
||||
print(f" 设备: {stats.get('device', 'N/A')}")
|
||||
print(f" Torch 版本: {stats.get('torch_version', 'N/A')}")
|
||||
else:
|
||||
print(f"⚠️ 连接失败(这是正常的,如果 ComfyUI 未运行)\n")
|
||||
print(f" 消息: {result['message']}")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"⚠️ 请求失败(这是正常的,如果 ComfyUI 未运行): {e}")
|
||||
return True # 不算失败,因为可能只是 ComfyUI 没启动
|
||||
|
||||
def test_cloud_connection():
|
||||
"""测试云端 API 连接"""
|
||||
print_section("测试 6: 测试云端 API 连接")
|
||||
|
||||
print("⚠️ 跳过此测试(需要真实的 API Key)\n")
|
||||
print(" 要测试此功能,请提供一个有效的 API Key")
|
||||
|
||||
return True
|
||||
|
||||
def main():
|
||||
"""主测试流程"""
|
||||
print("\n" + "="*60)
|
||||
print(" ComfyUI API 配置测试")
|
||||
print("="*60)
|
||||
|
||||
tests = [
|
||||
("列出工作流", test_list_workflows),
|
||||
("获取工作流详情", test_get_workflow),
|
||||
("上传工作流", test_upload_workflow),
|
||||
("删除工作流", test_delete_workflow),
|
||||
("测试 ComfyUI 连接", test_comfyui_connection),
|
||||
("测试云端 API 连接", test_cloud_connection),
|
||||
]
|
||||
|
||||
results = []
|
||||
for name, test_func in tests:
|
||||
try:
|
||||
success = test_func()
|
||||
results.append((name, success))
|
||||
except Exception as e:
|
||||
print(f"\n❌ 测试 '{name}' 发生异常: {e}")
|
||||
results.append((name, False))
|
||||
|
||||
# 打印总结
|
||||
print_section("测试总结")
|
||||
|
||||
passed = sum(1 for _, success in results if success)
|
||||
total = len(results)
|
||||
|
||||
for name, success in results:
|
||||
status = "✅ 通过" if success else "❌ 失败"
|
||||
print(f" {status} - {name}")
|
||||
|
||||
print(f"\n 总计: {passed}/{total} 通过")
|
||||
|
||||
if passed == total:
|
||||
print("\n🎉 所有测试通过!")
|
||||
else:
|
||||
print(f"\n⚠️ {total - passed} 个测试失败,请检查日志")
|
||||
|
||||
return passed == total
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
success = main()
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -1,82 +0,0 @@
|
||||
"""
|
||||
测试 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)
|
||||
@@ -1,99 +0,0 @@
|
||||
"""
|
||||
测试角色卡更新API
|
||||
"""
|
||||
import requests
|
||||
import json
|
||||
|
||||
# 测试配置
|
||||
BASE_URL = "http://localhost:8000/api"
|
||||
|
||||
def test_update_character():
|
||||
"""测试更新角色卡"""
|
||||
|
||||
# 1. 先获取所有角色
|
||||
print("1. 获取角色列表...")
|
||||
response = requests.get(f"{BASE_URL}/characters/")
|
||||
if response.status_code != 200:
|
||||
print(f"❌ 获取角色列表失败: {response.status_code}")
|
||||
return
|
||||
|
||||
characters = response.json()
|
||||
if not characters:
|
||||
print("❌ 没有角色卡可供测试")
|
||||
return
|
||||
|
||||
# 选择第一个角色
|
||||
test_char = characters[0]
|
||||
char_name = test_char['name']
|
||||
print(f"✅ 找到角色: {char_name}")
|
||||
|
||||
# 2. 获取角色详情
|
||||
print(f"\n2. 获取角色 '{char_name}' 的详情...")
|
||||
response = requests.get(f"{BASE_URL}/characters/{char_name}")
|
||||
if response.status_code != 200:
|
||||
print(f"❌ 获取角色详情失败: {response.status_code}")
|
||||
return
|
||||
|
||||
original_data = response.json()
|
||||
print(f"✅ 原始描述长度: {len(original_data.get('description', ''))}")
|
||||
|
||||
# 3. 更新角色(添加测试标记)
|
||||
print(f"\n3. 更新角色 '{char_name}'...")
|
||||
update_data = {
|
||||
"description": original_data.get('description', '') + '\n\n[测试更新标记]',
|
||||
"tags": original_data.get('tags', []) + ['test_update']
|
||||
}
|
||||
|
||||
response = requests.put(
|
||||
f"{BASE_URL}/characters/{char_name}",
|
||||
json=update_data,
|
||||
headers={'Content-Type': 'application/json'}
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
print(f"❌ 更新失败: {response.status_code}")
|
||||
print(f"响应内容: {response.text}")
|
||||
return
|
||||
|
||||
result = response.json()
|
||||
print(f"✅ 更新成功: {result.get('success')}")
|
||||
|
||||
# 4. 验证更新
|
||||
print(f"\n4. 验证更新结果...")
|
||||
response = requests.get(f"{BASE_URL}/characters/{char_name}")
|
||||
updated_data = response.json()
|
||||
|
||||
new_desc = updated_data.get('description', '')
|
||||
if '[测试更新标记]' in new_desc:
|
||||
print("✅ 验证成功:描述已更新")
|
||||
else:
|
||||
print("❌ 验证失败:描述未更新")
|
||||
|
||||
if 'test_update' in updated_data.get('tags', []):
|
||||
print("✅ 验证成功:标签已更新")
|
||||
else:
|
||||
print("❌ 验证失败:标签未更新")
|
||||
|
||||
# 5. 恢复原始数据
|
||||
print(f"\n5. 恢复原始数据...")
|
||||
response = requests.put(
|
||||
f"{BASE_URL}/characters/{char_name}",
|
||||
json={
|
||||
"description": original_data.get('description', ''),
|
||||
"tags": original_data.get('tags', [])
|
||||
},
|
||||
headers={'Content-Type': 'application/json'}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
print("✅ 数据已恢复")
|
||||
else:
|
||||
print(f"⚠️ 恢复失败: {response.status_code}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
test_update_character()
|
||||
except Exception as e:
|
||||
print(f"❌ 测试出错: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
@@ -1,131 +0,0 @@
|
||||
"""
|
||||
测试聊天服务
|
||||
"""
|
||||
import sys
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
# 直接加载chat_service模块,不通过__init__.py
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"chat_service",
|
||||
Path(__file__).parent / "backend" / "services" / "chat_service.py"
|
||||
)
|
||||
chat_service_module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(chat_service_module)
|
||||
ChatService = chat_service_module.ChatService
|
||||
|
||||
import json
|
||||
|
||||
# 初始化聊天服务
|
||||
data_path = Path("data")
|
||||
chat_service = ChatService(data_path)
|
||||
|
||||
print("=" * 60)
|
||||
print("测试1: 获取所有聊天列表")
|
||||
print("=" * 60)
|
||||
all_chats = chat_service.list_all_chats()
|
||||
print(json.dumps(all_chats, ensure_ascii=False, indent=2))
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("测试2: 获取指定聊天的完整内容")
|
||||
print("=" * 60)
|
||||
try:
|
||||
chat_data = chat_service.get_chat("testRole1", "111")
|
||||
print(f"Metadata: {json.dumps(chat_data['metadata'], ensure_ascii=False, indent=2)}")
|
||||
print(f"\nMessages count: {len(chat_data['messages'])}")
|
||||
for msg in chat_data['messages']:
|
||||
print(f" Floor {msg['floor']}: {msg['name']} - {msg['mes'][:50]}...")
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("测试3: 获取聊天的所有消息")
|
||||
print("=" * 60)
|
||||
try:
|
||||
messages_data = chat_service.get_chat("testRole2", "222")
|
||||
print(f"Messages count: {len(messages_data['messages'])}")
|
||||
for msg in messages_data['messages']:
|
||||
print(f" Floor {msg['floor']}: {msg['name']} ({'User' if msg['is_user'] else 'AI'})")
|
||||
print(f" Content: {msg['mes'][:80]}...")
|
||||
if msg.get('swipes') and len(msg['swipes']) > 0:
|
||||
print(f" Swipes: {len(msg['swipes'])} versions")
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("测试4: 创建新聊天")
|
||||
print("=" * 60)
|
||||
try:
|
||||
new_chat = chat_service.create_chat("testRole4", "test_chat", {
|
||||
"user_name": "TestUser",
|
||||
"character_name": "TestAI"
|
||||
})
|
||||
print(f"Created: {json.dumps(new_chat, ensure_ascii=False, indent=2)}")
|
||||
|
||||
# 添加测试消息
|
||||
msg1 = chat_service.add_message("testRole4", "test_chat", {
|
||||
"name": "TestUser",
|
||||
"is_user": True,
|
||||
"mes": "这是一条测试消息"
|
||||
})
|
||||
print(f"Added message 1: Floor {msg1['floor']}")
|
||||
|
||||
msg2 = chat_service.add_message("testRole4", "test_chat", {
|
||||
"name": "TestAI",
|
||||
"is_user": False,
|
||||
"mes": "这是AI的回复"
|
||||
})
|
||||
print(f"Added message 2: Floor {msg2['floor']}")
|
||||
|
||||
# 读取验证
|
||||
verify_chat = chat_service.get_chat("testRole4", "test_chat")
|
||||
print(f"\nVerification - Messages count: {len(verify_chat['messages'])}")
|
||||
for msg in verify_chat['messages']:
|
||||
print(f" Floor {msg['floor']}: {msg['name']} - {msg['mes']}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("测试5: 更新消息")
|
||||
print("=" * 60)
|
||||
try:
|
||||
updated = chat_service.update_message("testRole4", "test_chat", 1, {
|
||||
"mes": "这是更新后的测试消息"
|
||||
})
|
||||
print(f"Updated message at floor 1: {updated['mes']}")
|
||||
|
||||
# 验证更新
|
||||
verify_chat = chat_service.get_chat("testRole4", "test_chat")
|
||||
for msg in verify_chat['messages']:
|
||||
if msg['floor'] == 1:
|
||||
print(f"Verified - Floor 1: {msg['mes']}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("测试6: 删除消息")
|
||||
print("=" * 60)
|
||||
try:
|
||||
deleted = chat_service.delete_message("testRole4", "test_chat", 1)
|
||||
print(f"Deleted message from floor 1: {deleted['mes']}")
|
||||
|
||||
# 验证删除并检查floor重新编号
|
||||
verify_chat = chat_service.get_chat("testRole4", "test_chat")
|
||||
print(f"\nAfter deletion - Messages count: {len(verify_chat['messages'])}")
|
||||
for msg in verify_chat['messages']:
|
||||
print(f" Floor {msg['floor']}: {msg['name']} - {msg['mes']}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("所有测试完成!")
|
||||
print("=" * 60)
|
||||
@@ -1,76 +0,0 @@
|
||||
"""
|
||||
简化版聊天记录总结功能测试
|
||||
直接操作现有数据进行测试
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 添加backend目录到Python路径
|
||||
backend_root = Path(__file__).parent / "backend"
|
||||
sys.path.insert(0, str(backend_root))
|
||||
|
||||
from services.chat_service import chat_service
|
||||
|
||||
|
||||
def test_simple_summary():
|
||||
"""简单测试:直接操作现有聊天数据"""
|
||||
print("=" * 60)
|
||||
print("聊天记录总结功能简单测试")
|
||||
print("=" * 60)
|
||||
|
||||
# 列出所有现有聊天
|
||||
print("\n现有聊天列表:")
|
||||
all_chats = chat_service.list_all_chats()
|
||||
|
||||
if not all_chats:
|
||||
print(" 没有找到任何聊天,请先创建角色和聊天")
|
||||
return
|
||||
|
||||
for role_name, chats in all_chats.items():
|
||||
print(f"\n 角色: {role_name}")
|
||||
for chat in chats[:3]: # 只显示前3个
|
||||
print(f" - {chat['chat_name']} ({chat['message_count']}条消息)")
|
||||
|
||||
# 选择第一个聊天进行测试
|
||||
first_role = list(all_chats.keys())[0]
|
||||
first_chat = all_chats[first_role][0]['chat_name']
|
||||
|
||||
print(f"\n选择测试: {first_role}/{first_chat}")
|
||||
|
||||
# 获取聊天记录
|
||||
chat_data = chat_service.get_chat(first_role, first_chat)
|
||||
messages = chat_data['messages']
|
||||
|
||||
print(f"总消息数: {len(messages)}")
|
||||
|
||||
if len(messages) < 5:
|
||||
print("消息数量不足,无法测试总结功能")
|
||||
return
|
||||
|
||||
# 显示前5条消息
|
||||
print("\n前5条消息:")
|
||||
for i, msg in enumerate(messages[:5], 1):
|
||||
name = msg.get('name', 'Unknown')
|
||||
mes = msg.get('mes', '')[:50]
|
||||
print(f" L{i}: {name}: {mes}...")
|
||||
|
||||
# 测试总结功能(模拟)
|
||||
print("\n模拟总结 L1-L3:")
|
||||
print(" 原始消息:")
|
||||
for i in range(3):
|
||||
msg = messages[i]
|
||||
print(f" L{i+1}: {msg.get('name')}: {msg.get('mes', '')[:30]}...")
|
||||
|
||||
print("\n 总结后效果:")
|
||||
print(" L1: [空] (is_summarized=true)")
|
||||
print(" L2: [空] (is_summarized=true)")
|
||||
print(" L3: [总结文本] (is_summary=true, summary_range='L1-L3')")
|
||||
print(" L4+: 保持不变")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("✅ 测试完成!请在UI中验证实际效果")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_simple_summary()
|
||||
@@ -1,82 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
测试后端是否能正确访问 data 目录
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 添加 backend 到 Python 路径
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
try:
|
||||
from backend.core.config import settings
|
||||
except ImportError:
|
||||
from core.config import settings
|
||||
|
||||
def test_paths():
|
||||
"""测试所有路径配置"""
|
||||
print("=" * 60)
|
||||
print("路径配置测试")
|
||||
print("=" * 60)
|
||||
|
||||
# 测试基本路径
|
||||
print(f"\n1. 项目根目录 (BASE_PATH):")
|
||||
print(f" {settings.BASE_PATH}")
|
||||
print(f" 存在: {settings.BASE_PATH.exists()}")
|
||||
|
||||
print(f"\n2. 数据目录 (DATA_PATH):")
|
||||
print(f" {settings.DATA_PATH}")
|
||||
print(f" 存在: {settings.DATA_PATH.exists()}")
|
||||
|
||||
# 测试子目录
|
||||
directories = {
|
||||
"预设目录 (PRESET_PATH)": settings.PRESET_PATH,
|
||||
"角色卡目录 (CHARACTERS_PATH)": settings.CHARACTERS_PATH,
|
||||
"聊天目录 (CHAT_PATH)": settings.CHAT_PATH,
|
||||
"世界书目录 (WORLDBOOKS_PATH)": settings.WORLDBOOKS_PATH,
|
||||
"API配置目录": settings.DATA_PATH / "apiconfig",
|
||||
"图片目录 (IMAGES_PATH)": settings.IMAGES_PATH,
|
||||
}
|
||||
|
||||
print("\n3. 子目录检查:")
|
||||
for name, path in directories.items():
|
||||
exists = path.exists()
|
||||
status = "✅" if exists else "❌"
|
||||
print(f" {status} {name}:")
|
||||
print(f" {path}")
|
||||
|
||||
if exists:
|
||||
# 列出目录内容(如果有)
|
||||
try:
|
||||
items = list(path.iterdir())
|
||||
if items:
|
||||
print(f" 内容: {[item.name for item in items[:5]]}")
|
||||
else:
|
||||
print(f" (空目录)")
|
||||
except Exception as e:
|
||||
print(f" 读取失败: {e}")
|
||||
|
||||
# 测试预设文件
|
||||
print("\n4. 预设文件测试:")
|
||||
preset_files = list(settings.PRESET_PATH.glob("*.json")) if settings.PRESET_PATH.exists() else []
|
||||
print(f" 找到 {len(preset_files)} 个预设文件")
|
||||
for pf in preset_files[:3]:
|
||||
print(f" - {pf.name}")
|
||||
|
||||
# 测试角色卡
|
||||
print("\n5. 角色卡测试:")
|
||||
char_dirs = list(settings.CHARACTERS_PATH.iterdir()) if settings.CHARACTERS_PATH.exists() else []
|
||||
char_count = sum(1 for d in char_dirs if d.is_dir())
|
||||
print(f" 找到 {char_count} 个角色")
|
||||
for cd in char_dirs[:3]:
|
||||
if cd.is_dir():
|
||||
char_file = cd / "character.json"
|
||||
has_char_file = char_file.exists()
|
||||
print(f" - {cd.name} (character.json: {'✅' if has_char_file else '❌'})")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("测试完成!")
|
||||
print("=" * 60)
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_paths()
|
||||
@@ -1,129 +0,0 @@
|
||||
"""
|
||||
完整测试预设导入导出的顺序保持
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
sys.path.insert(0, 'backend')
|
||||
|
||||
from services.preset_service import PresetService
|
||||
|
||||
print("=" * 80)
|
||||
print("预设导入导出顺序完整性测试")
|
||||
print("=" * 80)
|
||||
|
||||
# ==================== 步骤 1: 创建测试预设(模拟 SillyTavern 导出)====================
|
||||
print("\n[步骤 1] 创建测试预设(prompts 乱序)")
|
||||
print("-" * 80)
|
||||
|
||||
test_data = {
|
||||
"name": "Order_Integrity_Test",
|
||||
"temperature": 0.8,
|
||||
"frequency_penalty": 0.3,
|
||||
"presence_penalty": 0.2,
|
||||
"top_p": 0.95,
|
||||
"top_k": 40,
|
||||
"max_tokens": 2048,
|
||||
|
||||
# prompts 是乱序的
|
||||
"prompts": [
|
||||
{"identifier": "z_last", "name": "Z Last", "content": "Last", "role": "user", "system_prompt": False, "enabled": True},
|
||||
{"identifier": "a_first", "name": "A First", "content": "First", "role": "system", "system_prompt": True, "enabled": True},
|
||||
{"identifier": "m_middle", "name": "M Middle", "content": "Middle", "role": "assistant", "system_prompt": False, "enabled": True}
|
||||
],
|
||||
|
||||
# prompt_order 指定正确顺序
|
||||
"prompt_order": [{
|
||||
"character_id": "global",
|
||||
"order": [
|
||||
{"identifier": "a_first", "enabled": True},
|
||||
{"identifier": "m_middle", "enabled": True},
|
||||
{"identifier": "z_last", "enabled": True}
|
||||
]
|
||||
}]
|
||||
}
|
||||
|
||||
print(f"原始 prompts 顺序: {[p['identifier'] for p in test_data['prompts']]}")
|
||||
print(f"prompt_order 顺序: {[o['identifier'] for o in test_data['prompt_order'][0]['order']]}")
|
||||
|
||||
# ==================== 步骤 2: 使用后端保存(模拟导入后保存)====================
|
||||
print("\n[步骤 2] 使用 PresetService 保存预设")
|
||||
print("-" * 80)
|
||||
|
||||
try:
|
||||
# 如果已存在则删除
|
||||
if PresetService._get_preset_path("Order_Integrity_Test").exists():
|
||||
PresetService.delete_preset("Order_Integrity_Test")
|
||||
|
||||
PresetService.create_preset("Order_Integrity_Test", test_data)
|
||||
print("✅ 保存成功")
|
||||
except Exception as e:
|
||||
print(f"❌ 保存失败: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# ==================== 步骤 3: 读取保存的文件检查顺序====================
|
||||
print("\n[步骤 3] 读取保存的文件")
|
||||
print("-" * 80)
|
||||
|
||||
saved_file = Path("data/preset/Order_Integrity_Test.json")
|
||||
with open(saved_file, 'r', encoding='utf-8') as f:
|
||||
saved_data = json.load(f)
|
||||
|
||||
saved_prompts_order = [p['identifier'] for p in saved_data.get('prompts', [])]
|
||||
saved_order_list = [o['identifier'] for o in saved_data.get('prompt_order', [{}])[0].get('order', [])]
|
||||
|
||||
print(f"保存后 prompts 顺序: {saved_prompts_order}")
|
||||
print(f"保存后 prompt_order 顺序: {saved_order_list}")
|
||||
|
||||
if saved_prompts_order == saved_order_list:
|
||||
print("\n✅ 保存后顺序一致!")
|
||||
else:
|
||||
print("\n❌ 保存后顺序不一致!")
|
||||
|
||||
# ==================== 步骤 4: 模拟前端加载(从后端读取)====================
|
||||
print("\n[步骤 4] 模拟前端从后端加载预设")
|
||||
print("-" * 80)
|
||||
|
||||
loaded_data = PresetService.get_preset("Order_Integrity_Test")
|
||||
loaded_prompts_order = [p['identifier'] for p in loaded_data.get('prompts', [])]
|
||||
loaded_order_list = [o['identifier'] for o in loaded_data.get('prompt_order', [{}])[0].get('order', [])]
|
||||
|
||||
print(f"加载后 prompts 顺序: {loaded_prompts_order}")
|
||||
print(f"加载后 prompt_order 顺序: {loaded_order_list}")
|
||||
|
||||
if loaded_prompts_order == loaded_order_list:
|
||||
print("\n✅ 加载后顺序一致!")
|
||||
else:
|
||||
print("\n❌ 加载后顺序不一致!")
|
||||
|
||||
# ==================== 步骤 5: 验证完整流程====================
|
||||
print("\n[步骤 5] 验证完整流程")
|
||||
print("-" * 80)
|
||||
|
||||
expected_order = ['a_first', 'm_middle', 'z_last']
|
||||
|
||||
print(f"期望顺序: {expected_order}")
|
||||
print(f"原始 prompts: {[p['identifier'] for p in test_data['prompts']]}")
|
||||
print(f"保存后 prompts: {saved_prompts_order}")
|
||||
print(f"加载后 prompts: {loaded_prompts_order}")
|
||||
|
||||
all_match = (
|
||||
saved_prompts_order == expected_order and
|
||||
loaded_prompts_order == expected_order and
|
||||
saved_prompts_order == loaded_prompts_order
|
||||
)
|
||||
|
||||
if all_match:
|
||||
print("\n🎉 完整流程顺序保持一致!")
|
||||
print("✅ 即使原始 prompts 是乱序的,保存和加载后都能按 prompt_order 正确排序")
|
||||
else:
|
||||
print("\n❌ 顺序在某个环节出现了问题")
|
||||
|
||||
# 清理测试文件
|
||||
try:
|
||||
PresetService.delete_preset("Order_Integrity_Test")
|
||||
print("\n✅ 已清理测试文件")
|
||||
except:
|
||||
pass
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
@@ -1,66 +0,0 @@
|
||||
"""
|
||||
测试 LLM 模型服务
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, 'backend')
|
||||
|
||||
from backend.services.llm_model_service import LLMModelService
|
||||
|
||||
|
||||
def test_detect_provider():
|
||||
"""测试提供商检测功能"""
|
||||
print("测试提供商检测...")
|
||||
|
||||
test_cases = [
|
||||
("https://api.openai.com/v1", "openai"),
|
||||
("https://api.anthropic.com/v1", "anthropic"),
|
||||
("http://localhost:11434", "ollama"),
|
||||
("http://127.0.0.1:11434", "ollama"),
|
||||
("https://api.siliconflow.cn/v1", "openai"),
|
||||
("https://api.deepseek.com/v1", "openai"),
|
||||
]
|
||||
|
||||
for url, expected in test_cases:
|
||||
result = LLMModelService.detect_provider(url)
|
||||
status = "✓" if result == expected else "✗"
|
||||
print(f" {status} {url} -> {result} (期望: {expected})")
|
||||
|
||||
|
||||
def test_get_openai_models():
|
||||
"""测试获取 OpenAI 模型列表(需要有效的 API Key)"""
|
||||
print("\n测试 OpenAI 模型列表获取...")
|
||||
|
||||
# 这里需要一个有效的 API Key 才能测试
|
||||
api_key = "sk-test-key" # 替换为实际的 API Key
|
||||
try:
|
||||
models = LLMModelService.get_openai_models(api_key)
|
||||
print(f" ✓ 成功获取 {len(models)} 个模型")
|
||||
print(f" 前5个模型: {models[:5]}")
|
||||
except Exception as e:
|
||||
print(f" ✗ 失败: {str(e)}")
|
||||
|
||||
|
||||
def test_get_ollama_models():
|
||||
"""测试获取 Ollama 模型列表(需要本地运行 Ollama)"""
|
||||
print("\n测试 Ollama 模型列表获取...")
|
||||
|
||||
try:
|
||||
models = LLMModelService.get_ollama_models()
|
||||
print(f" ✓ 成功获取 {len(models)} 个模型")
|
||||
print(f" 模型列表: {models}")
|
||||
except Exception as e:
|
||||
print(f" ✗ 失败: {str(e)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=" * 60)
|
||||
print("LLM 模型服务测试")
|
||||
print("=" * 60)
|
||||
|
||||
test_detect_provider()
|
||||
# test_get_openai_models() # 需要有效的 API Key
|
||||
# test_get_ollama_models() # 需要本地运行 Ollama
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("测试完成")
|
||||
print("=" * 60)
|
||||
@@ -1,11 +0,0 @@
|
||||
# Test your FastAPI endpoints
|
||||
|
||||
GET http://127.0.0.1:8000/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
|
||||
GET http://127.0.0.1:8000/hello/User
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -1,74 +0,0 @@
|
||||
"""
|
||||
简单测试世界书分页功能 - 通过代码审查验证
|
||||
"""
|
||||
|
||||
print("=" * 60)
|
||||
print("世界书分页功能实现验证")
|
||||
print("=" * 60)
|
||||
|
||||
# 1. 检查后端API路由
|
||||
print("\n1. 后端API路由检查:")
|
||||
print(" ✓ API路由已添加分页参数: page (默认1), page_size (默认20)")
|
||||
print(" ✓ 返回类型改为 Dict[str, Any],包含分页信息")
|
||||
print(" ✓ 调用服务层的 list_entries 方法并传递分页参数")
|
||||
|
||||
# 2. 检查服务层实现
|
||||
print("\n2. 服务层实现检查:")
|
||||
print(" ✓ list_entries 方法接受 page 和 page_size 参数")
|
||||
print(" ✓ 计算总条目数 total")
|
||||
print(" ✓ 根据页码和每页数量切片获取条目")
|
||||
print(" ✓ 返回包含 entries, total, page, page_size, total_pages 的字典")
|
||||
print(" ✓ total_pages 使用向上取整计算: (total + page_size - 1) // page_size")
|
||||
|
||||
# 3. 检查前端Store
|
||||
print("\n3. 前端Store检查:")
|
||||
print(" ✓ fetchWorldBookEntries 接受 page 和 page_size 参数")
|
||||
print(" ✓ 在请求URL中添加分页查询参数")
|
||||
print(" ✓ 解析响应中的 entries 和分页信息")
|
||||
print(" ✓ 更新 state 中的 currentEntries 和 entriesPagination")
|
||||
print(" ✓ entriesPagination 包含: total, page, page_size, total_pages")
|
||||
|
||||
# 4. 检查前端组件
|
||||
print("\n4. 前端组件检查:")
|
||||
print(" ✓ 从 store 中获取 entriesPagination")
|
||||
print(" ✓ 添加 currentPage 和 pageSize 状态")
|
||||
print(" ✓ handleSelectWorldBook 重置分页到第1页")
|
||||
print(" ✓ handlePageChange 处理页码变化")
|
||||
print(" ✓ handlePageSizeChange 处理每页数量变化")
|
||||
print(" ✓ 添加分页控件UI(上一页、下一页、每页数量选择)")
|
||||
print(" ✓ 分页控件显示总条目数和当前页信息")
|
||||
print(" ✓ 添加/更新/删除条目后刷新当前页数据")
|
||||
|
||||
# 5. 检查CSS样式
|
||||
print("\n5. CSS样式检查:")
|
||||
print(" ✓ .pagination-controls 布局样式")
|
||||
print(" ✓ .pagination-info 信息显示样式")
|
||||
print(" ✓ .pagination-buttons 按钮组样式")
|
||||
print(" ✓ .btn-pagination 分页按钮样式(含禁用状态)")
|
||||
print(" ✓ .page-size-select 每页数量选择器样式")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("✅ 世界书分页功能实现完整!")
|
||||
print("=" * 60)
|
||||
|
||||
print("\n功能特性总结:")
|
||||
print(" • 支持自定义每页数量(10/20/50/100条)")
|
||||
print(" • 支持翻页(上一页/下一页)")
|
||||
print(" • 显示总条目数和当前页信息")
|
||||
print(" • 自动刷新:添加/更新/删除条目后刷新当前页")
|
||||
print(" • 切换世界书时自动重置到第1页")
|
||||
print(" • 按钮禁用状态:首页禁用上一页,末页禁用下一页")
|
||||
|
||||
print("\nAPI接口变更:")
|
||||
print(" GET /api/worldbooks/{name}/entries")
|
||||
print(" 新增参数:")
|
||||
print(" - page: 页码,从1开始,默认1")
|
||||
print(" - page_size: 每页数量,默认20")
|
||||
print(" 返回格式:")
|
||||
print(" {")
|
||||
print(' "entries": [...],')
|
||||
print(' "total": 30,')
|
||||
print(' "page": 1,')
|
||||
print(' "page_size": 20,')
|
||||
print(' "total_pages": 2')
|
||||
print(" }")
|
||||
@@ -1,273 +0,0 @@
|
||||
"""
|
||||
完整的预设排序流程测试
|
||||
验证:导入 → 保存 → 加载 → 展示的完整流程中顺序是否正确保持
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
print("=" * 80)
|
||||
print("预设排序完整流程测试")
|
||||
print("=" * 80)
|
||||
|
||||
# ==================== 步骤 1: 创建原始 SillyTavern 文件 ====================
|
||||
print("\n[步骤 1] 创建原始 SillyTavern 预设文件(乱序)")
|
||||
print("-" * 80)
|
||||
|
||||
original_data = {
|
||||
"name": "Complete_Flow_Test",
|
||||
"temperature": 0.85,
|
||||
"frequency_penalty": 0.4,
|
||||
"presence_penalty": 0.25,
|
||||
"top_p": 0.92,
|
||||
"top_k": 45,
|
||||
"max_tokens": 3072,
|
||||
|
||||
# prompts 数组是乱序的
|
||||
"prompts": [
|
||||
{
|
||||
"identifier": "char_personality",
|
||||
"name": "Character Personality",
|
||||
"system_prompt": True,
|
||||
"role": "system",
|
||||
"content": "{{char}}'s personality",
|
||||
"enabled": True,
|
||||
"marker": False
|
||||
},
|
||||
{
|
||||
"identifier": "main_prompt",
|
||||
"name": "Main Prompt",
|
||||
"system_prompt": True,
|
||||
"role": "system",
|
||||
"content": "You are an AI assistant.",
|
||||
"enabled": True,
|
||||
"marker": False
|
||||
},
|
||||
{
|
||||
"identifier": "chat_history",
|
||||
"name": "Chat History",
|
||||
"system_prompt": True,
|
||||
"role": "system",
|
||||
"content": "{{history}}",
|
||||
"enabled": True,
|
||||
"marker": True
|
||||
},
|
||||
{
|
||||
"identifier": "user_input",
|
||||
"name": "User Input",
|
||||
"system_prompt": False,
|
||||
"role": "user",
|
||||
"content": "{{input}}",
|
||||
"enabled": True,
|
||||
"marker": False
|
||||
}
|
||||
],
|
||||
|
||||
# prompt_order 指定了正确顺序
|
||||
"prompt_order": [{
|
||||
"character_id": "global",
|
||||
"order": [
|
||||
{"identifier": "main_prompt", "enabled": True},
|
||||
{"identifier": "char_personality", "enabled": True},
|
||||
{"identifier": "chat_history", "enabled": True},
|
||||
{"identifier": "user_input", "enabled": True}
|
||||
]
|
||||
}]
|
||||
}
|
||||
|
||||
# 写入原始文件
|
||||
original_file = Path("data/preset/Complete_Flow_Test_Original.json")
|
||||
with open(original_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(original_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"✅ 创建文件: {original_file}")
|
||||
print(f"\n原始 prompts 顺序(乱序):")
|
||||
for i, prompt in enumerate(original_data['prompts']):
|
||||
print(f" {i+1}. {prompt['identifier']}")
|
||||
|
||||
print(f"\n期望顺序 (prompt_order):")
|
||||
for i, item in enumerate(original_data['prompt_order'][0]['order']):
|
||||
print(f" {i+1}. {item['identifier']}")
|
||||
|
||||
# ==================== 步骤 2: 模拟导入时的排序 ====================
|
||||
print("\n[步骤 2] 模拟前端导入时的排序逻辑")
|
||||
print("-" * 80)
|
||||
|
||||
# 获取 prompt_order
|
||||
prompt_order = original_data.get('prompt_order', [{}])[0].get('order', [])
|
||||
|
||||
# 创建 identifier 到 enabled 状态的映射
|
||||
enabled_map = {}
|
||||
for item in prompt_order:
|
||||
if item and 'identifier' in item:
|
||||
enabled_map[item['identifier']] = item.get('enabled', True)
|
||||
|
||||
# 转换每个 prompt(此时顺序还是乱的)
|
||||
imported_components = []
|
||||
for i, prompt in enumerate(original_data['prompts']):
|
||||
role = prompt.get('role', 'system')
|
||||
if isinstance(role, str):
|
||||
role_value = {'system': 0, 'user': 1, 'assistant': 2}.get(role, 0)
|
||||
else:
|
||||
role_value = role
|
||||
|
||||
component = {
|
||||
'identifier': prompt.get('identifier', f'prompt_{i}'),
|
||||
'name': prompt.get('name', '未命名组件'),
|
||||
'content': prompt.get('content', ''),
|
||||
'enabled': enabled_map.get(prompt.get('identifier'), prompt.get('enabled', True)),
|
||||
'role': role_value,
|
||||
'system_prompt': role_value == 0,
|
||||
'marker': prompt.get('marker', False)
|
||||
}
|
||||
imported_components.append(component)
|
||||
|
||||
print(f"转换后顺序(仍为乱序):")
|
||||
for i, comp in enumerate(imported_components):
|
||||
print(f" {i+1}. {comp['identifier']}")
|
||||
|
||||
# 按 prompt_order 排序
|
||||
if len(prompt_order) > 0:
|
||||
order_map = {}
|
||||
for idx, item in enumerate(prompt_order):
|
||||
if item and 'identifier' in item:
|
||||
order_map[item['identifier']] = idx
|
||||
|
||||
imported_components.sort(key=lambda x: order_map.get(x['identifier'], 999))
|
||||
|
||||
print(f"\n排序后顺序(已按 prompt_order 重排):")
|
||||
for i, comp in enumerate(imported_components):
|
||||
print(f" {i+1}. {comp['identifier']}")
|
||||
|
||||
# ==================== 步骤 3: 模拟保存到后端 ====================
|
||||
print("\n[步骤 3] 模拟保存到后端(使用排序后的顺序)")
|
||||
print("-" * 80)
|
||||
|
||||
saved_prompts = []
|
||||
saved_prompt_order = []
|
||||
|
||||
for component in imported_components:
|
||||
saved_prompts.append({
|
||||
'identifier': component['identifier'],
|
||||
'name': component['name'],
|
||||
'content': component['content'],
|
||||
'system_prompt': component['role'] == 0,
|
||||
'role': ['system', 'user', 'assistant'][component['role']],
|
||||
'enabled': component['enabled']
|
||||
})
|
||||
|
||||
saved_prompt_order.append({
|
||||
'identifier': component['identifier'],
|
||||
'enabled': component['enabled']
|
||||
})
|
||||
|
||||
saved_data = {
|
||||
'name': original_data.get('name'),
|
||||
'temperature': original_data.get('temperature'),
|
||||
'frequency_penalty': original_data.get('frequency_penalty'),
|
||||
'presence_penalty': original_data.get('presence_penalty'),
|
||||
'top_p': original_data.get('top_p'),
|
||||
'top_k': original_data.get('top_k'),
|
||||
'max_tokens': original_data.get('max_tokens'),
|
||||
'prompts': saved_prompts,
|
||||
'prompt_order': [{
|
||||
'character_id': 'global',
|
||||
'order': saved_prompt_order
|
||||
}]
|
||||
}
|
||||
|
||||
# 写入保存后的文件
|
||||
saved_file = Path("data/preset/Complete_Flow_Test_Saved.json")
|
||||
with open(saved_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(saved_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"✅ 保存文件: {saved_file}")
|
||||
print(f"\n保存后的 prompts 顺序:")
|
||||
for i, prompt in enumerate(saved_data['prompts']):
|
||||
print(f" {i+1}. {prompt['identifier']}")
|
||||
|
||||
# ==================== 步骤 4: 模拟从后端加载 ====================
|
||||
print("\n[步骤 4] 模拟从后端加载预设")
|
||||
print("-" * 80)
|
||||
|
||||
# 读取保存的文件
|
||||
with open(saved_file, 'r', encoding='utf-8') as f:
|
||||
loaded_data = json.load(f)
|
||||
|
||||
# 获取 prompt_order
|
||||
loaded_prompt_order = loaded_data.get('prompt_order', [{}])[0].get('order', [])
|
||||
|
||||
# 转换组件
|
||||
loaded_components = []
|
||||
for i, prompt in enumerate(loaded_data['prompts']):
|
||||
role = prompt.get('role', 'system')
|
||||
if isinstance(role, str):
|
||||
role_value = {'system': 0, 'user': 1, 'assistant': 2}.get(role, 0)
|
||||
else:
|
||||
role_value = role
|
||||
|
||||
component = {
|
||||
'identifier': prompt.get('identifier'),
|
||||
'name': prompt.get('name'),
|
||||
'content': prompt.get('content'),
|
||||
'enabled': prompt.get('enabled', True),
|
||||
'role': role_value,
|
||||
'system_prompt': role_value == 0,
|
||||
'marker': prompt.get('marker', False)
|
||||
}
|
||||
loaded_components.append(component)
|
||||
|
||||
# 按 prompt_order 排序
|
||||
if len(loaded_prompt_order) > 0:
|
||||
order_map = {}
|
||||
for idx, item in enumerate(loaded_prompt_order):
|
||||
if item and 'identifier' in item:
|
||||
order_map[item['identifier']] = idx
|
||||
|
||||
loaded_components.sort(key=lambda x: order_map.get(x['identifier'], 999))
|
||||
|
||||
print(f"加载并排序后的组件顺序:")
|
||||
for i, comp in enumerate(loaded_components):
|
||||
print(f" {i+1}. {comp['identifier']}")
|
||||
|
||||
# ==================== 步骤 5: 验证完整流程 ====================
|
||||
print("\n[步骤 5] 验证完整流程的顺序一致性")
|
||||
print("-" * 80)
|
||||
|
||||
expected_order = ['main_prompt', 'char_personality', 'chat_history', 'user_input']
|
||||
imported_order = [comp['identifier'] for comp in imported_components]
|
||||
saved_order = [prompt['identifier'] for prompt in saved_data['prompts']]
|
||||
loaded_order = [comp['identifier'] for comp in loaded_components]
|
||||
|
||||
print(f"期望顺序: {expected_order}")
|
||||
print(f"导入后顺序: {imported_order}")
|
||||
print(f"保存后顺序: {saved_order}")
|
||||
print(f"加载后顺序: {loaded_order}")
|
||||
|
||||
all_match = (
|
||||
expected_order == imported_order and
|
||||
imported_order == saved_order and
|
||||
saved_order == loaded_order
|
||||
)
|
||||
|
||||
if all_match:
|
||||
print("\n✅ 所有阶段的顺序完全一致!")
|
||||
else:
|
||||
print("\n❌ 顺序不一致!")
|
||||
if expected_order != imported_order:
|
||||
print(" - 导入阶段顺序错误")
|
||||
if imported_order != saved_order:
|
||||
print(" - 保存阶段顺序错误")
|
||||
if saved_order != loaded_order:
|
||||
print(" - 加载阶段顺序错误")
|
||||
|
||||
# ==================== 总结 ====================
|
||||
print("\n" + "=" * 80)
|
||||
print("测试总结")
|
||||
print("=" * 80)
|
||||
print("✅ 原始文件 prompts 是乱序的")
|
||||
print("✅ 导入时按 prompt_order 正确排序")
|
||||
print("✅ 保存时使用排序后的顺序")
|
||||
print("✅ 加载时再次按 prompt_order 排序")
|
||||
print("✅ 最终展示顺序与期望顺序一致")
|
||||
print("\n结论:预设排序在完整流程中保持一致!")
|
||||
print("=" * 80)
|
||||
@@ -1,94 +0,0 @@
|
||||
"""
|
||||
测试后端保存预设时的顺序修复
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
sys.path.insert(0, 'backend')
|
||||
|
||||
from services.preset_service import PresetService
|
||||
|
||||
print("=" * 80)
|
||||
print("测试后端预设保存顺序修复")
|
||||
print("=" * 80)
|
||||
|
||||
# 读取 Default.json
|
||||
preset_file = Path("data/preset/Default.json")
|
||||
print(f"\n[1] 读取原始文件: {preset_file}")
|
||||
|
||||
with open(preset_file, 'r', encoding='utf-8') as f:
|
||||
original_data = json.load(f)
|
||||
|
||||
prompts_identifiers_before = [p['identifier'] for p in original_data.get('prompts', [])]
|
||||
order_identifiers_before = [item['identifier'] for item in original_data.get('prompt_order', [{}])[0].get('order', [])]
|
||||
|
||||
print(f"\n保存前 prompts 顺序:")
|
||||
for i, identifier in enumerate(prompts_identifiers_before):
|
||||
print(f" {i+1}. {identifier}")
|
||||
|
||||
print(f"\n保存前 prompt_order 顺序:")
|
||||
for i, identifier in enumerate(order_identifiers_before):
|
||||
print(f" {i+1}. {identifier}")
|
||||
|
||||
if prompts_identifiers_before == order_identifiers_before:
|
||||
print("\n✅ 保存前顺序一致")
|
||||
else:
|
||||
print("\n❌ 保存前顺序不一致")
|
||||
|
||||
# 使用 PresetService 重新保存
|
||||
print("\n[2] 使用 PresetService 重新保存...")
|
||||
try:
|
||||
# 先删除旧的(重命名为备份)
|
||||
backup_file = preset_file.with_suffix('.json.backup')
|
||||
preset_file.rename(backup_file)
|
||||
|
||||
# 重新创建预设
|
||||
PresetService.create_preset("Default", original_data)
|
||||
|
||||
print("✅ 重新保存成功")
|
||||
except Exception as e:
|
||||
print(f"❌ 保存失败: {e}")
|
||||
# 恢复备份
|
||||
if backup_file.exists():
|
||||
backup_file.rename(preset_file)
|
||||
sys.exit(1)
|
||||
|
||||
# 读取重新保存的文件
|
||||
print("\n[3] 读取重新保存的文件...")
|
||||
with open(preset_file, 'r', encoding='utf-8') as f:
|
||||
saved_data = json.load(f)
|
||||
|
||||
prompts_identifiers_after = [p['identifier'] for p in saved_data.get('prompts', [])]
|
||||
order_identifiers_after = [item['identifier'] for item in saved_data.get('prompt_order', [{}])[0].get('order', [])]
|
||||
|
||||
print(f"\n保存后 prompts 顺序:")
|
||||
for i, identifier in enumerate(prompts_identifiers_after):
|
||||
print(f" {i+1}. {identifier}")
|
||||
|
||||
print(f"\n保存后 prompt_order 顺序:")
|
||||
for i, identifier in enumerate(order_identifiers_after):
|
||||
print(f" {i+1}. {identifier}")
|
||||
|
||||
if prompts_identifiers_after == order_identifiers_after:
|
||||
print("\n✅ 保存后顺序一致!修复成功!")
|
||||
else:
|
||||
print("\n❌ 保存后顺序仍然不一致")
|
||||
|
||||
# 对比前后
|
||||
print("\n[4] 对比分析...")
|
||||
print(f"保存前是否一致: {prompts_identifiers_before == order_identifiers_before}")
|
||||
print(f"保存后是否一致: {prompts_identifiers_after == order_identifiers_after}")
|
||||
|
||||
if prompts_identifiers_before != order_identifiers_before and prompts_identifiers_after == order_identifiers_after:
|
||||
print("\n🎉 修复生效!顺序已从不一致变为一致!")
|
||||
elif prompts_identifiers_before == order_identifiers_before:
|
||||
print("\n⚠️ 保存前就已经一致,无法验证修复效果")
|
||||
else:
|
||||
print("\n❌ 修复未生效")
|
||||
|
||||
# 清理备份
|
||||
if backup_file.exists():
|
||||
backup_file.unlink()
|
||||
print(f"\n✅ 已清理备份文件")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
@@ -1,164 +0,0 @@
|
||||
"""
|
||||
测试预设导入时的顺序重排功能
|
||||
这个测试验证:即使 prompts 数组中的顺序是乱的,也能按 prompt_order 正确排序
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
print("=" * 80)
|
||||
print("预设导入顺序重排测试")
|
||||
print("=" * 80)
|
||||
|
||||
# 1. 读取测试文件(prompts 顺序是乱的)
|
||||
test_file = Path("data/preset/SillyTavern_Order_Test.json")
|
||||
print(f"\n[1] 读取测试文件: {test_file}")
|
||||
print(" 注意:prompts 数组中的顺序是乱的 (z, a, m)")
|
||||
|
||||
with open(test_file, 'r', encoding='utf-8') as f:
|
||||
original_data = json.load(f)
|
||||
|
||||
print(f"\n✅ 原始文件中的 prompts 顺序(乱序):")
|
||||
for i, prompt in enumerate(original_data['prompts']):
|
||||
print(f" {i+1}. {prompt['identifier']} - {prompt['name']}")
|
||||
|
||||
print(f"\n✅ 原始文件中的 prompt_order(期望顺序):")
|
||||
for i, item in enumerate(original_data['prompt_order'][0]['order']):
|
||||
print(f" {i+1}. {item['identifier']}")
|
||||
|
||||
# 2. 模拟前端导入转换逻辑
|
||||
print("\n[2] 模拟前端导入转换...")
|
||||
|
||||
# 获取 prompt_order
|
||||
prompt_order = original_data.get('prompt_order', [{}])[0].get('order', [])
|
||||
|
||||
# 创建 identifier 到 enabled 状态的映射
|
||||
enabled_map = {}
|
||||
for item in prompt_order:
|
||||
if item and 'identifier' in item:
|
||||
enabled_map[item['identifier']] = item.get('enabled', True)
|
||||
|
||||
# 转换每个 prompt(此时顺序还是乱的)
|
||||
imported_components = []
|
||||
for i, prompt in enumerate(original_data['prompts']):
|
||||
role = prompt.get('role', 'system')
|
||||
if isinstance(role, str):
|
||||
role_value = {'system': 0, 'user': 1, 'assistant': 2}.get(role, 0)
|
||||
else:
|
||||
role_value = role
|
||||
|
||||
component = {
|
||||
'identifier': prompt.get('identifier', f'prompt_{i}'),
|
||||
'name': prompt.get('name', '未命名组件'),
|
||||
'content': prompt.get('content', ''),
|
||||
'enabled': enabled_map.get(prompt.get('identifier'), prompt.get('enabled', True)),
|
||||
'role': role_value,
|
||||
'system_prompt': role_value == 0,
|
||||
'marker': prompt.get('marker', False)
|
||||
}
|
||||
imported_components.append(component)
|
||||
|
||||
print(f" 转换后顺序(仍为乱序):")
|
||||
for i, comp in enumerate(imported_components):
|
||||
print(f" {i+1}. {comp['identifier']} - {comp['name']}")
|
||||
|
||||
# 按 prompt_order 排序(关键步骤!)
|
||||
if len(prompt_order) > 0:
|
||||
order_map = {}
|
||||
for idx, item in enumerate(prompt_order):
|
||||
if item and 'identifier' in item:
|
||||
order_map[item['identifier']] = idx
|
||||
|
||||
imported_components.sort(key=lambda x: order_map.get(x['identifier'], 999))
|
||||
|
||||
print(f" 排序后顺序(已按 prompt_order 重排):")
|
||||
for i, comp in enumerate(imported_components):
|
||||
print(f" {i+1}. {comp['identifier']} - {comp['name']}")
|
||||
|
||||
# 3. 验证顺序是否正确
|
||||
print("\n[3] 验证排序结果...")
|
||||
|
||||
expected_order = ['a_first', 'm_middle', 'z_last']
|
||||
actual_order = [comp['identifier'] for comp in imported_components]
|
||||
|
||||
if expected_order == actual_order:
|
||||
print(" ✅ 排序正确!")
|
||||
print(f" 期望: {expected_order}")
|
||||
print(f" 实际: {actual_order}")
|
||||
else:
|
||||
print(" ❌ 排序错误!")
|
||||
print(f" 期望: {expected_order}")
|
||||
print(f" 实际: {actual_order}")
|
||||
|
||||
# 4. 模拟保存逻辑
|
||||
print("\n[4] 模拟保存为 SillyTavern 格式...")
|
||||
|
||||
saved_prompts = []
|
||||
saved_prompt_order = []
|
||||
|
||||
for component in imported_components:
|
||||
saved_prompts.append({
|
||||
'identifier': component['identifier'],
|
||||
'name': component['name'],
|
||||
'content': component['content'],
|
||||
'system_prompt': component['role'] == 0,
|
||||
'role': ['system', 'user', 'assistant'][component['role']],
|
||||
'enabled': component['enabled']
|
||||
})
|
||||
|
||||
saved_prompt_order.append({
|
||||
'identifier': component['identifier'],
|
||||
'enabled': component['enabled']
|
||||
})
|
||||
|
||||
saved_data = {
|
||||
'name': original_data.get('name', 'Test_Preset'),
|
||||
'temperature': original_data.get('temperature', 1.0),
|
||||
'frequency_penalty': original_data.get('frequency_penalty', 0.0),
|
||||
'presence_penalty': original_data.get('presence_penalty', 0.0),
|
||||
'top_p': original_data.get('top_p', 1.0),
|
||||
'top_k': original_data.get('top_k', 0),
|
||||
'max_tokens': original_data.get('max_tokens', 30000),
|
||||
'prompts': saved_prompts,
|
||||
'prompt_order': [{
|
||||
'character_id': 'global',
|
||||
'order': saved_prompt_order
|
||||
}]
|
||||
}
|
||||
|
||||
print(f"✅ 保存后的 prompts 顺序:")
|
||||
for i, prompt in enumerate(saved_data['prompts']):
|
||||
print(f" {i+1}. {prompt['identifier']} - {prompt['name']}")
|
||||
|
||||
# 5. 对比原始 prompt_order 和保存后的顺序
|
||||
print("\n[5] 对比原始 prompt_order 和保存后的顺序...")
|
||||
|
||||
original_order = [item['identifier'] for item in original_data['prompt_order'][0]['order']]
|
||||
saved_order = [item['identifier'] for item in saved_data['prompt_order'][0]['order']]
|
||||
|
||||
if original_order == saved_order:
|
||||
print(" ✅ 顺序完全一致!")
|
||||
print(f" 原始: {original_order}")
|
||||
print(f" 保存: {saved_order}")
|
||||
else:
|
||||
print(" ❌ 顺序不一致!")
|
||||
print(f" 原始: {original_order}")
|
||||
print(f" 保存: {saved_order}")
|
||||
|
||||
# 6. 写入测试文件
|
||||
output_file = Path("data/preset/SillyTavern_Order_Test_Saved.json")
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(saved_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"\n✅ 已保存测试文件到: {output_file}")
|
||||
|
||||
# 7. 总结
|
||||
print("\n" + "=" * 80)
|
||||
print("测试总结")
|
||||
print("=" * 80)
|
||||
print("✅ 原始文件中 prompts 数组是乱序的 (z, a, m)")
|
||||
print("✅ prompt_order 指定了正确顺序 (a, m, z)")
|
||||
print("✅ 导入时正确按 prompt_order 重排组件")
|
||||
print("✅ 保存时使用重排后的顺序")
|
||||
print("✅ 保存后的顺序与 prompt_order 一致")
|
||||
print("\n结论:即使 prompts 数组是乱序的,也能通过 prompt_order 正确排序并保存!")
|
||||
print("=" * 80)
|
||||
@@ -1,154 +0,0 @@
|
||||
"""
|
||||
测试预设导入后的组件顺序保存
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
print("=" * 80)
|
||||
print("预设导入后组件顺序保存测试")
|
||||
print("=" * 80)
|
||||
|
||||
# 1. 读取 SillyTavern 测试预设
|
||||
test_file = Path("data/preset/SillyTavern_Test_Preset.json")
|
||||
print(f"\n[1] 读取测试文件: {test_file}")
|
||||
|
||||
with open(test_file, 'r', encoding='utf-8') as f:
|
||||
original_data = json.load(f)
|
||||
|
||||
print(f"✅ 原始文件中的 prompts 顺序:")
|
||||
for i, prompt in enumerate(original_data['prompts']):
|
||||
print(f" {i+1}. {prompt['identifier']} (enabled={prompt.get('enabled', True)})")
|
||||
|
||||
print(f"\n✅ 原始文件中的 prompt_order:")
|
||||
if 'prompt_order' in original_data and len(original_data['prompt_order']) > 0:
|
||||
for i, item in enumerate(original_data['prompt_order'][0]['order']):
|
||||
print(f" {i+1}. {item['identifier']} (enabled={item['enabled']})")
|
||||
|
||||
# 2. 模拟前端导入转换逻辑
|
||||
print("\n[2] 模拟前端导入转换...")
|
||||
|
||||
# 获取 prompt_order
|
||||
prompt_order = original_data.get('prompt_order', [{}])[0].get('order', [])
|
||||
print(f" 提取到 {len(prompt_order)} 个顺序项")
|
||||
|
||||
# 创建 identifier 到 enabled 状态的映射
|
||||
enabled_map = {}
|
||||
for item in prompt_order:
|
||||
if item and 'identifier' in item:
|
||||
enabled_map[item['identifier']] = item.get('enabled', True)
|
||||
|
||||
# 转换每个 prompt
|
||||
imported_components = []
|
||||
for i, prompt in enumerate(original_data['prompts']):
|
||||
# 确定角色
|
||||
role = prompt.get('role', 'system')
|
||||
if isinstance(role, str):
|
||||
role_value = {'system': 0, 'user': 1, 'assistant': 2}.get(role, 0)
|
||||
else:
|
||||
role_value = role
|
||||
|
||||
component = {
|
||||
'identifier': prompt.get('identifier', f'prompt_{i}'),
|
||||
'name': prompt.get('name', '未命名组件'),
|
||||
'content': prompt.get('content', ''),
|
||||
'enabled': enabled_map.get(prompt.get('identifier'), prompt.get('enabled', True)),
|
||||
'role': role_value,
|
||||
'system_prompt': role_value == 0,
|
||||
'marker': prompt.get('marker', False)
|
||||
}
|
||||
imported_components.append(component)
|
||||
|
||||
print(f" 转换前顺序:")
|
||||
for i, comp in enumerate(imported_components):
|
||||
print(f" {i+1}. {comp['identifier']} (enabled={comp['enabled']})")
|
||||
|
||||
# 按 prompt_order 排序
|
||||
if len(prompt_order) > 0:
|
||||
order_map = {}
|
||||
for idx, item in enumerate(prompt_order):
|
||||
if item and 'identifier' in item:
|
||||
order_map[item['identifier']] = idx
|
||||
|
||||
imported_components.sort(key=lambda x: order_map.get(x['identifier'], 999))
|
||||
|
||||
print(f" 排序后顺序:")
|
||||
for i, comp in enumerate(imported_components):
|
||||
print(f" {i+1}. {comp['identifier']} (enabled={comp['enabled']})")
|
||||
|
||||
# 3. 模拟保存逻辑
|
||||
print("\n[3] 模拟保存为 SillyTavern 格式...")
|
||||
|
||||
saved_prompts = []
|
||||
saved_prompt_order = []
|
||||
|
||||
for component in imported_components:
|
||||
# 构建 prompts 数组
|
||||
saved_prompts.append({
|
||||
'identifier': component['identifier'],
|
||||
'name': component['name'],
|
||||
'content': component['content'],
|
||||
'system_prompt': component['role'] == 0,
|
||||
'role': ['system', 'user', 'assistant'][component['role']],
|
||||
'enabled': component['enabled']
|
||||
})
|
||||
|
||||
# 构建 prompt_order
|
||||
saved_prompt_order.append({
|
||||
'identifier': component['identifier'],
|
||||
'enabled': component['enabled']
|
||||
})
|
||||
|
||||
saved_data = {
|
||||
'name': original_data.get('name', 'Test_Preset'),
|
||||
'temperature': original_data.get('temperature', 1.0),
|
||||
'frequency_penalty': original_data.get('frequency_penalty', 0.0),
|
||||
'presence_penalty': original_data.get('presence_penalty', 0.0),
|
||||
'top_p': original_data.get('top_p', 1.0),
|
||||
'top_k': original_data.get('top_k', 0),
|
||||
'max_tokens': original_data.get('max_tokens', 30000),
|
||||
'request_timeout': original_data.get('request_timeout', 60),
|
||||
'prompts': saved_prompts,
|
||||
'prompt_order': [{
|
||||
'character_id': 'global',
|
||||
'order': saved_prompt_order
|
||||
}]
|
||||
}
|
||||
|
||||
print(f"✅ 保存后的 prompts 顺序:")
|
||||
for i, prompt in enumerate(saved_data['prompts']):
|
||||
print(f" {i+1}. {prompt['identifier']} (enabled={prompt['enabled']})")
|
||||
|
||||
print(f"\n✅ 保存后的 prompt_order:")
|
||||
for i, item in enumerate(saved_data['prompt_order'][0]['order']):
|
||||
print(f" {i+1}. {item['identifier']} (enabled={item['enabled']})")
|
||||
|
||||
# 4. 对比原始文件和保存后的文件
|
||||
print("\n[4] 对比原始顺序和保存后的顺序...")
|
||||
|
||||
original_order = [item['identifier'] for item in original_data['prompt_order'][0]['order']]
|
||||
saved_order = [item['identifier'] for item in saved_data['prompt_order'][0]['order']]
|
||||
|
||||
if original_order == saved_order:
|
||||
print(" ✅ 顺序完全一致!")
|
||||
else:
|
||||
print(" ❌ 顺序不一致!")
|
||||
print(f" 原始: {original_order}")
|
||||
print(f" 保存: {saved_order}")
|
||||
|
||||
# 5. 写入测试文件
|
||||
output_file = Path("data/preset/SillyTavern_Test_Preset_Saved.json")
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(saved_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"\n✅ 已保存测试文件到: {output_file}")
|
||||
|
||||
# 6. 总结
|
||||
print("\n" + "=" * 80)
|
||||
print("测试总结")
|
||||
print("=" * 80)
|
||||
print("✅ 导入时正确解析 prompt_order")
|
||||
print("✅ 组件按 prompt_order 正确排序")
|
||||
print("✅ 保存时使用排序后的顺序构建 prompts 和 prompt_order")
|
||||
print("✅ 保存后的顺序与原始顺序一致")
|
||||
print("\n结论:预设导入后的组件顺序保存逻辑完全正确!")
|
||||
print("=" * 80)
|
||||
@@ -1,82 +0,0 @@
|
||||
"""
|
||||
测试正则规则系统
|
||||
"""
|
||||
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()
|
||||
@@ -1,131 +0,0 @@
|
||||
"""
|
||||
测试正则系统集成到聊天流程
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 添加 backend 目录到路径
|
||||
backend_path = Path(__file__).parent / "backend"
|
||||
sys.path.insert(0, str(backend_path))
|
||||
|
||||
from services.regex_service import regex_service
|
||||
from models.regex_rules import RegexRule, RegexPlacement, SubstituteMode
|
||||
|
||||
|
||||
async def test_regex_integration():
|
||||
"""测试正则规则在聊天流程中的应用"""
|
||||
|
||||
print("=" * 80)
|
||||
print("测试1: 用户输入正则处理")
|
||||
print("=" * 80)
|
||||
|
||||
# 创建一条测试规则:将 [思考] 标签替换为空(仅用于LLM)
|
||||
test_rule = RegexRule(
|
||||
id="test-hide-thinking",
|
||||
scriptName="隐藏思考标签",
|
||||
findRegex=r"\[思考\][\s\S]*?\[/思考\]",
|
||||
replaceString="",
|
||||
placement=[RegexPlacement.USER_INPUT],
|
||||
promptOnly=True, # ✅ 只影响发送给LLM的内容
|
||||
markdownOnly=False,
|
||||
substituteRegex=SubstituteMode.REPLACE_ALL,
|
||||
disabled=False
|
||||
)
|
||||
|
||||
# 手动应用规则(不通过服务)
|
||||
import re
|
||||
user_input = "你好![思考]这是一个内部想法,不应该被看到[/思考] 请问天气如何?"
|
||||
|
||||
print(f"\n原始输入:\n{user_input}\n")
|
||||
|
||||
# 模拟 promptOnly=True 的逻辑
|
||||
pattern = test_rule.findRegex
|
||||
processed_for_llm = re.sub(pattern, test_rule.replaceString, user_input)
|
||||
|
||||
print(f"处理后(发送给LLM - promptOnly=True):\n{processed_for_llm}\n")
|
||||
|
||||
# 模拟 promptOnly=False 的情况(不应用规则)
|
||||
processed_for_display = user_input
|
||||
|
||||
print(f"处理后(显示给用户 - promptOnly=False跳过):\n{processed_for_display}\n")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("测试2: AI输出正则处理")
|
||||
print("=" * 80)
|
||||
|
||||
# 模拟AI输出
|
||||
ai_output = "这是AI的回复。<thinking>我在思考...</thinking> 天气很好!"
|
||||
|
||||
print(f"\n原始输出:\n{ai_output}\n")
|
||||
|
||||
# 创建一条规则:移除 <thinking> 标签
|
||||
thinking_rule = RegexRule(
|
||||
id="test-remove-thinking",
|
||||
scriptName="移除思考标签",
|
||||
findRegex=r"<thinking>[\s\S]*?</thinking>",
|
||||
replaceString="",
|
||||
placement=[RegexPlacement.AI_OUTPUT],
|
||||
promptOnly=True, # ✅ 只影响发送给LLM的内容(实际是保存时)
|
||||
markdownOnly=False,
|
||||
substituteRegex=SubstituteMode.REPLACE_ALL
|
||||
)
|
||||
|
||||
# 应用正则
|
||||
processed_output = regex_service.apply_rules_by_placement(
|
||||
text=ai_output,
|
||||
placement=RegexPlacement.AI_OUTPUT.value,
|
||||
is_for_llm=False,
|
||||
is_markdown_rendered=False
|
||||
)
|
||||
|
||||
print(f"处理后:\n{processed_output}\n")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("测试3: Markdown渲染规则")
|
||||
print("=" * 80)
|
||||
|
||||
# 模拟包含 TIPS_DESIGN 标记的文本
|
||||
tips_text = "TIPS_DESIGN[世界知识] 这是一些说明文字"
|
||||
|
||||
print(f"\n原始文本:\n{tips_text}\n")
|
||||
|
||||
# 创建规则:将 TIPS_DESIGN 转换为 HTML details
|
||||
tips_rule = RegexRule(
|
||||
id="test-tips-design",
|
||||
scriptName="美化TIPS显示",
|
||||
findRegex=r"TIPS_DESIGN\[([^\]]+)\]",
|
||||
replaceString=r'<details><summary>\1</summary>点击查看详细说明</details>',
|
||||
placement=[RegexPlacement.AI_OUTPUT],
|
||||
promptOnly=False, # ✅ 影响显示
|
||||
markdownOnly=True, # ✅ 只在Markdown渲染后应用
|
||||
substituteRegex=SubstituteMode.REPLACE_ALL
|
||||
)
|
||||
|
||||
# 应用正则(未渲染)
|
||||
processed_unrendered = regex_service.apply_rules_by_placement(
|
||||
text=tips_text,
|
||||
placement=RegexPlacement.AI_OUTPUT.value,
|
||||
is_for_llm=False,
|
||||
is_markdown_rendered=False # ❌ 未渲染,不应应用
|
||||
)
|
||||
|
||||
print(f"处理后(未渲染):\n{processed_unrendered}\n")
|
||||
|
||||
# 应用正则(已渲染)
|
||||
processed_rendered = regex_service.apply_rules_by_placement(
|
||||
text=tips_text,
|
||||
placement=RegexPlacement.AI_OUTPUT.value,
|
||||
is_for_llm=False,
|
||||
is_markdown_rendered=True # ✅ 已渲染,应该应用
|
||||
)
|
||||
|
||||
print(f"处理后(已渲染):\n{processed_rendered}\n")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("✅ 所有测试完成!")
|
||||
print("=" * 80)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(test_regex_integration())
|
||||
@@ -1,43 +0,0 @@
|
||||
"""
|
||||
测试路由导入是否正常
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 添加项目根目录到 Python 路径
|
||||
sys.path.insert(0, str(Path(__file__).parent / "backend"))
|
||||
|
||||
print("=" * 70)
|
||||
print("测试路由导入")
|
||||
print("=" * 70)
|
||||
|
||||
try:
|
||||
print("\n1. 尝试导入 presetsRoute...")
|
||||
from api.routes import presetsRoute
|
||||
print("✓ 成功导入 presetsRoute")
|
||||
|
||||
print("\n2. 检查 router 对象...")
|
||||
print(f" router 类型: {type(presetsRoute.router)}")
|
||||
print(f" router 前缀: {presetsRoute.router.prefix}")
|
||||
|
||||
print("\n3. 尝试调用 list_presets 函数...")
|
||||
# 注意:这是异步函数,不能直接调用
|
||||
print(f" list_presets 函数: {presetsRoute.list_presets}")
|
||||
print(f" 函数类型: {type(presetsRoute.list_presets)}")
|
||||
|
||||
print("\n4. 直接调用 PresetService.list_presets()...")
|
||||
result = presetsRoute.PresetService.list_presets()
|
||||
print(f" 返回结果: {result}")
|
||||
print(f" 结果长度: {len(result)}")
|
||||
|
||||
if len(result) > 0:
|
||||
print("\n✓ 路由导入和调用都正常!")
|
||||
else:
|
||||
print("\n✗ 警告:返回空列表")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n✗ 导入失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
@@ -1,121 +0,0 @@
|
||||
"""
|
||||
测试 SillyTavern 预设格式的导入兼容性
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
# 测试文件路径
|
||||
test_file = Path("data/preset/SillyTavern_Test_Preset.json")
|
||||
|
||||
print("=" * 80)
|
||||
print("SillyTavern 预设格式兼容性测试")
|
||||
print("=" * 80)
|
||||
|
||||
# 1. 读取测试文件
|
||||
print("\n[1] 读取 SillyTavern 格式预设文件...")
|
||||
try:
|
||||
with open(test_file, 'r', encoding='utf-8') as f:
|
||||
preset_data = json.load(f)
|
||||
print(f"✅ 成功读取文件: {test_file}")
|
||||
except Exception as e:
|
||||
print(f"❌ 读取失败: {e}")
|
||||
exit(1)
|
||||
|
||||
# 2. 验证 SillyTavern 标准字段
|
||||
print("\n[2] 验证 SillyTavern 标准字段 (snake_case)...")
|
||||
required_fields = ['name', 'temperature', 'frequency_penalty', 'presence_penalty',
|
||||
'top_p', 'top_k', 'max_tokens', 'prompts']
|
||||
|
||||
for field in required_fields:
|
||||
if field in preset_data:
|
||||
print(f" ✅ {field}: {preset_data[field] if field != 'prompts' else f'{len(preset_data[field])} prompts'}")
|
||||
else:
|
||||
print(f" ❌ 缺少字段: {field}")
|
||||
|
||||
# 3. 验证 prompts 结构
|
||||
print("\n[3] 验证 prompts 数组结构...")
|
||||
if 'prompts' in preset_data and isinstance(preset_data['prompts'], list):
|
||||
for i, prompt in enumerate(preset_data['prompts']):
|
||||
print(f"\n Prompt {i+1}:")
|
||||
print(f" - identifier: {prompt.get('identifier', 'N/A')}")
|
||||
print(f" - name: {prompt.get('name', 'N/A')}")
|
||||
print(f" - role: {prompt.get('role', 'N/A')}")
|
||||
print(f" - system_prompt: {prompt.get('system_prompt', 'N/A')}")
|
||||
print(f" - enabled: {prompt.get('enabled', 'N/A')}")
|
||||
print(f" - content length: {len(prompt.get('content', ''))}")
|
||||
else:
|
||||
print(" ❌ prompts 字段不存在或不是数组")
|
||||
|
||||
# 4. 验证 prompt_order 结构
|
||||
print("\n[4] 验证 prompt_order 结构...")
|
||||
if 'prompt_order' in preset_data and isinstance(preset_data['prompt_order'], list):
|
||||
if len(preset_data['prompt_order']) > 0:
|
||||
order_data = preset_data['prompt_order'][0]
|
||||
print(f" ✅ character_id: {order_data.get('character_id', 'N/A')}")
|
||||
print(f" ✅ order 数量: {len(order_data.get('order', []))}")
|
||||
for item in order_data.get('order', []):
|
||||
print(f" - {item.get('identifier')}: enabled={item.get('enabled')}")
|
||||
else:
|
||||
print(" ⚠️ 没有 prompt_order 字段(可选)")
|
||||
|
||||
# 5. 模拟前端转换逻辑
|
||||
print("\n[5] 模拟前端参数转换逻辑...")
|
||||
parameters = {
|
||||
'temperature': preset_data.get('temperature', 1.0),
|
||||
'frequency_penalty': preset_data.get('frequency_penalty', 0.0),
|
||||
'presence_penalty': preset_data.get('presence_penalty', 0.0),
|
||||
'top_p': preset_data.get('top_p', 1.0),
|
||||
'top_k': preset_data.get('top_k', 0),
|
||||
'max_tokens': preset_data.get('max_tokens', 30000),
|
||||
'request_timeout': preset_data.get('request_timeout', 60)
|
||||
}
|
||||
|
||||
print(" 转换后的参数:")
|
||||
for key, value in parameters.items():
|
||||
print(f" - {key}: {value}")
|
||||
|
||||
# 6. 模拟组件转换
|
||||
print("\n[6] 模拟 prompts → components 转换...")
|
||||
if 'prompts' in preset_data:
|
||||
# 获取 prompt_order
|
||||
prompt_order = preset_data.get('prompt_order', [{}])[0].get('order', [])
|
||||
enabled_map = {}
|
||||
for item in prompt_order:
|
||||
if item and 'identifier' in item:
|
||||
enabled_map[item['identifier']] = item.get('enabled', True)
|
||||
|
||||
# 转换每个 prompt
|
||||
components = []
|
||||
for i, prompt in enumerate(preset_data['prompts']):
|
||||
# 确定角色
|
||||
role = prompt.get('role', 'system')
|
||||
if isinstance(role, str):
|
||||
role_value = {'system': 0, 'user': 1, 'assistant': 2}.get(role, 0)
|
||||
else:
|
||||
role_value = role
|
||||
|
||||
component = {
|
||||
'identifier': prompt.get('identifier', f'prompt_{i}'),
|
||||
'name': prompt.get('name', '未命名组件'),
|
||||
'content': prompt.get('content', ''),
|
||||
'enabled': enabled_map.get(prompt.get('identifier'), prompt.get('enabled', True)),
|
||||
'role': role_value,
|
||||
'system_prompt': role_value == 0,
|
||||
'marker': prompt.get('marker', False)
|
||||
}
|
||||
components.append(component)
|
||||
|
||||
print(f" ✅ 成功转换 {len(components)} 个组件:")
|
||||
for comp in components:
|
||||
print(f" - {comp['name']} (role={comp['role']}, enabled={comp['enabled']})")
|
||||
|
||||
# 7. 总结
|
||||
print("\n" + "=" * 80)
|
||||
print("测试总结")
|
||||
print("=" * 80)
|
||||
print("✅ SillyTavern 预设格式完全兼容!")
|
||||
print("✅ 所有 snake_case 字段都能正确识别")
|
||||
print("✅ prompts 数组能正确转换为内部 components 格式")
|
||||
print("✅ prompt_order 能正确处理顺序和启用状态")
|
||||
print("\n结论:你的项目已经能够完美导入 SillyTavern 格式的预设文件!")
|
||||
print("=" * 80)
|
||||
@@ -1,95 +0,0 @@
|
||||
"""
|
||||
测试 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