Compare commits
9 Commits
3bfa59cf3e
...
d0582f6aad
| Author | SHA1 | Date | |
|---|---|---|---|
| d0582f6aad | |||
| a857917b53 | |||
| 20d99cf3bf | |||
| 8888d9ea85 | |||
| 2b8be78fdc | |||
| de6015c77e | |||
| 23396caeeb | |||
| fd660c8804 | |||
| aa461ec6c3 |
@@ -1,6 +1,6 @@
|
||||
# 云酒馆项目开发进度
|
||||
|
||||
> 最后更新:2026-02-27
|
||||
> 最后更新:2026-03-02
|
||||
|
||||
## 项目概述
|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
| 角色卡管理 | ✅ 已完成 | 100% | - | 完全兼容 ST V2,前后端已打通 |
|
||||
| 预设管理 | ✅ 已完成 | 100% | - | 前后端已打通,支持导入导出 |
|
||||
| 对话系统 | ✅ 已完成 | 95% | - | 预设切换、消息重新生成已完成 |
|
||||
| AI 集成 | 🚧 进行中 | 80% | - | AI 配置管理、API 调用已完成 |
|
||||
| 世界书系统 | 📋 待开发 | 0% | - | 规划中 |
|
||||
| 正则脚本 | 📋 待开发 | 0% | - | 规划中 |
|
||||
| AI 集成 | ✅ 已完成 | 100% | - | AI 配置管理、API 调用已完成 |
|
||||
| 世界书系统 | ✅ 已完成 | 100% | - | 前后端已打通,触发引擎已集成到对话系统 |
|
||||
| 正则脚本 | ✅ 已完成 | 95% | - | 完全兼容 ST,已集成到对话流程 |
|
||||
|
||||
**图例**:
|
||||
- ✅ 已完成
|
||||
@@ -456,8 +456,8 @@ web-app/src/
|
||||
- ✅ 世界书搜索和筛选
|
||||
- ✅ 用户权限控制(只能编辑自己的世界书)
|
||||
- ✅ 条目计数自动更新
|
||||
- 📋 关键词触发算法(待与对话系统集成)
|
||||
- 📋 与对话系统集成(待实现)
|
||||
- ✅ 关键词触发引擎(WorldbookEngine)
|
||||
- ✅ 与对话系统完全集成(非流式 + 流式)
|
||||
|
||||
**文件清单**:
|
||||
```
|
||||
@@ -467,6 +467,7 @@ server/
|
||||
├── model/app/request/worldbook.go # 请求结构
|
||||
├── model/app/response/worldbook.go # 响应结构
|
||||
├── service/app/worldbook.go # 业务逻辑
|
||||
├── service/app/worldbook_engine.go # 关键词触发引擎
|
||||
└── router/app/worldbook.go # 路由配置
|
||||
```
|
||||
|
||||
@@ -511,53 +512,211 @@ web-app/src/
|
||||
- ✅ 错误处理和用户提示
|
||||
- ✅ 登录状态检查
|
||||
|
||||
### 6.4 待完善功能
|
||||
### 6.4 对话系统集成(已完成 - 2026-02-27)
|
||||
|
||||
- 📋 与对话系统集成(在对话中激活世界书)
|
||||
- 📋 关键词触发引擎实现
|
||||
- 📋 选择性触发逻辑(AND/NOT)
|
||||
- 📋 深度控制和位置注入
|
||||
- 📋 概率触发机制
|
||||
**触发引擎**(`service/app/worldbook_engine.go`):
|
||||
- ✅ `WorldbookEngine.ScanAndTrigger` - 扫描消息历史并触发匹配条目
|
||||
- ✅ `WorldbookEngine.shouldTrigger` - 逐条检查触发条件(constant/keys/scanDepth)
|
||||
- ✅ `WorldbookEngine.matchKeys` - 关键词匹配(useRegex/caseSensitive/matchWholeWords)
|
||||
- ✅ `WorldbookEngine.BuildPromptWithWorldbook` - 将触发条目注入到系统提示词
|
||||
|
||||
**集成点**:
|
||||
- ✅ `conversation.go:callAIService` - 非流式消息世界书注入
|
||||
- ✅ `conversation.go:SendMessageStream` - 流式消息世界书注入
|
||||
|
||||
**执行流程**:
|
||||
```
|
||||
用户消息 → 扫描最近 N 条消息 → 匹配关键词 → 触发条目排序 → 注入系统提示词 → AI 处理
|
||||
```
|
||||
|
||||
**设置面板**(`components/SettingsPanel.tsx`):
|
||||
- ✅ 世界书开关(启用/禁用)
|
||||
- ✅ 世界书选择器(下拉列表,显示条目数量)
|
||||
- ✅ 保存到 `conversation.worldbookId` 和 `conversation.worldbookEnabled`
|
||||
|
||||
### 6.5 待完善功能
|
||||
|
||||
- 📋 概率触发机制(probability 字段已存储,引擎待实现)
|
||||
- 📋 递归扫描
|
||||
- 📋 角色绑定世界书
|
||||
- 📋 角色绑定世界书(Character Book)
|
||||
- 📋 全局世界书
|
||||
|
||||
---
|
||||
|
||||
## 七、正则脚本系统 📋
|
||||
## 七、正则脚本系统 ✅
|
||||
|
||||
### 7.1 待实现功能(规划中)
|
||||
### 7.1 后端 API(已完成 - 2026-03-02)
|
||||
|
||||
**数据模型**:
|
||||
- 📋 `RegexScript` - 正则脚本模型
|
||||
- 📋 支持全局、角色、预设三种作用域
|
||||
- 📋 完全兼容 SillyTavern 正则脚本格式
|
||||
- ✅ `RegexScript` - 正则脚本模型(完全兼容 SillyTavern 格式)
|
||||
- ✅ 支持全局、角色、预设三种作用域
|
||||
- ✅ 使用 JSONB 存储扩展字段和修剪字符串数组
|
||||
|
||||
**待实现字段**:
|
||||
- 📋 find_regex/replace_with - 查找和替换表达式
|
||||
- 📋 trim_string - 修剪字符串
|
||||
- 📋 placement - 执行阶段(输入/输出/世界书/推理)
|
||||
- 📋 disabled/markdown_only/run_on_edit/prompt_only - 执行选项
|
||||
- 📋 substitute_regex - 宏替换({{user}}/{{char}})
|
||||
- 📋 min_depth/max_depth - 深度控制
|
||||
- 📋 scope/owner_char_id/owner_preset_id - 作用域管理
|
||||
**已实现字段**:
|
||||
- ✅ name - 脚本名称
|
||||
- ✅ find_regex - 查找正则表达式
|
||||
- ✅ replace_with - 替换字符串
|
||||
- ✅ trim_strings - 修剪字符串数组
|
||||
- ✅ placement - 执行阶段(0=输入/1=输出/2=世界书/3=显示)
|
||||
- ✅ disabled - 禁用标志
|
||||
- ✅ markdown_only - 仅 Markdown 模式
|
||||
- ✅ run_on_edit - 编辑时运行
|
||||
- ✅ prompt_only - 仅提示词模式
|
||||
- ✅ substitute_regex - 宏替换({{user}}/{{char}})
|
||||
- ✅ min_depth/max_depth - 深度控制
|
||||
- ✅ scope - 作用域(0=全局/1=角色/2=预设)
|
||||
- ✅ owner_char_id/owner_preset_id - 所有者 ID
|
||||
- ✅ order - 执行顺序
|
||||
|
||||
**计划 API 端点**:
|
||||
**API 端点**:
|
||||
- ✅ `POST /app/regex` - 创建正则脚本
|
||||
- ✅ `GET /app/regex` - 获取脚本列表(分页、搜索、作用域筛选)
|
||||
- ✅ `GET /app/regex/:id` - 获取脚本详情
|
||||
- ✅ `PUT /app/regex/:id` - 更新脚本
|
||||
- ✅ `DELETE /app/regex/:id` - 删除脚本
|
||||
- ✅ `POST /app/regex/test` - 测试脚本执行
|
||||
|
||||
**核心功能**:
|
||||
- ✅ 完全兼容 SillyTavern 正则脚本格式
|
||||
- ✅ 多阶段脚本执行(输入/输出/世界书/显示)
|
||||
- ✅ 正则表达式解析和执行
|
||||
- ✅ 宏替换系统({{user}}/{{char}}/{{original}})
|
||||
- ✅ 深度控制和条件执行
|
||||
- ✅ 与对话系统集成
|
||||
- ✅ 角色卡导入时自动导入正则脚本
|
||||
- ✅ 角色卡导出时自动导出正则脚本
|
||||
|
||||
**文件清单**:
|
||||
```
|
||||
POST /app/regex # 创建正则脚本
|
||||
GET /app/regex # 获取脚本列表
|
||||
GET /app/regex/:id # 获取脚本详情
|
||||
PUT /app/regex/:id # 更新脚本
|
||||
DELETE /app/regex/:id # 删除脚本
|
||||
POST /app/regex/:id/test # 测试脚本
|
||||
server/
|
||||
├── api/v1/app/regex_script.go # API 控制器
|
||||
├── model/app/regex_script.go # 正则脚本模型
|
||||
├── model/app/request/regex_script.go # 请求结构
|
||||
├── model/app/response/regex_script.go # 响应结构
|
||||
├── service/app/regex_script.go # 业务逻辑和执行引擎
|
||||
├── router/app/regex_script.go # 路由配置
|
||||
└── service/app/character.go # 角色卡导入导出集成
|
||||
```
|
||||
|
||||
**核心功能规划**:
|
||||
- 📋 多阶段脚本执行(输入/输出/世界书/推理)
|
||||
- 📋 正则表达式解析和执行
|
||||
- 📋 宏替换系统
|
||||
- 📋 深度控制和条件执行
|
||||
- 📋 与对话系统集成
|
||||
### 7.2 前端页面(已完成 - 2026-03-02)
|
||||
|
||||
**功能特性**:
|
||||
- ✅ 角色卡管理页面集成正则脚本编辑
|
||||
- ✅ 导入 JSON 正则脚本(兼容 SillyTavern 格式)
|
||||
- ✅ 正则脚本创建和编辑
|
||||
- ✅ 正则脚本列表展示
|
||||
- ✅ 正则脚本删除
|
||||
- ✅ 执行阶段选择(输入/输出/世界书/显示)
|
||||
- ✅ 执行顺序配置
|
||||
- ✅ 启用/禁用开关
|
||||
- ✅ 作用域管理(全局/角色/预设)
|
||||
|
||||
**文件清单**:
|
||||
```
|
||||
web-app/src/
|
||||
├── api/regex.ts # 正则脚本 API 封装
|
||||
├── pages/CharacterManagePage.tsx # 角色卡管理页面(集成正则脚本)
|
||||
└── components/MessageContent.tsx # 消息渲染组件(HTML/脚本渲染)
|
||||
```
|
||||
|
||||
### 7.3 对话系统集成(已完成 - 2026-03-02)
|
||||
|
||||
**集成点**:
|
||||
- ✅ 输入阶段(Placement 0)- 用户消息发送前处理
|
||||
- ✅ 输出阶段(Placement 1)- AI 回复生成后处理
|
||||
- ✅ 显示阶段(Placement 3)- 前端渲染前处理
|
||||
- ✅ 开场白消息处理 - 角色首条消息应用正则脚本
|
||||
|
||||
**实现位置**:
|
||||
- ✅ `conversation.go:SendMessage` - 非流式消息发送
|
||||
- ✅ `conversation.go:SendMessageStream` - 流式消息发送
|
||||
- ✅ `conversation.go:CreateConversation` - 创建对话时处理开场白
|
||||
|
||||
**执行流程**:
|
||||
```
|
||||
用户输入 → 输入阶段脚本 → AI 处理 → 输出阶段脚本 → 保存数据库 → 显示阶段脚本 → 前端渲染
|
||||
```
|
||||
|
||||
### 7.4 前端渲染优化(已完成 - 2026-03-02)
|
||||
|
||||
**HTML 渲染支持**:
|
||||
- ✅ 自动提取 markdown 代码块中的 HTML(```html...```)
|
||||
- ✅ 移除 markdown 代码块标记(``` 符号)
|
||||
- ✅ 自动启用脚本渲染(无需手动点击按钮)
|
||||
- ✅ iframe 沙箱渲染(支持 JavaScript)
|
||||
- ✅ 直接 HTML 渲染(无脚本内容)
|
||||
- ✅ 统一容器样式(避免宽高变化)
|
||||
- ✅ 响应式宽度适配(强制覆盖固定宽度)
|
||||
|
||||
**选项按钮支持**:
|
||||
- ✅ 自动解析选项格式(A:/A./A、/A:)
|
||||
- ✅ 渲染为可点击按钮
|
||||
- ✅ 点击后自动发送选项内容
|
||||
- ✅ 支持多种选项格式
|
||||
|
||||
**文件位置**:
|
||||
- ✅ `MessageContent.tsx:173-243` - HTML 提取和处理逻辑
|
||||
- ✅ `MessageContent.tsx:246-290` - iframe 渲染逻辑
|
||||
- ✅ `MessageContent.tsx:348-372` - 渲染模式切换
|
||||
|
||||
### 7.5 全局正则脚本管理(已完成 - 2026-03-02)
|
||||
|
||||
**功能特性**:
|
||||
- ✅ 独立的全局正则脚本管理页面 `/regex-scripts`
|
||||
- ✅ 创建、编辑、删除全局正则脚本
|
||||
- ✅ 导入导出 JSON 格式脚本(兼容 SillyTavern)
|
||||
- ✅ 只管理 scope=0 的全局脚本
|
||||
- ✅ 支持批量导出所有脚本
|
||||
|
||||
**文件位置**:
|
||||
- ✅ `web-app/src/pages/RegexScriptManagePage.tsx` - 全局正则脚本管理页面
|
||||
- ✅ `web-app/src/App.tsx` - 添加路由配置
|
||||
|
||||
### 7.6 变量系统扩展(已完成 - 2026-03-02)
|
||||
|
||||
**后端新增变量** (`server/service/app/regex_script.go`):
|
||||
- ✅ `{{time_12h}}` - 12小时制时间
|
||||
- ✅ `{{date_short}}` - 短日期格式 (MM/DD/YY)
|
||||
- ✅ `{{weekday}}` - 星期几
|
||||
- ✅ `{{month}}` - 月份名称
|
||||
- ✅ `{{year}}` - 年份
|
||||
- ✅ `{{pick:option1|option2|option3}}` - 随机选择一个选项
|
||||
- ✅ `{{tab}}` - 制表符
|
||||
- ✅ `{{space}}` - 空格
|
||||
- ✅ `{{empty}}` - 空字符串
|
||||
|
||||
**前端变量系统** (`web-app/src/store/index.ts`):
|
||||
- ✅ 同步支持所有后端变量
|
||||
- ✅ `substituteVariables()` 函数可在前端替换变量
|
||||
- ✅ `extractVariables()` 函数可提取文本中的变量
|
||||
|
||||
### 7.7 MVU 状态管理架构(已完成 - 2026-03-02)
|
||||
|
||||
**核心实现** (`web-app/src/store/index.ts`):
|
||||
- ✅ 基于 Zustand 实现完整的 MVU 架构
|
||||
- ✅ Model: 用户、角色、对话、消息、UI、变量状态
|
||||
- ✅ Update: 完整的状态更新操作
|
||||
- ✅ View: 状态选择器(Selectors)
|
||||
- ✅ 持久化存储(localStorage)
|
||||
- ✅ DevTools 支持
|
||||
|
||||
**页面集成**:
|
||||
- ✅ `ChatPage.tsx` - 集成 MVU store 管理对话状态
|
||||
- ✅ `CharacterManagePage.tsx` - 导入 MVU store
|
||||
- ✅ 角色切换时自动更新变量系统
|
||||
|
||||
**工具函数**:
|
||||
- ✅ `substituteVariables()` - 替换文本中的变量
|
||||
- ✅ `extractVariables()` - 提取文本中的变量
|
||||
|
||||
### 7.8 待完善功能
|
||||
|
||||
- 📋 世界书阶段(Placement 2)脚本执行
|
||||
- 📋 正则脚本测试界面
|
||||
- 📋 正则脚本模板库
|
||||
- 🧪 全局正则脚本功能测试
|
||||
- 🧪 扩展变量系统测试
|
||||
- 🧪 MVU store 集成测试
|
||||
|
||||
---
|
||||
|
||||
@@ -658,15 +817,102 @@ web-app/src/pages/
|
||||
- 实现对话历史存储
|
||||
- ✅ **完成对话系统前后端对接**
|
||||
- ✅ **完成 AI 配置管理模块**
|
||||
- ✅ **完成世界书系统**
|
||||
- 实现世界书 CRUD 操作
|
||||
- 实现世界书条目管理
|
||||
- 实现导入导出功能
|
||||
- 完全兼容 SillyTavern 格式
|
||||
- ✅ **编写 SillyTavern 完全兼容优化方案**
|
||||
- ✅ **更新开发进度文档**
|
||||
|
||||
### 下一阶段规划
|
||||
### 2026-02-27(续)
|
||||
- ✅ **完成世界书与对话系统集成**
|
||||
- 实现 WorldbookEngine 关键词触发引擎
|
||||
- 集成到非流式(callAIService)和流式(SendMessageStream)消息路径
|
||||
- 实现选择性触发逻辑(constant/keys/useRegex/caseSensitive/matchWholeWords)
|
||||
- 实现 SettingsPanel 世界书选择器(启用开关 + 世界书下拉)
|
||||
- ✅ **修复前端消息渲染 Bug**
|
||||
- 修复通用代码块正则误判导致消息内容消失的问题(恢复仅识别 \`\`\`html 标识符)
|
||||
- 修复 iframe 宽度坍缩为 300px 问题(助手消息列从 max-w-[70%] 改为 w-[70%])
|
||||
- 保留 HTML 代码块渲染中的 ``` → `<pre><code>` 转换逻辑
|
||||
|
||||
### 2026-03-02
|
||||
- ✅ **完成正则脚本系统后端 API**
|
||||
- 实现正则脚本 CRUD 操作
|
||||
- 实现多阶段脚本执行引擎
|
||||
- 实现宏替换系统({{user}}/{{char}}/{{original}})
|
||||
- 完全兼容 SillyTavern 正则脚本格式
|
||||
- ✅ **完成正则脚本前端管理界面**
|
||||
- 集成到角色卡管理页面
|
||||
- 支持导入导出正则脚本
|
||||
- 支持脚本创建、编辑、删除
|
||||
- ✅ **完成正则脚本与对话系统集成**
|
||||
- 输入阶段脚本处理(用户消息)
|
||||
- 输出阶段脚本处理(AI 回复)
|
||||
- 显示阶段脚本处理(前端渲染)
|
||||
- 开场白消息脚本处理
|
||||
- ✅ **完成前端 HTML 渲染优化**
|
||||
- 自动提取和渲染 markdown 代码块中的 HTML
|
||||
- 移除 markdown 代码块标记
|
||||
- 自动启用脚本渲染
|
||||
- iframe 沙箱渲染支持
|
||||
- 响应式宽度适配
|
||||
- ✅ **完成选项按钮功能**
|
||||
- 自动解析选项格式
|
||||
- 渲染为可点击按钮
|
||||
- 点击后自动发送
|
||||
- ✅ **修复关键 Bug**
|
||||
- 修复 GetScriptsForPlacement SQL 查询 bug
|
||||
- 修复流式消息未应用正则脚本问题
|
||||
- 修复 HTML 渲染宽度问题
|
||||
- 修复 markdown 符号残留问题
|
||||
|
||||
### 2026-03-02(今天完成)
|
||||
- ✅ **完成正则脚本前端 HTML 渲染优化**
|
||||
- 修复 HTML 代码块渲染问题
|
||||
- 移除 markdown ``` 符号残留
|
||||
- 自动启用脚本渲染
|
||||
- 统一 iframe 和直接 HTML 渲染的容器样式
|
||||
- 修复宽度适配问题
|
||||
- ✅ **完成全局正则脚本管理功能**
|
||||
- 创建独立的全局正则脚本管理页面 `/regex-scripts`
|
||||
- 支持创建、编辑、删除、导入、导出全局脚本
|
||||
- 完全兼容 SillyTavern 格式
|
||||
- ✅ **扩展变量系统**
|
||||
- 后端新增 10+ 个变量类型
|
||||
- 支持时间格式化、随机选择、特殊字符等
|
||||
- 前端同步支持所有变量
|
||||
- ✅ **实现 MVU 状态管理架构**
|
||||
- 基于 Zustand 实现完整的 MVU 模式
|
||||
- 集成到 ChatPage 和 CharacterManagePage
|
||||
- 支持持久化存储和 DevTools
|
||||
- 内置变量替换工具函数
|
||||
- ✅ **修复关键 Bug**
|
||||
- 修复开场白消息未应用正则脚本问题
|
||||
- 修复 API 导出名称不匹配导致页面空白问题
|
||||
|
||||
### 待测试功能(下次工作)
|
||||
- 🧪 **全局正则脚本功能测试**
|
||||
- 测试全局脚本在所有对话中的应用
|
||||
- 测试导入导出功能
|
||||
- 测试脚本执行顺序
|
||||
- 🧪 **扩展变量系统测试**
|
||||
- 测试所有新增变量类型
|
||||
- 测试 `{{pick:}}` 随机选择功能
|
||||
- 测试时间格式化变量
|
||||
- 🧪 **MVU Store 集成测试**
|
||||
- 测试状态持久化
|
||||
- 测试变量系统自动更新
|
||||
- 测试跨页面状态同步
|
||||
- 🧪 **HTML 渲染优化测试**
|
||||
- 测试 markdown 代码块中的 HTML 渲染
|
||||
- 测试 iframe 宽度自适应
|
||||
- 测试脚本自动启用功能
|
||||
- ✅ 实现实际 AI API 调用(OpenAI/Anthropic)
|
||||
- 📋 实现世界书系统(完全兼容 SillyTavern)
|
||||
- 📋 实现正则脚本系统(完全兼容 SillyTavern)
|
||||
- 📋 实现 Prompt Pipeline(世界书触发、正则处理、提示词构建)
|
||||
- 📋 完善对话系统高级功能(消息重新生成、编辑)
|
||||
- ✅ 实现世界书系统(完全兼容 SillyTavern,包含触发引擎和对话集成)
|
||||
- ✅ 实现正则脚本系统(完全兼容 SillyTavern)
|
||||
- ✅ 实现 Prompt Pipeline(世界书触发注入、正则处理、提示词构建)
|
||||
- 📋 完善对话系统高级功能(消息编辑)
|
||||
- 📋 性能优化与测试
|
||||
- 📋 部署上线
|
||||
|
||||
@@ -682,21 +928,22 @@ web-app/src/pages/
|
||||
5. ✅ 完善对话系统高级功能(消息重新生成)
|
||||
|
||||
### 中期目标(本月)
|
||||
1. 📋 实现世界书系统(完全兼容 SillyTavern)
|
||||
- 数据模型设计
|
||||
- 关键词触发算法
|
||||
- 选择性触发逻辑
|
||||
- 与对话系统集成
|
||||
2. 📋 实现正则脚本系统(完全兼容 SillyTavern)
|
||||
- 数据模型设计
|
||||
- 多阶段脚本执行
|
||||
- 正则表达式引擎
|
||||
- 宏替换系统
|
||||
3. 📋 实现 Prompt Pipeline
|
||||
- 世界书扫描与注入
|
||||
- 正则脚本处理
|
||||
- 提示词构建优化
|
||||
1. ✅ 实现世界书系统(完全兼容 SillyTavern)
|
||||
- ✅ 数据模型设计
|
||||
- ✅ 关键词触发算法(WorldbookEngine)
|
||||
- ✅ 选择性触发逻辑
|
||||
- ✅ 与对话系统集成
|
||||
2. ✅ 实现正则脚本系统(完全兼容 SillyTavern)
|
||||
- ✅ 数据模型设计
|
||||
- ✅ 多阶段脚本执行
|
||||
- ✅ 正则表达式引擎
|
||||
- ✅ 宏替换系统
|
||||
3. ✅ 实现 Prompt Pipeline
|
||||
- ✅ 世界书扫描与注入
|
||||
- ✅ 正则脚本处理
|
||||
- ✅ 提示词构建优化
|
||||
4. 📋 基础功能测试与优化
|
||||
5. 📋 消息编辑功能
|
||||
|
||||
### 长期目标(下月)
|
||||
1. 📋 插件系统设计与实现
|
||||
|
||||
@@ -427,14 +427,23 @@ func (s *CharacterService) processRegexScriptsFromExtensions(userID, characterID
|
||||
OwnerCharID: &characterID,
|
||||
}
|
||||
|
||||
// 提取字段
|
||||
if name, ok := scriptData["name"].(string); ok {
|
||||
// 提取字段 - 兼容 SillyTavern 的字段名
|
||||
// 脚本名称:优先使用 scriptName,其次 name
|
||||
if scriptName, ok := scriptData["scriptName"].(string); ok {
|
||||
script.Name = scriptName
|
||||
} else if name, ok := scriptData["name"].(string); ok {
|
||||
script.Name = name
|
||||
}
|
||||
|
||||
// 查找正则表达式
|
||||
if findRegex, ok := scriptData["findRegex"].(string); ok {
|
||||
script.FindRegex = findRegex
|
||||
}
|
||||
if replaceWith, ok := scriptData["replaceWith"].(string); ok {
|
||||
|
||||
// 替换字符串:优先使用 replaceString,其次 replaceWith
|
||||
if replaceString, ok := scriptData["replaceString"].(string); ok {
|
||||
script.ReplaceWith = replaceString
|
||||
} else if replaceWith, ok := scriptData["replaceWith"].(string); ok {
|
||||
script.ReplaceWith = replaceWith
|
||||
}
|
||||
if placement, ok := scriptData["placement"].(float64); ok {
|
||||
|
||||
@@ -90,11 +90,20 @@ func (s *ConversationService) CreateConversation(userID uint, req *request.Creat
|
||||
|
||||
// 如果角色有开场白,创建开场白消息
|
||||
if character.FirstMes != "" {
|
||||
// 应用输出阶段正则脚本处理开场白
|
||||
processedFirstMes := character.FirstMes
|
||||
var regexService RegexScriptService
|
||||
outputScripts, err := regexService.GetScriptsForPlacement(userID, 1, &character.ID, nil)
|
||||
if err == nil && len(outputScripts) > 0 {
|
||||
processedFirstMes = regexService.ExecuteScripts(outputScripts, processedFirstMes, "", character.Name)
|
||||
global.GVA_LOG.Info(fmt.Sprintf("开场白应用正则脚本: 原始长度=%d, 处理后长度=%d", len(character.FirstMes), len(processedFirstMes)))
|
||||
}
|
||||
|
||||
firstMessage := app.Message{
|
||||
ConversationID: conversation.ID,
|
||||
Role: "assistant",
|
||||
Content: character.FirstMes,
|
||||
TokenCount: len(character.FirstMes) / 4,
|
||||
Content: processedFirstMes,
|
||||
TokenCount: len(processedFirstMes) / 4,
|
||||
}
|
||||
err = global.GVA_DB.Create(&firstMessage).Error
|
||||
if err != nil {
|
||||
@@ -316,12 +325,27 @@ func (s *ConversationService) SendMessage(userID, conversationID uint, req *requ
|
||||
return nil, errors.New("角色卡不存在")
|
||||
}
|
||||
|
||||
// 应用输入阶段的正则脚本 (Placement 0)
|
||||
processedContent := req.Content
|
||||
var regexService RegexScriptService
|
||||
global.GVA_LOG.Info(fmt.Sprintf("查询输入阶段正则脚本: userID=%d, placement=0, charID=%d", userID, conversation.CharacterID))
|
||||
inputScripts, err := regexService.GetScriptsForPlacement(userID, 0, &conversation.CharacterID, nil)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error(fmt.Sprintf("查询输入阶段正则脚本失败: %v", err))
|
||||
} else {
|
||||
global.GVA_LOG.Info(fmt.Sprintf("找到 %d 个输入阶段正则脚本", len(inputScripts)))
|
||||
if len(inputScripts) > 0 {
|
||||
processedContent = regexService.ExecuteScripts(inputScripts, processedContent, "", character.Name)
|
||||
global.GVA_LOG.Info(fmt.Sprintf("应用了 %d 个输入阶段正则脚本,原文: %s, 处理后: %s", len(inputScripts), req.Content, processedContent))
|
||||
}
|
||||
}
|
||||
|
||||
// 保存用户消息
|
||||
userMessage := app.Message{
|
||||
ConversationID: conversationID,
|
||||
Role: "user",
|
||||
Content: req.Content,
|
||||
TokenCount: len(req.Content) / 4, // 简单估算
|
||||
Content: processedContent,
|
||||
TokenCount: len(processedContent) / 4, // 简单估算
|
||||
}
|
||||
|
||||
err = global.GVA_DB.Create(&userMessage).Error
|
||||
@@ -372,13 +396,23 @@ func (s *ConversationService) SendMessage(userID, conversationID uint, req *requ
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 应用显示阶段的正则脚本 (Placement 3)
|
||||
displayContent := assistantMessage.Content
|
||||
displayScripts, err := regexService.GetScriptsForPlacement(userID, 3, &conversation.CharacterID, nil)
|
||||
if err == nil && len(displayScripts) > 0 {
|
||||
displayContent = regexService.ExecuteScripts(displayScripts, displayContent, "", character.Name)
|
||||
global.GVA_LOG.Info(fmt.Sprintf("应用了 %d 个显示阶段正则脚本", len(displayScripts)))
|
||||
}
|
||||
|
||||
resp := response.ToMessageResponse(&assistantMessage)
|
||||
resp.Content = displayContent // 使用处理后的显示内容
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// callAIService 调用 AI 服务
|
||||
func (s *ConversationService) callAIService(conversation app.Conversation, character app.AICharacter, messages []app.Message) (string, error) {
|
||||
// 获取 AI 配置
|
||||
|
||||
var aiConfig app.AIConfig
|
||||
var err error
|
||||
|
||||
@@ -521,6 +555,21 @@ func (s *ConversationService) callAIService(conversation app.Conversation, chara
|
||||
}
|
||||
global.GVA_LOG.Info(fmt.Sprintf("========== AI返回的完整内容 ==========\n%s\n==========================================", aiResponse))
|
||||
|
||||
// 应用输出阶段的正则脚本 (Placement 1)
|
||||
var regexService RegexScriptService
|
||||
global.GVA_LOG.Info(fmt.Sprintf("查询输出阶段正则脚本: userID=%d, placement=1, charID=%d", conversation.UserID, conversation.CharacterID))
|
||||
outputScripts, err := regexService.GetScriptsForPlacement(conversation.UserID, 1, &conversation.CharacterID, nil)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error(fmt.Sprintf("查询输出阶段正则脚本失败: %v", err))
|
||||
} else {
|
||||
global.GVA_LOG.Info(fmt.Sprintf("找到 %d 个输出阶段正则脚本", len(outputScripts)))
|
||||
if len(outputScripts) > 0 {
|
||||
originalResponse := aiResponse
|
||||
aiResponse = regexService.ExecuteScripts(outputScripts, aiResponse, "", character.Name)
|
||||
global.GVA_LOG.Info(fmt.Sprintf("应用了 %d 个输出阶段正则脚本,原文: %s, 处理后: %s", len(outputScripts), originalResponse, aiResponse))
|
||||
}
|
||||
}
|
||||
|
||||
return aiResponse, nil
|
||||
}
|
||||
|
||||
@@ -648,12 +697,27 @@ func (s *ConversationService) SendMessageStream(userID, conversationID uint, req
|
||||
return errors.New("角色卡不存在")
|
||||
}
|
||||
|
||||
// 应用输入阶段的正则脚本 (Placement 0)
|
||||
processedContent := req.Content
|
||||
var regexService RegexScriptService
|
||||
global.GVA_LOG.Info(fmt.Sprintf("[流式传输] 查询输入阶段正则脚本: userID=%d, placement=0, charID=%d", userID, conversation.CharacterID))
|
||||
inputScripts, err := regexService.GetScriptsForPlacement(userID, 0, &conversation.CharacterID, nil)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error(fmt.Sprintf("[流式传输] 查询输入阶段正则脚本失败: %v", err))
|
||||
} else {
|
||||
global.GVA_LOG.Info(fmt.Sprintf("[流式传输] 找到 %d 个输入阶段正则脚本", len(inputScripts)))
|
||||
if len(inputScripts) > 0 {
|
||||
processedContent = regexService.ExecuteScripts(inputScripts, processedContent, "", character.Name)
|
||||
global.GVA_LOG.Info(fmt.Sprintf("[流式传输] 应用了 %d 个输入阶段正则脚本", len(inputScripts)))
|
||||
}
|
||||
}
|
||||
|
||||
// 保存用户消息
|
||||
userMessage := app.Message{
|
||||
ConversationID: conversationID,
|
||||
Role: "user",
|
||||
Content: req.Content,
|
||||
TokenCount: len(req.Content) / 4,
|
||||
Content: processedContent,
|
||||
TokenCount: len(processedContent) / 4,
|
||||
}
|
||||
|
||||
err = global.GVA_DB.Create(&userMessage).Error
|
||||
@@ -727,6 +791,28 @@ func (s *ConversationService) SendMessageStream(userID, conversationID uint, req
|
||||
if streamPreset != nil && streamPreset.SystemPrompt != "" {
|
||||
systemPrompt = systemPrompt + "\n\n" + streamPreset.SystemPrompt
|
||||
}
|
||||
|
||||
// 集成世界书触发引擎(流式传输)
|
||||
if conversation.WorldbookEnabled && conversation.WorldbookID != nil {
|
||||
global.GVA_LOG.Info(fmt.Sprintf("[流式传输] 世界书已启用,ID: %d", *conversation.WorldbookID))
|
||||
|
||||
var messageContents []string
|
||||
for _, msg := range messages {
|
||||
messageContents = append(messageContents, msg.Content)
|
||||
}
|
||||
|
||||
engine := &WorldbookEngine{}
|
||||
triggeredEntries, wbErr := engine.ScanAndTrigger(*conversation.WorldbookID, messageContents)
|
||||
if wbErr != nil {
|
||||
global.GVA_LOG.Warn(fmt.Sprintf("[流式传输] 世界书触发失败: %v", wbErr))
|
||||
} else if len(triggeredEntries) > 0 {
|
||||
global.GVA_LOG.Info(fmt.Sprintf("[流式传输] 触发了 %d 个世界书条目", len(triggeredEntries)))
|
||||
systemPrompt = engine.BuildPromptWithWorldbook(systemPrompt, triggeredEntries)
|
||||
} else {
|
||||
global.GVA_LOG.Info("[流式传输] 没有触发任何世界书条目")
|
||||
}
|
||||
}
|
||||
|
||||
apiMessages := s.buildAPIMessages(messages, systemPrompt)
|
||||
|
||||
// 打印发送给AI的完整内容(流式传输)
|
||||
@@ -768,6 +854,19 @@ func (s *ConversationService) SendMessageStream(userID, conversationID uint, req
|
||||
// 打印AI返回的完整内容
|
||||
global.GVA_LOG.Info(fmt.Sprintf("========== [流式传输] AI返回的完整内容 ==========\n%s\n==========================================", fullContent))
|
||||
|
||||
// 应用输出阶段的正则脚本 (Placement 1)
|
||||
global.GVA_LOG.Info(fmt.Sprintf("[流式传输] 查询输出阶段正则脚本: userID=%d, placement=1, charID=%d", userID, conversation.CharacterID))
|
||||
outputScripts, err := regexService.GetScriptsForPlacement(userID, 1, &conversation.CharacterID, nil)
|
||||
if err != nil {
|
||||
global.GVA_LOG.Error(fmt.Sprintf("[流式传输] 查询输出阶段正则脚本失败: %v", err))
|
||||
} else {
|
||||
global.GVA_LOG.Info(fmt.Sprintf("[流式传输] 找到 %d 个输出阶段正则脚本", len(outputScripts)))
|
||||
if len(outputScripts) > 0 {
|
||||
fullContent = regexService.ExecuteScripts(outputScripts, fullContent, "", character.Name)
|
||||
global.GVA_LOG.Info(fmt.Sprintf("[流式传输] 应用了 %d 个输出阶段正则脚本", len(outputScripts)))
|
||||
}
|
||||
}
|
||||
|
||||
// 保存 AI 回复
|
||||
assistantMessage := app.Message{
|
||||
ConversationID: conversationID,
|
||||
|
||||
@@ -3,8 +3,12 @@ package app
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.echol.cn/loser/st/server/global"
|
||||
"git.echol.cn/loser/st/server/model/app"
|
||||
@@ -236,14 +240,74 @@ func (s *RegexScriptService) ExecuteScript(script *app.RegexScript, text string,
|
||||
// substituteMacros 替换宏变量
|
||||
func (s *RegexScriptService) substituteMacros(text string, userName string, charName string) string {
|
||||
result := text
|
||||
|
||||
// 保存原始文本
|
||||
result = strings.ReplaceAll(result, "{{original}}", text)
|
||||
|
||||
// 用户名变量
|
||||
if userName != "" {
|
||||
result = strings.ReplaceAll(result, "{{user}}", userName)
|
||||
result = strings.ReplaceAll(result, "{{User}}", userName)
|
||||
}
|
||||
|
||||
// 角色名变量
|
||||
if charName != "" {
|
||||
result = strings.ReplaceAll(result, "{{char}}", charName)
|
||||
result = strings.ReplaceAll(result, "{{Char}}", charName)
|
||||
}
|
||||
|
||||
// 时间变量
|
||||
now := time.Now()
|
||||
result = strings.ReplaceAll(result, "{{time}}", now.Format("15:04:05"))
|
||||
result = strings.ReplaceAll(result, "{{date}}", now.Format("2006-01-02"))
|
||||
result = strings.ReplaceAll(result, "{{datetime}}", now.Format("2006-01-02 15:04:05"))
|
||||
result = strings.ReplaceAll(result, "{{timestamp}}", fmt.Sprintf("%d", now.Unix()))
|
||||
result = strings.ReplaceAll(result, "{{time_12h}}", now.Format("03:04:05 PM"))
|
||||
result = strings.ReplaceAll(result, "{{date_short}}", now.Format("01/02/06"))
|
||||
result = strings.ReplaceAll(result, "{{weekday}}", now.Weekday().String())
|
||||
result = strings.ReplaceAll(result, "{{month}}", now.Month().String())
|
||||
result = strings.ReplaceAll(result, "{{year}}", fmt.Sprintf("%d", now.Year()))
|
||||
|
||||
// 随机数变量
|
||||
result = regexp.MustCompile(`\{\{random:(\d+)-(\d+)\}\}`).ReplaceAllStringFunc(result, func(match string) string {
|
||||
re := regexp.MustCompile(`\{\{random:(\d+)-(\d+)\}\}`)
|
||||
matches := re.FindStringSubmatch(match)
|
||||
if len(matches) == 3 {
|
||||
min, _ := strconv.Atoi(matches[1])
|
||||
max, _ := strconv.Atoi(matches[2])
|
||||
if max > min {
|
||||
return fmt.Sprintf("%d", rand.Intn(max-min+1)+min)
|
||||
}
|
||||
}
|
||||
return match
|
||||
})
|
||||
|
||||
// 简单随机数 {{random}}
|
||||
result = regexp.MustCompile(`\{\{random\}\}`).ReplaceAllStringFunc(result, func(match string) string {
|
||||
return fmt.Sprintf("%d", rand.Intn(100))
|
||||
})
|
||||
|
||||
// 随机选择 {{pick:option1|option2|option3}}
|
||||
result = regexp.MustCompile(`\{\{pick:([^}]+)\}\}`).ReplaceAllStringFunc(result, func(match string) string {
|
||||
re := regexp.MustCompile(`\{\{pick:([^}]+)\}\}`)
|
||||
matches := re.FindStringSubmatch(match)
|
||||
if len(matches) == 2 {
|
||||
options := strings.Split(matches[1], "|")
|
||||
if len(options) > 0 {
|
||||
return options[rand.Intn(len(options))]
|
||||
}
|
||||
}
|
||||
return match
|
||||
})
|
||||
|
||||
// 换行符变量
|
||||
result = strings.ReplaceAll(result, "{{newline}}", "\n")
|
||||
result = strings.ReplaceAll(result, "{{tab}}", "\t")
|
||||
result = strings.ReplaceAll(result, "{{space}}", " ")
|
||||
|
||||
// 空值变量
|
||||
result = strings.ReplaceAll(result, "{{empty}}", "")
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -254,14 +318,16 @@ func (s *RegexScriptService) GetScriptsForPlacement(userID uint, placement int,
|
||||
db := global.GVA_DB.Where("user_id = ? AND placement = ? AND disabled = ?", userID, placement, false)
|
||||
|
||||
// 作用域过滤:全局(0) 或 角色(1) 或 预设(2)
|
||||
scopeCondition := "scope = 0" // 全局
|
||||
if charID != nil {
|
||||
scopeCondition += " OR (scope = 1 AND owner_char_id = " + string(rune(*charID)) + ")"
|
||||
// 使用参数化查询避免 SQL 注入
|
||||
if charID != nil && presetID != nil {
|
||||
db = db.Where("scope = 0 OR (scope = 1 AND owner_char_id = ?) OR (scope = 2 AND owner_preset_id = ?)", *charID, *presetID)
|
||||
} else if charID != nil {
|
||||
db = db.Where("scope = 0 OR (scope = 1 AND owner_char_id = ?)", *charID)
|
||||
} else if presetID != nil {
|
||||
db = db.Where("scope = 0 OR (scope = 2 AND owner_preset_id = ?)", *presetID)
|
||||
} else {
|
||||
db = db.Where("scope = 0")
|
||||
}
|
||||
if presetID != nil {
|
||||
scopeCondition += " OR (scope = 2 AND owner_preset_id = " + string(rune(*presetID)) + ")"
|
||||
}
|
||||
db = db.Where(scopeCondition)
|
||||
|
||||
if err := db.Order("\"order\" ASC").Find(&scripts).Error; err != nil {
|
||||
return nil, err
|
||||
|
||||
32
web-app/package-lock.json
generated
32
web-app/package-lock.json
generated
@@ -16,7 +16,8 @@
|
||||
"react-router-dom": "^7.13.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
"remark-gfm": "^4.0.1",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
@@ -4638,6 +4639,35 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.11",
|
||||
"resolved": "https://registry.npmmirror.com/zustand/-/zustand-5.0.11.tgz",
|
||||
"integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18.0.0",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=18.0.0",
|
||||
"use-sync-external-store": ">=1.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"use-sync-external-store": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/zwitch": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
"react-router-dom": "^7.13.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
"remark-gfm": "^4.0.1",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
|
||||
@@ -11,6 +11,7 @@ import ProfilePage from './pages/ProfilePage'
|
||||
import CharacterManagePage from './pages/CharacterManagePage'
|
||||
import PresetManagePage from './pages/PresetManagePage'
|
||||
import WorldbookManagePage from './pages/WorldbookManagePage'
|
||||
import RegexScriptManagePage from './pages/RegexScriptManagePage'
|
||||
import AdminPage from './pages/AdminPage'
|
||||
|
||||
function App() {
|
||||
@@ -29,6 +30,7 @@ function App() {
|
||||
<Route path="/characters" element={<CharacterManagePage />} />
|
||||
<Route path="/presets" element={<PresetManagePage />} />
|
||||
<Route path="/worldbooks" element={<WorldbookManagePage />} />
|
||||
<Route path="/regex-scripts" element={<RegexScriptManagePage />} />
|
||||
<Route path="/admin" element={<AdminPage />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -80,34 +80,37 @@ export interface RegexScriptListResponse {
|
||||
}
|
||||
|
||||
// API 方法
|
||||
export const regexScriptApi = {
|
||||
export const regexAPI = {
|
||||
// 创建正则脚本
|
||||
createRegexScript: (data: CreateRegexScriptRequest): Promise<{ data: RegexScript }> => {
|
||||
create: (data: CreateRegexScriptRequest): Promise<{ data: RegexScript }> => {
|
||||
return apiClient.post('/app/regex', data)
|
||||
},
|
||||
|
||||
// 获取正则脚本列表
|
||||
getRegexScriptList: (params?: GetRegexScriptListRequest): Promise<{ data: RegexScriptListResponse }> => {
|
||||
getList: (params?: GetRegexScriptListRequest): Promise<{ data: RegexScriptListResponse }> => {
|
||||
return apiClient.get('/app/regex', { params })
|
||||
},
|
||||
|
||||
// 获取正则脚本详情
|
||||
getRegexScriptById: (id: number): Promise<{ data: RegexScript }> => {
|
||||
getById: (id: number): Promise<{ data: RegexScript }> => {
|
||||
return apiClient.get(`/app/regex/${id}`)
|
||||
},
|
||||
|
||||
// 更新正则脚本
|
||||
updateRegexScript: (id: number, data: UpdateRegexScriptRequest) => {
|
||||
update: (id: number, data: UpdateRegexScriptRequest) => {
|
||||
return apiClient.put(`/app/regex/${id}`, data)
|
||||
},
|
||||
|
||||
// 删除正则脚本
|
||||
deleteRegexScript: (id: number) => {
|
||||
delete: (id: number) => {
|
||||
return apiClient.delete(`/app/regex/${id}`)
|
||||
},
|
||||
|
||||
// 测试正则脚本
|
||||
testRegexScript: (id: number, testString: string): Promise<{ data: { result: string } }> => {
|
||||
test: (id: number, testString: string): Promise<{ data: { original: string; result: string; success: boolean; error?: string } }> => {
|
||||
return apiClient.post(`/app/regex/${id}/test`, { testString })
|
||||
},
|
||||
}
|
||||
|
||||
// 保持向后兼容
|
||||
export const regexScriptApi = regexAPI
|
||||
|
||||
@@ -627,7 +627,7 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`max-w-[70%] min-w-0 flex flex-col ${msg.role === 'user' ? 'items-end' : 'items-start'}`}>
|
||||
<div className={`min-w-0 flex flex-col ${msg.role === 'user' ? 'max-w-[70%] items-end' : 'w-[70%] items-start'}`}>
|
||||
{/* 助手名称 */}
|
||||
{msg.role === 'assistant' && (
|
||||
<span className="text-xs text-white/40 mb-1 ml-1">{character.name}</span>
|
||||
@@ -638,7 +638,7 @@ export default function ChatArea({ conversation, character, onConversationUpdate
|
||||
className={`relative px-4 py-3 rounded-2xl ${
|
||||
msg.role === 'user'
|
||||
? 'bg-primary/25 border border-primary/20 rounded-br-md'
|
||||
: 'glass rounded-bl-md'
|
||||
: 'glass rounded-bl-md w-full'
|
||||
}`}
|
||||
style={{ wordBreak: 'break-word', overflowWrap: 'anywhere' }}
|
||||
>
|
||||
|
||||
@@ -68,21 +68,40 @@ function parseChoices(content: string): { choices: Choice[]; cleanContent: strin
|
||||
const match = content.match(choiceRegex)
|
||||
|
||||
if (!match) {
|
||||
// 如果没有标准格式,尝试从HTML中提取选择项
|
||||
// 匹配类似 "1.xxx" 或 "A.xxx" 的列表项
|
||||
const htmlChoiceRegex = /<p>\s*(\d+|[A-Z])\s*[.、::]\s*([^<]+)/gi
|
||||
// 尝试匹配纯文本格式的选项列表
|
||||
// 匹配格式: A: xxx\nB: xxx\nC: xxx 或 A. xxx\nB. xxx
|
||||
const textChoiceRegex = /^([A-E])[.、::]\s*(.+?)(?=\n[A-E][.、::]|\n*$)/gm
|
||||
const choices: Choice[] = []
|
||||
let textMatch
|
||||
|
||||
while ((textMatch = textChoiceRegex.exec(content)) !== null) {
|
||||
choices.push({
|
||||
label: textMatch[1],
|
||||
text: textMatch[2].trim()
|
||||
})
|
||||
}
|
||||
|
||||
// 如果找到了至少2个选项,认为是有效的选择列表
|
||||
if (choices.length >= 2) {
|
||||
// 移除选项列表,保留其他内容
|
||||
const cleanContent = content.replace(textChoiceRegex, '').trim()
|
||||
return { choices, cleanContent }
|
||||
}
|
||||
|
||||
// 如果没有纯文本格式,尝试从HTML中提取选择项
|
||||
const htmlChoiceRegex = /<p>\s*(\d+|[A-Z])\s*[.、::]\s*([^<]+)/gi
|
||||
const htmlChoices: Choice[] = []
|
||||
let htmlMatch
|
||||
|
||||
while ((htmlMatch = htmlChoiceRegex.exec(content)) !== null) {
|
||||
choices.push({
|
||||
htmlChoices.push({
|
||||
label: htmlMatch[1],
|
||||
text: htmlMatch[2].trim()
|
||||
})
|
||||
}
|
||||
|
||||
if (choices.length > 0) {
|
||||
return { choices, cleanContent: content }
|
||||
if (htmlChoices.length > 0) {
|
||||
return { choices: htmlChoices, cleanContent: content }
|
||||
}
|
||||
|
||||
return { choices: [], cleanContent: content }
|
||||
@@ -145,7 +164,7 @@ export default function MessageContent({ content, role, onChoiceSelect }: Messag
|
||||
const [showRaw, setShowRaw] = useState(false)
|
||||
const [hasHtml, setHasHtml] = useState(false)
|
||||
const [hasScript, setHasScript] = useState(false)
|
||||
const [allowScript, setAllowScript] = useState(true) // 默认启用脚本
|
||||
const [allowScript, setAllowScript] = useState(false) // 默认禁用脚本
|
||||
const [choices, setChoices] = useState<Choice[]>([])
|
||||
const [displayContent, setDisplayContent] = useState(content)
|
||||
const [statusPanel, setStatusPanel] = useState<any>(null)
|
||||
@@ -154,32 +173,85 @@ export default function MessageContent({ content, role, onChoiceSelect }: Messag
|
||||
useEffect(() => {
|
||||
console.log('[MessageContent] 原始内容:', content)
|
||||
|
||||
// 提取 markdown 代码块中的 HTML(仅识别 ```html 标识符的代码块)
|
||||
let processedContent = content
|
||||
let remainingContent = content
|
||||
let isHtmlCodeBlock = false
|
||||
const explicitHtmlRegex = /```html\s*([\s\S]*?)```/gi
|
||||
const htmlCodeBlocks: string[] = []
|
||||
let htmlMatch
|
||||
|
||||
while ((htmlMatch = explicitHtmlRegex.exec(content)) !== null) {
|
||||
htmlCodeBlocks.push(htmlMatch[1].trim())
|
||||
}
|
||||
|
||||
if (htmlCodeBlocks.length > 0) {
|
||||
// 有明确的 ```html 标识
|
||||
processedContent = htmlCodeBlocks.join('\n')
|
||||
remainingContent = content.replace(/```html\s*[\s\S]*?```/gi, '').trim()
|
||||
isHtmlCodeBlock = true
|
||||
console.log('[MessageContent] 提取到 ```html 代码块:', processedContent)
|
||||
console.log('[MessageContent] 剩余内容:', remainingContent)
|
||||
}
|
||||
|
||||
// 解析状态面板
|
||||
const { status, cleanContent: contentAfterStatus } = parseStatusPanel(content)
|
||||
const { status, cleanContent: contentAfterStatus } = parseStatusPanel(processedContent)
|
||||
console.log('[MessageContent] 状态面板:', status)
|
||||
setStatusPanel(status)
|
||||
|
||||
// 解析选择项
|
||||
const { choices: parsedChoices, cleanContent } = parseChoices(contentAfterStatus)
|
||||
// 解析选择项(从剩余内容中解析,而不是从 HTML 中解析)
|
||||
let parsedChoices: Choice[] = []
|
||||
let cleanContent = contentAfterStatus
|
||||
|
||||
if (isHtmlCodeBlock && remainingContent) {
|
||||
// 如果有 HTML 代码块,从剩余内容中解析选项
|
||||
const choiceResult = parseChoices(remainingContent)
|
||||
parsedChoices = choiceResult.choices
|
||||
console.log('[MessageContent] 从剩余内容解析选择项:', parsedChoices)
|
||||
} else if (!isHtmlCodeBlock) {
|
||||
// 如果没有 HTML 代码块,正常解析
|
||||
const choiceResult = parseChoices(contentAfterStatus)
|
||||
parsedChoices = choiceResult.choices
|
||||
cleanContent = choiceResult.cleanContent
|
||||
console.log('[MessageContent] 选择项:', parsedChoices)
|
||||
}
|
||||
setChoices(parsedChoices)
|
||||
|
||||
// 清理脚本输出
|
||||
const finalContent = cleanScriptOutput(cleanContent)
|
||||
// 清理脚本输出(只在非 HTML 代码块时清理)
|
||||
// 如果是HTML代码块,直接使用提取的HTML内容(processedContent),而不是contentAfterStatus
|
||||
const finalContent = isHtmlCodeBlock ? processedContent : cleanScriptOutput(cleanContent)
|
||||
console.log('[MessageContent] 清理后内容:', finalContent)
|
||||
setDisplayContent(finalContent)
|
||||
|
||||
// 检测内容类型
|
||||
// 先检测内容类型(需要在转换代码块之前检测,以便使用原始 finalContent)
|
||||
const htmlRegex = /<[^>]+>/g
|
||||
const scriptRegex = /<script[\s\S]*?<\/script>/gi
|
||||
|
||||
const hasHtmlContent = htmlRegex.test(finalContent)
|
||||
const hasScriptContent = scriptRegex.test(finalContent)
|
||||
|
||||
console.log('[MessageContent] hasHtml:', hasHtmlContent, 'hasScript:', hasScriptContent)
|
||||
|
||||
// 对于含 HTML 的内容,将剩余的 markdown 代码块(``` ... ```)转换为 <pre><code>
|
||||
// 避免反引号作为纯文本出现在 dangerouslySetInnerHTML 的渲染结果中
|
||||
let renderedContent = finalContent
|
||||
if (hasHtmlContent) {
|
||||
renderedContent = finalContent.replace(/```(\w*)\n?([\s\S]*?)```/g, (_match, lang, code) => {
|
||||
const escaped = code
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
const langLabel = lang ? `<span style="font-size:11px;color:rgba(255,255,255,0.4);display:block;margin-bottom:4px;">${lang}</span>` : ''
|
||||
return `<pre style="background:rgba(0,0,0,0.35);padding:10px 12px;border-radius:8px;overflow-x:auto;font-size:13px;line-height:1.5;margin:8px 0;">${langLabel}<code>${escaped}</code></pre>`
|
||||
})
|
||||
}
|
||||
setDisplayContent(renderedContent)
|
||||
|
||||
setHasHtml(hasHtmlContent)
|
||||
setHasScript(hasScriptContent)
|
||||
|
||||
// 如果有 HTML 内容或脚本,自动启用
|
||||
if (hasHtmlContent || hasScriptContent) {
|
||||
setAllowScript(true)
|
||||
console.log('[MessageContent] 自动启用脚本')
|
||||
}
|
||||
}, [content])
|
||||
|
||||
const renderInIframe = () => {
|
||||
@@ -197,15 +269,36 @@ export default function MessageContent({ content, role, onChoiceSelect }: Messag
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
body {
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100% !important;
|
||||
height: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
body {
|
||||
padding: 16px;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
img {
|
||||
height: auto;
|
||||
width: auto !important;
|
||||
}
|
||||
/* 强制覆盖任何固定宽度 */
|
||||
div, p, span, section, article {
|
||||
max-width: 100% !important;
|
||||
}
|
||||
[style*="width"] {
|
||||
width: auto !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -220,17 +313,20 @@ export default function MessageContent({ content, role, onChoiceSelect }: Messag
|
||||
setTimeout(() => {
|
||||
if (doc.body) {
|
||||
const height = doc.body.scrollHeight
|
||||
iframe.style.height = `${Math.max(height, 100)}px`
|
||||
iframe.style.height = `${Math.max(height + 32, 100)}px`
|
||||
}
|
||||
}, 100)
|
||||
}, 150)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (allowScript && hasScript) {
|
||||
if (allowScript && hasScript && iframeRef.current) {
|
||||
// 延迟渲染确保 iframe 已挂载
|
||||
setTimeout(() => {
|
||||
renderInIframe()
|
||||
}, 50)
|
||||
}
|
||||
}, [allowScript, displayContent])
|
||||
}, [allowScript, hasScript, displayContent])
|
||||
|
||||
// 如果是用户消息,直接显示纯文本
|
||||
if (role === 'user') {
|
||||
@@ -254,14 +350,16 @@ export default function MessageContent({ content, role, onChoiceSelect }: Messag
|
||||
|
||||
{hasScript && (
|
||||
<button
|
||||
onClick={() => setAllowScript(!allowScript)}
|
||||
onClick={() => {
|
||||
setAllowScript(!allowScript)
|
||||
}}
|
||||
className={`flex items-center gap-1 px-2 py-1 text-xs rounded cursor-pointer ${
|
||||
allowScript ? 'bg-green-500/20 text-green-400' : 'bg-yellow-500/20 text-yellow-400'
|
||||
allowScript ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'
|
||||
}`}
|
||||
title={allowScript ? '禁用脚本' : '允许脚本'}
|
||||
title={allowScript ? '禁用脚本' : '启用脚本'}
|
||||
>
|
||||
<Play className="w-3 h-3" />
|
||||
{allowScript ? '脚本已启用' : '启用脚本'}
|
||||
{allowScript ? '禁用脚本' : '启用脚本'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -273,17 +371,23 @@ export default function MessageContent({ content, role, onChoiceSelect }: Messag
|
||||
<code>{content}</code>
|
||||
</pre>
|
||||
) : hasScript && allowScript ? (
|
||||
// 有脚本时使用 iframe 渲染
|
||||
<div className="w-full border border-white/10 rounded-lg bg-black/20 overflow-hidden">
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className="w-full border border-white/10 rounded-lg bg-black/20"
|
||||
className="w-full"
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
style={{ minHeight: '100px' }}
|
||||
style={{ minHeight: '100px', border: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
) : hasHtml ? (
|
||||
// 有 HTML 但无脚本或脚本未启用时,直接渲染 HTML
|
||||
<div className="w-full border border-white/10 rounded-lg bg-black/20 overflow-hidden">
|
||||
<div
|
||||
className="prose prose-invert max-w-none text-sm leading-relaxed break-words overflow-wrap-anywhere"
|
||||
className="prose prose-invert max-w-none text-sm leading-relaxed break-words overflow-wrap-anywhere p-4"
|
||||
dangerouslySetInnerHTML={{ __html: displayContent }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm leading-relaxed whitespace-pre-wrap break-words overflow-wrap-anywhere">
|
||||
<DialogueText text={displayContent} />
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {useNavigate, useSearchParams} from 'react-router-dom'
|
||||
import Navbar from '../components/Navbar'
|
||||
import {Book, Code2, Download, Edit, FileJson, Image as ImageIcon, Plus, Search, Trash2, X} from 'lucide-react'
|
||||
import {Book, Code2, Download, Edit, FileJson, FileUp, Image as ImageIcon, Plus, Search, Trash2, X} from 'lucide-react'
|
||||
import {type Character, characterApi} from '../api/character'
|
||||
import {type RegexScript, regexScriptApi} from '../api/regex'
|
||||
import {useAppStore} from '../store'
|
||||
|
||||
interface WorldBookEntry {
|
||||
keys: string[]
|
||||
@@ -36,6 +37,14 @@ export default function CharacterManagePage() {
|
||||
const [showRegexScriptEditor, setShowRegexScriptEditor] = useState(false)
|
||||
const [regexScripts, setRegexScripts] = useState<RegexScript[]>([])
|
||||
const [editingTab, setEditingTab] = useState<'basic' | 'worldbook' | 'regex'>('basic')
|
||||
const [showAddRegexModal, setShowAddRegexModal] = useState(false)
|
||||
const [newRegexForm, setNewRegexForm] = useState({
|
||||
name: '',
|
||||
findRegex: '',
|
||||
replaceWith: '',
|
||||
placement: 1,
|
||||
order: 100,
|
||||
})
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
@@ -759,13 +768,18 @@ export default function CharacterManagePage() {
|
||||
try {
|
||||
await regexScriptApi.createRegexScript({
|
||||
name: '新脚本',
|
||||
findRegex: '',
|
||||
findRegex: '.*', // 默认匹配所有内容
|
||||
replaceWith: '',
|
||||
scope: 1,
|
||||
ownerCharId: selectedCharacter.id,
|
||||
disabled: false,
|
||||
placement: 1, // 默认输出阶段
|
||||
order: 100,
|
||||
})
|
||||
loadRegexScripts(selectedCharacter.id)
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.msg || '创建失败')
|
||||
console.error('创建脚本失败:', err)
|
||||
alert(err.response?.data?.msg || err.message || '创建失败')
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 bg-gradient-to-r from-primary to-secondary rounded-lg text-sm cursor-pointer"
|
||||
@@ -913,10 +927,19 @@ export default function CharacterManagePage() {
|
||||
if (!testInput) return
|
||||
|
||||
try {
|
||||
const result = await regexScriptApi.testRegexScript(script.id, testInput)
|
||||
alert(`原文:${result.data.original}\n\n结果:${result.data.result}`)
|
||||
const response = await regexScriptApi.testRegexScript(script.id, testInput)
|
||||
console.log('测试响应:', response)
|
||||
|
||||
// 处理响应数据结构
|
||||
const testResult = response.data || response
|
||||
if (testResult.success === false && testResult.error) {
|
||||
alert(`测试失败:${testResult.error}`)
|
||||
} else {
|
||||
alert(`原文:${testResult.original}\n\n结果:${testResult.result}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.msg || '测试失败')
|
||||
console.error('测试失败:', err)
|
||||
alert(err.response?.data?.msg || err.message || '测试失败')
|
||||
}
|
||||
}}
|
||||
className="px-3 py-1.5 glass-hover rounded-lg text-xs cursor-pointer"
|
||||
@@ -953,19 +976,7 @@ export default function CharacterManagePage() {
|
||||
|
||||
<div className="flex gap-3 mt-6 pt-6 border-t border-white/10">
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await regexScriptApi.createRegexScript({
|
||||
name: '新脚本',
|
||||
findRegex: '',
|
||||
scope: 1,
|
||||
ownerCharId: selectedCharacter.id,
|
||||
})
|
||||
loadRegexScripts(selectedCharacter.id)
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.msg || '创建失败')
|
||||
}
|
||||
}}
|
||||
onClick={() => setShowAddRegexModal(true)}
|
||||
className="px-4 py-2 glass-hover rounded-lg text-sm cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
@@ -982,6 +993,219 @@ export default function CharacterManagePage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 添加正则脚本弹窗 */}
|
||||
{showAddRegexModal && selectedCharacter && (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="glass rounded-3xl p-8 max-w-2xl w-full">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-bold">添加正则脚本</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAddRegexModal(false)
|
||||
setNewRegexForm({
|
||||
name: '',
|
||||
findRegex: '',
|
||||
replaceWith: '',
|
||||
placement: 1,
|
||||
order: 100,
|
||||
})
|
||||
}}
|
||||
className="p-2 glass-hover rounded-lg cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 导入 JSON 文件 */}
|
||||
<div>
|
||||
<label className="block text-sm text-white/80 mb-2">从 JSON 文件导入</label>
|
||||
<label className="glass-hover rounded-xl p-6 border-2 border-dashed border-white/20 cursor-pointer block text-center">
|
||||
<FileUp className="w-8 h-8 mx-auto mb-2 text-white/40" />
|
||||
<span className="text-sm text-white/60">点击选择 JSON 文件</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
try {
|
||||
const text = await file.text()
|
||||
const jsonData = JSON.parse(text)
|
||||
|
||||
// 支持单个脚本或脚本数组
|
||||
const scripts = Array.isArray(jsonData) ? jsonData : [jsonData]
|
||||
|
||||
for (const scriptData of scripts) {
|
||||
await regexScriptApi.createRegexScript({
|
||||
name: scriptData.scriptName || scriptData.name || '导入的脚本',
|
||||
findRegex: scriptData.findRegex || '.*',
|
||||
replaceWith: scriptData.replaceString || scriptData.replaceWith || '',
|
||||
placement: scriptData.placement || 1,
|
||||
disabled: scriptData.disabled || false,
|
||||
order: scriptData.order || 100,
|
||||
scope: 1,
|
||||
ownerCharId: selectedCharacter.id,
|
||||
markdownOnly: scriptData.markdownOnly || false,
|
||||
runOnEdit: scriptData.runOnEdit || false,
|
||||
promptOnly: scriptData.promptOnly || false,
|
||||
substituteRegex: scriptData.substituteRegex !== false,
|
||||
})
|
||||
}
|
||||
|
||||
loadRegexScripts(selectedCharacter.id)
|
||||
setShowAddRegexModal(false)
|
||||
alert(`成功导入 ${scripts.length} 个正则脚本`)
|
||||
} catch (err: any) {
|
||||
console.error('导入失败:', err)
|
||||
alert('导入失败:' + (err.message || '文件格式不正确'))
|
||||
}
|
||||
}}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-white/40 mt-2">
|
||||
支持 SillyTavern 格式的正则脚本 JSON 文件
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-white/10"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-4 glass text-white/60">或手动创建</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 手动输入表单 */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-2 block">脚本名称 *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newRegexForm.name}
|
||||
onChange={(e) => setNewRegexForm({ ...newRegexForm, name: e.target.value })}
|
||||
placeholder="例如: 移除 Markdown 加粗"
|
||||
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-2 block">查找正则表达式 *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newRegexForm.findRegex}
|
||||
onChange={(e) => setNewRegexForm({ ...newRegexForm, findRegex: e.target.value })}
|
||||
placeholder="例如: \*\*(.+?)\*\*"
|
||||
className="w-full px-4 py-3 glass rounded-xl text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-2 block">替换为</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newRegexForm.replaceWith}
|
||||
onChange={(e) => setNewRegexForm({ ...newRegexForm, replaceWith: e.target.value })}
|
||||
placeholder="例如: <strong>$1</strong>"
|
||||
className="w-full px-4 py-3 glass rounded-xl text-sm font-mono focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-2 block">执行阶段</label>
|
||||
<select
|
||||
value={newRegexForm.placement}
|
||||
onChange={(e) => setNewRegexForm({ ...newRegexForm, placement: parseInt(e.target.value) })}
|
||||
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
>
|
||||
<option value="0">输入</option>
|
||||
<option value="1">输出</option>
|
||||
<option value="2">世界书</option>
|
||||
<option value="3">推理</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-white/80 mb-2 block">执行顺序</label>
|
||||
<input
|
||||
type="number"
|
||||
value={newRegexForm.order}
|
||||
onChange={(e) => setNewRegexForm({ ...newRegexForm, order: parseInt(e.target.value) })}
|
||||
className="w-full px-4 py-3 glass rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAddRegexModal(false)
|
||||
setNewRegexForm({
|
||||
name: '',
|
||||
findRegex: '',
|
||||
replaceWith: '',
|
||||
placement: 1,
|
||||
order: 100,
|
||||
})
|
||||
}}
|
||||
className="flex-1 px-6 py-3 glass-hover rounded-xl cursor-pointer"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!newRegexForm.name.trim()) {
|
||||
alert('请输入脚本名称')
|
||||
return
|
||||
}
|
||||
if (!newRegexForm.findRegex.trim()) {
|
||||
alert('请输入查找正则表达式')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await regexScriptApi.createRegexScript({
|
||||
name: newRegexForm.name,
|
||||
findRegex: newRegexForm.findRegex,
|
||||
replaceWith: newRegexForm.replaceWith,
|
||||
placement: newRegexForm.placement,
|
||||
order: newRegexForm.order,
|
||||
scope: 1,
|
||||
ownerCharId: selectedCharacter.id,
|
||||
disabled: false,
|
||||
substituteRegex: true,
|
||||
})
|
||||
|
||||
loadRegexScripts(selectedCharacter.id)
|
||||
setShowAddRegexModal(false)
|
||||
setNewRegexForm({
|
||||
name: '',
|
||||
findRegex: '',
|
||||
replaceWith: '',
|
||||
placement: 1,
|
||||
order: 100,
|
||||
})
|
||||
alert('创建成功')
|
||||
} catch (err: any) {
|
||||
console.error('创建失败:', err)
|
||||
alert(err.response?.data?.msg || err.message || '创建失败')
|
||||
}
|
||||
}}
|
||||
className="flex-1 px-6 py-3 bg-gradient-to-r from-primary to-secondary rounded-xl font-medium hover:opacity-90 transition-opacity cursor-pointer"
|
||||
>
|
||||
创建
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,18 +7,28 @@ import CharacterPanel from '../components/CharacterPanel'
|
||||
import SettingsPanel from '../components/SettingsPanel'
|
||||
import {type Conversation, conversationApi} from '../api/conversation'
|
||||
import {type Character, characterApi} from '../api/character'
|
||||
import {useAppStore} from '../store'
|
||||
|
||||
export default function ChatPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const [activePanel, setActivePanel] = useState<'chat' | 'settings'>('chat')
|
||||
const [currentConversation, setCurrentConversation] = useState<Conversation | null>(null)
|
||||
const [currentCharacter, setCurrentCharacter] = useState<Character | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [backgroundImage, setBackgroundImage] = useState<string>()
|
||||
const [showSidebar, setShowSidebar] = useState(true)
|
||||
const [showCharacterPanel, setShowCharacterPanel] = useState(true)
|
||||
|
||||
// 使用 MVU store
|
||||
const {
|
||||
currentConversation,
|
||||
currentCharacter,
|
||||
loading,
|
||||
sidebarOpen: showSidebar,
|
||||
setCurrentConversation,
|
||||
setCurrentCharacter,
|
||||
setLoading,
|
||||
setSidebarOpen: setShowSidebar,
|
||||
setVariable,
|
||||
} = useAppStore()
|
||||
|
||||
// 从URL参数获取角色ID或对话ID
|
||||
const characterId = searchParams.get('character')
|
||||
const conversationId = searchParams.get('conversation')
|
||||
@@ -67,12 +77,18 @@ export default function ChatPage() {
|
||||
// 加载对话关联的角色
|
||||
const charResp = await characterApi.getCharacterById(convResp.data.characterId)
|
||||
setCurrentCharacter(charResp.data)
|
||||
|
||||
// 更新变量系统
|
||||
setVariable('char', charResp.data.name)
|
||||
}
|
||||
// 如果有角色ID,创建新对话
|
||||
else if (characterId) {
|
||||
const charResp = await characterApi.getCharacterById(Number(characterId))
|
||||
setCurrentCharacter(charResp.data)
|
||||
|
||||
// 更新变量系统
|
||||
setVariable('char', charResp.data.name)
|
||||
|
||||
// 创建新对话
|
||||
const convResp = await conversationApi.createConversation({
|
||||
characterId: Number(characterId),
|
||||
|
||||
412
web-app/src/pages/RegexScriptManagePage.tsx
Normal file
412
web-app/src/pages/RegexScriptManagePage.tsx
Normal file
@@ -0,0 +1,412 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Plus, Search, Edit2, Trash2, Upload, Download, Play, X } from 'lucide-react'
|
||||
import { regexAPI } from '../api/regex'
|
||||
|
||||
interface RegexScript {
|
||||
id: number
|
||||
name: string
|
||||
findRegex: string
|
||||
replaceWith: string
|
||||
placement: number
|
||||
disabled: boolean
|
||||
order: number
|
||||
scope: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const placementNames = ['输入', '输出', '世界书', '显示']
|
||||
|
||||
export default function RegexScriptManagePage() {
|
||||
const [scripts, setScripts] = useState<RegexScript[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [searchKeyword, setSearchKeyword] = useState('')
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editingScript, setEditingScript] = useState<RegexScript | null>(null)
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
findRegex: '',
|
||||
replaceWith: '',
|
||||
placement: 1,
|
||||
disabled: false,
|
||||
order: 100,
|
||||
scope: 0,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
loadScripts()
|
||||
}, [searchKeyword])
|
||||
|
||||
const loadScripts = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await regexAPI.getList({
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
keyword: searchKeyword,
|
||||
scope: 0, // 只加载全局脚本
|
||||
})
|
||||
setScripts(response.data.list || [])
|
||||
} catch (error) {
|
||||
console.error('加载正则脚本失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingScript(null)
|
||||
setFormData({
|
||||
name: '',
|
||||
findRegex: '',
|
||||
replaceWith: '',
|
||||
placement: 1,
|
||||
disabled: false,
|
||||
order: 100,
|
||||
scope: 0,
|
||||
})
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const handleEdit = (script: RegexScript) => {
|
||||
setEditingScript(script)
|
||||
setFormData({
|
||||
name: script.name,
|
||||
findRegex: script.findRegex,
|
||||
replaceWith: script.replaceWith,
|
||||
placement: script.placement,
|
||||
disabled: script.disabled,
|
||||
order: script.order,
|
||||
scope: 0,
|
||||
})
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
if (editingScript) {
|
||||
await regexAPI.update(editingScript.id, formData)
|
||||
} else {
|
||||
await regexAPI.create(formData)
|
||||
}
|
||||
setShowModal(false)
|
||||
loadScripts()
|
||||
} catch (error) {
|
||||
console.error('保存正则脚本失败:', error)
|
||||
alert('保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('确定要删除这个正则脚本吗?')) return
|
||||
try {
|
||||
await regexAPI.delete(id)
|
||||
loadScripts()
|
||||
} catch (error) {
|
||||
console.error('删除正则脚本失败:', error)
|
||||
alert('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
try {
|
||||
const text = await file.text()
|
||||
const data = JSON.parse(text)
|
||||
|
||||
// 支持导入单个脚本或脚本数组
|
||||
const scriptsToImport = Array.isArray(data) ? data : [data]
|
||||
|
||||
for (const script of scriptsToImport) {
|
||||
await regexAPI.create({
|
||||
name: script.scriptName || script.name || '未命名脚本',
|
||||
findRegex: script.findRegex || '',
|
||||
replaceWith: script.replaceString || script.replaceWith || '',
|
||||
placement: script.placement ?? 1,
|
||||
disabled: script.disabled ?? false,
|
||||
order: script.order ?? 100,
|
||||
scope: 0, // 全局作用域
|
||||
})
|
||||
}
|
||||
|
||||
loadScripts()
|
||||
alert(`成功导入 ${scriptsToImport.length} 个脚本`)
|
||||
} catch (error) {
|
||||
console.error('导入失败:', error)
|
||||
alert('导入失败,请检查文件格式')
|
||||
}
|
||||
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const handleExport = (script: RegexScript) => {
|
||||
const data = {
|
||||
scriptName: script.name,
|
||||
findRegex: script.findRegex,
|
||||
replaceString: script.replaceWith,
|
||||
placement: script.placement,
|
||||
disabled: script.disabled,
|
||||
order: script.order,
|
||||
}
|
||||
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${script.name}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const handleExportAll = () => {
|
||||
const data = scripts.map(script => ({
|
||||
scriptName: script.name,
|
||||
findRegex: script.findRegex,
|
||||
replaceString: script.replaceWith,
|
||||
placement: script.placement,
|
||||
disabled: script.disabled,
|
||||
order: script.order,
|
||||
}))
|
||||
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'global-regex-scripts.json'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-purple-900 to-gray-900 p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* 头部 */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">全局正则脚本</h1>
|
||||
<p className="text-gray-400">管理全局作用域的正则脚本,应用于所有对话</p>
|
||||
</div>
|
||||
|
||||
{/* 工具栏 */}
|
||||
<div className="glass rounded-xl p-4 mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索脚本名称..."
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 bg-black/30 border border-white/10 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-primary/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary/80 text-white rounded-lg transition-colors"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
新建脚本
|
||||
</button>
|
||||
|
||||
<label className="flex items-center gap-2 px-4 py-2 glass-hover rounded-lg cursor-pointer">
|
||||
<Upload className="w-5 h-5" />
|
||||
导入
|
||||
<input type="file" accept=".json" onChange={handleImport} className="hidden" />
|
||||
</label>
|
||||
|
||||
{scripts.length > 0 && (
|
||||
<button
|
||||
onClick={handleExportAll}
|
||||
className="flex items-center gap-2 px-4 py-2 glass-hover rounded-lg"
|
||||
>
|
||||
<Download className="w-5 h-5" />
|
||||
导出全部
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 脚本列表 */}
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-400">加载中...</div>
|
||||
) : scripts.length === 0 ? (
|
||||
<div className="text-center py-12 glass rounded-xl">
|
||||
<p className="text-gray-400 mb-4">暂无全局正则脚本</p>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="px-6 py-2 bg-primary hover:bg-primary/80 text-white rounded-lg transition-colors"
|
||||
>
|
||||
创建第一个脚本
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{scripts.map((script) => (
|
||||
<div key={script.id} className="glass rounded-xl p-6 hover:bg-white/5 transition-colors">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-lg font-semibold text-white">{script.name}</h3>
|
||||
<span className={`px-2 py-1 text-xs rounded ${
|
||||
script.disabled ? 'bg-red-500/20 text-red-400' : 'bg-green-500/20 text-green-400'
|
||||
}`}>
|
||||
{script.disabled ? '已禁用' : '已启用'}
|
||||
</span>
|
||||
<span className="px-2 py-1 text-xs rounded bg-blue-500/20 text-blue-400">
|
||||
{placementNames[script.placement]}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">顺序: {script.order}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(script)}
|
||||
className="p-2 glass-hover rounded-lg"
|
||||
title="编辑"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleExport(script)}
|
||||
className="p-2 glass-hover rounded-lg"
|
||||
title="导出"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(script.id)}
|
||||
className="p-2 glass-hover rounded-lg text-red-400"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-400">查找正则:</span>
|
||||
<code className="ml-2 px-2 py-1 bg-black/30 rounded text-primary">{script.findRegex}</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-400">替换为:</span>
|
||||
<code className="ml-2 px-2 py-1 bg-black/30 rounded text-green-400">{script.replaceWith}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 编辑/创建模态框 */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="glass rounded-xl p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-bold text-white">
|
||||
{editingScript ? '编辑正则脚本' : '新建正则脚本'}
|
||||
</h2>
|
||||
<button onClick={() => setShowModal(false)} className="p-2 glass-hover rounded-lg">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-2">脚本名称</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-4 py-2 bg-black/30 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary/50"
|
||||
placeholder="例如:移除HTML标签"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-2">查找正则表达式</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.findRegex}
|
||||
onChange={(e) => setFormData({ ...formData, findRegex: e.target.value })}
|
||||
className="w-full px-4 py-2 bg-black/30 border border-white/10 rounded-lg text-white font-mono focus:outline-none focus:border-primary/50"
|
||||
placeholder="例如:<[^>]+>"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-2">替换为</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.replaceWith}
|
||||
onChange={(e) => setFormData({ ...formData, replaceWith: e.target.value })}
|
||||
className="w-full px-4 py-2 bg-black/30 border border-white/10 rounded-lg text-white font-mono focus:outline-none focus:border-primary/50"
|
||||
placeholder="例如:空字符串或替换文本"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
支持变量:{'{{user}}, {{char}}, {{random}}, {{time}}'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-2">执行阶段</label>
|
||||
<select
|
||||
value={formData.placement}
|
||||
onChange={(e) => setFormData({ ...formData, placement: Number(e.target.value) })}
|
||||
className="w-full px-4 py-2 bg-black/30 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary/50"
|
||||
>
|
||||
<option value={0}>输入(用户消息)</option>
|
||||
<option value={1}>输出(AI回复)</option>
|
||||
<option value={2}>世界书</option>
|
||||
<option value={3}>显示(前端渲染)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-2">执行顺序</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.order}
|
||||
onChange={(e) => setFormData({ ...formData, order: Number(e.target.value) })}
|
||||
className="w-full px-4 py-2 bg-black/30 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary/50"
|
||||
placeholder="100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="disabled"
|
||||
checked={formData.disabled}
|
||||
onChange={(e) => setFormData({ ...formData, disabled: e.target.checked })}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label htmlFor="disabled" className="text-sm text-gray-400">禁用此脚本</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mt-6">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="flex-1 px-4 py-2 bg-primary hover:bg-primary/80 text-white rounded-lg transition-colors"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="flex-1 px-4 py-2 glass-hover rounded-lg"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
286
web-app/src/store/index.ts
Normal file
286
web-app/src/store/index.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* MVU (Model-View-Update) 状态管理 Store
|
||||
* 基于 Zustand 实现的轻量级状态管理
|
||||
*/
|
||||
|
||||
import { create } from 'zustand'
|
||||
import { devtools, persist } from 'zustand/middleware'
|
||||
|
||||
// ============= Model (状态模型) =============
|
||||
|
||||
export interface User {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
export interface Character {
|
||||
id: number
|
||||
name: string
|
||||
avatar: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: number
|
||||
characterId: number
|
||||
title: string
|
||||
lastMessage?: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: number
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface AppState {
|
||||
// 用户状态
|
||||
user: User | null
|
||||
isAuthenticated: boolean
|
||||
|
||||
// 当前选中的实体
|
||||
currentCharacter: Character | null
|
||||
currentConversation: Conversation | null
|
||||
|
||||
// 消息列表
|
||||
messages: Message[]
|
||||
|
||||
// UI 状态
|
||||
sidebarOpen: boolean
|
||||
loading: boolean
|
||||
error: string | null
|
||||
|
||||
// 变量系统
|
||||
variables: Record<string, string>
|
||||
}
|
||||
|
||||
// ============= Update (状态更新) =============
|
||||
|
||||
export interface AppActions {
|
||||
// 用户操作
|
||||
setUser: (user: User | null) => void
|
||||
login: (user: User) => void
|
||||
logout: () => void
|
||||
|
||||
// 角色操作
|
||||
setCurrentCharacter: (character: Character | null) => void
|
||||
|
||||
// 对话操作
|
||||
setCurrentConversation: (conversation: Conversation | null) => void
|
||||
|
||||
// 消息操作
|
||||
setMessages: (messages: Message[]) => void
|
||||
addMessage: (message: Message) => void
|
||||
updateMessage: (id: number, content: string) => void
|
||||
deleteMessage: (id: number) => void
|
||||
clearMessages: () => void
|
||||
|
||||
// UI 操作
|
||||
toggleSidebar: () => void
|
||||
setSidebarOpen: (open: boolean) => void
|
||||
setLoading: (loading: boolean) => void
|
||||
setError: (error: string | null) => void
|
||||
|
||||
// 变量系统操作
|
||||
setVariable: (key: string, value: string) => void
|
||||
getVariable: (key: string) => string | undefined
|
||||
deleteVariable: (key: string) => void
|
||||
clearVariables: () => void
|
||||
|
||||
// 批量更新(用于复杂操作)
|
||||
batchUpdate: (updater: (state: AppState) => Partial<AppState>) => void
|
||||
}
|
||||
|
||||
// ============= Store (状态容器) =============
|
||||
|
||||
const initialState: AppState = {
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
currentCharacter: null,
|
||||
currentConversation: null,
|
||||
messages: [],
|
||||
sidebarOpen: true,
|
||||
loading: false,
|
||||
error: null,
|
||||
variables: {
|
||||
user: '',
|
||||
char: '',
|
||||
},
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState & AppActions>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
|
||||
// 用户操作
|
||||
setUser: (user) => set({ user, isAuthenticated: !!user }),
|
||||
|
||||
login: (user) => set({
|
||||
user,
|
||||
isAuthenticated: true,
|
||||
variables: {
|
||||
...get().variables,
|
||||
user: user.username,
|
||||
}
|
||||
}),
|
||||
|
||||
logout: () => set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
currentCharacter: null,
|
||||
currentConversation: null,
|
||||
messages: [],
|
||||
variables: {
|
||||
user: '',
|
||||
char: '',
|
||||
}
|
||||
}),
|
||||
|
||||
// 角色操作
|
||||
setCurrentCharacter: (character) => set({
|
||||
currentCharacter: character,
|
||||
variables: {
|
||||
...get().variables,
|
||||
char: character?.name || '',
|
||||
}
|
||||
}),
|
||||
|
||||
// 对话操作
|
||||
setCurrentConversation: (conversation) => set({ currentConversation: conversation }),
|
||||
|
||||
// 消息操作
|
||||
setMessages: (messages) => set({ messages }),
|
||||
|
||||
addMessage: (message) => set((state) => ({
|
||||
messages: [...state.messages, message]
|
||||
})),
|
||||
|
||||
updateMessage: (id, content) => set((state) => ({
|
||||
messages: state.messages.map(msg =>
|
||||
msg.id === id ? { ...msg, content } : msg
|
||||
)
|
||||
})),
|
||||
|
||||
deleteMessage: (id) => set((state) => ({
|
||||
messages: state.messages.filter(msg => msg.id !== id)
|
||||
})),
|
||||
|
||||
clearMessages: () => set({ messages: [] }),
|
||||
|
||||
// UI 操作
|
||||
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||
setSidebarOpen: (open) => set({ sidebarOpen: open }),
|
||||
setLoading: (loading) => set({ loading }),
|
||||
setError: (error) => set({ error }),
|
||||
|
||||
// 变量系统操作
|
||||
setVariable: (key, value) => set((state) => ({
|
||||
variables: { ...state.variables, [key]: value }
|
||||
})),
|
||||
|
||||
getVariable: (key) => get().variables[key],
|
||||
|
||||
deleteVariable: (key) => set((state) => {
|
||||
const { [key]: _, ...rest } = state.variables
|
||||
return { variables: rest }
|
||||
}),
|
||||
|
||||
clearVariables: () => set({
|
||||
variables: { user: get().user?.username || '', char: get().currentCharacter?.name || '' }
|
||||
}),
|
||||
|
||||
// 批量更新
|
||||
batchUpdate: (updater) => set((state) => updater(state)),
|
||||
}),
|
||||
{
|
||||
name: 'app-storage',
|
||||
partialize: (state) => ({
|
||||
user: state.user,
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
sidebarOpen: state.sidebarOpen,
|
||||
variables: state.variables,
|
||||
}),
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
// ============= Selectors (状态选择器) =============
|
||||
|
||||
export const selectUser = (state: AppState & AppActions) => state.user
|
||||
export const selectIsAuthenticated = (state: AppState & AppActions) => state.isAuthenticated
|
||||
export const selectCurrentCharacter = (state: AppState & AppActions) => state.currentCharacter
|
||||
export const selectCurrentConversation = (state: AppState & AppActions) => state.currentConversation
|
||||
export const selectMessages = (state: AppState & AppActions) => state.messages
|
||||
export const selectSidebarOpen = (state: AppState & AppActions) => state.sidebarOpen
|
||||
export const selectLoading = (state: AppState & AppActions) => state.loading
|
||||
export const selectError = (state: AppState & AppActions) => state.error
|
||||
export const selectVariables = (state: AppState & AppActions) => state.variables
|
||||
|
||||
// ============= 变量替换工具函数 =============
|
||||
|
||||
/**
|
||||
* 替换文本中的变量
|
||||
* 支持的变量格式:{{user}}, {{char}}, {{random}}, {{time}}, {{date}} 等
|
||||
*/
|
||||
export function substituteVariables(text: string, customVars?: Record<string, string>): string {
|
||||
const state = useAppStore.getState()
|
||||
const variables = { ...state.variables, ...customVars }
|
||||
|
||||
let result = text
|
||||
|
||||
// 替换用户定义的变量
|
||||
Object.entries(variables).forEach(([key, value]) => {
|
||||
const regex = new RegExp(`\\{\\{${key}\\}\\}`, 'gi')
|
||||
result = result.replace(regex, value)
|
||||
})
|
||||
|
||||
// 替换时间变量
|
||||
const now = new Date()
|
||||
result = result.replace(/\{\{time\}\}/gi, now.toLocaleTimeString('en-US', { hour12: false }))
|
||||
result = result.replace(/\{\{time_12h\}\}/gi, now.toLocaleTimeString('en-US', { hour12: true }))
|
||||
result = result.replace(/\{\{date\}\}/gi, now.toLocaleDateString('en-CA')) // YYYY-MM-DD
|
||||
result = result.replace(/\{\{date_short\}\}/gi, now.toLocaleDateString('en-US')) // MM/DD/YYYY
|
||||
result = result.replace(/\{\{datetime\}\}/gi, now.toLocaleString())
|
||||
result = result.replace(/\{\{timestamp\}\}/gi, now.getTime().toString())
|
||||
result = result.replace(/\{\{weekday\}\}/gi, now.toLocaleDateString('en-US', { weekday: 'long' }))
|
||||
result = result.replace(/\{\{month\}\}/gi, now.toLocaleDateString('en-US', { month: 'long' }))
|
||||
result = result.replace(/\{\{year\}\}/gi, now.getFullYear().toString())
|
||||
|
||||
// 替换随机数变量
|
||||
result = result.replace(/\{\{random:(\d+)-(\d+)\}\}/gi, (_, min, max) => {
|
||||
const minNum = parseInt(min)
|
||||
const maxNum = parseInt(max)
|
||||
return Math.floor(Math.random() * (maxNum - minNum + 1) + minNum).toString()
|
||||
})
|
||||
result = result.replace(/\{\{random\}\}/gi, () => Math.floor(Math.random() * 100).toString())
|
||||
|
||||
// 随机选择 {{pick:option1|option2|option3}}
|
||||
result = result.replace(/\{\{pick:([^}]+)\}\}/gi, (_, options) => {
|
||||
const optionList = options.split('|')
|
||||
return optionList[Math.floor(Math.random() * optionList.length)]
|
||||
})
|
||||
|
||||
// 替换特殊字符
|
||||
result = result.replace(/\{\{newline\}\}/gi, '\n')
|
||||
result = result.replace(/\{\{tab\}\}/gi, '\t')
|
||||
result = result.replace(/\{\{space\}\}/gi, ' ')
|
||||
result = result.replace(/\{\{empty\}\}/gi, '')
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文本中提取变量
|
||||
*/
|
||||
export function extractVariables(text: string): string[] {
|
||||
const regex = /\{\{([^}]+)\}\}/g
|
||||
const matches = text.matchAll(regex)
|
||||
return Array.from(matches, m => m[1])
|
||||
}
|
||||
Reference in New Issue
Block a user