Compare commits

..

9 Commits

Author SHA1 Message Date
d0582f6aad 📝 更新进度文档,填写未测试的功能模块
Signed-off-by: Echo <1711788888@qq.com>
2026-03-02 01:17:21 +08:00
a857917b53 🎨 引入mvu变量和全局正则到ai对话
Signed-off-by: Echo <1711788888@qq.com>
2026-03-02 01:15:22 +08:00
20d99cf3bf 🎨 优化对话消息前端渲染(未完成存在bug)
Signed-off-by: Echo <1711788888@qq.com>
2026-03-02 01:14:42 +08:00
8888d9ea85 🎨 新增正则管理 和 全局正则功能
Signed-off-by: Echo <1711788888@qq.com>
2026-03-02 01:14:16 +08:00
2b8be78fdc 🎨 引入mvu功能
Signed-off-by: Echo <1711788888@qq.com>
2026-03-02 01:13:58 +08:00
de6015c77e 🎨 完善正则脚本功能
Signed-off-by: Echo <1711788888@qq.com>
2026-03-02 00:51:23 +08:00
23396caeeb 🎨 优化前端渲染
Signed-off-by: Echo <1711788888@qq.com>
2026-03-02 00:51:11 +08:00
fd660c8804 🎨 优化角色卡编辑功能,新增正则编辑和测试
Signed-off-by: Echo <1711788888@qq.com>
2026-03-02 00:50:56 +08:00
aa461ec6c3 📝 更新进度文档
Signed-off-by: Echo <1711788888@qq.com>
2026-03-02 00:50:31 +08:00
14 changed files with 1646 additions and 147 deletions

View File

@@ -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. 📋 插件系统设计与实现

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)
console.log('[MessageContent] 选择项:', parsedChoices)
// 解析选择项(从剩余内容中解析,而不是从 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
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) {
renderInIframe()
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
ref={iframeRef}
className="w-full border border-white/10 rounded-lg bg-black/20"
sandbox="allow-scripts allow-same-origin"
style={{ minHeight: '100px' }}
/>
// 有脚本时使用 iframe 渲染
<div className="w-full border border-white/10 rounded-lg bg-black/20 overflow-hidden">
<iframe
ref={iframeRef}
className="w-full"
sandbox="allow-scripts allow-same-origin"
style={{ minHeight: '100px', border: 'none' }}
/>
</div>
) : hasHtml ? (
<div
className="prose prose-invert max-w-none text-sm leading-relaxed break-words overflow-wrap-anywhere"
dangerouslySetInnerHTML={{ __html: displayContent }}
/>
// 有 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 p-4"
dangerouslySetInnerHTML={{ __html: displayContent }}
/>
</div>
) : (
<div className="text-sm leading-relaxed whitespace-pre-wrap break-words overflow-wrap-anywhere">
<DialogueText text={displayContent} />

View File

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

View File

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

View 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
View 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])
}