正则相关
This commit is contained in:
159
CHAT_MANAGER_AND_PROMPT_DEBUG.md
Normal file
159
CHAT_MANAGER_AND_PROMPT_DEBUG.md
Normal 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计数统计
|
||||
394
PROMPT_COMPONENT_ASSEMBLY_FIX.md
Normal file
394
PROMPT_COMPONENT_ASSEMBLY_FIX.md
Normal file
@@ -0,0 +1,394 @@
|
||||
# 预设组件组装修复说明
|
||||
|
||||
## 问题描述
|
||||
|
||||
之前的实现中,`PromptAssembler` **完全没有使用预设组件(promptComponents)**,而是按照固定的 SillyTavern 规范组装提示词。这导致:
|
||||
|
||||
1. ❌ 用户自定义的预设组件被忽略
|
||||
2. ❌ 预设中的自定义指令、系统提示等无法生效
|
||||
3. ❌ 只能使用硬编码的组装逻辑,缺乏灵活性
|
||||
|
||||
---
|
||||
|
||||
## 修复方案
|
||||
|
||||
### 核心思路
|
||||
|
||||
**根据预设组件动态组装提示词**,而不是使用固定的 SillyTavern 规范。
|
||||
|
||||
### 架构设计
|
||||
|
||||
```
|
||||
前端发送 presetConfig
|
||||
↓
|
||||
后端接收 promptComponents 数组
|
||||
↓
|
||||
检测是否有预设组件
|
||||
├─ 有 → 使用 _assemble_prompt_from_components() 动态组装
|
||||
└─ 无 → 使用默认的 PromptAssembler(SillyTavern 规范)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 实现细节
|
||||
|
||||
### 1. 修改 `_assemble_prompt()` 函数
|
||||
|
||||
**文件**: `backend/services/chat_workflow_service.py`
|
||||
|
||||
#### 修复前
|
||||
```python
|
||||
def _assemble_prompt(...):
|
||||
# ❌ 直接使用固定的 PromptAssembler
|
||||
messages = self.prompt_assembler.assemble(
|
||||
character=character,
|
||||
chat_history=chat_history,
|
||||
user_input=user_message,
|
||||
active_entries=active_entries,
|
||||
config=config
|
||||
)
|
||||
return messages
|
||||
```
|
||||
|
||||
#### 修复后
|
||||
```python
|
||||
def _assemble_prompt(...):
|
||||
preset_config = request_data.get("presetConfig", {})
|
||||
prompt_components = preset_config.get("promptComponents", [])
|
||||
|
||||
# ✅ 如果有预设组件,使用预设组件组装
|
||||
if prompt_components and len(prompt_components) > 0:
|
||||
return self._assemble_prompt_from_components(
|
||||
character,
|
||||
chat_history,
|
||||
user_message,
|
||||
active_entries,
|
||||
prompt_components,
|
||||
debug_prompt
|
||||
)
|
||||
else:
|
||||
# ✅ 否则使用默认的 SillyTavern 规范组装
|
||||
print(f"[PromptAssembler] ⚠️ 未检测到预设组件,使用默认SillyTavern规范")
|
||||
return self.prompt_assembler.assemble(...)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 新增 `_assemble_prompt_from_components()` 函数
|
||||
|
||||
**功能**:根据预设组件列表动态组装提示词
|
||||
|
||||
**流程**:
|
||||
1. 过滤出启用的组件(`enabled=True`)
|
||||
2. 按顺序处理每个组件
|
||||
3. 替换模板变量(如 `{{char}}`, `{{description}}` 等)
|
||||
4. 根据组件类型创建对应的消息(System/Human/AI)
|
||||
5. 最后添加用户输入(如果还没有)
|
||||
|
||||
**代码示例**:
|
||||
```python
|
||||
def _assemble_prompt_from_components(...):
|
||||
messages = []
|
||||
|
||||
# 按顺序处理每个启用的组件
|
||||
enabled_components = [comp for comp in prompt_components if comp.get('enabled', True)]
|
||||
|
||||
for component in enabled_components:
|
||||
comp_name = component.get('name', 'Unknown')
|
||||
comp_type = component.get('type', 'text')
|
||||
comp_content = component.get('content', '')
|
||||
|
||||
# 替换模板变量
|
||||
processed_content = self._process_component_content(
|
||||
comp_content,
|
||||
character,
|
||||
chat_history,
|
||||
user_message,
|
||||
active_entries
|
||||
)
|
||||
|
||||
# 跳过空内容
|
||||
if not processed_content or processed_content.strip() == "":
|
||||
continue
|
||||
|
||||
# 根据组件类型创建消息
|
||||
if comp_type == 'system':
|
||||
messages.append(SystemMessage(content=processed_content))
|
||||
elif comp_type == 'user':
|
||||
messages.append(HumanMessage(content=processed_content))
|
||||
elif comp_type == 'assistant':
|
||||
messages.append(AIMessage(content=processed_content))
|
||||
else:
|
||||
messages.append(SystemMessage(content=processed_content))
|
||||
|
||||
# 最后添加用户输入
|
||||
if not messages or not isinstance(messages[-1], HumanMessage):
|
||||
messages.append(HumanMessage(content=user_message))
|
||||
|
||||
return messages
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 新增 `_process_component_content()` 函数
|
||||
|
||||
**功能**:处理组件内容,替换模板变量
|
||||
|
||||
**支持的变量**:
|
||||
|
||||
| 变量 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `{{char}}` | 角色名称 | `GPT` |
|
||||
| `{{user}}` | 用户名称 | `User` |
|
||||
| `{{description}}` | 角色描述 | `你是一个AI助手...` |
|
||||
| `{{personality}}` | 角色性格 | `友好、专业` |
|
||||
| `{{scenario}}` | 场景设定 | `在一个咖啡馆里` |
|
||||
| `{{mes_example}}` | 对话示例 | `<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. **性能优化**
|
||||
- 缓存模板替换结果
|
||||
- 避免重复计算
|
||||
396
REGEX_INTEGRATION_COMPLETE.md
Normal file
396
REGEX_INTEGRATION_COMPLETE.md
Normal 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
|
||||
**状态**: ✅ 核心功能已完成,可投入使用
|
||||
@@ -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 版本
|
||||
|
||||
## 相关文件
|
||||
|
||||
|
||||
501
SILLYTAVERN_REGEX_ARCHITECTURE.md
Normal file
501
SILLYTAVERN_REGEX_ARCHITECTURE.md
Normal 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
312
SWIPE_PERSISTENCE_FIX.md
Normal 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
238
SWIPE_TEST_GUIDE.md
Normal 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`
|
||||
@@ -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()
|
||||
# 不抛出异常,避免影响主流程
|
||||
|
||||
@@ -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():
|
||||
"""导出所有全局规则"""
|
||||
|
||||
@@ -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 表示无限制)")
|
||||
|
||||
# 作用域配置
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
创建新聊天
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
35
data/regex/global/rule-hide-thinking-001.json
Normal file
35
data/regex/global/rule-hide-thinking-001.json
Normal 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
|
||||
}
|
||||
10
data/regex/global/ruleset-global-default.json
Normal file
10
data/regex/global/ruleset-global-default.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "ruleset-global-default",
|
||||
"name": "默认全局规则集",
|
||||
"description": "系统默认的全局正则规则",
|
||||
"rules": [],
|
||||
"createdAt": 1777998120,
|
||||
"updatedAt": 1777998120,
|
||||
"version": 1,
|
||||
"isSillyTavernFormat": false
|
||||
}
|
||||
323
data/regex/presets/MyPreset.json
Normal file
323
data/regex/presets/MyPreset.json
Normal file
File diff suppressed because one or more lines are too long
35
data/regex/presets/test.json
Normal file
35
data/regex/presets/test.json
Normal 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
|
||||
}
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"2026-05-05": {
|
||||
"promptTokens": 2060,
|
||||
"completionTokens": 519,
|
||||
"totalTokens": 2579,
|
||||
"count": 5
|
||||
"promptTokens": 9475,
|
||||
"completionTokens": 2059,
|
||||
"totalTokens": 11534,
|
||||
"count": 12
|
||||
}
|
||||
}
|
||||
@@ -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] ⚠️ 部分配置加载失败,但应用仍可正常使用');
|
||||
// }
|
||||
});
|
||||
}, []); // 仅在应用启动时执行一次
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
536
frontend/src/components/SideBarLeft/tabs/Regex/RegexPanel.css
Normal file
536
frontend/src/components/SideBarLeft/tabs/Regex/RegexPanel.css
Normal 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;
|
||||
}
|
||||
}
|
||||
575
frontend/src/components/SideBarLeft/tabs/Regex/RegexPanel.jsx
Normal file
575
frontend/src/components/SideBarLeft/tabs/Regex/RegexPanel.jsx
Normal 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 格式,例如:/<thinking>[\\s\\S]*?<\\/thinking>/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;
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
131
test_regex_integration.py
Normal 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())
|
||||
Reference in New Issue
Block a user