🎨 优化模型配置 && 新增apikey功能 && 完善通用接口

This commit is contained in:
2026-03-03 17:13:24 +08:00
parent 2714e63d2a
commit 7dae1a6e2b
46 changed files with 3063 additions and 278 deletions

49
.dockerignore Normal file
View File

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

72
Dockerfile Normal file
View File

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

267
config.yaml Normal file
View File

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

32
docker-compose.yml Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,5 +4,6 @@ type ApiGroup struct {
AiProxyApi
AiPresetApi
AiProviderApi
AiPresetBindingApi
AiApiKeyApi
AiModelApi
}

View File

@@ -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("前端静态文件路由配置成功")
}

View File

@@ -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) // 模型管理(需要登录)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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:超时时间(秒)"`

View File

@@ -3,6 +3,7 @@ package app
var AutoMigrateTables = []interface{}{
&AiPreset{},
&AiProvider{},
&AiPresetBinding{},
&AiRequestLog{},
&AiApiKey{},
&AiModel{},
}

View File

@@ -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 编码的图片数据
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) // 文件上传导入
}
}

View File

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

View File

@@ -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 兼容
}
}

View File

@@ -4,5 +4,6 @@ type RouterGroup struct {
AiProxyRouter
AiPresetRouter
AiProviderRouter
AiPresetBindingRouter
AiApiKeyRouter
AiModelRouter
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,5 +4,6 @@ type AppServiceGroup struct {
AiProxyService
AiPresetService
AiProviderService
AiPresetBindingService
AiApiKeyService
AiModelService
}

View File

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

View File

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

46
web/src/api/aiApiKey.js Normal file
View File

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

55
web/src/api/aiModel.js Normal file
View File

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

View File

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

View File

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

View File

@@ -0,0 +1,392 @@
<template>
<div>
<div class="gva-search-box">
<el-form :inline="true" :model="searchInfo" class="demo-form-inline">
<el-form-item label="密钥名称">
<el-input v-model="searchInfo.name" placeholder="搜索密钥名称" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSubmit">查询</el-button>
<el-button @click="onReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<div class="gva-table-box">
<div class="gva-btn-list">
<el-button type="primary" @click="openDialog">新增</el-button>
</div>
<el-table :data="tableData" style="width: 100%">
<el-table-column type="selection" width="55" />
<el-table-column label="ID" prop="ID" width="80" />
<el-table-column label="名称" prop="name" width="200" />
<el-table-column label="API Key" prop="key" min-width="300" show-overflow-tooltip>
<template #default="scope">
<span class="key-text">{{ maskKey(scope.row.key) }}</span>
<el-button type="primary" link size="small" @click="copyKey(scope.row.key)">
复制
</el-button>
</template>
</el-table-column>
<el-table-column label="允许的模型" width="150">
<template #default="scope">
<el-tag v-if="scope.row.allowed_models?.length > 0" type="info">
{{ scope.row.allowed_models.length }}
</el-tag>
<el-tag v-else type="success">全部</el-tag>
</template>
</el-table-column>
<el-table-column label="速率限制" width="120">
<template #default="scope">
{{ scope.row.rate_limit || '无限制' }}
</template>
</el-table-column>
<el-table-column label="过期时间" width="180">
<template #default="scope">
{{ scope.row.expires_at ? formatDate(scope.row.expires_at * 1000) : '永不过期' }}
</template>
</el-table-column>
<el-table-column label="状态" prop="enabled" width="80">
<template #default="scope">
<el-switch v-model="scope.row.enabled" @change="handleStatusChange(scope.row)" />
</template>
</el-table-column>
<el-table-column label="创建时间" prop="CreatedAt" width="180">
<template #default="scope">{{ formatDate(scope.row.CreatedAt) }}</template>
</el-table-column>
<el-table-column label="操作" fixed="right" width="200">
<template #default="scope">
<el-button type="primary" link @click="updateApiKey(scope.row)">编辑</el-button>
<el-button type="danger" link @click="deleteApiKey(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="gva-pagination">
<el-pagination
:current-page="page"
:page-size="pageSize"
:page-sizes="[10, 30, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@current-change="handleCurrentChange"
@size-change="handleSizeChange"
/>
</div>
</div>
<el-dialog
v-model="dialogFormVisible"
:title="type === 'create' ? '新增API密钥' : '编辑API密钥'"
width="700px"
>
<el-form ref="apiKeyForm" :model="formData" :rules="rules" label-width="120px">
<el-form-item label="名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入密钥名称" clearable />
</el-form-item>
<el-form-item v-if="type === 'create'" label="API Key" prop="key">
<el-input v-model="formData.key" placeholder="留空自动生成" clearable />
</el-form-item>
<el-form-item label="允许的模型">
<el-select
v-model="formData.allowed_models"
multiple
filterable
allow-create
placeholder="留空表示允许所有模型"
style="width: 100%"
>
<el-option
v-for="model in availableModels"
:key="model"
:label="model"
:value="model"
/>
</el-select>
<div style="margin-top: 5px; color: #909399; font-size: 12px">
模型列表来自提供商配置也可手动输入其他模型名称
</div>
</el-form-item>
<el-form-item label="允许的预设">
<el-select
v-model="formData.allowed_presets"
multiple
filterable
allow-create
placeholder="留空表示允许所有预设"
style="width: 100%"
>
<el-option
v-for="preset in presetList"
:key="preset.name"
:label="preset.name"
:value="preset.name"
/>
</el-select>
</el-form-item>
<el-form-item label="速率限制">
<el-input-number
v-model="formData.rate_limit"
:min="0"
placeholder="每分钟请求数0表示不限制"
/>
<span style="margin-left: 10px; color: #909399">/分钟</span>
</el-form-item>
<el-form-item label="过期时间">
<el-date-picker
v-model="expiresDate"
type="datetime"
placeholder="选择过期时间"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="启用" prop="enabled">
<el-switch v-model="formData.enabled" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="closeDialog">取消</el-button>
<el-button type="primary" @click="enterDialog">确定</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getAiApiKeyList,
createAiApiKey,
updateAiApiKey,
deleteAiApiKey
} from '@/api/aiApiKey'
import { getAiPresetList } from '@/api/aiPreset'
import { getAiProviderList } from '@/api/aiProvider'
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const tableData = ref([])
const searchInfo = reactive({ name: '' })
const presetList = ref([])
const providerList = ref([])
const availableModels = computed(() => {
const models = new Set()
providerList.value.forEach(provider => {
if (provider.model) {
models.add(provider.model)
}
})
return Array.from(models)
})
const dialogFormVisible = ref(false)
const type = ref('')
const apiKeyForm = ref(null)
const expiresDate = ref(null)
const formData = ref({
name: '',
key: '',
allowed_models: [],
allowed_presets: [],
rate_limit: 0,
expires_at: null,
enabled: true
})
const rules = reactive({
name: [{ required: true, message: '请输入密钥名称', trigger: 'blur' }]
})
const maskKey = (key) => {
if (!key) return ''
if (key.length <= 12) return key
return key.substring(0, 8) + '...' + key.substring(key.length - 4)
}
const copyKey = (key) => {
navigator.clipboard.writeText(key)
ElMessage.success('已复制到剪贴板')
}
const formatDate = (date) => {
if (!date) return ''
return new Date(date).toLocaleString('zh-CN')
}
const getTableData = async () => {
const table = await getAiApiKeyList({
page: page.value,
pageSize: pageSize.value,
...searchInfo
})
if (table.code === 0) {
tableData.value = table.data.list
total.value = table.data.total
page.value = table.data.page
pageSize.value = table.data.pageSize
}
}
const loadPresets = async () => {
const res = await getAiPresetList({ page: 1, pageSize: 100 })
if (res.code === 0) {
presetList.value = res.data.list
}
}
const loadProviders = async () => {
const res = await getAiProviderList({ page: 1, pageSize: 100 })
if (res.code === 0) {
providerList.value = res.data.list
}
}
const onSubmit = () => {
page.value = 1
getTableData()
}
const onReset = () => {
searchInfo.name = ''
getTableData()
}
const handleCurrentChange = (val) => {
page.value = val
getTableData()
}
const handleSizeChange = (val) => {
pageSize.value = val
getTableData()
}
const handleStatusChange = async (row) => {
const res = await updateAiApiKey(row)
if (res.code === 0) {
ElMessage.success('状态更新成功')
} else {
row.enabled = !row.enabled
ElMessage.error('状态更新失败')
}
}
const openDialog = () => {
type.value = 'create'
dialogFormVisible.value = true
}
const closeDialog = () => {
dialogFormVisible.value = false
expiresDate.value = null
formData.value = {
name: '',
key: '',
allowed_models: [],
allowed_presets: [],
rate_limit: 0,
expires_at: null,
enabled: true
}
}
const enterDialog = async () => {
apiKeyForm.value.validate(async (valid) => {
if (valid) {
// 处理过期时间
if (expiresDate.value) {
formData.value.expires_at = Math.floor(new Date(expiresDate.value).getTime() / 1000)
} else {
formData.value.expires_at = null
}
let res
if (type.value === 'create') {
res = await createAiApiKey(formData.value)
if (res.code === 0) {
ElMessage.success('创建成功')
if (res.data?.key) {
ElMessageBox.alert(
`API Key: ${res.data.key}`,
'请保存您的API密钥',
{
confirmButtonText: '复制',
callback: () => {
copyKey(res.data.key)
}
}
)
}
}
} else {
res = await updateAiApiKey(formData.value)
if (res.code === 0) {
ElMessage.success('更新成功')
}
}
if (res.code === 0) {
closeDialog()
getTableData()
}
}
})
}
const updateApiKey = (row) => {
type.value = 'update'
formData.value = { ...row }
if (row.expires_at) {
expiresDate.value = new Date(row.expires_at * 1000)
}
dialogFormVisible.value = true
}
const deleteApiKey = (row) => {
ElMessageBox.confirm('确定要删除该API密钥吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
const res = await deleteAiApiKey({ ID: row.ID })
if (res.code === 0) {
ElMessage.success('删除成功')
getTableData()
}
})
}
getTableData()
loadPresets()
loadProviders()
</script>
<style scoped>
.gva-search-box {
padding: 20px;
background: #fff;
margin-bottom: 10px;
}
.gva-table-box {
padding: 20px;
background: #fff;
}
.gva-btn-list {
margin-bottom: 10px;
}
.gva-pagination {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.key-text {
font-family: monospace;
color: #606266;
}
</style>

View File

@@ -0,0 +1,491 @@
<template>
<div>
<div class="gva-search-box">
<el-form :inline="true" :model="searchInfo" class="demo-form-inline">
<el-form-item label="显示模式">
<el-radio-group v-model="viewMode" @change="handleViewModeChange">
<el-radio-button value="list">列表视图</el-radio-button>
<el-radio-button value="group">分组视图</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="模型名称">
<el-input v-model="searchInfo.name" placeholder="搜索模型名称" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSubmit">查询</el-button>
<el-button @click="onReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<!-- 列表视图 -->
<div v-if="viewMode === 'list'" class="gva-table-box">
<div class="gva-btn-list">
<el-button type="primary" @click="openDialog">新增</el-button>
<el-button type="success" @click="openSyncDialog">同步模型</el-button>
</div>
<el-table :data="tableData" style="width: 100%">
<el-table-column type="selection" width="55" />
<el-table-column label="ID" prop="ID" width="80" />
<el-table-column label="模型名称" prop="name" width="200" />
<el-table-column label="显示名称" prop="display_name" width="150" />
<el-table-column label="提供商" width="150">
<template #default="scope">
<el-tag>{{ scope.row.provider?.name || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="绑定预设" width="150">
<template #default="scope">
<el-tag v-if="scope.row.preset" type="success">
{{ scope.row.preset.name }}
</el-tag>
<el-tag v-else type="info">未绑定</el-tag>
</template>
</el-table-column>
<el-table-column label="Max Tokens" prop="max_tokens" width="120" />
<el-table-column label="状态" prop="enabled" width="80">
<template #default="scope">
<el-switch v-model="scope.row.enabled" @change="handleStatusChange(scope.row)" />
</template>
</el-table-column>
<el-table-column label="描述" prop="description" min-width="200" show-overflow-tooltip />
<el-table-column label="操作" fixed="right" width="200">
<template #default="scope">
<el-button type="primary" link @click="updateModel(scope.row)">编辑</el-button>
<el-button type="danger" link @click="deleteModel(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="gva-pagination">
<el-pagination
:current-page="page"
:page-size="pageSize"
:page-sizes="[10, 30, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@current-change="handleCurrentChange"
@size-change="handleSizeChange"
/>
</div>
</div>
<!-- 分组视图 -->
<div v-else class="gva-table-box">
<div class="gva-btn-list">
<el-button type="primary" @click="openDialog">新增</el-button>
<el-button type="success" @click="openSyncDialog">同步模型</el-button>
</div>
<el-collapse v-model="activeProviders" accordion>
<el-collapse-item
v-for="provider in groupedModels"
:key="provider.id"
:name="provider.id"
>
<template #title>
<div class="provider-header">
<el-tag :type="provider.enabled ? 'success' : 'info'" style="margin-right: 10px">
{{ provider.name }}
</el-tag>
<span class="model-count">{{ provider.models.length }} 个模型</span>
<span class="enabled-count">
({{ provider.models.filter(m => m.enabled).length }} 已启用)
</span>
</div>
</template>
<el-table :data="provider.models" style="width: 100%">
<el-table-column label="模型名称" prop="name" width="200" />
<el-table-column label="显示名称" prop="display_name" width="150" />
<el-table-column label="绑定预设" width="150">
<template #default="scope">
<el-tag v-if="scope.row.preset" type="success">
{{ scope.row.preset.name }}
</el-tag>
<el-tag v-else type="info">未绑定</el-tag>
</template>
</el-table-column>
<el-table-column label="Max Tokens" prop="max_tokens" width="120" />
<el-table-column label="状态" prop="enabled" width="80">
<template #default="scope">
<el-switch v-model="scope.row.enabled" @change="handleStatusChange(scope.row)" />
</template>
</el-table-column>
<el-table-column label="描述" prop="description" min-width="200" show-overflow-tooltip />
<el-table-column label="操作" fixed="right" width="200">
<template #default="scope">
<el-button type="primary" link @click="updateModel(scope.row)">编辑</el-button>
<el-button type="danger" link @click="deleteModel(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-collapse-item>
</el-collapse>
</div>
<!-- 新增/编辑对话框 -->
<el-dialog
v-model="dialogFormVisible"
:title="type === 'create' ? '新增模型' : '编辑模型'"
width="700px"
>
<el-form ref="modelForm" :model="formData" :rules="rules" label-width="120px">
<el-form-item label="模型名称" prop="name">
<el-input v-model="formData.name" placeholder="如: gpt-4" clearable />
</el-form-item>
<el-form-item label="显示名称" prop="display_name">
<el-input v-model="formData.display_name" placeholder="如: GPT-4" clearable />
</el-form-item>
<el-form-item label="提供商" prop="provider_id">
<el-select v-model="formData.provider_id" placeholder="请选择提供商" style="width: 100%">
<el-option
v-for="provider in providerList"
:key="provider.ID"
:label="provider.name"
:value="provider.ID"
/>
</el-select>
</el-form-item>
<el-form-item label="绑定预设" prop="preset_id">
<el-select
v-model="formData.preset_id"
placeholder="选择要绑定的预设(可选)"
clearable
style="width: 100%"
>
<el-option
v-for="preset in presetList"
:key="preset.ID"
:label="preset.name"
:value="preset.ID"
/>
</el-select>
</el-form-item>
<el-form-item label="Max Tokens" prop="max_tokens">
<el-input-number v-model="formData.max_tokens" :min="1" :max="200000" />
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input
v-model="formData.description"
type="textarea"
:rows="3"
placeholder="模型描述"
/>
</el-form-item>
<el-form-item label="启用" prop="enabled">
<el-switch v-model="formData.enabled" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="closeDialog">取消</el-button>
<el-button type="primary" @click="enterDialog">确定</el-button>
</div>
</template>
</el-dialog>
<!-- 同步模型对话框 -->
<el-dialog v-model="syncDialogVisible" title="同步提供商模型" width="500px">
<el-form label-width="100px">
<el-form-item label="选择提供商">
<el-select v-model="syncProviderId" placeholder="请选择提供商" style="width: 100%">
<el-option
v-for="provider in providerList"
:key="provider.ID"
:label="provider.name"
:value="provider.ID"
/>
</el-select>
</el-form-item>
</el-form>
<el-alert
title="同步说明"
type="info"
:closable="false"
style="margin-top: 10px"
>
<p>将从提供商的 /v1/models 接口获取可用模型列表</p>
<p>新模型将被添加到系统中默认禁用状态</p>
<p>已存在的模型不会被修改</p>
</el-alert>
<template #footer>
<div class="dialog-footer">
<el-button @click="syncDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSync" :loading="syncing">开始同步</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getAiModelList,
createAiModel,
updateAiModel,
deleteAiModel,
syncProviderModels
} from '@/api/aiModel'
import { getAiProviderList } from '@/api/aiProvider'
import { getAiPresetList } from '@/api/aiPreset'
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const tableData = ref([])
const searchInfo = reactive({ name: '' })
const providerList = ref([])
const presetList = ref([])
const viewMode = ref('list')
const activeProviders = ref([])
const dialogFormVisible = ref(false)
const syncDialogVisible = ref(false)
const type = ref('')
const modelForm = ref(null)
const syncProviderId = ref(null)
const syncing = ref(false)
const formData = ref({
name: '',
display_name: '',
provider_id: null,
preset_id: null,
max_tokens: 4096,
description: '',
enabled: true
})
const rules = reactive({
name: [{ required: true, message: '请输入模型名称', trigger: 'blur' }],
provider_id: [{ required: true, message: '请选择提供商', trigger: 'change' }]
})
// 按提供商分组模型
const groupedModels = computed(() => {
const groups = {}
tableData.value.forEach(model => {
const providerId = model.provider?.ID
const providerName = model.provider?.name || '未知提供商'
if (!groups[providerId]) {
groups[providerId] = {
id: providerId,
name: providerName,
enabled: model.provider?.enabled || false,
models: []
}
}
groups[providerId].models.push(model)
})
return Object.values(groups).sort((a, b) => a.name.localeCompare(b.name))
})
const formatDate = (date) => {
if (!date) return ''
return new Date(date).toLocaleString('zh-CN')
}
const handleViewModeChange = () => {
if (viewMode.value === 'group') {
// 切换到分组视图时,默认展开第一个提供商
if (groupedModels.value.length > 0) {
activeProviders.value = [groupedModels.value[0].id]
}
}
}
const getTableData = async () => {
const table = await getAiModelList({
page: page.value,
pageSize: pageSize.value,
...searchInfo
})
if (table.code === 0) {
tableData.value = table.data.list
total.value = table.data.total
page.value = table.data.page
pageSize.value = table.data.pageSize
}
}
const loadProviders = async () => {
const res = await getAiProviderList({ page: 1, pageSize: 100 })
if (res.code === 0) {
providerList.value = res.data.list
}
}
const loadPresets = async () => {
const res = await getAiPresetList({ page: 1, pageSize: 100 })
if (res.code === 0) {
presetList.value = res.data.list
}
}
const onSubmit = () => {
page.value = 1
getTableData()
}
const onReset = () => {
searchInfo.name = ''
getTableData()
}
const handleCurrentChange = (val) => {
page.value = val
getTableData()
}
const handleSizeChange = (val) => {
pageSize.value = val
getTableData()
}
const handleStatusChange = async (row) => {
const res = await updateAiModel(row)
if (res.code === 0) {
ElMessage.success('状态更新成功')
} else {
row.enabled = !row.enabled
ElMessage.error('状态更新失败')
}
}
const openDialog = () => {
type.value = 'create'
dialogFormVisible.value = true
}
const closeDialog = () => {
dialogFormVisible.value = false
formData.value = {
name: '',
display_name: '',
provider_id: null,
preset_id: null,
max_tokens: 4096,
description: '',
enabled: true
}
}
const enterDialog = async () => {
modelForm.value.validate(async (valid) => {
if (valid) {
let res
if (type.value === 'create') {
res = await createAiModel(formData.value)
} else {
res = await updateAiModel(formData.value)
}
if (res.code === 0) {
ElMessage.success(type.value === 'create' ? '创建成功' : '更新成功')
closeDialog()
getTableData()
}
}
})
}
const updateModel = (row) => {
type.value = 'update'
formData.value = { ...row, provider_id: row.provider?.ID, preset_id: row.preset?.ID }
dialogFormVisible.value = true
}
const deleteModel = (row) => {
ElMessageBox.confirm('确定要删除该模型吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
const res = await deleteAiModel({ ID: row.ID })
if (res.code === 0) {
ElMessage.success('删除成功')
getTableData()
}
})
}
const openSyncDialog = () => {
syncProviderId.value = null
syncDialogVisible.value = true
}
const handleSync = async () => {
if (!syncProviderId.value) {
ElMessage.warning('请选择提供商')
return
}
syncing.value = true
try {
const res = await syncProviderModels({ ID: syncProviderId.value })
if (res.code === 0) {
ElMessage.success('同步成功')
syncDialogVisible.value = false
getTableData()
}
} finally {
syncing.value = false
}
}
getTableData()
loadProviders()
loadPresets()
</script>
<style scoped>
.gva-search-box {
padding: 20px;
background: #fff;
margin-bottom: 10px;
}
.gva-table-box {
padding: 20px;
background: #fff;
}
.gva-btn-list {
margin-bottom: 10px;
}
.gva-pagination {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.provider-header {
display: flex;
align-items: center;
width: 100%;
}
.model-count {
color: #606266;
font-size: 14px;
margin-right: 10px;
}
.enabled-count {
color: #67c23a;
font-size: 12px;
}
:deep(.el-collapse-item__header) {
font-weight: 500;
font-size: 15px;
}
:deep(.el-collapse-item__content) {
padding: 10px 20px;
}
</style>

View File

@@ -0,0 +1,143 @@
<template>
<el-dialog v-model="visible" title="编辑提示词" width="1000px" @close="handleClose">
<div class="prompt-editor">
<div class="toolbar">
<el-button type="primary" size="small" @click="addPrompt">
<el-icon><Plus /></el-icon>
添加提示词
</el-button>
</div>
<el-table :data="prompts" style="width: 100%" max-height="500">
<el-table-column label="启用" width="60" fixed>
<template #default="scope">
<el-switch v-model="scope.row.enabled" size="small" />
</template>
</el-table-column>
<el-table-column label="标识符" prop="identifier" width="120" />
<el-table-column label="名称" prop="name" width="150" />
<el-table-column label="角色" width="100">
<template #default="scope">
<el-select v-model="scope.row.role" size="small">
<el-option label="system" value="system" />
<el-option label="user" value="user" />
<el-option label="assistant" value="assistant" />
</el-select>
</template>
</el-table-column>
<el-table-column label="内容" min-width="200">
<template #default="scope">
<el-input
v-model="scope.row.content"
type="textarea"
:rows="2"
placeholder="提示词内容"
/>
</template>
</el-table-column>
<el-table-column label="系统提示" width="90">
<template #default="scope">
<el-checkbox v-model="scope.row.system_prompt" />
</template>
</el-table-column>
<el-table-column label="标记" width="70">
<template #default="scope">
<el-checkbox v-model="scope.row.marker" />
</template>
</el-table-column>
<el-table-column label="注入位置" width="100">
<template #default="scope">
<el-input-number v-model="scope.row.injection_position" :min="0" :max="1" size="small" />
</template>
</el-table-column>
<el-table-column label="注入深度" width="100">
<template #default="scope">
<el-input-number v-model="scope.row.injection_depth" :min="0" size="small" />
</template>
</el-table-column>
<el-table-column label="操作" width="80" fixed="right">
<template #default="scope">
<el-button type="danger" link size="small" @click="removePrompt(scope.$index)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch } from 'vue'
import { Plus } from '@element-plus/icons-vue'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
prompts: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['update:modelValue', 'save'])
const visible = ref(props.modelValue)
const prompts = ref([])
watch(() => props.modelValue, (val) => {
visible.value = val
if (val) {
prompts.value = JSON.parse(JSON.stringify(props.prompts || []))
}
})
const addPrompt = () => {
prompts.value.push({
identifier: `prompt_${Date.now()}`,
name: '新提示词',
role: 'system',
content: '',
system_prompt: false,
enabled: true,
marker: false,
injection_position: 0,
injection_depth: 4,
injection_order: 100,
injection_trigger: [],
forbid_overrides: false
})
}
const removePrompt = (index) => {
prompts.value.splice(index, 1)
}
const handleSave = () => {
emit('save', prompts.value)
handleClose()
}
const handleClose = () => {
emit('update:modelValue', false)
}
</script>
<style scoped>
.prompt-editor {
padding: 10px 0;
}
.toolbar {
margin-bottom: 15px;
}
</style>

View File

@@ -140,12 +140,36 @@
:closable="false"
style="margin-bottom: 20px"
/>
<el-input
v-model="importJson"
type="textarea"
:rows="15"
placeholder="请粘贴预设 JSON 内容"
/>
<el-tabs v-model="importTabActive">
<el-tab-pane label="文件上传" name="file">
<el-upload
ref="uploadRef"
:auto-upload="false"
:limit="1"
accept=".json"
drag
:on-change="handleFileChange"
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">
JSON 文件拖到此处<em>点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">
只支持 .json 格式的预设文件
</div>
</template>
</el-upload>
</el-tab-pane>
<el-tab-pane label="JSON 粘贴" name="json">
<el-input
v-model="importJson"
type="textarea"
:rows="15"
placeholder="请粘贴预设 JSON 内容"
/>
</el-tab-pane>
</el-tabs>
<template #footer>
<div class="dialog-footer">
<el-button @click="importDialogVisible = false">取消</el-button>
@@ -154,6 +178,13 @@
</template>
</el-dialog>
<!-- 提示词编辑器 -->
<PromptEditor
v-model="promptEditorVisible"
:prompts="formData.prompts"
@save="handlePromptSave"
/>
<!-- 查看预设对话框 -->
<el-dialog v-model="viewDialogVisible" title="预设详情" width="900px">
<el-descriptions :column="2" border>
@@ -212,13 +243,16 @@
<script setup>
import { ref, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { UploadFilled } from '@element-plus/icons-vue'
import {
getAiPresetList,
createAiPreset,
updateAiPreset,
deleteAiPreset,
importAiPreset
importAiPreset,
importAiPresetFile
} from '@/api/aiPreset'
import PromptEditor from './components/PromptEditor.vue'
const page = ref(1)
const pageSize = ref(10)
@@ -233,6 +267,10 @@ const type = ref('')
const presetForm = ref(null)
const importJson = ref('')
const viewData = ref({})
const importTabActive = ref('file')
const uploadRef = ref(null)
const uploadFile = ref(null)
const promptEditorVisible = ref(false)
const formData = ref({
name: '',
@@ -370,13 +408,34 @@ const deletePreset = (row) => {
const openImportDialog = () => {
importJson.value = ''
uploadFile.value = null
importTabActive.value = 'file'
importDialogVisible.value = true
}
const handleFileChange = (file) => {
uploadFile.value = file.raw
}
const handleImport = async () => {
try {
const data = JSON.parse(importJson.value)
const res = await importAiPreset(data)
let res
if (importTabActive.value === 'file') {
// 文件上传导入
if (!uploadFile.value) {
ElMessage.warning('请选择要上传的文件')
return
}
res = await importAiPresetFile(uploadFile.value)
} else {
// JSON 粘贴导入
if (!importJson.value.trim()) {
ElMessage.warning('请粘贴预设 JSON 内容')
return
}
const data = JSON.parse(importJson.value)
res = await importAiPreset(data)
}
if (res.code === 0) {
ElMessage.success('导入成功')
importDialogVisible.value = false
@@ -400,7 +459,12 @@ const exportPreset = (row) => {
}
const openPromptEditor = () => {
ElMessage.info('提示词编辑器功能开发中...')
promptEditorVisible.value = true
}
const handlePromptSave = (prompts) => {
formData.value.prompts = prompts
ElMessage.success('提示词已更新')
}
const openRegexEditor = () => {

View File

@@ -32,6 +32,14 @@
<el-table-column label="模型" prop="model" width="150" />
<el-table-column label="优先级" prop="priority" width="80" />
<el-table-column label="超时(秒)" prop="timeout" width="100" />
<el-table-column label="默认" width="80">
<template #default="scope">
<el-tag v-if="scope.row.is_default" type="success">默认</el-tag>
<el-button v-else type="primary" link size="small" @click="setDefault(scope.row)">
设为默认
</el-button>
</template>
</el-table-column>
<el-table-column label="状态" prop="enabled" width="80">
<template #default="scope">
<el-switch
@@ -107,6 +115,9 @@
<el-form-item label="启用" prop="enabled">
<el-switch v-model="formData.enabled" />
</el-form-item>
<el-form-item label="设为默认" prop="is_default">
<el-switch v-model="formData.is_default" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
@@ -148,7 +159,8 @@ const formData = ref({
priority: 0,
timeout: 60,
max_retries: 3,
enabled: true
enabled: true,
is_default: false
})
const rules = reactive({
@@ -237,7 +249,8 @@ const closeDialog = () => {
priority: 0,
timeout: 60,
max_retries: 3,
enabled: true
enabled: true,
is_default: false
}
}
@@ -279,6 +292,16 @@ const deleteProvider = (row) => {
})
}
const setDefault = async (row) => {
const res = await updateAiProvider({ ...row, is_default: true })
if (res.code === 0) {
ElMessage.success('已设为默认提供商')
getTableData()
} else {
ElMessage.error('设置失败')
}
}
getTableData()
</script>