diff --git a/CHANGELOG.md b/CHANGELOG.md index ac1b632e..9c4a39d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,12 @@ # 更新日志 +## v4.1.6 +* 功能新增:**支持OpenAI实时语音对话功能** :rocket: :rocket: :rocket:, Beta 版,目前没有做算力计费控制,目前只有 VIP 用户可以使用。 +* 功能优化:优化MysQL容器配置文档,解决MysQL容器资源占用过高问题 +* 功能新增:管理后台增加AI绘图任务管理,可在管理后台浏览和删除用户的绘图任务 +* 功能新增:管理后台增加Suno和Luma任务管理功能 +* Bug修复:修复管理后台删除兑换码报 404 错误 +* 功能优化:优化充值产品定价逻辑,可以设置原价和优惠价,**升级当前版本之后请务必要到管理后台去重新设置一下产品价格,以免造成损失!!!**,**升级当前版本之后请务必要到管理后台去重新设置一下产品价格,以免造成损失!!!**,**升级当前版本之后请务必要到管理后台去重新设置一下产品价格,以免造成损失!!!**。 + ## v4.1.5 * 功能优化:重构 websocket 组件,减少 websocket 连接数,全站共享一个 websocket 连接 * Bug修复:兼容手机端原生微信支付和支付宝支付渠道 diff --git a/api/core/app_server.go b/api/core/app_server.go index d89e43a5..3993f21c 100644 --- a/api/core/app_server.go +++ b/api/core/app_server.go @@ -124,12 +124,19 @@ func corsMiddleware() gin.HandlerFunc { // 用户授权验证 func authorizeMiddleware(s *AppServer, client *redis.Client) gin.HandlerFunc { return func(c *gin.Context) { + clientProtocols := c.GetHeader("Sec-WebSocket-Protocol") var tokenString string isAdminApi := strings.Contains(c.Request.URL.Path, "/api/admin/") if isAdminApi { // 后台管理 API tokenString = c.GetHeader(types.AdminAuthHeader) - } else if c.Request.URL.Path == "/api/ws" { // Websocket 连接 - tokenString = c.Query("token") + } else if clientProtocols != "" { // Websocket 连接 + // 解析子协议内容 + protocols := strings.Split(clientProtocols, ",") + if protocols[0] == "realtime" { + tokenString = strings.TrimSpace(protocols[1][25:]) + } else if protocols[0] == "token" { + tokenString = strings.TrimSpace(protocols[1]) + } } else { tokenString = c.GetHeader(types.UserAuthHeader) } diff --git a/api/handler/admin/config_handler.go b/api/handler/admin/config_handler.go index 2d87c9a1..f03bedb0 100644 --- a/api/handler/admin/config_handler.go +++ b/api/handler/admin/config_handler.go @@ -143,65 +143,67 @@ func (h *ConfigHandler) GetLicense(c *gin.Context) { // FixData 修复数据 func (h *ConfigHandler) FixData(c *gin.Context) { - var fixed bool - version := "data_fix_4.1.4" - err := h.levelDB.Get(version, &fixed) - if err == nil || fixed { - resp.ERROR(c, "当前版本数据修复已完成,请不要重复执行操作") - return - } - tx := h.DB.Begin() - var users []model.User - err = tx.Find(&users).Error - if err != nil { - resp.ERROR(c, err.Error()) - return - } - for _, user := range users { - if user.Email != "" || user.Mobile != "" { - continue - } - if utils.IsValidEmail(user.Username) { - user.Email = user.Username - } else if utils.IsValidMobile(user.Username) { - user.Mobile = user.Username - } - err = tx.Save(&user).Error - if err != nil { - resp.ERROR(c, err.Error()) - tx.Rollback() - return - } - } - - var orders []model.Order - err = h.DB.Find(&orders).Error - if err != nil { - resp.ERROR(c, err.Error()) - return - } - for _, order := range orders { - if order.PayWay == "支付宝" { - order.PayWay = "alipay" - order.PayType = "alipay" - } else if order.PayWay == "微信支付" { - order.PayWay = "wechat" - order.PayType = "wxpay" - } else if order.PayWay == "hupi" { - order.PayType = "wxpay" - } - err = tx.Save(&order).Error - if err != nil { - resp.ERROR(c, err.Error()) - tx.Rollback() - return - } - } - tx.Commit() - err = h.levelDB.Put(version, true) - if err != nil { - resp.ERROR(c, err.Error()) - return - } - resp.SUCCESS(c) + resp.ERROR(c, "当前升级版本没有数据需要修正!") + return + //var fixed bool + //version := "data_fix_4.1.4" + //err := h.levelDB.Get(version, &fixed) + //if err == nil || fixed { + // resp.ERROR(c, "当前版本数据修复已完成,请不要重复执行操作") + // return + //} + //tx := h.DB.Begin() + //var users []model.User + //err = tx.Find(&users).Error + //if err != nil { + // resp.ERROR(c, err.Error()) + // return + //} + //for _, user := range users { + // if user.Email != "" || user.Mobile != "" { + // continue + // } + // if utils.IsValidEmail(user.Username) { + // user.Email = user.Username + // } else if utils.IsValidMobile(user.Username) { + // user.Mobile = user.Username + // } + // err = tx.Save(&user).Error + // if err != nil { + // resp.ERROR(c, err.Error()) + // tx.Rollback() + // return + // } + //} + // + //var orders []model.Order + //err = h.DB.Find(&orders).Error + //if err != nil { + // resp.ERROR(c, err.Error()) + // return + //} + //for _, order := range orders { + // if order.PayWay == "支付宝" { + // order.PayWay = "alipay" + // order.PayType = "alipay" + // } else if order.PayWay == "微信支付" { + // order.PayWay = "wechat" + // order.PayType = "wxpay" + // } else if order.PayWay == "hupi" { + // order.PayType = "wxpay" + // } + // err = tx.Save(&order).Error + // if err != nil { + // resp.ERROR(c, err.Error()) + // tx.Rollback() + // return + // } + //} + //tx.Commit() + //err = h.levelDB.Put(version, true) + //if err != nil { + // resp.ERROR(c, err.Error()) + // return + //} + //resp.SUCCESS(c) } diff --git a/api/handler/admin/image_handler.go b/api/handler/admin/image_handler.go new file mode 100644 index 00000000..3685042a --- /dev/null +++ b/api/handler/admin/image_handler.go @@ -0,0 +1,254 @@ +package admin + +// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +// * Copyright 2023 The Geek-AI Authors. All rights reserved. +// * Use of this source code is governed by a Apache-2.0 license +// * that can be found in the LICENSE file. +// * @Author yangjian102621@163.com +// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +import ( + "fmt" + "geekai/core" + "geekai/core/types" + "geekai/handler" + "geekai/service" + "geekai/service/oss" + "geekai/store/model" + "geekai/store/vo" + "geekai/utils" + "geekai/utils/resp" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type ImageHandler struct { + handler.BaseHandler + userService *service.UserService + uploader *oss.UploaderManager +} + +func NewImageHandler(app *core.AppServer, db *gorm.DB, userService *service.UserService, manager *oss.UploaderManager) *ImageHandler { + return &ImageHandler{BaseHandler: handler.BaseHandler{App: app, DB: db}, userService: userService, uploader: manager} +} + +type imageQuery struct { + Prompt string `json:"prompt"` + Username string `json:"username"` + CreatedAt []string `json:"created_at"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +// MjList Midjourney 任务列表 +func (h *ImageHandler) MjList(c *gin.Context) { + var data imageQuery + if err := c.ShouldBindJSON(&data); err != nil { + resp.ERROR(c, types.InvalidArgs) + return + } + + session := h.DB.Session(&gorm.Session{}) + if data.Username != "" { + var user model.User + err := h.DB.Where("username", data.Username).First(&user).Error + if err == nil { + session = session.Where("user_id", user.Id) + } + } + if data.Prompt != "" { + session = session.Where("prompt LIKE ?", "%"+data.Prompt+"%") + } + if len(data.CreatedAt) == 2 { + session = session.Where("created_at >= ? AND created_at <= ?", data.CreatedAt[0], data.CreatedAt[1]) + } + var total int64 + session.Model(&model.MidJourneyJob{}).Count(&total) + var list []model.MidJourneyJob + var items = make([]vo.MidJourneyJob, 0) + offset := (data.Page - 1) * data.PageSize + err := session.Order("id DESC").Offset(offset).Limit(data.PageSize).Find(&list).Error + if err == nil { + // 填充数据 + for _, item := range list { + var job vo.MidJourneyJob + err = utils.CopyObject(item, &job) + if err != nil { + continue + } + job.CreatedAt = item.CreatedAt.Unix() + items = append(items, job) + } + } + + resp.SUCCESS(c, vo.NewPage(total, data.Page, data.PageSize, items)) +} + +// SdList Stable Diffusion 任务列表 +func (h *ImageHandler) SdList(c *gin.Context) { + var data imageQuery + if err := c.ShouldBindJSON(&data); err != nil { + resp.ERROR(c, types.InvalidArgs) + return + } + + session := h.DB.Session(&gorm.Session{}) + if data.Username != "" { + var user model.User + err := h.DB.Where("username", data.Username).First(&user).Error + if err == nil { + session = session.Where("user_id", user.Id) + } + } + if data.Prompt != "" { + session = session.Where("prompt LIKE ?", "%"+data.Prompt+"%") + } + if len(data.CreatedAt) == 2 { + session = session.Where("created_at >= ? AND created_at <= ?", data.CreatedAt[0], data.CreatedAt[1]) + } + var total int64 + session.Model(&model.SdJob{}).Count(&total) + var list []model.SdJob + var items = make([]vo.SdJob, 0) + offset := (data.Page - 1) * data.PageSize + err := session.Order("id DESC").Offset(offset).Limit(data.PageSize).Find(&list).Error + if err == nil { + // 填充数据 + for _, item := range list { + var job vo.SdJob + err = utils.CopyObject(item, &job) + if err != nil { + continue + } + job.CreatedAt = item.CreatedAt.Unix() + items = append(items, job) + } + } + + resp.SUCCESS(c, vo.NewPage(total, data.Page, data.PageSize, items)) +} + +// DallList DALL-E 任务列表 +func (h *ImageHandler) DallList(c *gin.Context) { + var data imageQuery + if err := c.ShouldBindJSON(&data); err != nil { + resp.ERROR(c, types.InvalidArgs) + return + } + + session := h.DB.Session(&gorm.Session{}) + if data.Username != "" { + var user model.User + err := h.DB.Where("username", data.Username).First(&user).Error + if err == nil { + session = session.Where("user_id", user.Id) + } + } + if data.Prompt != "" { + session = session.Where("prompt LIKE ?", "%"+data.Prompt+"%") + } + if len(data.CreatedAt) == 2 { + session = session.Where("created_at >= ? AND created_at <= ?", data.CreatedAt[0], data.CreatedAt[1]) + } + var total int64 + session.Model(&model.DallJob{}).Count(&total) + var list []model.DallJob + var items = make([]vo.DallJob, 0) + offset := (data.Page - 1) * data.PageSize + err := session.Order("id DESC").Offset(offset).Limit(data.PageSize).Find(&list).Error + if err == nil { + // 填充数据 + for _, item := range list { + var job vo.DallJob + err = utils.CopyObject(item, &job) + if err != nil { + continue + } + job.CreatedAt = item.CreatedAt.Unix() + items = append(items, job) + } + } + + resp.SUCCESS(c, vo.NewPage(total, data.Page, data.PageSize, items)) +} + +func (h *ImageHandler) Remove(c *gin.Context) { + id := h.GetInt(c, "id", 0) + tab := c.Query("tab") + + tx := h.DB.Begin() + var md, remark, imgURL string + var power, userId, progress int + switch tab { + case "mj": + var job model.MidJourneyJob + if err := h.DB.Where("id", id).First(&job).Error; err != nil { + resp.ERROR(c, "记录不存在") + return + } + tx.Delete(&job) + md = "mid-journey" + power = job.Power + userId = job.UserId + remark = fmt.Sprintf("任务失败,退回算力。任务ID:%d,Err: %s", job.Id, job.ErrMsg) + progress = job.Progress + imgURL = job.ImgURL + break + case "sd": + var job model.SdJob + if res := h.DB.Where("id", id).First(&job); res.Error != nil { + resp.ERROR(c, "记录不存在") + return + } + + // 删除任务 + tx.Delete(&job) + md = "stable-diffusion" + power = job.Power + userId = job.UserId + remark = fmt.Sprintf("任务失败,退回算力。任务ID:%d,Err: %s", job.Id, job.ErrMsg) + progress = job.Progress + imgURL = job.ImgURL + break + case "dall": + var job model.DallJob + if res := h.DB.Where("id", id).First(&job); res.Error != nil { + resp.ERROR(c, "记录不存在") + return + } + + // 删除任务 + tx.Delete(&job) + md = "dall-e-3" + power = job.Power + userId = int(job.UserId) + remark = fmt.Sprintf("任务失败,退回算力。任务ID:%d,Err: %s", job.Id, job.ErrMsg) + progress = job.Progress + imgURL = job.ImgURL + break + default: + resp.ERROR(c, types.InvalidArgs) + return + } + + if progress != 100 { + err := h.userService.IncreasePower(userId, power, model.PowerLog{ + Type: types.PowerRefund, + Model: md, + Remark: remark, + }) + if err != nil { + tx.Rollback() + resp.ERROR(c, err.Error()) + return + } + } + tx.Commit() + // remove image + err := h.uploader.GetUploadHandler().Delete(imgURL) + if err != nil { + logger.Error("remove image failed: ", err) + } + + resp.SUCCESS(c) +} diff --git a/api/handler/admin/media_handler.go b/api/handler/admin/media_handler.go new file mode 100644 index 00000000..4ce42c06 --- /dev/null +++ b/api/handler/admin/media_handler.go @@ -0,0 +1,200 @@ +package admin + +// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +// * Copyright 2023 The Geek-AI Authors. All rights reserved. +// * Use of this source code is governed by a Apache-2.0 license +// * that can be found in the LICENSE file. +// * @Author yangjian102621@163.com +// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +import ( + "fmt" + "geekai/core" + "geekai/core/types" + "geekai/handler" + "geekai/service" + "geekai/service/oss" + "geekai/store/model" + "geekai/store/vo" + "geekai/utils" + "geekai/utils/resp" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type MediaHandler struct { + handler.BaseHandler + userService *service.UserService + uploader *oss.UploaderManager +} + +func NewMediaHandler(app *core.AppServer, db *gorm.DB, userService *service.UserService, manager *oss.UploaderManager) *MediaHandler { + return &MediaHandler{BaseHandler: handler.BaseHandler{App: app, DB: db}, userService: userService, uploader: manager} +} + +type mediaQuery struct { + Prompt string `json:"prompt"` + Username string `json:"username"` + CreatedAt []string `json:"created_at"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +// SunoList Suno 任务列表 +func (h *MediaHandler) SunoList(c *gin.Context) { + var data mediaQuery + if err := c.ShouldBindJSON(&data); err != nil { + resp.ERROR(c, types.InvalidArgs) + return + } + + session := h.DB.Session(&gorm.Session{}) + if data.Username != "" { + var user model.User + err := h.DB.Where("username", data.Username).First(&user).Error + if err == nil { + session = session.Where("user_id", user.Id) + } + } + if data.Prompt != "" { + session = session.Where("prompt LIKE ?", "%"+data.Prompt+"%") + } + if len(data.CreatedAt) == 2 { + session = session.Where("created_at >= ? AND created_at <= ?", data.CreatedAt[0], data.CreatedAt[1]) + } + var total int64 + session.Model(&model.SunoJob{}).Count(&total) + var list []model.SunoJob + var items = make([]vo.SunoJob, 0) + offset := (data.Page - 1) * data.PageSize + err := session.Order("id DESC").Offset(offset).Limit(data.PageSize).Find(&list).Error + if err == nil { + // 填充数据 + for _, item := range list { + var job vo.SunoJob + err = utils.CopyObject(item, &job) + if err != nil { + continue + } + job.CreatedAt = item.CreatedAt.Unix() + items = append(items, job) + } + } + + resp.SUCCESS(c, vo.NewPage(total, data.Page, data.PageSize, items)) +} + +// LumaList Luma 视频任务列表 +func (h *MediaHandler) LumaList(c *gin.Context) { + var data mediaQuery + if err := c.ShouldBindJSON(&data); err != nil { + resp.ERROR(c, types.InvalidArgs) + return + } + + session := h.DB.Session(&gorm.Session{}) + if data.Username != "" { + var user model.User + err := h.DB.Where("username", data.Username).First(&user).Error + if err == nil { + session = session.Where("user_id", user.Id) + } + } + if data.Prompt != "" { + session = session.Where("prompt LIKE ?", "%"+data.Prompt+"%") + } + if len(data.CreatedAt) == 2 { + session = session.Where("created_at >= ? AND created_at <= ?", data.CreatedAt[0], data.CreatedAt[1]) + } + var total int64 + session.Model(&model.VideoJob{}).Count(&total) + var list []model.VideoJob + var items = make([]vo.VideoJob, 0) + offset := (data.Page - 1) * data.PageSize + err := session.Order("id DESC").Offset(offset).Limit(data.PageSize).Find(&list).Error + if err == nil { + // 填充数据 + for _, item := range list { + var job vo.VideoJob + err = utils.CopyObject(item, &job) + if err != nil { + continue + } + job.CreatedAt = item.CreatedAt.Unix() + if job.VideoURL == "" { + job.VideoURL = job.WaterURL + } + items = append(items, job) + } + } + + resp.SUCCESS(c, vo.NewPage(total, data.Page, data.PageSize, items)) +} + +func (h *MediaHandler) Remove(c *gin.Context) { + id := h.GetInt(c, "id", 0) + tab := c.Query("tab") + + tx := h.DB.Begin() + var md, remark, fileURL string + var power, userId, progress int + switch tab { + case "suno": + var job model.SunoJob + if err := h.DB.Where("id", id).First(&job).Error; err != nil { + resp.ERROR(c, "记录不存在") + return + } + tx.Delete(&job) + md = "suno" + power = job.Power + userId = job.UserId + remark = fmt.Sprintf("SUNO 任务失败,退回算力。任务ID:%d,Err: %s", job.Id, job.ErrMsg) + progress = job.Progress + fileURL = job.AudioURL + break + case "luma": + var job model.VideoJob + if res := h.DB.Where("id", id).First(&job); res.Error != nil { + resp.ERROR(c, "记录不存在") + return + } + + // 删除任务 + tx.Delete(&job) + md = job.Type + power = job.Power + userId = job.UserId + remark = fmt.Sprintf("LUMA 任务失败,退回算力。任务ID:%d,Err: %s", job.Id, job.ErrMsg) + progress = job.Progress + fileURL = job.VideoURL + if fileURL == "" { + fileURL = job.WaterURL + } + break + default: + resp.ERROR(c, types.InvalidArgs) + return + } + + if progress != 100 { + err := h.userService.IncreasePower(userId, power, model.PowerLog{ + Type: types.PowerRefund, + Model: md, + Remark: remark, + }) + if err != nil { + tx.Rollback() + resp.ERROR(c, err.Error()) + return + } + } + tx.Commit() + // remove image + err := h.uploader.GetUploadHandler().Delete(fileURL) + if err != nil { + logger.Error("remove image failed: ", err) + } + + resp.SUCCESS(c) +} diff --git a/api/handler/admin/redeem_handler.go b/api/handler/admin/redeem_handler.go index 97fc3620..3bce461c 100644 --- a/api/handler/admin/redeem_handler.go +++ b/api/handler/admin/redeem_handler.go @@ -146,19 +146,15 @@ func (h *RedeemHandler) Set(c *gin.Context) { } func (h *RedeemHandler) Remove(c *gin.Context) { - var data struct { - Id uint - } - if err := c.ShouldBindJSON(&data); err != nil { + id := h.GetInt(c, "id", 0) + if id <= 0 { resp.ERROR(c, types.InvalidArgs) return } - if data.Id > 0 { - err := h.DB.Where("id", data.Id).Delete(&model.Redeem{}).Error - if err != nil { - resp.ERROR(c, err.Error()) - return - } + err := h.DB.Where("id", id).Delete(&model.Redeem{}).Error + if err != nil { + resp.ERROR(c, err.Error()) + return } resp.SUCCESS(c) } diff --git a/api/handler/dalle_handler.go b/api/handler/dalle_handler.go index eb46710f..404c9704 100644 --- a/api/handler/dalle_handler.go +++ b/api/handler/dalle_handler.go @@ -180,12 +180,7 @@ func (h *DallJobHandler) Remove(c *gin.Context) { // 删除任务 tx := h.DB.Begin() - if err := tx.Delete(&job).Error; err != nil { - tx.Rollback() - resp.ERROR(c, err.Error()) - return - } - + tx.Delete(&job) // 如果任务未完成,或者任务失败,则恢复用户算力 if job.Progress != 100 { err := h.userService.IncreasePower(int(job.UserId), job.Power, model.PowerLog{ diff --git a/api/handler/mj_handler.go b/api/handler/mj_handler.go index 8feaeb53..858a0d89 100644 --- a/api/handler/mj_handler.go +++ b/api/handler/mj_handler.go @@ -403,12 +403,7 @@ func (h *MidJourneyHandler) Remove(c *gin.Context) { // remove job recode tx := h.DB.Begin() - if err := tx.Delete(&job).Error; err != nil { - tx.Rollback() - resp.ERROR(c, err.Error()) - return - } - + tx.Delete(&job) // 如果任务未完成,或者任务失败,则恢复用户算力 if job.Progress != 100 { err := h.userService.IncreasePower(job.UserId, job.Power, model.PowerLog{ diff --git a/api/handler/payment_handler.go b/api/handler/payment_handler.go index 39eb0887..ef450c5e 100644 --- a/api/handler/payment_handler.go +++ b/api/handler/payment_handler.go @@ -17,7 +17,6 @@ import ( "geekai/store/model" "geekai/utils" "geekai/utils/resp" - "github.com/shopspring/decimal" "net/http" "sync" "time" @@ -105,7 +104,7 @@ func (h *PaymentHandler) Pay(c *gin.Context) { return } - amount, _ := decimal.NewFromFloat(product.Price).Sub(decimal.NewFromFloat(product.Discount)).Float64() + amount := product.Discount var payURL, returnURL, notifyURL string switch data.PayWay { case "alipay": diff --git a/api/handler/realtime_handler.go b/api/handler/realtime_handler.go new file mode 100644 index 00000000..9cb49859 --- /dev/null +++ b/api/handler/realtime_handler.go @@ -0,0 +1,128 @@ +package handler + +import ( + "fmt" + "geekai/core" + "geekai/store/model" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "gorm.io/gorm" + "net/http" + "strings" + "time" +) + +// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +// * Copyright 2023 The Geek-AI Authors. All rights reserved. +// * Use of this source code is governed by a Apache-2.0 license +// * that can be found in the LICENSE file. +// * @Author yangjian102621@163.com +// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +// OpenAI Realtime API Relay Server + +type RealtimeHandler struct { + BaseHandler +} + +func NewRealtimeHandler(server *core.AppServer, db *gorm.DB) *RealtimeHandler { + return &RealtimeHandler{BaseHandler{App: server, DB: db}} +} + +func (h *RealtimeHandler) Connection(c *gin.Context) { + // 获取客户端请求中指定的子协议 + clientProtocols := c.GetHeader("Sec-WebSocket-Protocol") + md := c.Query("model") + + userId := h.GetLoginUserId(c) + var user model.User + if err := h.DB.Where("id", userId).First(&user).Error; err != nil { + c.Abort() + return + } + + // 将 HTTP 协议升级为 Websocket 协议 + subProtocols := strings.Split(clientProtocols, ",") + ws, err := (&websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, + Subprotocols: subProtocols, + }).Upgrade(c.Writer, c.Request, nil) + if err != nil { + logger.Error(err) + c.Abort() + return + } + defer ws.Close() + + // 目前只针对 VIP 用户可以访问 + if !user.Vip { + sendError(ws, "当前功能只针对 VIP 用户开放") + c.Abort() + return + } + + var apiKey model.ApiKey + h.DB.Where("type", "realtime").Where("enabled", true).Order("last_used_at ASC").First(&apiKey) + if apiKey.Id == 0 { + sendError(ws, "管理员未配置 Realtime API KEY") + c.Abort() + return + } + + apiURL := fmt.Sprintf("%s/v1/realtime?model=%s", apiKey.ApiURL, md) + // 连接到真实的后端服务器,传入相同的子协议 + headers := http.Header{} + // 修正子协议内容 + subProtocols[1] = "openai-insecure-api-key." + apiKey.Value + if clientProtocols != "" { + headers.Set("Sec-WebSocket-Protocol", strings.Join(subProtocols, ",")) + } + backendConn, _, err := websocket.DefaultDialer.Dial(apiURL, headers) + if err != nil { + sendError(ws, "桥接后端 API 失败:"+err.Error()) + c.Abort() + return + } + defer backendConn.Close() + + // 确保协议一致性,如果失败返回 + if ws.Subprotocol() != backendConn.Subprotocol() { + sendError(ws, "Websocket 子协议不匹配") + c.Abort() + return + } + + // 更新API KEY 最后使用时间 + h.DB.Model(&model.ApiKey{}).Where("id", apiKey.Id).UpdateColumn("last_used_at", time.Now().Unix()) + + // 开始双向转发 + errorChan := make(chan error, 2) + go relay(ws, backendConn, errorChan) + go relay(backendConn, ws, errorChan) + + // 等待其中一个连接关闭 + err = <-errorChan + logger.Infof("Relay ended: %v", err) +} + +func relay(src, dst *websocket.Conn, errorChan chan error) { + for { + messageType, message, err := src.ReadMessage() + if err != nil { + errorChan <- err + return + } + err = dst.WriteMessage(messageType, message) + if err != nil { + errorChan <- err + return + } + } +} + +func sendError(ws *websocket.Conn, message string) { + err := ws.WriteJSON(map[string]string{"event_id": "event_01", "type": "error", "error": message}) + if err != nil { + logger.Error(err) + } +} diff --git a/api/handler/sd_handler.go b/api/handler/sd_handler.go index 9f5345dd..437dceac 100644 --- a/api/handler/sd_handler.go +++ b/api/handler/sd_handler.go @@ -250,18 +250,13 @@ func (h *SdJobHandler) Remove(c *gin.Context) { // 删除任务 tx := h.DB.Begin() - if err := tx.Delete(&job).Error; err != nil { - tx.Rollback() - resp.ERROR(c, err.Error()) - return - } - + tx.Delete(&job) // 如果任务未完成,或者任务失败,则恢复用户算力 if job.Progress != 100 { err := h.userService.IncreasePower(job.UserId, job.Power, model.PowerLog{ Type: types.PowerRefund, Model: "stable-diffusion", - Remark: fmt.Sprintf("任务失败,退回算力。任务ID:%s, Err: %s", job.TaskId, job.ErrMsg), + Remark: fmt.Sprintf("任务失败,退回算力。任务ID:%d, Err: %s", job.Id, job.ErrMsg), }) if err != nil { tx.Rollback() diff --git a/api/handler/video_handler.go b/api/handler/video_handler.go index 02bf21cb..aaa0bd86 100644 --- a/api/handler/video_handler.go +++ b/api/handler/video_handler.go @@ -156,6 +156,9 @@ func (h *VideoHandler) List(c *gin.Context) { continue } item.CreatedAt = v.CreatedAt.Unix() + if item.VideoURL == "" { + item.VideoURL = v.WaterURL + } items = append(items, item) } diff --git a/api/handler/ws_handler.go b/api/handler/ws_handler.go index 1835f8ab..bac48ad3 100644 --- a/api/handler/ws_handler.go +++ b/api/handler/ws_handler.go @@ -18,6 +18,7 @@ import ( "github.com/gorilla/websocket" "gorm.io/gorm" "net/http" + "strings" ) // Websocket 连接处理 handler @@ -37,7 +38,11 @@ func NewWebsocketHandler(app *core.AppServer, s *service.WebsocketService, db *g } func (h *WebsocketHandler) Client(c *gin.Context) { - ws, err := (&websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}).Upgrade(c.Writer, c.Request, nil) + clientProtocols := c.GetHeader("Sec-WebSocket-Protocol") + ws, err := (&websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, + Subprotocols: strings.Split(clientProtocols, ","), + }).Upgrade(c.Writer, c.Request, nil) if err != nil { logger.Error(err) c.Abort() diff --git a/api/main.go b/api/main.go index 6d1f1058..8fd36c82 100644 --- a/api/main.go +++ b/api/main.go @@ -349,7 +349,7 @@ func main() { group.GET("list", h.List) group.POST("create", h.Create) group.POST("set", h.Set) - group.POST("remove", h.Remove) + group.GET("remove", h.Remove) }), fx.Invoke(func(s *core.AppServer, h *admin.DashboardHandler) { group := s.Engine.Group("/api/admin/dashboard/") @@ -539,6 +539,25 @@ func main() { }, }) }), + fx.Provide(admin.NewImageHandler), + fx.Invoke(func(s *core.AppServer, h *admin.ImageHandler) { + group := s.Engine.Group("/api/admin/image") + group.POST("/list/mj", h.MjList) + group.POST("/list/sd", h.SdList) + group.POST("/list/dall", h.DallList) + group.GET("/remove", h.Remove) + }), + fx.Provide(admin.NewMediaHandler), + fx.Invoke(func(s *core.AppServer, h *admin.MediaHandler) { + group := s.Engine.Group("/api/admin/media") + group.POST("/list/suno", h.SunoList) + group.POST("/list/luma", h.LumaList) + group.GET("/remove", h.Remove) + }), + fx.Provide(handler.NewRealtimeHandler), + fx.Invoke(func(s *core.AppServer, h *handler.RealtimeHandler) { + s.Engine.Any("/api/realtime", h.Connection) + }), ) // 启动应用程序 go func() { diff --git a/api/store/vo/mj_job.go b/api/store/vo/mj_job.go index 59ec11c6..458e9a2d 100644 --- a/api/store/vo/mj_job.go +++ b/api/store/vo/mj_job.go @@ -1,23 +1,21 @@ package vo -import "time" - type MidJourneyJob struct { - Id uint `json:"id"` - Type string `json:"type"` - UserId int `json:"user_id"` - ChannelId string `json:"channel_id"` - TaskId string `json:"task_id"` - MessageId string `json:"message_id"` - ReferenceId string `json:"reference_id"` - ImgURL string `json:"img_url"` - OrgURL string `json:"org_url"` - Hash string `json:"hash"` - Progress int `json:"progress"` - Prompt string `json:"prompt"` - UseProxy bool `json:"use_proxy"` - Publish bool `json:"publish"` - ErrMsg string `json:"err_msg"` - Power int `json:"power"` - CreatedAt time.Time `json:"created_at"` + Id uint `json:"id"` + Type string `json:"type"` + UserId int `json:"user_id"` + ChannelId string `json:"channel_id"` + TaskId string `json:"task_id"` + MessageId string `json:"message_id"` + ReferenceId string `json:"reference_id"` + ImgURL string `json:"img_url"` + OrgURL string `json:"org_url"` + Hash string `json:"hash"` + Progress int `json:"progress"` + Prompt string `json:"prompt"` + UseProxy bool `json:"use_proxy"` + Publish bool `json:"publish"` + ErrMsg string `json:"err_msg"` + Power int `json:"power"` + CreatedAt int64 `json:"created_at"` } diff --git a/api/store/vo/sd_job.go b/api/store/vo/sd_job.go index 8d521504..dc712dca 100644 --- a/api/store/vo/sd_job.go +++ b/api/store/vo/sd_job.go @@ -2,7 +2,6 @@ package vo import ( "geekai/core/types" - "time" ) type SdJob struct { @@ -17,5 +16,5 @@ type SdJob struct { Publish bool `json:"publish"` ErrMsg string `json:"err_msg"` Power int `json:"power"` - CreatedAt time.Time `json:"created_at"` + CreatedAt int64 `json:"created_at"` } diff --git a/database/geekai_plus-v4.1.5.sql b/database/geekai_plus-v4.1.5.sql index f7b7afd2..c42cde92 100644 --- a/database/geekai_plus-v4.1.5.sql +++ b/database/geekai_plus-v4.1.5.sql @@ -3,11 +3,6 @@ -- https://www.phpmyadmin.net/ -- -- 主机: 127.0.0.1 -<<<<<<<< HEAD:database/geekai_plus-v4.1.5.sql --- 生成日期: 2024-09-30 17:03:37 -======== --- 生成日期: 2024-09-23 14:54:46 ->>>>>>>> 5213bdf08ba96a15d550a92e561a29a71a3ac841:deploy/data/mysql/init.d/geekai_plus-v4.1.4.sql -- 服务器版本: 8.0.33 -- PHP 版本: 8.1.2-1ubuntu2.18 diff --git a/database/update-v4.1.6.sql b/database/update-v4.1.6.sql new file mode 100644 index 00000000..ab941925 --- /dev/null +++ b/database/update-v4.1.6.sql @@ -0,0 +1 @@ +ALTER TABLE `chatgpt_chat_models` CHANGE `value` `value` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '模型值'; \ No newline at end of file diff --git a/deploy/data/mysql/init.d/geekai_plus-v4.1.5.sql b/deploy/data/mysql/init.d/geekai_plus-v4.1.6.sql similarity index 93% rename from deploy/data/mysql/init.d/geekai_plus-v4.1.5.sql rename to deploy/data/mysql/init.d/geekai_plus-v4.1.6.sql index 1a4d76a3..db310abb 100644 --- a/deploy/data/mysql/init.d/geekai_plus-v4.1.5.sql +++ b/deploy/data/mysql/init.d/geekai_plus-v4.1.6.sql @@ -3,9 +3,9 @@ -- https://www.phpmyadmin.net/ -- -- 主机: 127.0.0.1 --- 生成日期: 2024-09-30 17:03:37 +-- 生成日期: 2024-10-23 18:15:28 -- 服务器版本: 8.0.33 --- PHP 版本: 8.1.2-1ubuntu2.18 +-- PHP 版本: 8.1.2-1ubuntu2.19 SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO"; START TRANSACTION; @@ -47,7 +47,7 @@ CREATE TABLE `chatgpt_admin_users` ( -- INSERT INTO `chatgpt_admin_users` (`id`, `username`, `password`, `salt`, `status`, `last_login_at`, `last_login_ip`, `created_at`, `updated_at`) VALUES -(1, 'admin', '6d17e80c87d209efb84ca4b2e0824f549d09fac8b2e1cc698de5bb5e1d75dfd0', 'mmrql75o', 1, 1727062596, '::1', '2024-03-11 16:30:20', '2024-09-23 11:36:37'); +(1, 'admin', '6d17e80c87d209efb84ca4b2e0824f549d09fac8b2e1cc698de5bb5e1d75dfd0', 'mmrql75o', 1, 1729506124, '172.22.11.200', '2024-03-11 16:30:20', '2024-10-21 18:22:04'); -- -------------------------------------------------------- @@ -69,7 +69,6 @@ CREATE TABLE `chatgpt_api_keys` ( `updated_at` datetime NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='OpenAI API '; --- -------------------------------------------------------- -- -- 表的结构 `chatgpt_app_types` @@ -139,7 +138,7 @@ DROP TABLE IF EXISTS `chatgpt_chat_models`; CREATE TABLE `chatgpt_chat_models` ( `id` int NOT NULL, `name` varchar(50) NOT NULL COMMENT '模型名称', - `value` varchar(50) NOT NULL COMMENT '模型值', + `value` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '模型值', `sort_num` tinyint(1) NOT NULL COMMENT '排序数字', `enabled` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否启用模型', `power` smallint NOT NULL COMMENT '消耗算力点数', @@ -161,7 +160,7 @@ INSERT INTO `chatgpt_chat_models` (`id`, `name`, `value`, `sort_num`, `enabled`, (15, 'GPT-超级模型', 'gpt-4-all', 4, 1, 30, 1.0, 4096, 32768, 1, 0, '2024-01-15 11:32:52', '2024-09-13 18:01:08'), (36, 'GPT-4O', 'gpt-4o', 3, 1, 15, 1.0, 4096, 16384, 1, 66, '2024-05-14 09:25:15', '2024-09-29 19:08:53'), (39, 'Claude35-snonet', 'claude-3-5-sonnet-20240620', 5, 1, 2, 1.0, 4000, 200000, 1, 0, '2024-05-29 15:04:19', '2024-09-14 18:07:25'), -(41, '通义千问', 'qwen-turbo', 7, 1, 2, 1.0, 1024, 8192, 1, 44, '2024-06-06 11:40:46', '2024-08-06 10:51:37'), +(41, 'Suno对话模型', 'suno-v3.5', 7, 1, 10, 1.0, 1024, 8192, 1, 57, '2024-06-06 11:40:46', '2024-10-14 15:07:05'), (42, 'DeekSeek', 'deepseek-chat', 8, 1, 1, 1.0, 4096, 32768, 1, 0, '2024-06-27 16:13:01', '2024-08-05 16:05:33'), (44, 'Claude3-opus', 'claude-3-opus-20240229', 6, 1, 5, 1.0, 4000, 128000, 1, 44, '2024-07-22 11:24:30', '2024-09-04 10:32:29'), (46, 'gpt-3.5-turbo', 'gpt-3.5-turbo', 2, 1, 1, 1.0, 1024, 4096, 1, 73, '2024-07-22 13:53:41', '2024-09-13 18:00:47'), @@ -234,7 +233,7 @@ CREATE TABLE `chatgpt_configs` ( INSERT INTO `chatgpt_configs` (`id`, `marker`, `config_json`) VALUES (1, 'system', '{\"title\":\"GeekAI 创作助手\",\"slogan\":\"我辈之人,先干为敬,让每一个人都能用好AI\",\"admin_title\":\"GeekAI 控制台\",\"logo\":\"/images/logo.png\",\"init_power\":100,\"invite_power\":200,\"vip_month_power\":1000,\"register_ways\":[\"username\",\"email\",\"mobile\"],\"enabled_register\":true,\"order_pay_timeout\":600,\"vip_info_text\":\"月度会员,年度会员每月赠送 1000 点算力,赠送算力当月有效当月没有消费完的算力不结余到下个月。 点卡充值的算力长期有效。\",\"default_models\":[1],\"mj_power\":20,\"mj_action_power\":5,\"sd_power\":5,\"dall_power\":10,\"suno_power\":10,\"luma_power\":120,\"wechat_card_url\":\"/images/wx.png\",\"enable_context\":true,\"context_deep\":4,\"sd_neg_prompt\":\"nsfw, paintings,low quality,easynegative,ng_deepnegative ,lowres,bad anatomy,bad hands,bad feet\",\"mj_mode\":\"fast\",\"index_bg_url\":\"color\",\"index_navs\":[1,5,13,19,9,12,6,20,8,10],\"copyright\":\"极客学长 © 2022- 2024 All rights reserved\",\"mark_map_text\":\"# GeekAI 演示站\\n\\n- 完整的开源系统,前端应用和后台管理系统皆可开箱即用。\\n- 基于 Websocket 实现,完美的打字机体验。\\n- 内置了各种预训练好的角色应用,轻松满足你的各种聊天和应用需求。\\n- 支持 OPenAI,Azure,文心一言,讯飞星火,清华 ChatGLM等多个大语言模型。\\n- 支持 MidJourney / Stable Diffusion AI 绘画集成,开箱即用。\\n- 支持使用个人微信二维码作为充值收费的支付渠道,无需企业支付通道。\\n- 已集成支付宝支付功能,微信支付,支持多种会员套餐和点卡购买功能。\\n- 集成插件 API 功能,可结合大语言模型的 function 功能开发各种强大的插件。\",\"enabled_verify\":false,\"email_white_list\":[\"qq.com\",\"163.com\",\"gmail.com\",\"hotmail.com\",\"126.com\",\"outlook.com\",\"foxmail.com\",\"yahoo.com\"]}'), -(3, 'notice', '{\"sd_neg_prompt\":\"\",\"mj_mode\":\"\",\"index_bg_url\":\"\",\"index_navs\":null,\"copyright\":\"\",\"mark_map_text\":\"\",\"enabled_verify\":false,\"email_white_list\":null,\"content\":\"## v4.1.5 更新日志\\n\\n* 功能优化:重构 websocket 组件,减少 websocket 连接数,全站共享一个 websocket 连接\\n* Bug修复:兼容手机端原生微信支付和支付宝支付渠道\\n* Bug修复:修复删除绘图任务时候因为字段长度过短导致SQL执行失败问题\\n* 功能优化:优化 Vue 组件通信代码,使用共享数据来替换之前的事件订阅模式,效率更高一些\\n* 功能优化:优化思维导图生成功果页面,优化用户体验\\n\\n注意:当前站点仅为开源项目 \\u003ca style=\\\"color: #F56C6C\\\" href=\\\"https://github.com/yangjian102621/geekai\\\" target=\\\"_blank\\\"\\u003eGeekAI-Plus\\u003c/a\\u003e 的演示项目,本项目单纯就是给大家体验项目功能使用。\\n\\u003cstrong style=\\\"color: #F56C6C\\\"\\u003e体验额度用完之后请不要在当前站点进行任何充值操作!!!\\u003c/strong\\u003e\\n\\u003cstrong style=\\\"color: #F56C6C\\\"\\u003e体验额度用完之后请不要在当前站点进行任何充值操作!!!\\u003c/strong\\u003e\\n\\u003cstrong style=\\\"color: #F56C6C\\\"\\u003e体验额度用完之后请不要在当前站点进行任何充值操作!!!\\u003c/strong\\u003e\\n 如果觉得好用你就花几分钟自己部署一套,没有API KEY 的同学可以去下面几个推荐的中转站购买:\\n1、\\u003ca href=\\\"https://api.chat-plus.net\\\" target=\\\"_blank\\\"\\n style=\\\"font-size: 20px;color:#F56C6C\\\"\\u003ehttps://api.chat-plus.net\\u003c/a\\u003e\\n2、\\u003ca href=\\\"https://api.geekai.me\\\" target=\\\"_blank\\\"\\n style=\\\"font-size: 20px;color:#F56C6C\\\"\\u003ehttps://api.geekai.me\\u003c/a\\u003e\\n支持MidJourney,GPT,Claude,Google Gemmi,以及国内各个厂家的大模型,现在有超级优惠,价格远低于 OpenAI 官方。关于中转 API 的优势和劣势请参考 [中转API技术原理](https://docs.geekai.me/config/chat/#%E4%B8%AD%E8%BD%ACapi%E5%B7%A5%E4%BD%9C%E5%8E%9F%E7%90%86)。GPT-3.5,GPT-4,DALL-E3 绘图......你都可以随意使用,无需魔法。\\n接入教程: \\u003ca href=\\\"https://docs.geekai.me\\\" target=\\\"_blank\\\"\\n style=\\\"font-size: 20px;color:#F56C6C\\\"\\u003ehttps://docs.geekai.me\\u003c/a\\u003e\\n本项目源码地址:\\u003ca href=\\\"https://github.com/yangjian102621/geekai\\\" target=\\\"_blank\\\"\\u003ehttps://github.com/yangjian102621/geekai\\u003c/a\\u003e\",\"updated\":true}'); +(3, 'notice', '{\"sd_neg_prompt\":\"\",\"mj_mode\":\"\",\"index_bg_url\":\"\",\"index_navs\":null,\"copyright\":\"\",\"mark_map_text\":\"\",\"enabled_verify\":false,\"email_white_list\":null,\"content\":\"## v4.1.6 更新日志\\n\\n* 功能新增:**支持OpenAI实时语音对话功能** Beta 版,目前没有做算力计费控制,目前只有 VIP 用户可以使用。\\n* 功能优化:优化MysQL容器配置文档,解决MysQL容器资源占用过高问题\\n* 功能新增:管理后台增加AI绘图任务管理,可在管理后台浏览和删除用户的绘图任务\\n* 功能新增:管理后台增加Suno和Luma任务管理功能\\n* Bug修复:修复管理后台删除兑换码报 404 错误\\n* 功能优化:优化充值产品定价逻辑,可以设置原价和优惠价,**升级当前版本之后请务必要到管理后台去重新设置一下产品价格,以免造成损失!!!\\n\\n注意:当前站点仅为开源项目 \\u003ca style=\\\"color: #F56C6C\\\" href=\\\"https://github.com/yangjian102621/geekai\\\" target=\\\"_blank\\\"\\u003eGeekAI-Plus\\u003c/a\\u003e 的演示项目,本项目单纯就是给大家体验项目功能使用。\\n\\u003cstrong style=\\\"color: #F56C6C\\\"\\u003e体验额度用完之后请不要在当前站点进行任何充值操作!!!\\u003c/strong\\u003e\\n\\u003cstrong style=\\\"color: #F56C6C\\\"\\u003e体验额度用完之后请不要在当前站点进行任何充值操作!!!\\u003c/strong\\u003e\\n\\u003cstrong style=\\\"color: #F56C6C\\\"\\u003e体验额度用完之后请不要在当前站点进行任何充值操作!!!\\u003c/strong\\u003e\\n 如果觉得好用你就花几分钟自己部署一套,没有API KEY 的同学可以去下面几个推荐的中转站购买:\\n1、\\u003ca href=\\\"https://api.chat-plus.net\\\" target=\\\"_blank\\\"\\n style=\\\"font-size: 20px;color:#F56C6C\\\"\\u003ehttps://api.chat-plus.net\\u003c/a\\u003e\\n2、\\u003ca href=\\\"https://api.geekai.me\\\" target=\\\"_blank\\\"\\n style=\\\"font-size: 20px;color:#F56C6C\\\"\\u003ehttps://api.geekai.me\\u003c/a\\u003e\\n支持MidJourney,GPT,Claude,Google Gemmi,以及国内各个厂家的大模型,现在有超级优惠,价格远低于 OpenAI 官方。关于中转 API 的优势和劣势请参考 [中转API技术原理](https://docs.geekai.me/config/chat/#%E4%B8%AD%E8%BD%ACapi%E5%B7%A5%E4%BD%9C%E5%8E%9F%E7%90%86)。GPT-3.5,GPT-4,DALL-E3 绘图......你都可以随意使用,无需魔法。\\n接入教程: \\u003ca href=\\\"https://docs.geekai.me\\\" target=\\\"_blank\\\"\\n style=\\\"font-size: 20px;color:#F56C6C\\\"\\u003ehttps://docs.geekai.me\\u003c/a\\u003e\\n本项目源码地址:\\u003ca href=\\\"https://github.com/yangjian102621/geekai\\\" target=\\\"_blank\\\"\\u003ehttps://github.com/yangjian102621/geekai\\u003c/a\\u003e\",\"updated\":true}'); -- -------------------------------------------------------- @@ -471,8 +470,8 @@ CREATE TABLE `chatgpt_products` ( -- INSERT INTO `chatgpt_products` (`id`, `name`, `price`, `discount`, `days`, `power`, `enabled`, `sales`, `sort_num`, `created_at`, `updated_at`, `app_url`, `url`) VALUES -(5, '100次点卡', 9.99, 9.88, 0, 100, 1, 19, 0, '2023-08-28 10:55:08', '2024-09-18 16:41:10', NULL, NULL), -(6, '200次点卡', 19.90, 15.00, 0, 200, 1, 2, 2, '1970-01-01 08:00:00', '2024-08-05 16:05:46', NULL, NULL); +(5, '100次点卡', 9.99, 6.99, 0, 100, 1, 0, 0, '2023-08-28 10:55:08', '2024-10-23 18:12:29', NULL, NULL), +(6, '200次点卡', 19.90, 15.99, 0, 200, 1, 0, 0, '1970-01-01 08:00:00', '2024-10-23 18:12:36', NULL, NULL); -- -------------------------------------------------------- @@ -584,7 +583,7 @@ CREATE TABLE `chatgpt_users` ( -- INSERT INTO `chatgpt_users` (`id`, `username`, `mobile`, `email`, `nickname`, `password`, `avatar`, `salt`, `power`, `expired_time`, `status`, `chat_config_json`, `chat_roles_json`, `chat_models_json`, `last_login_at`, `vip`, `last_login_ip`, `openid`, `platform`, `created_at`, `updated_at`) VALUES -(4, '18888888888', '18575670126', '', '极客学长', 'ccc3fb7ab61b8b5d096a4a166ae21d121fc38c71bbd1be6173d9ab973214a63b', 'http://localhost:5678/static/upload/2024/5/1715651569509929.png', 'ueedue5l', 5823, 0, 1, '{\"api_keys\":{\"Azure\":\"\",\"ChatGLM\":\"\",\"OpenAI\":\"\"}}', '[\"gpt\",\"programmer\",\"teacher\"]', '[1]', 1727683253, 1, '172.22.11.200', 'oCs0t64FaOLfiTbHZpOqk3aUp_94', NULL, '2023-06-12 16:47:17', '2024-09-30 16:00:53'), +(4, '18888888888', '18575670126', '', '极客学长', 'ccc3fb7ab61b8b5d096a4a166ae21d121fc38c71bbd1be6173d9ab973214a63b', 'http://localhost:5678/static/upload/2024/5/1715651569509929.png', 'ueedue5l', 6051, 0, 1, '{\"api_keys\":{\"Azure\":\"\",\"ChatGLM\":\"\",\"OpenAI\":\"\"}}', '[\"gpt\",\"programmer\",\"teacher\"]', '[1]', 1729650760, 1, '::1', 'oCs0t64FaOLfiTbHZpOqk3aUp_94', NULL, '2023-06-12 16:47:17', '2024-10-23 10:32:40'), (42, 'yangjian@pvc123.com', '', 'yangjian@pvc123.com', '极客学长@263103', '672992fe8be51df479b9727cf70ca2ae26bc6a6c0c51ff8f836d3a8748387632', '/images/avatar/user.png', 'ahmgvvgc', 99, 0, 1, '', '[\"gpt\"]', '[1]', 1726133100, 0, '::1', '', '', '2024-09-12 15:08:52', '2024-09-12 17:25:00'), (43, '18575670125', '18575670125', '', '极客学长@394312', '83a5f04d5fea15419c2a324d5fcc8e1f93f62c2e2f5b883307d591ee92234fcc', '/images/avatar/user.png', 'rfml917k', 100, 0, 1, '', '[\"gpt\"]', '[1]', 1726132554, 0, '::1', '', '', '2024-09-12 15:38:38', '2024-09-12 17:15:55'), (44, '13666666666', '13666666666', '', '极客学长@172197', '2c57a40f938d2ee134dffdf0fba6c45907b9bcf1c6decab8f57f034e39b71b26', '/images/avatar/user.png', 'f9wlaiuy', 83, 0, 1, '', '[\"gpt\"]', '[1]', 0, 0, '', '', '', '2024-09-20 11:55:53', '2024-09-20 16:47:31'); @@ -808,7 +807,7 @@ ALTER TABLE `chatgpt_admin_users` -- 使用表AUTO_INCREMENT `chatgpt_api_keys` -- ALTER TABLE `chatgpt_api_keys` - MODIFY `id` int NOT NULL AUTO_INCREMENT; + MODIFY `id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=77; -- -- 使用表AUTO_INCREMENT `chatgpt_app_types` diff --git a/deploy/docker-compose.yaml b/deploy/docker-compose.yaml index 5fc24fb5..af00f91b 100644 --- a/deploy/docker-compose.yaml +++ b/deploy/docker-compose.yaml @@ -68,7 +68,7 @@ services: # 后端 API 程序 geekai-api: - image: registry.cn-shenzhen.aliyuncs.com/geekmaster/geekai-api:v4.1.5-amd64 + image: registry.cn-shenzhen.aliyuncs.com/geekmaster/geekai-api:v4.1.6-amd64 container_name: geekai-api restart: always depends_on: @@ -92,7 +92,7 @@ services: # 前端应用 geekai-web: - image: registry.cn-shenzhen.aliyuncs.com/geekmaster/geekai-web:v4.1.5-amd64 + image: registry.cn-shenzhen.aliyuncs.com/geekmaster/geekai-web:v4.1.6-amd64 container_name: geekai-web restart: always depends_on: diff --git a/web/.env.development b/web/.env.development index 99693506..d563a103 100644 --- a/web/.env.development +++ b/web/.env.development @@ -6,6 +6,6 @@ VUE_APP_ADMIN_USER=admin VUE_APP_ADMIN_PASS=admin123 VUE_APP_KEY_PREFIX=GeekAI_DEV_ VUE_APP_TITLE="Geek-AI 创作系统" -VUE_APP_VERSION=v4.1.5 +VUE_APP_VERSION=v4.1.6 VUE_APP_DOCS_URL=https://docs.geekai.me VUE_APP_GIT_URL=https://github.com/yangjian102621/geekai diff --git a/web/.env.production b/web/.env.production index f628d2ef..32177b68 100644 --- a/web/.env.production +++ b/web/.env.production @@ -1,7 +1,7 @@ VUE_APP_API_HOST= VUE_APP_WS_HOST= VUE_APP_KEY_PREFIX=GeekAI_ -VUE_APP_VERSION=v4.1.5 VUE_APP_TITLE="Geek-AI 创作系统" +VUE_APP_VERSION=v4.1.6 VUE_APP_DOCS_URL=https://docs.geekai.me VUE_APP_GIT_URL=https://github.com/yangjian102621/geekai diff --git a/web/package-lock.json b/web/package-lock.json index 92bf98f4..5ff1a255 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@element-plus/icons-vue": "^2.1.0", + "@openai/realtime-api-beta": "github:openai/openai-realtime-api-beta", "axios": "^0.27.2", "clipboard": "^2.0.11", "compressorjs": "^1.2.1", @@ -27,7 +28,6 @@ "markmap-view": "^0.16.0", "md-editor-v3": "^2.2.1", "memfs": "^4.9.3", - "mitt": "^3.0.1", "pinia": "^2.1.4", "qrcode": "^1.5.3", "qs": "^6.11.1", @@ -2022,6 +2022,33 @@ "node": ">= 8" } }, + "node_modules/@openai/realtime-api-beta": { + "version": "0.0.0", + "resolved": "git+ssh://git@github.com/openai/openai-realtime-api-beta.git#339e9553a757ef1cf8c767272fc750c1e62effbb", + "dependencies": { + "ws": "^8.18.0" + } + }, + "node_modules/@openai/realtime-api-beta/node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmmirror.com/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.21", "resolved": "https://registry.npmmirror.com/@polka/url/-/url-1.0.0-next.21.tgz", @@ -8769,11 +8796,6 @@ "node": ">=8" } }, - "node_modules/mitt": { - "version": "3.0.1", - "resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz", - "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==" - }, "node_modules/mj-context-menu": { "version": "0.6.1", "resolved": "https://registry.npmmirror.com/mj-context-menu/-/mj-context-menu-0.6.1.tgz", @@ -14196,6 +14218,21 @@ "fastq": "^1.6.0" } }, + "@openai/realtime-api-beta": { + "version": "git+ssh://git@github.com/openai/openai-realtime-api-beta.git#339e9553a757ef1cf8c767272fc750c1e62effbb", + "from": "@openai/realtime-api-beta@github:openai/openai-realtime-api-beta", + "requires": { + "ws": "^8.18.0" + }, + "dependencies": { + "ws": { + "version": "8.18.0", + "resolved": "https://registry.npmmirror.com/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "requires": {} + } + } + }, "@polka/url": { "version": "1.0.0-next.21", "resolved": "https://registry.npmmirror.com/@polka/url/-/url-1.0.0-next.21.tgz", @@ -19700,11 +19737,6 @@ "yallist": "^4.0.0" } }, - "mitt": { - "version": "3.0.1", - "resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz", - "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==" - }, "mj-context-menu": { "version": "0.6.1", "resolved": "https://registry.npmmirror.com/mj-context-menu/-/mj-context-menu-0.6.1.tgz", diff --git a/web/package.json b/web/package.json index 984ca9b5..74c864cf 100644 --- a/web/package.json +++ b/web/package.json @@ -9,6 +9,7 @@ }, "dependencies": { "@element-plus/icons-vue": "^2.1.0", + "@openai/realtime-api-beta": "github:openai/openai-realtime-api-beta", "axios": "^0.27.2", "clipboard": "^2.0.11", "compressorjs": "^1.2.1", diff --git a/web/public/medias/calling.mp3 b/web/public/medias/calling.mp3 new file mode 100644 index 00000000..ff92bfb8 Binary files /dev/null and b/web/public/medias/calling.mp3 differ diff --git a/web/public/medias/hang-up.mp3 b/web/public/medias/hang-up.mp3 new file mode 100644 index 00000000..367973dd Binary files /dev/null and b/web/public/medias/hang-up.mp3 differ diff --git a/web/src/App.vue b/web/src/App.vue index ea30e4bf..f5c6b429 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -12,8 +12,6 @@ import {isChrome, isMobile} from "@/utils/libs"; import {showMessageInfo} from "@/utils/dialog"; import {useSharedStore} from "@/store/sharedata"; import {getUserToken} from "@/store/session"; -import {router} from "@/router"; -import {onBeforeRouteLeave, onBeforeRouteUpdate} from "vue-router"; const debounce = (fn, delay) => { let timer @@ -71,7 +69,7 @@ const connect = () => { } } const clientId = getClientId() - const _socket = new WebSocket(host + `/api/ws?client_id=${clientId}&token=${getUserToken()}`); + const _socket = new WebSocket(host + `/api/ws?client_id=${clientId}`,["token",getUserToken()]); _socket.addEventListener('open', () => { console.log('WebSocket 已连接') handler.value = setInterval(() => { @@ -116,7 +114,7 @@ html, body { margin 0; .el-dialog__body { - max-height 80vh + //max-height 80vh overflow-y auto } } diff --git a/web/src/assets/css/chat-plus.styl b/web/src/assets/css/chat-plus.styl index a2d73018..06b44eb5 100644 --- a/web/src/assets/css/chat-plus.styl +++ b/web/src/assets/css/chat-plus.styl @@ -14,7 +14,7 @@ $borderColor = #4676d0; padding 10px width var(--el-aside-width, 320px) - .chat-list { + .media-page { display: flex flex-flow: column //background-color: $sideBgColor diff --git a/web/src/assets/css/member.styl b/web/src/assets/css/member.styl index 60ec8adc..aca86dbf 100644 --- a/web/src/assets/css/member.styl +++ b/web/src/assets/css/member.styl @@ -112,8 +112,9 @@ justify-content right } - .price { + .discount { color #f56c6c + font-size 20px } .expire { diff --git a/web/src/assets/css/realtime.styl b/web/src/assets/css/realtime.styl new file mode 100644 index 00000000..18f5337a --- /dev/null +++ b/web/src/assets/css/realtime.styl @@ -0,0 +1,189 @@ +.realtime-conversation { + /********************** connection ****************************/ + .connection-container { + background-color: #000; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + margin: 0; + overflow: hidden; + font-family: Arial, sans-serif; + width 100% + + .phone-container { + position: relative; + width: 200px; + height: 200px; + } + + .phone { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 60px; + height: 60px; + background-color: #00ffcc; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M20 15.5c-1.25 0-2.45-.2-3.57-.57a1.02 1.02 0 0 0-1.02.24l-2.2 2.2a15.074 15.074 0 0 1-6.59-6.59l2.2-2.2c.27-.27.35-.68.24-1.02a11.36 11.36 0 0 1-.57-3.57c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1 0 9.39 7.61 17 17 17 .55 0 1-.45 1-1v-3.5c0-.55-.45-1-1-1zM5.03 5h1.5c.07.89.22 1.76.46 2.59l-1.2 1.2c-.41-1.2-.67-2.47-.76-3.79zM19 18.97c-1.32-.09-2.59-.35-3.8-.75l1.2-1.2c.85.24 1.72.39 2.6.45v1.5z'/%3E%3C/svg%3E") no-repeat 50% 50%; + mask-size: cover; + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M20 15.5c-1.25 0-2.45-.2-3.57-.57a1.02 1.02 0 0 0-1.02.24l-2.2 2.2a15.074 15.074 0 0 1-6.59-6.59l2.2-2.2c.27-.27.35-.68.24-1.02a11.36 11.36 0 0 1-.57-3.57c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1 0 9.39 7.61 17 17 17 .55 0 1-.45 1-1v-3.5c0-.55-.45-1-1-1zM5.03 5h1.5c.07.89.22 1.76.46 2.59l-1.2 1.2c-.41-1.2-.67-2.47-.76-3.79zM19 18.97c-1.32-.09-2.59-.35-3.8-.75l1.2-1.2c.85.24 1.72.39 2.6.45v1.5z'/%3E%3C/svg%3E") no-repeat 50% 50%; + -webkit-mask-size: cover; + animation: shake 0.5s ease-in-out infinite; + } + + .signal { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 100px; + height: 100px; + border: 2px dashed #00ffcc; + border-radius: 50%; + opacity: 0; + animation: signal 2s linear infinite; + } + + .signal:nth-child(2) { + animation-delay: 0.5s; + } + + .signal:nth-child(3) { + animation-delay: 1s; + } + + .status-text { + color: #00ffcc; + font-size: 18px; + margin-top: 20px; + height: 1.2em; + overflow: hidden; + } + + @keyframes shake { + 0%, 100% { transform: translate(-50%, -50%) rotate(0deg); } + 25% { transform: translate(-52%, -48%) rotate(-5deg); } + 75% { transform: translate(-48%, -52%) rotate(5deg); } + } + + @keyframes signal { + 0% { + width: 60px; + height: 60px; + opacity: 1; + } + 100% { + width: 200px; + height: 200px; + opacity: 0; + } + } + } + /*********** end of connection ************/ + + .conversation-container { + background: linear-gradient(to right, #2c3e50, #4a5568, #6b46c1); + display: flex; + height 100% + flex-direction: column; + justify-content: space-between; + align-items: center; + padding: 0; + width 100% + + .wave-container { + padding 3rem + .wave-animation { + display: flex; + justify-content: center; + align-items: center; + gap: 10px; + + .wave-ellipse { + width: 40px; + height: 40px; + background-color: white; + border-radius: 20px; + animation: wave 0.8s infinite ease-in-out; + } + + .wave-ellipse:nth-child(odd) { + height: 60px; + } + + .wave-ellipse:nth-child(even) { + height: 80px; + } + } + } + + @keyframes wave { + 0%, 100% { + transform: scaleY(0.8); + } + 50% { + transform: scaleY(1.2); + } + } + + .wave-ellipse:nth-child(2) { + animation-delay: 0.1s; + } + + .wave-ellipse:nth-child(3) { + animation-delay: 0.2s; + } + + .wave-ellipse:nth-child(4) { + animation-delay: 0.3s; + } + + .wave-ellipse:nth-child(5) { + animation-delay: 0.4s; + } + + .voice-indicators { + display flex + flex-flow row + justify-content: space-between; + width 100% + } + + .call-controls { + display: flex; + justify-content: center; + gap: 3rem; + padding 3rem + + .call-button { + width: 60px; + height: 60px; + border-radius: 50%; + border: none; + display: flex; + justify-content: center; + align-items: center; + font-size: 24px; + color: white; + cursor: pointer; + + .iconfont { + font-size 24px + } + } + .hangup { + background-color: #e74c3c; + } + + .answer { + background-color: #2ecc71; + } + + .icon { + font-size: 28px; + } + } + + } +} \ No newline at end of file diff --git a/web/src/assets/iconfont/iconfont.css b/web/src/assets/iconfont/iconfont.css index e0dee322..9871d1bb 100644 --- a/web/src/assets/iconfont/iconfont.css +++ b/web/src/assets/iconfont/iconfont.css @@ -1,8 +1,8 @@ @font-face { font-family: "iconfont"; /* Project id 4125778 */ - src: url('iconfont.woff2?t=1726622198991') format('woff2'), - url('iconfont.woff?t=1726622198991') format('woff'), - url('iconfont.ttf?t=1726622198991') format('truetype'); + src: url('iconfont.woff2?t=1728891448746') format('woff2'), + url('iconfont.woff?t=1728891448746') format('woff'), + url('iconfont.ttf?t=1728891448746') format('truetype'); } .iconfont { @@ -13,6 +13,14 @@ -moz-osx-font-smoothing: grayscale; } +.icon-call:before { + content: "\e769"; +} + +.icon-hung-up:before { + content: "\e609"; +} + .icon-paypal:before { content: "\e666"; } diff --git a/web/src/assets/iconfont/iconfont.js b/web/src/assets/iconfont/iconfont.js index 74291ab4..1cdb9982 100644 --- a/web/src/assets/iconfont/iconfont.js +++ b/web/src/assets/iconfont/iconfont.js @@ -1 +1 @@ -window._iconfont_svg_string_4125778='',(a=>{var l=(c=(c=document.getElementsByTagName("script"))[c.length-1]).getAttribute("data-injectcss"),c=c.getAttribute("data-disable-injectsvg");if(!c){var h,t,i,o,z,m=function(l,c){c.parentNode.insertBefore(l,c)};if(l&&!a.__iconfont__svg__cssinject__){a.__iconfont__svg__cssinject__=!0;try{document.write("")}catch(l){console&&console.log(l)}}h=function(){var l,c=document.createElement("div");c.innerHTML=a._iconfont_svg_string_4125778,(c=c.getElementsByTagName("svg")[0])&&(c.setAttribute("aria-hidden","true"),c.style.position="absolute",c.style.width=0,c.style.height=0,c.style.overflow="hidden",c=c,(l=document.body).firstChild?m(c,l.firstChild):l.appendChild(c))},document.addEventListener?~["complete","loaded","interactive"].indexOf(document.readyState)?setTimeout(h,0):(t=function(){document.removeEventListener("DOMContentLoaded",t,!1),h()},document.addEventListener("DOMContentLoaded",t,!1)):document.attachEvent&&(i=h,o=a.document,z=!1,s(),o.onreadystatechange=function(){"complete"==o.readyState&&(o.onreadystatechange=null,p())})}function p(){z||(z=!0,i())}function s(){try{o.documentElement.doScroll("left")}catch(l){return void setTimeout(s,50)}p()}})(window); \ No newline at end of file +window._iconfont_svg_string_4125778='',(a=>{var l=(c=(c=document.getElementsByTagName("script"))[c.length-1]).getAttribute("data-injectcss"),c=c.getAttribute("data-disable-injectsvg");if(!c){var h,t,i,o,z,m=function(l,c){c.parentNode.insertBefore(l,c)};if(l&&!a.__iconfont__svg__cssinject__){a.__iconfont__svg__cssinject__=!0;try{document.write("")}catch(l){console&&console.log(l)}}h=function(){var l,c=document.createElement("div");c.innerHTML=a._iconfont_svg_string_4125778,(c=c.getElementsByTagName("svg")[0])&&(c.setAttribute("aria-hidden","true"),c.style.position="absolute",c.style.width=0,c.style.height=0,c.style.overflow="hidden",c=c,(l=document.body).firstChild?m(c,l.firstChild):l.appendChild(c))},document.addEventListener?~["complete","loaded","interactive"].indexOf(document.readyState)?setTimeout(h,0):(t=function(){document.removeEventListener("DOMContentLoaded",t,!1),h()},document.addEventListener("DOMContentLoaded",t,!1)):document.attachEvent&&(i=h,o=a.document,z=!1,s(),o.onreadystatechange=function(){"complete"==o.readyState&&(o.onreadystatechange=null,p())})}function p(){z||(z=!0,i())}function s(){try{o.documentElement.doScroll("left")}catch(l){return void setTimeout(s,50)}p()}})(window); \ No newline at end of file diff --git a/web/src/assets/iconfont/iconfont.json b/web/src/assets/iconfont/iconfont.json index a38cbab5..e32aad7f 100644 --- a/web/src/assets/iconfont/iconfont.json +++ b/web/src/assets/iconfont/iconfont.json @@ -5,6 +5,20 @@ "css_prefix_text": "icon-", "description": "", "glyphs": [ + { + "icon_id": "11231556", + "name": "打电话", + "font_class": "call", + "unicode": "e769", + "unicode_decimal": 59241 + }, + { + "icon_id": "21969717", + "name": "挂机", + "font_class": "hung-up", + "unicode": "e609", + "unicode_decimal": 58889 + }, { "icon_id": "7443846", "name": "PayPal", diff --git a/web/src/assets/iconfont/iconfont.ttf b/web/src/assets/iconfont/iconfont.ttf index e5b3d7de..14ee893b 100644 Binary files a/web/src/assets/iconfont/iconfont.ttf and b/web/src/assets/iconfont/iconfont.ttf differ diff --git a/web/src/assets/iconfont/iconfont.woff b/web/src/assets/iconfont/iconfont.woff index c20755b7..ee2f117c 100644 Binary files a/web/src/assets/iconfont/iconfont.woff and b/web/src/assets/iconfont/iconfont.woff differ diff --git a/web/src/assets/iconfont/iconfont.woff2 b/web/src/assets/iconfont/iconfont.woff2 index 41f980f4..aba9800a 100644 Binary files a/web/src/assets/iconfont/iconfont.woff2 and b/web/src/assets/iconfont/iconfont.woff2 differ diff --git a/web/src/assets/img/admin-login-bg.jpg b/web/src/assets/img/admin-login-bg.jpg new file mode 100644 index 00000000..b001be12 Binary files /dev/null and b/web/src/assets/img/admin-login-bg.jpg differ diff --git a/web/src/assets/img/reg-bg.jpg b/web/src/assets/img/reg-bg.jpg deleted file mode 100644 index cfd76ed2..00000000 Binary files a/web/src/assets/img/reg-bg.jpg and /dev/null differ diff --git a/web/src/assets/img/reg_bg.png b/web/src/assets/img/reg_bg.png new file mode 100644 index 00000000..95957c4c Binary files /dev/null and b/web/src/assets/img/reg_bg.png differ diff --git a/web/src/components/Calling.vue b/web/src/components/Calling.vue new file mode 100644 index 00000000..e7b6c724 --- /dev/null +++ b/web/src/components/Calling.vue @@ -0,0 +1,130 @@ + + + + + \ No newline at end of file diff --git a/web/src/components/FileList.vue b/web/src/components/FileList.vue index 8bf96897..2819902a 100644 --- a/web/src/components/FileList.vue +++ b/web/src/components/FileList.vue @@ -1,6 +1,6 @@