diff --git a/.gitignore b/.gitignore index 4f031a9..cac249e 100644 --- a/.gitignore +++ b/.gitignore @@ -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.* diff --git a/API_CONFIG_DEBUG.md b/API_CONFIG_DEBUG.md deleted file mode 100644 index 9b8dbca..0000000 --- a/API_CONFIG_DEBUG.md +++ /dev/null @@ -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 配置页面的截图 \ No newline at end of file diff --git a/APP_INITIALIZATION_OPTIMIZATION.md b/APP_INITIALIZATION_OPTIMIZATION.md deleted file mode 100644 index 48f9e7c..0000000 --- a/APP_INITIALIZATION_OPTIMIZATION.md +++ /dev/null @@ -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% -- 操作步骤大幅简化 -- 无需手动切换页面配置 - -✅ **技术优势** -- 并行加载,速度更快 -- 容错性强,单个失败不影响整体 -- 详细日志,便于调试 - -✅ **代码质量** -- 清晰的加载逻辑 -- 完善的错误处理 -- 性能监控和统计 - -这种优化让应用更加"开箱即用",用户打开即可开始使用,无需繁琐的初始配置步骤。 \ No newline at end of file diff --git a/CHAT_MANAGER_AND_PROMPT_DEBUG.md b/CHAT_MANAGER_AND_PROMPT_DEBUG.md deleted file mode 100644 index e5827ab..0000000 --- a/CHAT_MANAGER_AND_PROMPT_DEBUG.md +++ /dev/null @@ -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计数统计 diff --git a/CHAT_SUMMARY_TEST_GUIDE.md b/CHAT_SUMMARY_TEST_GUIDE.md deleted file mode 100644 index 851dbf7..0000000 --- a/CHAT_SUMMARY_TEST_GUIDE.md +++ /dev/null @@ -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` diff --git a/FIX_GLOBAL_WORLDBOOK_SYNC.md b/FIX_GLOBAL_WORLDBOOK_SYNC.md deleted file mode 100644 index 7f8aa11..0000000 --- a/FIX_GLOBAL_WORLDBOOK_SYNC.md +++ /dev/null @@ -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与后端数据保持一致 -✅ **用户体验**: 不再显示"幽灵"世界书 -✅ **代码健壮**: 增加了数据一致性检查机制 diff --git a/PROMPT_COMPONENT_ASSEMBLY_FIX.md b/PROMPT_COMPONENT_ASSEMBLY_FIX.md deleted file mode 100644 index 64dcedf..0000000 --- a/PROMPT_COMPONENT_ASSEMBLY_FIX.md +++ /dev/null @@ -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}}` | 对话示例 | `\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. **性能优化** - - 缓存模板替换结果 - - 避免重复计算 diff --git a/README.md b/README.md index 37c3a5e..3fc18bf 100644 --- a/README.md +++ b/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 -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": "[\\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 或联系维护者。 \ No newline at end of file +## 许可证 + +本项目遵循与 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 diff --git a/REGEX_INTEGRATION_COMPLETE.md b/REGEX_INTEGRATION_COMPLETE.md deleted file mode 100644 index 50bcc28..0000000 --- a/REGEX_INTEGRATION_COMPLETE.md +++ /dev/null @@ -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": "/[\\s\\S]*?<\\/thinking>/gs", - "replaceString": "", - "placement": [2], - "promptOnly": true, - "markdownOnly": false -} -``` - -**效果**: -- ✅ 发送给LLM:不包含 `` 标签 -- ✅ 用户看到:仍然显示 `` 标签 - ---- - -### 示例2:美化TIPS显示(仅影响渲染) - -**规则配置**: -```json -{ - "scriptName": "世界知识", - "findRegex": "/TIPS_DESIGN\\[世界知识\\]/g", - "replaceString": "
点击查看...
", - "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 -**状态**: ✅ 核心功能已完成,可投入使用 diff --git a/REMOVE_CONFIRM_DIALOG.md b/REMOVE_CONFIRM_DIALOG.md deleted file mode 100644 index 0678be8..0000000 --- a/REMOVE_CONFIRM_DIALOG.md +++ /dev/null @@ -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 - -``` - -**修改后**: -```jsx - -``` - ---- - -## 🎯 影响范围 - -### 用户交互变化 - -**之前**: -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组件而非浏览器原生弹窗。 diff --git a/REROLL_DEBUG_GUIDE.md b/REROLL_DEBUG_GUIDE.md deleted file mode 100644 index 223ff17..0000000 --- a/REROLL_DEBUG_GUIDE.md +++ /dev/null @@ -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. ✅ 不影响页面其他部分 diff --git a/REROLL_IMPROVEMENT.md b/REROLL_IMPROVEMENT.md deleted file mode 100644 index bea75f8..0000000 --- a/REROLL_IMPROVEMENT.md +++ /dev/null @@ -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` diff --git a/SETTINGS_TEST_GUIDE.md b/SETTINGS_TEST_GUIDE.md deleted file mode 100644 index c5240f4..0000000 --- a/SETTINGS_TEST_GUIDE.md +++ /dev/null @@ -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. 修改前缀为:`` -3. 修改后缀为:`` -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. **规则模板** - 提供常用规则模板 diff --git a/SIDEBAR_CONTEXT_MENU.md b/SIDEBAR_CONTEXT_MENU.md deleted file mode 100644 index c445035..0000000 --- a/SIDEBAR_CONTEXT_MENU.md +++ /dev/null @@ -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. 可以添加菜单项的图标自定义功能 diff --git a/SILLYTAVERN_REGEX_ARCHITECTURE.md b/SILLYTAVERN_REGEX_ARCHITECTURE.md deleted file mode 100644 index 5490fd9..0000000 --- a/SILLYTAVERN_REGEX_ARCHITECTURE.md +++ /dev/null @@ -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 - 推理/思考内容(如 标签) -``` - -**示例**: -```json -{ - "placement": [1, 2], // 同时应用于用户输入和AI输出 - "scriptName": "隐藏思考标签", - "findRegex": "/[\\s\\S]*?<\\/thinking>/gs", - "replaceString": "" -} -``` - ---- - -### 3. 作用范围控制(关键!) - -#### `promptOnly: boolean` - -**是否仅应用于发送给 LLM 的提示词** - -- `true`: - - ✅ 只在发送给LLM**之前**应用 - - ❌ 前端显示时**不应用** - - 🎯 用途:清理发送给LLM的内容,但保留原始内容给用户看 - -- `false`: - - ✅ 在前端显示时也应用 - - 🎯 用途:修改用户看到的内容 - -**示例场景**: -```json -{ - "scriptName": "去除尾部破甲", - "findRegex": "/.*?<\\/disclaimer>/gs", - "replaceString": "", - "promptOnly": true, // ✅ 只影响发送给LLM的内容 - "markdownOnly": false, - "placement": [2] // AI输出 -} -``` -效果: -- 发送给LLM:不包含 `` 标签 -- 用户看到:仍然显示 `` 标签 - ---- - -#### `markdownOnly: boolean` - -**是否仅应用于 Markdown 渲染后的内容** - -- `true`: - - ✅ 只对最终渲染的HTML应用 - - ❌ 不对原始文本应用 - - 🎯 用途:修改显示效果,不影响原始数据 - -- `false`: - - ✅ 对原始文本应用 - - 🎯 用途:修改实际内容 - -**示例场景**: -```json -{ - "scriptName": "世界知识", - "findRegex": "/TIPS_DESIGN\\[世界知识\\]/g", - "replaceString": "
...
", - "promptOnly": false, - "markdownOnly": true, // ✅ 只影响Markdown渲染 - "placement": [1, 2] -} -``` -效果: -- 原始文本:保持 `TIPS_DESIGN[世界知识]` -- 渲染后:显示为折叠的 `
` 元素 - ---- - -### 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": "/[\\s\\S]*?<\\/thinking>/gs", - "replaceString": "", - "placement": [2], // AI输出 - "promptOnly": true, // 只影响发送给LLM的内容 - "markdownOnly": false, - "disabled": false -} -``` - -**效果**: -- 发送给LLM:移除 `` 标签 -- 用户看到:仍然显示 `` 标签 - ---- - -### 示例2:美化TIPS显示 - -```json -{ - "id": "tips-design-001", - "scriptName": "世界知识", - "findRegex": "/TIPS_DESIGN\\[世界知识\\]/g", - "replaceString": "
点击查看说明...
", - "placement": [1, 2], // 用户输入 + AI输出 - "promptOnly": false, // 影响显示 - "markdownOnly": true, // 只影响Markdown渲染 - "disabled": false -} -``` - -**效果**: -- 原始文本:保持 `TIPS_DESIGN[世界知识]` -- 渲染后:显示为可折叠的详情框 - ---- - -### 示例3:去除免责声明 - -```json -{ - "id": "remove-disclaimer-001", - "scriptName": "去除尾部破甲", - "findRegex": "/.*?<\\/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 diff --git a/SWIPE_BUTTON_FIX.md b/SWIPE_BUTTON_FIX.md deleted file mode 100644 index 907871d..0000000 --- a/SWIPE_BUTTON_FIX.md +++ /dev/null @@ -1,105 +0,0 @@ -# Swipe 切换功能优化 - -## 问题描述 - -之前的实现中,swipe 切换按钮只在**最新消息**上显示,这导致用户无法查看和切换历史消息的不同版本。 - -## SillyTavern 的设计 - -在 SillyTavern 中,**所有包含多个版本的 AI 消息**都会显示 swipe 切换按钮,用户可以随时查看和切换任何消息的不同版本。 - -## 修复内容 - -### 1. **移除 isLatestMessage 限制** - -**修改前:** -```jsx -{hasSwipes && !isUser && isLatestMessage && ( -
- ... -
-)} -``` - -**修改后:** -```jsx -{/* ✅ Swipe 控制按钮 - 所有有 swipe 的 AI 消息都显示 */} -{hasSwipes && !isUser && ( -
- ... -
-)} -``` - -### 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 版本的功能 diff --git a/SWIPE_PERSISTENCE_FIX.md b/SWIPE_PERSISTENCE_FIX.md deleted file mode 100644 index a984ee6..0000000 --- a/SWIPE_PERSISTENCE_FIX.md +++ /dev/null @@ -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 && ( -
- - {currentSwipeIndex + 1}/{message.swipes.length} - -
-)} -``` - -### 关键点 - -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 视图) diff --git a/SWIPE_REROLL_COMPARISON.md b/SWIPE_REROLL_COMPARISON.md deleted file mode 100644 index 9e7f0b7..0000000 --- a/SWIPE_REROLL_COMPARISON.md +++ /dev/null @@ -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 && ( -
- - {currentSwipeIndex + 1}/{message.swipes.length} - -
-)} -``` -✅ **符合 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 && ( -
handleRerollMessage(contextMenu.message)}> - 🔄 - 重roll -
-)} -``` -✅ **符合 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 的设计规范。 diff --git a/SWIPE_TEST_GUIDE.md b/SWIPE_TEST_GUIDE.md deleted file mode 100644 index b1066cf..0000000 --- a/SWIPE_TEST_GUIDE.md +++ /dev/null @@ -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` diff --git a/TEST_API_CONFIG.md b/TEST_API_CONFIG.md deleted file mode 100644 index 7919882..0000000 --- a/TEST_API_CONFIG.md +++ /dev/null @@ -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 | - ---- - -**祝测试顺利!** 🎉 diff --git a/WEBSOCKET_LOGS_EXAMPLE.md b/WEBSOCKET_LOGS_EXAMPLE.md deleted file mode 100644 index c9923e0..0000000 --- a/WEBSOCKET_LOGS_EXAMPLE.md +++ /dev/null @@ -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. ✅ 优化用户体验 - -建议在开发和测试阶段使用详细日志,在生产环境中根据需要调整日志级别。 \ No newline at end of file diff --git a/WORLDBOOK_ACTIVATION_LOGIC.md b/WORLDBOOK_ACTIVATION_LOGIC.md deleted file mode 100644 index 4c6cf19..0000000 --- a/WORLDBOOK_ACTIVATION_LOGIC.md +++ /dev/null @@ -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 => ( -
-
- {positionLabels[pos]} ({pos}) -
- {/* 渲染该位置的所有条目 */} -
-))} -``` - -## 扩展方向 - -### 短期优化 -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个插入位置) -- ✅ 详细统计分析(按位置和触发类型) -- ✅ 完整日志记录(便于调试) - -这为动态上下文管理提供了强大的基础,可以根据对话内容智能地注入相关知识。 diff --git a/WORLDBOOK_API_CHECK.md b/WORLDBOOK_API_CHECK.md deleted file mode 100644 index 92d9fbe..0000000 --- a/WORLDBOOK_API_CHECK.md +++ /dev/null @@ -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 => ( -
- ... -
-))} -``` - -**数据来源**: -- `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** - -- 下拉框正确读取所有世界书 -- 分页参数正确传递 -- 数据格式匹配 -- 错误处理完善 -- 用户体验流畅 diff --git a/backend/api/routes/chatWsRoute.py b/backend/api/routes/chatWsRoute.py index 4731f98..8b9a84f 100644 --- a/backend/api/routes/chatWsRoute.py +++ b/backend/api/routes/chatWsRoute.py @@ -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 diff --git a/backend/api/routes/chatsRoute.py b/backend/api/routes/chatsRoute.py index c339346..9f6d735 100644 --- a/backend/api/routes/chatsRoute.py +++ b/backend/api/routes/chatsRoute.py @@ -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)}") diff --git a/backend/api/routes/presetsRoute.py b/backend/api/routes/presetsRoute.py index 9815dcd..6c2cc30 100644 --- a/backend/api/routes/presetsRoute.py +++ b/backend/api/routes/presetsRoute.py @@ -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): """删除指定预设""" diff --git a/backend/api/routes/regexRoute.py b/backend/api/routes/regexRoute.py index 770abb4..3b2a8f7 100644 --- a/backend/api/routes/regexRoute.py +++ b/backend/api/routes/regexRoute.py @@ -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)) diff --git a/backend/services/chat_service.py b/backend/services/chat_service.py index 4030179..040e5d3 100644 --- a/backend/services/chat_service.py +++ b/backend/services/chat_service.py @@ -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 # 全局实例 diff --git a/backend/services/chat_workflow_service.py b/backend/services/chat_workflow_service.py index 8ee1cad..e491630 100644 --- a/backend/services/chat_workflow_service.py +++ b/backend/services/chat_workflow_service.py @@ -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: diff --git a/backend/services/js_sandbox.py b/backend/services/js_sandbox.py new file mode 100644 index 0000000..e0452da --- /dev/null +++ b/backend/services/js_sandbox.py @@ -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✅ 所有测试通过!") diff --git a/backend/services/preset_service.py b/backend/services/preset_service.py index 343dceb..9dcb6f3 100644 --- a/backend/services/preset_service.py +++ b/backend/services/preset_service.py @@ -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]: """ diff --git a/backend/services/regex_service.py b/backend/services/regex_service.py index fc21ce0..86b79cd 100644 --- a/backend/services/regex_service.py +++ b/backend/services/regex_service.py @@ -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]: diff --git a/backend/services/script_manager.py b/backend/services/script_manager.py new file mode 100644 index 0000000..82641bd --- /dev/null +++ b/backend/services/script_manager.py @@ -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✅ 脚本管理测试完成!") diff --git a/check_frontend_api.py b/check_frontend_api.py deleted file mode 100644 index ceeb431..0000000 --- a/check_frontend_api.py +++ /dev/null @@ -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) diff --git a/check_preset_order.py b/check_preset_order.py deleted file mode 100644 index cb97b10..0000000 --- a/check_preset_order.py +++ /dev/null @@ -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) diff --git a/check_worldbook_path.py b/check_worldbook_path.py deleted file mode 100644 index 1911966..0000000 --- a/check_worldbook_path.py +++ /dev/null @@ -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) diff --git a/clear_default_avatar.py b/clear_default_avatar.py deleted file mode 100644 index b28829c..0000000 --- a/clear_default_avatar.py +++ /dev/null @@ -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) diff --git a/convert_worldbooks.py b/convert_worldbooks.py deleted file mode 100644 index ab721ab..0000000 --- a/convert_worldbooks.py +++ /dev/null @@ -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 后缀,确认无误后可删除") diff --git a/create_demon_girlfriend_test.py b/create_demon_girlfriend_test.py deleted file mode 100644 index 1879b9e..0000000 --- a/create_demon_girlfriend_test.py +++ /dev/null @@ -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() diff --git a/data/regex/presets/MyPreset.json b/data/regex/presets/MyPreset.json index 97b9fe3..c61060c 100644 --- a/data/regex/presets/MyPreset.json +++ b/data/regex/presets/MyPreset.json @@ -1,10 +1,10 @@ { - "id": "ed1d0482-7f0c-474f-8146-35794fce82af", + "id": "4b674802-fcff-47d3-baf5-8acb21e055aa", "name": "MyPreset 规则集", "description": "从 SillyTavern 导入的规则", "rules": [ { - "id": "0fd10037-6090-42c5-8d2b-020c4e792d88", + "id": "78b99f83-1fca-4a74-aa23-c19081774315", "scriptName": "隐藏-thinking", "findRegex": ".*?<\\/thinking>", "replaceString": "", @@ -23,12 +23,12 @@ "presetName": "MyPreset", "disabled": false, "order": 0, - "createdAt": 1777998177, - "updatedAt": 1777998177, + "createdAt": 1778078941, + "updatedAt": 1778078941, "description": null }, { - "id": "f9670e9d-5924-4c84-b058-4ff0593f8030", + "id": "565c2e23-be93-44b8-bfba-e48103484dd2", "scriptName": "隐藏-MVU变量更新", "findRegex": "[\\s\\S]*?", "replaceString": "", @@ -47,12 +47,12 @@ "presetName": "MyPreset", "disabled": false, "order": 1, - "createdAt": 1777998177, - "updatedAt": 1777998177, + "createdAt": 1778078941, + "updatedAt": 1778078941, "description": null }, { - "id": "17578e3d-da97-4249-aeb2-010c769103f3", + "id": "a3411920-e1b2-4cea-8513-dbb41972648a", "scriptName": "隐藏-COT-正文前", "findRegex": ".*", "replaceString": "", @@ -71,12 +71,12 @@ "presetName": "MyPreset", "disabled": false, "order": 2, - "createdAt": 1777998177, - "updatedAt": 1777998177, + "createdAt": 1778078941, + "updatedAt": 1778078941, "description": null }, { - "id": "6e3063a6-9db4-4f8e-9a13-4429420af6f3", + "id": "0e93402e-f9f3-4049-bfc0-4b21b86b9bb4", "scriptName": "隐藏-COT-正文中", "findRegex": ".*?<\\/COT-MID>", "replaceString": "", @@ -95,12 +95,12 @@ "presetName": "MyPreset", "disabled": false, "order": 3, - "createdAt": 1777998177, - "updatedAt": 1777998177, + "createdAt": 1778078941, + "updatedAt": 1778078941, "description": null }, { - "id": "8ff67677-6531-4749-8c8c-11d63061b854", + "id": "dbf06b5e-f694-475d-8ed8-4fdc62833944", "scriptName": "隐藏-表格", "findRegex": ".*?<\\/tableEdit>", "replaceString": "", @@ -119,12 +119,12 @@ "presetName": "MyPreset", "disabled": false, "order": 4, - "createdAt": 1777998177, - "updatedAt": 1777998177, + "createdAt": 1778078941, + "updatedAt": 1778078941, "description": null }, { - "id": "8d0a0c3e-13a1-4f36-92f2-15ce4e9182e5", + "id": "7808d064-d7d1-4f5d-b7a1-f490e5b7b934", "scriptName": "隐藏-表格-确保", "findRegex": "<填写表格>.*?<\\/填写表格>", "replaceString": "", @@ -143,12 +143,12 @@ "presetName": "MyPreset", "disabled": false, "order": 5, - "createdAt": 1777998177, - "updatedAt": 1777998177, + "createdAt": 1778078941, + "updatedAt": 1778078941, "description": null }, { - "id": "2ba157c1-5d85-4b88-a29a-8b3cfe1a6412", + "id": "26beb992-6895-41fc-96a5-0f87c481d034", "scriptName": "对AI隐藏图像提示词", "findRegex": "\\[img-gen\\].*?\\[/img-gen\\]", "replaceString": "", @@ -167,12 +167,12 @@ "presetName": "MyPreset", "disabled": false, "order": 6, - "createdAt": 1777998177, - "updatedAt": 1777998177, + "createdAt": 1778078941, + "updatedAt": 1778078941, "description": null }, { - "id": "bedaebeb-7e5f-47e7-bf70-5b5181e7dae4", + "id": "f9632716-675d-4d66-b022-32ad035a544e", "scriptName": "美化-pornhub小剧场", "findRegex": "(?.*?<\\/pornhub_video>)", "replaceString": "```\n\n\n\n \n \n Pornhub Style Video Card\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```", @@ -191,12 +191,12 @@ "presetName": "MyPreset", "disabled": false, "order": 7, - "createdAt": 1777998177, - "updatedAt": 1777998177, + "createdAt": 1778078941, + "updatedAt": 1778078941, "description": null }, { - "id": "23b07b68-dad9-476b-a202-5a59affcb58f", + "id": "5f345459-8066-4df0-9641-5fa9113740ab", "scriptName": "对AI隐藏-小剧场", "findRegex": ".*?<\\/pornhub_video>", "replaceString": "", @@ -215,12 +215,12 @@ "presetName": "MyPreset", "disabled": false, "order": 8, - "createdAt": 1777998177, - "updatedAt": 1777998177, + "createdAt": 1778078941, + "updatedAt": 1778078941, "description": null }, { - "id": "eaedeb8a-cb88-4539-aca4-3339b0b31f88", + "id": "e301e033-fe97-4b12-b02e-7d8f76a2b324", "scriptName": "对AI隐藏-小剧场-确保", "findRegex": "<小剧场>.*?<\\/小剧场>", "replaceString": "", @@ -239,12 +239,12 @@ "presetName": "MyPreset", "disabled": false, "order": 9, - "createdAt": 1777998177, - "updatedAt": 1777998177, + "createdAt": 1778078941, + "updatedAt": 1778078941, "description": null }, { - "id": "93dd3756-1221-4df6-a962-f332668e75ab", + "id": "37c62238-b6d5-4445-8fa7-068b68e35109", "scriptName": "月经追踪状态栏", "findRegex": "", "replaceString": "```\n\n\n\n\n<%\nif (runType == 'render')\n{\n function SafeGetValue(value, defaultValue = \"\") {\n if (value === undefined || value === null) {\n return defaultValue;\n }\n if (Array.isArray(value)) {\n return value.length !== 0 ? value[0] : defaultValue;\n }\n return value;\n }\n \n function SafeGetDescription(value, defaultValue = \"\") {\n if (Array.isArray(value) && value.length > 1) {\n return value[1];\n }\n return defaultValue;\n }\n \n // 获取周期进度百分比\n function getCycleProgress(day) {\n return Math.min((day / 28) * 100, 100);\n }\n \n // 判断是否为危险期(根据分类判断)\n function isDangerousPeriod(category) {\n return category.includes('排卵期') || category.includes('危险期');\n }\n \n // 获取周期图标(根据全局calculateMenstrualCycle返回的分类)\n function getCycleIcon(category) {\n if (category.includes('月经期')) return '🩸';\n if (category.includes('排卵期')) return '🌸';\n if (category.includes('安全期')) return '✅';\n return '📅';\n }\n \n // 获取周期颜色(根据全局calculateMenstrualCycle返回的分类)\n function getCycleColor(category) {\n if (category.includes('月经期')) return '#DC143C';\n if (category.includes('排卵期')) return '#FF1493';\n if (category.includes('安全期')) return '#32CD32';\n return '#666';\n }\n \n // 获取受孕概率等级(基于天数,与计算器函数逻辑一致)\n function getFertilityLevel(day) {\n if (day >= 1 && day <= 5) return 0; // 月经期\n if (day >= 12 && day <= 16) return 5; // 排卵期高峰\n if (day >= 10 && day <= 19) return 4; // 排卵期\n if (day >= 6 && day <= 22) return 2; // 次级可能\n return 1; // 安全期\n }\n\nconst data = window.TavernHelper.getVariables({type: 'message', message_id: message_id});\nconst msg_data = data.stat_data;\n\n// 动态获取所有有月经数据的角色\nconst characters = [];\nfor (const key in msg_data) {\n if (msg_data[key] && msg_data[key].月经 && msg_data[key].月经.周期天数) {\n // 优先使用姓名字段,如果没有则使用键名\n const displayName = SafeGetValue(msg_data[key].姓名, key);\n characters.push({\n key: key,\n name: displayName,\n data: msg_data[key]\n });\n }\n}\n%>\n\n\n\n
\n
\n \n
\n
\n 🌸\n 月经周期追踪\n <% if (characters.length > 0) { %>\n (<%- characters.length %> 人)\n <% } %>\n
\n
\n
\n\n \n
\n <% if (characters.length === 0) { %>\n
\n
📅
\n
暂无月经周期数据
\n
请在角色数据中添加月经周期信息
\n
\n <% } else { %>\n \n
\n <% characters.forEach((char, index) => { %>\n \n <% }); %>\n
\n\n \n <% characters.forEach((char, index) => { \n const cycleDay = SafeGetValue(char.data.月经.周期天数, 1);\n const cycleInfo = typeof calculateMenstrualCycle !== 'undefined' \n ? calculateMenstrualCycle(Number(cycleDay))\n : ['未知', '数据加载中'];\n const cycleCategory = cycleInfo[0];\n const cyclePhase = cycleInfo[1];\n const cycleIcon = getCycleIcon(cycleCategory);\n const cycleColor = getCycleColor(cycleCategory);\n const cycleProgress = getCycleProgress(cycleDay);\n const isDanger = isDangerousPeriod(cycleCategory);\n // 计算距离下次月经的天数(如果当前在月经期,显示0;否则显示剩余天数)\n const daysUntilPeriod = cycleDay <= 5 ? 0 : (29 - cycleDay);\n const fertilityLevel = getFertilityLevel(cycleDay);\n %>\n
\" class=\"tab-content <%= index === 0 ? 'active' : '' %>\">\n
\n
\n
<%- cycleIcon %>
\n
\n
;\">\n <%- cycleCategory.replace(/(.*?)/g, '') %>\n <% if (isDanger) { %>\n ⚠️ 危险期\n <% } else if (cycleDay >= 1 && cycleDay <= 5) { %>\n \n <% } else { %>\n ✓ 安全期\n <% } %>\n
\n
周期第 <%- cycleDay %> 天 / 共 28 天
\n
\n
\n \n
\n
%; background: linear-gradient(90deg, <%- cycleColor %> 0%, <%- cycleColor %>dd 100%);\">
\n
\n \n
;\">\n <%- cyclePhase %>\n
\n \n
\n
\n
距离下次月经
\n
;\">\n <% if (daysUntilPeriod === 0) { %>\n 进行中\n <% } else { %>\n <%- daysUntilPeriod %> 天\n <% } %>\n
\n
\n
\n
周期进度
\n
;\"><%- Math.round(cycleProgress) %>%
\n
\n
\n \n
; color: <%- cycleColor %>;\">\n
\n <% if (cycleDay >= 1 && cycleDay <= 5) { %>\n 🩸 月经期间\n <% } else if (fertilityLevel >= 4) { %>\n 💋 受孕概率:高\n <% } else if (fertilityLevel >= 2) { %>\n 🤔 受孕概率:中\n <% } else { %>\n 😌 受孕概率:低\n <% } %>\n
\n
\n <% for (let i = 0; i < 5; i++) { %>\n
\">
\n <% } %>\n
\n
\n
\n
\n <% }); %>\n <% } %>\n
\n
\n
\n\n\n\n<% } %>\n\n```\n", @@ -263,12 +263,12 @@ "presetName": "MyPreset", "disabled": true, "order": 10, - "createdAt": 1777998177, - "updatedAt": 1777998177, + "createdAt": 1778078941, + "updatedAt": 1778078941, "description": null }, { - "id": "f77b730b-118c-4eac-9dc9-fd6284581780", + "id": "26ec780e-149f-489d-a177-de875e559e35", "scriptName": "[选项]主题", "findRegex": "((?:(?!).)*?)", "replaceString": "```html\n\n \n \n```", @@ -287,12 +287,12 @@ "presetName": "MyPreset", "disabled": false, "order": 11, - "createdAt": 1777998177, - "updatedAt": 1777998177, + "createdAt": 1778078941, + "updatedAt": 1778078941, "description": null }, { - "id": "6b8a38b4-8db7-4bde-be46-ca6e0c8c18e1", + "id": "e61a23ac-bc46-4d49-9f3f-a1ec4756c857", "scriptName": "[选项]隐藏", "findRegex": "(.*?)<\\/options>", "replaceString": "", @@ -311,13 +311,13 @@ "presetName": "MyPreset", "disabled": false, "order": 12, - "createdAt": 1777998177, - "updatedAt": 1777998177, + "createdAt": 1778078941, + "updatedAt": 1778078941, "description": null } ], - "createdAt": 1777998177, - "updatedAt": 1777998177, + "createdAt": 1778078941, + "updatedAt": 1778078941, "version": 1, "isSillyTavernFormat": true } \ No newline at end of file diff --git a/data/token_usage/2026/05.jsonl b/data/token_usage/2026/05.jsonl index 1545a79..95534b0 100644 --- a/data/token_usage/2026/05.jsonl +++ b/data/token_usage/2026/05.jsonl @@ -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"} diff --git a/data/token_usage/indexes/api_urls.json b/data/token_usage/indexes/api_urls.json index 6cddeba..b63476a 100644 --- a/data/token_usage/indexes/api_urls.json +++ b/data/token_usage/indexes/api_urls.json @@ -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 } } \ No newline at end of file diff --git a/data/token_usage/indexes/daily/2026-05.json b/data/token_usage/indexes/daily/2026-05.json index 16d19c6..295cba4 100644 --- a/data/token_usage/indexes/daily/2026-05.json +++ b/data/token_usage/indexes/daily/2026-05.json @@ -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 } } \ No newline at end of file diff --git a/frontend/src/Store/SideBarLeft/PresetSlice.jsx b/frontend/src/Store/SideBarLeft/PresetSlice.jsx index fe2634d..f6ba750 100644 --- a/frontend/src/Store/SideBarLeft/PresetSlice.jsx +++ b/frontend/src/Store/SideBarLeft/PresetSlice.jsx @@ -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; diff --git a/frontend/src/Store/UserSlice.jsx b/frontend/src/Store/UserSlice.jsx index 3c8e98c..b234739 100644 --- a/frontend/src/Store/UserSlice.jsx +++ b/frontend/src/Store/UserSlice.jsx @@ -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; + }); } }), { diff --git a/frontend/src/components/Mid/ChatBox/ChatBox.css b/frontend/src/components/Mid/ChatBox/ChatBox.css index 12e9a7e..f593946 100644 --- a/frontend/src/components/Mid/ChatBox/ChatBox.css +++ b/frontend/src/components/Mid/ChatBox/ChatBox.css @@ -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 ==================== */ diff --git a/frontend/src/components/Mid/ChatBox/ChatBox.jsx b/frontend/src/components/Mid/ChatBox/ChatBox.jsx index 58d2d90..61aa757 100644 --- a/frontend/src/components/Mid/ChatBox/ChatBox.jsx +++ b/frontend/src/components/Mid/ChatBox/ChatBox.jsx @@ -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 (
handleContextMenu(e, message)} // ✅ 添加右键菜单 > + {/* ✅ Swipe 控制按钮 - 仅在最后一楼显示 */} + {hasSwipes && !isUser && isLastMessage && ( +
+ + + {currentSwipeIndex + 1}/{message.swipes.length} + + +
+ )} +
{displayName} @@ -556,7 +690,7 @@ const ChatBox = () => {