diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f2e2fab --- /dev/null +++ b/.dockerignore @@ -0,0 +1,49 @@ +# .dockerignore +# 忽略不需要打包到镜像的文件 + +# Git +.git +.gitignore +.gitattributes + +# IDE +.vscode +.idea +*.swp +*.swo +*~ + +# 日志和临时文件 +*.log +log/ +logs/ +tmp/ +temp/ + +# 数据文件 +data/ +uploads/ +*.db +*.sqlite + +# Node modules (会在构建时重新安装) +web/node_modules/ +web/dist/ + +# Go +server/server +server/*.exe +server/*.test +server/*.out + +# 文档 +*.md +docs/ + +# 配置文件(保留 config.docker.yaml) +server/config.yaml +config.yaml + +# 其他 +.DS_Store +Thumbs.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a9d6c1b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,72 @@ +# 多阶段构建 Dockerfile - 前后端一体化部署 +# 阶段1: 构建前端 +FROM node:18-alpine as frontend-builder + +WORKDIR /app/web + +# 复制前端依赖文件 +COPY web/package*.json ./ + +# 安装依赖 +RUN npm install --registry=https://registry.npmmirror.com + +# 复制前端源码 +COPY web/ ./ + +# 构建前端 +RUN npm run build + +# 阶段2: 构建后端 +FROM golang:1.21-alpine as backend-builder + +WORKDIR /app/server + +# 复制后端依赖文件 +COPY server/go.mod server/go.sum ./ + +# 下载依赖 +RUN go env -w GO111MODULE=on \ + && go env -w GOPROXY=https://goproxy.cn,direct \ + && go env -w CGO_ENABLED=0 \ + && go mod download + +# 复制后端源码 +COPY server/ ./ + +# 构建后端 +RUN go build -ldflags="-s -w" -o ai_proxy . + +# 阶段3: 最终镜像 +FROM alpine:latest + +LABEL maintainer="AI Proxy Team" +LABEL description="AI Proxy - 前后端一体化部署" + +# 设置时区 +ENV TZ=Asia/Shanghai +RUN apk update && apk add --no-cache tzdata ca-certificates \ + && ln -sf /usr/share/zoneinfo/$TZ /etc/localtime \ + && echo $TZ > /etc/timezone + +WORKDIR /app + +# 从构建阶段复制文件 +COPY --from=backend-builder /app/server/ai_proxy ./ +COPY --from=backend-builder /app/server/resource ./resource/ +COPY --from=frontend-builder /app/web/dist ./dist/ + +# 复制配置文件(如果有 config.docker.yaml) +COPY server/config.docker.yaml ./config.yaml + +# 创建必要的目录 +RUN mkdir -p uploads/file log data + +# 暴露端口 +EXPOSE 8989 + +# 健康检查 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8989/api/health || exit 1 + +# 启动应用 +ENTRYPOINT ["./ai_proxy"] diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..fb2c43e --- /dev/null +++ b/config.yaml @@ -0,0 +1,267 @@ +aliyun-oss: + endpoint: oss-cn-hangzhou.aliyuncs.com + access-key-id: LTAI5tB3Mn5Y7mVo8h3zkf46 + access-key-secret: FtuHdFy4NFdVItEiNBnTun3Ddi8BMK + bucket-name: lckt + bucket-url: https://lckt.oss-cn-hangzhou.aliyuncs.com + base-path: st +autocode: + web: web/src + root: /Users/en/GolandProjects/st + server: server + module: git.echol.cn/loser/ai_proxy/server + ai-path: "" +aws-s3: + bucket: xxxxx-10005608 + region: ap-shanghai + endpoint: "" + secret-id: your-secret-id + secret-key: your-secret-key + base-url: https://gin.vue.admin + path-prefix: git.echol.cn/loser/ai_proxy/server + s3-force-path-style: false + disable-ssl: false +captcha: + key-long: 4 + img-width: 240 + img-height: 80 + open-captcha: 0 + open-captcha-timeout: 3600 +cloudflare-r2: + bucket: xxxx0bucket + base-url: https://gin.vue.admin.com + path: uploads + account-id: xxx_account_id + access-key-id: xxx_key_id + secret-access-key: xxx_secret_key +cors: + mode: strict-whitelist + whitelist: + - allow-origin: example1.com + allow-methods: POST, GET + allow-headers: Content-Type,AccessToken,X-CSRF-Token, Authorization, Token,X-Token,X-User-Id + expose-headers: Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers, Content-Type + allow-credentials: true + - allow-origin: example2.com + allow-methods: GET, POST + allow-headers: content-type + expose-headers: Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers, Content-Type + allow-credentials: true +db-list: + - type: "" + alias-name: "" + prefix: "" + port: "" + config: "" + db-name: "" + username: "" + password: "" + path: "" + engine: "" + log-mode: "" + max-idle-conns: 10 + max-open-conns: 100 + singular: false + log-zap: false + disable: true +disk-list: + - mount-point: / +email: + to: xxx@qq.com + from: xxx@163.com + host: smtp.163.com + secret: xxx + nickname: test + port: 465 + is-ssl: true + is-loginauth: false +excel: + dir: ./resource/excel/ +hua-wei-obs: + path: you-path + bucket: you-bucket + endpoint: you-endpoint + access-key: you-access-key + secret-key: you-secret-key + use-ssl: false +jwt: + signing-key: 53d59b59-dba8-4f83-886e-e5bd1bf3cbda + expires-time: 7d + buffer-time: 1d + issuer: qmPlus +local: + path: http://localhost:8888/uploads/file + store-path: uploads/file +mcp: + name: GVA_MCP + version: v1.0.0 + sse_path: /sse + message_path: /message + url_prefix: "" + addr: 8889 + separate: false +minio: + endpoint: yourEndpoint + access-key-id: yourAccessKeyId + access-key-secret: yourAccessKeySecret + bucket-name: yourBucketName + use-ssl: false + base-path: "" + bucket-url: http://host:9000/yourBucketName +mongo: + coll: "" + options: "" + database: "" + username: "" + password: "" + auth-source: "" + min-pool-size: 0 + max-pool-size: 100 + socket-timeout-ms: 0 + connect-timeout-ms: 0 + is-zap: false + hosts: + - host: "" + port: "" +mssql: + prefix: "" + port: "" + config: "" + db-name: "" + username: "" + password: "" + path: "" + engine: "" + log-mode: "" + max-idle-conns: 10 + max-open-conns: 100 + singular: false + log-zap: false +mysql: + prefix: "" + port: "" + config: "" + db-name: "" + username: "" + password: "" + path: "" + engine: "" + log-mode: "" + max-idle-conns: 10 + max-open-conns: 100 + singular: false + log-zap: false +oracle: + prefix: "" + port: "" + config: "" + db-name: "" + username: "" + password: "" + path: "" + engine: "" + log-mode: "" + max-idle-conns: 10 + max-open-conns: 100 + singular: false + log-zap: false +#pgsql: +# prefix: "" +# port: "5432" +# config: sslmode=disable TimeZone=Asia/Shanghai +# db-name: st_dev +# username: postgres +# password: loser765911. +# path: 149.88.74.188 +# engine: "" +# log-mode: error +# max-idle-conns: 10 +# max-open-conns: 100 +# singular: false +# log-zap: false +pgsql: + prefix: "" + port: "5432" + config: sslmode=disable TimeZone=Asia/Shanghai + db-name: ai_proxy + username: postgres + password: e5zse3Adrja7PNfA + path: 219.152.55.29 + engine: "" + log-mode: error + max-idle-conns: 10 + max-open-conns: 100 + singular: false + log-zap: true +qiniu: + zone: ZoneHuaDong + bucket: "" + img-path: "" + access-key: "" + secret-key: "" + use-https: false + use-cdn-domains: false +redis: + name: "sys-cache" + addr: 219.152.55.29:6379 + password: "THBA@6688" + db: 12 + useCluster: false + clusterAddrs: + - 172.21.0.3:7000 + - 172.21.0.4:7001 + - 172.21.0.2:7002 +redis-list: + - name: app-cache + addr: 219.152.55.29:6379 + password: "THBA@6688" + db: 11 + useCluster: false + clusterAddrs: + - 172.21.0.3:7000 + - 172.21.0.4:7001 + - 172.21.0.2:7002 +sqlite: + prefix: "" + port: "" + config: "" + db-name: "" + username: "" + password: "" + path: "" + engine: "" + log-mode: "" + max-idle-conns: 10 + max-open-conns: 100 + singular: false + log-zap: false +system: + db-type: pgsql + oss-type: aliyun-oss + router-prefix: "" + addr: 8889 + iplimit-count: 15000 + iplimit-time: 3600 + use-multipoint: false + use-redis: true + use-mongo: false + use-strict-auth: false + disable-auto-migrate: false + data-dir: data +tencent-cos: + bucket: xxxxx-10005608 + region: ap-shanghai + secret-id: your-secret-id + secret-key: your-secret-key + base-url: https://gin.vue.admin + path-prefix: git.echol.cn/loser/ai_proxy/server +zap: + level: info + prefix: '[git.echol.cn/loser/ai_proxy/server]' + format: console + director: log + encode-level: LowercaseColorLevelEncoder + stacktrace-key: stacktrace + show-line: true + log-in-console: true + retention-day: -1 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bb97650 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +version: '3.8' + +services: + ai-proxy: + build: + context: . + dockerfile: Dockerfile + container_name: ai-proxy + restart: unless-stopped + ports: + - "8989:8989" + environment: + - TZ=Asia/Shanghai + volumes: + # 持久化数据 + - ./data:/app/data + - ./uploads:/app/uploads + - ./log:/app/log + # 配置文件(可选,如果需要自定义配置) + - ./server/config.yaml:/app/config.yaml + networks: + - ai-proxy-network + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8989/api/health"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 10s + +networks: + ai-proxy-network: + driver: bridge diff --git a/server/api/v1/app/ai_preset_binding.go b/server/api/v1/app/ai_api_key.go similarity index 54% rename from server/api/v1/app/ai_preset_binding.go rename to server/api/v1/app/ai_api_key.go index 8a4d370..544b55c 100644 --- a/server/api/v1/app/ai_preset_binding.go +++ b/server/api/v1/app/ai_api_key.go @@ -5,52 +5,49 @@ import ( "git.echol.cn/loser/ai_proxy/server/model/app" "git.echol.cn/loser/ai_proxy/server/model/common/request" "git.echol.cn/loser/ai_proxy/server/model/common/response" - "git.echol.cn/loser/ai_proxy/server/service" "git.echol.cn/loser/ai_proxy/server/utils" "github.com/gin-gonic/gin" "go.uber.org/zap" ) -type AiPresetBindingApi struct{} +type AiApiKeyApi struct{} -var aiPresetBindingService = service.ServiceGroupApp.AppServiceGroup.AiPresetBindingService - -// CreateAiPresetBinding 创建绑定 -// @Tags AiPresetBinding -// @Summary 创建绑定 +// CreateAiApiKey 创建API密钥 +// @Tags AiApiKey +// @Summary 创建API密钥 // @Security ApiKeyAuth // @accept application/json // @Produce application/json -// @Param data body app.AiPresetBinding true "绑定信息" -// @Success 200 {object} response.Response{msg=string} "创建成功" -// @Router /aiPresetBinding/createAiPresetBinding [post] -func (a *AiPresetBindingApi) CreateAiPresetBinding(c *gin.Context) { - var binding app.AiPresetBinding - err := c.ShouldBindJSON(&binding) +// @Param data body app.AiApiKey true "API密钥信息" +// @Success 200 {object} response.Response{data=app.AiApiKey,msg=string} "创建成功" +// @Router /aiApiKey/createAiApiKey [post] +func (a *AiApiKeyApi) CreateAiApiKey(c *gin.Context) { + var apiKey app.AiApiKey + err := c.ShouldBindJSON(&apiKey) if err != nil { response.FailWithMessage(err.Error(), c) return } - binding.UserID = utils.GetUserID(c) + apiKey.UserID = utils.GetUserID(c) - if err := aiPresetBindingService.CreateAiPresetBinding(&binding); err != nil { + if err := aiApiKeyService.CreateAiApiKey(&apiKey); err != nil { global.GVA_LOG.Error("创建失败!", zap.Error(err)) response.FailWithMessage("创建失败", c) } else { - response.OkWithMessage("创建成功", c) + response.OkWithData(apiKey, c) } } -// DeleteAiPresetBinding 删除绑定 -// @Tags AiPresetBinding -// @Summary 删除绑定 +// DeleteAiApiKey 删除API密钥 +// @Tags AiApiKey +// @Summary 删除API密钥 // @Security ApiKeyAuth // @accept application/json // @Produce application/json // @Param data body request.GetById true "ID" // @Success 200 {object} response.Response{msg=string} "删除成功" -// @Router /aiPresetBinding/deleteAiPresetBinding [delete] -func (a *AiPresetBindingApi) DeleteAiPresetBinding(c *gin.Context) { +// @Router /aiApiKey/deleteAiApiKey [delete] +func (a *AiApiKeyApi) DeleteAiApiKey(c *gin.Context) { var reqId request.GetById err := c.ShouldBindJSON(&reqId) if err != nil { @@ -59,7 +56,7 @@ func (a *AiPresetBindingApi) DeleteAiPresetBinding(c *gin.Context) { } userID := utils.GetUserID(c) - if err := aiPresetBindingService.DeleteAiPresetBinding(reqId.Uint(), userID); err != nil { + if err := aiApiKeyService.DeleteAiApiKey(reqId.Uint(), userID); err != nil { global.GVA_LOG.Error("删除失败!", zap.Error(err)) response.FailWithMessage("删除失败", c) } else { @@ -67,25 +64,25 @@ func (a *AiPresetBindingApi) DeleteAiPresetBinding(c *gin.Context) { } } -// UpdateAiPresetBinding 更新绑定 -// @Tags AiPresetBinding -// @Summary 更新绑定 +// UpdateAiApiKey 更新API密钥 +// @Tags AiApiKey +// @Summary 更新API密钥 // @Security ApiKeyAuth // @accept application/json // @Produce application/json -// @Param data body app.AiPresetBinding true "绑定信息" +// @Param data body app.AiApiKey true "API密钥信息" // @Success 200 {object} response.Response{msg=string} "更新成功" -// @Router /aiPresetBinding/updateAiPresetBinding [put] -func (a *AiPresetBindingApi) UpdateAiPresetBinding(c *gin.Context) { - var binding app.AiPresetBinding - err := c.ShouldBindJSON(&binding) +// @Router /aiApiKey/updateAiApiKey [put] +func (a *AiApiKeyApi) UpdateAiApiKey(c *gin.Context) { + var apiKey app.AiApiKey + err := c.ShouldBindJSON(&apiKey) if err != nil { response.FailWithMessage(err.Error(), c) return } userID := utils.GetUserID(c) - if err := aiPresetBindingService.UpdateAiPresetBinding(&binding, userID); err != nil { + if err := aiApiKeyService.UpdateAiApiKey(&apiKey, userID); err != nil { global.GVA_LOG.Error("更新失败!", zap.Error(err)) response.FailWithMessage("更新失败", c) } else { @@ -93,16 +90,16 @@ func (a *AiPresetBindingApi) UpdateAiPresetBinding(c *gin.Context) { } } -// FindAiPresetBinding 查询绑定 -// @Tags AiPresetBinding -// @Summary 查询绑定 +// FindAiApiKey 查询API密钥 +// @Tags AiApiKey +// @Summary 查询API密钥 // @Security ApiKeyAuth // @accept application/json // @Produce application/json // @Param data query request.GetById true "ID" -// @Success 200 {object} response.Response{data=app.AiPresetBinding,msg=string} "查询成功" -// @Router /aiPresetBinding/findAiPresetBinding [get] -func (a *AiPresetBindingApi) FindAiPresetBinding(c *gin.Context) { +// @Success 200 {object} response.Response{data=app.AiApiKey,msg=string} "查询成功" +// @Router /aiApiKey/findAiApiKey [get] +func (a *AiApiKeyApi) FindAiApiKey(c *gin.Context) { var reqId request.GetById err := c.ShouldBindQuery(&reqId) if err != nil { @@ -111,24 +108,24 @@ func (a *AiPresetBindingApi) FindAiPresetBinding(c *gin.Context) { } userID := utils.GetUserID(c) - if binding, err := aiPresetBindingService.GetAiPresetBinding(reqId.Uint(), userID); err != nil { + if apiKey, err := aiApiKeyService.GetAiApiKey(reqId.Uint(), userID); err != nil { global.GVA_LOG.Error("查询失败!", zap.Error(err)) response.FailWithMessage("查询失败", c) } else { - response.OkWithData(binding, c) + response.OkWithData(apiKey, c) } } -// GetAiPresetBindingList 获取绑定列表 -// @Tags AiPresetBinding -// @Summary 获取绑定列表 +// GetAiApiKeyList 获取API密钥列表 +// @Tags AiApiKey +// @Summary 获取API密钥列表 // @Security ApiKeyAuth // @accept application/json // @Produce application/json // @Param data query request.PageInfo true "分页参数" // @Success 200 {object} response.Response{data=response.PageResult,msg=string} "获取成功" -// @Router /aiPresetBinding/getAiPresetBindingList [get] -func (a *AiPresetBindingApi) GetAiPresetBindingList(c *gin.Context) { +// @Router /aiApiKey/getAiApiKeyList [get] +func (a *AiApiKeyApi) GetAiApiKeyList(c *gin.Context) { var pageInfo request.PageInfo err := c.ShouldBindQuery(&pageInfo) if err != nil { @@ -137,7 +134,7 @@ func (a *AiPresetBindingApi) GetAiPresetBindingList(c *gin.Context) { } userID := utils.GetUserID(c) - if list, total, err := aiPresetBindingService.GetAiPresetBindingList(pageInfo, userID); err != nil { + if list, total, err := aiApiKeyService.GetAiApiKeyList(pageInfo, userID); err != nil { global.GVA_LOG.Error("获取失败!", zap.Error(err)) response.FailWithMessage("获取失败", c) } else { diff --git a/server/api/v1/app/ai_model.go b/server/api/v1/app/ai_model.go new file mode 100644 index 0000000..3c2a922 --- /dev/null +++ b/server/api/v1/app/ai_model.go @@ -0,0 +1,177 @@ +package app + +import ( + "git.echol.cn/loser/ai_proxy/server/global" + "git.echol.cn/loser/ai_proxy/server/model/app" + "git.echol.cn/loser/ai_proxy/server/model/common/request" + "git.echol.cn/loser/ai_proxy/server/model/common/response" + "git.echol.cn/loser/ai_proxy/server/service" + "git.echol.cn/loser/ai_proxy/server/utils" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +type AiModelApi struct{} + +var aiModelService = service.ServiceGroupApp.AppServiceGroup.AiModelService + +// CreateAiModel 创建模型 +// @Tags AiModel +// @Summary 创建模型 +// @Security ApiKeyAuth +// @accept application/json +// @Produce application/json +// @Param data body app.AiModel true "模型信息" +// @Success 200 {object} response.Response{msg=string} "创建成功" +// @Router /aiModel/createAiModel [post] +func (a *AiModelApi) CreateAiModel(c *gin.Context) { + var model app.AiModel + err := c.ShouldBindJSON(&model) + if err != nil { + response.FailWithMessage(err.Error(), c) + return + } + model.UserID = utils.GetUserID(c) + + if err := aiModelService.CreateAiModel(&model); err != nil { + global.GVA_LOG.Error("创建失败!", zap.Error(err)) + response.FailWithMessage("创建失败", c) + } else { + response.OkWithMessage("创建成功", c) + } +} + +// DeleteAiModel 删除模型 +// @Tags AiModel +// @Summary 删除模型 +// @Security ApiKeyAuth +// @accept application/json +// @Produce application/json +// @Param data body request.GetById true "ID" +// @Success 200 {object} response.Response{msg=string} "删除成功" +// @Router /aiModel/deleteAiModel [delete] +func (a *AiModelApi) DeleteAiModel(c *gin.Context) { + var reqId request.GetById + err := c.ShouldBindJSON(&reqId) + if err != nil { + response.FailWithMessage(err.Error(), c) + return + } + userID := utils.GetUserID(c) + + if err := aiModelService.DeleteAiModel(reqId.Uint(), userID); err != nil { + global.GVA_LOG.Error("删除失败!", zap.Error(err)) + response.FailWithMessage("删除失败", c) + } else { + response.OkWithMessage("删除成功", c) + } +} + +// UpdateAiModel 更新模型 +// @Tags AiModel +// @Summary 更新模型 +// @Security ApiKeyAuth +// @accept application/json +// @Produce application/json +// @Param data body app.AiModel true "模型信息" +// @Success 200 {object} response.Response{msg=string} "更新成功" +// @Router /aiModel/updateAiModel [put] +func (a *AiModelApi) UpdateAiModel(c *gin.Context) { + var model app.AiModel + err := c.ShouldBindJSON(&model) + if err != nil { + response.FailWithMessage(err.Error(), c) + return + } + userID := utils.GetUserID(c) + + if err := aiModelService.UpdateAiModel(&model, userID); err != nil { + global.GVA_LOG.Error("更新失败!", zap.Error(err)) + response.FailWithMessage("更新失败", c) + } else { + response.OkWithMessage("更新成功", c) + } +} + +// FindAiModel 查询模型 +// @Tags AiModel +// @Summary 查询模型 +// @Security ApiKeyAuth +// @accept application/json +// @Produce application/json +// @Param data query request.GetById true "ID" +// @Success 200 {object} response.Response{data=app.AiModel,msg=string} "查询成功" +// @Router /aiModel/findAiModel [get] +func (a *AiModelApi) FindAiModel(c *gin.Context) { + var reqId request.GetById + err := c.ShouldBindQuery(&reqId) + if err != nil { + response.FailWithMessage(err.Error(), c) + return + } + userID := utils.GetUserID(c) + + if model, err := aiModelService.GetAiModel(reqId.Uint(), userID); err != nil { + global.GVA_LOG.Error("查询失败!", zap.Error(err)) + response.FailWithMessage("查询失败", c) + } else { + response.OkWithData(model, c) + } +} + +// GetAiModelList 获取模型列表 +// @Tags AiModel +// @Summary 获取模型列表 +// @Security ApiKeyAuth +// @accept application/json +// @Produce application/json +// @Param data query request.PageInfo true "分页参数" +// @Success 200 {object} response.Response{data=response.PageResult,msg=string} "获取成功" +// @Router /aiModel/getAiModelList [get] +func (a *AiModelApi) GetAiModelList(c *gin.Context) { + var pageInfo request.PageInfo + err := c.ShouldBindQuery(&pageInfo) + if err != nil { + response.FailWithMessage(err.Error(), c) + return + } + userID := utils.GetUserID(c) + + if list, total, err := aiModelService.GetAiModelList(pageInfo, userID); err != nil { + global.GVA_LOG.Error("获取失败!", zap.Error(err)) + response.FailWithMessage("获取失败", c) + } else { + response.OkWithDetailed(response.PageResult{ + List: list, + Total: total, + Page: pageInfo.Page, + PageSize: pageInfo.PageSize, + }, "获取成功", c) + } +} + +// SyncProviderModels 同步提供商模型 +// @Tags AiModel +// @Summary 同步提供商模型 +// @Security ApiKeyAuth +// @accept application/json +// @Produce application/json +// @Param data body request.GetById true "提供商ID" +// @Success 200 {object} response.Response{msg=string} "同步成功" +// @Router /aiModel/syncProviderModels [post] +func (a *AiModelApi) SyncProviderModels(c *gin.Context) { + var reqId request.GetById + err := c.ShouldBindJSON(&reqId) + if err != nil { + response.FailWithMessage(err.Error(), c) + return + } + userID := utils.GetUserID(c) + + if err := aiModelService.SyncProviderModels(reqId.Uint(), userID); err != nil { + global.GVA_LOG.Error("同步失败!", zap.Error(err)) + response.FailWithMessage("同步失败: "+err.Error(), c) + } else { + response.OkWithMessage("同步成功", c) + } +} diff --git a/server/api/v1/app/ai_preset.go b/server/api/v1/app/ai_preset.go index 9c7d033..5f44fb2 100644 --- a/server/api/v1/app/ai_preset.go +++ b/server/api/v1/app/ai_preset.go @@ -1,6 +1,9 @@ package app import ( + "encoding/json" + "strings" + "git.echol.cn/loser/ai_proxy/server/global" "git.echol.cn/loser/ai_proxy/server/model/app" "git.echol.cn/loser/ai_proxy/server/model/common/request" @@ -150,7 +153,7 @@ func (a *AiPresetApi) GetAiPresetList(c *gin.Context) { } } -// ImportAiPreset 导入预设 +// ImportAiPreset 导入预设(JSON粘贴) // @Tags AiPreset // @Summary 导入预设(支持SillyTavern格式) // @Security ApiKeyAuth @@ -160,17 +163,75 @@ func (a *AiPresetApi) GetAiPresetList(c *gin.Context) { // @Success 200 {object} response.Response{msg=string} "导入成功" // @Router /aiPreset/importAiPreset [post] func (a *AiPresetApi) ImportAiPreset(c *gin.Context) { - var preset app.AiPreset - err := c.ShouldBindJSON(&preset) + var rawData map[string]interface{} + err := c.ShouldBindJSON(&rawData) if err != nil { response.FailWithMessage(err.Error(), c) return } + + preset, err := aiPresetService.ParseImportedPreset(rawData) + if err != nil { + response.FailWithMessage("解析预设失败:"+err.Error(), c) + return + } preset.UserID = utils.GetUserID(c) - if err := aiPresetService.CreateAiPreset(&preset); err != nil { + if err := aiPresetService.CreateAiPreset(preset); err != nil { global.GVA_LOG.Error("导入失败!", zap.Error(err)) - response.FailWithMessage("导入失败", c) + response.FailWithMessage("导入失败:"+err.Error(), c) + } else { + response.OkWithMessage("导入成功", c) + } +} + +// ImportAiPresetFile 导入预设文件 +// @Tags AiPreset +// @Summary 通过文件导入预设(支持SillyTavern格式) +// @Security ApiKeyAuth +// @accept multipart/form-data +// @Produce application/json +// @Param file formData file true "预设JSON文件" +// @Success 200 {object} response.Response{msg=string} "导入成功" +// @Router /aiPreset/importAiPresetFile [post] +func (a *AiPresetApi) ImportAiPresetFile(c *gin.Context) { + file, err := c.FormFile("file") + if err != nil { + response.FailWithMessage("文件上传失败:"+err.Error(), c) + return + } + + // 检查文件类型 + if file.Header.Get("Content-Type") != "application/json" && !strings.HasSuffix(file.Filename, ".json") { + response.FailWithMessage("只支持JSON文件", c) + return + } + + // 读取文件内容 + fileContent, err := file.Open() + if err != nil { + response.FailWithMessage("文件读取失败:"+err.Error(), c) + return + } + defer fileContent.Close() + + // 解析JSON + var rawData map[string]interface{} + if err := json.NewDecoder(fileContent).Decode(&rawData); err != nil { + response.FailWithMessage("JSON解析失败:"+err.Error(), c) + return + } + + preset, err := aiPresetService.ParseImportedPreset(rawData) + if err != nil { + response.FailWithMessage("解析预设失败:"+err.Error(), c) + return + } + preset.UserID = utils.GetUserID(c) + + if err := aiPresetService.CreateAiPreset(preset); err != nil { + global.GVA_LOG.Error("导入失败!", zap.Error(err)) + response.FailWithMessage("导入失败:"+err.Error(), c) } else { response.OkWithMessage("导入成功", c) } diff --git a/server/api/v1/app/ai_proxy.go b/server/api/v1/app/ai_proxy.go index daa0819..1ed6442 100644 --- a/server/api/v1/app/ai_proxy.go +++ b/server/api/v1/app/ai_proxy.go @@ -1,11 +1,13 @@ package app import ( + "net/http" + "git.echol.cn/loser/ai_proxy/server/global" + "git.echol.cn/loser/ai_proxy/server/model/app" "git.echol.cn/loser/ai_proxy/server/model/app/request" "git.echol.cn/loser/ai_proxy/server/model/common/response" "git.echol.cn/loser/ai_proxy/server/service" - "git.echol.cn/loser/ai_proxy/server/utils" "github.com/gin-gonic/gin" "go.uber.org/zap" ) @@ -13,11 +15,11 @@ import ( type AiProxyApi struct{} var aiProxyService = service.ServiceGroupApp.AppServiceGroup.AiProxyService +var aiApiKeyService = service.ServiceGroupApp.AppServiceGroup.AiApiKeyService // ChatCompletions OpenAI兼容的聊天补全接口 // @Tags AiProxy // @Summary 聊天补全(OpenAI兼容) -// @Security ApiKeyAuth // @accept application/json // @Produce application/json // @Param data body request.ChatCompletionRequest true "聊天请求" @@ -31,16 +33,46 @@ func (a *AiProxyApi) ChatCompletions(c *gin.Context) { return } - userId := utils.GetUserID(c) + // 获取 API Key 信息 + apiKeyInfo, exists := c.Get("ai_api_key") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "未找到API密钥信息"}) + return + } + apiKey := apiKeyInfo.(*app.AiApiKey) + + // 验证模型权限 + if req.Model != "" && !aiApiKeyService.CheckModelPermission(apiKey, req.Model) { + c.JSON(http.StatusForbidden, gin.H{ + "error": gin.H{ + "message": "该API密钥无权使用此模型: " + req.Model, + "type": "invalid_request_error", + "code": "model_not_allowed", + }, + }) + return + } + + // 验证预设权限 + if req.PresetName != "" && !aiApiKeyService.CheckPresetPermission(apiKey, req.PresetName) { + c.JSON(http.StatusForbidden, gin.H{ + "error": gin.H{ + "message": "该API密钥无权使用此预设: " + req.PresetName, + "type": "invalid_request_error", + "code": "preset_not_allowed", + }, + }) + return + } // 处理流式响应 if req.Stream { - aiProxyService.ProcessChatCompletionStream(c, userId, &req) + aiProxyService.ProcessChatCompletionStream(c, &req) return } // 处理普通响应 - resp, err := aiProxyService.ProcessChatCompletion(c.Request.Context(), userId, &req) + resp, err := aiProxyService.ProcessChatCompletion(c.Request.Context(), &req) if err != nil { global.GVA_LOG.Error("处理聊天请求失败!", zap.Error(err)) response.FailWithMessage(err.Error(), c) @@ -49,3 +81,95 @@ func (a *AiProxyApi) ChatCompletions(c *gin.Context) { c.JSON(200, resp) } + +// ListModels 获取可用模型列表 +// @Tags AiProxy +// @Summary 获取可用模型列表(OpenAI兼容) +// @accept application/json +// @Produce application/json +// @Success 200 {object} response.ModelListResponse "模型列表" +// @Router /v1/models [get] +func (a *AiProxyApi) ListModels(c *gin.Context) { + // 获取 API Key 信息 + apiKeyInfo, exists := c.Get("ai_api_key") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "未找到API密钥信息"}) + return + } + apiKey := apiKeyInfo.(*app.AiApiKey) + + // 获取可用模型列表 + models, err := aiProxyService.GetAvailableModels(apiKey) + if err != nil { + global.GVA_LOG.Error("获取模型列表失败!", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(200, models) +} + +// ClaudeMessages Claude API兼容的消息接口 +// @Tags AiProxy +// @Summary 消息接口(Claude兼容) +// @accept application/json +// @Produce application/json +// @Param data body request.ClaudeMessageRequest true "消息请求" +// @Success 200 {object} response.ClaudeMessageResponse "消息响应" +// @Router /v1/messages [post] +func (a *AiProxyApi) ClaudeMessages(c *gin.Context) { + var req request.ClaudeMessageRequest + err := c.ShouldBindJSON(&req) + if err != nil { + response.FailWithMessage(err.Error(), c) + return + } + + // 获取 API Key 信息 + apiKeyInfo, exists := c.Get("ai_api_key") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "未找到API密钥信息"}) + return + } + apiKey := apiKeyInfo.(*app.AiApiKey) + + // 验证模型权限 + if req.Model != "" && !aiApiKeyService.CheckModelPermission(apiKey, req.Model) { + c.JSON(http.StatusForbidden, gin.H{ + "error": gin.H{ + "message": "该API密钥无权使用此模型: " + req.Model, + "type": "invalid_request_error", + "code": "model_not_allowed", + }, + }) + return + } + + // 验证预设权限 + if req.PresetName != "" && !aiApiKeyService.CheckPresetPermission(apiKey, req.PresetName) { + c.JSON(http.StatusForbidden, gin.H{ + "error": gin.H{ + "message": "该API密钥无权使用此预设: " + req.PresetName, + "type": "invalid_request_error", + "code": "preset_not_allowed", + }, + }) + return + } + + // 处理流式响应 + if req.Stream { + aiProxyService.ProcessClaudeMessageStream(c, &req) + return + } + + // 处理普通响应 + resp, err := aiProxyService.ProcessClaudeMessage(c.Request.Context(), &req) + if err != nil { + global.GVA_LOG.Error("处理Claude消息请求失败!", zap.Error(err)) + response.FailWithMessage(err.Error(), c) + return + } + + c.JSON(200, resp) +} diff --git a/server/api/v1/app/enter.go b/server/api/v1/app/enter.go index e65bdd7..3b51ad3 100644 --- a/server/api/v1/app/enter.go +++ b/server/api/v1/app/enter.go @@ -4,5 +4,6 @@ type ApiGroup struct { AiProxyApi AiPresetApi AiProviderApi - AiPresetBindingApi + AiApiKeyApi + AiModelApi } diff --git a/server/initialize/router.go b/server/initialize/router.go index afd1130..ef0a52f 100644 --- a/server/initialize/router.go +++ b/server/initialize/router.go @@ -57,15 +57,9 @@ func Routers() *gin.Engine { systemRouter := router.RouterGroupApp.System exampleRouter := router.RouterGroupApp.Example - // 如果想要不使用nginx代理前端网页,可以修改 web/.env.production 下的 - // VUE_APP_BASE_API = / - // VUE_APP_BASE_PATH = http://localhost - // 然后执行打包命令 npm run build。在打开下面3行注释 - // Router.StaticFile("/favicon.ico", "./dist/favicon.ico") - // Router.Static("/assets", "./dist/assets") // dist里面的静态资源 - // Router.StaticFile("/", "./dist/index.html") // 前端网页入口页面 - Router.StaticFS(global.GVA_CONFIG.Local.StorePath, justFilesFilesystem{http.Dir(global.GVA_CONFIG.Local.StorePath)}) // Router.Use(middleware.LoadTls()) // 如果需要使用https 请打开此中间件 然后前往 core/server.go 将启动模式 更变为 Router.RunTLS("端口","你的cre/pem文件","你的key文件") + // 静态文件服务 + Router.StaticFS(global.GVA_CONFIG.Local.StorePath, justFilesFilesystem{http.Dir(global.GVA_CONFIG.Local.StorePath)}) // 跨域,如需跨域可以打开下面的注释 // Router.Use(middleware.Cors()) // 直接放行全部跨域请求 // Router.Use(middleware.CorsByRules()) // 按照配置的规则放行跨域请求 @@ -124,8 +118,41 @@ func Routers() *gin.Engine { // 注册业务路由 initBizRouter(PrivateGroup, PublicGroup) + // 前端静态文件服务(放在最后,作为兜底路由) + setupFrontendRoutes(Router) + global.GVA_ROUTERS = Router.Routes() global.GVA_LOG.Info("router register success") return Router } + +// setupFrontendRoutes 配置前端静态文件路由 +func setupFrontendRoutes(router *gin.Engine) { + // 检查 dist 目录是否存在 + if _, err := os.Stat("./dist"); os.IsNotExist(err) { + global.GVA_LOG.Warn("前端 dist 目录不存在,跳过前端路由配置") + return + } + + // 静态资源目录 + router.Static("/assets", "./dist/assets") + router.StaticFile("/favicon.ico", "./dist/favicon.ico") + + // SPA 路由处理:所有非 API 请求都返回 index.html + router.NoRoute(func(c *gin.Context) { + path := c.Request.URL.Path + // 如果是 API 请求,返回 404 + if strings.HasPrefix(path, global.GVA_CONFIG.System.RouterPrefix) { + c.JSON(http.StatusNotFound, gin.H{ + "code": 404, + "msg": "接口不存在", + }) + return + } + // 其他请求返回前端页面 + c.File("./dist/index.html") + }) + + global.GVA_LOG.Info("前端静态文件路由配置成功") +} diff --git a/server/initialize/router_biz.go b/server/initialize/router_biz.go index 3be2dcd..540524c 100644 --- a/server/initialize/router_biz.go +++ b/server/initialize/router_biz.go @@ -19,8 +19,9 @@ func initBizRouter(routers ...*gin.RouterGroup) { // 注册 AI 代理路由 appRouter := router.RouterGroupApp.App - appRouter.InitAiProxyRouter(publicGroup) // AI 代理接口(公开) - appRouter.InitAiPresetRouter(privateGroup) // 预设管理(需要登录) - appRouter.InitAiProviderRouter(privateGroup) // 提供商管理(需要登录) - appRouter.InitAiPresetBindingRouter(privateGroup) // 绑定管理(需要登录) + appRouter.InitAiProxyRouter(publicGroup) // AI 代理接口(公开) + appRouter.InitAiPresetRouter(privateGroup) // 预设管理(需要登录) + appRouter.InitAiProviderRouter(privateGroup) // 提供商管理(需要登录) + appRouter.InitAiApiKeyRouter(privateGroup) // API密钥管理(需要登录) + appRouter.InitAiModelRouter(privateGroup) // 模型管理(需要登录) } diff --git a/server/middleware/ai_auth.go b/server/middleware/ai_auth.go new file mode 100644 index 0000000..caf6df0 --- /dev/null +++ b/server/middleware/ai_auth.go @@ -0,0 +1,52 @@ +package middleware + +import ( + "net/http" + "strings" + + "git.echol.cn/loser/ai_proxy/server/service" + "github.com/gin-gonic/gin" +) + +var aiApiKeyService = service.ServiceGroupApp.AppServiceGroup.AiApiKeyService + +// AiAuth AI接口鉴权中间件 +func AiAuth() gin.HandlerFunc { + return func(c *gin.Context) { + // 从请求头获取 Authorization + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{ + "error": gin.H{ + "message": "未提供API密钥", + "type": "invalid_request_error", + "code": "missing_api_key", + }, + }) + c.Abort() + return + } + + // 提取 API Key (支持 "Bearer sk-xxx" 和 "sk-xxx" 两种格式) + apiKey := strings.TrimPrefix(authHeader, "Bearer ") + apiKey = strings.TrimSpace(apiKey) + + // 验证 API Key + keyInfo, err := aiApiKeyService.ValidateApiKey(apiKey) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "error": gin.H{ + "message": err.Error(), + "type": "invalid_request_error", + "code": "invalid_api_key", + }, + }) + c.Abort() + return + } + + // 将密钥信息存入上下文 + c.Set("ai_api_key", keyInfo) + c.Next() + } +} diff --git a/server/model/app/ai_api_key.go b/server/model/app/ai_api_key.go new file mode 100644 index 0000000..0b1e0dd --- /dev/null +++ b/server/model/app/ai_api_key.go @@ -0,0 +1,22 @@ +package app + +import ( + "git.echol.cn/loser/ai_proxy/server/global" +) + +// AiApiKey AI API密钥管理 +type AiApiKey struct { + global.GVA_MODEL + Name string `json:"name" gorm:"type:varchar(100);not null;comment:密钥名称"` + Key string `json:"key" gorm:"type:varchar(255);not null;uniqueIndex;comment:API密钥"` + AllowedModels []string `json:"allowed_models" gorm:"type:json;serializer:json;comment:允许使用的模型列表"` + AllowedPresets []string `json:"allowed_presets" gorm:"type:json;serializer:json;comment:允许使用的预设列表"` + RateLimit int `json:"rate_limit" gorm:"default:0;comment:速率限制(每分钟请求数,0表示不限制)"` + ExpiresAt *int64 `json:"expires_at" gorm:"comment:过期时间(Unix时间戳)"` + Enabled bool `json:"enabled" gorm:"default:true;comment:是否启用"` + UserID uint `json:"user_id" gorm:"index;comment:用户ID"` +} + +func (AiApiKey) TableName() string { + return "ai_api_keys" +} diff --git a/server/model/app/ai_model.go b/server/model/app/ai_model.go new file mode 100644 index 0000000..bbbe965 --- /dev/null +++ b/server/model/app/ai_model.go @@ -0,0 +1,24 @@ +package app + +import ( + "git.echol.cn/loser/ai_proxy/server/global" +) + +// AiModel AI模型配置 +type AiModel struct { + global.GVA_MODEL + Name string `json:"name" gorm:"type:varchar(100);not null;comment:模型名称"` + DisplayName string `json:"display_name" gorm:"type:varchar(100);comment:显示名称"` + ProviderID uint `json:"provider_id" gorm:"not null;index;comment:提供商ID"` + Provider AiProvider `json:"provider" gorm:"foreignKey:ProviderID"` + PresetID *uint `json:"preset_id" gorm:"index;comment:绑定的预设ID"` + Preset *AiPreset `json:"preset,omitempty" gorm:"foreignKey:PresetID"` + Enabled bool `json:"enabled" gorm:"default:true;comment:是否启用"` + MaxTokens int `json:"max_tokens" gorm:"default:4096;comment:最大token数"` + Description string `json:"description" gorm:"type:text;comment:模型描述"` + UserID uint `json:"user_id" gorm:"index;comment:用户ID"` +} + +func (AiModel) TableName() string { + return "ai_models" +} diff --git a/server/model/app/ai_preset.go b/server/model/app/ai_preset.go index 4d06bd4..4ea1d0f 100644 --- a/server/model/app/ai_preset.go +++ b/server/model/app/ai_preset.go @@ -15,10 +15,10 @@ type AiPreset struct { TopP float64 `json:"top_p" gorm:"type:decimal(3,2);default:0.9;comment:Top P"` TopK int `json:"top_k" gorm:"type:int;default:0;comment:Top K"` MaxTokens int `json:"max_tokens" gorm:"type:int;default:4096;comment:最大token数"` - Prompts PresetPrompts `json:"prompts" gorm:"type:json;comment:提示词列表"` - PromptOrder []PromptOrder `json:"prompt_order" gorm:"type:json;comment:提示词顺序"` - RegexScripts []RegexScript `json:"regex_scripts" gorm:"type:json;comment:正则脚本"` - Extensions PresetExtensions `json:"extensions" gorm:"type:json;comment:扩展配置"` + Prompts PresetPrompts `json:"prompts" gorm:"type:json;serializer:json;comment:提示词列表"` + PromptOrder []PromptOrder `json:"prompt_order" gorm:"type:json;serializer:json;comment:提示词顺序"` + RegexScripts []RegexScript `json:"regex_scripts" gorm:"type:json;serializer:json;comment:正则脚本"` + Extensions PresetExtensions `json:"extensions" gorm:"type:json;serializer:json;comment:扩展配置"` Enabled bool `json:"enabled" gorm:"default:true;comment:是否启用"` UserID uint `json:"user_id" gorm:"index;comment:用户ID"` } diff --git a/server/model/app/ai_preset_binding.go b/server/model/app/ai_preset_binding.go deleted file mode 100644 index 16098fb..0000000 --- a/server/model/app/ai_preset_binding.go +++ /dev/null @@ -1,23 +0,0 @@ -package app - -import ( - "git.echol.cn/loser/ai_proxy/server/global" -) - -// AiPresetBinding 预设绑定关系 -type AiPresetBinding struct { - global.GVA_MODEL - Name string `json:"name" gorm:"type:varchar(100);not null;uniqueIndex:idx_user_binding;comment:绑定名称"` - PresetID uint `json:"preset_id" gorm:"not null;index;comment:预设ID"` - ProviderID uint `json:"provider_id" gorm:"not null;index;comment:提供商ID"` - Enabled bool `json:"enabled" gorm:"default:true;comment:是否启用"` - UserID uint `json:"user_id" gorm:"index:idx_user_binding;comment:用户ID"` - - // 关联 - Preset AiPreset `json:"preset" gorm:"foreignKey:PresetID"` - Provider AiProvider `json:"provider" gorm:"foreignKey:ProviderID"` -} - -func (AiPresetBinding) TableName() string { - return "ai_preset_bindings" -} diff --git a/server/model/app/ai_provider.go b/server/model/app/ai_provider.go index 8583e31..588b7fc 100644 --- a/server/model/app/ai_provider.go +++ b/server/model/app/ai_provider.go @@ -13,6 +13,7 @@ type AiProvider struct { APIKey string `json:"api_key" gorm:"type:varchar(255);not null;comment:API密钥"` Model string `json:"model" gorm:"type:varchar(100);comment:默认模型"` Enabled bool `json:"enabled" gorm:"default:true;comment:是否启用"` + IsDefault bool `json:"is_default" gorm:"default:false;comment:是否为默认提供商"` Priority int `json:"priority" gorm:"default:0;comment:优先级"` MaxRetries int `json:"max_retries" gorm:"default:3;comment:最大重试次数"` Timeout int `json:"timeout" gorm:"default:60;comment:超时时间(秒)"` diff --git a/server/model/app/enter.go b/server/model/app/enter.go index 1796285..1e1c70f 100644 --- a/server/model/app/enter.go +++ b/server/model/app/enter.go @@ -3,6 +3,7 @@ package app var AutoMigrateTables = []interface{}{ &AiPreset{}, &AiProvider{}, - &AiPresetBinding{}, &AiRequestLog{}, + &AiApiKey{}, + &AiModel{}, } diff --git a/server/model/app/request/ai_claude.go b/server/model/app/request/ai_claude.go new file mode 100644 index 0000000..41a48d7 --- /dev/null +++ b/server/model/app/request/ai_claude.go @@ -0,0 +1,36 @@ +package request + +// ClaudeMessageRequest Claude API 兼容的消息请求 +type ClaudeMessageRequest struct { + Model string `json:"model"` + Messages []ClaudeMessage `json:"messages"` + MaxTokens int `json:"max_tokens"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + TopK *int `json:"top_k,omitempty"` + Stream bool `json:"stream,omitempty"` + StopSequences []string `json:"stop_sequences,omitempty"` + System string `json:"system,omitempty"` + + // 扩展字段 - 用于指定预设和提供商 + PresetName string `json:"preset_name,omitempty"` // 预设名称 + ProviderName string `json:"provider_name,omitempty"` // 提供商名称 + BindingName string `json:"binding_name,omitempty"` // 绑定名称(优先级最高) +} + +type ClaudeMessage struct { + Role string `json:"role"` // user, assistant + Content interface{} `json:"content"` // string 或 []ClaudeContentBlock +} + +type ClaudeContentBlock struct { + Type string `json:"type"` // text, image + Text string `json:"text,omitempty"` + Source *ClaudeImageSource `json:"source,omitempty"` +} + +type ClaudeImageSource struct { + Type string `json:"type"` // base64 + MediaType string `json:"media_type"` // image/jpeg, image/png, image/gif, image/webp + Data string `json:"data"` // base64 编码的图片数据 +} diff --git a/server/model/app/response/ai_claude.go b/server/model/app/response/ai_claude.go new file mode 100644 index 0000000..443db4c --- /dev/null +++ b/server/model/app/response/ai_claude.go @@ -0,0 +1,38 @@ +package response + +// ClaudeMessageResponse Claude API 兼容的消息响应 +type ClaudeMessageResponse struct { + ID string `json:"id"` + Type string `json:"type"` // message + Role string `json:"role"` // assistant + Content []ClaudeContentBlock `json:"content"` + Model string `json:"model"` + StopReason string `json:"stop_reason,omitempty"` // end_turn, max_tokens, stop_sequence + StopSequence string `json:"stop_sequence,omitempty"` + Usage ClaudeUsage `json:"usage"` +} + +type ClaudeContentBlock struct { + Type string `json:"type"` // text + Text string `json:"text"` +} + +type ClaudeUsage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` +} + +// ClaudeStreamResponse Claude 流式响应 +type ClaudeStreamResponse struct { + Type string `json:"type"` // message_start, content_block_start, content_block_delta, content_block_stop, message_delta, message_stop + Message *ClaudeMessageResponse `json:"message,omitempty"` + Index int `json:"index,omitempty"` + ContentBlock *ClaudeContentBlock `json:"content_block,omitempty"` + Delta *ClaudeContentBlockDelta `json:"delta,omitempty"` + Usage *ClaudeUsage `json:"usage,omitempty"` +} + +type ClaudeContentBlockDelta struct { + Type string `json:"type"` // text_delta + Text string `json:"text"` +} diff --git a/server/model/app/response/ai_models.go b/server/model/app/response/ai_models.go new file mode 100644 index 0000000..ac12f19 --- /dev/null +++ b/server/model/app/response/ai_models.go @@ -0,0 +1,15 @@ +package response + +// ModelListResponse OpenAI 兼容的模型列表响应 +type ModelListResponse struct { + Object string `json:"object"` // "list" + Data []ModelInfo `json:"data"` +} + +// ModelInfo 模型信息 +type ModelInfo struct { + ID string `json:"id"` + Object string `json:"object"` // "model" + Created int64 `json:"created"` + OwnedBy string `json:"owned_by"` +} diff --git a/server/router/app/ai_api_key.go b/server/router/app/ai_api_key.go new file mode 100644 index 0000000..abbcc18 --- /dev/null +++ b/server/router/app/ai_api_key.go @@ -0,0 +1,20 @@ +package app + +import ( + v1 "git.echol.cn/loser/ai_proxy/server/api/v1" + "github.com/gin-gonic/gin" +) + +type AiApiKeyRouter struct{} + +func (s *AiApiKeyRouter) InitAiApiKeyRouter(Router *gin.RouterGroup) { + aiApiKeyRouter := Router.Group("aiApiKey") + aiApiKeyApi := v1.ApiGroupApp.AppApiGroup.AiApiKeyApi + { + aiApiKeyRouter.POST("createAiApiKey", aiApiKeyApi.CreateAiApiKey) + aiApiKeyRouter.DELETE("deleteAiApiKey", aiApiKeyApi.DeleteAiApiKey) + aiApiKeyRouter.PUT("updateAiApiKey", aiApiKeyApi.UpdateAiApiKey) + aiApiKeyRouter.GET("findAiApiKey", aiApiKeyApi.FindAiApiKey) + aiApiKeyRouter.GET("getAiApiKeyList", aiApiKeyApi.GetAiApiKeyList) + } +} diff --git a/server/router/app/ai_model.go b/server/router/app/ai_model.go new file mode 100644 index 0000000..3e34f8a --- /dev/null +++ b/server/router/app/ai_model.go @@ -0,0 +1,21 @@ +package app + +import ( + v1 "git.echol.cn/loser/ai_proxy/server/api/v1" + "github.com/gin-gonic/gin" +) + +type AiModelRouter struct{} + +func (s *AiModelRouter) InitAiModelRouter(Router *gin.RouterGroup) { + aiModelRouter := Router.Group("aiModel") + aiModelApi := v1.ApiGroupApp.AppApiGroup.AiModelApi + { + aiModelRouter.POST("createAiModel", aiModelApi.CreateAiModel) + aiModelRouter.DELETE("deleteAiModel", aiModelApi.DeleteAiModel) + aiModelRouter.PUT("updateAiModel", aiModelApi.UpdateAiModel) + aiModelRouter.GET("findAiModel", aiModelApi.FindAiModel) + aiModelRouter.GET("getAiModelList", aiModelApi.GetAiModelList) + aiModelRouter.POST("syncProviderModels", aiModelApi.SyncProviderModels) + } +} diff --git a/server/router/app/ai_preset.go b/server/router/app/ai_preset.go index d7edfba..8dee507 100644 --- a/server/router/app/ai_preset.go +++ b/server/router/app/ai_preset.go @@ -16,6 +16,7 @@ func (s *AiPresetRouter) InitAiPresetRouter(Router *gin.RouterGroup) { aiPresetRouter.PUT("updateAiPreset", aiPresetApi.UpdateAiPreset) aiPresetRouter.GET("findAiPreset", aiPresetApi.FindAiPreset) aiPresetRouter.GET("getAiPresetList", aiPresetApi.GetAiPresetList) - aiPresetRouter.POST("importAiPreset", aiPresetApi.ImportAiPreset) + aiPresetRouter.POST("importAiPreset", aiPresetApi.ImportAiPreset) // JSON粘贴导入 + aiPresetRouter.POST("importAiPresetFile", aiPresetApi.ImportAiPresetFile) // 文件上传导入 } } diff --git a/server/router/app/ai_preset_binding.go b/server/router/app/ai_preset_binding.go deleted file mode 100644 index fbc8e2c..0000000 --- a/server/router/app/ai_preset_binding.go +++ /dev/null @@ -1,20 +0,0 @@ -package app - -import ( - v1 "git.echol.cn/loser/ai_proxy/server/api/v1" - "github.com/gin-gonic/gin" -) - -type AiPresetBindingRouter struct{} - -func (s *AiPresetBindingRouter) InitAiPresetBindingRouter(Router *gin.RouterGroup) { - aiPresetBindingRouter := Router.Group("aiPresetBinding") - aiPresetBindingApi := v1.ApiGroupApp.AppApiGroup.AiPresetBindingApi - { - aiPresetBindingRouter.POST("createAiPresetBinding", aiPresetBindingApi.CreateAiPresetBinding) - aiPresetBindingRouter.DELETE("deleteAiPresetBinding", aiPresetBindingApi.DeleteAiPresetBinding) - aiPresetBindingRouter.PUT("updateAiPresetBinding", aiPresetBindingApi.UpdateAiPresetBinding) - aiPresetBindingRouter.GET("findAiPresetBinding", aiPresetBindingApi.FindAiPresetBinding) - aiPresetBindingRouter.GET("getAiPresetBindingList", aiPresetBindingApi.GetAiPresetBindingList) - } -} diff --git a/server/router/app/ai_proxy.go b/server/router/app/ai_proxy.go index 78999db..481effd 100644 --- a/server/router/app/ai_proxy.go +++ b/server/router/app/ai_proxy.go @@ -2,15 +2,18 @@ package app import ( v1 "git.echol.cn/loser/ai_proxy/server/api/v1" + "git.echol.cn/loser/ai_proxy/server/middleware" "github.com/gin-gonic/gin" ) type AiProxyRouter struct{} func (s *AiProxyRouter) InitAiProxyRouter(Router *gin.RouterGroup) { - aiProxyRouter := Router.Group("v1") + aiProxyRouter := Router.Group("v1").Use(middleware.AiAuth()) aiProxyApi := v1.ApiGroupApp.AppApiGroup.AiProxyApi { - aiProxyRouter.POST("chat/completions", aiProxyApi.ChatCompletions) + aiProxyRouter.GET("models", aiProxyApi.ListModels) // 获取模型列表 + aiProxyRouter.POST("chat/completions", aiProxyApi.ChatCompletions) // OpenAI 兼容 + aiProxyRouter.POST("messages", aiProxyApi.ClaudeMessages) // Claude 兼容 } } diff --git a/server/router/app/enter.go b/server/router/app/enter.go index 4de0e0d..e9312b0 100644 --- a/server/router/app/enter.go +++ b/server/router/app/enter.go @@ -4,5 +4,6 @@ type RouterGroup struct { AiProxyRouter AiPresetRouter AiProviderRouter - AiPresetBindingRouter + AiApiKeyRouter + AiModelRouter } diff --git a/server/service/app/ai_api_key.go b/server/service/app/ai_api_key.go new file mode 100644 index 0000000..8845624 --- /dev/null +++ b/server/service/app/ai_api_key.go @@ -0,0 +1,107 @@ +package app + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "time" + + "git.echol.cn/loser/ai_proxy/server/global" + "git.echol.cn/loser/ai_proxy/server/model/app" + "git.echol.cn/loser/ai_proxy/server/model/common/request" +) + +type AiApiKeyService struct{} + +// GenerateApiKey 生成API密钥 +func (s *AiApiKeyService) GenerateApiKey() string { + bytes := make([]byte, 32) + rand.Read(bytes) + return "sk-" + hex.EncodeToString(bytes) +} + +// CreateAiApiKey 创建API密钥 +func (s *AiApiKeyService) CreateAiApiKey(apiKey *app.AiApiKey) error { + if apiKey.Key == "" { + apiKey.Key = s.GenerateApiKey() + } + return global.GVA_DB.Create(apiKey).Error +} + +// DeleteAiApiKey 删除API密钥 +func (s *AiApiKeyService) DeleteAiApiKey(id uint, userID uint) error { + return global.GVA_DB.Where("id = ? AND user_id = ?", id, userID).Delete(&app.AiApiKey{}).Error +} + +// UpdateAiApiKey 更新API密钥 +func (s *AiApiKeyService) UpdateAiApiKey(apiKey *app.AiApiKey, userID uint) error { + return global.GVA_DB.Where("user_id = ?", userID).Updates(apiKey).Error +} + +// GetAiApiKey 查询API密钥 +func (s *AiApiKeyService) GetAiApiKey(id uint, userID uint) (apiKey app.AiApiKey, err error) { + err = global.GVA_DB.Where("id = ? AND user_id = ?", id, userID).First(&apiKey).Error + return +} + +// GetAiApiKeyList 获取API密钥列表 +func (s *AiApiKeyService) GetAiApiKeyList(info request.PageInfo, userID uint) (list []app.AiApiKey, total int64, err error) { + limit := info.PageSize + offset := info.PageSize * (info.Page - 1) + db := global.GVA_DB.Model(&app.AiApiKey{}).Where("user_id = ?", userID) + err = db.Count(&total).Error + if err != nil { + return + } + err = db.Limit(limit).Offset(offset).Order("id desc").Find(&list).Error + return +} + +// ValidateApiKey 验证API密钥 +func (s *AiApiKeyService) ValidateApiKey(key string) (*app.AiApiKey, error) { + var apiKey app.AiApiKey + if err := global.GVA_DB.Where("key = ? AND enabled = ?", key, true).First(&apiKey).Error; err != nil { + return nil, fmt.Errorf("无效的API密钥") + } + + // 检查是否过期 + if apiKey.ExpiresAt != nil && time.Now().Unix() > *apiKey.ExpiresAt { + return nil, fmt.Errorf("API密钥已过期") + } + + return &apiKey, nil +} + +// CheckModelPermission 检查模型权限 +func (s *AiApiKeyService) CheckModelPermission(apiKey *app.AiApiKey, model string) bool { + // 如果没有限制,允许所有模型 + if len(apiKey.AllowedModels) == 0 { + return true + } + + // 检查模型是否在允许列表中 + for _, allowedModel := range apiKey.AllowedModels { + if allowedModel == model || allowedModel == "*" { + return true + } + } + + return false +} + +// CheckPresetPermission 检查预设权限 +func (s *AiApiKeyService) CheckPresetPermission(apiKey *app.AiApiKey, presetName string) bool { + // 如果没有限制,允许所有预设 + if len(apiKey.AllowedPresets) == 0 { + return true + } + + // 检查预设是否在允许列表中 + for _, allowedPreset := range apiKey.AllowedPresets { + if allowedPreset == presetName || allowedPreset == "*" { + return true + } + } + + return false +} diff --git a/server/service/app/ai_claude.go b/server/service/app/ai_claude.go new file mode 100644 index 0000000..d0e0763 --- /dev/null +++ b/server/service/app/ai_claude.go @@ -0,0 +1,200 @@ +package app + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "git.echol.cn/loser/ai_proxy/server/global" + "git.echol.cn/loser/ai_proxy/server/model/app" + "git.echol.cn/loser/ai_proxy/server/model/app/request" + "git.echol.cn/loser/ai_proxy/server/model/app/response" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// ProcessClaudeMessage 处理 Claude 消息请求 +func (s *AiProxyService) ProcessClaudeMessage(ctx context.Context, req *request.ClaudeMessageRequest) (*response.ClaudeMessageResponse, error) { + // 1. 根据模型获取配置 + if req.Model == "" { + return nil, fmt.Errorf("model 参数不能为空") + } + + preset, provider, err := s.getConfigByModel(req.Model) + if err != nil { + return nil, err + } + + // 2. 注入预设 + if preset != nil { + injector := NewPresetInjector(preset) + req.Messages = s.convertClaudeMessages(injector.InjectMessages(s.convertToOpenAIMessages(req.Messages))) + } + + // 3. 转发请求到上游 + resp, err := s.forwardClaudeRequest(ctx, provider, req) + if err != nil { + return nil, err + } + + // 4. 处理响应 + if preset != nil && len(resp.Content) > 0 { + injector := NewPresetInjector(preset) + resp.Content[0].Text = injector.ProcessResponse(resp.Content[0].Text) + } + + return resp, nil +} + +// ProcessClaudeMessageStream 处理 Claude 流式消息请求 +func (s *AiProxyService) ProcessClaudeMessageStream(c *gin.Context, req *request.ClaudeMessageRequest) { + // 1. 根据模型获取配置 + if req.Model == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "model 参数不能为空"}) + return + } + + preset, provider, err := s.getConfigByModel(req.Model) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // 2. 注入预设 + var injector *PresetInjector + if preset != nil { + injector = NewPresetInjector(preset) + req.Messages = s.convertClaudeMessages(injector.InjectMessages(s.convertToOpenAIMessages(req.Messages))) + } + + // 3. 设置 SSE 响应头 + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + + // 4. 转发流式请求 + err = s.forwardClaudeStreamRequest(c, provider, req, injector) + if err != nil { + global.GVA_LOG.Error("Claude流式请求失败", zap.Error(err)) + } +} + +// forwardClaudeRequest 转发 Claude 请求 +func (s *AiProxyService) forwardClaudeRequest(ctx context.Context, provider *app.AiProvider, req *request.ClaudeMessageRequest) (*response.ClaudeMessageResponse, error) { + if req.Model == "" && provider.Model != "" { + req.Model = provider.Model + } + + reqBody, _ := json.Marshal(req) + url := strings.TrimRight(provider.BaseURL, "/") + "/v1/messages" + httpReq, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(reqBody)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("x-api-key", provider.APIKey) + httpReq.Header.Set("anthropic-version", "2023-06-01") + + client := &http.Client{Timeout: time.Duration(provider.Timeout) * time.Second} + httpResp, err := client.Do(httpReq) + if err != nil { + return nil, err + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(httpResp.Body) + return nil, fmt.Errorf("上游返回错误: %d - %s", httpResp.StatusCode, string(body)) + } + + var resp response.ClaudeMessageResponse + json.NewDecoder(httpResp.Body).Decode(&resp) + return &resp, nil +} + +// forwardClaudeStreamRequest 转发 Claude 流式请求 +func (s *AiProxyService) forwardClaudeStreamRequest(c *gin.Context, provider *app.AiProvider, req *request.ClaudeMessageRequest, injector *PresetInjector) error { + if req.Model == "" && provider.Model != "" { + req.Model = provider.Model + } + + reqBody, _ := json.Marshal(req) + url := strings.TrimRight(provider.BaseURL, "/") + "/v1/messages" + httpReq, _ := http.NewRequestWithContext(c.Request.Context(), "POST", url, bytes.NewReader(reqBody)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("x-api-key", provider.APIKey) + httpReq.Header.Set("anthropic-version", "2023-06-01") + + client := &http.Client{Timeout: time.Duration(provider.Timeout) * time.Second} + httpResp, err := client.Do(httpReq) + if err != nil { + return err + } + defer httpResp.Body.Close() + + reader := bufio.NewReader(httpResp.Body) + flusher, _ := c.Writer.(http.Flusher) + + for { + line, err := reader.ReadBytes('\n') + if err == io.EOF { + break + } + if len(bytes.TrimSpace(line)) == 0 { + continue + } + + if bytes.HasPrefix(line, []byte("data: ")) { + data := bytes.TrimPrefix(line, []byte("data: ")) + var chunk response.ClaudeStreamResponse + if json.Unmarshal(data, &chunk) == nil && chunk.Delta != nil { + if injector != nil { + chunk.Delta.Text = injector.ProcessResponse(chunk.Delta.Text) + } + processedData, _ := json.Marshal(chunk) + c.Writer.Write([]byte("data: ")) + c.Writer.Write(processedData) + c.Writer.Write([]byte("\n\n")) + flusher.Flush() + } + } + } + return nil +} + +// convertToOpenAIMessages 转换 Claude 消息为 OpenAI 格式 +func (s *AiProxyService) convertToOpenAIMessages(messages []request.ClaudeMessage) []request.ChatMessage { + result := make([]request.ChatMessage, len(messages)) + for i, msg := range messages { + content := "" + // 处理字符串类型的 content + if str, ok := msg.Content.(string); ok { + content = str + } else if blocks, ok := msg.Content.([]interface{}); ok { + // 处理对象数组类型的 content (Claude API 标准格式) + for _, block := range blocks { + if blockMap, ok := block.(map[string]interface{}); ok { + if blockMap["type"] == "text" { + if text, ok := blockMap["text"].(string); ok { + content += text + } + } + } + } + } + result[i] = request.ChatMessage{Role: msg.Role, Content: content} + } + return result +} + +// convertClaudeMessages 转换 OpenAI 消息为 Claude 格式 +func (s *AiProxyService) convertClaudeMessages(messages []request.ChatMessage) []request.ClaudeMessage { + result := make([]request.ClaudeMessage, len(messages)) + for i, msg := range messages { + result[i] = request.ClaudeMessage{Role: msg.Role, Content: msg.Content} + } + return result +} diff --git a/server/service/app/ai_model.go b/server/service/app/ai_model.go new file mode 100644 index 0000000..aff43a8 --- /dev/null +++ b/server/service/app/ai_model.go @@ -0,0 +1,148 @@ +package app + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "git.echol.cn/loser/ai_proxy/server/global" + "git.echol.cn/loser/ai_proxy/server/model/app" + "git.echol.cn/loser/ai_proxy/server/model/common/request" +) + +type AiModelService struct{} + +// CreateAiModel 创建模型 +func (s *AiModelService) CreateAiModel(model *app.AiModel) error { + return global.GVA_DB.Create(model).Error +} + +// DeleteAiModel 删除模型 +func (s *AiModelService) DeleteAiModel(id uint, userID uint) error { + return global.GVA_DB.Where("id = ? AND user_id = ?", id, userID).Delete(&app.AiModel{}).Error +} + +// UpdateAiModel 更新模型 +func (s *AiModelService) UpdateAiModel(model *app.AiModel, userID uint) error { + return global.GVA_DB.Where("user_id = ?", userID).Updates(model).Error +} + +// GetAiModel 查询模型 +func (s *AiModelService) GetAiModel(id uint, userID uint) (model app.AiModel, err error) { + err = global.GVA_DB.Preload("Provider").Preload("Preset").Where("id = ? AND user_id = ?", id, userID).First(&model).Error + return +} + +// GetAiModelList 获取模型列表 +func (s *AiModelService) GetAiModelList(info request.PageInfo, userID uint) (list []app.AiModel, total int64, err error) { + limit := info.PageSize + offset := info.PageSize * (info.Page - 1) + db := global.GVA_DB.Model(&app.AiModel{}).Preload("Provider").Preload("Preset").Where("user_id = ?", userID) + err = db.Count(&total).Error + if err != nil { + return + } + err = db.Limit(limit).Offset(offset).Order("id desc").Find(&list).Error + return +} + +// GetModelByNameAndProvider 根据模型名称和提供商ID查询模型配置 +func (s *AiModelService) GetModelByNameAndProvider(modelName string, providerID uint) (*app.AiModel, error) { + var model app.AiModel + err := global.GVA_DB.Preload("Provider").Preload("Preset"). + Where("name = ? AND provider_id = ? AND enabled = ?", modelName, providerID, true). + First(&model).Error + if err != nil { + return nil, fmt.Errorf("未找到模型配置: %s", modelName) + } + return &model, nil +} + +// FetchProviderModels 从提供商获取可用模型列表 +func (s *AiModelService) FetchProviderModels(provider *app.AiProvider) ([]ProviderModel, error) { + // 构建请求 URL + url := fmt.Sprintf("%s/v1/models", provider.BaseURL) + + // 创建 HTTP 请求 + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + // 设置请求头 + if provider.Type == "openai" || provider.Type == "other" { + req.Header.Set("Authorization", "Bearer "+provider.APIKey) + } else if provider.Type == "claude" { + req.Header.Set("x-api-key", provider.APIKey) + req.Header.Set("anthropic-version", "2023-06-01") + } + + // 发送请求 + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("请求失败: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("获取模型列表失败: %d - %s", resp.StatusCode, string(body)) + } + + // 解析响应 + var result struct { + Data []ProviderModel `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("解析响应失败: %w", err) + } + + return result.Data, nil +} + +// SyncProviderModels 同步提供商的模型列表 +func (s *AiModelService) SyncProviderModels(providerID uint, userID uint) error { + // 获取提供商信息 + var provider app.AiProvider + if err := global.GVA_DB.Where("id = ? AND user_id = ?", providerID, userID).First(&provider).Error; err != nil { + return fmt.Errorf("提供商不存在") + } + + // 从提供商获取模型列表 + models, err := s.FetchProviderModels(&provider) + if err != nil { + return err + } + + // 同步到数据库 + for _, model := range models { + var existingModel app.AiModel + err := global.GVA_DB.Where("name = ? AND provider_id = ? AND user_id = ?", model.ID, providerID, userID).First(&existingModel).Error + + if err != nil { + // 模型不存在,创建新记录 + newModel := app.AiModel{ + Name: model.ID, + DisplayName: model.ID, + ProviderID: providerID, + Enabled: false, // 默认不启用,需要管理员手动启用 + UserID: userID, + } + global.GVA_DB.Create(&newModel) + } + // 如果模型已存在,不做任何操作(保留用户的配置) + } + + return nil +} + +// ProviderModel 提供商返回的模型信息 +type ProviderModel struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + OwnedBy string `json:"owned_by"` +} diff --git a/server/service/app/ai_preset.go b/server/service/app/ai_preset.go index 2c3bb7f..b9e897e 100644 --- a/server/service/app/ai_preset.go +++ b/server/service/app/ai_preset.go @@ -1,6 +1,9 @@ package app import ( + "encoding/json" + "fmt" + "git.echol.cn/loser/ai_proxy/server/global" "git.echol.cn/loser/ai_proxy/server/model/app" "git.echol.cn/loser/ai_proxy/server/model/common/request" @@ -41,3 +44,88 @@ func (s *AiPresetService) GetAiPresetList(info request.PageInfo, userID uint) (l err = db.Limit(limit).Offset(offset).Order("id desc").Find(&list).Error return } + +// ParseImportedPreset 解析导入的预设,支持 SillyTavern 格式 +func (s *AiPresetService) ParseImportedPreset(rawData map[string]interface{}) (*app.AiPreset, error) { + preset := &app.AiPreset{ + Enabled: true, + } + + // 处理名称字段 - 支持多种格式 + if name, ok := rawData["name"].(string); ok && name != "" { + preset.Name = name + } else if name, ok := rawData["preset_name"].(string); ok && name != "" { + preset.Name = name + } else if name, ok := rawData["presetName"].(string); ok && name != "" { + preset.Name = name + } else { + return nil, fmt.Errorf("预设名称不能为空") + } + + // 处理描述 + if desc, ok := rawData["description"].(string); ok { + preset.Description = desc + } + + // 处理参数 + if temp, ok := rawData["temperature"].(float64); ok { + preset.Temperature = temp + } else { + preset.Temperature = 1.0 + } + + if topP, ok := rawData["top_p"].(float64); ok { + preset.TopP = topP + } else { + preset.TopP = 0.9 + } + + if topK, ok := rawData["top_k"].(float64); ok { + preset.TopK = int(topK) + } + + if maxTokens, ok := rawData["max_tokens"].(float64); ok { + preset.MaxTokens = int(maxTokens) + } else { + preset.MaxTokens = 4096 + } + + if freqPenalty, ok := rawData["frequency_penalty"].(float64); ok { + preset.FrequencyPenalty = freqPenalty + } + + if presPenalty, ok := rawData["presence_penalty"].(float64); ok { + preset.PresencePenalty = presPenalty + } + + // 处理提示词 + if prompts, ok := rawData["prompts"].([]interface{}); ok { + promptsData, _ := json.Marshal(prompts) + json.Unmarshal(promptsData, &preset.Prompts) + } + + // 处理提示词顺序 + if promptOrder, ok := rawData["prompt_order"].([]interface{}); ok { + orderData, _ := json.Marshal(promptOrder) + json.Unmarshal(orderData, &preset.PromptOrder) + } + + // 处理正则脚本 + if regexScripts, ok := rawData["regex_scripts"].([]interface{}); ok { + scriptsData, _ := json.Marshal(regexScripts) + json.Unmarshal(scriptsData, &preset.RegexScripts) + } + + // 处理扩展配置 + if extensions, ok := rawData["extensions"].(map[string]interface{}); ok { + extData, _ := json.Marshal(extensions) + json.Unmarshal(extData, &preset.Extensions) + } + + // 处理启用状态 + if enabled, ok := rawData["enabled"].(bool); ok { + preset.Enabled = enabled + } + + return preset, nil +} diff --git a/server/service/app/ai_preset_binding.go b/server/service/app/ai_preset_binding.go deleted file mode 100644 index e4aebca..0000000 --- a/server/service/app/ai_preset_binding.go +++ /dev/null @@ -1,43 +0,0 @@ -package app - -import ( - "git.echol.cn/loser/ai_proxy/server/global" - "git.echol.cn/loser/ai_proxy/server/model/app" - "git.echol.cn/loser/ai_proxy/server/model/common/request" -) - -type AiPresetBindingService struct{} - -// CreateAiPresetBinding 创建绑定 -func (s *AiPresetBindingService) CreateAiPresetBinding(binding *app.AiPresetBinding) error { - return global.GVA_DB.Create(binding).Error -} - -// DeleteAiPresetBinding 删除绑定 -func (s *AiPresetBindingService) DeleteAiPresetBinding(id uint, userID uint) error { - return global.GVA_DB.Where("id = ? AND user_id = ?", id, userID).Delete(&app.AiPresetBinding{}).Error -} - -// UpdateAiPresetBinding 更新绑定 -func (s *AiPresetBindingService) UpdateAiPresetBinding(binding *app.AiPresetBinding, userID uint) error { - return global.GVA_DB.Where("user_id = ?", userID).Updates(binding).Error -} - -// GetAiPresetBinding 查询绑定 -func (s *AiPresetBindingService) GetAiPresetBinding(id uint, userID uint) (binding app.AiPresetBinding, err error) { - err = global.GVA_DB.Preload("Preset").Preload("Provider").Where("id = ? AND user_id = ?", id, userID).First(&binding).Error - return -} - -// GetAiPresetBindingList 获取绑定列表 -func (s *AiPresetBindingService) GetAiPresetBindingList(info request.PageInfo, userID uint) (list []app.AiPresetBinding, total int64, err error) { - limit := info.PageSize - offset := info.PageSize * (info.Page - 1) - db := global.GVA_DB.Model(&app.AiPresetBinding{}).Preload("Preset").Preload("Provider").Where("user_id = ?", userID) - err = db.Count(&total).Error - if err != nil { - return - } - err = db.Limit(limit).Offset(offset).Order("id desc").Find(&list).Error - return -} diff --git a/server/service/app/ai_preset_injector.go b/server/service/app/ai_preset_injector.go index 75c6941..b21fe3f 100644 --- a/server/service/app/ai_preset_injector.go +++ b/server/service/app/ai_preset_injector.go @@ -59,45 +59,94 @@ func (p *PresetInjector) getEnabledPrompts() []app.PresetPrompt { } // buildInjectedMessages 构建注入后的消息列表 +// 参考 SillyTavern 的实现逻辑 func (p *PresetInjector) buildInjectedMessages(messages []request.ChatMessage, prompts []app.PresetPrompt) []request.ChatMessage { result := make([]request.ChatMessage, 0) - // 分离系统提示词和对话消息 - var systemPrompts []app.PresetPrompt - var otherPrompts []app.PresetPrompt + // 按照 injection_position 分组 + // 0 = 相对位置(从对话历史的特定深度注入) + // 1 = 绝对位置(在消息列表的固定位置注入) + var relativePrompts []app.PresetPrompt + var absolutePrompts []app.PresetPrompt for _, prompt := range prompts { - if prompt.Role == "system" { - systemPrompts = append(systemPrompts, prompt) + if prompt.InjectionPosition == 0 { + relativePrompts = append(relativePrompts, prompt) } else { - otherPrompts = append(otherPrompts, prompt) + absolutePrompts = append(absolutePrompts, prompt) } } - // 1. 先添加系统提示词 - for _, prompt := range systemPrompts { - result = append(result, request.ChatMessage{ - Role: "system", - Content: p.processPromptContent(prompt.Content), - }) + // 处理绝对位置的提示词(通常是系统提示) + for _, prompt := range absolutePrompts { + if prompt.InjectionDepth == 0 { + // depth=0 表示在最开始 + result = append(result, request.ChatMessage{ + Role: prompt.Role, + Content: p.processPromptContent(prompt.Content), + }) + } } - // 2. 处理对话历史注入 - chatHistoryIndex := p.findMarkerIndex("chatHistory") - if chatHistoryIndex >= 0 { - // 在 chatHistory 标记位置注入原始消息 - result = append(result, messages...) - } else { - // 如果没有 chatHistory 标记,直接添加到末尾 - result = append(result, messages...) + // 处理相对位置的提示词和对话历史 + // 按 injection_depth 从大到小排序(深度越大越靠前) + sort.Slice(relativePrompts, func(i, j int) bool { + if relativePrompts[i].InjectionDepth != relativePrompts[j].InjectionDepth { + return relativePrompts[i].InjectionDepth > relativePrompts[j].InjectionDepth + } + return relativePrompts[i].InjectionOrder < relativePrompts[j].InjectionOrder + }) + + // 注入相对位置的提示词到对话历史中 + injectedMessages := p.injectRelativePrompts(messages, relativePrompts) + result = append(result, injectedMessages...) + + // 处理绝对位置在末尾的提示词 + for _, prompt := range absolutePrompts { + if prompt.InjectionDepth > 0 { + // depth>0 表示在末尾 + result = append(result, request.ChatMessage{ + Role: prompt.Role, + Content: p.processPromptContent(prompt.Content), + }) + } } - // 3. 添加其他角色的提示词(assistant等) - for _, prompt := range otherPrompts { - result = append(result, request.ChatMessage{ - Role: prompt.Role, - Content: p.processPromptContent(prompt.Content), - }) + return result +} + +// injectRelativePrompts 将相对位置的提示词注入到对话历史中 +func (p *PresetInjector) injectRelativePrompts(messages []request.ChatMessage, prompts []app.PresetPrompt) []request.ChatMessage { + if len(prompts) == 0 { + return messages + } + + result := make([]request.ChatMessage, 0, len(messages)+len(prompts)) + messageCount := len(messages) + + // 按深度分组提示词 + depthMap := make(map[int][]app.PresetPrompt) + for _, prompt := range prompts { + depthMap[prompt.InjectionDepth] = append(depthMap[prompt.InjectionDepth], prompt) + } + + // 遍历消息,在指定深度注入提示词 + for i, msg := range messages { + // 计算当前位置的深度(从末尾开始计数) + depth := messageCount - i + + // 在当前消息之前注入对应深度的提示词 + if promptsAtDepth, exists := depthMap[depth]; exists { + for _, prompt := range promptsAtDepth { + result = append(result, request.ChatMessage{ + Role: prompt.Role, + Content: p.processPromptContent(prompt.Content), + }) + } + } + + // 添加当前消息 + result = append(result, msg) } return result diff --git a/server/service/app/ai_proxy.go b/server/service/app/ai_proxy.go index 4cc7604..4b17d0d 100644 --- a/server/service/app/ai_proxy.go +++ b/server/service/app/ai_proxy.go @@ -22,53 +22,60 @@ import ( type AiProxyService struct{} // ProcessChatCompletion 处理聊天补全请求 -func (s *AiProxyService) ProcessChatCompletion(ctx context.Context, userID uint, req *request.ChatCompletionRequest) (*response.ChatCompletionResponse, error) { - startTime := time.Now() +func (s *AiProxyService) ProcessChatCompletion(ctx context.Context, req *request.ChatCompletionRequest) (*response.ChatCompletionResponse, error) { + // 1. 根据模型获取配置 + if req.Model == "" { + return nil, fmt.Errorf("model 参数不能为空") + } - // 1. 获取绑定配置 - binding, err := s.getBinding(userID, req) + preset, provider, err := s.getConfigByModel(req.Model) if err != nil { - return nil, fmt.Errorf("获取绑定配置失败: %w", err) + return nil, err } // 2. 注入预设 - injector := NewPresetInjector(&binding.Preset) - req.Messages = injector.InjectMessages(req.Messages) - injector.ApplyPresetParameters(req) + if preset != nil { + injector := NewPresetInjector(preset) + req.Messages = injector.InjectMessages(req.Messages) + injector.ApplyPresetParameters(req) + } // 3. 转发请求到上游 - resp, err := s.forwardRequest(ctx, &binding.Provider, req) + resp, err := s.forwardRequest(ctx, provider, req) if err != nil { - s.logRequest(userID, binding, req, nil, err, time.Since(startTime)) return nil, err } // 4. 处理响应 - if len(resp.Choices) > 0 { + if preset != nil && len(resp.Choices) > 0 { + injector := NewPresetInjector(preset) resp.Choices[0].Message.Content = injector.ProcessResponse(resp.Choices[0].Message.Content) } - // 5. 记录日志 - s.logRequest(userID, binding, req, resp, nil, time.Since(startTime)) - return resp, nil } // ProcessChatCompletionStream 处理流式聊天补全请求 -func (s *AiProxyService) ProcessChatCompletionStream(c *gin.Context, userID uint, req *request.ChatCompletionRequest) { - startTime := time.Now() +func (s *AiProxyService) ProcessChatCompletionStream(c *gin.Context, req *request.ChatCompletionRequest) { + // 1. 根据模型获取配置 + if req.Model == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "model 参数不能为空"}) + return + } - // 1. 获取绑定配置 - binding, err := s.getBinding(userID, req) + preset, provider, err := s.getConfigByModel(req.Model) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // 2. 注入预设 - injector := NewPresetInjector(&binding.Preset) - req.Messages = injector.InjectMessages(req.Messages) - injector.ApplyPresetParameters(req) + var injector *PresetInjector + if preset != nil { + injector = NewPresetInjector(preset) + req.Messages = injector.InjectMessages(req.Messages) + injector.ApplyPresetParameters(req) + } // 3. 设置 SSE 响应头 c.Header("Content-Type", "text/event-stream") @@ -77,45 +84,30 @@ func (s *AiProxyService) ProcessChatCompletionStream(c *gin.Context, userID uint c.Header("X-Accel-Buffering", "no") // 4. 转发流式请求 - err = s.forwardStreamRequest(c, &binding.Provider, req, injector) + err = s.forwardStreamRequest(c, provider, req, injector) if err != nil { global.GVA_LOG.Error("流式请求失败", zap.Error(err)) - s.logRequest(userID, binding, req, nil, err, time.Since(startTime)) } } -// getBinding 获取绑定配置 -func (s *AiProxyService) getBinding(userID uint, req *request.ChatCompletionRequest) (*app.AiPresetBinding, error) { - var binding app.AiPresetBinding +// getConfigByModel 根据模型名称获取配置 +func (s *AiProxyService) getConfigByModel(modelName string) (*app.AiPreset, *app.AiProvider, error) { + // 查找启用的模型配置 + var model app.AiModel + err := global.GVA_DB.Preload("Provider").Preload("Preset"). + Where("name = ? AND enabled = ?", modelName, true). + First(&model).Error - query := global.GVA_DB.Preload("Preset").Preload("Provider").Where("user_id = ? AND enabled = ?", userID, true) - - // 优先使用 binding_name - if req.BindingName != "" { - query = query.Where("name = ?", req.BindingName) - } else if req.PresetName != "" && req.ProviderName != "" { - // 使用 preset_name 和 provider_name - query = query.Joins("JOIN ai_presets ON ai_presets.id = ai_preset_bindings.preset_id"). - Joins("JOIN ai_providers ON ai_providers.id = ai_preset_bindings.provider_id"). - Where("ai_presets.name = ? AND ai_providers.name = ?", req.PresetName, req.ProviderName) - } else { - // 使用默认绑定(第一个启用的) - query = query.Order("id ASC") + if err != nil { + return nil, nil, fmt.Errorf("未找到模型配置: %s", modelName) } - if err := query.First(&binding).Error; err != nil { - return nil, fmt.Errorf("未找到可用的绑定配置") + // 检查提供商是否启用 + if !model.Provider.Enabled { + return nil, nil, fmt.Errorf("提供商已禁用") } - if !binding.Provider.Enabled { - return nil, fmt.Errorf("提供商已禁用") - } - - if !binding.Preset.Enabled { - return nil, fmt.Errorf("预设已禁用") - } - - return &binding, nil + return model.Preset, &model.Provider, nil } // forwardRequest 转发请求到上游 AI 服务 @@ -235,7 +227,7 @@ func (s *AiProxyService) forwardStreamRequest(c *gin.Context, provider *app.AiPr } // 应用输出正则处理 - if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" { + if injector != nil && len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" { chunk.Choices[0].Delta.Content = injector.ProcessResponse(chunk.Choices[0].Delta.Content) } @@ -251,29 +243,40 @@ func (s *AiProxyService) forwardStreamRequest(c *gin.Context, provider *app.AiPr return nil } -// logRequest 记录请求日志 -func (s *AiProxyService) logRequest(userID uint, binding *app.AiPresetBinding, req *request.ChatCompletionRequest, resp *response.ChatCompletionResponse, err error, duration time.Duration) { - log := app.AiRequestLog{ - UserID: userID, - BindingID: binding.ID, - ProviderID: binding.ProviderID, - PresetID: binding.PresetID, - Model: req.Model, - Duration: duration.Milliseconds(), - RequestTime: time.Now(), +// GetAvailableModels 获取用户可用的模型列表 +func (s *AiProxyService) GetAvailableModels(apiKey *app.AiApiKey) (*response.ModelListResponse, error) { + // 查询所有启用的模型 + var models []app.AiModel + query := global.GVA_DB.Where("enabled = ?", true) + + // 如果 API Key 限制了模型,只返回允许的模型 + if len(apiKey.AllowedModels) > 0 { + query = query.Where("name IN ?", apiKey.AllowedModels) } - if err != nil { - log.Status = "error" - log.ErrorMessage = err.Error() - } else { - log.Status = "success" - if resp != nil { - log.PromptTokens = resp.Usage.PromptTokens - log.CompletionTokens = resp.Usage.CompletionTokens - log.TotalTokens = resp.Usage.TotalTokens + if err := query.Find(&models).Error; err != nil { + return nil, fmt.Errorf("查询模型列表失败: %w", err) + } + + // 构建响应 + modelList := &response.ModelListResponse{ + Object: "list", + Data: make([]response.ModelInfo, 0, len(models)), + } + + // 去重(同一模型可能在多个提供商下配置) + seen := make(map[string]bool) + for _, model := range models { + if !seen[model.Name] { + seen[model.Name] = true + modelList.Data = append(modelList.Data, response.ModelInfo{ + ID: model.Name, + Object: "model", + Created: model.CreatedAt.Unix(), + OwnedBy: "system", + }) } } - global.GVA_DB.Create(&log) + return modelList, nil } diff --git a/server/service/app/enter.go b/server/service/app/enter.go index af6f06c..21e73ff 100644 --- a/server/service/app/enter.go +++ b/server/service/app/enter.go @@ -4,5 +4,6 @@ type AppServiceGroup struct { AiProxyService AiPresetService AiProviderService - AiPresetBindingService + AiApiKeyService + AiModelService } diff --git a/server/source/system/api.go b/server/source/system/api.go index a136193..7488ed1 100644 --- a/server/source/system/api.go +++ b/server/source/system/api.go @@ -252,7 +252,8 @@ func (i *initApi) InitializeData(ctx context.Context) (context.Context, error) { {ApiGroup: "AI预设管理", Method: "PUT", Path: "/aiPreset/updateAiPreset", Description: "更新AI预设"}, {ApiGroup: "AI预设管理", Method: "GET", Path: "/aiPreset/findAiPreset", Description: "查询AI预设"}, {ApiGroup: "AI预设管理", Method: "GET", Path: "/aiPreset/getAiPresetList", Description: "获取AI预设列表"}, - {ApiGroup: "AI预设管理", Method: "POST", Path: "/aiPreset/importAiPreset", Description: "导入AI预设"}, + {ApiGroup: "AI预设管理", Method: "POST", Path: "/aiPreset/importAiPreset", Description: "导入AI预设(JSON)"}, + {ApiGroup: "AI预设管理", Method: "POST", Path: "/aiPreset/importAiPresetFile", Description: "导入AI预设(文件)"}, {ApiGroup: "AI提供商管理", Method: "POST", Path: "/aiProvider/createAiProvider", Description: "创建AI提供商"}, {ApiGroup: "AI提供商管理", Method: "DELETE", Path: "/aiProvider/deleteAiProvider", Description: "删除AI提供商"}, diff --git a/web/.env.production b/web/.env.production index 9345df2..f620277 100644 --- a/web/.env.production +++ b/web/.env.production @@ -1,7 +1,7 @@ ENV = 'production' -#下方为上线需要用到的程序代理前缀,一般用于nginx代理转发 +# API 路径前缀 VITE_BASE_API = /api VITE_FILE_API = /api -#下方修改为你的线上ip(如果需要在线使用表单构建工具时使用,其余情况无需使用以下环境变量) -VITE_BASE_PATH = https://demo.gin-vue-admin.com +# 基础路径(部署时会自动使用当前域名) +VITE_BASE_PATH = / diff --git a/web/src/api/aiApiKey.js b/web/src/api/aiApiKey.js new file mode 100644 index 0000000..b3f6967 --- /dev/null +++ b/web/src/api/aiApiKey.js @@ -0,0 +1,46 @@ +import service from '@/utils/request' + +// 创建API密钥 +export const createAiApiKey = (data) => { + return service({ + url: '/aiApiKey/createAiApiKey', + method: 'post', + data + }) +} + +// 删除API密钥 +export const deleteAiApiKey = (data) => { + return service({ + url: '/aiApiKey/deleteAiApiKey', + method: 'delete', + data + }) +} + +// 更新API密钥 +export const updateAiApiKey = (data) => { + return service({ + url: '/aiApiKey/updateAiApiKey', + method: 'put', + data + }) +} + +// 查询API密钥 +export const findAiApiKey = (params) => { + return service({ + url: '/aiApiKey/findAiApiKey', + method: 'get', + params + }) +} + +// 获取API密钥列表 +export const getAiApiKeyList = (params) => { + return service({ + url: '/aiApiKey/getAiApiKeyList', + method: 'get', + params + }) +} diff --git a/web/src/api/aiModel.js b/web/src/api/aiModel.js new file mode 100644 index 0000000..0e7ed12 --- /dev/null +++ b/web/src/api/aiModel.js @@ -0,0 +1,55 @@ +import service from '@/utils/request' + +// 创建模型 +export const createAiModel = (data) => { + return service({ + url: '/aiModel/createAiModel', + method: 'post', + data + }) +} + +// 删除模型 +export const deleteAiModel = (data) => { + return service({ + url: '/aiModel/deleteAiModel', + method: 'delete', + data + }) +} + +// 更新模型 +export const updateAiModel = (data) => { + return service({ + url: '/aiModel/updateAiModel', + method: 'put', + data + }) +} + +// 查询模型 +export const findAiModel = (params) => { + return service({ + url: '/aiModel/findAiModel', + method: 'get', + params + }) +} + +// 获取模型列表 +export const getAiModelList = (params) => { + return service({ + url: '/aiModel/getAiModelList', + method: 'get', + params + }) +} + +// 同步提供商模型 +export const syncProviderModels = (data) => { + return service({ + url: '/aiModel/syncProviderModels', + method: 'post', + data + }) +} diff --git a/web/src/api/aiPreset.js b/web/src/api/aiPreset.js index 79721d7..7552024 100644 --- a/web/src/api/aiPreset.js +++ b/web/src/api/aiPreset.js @@ -45,7 +45,7 @@ export const getAiPresetList = (params) => { }) } -// 导入预设 +// 导入预设(JSON粘贴) export const importAiPreset = (data) => { return service({ url: '/aiPreset/importAiPreset', @@ -53,3 +53,17 @@ export const importAiPreset = (data) => { data }) } + +// 导入预设(文件上传) +export const importAiPresetFile = (file) => { + const formData = new FormData() + formData.append('file', file) + return service({ + url: '/aiPreset/importAiPresetFile', + method: 'post', + data: formData, + headers: { + 'Content-Type': 'multipart/form-data' + } + }) +} diff --git a/web/src/pathInfo.json b/web/src/pathInfo.json index ccb1aa3..3feca9a 100644 --- a/web/src/pathInfo.json +++ b/web/src/pathInfo.json @@ -1,6 +1,9 @@ { "/src/view/about/index.vue": "About", + "/src/view/ai/apikey/index.vue": "Index", "/src/view/ai/binding/index.vue": "Index", + "/src/view/ai/model/index.vue": "Index", + "/src/view/ai/preset/components/PromptEditor.vue": "PromptEditor", "/src/view/ai/preset/index.vue": "Index", "/src/view/ai/provider/index.vue": "Index", "/src/view/dashboard/components/banner.vue": "Banner", diff --git a/web/src/view/ai/apikey/index.vue b/web/src/view/ai/apikey/index.vue new file mode 100644 index 0000000..5757959 --- /dev/null +++ b/web/src/view/ai/apikey/index.vue @@ -0,0 +1,392 @@ + + + + + diff --git a/web/src/view/ai/model/index.vue b/web/src/view/ai/model/index.vue new file mode 100644 index 0000000..add64e4 --- /dev/null +++ b/web/src/view/ai/model/index.vue @@ -0,0 +1,491 @@ + + + + + diff --git a/web/src/view/ai/preset/components/PromptEditor.vue b/web/src/view/ai/preset/components/PromptEditor.vue new file mode 100644 index 0000000..95ebc22 --- /dev/null +++ b/web/src/view/ai/preset/components/PromptEditor.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/web/src/view/ai/preset/index.vue b/web/src/view/ai/preset/index.vue index b2bc338..12df9bb 100644 --- a/web/src/view/ai/preset/index.vue +++ b/web/src/view/ai/preset/index.vue @@ -140,12 +140,36 @@ :closable="false" style="margin-bottom: 20px" /> - + + + + +
+ 将 JSON 文件拖到此处,或点击上传 +
+ +
+
+ + + +
+ + + @@ -212,13 +243,16 @@