From 9faccc2c03efb51d8ac6c95fe8c695506ecddb57 Mon Sep 17 00:00:00 2001 From: moranzhi Date: Wed, 6 May 2026 00:40:18 +0800 Subject: [PATCH] =?UTF-8?q?=E6=AD=A3=E5=88=99=E7=9B=B8=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHAT_MANAGER_AND_PROMPT_DEBUG.md | 159 +++++ PROMPT_COMPONENT_ASSEMBLY_FIX.md | 394 ++++++++++++ REGEX_INTEGRATION_COMPLETE.md | 396 ++++++++++++ REROLL_IMPROVEMENT.md | 102 ++-- SILLYTAVERN_REGEX_ARCHITECTURE.md | 501 +++++++++++++++ SWIPE_PERSISTENCE_FIX.md | 312 ++++++++++ SWIPE_TEST_GUIDE.md | 238 ++++++++ backend/api/routes/chatWsRoute.py | 115 +++- backend/api/routes/regexRoute.py | 123 +++- backend/models/regex_rules.py | 2 +- backend/services/chat_service.py | 35 ++ backend/services/chat_workflow_service.py | 319 +++++++++- backend/services/regex_service.py | 14 +- data/regex/global/rule-hide-thinking-001.json | 35 ++ data/regex/global/ruleset-global-default.json | 10 + data/regex/presets/MyPreset.json | 323 ++++++++++ data/regex/presets/test.json | 35 ++ data/token_usage/2026/05.jsonl | 7 + data/token_usage/indexes/api_urls.json | 10 +- data/token_usage/indexes/daily/2026-05.json | 8 +- frontend/src/App.jsx | 36 +- frontend/src/Store/Mid/ChatBoxSlice.jsx | 216 +++++-- .../src/Store/SideBarLeft/PresetSlice.jsx | 14 +- .../src/components/Mid/ChatBox/ChatBox.css | 38 +- .../src/components/Mid/ChatBox/ChatBox.jsx | 77 ++- .../SideBarLeft/tabs/Presets/Presets.jsx | 89 ++- .../SideBarLeft/tabs/Regex/RegexPanel.css | 536 ++++++++++++++++ .../SideBarLeft/tabs/Regex/RegexPanel.jsx | 575 ++++++++++++++++++ .../SideBarLeft/tabs/Settings/Settings.jsx | 73 +-- frontend/src/components/TopBar/TopBar.css | 178 ++++++ frontend/src/components/TopBar/TopBar.jsx | 59 +- test_regex_integration.py | 131 ++++ 32 files changed, 4814 insertions(+), 346 deletions(-) create mode 100644 CHAT_MANAGER_AND_PROMPT_DEBUG.md create mode 100644 PROMPT_COMPONENT_ASSEMBLY_FIX.md create mode 100644 REGEX_INTEGRATION_COMPLETE.md create mode 100644 SILLYTAVERN_REGEX_ARCHITECTURE.md create mode 100644 SWIPE_PERSISTENCE_FIX.md create mode 100644 SWIPE_TEST_GUIDE.md create mode 100644 data/regex/global/rule-hide-thinking-001.json create mode 100644 data/regex/global/ruleset-global-default.json create mode 100644 data/regex/presets/MyPreset.json create mode 100644 data/regex/presets/test.json create mode 100644 frontend/src/components/SideBarLeft/tabs/Regex/RegexPanel.css create mode 100644 frontend/src/components/SideBarLeft/tabs/Regex/RegexPanel.jsx create mode 100644 test_regex_integration.py diff --git a/CHAT_MANAGER_AND_PROMPT_DEBUG.md b/CHAT_MANAGER_AND_PROMPT_DEBUG.md new file mode 100644 index 0000000..e5827ab --- /dev/null +++ b/CHAT_MANAGER_AND_PROMPT_DEBUG.md @@ -0,0 +1,159 @@ +# 聊天管理器和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/PROMPT_COMPONENT_ASSEMBLY_FIX.md b/PROMPT_COMPONENT_ASSEMBLY_FIX.md new file mode 100644 index 0000000..64dcedf --- /dev/null +++ b/PROMPT_COMPONENT_ASSEMBLY_FIX.md @@ -0,0 +1,394 @@ +# 预设组件组装修复说明 + +## 问题描述 + +之前的实现中,`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/REGEX_INTEGRATION_COMPLETE.md b/REGEX_INTEGRATION_COMPLETE.md new file mode 100644 index 0000000..50bcc28 --- /dev/null +++ b/REGEX_INTEGRATION_COMPLETE.md @@ -0,0 +1,396 @@ +# 正则系统集成完成报告 + +## 📋 概述 + +已成功将 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/REROLL_IMPROVEMENT.md b/REROLL_IMPROVEMENT.md index 4d2b40d..bea75f8 100644 --- a/REROLL_IMPROVEMENT.md +++ b/REROLL_IMPROVEMENT.md @@ -5,7 +5,7 @@ 本次修改主要解决了以下问题: 1. **修复右键菜单问题**:当 swipe 消失时,右键菜单不能取消 -2. **改变重roll行为**:从添加 swipe 版本改为发送新消息(模仿 SillyTavern 的行为) +2. **改变重roll行为**:在目标消息的 swipe 数组中添加新版本(模仿 SillyTavern 的行为) 3. **改进箭头按钮样式**:模仿 SillyTavern 在气泡栏的最左最右半透明悬浮 ## 具体修改内容 @@ -13,15 +13,11 @@ ### 1. ChatBox.jsx 修改 #### 1.1 重roll函数修改 -- **原行为**:调用 `sendMessage(userMessage.mes, message.floor)` 传入 targetFloor,触发 swipe 添加逻辑 -- **新行为**:调用 `sendMessage(userMessage.mes)` 不传入 targetFloor,创建新的消息对 +- **行为**:调用 `sendMessage(userMessage.mes, message.floor)` 传入 targetFloor,触发在目标消息的swipe数组中添加新版本的逻辑 ```javascript -// 修改前 +// 重roll时传入 targetFloor await sendMessage(userMessage.mes, message.floor); // 传入 targetFloor - -// 修改后 -await sendMessage(userMessage.mes); // 不传入 targetFloor,创建新消息 ``` #### 1.2 右键菜单关闭逻辑增强 @@ -37,27 +33,59 @@ if (contextMenu.visible) { ### 2. ChatBoxSlice.jsx 修改 -#### 2.1 简化 sendMessage 逻辑 -移除了重roll模式的特殊处理,现在无论是否重roll,都创建新的消息对: +#### 2.1 恢复 sendMessage 中的重roll逻辑 +重roll模式会: +- 不创建新的用户消息 +- 使用目标楼层作为助手楼层 +- 在 WebSocket complete 事件中,将新生成的内容添加到目标消息的 swipes 数组中 ```javascript -// 修改前:根据 isReroll 分别处理 if (isReroll) { - // 重roll模式:更新现有消息 + // 重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, { /* 新用户消息 */ }] + }); } - -// 修改后:统一创建新消息 -nextFloor = get().getNextFloor(messages); -userFloor = nextFloor; -assistantFloor = nextFloor + 1; ``` -#### 2.2 移除 WebSocket 消息处理中的重roll特殊逻辑 -- 移除了 chunk 事件中的重roll特殊处理 -- 移除了 complete 事件中的 swipes 数组更新逻辑 -- 统一使用正常的消息创建流程 +#### 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 修改 @@ -92,42 +120,41 @@ assistantFloor = nextFloor + 1; ## 用户体验改进 ### 修改前的问题 -1. 重roll会在当前消息添加新的 swipe 版本,导致消息历史混乱 -2. 右键菜单在滚动时不会自动关闭 -3. Swipe 按钮样式不够美观,不符合 SillyTavern 的风格 +1. 右键菜单在滚动时不会自动关闭 +2. Swipe 按钮样式不够美观,不符合 SillyTavern 的风格 ### 修改后的效果 -1. **重roll行为更符合预期**:每次重roll都会创建新的消息对,保持消息历史的清晰 +1. **重roll行为更符合预期**:每次重roll都会在目标消息的 swipe 数组中添加新版本 2. **右键菜单更稳定**:滚动时会自动关闭,避免界面混乱 3. **Swipe 按钮更美观**:半透明悬浮效果,左右分布,模仿 SillyTavern 的设计 ## 技术细节 -### 为什么改为发送新消息? +### 重roll的工作流程 SillyTavern 的重roll行为是: - 忽略当前 AI 回复 - 使用上一条用户消息重新发送给 LLM -- 创建新的消息对(用户消息 + AI 回复) +- 在目标消息的 swipe 数组中添加新生成的版本 这样做的好处: -1. **保持消息历史清晰**:每次重roll都是独立的消息对 -2. **便于回溯**:可以看到所有尝试过的回复 -3. **符合用户预期**:重roll就是"再来一次",而不是"替换当前回复" +1. **保持消息历史清晰**:每次重roll都在同一楼层添加新版本 +2. **便于比较**:可以通过 swipe 切换查看不同版本的回复 +3. **符合用户预期**:重roll就是“再来一次”,生成新的备选回复 -### Swipe 功能的保留 +### Swipe 功能的作用 -虽然重roll不再使用 swipe,但 swipe 功能仍然保留用于: -- 手动切换已有的多个版本 +Swipe 功能用于: +- 手动切换同一消息的多个版本 +- 重roll时添加新的版本 - 通过 API 或其他方式添加的多个版本 -- 未来可能的其他用途 ## 测试建议 1. **测试重roll功能**: - - 右键点击 AI 消息,选择"重roll" - - 观察是否创建了新的消息对 - - 确认旧消息保持不变 + - 右键点击 AI 消息,选择“重roll” + - 观察是否在目标消息的 swipe 数组中添加了新版本 + - 确认可以通过 swipe 按钮切换不同版本 2. **测试右键菜单**: - 打开右键菜单后滚动页面 @@ -141,6 +168,7 @@ SillyTavern 的重roll行为是: 4. **测试键盘快捷键**: - 使用左右箭头键切换 swipe - 确认最后一个版本时按右键是否触发重roll + - 确认重roll后是否添加了新的 swipe 版本 ## 相关文件 diff --git a/SILLYTAVERN_REGEX_ARCHITECTURE.md b/SILLYTAVERN_REGEX_ARCHITECTURE.md new file mode 100644 index 0000000..5490fd9 --- /dev/null +++ b/SILLYTAVERN_REGEX_ARCHITECTURE.md @@ -0,0 +1,501 @@ +# 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_PERSISTENCE_FIX.md b/SWIPE_PERSISTENCE_FIX.md new file mode 100644 index 0000000..a984ee6 --- /dev/null +++ b/SWIPE_PERSISTENCE_FIX.md @@ -0,0 +1,312 @@ +# 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_TEST_GUIDE.md b/SWIPE_TEST_GUIDE.md new file mode 100644 index 0000000..b1066cf --- /dev/null +++ b/SWIPE_TEST_GUIDE.md @@ -0,0 +1,238 @@ +# 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/backend/api/routes/chatWsRoute.py b/backend/api/routes/chatWsRoute.py index d16dbf8..4731f98 100644 --- a/backend/api/routes/chatWsRoute.py +++ b/backend/api/routes/chatWsRoute.py @@ -389,36 +389,95 @@ async def _save_messages( try: from datetime import datetime - # 1. 保存用户消息 - user_message = { - "id": f"msg_{datetime.now().timestamp()}_user", - "name": request_data.get("userName", "User"), - "is_user": True, - "is_system": False, - "sendDate": datetime.now().isoformat(), - "mes": request_data.get("mes", ""), - "chatId": f"{role_name}/{chat_name}", - "floor": request_data.get("floor", 0) - } + # ✅ 检查是否是重roll模式(targetFloor 存在且不为 null) + target_floor = request_data.get("floor") + is_reroll = target_floor is not None - chat_service.add_message(role_name, chat_name, user_message) - - # 2. 保存AI回复 - ai_message = { - "id": f"msg_{datetime.now().timestamp()}_ai", - "name": request_data.get("characterName", role_name), - "is_user": False, - "is_system": False, - "sendDate": datetime.now().isoformat(), - "mes": ai_response, - "chatId": f"{role_name}/{chat_name}", - "floor": request_data.get("floor", 0) + 1 - } - - chat_service.add_message(role_name, chat_name, ai_message) - - print(f"[WebSocket] 消息已保存: {role_name}/{chat_name}") + if is_reroll: + # ✅ 重roll模式:更新现有消息的 swipes 数组 + print(f"[WebSocket] 🔄 重roll模式,更新楼层 {target_floor} 的 swipes") + + # 获取现有的消息 + existing_message = chat_service.get_message(role_name, chat_name, target_floor) + + if not existing_message: + print(f"[WebSocket] ⚠️ 找不到楼层 {target_floor} 的消息,创建新消息") + # 如果找不到,创建新消息(兼容处理) + ai_message = { + "id": f"msg_{datetime.now().timestamp()}_ai", + "name": request_data.get("characterName", role_name), + "is_user": False, + "is_system": False, + "sendDate": datetime.now().isoformat(), + "mes": ai_response, + "chatId": f"{role_name}/{chat_name}", + "floor": target_floor, + "swipes": [ai_response], + "swipe_id": 0 + } + chat_service.add_message(role_name, chat_name, ai_message) + else: + # ✅ 更新 swipes 数组 + existing_swipes = existing_message.get("swipes", []) + current_mes = existing_message.get("mes", "") + + # 构建新的 swipes 数组 + updated_swipes = list(existing_swipes) # 复制现有swipes + + # 如果当前 mes 不在 swipes 中,先添加它 + if current_mes and current_mes not in updated_swipes: + updated_swipes.append(current_mes) + print(f"[WebSocket] 📝 将当前内容添加到 swipes") + + # 添加新生成的内容 + updated_swipes.append(ai_response) + print(f"[WebSocket] 📊 Swipes 更新: {len(existing_swipes)} -> {len(updated_swipes)}") + + # 更新消息 + 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) + print(f"[WebSocket] ✅ 楼层 {target_floor} 已更新,swipes 数量: {len(updated_swipes)}") + else: + # ✅ 正常模式:创建新的用户消息和AI消息 + print(f"[WebSocket] ➕ 正常模式,创建新消息") + + # 1. 保存用户消息 + user_message = { + "id": f"msg_{datetime.now().timestamp()}_user", + "name": request_data.get("userName", "User"), + "is_user": True, + "is_system": False, + "sendDate": datetime.now().isoformat(), + "mes": request_data.get("mes", ""), + "chatId": f"{role_name}/{chat_name}", + "floor": request_data.get("floor", 0) + } + + chat_service.add_message(role_name, chat_name, user_message) + + # 2. 保存AI回复 + ai_message = { + "id": f"msg_{datetime.now().timestamp()}_ai", + "name": request_data.get("characterName", role_name), + "is_user": False, + "is_system": False, + "sendDate": datetime.now().isoformat(), + "mes": ai_response, + "chatId": f"{role_name}/{chat_name}", + "floor": request_data.get("floor", 0) + 1 + } + + chat_service.add_message(role_name, chat_name, ai_message) + + print(f"[WebSocket] ✅ 新消息已保存: {role_name}/{chat_name}") except Exception as e: print(f"[WebSocket] 保存消息失败: {e}") + import traceback + traceback.print_exc() # 不抛出异常,避免影响主流程 diff --git a/backend/api/routes/regexRoute.py b/backend/api/routes/regexRoute.py index 15d992e..770abb4 100644 --- a/backend/api/routes/regexRoute.py +++ b/backend/api/routes/regexRoute.py @@ -15,7 +15,7 @@ from services.system_settings_service import system_settings_service logger = logging.getLogger(__name__) -router = APIRouter(prefix="/api/regex", tags=["regex"]) +router = APIRouter(prefix="/regex", tags=["regex"]) # ==================== 数据模型 ==================== @@ -146,13 +146,68 @@ async def add_rule(request: RuleUpdateRequest): @router.delete("/rules/{rule_id}") -async def delete_rule(rule_id: str): - """删除规则(需要指定作用域和名称)""" - # TODO: 实现具体的删除逻辑 - return { - "success": False, - "message": "暂未实现,请使用规则集级别的删除" - } +async def delete_rule(rule_id: str, scope: str = "global", name: Optional[str] = None): + """ + 删除规则 + + Args: + rule_id: 规则ID + scope: 作用域 (global/character/preset) + name: 角色名或预设名(scope 为 character/preset 时需要) + """ + try: + from models.regex_rules import RegexScope + + scope_map = { + "global": RegexScope.GLOBAL, + "character": RegexScope.CHARACTER, + "preset": RegexScope.PRESET + } + scope_enum = scope_map.get(scope, RegexScope.GLOBAL) + + # 找到包含该规则的规则集 + if scope_enum == RegexScope.GLOBAL: + rulesets = regex_service.global_rulesets + elif scope_enum == RegexScope.CHARACTER: + if not name: + raise ValueError("删除角色规则需要提供角色名称") + rulesets = {name: regex_service.character_rulesets.get(name)} if name in regex_service.character_rulesets else {} + elif scope_enum == RegexScope.PRESET: + if not name: + raise ValueError("删除预设规则需要提供预设名称") + rulesets = {name: regex_service.preset_rulesets.get(name)} if name in regex_service.preset_rulesets else {} + + # 查找并删除规则 + deleted = False + for ruleset_name, ruleset in rulesets.items(): + if not ruleset: + continue + + original_count = len(ruleset.rules) + ruleset.rules = [r for r in ruleset.rules if r.id != rule_id] + + if len(ruleset.rules) < original_count: + # 保存更新后的规则集 + regex_service.save_ruleset(ruleset, scope_enum, ruleset_name if scope_enum != RegexScope.GLOBAL else None) + deleted = True + break + + if not deleted: + return { + "success": False, + "message": "未找到指定的规则" + } + + # 重新加载规则 + regex_service._load_all_rules() + + return { + "success": True, + "message": "规则已删除" + } + except Exception as e: + logger.error(f"删除规则失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) # ==================== 规则导入导出 ==================== @@ -160,7 +215,7 @@ async def delete_rule(rule_id: str): @router.post("/import") async def import_rules(file: UploadFile = File(...)): """ - 导入正则规则(支持 SillyTavern 格式) + 导入正则规则(支持 SillyTavern 格式)- 文件上传方式 可以导入: 1. 单个规则文件(JSON 数组) @@ -197,6 +252,56 @@ async def import_rules(file: UploadFile = File(...)): raise HTTPException(status_code=500, detail=str(e)) +@router.post("/import-from-preset") +async def import_rules_from_preset(request: dict): + """ + 从预设导入正则规则 - JSON 数据方式 + + Request Body: + { + "rules": [...], // SillyTavern 格式的 regex_scripts 数组 + "scope": "preset", // 作用域:global/character/preset + "presetName": "预设名称" // 当 scope 为 preset 时需要 + } + """ + try: + rules_data = request.get("rules", []) + scope_str = request.get("scope", "global") + preset_name = request.get("presetName") + + if not rules_data or not isinstance(rules_data, list): + raise ValueError("无效的规则数据") + + # 转换作用域字符串为枚举 + scope_map = { + "global": RegexScope.GLOBAL, + "character": RegexScope.CHARACTER, + "preset": RegexScope.PRESET + } + scope = scope_map.get(scope_str, RegexScope.GLOBAL) + + # 转换 SillyTavern 格式 + name = preset_name or "imported_rules" + ruleset = regex_service._convert_sillytavern_format(rules_data, name, scope) + + # 保存规则集 + regex_service.save_ruleset(ruleset, scope, name) + + # 重新加载规则 + regex_service._load_all_rules() + + logger.info(f"✅ 从预设导入 {len(rules_data)} 条正则规则到 {scope.value}: {name}") + + return { + "success": True, + "message": f"成功导入 {len(rules_data)} 条正则规则", + "rulesetId": ruleset.id + } + except Exception as e: + logger.error(f"从预设导入规则失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @router.get("/export/global") async def export_global_rules(): """导出所有全局规则""" diff --git a/backend/models/regex_rules.py b/backend/models/regex_rules.py index 7f341f9..d3c0b5e 100644 --- a/backend/models/regex_rules.py +++ b/backend/models/regex_rules.py @@ -84,7 +84,7 @@ class RegexRule(BaseModel): runOnEdit: bool = Field(True, description="用户编辑消息时是否重新应用") # 消息深度控制 - minDepth: int = Field(0, ge=0, description="最小消息深度(从最新消息开始计数)") + minDepth: Optional[int] = Field(None, ge=0, description="最小消息深度(从最新消息开始计数,None 表示无限制)") maxDepth: Optional[int] = Field(None, ge=0, description="最大消息深度(None 表示无限制)") # 作用域配置 diff --git a/backend/services/chat_service.py b/backend/services/chat_service.py index 992de1e..4030179 100644 --- a/backend/services/chat_service.py +++ b/backend/services/chat_service.py @@ -172,6 +172,41 @@ class ChatService: logger.error(f"读取聊天失败 {role_name}/{chat_name}: {str(e)}") raise + def get_message(self, role_name: str, chat_name: str, floor: int) -> Dict: + """ + 获取指定楼层的消息 + + Args: + role_name: 角色名称 + chat_name: 聊天名称 + floor: 楼层号 + + Returns: + Dict: 消息数据,如果不存在则返回 None + """ + chat_file = self.chat_dir / role_name / f"{chat_name}.jsonl" + + if not chat_file.exists(): + return None + + try: + with open(chat_file, 'r', encoding='utf-8') as f: + lines = f.readlines() + + # 找到对应的消息行(floor + 1,因为第0行是header) + message_line_index = floor + 1 + + if message_line_index >= len(lines): + return None + + # 解析并返回消息 + msg_data = json.loads(lines[message_line_index]) + return msg_data + + except Exception as e: + logger.error(f"获取消息失败 {role_name}/{chat_name}/{floor}: {str(e)}") + return None + def create_chat(self, role_name: str, chat_name: str, metadata: Dict = None) -> Dict: """ 创建新聊天 diff --git a/backend/services/chat_workflow_service.py b/backend/services/chat_workflow_service.py index ddd98af..8ee1cad 100644 --- a/backend/services/chat_workflow_service.py +++ b/backend/services/chat_workflow_service.py @@ -156,6 +156,29 @@ class ChatWorkflowService: print(f"[ChatWorkflow] 开始处理请求: role={current_role}, chat={current_chat}") + # ✅ 获取预设名称(用于加载预设绑定的正则规则) + preset_config = request_data.get("presetConfig", {}) + preset_name = preset_config.get("selectedPreset") + + # ✅ 第1.5步:应用用户输入的正则规则 + from services.regex_service import regex_service + from models.regex_rules import RegexPlacement + + 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 + ) + + if processed_user_message != user_message: + print(f"[Regex] ✅ 已应用用户输入正则规则") + + user_message = processed_user_message + # === 第2步:加载角色卡 === character_data = request_data.get("characterData") if not character_data: @@ -217,6 +240,21 @@ class ChatWorkflowService: print(f"[ChatWorkflow] 生成完成,内容长度: {len(generated_content)}") print(f"[ChatWorkflow] Token 使用: {token_usage}") + # ✅ 第6.5步:应用 AI 输出的正则规则 + 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 + ) + + if processed_ai_output != generated_content: + print(f"[Regex] ✅ 已应用 AI 输出正则规则") + generated_content = processed_ai_output + # === 第7步:记录 Token 使用 === chat_id = f"{current_role}/{request_data.get('currentChat', '')}" floor = request_data.get("floor", 0) @@ -710,30 +748,243 @@ class ChatWorkflowService: """ 组装提示词 - 使用PromptAssembler将角色卡、世界书、聊天历史等组装成LLM消息列表 + 根据预设组件(promptComponents)动态组装LLM消息列表 """ # 从预设配置中获取prompt components preset_config = request_data.get("presetConfig", {}) prompt_components = preset_config.get("promptComponents", []) - # 创建配置 - config = PromptConfig( - an_position="after_history", # 默认在历史之后 - an_depth=4, - post_history_instructions=None - ) + # ✅ 检查是否启用调试模式 + debug_prompt = request_data.get("debugPrompt", False) - # 组装提示词 - messages = self.prompt_assembler.assemble( - character=character, - chat_history=chat_history, - user_input=user_message, - active_entries=active_entries, - config=config - ) + if debug_prompt: + print(f"\n{'='*80}") + print(f"[Prompt Debug] 🧩 预设组件配置") + print(f"{'='*80}") + print(f"[Prompt Debug] 组件数量: {len(prompt_components)}") + for i, comp in enumerate(prompt_components, 1): + print(f" {i}. {comp.get('name', 'Unknown')} (enabled={comp.get('enabled', True)}, type={comp.get('type', 'N/A')})") + print(f"{'='*80}\n") + + # ✅ 如果有预设组件,使用预设组件组装 + 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规范") + + # 创建配置 + config = PromptConfig( + an_position="after_history", # 默认在历史之后 + an_depth=4, + post_history_instructions=None + ) + + # 组装提示词 + messages = self.prompt_assembler.assemble( + character=character, + chat_history=chat_history, + user_input=user_message, + active_entries=active_entries, + config=config + ) + + return messages + + def _assemble_prompt_from_components( + self, + character, + chat_history: List, + user_message: str, + active_entries: List, + prompt_components: List[Dict], + debug_prompt: bool = False + ) -> List: + """ + 根据预设组件组装提示词 + + Args: + character: 角色卡数据 + chat_history: 聊天历史 + user_message: 用户输入 + active_entries: 激活的世界书条目 + prompt_components: 预设组件列表 + debug_prompt: 是否输出调试信息 + + Returns: + List[BaseMessage]: 组装好的消息列表 + """ + from langchain_core.messages import SystemMessage, HumanMessage, AIMessage + + messages = [] + + # ✅ 按顺序处理每个启用的组件 + enabled_components = [comp for comp in prompt_components if comp.get('enabled', True)] + + if debug_prompt: + print(f"\n[Prompt Debug] 🔄 开始组装 {len(enabled_components)} 个启用的组件") + + for i, component in enumerate(enabled_components, 1): + comp_name = component.get('name', 'Unknown') + comp_type = component.get('type', 'text') # text, system, user, assistant + comp_content = component.get('content', '') + + if debug_prompt: + print(f"\n[Prompt Debug] --- 组件 {i}: {comp_name} ---") + print(f" 类型: {comp_type}") + print(f" 内容长度: {len(comp_content)} 字符") + + # ✅ 替换模板变量 + processed_content = self._process_component_content( + comp_content, + character, + chat_history, + user_message, + active_entries + ) + + if debug_prompt: + print(f" 处理后长度: {len(processed_content)} 字符") + if len(processed_content) < 500: + print(f" 完整内容:\n{processed_content}") + else: + print(f" 预览:\n{processed_content[:200]}...") + + # ✅ 跳过空内容 + if not processed_content or processed_content.strip() == "": + if debug_prompt: + print(f" ⚠️ 内容为空,跳过") + 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: + # 默认为 system + messages.append(SystemMessage(content=processed_content)) + + # ✅ 最后添加用户输入(如果还没有添加) + # 检查最后一个消息是否是用户输入 + if messages and isinstance(messages[-1], HumanMessage): + # 已经包含用户输入,不需要再添加 + pass + else: + # 添加用户输入 + messages.append(HumanMessage(content=user_message)) + + if debug_prompt: + print(f"\n[Prompt Debug] ✅ 组装完成,总消息数: {len(messages)}") + print(f"{'='*80}\n") return messages + def _process_component_content( + self, + content: str, + character, + chat_history: List, + user_message: str, + active_entries: List + ) -> str: + """ + 处理组件内容,替换模板变量 + + 支持的变量: + - {{char}}: 角色名称 + - {{user}}: 用户名称(暂时用 User) + - {{description}}: 角色描述 + - {{personality}}: 角色性格 + - {{scenario}}: 场景 + - {{mes_example}}: 对话示例 + - {{first_mes}}: 第一条消息 + - {{history}}: 聊天历史 + - {{world_info}}: 世界书信息 + + Args: + content: 原始内容 + character: 角色卡数据 + chat_history: 聊天历史 + user_message: 用户输入 + active_entries: 激活的世界书条目 + + Returns: + str: 处理后的内容 + """ + if not content: + return "" + + # 替换角色相关变量 + content = content.replace('{{char}}', getattr(character, 'name', 'Character')) + content = content.replace('{{user}}', 'User') # TODO: 从配置获取用户名 + content = content.replace('{{description}}', getattr(character, 'description', '')) + content = content.replace('{{personality}}', getattr(character, 'personality', '')) + content = content.replace('{{scenario}}', getattr(character, 'scenario', '')) + content = content.replace('{{mes_example}}', getattr(character, 'mes_example', '')) + content = content.replace('{{first_mes}}', getattr(character, 'first_mes', '')) + + # 替换聊天历史 + 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 + + def _format_chat_history(self, chat_history: List) -> str: + """ + 格式化聊天历史为文本 + + Args: + chat_history: 聊天历史列表 + + Returns: + str: 格式化后的历史文本 + """ + lines = [] + for msg in chat_history: + # ✅ 过滤已被总结的空消息 + if hasattr(msg, 'is_summarized') and msg.is_summarized and (msg.mes == "" or msg.mes.strip() == ""): + continue + + name = getattr(msg, 'name', 'User' if msg.is_user else 'Assistant') + mes = getattr(msg, 'mes', '') + lines.append(f"{name}: {mes}") + + return "\n".join(lines) + + def _format_world_info(self, active_entries: List) -> str: + """ + 格式化世界书信息为文本 + + Args: + active_entries: 激活的世界书条目列表 + + Returns: + str: 格式化后的世界书文本 + """ + lines = [] + for entry in active_entries: + name = getattr(entry, 'name', 'Unknown') + content = getattr(entry, 'content', '') + lines.append(f"[{name}]\n{content}") + + return "\n\n".join(lines) + async def _generate_response( self, prompt_messages: List, @@ -978,6 +1229,29 @@ class ChatWorkflowService: print(f" - Message Length: {len(user_message)}") print(f"{'#'*80}\n") + # ✅ 获取预设名称(用于加载预设绑定的正则规则) + preset_config = request_data.get("presetConfig", {}) + preset_name = preset_config.get("selectedPreset") + + # ✅ 第1.5步:应用用户输入的正则规则 + from services.regex_service import regex_service + from models.regex_rules import RegexPlacement + + 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 + ) + + if processed_user_message != user_message: + print(f"[Regex] ✅ 已应用用户输入正则规则") + + user_message = processed_user_message + # === 第2步:加载角色卡 === character_data = request_data.get("characterData") if not character_data: @@ -1109,6 +1383,21 @@ class ChatWorkflowService: print(f" - 平均速度: {len(generated_content)/elapsed if elapsed > 0 else 0:.0f} chars/s") print(f"{'#'*80}\n") + # ✅ 第6.5步:应用 AI 输出的正则规则 + 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 + ) + + if processed_ai_output != generated_content: + print(f"[Regex] ✅ 已应用 AI 输出正则规则") + generated_content = processed_ai_output + # === 第7步:记录 Token 使用(估算) === chat_id = f"{current_role}/{request_data.get('currentChat', '')}" floor = request_data.get("floor", 0) diff --git a/backend/services/regex_service.py b/backend/services/regex_service.py index ef3659b..fc21ce0 100644 --- a/backend/services/regex_service.py +++ b/backend/services/regex_service.py @@ -201,7 +201,9 @@ class RegexService: placement: int, character_name: Optional[str] = None, preset_name: Optional[str] = None, - message_depth: int = 0 + message_depth: int = 0, + is_for_llm: bool = False, # ✅ 新增:是否发送给LLM + is_markdown_rendered: bool = False # ✅ 新增:是否已Markdown渲染 ) -> str: """ 根据 placement 应用正则规则 @@ -212,6 +214,8 @@ class RegexService: character_name: 当前角色卡名称 preset_name: 当前预设名称 message_depth: 消息深度 + is_for_llm: 是否用于发送给 LLM(影响 promptOnly 逻辑) + is_markdown_rendered: 是否是 Markdown 渲染后的内容(影响 markdownOnly 逻辑) Returns: 处理后的文本 @@ -220,6 +224,14 @@ class RegexService: result = text 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 + # 检查此规则是否适用于当前 placement if placement not in [p.value for p in rule.placement]: continue diff --git a/data/regex/global/rule-hide-thinking-001.json b/data/regex/global/rule-hide-thinking-001.json new file mode 100644 index 0000000..dc7b063 --- /dev/null +++ b/data/regex/global/rule-hide-thinking-001.json @@ -0,0 +1,35 @@ +{ + "id": "rule-hide-thinking-001", + "name": "隐藏思考标签", + "description": null, + "rules": [ + { + "id": "rule-hide-thinking-001", + "scriptName": "隐藏思考标签", + "findRegex": "[\\s\\S]*?<\\/thinking>", + "replaceString": "", + "trimStrings": [], + "placement": [ + 2 + ], + "substituteRegex": 0, + "markdownOnly": false, + "promptOnly": false, + "runOnEdit": true, + "minDepth": 0, + "maxDepth": null, + "scope": "global", + "characterName": null, + "presetName": null, + "disabled": false, + "order": 0, + "createdAt": 1777997833, + "updatedAt": 1777997833, + "description": "隐藏 AI 回复中的 标签及其内容" + } + ], + "createdAt": 1777997834, + "updatedAt": 1777997834, + "version": 1, + "isSillyTavernFormat": false +} \ No newline at end of file diff --git a/data/regex/global/ruleset-global-default.json b/data/regex/global/ruleset-global-default.json new file mode 100644 index 0000000..1902de3 --- /dev/null +++ b/data/regex/global/ruleset-global-default.json @@ -0,0 +1,10 @@ +{ + "id": "ruleset-global-default", + "name": "默认全局规则集", + "description": "系统默认的全局正则规则", + "rules": [], + "createdAt": 1777998120, + "updatedAt": 1777998120, + "version": 1, + "isSillyTavernFormat": false +} \ No newline at end of file diff --git a/data/regex/presets/MyPreset.json b/data/regex/presets/MyPreset.json new file mode 100644 index 0000000..97b9fe3 --- /dev/null +++ b/data/regex/presets/MyPreset.json @@ -0,0 +1,323 @@ +{ + "id": "ed1d0482-7f0c-474f-8146-35794fce82af", + "name": "MyPreset 规则集", + "description": "从 SillyTavern 导入的规则", + "rules": [ + { + "id": "0fd10037-6090-42c5-8d2b-020c4e792d88", + "scriptName": "隐藏-thinking", + "findRegex": ".*?<\\/thinking>", + "replaceString": "", + "trimStrings": [], + "placement": [ + 2 + ], + "substituteRegex": 0, + "markdownOnly": true, + "promptOnly": true, + "runOnEdit": true, + "minDepth": null, + "maxDepth": null, + "scope": "preset", + "characterName": null, + "presetName": "MyPreset", + "disabled": false, + "order": 0, + "createdAt": 1777998177, + "updatedAt": 1777998177, + "description": null + }, + { + "id": "f9670e9d-5924-4c84-b058-4ff0593f8030", + "scriptName": "隐藏-MVU变量更新", + "findRegex": "[\\s\\S]*?", + "replaceString": "", + "trimStrings": [], + "placement": [ + 2 + ], + "substituteRegex": 0, + "markdownOnly": true, + "promptOnly": true, + "runOnEdit": true, + "minDepth": null, + "maxDepth": null, + "scope": "preset", + "characterName": null, + "presetName": "MyPreset", + "disabled": false, + "order": 1, + "createdAt": 1777998177, + "updatedAt": 1777998177, + "description": null + }, + { + "id": "17578e3d-da97-4249-aeb2-010c769103f3", + "scriptName": "隐藏-COT-正文前", + "findRegex": ".*", + "replaceString": "", + "trimStrings": [], + "placement": [ + 2 + ], + "substituteRegex": 0, + "markdownOnly": true, + "promptOnly": true, + "runOnEdit": true, + "minDepth": null, + "maxDepth": null, + "scope": "preset", + "characterName": null, + "presetName": "MyPreset", + "disabled": false, + "order": 2, + "createdAt": 1777998177, + "updatedAt": 1777998177, + "description": null + }, + { + "id": "6e3063a6-9db4-4f8e-9a13-4429420af6f3", + "scriptName": "隐藏-COT-正文中", + "findRegex": ".*?<\\/COT-MID>", + "replaceString": "", + "trimStrings": [], + "placement": [ + 2 + ], + "substituteRegex": 0, + "markdownOnly": true, + "promptOnly": true, + "runOnEdit": true, + "minDepth": null, + "maxDepth": null, + "scope": "preset", + "characterName": null, + "presetName": "MyPreset", + "disabled": false, + "order": 3, + "createdAt": 1777998177, + "updatedAt": 1777998177, + "description": null + }, + { + "id": "8ff67677-6531-4749-8c8c-11d63061b854", + "scriptName": "隐藏-表格", + "findRegex": ".*?<\\/tableEdit>", + "replaceString": "", + "trimStrings": [], + "placement": [ + 2 + ], + "substituteRegex": 0, + "markdownOnly": true, + "promptOnly": true, + "runOnEdit": true, + "minDepth": null, + "maxDepth": null, + "scope": "preset", + "characterName": null, + "presetName": "MyPreset", + "disabled": false, + "order": 4, + "createdAt": 1777998177, + "updatedAt": 1777998177, + "description": null + }, + { + "id": "8d0a0c3e-13a1-4f36-92f2-15ce4e9182e5", + "scriptName": "隐藏-表格-确保", + "findRegex": "<填写表格>.*?<\\/填写表格>", + "replaceString": "", + "trimStrings": [], + "placement": [ + 2 + ], + "substituteRegex": 0, + "markdownOnly": true, + "promptOnly": true, + "runOnEdit": true, + "minDepth": null, + "maxDepth": null, + "scope": "preset", + "characterName": null, + "presetName": "MyPreset", + "disabled": false, + "order": 5, + "createdAt": 1777998177, + "updatedAt": 1777998177, + "description": null + }, + { + "id": "2ba157c1-5d85-4b88-a29a-8b3cfe1a6412", + "scriptName": "对AI隐藏图像提示词", + "findRegex": "\\[img-gen\\].*?\\[/img-gen\\]", + "replaceString": "", + "trimStrings": [], + "placement": [ + 2 + ], + "substituteRegex": 0, + "markdownOnly": false, + "promptOnly": true, + "runOnEdit": true, + "minDepth": null, + "maxDepth": null, + "scope": "preset", + "characterName": null, + "presetName": "MyPreset", + "disabled": false, + "order": 6, + "createdAt": 1777998177, + "updatedAt": 1777998177, + "description": null + }, + { + "id": "bedaebeb-7e5f-47e7-bf70-5b5181e7dae4", + "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```", + "trimStrings": [], + "placement": [ + 2 + ], + "substituteRegex": 0, + "markdownOnly": true, + "promptOnly": false, + "runOnEdit": true, + "minDepth": null, + "maxDepth": null, + "scope": "preset", + "characterName": null, + "presetName": "MyPreset", + "disabled": false, + "order": 7, + "createdAt": 1777998177, + "updatedAt": 1777998177, + "description": null + }, + { + "id": "23b07b68-dad9-476b-a202-5a59affcb58f", + "scriptName": "对AI隐藏-小剧场", + "findRegex": ".*?<\\/pornhub_video>", + "replaceString": "", + "trimStrings": [], + "placement": [ + 2 + ], + "substituteRegex": 0, + "markdownOnly": false, + "promptOnly": true, + "runOnEdit": true, + "minDepth": null, + "maxDepth": null, + "scope": "preset", + "characterName": null, + "presetName": "MyPreset", + "disabled": false, + "order": 8, + "createdAt": 1777998177, + "updatedAt": 1777998177, + "description": null + }, + { + "id": "eaedeb8a-cb88-4539-aca4-3339b0b31f88", + "scriptName": "对AI隐藏-小剧场-确保", + "findRegex": "<小剧场>.*?<\\/小剧场>", + "replaceString": "", + "trimStrings": [], + "placement": [ + 2 + ], + "substituteRegex": 0, + "markdownOnly": false, + "promptOnly": true, + "runOnEdit": true, + "minDepth": null, + "maxDepth": null, + "scope": "preset", + "characterName": null, + "presetName": "MyPreset", + "disabled": false, + "order": 9, + "createdAt": 1777998177, + "updatedAt": 1777998177, + "description": null + }, + { + "id": "93dd3756-1221-4df6-a962-f332668e75ab", + "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", + "trimStrings": [], + "placement": [ + 2 + ], + "substituteRegex": 0, + "markdownOnly": true, + "promptOnly": false, + "runOnEdit": true, + "minDepth": null, + "maxDepth": null, + "scope": "preset", + "characterName": null, + "presetName": "MyPreset", + "disabled": true, + "order": 10, + "createdAt": 1777998177, + "updatedAt": 1777998177, + "description": null + }, + { + "id": "f77b730b-118c-4eac-9dc9-fd6284581780", + "scriptName": "[选项]主题", + "findRegex": "((?:(?!).)*?)", + "replaceString": "```html\n\n \n \n```", + "trimStrings": [], + "placement": [ + 2 + ], + "substituteRegex": 0, + "markdownOnly": true, + "promptOnly": false, + "runOnEdit": true, + "minDepth": null, + "maxDepth": 2, + "scope": "preset", + "characterName": null, + "presetName": "MyPreset", + "disabled": false, + "order": 11, + "createdAt": 1777998177, + "updatedAt": 1777998177, + "description": null + }, + { + "id": "6b8a38b4-8db7-4bde-be46-ca6e0c8c18e1", + "scriptName": "[选项]隐藏", + "findRegex": "(.*?)<\\/options>", + "replaceString": "", + "trimStrings": [], + "placement": [ + 2 + ], + "substituteRegex": 0, + "markdownOnly": false, + "promptOnly": true, + "runOnEdit": true, + "minDepth": null, + "maxDepth": null, + "scope": "preset", + "characterName": null, + "presetName": "MyPreset", + "disabled": false, + "order": 12, + "createdAt": 1777998177, + "updatedAt": 1777998177, + "description": null + } + ], + "createdAt": 1777998177, + "updatedAt": 1777998177, + "version": 1, + "isSillyTavernFormat": true +} \ No newline at end of file diff --git a/data/regex/presets/test.json b/data/regex/presets/test.json new file mode 100644 index 0000000..22a34ed --- /dev/null +++ b/data/regex/presets/test.json @@ -0,0 +1,35 @@ +{ + "id": "6acc2ea6-0dd2-4542-b9f5-0bcefe2a5947", + "name": "test 规则集", + "description": "从 SillyTavern 导入的规则", + "rules": [ + { + "id": "af24f3c2-9ded-4593-a6db-98449c022696", + "scriptName": "test", + "findRegex": "test", + "replaceString": "", + "trimStrings": [], + "placement": [ + 2 + ], + "substituteRegex": 0, + "markdownOnly": false, + "promptOnly": false, + "runOnEdit": true, + "minDepth": 0, + "maxDepth": null, + "scope": "preset", + "characterName": null, + "presetName": "test", + "disabled": false, + "order": 0, + "createdAt": 1777995980, + "updatedAt": 1777995980, + "description": null + } + ], + "createdAt": 1777995980, + "updatedAt": 1777995980, + "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 2a01291..1545a79 100644 --- a/data/token_usage/2026/05.jsonl +++ b/data/token_usage/2026/05.jsonl @@ -3,3 +3,10 @@ {"id": "5d3980d1-6c9d-4f6c-ae89-ec1b93b4d4c5", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 5, "promptTokens": 448, "completionTokens": 156, "totalTokens": 604, "status": "completed", "errorMessage": null, "timestamp": 1777984695, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"} {"id": "c939e2d6-9bce-4484-8a07-b49dfc51ca46", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 6, "promptTokens": 703, "completionTokens": 104, "totalTokens": 807, "status": "completed", "errorMessage": null, "timestamp": 1777986964, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"} {"id": "6cf8bfc1-73aa-4c52-92b6-184d40a57fea", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 6, "promptTokens": 703, "completionTokens": 42, "totalTokens": 745, "status": "completed", "errorMessage": null, "timestamp": 1777987419, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"} +{"id": "e702de1c-3fed-45cc-87cc-fa42745cdeb6", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 8, "promptTokens": 841, "completionTokens": 72, "totalTokens": 913, "status": "completed", "errorMessage": null, "timestamp": 1777989342, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"} +{"id": "320c1ed6-bbd7-487c-b61c-148a76605fcc", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 10, "promptTokens": 1009, "completionTokens": 43, "totalTokens": 1052, "status": "completed", "errorMessage": null, "timestamp": 1777989675, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"} +{"id": "2bd0cf29-4451-462a-9b4a-c643ceeca3d0", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 12, "promptTokens": 1146, "completionTokens": 19, "totalTokens": 1165, "status": "completed", "errorMessage": null, "timestamp": 1777990077, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"} +{"id": "4ff6d3c6-c986-470b-bd63-6d811fd38651", "chatId": "写卡机/默认聊天", "roleName": "写卡机", "chatName": "默认聊天", "messageId": null, "floor": 14, "promptTokens": 1258, "completionTokens": 26, "totalTokens": 1284, "status": "completed", "errorMessage": null, "timestamp": 1777990303, "duration": 0.0, "model": "deepseek-v4-pro", "apiProvider": "openai", "apiUrl": "https://api.deepseek.com/v1"} +{"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"} diff --git a/data/token_usage/indexes/api_urls.json b/data/token_usage/indexes/api_urls.json index 36aa593..6cddeba 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": 2060, - "totalCompletionTokens": 519, - "totalTokens": 2579, - "count": 5, + "totalPromptTokens": 9475, + "totalCompletionTokens": 2059, + "totalTokens": 11534, + "count": 12, "firstUsed": 1777984056, - "lastUsed": 1777987419 + "lastUsed": 1777998427 } } \ 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 baddb05..16d19c6 100644 --- a/data/token_usage/indexes/daily/2026-05.json +++ b/data/token_usage/indexes/daily/2026-05.json @@ -1,8 +1,8 @@ { "2026-05-05": { - "promptTokens": 2060, - "completionTokens": 519, - "totalTokens": 2579, - "count": 5 + "promptTokens": 9475, + "completionTokens": 2059, + "totalTokens": 11534, + "count": 12 } } \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index dac9d2c..72781f7 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -76,7 +76,7 @@ function App() { // ✅ 应用启动时自动加载必要的配置数据 useEffect(() => { - console.log('[App] 🚀 应用启动,开始加载默认配置...'); + // console.log('[App] 🚀 应用启动,开始加载默认配置...'); const startTime = Date.now(); // 获取各个 Store 的方法 @@ -91,7 +91,7 @@ function App() { (async () => { try { await apiConfigStore.fetchProfiles(); - console.log('[App] ✅ API 配置文件列表加载完成'); + // console.log('[App] ✅ API 配置文件列表加载完成'); // ✅ 如果有持久化的配置 ID,优先使用;否则加载第一个 const persistedProfileId = apiConfigStore.currentProfileId; @@ -99,7 +99,7 @@ function App() { if (persistedProfileId && !currentProfile) { // 有持久化 ID 但没有详情,加载详情 - console.log(`[App] 🔄 恢复上次选中的配置: ${persistedProfileId}`); + // console.log(`[App] 🔄 恢复上次选中的配置: ${persistedProfileId}`); await apiConfigStore.fetchProfile(persistedProfileId); console.log('[App] ✅ API 配置详情加载完成'); } else if (!currentProfile) { @@ -107,17 +107,17 @@ function App() { const profiles = useApiConfigStore.getState().profiles; if (profiles.length > 0) { const firstProfile = profiles[0]; - console.log(`[App] 📝 自动加载第一个配置文件: ${firstProfile.name}`); + // console.log(`[App] 📝 自动加载第一个配置文件: ${firstProfile.name}`); await apiConfigStore.fetchProfile(firstProfile.id); - console.log('[App] ✅ API 配置详情加载完成'); + // console.log('[App] ✅ API 配置详情加载完成'); } else { - console.warn('[App] ⚠️ 没有可用的 API 配置文件,请先到 API 配置页面创建'); + // console.warn('[App] ⚠️ 没有可用的 API 配置文件,请先到 API 配置页面创建'); } } else { // 从缓存恢复 } } catch (err) { - console.error('[App] ❌ API 配置加载失败:', err); + // console.error('[App] ❌ API 配置加载失败:', err); } })(), @@ -143,11 +143,11 @@ function App() { console.log(`[App] 📝 自动选择第一个预设: ${firstPreset.name}`); presetStore.selectPreset(firstPreset.name); } else { - console.warn('[App] ⚠️ 没有可用的预设'); + // console.warn('[App] ⚠️ 没有可用的预设'); } } } catch (err) { - console.error('[App] ❌ 预设列表加载失败:', err); + // console.error('[App] ❌ 预设列表加载失败:', err); } })(), @@ -155,9 +155,9 @@ function App() { (async () => { try { await characterStore.fetchCharacters(); - console.log('[App] ✅ 角色卡列表加载完成'); + // console.log('[App] ✅ 角色卡列表加载完成'); } catch (err) { - console.error('[App] ❌ 角色卡列表加载失败:', err); + // console.error('[App] ❌ 角色卡列表加载失败:', err); } })(), @@ -165,9 +165,9 @@ function App() { (async () => { try { await worldBookStore.fetchWorldBooks(); - console.log('[App] ✅ 世界书列表加载完成'); + // console.log('[App] ✅ 世界书列表加载完成'); } catch (err) { - console.error('[App] ❌ 世界书列表加载失败:', err); + // console.error('[App] ❌ 世界书列表加载失败:', err); } })() ]).then((results) => { @@ -178,12 +178,12 @@ function App() { const successCount = results.filter(r => r.status === 'fulfilled').length; const failCount = results.filter(r => r.status === 'rejected').length; - console.log(`[App] 🎉 配置加载完成 (${duration}s)`); - console.log(`[App] 📊 成功: ${successCount}, 失败: ${failCount}`); + // console.log(`[App] 🎉 配置加载完成 (${duration}s)`); + // console.log(`[App] 📊 成功: ${successCount}, 失败: ${failCount}`); - if (failCount > 0) { - console.warn('[App] ⚠️ 部分配置加载失败,但应用仍可正常使用'); - } + // if (failCount > 0) { + // console.warn('[App] ⚠️ 部分配置加载失败,但应用仍可正常使用'); + // } }); }, []); // 仅在应用启动时执行一次 diff --git a/frontend/src/Store/Mid/ChatBoxSlice.jsx b/frontend/src/Store/Mid/ChatBoxSlice.jsx index a32ba08..df41ad1 100644 --- a/frontend/src/Store/Mid/ChatBoxSlice.jsx +++ b/frontend/src/Store/Mid/ChatBoxSlice.jsx @@ -110,7 +110,7 @@ const useChatBoxStore = create( // 同时设置角色和聊天 setChatBoxRoleAndChat: (role, chat) => { - console.log('[ChatBoxStore] setChatBoxRoleAndChat called with:', { role, chat }); + // console.log('[ChatBoxStore] setChatBoxRoleAndChat called with:', { role, chat }); set({ currentRole: role, currentChat: typeof chat === 'object' && chat !== null ? chat.chat_name : chat @@ -145,7 +145,7 @@ const useChatBoxStore = create( // ✅ 如果没有 currentChat,先创建聊天文件 let actualChat = currentChat; if (!currentChat && currentRole) { - console.log(`[ChatBoxStore] 检测到未选择聊天,自动创建...`); + // console.log(`[ChatBoxStore] 检测到未选择聊天,自动创建...`); try { const chatName = '默认聊天'; const response = await fetch(`/api/chat/${encodeURIComponent(currentRole)}`, { @@ -164,12 +164,12 @@ const useChatBoxStore = create( actualChat = chatName; // 更新 currentChat set({ currentChat: chatName }); - console.log(`[ChatBoxStore] ✅ 已创建/使用聊天: ${chatName}`); + // console.log(`[ChatBoxStore] ✅ 已创建/使用聊天: ${chatName}`); } else { throw new Error(`创建聊天失败: ${response.status}`); } } catch (error) { - console.error('[ChatBoxStore] ❌ 创建聊天失败:', error); + // console.error('[ChatBoxStore] ❌ 创建聊天失败:', error); set({ error: '创建聊天失败: ' + error.message }); return; } @@ -194,28 +194,50 @@ const useChatBoxStore = create( wsConnection.close(); } - // ✅ 判断是重roll还是新消息(现在重roll也创建新消息) + // ✅ 判断是重roll还是新消息 const isReroll = targetFloor !== null; let userFloor, assistantFloor, nextFloor; - // ✅ 重roll模式也创建新消息,而不是更新现有消息 - nextFloor = get().getNextFloor(messages); - userFloor = nextFloor; - assistantFloor = nextFloor + 1; - - set({ - isGenerating: true, - wsConnection: null, // 重置连接 - messages: [...messages, { - id: `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, // ✅ 使用唯一ID - floor: userFloor, - mes: processedContent, // 使用处理后的内容 - is_user: true, - name: userName || 'User', - sendDate: new Date().toISOString() - }] - }); + if (isReroll) { + // 重roll模式:不创建新用户消息楼层,直接使用目标楼层的上一条用户消息 + // console.log('[ChatBoxStore] 🔄 重roll模式,目标楼层:', targetFloor); + + // 找到目标 AI 消息 + const targetMessage = messages.find(m => m.floor === targetFloor); + if (!targetMessage || targetMessage.is_user) { + // console.error('[ChatBoxStore] ❌ 无效的目标楼层'); + return; + } + + // 助手楼层就是目标楼层 + assistantFloor = targetFloor; + nextFloor = targetFloor; // ✅ 设置 nextFloor 用于后续发送 + + // ✅ 不添加新的用户消息,只准备更新 AI 消息 + set({ + isGenerating: true, + wsConnection: null, // 重置连接 + }); + } else { + // 正常模式:创建新的用户消息和 AI 消息 + nextFloor = get().getNextFloor(messages); + userFloor = nextFloor; + assistantFloor = nextFloor + 1; + + set({ + isGenerating: true, + wsConnection: null, // 重置连接 + messages: [...messages, { + id: `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, // ✅ 使用唯一ID + floor: userFloor, + mes: processedContent, // 使用处理后的内容 + is_user: true, + name: userName || 'User', + sendDate: new Date().toISOString() + }] + }); + } try { // 统一使用WebSocket处理流式和非流式输出 @@ -245,19 +267,34 @@ const useChatBoxStore = create( // ✅ 根据模式处理 AI 消息 let newMessageId; - // ✅ 正常模式:添加一个空的助手消息,稍后会更新 - newMessageId = `ai_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; // ✅ 使用唯一ID - set((state) => ({ - messages: [...state.messages, { - id: newMessageId, - floor: assistantFloor, - mes: '', - is_user: false, - name: characterName || 'Assistant', - sendDate: new Date().toISOString() - }], - isGenerating: true - })); + if (isReroll) { + // 重roll模式:找到目标消息并准备添加新的 swipe + const targetMessage = messages.find(m => m.floor === assistantFloor); + if (!targetMessage) { + // console.error('[ChatBoxStore] ❌ 找不到目标消息'); + return; + } + + newMessageId = targetMessage.id; // 使用现有的 ID + // console.log('[ChatBoxStore] 🔄 重roll模式,更新消息:', newMessageId); + + // ✅ 不添加新消息,只是标记为正在生成 + set({ isGenerating: true }); + } else { + // 正常模式:添加一个空的助手消息,稍后会更新 + newMessageId = `ai_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; // ✅ 使用唯一ID + set((state) => ({ + messages: [...state.messages, { + id: newMessageId, + floor: assistantFloor, + mes: '', + is_user: false, + name: characterName || 'Assistant', + sendDate: new Date().toISOString() + }], + isGenerating: true + })); + } let assistantMessage = ''; let isStreamComplete = false; @@ -295,12 +332,17 @@ const useChatBoxStore = create( // 处理流式数据块 assistantMessage += data.content; - // ✅ 更新新创建的消息 - set((state) => ({ - messages: state.messages.map((msg) => - msg.id === newMessageId ? { ...msg, mes: assistantMessage } : msg - ) - })); + if (isReroll) { + // ✅ 重roll模式:不更新 mes,只在内部累积 assistantMessage + // mes 保持不变,直到 complete 事件才更新 + } else { + // 正常模式:更新新创建的消息 + set((state) => ({ + messages: state.messages.map((msg) => + msg.id === newMessageId ? { ...msg, mes: assistantMessage } : msg + ) + })); + } } else if (data.type === 'worldbook_active') { console.log('[WebSocket] 📚 收到世界书激活信息:', data.entries.length, '个条目'); // ✅ 更新世界书激活显示 @@ -358,6 +400,48 @@ const useChatBoxStore = create( console.log(' - 总 Chunks:', chunkCount); console.log(' - 消息长度:', assistantMessage.length); + // ✅ 处理重roll模式:将新生成的内容添加到 swipes 数组 + if (isReroll) { + // console.log('[ChatBoxStore] 🔄 重roll完成,添加新的 swipe 版本'); + + set((state) => ({ + messages: state.messages.map((msg) => { + if (msg.id === newMessageId) { + // 获取现有的 swipes 数组 + const existingSwipes = msg.swipes || []; + const currentMes = msg.mes; // 当前显示的内容(旧版本) + + // 构建新的 swipes 数组:包含所有旧版本 + 新版本 + let updatedSwipes = [...existingSwipes]; + + // 如果当前 mes 不在 swipes 中,先添加它 + if (!updatedSwipes.includes(currentMes)) { + updatedSwipes.push(currentMes); + } + + // 添加新生成的内容 + updatedSwipes.push(assistantMessage); + + // console.log('[ChatBoxStore] 📊 Swipes 更新:', { + // oldCount: existingSwipes.length, + // newCount: updatedSwipes.length, + // newSwipeIndex: updatedSwipes.length - 1, + // currentMesLength: currentMes.length, + // assistantMessageLength: assistantMessage.length + // }); + + return { + ...msg, + mes: assistantMessage, // 显示新生成的内容 + swipes: updatedSwipes, // 更新 swipes 数组 + swipe_id: updatedSwipes.length - 1 // 自动切换到新版本 + }; + } + return msg; + }) + })); + } + // 完成响应 isStreamComplete = true; ws.close(); @@ -406,21 +490,21 @@ const useChatBoxStore = create( model: apiConfigStore.currentProfile?.apis?.mainLLM?.model || '' }; - console.log('\n' + '-'.repeat(80)); - console.log('[WebSocket] 📤 发送消息:'); - console.log(' - Floor:', nextFloor); - console.log(' - Mode:', isReroll ? '🔄 Reroll (新消息)' : '➕ New Message'); - console.log(' - Role:', currentRole); - console.log(' - Chat:', actualChat); - console.log(' - Stream:', options.streamOutput); - console.log(' - Message Length:', processedContent.length); - console.log(' - API Config:', apiConfigData); - console.log(' - Current Profile:', apiConfigStore.currentProfile); - console.log('-'.repeat(80) + '\n'); + // console.log('\n' + '-'.repeat(80)); + // console.log('[WebSocket] 📤 发送消息:'); + // console.log(' - Floor:', isReroll ? assistantFloor : nextFloor); + // console.log(' - Mode:', isReroll ? '🔄 Reroll (添加swipe)' : '➕ New Message'); + // console.log(' - Role:', currentRole); + // console.log(' - Chat:', actualChat); + // console.log(' - Stream:', options.streamOutput); + // console.log(' - Message Length:', processedContent.length); + // console.log(' - API Config:', apiConfigData); + // console.log(' - Current Profile:', apiConfigStore.currentProfile); + // console.log('-'.repeat(80) + '\n'); // ✅ 实际发送消息 const messageData = JSON.stringify({ - floor: nextFloor, // ✅ 总是使用 nextFloor,因为重roll也创建新消息 + floor: isReroll ? assistantFloor : nextFloor, // ✅ 重roll模式使用目标楼层 mes: processedContent, // 使用处理后的内容 is_user: true, currentRole: currentRole, @@ -469,19 +553,21 @@ const useChatBoxStore = create( } : null, // ✅ 时间戳(用于冲突解决) timestamp: Date.now(), - stream: options.streamOutput + stream: options.streamOutput, + // ✅ 调试标志:请求后端返回完整的prompt拼接内容 + debugPrompt: true }); - console.log('[WebSocket] 📤 正在发送消息,数据长度:', messageData.length); + // console.log('[WebSocket] 📤 正在发送消息,数据长度:', messageData.length); ws.send(messageData); - console.log('[WebSocket] ✅ 消息已发送'); + // console.log('[WebSocket] ✅ 消息已发送'); } else if (ws.readyState === WebSocket.CONNECTING) { // 如果正在连接,继续等待 - console.log('[WebSocket] 等待连接...', { readyState: ws.readyState }); + // console.log('[WebSocket] 等待连接...', { readyState: ws.readyState }); setTimeout(sendAfterConnect, 100); } else { // 连接失败或已关闭 - console.error('[WebSocket] 连接失败', { readyState: ws.readyState }); + // console.error('[WebSocket] 连接失败', { readyState: ws.readyState }); set({ error: 'WebSocket connection failed', isGenerating: false, @@ -502,7 +588,7 @@ const useChatBoxStore = create( // 终止生成 stopGeneration: () => set((state) => { if (state.wsConnection && state.wsConnection.readyState === WebSocket.OPEN) { - console.log('[ChatBoxStore] 🛑 发送终止信号...'); + // console.log('[ChatBoxStore] 🛑 发送终止信号...'); // ✅ 先发送取消任务信号给后端 state.wsConnection.send(JSON.stringify({ @@ -514,7 +600,7 @@ const useChatBoxStore = create( setTimeout(() => { if (state.wsConnection) { state.wsConnection.close(); - console.log('[ChatBoxStore] 🔌 WebSocket 已关闭'); + // console.log('[ChatBoxStore] 🔌 WebSocket 已关闭'); } }, 100); } @@ -531,7 +617,7 @@ const useChatBoxStore = create( // 如果已经在加载中,跳过 if (currentState.isLoading) { - console.log(`[ChatBoxStore] 跳过重复加载: ${roleName}/${chatName}`); + // console.log(`[ChatBoxStore] 跳过重复加载: ${roleName}/${chatName}`); return; } @@ -563,11 +649,11 @@ const useChatBoxStore = create( name: characterData.name || roleName, sendDate: new Date().toISOString() }]; - console.log(`[ChatBoxStore] 显示角色 ${roleName} 的开场白(临时消息)`); + // console.log(`[ChatBoxStore] 显示角色 ${roleName} 的开场白(临时消息)`); } } } catch (error) { - console.warn('[ChatBoxStore] 获取角色信息失败:', error); + // console.warn('[ChatBoxStore] 获取角色信息失败:', error); } } @@ -579,13 +665,13 @@ const useChatBoxStore = create( isLoading: false }); - console.log(`[ChatBoxStore] 已加载聊天: ${roleName}/${actualChatName}, 消息数: ${messages.length}`); + // console.log(`[ChatBoxStore] 已加载聊天: ${roleName}/${actualChatName}, 消息数: ${messages.length}`); } catch (error) { set({ error: error.message, isLoading: false }); - console.error('[ChatBoxStore] 加载聊天失败:', error); + // console.error('[ChatBoxStore] 加载聊天失败:', error); } }, diff --git a/frontend/src/Store/SideBarLeft/PresetSlice.jsx b/frontend/src/Store/SideBarLeft/PresetSlice.jsx index 6efb8ad..fe2634d 100644 --- a/frontend/src/Store/SideBarLeft/PresetSlice.jsx +++ b/frontend/src/Store/SideBarLeft/PresetSlice.jsx @@ -121,7 +121,7 @@ const usePresetStore = create( set({ presets: presetList, isLoadingPresets: false }); } catch (error) { - console.error('Failed to fetch presets:', error); + // console.error('Failed to fetch presets:', error); set({ isLoadingPresets: false }); } }, @@ -217,7 +217,7 @@ const usePresetStore = create( isParametersExpanded: true // 确保参数容器展开 }); } catch (error) { - console.error('Failed to load preset:', error); + // console.error('Failed to load preset:', error); } }, @@ -298,7 +298,7 @@ const usePresetStore = create( return result; } catch (error) { - console.error('Failed to save preset:', error); + // console.error('Failed to save preset:', error); throw error; } }, @@ -329,7 +329,7 @@ const usePresetStore = create( return await response.json(); } catch (error) { - console.error('Failed to update preset name:', error); + // console.error('Failed to update preset name:', error); throw error; } }, @@ -383,7 +383,7 @@ const usePresetStore = create( saveComponentOrder: async () => { const state = get(); if (!state.selectedPreset) { - console.warn('No preset selected, cannot save order'); + // console.warn('No preset selected, cannot save order'); return; } @@ -406,10 +406,10 @@ const usePresetStore = create( } const result = await response.json(); - console.log('Component order saved successfully:', result); + // console.log('Component order saved successfully:', result); return result; } catch (error) { - console.error('Failed to save component order:', error); + // console.error('Failed to save component order:', error); throw error; } }, diff --git a/frontend/src/components/Mid/ChatBox/ChatBox.css b/frontend/src/components/Mid/ChatBox/ChatBox.css index f531e37..12e9a7e 100644 --- a/frontend/src/components/Mid/ChatBox/ChatBox.css +++ b/frontend/src/components/Mid/ChatBox/ChatBox.css @@ -522,13 +522,20 @@ color: var(--color-text-primary); font-size: 0.95rem; resize: none; - overflow-y: hidden; + overflow-y: auto; min-height: 36px; max-height: 300px; transition: all 0.15s ease; font-family: inherit; line-height: 1.6; display: block; + + /* ✅ 业界标准:使用 field-sizing 实现自适应高度 */ + /* 支持此属性的浏览器会自动根据内容调整高度 */ + field-sizing: content; + + /* ✅ 兼容性方案:为不支持 field-sizing 的浏览器提供回退 */ + height: auto; } .message-input:focus { @@ -717,6 +724,10 @@ border-radius: var(--radius-md); cursor: pointer; transition: all var(--transition-fast); + position: relative; /* ✅ 为删除按钮定位 */ + display: flex; + align-items: center; + gap: var(--spacing-sm); } .chat-option:hover { @@ -726,6 +737,31 @@ .chat-option-content { cursor: pointer; + flex: 1; /* ✅ 占据剩余空间 */ +} + +/* ✅ 删除按钮样式 */ +.chat-delete-btn { + background: none; + border: none; + font-size: 1.2rem; + cursor: pointer; + padding: var(--spacing-xs) var(--spacing-sm); + border-radius: var(--radius-sm); + opacity: 0; /* 默认隐藏 */ + transition: all var(--transition-fast); + color: var(--color-text-muted); +} + +.chat-option:hover .chat-delete-btn { + opacity: 0.6; /* hover 时显示 */ +} + +.chat-delete-btn:hover { + opacity: 1 !important; + background: rgba(255, 77, 77, 0.15); + color: #ff4d4d; + transform: scale(1.1); } .chat-option-name { diff --git a/frontend/src/components/Mid/ChatBox/ChatBox.jsx b/frontend/src/components/Mid/ChatBox/ChatBox.jsx index ef55fda..58d2d90 100644 --- a/frontend/src/components/Mid/ChatBox/ChatBox.jsx +++ b/frontend/src/components/Mid/ChatBox/ChatBox.jsx @@ -120,10 +120,11 @@ const ChatBox = () => { const handleInputHeight = (e) => { const textarea = e.target; - // 标准方案:先重置为 auto,再设置为 scrollHeight - // 这是业界公认的最佳实践,确保高度计算准确 + // ✅ 业界标准方案:先重置为 auto,再设置为 scrollHeight + // 这是 SillyTavern、Discord、Slack 等成熟产品使用的方案 + // 确保删除内容时高度也能正确收缩 textarea.style.height = 'auto'; - textarea.style.height = textarea.scrollHeight + 'px'; + textarea.style.height = Math.min(textarea.scrollHeight, 300) + 'px'; }; // 处理发送或终止 @@ -133,7 +134,11 @@ const ChatBox = () => { } else { sendMessage(inputValue); clearInput(); // ✅ 使用 store 方法 - setInputHeight(42); // ✅ 重置为一行高度 + // ✅ 重置输入框高度:直接操作 DOM 元素重置为 auto + const textarea = document.querySelector('.message-input'); + if (textarea) { + textarea.style.height = 'auto'; + } } }; @@ -228,13 +233,13 @@ const ChatBox = () => { if (currentIndex < lastAiMessage.swipes.length - 1) { handleSwipeChange(lastAiMessage.floor, 1); } else { - // 已在最后一个版本,触发重roll - console.log('[ChatBox] 已在最后一个版本,触发重roll(发送新消息)'); + // 已在最后一个版本,触发重roll(在目标消息的swipe数组中添加新版本) + console.log('[ChatBox] 已在最后一个版本,触发重roll(添加swipe)'); handleRerollMessage(lastAiMessage); } } else { - // 没有 swipe,直接触发重roll - console.log('[ChatBox] 没有 swipe,触发重roll(发送新消息)'); + // 没有 swipe,直接触发重roll(在目标消息的swipe数组中添加新版本) + console.log('[ChatBox] 没有 swipe,触发重roll(添加swipe)'); handleRerollMessage(lastAiMessage); } } @@ -271,7 +276,8 @@ const ChatBox = () => { const newIndex = currentIndex + direction; if (newIndex >= 0 && newIndex < message.swipes.length) { - setCurrentSwipeId({ [messageId]: newIndex }); // ✅ 使用 store 方法 + // ✅ 合并更新,保留其他楼层的 swipe 状态 + setCurrentSwipeId({ ...currentSwipeId, [messageId]: newIndex }); } } }; @@ -428,6 +434,47 @@ const ChatBox = () => { } }; + // ✅ 新增:删除聊天 + const handleDeleteChat = async (chatName, e) => { + e.stopPropagation(); // 阻止事件冒泡,避免触发选择聊天 + + if (!confirm(`确定要删除聊天 "${chatName}" 吗?此操作不可恢复!`)) { + return; + } + + try { + const { currentRole } = useChatBoxStore.getState(); + + const response = await fetch(`/api/chat/${encodeURIComponent(currentRole)}/${encodeURIComponent(chatName)}`, { + method: 'DELETE' + }); + + if (!response.ok) { + throw new Error('删除聊天失败'); + } + + console.log(`[ChatBox] 已删除聊天: ${chatName}`); + + // 重新获取聊天列表 + const chatsResponse = await fetch(`/api/chat/${encodeURIComponent(currentRole)}`); + if (chatsResponse.ok) { + const chats = await chatsResponse.json(); + setCharacterChats(chats); + } + + // 如果删除的是当前聊天,清空当前聊天 + const { currentChat, setChatBoxRoleAndChat } = useChatBoxStore.getState(); + if (currentChat === chatName) { + setChatBoxRoleAndChat(currentRole, null); + } + + alert('聊天已删除'); + } catch (error) { + console.error('[ChatBox] 删除聊天失败:', error); + alert('删除聊天失败: ' + error.message); + } + }; + // 新建聊天 const handleCreateNewChat = async () => { try { @@ -686,10 +733,6 @@ const ChatBox = () => { handleInputHeight(e); }} onKeyDown={handleKeyDown} - style={{ - height: `${inputHeight}px`, - overflowY: inputHeight >= 300 ? 'auto' : 'hidden' - }} placeholder="Type your message..." /> @@ -751,6 +794,14 @@ const ChatBox = () => { )} + {/* ✅ 删除按钮 */} + ))} diff --git a/frontend/src/components/SideBarLeft/tabs/Presets/Presets.jsx b/frontend/src/components/SideBarLeft/tabs/Presets/Presets.jsx index 24d85ab..e6acb51 100644 --- a/frontend/src/components/SideBarLeft/tabs/Presets/Presets.jsx +++ b/frontend/src/components/SideBarLeft/tabs/Presets/Presets.jsx @@ -298,7 +298,9 @@ const PresetPanel = () => { // 导入预设 const handleImportPreset = async (file) => { - console.log('[Preset Import] 开始导入文件:', file.name); + console.log('\n' + '='.repeat(80)); + console.log('[预设导入] 📥 开始导入文件:', file.name); + console.log('='.repeat(80)); try { // 读取文件内容 const importData = await new Promise((resolve, reject) => { @@ -308,9 +310,7 @@ const PresetPanel = () => { reader.readAsText(file); }); - console.log('[Preset Import] 文件内容读取成功'); const importedPreset = JSON.parse(importData); - console.log('[Preset Import] JSON 解析成功'); // 支持内部结构和SillyTavern结构 let presetName = ''; @@ -321,7 +321,8 @@ const PresetPanel = () => { if (importedPreset.entries && Array.isArray(importedPreset.entries)) { // 内部结构 presetName = importedPreset.name || file.name.replace('.json', ''); - console.log('[Preset Import] 检测到内部格式, preset name:', presetName); + console.log('[预设导入] ✅ 检测到内部格式'); + console.log(' - 预设名称:', presetName); importedParameters = { temperature: importedPreset.temperature, top_p: importedPreset.topP, @@ -351,7 +352,8 @@ const PresetPanel = () => { else if (importedPreset.name || importedPreset.temperature || importedPreset.parameters || importedPreset.prompts) { // SillyTavern 格式通常没有 name 字段,使用文件名 presetName = importedPreset.name || file.name.replace('.json', ''); - console.log('[Preset Import] 检测到 SillyTavern 格式, preset name:', presetName); + console.log('[预设导入] ✅ 检测到 SillyTavern 格式'); + console.log(' - 预设名称:', presetName); // 提取参数 importedParameters = importedPreset.parameters || { @@ -366,7 +368,7 @@ const PresetPanel = () => { // 转换 SillyTavern 的 prompts 为内部格式 if (importedPreset.prompts && Array.isArray(importedPreset.prompts)) { - console.log('[Preset Import] 找到', importedPreset.prompts.length, '个 prompts'); + console.log(' - Prompts 数量:', importedPreset.prompts.length); // 获取 prompt_order(支持多个 prompt_order,优先使用最后一个或组件数最多的) let promptOrder = []; @@ -376,19 +378,21 @@ const PresetPanel = () => { if (importedPreset.prompt_order.length === 1) { if (importedPreset.prompt_order[0].order) { promptOrder = importedPreset.prompt_order[0].order; - console.log('[Preset Import] 使用唯一的 prompt_order'); + console.log(' - 使用唯一的 prompt_order (组件数:', promptOrder.length, ')'); } } else if (importedPreset.prompt_order.length > 1) { // 有多个时,使用最后一个(通常是最完整的) const lastOrder = importedPreset.prompt_order[importedPreset.prompt_order.length - 1]; if (lastOrder && lastOrder.order) { promptOrder = lastOrder.order; - console.log('[Preset Import] 使用最后一个 prompt_order (character_id:', lastOrder.character_id, ', 组件数:', promptOrder.length, ')'); + console.log(' - 使用最后一个 prompt_order'); + console.log(' * character_id:', lastOrder.character_id); + console.log(' * 组件数:', promptOrder.length); } - console.log('[Preset Import] 检测到', importedPreset.prompt_order.length, '个 prompt_order'); + console.log(' - 检测到', importedPreset.prompt_order.length, '个 prompt_order:'); importedPreset.prompt_order.forEach((po, idx) => { - console.log(` [${idx}] character_id: ${po.character_id}, order_count: ${po.order?.length || 0}`); + console.log(` [${idx}] character_id: ${po.character_id}, order_count: ${po.order?.length || 0}`); }); } } @@ -444,18 +448,62 @@ const PresetPanel = () => { }); } - console.log('[Preset Import] 转换后组件数量:', importedComponents.length); + console.log(' - 转换后组件数量:', importedComponents.length); } else { - console.log('[Preset Import] 未找到 prompts 字段'); + console.log(' - ⚠️ 未找到 prompts 字段'); importedComponents = []; } } if (presetName) { - console.log('[Preset Import] 准备应用导入的配置'); + console.log('\n[预设导入] 🔄 应用导入的配置...'); + + // ✅ 调试:检查导入的预设结构 + console.log('[预设导入] 🔍 检查预设文件结构...'); + console.log('[预设导入] 📋 预设文件名:', presetName); + console.log('[预设导入] 🔑 是否有 extensions:', !!importedPreset.extensions); + console.log('[预设导入] 📜 是否有 regex_scripts:', !!importedPreset.extensions?.regex_scripts); + console.log('[预设导入] 📊 regex_scripts 数量:', importedPreset.extensions?.regex_scripts?.length || 0); + + // ✅ 提取并保存正则规则(如果存在) + if (importedPreset.extensions?.regex_scripts && importedPreset.extensions.regex_scripts.length > 0) { + const regexScripts = importedPreset.extensions.regex_scripts; + console.log(`\n[预设导入] ✅ 检测到 ${regexScripts.length} 条正则规则`); + console.log('[预设导入] 📜 前3条规则:', regexScripts.slice(0, 3).map(r => r.scriptName)); + + try { + // 将正则规则发送到后端保存 + console.log('[预设导入] 开始保存正则规则到后端...'); + 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 + }) + }); + + console.log('[预设导入] 📥 后端响应状态:', response.status); + + if (response.ok) { + const result = await response.json(); + console.log('[预设导入] ✅ 正则规则已保存到:', `data/regex/presets/${presetName}.json`); + console.log('[预设导入] 📊 导入结果:', result.message); + } else { + const errorText = await response.text(); + console.error('[预设导入] ❌ 保存正则规则失败:', response.status, errorText); + } + } catch (error) { + console.error('[预设导入] ❌ 保存正则规则失败:', error); + } + } else { + console.warn('[预设导入] ️ 预设中未找到 extensions.regex_scripts'); + console.log('[预设导入] 💡 预设文件中的 extensions 字段:', Object.keys(importedPreset.extensions || {})); + } // 先更新参数 - console.log('[Preset Import] 更新参数:', importedParameters); + console.log(' - 参数配置:', Object.keys(importedParameters).filter(k => importedParameters[k] !== undefined).join(', ')); Object.keys(importedParameters).forEach(key => { if (importedParameters[key] !== undefined) { updateParameter({ name: key, value: importedParameters[key] }); @@ -464,30 +512,33 @@ const PresetPanel = () => { // 再更新组件列表 if (importedComponents.length > 0) { - console.log('[Preset Import] 更新组件列表:', importedComponents.length, '个组件'); + console.log(' - 组件列表:', importedComponents.length, '个组件'); setPromptComponents(importedComponents); } // 使用 requestAnimationFrame 确保状态更新完成后再保存 requestAnimationFrame(() => { setTimeout(async () => { - console.log('[Preset Import] 准备保存预设:', presetName); + console.log('\n[预设导入] 💾 保存预设:', presetName); try { await saveCurrentAsPreset({ name: presetName }); - console.log('[Preset Import] 预设保存成功'); + console.log('[预设导入] ✅ 预设保存成功'); + console.log('='.repeat(80) + '\n'); // 重新加载预设列表并重置到第一页 setCurrentPage(1); fetchPresets(); } catch (error) { - console.error('[Preset Import] 保存预设失败:', error); + console.error('[预设导入] ❌ 保存预设失败:', error); + console.log('='.repeat(80) + '\n'); alert('保存预设失败: ' + error.message); } }, 50); }); } } catch (error) { - console.error('导入预设失败:', error); + console.error('\n[预设导入] ❌ 导入失败:', error); + console.log('='.repeat(80) + '\n'); alert('导入预设失败: ' + error.message); } }; diff --git a/frontend/src/components/SideBarLeft/tabs/Regex/RegexPanel.css b/frontend/src/components/SideBarLeft/tabs/Regex/RegexPanel.css new file mode 100644 index 0000000..3e20891 --- /dev/null +++ b/frontend/src/components/SideBarLeft/tabs/Regex/RegexPanel.css @@ -0,0 +1,536 @@ +/* ==================== 正则面板 - 完整重写 ==================== */ + +/* 主容器 */ +.regex-panel { + padding: 16px; + height: 100%; + overflow-y: auto; + background: var(--color-bg-primary); + color: var(--color-text-primary); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +/* 头部 */ +.regex-panel-header { + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid var(--color-border); +} + +.regex-panel-header h2 { + margin: 0 0 8px 0; + font-size: 22px; + font-weight: 600; + color: var(--color-text-primary); +} + +.subtitle { + margin: 0; + font-size: 13px; + color: var(--color-text-muted); +} + +/* ==================== 左右布局 ==================== */ + +.regex-layout { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 20px; + height: calc(100% - 80px); +} + +.regex-left { + overflow-y: auto; + padding-right: 12px; +} + +.regex-right { + overflow-y: auto; +} + +/* ==================== 可折叠区域 ==================== */ + +.regex-section { + margin-bottom: 16px; + background: var(--color-bg-secondary); + border-radius: 8px; + overflow: hidden; + border: 1px solid var(--color-border); +} + +.regex-section-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background: var(--color-bg-tertiary); + cursor: pointer; + user-select: none; + transition: background 0.2s; +} + +.regex-section-header:hover { + background: var(--color-bg-hover); +} + +.section-title { + display: flex; + align-items: center; + gap: 8px; +} + +.collapse-icon { + font-size: 12px; + color: var(--color-text-muted); + transition: transform 0.2s; +} + +.section-icon { + font-size: 16px; +} + +.section-title h4 { + margin: 0; + font-size: 15px; + font-weight: 600; + color: var(--color-text-primary); +} + +.rule-count { + font-size: 12px; + color: var(--color-text-muted); +} + +.btn-add-rule-small { + padding: 4px 12px; + background: var(--color-accent); + border: none; + border-radius: 4px; + color: white; + font-size: 12px; + cursor: pointer; + transition: background 0.2s; +} + +.btn-add-rule-small:hover { + background: var(--color-accent-hover); +} + +.regex-section-content { + padding: 12px; + background: var(--color-bg-primary); +} + +.empty-rules { + text-align: center; + padding: 20px; + color: var(--color-text-muted); + font-size: 13px; +} + +/* ==================== 规则列表 ==================== */ + +.rules-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.rule-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + background: var(--color-bg-tertiary); + border-radius: 6px; + transition: all 0.2s; + border: 1px solid transparent; +} + +.rule-item:hover { + background: var(--color-bg-hover); + border-color: var(--color-border); +} + +.rule-info { + flex: 1; + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.rule-name { + font-size: 14px; + font-weight: 500; + color: var(--color-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.rule-badges { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +.rule-badge { + padding: 2px 6px; + border-radius: 4px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + white-space: nowrap; +} + +.rule-badge.disabled { + background: var(--color-error); + color: white; +} + +.rule-badge.prompt { + background: var(--color-info); + color: white; +} + +.rule-badge.markdown { + /* 注意:Markdown 徽章使用固定紫色,作为功能标识色 */ + background: #8844ff; + color: white; +} + +.rule-actions { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.icon-btn { + padding: 4px 6px; + background: transparent; + border: none; + color: var(--color-text-muted); + cursor: pointer; + border-radius: 4px; + font-size: 14px; + transition: all 0.2s; +} + +.icon-btn:hover { + background: var(--color-bg-elevated); + color: var(--color-text-primary); +} + +/* 开关按钮 */ +.toggle-switch { + position: relative; + width: 40px; + height: 22px; + display: inline-block; + cursor: pointer; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-slider { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--color-border); + transition: 0.3s; + border-radius: 22px; +} + +.toggle-slider:before { + position: absolute; + content: ""; + height: 16px; + width: 16px; + left: 3px; + bottom: 3px; + background-color: white; + transition: 0.3s; + border-radius: 50%; +} + +.toggle-switch input:checked + .toggle-slider { + background-color: var(--color-accent); +} + +.toggle-switch input:checked + .toggle-slider:before { + transform: translateX(18px); +} + +/* ==================== 右侧信息卡片 ==================== */ + +.regex-info-card { + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 16px; +} + +.regex-info-card h4 { + margin: 0 0 12px 0; + font-size: 15px; + font-weight: 600; + color: var(--color-text-primary); +} + +.regex-info-card ul { + margin: 0; + padding-left: 20px; + list-style: none; +} + +.regex-info-card li { + margin-bottom: 8px; + font-size: 13px; + color: var(--color-text-secondary); + line-height: 1.5; +} + +.regex-info-card li strong { + color: var(--color-text-primary); +} + +.quick-actions { + display: flex; + flex-direction: column; + gap: 8px; +} + +.btn-quick { + padding: 10px 16px; + background: var(--color-accent); + border: none; + border-radius: 6px; + color: white; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; +} + +.btn-quick:hover { + background: var(--color-accent-hover); +} + +/* ==================== 编辑器弹窗 ==================== */ + +.regex-editor-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; +} + +.regex-editor-modal { + width: 90%; + max-width: 800px; + max-height: 90vh; + background: var(--color-bg-primary); + border-radius: 12px; + border: 1px solid var(--color-border); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.regex-editor-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + background: var(--color-bg-secondary); + border-bottom: 1px solid var(--color-border); +} + +.regex-editor-header h3 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: var(--color-text-primary); +} + +.close-btn { + background: transparent; + border: none; + color: var(--color-text-muted); + font-size: 24px; + cursor: pointer; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: all 0.2s; +} + +.close-btn:hover { + background: var(--color-bg-tertiary); + color: var(--color-text-primary); +} + +.regex-editor-body { + flex: 1; + overflow-y: auto; + padding: 20px; +} + +.form-group { + margin-bottom: 16px; +} + +.form-group label { + display: block; + margin-bottom: 6px; + font-size: 13px; + font-weight: 500; + color: var(--color-text-secondary); +} + +.form-group small { + display: block; + margin-top: 4px; + font-size: 11px; + color: var(--color-text-muted); +} + +.form-group input[type="text"], +.form-group input[type="number"], +.form-group textarea, +.form-group select { + width: 100%; + padding: 8px 12px; + background: var(--color-bg-tertiary); + border: 1px solid var(--color-border); + border-radius: 6px; + color: var(--color-text-primary); + font-size: 14px; + font-family: inherit; + transition: border-color 0.2s; +} + +.form-group input:focus, +.form-group textarea:focus, +.form-group select:focus { + outline: none; + border-color: var(--color-accent); +} + +.form-group textarea { + resize: vertical; + min-height: 60px; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-bottom: 16px; +} + +.form-section h4 { + margin: 0 0 8px 0; + font-size: 14px; + font-weight: 600; + color: var(--color-text-primary); +} + +.checkbox-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.checkbox-item { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 13px; + color: var(--color-text-secondary); +} + +.checkbox-item input[type="checkbox"] { + width: 16px; + height: 16px; + cursor: pointer; +} + +.form-group.small { + margin-bottom: 0; +} + +.regex-editor-footer { + display: flex; + justify-content: flex-end; + gap: 12px; + padding: 16px 20px; + background: var(--color-bg-secondary); + border-top: 1px solid var(--color-border); +} + +.btn-cancel, +.btn-save { + padding: 8px 20px; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.btn-cancel { + background: var(--color-bg-tertiary); + color: var(--color-text-primary); +} + +.btn-cancel:hover { + background: var(--color-bg-hover); +} + +.btn-save { + background: var(--color-accent); + color: white; +} + +.btn-save:hover { + background: var(--color-accent-hover); +} + +.btn-save:disabled { + background: var(--color-border); + cursor: not-allowed; +} + +/* ==================== 响应式 ==================== */ + +@media (max-width: 1200px) { + .regex-layout { + grid-template-columns: 1fr; + } + + .regex-right { + display: none; + } +} diff --git a/frontend/src/components/SideBarLeft/tabs/Regex/RegexPanel.jsx b/frontend/src/components/SideBarLeft/tabs/Regex/RegexPanel.jsx new file mode 100644 index 0000000..cc67e0b --- /dev/null +++ b/frontend/src/components/SideBarLeft/tabs/Regex/RegexPanel.jsx @@ -0,0 +1,575 @@ +import React, { useState, useEffect } from 'react'; +import usePresetStore from '../../../../Store/SideBarLeft/PresetSlice'; +import './RegexPanel.css'; + +const RegexPanel = () => { + // 状态管理 + const [globalRulesets, setGlobalRulesets] = useState([]); + const [presetRulesets, setPresetRulesets] = useState([]); + const [editingRule, setEditingRule] = useState(null); + const [showEditor, setShowEditor] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + // 折叠状态 + const [collapsedSections, setCollapsedSections] = useState({ + global: false, + preset: false + }); + + // 获取当前预设 + const { selectedPreset } = usePresetStore(); + + // 编辑器表单状态 + const [formData, setFormData] = useState({ + id: '', + scriptName: '', + findRegex: '', + replaceString: '', + trimStrings: [], + placement: [1], // 默认 AI输出 (注意:0=System, 1=AI Output, 2=User Input) + disabled: false, + markdownOnly: false, + promptOnly: false, + runOnEdit: true, + substituteRegex: 0, + minDepth: null, + maxDepth: null + }); + + // 作用范围选项(修正顺序) + const placementOptions = [ + { value: 0, label: '系统提示词' }, + { value: 1, label: 'AI输出' }, + { value: 2, label: '用户输入' }, + { value: 3, label: '快捷命令' }, + { value: 4, label: '世界信息' }, + { value: 5, label: '推理' } + ]; + + // 替换模式选项 + const substituteOptions = [ + { value: 0, label: '不替换' }, + { value: 1, label: '替换首次匹配' }, + { value: 2, label: '替换所有匹配' } + ]; + + // 加载全局规则集 + useEffect(() => { + fetchGlobalRulesets(); + }, []); + + // 当预设变化时加载预设规则集 + useEffect(() => { + if (selectedPreset) { + fetchPresetRulesets(selectedPreset); + } else { + setPresetRulesets([]); + } + }, [selectedPreset]); + + const fetchGlobalRulesets = async () => { + try { + setIsLoading(true); + const response = await fetch('/api/regex/rulesets/global'); + const data = await response.json(); + + if (data.success) { + setGlobalRulesets(data.rulesets || []); + } + } catch (error) { + console.error('加载全局规则集失败:', error); + } finally { + setIsLoading(false); + } + }; + + const fetchPresetRulesets = async (presetName) => { + try { + const response = await fetch(`/api/regex/rulesets/preset/${encodeURIComponent(presetName)}`); + const data = await response.json(); + + if (data.success && data.ruleset) { + setPresetRulesets([data.ruleset]); + } else { + setPresetRulesets([]); + } + } catch (error) { + console.error('加载预设规则集失败:', error); + setPresetRulesets([]); + } + }; + + // 切换折叠状态 + const toggleCollapse = (section) => { + setCollapsedSections(prev => ({ + ...prev, + [section]: !prev[section] + })); + }; + + // 打开编辑器 - 新建规则 + const handleNewRule = (scope = 'global') => { + setFormData({ + id: crypto.randomUUID(), + scriptName: '', + findRegex: '', + replaceString: '', + trimStrings: [], + placement: [1], + disabled: false, + markdownOnly: false, + promptOnly: false, + runOnEdit: true, + substituteRegex: 0, + minDepth: null, + maxDepth: null + }); + setEditingRule({ scope }); + setShowEditor(true); + }; + + // 打开编辑器 - 编辑规则 + const handleEditRule = (rule) => { + setFormData({ + ...rule, + placement: rule.placement || [2], + trimStrings: rule.trimStrings || [] + }); + setEditingRule(rule); + setShowEditor(true); + }; + + // 保存规则 + const handleSaveRule = async () => { + try { + setIsLoading(true); + + const scope = editingRule?.scope || 'global'; + const presetName = scope === 'preset' ? selectedPreset : null; + + const response = await fetch('/api/regex/rules', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + rule: formData, + scope: scope, + name: presetName + }) + }); + + const data = await response.json(); + + if (data.success) { + alert('规则保存成功!'); + setShowEditor(false); + if (scope === 'preset') { + fetchPresetRulesets(selectedPreset); + } else { + fetchGlobalRulesets(); + } + } else { + alert('保存失败: ' + data.message); + } + } catch (error) { + console.error('保存规则失败:', error); + alert('保存失败: ' + error.message); + } finally { + setIsLoading(false); + } + }; + + // 删除规则 + const handleDeleteRule = async (ruleId, ruleName, sectionKey = 'global') => { + if (!confirm(`确定要删除规则 "${ruleName}" 吗?`)) { + return; + } + + try { + setIsLoading(true); + + const scope = sectionKey === 'preset' ? 'preset' : 'global'; + const presetName = scope === 'preset' ? selectedPreset : null; + + const url = new URL(`/api/regex/rules/${ruleId}`, window.location.origin); + url.searchParams.set('scope', scope); + if (presetName) { + url.searchParams.set('name', presetName); + } + + const response = await fetch(url.toString(), { + method: 'DELETE' + }); + + const data = await response.json(); + + if (data.success) { + alert('规则已删除'); + if (sectionKey === 'preset') { + fetchPresetRulesets(selectedPreset); + } else { + fetchGlobalRulesets(); + } + } else { + alert('删除失败: ' + data.message); + } + } catch (error) { + console.error('删除规则失败:', error); + alert('删除失败: ' + error.message); + } finally { + setIsLoading(false); + } + }; + + // 切换规则启用状态 + const handleToggleRule = async (rule, sectionKey = 'global') => { + try { + const updatedRule = { ...rule, disabled: !rule.disabled }; + const scope = sectionKey === 'preset' ? 'preset' : 'global'; + const presetName = scope === 'preset' ? selectedPreset : null; + + const response = await fetch('/api/regex/rules', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + rule: updatedRule, + scope: scope, + name: presetName + }) + }); + + const data = await response.json(); + + if (data.success) { + if (sectionKey === 'preset') { + fetchPresetRulesets(selectedPreset); + } else { + fetchGlobalRulesets(); + } + } + } catch (error) { + console.error('更新规则状态失败:', error); + } + }; + + // 更新表单字段 + const updateFormField = (field, value) => { + setFormData(prev => ({ + ...prev, + [field]: value + })); + }; + + // 切换作用范围 + const togglePlacement = (value) => { + setFormData(prev => { + const current = prev.placement || []; + const updated = current.includes(value) + ? current.filter(v => v !== value) + : [...current, value]; + return { ...prev, placement: updated }; + }); + }; + + // 更新修剪字符串 + const updateTrimStrings = (value) => { + const trimStrings = value.split('\n').filter(s => s.trim()); + setFormData(prev => ({ ...prev, trimStrings })); + }; + + // 渲染编辑器 + const renderEditor = () => { + if (!showEditor) return null; + + return ( +
+
+
+

正则表达式编辑器

+ +
+ +
+ {/* 脚本名称 */} +
+ + updateFormField('scriptName', e.target.value)} + placeholder="例如:隐藏thinking" + /> +
+ + {/* 查找正则表达式 */} +
+ + updateFormField('findRegex', e.target.value)} + placeholder="/pattern/flags" + /> + SillyTavern 格式,例如:/<thinking>[\\s\\S]*?<\\/thinking>/gs +
+ + {/* 替换为 */} +
+ +