✨ 新增正则和扩展模块
This commit is contained in:
228
docs/扩展模块修复说明.md
Normal file
228
docs/扩展模块修复说明.md
Normal file
@@ -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. **自动保存**:修改配置后会自动保存,无需手动点击保存按钮
|
||||
|
||||
## 未来改进
|
||||
|
||||
- [ ] 支持更多配置项类型(颜色选择器、文件选择器等)
|
||||
- [ ] 配置项分组和标签页
|
||||
- [ ] 配置导入/导出
|
||||
- [ ] 配置重置到默认值
|
||||
- [ ] 配置历史记录
|
||||
- [ ] 扩展依赖检查和冲突提示
|
||||
99
docs/示例扩展manifest.json
Normal file
99
docs/示例扩展manifest.json
Normal file
@@ -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": []
|
||||
}
|
||||
592
docs/重构进度管理.md
592
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)(暂停)
|
||||
|
||||
@@ -6,6 +6,8 @@ type ApiGroup struct {
|
||||
AuthApi
|
||||
CharacterApi
|
||||
WorldInfoApi
|
||||
ExtensionApi
|
||||
RegexScriptApi
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
565
server/api/v1/app/extension.go
Normal file
565
server/api/v1/app/extension.go
Normal file
@@ -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)
|
||||
}
|
||||
479
server/api/v1/app/regex_script.go
Normal file
479
server/api/v1/app/regex_script.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
9
server/initialize/fix_world_info_table.sql
Normal file
9
server/initialize/fix_world_info_table.sql
Normal file
@@ -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 自动创建
|
||||
-- 重启服务器即可
|
||||
@@ -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))
|
||||
|
||||
@@ -147,6 +147,8 @@ func Routers() *gin.Engine {
|
||||
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/*
|
||||
}
|
||||
|
||||
//插件路由安装
|
||||
|
||||
108
server/model/app/ai_extension.go
Normal file
108
server/model/app/ai_extension.go
Normal file
@@ -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"
|
||||
// }
|
||||
65
server/model/app/ai_regex_script.go
Normal file
65
server/model/app/ai_regex_script.go
Normal file
@@ -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"
|
||||
}
|
||||
107
server/model/app/request/extension.go
Normal file
107
server/model/app/request/extension.go
Normal file
@@ -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"`
|
||||
}
|
||||
88
server/model/app/request/regex_script.go
Normal file
88
server/model/app/request/regex_script.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
188
server/model/app/response/extension.go
Normal file
188
server/model/app/response/extension.go
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
106
server/model/app/response/regex_script.go
Normal file
106
server/model/app/response/regex_script.go
Normal file
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
|
||||
// PageInfo Paging common input parameter structure
|
||||
type PageInfo struct {
|
||||
Page int `json:"page" form:"page"` // 页码
|
||||
PageSize int `json:"pageSize" form:"pageSize"` // 每页大小
|
||||
Page int `json:"page" form:"page,default=1"` // 页码
|
||||
PageSize int `json:"pageSize" form:"pageSize,default=20"` // 每页大小
|
||||
Keyword string `json:"keyword" form:"keyword"` // 关键字
|
||||
}
|
||||
|
||||
|
||||
@@ -4,4 +4,6 @@ type RouterGroup struct {
|
||||
AuthRouter
|
||||
CharacterRouter
|
||||
WorldInfoRouter
|
||||
ExtensionRouter
|
||||
RegexScriptRouter
|
||||
}
|
||||
|
||||
46
server/router/app/extension.go
Normal file
46
server/router/app/extension.go
Normal file
@@ -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) // 更新扩展统计
|
||||
}
|
||||
}
|
||||
29
server/router/app/regex_script.go
Normal file
29
server/router/app/regex_script.go
Normal file
@@ -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) // 导出正则脚本
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -4,4 +4,6 @@ type AppServiceGroup struct {
|
||||
AuthService
|
||||
CharacterService
|
||||
WorldInfoService
|
||||
ExtensionService
|
||||
RegexScriptService
|
||||
}
|
||||
|
||||
803
server/service/app/extension.go
Normal file
803
server/service/app/extension.go
Normal file
@@ -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
|
||||
}
|
||||
476
server/service/app/regex_script.go
Normal file
476
server/service/app/regex_script.go
Normal file
@@ -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
|
||||
}
|
||||
21
web-app-vue/package-lock.json
generated
21
web-app-vue/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
103
web-app-vue/src/api/extension.ts
Normal file
103
web-app-vue/src/api/extension.ts
Normal file
@@ -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<Extension>('/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<Extension>(`/app/extension/${id}`)
|
||||
}
|
||||
|
||||
// 获取扩展列表
|
||||
export function getExtensionList(params?: ExtensionListParams) {
|
||||
return request.get<ExtensionListResponse>('/app/extension/list', { params })
|
||||
}
|
||||
|
||||
// 获取启用的扩展列表
|
||||
export function getEnabledExtensions() {
|
||||
return request.get<Extension[]>('/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<Record<string, any>>(`/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<ExtensionManifest>(`/app/extension/${id}/manifest`)
|
||||
}
|
||||
|
||||
// 导入扩展
|
||||
export function importExtension(file: File) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return request.post<Extension>('/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<Extension>('/app/extension/install/url', { url, branch })
|
||||
}
|
||||
|
||||
// 从 Git URL 安装扩展
|
||||
export function installExtensionFromGit(gitUrl: string, branch = 'main') {
|
||||
return request.post<Extension>('/app/extension/install/git', {
|
||||
gitUrl,
|
||||
branch,
|
||||
})
|
||||
}
|
||||
|
||||
// 升级扩展版本(从源重新安装)
|
||||
export function upgradeExtension(id: number, force = false) {
|
||||
return request.post<Extension>(`/app/extension/${id}/update`, { force })
|
||||
}
|
||||
141
web-app-vue/src/api/regexScript.ts
Normal file
141
web-app-vue/src/api/regexScript.ts
Normal file
@@ -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<RegexScript>({
|
||||
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<RegexScript>({
|
||||
url: `/app/regex/${id}`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
// 获取正则脚本列表
|
||||
export function getRegexScriptList(params?: RegexScriptListParams) {
|
||||
return request<RegexScriptListResponse>({
|
||||
url: '/app/regex',
|
||||
method: 'get',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
// 复制正则脚本
|
||||
export function duplicateRegexScript(id: number) {
|
||||
return request<RegexScript>({
|
||||
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<RegexScript[]>({
|
||||
url: `/app/regex/character/${characterId}`,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
|
||||
// 测试正则脚本
|
||||
export function testRegexScript(data: TestRegexScriptRequest) {
|
||||
return request<TestRegexScriptResponse>({
|
||||
url: '/app/regex/test',
|
||||
method: 'post',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
// 应用正则脚本
|
||||
export function applyRegexScripts(data: ApplyRegexScriptsRequest) {
|
||||
return request<ApplyRegexScriptsResponse>({
|
||||
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<RegexScriptExportData>({
|
||||
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)
|
||||
})
|
||||
}
|
||||
18
web-app-vue/src/components.d.ts
vendored
18
web-app-vue/src/components.d.ts
vendored
@@ -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']
|
||||
|
||||
740
web-app-vue/src/components/ExtensionDrawer.vue
Normal file
740
web-app-vue/src/components/ExtensionDrawer.vue
Normal file
@@ -0,0 +1,740 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
v-model="drawerVisible"
|
||||
title="扩展管理"
|
||||
:size="600"
|
||||
direction="rtl"
|
||||
destroy-on-close
|
||||
>
|
||||
<div class="extension-drawer-content">
|
||||
<!-- 搜索栏 -->
|
||||
<div class="search-bar">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索扩展..."
|
||||
clearable
|
||||
size="small"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="action-bar">
|
||||
<el-button size="small" @click="showInstallDialog = true">
|
||||
<el-icon><Plus /></el-icon>
|
||||
安装扩展
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
@click="handleUpdateAll"
|
||||
:disabled="!hasUpdatableExtensions"
|
||||
>
|
||||
<el-icon><Refresh /></el-icon>
|
||||
全部更新
|
||||
</el-button>
|
||||
<el-button size="small" @click="handleRefresh">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 扩展折叠面板 -->
|
||||
<div class="extension-list" v-loading="extensionStore.loading">
|
||||
<el-empty v-if="filteredExtensions.length === 0" description="暂无扩展" />
|
||||
|
||||
<el-collapse v-else v-model="activeNames">
|
||||
<el-collapse-item
|
||||
v-for="ext in filteredExtensions"
|
||||
:key="ext.id"
|
||||
:name="ext.id"
|
||||
class="extension-item"
|
||||
>
|
||||
<!-- 扩展头部 -->
|
||||
<template #title>
|
||||
<div class="extension-header" @click.stop>
|
||||
<el-switch
|
||||
v-model="ext.isEnabled"
|
||||
size="default"
|
||||
@change="handleToggle(ext)"
|
||||
style="margin-right: 12px"
|
||||
/>
|
||||
<div class="extension-title">
|
||||
<span class="name">{{ ext.displayName || ext.name }}</span>
|
||||
<el-tag v-if="ext.isSystemExt" size="small" type="info" style="margin-left: 8px">
|
||||
系统
|
||||
</el-tag>
|
||||
<el-tag :type="getRunningStatus(ext).type" size="small" style="margin-left: 8px">
|
||||
{{ getRunningStatus(ext).text }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 扩展内容 -->
|
||||
<div class="extension-content">
|
||||
<div class="extension-description" v-if="ext.description">
|
||||
<p>{{ ext.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 扩展配置 -->
|
||||
<el-divider content-position="left">配置</el-divider>
|
||||
|
||||
<!-- 自定义配置容器(扩展可以在运行时注入 UI) -->
|
||||
<div
|
||||
:id="`extension-settings-${ext.name}`"
|
||||
class="extension-custom-settings"
|
||||
:data-extension-id="ext.id"
|
||||
:data-extension-name="ext.name"
|
||||
>
|
||||
<!-- 扩展的自定义配置 UI 将动态注入到这里 -->
|
||||
</div>
|
||||
|
||||
<!-- 基于 manifest schema 的配置表单(兜底方案) -->
|
||||
<el-form label-position="top" size="small">
|
||||
<template v-for="(setting, key) in getSettingsSchema(ext)" :key="key">
|
||||
<!-- Text 输入框 -->
|
||||
<el-form-item
|
||||
v-if="setting.type === 'text' || !setting.type"
|
||||
:label="setting.label || key"
|
||||
>
|
||||
<el-input
|
||||
v-model="extensionSettings[ext.id][key]"
|
||||
:placeholder="setting.placeholder"
|
||||
@change="handleSettingChange(ext.id, key)"
|
||||
/>
|
||||
<div v-if="setting.description" class="setting-hint">
|
||||
{{ setting.description }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- Number 数字输入 -->
|
||||
<el-form-item
|
||||
v-else-if="setting.type === 'number'"
|
||||
:label="setting.label || key"
|
||||
>
|
||||
<el-input-number
|
||||
v-model="extensionSettings[ext.id][key]"
|
||||
:min="setting.min"
|
||||
:max="setting.max"
|
||||
:step="setting.step || 1"
|
||||
@change="handleSettingChange(ext.id, key)"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div v-if="setting.description" class="setting-hint">
|
||||
{{ setting.description }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- Boolean 开关 -->
|
||||
<el-form-item
|
||||
v-else-if="setting.type === 'boolean'"
|
||||
:label="setting.label || key"
|
||||
>
|
||||
<el-switch
|
||||
v-model="extensionSettings[ext.id][key]"
|
||||
@change="handleSettingChange(ext.id, key)"
|
||||
/>
|
||||
<div v-if="setting.description" class="setting-hint">
|
||||
{{ setting.description }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- Select 下拉选择 -->
|
||||
<el-form-item
|
||||
v-else-if="setting.type === 'select'"
|
||||
:label="setting.label || key"
|
||||
>
|
||||
<el-select
|
||||
v-model="extensionSettings[ext.id][key]"
|
||||
@change="handleSettingChange(ext.id, key)"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in setting.options"
|
||||
:key="option.value || option"
|
||||
:label="option.label || option"
|
||||
:value="option.value || option"
|
||||
/>
|
||||
</el-select>
|
||||
<div v-if="setting.description" class="setting-hint">
|
||||
{{ setting.description }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- Textarea 文本域 -->
|
||||
<el-form-item
|
||||
v-else-if="setting.type === 'textarea'"
|
||||
:label="setting.label || key"
|
||||
>
|
||||
<el-input
|
||||
v-model="extensionSettings[ext.id][key]"
|
||||
type="textarea"
|
||||
:rows="setting.rows || 3"
|
||||
:placeholder="setting.placeholder"
|
||||
@change="handleSettingChange(ext.id, key)"
|
||||
/>
|
||||
<div v-if="setting.description" class="setting-hint">
|
||||
{{ setting.description }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- Slider 滑块 -->
|
||||
<el-form-item
|
||||
v-else-if="setting.type === 'slider'"
|
||||
:label="setting.label || key"
|
||||
>
|
||||
<el-slider
|
||||
v-model="extensionSettings[ext.id][key]"
|
||||
:min="setting.min || 0"
|
||||
:max="setting.max || 100"
|
||||
:step="setting.step || 1"
|
||||
:show-input="true"
|
||||
@change="handleSettingChange(ext.id, key)"
|
||||
/>
|
||||
<div v-if="setting.description" class="setting-hint">
|
||||
{{ setting.description }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 没有配置项 -->
|
||||
<el-empty
|
||||
v-if="Object.keys(getSettingsSchema(ext)).length === 0"
|
||||
description="此扩展没有可配置项"
|
||||
:image-size="60"
|
||||
/>
|
||||
</el-form>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="extension-actions">
|
||||
<el-button
|
||||
v-if="ext.isEnabled && getRunningStatus(ext).isRunning"
|
||||
size="small"
|
||||
type="warning"
|
||||
@click="handleReload(ext)"
|
||||
>
|
||||
<el-icon><RefreshRight /></el-icon>
|
||||
重新加载
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="ext.sourceUrl && (ext.installSource === 'git' || ext.installSource === 'url')"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handleUpdate(ext)"
|
||||
>
|
||||
<el-icon><Refresh /></el-icon>
|
||||
更新
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="!ext.isSystemExt"
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="handleDelete(ext)"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>
|
||||
卸载
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 安装扩展对话框 -->
|
||||
<el-dialog
|
||||
v-model="showInstallDialog"
|
||||
title="安装扩展"
|
||||
width="500px"
|
||||
append-to-body
|
||||
>
|
||||
<el-tabs v-model="installTab">
|
||||
<el-tab-pane label="上传文件" name="file">
|
||||
<el-upload
|
||||
drag
|
||||
:auto-upload="false"
|
||||
:on-change="handleFileChange"
|
||||
:show-file-list="true"
|
||||
:limit="1"
|
||||
accept=".json"
|
||||
>
|
||||
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
|
||||
<div class="el-upload__text">
|
||||
将 manifest.json 拖到此处,或<em>点击上传</em>
|
||||
</div>
|
||||
</el-upload>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="URL 安装" name="url">
|
||||
<el-form :model="installForm" label-width="120px">
|
||||
<el-form-item label="URL">
|
||||
<el-input
|
||||
v-model="installForm.url"
|
||||
placeholder="Git 仓库 URL 或 manifest.json URL"
|
||||
>
|
||||
<template #append>
|
||||
<el-tooltip content="支持 Git 仓库 URL(如 GitHub、GitLab)或直接的 manifest.json URL" placement="top">
|
||||
<el-icon><QuestionFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="分支(可选)">
|
||||
<el-input
|
||||
v-model="installForm.branch"
|
||||
placeholder="main"
|
||||
>
|
||||
<template #append>
|
||||
<el-tooltip content="Git 仓库的分支名称,默认为 main" placement="top">
|
||||
<el-icon><QuestionFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-alert type="info" :closable="false" show-icon>
|
||||
<template #title>
|
||||
<div style="font-size: 12px;">
|
||||
示例:<br />
|
||||
• https://github.com/user/extension.git<br />
|
||||
• https://gitlab.com/user/extension<br />
|
||||
• https://example.com/manifest.json
|
||||
</div>
|
||||
</template>
|
||||
</el-alert>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="showInstallDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleInstall" :loading="installing">
|
||||
安装
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { useExtensionStore } from '@/stores/extension'
|
||||
import { extensionRuntime } from '@/utils/extensionRuntime'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
Refresh,
|
||||
RefreshRight,
|
||||
Delete,
|
||||
UploadFilled,
|
||||
QuestionFilled,
|
||||
} from '@element-plus/icons-vue'
|
||||
import type { Extension } from '@/types/extension'
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
visible?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
visible: false,
|
||||
})
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', value: boolean): void
|
||||
}>()
|
||||
|
||||
// Store
|
||||
const extensionStore = useExtensionStore()
|
||||
|
||||
// State
|
||||
const drawerVisible = computed({
|
||||
get: () => props.visible,
|
||||
set: (val) => emit('update:visible', val),
|
||||
})
|
||||
|
||||
const searchKeyword = ref('')
|
||||
const activeNames = ref<number[]>([])
|
||||
const extensionSettings = reactive<Record<number, Record<string, any>>>({})
|
||||
const showInstallDialog = ref(false)
|
||||
const installTab = ref('file')
|
||||
const installing = ref(false)
|
||||
const selectedFile = ref<File | null>(null)
|
||||
const installForm = reactive({
|
||||
url: '',
|
||||
branch: 'main',
|
||||
})
|
||||
|
||||
// Computed
|
||||
const filteredExtensions = computed(() => {
|
||||
let extensions = extensionStore.extensions
|
||||
|
||||
if (searchKeyword.value) {
|
||||
const keyword = searchKeyword.value.toLowerCase()
|
||||
extensions = extensions.filter(ext =>
|
||||
(ext.name?.toLowerCase().includes(keyword)) ||
|
||||
(ext.displayName?.toLowerCase().includes(keyword)) ||
|
||||
(ext.description?.toLowerCase().includes(keyword))
|
||||
)
|
||||
}
|
||||
|
||||
return extensions
|
||||
})
|
||||
|
||||
// 是否有可更新的扩展
|
||||
const hasUpdatableExtensions = computed(() => {
|
||||
return extensionStore.extensions.some(ext =>
|
||||
ext.sourceUrl && (ext.installSource === 'git' || ext.installSource === 'url')
|
||||
)
|
||||
})
|
||||
|
||||
// Methods
|
||||
const getSettingsSchema = (ext: Extension) => {
|
||||
try {
|
||||
const manifest = typeof ext.manifestData === 'string'
|
||||
? JSON.parse(ext.manifestData)
|
||||
: ext.manifestData
|
||||
return manifest?.settings || {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const getRunningStatus = (ext: Extension) => {
|
||||
const instance = extensionRuntime.getInstance(ext.id)
|
||||
if (!ext.isEnabled) {
|
||||
return { text: '已禁用', type: 'info', isRunning: false }
|
||||
}
|
||||
if (instance?.isRunning) {
|
||||
return { text: '运行中', type: 'success', isRunning: true }
|
||||
}
|
||||
if (instance?.isLoaded) {
|
||||
return { text: '已加载', type: 'warning', isRunning: false }
|
||||
}
|
||||
return { text: '未加载', type: 'danger', isRunning: false }
|
||||
}
|
||||
|
||||
const handleToggle = async (ext: Extension) => {
|
||||
try {
|
||||
// 传递当前的启用状态
|
||||
await extensionStore.toggleExtension(ext.id, ext.isEnabled)
|
||||
|
||||
// 根据状态加载/卸载扩展
|
||||
if (ext.isEnabled) {
|
||||
const success = await extensionRuntime.enableExtension(ext)
|
||||
if (!success) {
|
||||
ext.isEnabled = false
|
||||
ElMessage.error('启用扩展失败')
|
||||
} else {
|
||||
ElMessage.success('扩展已启用并运行')
|
||||
}
|
||||
} else {
|
||||
await extensionRuntime.disableExtension(ext.id)
|
||||
ElMessage.success('扩展已禁用')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('切换扩展状态失败:', error)
|
||||
// 恢复状态
|
||||
ext.isEnabled = !ext.isEnabled
|
||||
}
|
||||
}
|
||||
|
||||
const handleSettingChange = async (extId: number, key: string) => {
|
||||
try {
|
||||
await extensionStore.updateExtensionSettings(extId, {
|
||||
[key]: extensionSettings[extId][key],
|
||||
})
|
||||
|
||||
// 通知扩展配置已更改
|
||||
extensionRuntime.emitEvent('settingsChanged', {
|
||||
extensionId: extId,
|
||||
key,
|
||||
value: extensionSettings[extId][key],
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('保存设置失败:', error)
|
||||
ElMessage.error('保存设置失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleReload = async (ext: Extension) => {
|
||||
try {
|
||||
await extensionRuntime.reloadExtension(ext)
|
||||
ElMessage.success('扩展已重新加载')
|
||||
} catch (error) {
|
||||
console.error('重新加载失败:', error)
|
||||
ElMessage.error('重新加载失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdate = async (ext: Extension) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要更新扩展 "${ext.displayName || ext.name}" 吗?这将从原始安装源重新下载扩展。`,
|
||||
'确认更新',
|
||||
{
|
||||
type: 'info',
|
||||
confirmButtonText: '更新',
|
||||
cancelButtonText: '取消',
|
||||
}
|
||||
)
|
||||
|
||||
// 更新扩展
|
||||
await extensionStore.upgradeExtension(ext.id)
|
||||
|
||||
// 如果扩展正在运行,重新加载
|
||||
if (ext.isEnabled) {
|
||||
await extensionRuntime.reloadExtension(ext)
|
||||
}
|
||||
|
||||
ElMessage.success('扩展已更新')
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('更新失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (ext: Extension) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要卸载扩展 "${ext.displayName || ext.name}" 吗?`,
|
||||
'确认卸载',
|
||||
{ type: 'warning' }
|
||||
)
|
||||
|
||||
// 先卸载运行时
|
||||
await extensionRuntime.unloadExtension(ext.id)
|
||||
|
||||
// 再删除数据
|
||||
await extensionStore.deleteExtension(ext.id)
|
||||
|
||||
ElMessage.success('扩展已卸载')
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('卸载失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
extensionStore.fetchExtensionList()
|
||||
}
|
||||
|
||||
// 批量更新所有扩展
|
||||
const handleUpdateAll = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'确定要更新所有可更新的扩展吗?',
|
||||
'批量更新',
|
||||
{
|
||||
type: 'info',
|
||||
confirmButtonText: '更新',
|
||||
cancelButtonText: '取消',
|
||||
}
|
||||
)
|
||||
|
||||
const updatableExtensions = extensionStore.extensions.filter(ext =>
|
||||
ext.sourceUrl && (ext.installSource === 'git' || ext.installSource === 'url')
|
||||
)
|
||||
|
||||
if (updatableExtensions.length === 0) {
|
||||
ElMessage.info('没有可更新的扩展')
|
||||
return
|
||||
}
|
||||
|
||||
ElMessage.info(`开始更新 ${updatableExtensions.length} 个扩展...`)
|
||||
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
|
||||
for (const ext of updatableExtensions) {
|
||||
try {
|
||||
await extensionStore.upgradeExtension(ext.id)
|
||||
successCount++
|
||||
} catch (error) {
|
||||
console.error(`更新扩展 ${ext.name} 失败:`, error)
|
||||
failCount++
|
||||
}
|
||||
}
|
||||
|
||||
if (failCount === 0) {
|
||||
ElMessage.success(`成功更新 ${successCount} 个扩展`)
|
||||
} else {
|
||||
ElMessage.warning(`更新完成:成功 ${successCount} 个,失败 ${failCount} 个`)
|
||||
}
|
||||
|
||||
// 重新加载所有启用的扩展
|
||||
await extensionStore.fetchExtensionList()
|
||||
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('批量更新失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileChange = (file: any) => {
|
||||
selectedFile.value = file.raw
|
||||
}
|
||||
|
||||
const handleInstall = async () => {
|
||||
try {
|
||||
installing.value = true
|
||||
|
||||
if (installTab.value === 'file') {
|
||||
if (!selectedFile.value) {
|
||||
ElMessage.warning('请选择 manifest.json 文件')
|
||||
return
|
||||
}
|
||||
await extensionStore.importExtension(selectedFile.value)
|
||||
} else {
|
||||
if (!installForm.url) {
|
||||
ElMessage.warning('请输入 Manifest URL')
|
||||
return
|
||||
}
|
||||
|
||||
// 从 URL 安装扩展(支持分支参数)
|
||||
await installFromURL(installForm.url, installForm.branch || 'main')
|
||||
}
|
||||
|
||||
showInstallDialog.value = false
|
||||
selectedFile.value = null
|
||||
installForm.url = ''
|
||||
installForm.branch = 'main'
|
||||
} catch (error) {
|
||||
console.error('安装失败:', error)
|
||||
} finally {
|
||||
installing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 从 URL 安装扩展(智能识别 Git URL 或 Manifest URL)
|
||||
const installFromURL = async (url: string, branch = 'main') => {
|
||||
try {
|
||||
// 验证 URL 格式
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
ElMessage.error('请输入有效的 HTTP(S) URL')
|
||||
return
|
||||
}
|
||||
|
||||
ElMessage.info('正在安装扩展...')
|
||||
|
||||
// 调用后端智能安装 API(自动识别 Git URL 或 Manifest URL)
|
||||
await extensionStore.installExtensionFromUrl(url, branch)
|
||||
|
||||
ElMessage.success('扩展安装成功')
|
||||
} catch (error: any) {
|
||||
console.error('安装失败:', error)
|
||||
ElMessage.error(error.message || '安装失败')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 监听扩展列表变化,初始化设置
|
||||
watch(
|
||||
() => extensionStore.extensions,
|
||||
(extensions) => {
|
||||
extensions.forEach(ext => {
|
||||
if (!extensionSettings[ext.id]) {
|
||||
extensionSettings[ext.id] = {}
|
||||
}
|
||||
|
||||
// 加载已保存的设置
|
||||
if (ext.settings) {
|
||||
const settings = typeof ext.settings === 'string'
|
||||
? JSON.parse(ext.settings)
|
||||
: ext.settings
|
||||
Object.assign(extensionSettings[ext.id], settings)
|
||||
}
|
||||
|
||||
// 如果扩展已启用,自动加载
|
||||
if (ext.isEnabled && !extensionRuntime.getInstance(ext.id)) {
|
||||
extensionRuntime.enableExtension(ext)
|
||||
}
|
||||
})
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
// 当抽屉打开时刷新列表
|
||||
watch(drawerVisible, (visible) => {
|
||||
if (visible) {
|
||||
handleRefresh()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.extension-drawer-content {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.search-bar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.extension-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
.extension-item {
|
||||
margin-bottom: 8px;
|
||||
|
||||
.extension-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
.extension-title {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.name {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.extension-content {
|
||||
.extension-description {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.setting-hint {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.extension-actions {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -21,7 +21,26 @@
|
||||
<el-icon><Files /></el-icon>
|
||||
<span>我的角色卡</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item v-if="authStore.isLoggedIn" index="/worldbook">
|
||||
<el-icon><Reading /></el-icon>
|
||||
<span>世界书</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item v-if="authStore.isLoggedIn" index="/regex">
|
||||
<el-icon><MagicStick /></el-icon>
|
||||
<span>正则脚本</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
|
||||
<!-- 扩展快捷按钮 -->
|
||||
<el-button
|
||||
v-if="authStore.isLoggedIn"
|
||||
text
|
||||
@click="extensionDrawerVisible = true"
|
||||
class="extension-menu-button"
|
||||
>
|
||||
<el-icon><Connection /></el-icon>
|
||||
<span>扩展</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
@@ -58,25 +77,34 @@
|
||||
<el-main class="layout-main">
|
||||
<router-view />
|
||||
</el-main>
|
||||
|
||||
<!-- 全局扩展抽屉 -->
|
||||
<ExtensionDrawer v-model:visible="extensionDrawerVisible" />
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { Grid, Files } from '@element-plus/icons-vue'
|
||||
import { Grid, Files, Reading, Connection, MagicStick } from '@element-plus/icons-vue'
|
||||
import ExtensionDrawer from '@/components/ExtensionDrawer.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 扩展抽屉状态
|
||||
const extensionDrawerVisible = ref(false)
|
||||
|
||||
// 初始化用户信息
|
||||
authStore.initUserInfo()
|
||||
|
||||
// 当前激活的菜单
|
||||
const activeMenu = computed(() => {
|
||||
if (route.path.startsWith('/my-characters')) return '/my-characters'
|
||||
if (route.path.startsWith('/worldbook')) return '/worldbook'
|
||||
if (route.path.startsWith('/regex')) return '/regex'
|
||||
return '/'
|
||||
})
|
||||
|
||||
@@ -138,6 +166,15 @@ function handleCommand(command: string) {
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.extension-menu-button {
|
||||
margin-left: 12px;
|
||||
font-size: 14px;
|
||||
|
||||
.el-icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
|
||||
@@ -58,6 +58,54 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/character/Edit.vue'),
|
||||
meta: { title: '编辑角色卡', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'worldbook',
|
||||
name: 'WorldBookList',
|
||||
component: () => import('@/views/worldbook/WorldBookList.vue'),
|
||||
meta: { title: '世界书管理', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'worldbook/create',
|
||||
name: 'CreateWorldBook',
|
||||
component: () => import('@/views/worldbook/WorldBookEdit.vue'),
|
||||
meta: { title: '创建世界书', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'worldbook/edit/:id',
|
||||
name: 'EditWorldBook',
|
||||
component: () => import('@/views/worldbook/WorldBookEdit.vue'),
|
||||
meta: { title: '编辑世界书', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'extension',
|
||||
name: 'ExtensionList',
|
||||
component: () => import('@/views/extension/ExtensionListNew.vue'),
|
||||
meta: { title: '扩展管理', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'extension/settings/:id',
|
||||
name: 'ExtensionSettings',
|
||||
component: () => import('@/views/extension/ExtensionSettings.vue'),
|
||||
meta: { title: '扩展配置', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'regex',
|
||||
name: 'RegexScriptList',
|
||||
component: () => import('@/views/regex/RegexScriptList.vue'),
|
||||
meta: { title: '正则脚本管理', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'regex/create',
|
||||
name: 'CreateRegexScript',
|
||||
component: () => import('@/views/regex/RegexScriptEdit.vue'),
|
||||
meta: { title: '创建正则脚本', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: 'regex/edit/:id',
|
||||
name: 'EditRegexScript',
|
||||
component: () => import('@/views/regex/RegexScriptEdit.vue'),
|
||||
meta: { title: '编辑正则脚本', requiresAuth: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
368
web-app-vue/src/stores/extension.ts
Normal file
368
web-app-vue/src/stores/extension.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import * as extensionApi from '@/api/extension'
|
||||
import type {
|
||||
Extension,
|
||||
ExtensionListParams,
|
||||
CreateExtensionRequest,
|
||||
UpdateExtensionRequest,
|
||||
} from '@/types/extension'
|
||||
|
||||
export const useExtensionStore = defineStore('extension', () => {
|
||||
// 状态
|
||||
const extensions = ref<Extension[]>([])
|
||||
const enabledExtensions = ref<Extension[]>([])
|
||||
const currentExtension = ref<Extension | null>(null)
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const loading = ref(false)
|
||||
|
||||
// 过滤条件
|
||||
const filters = ref<ExtensionListParams>({
|
||||
extensionType: '',
|
||||
category: '',
|
||||
isEnabled: undefined,
|
||||
tag: '',
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取扩展列表
|
||||
*/
|
||||
const fetchExtensionList = async (params?: ExtensionListParams) => {
|
||||
try {
|
||||
loading.value = true
|
||||
const requestParams: ExtensionListParams = {
|
||||
page: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
...filters.value,
|
||||
...params,
|
||||
}
|
||||
|
||||
const response = await extensionApi.getExtensionList(requestParams)
|
||||
extensions.value = response.data.list
|
||||
total.value = response.data.total
|
||||
currentPage.value = response.data.page
|
||||
pageSize.value = response.data.pageSize
|
||||
} catch (error: any) {
|
||||
console.error('获取扩展列表失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '获取扩展列表失败')
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取启用的扩展列表
|
||||
*/
|
||||
const fetchEnabledExtensions = async () => {
|
||||
try {
|
||||
const response = await extensionApi.getEnabledExtensions()
|
||||
enabledExtensions.value = response.data
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
console.error('获取启用扩展列表失败:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取扩展详情
|
||||
*/
|
||||
const fetchExtension = async (id: number) => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await extensionApi.getExtension(id)
|
||||
currentExtension.value = response.data
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
console.error('获取扩展详情失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '获取扩展详情失败')
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建/安装扩展
|
||||
*/
|
||||
const createExtension = async (data: CreateExtensionRequest) => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await extensionApi.createExtension(data)
|
||||
ElMessage.success('安装成功')
|
||||
await fetchExtensionList()
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
console.error('创建扩展失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '创建扩展失败')
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新扩展
|
||||
*/
|
||||
const updateExtension = async (id: number, data: UpdateExtensionRequest) => {
|
||||
try {
|
||||
loading.value = true
|
||||
await extensionApi.updateExtension(id, data)
|
||||
ElMessage.success('更新成功')
|
||||
await fetchExtensionList()
|
||||
} catch (error: any) {
|
||||
console.error('更新扩展失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '更新扩展失败')
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除/卸载扩展
|
||||
*/
|
||||
const deleteExtension = async (id: number, deleteFiles = false) => {
|
||||
try {
|
||||
loading.value = true
|
||||
await extensionApi.deleteExtension(id, deleteFiles)
|
||||
ElMessage.success('卸载成功')
|
||||
await fetchExtensionList()
|
||||
} catch (error: any) {
|
||||
console.error('删除扩展失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '删除扩展失败')
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用/禁用扩展
|
||||
*/
|
||||
const toggleExtension = async (id: number, isEnabled: boolean) => {
|
||||
try {
|
||||
await extensionApi.toggleExtension(id, { isEnabled })
|
||||
ElMessage.success(isEnabled ? '启用成功' : '禁用成功')
|
||||
await fetchExtensionList()
|
||||
await fetchEnabledExtensions()
|
||||
} catch (error: any) {
|
||||
console.error('切换扩展状态失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '操作失败')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取扩展配置
|
||||
*/
|
||||
const getExtensionSettings = async (id: number) => {
|
||||
try {
|
||||
const response = await extensionApi.getExtensionSettings(id)
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
console.error('获取扩展配置失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '获取扩展配置失败')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新扩展配置
|
||||
*/
|
||||
const updateExtensionSettings = async (id: number, settings: Record<string, any>) => {
|
||||
try {
|
||||
await extensionApi.updateExtensionSettings(id, { settings })
|
||||
ElMessage.success('配置更新成功')
|
||||
} catch (error: any) {
|
||||
console.error('更新扩展配置失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '更新配置失败')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取扩展 manifest
|
||||
*/
|
||||
const getExtensionManifest = async (id: number) => {
|
||||
try {
|
||||
const response = await extensionApi.getExtensionManifest(id)
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
console.error('获取扩展 manifest 失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入扩展
|
||||
*/
|
||||
const importExtension = async (file: File) => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await extensionApi.importExtension(file)
|
||||
ElMessage.success('导入成功')
|
||||
await fetchExtensionList()
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
console.error('导入扩展失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '导入扩展失败')
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出扩展
|
||||
*/
|
||||
const exportExtension = async (id: number, name: string) => {
|
||||
try {
|
||||
const response = await extensionApi.exportExtension(id)
|
||||
|
||||
// 创建下载链接
|
||||
const blob = new Blob([response.data], { type: 'application/json' })
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `${name}_manifest.json`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
|
||||
ElMessage.success('导出成功')
|
||||
} catch (error: any) {
|
||||
console.error('导出扩展失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '导出扩展失败')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能安装扩展(自动识别 Git URL 或 Manifest URL)
|
||||
*/
|
||||
const installExtensionFromUrl = async (url: string, branch = 'main') => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await extensionApi.installExtensionFromUrl(url, branch)
|
||||
ElMessage.success('安装成功')
|
||||
await fetchExtensionList()
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
console.error('安装扩展失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || error.message || '安装扩展失败')
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Git URL 安装扩展
|
||||
*/
|
||||
const installExtensionFromGit = async (gitUrl: string, branch = 'main') => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await extensionApi.installExtensionFromGit(gitUrl, branch)
|
||||
ElMessage.success('安装成功')
|
||||
await fetchExtensionList()
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
console.error('从 Git 安装扩展失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '安装扩展失败')
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 升级扩展版本(从源重新安装)
|
||||
*/
|
||||
const upgradeExtension = async (id: number, force = false) => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await extensionApi.upgradeExtension(id, force)
|
||||
ElMessage.success('升级成功')
|
||||
await fetchExtensionList()
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
console.error('升级扩展失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '升级扩展失败')
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新扩展统计
|
||||
*/
|
||||
const updateStats = async (extensionId: number, action: 'usage' | 'error' | 'load', value = 1) => {
|
||||
try {
|
||||
await extensionApi.updateExtensionStats({ extensionId, action, value })
|
||||
} catch (error: any) {
|
||||
console.error('更新扩展统计失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置过滤条件
|
||||
*/
|
||||
const setFilters = (newFilters: Partial<ExtensionListParams>) => {
|
||||
filters.value = { ...filters.value, ...newFilters }
|
||||
currentPage.value = 1
|
||||
fetchExtensionList()
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置过滤条件
|
||||
*/
|
||||
const resetFilters = () => {
|
||||
filters.value = {
|
||||
extensionType: '',
|
||||
category: '',
|
||||
isEnabled: undefined,
|
||||
tag: '',
|
||||
}
|
||||
currentPage.value = 1
|
||||
fetchExtensionList()
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
extensions,
|
||||
enabledExtensions,
|
||||
currentExtension,
|
||||
total,
|
||||
currentPage,
|
||||
pageSize,
|
||||
loading,
|
||||
filters,
|
||||
|
||||
// 操作
|
||||
fetchExtensionList,
|
||||
fetchEnabledExtensions,
|
||||
fetchExtension,
|
||||
createExtension,
|
||||
updateExtension,
|
||||
deleteExtension,
|
||||
toggleExtension,
|
||||
getExtensionSettings,
|
||||
updateExtensionSettings,
|
||||
getExtensionManifest,
|
||||
importExtension,
|
||||
exportExtension,
|
||||
installExtensionFromUrl,
|
||||
installExtensionFromGit,
|
||||
upgradeExtension,
|
||||
updateStats,
|
||||
setFilters,
|
||||
resetFilters,
|
||||
}
|
||||
})
|
||||
229
web-app-vue/src/stores/regexScript.ts
Normal file
229
web-app-vue/src/stores/regexScript.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import * as regexScriptApi from '@/api/regexScript'
|
||||
import type {
|
||||
RegexScript,
|
||||
RegexScriptListParams,
|
||||
CreateRegexScriptRequest,
|
||||
UpdateRegexScriptRequest,
|
||||
TestRegexScriptRequest,
|
||||
} from '@/types/regexScript'
|
||||
|
||||
export const useRegexScriptStore = defineStore('regexScript', () => {
|
||||
// 状态
|
||||
const regexScripts = ref<RegexScript[]>([])
|
||||
const currentRegexScript = ref<RegexScript | null>(null)
|
||||
const loading = ref(false)
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
|
||||
// 获取正则脚本列表
|
||||
const fetchRegexScriptList = async (params?: RegexScriptListParams) => {
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
const requestParams: RegexScriptListParams = {
|
||||
page: params?.page || currentPage.value,
|
||||
pageSize: params?.pageSize || pageSize.value,
|
||||
...params,
|
||||
}
|
||||
|
||||
const response = await regexScriptApi.getRegexScriptList(requestParams)
|
||||
regexScripts.value = response.data.list
|
||||
total.value = response.data.total
|
||||
currentPage.value = response.data.page
|
||||
pageSize.value = response.data.pageSize
|
||||
} catch (error: any) {
|
||||
console.error('获取正则脚本列表失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '获取正则脚本列表失败')
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取正则脚本详情
|
||||
const fetchRegexScriptDetail = async (id: number): Promise<RegexScript> => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await regexScriptApi.getRegexScript(id)
|
||||
currentRegexScript.value = response.data
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
console.error('获取正则脚本详情失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '获取正则脚本详情失败')
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 创建正则脚本
|
||||
const createRegexScript = async (data: CreateRegexScriptRequest): Promise<RegexScript> => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await regexScriptApi.createRegexScript(data)
|
||||
ElMessage.success('创建成功')
|
||||
await fetchRegexScriptList()
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
console.error('创建正则脚本失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '创建正则脚本失败')
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 更新正则脚本
|
||||
const updateRegexScript = async (id: number, data: UpdateRegexScriptRequest) => {
|
||||
try {
|
||||
loading.value = true
|
||||
await regexScriptApi.updateRegexScript(id, data)
|
||||
ElMessage.success('更新成功')
|
||||
await fetchRegexScriptList()
|
||||
} catch (error: any) {
|
||||
console.error('更新正则脚本失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '更新正则脚本失败')
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除正则脚本
|
||||
const deleteRegexScript = async (id: number) => {
|
||||
try {
|
||||
loading.value = true
|
||||
await regexScriptApi.deleteRegexScript(id)
|
||||
ElMessage.success('删除成功')
|
||||
await fetchRegexScriptList()
|
||||
} catch (error: any) {
|
||||
console.error('删除正则脚本失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '删除正则脚本失败')
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 复制正则脚本
|
||||
const duplicateRegexScript = async (id: number): Promise<RegexScript> => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await regexScriptApi.duplicateRegexScript(id)
|
||||
ElMessage.success('复制成功')
|
||||
await fetchRegexScriptList()
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
console.error('复制正则脚本失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '复制正则脚本失败')
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 关联角色到脚本
|
||||
const linkCharacters = async (id: number, characterIds: number[]) => {
|
||||
try {
|
||||
loading.value = true
|
||||
await regexScriptApi.linkCharactersToRegex(id, characterIds)
|
||||
ElMessage.success('关联成功')
|
||||
await fetchRegexScriptDetail(id)
|
||||
} catch (error: any) {
|
||||
console.error('关联角色失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '关联角色失败')
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取角色的脚本
|
||||
const fetchCharacterRegexScripts = async (characterId: number): Promise<RegexScript[]> => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await regexScriptApi.getCharacterRegexScripts(characterId)
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
console.error('获取角色脚本失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '获取角色脚本失败')
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 测试正则脚本
|
||||
const testRegexScript = async (data: TestRegexScriptRequest) => {
|
||||
try {
|
||||
const response = await regexScriptApi.testRegexScript(data)
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
console.error('测试正则脚本失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '测试正则脚本失败')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 导入正则脚本
|
||||
const importRegexScripts = async (file: File, overwriteMode: string = 'skip') => {
|
||||
try {
|
||||
loading.value = true
|
||||
await regexScriptApi.importRegexScripts(file, overwriteMode)
|
||||
ElMessage.success('导入成功')
|
||||
await fetchRegexScriptList()
|
||||
} catch (error: any) {
|
||||
console.error('导入正则脚本失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '导入正则脚本失败')
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 导出正则脚本
|
||||
const exportRegexScripts = async (scriptIds?: number[]) => {
|
||||
try {
|
||||
await regexScriptApi.downloadRegexScriptsJSON(scriptIds)
|
||||
ElMessage.success('导出成功')
|
||||
} catch (error: any) {
|
||||
console.error('导出正则脚本失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '导出正则脚本失败')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 重置分页
|
||||
const resetPagination = () => {
|
||||
currentPage.value = 1
|
||||
pageSize.value = 20
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
regexScripts,
|
||||
currentRegexScript,
|
||||
loading,
|
||||
total,
|
||||
currentPage,
|
||||
pageSize,
|
||||
|
||||
// 方法
|
||||
fetchRegexScriptList,
|
||||
fetchRegexScriptDetail,
|
||||
createRegexScript,
|
||||
updateRegexScript,
|
||||
deleteRegexScript,
|
||||
duplicateRegexScript,
|
||||
linkCharacters,
|
||||
fetchCharacterRegexScripts,
|
||||
testRegexScript,
|
||||
importRegexScripts,
|
||||
exportRegexScripts,
|
||||
resetPagination,
|
||||
}
|
||||
})
|
||||
@@ -35,10 +35,10 @@ export const useWorldInfoStore = defineStore('worldInfo', () => {
|
||||
}
|
||||
|
||||
const response = await worldInfoApi.getWorldBookList(requestParams)
|
||||
worldBooks.value = response.data.data.list
|
||||
total.value = response.data.data.total
|
||||
currentPage.value = response.data.data.page
|
||||
pageSize.value = response.data.data.pageSize
|
||||
worldBooks.value = response.data.list
|
||||
total.value = response.data.total
|
||||
currentPage.value = response.data.page
|
||||
pageSize.value = response.data.pageSize
|
||||
} catch (error: any) {
|
||||
console.error('获取世界书列表失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '获取世界书列表失败')
|
||||
@@ -53,8 +53,8 @@ export const useWorldInfoStore = defineStore('worldInfo', () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await worldInfoApi.getWorldBook(id)
|
||||
currentWorldBook.value = response.data.data
|
||||
return response.data.data
|
||||
currentWorldBook.value = response.data
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
console.error('获取世界书详情失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '获取世界书详情失败')
|
||||
@@ -71,7 +71,7 @@ export const useWorldInfoStore = defineStore('worldInfo', () => {
|
||||
const response = await worldInfoApi.createWorldBook(data)
|
||||
ElMessage.success('创建成功')
|
||||
await fetchWorldBookList()
|
||||
return response.data.data
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
console.error('创建世界书失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '创建世界书失败')
|
||||
@@ -120,7 +120,7 @@ export const useWorldInfoStore = defineStore('worldInfo', () => {
|
||||
const response = await worldInfoApi.duplicateWorldBook(id)
|
||||
ElMessage.success('复制成功')
|
||||
await fetchWorldBookList()
|
||||
return response.data.data
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
console.error('复制世界书失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '复制世界书失败')
|
||||
@@ -207,8 +207,8 @@ export const useWorldInfoStore = defineStore('worldInfo', () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await worldInfoApi.getCharacterWorldBooks(characterId)
|
||||
characterWorldBooks.value = response.data.data
|
||||
return response.data.data
|
||||
characterWorldBooks.value = response.data
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
console.error('获取角色世界书失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '获取角色世界书失败')
|
||||
@@ -225,7 +225,7 @@ export const useWorldInfoStore = defineStore('worldInfo', () => {
|
||||
const response = await worldInfoApi.importWorldBook(file, bookName)
|
||||
ElMessage.success('导入成功')
|
||||
await fetchWorldBookList()
|
||||
return response.data.data
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
console.error('导入世界书失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '导入世界书失败')
|
||||
@@ -254,7 +254,7 @@ export const useWorldInfoStore = defineStore('worldInfo', () => {
|
||||
const matchWorldInfo = async (params: MatchWorldInfoRequest): Promise<MatchedWorldInfoEntry[]> => {
|
||||
try {
|
||||
const response = await worldInfoApi.matchWorldInfo(params)
|
||||
return response.data.data.entries
|
||||
return response.data.entries
|
||||
} catch (error: any) {
|
||||
console.error('匹配世界书失败:', error)
|
||||
return []
|
||||
|
||||
129
web-app-vue/src/types/extension.d.ts
vendored
Normal file
129
web-app-vue/src/types/extension.d.ts
vendored
Normal file
@@ -0,0 +1,129 @@
|
||||
// 扩展类型定义(兼容 SillyTavern Extension 规范)
|
||||
|
||||
export interface Extension {
|
||||
id: number
|
||||
userId: number
|
||||
name: string
|
||||
displayName: string
|
||||
version: string
|
||||
author: string
|
||||
description: string
|
||||
homepage: string
|
||||
repository: string
|
||||
license: string
|
||||
tags: string[]
|
||||
extensionType: 'ui' | 'server' | 'hybrid'
|
||||
category: 'utilities' | 'themes' | 'integrations' | 'tools' | string
|
||||
dependencies: Record<string, string>
|
||||
conflicts: string[]
|
||||
manifestData: Record<string, any>
|
||||
scriptPath: string
|
||||
stylePath: string
|
||||
assetsPaths: string[]
|
||||
settings: Record<string, any>
|
||||
options: Record<string, any>
|
||||
isEnabled: boolean
|
||||
isInstalled: boolean
|
||||
isSystemExt: boolean
|
||||
installSource: 'url' | 'git' | 'file' | 'marketplace'
|
||||
sourceUrl: string // 原始安装 URL(用于更新)
|
||||
branch: string // Git 分支
|
||||
autoUpdate: boolean // 是否自动更新
|
||||
lastUpdateCheck?: string // 最后检查更新时间
|
||||
availableVersion?: string // 可用的新版本
|
||||
installDate: string
|
||||
lastEnabled: string
|
||||
usageCount: number
|
||||
errorCount: number
|
||||
loadTime: number
|
||||
metadata: Record<string, any>
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface ExtensionManifest {
|
||||
name: string
|
||||
display_name?: string
|
||||
version: string
|
||||
description: string
|
||||
author: string
|
||||
homepage?: string
|
||||
repository?: string
|
||||
license?: string
|
||||
tags?: string[]
|
||||
type?: 'ui' | 'server' | 'hybrid'
|
||||
category?: string
|
||||
dependencies?: Record<string, string>
|
||||
conflicts?: string[]
|
||||
entry?: string
|
||||
style?: string
|
||||
assets?: string[]
|
||||
settings?: Record<string, any>
|
||||
options?: Record<string, any>
|
||||
metadata?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface CreateExtensionRequest {
|
||||
name: string
|
||||
displayName?: string
|
||||
version?: string
|
||||
author?: string
|
||||
description?: string
|
||||
homepage?: string
|
||||
repository?: string
|
||||
license?: string
|
||||
tags?: string[]
|
||||
extensionType: 'ui' | 'server' | 'hybrid'
|
||||
category?: string
|
||||
dependencies?: Record<string, string>
|
||||
conflicts?: string[]
|
||||
manifestData: Record<string, any>
|
||||
scriptPath?: string
|
||||
stylePath?: string
|
||||
assetsPaths?: string[]
|
||||
settings?: Record<string, any>
|
||||
options?: Record<string, any>
|
||||
installSource?: string
|
||||
metadata?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface UpdateExtensionRequest {
|
||||
displayName?: string
|
||||
description?: string
|
||||
settings?: Record<string, any>
|
||||
options?: Record<string, any>
|
||||
metadata?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface ExtensionListParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
name?: string
|
||||
extensionType?: string
|
||||
category?: string
|
||||
isEnabled?: boolean
|
||||
isInstalled?: boolean
|
||||
tag?: string
|
||||
}
|
||||
|
||||
export interface ExtensionListResponse {
|
||||
list: Extension[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export interface ToggleExtensionRequest {
|
||||
isEnabled: boolean
|
||||
}
|
||||
|
||||
export interface UpdateExtensionSettingsRequest {
|
||||
settings: Record<string, any>
|
||||
}
|
||||
|
||||
export interface ExtensionStatsRequest {
|
||||
extensionId: number
|
||||
action: 'usage' | 'error' | 'load'
|
||||
value?: number
|
||||
}
|
||||
123
web-app-vue/src/types/regexScript.d.ts
vendored
Normal file
123
web-app-vue/src/types/regexScript.d.ts
vendored
Normal file
@@ -0,0 +1,123 @@
|
||||
// 正则脚本类型定义
|
||||
|
||||
export interface RegexScript {
|
||||
id: number
|
||||
userId: number
|
||||
scriptName: string
|
||||
description: string
|
||||
findRegex: string
|
||||
replaceString: string
|
||||
enabled: boolean
|
||||
isGlobal: boolean
|
||||
trimStrings: boolean
|
||||
onlyFormat: boolean
|
||||
runOnEdit: boolean
|
||||
substituteRegex: boolean
|
||||
minDepth?: number
|
||||
maxDepth?: number
|
||||
placement: string
|
||||
affectMinDepth?: number
|
||||
affectMaxDepth?: number
|
||||
linkedChars: string[]
|
||||
scriptData?: Record<string, any>
|
||||
usageCount: number
|
||||
lastUsedAt?: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface RegexScriptListParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
scriptName?: string
|
||||
isGlobal?: boolean
|
||||
enabled?: boolean
|
||||
characterId?: number
|
||||
}
|
||||
|
||||
export interface RegexScriptListResponse {
|
||||
list: RegexScript[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export interface CreateRegexScriptRequest {
|
||||
scriptName: string
|
||||
description?: string
|
||||
findRegex: string
|
||||
replaceString?: string
|
||||
enabled?: boolean
|
||||
isGlobal?: boolean
|
||||
trimStrings?: boolean
|
||||
onlyFormat?: boolean
|
||||
runOnEdit?: boolean
|
||||
substituteRegex?: boolean
|
||||
minDepth?: number
|
||||
maxDepth?: number
|
||||
placement?: string
|
||||
affectMinDepth?: number
|
||||
affectMaxDepth?: number
|
||||
linkedChars?: string[]
|
||||
scriptData?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface UpdateRegexScriptRequest {
|
||||
scriptName?: string
|
||||
description?: string
|
||||
findRegex?: string
|
||||
replaceString?: string
|
||||
enabled?: boolean
|
||||
isGlobal?: boolean
|
||||
trimStrings?: boolean
|
||||
onlyFormat?: boolean
|
||||
runOnEdit?: boolean
|
||||
substituteRegex?: boolean
|
||||
minDepth?: number
|
||||
maxDepth?: number
|
||||
placement?: string
|
||||
affectMinDepth?: number
|
||||
affectMaxDepth?: number
|
||||
linkedChars?: string[]
|
||||
scriptData?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface TestRegexScriptRequest {
|
||||
findRegex: string
|
||||
replaceString?: string
|
||||
testInput: string
|
||||
trimStrings?: boolean
|
||||
substituteRegex?: boolean
|
||||
}
|
||||
|
||||
export interface TestRegexScriptResponse {
|
||||
success: boolean
|
||||
input: string
|
||||
output: string
|
||||
matchedCount: number
|
||||
matches: string[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ApplyRegexScriptsRequest {
|
||||
text: string
|
||||
regexIds?: number[]
|
||||
characterId?: number
|
||||
placement?: string
|
||||
minDepth?: number
|
||||
maxDepth?: number
|
||||
useGlobal?: boolean
|
||||
}
|
||||
|
||||
export interface ApplyRegexScriptsResponse {
|
||||
originalText: string
|
||||
processedText: string
|
||||
appliedCount: number
|
||||
appliedScripts: number[]
|
||||
}
|
||||
|
||||
export interface RegexScriptExportData {
|
||||
version: string
|
||||
scripts: RegexScript[]
|
||||
exportedAt: number
|
||||
}
|
||||
488
web-app-vue/src/utils/extensionRuntime.ts
Normal file
488
web-app-vue/src/utils/extensionRuntime.ts
Normal file
@@ -0,0 +1,488 @@
|
||||
/**
|
||||
* SillyTavern 扩展运行时环境
|
||||
* 负责加载、运行和管理扩展的生命周期
|
||||
*/
|
||||
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { Extension } from '@/types/extension'
|
||||
|
||||
interface ExtensionInstance {
|
||||
extension: Extension
|
||||
manifest: any
|
||||
scriptElement?: HTMLScriptElement
|
||||
styleElement?: HTMLLinkElement
|
||||
isLoaded: boolean
|
||||
isRunning: boolean
|
||||
api?: any
|
||||
}
|
||||
|
||||
class ExtensionRuntime {
|
||||
private instances: Map<number, ExtensionInstance> = new Map()
|
||||
private stAPI: any = null
|
||||
// 扩展设置存储(兼容原版 SillyTavern 的 extension_settings)
|
||||
private extensionSettings: Record<string, any> = {}
|
||||
|
||||
constructor() {
|
||||
this.initSillyTavernAPI()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取扩展的配置容器 DOM 元素
|
||||
*/
|
||||
private getExtensionSettingsContainer(extensionName: string): HTMLElement | null {
|
||||
return document.getElementById(`extension-settings-${extensionName}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载扩展的已保存设置
|
||||
*/
|
||||
private loadExtensionSettings(extensionName: string): any {
|
||||
if (!this.extensionSettings[extensionName]) {
|
||||
const key = `ext_settings_${extensionName}`
|
||||
const saved = localStorage.getItem(key)
|
||||
if (saved) {
|
||||
try {
|
||||
this.extensionSettings[extensionName] = JSON.parse(saved)
|
||||
} catch (e) {
|
||||
console.error(`[ExtensionRuntime] 加载设置失败: ${extensionName}`, e)
|
||||
this.extensionSettings[extensionName] = {}
|
||||
}
|
||||
} else {
|
||||
this.extensionSettings[extensionName] = {}
|
||||
}
|
||||
}
|
||||
return this.extensionSettings[extensionName]
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存扩展设置到 localStorage
|
||||
*/
|
||||
private saveExtensionSettingsToStorage(extensionName: string) {
|
||||
const key = `ext_settings_${extensionName}`
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(this.extensionSettings[extensionName] || {}))
|
||||
} catch (e) {
|
||||
console.error(`[ExtensionRuntime] 保存设置失败: ${extensionName}`, e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 SillyTavern API(兼容层)
|
||||
*/
|
||||
private initSillyTavernAPI() {
|
||||
// 创建全局 SillyTavern API 对象
|
||||
this.stAPI = {
|
||||
// 扩展管理
|
||||
extensions: {
|
||||
register: (name: string, init: Function) => {
|
||||
console.log(`[Extension] 注册扩展: ${name}`)
|
||||
// 执行扩展的初始化函数
|
||||
try {
|
||||
init()
|
||||
} catch (error) {
|
||||
console.error(`[Extension] 初始化失败: ${name}`, error)
|
||||
}
|
||||
},
|
||||
|
||||
// 获取扩展设置
|
||||
getSettings: (extName: string) => {
|
||||
// 从 localStorage 或 store 获取设置
|
||||
const settings = localStorage.getItem(`ext_settings_${extName}`)
|
||||
return settings ? JSON.parse(settings) : {}
|
||||
},
|
||||
|
||||
// 保存扩展设置
|
||||
saveSettings: (extName: string, settings: any) => {
|
||||
localStorage.setItem(`ext_settings_${extName}`, JSON.stringify(settings))
|
||||
// 触发设置变更事件
|
||||
this.emitEvent('extensionSettingsLoaded', { name: extName })
|
||||
},
|
||||
|
||||
// 获取扩展列表
|
||||
list: () => {
|
||||
return Array.from(this.instances.values()).map(inst => ({
|
||||
name: inst.extension.name,
|
||||
displayName: inst.extension.displayName,
|
||||
version: inst.extension.version,
|
||||
enabled: inst.extension.isEnabled,
|
||||
loaded: inst.isLoaded,
|
||||
running: inst.isRunning,
|
||||
}))
|
||||
},
|
||||
},
|
||||
|
||||
// UI 工具
|
||||
ui: {
|
||||
// 创建设置面板
|
||||
createSettings: (title: string, content: HTMLElement) => {
|
||||
console.log(`[Extension] 创建设置面板: ${title}`)
|
||||
// 可以在这里创建一个设置面板并添加到页面
|
||||
},
|
||||
|
||||
// 显示通知
|
||||
notify: (message: string, type: 'success' | 'error' | 'warning' | 'info' = 'info') => {
|
||||
ElMessage[type](message)
|
||||
},
|
||||
|
||||
// 添加 UI 元素到指定位置
|
||||
addElement: (element: HTMLElement, targetSelector: string) => {
|
||||
const target = document.querySelector(targetSelector)
|
||||
if (target) {
|
||||
target.appendChild(element)
|
||||
}
|
||||
},
|
||||
|
||||
// 创建按钮
|
||||
createButton: (text: string, onClick: Function) => {
|
||||
const button = document.createElement('button')
|
||||
button.textContent = text
|
||||
button.onclick = onClick as any
|
||||
return button
|
||||
},
|
||||
},
|
||||
|
||||
// 事件系统
|
||||
eventSource: {
|
||||
on: (event: string, callback: Function) => {
|
||||
window.addEventListener(`st:${event}`, (e: any) => callback(e.detail))
|
||||
},
|
||||
|
||||
once: (event: string, callback: Function) => {
|
||||
const handler = (e: any) => {
|
||||
callback(e.detail)
|
||||
window.removeEventListener(`st:${event}`, handler)
|
||||
}
|
||||
window.addEventListener(`st:${event}`, handler)
|
||||
},
|
||||
|
||||
off: (event: string, callback?: Function) => {
|
||||
if (callback) {
|
||||
window.removeEventListener(`st:${event}`, callback as any)
|
||||
}
|
||||
},
|
||||
|
||||
emit: (event: string, data?: any) => {
|
||||
window.dispatchEvent(new CustomEvent(`st:${event}`, { detail: data }))
|
||||
},
|
||||
},
|
||||
|
||||
// 获取当前上下文
|
||||
getContext: () => {
|
||||
// 尝试从路由或 store 获取当前上下文
|
||||
const context = {
|
||||
characterId: null as number | null,
|
||||
chatId: null as number | null,
|
||||
groupId: null as number | null,
|
||||
userName: '',
|
||||
characterName: '',
|
||||
|
||||
// extension_settings 对象(兼容原版 SillyTavern)
|
||||
extension_settings: new Proxy(this.extensionSettings, {
|
||||
get: (target, prop: string) => {
|
||||
if (typeof prop === 'string') {
|
||||
// 自动加载设置(如果还没加载)
|
||||
this.loadExtensionSettings(prop)
|
||||
return target[prop] || {}
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
set: (target, prop: string, value) => {
|
||||
if (typeof prop === 'string') {
|
||||
target[prop] = value
|
||||
// 自动保存到 localStorage
|
||||
this.saveExtensionSettingsToStorage(prop)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
}),
|
||||
|
||||
// 获取扩展配置容器的方法
|
||||
getExtensionSettingsContainer: (extensionName: string) => {
|
||||
return this.getExtensionSettingsContainer(extensionName)
|
||||
},
|
||||
}
|
||||
|
||||
// 可以从实际应用状态获取
|
||||
try {
|
||||
const route = (window as any).$route
|
||||
if (route) {
|
||||
context.characterId = route.params.characterId ? parseInt(route.params.characterId) : null
|
||||
context.chatId = route.params.chatId ? parseInt(route.params.chatId) : null
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return context
|
||||
},
|
||||
|
||||
// 工具函数
|
||||
utils: {
|
||||
// 延迟执行
|
||||
delay: (ms: number) => new Promise(resolve => setTimeout(resolve, ms)),
|
||||
|
||||
// 生成唯一 ID
|
||||
generateId: () => `ext_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
|
||||
// 安全解析 JSON
|
||||
parseJSON: (str: string, fallback: any = null) => {
|
||||
try {
|
||||
return JSON.parse(str)
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// 存储工具
|
||||
storage: {
|
||||
get: (key: string, defaultValue: any = null) => {
|
||||
const value = localStorage.getItem(`st_${key}`)
|
||||
return value ? JSON.parse(value) : defaultValue
|
||||
},
|
||||
|
||||
set: (key: string, value: any) => {
|
||||
localStorage.setItem(`st_${key}`, JSON.stringify(value))
|
||||
},
|
||||
|
||||
remove: (key: string) => {
|
||||
localStorage.removeItem(`st_${key}`)
|
||||
},
|
||||
|
||||
clear: () => {
|
||||
const keys = Object.keys(localStorage).filter(k => k.startsWith('st_'))
|
||||
keys.forEach(k => localStorage.removeItem(k))
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// 挂载到 window 对象
|
||||
;(window as any).SillyTavern = this.stAPI
|
||||
;(window as any).st = this.stAPI // 简写别名
|
||||
|
||||
// 触发 API 初始化完成事件
|
||||
this.emitEvent('apiReady')
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载扩展
|
||||
*/
|
||||
async loadExtension(extension: Extension): Promise<boolean> {
|
||||
try {
|
||||
console.log(`[Extension] 加载扩展: ${extension.name}`)
|
||||
|
||||
// 检查是否已加载
|
||||
if (this.instances.has(extension.id) && this.instances.get(extension.id)?.isLoaded) {
|
||||
console.log(`[Extension] 扩展已加载: ${extension.name}`)
|
||||
return true
|
||||
}
|
||||
|
||||
// 解析 manifest
|
||||
const manifest = typeof extension.manifestData === 'string'
|
||||
? JSON.parse(extension.manifestData)
|
||||
: extension.manifestData
|
||||
|
||||
const instance: ExtensionInstance = {
|
||||
extension,
|
||||
manifest,
|
||||
isLoaded: false,
|
||||
isRunning: false,
|
||||
}
|
||||
|
||||
// 加载样式文件
|
||||
if (extension.stylePath) {
|
||||
await this.loadStyle(extension, instance)
|
||||
}
|
||||
|
||||
// 加载脚本文件
|
||||
if (extension.scriptPath) {
|
||||
await this.loadScript(extension, instance)
|
||||
} else if (manifest.inline_script) {
|
||||
// 支持内联脚本
|
||||
await this.loadInlineScript(extension, instance, manifest.inline_script)
|
||||
}
|
||||
|
||||
instance.isLoaded = true
|
||||
this.instances.set(extension.id, instance)
|
||||
|
||||
console.log(`[Extension] 加载成功: ${extension.name}`)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error(`[Extension] 加载失败: ${extension.name}`, error)
|
||||
ElMessage.error(`加载扩展失败: ${extension.name}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载扩展样式
|
||||
*/
|
||||
private async loadStyle(extension: Extension, instance: ExtensionInstance): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.href = this.getAssetURL(extension.stylePath!)
|
||||
link.dataset.extension = extension.name
|
||||
|
||||
link.onload = () => {
|
||||
instance.styleElement = link
|
||||
resolve()
|
||||
}
|
||||
|
||||
link.onerror = () => reject(new Error(`样式加载失败: ${extension.stylePath}`))
|
||||
|
||||
document.head.appendChild(link)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载扩展脚本
|
||||
*/
|
||||
private async loadScript(extension: Extension, instance: ExtensionInstance): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script')
|
||||
script.src = this.getAssetURL(extension.scriptPath!)
|
||||
script.dataset.extension = extension.name
|
||||
script.async = true
|
||||
|
||||
script.onload = () => {
|
||||
instance.scriptElement = script
|
||||
instance.isRunning = true
|
||||
resolve()
|
||||
}
|
||||
|
||||
script.onerror = () => reject(new Error(`脚本加载失败: ${extension.scriptPath}`))
|
||||
|
||||
document.body.appendChild(script)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载内联脚本
|
||||
*/
|
||||
private async loadInlineScript(extension: Extension, instance: ExtensionInstance, scriptCode: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const script = document.createElement('script')
|
||||
script.textContent = scriptCode
|
||||
script.dataset.extension = extension.name
|
||||
script.dataset.inline = 'true'
|
||||
|
||||
document.body.appendChild(script)
|
||||
|
||||
instance.scriptElement = script
|
||||
instance.isRunning = true
|
||||
resolve()
|
||||
} catch (error) {
|
||||
reject(new Error(`内联脚本执行失败: ${error}`))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 卸载扩展
|
||||
*/
|
||||
async unloadExtension(extensionId: number): Promise<boolean> {
|
||||
const instance = this.instances.get(extensionId)
|
||||
if (!instance) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`[Extension] 卸载扩展: ${instance.extension.name}`)
|
||||
|
||||
// 移除样式
|
||||
if (instance.styleElement) {
|
||||
instance.styleElement.remove()
|
||||
}
|
||||
|
||||
// 移除脚本
|
||||
if (instance.scriptElement) {
|
||||
instance.scriptElement.remove()
|
||||
}
|
||||
|
||||
instance.isLoaded = false
|
||||
instance.isRunning = false
|
||||
|
||||
this.instances.delete(extensionId)
|
||||
|
||||
console.log(`[Extension] 卸载成功: ${instance.extension.name}`)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error(`[Extension] 卸载失败: ${instance.extension.name}`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用扩展
|
||||
*/
|
||||
async enableExtension(extension: Extension): Promise<boolean> {
|
||||
if (!extension.isEnabled) {
|
||||
return false
|
||||
}
|
||||
|
||||
return await this.loadExtension(extension)
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用扩展
|
||||
*/
|
||||
async disableExtension(extensionId: number): Promise<boolean> {
|
||||
return await this.unloadExtension(extensionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新加载扩展
|
||||
*/
|
||||
async reloadExtension(extension: Extension): Promise<boolean> {
|
||||
await this.unloadExtension(extension.id)
|
||||
return await this.loadExtension(extension)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资源 URL
|
||||
* 这里需要根据实际情况处理:
|
||||
* 1. 如果扩展文件存储在服务器上,需要通过 API 获取
|
||||
* 2. 如果是 CDN,直接使用 URL
|
||||
* 3. 如果是 base64 编码,需要转换
|
||||
*/
|
||||
private getAssetURL(path: string): string {
|
||||
// 如果是完整 URL,直接返回
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
return path
|
||||
}
|
||||
|
||||
// 如果是相对路径,需要根据实际部署情况构建完整 URL
|
||||
// 这里假设扩展文件存储在 /api/extension/assets/ 路径下
|
||||
return `/api/app/extension/assets/${path}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取扩展实例
|
||||
*/
|
||||
getInstance(extensionId: number): ExtensionInstance | undefined {
|
||||
return this.instances.get(extensionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有已加载的扩展
|
||||
*/
|
||||
getLoadedExtensions(): ExtensionInstance[] {
|
||||
return Array.from(this.instances.values()).filter(inst => inst.isLoaded)
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发扩展事件
|
||||
*/
|
||||
emitEvent(event: string, data?: any) {
|
||||
this.stAPI.eventSource.emit(event, data)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建单例
|
||||
export const extensionRuntime = new ExtensionRuntime()
|
||||
|
||||
// 导出类型
|
||||
export type { ExtensionInstance }
|
||||
522
web-app-vue/src/views/extension/ExtensionList.vue
Normal file
522
web-app-vue/src/views/extension/ExtensionList.vue
Normal file
@@ -0,0 +1,522 @@
|
||||
<template>
|
||||
<div class="extension-list-page">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<h2>扩展管理</h2>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" @click="showInstallDialog = true">
|
||||
<el-icon><Plus /></el-icon>
|
||||
安装扩展
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 过滤器 -->
|
||||
<div class="filters">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索扩展名称..."
|
||||
clearable
|
||||
@change="handleSearch"
|
||||
style="width: 300px; margin-right: 10px"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
|
||||
<el-select
|
||||
v-model="filterType"
|
||||
placeholder="扩展类型"
|
||||
clearable
|
||||
@change="handleFilterChange"
|
||||
style="width: 150px; margin-right: 10px"
|
||||
>
|
||||
<el-option label="UI 扩展" value="ui" />
|
||||
<el-option label="服务端扩展" value="server" />
|
||||
<el-option label="混合扩展" value="hybrid" />
|
||||
</el-select>
|
||||
|
||||
<el-select
|
||||
v-model="filterCategory"
|
||||
placeholder="分类"
|
||||
clearable
|
||||
@change="handleFilterChange"
|
||||
style="width: 150px; margin-right: 10px"
|
||||
>
|
||||
<el-option label="工具" value="utilities" />
|
||||
<el-option label="主题" value="themes" />
|
||||
<el-option label="集成" value="integrations" />
|
||||
<el-option label="其他" value="tools" />
|
||||
</el-select>
|
||||
|
||||
<el-select
|
||||
v-model="filterEnabled"
|
||||
placeholder="状态"
|
||||
clearable
|
||||
@change="handleFilterChange"
|
||||
style="width: 120px; margin-right: 10px"
|
||||
>
|
||||
<el-option label="已启用" :value="true" />
|
||||
<el-option label="已禁用" :value="false" />
|
||||
</el-select>
|
||||
|
||||
<el-button @click="handleResetFilters">重置</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 扩展列表 -->
|
||||
<el-table
|
||||
v-loading="extensionStore.loading"
|
||||
:data="extensionStore.extensions"
|
||||
style="width: 100%; margin-top: 20px"
|
||||
>
|
||||
<el-table-column prop="displayName" label="扩展名称" width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="extension-name">
|
||||
<strong>{{ row.displayName || row.name }}</strong>
|
||||
<el-tag
|
||||
v-if="row.isSystemExt"
|
||||
size="small"
|
||||
type="info"
|
||||
style="margin-left: 8px"
|
||||
>
|
||||
系统
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="description" label="描述" show-overflow-tooltip />
|
||||
|
||||
<el-table-column prop="version" label="版本" width="100" />
|
||||
|
||||
<el-table-column prop="author" label="作者" width="120" />
|
||||
|
||||
<el-table-column prop="extensionType" label="类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getTypeTagType(row.extensionType)" size="small">
|
||||
{{ getTypeLabel(row.extensionType) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="category" label="分类" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ getCategoryLabel(row.category) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="isEnabled" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
v-model="row.isEnabled"
|
||||
:disabled="row.isSystemExt"
|
||||
@change="handleToggle(row)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="280" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
size="small"
|
||||
@click="handleViewDetail(row)"
|
||||
>
|
||||
详情
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
@click="handleSettings(row)"
|
||||
>
|
||||
配置
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
@click="handleExport(row)"
|
||||
>
|
||||
导出
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="!row.isSystemExt"
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="handleDelete(row)"
|
||||
>
|
||||
卸载
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<el-pagination
|
||||
v-model:current-page="extensionStore.currentPage"
|
||||
v-model:page-size="extensionStore.pageSize"
|
||||
:total="extensionStore.total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
style="margin-top: 20px; justify-content: flex-end"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 安装扩展对话框 -->
|
||||
<el-dialog
|
||||
v-model="showInstallDialog"
|
||||
title="安装扩展"
|
||||
width="600px"
|
||||
>
|
||||
<el-tabs v-model="installTab">
|
||||
<el-tab-pane label="从文件导入" name="file">
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
:auto-upload="false"
|
||||
:limit="1"
|
||||
accept=".json"
|
||||
:on-change="handleFileSelect"
|
||||
drag
|
||||
>
|
||||
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
|
||||
<div class="el-upload__text">
|
||||
将 manifest.json 拖到此处,或<em>点击上传</em>
|
||||
</div>
|
||||
<template #tip>
|
||||
<div class="el-upload__tip">
|
||||
只能上传 manifest.json 文件
|
||||
</div>
|
||||
</template>
|
||||
</el-upload>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="从 Git 安装" name="git">
|
||||
<el-form :model="installForm" label-width="120px">
|
||||
<el-form-item label="Git 仓库 URL">
|
||||
<el-input
|
||||
v-model="installForm.gitUrl"
|
||||
placeholder="https://github.com/username/extension-name.git"
|
||||
/>
|
||||
<template #extra>
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 5px">
|
||||
支持 GitHub、GitLab、Gitee 等 Git 仓库<br />
|
||||
示例:https://github.com/SillyTavern/Extension-Example.git
|
||||
</div>
|
||||
</template>
|
||||
</el-form-item>
|
||||
<el-form-item label="分支(可选)">
|
||||
<el-input
|
||||
v-model="installForm.branch"
|
||||
placeholder="main"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="从 URL 安装" name="url">
|
||||
<el-form :model="installForm" label-width="120px">
|
||||
<el-form-item label="Manifest URL">
|
||||
<el-input
|
||||
v-model="installForm.url"
|
||||
placeholder="https://example.com/manifest.json"
|
||||
/>
|
||||
<template #extra>
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 5px">
|
||||
直接指向 manifest.json 文件的 URL
|
||||
</div>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="showInstallDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleInstall" :loading="extensionStore.loading">
|
||||
安装
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 扩展详情对话框 -->
|
||||
<el-dialog
|
||||
v-model="showDetailDialog"
|
||||
:title="currentExtension?.displayName || currentExtension?.name"
|
||||
width="800px"
|
||||
>
|
||||
<el-descriptions v-if="currentExtension" :column="2" border>
|
||||
<el-descriptions-item label="扩展名称">
|
||||
{{ currentExtension.displayName || currentExtension.name }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="版本">
|
||||
{{ currentExtension.version }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="作者">
|
||||
{{ currentExtension.author }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="许可证">
|
||||
{{ currentExtension.license || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="类型">
|
||||
{{ getTypeLabel(currentExtension.extensionType) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="分类">
|
||||
{{ getCategoryLabel(currentExtension.category) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="主页" :span="2">
|
||||
<a v-if="currentExtension.homepage" :href="currentExtension.homepage" target="_blank">
|
||||
{{ currentExtension.homepage }}
|
||||
</a>
|
||||
<span v-else>-</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="仓库" :span="2">
|
||||
<a v-if="currentExtension.repository" :href="currentExtension.repository" target="_blank">
|
||||
{{ currentExtension.repository }}
|
||||
</a>
|
||||
<span v-else>-</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="描述" :span="2">
|
||||
{{ currentExtension.description }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="标签" :span="2">
|
||||
<el-tag
|
||||
v-for="tag in currentExtension.tags"
|
||||
:key="tag"
|
||||
size="small"
|
||||
style="margin-right: 5px"
|
||||
>
|
||||
{{ tag }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="安装日期">
|
||||
{{ formatDate(currentExtension.installDate) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="使用次数">
|
||||
{{ currentExtension.usageCount }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessageBox, ElMessage } from 'element-plus'
|
||||
import { Plus, Search, UploadFilled } from '@element-plus/icons-vue'
|
||||
import { useExtensionStore } from '@/stores/extension'
|
||||
import type { Extension } from '@/types/extension'
|
||||
|
||||
const router = useRouter()
|
||||
const extensionStore = useExtensionStore()
|
||||
|
||||
// 搜索和过滤
|
||||
const searchKeyword = ref('')
|
||||
const filterType = ref('')
|
||||
const filterCategory = ref('')
|
||||
const filterEnabled = ref<boolean | undefined>(undefined)
|
||||
|
||||
// 对话框
|
||||
const showInstallDialog = ref(false)
|
||||
const showDetailDialog = ref(false)
|
||||
const installTab = ref('git')
|
||||
const installForm = ref({
|
||||
gitUrl: '',
|
||||
branch: 'main',
|
||||
url: '',
|
||||
})
|
||||
const uploadRef = ref()
|
||||
const selectedFile = ref<File | null>(null)
|
||||
const currentExtension = ref<Extension | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
extensionStore.fetchExtensionList()
|
||||
})
|
||||
|
||||
const handleSearch = () => {
|
||||
extensionStore.fetchExtensionList({ keyword: searchKeyword.value })
|
||||
}
|
||||
|
||||
const handleFilterChange = () => {
|
||||
extensionStore.setFilters({
|
||||
extensionType: filterType.value,
|
||||
category: filterCategory.value,
|
||||
isEnabled: filterEnabled.value,
|
||||
})
|
||||
}
|
||||
|
||||
const handleResetFilters = () => {
|
||||
searchKeyword.value = ''
|
||||
filterType.value = ''
|
||||
filterCategory.value = ''
|
||||
filterEnabled.value = undefined
|
||||
extensionStore.resetFilters()
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
extensionStore.currentPage = page
|
||||
extensionStore.fetchExtensionList()
|
||||
}
|
||||
|
||||
const handleSizeChange = (size: number) => {
|
||||
extensionStore.pageSize = size
|
||||
extensionStore.fetchExtensionList()
|
||||
}
|
||||
|
||||
const handleToggle = async (row: Extension) => {
|
||||
try {
|
||||
await extensionStore.toggleExtension(row.id, row.isEnabled)
|
||||
} catch (error) {
|
||||
// 切换失败,还原状态
|
||||
row.isEnabled = !row.isEnabled
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewDetail = (row: Extension) => {
|
||||
currentExtension.value = row
|
||||
showDetailDialog.value = true
|
||||
}
|
||||
|
||||
const handleSettings = (row: Extension) => {
|
||||
router.push(`/extension/settings/${row.id}`)
|
||||
}
|
||||
|
||||
const handleExport = async (row: Extension) => {
|
||||
try {
|
||||
await extensionStore.exportExtension(row.id, row.name)
|
||||
} catch (error) {
|
||||
console.error('导出失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (row: Extension) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要卸载扩展 "${row.displayName || row.name}" 吗?`,
|
||||
'确认卸载',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
}
|
||||
)
|
||||
await extensionStore.deleteExtension(row.id, false)
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('卸载失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileSelect = (file: any) => {
|
||||
selectedFile.value = file.raw
|
||||
}
|
||||
|
||||
const handleInstall = async () => {
|
||||
try {
|
||||
if (installTab.value === 'file') {
|
||||
if (!selectedFile.value) {
|
||||
ElMessage.warning('请选择要上传的文件')
|
||||
return
|
||||
}
|
||||
await extensionStore.importExtension(selectedFile.value)
|
||||
} else if (installTab.value === 'git') {
|
||||
if (!installForm.value.gitUrl) {
|
||||
ElMessage.warning('请输入 Git 仓库 URL')
|
||||
return
|
||||
}
|
||||
await extensionStore.installExtensionFromGit(
|
||||
installForm.value.gitUrl,
|
||||
installForm.value.branch || 'main'
|
||||
)
|
||||
} else {
|
||||
if (!installForm.value.url) {
|
||||
ElMessage.warning('请输入 Manifest URL')
|
||||
return
|
||||
}
|
||||
await extensionStore.installExtensionFromUrl(installForm.value.url)
|
||||
}
|
||||
|
||||
showInstallDialog.value = false
|
||||
selectedFile.value = null
|
||||
installForm.value = { gitUrl: '', branch: 'main', url: '' }
|
||||
} catch (error) {
|
||||
console.error('安装失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
ui: 'UI',
|
||||
server: '服务端',
|
||||
hybrid: '混合',
|
||||
}
|
||||
return labels[type] || type
|
||||
}
|
||||
|
||||
const getTypeTagType = (type: string) => {
|
||||
const types: Record<string, any> = {
|
||||
ui: 'primary',
|
||||
server: 'success',
|
||||
hybrid: 'warning',
|
||||
}
|
||||
return types[type] || ''
|
||||
}
|
||||
|
||||
const getCategoryLabel = (category: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
utilities: '工具',
|
||||
themes: '主题',
|
||||
integrations: '集成',
|
||||
tools: '其他',
|
||||
}
|
||||
return labels[category] || category || '-'
|
||||
}
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
if (!date) return '-'
|
||||
return new Date(date).toLocaleString('zh-CN')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.extension-list-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.extension-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.el-icon--upload {
|
||||
font-size: 67px;
|
||||
color: #c0c4cc;
|
||||
margin: 40px 0 16px;
|
||||
line-height: 50px;
|
||||
}
|
||||
</style>
|
||||
798
web-app-vue/src/views/extension/ExtensionListNew.vue
Normal file
798
web-app-vue/src/views/extension/ExtensionListNew.vue
Normal file
@@ -0,0 +1,798 @@
|
||||
<template>
|
||||
<div class="extension-panel-page">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<h2>扩展管理</h2>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" @click="showInstallDialog = true" size="small">
|
||||
<el-icon><Plus /></el-icon>
|
||||
安装扩展
|
||||
</el-button>
|
||||
<el-button @click="handleRefresh" size="small">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 搜索和过滤 -->
|
||||
<div class="filters">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索扩展..."
|
||||
clearable
|
||||
@input="handleSearch"
|
||||
style="max-width: 400px"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<!-- 折叠面板式扩展列表 -->
|
||||
<div class="extension-panels" v-loading="extensionStore.loading">
|
||||
<el-empty v-if="filteredExtensions.length === 0" description="暂无扩展" />
|
||||
|
||||
<el-collapse v-else v-model="activeNames" accordion>
|
||||
<el-collapse-item
|
||||
v-for="ext in filteredExtensions"
|
||||
:key="ext.id"
|
||||
:name="ext.id"
|
||||
class="extension-item"
|
||||
>
|
||||
<!-- 扩展头部 -->
|
||||
<template #title>
|
||||
<div class="extension-header">
|
||||
<div class="extension-info">
|
||||
<div class="extension-title">
|
||||
<el-switch
|
||||
v-model="ext.isEnabled"
|
||||
size="default"
|
||||
@click.stop="handleToggle(ext)"
|
||||
@change="handleToggle(ext)"
|
||||
style="margin-right: 12px"
|
||||
/>
|
||||
<span class="name">{{ ext.displayName || ext.name }}</span>
|
||||
<el-tag
|
||||
v-if="ext.isSystemExt"
|
||||
size="small"
|
||||
type="info"
|
||||
style="margin-left: 8px"
|
||||
>
|
||||
系统
|
||||
</el-tag>
|
||||
<el-tag
|
||||
:type="getTypeTagType(ext.extensionType)"
|
||||
size="small"
|
||||
style="margin-left: 8px"
|
||||
>
|
||||
{{ getTypeLabel(ext.extensionType) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="extension-meta">
|
||||
<span class="version">v{{ ext.version }}</span>
|
||||
<span class="author" v-if="ext.author">by {{ ext.author }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="extension-actions" @click.stop>
|
||||
<el-button
|
||||
v-if="!ext.isSystemExt"
|
||||
size="small"
|
||||
type="danger"
|
||||
text
|
||||
@click="handleDelete(ext)"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 扩展详情和配置 -->
|
||||
<div class="extension-content">
|
||||
<div class="extension-description" v-if="ext.description">
|
||||
<p>{{ ext.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 扩展配置区域 -->
|
||||
<div class="extension-settings" v-loading="loadingSettings[ext.id]">
|
||||
<el-form
|
||||
v-if="extensionSettings[ext.id]"
|
||||
:model="extensionSettings[ext.id]"
|
||||
label-position="top"
|
||||
size="default"
|
||||
>
|
||||
<!-- 动态生成配置项 -->
|
||||
<template v-for="(setting, key) in getSettingsSchema(ext)" :key="key">
|
||||
<!-- 文本输入 -->
|
||||
<el-form-item
|
||||
v-if="setting.type === 'text' || setting.type === 'string'"
|
||||
:label="setting.label || key"
|
||||
>
|
||||
<el-input
|
||||
v-model="extensionSettings[ext.id][key]"
|
||||
:placeholder="setting.placeholder || setting.description"
|
||||
@change="handleSettingChange(ext.id, key)"
|
||||
/>
|
||||
<div v-if="setting.description" class="setting-hint">
|
||||
{{ setting.description }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 数字输入 -->
|
||||
<el-form-item
|
||||
v-else-if="setting.type === 'number'"
|
||||
:label="setting.label || key"
|
||||
>
|
||||
<el-input-number
|
||||
v-model="extensionSettings[ext.id][key]"
|
||||
:min="setting.min"
|
||||
:max="setting.max"
|
||||
:step="setting.step || 1"
|
||||
@change="handleSettingChange(ext.id, key)"
|
||||
/>
|
||||
<div v-if="setting.description" class="setting-hint">
|
||||
{{ setting.description }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 布尔开关 -->
|
||||
<el-form-item
|
||||
v-else-if="setting.type === 'boolean' || setting.type === 'checkbox'"
|
||||
:label="setting.label || key"
|
||||
>
|
||||
<el-switch
|
||||
v-model="extensionSettings[ext.id][key]"
|
||||
@change="handleSettingChange(ext.id, key)"
|
||||
/>
|
||||
<div v-if="setting.description" class="setting-hint">
|
||||
{{ setting.description }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 下拉选择 -->
|
||||
<el-form-item
|
||||
v-else-if="setting.type === 'select'"
|
||||
:label="setting.label || key"
|
||||
>
|
||||
<el-select
|
||||
v-model="extensionSettings[ext.id][key]"
|
||||
:placeholder="setting.placeholder"
|
||||
@change="handleSettingChange(ext.id, key)"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in setting.options"
|
||||
:key="option.value || option"
|
||||
:label="option.label || option"
|
||||
:value="option.value || option"
|
||||
/>
|
||||
</el-select>
|
||||
<div v-if="setting.description" class="setting-hint">
|
||||
{{ setting.description }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 文本域 -->
|
||||
<el-form-item
|
||||
v-else-if="setting.type === 'textarea'"
|
||||
:label="setting.label || key"
|
||||
>
|
||||
<el-input
|
||||
v-model="extensionSettings[ext.id][key]"
|
||||
type="textarea"
|
||||
:rows="setting.rows || 3"
|
||||
:placeholder="setting.placeholder || setting.description"
|
||||
@change="handleSettingChange(ext.id, key)"
|
||||
/>
|
||||
<div v-if="setting.description" class="setting-hint">
|
||||
{{ setting.description }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 滑块 -->
|
||||
<el-form-item
|
||||
v-else-if="setting.type === 'slider'"
|
||||
:label="setting.label || key"
|
||||
>
|
||||
<el-slider
|
||||
v-model="extensionSettings[ext.id][key]"
|
||||
:min="setting.min || 0"
|
||||
:max="setting.max || 100"
|
||||
:step="setting.step || 1"
|
||||
:show-input="true"
|
||||
@change="handleSettingChange(ext.id, key)"
|
||||
/>
|
||||
<div v-if="setting.description" class="setting-hint">
|
||||
{{ setting.description }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 如果没有配置项 -->
|
||||
<el-empty
|
||||
v-if="Object.keys(getSettingsSchema(ext)).length === 0"
|
||||
description="此扩展没有可配置项"
|
||||
:image-size="60"
|
||||
/>
|
||||
</el-form>
|
||||
|
||||
<el-alert
|
||||
v-else-if="settingsError[ext.id]"
|
||||
type="error"
|
||||
:title="settingsError[ext.id]"
|
||||
:closable="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 扩展详细信息 -->
|
||||
<el-divider />
|
||||
<div class="extension-info-detail">
|
||||
<el-descriptions :column="2" size="small" border>
|
||||
<el-descriptions-item label="分类">
|
||||
{{ getCategoryLabel(ext.category) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="许可证">
|
||||
{{ ext.license || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="主页" :span="2" v-if="ext.homepage">
|
||||
<el-link :href="ext.homepage" target="_blank" type="primary">
|
||||
{{ ext.homepage }}
|
||||
</el-link>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="仓库" :span="2" v-if="ext.repository">
|
||||
<el-link :href="ext.repository" target="_blank" type="primary">
|
||||
{{ ext.repository }}
|
||||
</el-link>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="安装来源">
|
||||
{{ getInstallSourceLabel(ext.installSource) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="安装日期">
|
||||
{{ formatDate(ext.installDate) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="extension-actions-bar">
|
||||
<el-button size="small" @click="handleExport(ext)">
|
||||
<el-icon><Download /></el-icon>
|
||||
导出
|
||||
</el-button>
|
||||
<el-button size="small" @click="handleRefreshExtension(ext)">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新配置
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 安装扩展对话框 -->
|
||||
<el-dialog
|
||||
v-model="showInstallDialog"
|
||||
title="安装扩展"
|
||||
width="600px"
|
||||
>
|
||||
<el-tabs v-model="installTab">
|
||||
<el-tab-pane label="从文件导入" name="file">
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
:auto-upload="false"
|
||||
:limit="1"
|
||||
accept=".json"
|
||||
:on-change="handleFileSelect"
|
||||
drag
|
||||
>
|
||||
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
|
||||
<div class="el-upload__text">
|
||||
将 manifest.json 拖到此处,或<em>点击上传</em>
|
||||
</div>
|
||||
<template #tip>
|
||||
<div class="el-upload__tip">
|
||||
只能上传 manifest.json 文件
|
||||
</div>
|
||||
</template>
|
||||
</el-upload>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="从 Git 安装" name="git">
|
||||
<el-form :model="installForm" label-width="120px">
|
||||
<el-form-item label="Git 仓库 URL">
|
||||
<el-input
|
||||
v-model="installForm.gitUrl"
|
||||
placeholder="https://github.com/username/extension-name.git"
|
||||
/>
|
||||
<template #extra>
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 5px">
|
||||
支持 GitHub、GitLab、Gitee 等 Git 仓库<br />
|
||||
示例:https://github.com/SillyTavern/Extension-Example.git
|
||||
</div>
|
||||
</template>
|
||||
</el-form-item>
|
||||
<el-form-item label="分支(可选)">
|
||||
<el-input
|
||||
v-model="installForm.branch"
|
||||
placeholder="main"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="从 URL 安装" name="url">
|
||||
<el-form :model="installForm" label-width="120px">
|
||||
<el-form-item label="Manifest URL">
|
||||
<el-input
|
||||
v-model="installForm.url"
|
||||
placeholder="https://example.com/manifest.json"
|
||||
/>
|
||||
<template #extra>
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 5px">
|
||||
直接指向 manifest.json 文件的 URL
|
||||
</div>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="showInstallDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleInstall" :loading="extensionStore.loading">
|
||||
安装
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { useExtensionStore } from '@/stores/extension'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Refresh,
|
||||
Delete,
|
||||
Download,
|
||||
UploadFilled,
|
||||
} from '@element-plus/icons-vue'
|
||||
import type { Extension } from '@/types/extension'
|
||||
|
||||
const extensionStore = useExtensionStore()
|
||||
|
||||
// 搜索和过滤
|
||||
const searchKeyword = ref('')
|
||||
const activeNames = ref<number | null>(null)
|
||||
|
||||
// 安装对话框
|
||||
const showInstallDialog = ref(false)
|
||||
const installTab = ref('git')
|
||||
const installForm = reactive({
|
||||
gitUrl: '',
|
||||
branch: 'main',
|
||||
url: '',
|
||||
})
|
||||
const selectedFile = ref<File | null>(null)
|
||||
const uploadRef = ref()
|
||||
|
||||
// 扩展配置
|
||||
const extensionSettings = ref<Record<number, any>>({})
|
||||
const loadingSettings = ref<Record<number, boolean>>({})
|
||||
const settingsError = ref<Record<number, string>>({})
|
||||
|
||||
// 计算属性:过滤后的扩展列表
|
||||
const filteredExtensions = computed(() => {
|
||||
let extensions = extensionStore.extensions
|
||||
|
||||
if (searchKeyword.value) {
|
||||
const keyword = searchKeyword.value.toLowerCase()
|
||||
extensions = extensions.filter(ext =>
|
||||
(ext.name?.toLowerCase().includes(keyword)) ||
|
||||
(ext.displayName?.toLowerCase().includes(keyword)) ||
|
||||
(ext.description?.toLowerCase().includes(keyword)) ||
|
||||
(ext.author?.toLowerCase().includes(keyword))
|
||||
)
|
||||
}
|
||||
|
||||
return extensions
|
||||
})
|
||||
|
||||
// 获取扩展的配置结构
|
||||
const getSettingsSchema = (ext: Extension) => {
|
||||
try {
|
||||
// 从 manifest 中提取 settings 定义
|
||||
if (ext.manifestData && typeof ext.manifestData === 'object') {
|
||||
const manifest = ext.manifestData as any
|
||||
if (manifest.settings && typeof manifest.settings === 'object') {
|
||||
return manifest.settings
|
||||
}
|
||||
}
|
||||
|
||||
// 从扩展的 Settings 字段提取
|
||||
if (ext.settings && typeof ext.settings === 'object') {
|
||||
return ext.settings
|
||||
}
|
||||
|
||||
return {}
|
||||
} catch (error) {
|
||||
console.error('解析配置结构失败:', error)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
// 加载扩展配置
|
||||
const loadExtensionSettings = async (extId: number) => {
|
||||
if (loadingSettings.value[extId]) return
|
||||
|
||||
try {
|
||||
loadingSettings.value[extId] = true
|
||||
settingsError.value[extId] = ''
|
||||
|
||||
const settings = await extensionStore.getExtensionSettings(extId)
|
||||
extensionSettings.value[extId] = settings || {}
|
||||
} catch (error: any) {
|
||||
console.error('加载扩展配置失败:', error)
|
||||
settingsError.value[extId] = error.response?.data?.msg || '加载配置失败'
|
||||
extensionSettings.value[extId] = {}
|
||||
} finally {
|
||||
loadingSettings.value[extId] = false
|
||||
}
|
||||
}
|
||||
|
||||
// 监听折叠面板展开,自动加载配置
|
||||
watch(activeNames, (newVal) => {
|
||||
if (newVal && !extensionSettings.value[newVal]) {
|
||||
loadExtensionSettings(newVal)
|
||||
}
|
||||
})
|
||||
|
||||
// 配置项变更
|
||||
const handleSettingChange = async (extId: number, key: string) => {
|
||||
try {
|
||||
await extensionStore.updateExtensionSettings(extId, extensionSettings.value[extId])
|
||||
ElMessage.success('配置已保存')
|
||||
} catch (error: any) {
|
||||
console.error('保存配置失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '保存配置失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
// 搜索逻辑已在 computed 中处理
|
||||
}
|
||||
|
||||
// 刷新
|
||||
const handleRefresh = () => {
|
||||
extensionStore.fetchExtensionList()
|
||||
}
|
||||
|
||||
// 启用/禁用扩展
|
||||
const handleToggle = async (ext: Extension) => {
|
||||
try {
|
||||
await extensionStore.toggleExtension(ext.id, ext.isEnabled)
|
||||
ElMessage.success(ext.isEnabled ? '已启用' : '已禁用')
|
||||
} catch (error: any) {
|
||||
console.error('切换扩展状态失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '操作失败')
|
||||
ext.isEnabled = !ext.isEnabled // 恢复状态
|
||||
}
|
||||
}
|
||||
|
||||
// 删除扩展
|
||||
const handleDelete = async (ext: Extension) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要卸载扩展"${ext.displayName || ext.name}"吗?`,
|
||||
'确认卸载',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
}
|
||||
)
|
||||
|
||||
await extensionStore.deleteExtension(ext.id)
|
||||
ElMessage.success('卸载成功')
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('卸载扩展失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '卸载失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出扩展
|
||||
const handleExport = async (ext: Extension) => {
|
||||
try {
|
||||
await extensionStore.exportExtension(ext.id)
|
||||
ElMessage.success('导出成功')
|
||||
} catch (error: any) {
|
||||
console.error('导出扩展失败:', error)
|
||||
ElMessage.error(error.response?.data?.msg || '导出失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新扩展配置
|
||||
const handleRefreshExtension = (ext: Extension) => {
|
||||
delete extensionSettings.value[ext.id]
|
||||
loadExtensionSettings(ext.id)
|
||||
}
|
||||
|
||||
// 文件选择
|
||||
const handleFileSelect = (file: any) => {
|
||||
selectedFile.value = file.raw
|
||||
}
|
||||
|
||||
// 安装扩展
|
||||
const handleInstall = async () => {
|
||||
try {
|
||||
if (installTab.value === 'file') {
|
||||
if (!selectedFile.value) {
|
||||
ElMessage.warning('请选择 manifest.json 文件')
|
||||
return
|
||||
}
|
||||
await extensionStore.importExtension(selectedFile.value)
|
||||
} else if (installTab.value === 'git') {
|
||||
if (!installForm.gitUrl) {
|
||||
ElMessage.warning('请输入 Git 仓库 URL')
|
||||
return
|
||||
}
|
||||
await extensionStore.installExtensionFromGit(
|
||||
installForm.gitUrl,
|
||||
installForm.branch || 'main'
|
||||
)
|
||||
} else {
|
||||
if (!installForm.url) {
|
||||
ElMessage.warning('请输入 Manifest URL')
|
||||
return
|
||||
}
|
||||
await extensionStore.installExtensionFromUrl(installForm.url)
|
||||
}
|
||||
|
||||
showInstallDialog.value = false
|
||||
selectedFile.value = null
|
||||
installForm.gitUrl = ''
|
||||
installForm.branch = 'main'
|
||||
installForm.url = ''
|
||||
|
||||
// 重置上传组件
|
||||
if (uploadRef.value) {
|
||||
uploadRef.value.clearFiles()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('安装失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (date: string | Date) => {
|
||||
if (!date) return '-'
|
||||
const d = new Date(date)
|
||||
return d.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 类型标签颜色
|
||||
const getTypeTagType = (type: string) => {
|
||||
const types: Record<string, string> = {
|
||||
ui: '',
|
||||
server: 'success',
|
||||
hybrid: 'warning',
|
||||
}
|
||||
return types[type] || ''
|
||||
}
|
||||
|
||||
// 类型标签文本
|
||||
const getTypeLabel = (type: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
ui: 'UI',
|
||||
server: 'Server',
|
||||
hybrid: 'Hybrid',
|
||||
}
|
||||
return labels[type] || type
|
||||
}
|
||||
|
||||
// 分类标签文本
|
||||
const getCategoryLabel = (category: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
utilities: '工具',
|
||||
themes: '主题',
|
||||
integrations: '集成',
|
||||
tools: '其他',
|
||||
}
|
||||
return labels[category] || category || '-'
|
||||
}
|
||||
|
||||
// 安装来源标签
|
||||
const getInstallSourceLabel = (source: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
file: '文件导入',
|
||||
url: 'URL 安装',
|
||||
git: 'Git 安装',
|
||||
marketplace: '扩展市场',
|
||||
}
|
||||
return labels[source] || source || '-'
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
extensionStore.fetchExtensionList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.extension-panel-page {
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.filters {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.extension-panels {
|
||||
margin-top: 20px;
|
||||
|
||||
:deep(.el-collapse) {
|
||||
border: none;
|
||||
}
|
||||
|
||||
:deep(.el-collapse-item) {
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-collapse-item__header) {
|
||||
height: auto;
|
||||
min-height: 56px;
|
||||
padding: 12px 16px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-fill-color);
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
border-bottom: 1px solid var(--el-border-color);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-collapse-item__wrap) {
|
||||
border: none;
|
||||
}
|
||||
|
||||
:deep(.el-collapse-item__content) {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.extension-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex: 1;
|
||||
padding-right: 10px;
|
||||
|
||||
.extension-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.extension-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.name {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.extension-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
|
||||
.version {
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.extension-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.extension-content {
|
||||
.extension-description {
|
||||
margin-bottom: 20px;
|
||||
padding: 12px;
|
||||
background-color: var(--el-fill-color-light);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-regular);
|
||||
line-height: 1.6;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.extension-settings {
|
||||
margin-bottom: 20px;
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 20px;
|
||||
|
||||
.el-form-item__label {
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.setting-hint {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.extension-info-detail {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.extension-actions-bar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-upload-dragger) {
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
:deep(.el-descriptions) {
|
||||
.el-descriptions__label {
|
||||
width: 120px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
329
web-app-vue/src/views/extension/ExtensionSettings.vue
Normal file
329
web-app-vue/src/views/extension/ExtensionSettings.vue
Normal file
@@ -0,0 +1,329 @@
|
||||
<template>
|
||||
<div class="extension-settings-page">
|
||||
<el-card v-loading="loading">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<h2>{{ extension?.displayName || extension?.name }} - 配置</h2>
|
||||
<p class="subtitle">{{ extension?.description }}</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button @click="handleBack">返回</el-button>
|
||||
<el-button type="primary" @click="handleSave" :loading="saving">
|
||||
保存配置
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 扩展信息 -->
|
||||
<el-descriptions :column="3" border style="margin-bottom: 20px">
|
||||
<el-descriptions-item label="版本">
|
||||
{{ extension?.version }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="作者">
|
||||
{{ extension?.author }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="类型">
|
||||
{{ getTypeLabel(extension?.extensionType) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<!-- 配置表单 -->
|
||||
<el-form
|
||||
v-if="settings && extension"
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
label-width="200px"
|
||||
style="max-width: 800px"
|
||||
>
|
||||
<el-divider content-position="left">扩展配置</el-divider>
|
||||
|
||||
<!-- 动态生成配置项 -->
|
||||
<template v-if="hasSettings">
|
||||
<el-form-item
|
||||
v-for="(value, key) in settings"
|
||||
:key="key"
|
||||
:label="getSettingLabel(key)"
|
||||
>
|
||||
<!-- 布尔值 -->
|
||||
<el-switch
|
||||
v-if="typeof value === 'boolean'"
|
||||
v-model="formData[key]"
|
||||
/>
|
||||
|
||||
<!-- 数字 -->
|
||||
<el-input-number
|
||||
v-else-if="typeof value === 'number'"
|
||||
v-model="formData[key]"
|
||||
:min="0"
|
||||
/>
|
||||
|
||||
<!-- 选择器(如果有 options) -->
|
||||
<el-select
|
||||
v-else-if="isSelectField(key)"
|
||||
v-model="formData[key]"
|
||||
placeholder="请选择"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in getSelectOptions(key)"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<!-- 多行文本 -->
|
||||
<el-input
|
||||
v-else-if="isTextareaField(key)"
|
||||
v-model="formData[key]"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
/>
|
||||
|
||||
<!-- 默认文本输入 -->
|
||||
<el-input
|
||||
v-else
|
||||
v-model="formData[key]"
|
||||
:placeholder="getSettingPlaceholder(key)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 没有配置项 -->
|
||||
<el-empty v-else description="该扩展暂无可配置项" />
|
||||
|
||||
<!-- 高级选项 -->
|
||||
<template v-if="extension.options && Object.keys(extension.options).length > 0">
|
||||
<el-divider content-position="left">高级选项</el-divider>
|
||||
|
||||
<el-form-item
|
||||
v-for="(value, key) in extension.options"
|
||||
:key="'option_' + key"
|
||||
:label="getSettingLabel(key)"
|
||||
>
|
||||
<el-input
|
||||
v-model="optionsData[key]"
|
||||
:placeholder="getSettingPlaceholder(key)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
|
||||
<!-- Manifest 信息 -->
|
||||
<el-divider content-position="left">Manifest 信息</el-divider>
|
||||
<el-descriptions v-if="extension" :column="2" border>
|
||||
<el-descriptions-item label="主脚本">
|
||||
{{ extension.scriptPath || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="样式文件">
|
||||
{{ extension.stylePath || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="依赖扩展" :span="2">
|
||||
<template v-if="extension.dependencies && Object.keys(extension.dependencies).length > 0">
|
||||
<el-tag
|
||||
v-for="(version, name) in extension.dependencies"
|
||||
:key="name"
|
||||
size="small"
|
||||
style="margin-right: 5px"
|
||||
>
|
||||
{{ name }} {{ version }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<span v-else>无</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="冲突扩展" :span="2">
|
||||
<template v-if="extension.conflicts && extension.conflicts.length > 0">
|
||||
<el-tag
|
||||
v-for="conflict in extension.conflicts"
|
||||
:key="conflict"
|
||||
size="small"
|
||||
type="danger"
|
||||
style="margin-right: 5px"
|
||||
>
|
||||
{{ conflict }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<span v-else>无</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<el-divider content-position="left">使用统计</el-divider>
|
||||
<el-descriptions v-if="extension" :column="3" border>
|
||||
<el-descriptions-item label="使用次数">
|
||||
{{ extension.usageCount }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="错误次数">
|
||||
{{ extension.errorCount }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="平均加载时间">
|
||||
{{ extension.loadTime }}ms
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="安装日期">
|
||||
{{ formatDate(extension.installDate) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="最后启用时间">
|
||||
{{ formatDate(extension.lastEnabled) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="安装来源">
|
||||
{{ getInstallSourceLabel(extension.installSource) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useExtensionStore } from '@/stores/extension'
|
||||
import type { Extension } from '@/types/extension'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const extensionStore = useExtensionStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const extension = ref<Extension | null>(null)
|
||||
const settings = ref<Record<string, any> | null>(null)
|
||||
const formData = ref<Record<string, any>>({})
|
||||
const optionsData = ref<Record<string, any>>({})
|
||||
const formRef = ref()
|
||||
|
||||
const extensionId = computed(() => Number(route.params.id))
|
||||
|
||||
const hasSettings = computed(() => {
|
||||
return settings.value && Object.keys(settings.value).length > 0
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadExtension()
|
||||
await loadSettings()
|
||||
})
|
||||
|
||||
const loadExtension = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
extension.value = await extensionStore.fetchExtension(extensionId.value)
|
||||
|
||||
// 初始化 options 数据
|
||||
if (extension.value.options) {
|
||||
optionsData.value = { ...extension.value.options }
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载扩展失败:', error)
|
||||
ElMessage.error('加载扩展失败')
|
||||
router.back()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
settings.value = await extensionStore.getExtensionSettings(extensionId.value)
|
||||
formData.value = { ...settings.value }
|
||||
} catch (error) {
|
||||
console.error('加载配置失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
saving.value = true
|
||||
await extensionStore.updateExtensionSettings(extensionId.value, formData.value)
|
||||
ElMessage.success('配置保存成功')
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
const getTypeLabel = (type?: string) => {
|
||||
if (!type) return '-'
|
||||
const labels: Record<string, string> = {
|
||||
ui: 'UI 扩展',
|
||||
server: '服务端扩展',
|
||||
hybrid: '混合扩展',
|
||||
}
|
||||
return labels[type] || type
|
||||
}
|
||||
|
||||
const getSettingLabel = (key: string) => {
|
||||
// 将驼峰命名转换为可读标签
|
||||
return key
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.replace(/^./, (str) => str.toUpperCase())
|
||||
.replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
const getSettingPlaceholder = (key: string) => {
|
||||
return `请输入 ${getSettingLabel(key)}`
|
||||
}
|
||||
|
||||
const isSelectField = (key: string) => {
|
||||
// 判断是否是选择字段(可根据 extension.options 或 manifest 定义)
|
||||
return false
|
||||
}
|
||||
|
||||
const getSelectOptions = (key: string) => {
|
||||
// 返回选择器选项
|
||||
return []
|
||||
}
|
||||
|
||||
const isTextareaField = (key: string) => {
|
||||
// 判断是否是多行文本字段
|
||||
const textareaFields = ['description', 'content', 'notes', 'script', 'code']
|
||||
return textareaFields.some(field => key.toLowerCase().includes(field))
|
||||
}
|
||||
|
||||
const getInstallSourceLabel = (source: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
url: 'URL',
|
||||
file: '文件',
|
||||
marketplace: '应用市场',
|
||||
}
|
||||
return labels[source] || source
|
||||
}
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
if (!date) return '-'
|
||||
return new Date(date).toLocaleString('zh-CN')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.extension-settings-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
margin: 0 0 5px 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
421
web-app-vue/src/views/regex/RegexScriptEdit.vue
Normal file
421
web-app-vue/src/views/regex/RegexScriptEdit.vue
Normal file
@@ -0,0 +1,421 @@
|
||||
<template>
|
||||
<div class="regex-script-edit">
|
||||
<el-page-header @back="handleBack" :title="isEdit ? '编辑正则脚本' : '创建正则脚本'">
|
||||
<template #content>
|
||||
<span class="page-title">{{ isEdit ? '编辑正则脚本' : '创建正则脚本' }}</span>
|
||||
</template>
|
||||
</el-page-header>
|
||||
|
||||
<el-card shadow="never" style="margin-top: 20px">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
label-width="140px"
|
||||
label-position="left"
|
||||
>
|
||||
<!-- 基础信息 -->
|
||||
<el-divider content-position="left">基础信息</el-divider>
|
||||
|
||||
<el-form-item label="脚本名称" prop="scriptName">
|
||||
<el-input
|
||||
v-model="formData.scriptName"
|
||||
placeholder="请输入脚本名称"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="脚本描述" prop="description">
|
||||
<el-input
|
||||
v-model="formData.description"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="请输入脚本描述"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="是否启用" prop="enabled">
|
||||
<el-switch v-model="formData.enabled" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="全局脚本" prop="isGlobal">
|
||||
<el-switch v-model="formData.isGlobal" />
|
||||
<span style="margin-left: 10px; color: #909399; font-size: 12px">
|
||||
全局脚本会应用到所有对话,角色脚本仅应用到关联的角色
|
||||
</span>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 正则表达式 -->
|
||||
<el-divider content-position="left">正则表达式</el-divider>
|
||||
|
||||
<el-form-item label="查找正则" prop="findRegex">
|
||||
<el-input
|
||||
v-model="formData.findRegex"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入正则表达式,例如:\b(Hello|Hi)\b"
|
||||
@input="handleRegexChange"
|
||||
/>
|
||||
<div style="margin-top: 5px; font-size: 12px; color: #909399">
|
||||
<el-link
|
||||
href="https://regexr.com/"
|
||||
target="_blank"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
style="font-size: 12px"
|
||||
>
|
||||
正则表达式测试工具 →
|
||||
</el-link>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="替换为" prop="replaceString">
|
||||
<el-input
|
||||
v-model="formData.replaceString"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="请输入替换字符串,支持 $1、$2 等捕获组引用"
|
||||
@input="handleRegexChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 测试区域 -->
|
||||
<el-form-item label="测试输入">
|
||||
<el-input
|
||||
v-model="testInput"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="输入测试文本"
|
||||
@input="handleTest"
|
||||
/>
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
style="margin-top: 10px"
|
||||
:loading="testing"
|
||||
@click="handleTest"
|
||||
>
|
||||
测试
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="testResult" label="测试结果">
|
||||
<div class="test-result">
|
||||
<div v-if="testResult.success" class="result-success">
|
||||
<div class="result-row">
|
||||
<span class="label">匹配次数:</span>
|
||||
<el-tag type="success">{{ testResult.matchedCount }}</el-tag>
|
||||
</div>
|
||||
<div v-if="testResult.matches.length > 0" class="result-row">
|
||||
<span class="label">匹配内容:</span>
|
||||
<el-tag
|
||||
v-for="(match, idx) in testResult.matches"
|
||||
:key="idx"
|
||||
size="small"
|
||||
style="margin-right: 5px"
|
||||
>
|
||||
{{ match }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="result-row">
|
||||
<span class="label">处理后:</span>
|
||||
<div class="output-text">{{ testResult.output }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="result-error">
|
||||
<el-alert type="error" :title="testResult.error" :closable="false" />
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 高级选项 -->
|
||||
<el-divider content-position="left">高级选项</el-divider>
|
||||
|
||||
<el-form-item label="去除空格">
|
||||
<el-switch v-model="formData.trimStrings" />
|
||||
<span style="margin-left: 10px; color: #909399; font-size: 12px">
|
||||
处理前去除文本首尾空格
|
||||
</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="正则替换">
|
||||
<el-switch v-model="formData.substituteRegex" />
|
||||
<span style="margin-left: 10px; color: #909399; font-size: 12px">
|
||||
启用后支持 $1、$2 等捕获组引用
|
||||
</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="仅格式化">
|
||||
<el-switch v-model="formData.onlyFormat" />
|
||||
<span style="margin-left: 10px; color: #909399; font-size: 12px">
|
||||
仅用于格式化消息,不影响 AI 处理
|
||||
</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="编辑时运行">
|
||||
<el-switch v-model="formData.runOnEdit" />
|
||||
<span style="margin-left: 10px; color: #909399; font-size: 12px">
|
||||
在编辑消息时也运行脚本
|
||||
</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="应用位置">
|
||||
<el-select
|
||||
v-model="formData.placement"
|
||||
placeholder="选择应用位置"
|
||||
clearable
|
||||
>
|
||||
<el-option label="全部消息" value="" />
|
||||
<el-option label="用户消息" value="user" />
|
||||
<el-option label="AI消息" value="ai" />
|
||||
<el-option label="系统消息" value="sys" />
|
||||
<el-option label="斜杠命令" value="slash" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="最小深度">
|
||||
<el-input-number
|
||||
v-model="formData.minDepth"
|
||||
:min="0"
|
||||
:max="100"
|
||||
placeholder="不限制"
|
||||
controls-position="right"
|
||||
/>
|
||||
<span style="margin-left: 10px; color: #909399; font-size: 12px">
|
||||
仅在消息深度 ≥ 此值时应用
|
||||
</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="最大深度">
|
||||
<el-input-number
|
||||
v-model="formData.maxDepth"
|
||||
:min="0"
|
||||
:max="100"
|
||||
placeholder="不限制"
|
||||
controls-position="right"
|
||||
/>
|
||||
<span style="margin-left: 10px; color: #909399; font-size: 12px">
|
||||
仅在消息深度 ≤ 此值时应用
|
||||
</span>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="regexScriptStore.loading" @click="handleSubmit">
|
||||
{{ isEdit ? '保存' : '创建' }}
|
||||
</el-button>
|
||||
<el-button @click="handleBack">取消</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
Edit,
|
||||
Delete,
|
||||
CopyDocument,
|
||||
Upload,
|
||||
Download,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useRegexScriptStore } from '@/stores/regexScript'
|
||||
import type { CreateRegexScriptRequest, TestRegexScriptResponse } from '@/types/regexScript'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const regexScriptStore = useRegexScriptStore()
|
||||
|
||||
const isEdit = ref(false)
|
||||
const scriptId = ref<number | null>(null)
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive<CreateRegexScriptRequest>({
|
||||
scriptName: '',
|
||||
description: '',
|
||||
findRegex: '',
|
||||
replaceString: '',
|
||||
enabled: true,
|
||||
isGlobal: false,
|
||||
trimStrings: false,
|
||||
onlyFormat: false,
|
||||
runOnEdit: false,
|
||||
substituteRegex: false,
|
||||
placement: '',
|
||||
linkedChars: [],
|
||||
})
|
||||
|
||||
// 测试相关
|
||||
const testInput = ref('')
|
||||
const testResult = ref<TestRegexScriptResponse | null>(null)
|
||||
const testing = ref(false)
|
||||
|
||||
// 表单验证规则
|
||||
const rules: FormRules = {
|
||||
scriptName: [{ required: true, message: '请输入脚本名称', trigger: 'blur' }],
|
||||
findRegex: [
|
||||
{ required: true, message: '请输入正则表达式', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
// 正则表达式变化时重新测试
|
||||
const handleRegexChange = () => {
|
||||
if (testInput.value) {
|
||||
testResult.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 测试正则脚本
|
||||
const handleTest = async () => {
|
||||
if (!formData.findRegex || !testInput.value) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
testing.value = true
|
||||
testResult.value = await regexScriptStore.testRegexScript({
|
||||
findRegex: formData.findRegex,
|
||||
replaceString: formData.replaceString || '',
|
||||
testInput: testInput.value,
|
||||
trimStrings: formData.trimStrings,
|
||||
substituteRegex: formData.substituteRegex,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('测试失败:', error)
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
await formRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
|
||||
try {
|
||||
if (isEdit.value && scriptId.value) {
|
||||
await regexScriptStore.updateRegexScript(scriptId.value, formData)
|
||||
} else {
|
||||
await regexScriptStore.createRegexScript(formData)
|
||||
}
|
||||
router.push('/regex')
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 返回列表
|
||||
const handleBack = () => {
|
||||
router.push('/regex')
|
||||
}
|
||||
|
||||
// 加载脚本详情
|
||||
const loadScriptDetail = async (id: number) => {
|
||||
try {
|
||||
const script = await regexScriptStore.fetchRegexScriptDetail(id)
|
||||
|
||||
// 填充表单
|
||||
Object.assign(formData, {
|
||||
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: script.linkedChars,
|
||||
scriptData: script.scriptData,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('加载脚本详情失败:', error)
|
||||
router.push('/regex')
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
const id = route.params.id
|
||||
if (id && typeof id === 'string') {
|
||||
isEdit.value = true
|
||||
scriptId.value = parseInt(id)
|
||||
loadScriptDetail(scriptId.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.regex-script-edit {
|
||||
padding: 20px;
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.test-result {
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
|
||||
.result-success {
|
||||
.result-row {
|
||||
margin-bottom: 12px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: 500;
|
||||
margin-right: 10px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.output-text {
|
||||
margin-top: 8px;
|
||||
padding: 10px;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.result-error {
|
||||
:deep(.el-alert) {
|
||||
background: #fef0f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-divider__text) {
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
</style>
|
||||
314
web-app-vue/src/views/regex/RegexScriptList.vue
Normal file
314
web-app-vue/src/views/regex/RegexScriptList.vue
Normal file
@@ -0,0 +1,314 @@
|
||||
<template>
|
||||
<div class="regex-script-list">
|
||||
<el-card shadow="never">
|
||||
<!-- 搜索和操作栏 -->
|
||||
<div class="header-actions">
|
||||
<div class="search-bar">
|
||||
<el-input
|
||||
v-model="searchParams.scriptName"
|
||||
placeholder="搜索脚本名称"
|
||||
style="width: 300px"
|
||||
clearable
|
||||
@clear="handleSearch"
|
||||
@keyup.enter="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
|
||||
<el-select
|
||||
v-model="searchParams.enabled"
|
||||
placeholder="状态"
|
||||
style="width: 120px; margin-left: 10px"
|
||||
clearable
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option label="已启用" :value="true" />
|
||||
<el-option label="已禁用" :value="false" />
|
||||
</el-select>
|
||||
|
||||
<el-select
|
||||
v-model="searchParams.isGlobal"
|
||||
placeholder="范围"
|
||||
style="width: 120px; margin-left: 10px"
|
||||
clearable
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option label="全局脚本" :value="true" />
|
||||
<el-option label="角色脚本" :value="false" />
|
||||
</el-select>
|
||||
|
||||
<el-button type="primary" :icon="Search" @click="handleSearch" style="margin-left: 10px">
|
||||
搜索
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
<el-upload
|
||||
:show-file-list="false"
|
||||
:before-upload="handleImport"
|
||||
accept=".json"
|
||||
>
|
||||
<el-button :icon="Upload">导入</el-button>
|
||||
</el-upload>
|
||||
|
||||
<el-button :icon="Download" @click="handleExport">
|
||||
导出
|
||||
</el-button>
|
||||
|
||||
<el-button type="primary" :icon="Plus" @click="handleCreate">
|
||||
新建脚本
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 脚本列表 -->
|
||||
<el-table
|
||||
v-loading="regexScriptStore.loading"
|
||||
:data="regexScriptStore.regexScripts"
|
||||
style="width: 100%; margin-top: 20px"
|
||||
>
|
||||
<el-table-column prop="scriptName" label="脚本名称" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div>
|
||||
<el-tag v-if="row.isGlobal" type="warning" size="small" style="margin-right: 8px">
|
||||
全局
|
||||
</el-tag>
|
||||
{{ row.scriptName }}
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="findRegex" label="正则表达式" min-width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<code style="font-size: 12px">{{ row.findRegex }}</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="replaceString" label="替换为" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<code v-if="row.replaceString" style="font-size: 12px">{{ row.replaceString }}</code>
|
||||
<span v-else style="color: #ccc">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="placement" label="应用位置" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.placement" size="small">{{ row.placement }}</el-tag>
|
||||
<span v-else style="color: #ccc">全部</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="usageCount" label="使用次数" width="100" align="center" />
|
||||
|
||||
<el-table-column prop="enabled" label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
v-model="row.enabled"
|
||||
@change="handleToggleEnabled(row)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="260" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
type="primary"
|
||||
text
|
||||
size="small"
|
||||
:icon="Edit"
|
||||
@click="handleEdit(row.id)"
|
||||
>
|
||||
编辑
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
type="info"
|
||||
text
|
||||
size="small"
|
||||
:icon="CopyDocument"
|
||||
@click="handleDuplicate(row.id)"
|
||||
>
|
||||
复制
|
||||
</el-button>
|
||||
|
||||
<el-popconfirm
|
||||
title="确定要删除这个脚本吗?"
|
||||
@confirm="handleDelete(row.id)"
|
||||
>
|
||||
<template #reference>
|
||||
<el-button
|
||||
type="danger"
|
||||
text
|
||||
size="small"
|
||||
:icon="Delete"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div style="margin-top: 20px; display: flex; justify-content: flex-end">
|
||||
<el-pagination
|
||||
v-model:current-page="regexScriptStore.currentPage"
|
||||
v-model:page-size="regexScriptStore.pageSize"
|
||||
:total="regexScriptStore.total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSearch"
|
||||
@current-change="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
Edit,
|
||||
Delete,
|
||||
CopyDocument,
|
||||
Upload,
|
||||
Download,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useRegexScriptStore } from '@/stores/regexScript'
|
||||
import type { RegexScriptListParams } from '@/types/regexScript'
|
||||
|
||||
const router = useRouter()
|
||||
const regexScriptStore = useRegexScriptStore()
|
||||
|
||||
// 搜索参数
|
||||
const searchParams = ref<RegexScriptListParams>({
|
||||
scriptName: '',
|
||||
enabled: undefined,
|
||||
isGlobal: undefined,
|
||||
})
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
regexScriptStore.resetPagination()
|
||||
regexScriptStore.fetchRegexScriptList(searchParams.value)
|
||||
}
|
||||
|
||||
// 新建
|
||||
const handleCreate = () => {
|
||||
router.push('/regex/create')
|
||||
}
|
||||
|
||||
// 编辑
|
||||
const handleEdit = (id: number) => {
|
||||
router.push(`/regex/edit/${id}`)
|
||||
}
|
||||
|
||||
// 删除
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await regexScriptStore.deleteRegexScript(id)
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 复制
|
||||
const handleDuplicate = async (id: number) => {
|
||||
try {
|
||||
await regexScriptStore.duplicateRegexScript(id)
|
||||
} catch (error) {
|
||||
console.error('复制失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换启用状态
|
||||
const handleToggleEnabled = async (row: any) => {
|
||||
try {
|
||||
await regexScriptStore.updateRegexScript(row.id, {
|
||||
enabled: row.enabled,
|
||||
})
|
||||
} catch (error) {
|
||||
// 恢复状态
|
||||
row.enabled = !row.enabled
|
||||
}
|
||||
}
|
||||
|
||||
// 导入
|
||||
const handleImport = async (file: File) => {
|
||||
try {
|
||||
const mode = await ElMessageBox.prompt(
|
||||
'选择导入模式:\nskip - 跳过已存在的脚本\noverwrite - 覆盖已存在的脚本\nmerge - 合并(重命名)',
|
||||
'导入正则脚本',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
inputValue: 'skip',
|
||||
inputPattern: /^(skip|overwrite|merge)$/,
|
||||
inputErrorMessage: '请输入 skip、overwrite 或 merge',
|
||||
}
|
||||
)
|
||||
|
||||
await regexScriptStore.importRegexScripts(file, mode.value)
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('导入失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return false // 阻止自动上传
|
||||
}
|
||||
|
||||
// 导出
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
await regexScriptStore.exportRegexScripts()
|
||||
} catch (error) {
|
||||
console.error('导出失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
regexScriptStore.fetchRegexScriptList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.regex-script-list {
|
||||
padding: 20px;
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 400px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
code {
|
||||
background: #f5f7fa;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
|
||||
}
|
||||
</style>
|
||||
302
web-app-vue/src/views/worldbook/WorldBookEdit.vue
Normal file
302
web-app-vue/src/views/worldbook/WorldBookEdit.vue
Normal file
@@ -0,0 +1,302 @@
|
||||
<template>
|
||||
<div class="world-book-edit">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<h2>{{ isEdit ? '编辑世界书' : '创建世界书' }}</h2>
|
||||
<div class="actions">
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
<el-button type="primary" :loading="worldInfoStore.loading" @click="handleSave">
|
||||
保存
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 世界书基本信息 -->
|
||||
<el-card class="info-card">
|
||||
<template #header>
|
||||
<span>基本信息</span>
|
||||
</template>
|
||||
<el-form :model="formData" :rules="rules" ref="formRef" label-width="120px">
|
||||
<el-form-item label="世界书名称" prop="bookName">
|
||||
<el-input
|
||||
v-model="formData.bookName"
|
||||
placeholder="请输入世界书名称"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="类型">
|
||||
<el-switch
|
||||
v-model="formData.isGlobal"
|
||||
active-text="全局世界书"
|
||||
inactive-text="角色专属"
|
||||
/>
|
||||
<el-tooltip
|
||||
content="全局世界书将应用于所有角色,角色专属世界书需要手动关联角色"
|
||||
placement="right"
|
||||
>
|
||||
<el-icon style="margin-left: 8px"><QuestionFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 条目列表 -->
|
||||
<el-card class="entries-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>条目列表({{ formData.entries.length }})</span>
|
||||
<el-button type="primary" size="small" :icon="Plus" @click="handleAddEntry">
|
||||
添加条目
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<el-empty
|
||||
v-if="formData.entries.length === 0"
|
||||
description="暂无条目,点击上方按钮添加"
|
||||
/>
|
||||
|
||||
<!-- 条目列表 -->
|
||||
<div v-else class="entry-list">
|
||||
<el-collapse v-model="activeEntries" accordion>
|
||||
<el-collapse-item
|
||||
v-for="(entry, index) in formData.entries"
|
||||
:key="entry.uid"
|
||||
:name="entry.uid"
|
||||
>
|
||||
<template #title>
|
||||
<div class="entry-title">
|
||||
<el-tag :type="entry.enabled ? 'success' : 'info'" size="small">
|
||||
{{ entry.enabled ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
<span class="entry-index">#{{ index + 1 }}</span>
|
||||
<span class="entry-keys">{{ formatKeys(entry.keys) }}</span>
|
||||
<el-tag v-if="entry.constant" type="warning" size="small">常驻</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 条目编辑表单 -->
|
||||
<WorldInfoEntryForm
|
||||
:entry="entry"
|
||||
@update="(updated) => handleUpdateEntry(index, updated)"
|
||||
@delete="() => handleDeleteEntry(index)"
|
||||
/>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, QuestionFilled } from '@element-plus/icons-vue'
|
||||
import { useWorldInfoStore } from '@/stores/worldInfo'
|
||||
import type { CreateWorldBookRequest, WorldInfoEntry } from '@/types/worldInfo'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import WorldInfoEntryForm from './WorldInfoEntryForm.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const worldInfoStore = useWorldInfoStore()
|
||||
|
||||
// 表单引用
|
||||
const formRef = ref()
|
||||
|
||||
// 是否为编辑模式
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
|
||||
// 展开的条目
|
||||
const activeEntries = ref<string[]>([])
|
||||
|
||||
// 表单数据
|
||||
const formData = ref<CreateWorldBookRequest>({
|
||||
bookName: '',
|
||||
isGlobal: false,
|
||||
entries: [],
|
||||
linkedChars: []
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
bookName: [
|
||||
{ required: true, message: '请输入世界书名称', trigger: 'blur' },
|
||||
{ min: 1, max: 500, message: '长度在 1 到 500 个字符', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 格式化关键词显示
|
||||
const formatKeys = (keys: string[]) => {
|
||||
if (!keys || keys.length === 0) return '无关键词'
|
||||
return keys.slice(0, 3).join(', ') + (keys.length > 3 ? '...' : '')
|
||||
}
|
||||
|
||||
// 创建默认条目
|
||||
const createDefaultEntry = (): WorldInfoEntry => ({
|
||||
uid: uuidv4(),
|
||||
keys: [],
|
||||
secondary_keys: [],
|
||||
content: '',
|
||||
comment: '',
|
||||
enabled: true,
|
||||
constant: false,
|
||||
selective: false,
|
||||
order: formData.value.entries.length,
|
||||
position: 'before_char',
|
||||
depth: 4,
|
||||
probability: 100,
|
||||
use_probability: false,
|
||||
group: '',
|
||||
group_override: false,
|
||||
group_weight: 0,
|
||||
prevent_recursion: false,
|
||||
delay_until_recursion: false,
|
||||
scan_depth: null,
|
||||
case_sensitive: null,
|
||||
match_whole_words: null,
|
||||
use_regex: null,
|
||||
automation_id: '',
|
||||
role: '',
|
||||
vectorized: '',
|
||||
extensions: {}
|
||||
})
|
||||
|
||||
// 添加条目
|
||||
const handleAddEntry = () => {
|
||||
const newEntry = createDefaultEntry()
|
||||
formData.value.entries.push(newEntry)
|
||||
activeEntries.value = [newEntry.uid]
|
||||
}
|
||||
|
||||
// 更新条目
|
||||
const handleUpdateEntry = (index: number, updatedEntry: WorldInfoEntry) => {
|
||||
formData.value.entries[index] = updatedEntry
|
||||
}
|
||||
|
||||
// 删除条目
|
||||
const handleDeleteEntry = async (index: number) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除此条目吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
formData.value.entries.splice(index, 1)
|
||||
} catch (error) {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
|
||||
// 保存
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
// 验证表单
|
||||
await formRef.value.validate()
|
||||
|
||||
// 验证至少有一个条目
|
||||
if (formData.value.entries.length === 0) {
|
||||
ElMessage.warning('请至少添加一个条目')
|
||||
return
|
||||
}
|
||||
|
||||
// 保存
|
||||
if (isEdit.value) {
|
||||
await worldInfoStore.updateWorldBook(Number(route.params.id), {
|
||||
bookName: formData.value.bookName,
|
||||
isGlobal: formData.value.isGlobal,
|
||||
entries: formData.value.entries,
|
||||
linkedChars: formData.value.linkedChars
|
||||
})
|
||||
} else {
|
||||
await worldInfoStore.createWorldBook(formData.value)
|
||||
}
|
||||
|
||||
router.push('/worldbook')
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 取消
|
||||
const handleCancel = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(async () => {
|
||||
if (isEdit.value) {
|
||||
try {
|
||||
const book = await worldInfoStore.fetchWorldBookDetail(Number(route.params.id))
|
||||
formData.value = {
|
||||
bookName: book.bookName,
|
||||
isGlobal: book.isGlobal,
|
||||
entries: book.entries || [],
|
||||
linkedChars: book.linkedChars || []
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('加载世界书失败')
|
||||
router.push('/worldbook')
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.world-book-edit {
|
||||
padding: 20px;
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.info-card {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.entries-card {
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.entry-list {
|
||||
.entry-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
|
||||
.entry-index {
|
||||
font-weight: bold;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.entry-keys {
|
||||
color: #606266;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
236
web-app-vue/src/views/worldbook/WorldInfoEntryForm.vue
Normal file
236
web-app-vue/src/views/worldbook/WorldInfoEntryForm.vue
Normal file
@@ -0,0 +1,236 @@
|
||||
<template>
|
||||
<div class="entry-form">
|
||||
<el-form :model="localEntry" label-width="140px" size="default">
|
||||
<!-- 基础信息 -->
|
||||
<el-divider content-position="left">基础设置</el-divider>
|
||||
|
||||
<el-form-item label="启用状态">
|
||||
<el-switch v-model="localEntry.enabled" active-text="启用" inactive-text="禁用" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="主要关键词" required>
|
||||
<el-select
|
||||
v-model="localEntry.keys"
|
||||
multiple
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
placeholder="输入关键词后按回车添加"
|
||||
style="width: 100%"
|
||||
>
|
||||
</el-select>
|
||||
<div class="form-tip">当消息中包含这些关键词时,条目将被激活</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="次要关键词">
|
||||
<el-select
|
||||
v-model="localEntry.secondary_keys"
|
||||
multiple
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
placeholder="输入关键词后按回车添加(可选)"
|
||||
style="width: 100%"
|
||||
>
|
||||
</el-select>
|
||||
<div class="form-tip">启用"选择性激活"时,需同时匹配主要和次要关键词</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="条目内容" required>
|
||||
<el-input
|
||||
v-model="localEntry.content"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
placeholder="输入条目内容,这将被插入到提示词中"
|
||||
maxlength="5000"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="备注">
|
||||
<el-input
|
||||
v-model="localEntry.comment"
|
||||
placeholder="添加备注(不会被插入到提示词中)"
|
||||
maxlength="500"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 激活设置 -->
|
||||
<el-divider content-position="left">激活设置</el-divider>
|
||||
|
||||
<el-form-item label="常驻激活">
|
||||
<el-switch v-model="localEntry.constant" active-text="是" inactive-text="否" />
|
||||
<div class="form-tip">启用后,此条目将始终激活(忽略关键词匹配)</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="选择性激活">
|
||||
<el-switch v-model="localEntry.selective" active-text="是" inactive-text="否" />
|
||||
<div class="form-tip">启用后,需要同时匹配主要和次要关键词才能激活</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="使用概率">
|
||||
<el-switch v-model="localEntry.use_probability" active-text="是" inactive-text="否" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="localEntry.use_probability" label="激活概率">
|
||||
<el-slider v-model="localEntry.probability" :min="0" :max="100" show-input />
|
||||
<div class="form-tip">即使匹配成功,也有 {{ localEntry.probability }}% 的概率激活</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 匹配设置 -->
|
||||
<el-divider content-position="left">匹配设置</el-divider>
|
||||
|
||||
<el-form-item label="大小写敏感">
|
||||
<el-select v-model="localEntry.case_sensitive" placeholder="使用全局设置">
|
||||
<el-option label="使用全局设置" :value="null" />
|
||||
<el-option label="是" :value="true" />
|
||||
<el-option label="否" :value="false" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="匹配整词">
|
||||
<el-select v-model="localEntry.match_whole_words" placeholder="使用全局设置">
|
||||
<el-option label="使用全局设置" :value="null" />
|
||||
<el-option label="是" :value="true" />
|
||||
<el-option label="否" :value="false" />
|
||||
</el-select>
|
||||
<div class="form-tip">启用后,"world" 不会匹配 "worldbuilding"</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="使用正则表达式">
|
||||
<el-select v-model="localEntry.use_regex" placeholder="使用全局设置">
|
||||
<el-option label="使用全局设置" :value="null" />
|
||||
<el-option label="是" :value="true" />
|
||||
<el-option label="否" :value="false" />
|
||||
</el-select>
|
||||
<div class="form-tip">启用后,关键词将被视为正则表达式</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="扫描深度">
|
||||
<el-input-number
|
||||
v-model="localEntry.scan_depth"
|
||||
:min="0"
|
||||
:max="100"
|
||||
placeholder="使用全局设置"
|
||||
/>
|
||||
<div class="form-tip">从最近消息往前扫描几条(null=使用全局设置)</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 插入设置 -->
|
||||
<el-divider content-position="left">插入设置</el-divider>
|
||||
|
||||
<el-form-item label="插入顺序">
|
||||
<el-input-number v-model="localEntry.order" :min="0" :max="999" />
|
||||
<div class="form-tip">数字越小越靠前</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="插入位置">
|
||||
<el-select v-model="localEntry.position">
|
||||
<el-option label="在角色定义之前" value="before_char" />
|
||||
<el-option label="在角色定义之后" value="after_char" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="角色">
|
||||
<el-select v-model="localEntry.role" placeholder="默认" clearable>
|
||||
<el-option label="系统" value="system" />
|
||||
<el-option label="用户" value="user" />
|
||||
<el-option label="助手" value="assistant" />
|
||||
</el-select>
|
||||
<div class="form-tip">指定此条目的角色类型</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 递归设置 -->
|
||||
<el-divider content-position="left">递归设置</el-divider>
|
||||
|
||||
<el-form-item label="防止递归">
|
||||
<el-switch v-model="localEntry.prevent_recursion" active-text="是" inactive-text="否" />
|
||||
<div class="form-tip">防止此条目在递归激活中被触发</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="延迟递归">
|
||||
<el-switch v-model="localEntry.delay_until_recursion" active-text="是" inactive-text="否" />
|
||||
<div class="form-tip">仅在递归激活时触发此条目</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 分组设置 -->
|
||||
<el-divider content-position="left">分组设置</el-divider>
|
||||
|
||||
<el-form-item label="分组">
|
||||
<el-input v-model="localEntry.group" placeholder="留空表示不分组" />
|
||||
<div class="form-tip">同一分组中只会激活一个条目(按权重选择)</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="localEntry.group" label="分组权重">
|
||||
<el-input-number v-model="localEntry.group_weight" :min="0" :max="999" />
|
||||
<div class="form-tip">数字越大优先级越高</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="localEntry.group" label="分组覆盖">
|
||||
<el-switch v-model="localEntry.group_override" active-text="是" inactive-text="否" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<el-divider />
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleUpdate">保存更改</el-button>
|
||||
<el-button type="danger" @click="handleDelete">删除条目</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import type { WorldInfoEntry } from '@/types/worldInfo'
|
||||
|
||||
const props = defineProps<{
|
||||
entry: WorldInfoEntry
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
update: [entry: WorldInfoEntry]
|
||||
delete: []
|
||||
}>()
|
||||
|
||||
// 本地编辑副本
|
||||
const localEntry = ref<WorldInfoEntry>({ ...props.entry })
|
||||
|
||||
// 监听 props 变化
|
||||
watch(
|
||||
() => props.entry,
|
||||
(newEntry) => {
|
||||
localEntry.value = { ...newEntry }
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// 保存更改
|
||||
const handleUpdate = () => {
|
||||
emit('update', { ...localEntry.value })
|
||||
}
|
||||
|
||||
// 删除条目
|
||||
const handleDelete = () => {
|
||||
emit('delete')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.entry-form {
|
||||
padding: 20px;
|
||||
background-color: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
:deep(.el-divider__text) {
|
||||
font-weight: bold;
|
||||
color: #409eff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user