正则相关

This commit is contained in:
2026-05-06 00:40:18 +08:00
parent f843a74715
commit 9faccc2c03
32 changed files with 4814 additions and 346 deletions

View File

@@ -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计数统计

View File

@@ -0,0 +1,394 @@
# 预设组件组装修复说明
## 问题描述
之前的实现中,`PromptAssembler` **完全没有使用预设组件promptComponents**,而是按照固定的 SillyTavern 规范组装提示词。这导致:
1. ❌ 用户自定义的预设组件被忽略
2. ❌ 预设中的自定义指令、系统提示等无法生效
3. ❌ 只能使用硬编码的组装逻辑,缺乏灵活性
---
## 修复方案
### 核心思路
**根据预设组件动态组装提示词**,而不是使用固定的 SillyTavern 规范。
### 架构设计
```
前端发送 presetConfig
后端接收 promptComponents 数组
检测是否有预设组件
├─ 有 → 使用 _assemble_prompt_from_components() 动态组装
└─ 无 → 使用默认的 PromptAssemblerSillyTavern 规范)
```
---
## 实现细节
### 1. 修改 `_assemble_prompt()` 函数
**文件**: `backend/services/chat_workflow_service.py`
#### 修复前
```python
def _assemble_prompt(...):
# ❌ 直接使用固定的 PromptAssembler
messages = self.prompt_assembler.assemble(
character=character,
chat_history=chat_history,
user_input=user_message,
active_entries=active_entries,
config=config
)
return messages
```
#### 修复后
```python
def _assemble_prompt(...):
preset_config = request_data.get("presetConfig", {})
prompt_components = preset_config.get("promptComponents", [])
# ✅ 如果有预设组件,使用预设组件组装
if prompt_components and len(prompt_components) > 0:
return self._assemble_prompt_from_components(
character,
chat_history,
user_message,
active_entries,
prompt_components,
debug_prompt
)
else:
# ✅ 否则使用默认的 SillyTavern 规范组装
print(f"[PromptAssembler] ⚠️ 未检测到预设组件使用默认SillyTavern规范")
return self.prompt_assembler.assemble(...)
```
---
### 2. 新增 `_assemble_prompt_from_components()` 函数
**功能**:根据预设组件列表动态组装提示词
**流程**
1. 过滤出启用的组件(`enabled=True`
2. 按顺序处理每个组件
3. 替换模板变量(如 `{{char}}`, `{{description}}` 等)
4. 根据组件类型创建对应的消息System/Human/AI
5. 最后添加用户输入(如果还没有)
**代码示例**
```python
def _assemble_prompt_from_components(...):
messages = []
# 按顺序处理每个启用的组件
enabled_components = [comp for comp in prompt_components if comp.get('enabled', True)]
for component in enabled_components:
comp_name = component.get('name', 'Unknown')
comp_type = component.get('type', 'text')
comp_content = component.get('content', '')
# 替换模板变量
processed_content = self._process_component_content(
comp_content,
character,
chat_history,
user_message,
active_entries
)
# 跳过空内容
if not processed_content or processed_content.strip() == "":
continue
# 根据组件类型创建消息
if comp_type == 'system':
messages.append(SystemMessage(content=processed_content))
elif comp_type == 'user':
messages.append(HumanMessage(content=processed_content))
elif comp_type == 'assistant':
messages.append(AIMessage(content=processed_content))
else:
messages.append(SystemMessage(content=processed_content))
# 最后添加用户输入
if not messages or not isinstance(messages[-1], HumanMessage):
messages.append(HumanMessage(content=user_message))
return messages
```
---
### 3. 新增 `_process_component_content()` 函数
**功能**:处理组件内容,替换模板变量
**支持的变量**
| 变量 | 说明 | 示例 |
|------|------|------|
| `{{char}}` | 角色名称 | `GPT` |
| `{{user}}` | 用户名称 | `User` |
| `{{description}}` | 角色描述 | `你是一个AI助手...` |
| `{{personality}}` | 角色性格 | `友好、专业` |
| `{{scenario}}` | 场景设定 | `在一个咖啡馆里` |
| `{{mes_example}}` | 对话示例 | `<START>\nUser: ...` |
| `{{first_mes}}` | 第一条消息 | `你好!我是...` |
| `{{history}}` | 聊天历史 | `User: ...\nGPT: ...` |
| `{{world_info}}` | 世界书信息 | `[世界观]\n...` |
**代码示例**
```python
def _process_component_content(self, content, character, ...):
# 替换角色相关变量
content = content.replace('{{char}}', character.name)
content = content.replace('{{description}}', character.description)
content = content.replace('{{personality}}', character.personality)
# 替换聊天历史
if '{{history}}' in content:
history_text = self._format_chat_history(chat_history)
content = content.replace('{{history}}', history_text)
# 替换世界书信息
if '{{world_info}}' in content and active_entries:
world_info_text = self._format_world_info(active_entries)
content = content.replace('{{world_info}}', world_info_text)
return content
```
---
### 4. 新增辅助函数
#### `_format_chat_history()`
格式化聊天历史为文本:
```
User: 你好
Assistant: 你好!有什么可以帮助你的吗?
User: 今天天气怎么样?
```
#### `_format_world_info()`
格式化世界书信息为文本:
```
[世界观]
这是一个奇幻世界...
[地点]
咖啡馆位于市中心...
```
---
## 调试输出
启用 `debugPrompt: true` 时,后端会输出详细的调试信息:
```
================================================================================
[Prompt Debug] 🧩 预设组件配置
================================================================================
[Prompt Debug] 组件数量: 6
1. Main Prompt (enabled=True, type=system)
2. Character Description (enabled=True, type=system)
3. World Info (enabled=True, type=system)
4. Chat History (enabled=True, type=text)
5. Author's Note (enabled=True, type=system)
6. Post-History Instructions (enabled=False, type=system)
================================================================================
[Prompt Debug] 🔄 开始组装 5 个启用的组件
[Prompt Debug] --- 组件 1: Main Prompt ---
类型: system
内容长度: 150 字符
处理后长度: 150 字符
完整内容:
你是一个专业的角色扮演AI助手...
[Prompt Debug] --- 组件 2: Character Description ---
类型: system
内容长度: 300 字符
处理后长度: 300 字符
预览:
[Character('GPT')]
你是一个AI助手...
...
[Prompt Debug] ✅ 组装完成,总消息数: 5
================================================================================
```
---
## 预设组件数据结构
### 前端发送的格式
```javascript
presetConfig: {
selectedPreset: "MyPreset",
parameters: {
temperature: 0.8,
max_tokens: 2000
},
promptComponents: [
{
name: "Main Prompt",
type: "system",
content: "你是一个专业的角色扮演AI助手...",
enabled: true,
order: 0
},
{
name: "Character Description",
type: "system",
content: "[Character('{{char}}')]\n{{description}}\n\nPersonality: {{personality}}",
enabled: true,
order: 1
},
{
name: "Chat History",
type: "text",
content: "{{history}}",
enabled: true,
order: 2
},
{
name: "User Input",
type: "user",
content: "", // 空内容,后端会自动添加
enabled: true,
order: 3
}
]
}
```
### 字段说明
| 字段 | 类型 | 说明 |
|------|------|------|
| `name` | string | 组件名称(用于显示和调试) |
| `type` | string | 消息类型:`system`, `user`, `assistant`, `text` |
| `content` | string | 组件内容,支持模板变量 |
| `enabled` | boolean | 是否启用该组件 |
| `order` | number | 组件的顺序(从小到大) |
---
## 测试步骤
### 1. 测试预设组件组装
1. 在前端选择一个包含多个组件的预设
2. 发送一条消息
3. 检查后端控制台输出:
```
[Prompt Debug] 🧩 预设组件配置
[Prompt Debug] 组件数量: 6
[Prompt Debug] 🔄 开始组装 5 个启用的组件
[Prompt Debug] ✅ 组装完成,总消息数: 5
```
4. 确认每个组件都被正确处理
### 2. 测试模板变量替换
1. 在预设组件中使用 `{{char}}`, `{{description}}` 等变量
2. 发送消息
3. 检查调试输出中的"处理后长度"和"完整内容"
4. 确认变量已被正确替换
### 3. 测试禁用组件
1. 在预设中禁用某个组件(`enabled: false`
2. 发送消息
3. 检查调试输出,确认该组件被跳过
### 4. 测试回退机制
1. 选择一个没有预设组件的预设(或清空组件)
2. 发送消息
3. 检查后端日志:
```
[PromptAssembler] ⚠️ 未检测到预设组件使用默认SillyTavern规范
```
4. 确认使用了默认的 PromptAssembler
---
## 相关文件清单
### 后端文件
- `backend/services/chat_workflow_service.py`
- 修改 `_assemble_prompt()` - 添加预设组件检测
- 新增 `_assemble_prompt_from_components()` - 基于预设组件组装
- 新增 `_process_component_content()` - 模板变量替换
- 新增 `_format_chat_history()` - 格式化聊天历史
- 新增 `_format_world_info()` - 格式化世界书信息
### 前端文件
- `frontend/src/Store/Mid/ChatBoxSlice.jsx`
- 发送 `presetConfig.promptComponents` 到后端
---
## 注意事项
### ⚠️ 重要提醒
1. **组件顺序很重要**
- 组件按 `order` 字段从小到大处理
- 确保系统提示在聊天历史之前
- 用户输入应该在最后
2. **模板变量的大小写**
- 变量名区分大小写:`{{char}}` ≠ `{{Char}}`
- 建议使用小写
3. **空内容的处理**
- 内容为空的组件会被自动跳过
- 可以用作占位符
4. **兼容性**
- 如果没有预设组件,会自动回退到 SillyTavern 规范
- 保证向后兼容
---
## 未来改进方向
1. **更多模板变量**
- `{{date}}`: 当前日期
- `{{time}}`: 当前时间
- `{{random}}`: 随机数
- `{{counter}}`: 计数器
2. **条件渲染**
- 支持 `{{#if variable}}...{{/if}}` 语法
- 根据条件显示/隐藏组件
3. **循环渲染**
- 支持 `{{#each entries}}...{{/each}}` 语法
- 遍历世界书条目
4. **自定义函数**
- 支持调用 Python 函数处理内容
- 例如:`{{uppercase text}}`, `{{lowercase text}}`
5. **性能优化**
- 缓存模板替换结果
- 避免重复计算

View File

@@ -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": "/<thinking>[\\s\\S]*?<\\/thinking>/gs",
"replaceString": "",
"placement": [2],
"promptOnly": true,
"markdownOnly": false
}
```
**效果**
- ✅ 发送给LLM不包含 `<thinking>` 标签
- ✅ 用户看到:仍然显示 `<thinking>` 标签
---
### 示例2美化TIPS显示仅影响渲染
**规则配置**
```json
{
"scriptName": "世界知识",
"findRegex": "/TIPS_DESIGN\\[世界知识\\]/g",
"replaceString": "<details><summary>点击查看</summary>...</details>",
"placement": [1, 2],
"promptOnly": false,
"markdownOnly": true
}
```
**效果**
- ✅ 原始文本:保持 `TIPS_DESIGN[世界知识]`
- ✅ 渲染后:显示为可折叠的详情框
---
## 📁 文件结构
```
llm_workflow_engine/
├── backend/
│ ├── services/
│ │ ├── regex_service.py ✅ 已增强(添加 promptOnly/markdownOnly
│ │ └── chat_workflow_service.py ✅ 已集成(用户输入 + AI输出处理
│ ├── api/routes/
│ │ └── regexRoute.py ✅ 已添加新端点 /import-from-preset
│ └── models/
│ └── regex_rules.py ✅ 已有完整模型定义
├── frontend/
│ └── src/components/SideBarLeft/tabs/Presets/
│ └── Presets.jsx ✅ 已集成预设导入时的正则提取
├── data/
│ └── regex/
│ ├── global/ # 全局规则
│ ├── characters/ # 角色卡绑定规则
│ └── presets/ # 预设绑定规则(自动创建)
├── SILLYTAVERN_REGEX_ARCHITECTURE.md ✅ 完整架构文档
└── test_regex_integration.py ✅ 测试文件
```
---
## ⚠️ 注意事项
### 1. 字段含义澄清
根据 SillyTavern 官方实现和我们从预设文件中分析的结果:
- **`promptOnly: boolean`**
- `true`: 仅应用于**发送给LLM的提示词**(用户看不到变化)
- `false`: 应用于**前端显示给用户的内容**
- **`markdownOnly: boolean`**
- `true`: 仅在 **Markdown 渲染后**应用影响最终HTML显示
- `false`: 在**原始文本**上应用
### 2. 当前限制
- ❌ 尚未实现前端正则管理UI可选功能
- ❌ 尚未实现对系统提示词和世界书的正则处理(需要进一步集成)
- ⚠️ `markdownOnly` 需要前端配合在Markdown渲染后再次应用规则
---
## 🚀 下一步计划
### 第二阶段(已完成)
- ✅ 预设导入时提取正则规则
- ✅ 后端API支持JSON数据导入
- ✅ 自动保存到 `data/regex/presets/`
### 第三阶段(可选)
- [ ] 创建前端正则管理页面
- [ ] 实现规则编辑器
- [ ] 添加导入/导出按钮
- [ ] 实现启用/禁用开关
- [ ] 实现拖拽排序
### 第四阶段(高级)
- [ ]`_assemble_prompt_from_components()` 中处理系统提示词
- [ ] 在世界书激活时应用正则规则
- [ ] 前端Markdown渲染后再次应用 `markdownOnly` 规则
---
## 💡 关键代码片段
### 后端promptOnly 检查逻辑
```python
# backend/services/regex_service.py - 第227-232行
for rule in rules:
# ✅ 检查 promptOnly如果规则只应用于LLM但当前不是LLM场景则跳过
if rule.promptOnly and not is_for_llm:
continue
# ✅ 检查 markdownOnly如果规则只应用于Markdown渲染但当前不是渲染后则跳过
if rule.markdownOnly and not is_markdown_rendered:
continue
# ... 继续应用规则
```
### 前端:预设导入时提取正则
```javascript
// frontend/src/components/SideBarLeft/tabs/Presets/Presets.jsx - 第461-485行
if (importedPreset.extensions?.regex_scripts && importedPreset.extensions.regex_scripts.length > 0) {
const regexScripts = importedPreset.extensions.regex_scripts;
console.log(`\n[预设导入] 🔍 检测到 ${regexScripts.length} 条正则规则`);
try {
const response = await fetch('/api/regex/import-from-preset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
rules: regexScripts,
scope: 'preset',
presetName: presetName
})
});
if (response.ok) {
const result = await response.json();
console.log('[预设导入] ✅ 正则规则已保存到:', `data/regex/presets/${presetName}.json`);
console.log('[预设导入] 📊 导入结果:', result.message);
}
} catch (error) {
console.error('[预设导入] ❌ 保存正则规则失败:', error);
}
}
```
---
## ✨ 总结
**核心功能已完成**
1. `promptOnly``markdownOnly` 字段逻辑已实现
2. 用户输入和AI输出的正则处理已集成到聊天流程
3. 预设导入时自动提取并保存正则规则
4. 完整的架构文档已创建
🎉 **现在可以**
- 导入包含 `extensions.regex_scripts` 的 SillyTavern 预设
- 正则规则会自动保存到 `data/regex/presets/` 文件夹
- 聊天时自动应用适用的正则规则(全局+角色+预设)
- 根据 `promptOnly``markdownOnly` 智能控制规则应用时机
📝 **建议测试**
1. 导入一个包含正则规则的预设(如 A.U.T.O.预设)
2. 检查 `data/regex/presets/` 文件夹是否生成了对应的JSON文件
3. 发送消息,观察控制台日志中的 `[Regex] ✅ 已应用...` 输出
4. 验证规则是否正确应用(如思考标签是否被隐藏)
---
**完成时间**: 2026-05-05
**版本**: v1.0
**状态**: ✅ 核心功能已完成,可投入使用

View File

@@ -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 版本
## 相关文件

View File

@@ -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 - / <thinking>
```
**示例**
```json
{
"placement": [1, 2], // 同时应用于用户输入和AI输出
"scriptName": "隐藏思考标签",
"findRegex": "/<thinking>[\\s\\S]*?<\\/thinking>/gs",
"replaceString": ""
}
```
---
### 3. 作用范围控制(关键!)
#### `promptOnly: boolean`
**是否仅应用于发送给 LLM 的提示词**
- `true`:
- ✅ 只在发送给LLM**之前**应用
- ❌ 前端显示时**不应用**
- 🎯 用途清理发送给LLM的内容但保留原始内容给用户看
- `false`:
- ✅ 在前端显示时也应用
- 🎯 用途:修改用户看到的内容
**示例场景**
```json
{
"scriptName": "去除尾部破甲",
"findRegex": "/<disclaimer>.*?<\\/disclaimer>/gs",
"replaceString": "",
"promptOnly": true, // ✅ 只影响发送给LLM的内容
"markdownOnly": false,
"placement": [2] // AI输出
}
```
效果:
- 发送给LLM不包含 `<disclaimer>` 标签
- 用户看到:仍然显示 `<disclaimer>` 标签
---
#### `markdownOnly: boolean`
**是否仅应用于 Markdown 渲染后的内容**
- `true`:
- ✅ 只对最终渲染的HTML应用
- ❌ 不对原始文本应用
- 🎯 用途:修改显示效果,不影响原始数据
- `false`:
- ✅ 对原始文本应用
- 🎯 用途:修改实际内容
**示例场景**
```json
{
"scriptName": "世界知识",
"findRegex": "/TIPS_DESIGN\\[世界知识\\]/g",
"replaceString": "<details>...</details>",
"promptOnly": false,
"markdownOnly": true, // ✅ 只影响Markdown渲染
"placement": [1, 2]
}
```
效果:
- 原始文本:保持 `TIPS_DESIGN[世界知识]`
- 渲染后:显示为折叠的 `<details>` 元素
---
### 4. 其他重要字段
#### `substituteRegex: number`
替换模式:
- `0`: REPLACE_ALL - 替换所有匹配
- `1`: REPLACE_FIRST - 仅替换首次匹配
- `2`: REPLACE_CAPTURED - 替换捕获组
#### `runOnEdit: boolean`
用户编辑消息时是否重新应用正则规则。
#### `minDepth / maxDepth: number`
消息深度控制(从最新消息开始计数):
- `minDepth`: 最小深度
- `maxDepth`: 最大深度null 表示无限制)
**用途**:只对特定深度的历史消息应用规则
---
## 数据结构
### 单条规则RegexRule
```json
{
"id": "uuid-string",
"scriptName": "规则名称",
"findRegex": "/pattern/flags",
"replaceString": "replacement",
"trimStrings": [],
"placement": [1, 2],
"disabled": false,
"markdownOnly": true,
"promptOnly": false,
"runOnEdit": true,
"substituteRegex": 0,
"minDepth": null,
"maxDepth": null
}
```
### 规则集RegexRuleset
```json
{
"id": "uuid-string",
"name": "规则集名称",
"description": "描述",
"rules": [...],
"isSillyTavernFormat": true
}
```
---
## 导入导出
### 1. 正则规则的存储位置
#### 方式一:独立的正则文件(推荐)
```
data/regex/
├── global/ # 全局规则
│ └── default.json
├── characters/ # 角色卡绑定规则
│ ├── 角色A.json
│ └── 角色B.json
└── presets/ # 预设绑定规则
├── 预设A.json
└── 预设B.json
```
**导入方式**
- 前端:系统设置 → 正则管理 → 导入
- 后端自动加载文件夹中的所有JSON文件
#### 方式二:嵌入预设文件
**预设文件结构**
```json
{
"prompts": [...],
"prompt_order": [...],
"extensions": {
"regex_scripts": [
{
"id": "...",
"scriptName": "...",
"findRegex": "...",
...
}
]
}
}
```
**导入方式**
- 导入预设时,自动提取 `extensions.regex_scripts`
- 根据预设名称保存到 `data/regex/presets/{presetName}.json`
---
### 2. 导入流程
```mermaid
graph TD
A[用户上传JSON文件] --> B{检测文件类型}
B -->|正则文件| C[直接导入正则规则]
B -->|预设文件| D[解析预设]
D --> E{是否有 extensions.regex_scripts?}
E -->|是| F[提取正则规则]
E -->|否| G[仅导入预设]
F --> H[保存到 data/regex/presets/]
C --> I[更新内存缓存]
H --> I
I --> J[完成]
```
---
## 应用时机
### 聊天流程中的正则应用点
```
用户输入
[Placement 1: USER_INPUT]
↓ apply_rules_by_placement(text, 1)
发送到后端
组装Prompt
[Placement 0: SYSTEM_PROMPT]
↓ apply_rules_by_placement(prompt, 0)
[Placement 4: WORLD_INFO]
↓ apply_rules_by_placement(world_info, 4)
[Placement 5: REASONING]
↓ apply_rules_by_placement(reasoning, 5)
发送给LLM
LLM生成回复
接收AI输出
[Placement 2: AI_OUTPUT]
↓ apply_rules_by_placement(ai_output, 2)
保存到文件
返回前端
前端显示
[Placement 3: QUICK_REPLY] (如果启用)
Markdown渲染
[markdownOnly=true 的规则]
最终显示给用户
```
---
## 实现细节
### 后端实现
#### 1. RegexService
**文件**: `backend/services/regex_service.py`
**核心方法**
```python
def apply_rules_by_placement(
self,
text: str,
placement: int,
character_name: Optional[str] = None,
preset_name: Optional[str] = None,
message_depth: int = 0
) -> str:
"""根据 placement 应用正则规则"""
def get_rules_for_context(
self,
character_name: Optional[str] = None,
preset_name: Optional[str] = None
) -> List[RegexRule]:
"""获取适用的规则列表(全局+角色+预设)"""
```
#### 2. 集成到聊天流程
**文件**: `backend/services/chat_workflow_service.py`
需要在以下位置调用正则处理:
```python
# 1. 处理用户输入
processed_user_input = regex_service.apply_rules_by_placement(
user_message,
placement=RegexPlacement.USER_INPUT.value,
character_name=current_role,
preset_name=preset_name
)
# 2. 处理系统提示词
for i, msg in enumerate(messages):
if msg.role == 'system':
msg.content = regex_service.apply_rules_by_placement(
msg.content,
placement=RegexPlacement.SYSTEM_PROMPT.value,
character_name=current_role,
preset_name=preset_name
)
# 3. 处理AI输出
processed_ai_output = regex_service.apply_rules_by_placement(
ai_response,
placement=RegexPlacement.AI_OUTPUT.value,
character_name=current_role,
preset_name=preset_name
)
```
---
### 前端实现
#### 1. 正则管理UI
**位置**: 左侧边栏 → 系统设置 → 正则管理
**功能**
- 查看当前生效的规则列表
- 启用/禁用规则
- 调整规则顺序
- 导入/导出规则集
- 创建/编辑规则
#### 2. 预设导入时的正则提取
**文件**: `frontend/src/components/SideBarLeft/tabs/Presets/Presets.jsx`
```javascript
const handleImportPreset = async (file) => {
const importedPreset = JSON.parse(importData);
// ✅ 提取正则规则
if (importedPreset.extensions?.regex_scripts) {
const regexScripts = importedPreset.extensions.regex_scripts;
console.log(`[预设导入] 检测到 ${regexScripts.length} 条正则规则`);
// 保存到后端
await fetch('/api/regex/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
rules: regexScripts,
scope: 'preset',
presetName: presetName
})
});
}
// ... 继续导入预设
};
```
---
## 使用示例
### 示例1隐藏思考标签
```json
{
"id": "hide-thinking-001",
"scriptName": "隐藏思考标签",
"findRegex": "/<thinking>[\\s\\S]*?<\\/thinking>/gs",
"replaceString": "",
"placement": [2], // AI输出
"promptOnly": true, // 只影响发送给LLM的内容
"markdownOnly": false,
"disabled": false
}
```
**效果**
- 发送给LLM移除 `<thinking>` 标签
- 用户看到:仍然显示 `<thinking>` 标签
---
### 示例2美化TIPS显示
```json
{
"id": "tips-design-001",
"scriptName": "世界知识",
"findRegex": "/TIPS_DESIGN\\[世界知识\\]/g",
"replaceString": "<details><summary>点击查看说明</summary>...</details>",
"placement": [1, 2], // 用户输入 + AI输出
"promptOnly": false, // 影响显示
"markdownOnly": true, // 只影响Markdown渲染
"disabled": false
}
```
**效果**
- 原始文本:保持 `TIPS_DESIGN[世界知识]`
- 渲染后:显示为可折叠的详情框
---
### 示例3去除免责声明
```json
{
"id": "remove-disclaimer-001",
"scriptName": "去除尾部破甲",
"findRegex": "/<disclaimer>.*?<\\/disclaimer>/gs",
"replaceString": "",
"placement": [2], // AI输出
"promptOnly": true, // 只影响发送给LLM
"markdownOnly": false,
"disabled": false
}
```
---
## 注意事项
### ⚠️ 重要提醒
1. **`promptOnly` vs `markdownOnly` 的区别**
- `promptOnly`: 控制是否影响发送给LLM的内容
- `markdownOnly`: 控制是否影响Markdown渲染
- 两者可以组合使用
2. **规则的优先级**
-`order` 字段从小到大执行
- 同一 placement 的规则按顺序应用
3. **性能考虑**
- 正则表达式应尽量高效
- 避免在长文本上使用复杂的正则
- 可以使用 `minDepth/maxDepth` 限制应用范围
4. **调试技巧**
- 在后端日志中输出应用的正则规则
- 使用 `debugPrompt=true` 查看完整的prompt拼接结果
- 前端可以提供"预览"功能,显示正则应用前后的对比
---
## 待实现功能
### 1. 前端UI
- [ ] 正则管理页面
- [ ] 规则编辑器
- [ ] 导入/导出功能
- [ ] 实时预览
### 2. 后端集成
- [ ] 在聊天流程中调用正则处理
- [ ] 支持 `promptOnly``markdownOnly` 逻辑
- [ ] 预设导入时自动提取正则规则
### 3. 测试
- [ ] 单元测试
- [ ] 集成测试
- [ ] 性能测试
---
## 参考资源
- SillyTavern 官方文档: https://docs.sillytavern.app/
- SillyTavern GitHub: https://github.com/SillyTavern/SillyTavern
- 正则表达式教程: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Guide/Regular_Expressions

312
SWIPE_PERSISTENCE_FIX.md Normal file
View File

@@ -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 && (
<div className="swipe-controls">
<button onClick={() => handleSwipeChange(message.floor, -1)}>◀</button>
<span>{currentSwipeIndex + 1}/{message.swipes.length}</span>
<button onClick={() => handleSwipeChange(message.floor, 1)}>▶</button>
</div>
)}
```
### 关键点
1. **后端是权威数据源**`message.swipes` 和 `message.swipe_id` 来自后端
2. **前端只维护临时状态**`currentSwipeId` 只在当前会话有效,刷新后从后端重新加载
3. **合并更新**:切换 swipe 时使用 `{ ...currentSwipeId, [messageId]: newIndex }` 保留其他楼层的状态
---
## 测试步骤
### 1. 测试重roll持久化
1. 发送一条消息等待AI回复
2. 右键点击AI消息选择"重roll"
3. 重复重roll 2-3 次
4. **刷新页面**
5. 观察:
- ✅ swipe 控制按钮仍然存在
- ✅ 可以切换到之前的所有版本
- ✅ 每个版本的内容正确显示
### 2. 测试多次重roll
1. 重roll 5 次
2. 检查后端控制台日志:
```
[WebSocket] 🔄 重roll模式更新楼层 5 的 swipes
[WebSocket] 📊 Swipes 更新: 4 -> 5
[WebSocket] ✅ 楼层 5 已更新swipes 数量: 5
```
3. 打开 JSONL 文件,确认 swipes 数组有 5 个元素
### 3. 测试正常模式
1. 发送新消息不是重roll
2. 检查后端控制台日志:
```
[WebSocket] 正常模式,创建新消息
[WebSocket] ✅ 新消息已保存: 角色名/聊天名
```
3. 确认创建了两条新消息(用户消息 + AI消息
---
## 相关文件清单
### 后端文件
- `backend/services/chat_service.py`
- 新增 `get_message()` 方法
- `backend/api/routes/chatWsRoute.py`
- 修改 `_save_messages()` 函数区分重roll和正常模式
### 前端文件
- `frontend/src/components/Mid/ChatBox/ChatBox.jsx`
- `handleSwipeChange()` - 合并更新 currentSwipeId
- 渲染逻辑 - 从 swipes 数组读取内容
- `frontend/src/Store/Mid/ChatBoxSlice.jsx`
- 发送消息时传递 `floor` 参数重roll时为目标楼层
---
## 注意事项
### ⚠️ 重要提醒
1. **不要在前端直接修改 swipes**
- swipes 的增删改必须通过后端API
- 前端只负责读取和显示
2. **swipe_id 的优先级**
- 后端保存的 `message.swipe_id` 是默认值
- 前端的 `currentSwipeId[floor]` 可以临时覆盖(仅当前会话)
- 刷新页面后,以后端的 `swipe_id` 为准
3. **兼容性处理**
- 旧消息可能没有 swipes 字段
- 代码中使用了 `existing_message.get("swipes", [])` 提供默认值
- 第一次重roll时会初始化 swipes 数组
---
## 未来改进方向
1. **swipe 管理功能**
- 删除某个版本
- 重命名版本(添加备注)
- 导出特定版本
2. **性能优化**
- swipes 数组过大时,考虑分页加载
- 压缩存储长文本
3. **用户体验**
- 显示每个版本的生成时间
- 显示 Token 使用量
- 支持版本对比diff 视图)

238
SWIPE_TEST_GUIDE.md Normal file
View File

@@ -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`

View File

@@ -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()
# 不抛出异常,避免影响主流程

View File

@@ -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():
"""导出所有全局规则"""

View File

@@ -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 表示无限制)")
# 作用域配置

View File

@@ -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:
"""
创建新聊天

View File

@@ -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)

View File

@@ -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

View File

@@ -0,0 +1,35 @@
{
"id": "rule-hide-thinking-001",
"name": "隐藏思考标签",
"description": null,
"rules": [
{
"id": "rule-hide-thinking-001",
"scriptName": "隐藏思考标签",
"findRegex": "<thinking>[\\s\\S]*?<\\/thinking>",
"replaceString": "",
"trimStrings": [],
"placement": [
2
],
"substituteRegex": 0,
"markdownOnly": false,
"promptOnly": false,
"runOnEdit": true,
"minDepth": 0,
"maxDepth": null,
"scope": "global",
"characterName": null,
"presetName": null,
"disabled": false,
"order": 0,
"createdAt": 1777997833,
"updatedAt": 1777997833,
"description": "隐藏 AI 回复中的 <thinking> 标签及其内容"
}
],
"createdAt": 1777997834,
"updatedAt": 1777997834,
"version": 1,
"isSillyTavernFormat": false
}

View File

@@ -0,0 +1,10 @@
{
"id": "ruleset-global-default",
"name": "默认全局规则集",
"description": "系统默认的全局正则规则",
"rules": [],
"createdAt": 1777998120,
"updatedAt": 1777998120,
"version": 1,
"isSillyTavernFormat": false
}

File diff suppressed because one or more lines are too long

View File

@@ -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
}

View File

@@ -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"}

View File

@@ -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
}
}

View File

@@ -1,8 +1,8 @@
{
"2026-05-05": {
"promptTokens": 2060,
"completionTokens": 519,
"totalTokens": 2579,
"count": 5
"promptTokens": 9475,
"completionTokens": 2059,
"totalTokens": 11534,
"count": 12
}
}

View File

@@ -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] ⚠️ 部分配置加载失败,但应用仍可正常使用');
// }
});
}, []); // 仅在应用启动时执行一次

View File

@@ -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);
}
},

View File

@@ -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;
}
},

View File

@@ -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 {

View File

@@ -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..."
/>
</div>
@@ -751,6 +794,14 @@ const ChatBox = () => {
</div>
)}
</div>
{/* ✅ 删除按钮 */}
<button
className="chat-delete-btn"
onClick={(e) => handleDeleteChat(chat.chat_name, e)}
title="删除此聊天"
>
🗑
</button>
</div>
))}
</div>

View File

@@ -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);
}
};

View File

@@ -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;
}
}

View File

@@ -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 (
<div className="regex-editor-overlay">
<div className="regex-editor-modal">
<div className="regex-editor-header">
<h3>正则表达式编辑器</h3>
<button className="close-btn" onClick={() => setShowEditor(false)}>×</button>
</div>
<div className="regex-editor-body">
{/* 脚本名称 */}
<div className="form-group">
<label>脚本名称</label>
<input
type="text"
value={formData.scriptName}
onChange={(e) => updateFormField('scriptName', e.target.value)}
placeholder="例如隐藏thinking"
/>
</div>
{/* 查找正则表达式 */}
<div className="form-group">
<label>查找正则表达式</label>
<input
type="text"
value={formData.findRegex}
onChange={(e) => updateFormField('findRegex', e.target.value)}
placeholder="/pattern/flags"
/>
<small>SillyTavern 格式例如/&lt;thinking&gt;[\\s\\S]*?&lt;\\/thinking&gt;/gs</small>
</div>
{/* 替换为 */}
<div className="form-group">
<label>替换为</label>
<textarea
value={formData.replaceString}
onChange={(e) => updateFormField('replaceString', e.target.value)}
placeholder="使用 {{match}} 或 $1, $2 等捕获组"
rows={3}
/>
</div>
{/* 修剪掉 */}
<div className="form-group">
<label>修剪掉</label>
<textarea
value={formData.trimStrings.join('\n')}
onChange={(e) => updateTrimStrings(e.target.value)}
placeholder="每行一个字符串"
rows={2}
/>
</div>
{/* 作用范围和其他选项 */}
<div className="form-row">
<div className="form-section">
<h4>作用范围</h4>
<div className="checkbox-group">
{placementOptions.map(opt => (
<label key={opt.value} className="checkbox-item">
<input
type="checkbox"
checked={formData.placement.includes(opt.value)}
onChange={() => togglePlacement(opt.value)}
/>
<span>{opt.label}</span>
</label>
))}
</div>
</div>
<div className="form-section">
<h4>其他选项</h4>
<div className="checkbox-group">
<label className="checkbox-item">
<input
type="checkbox"
checked={formData.disabled}
onChange={(e) => updateFormField('disabled', e.target.checked)}
/>
<span>已禁用</span>
</label>
<label className="checkbox-item">
<input
type="checkbox"
checked={formData.runOnEdit}
onChange={(e) => updateFormField('runOnEdit', e.target.checked)}
/>
<span>在编辑时运行</span>
</label>
<label className="checkbox-item">
<input
type="checkbox"
checked={formData.markdownOnly}
onChange={(e) => updateFormField('markdownOnly', e.target.checked)}
/>
<span>仅格式显示</span>
</label>
<label className="checkbox-item">
<input
type="checkbox"
checked={formData.promptOnly}
onChange={(e) => updateFormField('promptOnly', e.target.checked)}
/>
<span>仅格式提示词</span>
</label>
</div>
</div>
</div>
{/* 深度设置 */}
<div className="form-row">
<div className="form-group small">
<label>最小深度</label>
<input
type="number"
value={formData.minDepth || ''}
onChange={(e) => updateFormField('minDepth', e.target.value ? parseInt(e.target.value) : null)}
placeholder="无限"
/>
</div>
<div className="form-group small">
<label>最大深度</label>
<input
type="number"
value={formData.maxDepth || ''}
onChange={(e) => updateFormField('maxDepth', e.target.value ? parseInt(e.target.value) : null)}
placeholder="无限"
/>
</div>
</div>
{/* 替换模式 */}
<div className="form-group">
<label>替换模式</label>
<select
value={formData.substituteRegex}
onChange={(e) => updateFormField('substituteRegex', parseInt(e.target.value))}
>
{substituteOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
</div>
<div className="regex-editor-footer">
<button className="btn-cancel" onClick={() => setShowEditor(false)}>
取消
</button>
<button className="btn-save" onClick={handleSaveRule} disabled={isLoading}>
{isLoading ? '保存中...' : '保存'}
</button>
</div>
</div>
</div>
);
};
// 渲染可折叠的规则列表
const renderCollapsibleRules = (title, rulesets, sectionKey, icon = '📜') => {
const allRules = rulesets.flatMap(ruleset => ruleset.rules || []);
const isCollapsed = collapsedSections[sectionKey];
return (
<div className="regex-section">
<div
className="regex-section-header"
onClick={() => toggleCollapse(sectionKey)}
>
<div className="section-title">
<span className="collapse-icon">{isCollapsed ? '▶' : '▼'}</span>
<span className="section-icon">{icon}</span>
<h4>{title}</h4>
<span className="rule-count">({allRules.length})</span>
</div>
{!isCollapsed && allRules.length > 0 && (
<button className="btn-add-rule-small" onClick={(e) => {
e.stopPropagation();
handleNewRule(sectionKey);
}}>
+ 添加
</button>
)}
</div>
{!isCollapsed && (
<div className="regex-section-content">
{allRules.length === 0 ? (
<div className="empty-rules">
<p>暂无规则</p>
</div>
) : (
<div className="rules-list">
{allRules.map((rule) => (
<div key={rule.id} className="rule-item">
<div className="rule-info">
<span className="rule-name">{rule.scriptName}</span>
<div className="rule-badges">
{rule.disabled && <span className="rule-badge disabled">已禁用</span>}
{rule.promptOnly && <span className="rule-badge prompt">仅提示词</span>}
{rule.markdownOnly && <span className="rule-badge markdown">仅格式</span>}
</div>
</div>
<div className="rule-actions">
<label className="toggle-switch" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={!rule.disabled}
onChange={() => handleToggleRule(rule, sectionKey)}
/>
<span className="toggle-slider"></span>
</label>
<button className="icon-btn" onClick={(e) => {
e.stopPropagation();
handleEditRule(rule);
}} title="编辑">
</button>
<button className="icon-btn" onClick={(e) => {
e.stopPropagation();
handleDeleteRule(rule.id, rule.scriptName, sectionKey);
}} title="删除">
🗑
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
);
};
return (
<div className="regex-panel">
<div className="regex-panel-header">
<h2>正则管理</h2>
<p className="subtitle">管理全局和预设的正则规则</p>
</div>
{/* 左右布局 */}
<div className="regex-layout">
{/* 左侧:规则列表 */}
<div className="regex-left">
{/* 全局规则 */}
{renderCollapsibleRules('全局正则脚本', globalRulesets, 'global', '🌍')}
{/* 预设规则 */}
{renderCollapsibleRules(
selectedPreset ? `预设正则脚本 (${selectedPreset})` : '预设正则脚本',
presetRulesets,
'preset',
'⚙️'
)}
</div>
{/* 右侧:说明和快速操作 */}
<div className="regex-right">
<div className="regex-info-card">
<h4>📖 正则规则说明</h4>
<ul>
<li><strong>全局规则</strong>影响所有角色和聊天</li>
<li><strong>预设规则</strong>仅影响当前预设</li>
<li><strong>仅提示词</strong>只应用于发送LLM的内容</li>
<li><strong>仅格式</strong>只应用于Markdown渲染</li>
</ul>
<h4 style={{ marginTop: '20px' }}> 快捷操作</h4>
<div className="quick-actions">
<button className="btn-quick" onClick={() => handleNewRule('global')}>
+ 新建全局规则
</button>
{selectedPreset && (
<button className="btn-quick" onClick={() => handleNewRule('preset')}>
+ 新建预设规则
</button>
)}
</div>
</div>
</div>
</div>
{/* 编辑器弹窗 */}
{renderEditor()}
</div>
);
};
export default RegexPanel;

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import './Settings.css';
import RegexPanel from '../Regex/RegexPanel';
/**
* 系统设置组件
@@ -122,77 +123,7 @@ const Settings = () => {
{/* 正则规则设置 */}
{activeTab === 'regex' && (
<div className="settings-panel">
<div className="panel-header">
<h3>📝 正则规则管理</h3>
<p className="panel-description">
管理文本处理规则支持 SillyTavern 格式导入
</p>
</div>
<div className="settings-group">
<div className="group-title">规则文件结构</div>
<div className="setting-row">
<div className="row-label">存储位置</div>
<div className="row-content">
<code className="path-code">data/regex/</code>
</div>
</div>
<ul className="file-structure-list">
<li><span className="folder-icon">📁</span> global/ - 全局规则</li>
<li><span className="folder-icon">📁</span> characters/ - 角色卡规则</li>
<li><span className="folder-icon">📁</span> presets/ - 预设规则</li>
</ul>
</div>
<div className="settings-group">
<div className="group-title">导入导出</div>
<div className="setting-row">
<div className="row-label">导入规则文件</div>
<div className="row-content">
<input
type="file"
accept=".json"
className="file-input"
onChange={async (e) => {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/regex/import', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
showMessage('success', data.message);
} else {
showMessage('error', '导入失败');
}
} catch (error) {
showMessage('error', '导入失败');
}
}}
/>
<small className="hint-text">支持 SillyTavern 格式的规则文件</small>
</div>
</div>
<div className="setting-row">
<div className="row-label">导出全局规则</div>
<div className="row-content">
<button
className="btn-action"
onClick={() => window.open('/api/regex/export/global', '_blank')}
>
📥 导出 JSON
</button>
</div>
</div>
</div>
<RegexPanel />
</div>
)}

View File

@@ -487,3 +487,181 @@
font-size: 13px;
margin-bottom: 16px;
}
/* ==================== 正则规则列表样式 ==================== */
.regex-rules-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 400px;
overflow-y: auto;
}
.regex-rule-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: var(--color-bg-tertiary);
border-radius: 8px;
transition: all 0.2s;
}
.regex-rule-item:hover {
background: var(--color-bg-hover);
}
.regex-rule-drag {
cursor: grab;
color: var(--color-text-muted);
font-size: 16px;
user-select: none;
}
.regex-rule-info {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.regex-rule-name {
font-size: 14px;
font-weight: 500;
color: var(--color-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.regex-badge {
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
white-space: nowrap;
flex-shrink: 0;
}
.regex-badge.disabled {
background: var(--color-error);
color: white;
}
.regex-badge.prompt {
background: var(--color-info);
color: white;
}
.regex-badge.markdown {
/* 注意Markdown 徽章使用固定紫色,作为功能标识色 */
background: #8844ff;
color: white;
}
.regex-rule-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
/* 开关按钮 */
.toggle-switch {
position: relative;
width: 44px;
height: 24px;
display: inline-block;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--color-border);
transition: 0.3s;
border-radius: 24px;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
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(20px);
}
.icon-btn-sm {
padding: 4px 6px;
background: transparent;
border: none;
color: var(--color-text-muted);
cursor: pointer;
border-radius: 4px;
font-size: 12px;
transition: all 0.2s;
}
.icon-btn-sm:hover {
background: var(--color-bg-hover);
color: var(--color-text-primary);
}
.btn-icon {
padding: 6px 10px;
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: 4px;
color: var(--color-text-secondary);
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.btn-icon:hover {
background: var(--color-bg-hover);
color: var(--color-text-primary);
border-color: var(--color-text-muted);
}
.btn-add-rule {
width: 100%;
padding: 12px;
background: var(--color-bg-tertiary);
border: 2px dashed var(--color-border);
border-radius: 8px;
color: var(--color-text-secondary);
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.btn-add-rule:hover {
background: var(--color-bg-hover);
border-color: var(--color-text-muted);
color: var(--color-text-primary);
}

View File

@@ -5,6 +5,7 @@ import useUserStore from '../../Store/UserSlice'; // ✅ 新增 - 用户角色
import useWorldBookStore from '../../Store/SideBarLeft/WorldBookSlice';
import useApiConfigStore from '../../Store/SideBarLeft/ApiConfigSlice';
import ThemeToggle from './items/ThemeToggle';
import RegexPanel from '../SideBarLeft/tabs/Regex/RegexPanel'; // ✅ 新增:导入正则管理组件
import './TopBar.css';
const Toolbar = () => {
@@ -372,59 +373,13 @@ const Toolbar = () => {
</div>
<div className="setting-section" style={{ marginTop: '20px', paddingTop: '20px', borderTop: '1px solid var(--border-color)' }}>
<h4>则规则管理</h4>
<div style={{ marginBottom: '16px' }}>
<code className="settings-code">
data/regex/
</code>
<ul className="settings-file-list">
<li>📁 global/ - 全局规则</li>
<li>📁 characters/ - 角色卡规则</li>
<li>📁 presets/ - 预设规则</li>
</ul>
</div>
<h4>📝 正则管理</h4>
<p className="settings-hint" style={{ marginBottom: '16px' }}>
管理全局和预设的正则规则
</p>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', marginBottom: '8px', fontSize: '14px', fontWeight: '500', color: 'var(--color-text-primary)' }}>导入规则文件</label>
<input
type="file"
accept=".json"
onChange={async (e) => {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/regex/import', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
showSettingsMessage('success', data.message);
} else {
showSettingsMessage('error', '导入失败');
}
} catch (error) {
showSettingsMessage('error', '导入失败');
}
}}
className="settings-input"
/>
<small className="settings-hint">
支持 SillyTavern 格式的规则文件
</small>
</div>
<button
className="btn-primary"
onClick={() => window.open('/api/regex/export/global', '_blank')}
>
导出全局规则
</button>
{/* ✅ 使用 RegexPanel 组件 */}
<RegexPanel />
</div>
<div style={{ marginTop: '20px' }}>

131
test_regex_integration.py Normal file
View File

@@ -0,0 +1,131 @@
"""
测试正则系统集成到聊天流程
"""
import asyncio
import sys
from pathlib import Path
# 添加 backend 目录到路径
backend_path = Path(__file__).parent / "backend"
sys.path.insert(0, str(backend_path))
from services.regex_service import regex_service
from models.regex_rules import RegexRule, RegexPlacement, SubstituteMode
async def test_regex_integration():
"""测试正则规则在聊天流程中的应用"""
print("=" * 80)
print("测试1: 用户输入正则处理")
print("=" * 80)
# 创建一条测试规则:将 [思考] 标签替换为空仅用于LLM
test_rule = RegexRule(
id="test-hide-thinking",
scriptName="隐藏思考标签",
findRegex=r"\[思考\][\s\S]*?\[/思考\]",
replaceString="",
placement=[RegexPlacement.USER_INPUT],
promptOnly=True, # ✅ 只影响发送给LLM的内容
markdownOnly=False,
substituteRegex=SubstituteMode.REPLACE_ALL,
disabled=False
)
# 手动应用规则(不通过服务)
import re
user_input = "你好![思考]这是一个内部想法,不应该被看到[/思考] 请问天气如何?"
print(f"\n原始输入:\n{user_input}\n")
# 模拟 promptOnly=True 的逻辑
pattern = test_rule.findRegex
processed_for_llm = re.sub(pattern, test_rule.replaceString, user_input)
print(f"处理后发送给LLM - promptOnly=True:\n{processed_for_llm}\n")
# 模拟 promptOnly=False 的情况(不应用规则)
processed_for_display = user_input
print(f"处理后(显示给用户 - promptOnly=False跳过:\n{processed_for_display}\n")
print("\n" + "=" * 80)
print("测试2: AI输出正则处理")
print("=" * 80)
# 模拟AI输出
ai_output = "这是AI的回复。<thinking>我在思考...</thinking> 天气很好!"
print(f"\n原始输出:\n{ai_output}\n")
# 创建一条规则:移除 <thinking> 标签
thinking_rule = RegexRule(
id="test-remove-thinking",
scriptName="移除思考标签",
findRegex=r"<thinking>[\s\S]*?</thinking>",
replaceString="",
placement=[RegexPlacement.AI_OUTPUT],
promptOnly=True, # ✅ 只影响发送给LLM的内容实际是保存时
markdownOnly=False,
substituteRegex=SubstituteMode.REPLACE_ALL
)
# 应用正则
processed_output = regex_service.apply_rules_by_placement(
text=ai_output,
placement=RegexPlacement.AI_OUTPUT.value,
is_for_llm=False,
is_markdown_rendered=False
)
print(f"处理后:\n{processed_output}\n")
print("\n" + "=" * 80)
print("测试3: Markdown渲染规则")
print("=" * 80)
# 模拟包含 TIPS_DESIGN 标记的文本
tips_text = "TIPS_DESIGN[世界知识] 这是一些说明文字"
print(f"\n原始文本:\n{tips_text}\n")
# 创建规则:将 TIPS_DESIGN 转换为 HTML details
tips_rule = RegexRule(
id="test-tips-design",
scriptName="美化TIPS显示",
findRegex=r"TIPS_DESIGN\[([^\]]+)\]",
replaceString=r'<details><summary>\1</summary>点击查看详细说明</details>',
placement=[RegexPlacement.AI_OUTPUT],
promptOnly=False, # ✅ 影响显示
markdownOnly=True, # ✅ 只在Markdown渲染后应用
substituteRegex=SubstituteMode.REPLACE_ALL
)
# 应用正则(未渲染)
processed_unrendered = regex_service.apply_rules_by_placement(
text=tips_text,
placement=RegexPlacement.AI_OUTPUT.value,
is_for_llm=False,
is_markdown_rendered=False # ❌ 未渲染,不应应用
)
print(f"处理后(未渲染):\n{processed_unrendered}\n")
# 应用正则(已渲染)
processed_rendered = regex_service.apply_rules_by_placement(
text=tips_text,
placement=RegexPlacement.AI_OUTPUT.value,
is_for_llm=False,
is_markdown_rendered=True # ✅ 已渲染,应该应用
)
print(f"处理后(已渲染):\n{processed_rendered}\n")
print("\n" + "=" * 80)
print("✅ 所有测试完成!")
print("=" * 80)
if __name__ == "__main__":
asyncio.run(test_regex_integration())