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" ) // extensionDataDir 扩展本地存储根目录 // 与原版 SillyTavern 完全一致的路径结构:scripts/extensions/third-party/{name}/ // 扩展 JS 中的相对路径 import(如 ../../../../../script.js)依赖此目录层级来正确解析 // 所有 SillyTavern 核心脚本和扩展文件统一存储在 data/st-core-scripts/ 下,独立于 web-app/ // 扩展代码是公共的(不按用户隔离),用户间差异仅在于数据库中的配置和启用状态 const extensionDataDir = "data/st-core-scripts/scripts/extensions/third-party" // getExtensionStorePath 获取扩展的本地存储路径: {extensionDataDir}/{extensionName}/ func getExtensionStorePath(extensionName string) string { return filepath.Join(extensionDataDir, extensionName) } // GetExtensionAssetLocalPath 获取扩展资源文件的本地绝对路径 func (es *ExtensionService) GetExtensionAssetLocalPath(extensionName string, assetPath string) (string, error) { storePath := getExtensionStorePath(extensionName) fullPath := filepath.Join(storePath, assetPath) // 安全检查:防止路径遍历攻击 absStore, _ := filepath.Abs(storePath) absFile, _ := filepath.Abs(fullPath) if !strings.HasPrefix(absFile, absStore) { return "", errors.New("非法的资源路径") } // 检查文件是否存在 if _, err := os.Stat(fullPath); os.IsNotExist(err) { return "", fmt.Errorf("资源文件不存在: %s", assetPath) } return fullPath, nil } // ensureExtensionDir 确保扩展存储目录存在 func ensureExtensionDir(extensionName string) (string, error) { storePath := getExtensionStorePath(extensionName) if err := os.MkdirAll(storePath, 0755); err != nil { return "", fmt.Errorf("创建扩展存储目录失败: %w", err) } return storePath, nil } // removeExtensionDir 删除扩展的本地存储目录 func removeExtensionDir(extensionName string) error { storePath := getExtensionStorePath(extensionName) if _, err := os.Stat(storePath); os.IsNotExist(err) { return nil // 目录不存在,无需删除 } return os.RemoveAll(storePath) } type ExtensionService struct{} // CreateExtension 创建/安装扩展 func (es *ExtensionService) CreateExtension(userID uint, req *request.CreateExtensionRequest) (*app.AIExtension, error) { // 校验名称 if req.Name == "" { return nil, errors.New("扩展名称不能为空") } // 检查扩展是否已存在 var existing app.AIExtension err := global.GVA_DB.Where("user_id = ? AND name = ?", userID, req.Name).First(&existing).Error if err == nil { return nil, fmt.Errorf("扩展 %s 已存在", req.Name) } 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("系统内置扩展不允许删除") } // 删除本地扩展文件(与原版 SillyTavern 一致:卸载扩展时清理本地文件) if err := removeExtensionDir(extension.Name); err != nil { global.GVA_LOG.Warn("删除扩展本地文件失败", zap.Error(err), zap.String("name", extension.Name)) // 不阻断删除流程 } // 删除数据库记录 if err := global.GVA_DB.Delete(&extension).Error; err != nil { return err } global.GVA_LOG.Info("扩展卸载成功", zap.Uint("extensionID", extensionID), zap.String("name", extension.Name)) 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 } // GetExtensionByID 通过扩展 ID 获取扩展信息(不限制用户,用于公开资源路由) func (es *ExtensionService) GetExtensionByID(extensionID uint) (*app.AIExtension, error) { var extension app.AIExtension if err := global.GVA_DB.Where("id = ?", extensionID).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 } // GetExtensionAssetURL 根据扩展的安装来源构建资源文件的远程 URL func (es *ExtensionService) GetExtensionAssetURL(extension *app.AIExtension, assetPath string) (string, error) { if extension.SourceURL == "" { return "", errors.New("扩展没有源地址") } sourceURL := strings.TrimSuffix(strings.TrimSuffix(extension.SourceURL, "/"), ".git") branch := extension.Branch if branch == "" { branch = "main" } // GitLab: repo/-/raw/branch/path if strings.Contains(sourceURL, "gitlab.com") { return fmt.Sprintf("%s/-/raw/%s/%s", sourceURL, branch, assetPath), nil } // GitHub: raw.githubusercontent.com/user/repo/branch/path if strings.Contains(sourceURL, "github.com") { rawURL := strings.Replace(sourceURL, "github.com", "raw.githubusercontent.com", 1) return fmt.Sprintf("%s/%s/%s", rawURL, branch, assetPath), nil } // Gitee: repo/raw/branch/path if strings.Contains(sourceURL, "gitee.com") { return fmt.Sprintf("%s/raw/%s/%s", sourceURL, branch, assetPath), nil } return fmt.Sprintf("%s/%s", sourceURL, assetPath), nil } // downloadAndInstallFromManifestURL 从 Manifest URL 下载并安装(同时下载资源文件到本地) func (es *ExtensionService) downloadAndInstallFromManifestURL(userID uint, manifestURL string) (*app.AIExtension, error) { 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) } // 获取有效名称 effectiveName := manifest.GetEffectiveName() if effectiveName == "" { return nil, errors.New("manifest.json 缺少 name 或 display_name 字段") } // 检查扩展是否已存在 var existing app.AIExtension err = global.GVA_DB.Where("user_id = ? AND name = ?", userID, effectiveName).First(&existing).Error if err == nil { return nil, fmt.Errorf("扩展 %s 已安装", effectiveName) } if err != gorm.ErrRecordNotFound { return nil, err } // 创建本地存储目录并保存 manifest.json storePath, err := ensureExtensionDir(effectiveName) if err != nil { return nil, err } if err := os.WriteFile(filepath.Join(storePath, "manifest.json"), manifestData, 0644); err != nil { _ = removeExtensionDir(effectiveName) return nil, fmt.Errorf("保存 manifest.json 失败: %w", err) } // 获取 manifest URL 的基础目录(用于下载关联资源) baseURL := manifestURL[:strings.LastIndex(manifestURL, "/")+1] // 下载 JS/CSS 等资源文件到本地 filesToDownload := []string{} if entry := manifest.GetEffectiveEntry(); entry != "" { filesToDownload = append(filesToDownload, entry) } if style := manifest.GetEffectiveStyle(); style != "" { filesToDownload = append(filesToDownload, style) } filesToDownload = append(filesToDownload, manifest.Assets...) for _, file := range filesToDownload { if file == "" { continue } fileURL := baseURL + file if err := downloadFileToLocal(client, fileURL, filepath.Join(storePath, file)); err != nil { global.GVA_LOG.Warn("下载扩展资源文件失败(非致命)", zap.String("file", file), zap.String("url", fileURL), zap.Error(err)) } } global.GVA_LOG.Info("扩展文件已保存到本地", zap.String("name", effectiveName), zap.String("path", storePath)) // 将 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: effectiveName, DisplayName: manifest.DisplayName, Version: manifest.Version, Author: manifest.Author, Description: manifest.Description, Homepage: manifest.GetEffectiveHomepage(), 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.GetEffectiveEntry(), StylePath: manifest.GetEffectiveStyle(), AssetsPaths: manifest.Assets, Settings: manifest.Settings, Options: manifest.Options, InstallSource: "url", SourceURL: manifestURL, AutoUpdate: manifest.AutoUpdate, Metadata: nil, } // 确保扩展类型有效 if createReq.ExtensionType == "" { createReq.ExtensionType = "ui" } // 创建扩展 extension, err := es.CreateExtension(userID, createReq) if err != nil { _ = removeExtensionDir(effectiveName) 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 } // downloadFileToLocal 下载远程文件到本地路径 func downloadFileToLocal(client *http.Client, url string, localPath string) error { // 确保目标文件的父目录存在 if err := os.MkdirAll(filepath.Dir(localPath), 0755); err != nil { return err } resp, err := client.Get(url) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("HTTP %d", resp.StatusCode) } data, err := io.ReadAll(resp.Body) if err != nil { return err } return os.WriteFile(localPath, data, 0644) } // 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)) // 先删除旧的数据库记录和本地文件 if err := global.GVA_DB.Unscoped().Delete(extension).Error; err != nil { return nil, fmt.Errorf("删除旧扩展记录失败: %w", err) } _ = removeExtensionDir(extension.Name) // 重新克隆安装 return es.InstallExtensionFromGit(userID, extension.SourceURL, extension.Branch) } // updateExtensionFromURL 从 URL 更新扩展(先删除旧记录和文件,再重新下载安装) 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)) // 先删除旧的数据库记录和本地文件 if err := global.GVA_DB.Unscoped().Delete(extension).Error; err != nil { return nil, fmt.Errorf("删除旧扩展记录失败: %w", err) } _ = removeExtensionDir(extension.Name) // 重新下载安装 return es.downloadAndInstallFromManifestURL(userID, extension.SourceURL) } // InstallExtensionFromGit 从 Git URL 安装扩展(与原版 SillyTavern 一致:将源码下载到本地) 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") } // 先 clone 到临时目录读取 manifest(获取扩展名后再移动到正式目录) 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)) // 执行 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) } // 获取有效名称(兼容 SillyTavern manifest 没有 name 字段的情况) effectiveName := manifest.GetEffectiveName() if effectiveName == "" { return nil, errors.New("manifest.json 缺少 name 或 display_name 字段") } // 检查扩展是否已存在 var existing app.AIExtension err = global.GVA_DB.Where("user_id = ? AND name = ?", userID, effectiveName).First(&existing).Error if err == nil { return nil, fmt.Errorf("扩展 %s 已安装", effectiveName) } if err != gorm.ErrRecordNotFound { return nil, err } // 将扩展文件保存到公共目录: web-app/public/scripts/extensions/third-party/{extensionName}/ storePath, err := ensureExtensionDir(effectiveName) if err != nil { return nil, err } // 清空目标目录(如果有残留文件)后复制 clone 内容 _ = os.RemoveAll(storePath) if err := copyDir(tempDir, storePath); err != nil { return nil, fmt.Errorf("保存扩展文件失败: %w", err) } global.GVA_LOG.Info("扩展文件已保存到本地", zap.String("name", effectiveName), zap.String("path", storePath)) // 将 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: effectiveName, DisplayName: manifest.DisplayName, Version: manifest.Version, Author: manifest.Author, Description: manifest.Description, Homepage: manifest.GetEffectiveHomepage(), 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.GetEffectiveEntry(), StylePath: manifest.GetEffectiveStyle(), AssetsPaths: manifest.Assets, Settings: manifest.Settings, Options: manifest.Options, InstallSource: "git", SourceURL: gitUrl, Branch: branch, AutoUpdate: manifest.AutoUpdate, Metadata: manifest.Metadata, } // 确保扩展类型有效 if createReq.ExtensionType == "" { createReq.ExtensionType = "ui" } // 创建扩展记录 extension, err := es.CreateExtension(userID, createReq) if err != nil { // 创建失败则清理本地文件 _ = removeExtensionDir(effectiveName) 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), zap.String("localPath", storePath)) return extension, nil } // copyDir 递归复制目录(排除 .git 目录以节省空间) func copyDir(src, dst string) error { return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { if err != nil { return err } // 计算相对路径 relPath, err := filepath.Rel(src, path) if err != nil { return err } // 排除 .git 目录 if info.IsDir() && info.Name() == ".git" { return filepath.SkipDir } dstPath := filepath.Join(dst, relPath) if info.IsDir() { return os.MkdirAll(dstPath, info.Mode()) } // 复制文件 srcFile, err := os.ReadFile(path) if err != nil { return err } return os.WriteFile(dstPath, srcFile, info.Mode()) }) }