新增正则和扩展模块

This commit is contained in:
2026-02-11 23:44:09 +08:00
parent 2bca8e2788
commit 4e611d3a5e
47 changed files with 10058 additions and 49 deletions

View 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. **自动保存**:修改配置后会自动保存,无需手动点击保存按钮
## 未来改进
- [ ] 支持更多配置项类型(颜色选择器、文件选择器等)
- [ ] 配置项分组和标签页
- [ ] 配置导入/导出
- [ ] 配置重置到默认值
- [ ] 配置历史记录
- [ ] 扩展依赖检查和冲突提示

View 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": []
}

View File

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

View File

@@ -6,6 +6,8 @@ type ApiGroup struct {
AuthApi
CharacterApi
WorldInfoApi
ExtensionApi
RegexScriptApi
}
var (

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

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

View File

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

View File

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

View 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 自动创建
-- 重启服务器即可

View File

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

View File

@@ -143,10 +143,12 @@ func Routers() *gin.Engine {
// 前台应用路由(新增)
{
appGroup := PublicGroup.Group("app") // 统一使用 /app 前缀
appRouter.InitAuthRouter(appGroup) // 认证路由:/app/auth/* 和 /app/user/*
appRouter.InitCharacterRouter(appGroup) // 角色卡路由:/app/character/*
appRouter.InitWorldInfoRouter(appGroup) // 世界书路由:/app/worldbook/*
appGroup := PublicGroup.Group("app") // 统一使用 /app 前缀
appRouter.InitAuthRouter(appGroup) // 认证路由:/app/auth/* 和 /app/user/*
appRouter.InitCharacterRouter(appGroup) // 角色卡路由:/app/character/*
appRouter.InitWorldInfoRouter(appGroup) // 世界书路由:/app/worldbook/*
appRouter.InitExtensionRouter(appGroup) // 扩展路由:/app/extension/*
appRouter.InitRegexScriptRouter(appGroup) // 正则脚本路由:/app/regex/*
}
//插件路由安装

View 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"
// }

View 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"
}

View 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"`
}

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

View File

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

View 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,
}
}

View 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(),
}
}

View File

@@ -6,9 +6,9 @@ import (
// PageInfo Paging common input parameter structure
type PageInfo struct {
Page int `json:"page" form:"page"` // 页码
PageSize int `json:"pageSize" form:"pageSize"` // 每页大小
Keyword string `json:"keyword" form:"keyword"` // 关键字
Page int `json:"page" form:"page,default=1"` // 页码
PageSize int `json:"pageSize" form:"pageSize,default=20"` // 每页大小
Keyword string `json:"keyword" form:"keyword"` // 关键字
}
func (r *PageInfo) Paginate() func(db *gorm.DB) *gorm.DB {

View File

@@ -4,4 +4,6 @@ type RouterGroup struct {
AuthRouter
CharacterRouter
WorldInfoRouter
ExtensionRouter
RegexScriptRouter
}

View 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) // 更新扩展统计
}
}

View 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) // 导出正则脚本
}
}

View File

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

View File

@@ -4,4 +4,6 @@ type AppServiceGroup struct {
AuthService
CharacterService
WorldInfoService
ExtensionService
RegexScriptService
}

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

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

View File

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

View File

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

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

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

View File

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

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

View File

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

View File

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

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

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

View File

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

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

View 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">
支持 GitHubGitLabGitee 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>

View 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">
支持 GitHubGitLabGitee 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>

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

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

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

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

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