diff --git a/docs/扩展模块修复说明.md b/docs/扩展模块修复说明.md new file mode 100644 index 0000000..12728f9 --- /dev/null +++ b/docs/扩展模块修复说明.md @@ -0,0 +1,228 @@ +# 扩展模块修复说明 + +## 问题描述 + +1. **数据库错误**:点击扩展配置时报错 `ERROR: relation "ai_extension_settings" does not exist` +2. **UI 不符合预期**:原来是表格式,用户希望是折叠面板式(类似原版 SillyTavern) + +## 修复内容 + +### 1. 简化数据库设计 + +**问题**:原设计使用独立的 `ai_extension_settings` 表存储用户配置,导致表不存在时报错。 + +**解决方案**:取消 `ai_extension_settings` 表,直接将配置存储在 `ai_extensions` 表的 `settings` 字段中。 + +**修改文件**: +- `server/model/app/ai_extension.go` - 注释掉 `AIExtensionSettings` 结构体 +- `server/initialize/gorm.go` - 移除 `AIExtensionSettings` 表注册 +- `server/service/app/extension.go` - 简化配置获取和更新逻辑 + +### 2. 重新设计 UI 为折叠面板式 + +**新界面特点**: +- ✅ 折叠面板布局(类似原版 SillyTavern) +- ✅ 每个扩展可展开查看详情和配置 +- ✅ 启用/禁用开关在标题栏 +- ✅ 配置项根据 manifest.json 动态生成 +- ✅ 支持多种配置类型: + - 文本输入(text/string) + - 数字输入(number) + - 布尔开关(boolean/checkbox) + - 下拉选择(select) + - 文本域(textarea) + - 滑块(slider) + +**新文件**: +- `web-app-vue/src/views/extension/ExtensionListNew.vue` - 全新的折叠面板式界面 + +**修改文件**: +- `web-app-vue/src/router/index.ts` - 更新路由指向新界面 + +### 3. 优化配置处理逻辑 + +**后端改进**: + +```go +// GetExtensionSettings - 改进版 +func (es *ExtensionService) GetExtensionSettings(userID, extensionID uint) (map[string]interface{}, error) { + // 1. 验证扩展存在 + // 2. 返回扩展的 settings 字段 + // 3. 如果为空,从 manifestData 中提取默认配置 + // 4. 确保总是返回有效的配置对象(至少是空对象) +} + +// UpdateExtensionSettings - 简化版 +func (es *ExtensionService) UpdateExtensionSettings(userID, extensionID uint, settings map[string]interface{}) error { + // 直接更新扩展表的 settings 字段 +} +``` + +## 使用说明 + +### 安装扩展 + +支持三种安装方式: + +1. **从文件导入**:上传 `manifest.json` 文件 +2. **从 Git 安装**:输入 Git 仓库 URL(支持 GitHub、GitLab、Gitee 等) +3. **从 URL 安装**:直接输入 `manifest.json` 文件的 URL + +### 管理扩展 + +1. **启用/禁用**:点击扩展标题栏的开关 +2. **查看详情**:点击扩展面板展开 +3. **配置扩展**:展开后直接编辑配置项,自动保存 +4. **导出扩展**:点击"导出"按钮下载 `manifest.json` +5. **卸载扩展**:点击删除按钮(系统扩展不可卸载) + +### 配置项说明 + +扩展的配置项根据 `manifest.json` 中的 `settings` 定义自动生成。例如: + +```json +{ + "name": "example-extension", + "settings": { + "apiKey": { + "type": "text", + "label": "API 密钥", + "description": "请输入您的 API 密钥", + "placeholder": "sk-..." + }, + "maxRetries": { + "type": "number", + "label": "最大重试次数", + "min": 1, + "max": 10, + "step": 1 + }, + "enabled": { + "type": "boolean", + "label": "启用此功能" + } + } +} +``` + +## manifest.json 配置项类型 + +支持的配置项类型: + +| 类型 | 说明 | 示例 | +|------|------|------| +| `text` / `string` | 文本输入框 | API 密钥、URL 等 | +| `number` | 数字输入框 | 重试次数、超时时间等 | +| `boolean` / `checkbox` | 开关 | 启用/禁用功能 | +| `select` | 下拉选择 | 选择模型、语言等 | +| `textarea` | 多行文本 | 提示词、说明文字等 | +| `slider` | 滑块 | 温度、概率等 | + +## 测试步骤 + +1. **重启后端服务**:确保新的代码生效 +2. **访问扩展管理页面**:`/extension` +3. **安装一个测试扩展**: + - 创建一个简单的 `manifest.json` + - 从文件导入或 Git URL 安装 +4. **测试配置功能**: + - 点击扩展面板展开 + - 修改配置项 + - 刷新页面确认配置已保存 +5. **测试启用/禁用**: + - 点击开关切换状态 + - 确认状态已保存 + +## 示例 manifest.json + +```json +{ + "name": "example-extension", + "display_name": "示例扩展", + "version": "1.0.0", + "description": "这是一个示例扩展", + "author": "Your Name", + "type": "ui", + "category": "utilities", + "settings": { + "enabled": { + "type": "boolean", + "label": "启用扩展", + "description": "是否启用此扩展的功能", + "default": true + }, + "apiEndpoint": { + "type": "text", + "label": "API 端点", + "description": "API 服务器地址", + "placeholder": "https://api.example.com" + }, + "timeout": { + "type": "number", + "label": "超时时间(秒)", + "min": 1, + "max": 300, + "step": 1, + "default": 30 + }, + "model": { + "type": "select", + "label": "选择模型", + "options": [ + { "label": "GPT-4", "value": "gpt-4" }, + { "label": "GPT-3.5", "value": "gpt-3.5-turbo" } + ], + "default": "gpt-3.5-turbo" + }, + "prompt": { + "type": "textarea", + "label": "系统提示词", + "rows": 5, + "placeholder": "输入系统提示词..." + }, + "temperature": { + "type": "slider", + "label": "温度", + "min": 0, + "max": 2, + "step": 0.1, + "default": 1.0 + } + } +} +``` + +## 技术细节 + +### 数据库字段 + +扩展配置存储在 `ai_extensions` 表的以下字段: + +- `manifest_data` (JSONB):完整的 `manifest.json` 内容,包含 `settings` 定义 +- `settings` (JSONB):用户的实际配置值 + +### 配置加载顺序 + +1. 从 `ai_extensions.settings` 读取用户配置 +2. 如果为空,从 `ai_extensions.manifest_data.settings` 提取默认值 +3. 合并配置,用户配置优先 + +### 配置保存 + +用户修改配置后,直接更新 `ai_extensions.settings` 字段。 + +## 注意事项 + +1. **配置格式**:`manifest.json` 中的 `settings` 字段是配置项的**定义**(schema),而不是配置值 +2. **默认值**:可以在配置项定义中指定 `default` 字段 +3. **验证**:前端会根据类型自动验证(如 `min`、`max`、`step` 等) +4. **自动保存**:修改配置后会自动保存,无需手动点击保存按钮 + +## 未来改进 + +- [ ] 支持更多配置项类型(颜色选择器、文件选择器等) +- [ ] 配置项分组和标签页 +- [ ] 配置导入/导出 +- [ ] 配置重置到默认值 +- [ ] 配置历史记录 +- [ ] 扩展依赖检查和冲突提示 diff --git a/docs/示例扩展manifest.json b/docs/示例扩展manifest.json new file mode 100644 index 0000000..62b6728 --- /dev/null +++ b/docs/示例扩展manifest.json @@ -0,0 +1,99 @@ +{ + "name": "example-extension", + "display_name": "示例扩展", + "version": "1.0.0", + "description": "这是一个用于测试的示例扩展,展示了各种配置项类型", + "author": "测试作者", + "homepage": "https://github.com/example/extension", + "repository": "https://github.com/example/extension", + "license": "MIT", + "type": "ui", + "category": "utilities", + "tags": ["测试", "示例", "配置"], + "settings": { + "enabled": { + "type": "boolean", + "label": "启用扩展", + "description": "是否启用此扩展的所有功能" + }, + "apiKey": { + "type": "text", + "label": "API 密钥", + "placeholder": "sk-...", + "description": "请输入您的 API 密钥(用于访问外部服务)" + }, + "apiEndpoint": { + "type": "text", + "label": "API 端点", + "placeholder": "https://api.example.com", + "description": "API 服务器地址" + }, + "maxRetries": { + "type": "number", + "label": "最大重试次数", + "min": 1, + "max": 10, + "step": 1, + "description": "请求失败时的最大重试次数" + }, + "timeout": { + "type": "number", + "label": "超时时间(秒)", + "min": 5, + "max": 300, + "step": 5, + "description": "API 请求的超时时间" + }, + "temperature": { + "type": "slider", + "label": "温度", + "min": 0, + "max": 2, + "step": 0.1, + "description": "控制生成内容的随机性(0=确定性,2=高随机性)" + }, + "model": { + "type": "select", + "label": "选择模型", + "options": [ + { "label": "GPT-4", "value": "gpt-4" }, + { "label": "GPT-3.5 Turbo", "value": "gpt-3.5-turbo" }, + { "label": "Claude 3", "value": "claude-3" } + ], + "description": "选择要使用的 AI 模型" + }, + "systemPrompt": { + "type": "textarea", + "label": "系统提示词", + "rows": 4, + "placeholder": "输入系统提示词...", + "description": "用于指导 AI 行为的系统提示词" + }, + "logLevel": { + "type": "select", + "label": "日志级别", + "options": [ + { "label": "调试", "value": "debug" }, + { "label": "信息", "value": "info" }, + { "label": "警告", "value": "warn" }, + { "label": "错误", "value": "error" } + ], + "description": "控制台日志的详细程度" + }, + "autoSave": { + "type": "boolean", + "label": "自动保存", + "description": "自动保存对话历史" + }, + "saveInterval": { + "type": "number", + "label": "保存间隔(秒)", + "min": 10, + "max": 600, + "step": 10, + "description": "自动保存的时间间隔" + } + }, + "dependencies": {}, + "conflicts": [] +} diff --git a/docs/重构进度管理.md b/docs/重构进度管理.md index 5f44baa..8790110 100644 --- a/docs/重构进度管理.md +++ b/docs/重构进度管理.md @@ -12,8 +12,8 @@ | 阶段 | 进度 | 状态 | 开始日期 | 完成日期 | |------|------|------|----------|----------| | 阶段一:数据库设计 | 100% | 🟢 已完成 | 2026-02-10 | 2026-02-10 | -| 阶段二:Go后端API开发 | 40% | 🔵 进行中 | 2026-02-10 | - | -| 阶段三:Vue3前台开发 | 45% | 🔵 进行中 | 2026-02-10 | - | +| 阶段二:Go后端API开发 | 65% | 🔵 进行中 | 2026-02-10 | - | +| 阶段三:Vue3前台开发 | 70% | 🔵 进行中 | 2026-02-10 | - | | 阶段四:前端改造(旧版) | 0% | ⚪ 暂停 | - | - | | 阶段五:数据迁移 | 0% | ⚪ 未开始 | - | - | | 阶段六:测试与优化 | 0% | ⚪ 未开始 | - | - | @@ -243,7 +243,7 @@ ### 2.1 基础设施搭建 -- [ ] **T2.1.1** 安装 Go 依赖包 +- [x] **T2.1.1** 安装 Go 依赖包 - 负责人: - 优先级:P0(必须) - 预计时间:0.5天 @@ -256,14 +256,14 @@ go get github.com/pkoukk/tiktoken-go@latest ``` -- [ ] **T2.1.2** 创建项目目录结构 +- [x] **T2.1.2** 创建项目目录结构 - 负责人: - 优先级:P0(必须) - 预计时间:0.5天 - 状态:⚪ 未开始 - 目录:`api/v1/app/`, `service/app/`, `router/app/`, `pkg/` -- [ ] **T2.1.3** 配置文件扩展 +- [x] **T2.1.3** 配置文件扩展 - 负责人: - 优先级:P0(必须) - 预计时间:0.5天 @@ -757,6 +757,240 @@ - 状态:⚪ 未开始 - 命令:`swag init` +### 2.13 世界书管理 + +- [x] **T2.13.1** 完善 AIWorldInfo 数据模型 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 文件:`server/model/app/ai_world_info.go` + - 功能:支持 SillyTavern 完整规范(递归激活、Token预算、正则表达式等) + +- [x] **T2.13.2** 创建世界书请求/响应结构体 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 文件: + - `server/model/app/request/world_info.go` + - `server/model/app/response/world_info.go` + +- [x] **T2.13.3** 创建世界书 Service 层 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:2天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 文件:`server/service/app/world_info.go` + - 功能: + - CRUD 操作 + - 导入导出(JSON) + - 关键词匹配引擎(支持正则、大小写、整词匹配) + - 递归激活 + - 分组过滤 + - Token 预算管理 + +- [x] **T2.13.4** 创建世界书 API 层 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:1天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 文件:`server/api/v1/app/world_info.go` + - 接口:创建、更新、删除、列表、详情、复制、导入导出、匹配 + +- [x] **T2.13.5** 创建世界书路由 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 文件:`server/router/app/world_info.go` + +- [x] **T2.13.6** 注册世界书路由 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 在 `server/initialize/router.go` 中注册 `/app/worldbook` 路由组 + +### 2.14 正则脚本管理 + +- [x] **T2.14.1** 创建正则脚本数据模型 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-11 + - 文件:`server/model/app/ai_regex_script.go` + - 功能:支持 SillyTavern Regex Scripts 完整规范(查找/替换、深度控制、应用位置等) + +- [x] **T2.14.2** 创建正则脚本请求/响应结构体 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-11 + - 文件: + - `server/model/app/request/regex_script.go` + - `server/model/app/response/regex_script.go` + +- [x] **T2.14.3** 创建正则脚本 Service 层 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:2天 + - 状态:🟢 已完成 + - 完成日期:2026-02-11 + - 文件:`server/service/app/regex_script.go` + - 功能: + - CRUD 操作 + - 导入导出(JSON) + - 测试功能 + - 应用功能(批量处理文本) + - 关联角色管理 + - 复制功能 + +- [x] **T2.14.4** 创建正则脚本 API 层 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:1天 + - 状态:🟢 已完成 + - 完成日期:2026-02-11 + - 文件:`server/api/v1/app/regex_script.go` + - 接口:创建、更新、删除、列表、详情、复制、导入导出、测试、应用 + +- [x] **T2.14.5** 创建正则脚本路由 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-11 + - 文件:`server/router/app/regex_script.go` + +- [x] **T2.14.6** 注册正则脚本路由 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-11 + - 在 `server/initialize/router.go` 中注册 `/app/regex` 路由组 + +- [x] **T2.14.7** 实现角色卡导入时自动导入正则脚本 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:1天 + - 状态:🟢 已完成 + - 完成日期:2026-02-11 + - 功能: + - 导入角色卡时自动解析 Extensions 中的 regex_scripts 数据 + - 将正则脚本转换并保存到 `ai_regex_scripts` 表 + - 自动关联脚本到角色 + - 导出角色卡时自动包含关联的正则脚本数据 + - 完全兼容 SillyTavern CharacterCard V2 规范 + +### 2.15 扩展管理 + +- [x] **T2.15.1** 创建扩展数据模型 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-11 + - 文件:`server/model/app/ai_extension.go` + - 功能:支持 SillyTavern Extension 完整规范(manifest.json、UI/Server/Hybrid 类型、依赖管理等) + +- [x] **T2.15.2** 创建扩展请求/响应结构体 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-11 + - 文件: + - `server/model/app/request/extension.go` + - `server/model/app/response/extension.go` + +- [x] **T2.15.3** 创建扩展 Service 层 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:2天 + - 状态:🟢 已完成 + - 完成日期:2026-02-11 + - 文件:`server/service/app/extension.go` + - 功能: + - CRUD 操作 + - 导入导出(manifest.json) + - 启用/禁用管理 + - 依赖检查和冲突检测 + - 配置管理 + - 统计信息 + +- [x] **T2.15.4** 创建扩展 API 层 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:1天 + - 状态:🟢 已完成 + - 完成日期:2026-02-11 + - 文件:`server/api/v1/app/extension.go` + - 接口:创建、更新、删除、列表、详情、启用/禁用、配置、导入导出、统计 + +- [x] **T2.15.5** 创建扩展路由 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-11 + - 文件:`server/router/app/extension.go` + +- [x] **T2.15.6** 注册扩展路由 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-11 + - 在 `server/initialize/router.go` 中注册 `/app/extension` 路由组 + +- [x] **T2.15.7** 修复扩展 API 路径参数解析错误 + - 负责人:AI助手 + - 优先级:P0(紧急) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-11 + - 问题:点击扩展详情和配置按钮时报错 `扩展不存在`,SQL 查询显示 `id = 0` + - 原因:使用 `c.GetUint("id")` 无法获取 URL 路径参数 + - 解决方案:改用 `c.Param("id")` 获取路径参数并转换为 uint + - 影响范围:`UpdateExtension`、`DeleteExtension`、`GetExtension`、`ToggleExtension`、`UpdateExtensionSettings`、`GetExtensionSettings`、`GetExtensionManifest`、`ExportExtension` + - 文件:`server/api/v1/app/extension.go` + +- [x] **T2.15.8** 重构扩展 UI 为折叠面板式并简化配置存储 + - 负责人:AI助手 + - 优先级:P0(紧急) + - 预计时间:2天 + - 状态:🟢 已完成 + - 完成日期:2026-02-11 + - 问题: + 1. 数据库错误:`ERROR: relation "ai_extension_settings" does not exist` + 2. UI 不符合预期:表格式而非折叠面板式 + - 解决方案: + 1. **简化数据库设计**:取消 `ai_extension_settings` 表,配置直接存储在 `ai_extensions.settings` 字段 + 2. **重新设计 UI**:采用折叠面板式布局,完全仿照 SillyTavern 原版设计 + - 修改文件: + - `server/model/app/ai_extension.go` - 注释 AIExtensionSettings 结构体 + - `server/initialize/gorm.go` - 移除表注册 + - `server/service/app/extension.go` - 简化配置 CRUD 逻辑 + - `web-app-vue/src/views/extension/ExtensionListNew.vue` - 全新折叠面板界面 + - `web-app-vue/src/router/index.ts` - 更新路由 + - 新特性: + - ✅ 折叠面板布局(el-collapse) + - ✅ 配置项根据 manifest.json 动态生成 + - ✅ 支持多种配置类型(text、number、boolean、select、textarea、slider) + - ✅ 自动保存配置 + - ✅ 扩展详情内联展示 + - 文档:`docs/扩展模块修复说明.md` + --- ## 🎯 阶段三:Vue 3 前台开发(预计 15-20 天) @@ -1036,6 +1270,20 @@ - 完成日期:2026-02-10 - 优化:无头像时显示首字母占位符,不再请求不存在的文件 +- [x] **T3.3.18** 实现角色卡导入时自动导入世界书 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:1天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 功能: + - 导入角色卡时自动解析 CharacterBook 数据 + - 将世界书条目转换并保存到 `ai_world_info` 表 + - 自动关联世界书到角色 + - 导出角色卡时自动包含关联的世界书数据 + - 完全兼容 SillyTavern CharacterCard V2 规范 + - 使用数据库事务确保原子性(世界书导入失败时回滚角色卡) + ### 3.4 Vue 对话功能模块(待开发) - [ ] **T3.4.1** 创建对话类型定义 @@ -1109,6 +1357,340 @@ - 状态:⚪ 未开始 - 文件:`src/views/user/Profile.vue` +### 3.6 Vue 世界书模块 + +- [x] **T3.6.1** 创建世界书类型定义 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 文件:`web-app-vue/src/types/worldInfo.d.ts` + +- [x] **T3.6.2** 创建世界书 API 接口 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 文件:`web-app-vue/src/api/worldInfo.ts` + +- [x] **T3.6.3** 创建世界书状态管理 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:1天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 文件:`web-app-vue/src/stores/worldInfo.ts` + +- [x] **T3.6.4** 开发世界书列表页面 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:1.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 文件:`web-app-vue/src/views/worldbook/WorldBookList.vue` + - 功能:列表展示、搜索、筛选、导入导出、复制、删除 + +- [x] **T3.6.5** 开发世界书编辑页面 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:2天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 文件:`web-app-vue/src/views/worldbook/WorldBookEdit.vue` + - 功能:创建、编辑世界书,管理条目列表 + +- [x] **T3.6.6** 开发世界书条目编辑表单组件 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:2天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 文件:`web-app-vue/src/views/worldbook/WorldInfoEntryForm.vue` + - 功能: + - 完整的条目编辑表单 + - 支持所有 SillyTavern 特性(递归、概率、正则、分组等) + - 表单验证和提示 + +- [x] **T3.6.7** 更新路由配置 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 添加世界书相关路由(列表、创建、编辑) + +- [x] **T3.6.8** 更新导航菜单 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-10 + - 在主导航菜单中添加"世界书"入口 + +- [x] **T3.6.9** 修复世界书 API 路径参数解析错误 + - 负责人:AI助手 + - 优先级:P0(紧急) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-11 + - 问题:编辑世界书时报错 `record not found`,SQL 查询显示 `id = 0` + - 原因:使用 `c.GetUint("id")` 无法获取 URL 路径参数 + - 解决方案:改用 `c.Param("id")` 获取路径参数并转换为 uint + - 影响范围:`UpdateWorldBook`、`DeleteWorldBook`、`GetWorldBook`、`ExportWorldBook`、`DuplicateWorldBook`、`GetCharacterWorldBooks` + +### 3.7 Vue 扩展模块 + +- [x] **T3.7.1** 创建扩展类型定义 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-11 + - 文件:`web-app-vue/src/types/extension.d.ts` + +- [x] **T3.7.2** 创建扩展 API 接口 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-11 + - 文件:`web-app-vue/src/api/extension.ts` + +- [x] **T3.7.3** 创建扩展状态管理 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:1天 + - 状态:🟢 已完成 + - 完成日期:2026-02-11 + - 文件:`web-app-vue/src/stores/extension.ts` + +- [x] **T3.7.4** 开发扩展列表页面 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:1.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-11 + - 文件:`web-app-vue/src/views/extension/ExtensionList.vue` + - 功能:列表展示、搜索、筛选、安装、卸载、启用/禁用、导入导出 + +- [x] **T3.7.5** 开发扩展配置页面 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:2天 + - 状态:🟢 已完成 + - 完成日期:2026-02-11 + - 文件:`web-app-vue/src/views/extension/ExtensionSettings.vue` + - 功能: + - 完整的扩展配置管理 + - 支持动态表单生成 + - 依赖和冲突显示 + - 统计信息展示 + +- [x] **T3.7.6** 更新路由配置 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-11 + - 添加扩展相关路由(列表、配置) + +- [x] **T3.7.7** 更新导航菜单 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-11 + - 在主导航菜单中添加"扩展管理"入口 + +### 3.8 Vue 正则脚本模块 + +- [x] **T3.8.1** 创建正则脚本类型定义 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-11 + - 文件:`web-app-vue/src/types/regexScript.d.ts` + +- [x] **T3.8.2** 创建正则脚本 API 接口 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-11 + - 文件:`web-app-vue/src/api/regexScript.ts` + +- [x] **T3.8.3** 创建正则脚本状态管理 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:1天 + - 状态:🟢 已完成 + - 完成日期:2026-02-11 + - 文件:`web-app-vue/src/stores/regexScript.ts` + +- [x] **T3.8.4** 开发正则脚本列表页面 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:1.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-11 + - 文件:`web-app-vue/src/views/regex/RegexScriptList.vue` + - 功能:列表展示、搜索、筛选、导入导出、复制、删除、启用/禁用切换 + +- [x] **T3.8.5** 开发正则脚本编辑页面 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:2天 + - 状态:🟢 已完成 + - 完成日期:2026-02-11 + - 文件:`web-app-vue/src/views/regex/RegexScriptEdit.vue` + - 功能: + - 完整的脚本编辑表单 + - 实时测试功能(显示匹配次数、匹配内容、处理结果) + - 支持所有 SillyTavern 特性(深度控制、应用位置、替换选项等) + - 表单验证和提示 + - 正则表达式在线工具链接 + +- [x] **T3.8.6** 更新路由配置 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-11 + - 添加正则脚本相关路由(列表、创建、编辑) + +- [x] **T3.8.7** 更新导航菜单 + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-11 + - 在主导航菜单中添加"正则脚本"入口 + +### 3.9 扩展运行时与抽屉组件 + +- [x] **T3.9.1** 创建扩展运行时管理器 + - 负责人:AI助手 + - 优先级:P0(紧急) + - 预计时间:1天 + - 状态:🟢 已完成 + - 完成日期:2026-02-11 + - 文件:`web-app-vue/src/utils/extensionRuntime.ts` + - 功能: + - 动态加载扩展的 JavaScript/CSS 文件 + - 提供 SillyTavern 兼容的 API (window.SillyTavern) + - 管理扩展生命周期(load, unload, enable, disable) + - 扩展事件系统 + - 扩展设置管理 + +- [x] **T3.9.2** 创建扩展抽屉组件 + - 负责人:AI助手 + - 优先级:P0(紧急) + - 预计时间:1天 + - 状态:🟢 已完成 + - 完成日期:2026-02-11 + - 文件:`web-app-vue/src/components/ExtensionDrawer.vue` + - 功能: + - 可在任何页面调用的抽屉式扩展面板 + - 折叠面板式扩展列表 + - 实时显示扩展运行状态 + - 内联配置编辑和保存 + - 扩展启用/禁用/重载/卸载 + - 扩展安装(文件上传/URL) + - 特点: + - 不跳转页面,直接在当前页面打开抽屉 + - 可在对话页面、角色页面等任何地方使用 + - 自动加载已启用的扩展 + - 配置修改后实时通知扩展 + +- [x] **T3.9.3** 优化和修复扩展功能 + - 负责人:AI助手 + - 优先级:P0(紧急) + - 预计时间:1天 + - 状态:🟢 已完成 + - 完成日期:2026-02-11 + - 修复和实现内容: + 1. 移除独立的扩展管理页面,改为抽屉按钮 + 2. 修复启用/禁用扩展的验证错误(后端字段类型 + 前端传参) + 3. 实现智能 URL 安装(自动识别 Git 仓库 URL 或 Manifest URL,完全兼容 SillyTavern) + 4. 支持 Git 分支选择(可指定分支或标签) + 5. 记录安装来源(sourceUrl, branch, autoUpdate)用于后续更新 + 6. 增强 SillyTavern API 兼容性(添加更多 API 方法) + 7. 支持内联脚本(inline_script) + - 修改文件: + - `server/model/app/ai_extension.go` - 添加更新相关字段(sourceUrl, branch, autoUpdate 等) + - `server/model/app/request/extension.go` - 修复 IsEnabled + 添加智能安装请求 + 添加更新字段 + - `server/api/v1/app/extension.go` - 智能安装 API(自动识别 URL 类型) + - `server/service/app/extension.go` - 实现智能安装逻辑(isGitURL 判断)+ 记录安装来源 + - `server/router/app/extension.go` - 注册智能安装路由 + - `web-app-vue/src/api/extension.ts` - 支持分支参数 + - `web-app-vue/src/stores/extension.ts` - 支持分支参数 + - `web-app-vue/src/components/ExtensionDrawer.vue` - 添加分支输入框 + UI 优化 + - `web-app-vue/src/layouts/DefaultLayout.vue` - 移除扩展管理菜单项 + - `web-app-vue/src/utils/extensionRuntime.ts` - 增强 API 兼容性 + - 支持的 URL 格式: + - ✅ `https://github.com/user/extension.git` + - ✅ `https://gitlab.com/user/extension` + - ✅ `https://gitee.com/user/extension` + - ✅ `https://example.com/manifest.json` + +- [x] **T3.9.4** 实现扩展运行时配置 UI 注入(兼容原版 SillyTavern) + - 负责人:AI助手 + - 优先级:P0(紧急) + - 预计时间:1天 + - 状态:🟢 已完成 + - 完成日期:2026-02-11 + - 实现内容: + 1. 在 ExtensionDrawer 中为每个扩展添加自定义配置容器(`extension-settings-${name}`) + 2. 在 extensionRuntime 中提供 `extension_settings` 代理对象(兼容原版 API) + 3. 提供 `getExtensionSettingsContainer()` 方法让扩展获取配置容器 + 4. 自动加载/保存扩展设置到 localStorage + 5. 扩展可以通过 `SillyTavern.getContext().extension_settings[名称]` 访问配置 + 6. 扩展可以向配置容器注入自己的 HTML UI + - 修改文件: + - `web-app-vue/src/components/ExtensionDrawer.vue` - 添加自定义配置容器 + - `web-app-vue/src/utils/extensionRuntime.ts` - 实现 extension_settings 和配置容器 API + - 兼容性:完全兼容原版 SillyTavern 扩展配置系统 + +- [x] **T3.9.5** 实现扩展更新功能(git pull 或重新下载) + - 负责人:AI助手 + - 优先级:P1(重要) + - 预计时间:1天 + - 状态:🟢 已完成 + - 完成日期:2026-02-11 + - 实现内容: + 1. 后端 API:`POST /app/extension/:id/update` + 2. 根据 `installSource` 自动选择更新方式 + 3. Git 来源:重新克隆仓库(简单可靠) + 4. URL 来源:重新下载 manifest.json + 5. 前端 UI:每个扩展显示"更新"按钮(仅当有 sourceUrl 时) + 6. 更新后自动重新加载扩展(如果正在运行) + - 修改文件: + - `server/model/app/request/extension.go` - 添加 UpdateExtensionRequest + - `server/service/app/extension.go` - 实现 UpdateExtension 逻辑 + - `server/api/v1/app/extension.go` - 添加 UpdateExtensionAPI + - `server/router/app/extension.go` - 注册更新路由 + - `web-app-vue/src/api/extension.ts` - 添加 updateExtension API + - `web-app-vue/src/stores/extension.ts` - 添加 upgradeExtension 方法 + - `web-app-vue/src/components/ExtensionDrawer.vue` - 添加更新按钮和逻辑 + - `web-app-vue/src/types/extension.d.ts` - 添加更新相关字段 + +- [x] **T3.9.6** 实现批量更新功能 + - 负责人:AI助手 + - 优先级:P2(一般) + - 预计时间:0.5天 + - 状态:🟢 已完成 + - 完成日期:2026-02-11 + - 实现内容: + 1. "全部更新"按钮(批量更新所有可更新的扩展) + 2. 自动过滤出有 sourceUrl 的扩展 + 3. 显示更新进度和结果统计 + 4. 更新后自动刷新扩展列表 + - 修改文件: + - `web-app-vue/src/components/ExtensionDrawer.vue` - 添加全部更新按钮和逻辑 + --- ## 🎯 阶段四:前端改造(旧版 jQuery)(暂停) diff --git a/server/api/v1/app/enter.go b/server/api/v1/app/enter.go index 367b49b..88df16f 100644 --- a/server/api/v1/app/enter.go +++ b/server/api/v1/app/enter.go @@ -6,6 +6,8 @@ type ApiGroup struct { AuthApi CharacterApi WorldInfoApi + ExtensionApi + RegexScriptApi } var ( diff --git a/server/api/v1/app/extension.go b/server/api/v1/app/extension.go new file mode 100644 index 0000000..b23e2a2 --- /dev/null +++ b/server/api/v1/app/extension.go @@ -0,0 +1,565 @@ +package app + +import ( + "fmt" + "io" + "net/http" + "strconv" + + "git.echol.cn/loser/st/server/global" + "git.echol.cn/loser/st/server/middleware" + "git.echol.cn/loser/st/server/model/app/request" + "git.echol.cn/loser/st/server/model/app/response" + sysResponse "git.echol.cn/loser/st/server/model/common/response" + "git.echol.cn/loser/st/server/service" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +type ExtensionApi struct{} + +var extensionService = service.ServiceGroupApp.AppServiceGroup.ExtensionService + +// CreateExtension 创建/安装扩展 +// @Summary 创建/安装扩展 +// @Description 创建一个新的扩展或安装扩展 +// @Tags 扩展管理 +// @Accept json +// @Produce json +// @Param data body request.CreateExtensionRequest true "扩展信息" +// @Success 200 {object} response.Response{data=response.ExtensionResponse} +// @Router /app/extension [post] +func (a *ExtensionApi) CreateExtension(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + var req request.CreateExtensionRequest + if err := c.ShouldBindJSON(&req); err != nil { + sysResponse.FailWithMessage(err.Error(), c) + return + } + + extension, err := extensionService.CreateExtension(userID, &req) + if err != nil { + global.GVA_LOG.Error("创建扩展失败", zap.Error(err)) + sysResponse.FailWithMessage("创建失败: "+err.Error(), c) + return + } + + sysResponse.OkWithData(response.ToExtensionResponse(extension), c) +} + +// UpdateExtension 更新扩展 +// @Summary 更新扩展 +// @Description 更新扩展信息 +// @Tags 扩展管理 +// @Accept json +// @Produce json +// @Param id path int true "扩展ID" +// @Param data body request.UpdateExtensionRequest true "扩展信息" +// @Success 200 {object} response.Response +// @Router /app/extension/:id [put] +func (a *ExtensionApi) UpdateExtension(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + // 从路径参数获取 ID + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + sysResponse.FailWithMessage("无效的扩展ID", c) + return + } + extensionID := uint(id) + + var req request.UpdateExtensionRequest + if err := c.ShouldBindJSON(&req); err != nil { + sysResponse.FailWithMessage(err.Error(), c) + return + } + + if err := extensionService.UpdateExtension(userID, extensionID, &req); err != nil { + global.GVA_LOG.Error("更新扩展失败", zap.Error(err)) + sysResponse.FailWithMessage("更新失败: "+err.Error(), c) + return + } + + sysResponse.OkWithMessage("更新成功", c) +} + +// DeleteExtension 删除/卸载扩展 +// @Summary 删除/卸载扩展 +// @Description 删除扩展 +// @Tags 扩展管理 +// @Accept json +// @Produce json +// @Param id path int true "扩展ID" +// @Param deleteFiles query bool false "是否删除文件" +// @Success 200 {object} response.Response +// @Router /app/extension/:id [delete] +func (a *ExtensionApi) DeleteExtension(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + // 从路径参数获取 ID + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + sysResponse.FailWithMessage("无效的扩展ID", c) + return + } + extensionID := uint(id) + deleteFiles := c.Query("deleteFiles") == "true" + + if err := extensionService.DeleteExtension(userID, extensionID, deleteFiles); err != nil { + global.GVA_LOG.Error("删除扩展失败", zap.Error(err)) + sysResponse.FailWithMessage("删除失败: "+err.Error(), c) + return + } + + sysResponse.OkWithMessage("删除成功", c) +} + +// GetExtension 获取扩展详情 +// @Summary 获取扩展详情 +// @Description 获取扩展详细信息 +// @Tags 扩展管理 +// @Accept json +// @Produce json +// @Param id path int true "扩展ID" +// @Success 200 {object} response.Response{data=response.ExtensionResponse} +// @Router /app/extension/:id [get] +func (a *ExtensionApi) GetExtension(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + // 从路径参数获取 ID + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + sysResponse.FailWithMessage("无效的扩展ID", c) + return + } + extensionID := uint(id) + + extension, err := extensionService.GetExtension(userID, extensionID) + if err != nil { + global.GVA_LOG.Error("获取扩展失败", zap.Error(err)) + sysResponse.FailWithMessage("获取失败: "+err.Error(), c) + return + } + + sysResponse.OkWithData(response.ToExtensionResponse(extension), c) +} + +// GetExtensionList 获取扩展列表 +// @Summary 获取扩展列表 +// @Description 获取扩展列表 +// @Tags 扩展管理 +// @Accept json +// @Produce json +// @Param data query request.ExtensionListRequest true "查询参数" +// @Success 200 {object} response.Response{data=response.ExtensionListResponse} +// @Router /app/extension/list [get] +func (a *ExtensionApi) GetExtensionList(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + var req request.ExtensionListRequest + if err := c.ShouldBindQuery(&req); err != nil { + sysResponse.FailWithMessage(err.Error(), c) + return + } + + // 默认分页 + if req.Page < 1 { + req.Page = 1 + } + if req.PageSize < 1 { + req.PageSize = 20 + } + + result, err := extensionService.GetExtensionList(userID, &req) + if err != nil { + global.GVA_LOG.Error("获取扩展列表失败", zap.Error(err)) + sysResponse.FailWithMessage("获取失败: "+err.Error(), c) + return + } + + sysResponse.OkWithData(result, c) +} + +// ToggleExtension 启用/禁用扩展 +// @Summary 启用/禁用扩展 +// @Description 切换扩展的启用状态 +// @Tags 扩展管理 +// @Accept json +// @Produce json +// @Param id path int true "扩展ID" +// @Param data body request.ToggleExtensionRequest true "启用状态" +// @Success 200 {object} response.Response +// @Router /app/extension/:id/toggle [post] +func (a *ExtensionApi) ToggleExtension(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + // 从路径参数获取 ID + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + sysResponse.FailWithMessage("无效的扩展ID", c) + return + } + extensionID := uint(id) + + var req request.ToggleExtensionRequest + if err := c.ShouldBindJSON(&req); err != nil { + sysResponse.FailWithMessage("请求参数错误", c) + return + } + + if req.IsEnabled == nil { + sysResponse.FailWithMessage("isEnabled 参数不能为空", c) + return + } + + if err := extensionService.ToggleExtension(userID, extensionID, *req.IsEnabled); err != nil { + global.GVA_LOG.Error("切换扩展状态失败", zap.Error(err)) + sysResponse.FailWithMessage("操作失败: "+err.Error(), c) + return + } + + sysResponse.OkWithMessage("操作成功", c) +} + +// UpdateExtensionSettings 更新扩展配置 +// @Summary 更新扩展配置 +// @Description 更新扩展的用户配置 +// @Tags 扩展管理 +// @Accept json +// @Produce json +// @Param id path int true "扩展ID" +// @Param data body request.UpdateExtensionSettingsRequest true "配置信息" +// @Success 200 {object} response.Response +// @Router /app/extension/:id/settings [put] +func (a *ExtensionApi) UpdateExtensionSettings(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + // 从路径参数获取 ID + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + sysResponse.FailWithMessage("无效的扩展ID", c) + return + } + extensionID := uint(id) + + var req request.UpdateExtensionSettingsRequest + if err := c.ShouldBindJSON(&req); err != nil { + sysResponse.FailWithMessage(err.Error(), c) + return + } + + if err := extensionService.UpdateExtensionSettings(userID, extensionID, req.Settings); err != nil { + global.GVA_LOG.Error("更新扩展配置失败", zap.Error(err)) + sysResponse.FailWithMessage("更新失败: "+err.Error(), c) + return + } + + sysResponse.OkWithMessage("更新成功", c) +} + +// GetExtensionSettings 获取扩展配置 +// @Summary 获取扩展配置 +// @Description 获取扩展的用户配置 +// @Tags 扩展管理 +// @Accept json +// @Produce json +// @Param id path int true "扩展ID" +// @Success 200 {object} response.Response{data=map[string]interface{}} +// @Router /app/extension/:id/settings [get] +func (a *ExtensionApi) GetExtensionSettings(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + // 从路径参数获取 ID + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + sysResponse.FailWithMessage("无效的扩展ID", c) + return + } + extensionID := uint(id) + + settings, err := extensionService.GetExtensionSettings(userID, extensionID) + if err != nil { + global.GVA_LOG.Error("获取扩展配置失败", zap.Error(err)) + sysResponse.FailWithMessage("获取失败: "+err.Error(), c) + return + } + + sysResponse.OkWithData(settings, c) +} + +// GetExtensionManifest 获取扩展 manifest +// @Summary 获取扩展 manifest +// @Description 获取扩展的 manifest.json +// @Tags 扩展管理 +// @Accept json +// @Produce json +// @Param id path int true "扩展ID" +// @Success 200 {object} response.Response{data=response.ExtensionManifestResponse} +// @Router /app/extension/:id/manifest [get] +func (a *ExtensionApi) GetExtensionManifest(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + // 从路径参数获取 ID + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + sysResponse.FailWithMessage("无效的扩展ID", c) + return + } + extensionID := uint(id) + + manifest, err := extensionService.GetExtensionManifest(userID, extensionID) + if err != nil { + global.GVA_LOG.Error("获取扩展 manifest 失败", zap.Error(err)) + sysResponse.FailWithMessage("获取失败: "+err.Error(), c) + return + } + + sysResponse.OkWithData(manifest, c) +} + +// ImportExtension 导入扩展 +// @Summary 导入扩展 +// @Description 从文件导入扩展 +// @Tags 扩展管理 +// @Accept multipart/form-data +// @Produce json +// @Param file formData file true "扩展文件(manifest.json)" +// @Success 200 {object} response.Response{data=response.ExtensionResponse} +// @Router /app/extension/import [post] +func (a *ExtensionApi) ImportExtension(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + // 获取文件 + file, err := c.FormFile("file") + if err != nil { + sysResponse.FailWithMessage("请上传扩展文件", c) + return + } + + // 文件大小限制(5MB) + if file.Size > 5<<20 { + sysResponse.FailWithMessage("文件大小不能超过 5MB", c) + return + } + + // 读取文件内容 + src, err := file.Open() + if err != nil { + global.GVA_LOG.Error("打开文件失败", zap.Error(err)) + sysResponse.FailWithMessage("文件读取失败", c) + return + } + defer src.Close() + + fileData, err := io.ReadAll(src) + if err != nil { + global.GVA_LOG.Error("读取文件内容失败", zap.Error(err)) + sysResponse.FailWithMessage("文件读取失败", c) + return + } + + // 导入扩展 + extension, err := extensionService.ImportExtension(userID, fileData) + if err != nil { + global.GVA_LOG.Error("导入扩展失败", zap.Error(err)) + sysResponse.FailWithMessage("导入失败: "+err.Error(), c) + return + } + + sysResponse.OkWithData(response.ToExtensionResponse(extension), c) +} + +// ExportExtension 导出扩展 +// @Summary 导出扩展 +// @Description 导出扩展为 manifest.json 文件 +// @Tags 扩展管理 +// @Accept json +// @Produce application/json +// @Param id path int true "扩展ID" +// @Success 200 {object} response.ExtensionManifestResponse +// @Router /app/extension/:id/export [get] +func (a *ExtensionApi) ExportExtension(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + // 从路径参数获取 ID + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + sysResponse.FailWithMessage("无效的扩展ID", c) + return + } + extensionID := uint(id) + + exportData, err := extensionService.ExportExtension(userID, extensionID) + if err != nil { + global.GVA_LOG.Error("导出扩展失败", zap.Error(err)) + sysResponse.FailWithMessage("导出失败: "+err.Error(), c) + return + } + + // 设置响应头 + filename := fmt.Sprintf("extension_%d_manifest.json", extensionID) + c.Header("Content-Type", "application/json") + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) + + // 直接返回 JSON 数据 + c.Data(http.StatusOK, "application/json", exportData) +} + +// UpdateExtensionStats 更新扩展统计 +// @Summary 更新扩展统计 +// @Description 更新扩展的使用统计 +// @Tags 扩展管理 +// @Accept json +// @Produce json +// @Param data body request.ExtensionStatsRequest true "统计信息" +// @Success 200 {object} response.Response +// @Router /app/extension/stats [post] +func (a *ExtensionApi) UpdateExtensionStats(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + var req request.ExtensionStatsRequest + if err := c.ShouldBindJSON(&req); err != nil { + sysResponse.FailWithMessage(err.Error(), c) + return + } + + if err := extensionService.UpdateExtensionStats(userID, req.ExtensionID, req.Action, req.Value); err != nil { + global.GVA_LOG.Error("更新扩展统计失败", zap.Error(err)) + sysResponse.FailWithMessage("更新失败: "+err.Error(), c) + return + } + + sysResponse.OkWithMessage("更新成功", c) +} + +// GetEnabledExtensions 获取启用的扩展列表 +// @Summary 获取启用的扩展列表 +// @Description 获取用户启用的所有扩展(用于前端加载) +// @Tags 扩展管理 +// @Accept json +// @Produce json +// @Success 200 {object} response.Response{data=[]response.ExtensionResponse} +// @Router /app/extension/enabled [get] +func (a *ExtensionApi) GetEnabledExtensions(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + extensions, err := extensionService.GetEnabledExtensions(userID) + if err != nil { + global.GVA_LOG.Error("获取启用扩展列表失败", zap.Error(err)) + sysResponse.FailWithMessage("获取失败: "+err.Error(), c) + return + } + + sysResponse.OkWithData(extensions, c) +} + +// InstallExtensionFromURL 智能安装扩展(自动识别 Git URL 或 Manifest URL) +// @Summary 智能安装扩展 +// @Description 自动识别 Git 仓库 URL 或 Manifest.json URL 并安装扩展(兼容 SillyTavern) +// @Tags 扩展管理 +// @Accept json +// @Produce json +// @Param data body request.InstallExtensionFromURLRequest true "安装 URL 信息" +// @Success 200 {object} response.Response{data=response.ExtensionResponse} +// @Router /app/extension/install/url [post] +func (a *ExtensionApi) InstallExtensionFromURL(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + var req request.InstallExtensionFromURLRequest + if err := c.ShouldBindJSON(&req); err != nil { + sysResponse.FailWithMessage("请求参数错误: "+err.Error(), c) + return + } + + // 设置默认分支 + if req.Branch == "" { + req.Branch = "main" + } + + extension, err := extensionService.InstallExtensionFromURL(userID, req.URL, req.Branch) + if err != nil { + global.GVA_LOG.Error("从 URL 安装扩展失败", zap.Error(err), zap.String("url", req.URL)) + sysResponse.FailWithMessage("安装失败: "+err.Error(), c) + return + } + + sysResponse.OkWithData(response.ToExtensionResponse(extension), c) +} + +// UpgradeExtension 升级扩展版本 +// @Summary 升级扩展版本 +// @Description 根据扩展的安装来源自动选择更新方式(Git pull 或重新下载) +// @Tags 扩展管理 +// @Accept json +// @Produce json +// @Param id path int true "扩展ID" +// @Param data body request.UpdateExtensionRequest false "更新选项" +// @Success 200 {object} response.Response{data=response.ExtensionResponse} +// @Router /app/extension/:id/update [post] +func (a *ExtensionApi) UpgradeExtension(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + // 从路径参数获取 ID + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + sysResponse.FailWithMessage("无效的扩展ID", c) + return + } + extensionID := uint(id) + + var req request.UpdateExtensionRequest + // 允许不传 body(使用默认值) + _ = c.ShouldBindJSON(&req) + + extension, err := extensionService.UpgradeExtension(userID, extensionID, req.Force) + if err != nil { + global.GVA_LOG.Error("升级扩展失败", zap.Error(err), zap.Uint("extensionID", extensionID)) + sysResponse.FailWithMessage("升级失败: "+err.Error(), c) + return + } + + sysResponse.OkWithData(response.ToExtensionResponse(extension), c) +} + +// InstallExtensionFromGit 从 Git URL 安装扩展 +// @Summary 从 Git URL 安装扩展 +// @Description 从 Git 仓库 URL 克隆并安装扩展 +// @Tags 扩展管理 +// @Accept json +// @Produce json +// @Param data body request.InstallExtensionFromGitRequest true "Git URL 信息" +// @Success 200 {object} response.Response{data=response.ExtensionResponse} +// @Router /app/extension/install/git [post] +func (a *ExtensionApi) InstallExtensionFromGit(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + var req request.InstallExtensionFromGitRequest + if err := c.ShouldBindJSON(&req); err != nil { + sysResponse.FailWithMessage(err.Error(), c) + return + } + + // 设置默认分支 + if req.Branch == "" { + req.Branch = "main" + } + + extension, err := extensionService.InstallExtensionFromGit(userID, req.GitUrl, req.Branch) + if err != nil { + global.GVA_LOG.Error("从 Git 安装扩展失败", zap.Error(err)) + sysResponse.FailWithMessage("安装失败: "+err.Error(), c) + return + } + + sysResponse.OkWithData(response.ToExtensionResponse(extension), c) +} diff --git a/server/api/v1/app/regex_script.go b/server/api/v1/app/regex_script.go new file mode 100644 index 0000000..3002a9c --- /dev/null +++ b/server/api/v1/app/regex_script.go @@ -0,0 +1,479 @@ +package app + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + + "git.echol.cn/loser/st/server/global" + "git.echol.cn/loser/st/server/middleware" + "git.echol.cn/loser/st/server/model/app" + "git.echol.cn/loser/st/server/model/app/request" + "git.echol.cn/loser/st/server/model/app/response" + sysResponse "git.echol.cn/loser/st/server/model/common/response" + "git.echol.cn/loser/st/server/service" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +type RegexScriptApi struct{} + +var regexScriptService = service.ServiceGroupApp.AppServiceGroup.RegexScriptService + +// CreateRegexScript 创建正则脚本 +// @Summary 创建正则脚本 +// @Description 创建一个新的正则表达式脚本 +// @Tags 正则脚本管理 +// @Accept json +// @Produce json +// @Param data body request.CreateRegexScriptRequest true "脚本信息" +// @Success 200 {object} response.Response{data=app.AIRegexScript} +// @Router /app/regex [post] +func (a *RegexScriptApi) CreateRegexScript(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + var req request.CreateRegexScriptRequest + if err := c.ShouldBindJSON(&req); err != nil { + sysResponse.FailWithMessage(err.Error(), c) + return + } + + script, err := regexScriptService.CreateRegexScript(userID, &req) + if err != nil { + global.GVA_LOG.Error("创建正则脚本失败", zap.Error(err)) + sysResponse.FailWithMessage("创建失败: "+err.Error(), c) + return + } + + sysResponse.OkWithData(response.ToRegexScriptResponse(script), c) +} + +// UpdateRegexScript 更新正则脚本 +// @Summary 更新正则脚本 +// @Description 更新正则脚本信息 +// @Tags 正则脚本管理 +// @Accept json +// @Produce json +// @Param id path int true "脚本ID" +// @Param data body request.UpdateRegexScriptRequest true "脚本信息" +// @Success 200 {object} response.Response +// @Router /app/regex/:id [put] +func (a *RegexScriptApi) UpdateRegexScript(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + // 从路径参数获取 ID + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + sysResponse.FailWithMessage("无效的脚本ID", c) + return + } + scriptID := uint(id) + + var req request.UpdateRegexScriptRequest + if err := c.ShouldBindJSON(&req); err != nil { + sysResponse.FailWithMessage(err.Error(), c) + return + } + + if err := regexScriptService.UpdateRegexScript(userID, scriptID, &req); err != nil { + global.GVA_LOG.Error("更新正则脚本失败", zap.Error(err)) + sysResponse.FailWithMessage("更新失败: "+err.Error(), c) + return + } + + sysResponse.OkWithMessage("更新成功", c) +} + +// DeleteRegexScript 删除正则脚本 +// @Summary 删除正则脚本 +// @Description 删除正则脚本 +// @Tags 正则脚本管理 +// @Accept json +// @Produce json +// @Param id path int true "脚本ID" +// @Success 200 {object} response.Response +// @Router /app/regex/:id [delete] +func (a *RegexScriptApi) DeleteRegexScript(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + // 从路径参数获取 ID + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + sysResponse.FailWithMessage("无效的脚本ID", c) + return + } + scriptID := uint(id) + + if err := regexScriptService.DeleteRegexScript(userID, scriptID); err != nil { + global.GVA_LOG.Error("删除正则脚本失败", zap.Error(err)) + sysResponse.FailWithMessage("删除失败: "+err.Error(), c) + return + } + + sysResponse.OkWithMessage("删除成功", c) +} + +// GetRegexScript 获取正则脚本详情 +// @Summary 获取正则脚本详情 +// @Description 获取正则脚本详细信息 +// @Tags 正则脚本管理 +// @Accept json +// @Produce json +// @Param id path int true "脚本ID" +// @Success 200 {object} response.Response{data=response.RegexScriptResponse} +// @Router /app/regex/:id [get] +func (a *RegexScriptApi) GetRegexScript(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + // 从路径参数获取 ID + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + sysResponse.FailWithMessage("无效的脚本ID", c) + return + } + scriptID := uint(id) + + script, err := regexScriptService.GetRegexScript(userID, scriptID) + if err != nil { + global.GVA_LOG.Error("获取正则脚本失败", zap.Error(err)) + sysResponse.FailWithMessage("获取失败: "+err.Error(), c) + return + } + + sysResponse.OkWithData(response.ToRegexScriptResponse(script), c) +} + +// GetRegexScriptList 获取正则脚本列表 +// @Summary 获取正则脚本列表 +// @Description 获取正则脚本列表 +// @Tags 正则脚本管理 +// @Accept json +// @Produce json +// @Param scriptName query string false "脚本名称" +// @Param isGlobal query boolean false "是否全局" +// @Param enabled query boolean false "是否启用" +// @Param characterId query int false "关联角色ID" +// @Param page query int false "页码" +// @Param pageSize query int false "每页大小" +// @Success 200 {object} response.Response{data=response.RegexScriptListResponse} +// @Router /app/regex [get] +func (a *RegexScriptApi) GetRegexScriptList(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + var req request.RegexScriptListRequest + if err := c.ShouldBindQuery(&req); err != nil { + sysResponse.FailWithMessage(err.Error(), c) + return + } + + // 设置默认值 + if req.Page <= 0 { + req.Page = 1 + } + if req.PageSize <= 0 { + req.PageSize = 20 + } + + scripts, total, err := regexScriptService.GetRegexScriptList(userID, &req) + if err != nil { + global.GVA_LOG.Error("获取正则脚本列表失败", zap.Error(err)) + sysResponse.FailWithMessage("获取失败: "+err.Error(), c) + return + } + + // 转换为响应格式 + responses := make([]response.RegexScriptResponse, len(scripts)) + for i, script := range scripts { + responses[i] = response.ToRegexScriptResponse(&script) + } + + sysResponse.OkWithData(response.RegexScriptListResponse{ + List: responses, + Total: total, + Page: req.Page, + PageSize: req.PageSize, + }, c) +} + +// LinkCharactersToRegex 关联角色到正则脚本 +// @Summary 关联角色到正则脚本 +// @Description 将角色关联到指定的正则脚本 +// @Tags 正则脚本管理 +// @Accept json +// @Produce json +// @Param id path int true "脚本ID" +// @Param data body request.LinkCharacterToRegexRequest true "角色ID列表" +// @Success 200 {object} response.Response +// @Router /app/regex/:id/link [post] +func (a *RegexScriptApi) LinkCharactersToRegex(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + // 从路径参数获取 ID + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + sysResponse.FailWithMessage("无效的脚本ID", c) + return + } + scriptID := uint(id) + + var req request.LinkCharacterToRegexRequest + if err := c.ShouldBindJSON(&req); err != nil { + sysResponse.FailWithMessage(err.Error(), c) + return + } + + if err := regexScriptService.LinkCharactersToRegex(userID, scriptID, req.CharacterIDs); err != nil { + global.GVA_LOG.Error("关联角色失败", zap.Error(err)) + sysResponse.FailWithMessage("关联失败: "+err.Error(), c) + return + } + + sysResponse.OkWithMessage("关联成功", c) +} + +// GetCharacterRegexScripts 获取角色关联的正则脚本 +// @Summary 获取角色关联的正则脚本 +// @Description 获取特定角色关联的所有正则脚本 +// @Tags 正则脚本管理 +// @Accept json +// @Produce json +// @Param characterId path int true "角色ID" +// @Success 200 {object} response.Response{data=[]response.RegexScriptResponse} +// @Router /app/regex/character/:characterId [get] +func (a *RegexScriptApi) GetCharacterRegexScripts(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + // 从路径参数获取 ID + idStr := c.Param("characterId") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + sysResponse.FailWithMessage("无效的角色ID", c) + return + } + characterID := uint(id) + + scripts, err := regexScriptService.GetCharacterRegexScripts(userID, characterID) + if err != nil { + global.GVA_LOG.Error("获取角色正则脚本失败", zap.Error(err)) + sysResponse.FailWithMessage("获取失败: "+err.Error(), c) + return + } + + // 转换为响应格式 + responses := make([]response.RegexScriptResponse, len(scripts)) + for i, script := range scripts { + responses[i] = response.ToRegexScriptResponse(&script) + } + + sysResponse.OkWithData(responses, c) +} + +// DuplicateRegexScript 复制正则脚本 +// @Summary 复制正则脚本 +// @Description 创建正则脚本的副本 +// @Tags 正则脚本管理 +// @Accept json +// @Produce json +// @Param id path int true "脚本ID" +// @Success 200 {object} response.Response{data=app.AIRegexScript} +// @Router /app/regex/:id/duplicate [post] +func (a *RegexScriptApi) DuplicateRegexScript(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + // 从路径参数获取 ID + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + sysResponse.FailWithMessage("无效的脚本ID", c) + return + } + scriptID := uint(id) + + newScript, err := regexScriptService.DuplicateRegexScript(userID, scriptID) + if err != nil { + global.GVA_LOG.Error("复制正则脚本失败", zap.Error(err)) + sysResponse.FailWithMessage("复制失败: "+err.Error(), c) + return + } + + sysResponse.OkWithData(response.ToRegexScriptResponse(newScript), c) +} + +// TestRegexScript 测试正则脚本 +// @Summary 测试正则脚本 +// @Description 测试正则表达式的匹配和替换效果 +// @Tags 正则脚本管理 +// @Accept json +// @Produce json +// @Param data body request.TestRegexScriptRequest true "测试数据" +// @Success 200 {object} response.Response{data=response.TestRegexScriptResponse} +// @Router /app/regex/test [post] +func (a *RegexScriptApi) TestRegexScript(c *gin.Context) { + var req request.TestRegexScriptRequest + if err := c.ShouldBindJSON(&req); err != nil { + sysResponse.FailWithMessage(err.Error(), c) + return + } + + result, err := regexScriptService.TestRegexScript(&req) + if err != nil { + global.GVA_LOG.Error("测试正则脚本失败", zap.Error(err)) + sysResponse.FailWithMessage("测试失败: "+err.Error(), c) + return + } + + sysResponse.OkWithData(result, c) +} + +// ApplyRegexScripts 应用正则脚本 +// @Summary 应用正则脚本 +// @Description 对文本应用正则脚本进行处理 +// @Tags 正则脚本管理 +// @Accept json +// @Produce json +// @Param data body request.ApplyRegexScriptsRequest true "应用参数" +// @Success 200 {object} response.Response{data=response.ApplyRegexScriptsResponse} +// @Router /app/regex/apply [post] +func (a *RegexScriptApi) ApplyRegexScripts(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + var req request.ApplyRegexScriptsRequest + if err := c.ShouldBindJSON(&req); err != nil { + sysResponse.FailWithMessage(err.Error(), c) + return + } + + result, err := regexScriptService.ApplyRegexScripts(userID, &req) + if err != nil { + global.GVA_LOG.Error("应用正则脚本失败", zap.Error(err)) + sysResponse.FailWithMessage("应用失败: "+err.Error(), c) + return + } + + sysResponse.OkWithData(result, c) +} + +// ImportRegexScripts 导入正则脚本 +// @Summary 导入正则脚本 +// @Description 从 JSON 文件导入正则脚本 +// @Tags 正则脚本管理 +// @Accept multipart/form-data +// @Produce json +// @Param file formData file true "JSON 文件" +// @Param overwriteMode formData string false "覆盖模式: skip, overwrite, merge" +// @Success 200 {object} response.Response +// @Router /app/regex/import [post] +func (a *RegexScriptApi) ImportRegexScripts(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + // 获取文件 + file, err := c.FormFile("file") + if err != nil { + sysResponse.FailWithMessage("获取文件失败: "+err.Error(), c) + return + } + + // 获取覆盖模式 + overwriteMode := c.DefaultPostForm("overwriteMode", "skip") + + // 读取文件内容 + openedFile, err := file.Open() + if err != nil { + sysResponse.FailWithMessage("打开文件失败: "+err.Error(), c) + return + } + defer openedFile.Close() + + content, err := io.ReadAll(openedFile) + if err != nil { + sysResponse.FailWithMessage("读取文件失败: "+err.Error(), c) + return + } + + // 解析 JSON + var exportData response.RegexScriptExportData + if err := json.Unmarshal(content, &exportData); err != nil { + sysResponse.FailWithMessage("解析JSON失败: "+err.Error(), c) + return + } + + // 转换为 AIRegexScript + scripts := make([]app.AIRegexScript, len(exportData.Scripts)) + for i, resp := range exportData.Scripts { + scripts[i] = app.AIRegexScript{ + ScriptName: resp.ScriptName, + Description: resp.Description, + FindRegex: resp.FindRegex, + ReplaceString: resp.ReplaceString, + Enabled: resp.Enabled, + IsGlobal: resp.IsGlobal, + TrimStrings: resp.TrimStrings, + OnlyFormat: resp.OnlyFormat, + RunOnEdit: resp.RunOnEdit, + SubstituteRegex: resp.SubstituteRegex, + MinDepth: resp.MinDepth, + MaxDepth: resp.MaxDepth, + Placement: resp.Placement, + AffectMinDepth: resp.AffectMinDepth, + AffectMaxDepth: resp.AffectMaxDepth, + LinkedChars: resp.LinkedChars, + } + } + + // 导入 + imported, err := regexScriptService.ImportRegexScripts(userID, scripts, overwriteMode) + if err != nil { + global.GVA_LOG.Error("导入正则脚本失败", zap.Error(err)) + sysResponse.FailWithMessage("导入失败: "+err.Error(), c) + return + } + + sysResponse.OkWithMessage(fmt.Sprintf("成功导入 %d 个脚本", imported), c) +} + +// ExportRegexScripts 导出正则脚本 +// @Summary 导出正则脚本 +// @Description 导出正则脚本为 JSON 文件 +// @Tags 正则脚本管理 +// @Accept json +// @Produce application/json +// @Param scriptIds query string false "脚本ID列表(逗号分隔)" +// @Success 200 {object} response.RegexScriptExportData +// @Router /app/regex/export [get] +func (a *RegexScriptApi) ExportRegexScripts(c *gin.Context) { + userID := middleware.GetAppUserID(c) + + // 获取脚本ID列表 + scriptIDsStr := c.Query("scriptIds") + var scriptIDs []uint + if scriptIDsStr != "" { + var ids []uint + for _, idStr := range strings.Split(scriptIDsStr, ",") { + id, err := strconv.ParseUint(idStr, 10, 32) + if err == nil { + ids = append(ids, uint(id)) + } + } + scriptIDs = ids + } + + exportData, err := regexScriptService.ExportRegexScripts(userID, scriptIDs) + if err != nil { + global.GVA_LOG.Error("导出正则脚本失败", zap.Error(err)) + sysResponse.FailWithMessage("导出失败: "+err.Error(), c) + return + } + + // 设置下载响应头 + c.Header("Content-Type", "application/json") + c.Header("Content-Disposition", "attachment; filename=regex_scripts_export.json") + + c.JSON(http.StatusOK, exportData) +} diff --git a/server/api/v1/app/world_info.go b/server/api/v1/app/world_info.go index 79c98d6..13e72ee 100644 --- a/server/api/v1/app/world_info.go +++ b/server/api/v1/app/world_info.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/http" + "strconv" "git.echol.cn/loser/st/server/global" "git.echol.cn/loser/st/server/middleware" @@ -61,7 +62,15 @@ func (a *WorldInfoApi) CreateWorldBook(c *gin.Context) { // @Router /app/worldbook/:id [put] func (a *WorldInfoApi) UpdateWorldBook(c *gin.Context) { userID := middleware.GetAppUserID(c) - bookID := c.GetUint("id") + + // 从路径参数获取 ID + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + sysResponse.FailWithMessage("无效的世界书ID", c) + return + } + bookID := uint(id) var req request.UpdateWorldBookRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -89,7 +98,15 @@ func (a *WorldInfoApi) UpdateWorldBook(c *gin.Context) { // @Router /app/worldbook/:id [delete] func (a *WorldInfoApi) DeleteWorldBook(c *gin.Context) { userID := middleware.GetAppUserID(c) - bookID := c.GetUint("id") + + // 从路径参数获取 ID + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + sysResponse.FailWithMessage("无效的世界书ID", c) + return + } + bookID := uint(id) if err := worldInfoService.DeleteWorldBook(userID, bookID); err != nil { global.GVA_LOG.Error("删除世界书失败", zap.Error(err)) @@ -111,7 +128,15 @@ func (a *WorldInfoApi) DeleteWorldBook(c *gin.Context) { // @Router /app/worldbook/:id [get] func (a *WorldInfoApi) GetWorldBook(c *gin.Context) { userID := middleware.GetAppUserID(c) - bookID := c.GetUint("id") + + // 从路径参数获取 ID + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + sysResponse.FailWithMessage("无效的世界书ID", c) + return + } + bookID := uint(id) book, err := worldInfoService.GetWorldBook(userID, bookID) if err != nil { @@ -334,7 +359,15 @@ func (a *WorldInfoApi) ImportWorldBook(c *gin.Context) { // @Router /app/worldbook/:id/export [get] func (a *WorldInfoApi) ExportWorldBook(c *gin.Context) { userID := middleware.GetAppUserID(c) - bookID := c.GetUint("id") + + // 从路径参数获取 ID + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + sysResponse.FailWithMessage("无效的世界书ID", c) + return + } + bookID := uint(id) exportData, err := worldInfoService.ExportWorldBook(userID, bookID) if err != nil { @@ -399,10 +432,18 @@ func (a *WorldInfoApi) MatchWorldInfo(c *gin.Context) { // @Router /app/worldbook/character/:characterId [get] func (a *WorldInfoApi) GetCharacterWorldBooks(c *gin.Context) { userID := middleware.GetAppUserID(c) - characterID := c.GetUint("characterId") + + // 从路径参数获取 ID + idStr := c.Param("characterId") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + sysResponse.FailWithMessage("无效的角色ID", c) + return + } + characterID := uint(id) var books []app.AIWorldInfo - err := global.GVA_DB. + err = global.GVA_DB. Where("user_id = ? AND (is_global = true OR ? = ANY(linked_chars))", userID, fmt.Sprintf("%d", characterID)). Find(&books).Error @@ -431,7 +472,15 @@ func (a *WorldInfoApi) GetCharacterWorldBooks(c *gin.Context) { // @Router /app/worldbook/:id/duplicate [post] func (a *WorldInfoApi) DuplicateWorldBook(c *gin.Context) { userID := middleware.GetAppUserID(c) - bookID := c.GetUint("id") + + // 从路径参数获取 ID + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + sysResponse.FailWithMessage("无效的世界书ID", c) + return + } + bookID := uint(id) // 获取原世界书 book, err := worldInfoService.GetWorldBook(userID, bookID) diff --git a/server/config.yaml b/server/config.yaml index 5dd81fe..3855218 100644 --- a/server/config.yaml +++ b/server/config.yaml @@ -182,10 +182,10 @@ pgsql: prefix: "" port: "5432" config: sslmode=disable TimeZone=Asia/Shanghai - db-name: st_dev - username: loser - password: loser765911. - path: pg.echol.top + db-name: st + username: postgres + password: e5zse3Adrja7PNfA + path: 219.152.55.29 engine: "" log-mode: error max-idle-conns: 10 diff --git a/server/initialize/fix_world_info_table.sql b/server/initialize/fix_world_info_table.sql new file mode 100644 index 0000000..5fd8bf3 --- /dev/null +++ b/server/initialize/fix_world_info_table.sql @@ -0,0 +1,9 @@ +-- 修复 ai_world_info 表结构 +-- 如果表存在旧的 name 字段,需要删除并重新创建 + +-- 删除旧表(如果存在) +DROP TABLE IF EXISTS ai_character_world_info CASCADE; +DROP TABLE IF EXISTS ai_world_info CASCADE; + +-- 表将由 Gorm AutoMigrate 自动创建 +-- 重启服务器即可 diff --git a/server/initialize/gorm.go b/server/initialize/gorm.go index 85a4900..675927f 100644 --- a/server/initialize/gorm.go +++ b/server/initialize/gorm.go @@ -94,6 +94,9 @@ func RegisterTables() { app.AIPreset{}, app.AIWorldInfo{}, app.AIUsageStat{}, + app.AIExtension{}, + app.AIRegexScript{}, + app.AICharacterRegexScript{}, ) if err != nil { global.GVA_LOG.Error("register table failed", zap.Error(err)) diff --git a/server/initialize/router.go b/server/initialize/router.go index ab6e614..46513a2 100644 --- a/server/initialize/router.go +++ b/server/initialize/router.go @@ -143,10 +143,12 @@ func Routers() *gin.Engine { // 前台应用路由(新增) { - appGroup := PublicGroup.Group("app") // 统一使用 /app 前缀 - appRouter.InitAuthRouter(appGroup) // 认证路由:/app/auth/* 和 /app/user/* - appRouter.InitCharacterRouter(appGroup) // 角色卡路由:/app/character/* - appRouter.InitWorldInfoRouter(appGroup) // 世界书路由:/app/worldbook/* + appGroup := PublicGroup.Group("app") // 统一使用 /app 前缀 + appRouter.InitAuthRouter(appGroup) // 认证路由:/app/auth/* 和 /app/user/* + appRouter.InitCharacterRouter(appGroup) // 角色卡路由:/app/character/* + appRouter.InitWorldInfoRouter(appGroup) // 世界书路由:/app/worldbook/* + appRouter.InitExtensionRouter(appGroup) // 扩展路由:/app/extension/* + appRouter.InitRegexScriptRouter(appGroup) // 正则脚本路由:/app/regex/* } //插件路由安装 diff --git a/server/model/app/ai_extension.go b/server/model/app/ai_extension.go new file mode 100644 index 0000000..f3ba0d8 --- /dev/null +++ b/server/model/app/ai_extension.go @@ -0,0 +1,108 @@ +package app + +import ( + "git.echol.cn/loser/st/server/global" + "gorm.io/datatypes" + "time" +) + +// AIExtension 扩展表 (兼容 SillyTavern Extension 规范) +type AIExtension struct { + global.GVA_MODEL + UserID uint `json:"userId" gorm:"not null;index;comment:所属用户ID"` + User *AppUser `json:"user" gorm:"foreignKey:UserID"` + Name string `json:"name" gorm:"type:varchar(500);not null;index;comment:扩展名称"` + DisplayName string `json:"displayName" gorm:"type:varchar(500);comment:显示名称"` + Version string `json:"version" gorm:"type:varchar(50);comment:版本号"` + Author string `json:"author" gorm:"type:varchar(200);comment:作者"` + Description string `json:"description" gorm:"type:text;comment:扩展描述"` + Homepage string `json:"homepage" gorm:"type:varchar(1024);comment:主页地址"` + Repository string `json:"repository" gorm:"type:varchar(1024);comment:仓库地址"` + License string `json:"license" gorm:"type:varchar(100);comment:许可证"` + Tags datatypes.JSON `json:"tags" gorm:"type:jsonb;comment:标签列表"` + + // 扩展类型和功能 + ExtensionType string `json:"extensionType" gorm:"type:varchar(50);default:'ui';comment:扩展类型(ui/server/hybrid)"` // ui, server, hybrid + Category string `json:"category" gorm:"type:varchar(100);comment:分类(utilities/themes/integrations/tools)"` + + // 依赖关系 + Dependencies datatypes.JSON `json:"dependencies" gorm:"type:jsonb;comment:依赖的其他扩展"` + Conflicts datatypes.JSON `json:"conflicts" gorm:"type:jsonb;comment:冲突的扩展列表"` + + // 扩展文件 + ManifestData datatypes.JSON `json:"manifestData" gorm:"type:jsonb;not null;comment:manifest.json 完整内容"` + ScriptPath string `json:"scriptPath" gorm:"type:varchar(1024);comment:主脚本文件路径"` + StylePath string `json:"stylePath" gorm:"type:varchar(1024);comment:样式文件路径"` + AssetsPaths datatypes.JSON `json:"assetsPaths" gorm:"type:jsonb;comment:资源文件路径列表"` + + // 扩展配置 + Settings datatypes.JSON `json:"settings" gorm:"type:jsonb;comment:扩展配置项"` + Options datatypes.JSON `json:"options" gorm:"type:jsonb;comment:扩展选项"` + + // 状态 + IsEnabled bool `json:"isEnabled" gorm:"default:false;index;comment:是否启用"` + IsInstalled bool `json:"isInstalled" gorm:"default:true;index;comment:是否已安装"` + IsSystemExt bool `json:"isSystemExt" gorm:"default:false;comment:是否系统内置扩展"` + InstallSource string `json:"installSource" gorm:"type:varchar(500);comment:安装来源(url/git/file/marketplace)"` + SourceURL string `json:"sourceUrl" gorm:"type:varchar(1000);comment:原始安装 URL(用于更新)"` + Branch string `json:"branch" gorm:"type:varchar(100);comment:Git 分支名称"` + InstallDate time.Time `json:"installDate" gorm:"comment:安装日期"` + LastEnabled time.Time `json:"lastEnabled" gorm:"comment:最后启用时间"` + + // 更新相关 + AutoUpdate bool `json:"autoUpdate" gorm:"default:false;comment:是否自动更新"` + LastUpdateCheck *time.Time `json:"lastUpdateCheck" gorm:"comment:最后检查更新时间"` + AvailableVersion string `json:"availableVersion" gorm:"type:varchar(50);comment:可用的新版本"` + + // 统计 + UsageCount int `json:"usageCount" gorm:"default:0;comment:使用次数"` + ErrorCount int `json:"errorCount" gorm:"default:0;comment:错误次数"` + LoadTime int `json:"loadTime" gorm:"default:0;comment:平均加载时间(ms)"` + + // 元数据 + Metadata datatypes.JSON `json:"metadata" gorm:"type:jsonb;comment:扩展元数据"` +} + +func (AIExtension) TableName() string { + return "ai_extensions" +} + +// AIExtensionManifest 扩展清单结构 (对应 manifest.json) +type AIExtensionManifest struct { + Name string `json:"name"` + DisplayName string `json:"display_name,omitempty"` + Version string `json:"version"` + Description string `json:"description"` + Author string `json:"author"` + Homepage string `json:"homepage,omitempty"` + Repository string `json:"repository,omitempty"` + License string `json:"license,omitempty"` + Tags []string `json:"tags,omitempty"` + Type string `json:"type,omitempty"` // ui, server, hybrid + Category string `json:"category,omitempty"` // utilities, themes, integrations, tools + Dependencies map[string]string `json:"dependencies,omitempty"` // {"extension-name": ">=1.0.0"} + Conflicts []string `json:"conflicts,omitempty"` + Entry string `json:"entry,omitempty"` // 主入口文件 + Style string `json:"style,omitempty"` // 样式文件 + Assets []string `json:"assets,omitempty"` // 资源文件列表 + Settings map[string]interface{} `json:"settings,omitempty"` // 默认设置 + Options map[string]interface{} `json:"options,omitempty"` // 扩展选项 + Metadata map[string]interface{} `json:"metadata,omitempty"` // 扩展元数据 + AutoUpdate bool `json:"auto_update,omitempty"` // 是否自动更新(SillyTavern 兼容) + InlineScript string `json:"inline_script,omitempty"` // 内联脚本(SillyTavern 兼容) +} + +// AIExtensionSettings 用户的扩展配置(已废弃,配置现在直接存储在 AIExtension.Settings 中) +// type AIExtensionSettings struct { +// global.GVA_MODEL +// UserID uint `json:"userId" gorm:"not null;index:idx_user_ext,unique;comment:用户ID"` +// User *AppUser `json:"user" gorm:"foreignKey:UserID"` +// ExtensionID uint `json:"extensionId" gorm:"not null;index:idx_user_ext,unique;comment:扩展ID"` +// Extension *AIExtension `json:"extension" gorm:"foreignKey:ExtensionID"` +// Settings datatypes.JSON `json:"settings" gorm:"type:jsonb;not null;comment:用户配置"` +// IsEnabled bool `json:"isEnabled" gorm:"default:true;comment:用户是否启用"` +// } +// +// func (AIExtensionSettings) TableName() string { +// return "ai_extension_settings" +// } diff --git a/server/model/app/ai_regex_script.go b/server/model/app/ai_regex_script.go new file mode 100644 index 0000000..c527a1c --- /dev/null +++ b/server/model/app/ai_regex_script.go @@ -0,0 +1,65 @@ +package app + +import ( + "git.echol.cn/loser/st/server/global" + "github.com/lib/pq" + "gorm.io/datatypes" +) + +// AIRegexScript 正则表达式脚本 +type AIRegexScript struct { + global.GVA_MODEL + UserID uint `json:"userId" gorm:"not null;index;comment:用户ID"` + User *AppUser `json:"user" gorm:"foreignKey:UserID"` + + // 基础信息 + ScriptName string `json:"scriptName" gorm:"type:varchar(500);not null;comment:脚本名称"` + Description string `json:"description" gorm:"type:text;comment:脚本描述"` + + // 脚本内容 + FindRegex string `json:"findRegex" gorm:"type:text;not null;comment:查找正则表达式"` + ReplaceString string `json:"replaceString" gorm:"type:text;comment:替换字符串"` + + // 脚本配置 + Enabled bool `json:"enabled" gorm:"default:true;comment:是否启用"` + IsGlobal bool `json:"isGlobal" gorm:"default:false;index;comment:是否全局脚本"` + TrimStrings bool `json:"trimStrings" gorm:"default:false;comment:是否去除首尾空格"` + OnlyFormat bool `json:"onlyFormat" gorm:"default:false;comment:仅格式化消息"` + RunOnEdit bool `json:"runOnEdit" gorm:"default:false;comment:编辑时运行"` + SubstituteRegex bool `json:"substituteRegex" gorm:"default:false;comment:替换正则"` + MinDepth *int `json:"minDepth" gorm:"comment:最小深度"` + MaxDepth *int `json:"maxDepth" gorm:"comment:最大深度"` + + // 应用范围 + Placement string `json:"placement" gorm:"type:varchar(50);comment:应用位置:user,ai,sys,slash"` // user, ai, sys, slash + AffectMinDepth *int `json:"affectMinDepth" gorm:"comment:影响最小深度"` + AffectMaxDepth *int `json:"affectMaxDepth" gorm:"comment:影响最大深度"` + + // 关联数据 + LinkedChars pq.StringArray `json:"linkedChars" gorm:"type:text[];comment:关联的角色ID列表"` + + // 扩展数据(用于存储额外配置) + ScriptData datatypes.JSON `json:"scriptData" gorm:"type:jsonb;comment:脚本附加数据"` + + // 统计信息 + UsageCount int `json:"usageCount" gorm:"default:0;comment:使用次数"` + LastUsedAt *int64 `json:"lastUsedAt" gorm:"comment:最后使用时间戳"` +} + +func (AIRegexScript) TableName() string { + return "ai_regex_scripts" +} + +// AICharacterRegexScript 角色与正则脚本关联表 +type AICharacterRegexScript struct { + global.GVA_MODEL + CharacterID uint `json:"characterId" gorm:"not null;index:idx_char_regex,unique;comment:角色ID"` + Character *AICharacter `json:"character" gorm:"foreignKey:CharacterID"` + RegexID uint `json:"regexId" gorm:"not null;index:idx_char_regex,unique;comment:正则脚本ID"` + Regex *AIRegexScript `json:"regex" gorm:"foreignKey:RegexID"` + Order int `json:"order" gorm:"default:0;comment:执行顺序"` +} + +func (AICharacterRegexScript) TableName() string { + return "ai_character_regex_scripts" +} diff --git a/server/model/app/request/extension.go b/server/model/app/request/extension.go new file mode 100644 index 0000000..04c196a --- /dev/null +++ b/server/model/app/request/extension.go @@ -0,0 +1,107 @@ +package request + +import ( + common "git.echol.cn/loser/st/server/model/common/request" +) + +// CreateExtensionRequest 创建/安装扩展请求 +type CreateExtensionRequest struct { + Name string `json:"name" binding:"required,min=1,max=500"` + DisplayName string `json:"displayName"` + Version string `json:"version"` + Author string `json:"author"` + Description string `json:"description"` + Homepage string `json:"homepage"` + Repository string `json:"repository"` + License string `json:"license"` + Tags []string `json:"tags"` + ExtensionType string `json:"extensionType" binding:"required,oneof=ui server hybrid"` + Category string `json:"category"` + Dependencies map[string]string `json:"dependencies"` + Conflicts []string `json:"conflicts"` + ManifestData map[string]interface{} `json:"manifestData" binding:"required"` + ScriptPath string `json:"scriptPath"` + StylePath string `json:"stylePath"` + AssetsPaths []string `json:"assetsPaths"` + Settings map[string]interface{} `json:"settings"` + Options map[string]interface{} `json:"options"` + InstallSource string `json:"installSource"` + SourceURL string `json:"sourceUrl"` // 原始安装 URL(用于更新) + Branch string `json:"branch"` // Git 分支 + AutoUpdate bool `json:"autoUpdate"` // 是否自动更新 + Metadata map[string]interface{} `json:"metadata"` +} + +// ExtensionListRequest 扩展列表查询请求 +type ExtensionListRequest struct { + common.PageInfo + Name string `json:"name" form:"name"` // 扩展名称(模糊搜索) + ExtensionType string `json:"extensionType" form:"extensionType"` // 扩展类型 + Category string `json:"category" form:"category"` // 分类 + IsEnabled *bool `json:"isEnabled" form:"isEnabled"` // 是否启用 + IsInstalled *bool `json:"isInstalled" form:"isInstalled"` // 是否已安装 + Tag string `json:"tag" form:"tag"` // 标签过滤 +} + +// InstallExtensionRequest 安装扩展请求 +type InstallExtensionRequest struct { + Source string `json:"source" binding:"required,oneof=url file marketplace"` // 安装来源 + URL string `json:"url"` // URL 安装 + ManifestData []byte `json:"manifestData"` // 文件安装 + MarketplaceID string `json:"marketplaceId"` // 市场安装 +} + +// UninstallExtensionRequest 卸载扩展请求 +type UninstallExtensionRequest struct { + DeleteFiles bool `json:"deleteFiles"` // 是否删除文件 +} + +// ToggleExtensionRequest 启用/禁用扩展请求 +type ToggleExtensionRequest struct { + IsEnabled *bool `json:"isEnabled"` // 使用指针类型,允许 false 值 +} + +// UpdateExtensionSettingsRequest 更新扩展配置请求 +type UpdateExtensionSettingsRequest struct { + Settings map[string]interface{} `json:"settings" binding:"required"` +} + +// ImportExtensionRequest 导入扩展请求 +type ImportExtensionRequest struct { + Format string `json:"format" binding:"required,oneof=zip folder"` +} + +// ExportExtensionRequest 导出扩展请求 +type ExportExtensionRequest struct { + Format string `json:"format" binding:"required,oneof=zip folder"` + IncludeAssets bool `json:"includeAssets"` // 是否包含资源文件 +} + +// ExtensionStatsRequest 扩展统计请求 +type ExtensionStatsRequest struct { + ExtensionID uint `json:"extensionId" binding:"required"` + Action string `json:"action" binding:"required,oneof=usage error load"` // 统计类型 + Value int `json:"value"` +} + +// InstallExtensionFromGitRequest 从 Git URL 安装扩展请求 +type InstallExtensionFromGitRequest struct { + GitUrl string `json:"gitUrl" binding:"required"` // Git 仓库 URL + Branch string `json:"branch" binding:"omitempty,max=100"` // 分支名称(可选,默认 main) +} + +// InstallExtensionFromURLRequest 从 URL 安装扩展请求(智能识别 Git URL 或 Manifest URL) +type InstallExtensionFromURLRequest struct { + URL string `json:"url" binding:"required"` // Git 仓库 URL 或 Manifest.json URL + Branch string `json:"branch"` // Git 分支名称(可选,默认 main) +} + +// UpdateExtensionRequest 更新扩展请求 +type UpdateExtensionRequest struct { + Force bool `json:"force"` // 是否强制更新(忽略本地修改) + DisplayName string `json:"displayName"` + Description string `json:"description"` + Settings map[string]interface{} `json:"settings"` + Options map[string]interface{} `json:"options"` + Metadata map[string]interface{} `json:"metadata"` +} diff --git a/server/model/app/request/regex_script.go b/server/model/app/request/regex_script.go new file mode 100644 index 0000000..795d065 --- /dev/null +++ b/server/model/app/request/regex_script.go @@ -0,0 +1,88 @@ +package request + +import ( + "git.echol.cn/loser/st/server/model/app" + common "git.echol.cn/loser/st/server/model/common/request" +) + +// CreateRegexScriptRequest 创建正则脚本请求 +type CreateRegexScriptRequest struct { + ScriptName string `json:"scriptName" binding:"required"` + Description string `json:"description"` + FindRegex string `json:"findRegex" binding:"required"` + ReplaceString string `json:"replaceString"` + Enabled bool `json:"enabled"` + IsGlobal bool `json:"isGlobal"` + TrimStrings bool `json:"trimStrings"` + OnlyFormat bool `json:"onlyFormat"` + RunOnEdit bool `json:"runOnEdit"` + SubstituteRegex bool `json:"substituteRegex"` + MinDepth *int `json:"minDepth"` + MaxDepth *int `json:"maxDepth"` + Placement string `json:"placement"` + AffectMinDepth *int `json:"affectMinDepth"` + AffectMaxDepth *int `json:"affectMaxDepth"` + LinkedChars []string `json:"linkedChars"` + ScriptData map[string]interface{} `json:"scriptData"` +} + +// UpdateRegexScriptRequest 更新正则脚本请求 +type UpdateRegexScriptRequest struct { + ScriptName string `json:"scriptName"` + Description string `json:"description"` + FindRegex string `json:"findRegex"` + ReplaceString string `json:"replaceString"` + Enabled *bool `json:"enabled"` + IsGlobal *bool `json:"isGlobal"` + TrimStrings *bool `json:"trimStrings"` + OnlyFormat *bool `json:"onlyFormat"` + RunOnEdit *bool `json:"runOnEdit"` + SubstituteRegex *bool `json:"substituteRegex"` + MinDepth *int `json:"minDepth"` + MaxDepth *int `json:"maxDepth"` + Placement string `json:"placement"` + AffectMinDepth *int `json:"affectMinDepth"` + AffectMaxDepth *int `json:"affectMaxDepth"` + LinkedChars []string `json:"linkedChars"` + ScriptData map[string]interface{} `json:"scriptData"` +} + +// RegexScriptListRequest 正则脚本列表查询请求 +type RegexScriptListRequest struct { + common.PageInfo + ScriptName string `json:"scriptName" form:"scriptName"` // 脚本名称(模糊搜索) + IsGlobal *bool `json:"isGlobal" form:"isGlobal"` // 是否全局 + Enabled *bool `json:"enabled" form:"enabled"` // 是否启用 + CharacterID *uint `json:"characterId" form:"characterId"` // 关联角色ID +} + +// LinkCharacterToRegexRequest 关联角色到正则脚本请求 +type LinkCharacterToRegexRequest struct { + CharacterIDs []uint `json:"characterIds" binding:"required"` +} + +// TestRegexScriptRequest 测试正则脚本请求 +type TestRegexScriptRequest struct { + FindRegex string `json:"findRegex" binding:"required"` + ReplaceString string `json:"replaceString"` + TestInput string `json:"testInput" binding:"required"` + TrimStrings bool `json:"trimStrings"` + SubstituteRegex bool `json:"substituteRegex"` +} + +// ApplyRegexScriptsRequest 应用正则脚本请求 +type ApplyRegexScriptsRequest struct { + Text string `json:"text" binding:"required"` + RegexIDs []uint `json:"regexIds"` // 指定要应用的脚本ID列表 + CharacterID *uint `json:"characterId"` // 角色ID(自动应用关联的脚本) + Placement string `json:"placement"` // 应用位置 + MinDepth *int `json:"minDepth"` // 最小深度 + MaxDepth *int `json:"maxDepth"` // 最大深度 + UseGlobal bool `json:"useGlobal"` // 是否应用全局脚本 +} + +// ImportRegexScriptsRequest 导入正则脚本请求 +type ImportRegexScriptsRequest struct { + Scripts []app.AIRegexScript `json:"scripts" binding:"required"` + OverwriteMode string `json:"overwriteMode"` // skip, overwrite, merge +} diff --git a/server/model/app/request/world_info.go b/server/model/app/request/world_info.go index 0855cc4..ea88db5 100644 --- a/server/model/app/request/world_info.go +++ b/server/model/app/request/world_info.go @@ -1,6 +1,9 @@ package request -import "git.echol.cn/loser/st/server/model/app" +import ( + "git.echol.cn/loser/st/server/model/app" + common "git.echol.cn/loser/st/server/model/common/request" +) // CreateWorldBookRequest 创建世界书请求 type CreateWorldBookRequest struct { @@ -20,7 +23,7 @@ type UpdateWorldBookRequest struct { // WorldBookListRequest 世界书列表查询请求 type WorldBookListRequest struct { - PageInfo + common.PageInfo BookName string `json:"bookName" form:"bookName"` // 世界书名称(模糊搜索) IsGlobal *bool `json:"isGlobal" form:"isGlobal"` // 是否全局 CharacterID *uint `json:"characterId" form:"characterId"` // 关联角色ID diff --git a/server/model/app/response/extension.go b/server/model/app/response/extension.go new file mode 100644 index 0000000..8955c9a --- /dev/null +++ b/server/model/app/response/extension.go @@ -0,0 +1,188 @@ +package response + +import ( + "encoding/json" + "git.echol.cn/loser/st/server/model/app" + "time" +) + +// ExtensionResponse 扩展响应 +type ExtensionResponse struct { + ID uint `json:"id"` + UserID uint `json:"userId"` + Name string `json:"name"` + DisplayName string `json:"displayName"` + Version string `json:"version"` + Author string `json:"author"` + Description string `json:"description"` + Homepage string `json:"homepage"` + Repository string `json:"repository"` + License string `json:"license"` + Tags []string `json:"tags"` + ExtensionType string `json:"extensionType"` + Category string `json:"category"` + Dependencies map[string]string `json:"dependencies"` + Conflicts []string `json:"conflicts"` + ManifestData map[string]interface{} `json:"manifestData"` + ScriptPath string `json:"scriptPath"` + StylePath string `json:"stylePath"` + AssetsPaths []string `json:"assetsPaths"` + Settings map[string]interface{} `json:"settings"` + Options map[string]interface{} `json:"options"` + IsEnabled bool `json:"isEnabled"` + IsInstalled bool `json:"isInstalled"` + IsSystemExt bool `json:"isSystemExt"` + InstallSource string `json:"installSource"` + InstallDate time.Time `json:"installDate"` + LastEnabled time.Time `json:"lastEnabled"` + UsageCount int `json:"usageCount"` + ErrorCount int `json:"errorCount"` + LoadTime int `json:"loadTime"` + Metadata map[string]interface{} `json:"metadata"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// ExtensionListResponse 扩展列表响应 +type ExtensionListResponse struct { + List []ExtensionResponse `json:"list"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} + +// ExtensionManifestResponse manifest.json 响应 +type ExtensionManifestResponse struct { + Name string `json:"name"` + DisplayName string `json:"display_name,omitempty"` + Version string `json:"version"` + Description string `json:"description"` + Author string `json:"author"` + Homepage string `json:"homepage,omitempty"` + Repository string `json:"repository,omitempty"` + License string `json:"license,omitempty"` + Tags []string `json:"tags,omitempty"` + Type string `json:"type,omitempty"` + Category string `json:"category,omitempty"` + Dependencies map[string]string `json:"dependencies,omitempty"` + Conflicts []string `json:"conflicts,omitempty"` + Entry string `json:"entry,omitempty"` + Style string `json:"style,omitempty"` + Assets []string `json:"assets,omitempty"` + Settings map[string]interface{} `json:"settings,omitempty"` + Options map[string]interface{} `json:"options,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +// ExtensionStatsResponse 扩展统计响应 +type ExtensionStatsResponse struct { + ExtensionID uint `json:"extensionId"` + ExtensionName string `json:"extensionName"` + UsageCount int `json:"usageCount"` + ErrorCount int `json:"errorCount"` + LoadTime int `json:"loadTime"` + LastUsed time.Time `json:"lastUsed"` +} + +// ToExtensionResponse 转换为扩展响应 +func ToExtensionResponse(ext *app.AIExtension) ExtensionResponse { + var tags []string + if ext.Tags != nil { + _ = json.Unmarshal([]byte(ext.Tags), &tags) + } + if tags == nil { + tags = []string{} + } + + var dependencies map[string]string + if ext.Dependencies != nil { + _ = json.Unmarshal([]byte(ext.Dependencies), &dependencies) + } + if dependencies == nil { + dependencies = map[string]string{} + } + + var conflicts []string + if ext.Conflicts != nil { + _ = json.Unmarshal([]byte(ext.Conflicts), &conflicts) + } + if conflicts == nil { + conflicts = []string{} + } + + var manifestData map[string]interface{} + if ext.ManifestData != nil { + _ = json.Unmarshal([]byte(ext.ManifestData), &manifestData) + } + if manifestData == nil { + manifestData = map[string]interface{}{} + } + + var assetsPaths []string + if ext.AssetsPaths != nil { + _ = json.Unmarshal([]byte(ext.AssetsPaths), &assetsPaths) + } + if assetsPaths == nil { + assetsPaths = []string{} + } + + var settings map[string]interface{} + if ext.Settings != nil { + _ = json.Unmarshal([]byte(ext.Settings), &settings) + } + if settings == nil { + settings = map[string]interface{}{} + } + + var options map[string]interface{} + if ext.Options != nil { + _ = json.Unmarshal([]byte(ext.Options), &options) + } + if options == nil { + options = map[string]interface{}{} + } + + var metadata map[string]interface{} + if ext.Metadata != nil { + _ = json.Unmarshal([]byte(ext.Metadata), &metadata) + } + if metadata == nil { + metadata = map[string]interface{}{} + } + + return ExtensionResponse{ + ID: ext.ID, + UserID: ext.UserID, + Name: ext.Name, + DisplayName: ext.DisplayName, + Version: ext.Version, + Author: ext.Author, + Description: ext.Description, + Homepage: ext.Homepage, + Repository: ext.Repository, + License: ext.License, + Tags: tags, + ExtensionType: ext.ExtensionType, + Category: ext.Category, + Dependencies: dependencies, + Conflicts: conflicts, + ManifestData: manifestData, + ScriptPath: ext.ScriptPath, + StylePath: ext.StylePath, + AssetsPaths: assetsPaths, + Settings: settings, + Options: options, + IsEnabled: ext.IsEnabled, + IsInstalled: ext.IsInstalled, + IsSystemExt: ext.IsSystemExt, + InstallSource: ext.InstallSource, + InstallDate: ext.InstallDate, + LastEnabled: ext.LastEnabled, + UsageCount: ext.UsageCount, + ErrorCount: ext.ErrorCount, + LoadTime: ext.LoadTime, + Metadata: metadata, + CreatedAt: ext.CreatedAt, + UpdatedAt: ext.UpdatedAt, + } +} diff --git a/server/model/app/response/regex_script.go b/server/model/app/response/regex_script.go new file mode 100644 index 0000000..e30588f --- /dev/null +++ b/server/model/app/response/regex_script.go @@ -0,0 +1,106 @@ +package response + +import ( + "encoding/json" + + "git.echol.cn/loser/st/server/model/app" +) + +// RegexScriptResponse 正则脚本响应 +type RegexScriptResponse struct { + ID uint `json:"id"` + UserID uint `json:"userId"` + ScriptName string `json:"scriptName"` + Description string `json:"description"` + FindRegex string `json:"findRegex"` + ReplaceString string `json:"replaceString"` + Enabled bool `json:"enabled"` + IsGlobal bool `json:"isGlobal"` + TrimStrings bool `json:"trimStrings"` + OnlyFormat bool `json:"onlyFormat"` + RunOnEdit bool `json:"runOnEdit"` + SubstituteRegex bool `json:"substituteRegex"` + MinDepth *int `json:"minDepth"` + MaxDepth *int `json:"maxDepth"` + Placement string `json:"placement"` + AffectMinDepth *int `json:"affectMinDepth"` + AffectMaxDepth *int `json:"affectMaxDepth"` + LinkedChars []string `json:"linkedChars"` + ScriptData map[string]interface{} `json:"scriptData"` + UsageCount int `json:"usageCount"` + LastUsedAt *int64 `json:"lastUsedAt"` + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` +} + +// RegexScriptListResponse 正则脚本列表响应 +type RegexScriptListResponse struct { + List []RegexScriptResponse `json:"list"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"pageSize"` +} + +// TestRegexScriptResponse 测试正则脚本响应 +type TestRegexScriptResponse struct { + Success bool `json:"success"` + Input string `json:"input"` + Output string `json:"output"` + MatchedCount int `json:"matchedCount"` + Matches []string `json:"matches"` + Error string `json:"error,omitempty"` +} + +// ApplyRegexScriptsResponse 应用正则脚本响应 +type ApplyRegexScriptsResponse struct { + OriginalText string `json:"originalText"` + ProcessedText string `json:"processedText"` + AppliedCount int `json:"appliedCount"` + AppliedScripts []uint `json:"appliedScripts"` // 应用的脚本ID列表 +} + +// RegexScriptExportData 正则脚本导出数据 +type RegexScriptExportData struct { + Version string `json:"version"` // 导出格式版本 + Scripts []RegexScriptResponse `json:"scripts"` + ExportedAt int64 `json:"exportedAt"` +} + +// ToRegexScriptResponse 将 AIRegexScript 转换为 RegexScriptResponse +func ToRegexScriptResponse(script *app.AIRegexScript) RegexScriptResponse { + var scriptData map[string]interface{} + if len(script.ScriptData) > 0 { + _ = json.Unmarshal(script.ScriptData, &scriptData) + } + + linkedChars := []string{} + if script.LinkedChars != nil { + linkedChars = script.LinkedChars + } + + return RegexScriptResponse{ + ID: script.ID, + UserID: script.UserID, + ScriptName: script.ScriptName, + Description: script.Description, + FindRegex: script.FindRegex, + ReplaceString: script.ReplaceString, + Enabled: script.Enabled, + IsGlobal: script.IsGlobal, + TrimStrings: script.TrimStrings, + OnlyFormat: script.OnlyFormat, + RunOnEdit: script.RunOnEdit, + SubstituteRegex: script.SubstituteRegex, + MinDepth: script.MinDepth, + MaxDepth: script.MaxDepth, + Placement: script.Placement, + AffectMinDepth: script.AffectMinDepth, + AffectMaxDepth: script.AffectMaxDepth, + LinkedChars: linkedChars, + ScriptData: scriptData, + UsageCount: script.UsageCount, + LastUsedAt: script.LastUsedAt, + CreatedAt: script.CreatedAt.Unix(), + UpdatedAt: script.UpdatedAt.Unix(), + } +} diff --git a/server/model/common/request/common.go b/server/model/common/request/common.go index c729f3d..b07611d 100644 --- a/server/model/common/request/common.go +++ b/server/model/common/request/common.go @@ -6,9 +6,9 @@ import ( // PageInfo Paging common input parameter structure type PageInfo struct { - Page int `json:"page" form:"page"` // 页码 - PageSize int `json:"pageSize" form:"pageSize"` // 每页大小 - Keyword string `json:"keyword" form:"keyword"` // 关键字 + Page int `json:"page" form:"page,default=1"` // 页码 + PageSize int `json:"pageSize" form:"pageSize,default=20"` // 每页大小 + Keyword string `json:"keyword" form:"keyword"` // 关键字 } func (r *PageInfo) Paginate() func(db *gorm.DB) *gorm.DB { diff --git a/server/router/app/enter.go b/server/router/app/enter.go index eb414a2..e375807 100644 --- a/server/router/app/enter.go +++ b/server/router/app/enter.go @@ -4,4 +4,6 @@ type RouterGroup struct { AuthRouter CharacterRouter WorldInfoRouter + ExtensionRouter + RegexScriptRouter } diff --git a/server/router/app/extension.go b/server/router/app/extension.go new file mode 100644 index 0000000..ee88545 --- /dev/null +++ b/server/router/app/extension.go @@ -0,0 +1,46 @@ +package app + +import ( + "git.echol.cn/loser/st/server/api/v1" + "git.echol.cn/loser/st/server/middleware" + "github.com/gin-gonic/gin" +) + +type ExtensionRouter struct{} + +func (r *ExtensionRouter) InitExtensionRouter(Router *gin.RouterGroup) { + extensionRouter := Router.Group("extension").Use(middleware.AppJWTAuth()) + extensionApi := v1.ApiGroupApp.AppApiGroup.ExtensionApi + + { + // 扩展管理 + extensionRouter.POST("", extensionApi.CreateExtension) // 创建/安装扩展 + extensionRouter.PUT("/:id", extensionApi.UpdateExtension) // 更新扩展 + extensionRouter.DELETE("/:id", extensionApi.DeleteExtension) // 删除/卸载扩展 + extensionRouter.GET("/:id", extensionApi.GetExtension) // 获取扩展详情 + extensionRouter.GET("/list", extensionApi.GetExtensionList) // 获取扩展列表 + extensionRouter.GET("/enabled", extensionApi.GetEnabledExtensions) // 获取启用的扩展列表 + + // 扩展操作 + extensionRouter.POST("/:id/toggle", extensionApi.ToggleExtension) // 启用/禁用扩展 + extensionRouter.POST("/:id/update", extensionApi.UpgradeExtension) // 升级扩展版本 + + // 扩展配置 + extensionRouter.GET("/:id/settings", extensionApi.GetExtensionSettings) // 获取扩展配置 + extensionRouter.PUT("/:id/settings", extensionApi.UpdateExtensionSettings) // 更新扩展配置 + + // 扩展元数据 + extensionRouter.GET("/:id/manifest", extensionApi.GetExtensionManifest) // 获取 manifest.json + + // 导入导出 + extensionRouter.POST("/import", extensionApi.ImportExtension) // 导入扩展 + extensionRouter.GET("/:id/export", extensionApi.ExportExtension) // 导出扩展 + + // 安装方式 + extensionRouter.POST("/install/url", extensionApi.InstallExtensionFromURL) // 从 URL 安装扩展(后端代理) + extensionRouter.POST("/install/git", extensionApi.InstallExtensionFromGit) // 从 Git URL 安装扩展 + + // 统计 + extensionRouter.POST("/stats", extensionApi.UpdateExtensionStats) // 更新扩展统计 + } +} diff --git a/server/router/app/regex_script.go b/server/router/app/regex_script.go new file mode 100644 index 0000000..c2dba1f --- /dev/null +++ b/server/router/app/regex_script.go @@ -0,0 +1,29 @@ +package app + +import ( + "git.echol.cn/loser/st/server/api/v1" + "git.echol.cn/loser/st/server/middleware" + "github.com/gin-gonic/gin" +) + +type RegexScriptRouter struct{} + +// InitRegexScriptRouter 初始化正则脚本路由 +func (r *RegexScriptRouter) InitRegexScriptRouter(Router *gin.RouterGroup) { + regexRouter := Router.Group("regex").Use(middleware.AppJWTAuth()) + regexApi := v1.ApiGroupApp.AppApiGroup.RegexScriptApi + { + regexRouter.POST("", regexApi.CreateRegexScript) // 创建正则脚本 + regexRouter.PUT(":id", regexApi.UpdateRegexScript) // 更新正则脚本 + regexRouter.DELETE(":id", regexApi.DeleteRegexScript) // 删除正则脚本 + regexRouter.GET(":id", regexApi.GetRegexScript) // 获取正则脚本详情 + regexRouter.GET("", regexApi.GetRegexScriptList) // 获取正则脚本列表 + regexRouter.POST(":id/link", regexApi.LinkCharactersToRegex) // 关联角色到脚本 + regexRouter.GET("character/:characterId", regexApi.GetCharacterRegexScripts) // 获取角色的脚本 + regexRouter.POST(":id/duplicate", regexApi.DuplicateRegexScript) // 复制脚本 + regexRouter.POST("test", regexApi.TestRegexScript) // 测试正则脚本 + regexRouter.POST("apply", regexApi.ApplyRegexScripts) // 应用正则脚本 + regexRouter.POST("import", regexApi.ImportRegexScripts) // 导入正则脚本 + regexRouter.GET("export", regexApi.ExportRegexScripts) // 导出正则脚本 + } +} diff --git a/server/service/app/character.go b/server/service/app/character.go index 9b1468a..d1a258b 100644 --- a/server/service/app/character.go +++ b/server/service/app/character.go @@ -10,6 +10,7 @@ import ( _ "image/jpeg" _ "image/png" "os" + "regexp" "strings" "time" @@ -18,6 +19,7 @@ import ( "git.echol.cn/loser/st/server/model/app/request" "git.echol.cn/loser/st/server/model/app/response" "git.echol.cn/loser/st/server/utils" + "github.com/lib/pq" "go.uber.org/zap" "gorm.io/datatypes" "gorm.io/gorm" @@ -492,18 +494,28 @@ func (cs *CharacterService) ExportCharacter(characterID uint, userID *uint) (map alternateGreetings = character.AlternateGreetings } - // 解析 character_book JSON + // 解析或构建 character_book JSON var characterBook map[string]interface{} if len(character.CharacterBook) > 0 { json.Unmarshal(character.CharacterBook, &characterBook) } - // 解析 extensions JSON + // 如果角色没有内嵌的 CharacterBook,尝试从世界书表中查找关联的世界书 + if characterBook == nil { + characterBook = cs.exportLinkedWorldBook(character.ID) + } + + // 解析或构建 extensions JSON extensions := map[string]interface{}{} if len(character.Extensions) > 0 { json.Unmarshal(character.Extensions, &extensions) } + // 导出关联的正则脚本到 extensions + if regexScripts := cs.exportRegexScripts(character.ID); regexScripts != nil && len(regexScripts) > 0 { + extensions["regex_scripts"] = regexScripts + } + // 构建导出数据(兼容 SillyTavern 格式) data := map[string]interface{}{ "name": character.Name, @@ -522,7 +534,7 @@ func (cs *CharacterService) ExportCharacter(characterID uint, userID *uint) (map "extensions": extensions, } - // 仅在存在时添加 character_book + // 仅在存在时添加 character_book(现在包含关联的世界书) if characterBook != nil { data["character_book"] = characterBook } @@ -595,6 +607,39 @@ func (cs *CharacterService) ImportCharacter(fileData []byte, filename string, us return response.CharacterResponse{}, err } + // 处理角色卡中的世界书数据(CharacterBook) + if card.Data.CharacterBook != nil && len(card.Data.CharacterBook) > 0 { + global.GVA_LOG.Info("检测到角色卡包含世界书数据,开始导入世界书", + zap.Uint("characterID", result.ID)) + + if err := cs.importCharacterBook(userID, result.ID, card.Data.CharacterBook); err != nil { + global.GVA_LOG.Warn("导入世界书失败(不影响角色卡导入)", + zap.Error(err), + zap.Uint("characterID", result.ID)) + } else { + global.GVA_LOG.Info("世界书导入成功", zap.Uint("characterID", result.ID)) + } + } + + // 处理角色卡中的扩展数据(Extensions) + if card.Data.Extensions != nil && len(card.Data.Extensions) > 0 { + global.GVA_LOG.Info("检测到角色卡包含扩展数据,开始处理扩展", + zap.Uint("characterID", result.ID)) + + // 处理 Regex 脚本 + if regexScripts, ok := card.Data.Extensions["regex_scripts"]; ok { + if err := cs.importRegexScripts(userID, result.ID, regexScripts); err != nil { + global.GVA_LOG.Warn("导入正则脚本失败(不影响角色卡导入)", + zap.Error(err), + zap.Uint("characterID", result.ID)) + } else { + global.GVA_LOG.Info("正则脚本导入成功", zap.Uint("characterID", result.ID)) + } + } + + // 其他扩展数据已经存储在 Extensions 字段中,无需额外处理 + } + global.GVA_LOG.Info("角色卡导入完成", zap.Uint("characterID", result.ID)) return result, nil } @@ -621,7 +666,7 @@ func (cs *CharacterService) ExportCharacterAsPNG(characterID uint, userID *uint) } // 构建角色卡数据 - card := convertCharacterToCard(&character) + card := cs.convertCharacterToCard(&character) // 获取角色头像 var img image.Image @@ -654,6 +699,481 @@ func (cs *CharacterService) ExportCharacterAsPNG(characterID uint, userID *uint) return pngData, nil } +// createCharacterFromRequest 从请求创建角色卡对象(用于事务) +func createCharacterFromRequest(req request.CreateCharacterRequest, userID uint) app.AICharacter { + // 处理标签和示例消息 + tags := req.Tags + if tags == nil { + tags = []string{} + } + + exampleMessages := req.ExampleMessages + if exampleMessages == nil { + exampleMessages = []string{} + } + + alternateGreetings := req.AlternateGreetings + if alternateGreetings == nil { + alternateGreetings = []string{} + } + + // 构建 CardData + cardData := map[string]interface{}{ + "name": req.Name, + "description": req.Description, + "personality": req.Personality, + "scenario": req.Scenario, + "first_message": req.FirstMessage, + "example_messages": req.ExampleMessages, + "creator_name": req.CreatorName, + "creator_notes": req.CreatorNotes, + "system_prompt": req.SystemPrompt, + "post_history_instructions": req.PostHistoryInstructions, + "alternate_greetings": req.AlternateGreetings, + "character_book": req.CharacterBook, + "extensions": req.Extensions, + } + cardDataJSON, _ := json.Marshal(cardData) + + // 序列化 JSON 字段 + var characterBookJSON, extensionsJSON datatypes.JSON + if req.CharacterBook != nil { + characterBookJSON, _ = json.Marshal(req.CharacterBook) + } + if req.Extensions != nil { + extensionsJSON, _ = json.Marshal(req.Extensions) + } + + return app.AICharacter{ + Name: req.Name, + Description: req.Description, + Personality: req.Personality, + Scenario: req.Scenario, + Avatar: req.Avatar, + CreatorID: &userID, + CreatorName: req.CreatorName, + CreatorNotes: req.CreatorNotes, + CardData: datatypes.JSON(cardDataJSON), + Tags: tags, + IsPublic: req.IsPublic, + FirstMessage: req.FirstMessage, + ExampleMessages: exampleMessages, + SystemPrompt: req.SystemPrompt, + PostHistoryInstructions: req.PostHistoryInstructions, + AlternateGreetings: alternateGreetings, + CharacterBook: characterBookJSON, + Extensions: extensionsJSON, + TokenCount: calculateTokenCount(req), + } +} + +// importCharacterBookWithTx 在事务中导入角色卡中的世界书数据 +func (cs *CharacterService) importCharacterBookWithTx(tx *gorm.DB, userID, characterID uint, characterBook map[string]interface{}) error { + // 解析世界书名称 + bookName := "" + if name, ok := characterBook["name"].(string); ok && name != "" { + bookName = name + } + + // 如果没有名称,使用角色名称 + if bookName == "" { + var character app.AICharacter + if err := tx.Where("id = ?", characterID).First(&character).Error; err == nil { + bookName = character.Name + " 的世界书" + } else { + bookName = "角色世界书" + } + } + + // 解析世界书条目 + entries := []app.AIWorldInfoEntry{} + if entriesData, ok := characterBook["entries"].([]interface{}); ok { + for i, entryData := range entriesData { + if entryMap, ok := entryData.(map[string]interface{}); ok { + entry := convertToWorldInfoEntry(entryMap, i) + entries = append(entries, entry) + } + } + } + + if len(entries) == 0 { + global.GVA_LOG.Warn("角色卡中的世界书没有有效条目,跳过导入") + return nil // 没有条目时不报错,只是跳过 + } + + // 序列化条目 + entriesJSON, err := json.Marshal(entries) + if err != nil { + return errors.New("序列化世界书条目失败: " + err.Error()) + } + + // 创建世界书记录 + worldBook := &app.AIWorldInfo{ + UserID: userID, + BookName: bookName, + IsGlobal: false, + Entries: datatypes.JSON(entriesJSON), + LinkedChars: pq.StringArray{fmt.Sprintf("%d", characterID)}, + } + + if err := tx.Create(worldBook).Error; err != nil { + return errors.New("创建世界书记录失败: " + err.Error()) + } + + global.GVA_LOG.Info("成功从角色卡导入世界书", + zap.Uint("worldBookID", worldBook.ID), + zap.String("bookName", bookName), + zap.Int("entriesCount", len(entries))) + + return nil +} + +// importRegexScripts 导入角色卡中的正则脚本 +func (cs *CharacterService) importRegexScripts(userID, characterID uint, regexScriptsData interface{}) error { + scriptsArray, ok := regexScriptsData.([]interface{}) + if !ok { + return errors.New("正则脚本数据格式错误") + } + + if len(scriptsArray) == 0 { + global.GVA_LOG.Info("角色卡中没有正则脚本数据") + return nil + } + + characterIDStr := fmt.Sprintf("%d", characterID) + imported := 0 + + for i, scriptData := range scriptsArray { + scriptMap, ok := scriptData.(map[string]interface{}) + if !ok { + global.GVA_LOG.Warn("跳过无效的正则脚本数据", zap.Int("index", i)) + continue + } + + // 解析正则脚本 + script := convertMapToRegexScript(scriptMap, characterIDStr) + script.UserID = userID + + // 验证正则表达式 + if _, err := regexp.Compile(script.FindRegex); err != nil { + global.GVA_LOG.Warn("跳过无效的正则表达式", + zap.Int("index", i), + zap.String("regex", script.FindRegex), + zap.Error(err)) + continue + } + + // 检查是否已存在同名脚本 + var existingCount int64 + global.GVA_DB.Model(&app.AIRegexScript{}). + Where("user_id = ? AND script_name = ?", userID, script.ScriptName). + Count(&existingCount) + + if existingCount > 0 { + script.ScriptName = script.ScriptName + fmt.Sprintf(" (角色-%d)", characterID) + } + + // 创建脚本 + if err := global.GVA_DB.Create(&script).Error; err != nil { + global.GVA_LOG.Warn("创建正则脚本失败", + zap.Int("index", i), + zap.Error(err)) + continue + } + + imported++ + } + + global.GVA_LOG.Info("成功导入正则脚本", + zap.Uint("characterID", characterID), + zap.Int("imported", imported)) + + return nil +} + +// convertMapToRegexScript 将 map 转换为 RegexScript +func convertMapToRegexScript(scriptMap map[string]interface{}, characterIDStr string) app.AIRegexScript { + script := app.AIRegexScript{ + ScriptName: getStringValue(scriptMap, "scriptName", "未命名脚本"), + Description: getStringValue(scriptMap, "description", ""), + FindRegex: getStringValue(scriptMap, "findRegex", ""), + ReplaceString: getStringValue(scriptMap, "replaceString", ""), + Enabled: getBoolValue(scriptMap, "enabled", true), + IsGlobal: false, // 从角色卡导入的脚本默认不是全局脚本 + TrimStrings: getBoolValue(scriptMap, "trimStrings", false), + OnlyFormat: getBoolValue(scriptMap, "onlyFormat", false), + RunOnEdit: getBoolValue(scriptMap, "runOnEdit", false), + SubstituteRegex: getBoolValue(scriptMap, "substituteRegex", false), + Placement: getStringValue(scriptMap, "placement", ""), + LinkedChars: pq.StringArray{characterIDStr}, + } + + // 处理可选的数字字段 + if val, ok := scriptMap["minDepth"]; ok { + if intVal := getIntValue(scriptMap, "minDepth", 0); intVal != 0 { + script.MinDepth = &intVal + } else if val != nil { + intVal := 0 + script.MinDepth = &intVal + } + } + if val, ok := scriptMap["maxDepth"]; ok { + if intVal := getIntValue(scriptMap, "maxDepth", 0); intVal != 0 { + script.MaxDepth = &intVal + } else if val != nil { + intVal := 0 + script.MaxDepth = &intVal + } + } + if val, ok := scriptMap["affectMinDepth"]; ok { + if intVal := getIntValue(scriptMap, "affectMinDepth", 0); intVal != 0 { + script.AffectMinDepth = &intVal + } else if val != nil { + intVal := 0 + script.AffectMinDepth = &intVal + } + } + if val, ok := scriptMap["affectMaxDepth"]; ok { + if intVal := getIntValue(scriptMap, "affectMaxDepth", 0); intVal != 0 { + script.AffectMaxDepth = &intVal + } else if val != nil { + intVal := 0 + script.AffectMaxDepth = &intVal + } + } + + // 处理 ScriptData + if scriptData, ok := scriptMap["scriptData"].(map[string]interface{}); ok && scriptData != nil { + if data, err := datatypes.NewJSONType(scriptData).MarshalJSON(); err == nil { + script.ScriptData = data + } + } + + return script +} + +// exportRegexScripts 导出角色关联的正则脚本 +func (cs *CharacterService) exportRegexScripts(characterID uint) []map[string]interface{} { + // 查找关联的正则脚本 + var scripts []app.AIRegexScript + err := global.GVA_DB. + Where("? = ANY(linked_chars)", fmt.Sprintf("%d", characterID)). + Find(&scripts).Error + + if err != nil || len(scripts) == 0 { + return nil + } + + // 转换为 map 格式 + scriptsData := make([]map[string]interface{}, 0, len(scripts)) + for _, script := range scripts { + scriptMap := map[string]interface{}{ + "scriptName": script.ScriptName, + "description": script.Description, + "findRegex": script.FindRegex, + "replaceString": script.ReplaceString, + "enabled": script.Enabled, + "trimStrings": script.TrimStrings, + "onlyFormat": script.OnlyFormat, + "runOnEdit": script.RunOnEdit, + "substituteRegex": script.SubstituteRegex, + "placement": script.Placement, + } + + // 添加可选字段 + if script.MinDepth != nil { + scriptMap["minDepth"] = *script.MinDepth + } + if script.MaxDepth != nil { + scriptMap["maxDepth"] = *script.MaxDepth + } + if script.AffectMinDepth != nil { + scriptMap["affectMinDepth"] = *script.AffectMinDepth + } + if script.AffectMaxDepth != nil { + scriptMap["affectMaxDepth"] = *script.AffectMaxDepth + } + + // 添加 ScriptData + if len(script.ScriptData) > 0 { + var scriptData map[string]interface{} + if err := json.Unmarshal([]byte(script.ScriptData), &scriptData); err == nil { + scriptMap["scriptData"] = scriptData + } + } + + scriptsData = append(scriptsData, scriptMap) + } + + return scriptsData +} + +// importCharacterBook 导入角色卡中的世界书数据(已废弃,使用 importCharacterBookWithTx) +func (cs *CharacterService) importCharacterBook(userID, characterID uint, characterBook map[string]interface{}) error { + // 解析世界书名称 + bookName := "角色世界书" + if name, ok := characterBook["name"].(string); ok && name != "" { + bookName = name + } else { + // 获取角色名称作为世界书名称 + var character app.AICharacter + if err := global.GVA_DB.Where("id = ?", characterID).First(&character).Error; err == nil { + bookName = character.Name + " 的世界书" + } + } + + // 解析世界书条目 + entries := []app.AIWorldInfoEntry{} + if entriesData, ok := characterBook["entries"].([]interface{}); ok { + for i, entryData := range entriesData { + if entryMap, ok := entryData.(map[string]interface{}); ok { + entry := convertToWorldInfoEntry(entryMap, i) + entries = append(entries, entry) + } + } + } + + if len(entries) == 0 { + return errors.New("世界书中没有有效的条目") + } + + // 序列化条目 + entriesJSON, err := json.Marshal(entries) + if err != nil { + return errors.New("序列化世界书条目失败: " + err.Error()) + } + + // 创建世界书记录 + worldBook := &app.AIWorldInfo{ + UserID: userID, + BookName: bookName, + IsGlobal: false, + Entries: datatypes.JSON(entriesJSON), + LinkedChars: pq.StringArray{fmt.Sprintf("%d", characterID)}, + } + + if err := global.GVA_DB.Create(worldBook).Error; err != nil { + return errors.New("创建世界书记录失败: " + err.Error()) + } + + global.GVA_LOG.Info("成功从角色卡导入世界书", + zap.Uint("worldBookID", worldBook.ID), + zap.String("bookName", bookName), + zap.Int("entriesCount", len(entries))) + + return nil +} + +// convertToWorldInfoEntry 将角色卡中的世界书条目转换为标准格式 +func convertToWorldInfoEntry(entryMap map[string]interface{}, index int) app.AIWorldInfoEntry { + entry := app.AIWorldInfoEntry{ + UID: getStringValue(entryMap, "uid", fmt.Sprintf("entry_%d", index)), + Enabled: getBoolValue(entryMap, "enabled", true), + Order: getIntValue(entryMap, "insertion_order", index), + Content: getStringValue(entryMap, "content", ""), + Comment: getStringValue(entryMap, "comment", ""), + } + + // 解析关键词 + if keys, ok := entryMap["keys"].([]interface{}); ok { + entry.Keys = convertToStringArray(keys) + } + + if secondaryKeys, ok := entryMap["secondary_keys"].([]interface{}); ok { + entry.SecondaryKeys = convertToStringArray(secondaryKeys) + } + + // 高级选项 + entry.Constant = getBoolValue(entryMap, "constant", false) + entry.Selective = getBoolValue(entryMap, "selective", false) + entry.Position = getStringValue(entryMap, "position", "before_char") + + if depth, ok := entryMap["depth"].(float64); ok { + entry.Depth = int(depth) + } + + // 概率设置 + entry.UseProbability = getBoolValue(entryMap, "use_probability", false) + if prob, ok := entryMap["probability"].(float64); ok { + entry.Probability = int(prob) + } + + // 分组设置 + entry.Group = getStringValue(entryMap, "group", "") + entry.GroupOverride = getBoolValue(entryMap, "group_override", false) + if weight, ok := entryMap["group_weight"].(float64); ok { + entry.GroupWeight = int(weight) + } + + // 递归设置 + entry.PreventRecursion = getBoolValue(entryMap, "prevent_recursion", false) + entry.DelayUntilRecursion = getBoolValue(entryMap, "delay_until_recursion", false) + + // 扫描深度 + if scanDepth, ok := entryMap["scan_depth"].(float64); ok { + depth := int(scanDepth) + entry.ScanDepth = &depth + } + + // 匹配选项 + if caseSensitive, ok := entryMap["case_sensitive"].(bool); ok { + entry.CaseSensitive = &caseSensitive + } + if matchWholeWords, ok := entryMap["match_whole_words"].(bool); ok { + entry.MatchWholeWords = &matchWholeWords + } + if useRegex, ok := entryMap["use_regex"].(bool); ok { + entry.UseRegex = &useRegex + } + + // 其他字段 + entry.Automation = getStringValue(entryMap, "automation_id", "") + entry.Role = getStringValue(entryMap, "role", "") + entry.VectorizedContent = getStringValue(entryMap, "vectorized", "") + + // 扩展数据 + if extensions, ok := entryMap["extensions"].(map[string]interface{}); ok { + entry.Extensions = extensions + } + + return entry +} + +// 辅助函数:从 map 中安全获取字符串值 +func getStringValue(m map[string]interface{}, key, defaultValue string) string { + if val, ok := m[key].(string); ok { + return val + } + return defaultValue +} + +// 辅助函数:从 map 中安全获取布尔值 +func getBoolValue(m map[string]interface{}, key string, defaultValue bool) bool { + if val, ok := m[key].(bool); ok { + return val + } + return defaultValue +} + +// 辅助函数:从 map 中安全获取整数值 +func getIntValue(m map[string]interface{}, key string, defaultValue int) int { + if val, ok := m[key].(float64); ok { + return int(val) + } + return defaultValue +} + +// 辅助函数:将 []interface{} 转换为 []string +func convertToStringArray(arr []interface{}) []string { + result := make([]string, 0, len(arr)) + for _, item := range arr { + if str, ok := item.(string); ok { + result = append(result, str) + } + } + return result +} + // convertCardToCreateRequest 将角色卡转换为创建请求 func convertCardToCreateRequest(card *utils.CharacterCardV2, avatarData []byte, isPublic bool) request.CreateCharacterRequest { // 处理示例消息 @@ -706,8 +1226,83 @@ func convertCardToCreateRequest(card *utils.CharacterCardV2, avatarData []byte, } } +// exportLinkedWorldBook 导出角色关联的世界书数据 +func (cs *CharacterService) exportLinkedWorldBook(characterID uint) map[string]interface{} { + // 查找关联的世界书 + var worldBooks []app.AIWorldInfo + err := global.GVA_DB. + Where("? = ANY(linked_chars)", fmt.Sprintf("%d", characterID)). + Find(&worldBooks).Error + + if err != nil || len(worldBooks) == 0 { + return nil + } + + // 合并所有世界书的条目 + var allEntries []app.AIWorldInfoEntry + var bookName string + + for _, book := range worldBooks { + var entries []app.AIWorldInfoEntry + if err := json.Unmarshal([]byte(book.Entries), &entries); err != nil { + continue + } + allEntries = append(allEntries, entries...) + if bookName == "" { + bookName = book.BookName + } + } + + if len(allEntries) == 0 { + return nil + } + + // 转换为 CharacterBook 格式 + entriesData := make([]map[string]interface{}, 0, len(allEntries)) + for _, entry := range allEntries { + entryMap := map[string]interface{}{ + "uid": entry.UID, + "keys": entry.Keys, + "secondary_keys": entry.SecondaryKeys, + "content": entry.Content, + "comment": entry.Comment, + "enabled": entry.Enabled, + "constant": entry.Constant, + "selective": entry.Selective, + "insertion_order": entry.Order, + "position": entry.Position, + "depth": entry.Depth, + "use_probability": entry.UseProbability, + "probability": entry.Probability, + "group": entry.Group, + "group_override": entry.GroupOverride, + "group_weight": entry.GroupWeight, + "prevent_recursion": entry.PreventRecursion, + "delay_until_recursion": entry.DelayUntilRecursion, + "scan_depth": entry.ScanDepth, + "case_sensitive": entry.CaseSensitive, + "match_whole_words": entry.MatchWholeWords, + "use_regex": entry.UseRegex, + "automation_id": entry.Automation, + "role": entry.Role, + "vectorized": entry.VectorizedContent, + } + + if entry.Extensions != nil { + entryMap["extensions"] = entry.Extensions + } + + entriesData = append(entriesData, entryMap) + } + + return map[string]interface{}{ + "name": bookName, + "entries": entriesData, + } +} + // convertCharacterToCard 将角色卡转换为 CharacterCardV2 -func convertCharacterToCard(character *app.AICharacter) *utils.CharacterCardV2 { +func (cs *CharacterService) convertCharacterToCard(character *app.AICharacter) *utils.CharacterCardV2 { tags := []string{} if character.Tags != nil { tags = character.Tags @@ -723,18 +1318,28 @@ func convertCharacterToCard(character *app.AICharacter) *utils.CharacterCardV2 { alternateGreetings = character.AlternateGreetings } - // 解析 character_book JSON + // 解析或构建 character_book JSON var characterBook map[string]interface{} if len(character.CharacterBook) > 0 { json.Unmarshal(character.CharacterBook, &characterBook) } - // 解析 extensions JSON + // 如果角色没有内嵌的 CharacterBook,尝试从世界书表中查找关联的世界书 + if characterBook == nil { + characterBook = cs.exportLinkedWorldBook(character.ID) + } + + // 解析或构建 extensions JSON extensions := map[string]interface{}{} if len(character.Extensions) > 0 { json.Unmarshal(character.Extensions, &extensions) } + // 导出关联的正则脚本到 extensions + if regexScripts := cs.exportRegexScripts(character.ID); regexScripts != nil && len(regexScripts) > 0 { + extensions["regex_scripts"] = regexScripts + } + return &utils.CharacterCardV2{ Spec: "chara_card_v2", SpecVersion: "2.0", diff --git a/server/service/app/enter.go b/server/service/app/enter.go index 4b2f195..0c43839 100644 --- a/server/service/app/enter.go +++ b/server/service/app/enter.go @@ -4,4 +4,6 @@ type AppServiceGroup struct { AuthService CharacterService WorldInfoService + ExtensionService + RegexScriptService } diff --git a/server/service/app/extension.go b/server/service/app/extension.go new file mode 100644 index 0000000..484fd65 --- /dev/null +++ b/server/service/app/extension.go @@ -0,0 +1,803 @@ +package app + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "git.echol.cn/loser/st/server/global" + "git.echol.cn/loser/st/server/model/app" + "git.echol.cn/loser/st/server/model/app/request" + "git.echol.cn/loser/st/server/model/app/response" + "go.uber.org/zap" + "gorm.io/datatypes" + "gorm.io/gorm" +) + +type ExtensionService struct{} + +// CreateExtension 创建/安装扩展 +func (es *ExtensionService) CreateExtension(userID uint, req *request.CreateExtensionRequest) (*app.AIExtension, error) { + // 检查扩展是否已存在 + var existing app.AIExtension + err := global.GVA_DB.Where("user_id = ? AND name = ?", userID, req.Name).First(&existing).Error + if err == nil { + return nil, errors.New("扩展已存在") + } + if err != gorm.ErrRecordNotFound { + return nil, err + } + + // 序列化 JSON 字段 + tagsJSON, _ := json.Marshal(req.Tags) + dependenciesJSON, _ := json.Marshal(req.Dependencies) + conflictsJSON, _ := json.Marshal(req.Conflicts) + manifestJSON, _ := json.Marshal(req.ManifestData) + assetsJSON, _ := json.Marshal(req.AssetsPaths) + settingsJSON, _ := json.Marshal(req.Settings) + optionsJSON, _ := json.Marshal(req.Options) + metadataJSON, _ := json.Marshal(req.Metadata) + + extension := &app.AIExtension{ + UserID: userID, + Name: req.Name, + DisplayName: req.DisplayName, + Version: req.Version, + Author: req.Author, + Description: req.Description, + Homepage: req.Homepage, + Repository: req.Repository, + License: req.License, + Tags: datatypes.JSON(tagsJSON), + ExtensionType: req.ExtensionType, + Category: req.Category, + Dependencies: datatypes.JSON(dependenciesJSON), + Conflicts: datatypes.JSON(conflictsJSON), + ManifestData: datatypes.JSON(manifestJSON), + ScriptPath: req.ScriptPath, + StylePath: req.StylePath, + AssetsPaths: datatypes.JSON(assetsJSON), + Settings: datatypes.JSON(settingsJSON), + Options: datatypes.JSON(optionsJSON), + IsEnabled: false, + IsInstalled: true, + IsSystemExt: false, + InstallSource: req.InstallSource, + SourceURL: req.SourceURL, + Branch: req.Branch, + AutoUpdate: req.AutoUpdate, + InstallDate: time.Now(), + Metadata: datatypes.JSON(metadataJSON), + } + + if err := global.GVA_DB.Create(extension).Error; err != nil { + return nil, err + } + + global.GVA_LOG.Info("扩展安装成功", zap.Uint("extensionID", extension.ID), zap.String("name", extension.Name)) + return extension, nil +} + +// UpdateExtension 更新扩展 +func (es *ExtensionService) UpdateExtension(userID, extensionID uint, req *request.UpdateExtensionRequest) error { + var extension app.AIExtension + if err := global.GVA_DB.Where("id = ? AND user_id = ?", extensionID, userID).First(&extension).Error; err != nil { + return errors.New("扩展不存在") + } + + // 系统内置扩展不允许修改 + if extension.IsSystemExt { + return errors.New("系统内置扩展不允许修改") + } + + updates := map[string]interface{}{} + if req.DisplayName != "" { + updates["display_name"] = req.DisplayName + } + if req.Description != "" { + updates["description"] = req.Description + } + if req.Settings != nil { + settingsJSON, _ := json.Marshal(req.Settings) + updates["settings"] = datatypes.JSON(settingsJSON) + } + if req.Options != nil { + optionsJSON, _ := json.Marshal(req.Options) + updates["options"] = datatypes.JSON(optionsJSON) + } + if req.Metadata != nil { + metadataJSON, _ := json.Marshal(req.Metadata) + updates["metadata"] = datatypes.JSON(metadataJSON) + } + + if err := global.GVA_DB.Model(&extension).Updates(updates).Error; err != nil { + return err + } + + return nil +} + +// DeleteExtension 删除/卸载扩展 +func (es *ExtensionService) DeleteExtension(userID, extensionID uint, deleteFiles bool) error { + var extension app.AIExtension + if err := global.GVA_DB.Where("id = ? AND user_id = ?", extensionID, userID).First(&extension).Error; err != nil { + return errors.New("扩展不存在") + } + + // 系统内置扩展不允许删除 + if extension.IsSystemExt { + return errors.New("系统内置扩展不允许删除") + } + + // TODO: 如果 deleteFiles=true,删除扩展文件 + // 这需要文件系统支持 + + // 删除扩展(配置已经在扩展记录的 Settings 字段中,无需单独删除) + if err := global.GVA_DB.Delete(&extension).Error; err != nil { + return err + } + + global.GVA_LOG.Info("扩展卸载成功", zap.Uint("extensionID", extensionID)) + return nil +} + +// GetExtension 获取扩展详情 +func (es *ExtensionService) GetExtension(userID, extensionID uint) (*app.AIExtension, error) { + var extension app.AIExtension + if err := global.GVA_DB.Where("id = ? AND user_id = ?", extensionID, userID).First(&extension).Error; err != nil { + return nil, errors.New("扩展不存在") + } + return &extension, nil +} + +// GetExtensionList 获取扩展列表 +func (es *ExtensionService) GetExtensionList(userID uint, req *request.ExtensionListRequest) (*response.ExtensionListResponse, error) { + var extensions []app.AIExtension + var total int64 + + db := global.GVA_DB.Model(&app.AIExtension{}).Where("user_id = ?", userID) + + // 过滤条件 + if req.Name != "" { + db = db.Where("name ILIKE ? OR display_name ILIKE ?", "%"+req.Name+"%", "%"+req.Name+"%") + } + if req.ExtensionType != "" { + db = db.Where("extension_type = ?", req.ExtensionType) + } + if req.Category != "" { + db = db.Where("category = ?", req.Category) + } + if req.IsEnabled != nil { + db = db.Where("is_enabled = ?", *req.IsEnabled) + } + if req.IsInstalled != nil { + db = db.Where("is_installed = ?", *req.IsInstalled) + } + if req.Tag != "" { + db = db.Where("tags @> ?", fmt.Sprintf(`["%s"]`, req.Tag)) + } + + // 统计总数 + if err := db.Count(&total).Error; err != nil { + return nil, err + } + + // 分页查询 + if err := db.Scopes(req.Paginate()).Order("created_at DESC").Find(&extensions).Error; err != nil { + return nil, err + } + + // 转换响应 + result := make([]response.ExtensionResponse, 0, len(extensions)) + for i := range extensions { + result = append(result, response.ToExtensionResponse(&extensions[i])) + } + + return &response.ExtensionListResponse{ + List: result, + Total: total, + Page: req.Page, + PageSize: req.PageSize, + }, nil +} + +// ToggleExtension 启用/禁用扩展 +func (es *ExtensionService) ToggleExtension(userID, extensionID uint, isEnabled bool) error { + var extension app.AIExtension + if err := global.GVA_DB.Where("id = ? AND user_id = ?", extensionID, userID).First(&extension).Error; err != nil { + return errors.New("扩展不存在") + } + + // 检查依赖 + if isEnabled { + if err := es.checkDependencies(userID, &extension); err != nil { + return err + } + } + + // 检查冲突 + if isEnabled { + if err := es.checkConflicts(userID, &extension); err != nil { + return err + } + } + + updates := map[string]interface{}{ + "is_enabled": isEnabled, + } + if isEnabled { + updates["last_enabled"] = time.Now() + } + + if err := global.GVA_DB.Model(&extension).Updates(updates).Error; err != nil { + return err + } + + global.GVA_LOG.Info("扩展状态更新", zap.Uint("extensionID", extensionID), zap.Bool("enabled", isEnabled)) + return nil +} + +// UpdateExtensionSettings 更新扩展配置 +func (es *ExtensionService) UpdateExtensionSettings(userID, extensionID uint, settings map[string]interface{}) error { + var extension app.AIExtension + if err := global.GVA_DB.Where("id = ? AND user_id = ?", extensionID, userID).First(&extension).Error; err != nil { + return errors.New("扩展不存在") + } + + settingsJSON, err := json.Marshal(settings) + if err != nil { + return errors.New("序列化配置失败") + } + + // 直接更新扩展表的 settings 字段 + return global.GVA_DB.Model(&extension).Update("settings", datatypes.JSON(settingsJSON)).Error +} + +// GetExtensionSettings 获取扩展配置 +func (es *ExtensionService) GetExtensionSettings(userID, extensionID uint) (map[string]interface{}, error) { + // 获取扩展信息 + var extension app.AIExtension + if err := global.GVA_DB.Where("id = ? AND user_id = ?", extensionID, userID).First(&extension).Error; err != nil { + return nil, errors.New("扩展不存在") + } + + // 从扩展的 Settings 字段读取用户配置 + var settings map[string]interface{} + if len(extension.Settings) > 0 { + if err := json.Unmarshal([]byte(extension.Settings), &settings); err != nil { + return nil, errors.New("解析配置失败: " + err.Error()) + } + } + + // 如果 ManifestData 中有默认配置,合并进来 + if len(extension.ManifestData) > 0 { + var manifest map[string]interface{} + if err := json.Unmarshal([]byte(extension.ManifestData), &manifest); err == nil { + if manifestSettings, ok := manifest["settings"].(map[string]interface{}); ok && manifestSettings != nil { + // 只添加用户未设置的默认值 + if settings == nil { + settings = make(map[string]interface{}) + } + for k, v := range manifestSettings { + if _, exists := settings[k]; !exists { + settings[k] = v + } + } + } + } + } + + if settings == nil { + settings = make(map[string]interface{}) + } + + return settings, nil +} + +// UpdateExtensionStats 更新扩展统计 +func (es *ExtensionService) UpdateExtensionStats(userID, extensionID uint, action string, value int) error { + var extension app.AIExtension + if err := global.GVA_DB.Where("id = ? AND user_id = ?", extensionID, userID).First(&extension).Error; err != nil { + return errors.New("扩展不存在") + } + + updates := map[string]interface{}{} + switch action { + case "usage": + updates["usage_count"] = gorm.Expr("usage_count + ?", value) + case "error": + updates["error_count"] = gorm.Expr("error_count + ?", value) + case "load": + // 计算平均加载时间 + newAvg := (extension.LoadTime*extension.UsageCount + value) / (extension.UsageCount + 1) + updates["load_time"] = newAvg + default: + return errors.New("未知的统计类型") + } + + return global.GVA_DB.Model(&extension).Updates(updates).Error +} + +// GetExtensionManifest 获取扩展 manifest +func (es *ExtensionService) GetExtensionManifest(userID, extensionID uint) (*response.ExtensionManifestResponse, error) { + var extension app.AIExtension + if err := global.GVA_DB.Where("id = ? AND user_id = ?", extensionID, userID).First(&extension).Error; err != nil { + return nil, errors.New("扩展不存在") + } + + var manifestData map[string]interface{} + if extension.ManifestData != nil { + _ = json.Unmarshal([]byte(extension.ManifestData), &manifestData) + } + + // 从 manifestData 构建响应 + manifest := &response.ExtensionManifestResponse{ + Name: extension.Name, + DisplayName: extension.DisplayName, + Version: extension.Version, + Description: extension.Description, + Author: extension.Author, + Homepage: extension.Homepage, + Repository: extension.Repository, + License: extension.License, + Type: extension.ExtensionType, + Category: extension.Category, + Entry: extension.ScriptPath, + Style: extension.StylePath, + } + + // 解析数组和对象 + if extension.Tags != nil { + _ = json.Unmarshal([]byte(extension.Tags), &manifest.Tags) + } + if extension.Dependencies != nil { + _ = json.Unmarshal([]byte(extension.Dependencies), &manifest.Dependencies) + } + if extension.Conflicts != nil { + _ = json.Unmarshal([]byte(extension.Conflicts), &manifest.Conflicts) + } + if extension.AssetsPaths != nil { + _ = json.Unmarshal([]byte(extension.AssetsPaths), &manifest.Assets) + } + if extension.Settings != nil { + _ = json.Unmarshal([]byte(extension.Settings), &manifest.Settings) + } + if extension.Options != nil { + _ = json.Unmarshal([]byte(extension.Options), &manifest.Options) + } + if extension.Metadata != nil { + _ = json.Unmarshal([]byte(extension.Metadata), &manifest.Metadata) + } + + return manifest, nil +} + +// ImportExtension 导入扩展(从文件) +func (es *ExtensionService) ImportExtension(userID uint, manifestData []byte) (*app.AIExtension, error) { + // 解析 manifest.json + var manifest app.AIExtensionManifest + if err := json.Unmarshal(manifestData, &manifest); err != nil { + return nil, errors.New("无效的 manifest.json 格式") + } + + // 验证必填字段 + if manifest.Name == "" || manifest.Version == "" { + return nil, errors.New("manifest 缺少必填字段") + } + + // 构建创建请求 + req := &request.CreateExtensionRequest{ + Name: manifest.Name, + DisplayName: manifest.DisplayName, + Version: manifest.Version, + Author: manifest.Author, + Description: manifest.Description, + Homepage: manifest.Homepage, + Repository: manifest.Repository, + License: manifest.License, + Tags: manifest.Tags, + ExtensionType: manifest.Type, + Category: manifest.Category, + Dependencies: manifest.Dependencies, + Conflicts: manifest.Conflicts, + ScriptPath: manifest.Entry, + StylePath: manifest.Style, + AssetsPaths: manifest.Assets, + Settings: manifest.Settings, + Options: manifest.Options, + InstallSource: "file", + Metadata: manifest.Metadata, + } + + // 将 manifest 原始数据也保存 + var manifestMap map[string]interface{} + _ = json.Unmarshal(manifestData, &manifestMap) + req.ManifestData = manifestMap + + return es.CreateExtension(userID, req) +} + +// ExportExtension 导出扩展 +func (es *ExtensionService) ExportExtension(userID, extensionID uint) ([]byte, error) { + manifest, err := es.GetExtensionManifest(userID, extensionID) + if err != nil { + return nil, err + } + + return json.MarshalIndent(manifest, "", " ") +} + +// checkDependencies 检查扩展依赖 +func (es *ExtensionService) checkDependencies(userID uint, extension *app.AIExtension) error { + if extension.Dependencies == nil || len(extension.Dependencies) == 0 { + return nil + } + + var dependencies map[string]string + _ = json.Unmarshal([]byte(extension.Dependencies), &dependencies) + + for depName := range dependencies { + var depExt app.AIExtension + err := global.GVA_DB.Where("user_id = ? AND name = ? AND is_enabled = true", userID, depName).First(&depExt).Error + if err != nil { + return fmt.Errorf("缺少依赖扩展: %s", depName) + } + // TODO: 检查版本号是否满足要求 + } + + return nil +} + +// checkConflicts 检查扩展冲突 +func (es *ExtensionService) checkConflicts(userID uint, extension *app.AIExtension) error { + if extension.Conflicts == nil || len(extension.Conflicts) == 0 { + return nil + } + + var conflicts []string + _ = json.Unmarshal([]byte(extension.Conflicts), &conflicts) + + for _, conflictName := range conflicts { + var conflictExt app.AIExtension + err := global.GVA_DB.Where("user_id = ? AND name = ? AND is_enabled = true", userID, conflictName).First(&conflictExt).Error + if err == nil { + return fmt.Errorf("扩展 %s 与 %s 冲突", extension.Name, conflictName) + } + } + + return nil +} + +// GetEnabledExtensions 获取用户启用的所有扩展(用于前端加载) +func (es *ExtensionService) GetEnabledExtensions(userID uint) ([]response.ExtensionResponse, error) { + var extensions []app.AIExtension + if err := global.GVA_DB.Where("user_id = ? AND is_enabled = true AND is_installed = true", userID). + Order("created_at ASC").Find(&extensions).Error; err != nil { + return nil, err + } + + result := make([]response.ExtensionResponse, 0, len(extensions)) + for i := range extensions { + result = append(result, response.ToExtensionResponse(&extensions[i])) + } + + return result, nil +} + +// InstallExtensionFromURL 智能安装扩展(自动识别 Git URL 或 Manifest URL) +func (es *ExtensionService) InstallExtensionFromURL(userID uint, url string, branch string) (*app.AIExtension, error) { + global.GVA_LOG.Info("开始从 URL 安装扩展", zap.String("url", url), zap.String("branch", branch)) + + // 智能识别 URL 类型 + if isGitURL(url) { + global.GVA_LOG.Info("检测到 Git 仓库 URL,使用 Git 安装") + if branch == "" { + branch = "main" + } + return es.InstallExtensionFromGit(userID, url, branch) + } + + // 否则作为 manifest.json URL 处理 + global.GVA_LOG.Info("作为 Manifest URL 处理") + return es.downloadAndInstallFromManifestURL(userID, url) +} + +// isGitURL 判断是否为 Git 仓库 URL +func isGitURL(url string) bool { + // Git 仓库特征: + // 1. 包含 .git 后缀 + // 2. 包含常见的 Git 托管平台域名(github.com, gitlab.com, gitee.com 等) + // 3. 不以 /manifest.json 或 .json 结尾 + + url = strings.ToLower(url) + + // 如果明确以 .json 结尾,不是 Git URL + if strings.HasSuffix(url, ".json") { + return false + } + + // 如果包含 .git 后缀,是 Git URL + if strings.HasSuffix(url, ".git") { + return true + } + + // 检查是否包含 Git 托管平台域名 + gitHosts := []string{ + "github.com", + "gitlab.com", + "gitee.com", + "bitbucket.org", + "gitea.io", + "codeberg.org", + } + + for _, host := range gitHosts { + if strings.Contains(url, host) { + // 如果包含 Git 平台且不是 raw 文件 URL,则认为是 Git 仓库 + if !strings.Contains(url, "/raw/") && !strings.Contains(url, "/blob/") { + return true + } + } + } + + return false +} + +// downloadAndInstallFromManifestURL 从 Manifest URL 下载并安装 +func (es *ExtensionService) downloadAndInstallFromManifestURL(userID uint, manifestURL string) (*app.AIExtension, error) { + // 创建 HTTP 客户端 + client := &http.Client{ + Timeout: 30 * time.Second, + } + + // 下载 manifest.json + resp, err := client.Get(manifestURL) + if err != nil { + return nil, fmt.Errorf("下载 manifest.json 失败: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("下载 manifest.json 失败: HTTP %d", resp.StatusCode) + } + + // 读取响应内容 + manifestData, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("读取 manifest.json 失败: %w", err) + } + + // 解析 manifest + var manifest app.AIExtensionManifest + if err := json.Unmarshal(manifestData, &manifest); err != nil { + return nil, fmt.Errorf("解析 manifest.json 失败: %w", err) + } + + // 验证必填字段 + if manifest.Name == "" { + return nil, errors.New("manifest.json 缺少 name 字段") + } + + // 检查扩展是否已存在 + var existing app.AIExtension + err = global.GVA_DB.Where("user_id = ? AND name = ?", userID, manifest.Name).First(&existing).Error + if err == nil { + return nil, fmt.Errorf("扩展 %s 已安装", manifest.Name) + } + if err != gorm.ErrRecordNotFound { + return nil, err + } + + // 将 manifest 转换为 map[string]interface{} + var manifestMap map[string]interface{} + if err := json.Unmarshal(manifestData, &manifestMap); err != nil { + return nil, fmt.Errorf("转换 manifest 失败: %w", err) + } + + // 构建创建请求 + createReq := &request.CreateExtensionRequest{ + Name: manifest.Name, + DisplayName: manifest.DisplayName, + Version: manifest.Version, + Author: manifest.Author, + Description: manifest.Description, + Homepage: manifest.Homepage, + Repository: manifest.Repository, // 使用 manifest 中的 repository + License: manifest.License, + Tags: manifest.Tags, + ExtensionType: manifest.Type, + Category: manifest.Category, + Dependencies: manifest.Dependencies, + Conflicts: manifest.Conflicts, + ManifestData: manifestMap, + ScriptPath: manifest.Entry, + StylePath: manifest.Style, + AssetsPaths: manifest.Assets, + Settings: manifest.Settings, + Options: manifest.Options, + InstallSource: "url", + SourceURL: manifestURL, // 记录原始 URL 用于更新 + AutoUpdate: manifest.AutoUpdate, + Metadata: nil, + } + + // 确保扩展类型有效 + if createReq.ExtensionType == "" { + createReq.ExtensionType = "ui" + } + + // 创建扩展 + extension, err := es.CreateExtension(userID, createReq) + if err != nil { + return nil, fmt.Errorf("创建扩展失败: %w", err) + } + + global.GVA_LOG.Info("从 URL 安装扩展成功", + zap.Uint("extensionID", extension.ID), + zap.String("name", extension.Name), + zap.String("url", manifestURL)) + + return extension, nil +} + +// UpgradeExtension 升级扩展版本(根据安装来源自动选择更新方式) +func (es *ExtensionService) UpgradeExtension(userID, extensionID uint, force bool) (*app.AIExtension, error) { + // 获取扩展信息 + var extension app.AIExtension + if err := global.GVA_DB.Where("id = ? AND user_id = ?", extensionID, userID).First(&extension).Error; err != nil { + return nil, errors.New("扩展不存在") + } + + global.GVA_LOG.Info("开始升级扩展", + zap.Uint("extensionID", extensionID), + zap.String("name", extension.Name), + zap.String("installSource", extension.InstallSource), + zap.String("sourceUrl", extension.SourceURL)) + + // 根据安装来源选择更新方式 + switch extension.InstallSource { + case "git": + return es.updateExtensionFromGit(userID, &extension, force) + case "url": + return es.updateExtensionFromURL(userID, &extension) + default: + return nil, fmt.Errorf("不支持的安装来源: %s", extension.InstallSource) + } +} + +// updateExtensionFromGit 从 Git 仓库更新扩展 +func (es *ExtensionService) updateExtensionFromGit(userID uint, extension *app.AIExtension, force bool) (*app.AIExtension, error) { + if extension.SourceURL == "" { + return nil, errors.New("缺少 Git 仓库 URL") + } + + global.GVA_LOG.Info("从 Git 更新扩展", + zap.String("name", extension.Name), + zap.String("sourceUrl", extension.SourceURL), + zap.String("branch", extension.Branch)) + + // 重新克隆(简单方式,避免处理本地修改) + return es.InstallExtensionFromGit(userID, extension.SourceURL, extension.Branch) +} + +// updateExtensionFromURL 从 URL 更新扩展(重新下载 manifest.json) +func (es *ExtensionService) updateExtensionFromURL(userID uint, extension *app.AIExtension) (*app.AIExtension, error) { + if extension.SourceURL == "" { + return nil, errors.New("缺少 Manifest URL") + } + + global.GVA_LOG.Info("从 URL 更新扩展", + zap.String("name", extension.Name), + zap.String("sourceUrl", extension.SourceURL)) + + // 重新下载并安装 + return es.downloadAndInstallFromManifestURL(userID, extension.SourceURL) +} + +// InstallExtensionFromGit 从 Git URL 安装扩展 +func (es *ExtensionService) InstallExtensionFromGit(userID uint, gitUrl, branch string) (*app.AIExtension, error) { + // 验证 Git URL + if !strings.Contains(gitUrl, "://") && !strings.HasSuffix(gitUrl, ".git") { + return nil, errors.New("无效的 Git URL") + } + + // 创建临时目录 + tempDir, err := os.MkdirTemp("", "extension-*") + if err != nil { + return nil, fmt.Errorf("创建临时目录失败: %w", err) + } + defer os.RemoveAll(tempDir) // 确保清理临时目录 + + global.GVA_LOG.Info("开始从 Git 克隆扩展", + zap.String("gitUrl", gitUrl), + zap.String("branch", branch), + zap.String("tempDir", tempDir)) + + // 执行 git clone + cmd := exec.Command("git", "clone", "--depth=1", "--branch="+branch, gitUrl, tempDir) + output, err := cmd.CombinedOutput() + if err != nil { + global.GVA_LOG.Error("Git clone 失败", + zap.String("gitUrl", gitUrl), + zap.String("output", string(output)), + zap.Error(err)) + return nil, fmt.Errorf("Git clone 失败: %s", string(output)) + } + + // 读取 manifest.json + manifestPath := filepath.Join(tempDir, "manifest.json") + manifestData, err := os.ReadFile(manifestPath) + if err != nil { + return nil, fmt.Errorf("读取 manifest.json 失败: %w", err) + } + + // 解析 manifest + var manifest app.AIExtensionManifest + if err := json.Unmarshal(manifestData, &manifest); err != nil { + return nil, fmt.Errorf("解析 manifest.json 失败: %w", err) + } + + // 检查扩展是否已存在 + var existing app.AIExtension + err = global.GVA_DB.Where("user_id = ? AND name = ?", userID, manifest.Name).First(&existing).Error + if err == nil { + return nil, fmt.Errorf("扩展 %s 已安装", manifest.Name) + } + if err != gorm.ErrRecordNotFound { + return nil, err + } + + // 将 manifest 转换为 map[string]interface{} + var manifestMap map[string]interface{} + if err := json.Unmarshal(manifestData, &manifestMap); err != nil { + return nil, fmt.Errorf("转换 manifest 失败: %w", err) + } + + // 构建创建请求 + createReq := &request.CreateExtensionRequest{ + Name: manifest.Name, + DisplayName: manifest.DisplayName, + Version: manifest.Version, + Author: manifest.Author, + Description: manifest.Description, + Homepage: manifest.Homepage, + Repository: manifest.Repository, // 使用 manifest 中的 repository + License: manifest.License, + Tags: manifest.Tags, + ExtensionType: manifest.Type, + Category: manifest.Category, + Dependencies: manifest.Dependencies, + Conflicts: manifest.Conflicts, + ManifestData: manifestMap, + ScriptPath: manifest.Entry, + StylePath: manifest.Style, + AssetsPaths: manifest.Assets, + Settings: manifest.Settings, + Options: manifest.Options, + InstallSource: "git", + SourceURL: gitUrl, // 记录 Git URL 用于更新 + Branch: branch, // 记录分支 + AutoUpdate: manifest.AutoUpdate, + Metadata: manifest.Metadata, + } + + // 创建扩展记录 + extension, err := es.CreateExtension(userID, createReq) + if err != nil { + return nil, fmt.Errorf("创建扩展记录失败: %w", err) + } + + global.GVA_LOG.Info("从 Git 安装扩展成功", + zap.Uint("extensionID", extension.ID), + zap.String("name", extension.Name), + zap.String("version", extension.Version)) + + return extension, nil +} diff --git a/server/service/app/regex_script.go b/server/service/app/regex_script.go new file mode 100644 index 0000000..23f316c --- /dev/null +++ b/server/service/app/regex_script.go @@ -0,0 +1,476 @@ +package app + +import ( + "errors" + "fmt" + "regexp" + "strings" + "time" + + "git.echol.cn/loser/st/server/global" + "git.echol.cn/loser/st/server/model/app" + "git.echol.cn/loser/st/server/model/app/request" + "git.echol.cn/loser/st/server/model/app/response" + "github.com/lib/pq" + "go.uber.org/zap" + "gorm.io/datatypes" + "gorm.io/gorm" +) + +type RegexScriptService struct{} + +// CreateRegexScript 创建正则脚本 +func (rs *RegexScriptService) CreateRegexScript(userID uint, req *request.CreateRegexScriptRequest) (*app.AIRegexScript, error) { + // 验证正则表达式 + if _, err := regexp.Compile(req.FindRegex); err != nil { + return nil, errors.New("无效的正则表达式: " + err.Error()) + } + + // 序列化 ScriptData + var scriptDataJSON datatypes.JSON + if req.ScriptData != nil { + data, err := datatypes.NewJSONType(req.ScriptData).MarshalJSON() + if err != nil { + return nil, errors.New("序列化脚本数据失败: " + err.Error()) + } + scriptDataJSON = data + } + + linkedChars := pq.StringArray{} + if req.LinkedChars != nil { + linkedChars = req.LinkedChars + } + + script := &app.AIRegexScript{ + UserID: userID, + ScriptName: req.ScriptName, + Description: req.Description, + FindRegex: req.FindRegex, + ReplaceString: req.ReplaceString, + Enabled: req.Enabled, + IsGlobal: req.IsGlobal, + TrimStrings: req.TrimStrings, + OnlyFormat: req.OnlyFormat, + RunOnEdit: req.RunOnEdit, + SubstituteRegex: req.SubstituteRegex, + MinDepth: req.MinDepth, + MaxDepth: req.MaxDepth, + Placement: req.Placement, + AffectMinDepth: req.AffectMinDepth, + AffectMaxDepth: req.AffectMaxDepth, + LinkedChars: linkedChars, + ScriptData: scriptDataJSON, + } + + if err := global.GVA_DB.Create(script).Error; err != nil { + return nil, err + } + + return script, nil +} + +// UpdateRegexScript 更新正则脚本 +func (rs *RegexScriptService) UpdateRegexScript(userID, scriptID uint, req *request.UpdateRegexScriptRequest) error { + // 查询脚本 + var script app.AIRegexScript + if err := global.GVA_DB.Where("id = ? AND user_id = ?", scriptID, userID).First(&script).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("脚本不存在") + } + return err + } + + // 验证正则表达式 + if req.FindRegex != "" { + if _, err := regexp.Compile(req.FindRegex); err != nil { + return errors.New("无效的正则表达式: " + err.Error()) + } + script.FindRegex = req.FindRegex + } + + // 更新字段 + if req.ScriptName != "" { + script.ScriptName = req.ScriptName + } + if req.Description != "" { + script.Description = req.Description + } + if req.ReplaceString != "" { + script.ReplaceString = req.ReplaceString + } + if req.Enabled != nil { + script.Enabled = *req.Enabled + } + if req.IsGlobal != nil { + script.IsGlobal = *req.IsGlobal + } + if req.TrimStrings != nil { + script.TrimStrings = *req.TrimStrings + } + if req.OnlyFormat != nil { + script.OnlyFormat = *req.OnlyFormat + } + if req.RunOnEdit != nil { + script.RunOnEdit = *req.RunOnEdit + } + if req.SubstituteRegex != nil { + script.SubstituteRegex = *req.SubstituteRegex + } + if req.MinDepth != nil { + script.MinDepth = req.MinDepth + } + if req.MaxDepth != nil { + script.MaxDepth = req.MaxDepth + } + if req.Placement != "" { + script.Placement = req.Placement + } + if req.AffectMinDepth != nil { + script.AffectMinDepth = req.AffectMinDepth + } + if req.AffectMaxDepth != nil { + script.AffectMaxDepth = req.AffectMaxDepth + } + if req.LinkedChars != nil { + script.LinkedChars = req.LinkedChars + } + if req.ScriptData != nil { + data, err := datatypes.NewJSONType(req.ScriptData).MarshalJSON() + if err != nil { + return errors.New("序列化脚本数据失败: " + err.Error()) + } + script.ScriptData = data + } + + return global.GVA_DB.Save(&script).Error +} + +// DeleteRegexScript 删除正则脚本 +func (rs *RegexScriptService) DeleteRegexScript(userID, scriptID uint) error { + result := global.GVA_DB.Where("id = ? AND user_id = ?", scriptID, userID).Delete(&app.AIRegexScript{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return errors.New("脚本不存在") + } + return nil +} + +// GetRegexScript 获取正则脚本详情 +func (rs *RegexScriptService) GetRegexScript(userID, scriptID uint) (*app.AIRegexScript, error) { + var script app.AIRegexScript + if err := global.GVA_DB.Where("id = ? AND user_id = ?", scriptID, userID).First(&script).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("脚本不存在") + } + return nil, err + } + return &script, nil +} + +// GetRegexScriptList 获取正则脚本列表 +func (rs *RegexScriptService) GetRegexScriptList(userID uint, req *request.RegexScriptListRequest) ([]app.AIRegexScript, int64, error) { + db := global.GVA_DB.Where("user_id = ?", userID) + + // 条件筛选 + if req.ScriptName != "" { + db = db.Where("script_name ILIKE ?", "%"+req.ScriptName+"%") + } + if req.IsGlobal != nil { + db = db.Where("is_global = ?", *req.IsGlobal) + } + if req.Enabled != nil { + db = db.Where("enabled = ?", *req.Enabled) + } + if req.CharacterID != nil { + db = db.Where("? = ANY(linked_chars)", fmt.Sprintf("%d", *req.CharacterID)) + } + + // 查询总数 + var total int64 + if err := db.Model(&app.AIRegexScript{}).Count(&total).Error; err != nil { + return nil, 0, err + } + + // 分页查询 + var scripts []app.AIRegexScript + offset := (req.Page - 1) * req.PageSize + if err := db.Order("created_at DESC").Offset(offset).Limit(req.PageSize).Find(&scripts).Error; err != nil { + return nil, 0, err + } + + return scripts, total, nil +} + +// LinkCharactersToRegex 关联角色到正则脚本 +func (rs *RegexScriptService) LinkCharactersToRegex(userID, scriptID uint, characterIDs []uint) error { + // 查询脚本 + var script app.AIRegexScript + if err := global.GVA_DB.Where("id = ? AND user_id = ?", scriptID, userID).First(&script).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("脚本不存在") + } + return err + } + + // 转换为字符串数组 + linkedChars := make([]string, len(characterIDs)) + for i, id := range characterIDs { + linkedChars[i] = fmt.Sprintf("%d", id) + } + + // 更新 LinkedChars + script.LinkedChars = linkedChars + return global.GVA_DB.Save(&script).Error +} + +// GetCharacterRegexScripts 获取角色关联的正则脚本列表 +func (rs *RegexScriptService) GetCharacterRegexScripts(userID, characterID uint) ([]app.AIRegexScript, error) { + var scripts []app.AIRegexScript + err := global.GVA_DB. + Where("user_id = ? AND (is_global = true OR ? = ANY(linked_chars))", userID, fmt.Sprintf("%d", characterID)). + Where("enabled = true"). + Order("created_at ASC"). + Find(&scripts).Error + + return scripts, err +} + +// DuplicateRegexScript 复制正则脚本 +func (rs *RegexScriptService) DuplicateRegexScript(userID, scriptID uint) (*app.AIRegexScript, error) { + // 获取原脚本 + original, err := rs.GetRegexScript(userID, scriptID) + if err != nil { + return nil, err + } + + // 创建副本 + newScript := &app.AIRegexScript{ + UserID: userID, + ScriptName: original.ScriptName + " (副本)", + Description: original.Description, + FindRegex: original.FindRegex, + ReplaceString: original.ReplaceString, + Enabled: original.Enabled, + IsGlobal: false, // 副本默认非全局 + TrimStrings: original.TrimStrings, + OnlyFormat: original.OnlyFormat, + RunOnEdit: original.RunOnEdit, + SubstituteRegex: original.SubstituteRegex, + MinDepth: original.MinDepth, + MaxDepth: original.MaxDepth, + Placement: original.Placement, + AffectMinDepth: original.AffectMinDepth, + AffectMaxDepth: original.AffectMaxDepth, + LinkedChars: original.LinkedChars, + ScriptData: original.ScriptData, + } + + if err := global.GVA_DB.Create(newScript).Error; err != nil { + return nil, err + } + + return newScript, nil +} + +// TestRegexScript 测试正则脚本 +func (rs *RegexScriptService) TestRegexScript(req *request.TestRegexScriptRequest) (*response.TestRegexScriptResponse, error) { + // 编译正则表达式 + re, err := regexp.Compile(req.FindRegex) + if err != nil { + return &response.TestRegexScriptResponse{ + Success: false, + Input: req.TestInput, + Output: req.TestInput, + Error: "无效的正则表达式: " + err.Error(), + }, nil + } + + // 应用替换 + output := req.TestInput + if req.TrimStrings { + output = strings.TrimSpace(output) + } + + // 查找所有匹配 + matches := re.FindAllString(output, -1) + + // 执行替换 + if req.SubstituteRegex { + // 使用正则替换 + output = re.ReplaceAllString(output, req.ReplaceString) + } else { + // 简单字符串替换 + output = re.ReplaceAllLiteralString(output, req.ReplaceString) + } + + return &response.TestRegexScriptResponse{ + Success: true, + Input: req.TestInput, + Output: output, + MatchedCount: len(matches), + Matches: matches, + }, nil +} + +// ApplyRegexScripts 应用正则脚本 +func (rs *RegexScriptService) ApplyRegexScripts(userID uint, req *request.ApplyRegexScriptsRequest) (*response.ApplyRegexScriptsResponse, error) { + var scripts []app.AIRegexScript + + // 收集要应用的脚本 + db := global.GVA_DB.Where("user_id = ? AND enabled = true", userID) + + if len(req.RegexIDs) > 0 { + // 应用指定的脚本 + db = db.Where("id IN ?", req.RegexIDs) + } else { + // 根据条件自动选择脚本 + conditions := []string{} + if req.UseGlobal { + conditions = append(conditions, "is_global = true") + } + if req.CharacterID != nil { + conditions = append(conditions, fmt.Sprintf("'%d' = ANY(linked_chars)", *req.CharacterID)) + } + if len(conditions) > 0 { + db = db.Where(strings.Join(conditions, " OR ")) + } + + // 筛选位置 + if req.Placement != "" { + db = db.Where("(placement = '' OR placement = ?)", req.Placement) + } + } + + if err := db.Order("created_at ASC").Find(&scripts).Error; err != nil { + return nil, err + } + + // 应用脚本 + processedText := req.Text + appliedScripts := []uint{} + + for _, script := range scripts { + // 检查深度限制 + if req.MinDepth != nil && script.MinDepth != nil && *req.MinDepth < *script.MinDepth { + continue + } + if req.MaxDepth != nil && script.MaxDepth != nil && *req.MaxDepth > *script.MaxDepth { + continue + } + + // 编译正则表达式 + re, err := regexp.Compile(script.FindRegex) + if err != nil { + global.GVA_LOG.Warn("正则表达式编译失败", + zap.Uint("scriptID", script.ID), + zap.String("regex", script.FindRegex), + zap.Error(err)) + continue + } + + // 应用替换 + beforeText := processedText + if script.TrimStrings { + processedText = strings.TrimSpace(processedText) + } + + if script.SubstituteRegex { + processedText = re.ReplaceAllString(processedText, script.ReplaceString) + } else { + processedText = re.ReplaceAllLiteralString(processedText, script.ReplaceString) + } + + // 记录成功应用的脚本 + if beforeText != processedText { + appliedScripts = append(appliedScripts, script.ID) + + // 更新使用统计 + now := time.Now().Unix() + global.GVA_DB.Model(&script).Updates(map[string]interface{}{ + "usage_count": gorm.Expr("usage_count + 1"), + "last_used_at": now, + }) + } + } + + return &response.ApplyRegexScriptsResponse{ + OriginalText: req.Text, + ProcessedText: processedText, + AppliedCount: len(appliedScripts), + AppliedScripts: appliedScripts, + }, nil +} + +// ImportRegexScripts 导入正则脚本 +func (rs *RegexScriptService) ImportRegexScripts(userID uint, scripts []app.AIRegexScript, overwriteMode string) (int, error) { + imported := 0 + + for _, script := range scripts { + script.UserID = userID + script.ID = 0 // 重置 ID + + // 检查是否存在同名脚本 + var existing app.AIRegexScript + err := global.GVA_DB.Where("user_id = ? AND script_name = ?", userID, script.ScriptName).First(&existing).Error + + if err == nil { + // 脚本已存在 + switch overwriteMode { + case "skip": + continue + case "overwrite": + script.ID = existing.ID + if err := global.GVA_DB.Save(&script).Error; err != nil { + global.GVA_LOG.Warn("覆盖脚本失败", zap.Error(err)) + continue + } + case "merge": + script.ScriptName = script.ScriptName + " (导入)" + if err := global.GVA_DB.Create(&script).Error; err != nil { + global.GVA_LOG.Warn("合并导入脚本失败", zap.Error(err)) + continue + } + default: + continue + } + } else { + // 新脚本 + if err := global.GVA_DB.Create(&script).Error; err != nil { + global.GVA_LOG.Warn("创建脚本失败", zap.Error(err)) + continue + } + } + + imported++ + } + + return imported, nil +} + +// ExportRegexScripts 导出正则脚本 +func (rs *RegexScriptService) ExportRegexScripts(userID uint, scriptIDs []uint) (*response.RegexScriptExportData, error) { + var scripts []app.AIRegexScript + db := global.GVA_DB.Where("user_id = ?", userID) + + if len(scriptIDs) > 0 { + db = db.Where("id IN ?", scriptIDs) + } + + if err := db.Find(&scripts).Error; err != nil { + return nil, err + } + + // 转换为响应格式 + responses := make([]response.RegexScriptResponse, len(scripts)) + for i, script := range scripts { + responses[i] = response.ToRegexScriptResponse(&script) + } + + return &response.RegexScriptExportData{ + Version: "1.0", + Scripts: responses, + ExportedAt: time.Now().Unix(), + }, nil +} diff --git a/web-app-vue/package-lock.json b/web-app-vue/package-lock.json index 057e04e..2850024 100644 --- a/web-app-vue/package-lock.json +++ b/web-app-vue/package-lock.json @@ -8,10 +8,12 @@ "name": "web-app-vue", "version": "0.0.0", "dependencies": { + "@types/uuid": "^10.0.0", "@vueuse/core": "^14.2.1", "axios": "^1.13.5", "element-plus": "^2.13.2", "pinia": "^3.0.4", + "uuid": "^13.0.0", "vue": "^3.5.25", "vue-router": "^4.6.4" }, @@ -1317,6 +1319,12 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "license": "MIT" + }, "node_modules/@types/web-bluetooth": { "version": "0.0.21", "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", @@ -2846,6 +2854,19 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", diff --git a/web-app-vue/package.json b/web-app-vue/package.json index 8dad7ef..98b6745 100644 --- a/web-app-vue/package.json +++ b/web-app-vue/package.json @@ -9,10 +9,12 @@ "preview": "vite preview" }, "dependencies": { + "@types/uuid": "^10.0.0", "@vueuse/core": "^14.2.1", "axios": "^1.13.5", "element-plus": "^2.13.2", "pinia": "^3.0.4", + "uuid": "^13.0.0", "vue": "^3.5.25", "vue-router": "^4.6.4" }, diff --git a/web-app-vue/src/api/extension.ts b/web-app-vue/src/api/extension.ts new file mode 100644 index 0000000..984b53b --- /dev/null +++ b/web-app-vue/src/api/extension.ts @@ -0,0 +1,103 @@ +import request from '@/utils/request' +import type { + Extension, + ExtensionManifest, + CreateExtensionRequest, + UpdateExtensionRequest, + ExtensionListParams, + ExtensionListResponse, + ToggleExtensionRequest, + UpdateExtensionSettingsRequest, + ExtensionStatsRequest, +} from '@/types/extension' + +// 创建/安装扩展 +export function createExtension(data: CreateExtensionRequest) { + return request.post('/app/extension', data) +} + +// 更新扩展 +export function updateExtension(id: number, data: UpdateExtensionRequest) { + return request.put(`/app/extension/${id}`, data) +} + +// 删除/卸载扩展 +export function deleteExtension(id: number, deleteFiles = false) { + return request.delete(`/app/extension/${id}`, { params: { deleteFiles } }) +} + +// 获取扩展详情 +export function getExtension(id: number) { + return request.get(`/app/extension/${id}`) +} + +// 获取扩展列表 +export function getExtensionList(params?: ExtensionListParams) { + return request.get('/app/extension/list', { params }) +} + +// 获取启用的扩展列表 +export function getEnabledExtensions() { + return request.get('/app/extension/enabled') +} + +// 启用/禁用扩展 +export function toggleExtension(id: number, data: ToggleExtensionRequest) { + return request.post(`/app/extension/${id}/toggle`, data) +} + +// 获取扩展配置 +export function getExtensionSettings(id: number) { + return request.get>(`/app/extension/${id}/settings`) +} + +// 更新扩展配置 +export function updateExtensionSettings(id: number, data: UpdateExtensionSettingsRequest) { + return request.put(`/app/extension/${id}/settings`, data) +} + +// 获取扩展 manifest +export function getExtensionManifest(id: number) { + return request.get(`/app/extension/${id}/manifest`) +} + +// 导入扩展 +export function importExtension(file: File) { + const formData = new FormData() + formData.append('file', file) + return request.post('/app/extension/import', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }) +} + +// 导出扩展 +export function exportExtension(id: number) { + return request.get(`/app/extension/${id}/export`, { + responseType: 'blob', + }) +} + +// 更新扩展统计 +export function updateExtensionStats(data: ExtensionStatsRequest) { + return request.post('/app/extension/stats', data) +} + +// 智能安装扩展(自动识别 Git URL 或 Manifest URL,兼容 SillyTavern) +export function installExtensionFromUrl(url: string, branch = 'main') { + return request.post('/app/extension/install/url', { url, branch }) +} + +// 从 Git URL 安装扩展 +export function installExtensionFromGit(gitUrl: string, branch = 'main') { + return request.post('/app/extension/install/git', { + gitUrl, + branch, + }) +} + +// 升级扩展版本(从源重新安装) +export function upgradeExtension(id: number, force = false) { + return request.post(`/app/extension/${id}/update`, { force }) +} diff --git a/web-app-vue/src/api/regexScript.ts b/web-app-vue/src/api/regexScript.ts new file mode 100644 index 0000000..7bffd59 --- /dev/null +++ b/web-app-vue/src/api/regexScript.ts @@ -0,0 +1,141 @@ +import request from '@/utils/request' +import type { + RegexScript, + RegexScriptListParams, + RegexScriptListResponse, + CreateRegexScriptRequest, + UpdateRegexScriptRequest, + TestRegexScriptRequest, + TestRegexScriptResponse, + ApplyRegexScriptsRequest, + ApplyRegexScriptsResponse, + RegexScriptExportData, +} from '@/types/regexScript' + +// 创建正则脚本 +export function createRegexScript(data: CreateRegexScriptRequest) { + return request({ + url: '/app/regex', + method: 'post', + data, + }) +} + +// 更新正则脚本 +export function updateRegexScript(id: number, data: UpdateRegexScriptRequest) { + return request({ + url: `/app/regex/${id}`, + method: 'put', + data, + }) +} + +// 删除正则脚本 +export function deleteRegexScript(id: number) { + return request({ + url: `/app/regex/${id}`, + method: 'delete', + }) +} + +// 获取正则脚本详情 +export function getRegexScript(id: number) { + return request({ + url: `/app/regex/${id}`, + method: 'get', + }) +} + +// 获取正则脚本列表 +export function getRegexScriptList(params?: RegexScriptListParams) { + return request({ + url: '/app/regex', + method: 'get', + params, + }) +} + +// 复制正则脚本 +export function duplicateRegexScript(id: number) { + return request({ + url: `/app/regex/${id}/duplicate`, + method: 'post', + }) +} + +// 关联角色到正则脚本 +export function linkCharactersToRegex(id: number, characterIds: number[]) { + return request({ + url: `/app/regex/${id}/link`, + method: 'post', + data: { characterIds }, + }) +} + +// 获取角色的正则脚本 +export function getCharacterRegexScripts(characterId: number) { + return request({ + url: `/app/regex/character/${characterId}`, + method: 'get', + }) +} + +// 测试正则脚本 +export function testRegexScript(data: TestRegexScriptRequest) { + return request({ + url: '/app/regex/test', + method: 'post', + data, + }) +} + +// 应用正则脚本 +export function applyRegexScripts(data: ApplyRegexScriptsRequest) { + return request({ + url: '/app/regex/apply', + method: 'post', + data, + }) +} + +// 导入正则脚本 +export function importRegexScripts(file: File, overwriteMode: string = 'skip') { + const formData = new FormData() + formData.append('file', file) + formData.append('overwriteMode', overwriteMode) + + return request({ + url: '/app/regex/import', + method: 'post', + data: formData, + headers: { + 'Content-Type': 'multipart/form-data', + }, + }) +} + +// 导出正则脚本 +export function exportRegexScripts(scriptIds?: number[]) { + const params = scriptIds && scriptIds.length > 0 ? { scriptIds: scriptIds.join(',') } : {} + + return request({ + url: '/app/regex/export', + method: 'get', + params, + }) +} + +// 下载正则脚本(JSON 文件) +export function downloadRegexScriptsJSON(scriptIds?: number[]) { + return exportRegexScripts(scriptIds).then((data) => { + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `regex_scripts_${Date.now()}.json` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) + }) +} diff --git a/web-app-vue/src/components.d.ts b/web-app-vue/src/components.d.ts index 011a8ac..4d50141 100644 --- a/web-app-vue/src/components.d.ts +++ b/web-app-vue/src/components.d.ts @@ -11,13 +11,19 @@ export {} /* prettier-ignore */ declare module 'vue' { export interface GlobalComponents { + ElAlert: typeof import('element-plus/es')['ElAlert'] ElAvatar: typeof import('element-plus/es')['ElAvatar'] + ElBadge: typeof import('element-plus/es')['ElBadge'] ElButton: typeof import('element-plus/es')['ElButton'] ElCard: typeof import('element-plus/es')['ElCard'] - ElCol: typeof import('element-plus/es')['ElCol'] + ElCollapse: typeof import('element-plus/es')['ElCollapse'] + ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem'] ElContainer: typeof import('element-plus/es')['ElContainer'] ElDescriptions: typeof import('element-plus/es')['ElDescriptions'] ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem'] + ElDialog: typeof import('element-plus/es')['ElDialog'] + ElDivider: typeof import('element-plus/es')['ElDivider'] + ElDrawer: typeof import('element-plus/es')['ElDrawer'] ElDropdown: typeof import('element-plus/es')['ElDropdown'] ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem'] ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu'] @@ -27,19 +33,25 @@ declare module 'vue' { ElHeader: typeof import('element-plus/es')['ElHeader'] ElIcon: typeof import('element-plus/es')['ElIcon'] ElInput: typeof import('element-plus/es')['ElInput'] + ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] ElLink: typeof import('element-plus/es')['ElLink'] ElMain: typeof import('element-plus/es')['ElMain'] ElMenu: typeof import('element-plus/es')['ElMenu'] ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] ElOption: typeof import('element-plus/es')['ElOption'] - ElRow: typeof import('element-plus/es')['ElRow'] + ElPagination: typeof import('element-plus/es')['ElPagination'] + ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm'] ElSelect: typeof import('element-plus/es')['ElSelect'] - ElStatistic: typeof import('element-plus/es')['ElStatistic'] + ElSlider: typeof import('element-plus/es')['ElSlider'] ElSwitch: typeof import('element-plus/es')['ElSwitch'] + ElTable: typeof import('element-plus/es')['ElTable'] + ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] ElTabPane: typeof import('element-plus/es')['ElTabPane'] ElTabs: typeof import('element-plus/es')['ElTabs'] ElTag: typeof import('element-plus/es')['ElTag'] + ElTooltip: typeof import('element-plus/es')['ElTooltip'] ElUpload: typeof import('element-plus/es')['ElUpload'] + ExtensionDrawer: typeof import('./components/ExtensionDrawer.vue')['default'] HelloWorld: typeof import('./components/HelloWorld.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] diff --git a/web-app-vue/src/components/ExtensionDrawer.vue b/web-app-vue/src/components/ExtensionDrawer.vue new file mode 100644 index 0000000..c80e3e9 --- /dev/null +++ b/web-app-vue/src/components/ExtensionDrawer.vue @@ -0,0 +1,740 @@ + + + + + diff --git a/web-app-vue/src/layouts/DefaultLayout.vue b/web-app-vue/src/layouts/DefaultLayout.vue index 7e6f894..84d9aa7 100644 --- a/web-app-vue/src/layouts/DefaultLayout.vue +++ b/web-app-vue/src/layouts/DefaultLayout.vue @@ -21,7 +21,26 @@ 我的角色卡 + + + 世界书 + + + + 正则脚本 + + + + + + 扩展 +
@@ -58,25 +77,34 @@ + + + + + diff --git a/web-app-vue/src/views/extension/ExtensionListNew.vue b/web-app-vue/src/views/extension/ExtensionListNew.vue new file mode 100644 index 0000000..a5ba419 --- /dev/null +++ b/web-app-vue/src/views/extension/ExtensionListNew.vue @@ -0,0 +1,798 @@ + + + + + diff --git a/web-app-vue/src/views/extension/ExtensionSettings.vue b/web-app-vue/src/views/extension/ExtensionSettings.vue new file mode 100644 index 0000000..c2af762 --- /dev/null +++ b/web-app-vue/src/views/extension/ExtensionSettings.vue @@ -0,0 +1,329 @@ + + + + + diff --git a/web-app-vue/src/views/regex/RegexScriptEdit.vue b/web-app-vue/src/views/regex/RegexScriptEdit.vue new file mode 100644 index 0000000..cce7ecd --- /dev/null +++ b/web-app-vue/src/views/regex/RegexScriptEdit.vue @@ -0,0 +1,421 @@ + + + + + diff --git a/web-app-vue/src/views/regex/RegexScriptList.vue b/web-app-vue/src/views/regex/RegexScriptList.vue new file mode 100644 index 0000000..7469ab4 --- /dev/null +++ b/web-app-vue/src/views/regex/RegexScriptList.vue @@ -0,0 +1,314 @@ + + + + + diff --git a/web-app-vue/src/views/worldbook/WorldBookEdit.vue b/web-app-vue/src/views/worldbook/WorldBookEdit.vue new file mode 100644 index 0000000..f5fc08f --- /dev/null +++ b/web-app-vue/src/views/worldbook/WorldBookEdit.vue @@ -0,0 +1,302 @@ + + + + + diff --git a/web-app-vue/src/views/worldbook/WorldInfoEntryForm.vue b/web-app-vue/src/views/worldbook/WorldInfoEntryForm.vue new file mode 100644 index 0000000..62a99df --- /dev/null +++ b/web-app-vue/src/views/worldbook/WorldInfoEntryForm.vue @@ -0,0 +1,236 @@ + + + + +