feat(admin): 模型管理 - 支持从上游渠道拉取模型目录 (#36)
以 main 为基线仅补齐拉取模型能力:管理员可一键导入缺失模型(默认停用待定价),并提供登录态后端代理目录接口避免 CORS。 Co-authored-by: ddcat666 <2695092045@qq.com>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
# CHANGELOG
|
||||
# CHANGELOG
|
||||
|
||||
## Unreleased
|
||||
|
||||
- 管理员可从上游渠道一键拉取模型目录;缺失模型自动导入为停用待定价,避免浏览器直连上游时的 CORS 问题。
|
||||
- 管理后台新增统一资源与请求策略配置,支持默认重置、自用模式、即时生效和用户配额用量查看。
|
||||
|
||||
## v1.0.4
|
||||
|
||||
@@ -130,7 +130,7 @@ docker compose -f docker-compose.local.yml up -d --build
|
||||
|
||||
## 数据说明
|
||||
|
||||
- 用户自定义 AI API Key 保存在浏览器本地;创建异步任务时会提交给自部署后端并加密入队,生产环境必须使用 HTTPS。
|
||||
- 用户自定义 AI API Key 保存在浏览器本地;登录态拉取模型目录时会临时提交给自部署后端但不会保存,创建异步任务时会加密入队;仅应使用可信部署,生产环境必须启用 HTTPS。
|
||||
- 画布和素材登录后同步到后端,本地 `localForage` 继续承担缓存和降级存储。
|
||||
- 媒体资源在启用 OSS 时保存到私有 OSS,否则保存到后端数据目录;删除业务记录不会自动清理 OSS 对象。
|
||||
- 用户主动上传、Agent 会话附件和 AI 生成资源的单文件上限、账号容量及 UTC 日上传总量由后台“资源与策略”统一维护,默认分别为 50MB、32MB、64MB、2GB 和 200MB;管理员可按可信部署需要调整,单文件业务上限最高 999MB,Nginx 请求体硬上限为 1024MB。
|
||||
|
||||
@@ -71,6 +71,8 @@ func main() {
|
||||
handler.RegisterAdminAnalyticsRoutes(api, svc)
|
||||
handler.RegisterAnnouncementRoutes(api, svc)
|
||||
handler.RegisterFinanceRoutes(api, svc)
|
||||
// 登录态模型目录代理:避免浏览器直连各上游时分别处理 CORS。
|
||||
handler.RegisterChannelModelRoutes(api, svc)
|
||||
handler.RegisterSystemProxyRoutes(api, svc)
|
||||
handler.RegisterCustomRelayRoutes(api, svc)
|
||||
handler.RegisterTaskRoutes(api, svc)
|
||||
|
||||
36
backend/internal/handler/channel_models.go
Normal file
36
backend/internal/handler/channel_models.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func RegisterChannelModelRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
r.POST("/ai/models", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
if !enforceRateLimit(c, "channel-models:"+user.ID, 30, time.Minute) {
|
||||
return
|
||||
}
|
||||
var input service.ChannelModelsRequest
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
fail(c, http.StatusBadRequest, errors.New("模型渠道参数格式错误"))
|
||||
return
|
||||
}
|
||||
// handler 不接触上游响应细节,密钥校验和模型目录解析统一收敛到 service。
|
||||
models, err := svc.FetchChannelModels(c.Request.Context(), user, input)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"models": models})
|
||||
})
|
||||
}
|
||||
@@ -205,6 +205,23 @@ func RegisterFinanceRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
}
|
||||
ok(c, gin.H{"models": items})
|
||||
})
|
||||
r.POST("/admin/channels/:id/models/fetch", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
if !enforceRateLimit(c, "admin-channel-models-fetch:"+user.ID+":"+c.Param("id"), 10, time.Minute) {
|
||||
return
|
||||
}
|
||||
// 上游密钥只在 service 内使用,handler 仅返回去重后的模型标识和新增数量。
|
||||
result, err := svc.FetchAdminChannelModels(c.Request.Context(), user, c.Param("id"))
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, result)
|
||||
})
|
||||
r.POST("/admin/channels/:id/models", func(c *gin.Context) {
|
||||
saveChannelModel(c, svc, "")
|
||||
})
|
||||
|
||||
@@ -93,6 +93,15 @@ func (r *Repository) SaveChannelModel(item *model.ChannelModel) error {
|
||||
return r.db.Save(item).Error
|
||||
}
|
||||
|
||||
func (r *Repository) CreateMissingChannelModels(items []model.ChannelModel) (int64, error) {
|
||||
if len(items) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
// 拉取目录可能与其他管理员操作并发,唯一键冲突时保留已有定价配置。
|
||||
result := r.db.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "channel_id"}, {Name: "model_key"}}, DoNothing: true}).Create(&items)
|
||||
return result.RowsAffected, result.Error
|
||||
}
|
||||
|
||||
func (r *Repository) CreditAccount(userID string) (*model.CreditAccount, error) {
|
||||
account := model.CreditAccount{UserID: userID}
|
||||
if err := r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&account).Error; err != nil {
|
||||
|
||||
128
backend/internal/service/channel_model_catalog.go
Normal file
128
backend/internal/service/channel_model_catalog.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
)
|
||||
|
||||
type ChannelModelsRequest struct {
|
||||
BaseURL string `json:"baseUrl"`
|
||||
APIKey string `json:"apiKey"`
|
||||
APIFormat string `json:"apiFormat"`
|
||||
}
|
||||
|
||||
type channelModelsPayload struct {
|
||||
Data []channelModelItem `json:"data"`
|
||||
Models []channelModelItem `json:"models"`
|
||||
Error *providerError `json:"error"`
|
||||
Code *int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
|
||||
type channelModelItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (s *Service) FetchChannelModels(ctx context.Context, actor *model.User, input ChannelModelsRequest) ([]string, error) {
|
||||
if actor == nil || strings.TrimSpace(actor.ID) == "" {
|
||||
return nil, Unauthorized("请先登录")
|
||||
}
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(input.BaseURL), "/")
|
||||
apiKey := strings.TrimSpace(input.APIKey)
|
||||
if baseURL == "" {
|
||||
return nil, BadAuthRequest("请填写 Base URL")
|
||||
}
|
||||
if apiKey == "" {
|
||||
return nil, BadAuthRequest("请填写 API Key")
|
||||
}
|
||||
apiFormat := strings.ToLower(strings.TrimSpace(input.APIFormat))
|
||||
if apiFormat == "" {
|
||||
apiFormat = "openai"
|
||||
}
|
||||
if apiFormat != "openai" && apiFormat != "gemini" {
|
||||
return nil, BadAuthRequest("接口协议不支持拉取模型")
|
||||
}
|
||||
|
||||
target := apiURL(baseURL, "/models")
|
||||
if apiFormat == "gemini" {
|
||||
if !strings.HasSuffix(strings.ToLower(baseURL), "/v1beta") {
|
||||
baseURL += "/v1beta"
|
||||
}
|
||||
target = baseURL + "/models"
|
||||
}
|
||||
if _, err := ValidateOutboundURL(target); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
return nil, BadAuthRequest("模型服务地址无效")
|
||||
}
|
||||
if apiFormat == "gemini" {
|
||||
request.Header.Set("x-goog-api-key", apiKey)
|
||||
} else {
|
||||
request.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
}
|
||||
|
||||
// 只代理固定的模型目录 GET;用户密钥仅用于本次请求,不写入数据库或日志。
|
||||
data, _, err := doBinary(request)
|
||||
if err != nil {
|
||||
return nil, channelModelsUpstreamError(err)
|
||||
}
|
||||
var payload channelModelsPayload
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return nil, &AuthError{Status: http.StatusBadGateway, Message: "模型服务返回的不是有效 JSON"}
|
||||
}
|
||||
if payload.Error != nil && strings.TrimSpace(payload.Error.Message) != "" {
|
||||
return nil, &AuthError{Status: http.StatusBadGateway, Message: payload.Error.Message}
|
||||
}
|
||||
if payload.Code != nil && *payload.Code != 0 {
|
||||
return nil, &AuthError{Status: http.StatusBadGateway, Message: firstNonEmpty(strings.TrimSpace(payload.Msg), "模型服务返回失败")}
|
||||
}
|
||||
|
||||
items := payload.Data
|
||||
if apiFormat == "gemini" {
|
||||
items = payload.Models
|
||||
}
|
||||
seen := make(map[string]bool, len(items))
|
||||
models := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
name := strings.TrimPrefix(strings.TrimSpace(firstNonEmpty(item.ID, item.Name)), "models/")
|
||||
if name == "" || seen[name] {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
models = append(models, name)
|
||||
}
|
||||
sort.Strings(models)
|
||||
return models, nil
|
||||
}
|
||||
|
||||
func channelModelsUpstreamError(err error) error {
|
||||
var authErr *AuthError
|
||||
if errors.As(err, &authErr) {
|
||||
return authErr
|
||||
}
|
||||
var httpErr providerHTTPError
|
||||
if !errors.As(err, &httpErr) {
|
||||
return &AuthError{Status: http.StatusBadGateway, Message: "连接模型服务失败:" + err.Error()}
|
||||
}
|
||||
switch httpErr.StatusCode {
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
return &AuthError{Status: http.StatusBadGateway, Message: "模型服务鉴权失败,请检查 API Key"}
|
||||
case http.StatusNotFound:
|
||||
return &AuthError{Status: http.StatusBadGateway, Message: "模型服务未提供 /models 接口"}
|
||||
case http.StatusTooManyRequests:
|
||||
return &AuthError{Status: http.StatusBadGateway, Message: "模型服务请求过于频繁或额度不足"}
|
||||
default:
|
||||
return &AuthError{Status: http.StatusBadGateway, Message: fmt.Sprintf("模型服务请求失败:HTTP %d", httpErr.StatusCode)}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
@@ -17,6 +18,12 @@ type ChannelModelRequest struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// AdminChannelModelFetchResult 是管理员从上游拉目录后的汇总:models 为去重后的标识,added 为本次新建条数。
|
||||
type AdminChannelModelFetchResult struct {
|
||||
Models []string `json:"models"`
|
||||
Added int64 `json:"added"`
|
||||
}
|
||||
|
||||
func (s *Service) EnsureSystemChannelModels() error {
|
||||
channels, err := s.repo.SystemChannels(true)
|
||||
if err != nil {
|
||||
@@ -46,6 +53,42 @@ func (s *Service) AdminChannelModels(actor *model.User, channelID string) ([]mod
|
||||
return s.ensureChannelModels(channelID, true)
|
||||
}
|
||||
|
||||
func (s *Service) FetchAdminChannelModels(ctx context.Context, actor *model.User, channelID string) (*AdminChannelModelFetchResult, error) {
|
||||
if err := s.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
channel, err := s.repo.AdminSystemChannel(channelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 使用服务端保存的渠道密钥请求上游,避免密钥为了拉目录再次经过浏览器。
|
||||
models, err := s.FetchChannelModels(ctx, actor, ChannelModelsRequest{BaseURL: channel.BaseURL, APIKey: channel.APIKey, APIFormat: channel.APIFormat})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existing, err := s.repo.ChannelModels(channelID, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
known := make(map[string]struct{}, len(existing))
|
||||
for _, item := range existing {
|
||||
known[item.ModelKey] = struct{}{}
|
||||
}
|
||||
missing := make([]model.ChannelModel, 0, len(models))
|
||||
for _, name := range models {
|
||||
if _, ok := known[name]; ok {
|
||||
continue
|
||||
}
|
||||
// 自动发现不能绕过定价边界;新模型由管理员定价后再手动启用。
|
||||
missing = append(missing, model.ChannelModel{ID: newID(), ChannelID: channelID, ModelKey: name, DisplayName: name, Capability: capabilityForChannel(*channel), BillingMode: "fixed_request", Enabled: false, PriceVersion: 1})
|
||||
}
|
||||
added, err := s.repo.CreateMissingChannelModels(missing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &AdminChannelModelFetchResult{Models: models, Added: added}, nil
|
||||
}
|
||||
|
||||
func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id string, req ChannelModelRequest) (*model.ChannelModel, error) {
|
||||
if err := s.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { App, Button, Drawer, Form, Input, InputNumber, Popconfirm, Select, Space, Switch, Table, Tag } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { ArrowLeft, Plus, Search, Trash2 } from "lucide-react";
|
||||
import { ArrowLeft, Plus, RefreshCw, Search, Trash2 } from "lucide-react";
|
||||
|
||||
import { ListToolbar, TableSurface } from "@/components/layout/workspace-page";
|
||||
import { createAdminChannelModel, disableAdminChannelModel, listAdminChannelModels, updateAdminChannelModel, type ChannelModel } from "@/services/api/wallet";
|
||||
import { createAdminChannelModel, disableAdminChannelModel, fetchAdminChannelModels, listAdminChannelModels, updateAdminChannelModel, type ChannelModel } from "@/services/api/wallet";
|
||||
import type { ModelChannel } from "@/stores/use-config-store";
|
||||
|
||||
type FormValues = {
|
||||
@@ -20,6 +20,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
const [items, setItems] = useState<ChannelModel[]>([]);
|
||||
const [editing, setEditing] = useState<ChannelModel | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
@@ -51,6 +52,23 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
setPage(1);
|
||||
}, [channel.id]);
|
||||
|
||||
const fetchModels = async () => {
|
||||
setFetching(true);
|
||||
try {
|
||||
// 拉取只导入缺失项;新模型仍需管理员定价并手动启用。
|
||||
const result = await fetchAdminChannelModels(channel.id);
|
||||
await reload();
|
||||
await onChanged();
|
||||
if (result.models.length === 0) message.warning("上游没有返回可用模型");
|
||||
else if (result.added > 0) message.success(`已拉取 ${result.models.length} 个模型,新增 ${result.added} 个待配置模型`);
|
||||
else message.info(`已拉取 ${result.models.length} 个模型,没有需要新增的模型`);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "拉取模型失败");
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startCreate = () => {
|
||||
setEditing(null);
|
||||
form.setFieldsValue({ modelKey: "", displayName: "", capability: capabilityFromInterface(channel?.interfaceType), unitPrice: 0, enabled: true });
|
||||
@@ -148,7 +166,14 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
<p className="mt-1 text-xs text-foreground/50">维护此渠道的模型能力、启用状态和每次积分价格。</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="primary" icon={<Plus className="size-4" />} onClick={startCreate}>新增模型</Button>
|
||||
<Space wrap>
|
||||
<Button loading={fetching} icon={<RefreshCw className="size-4" />} onClick={() => void fetchModels()}>
|
||||
拉取模型
|
||||
</Button>
|
||||
<Button type="primary" icon={<Plus className="size-4" />} onClick={startCreate}>
|
||||
新增模型
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<ListToolbar active={Boolean(keyword || capability !== "all" || status !== "all")} onReset={() => { setKeyword(""); setCapability("all"); setStatus("all"); setPage(1); }}>
|
||||
<Input allowClear className="w-full sm:w-72" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索模型标识或显示名称" onChange={(event) => { setKeyword(event.target.value); setPage(1); }} />
|
||||
|
||||
@@ -913,8 +913,28 @@ export async function fetchImageModels(config: Pick<AiConfig, "baseUrl" | "apiKe
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchChannelModels(channel: ModelChannel) {
|
||||
return fetchImageModels({ baseUrl: channel.baseUrl, apiKey: channel.apiKey, apiFormat: channel.apiFormat });
|
||||
export async function fetchChannelModels(channel: ModelChannel, viaBackend = false) {
|
||||
if (!viaBackend) {
|
||||
return fetchImageModels({ baseUrl: channel.baseUrl, apiKey: channel.apiKey, apiFormat: channel.apiFormat });
|
||||
}
|
||||
try {
|
||||
// 登录态由同源后端代取模型目录,避免每个 OpenAI 兼容服务分别维护浏览器 CORS 白名单。
|
||||
const response = await axios.post<{ code?: number; data?: { models?: string[] }; msg?: string }>(
|
||||
resolveBackendApiUrl("/api/ai/models"),
|
||||
{
|
||||
baseUrl: channel.baseUrl,
|
||||
apiKey: channel.apiKey,
|
||||
apiFormat: channel.apiFormat,
|
||||
},
|
||||
{ withCredentials: true },
|
||||
);
|
||||
if (typeof response.data.code === "number" && response.data.code !== 0) {
|
||||
throw new Error(response.data.msg || "读取模型失败");
|
||||
}
|
||||
return Array.from(new Set((response.data.data?.models || []).map((model) => model.trim()).filter(Boolean))).sort((a, b) => a.localeCompare(b));
|
||||
} catch (error) {
|
||||
throw new Error(readAxiosError(error, "读取模型失败"));
|
||||
}
|
||||
}
|
||||
|
||||
const defaultGeminiConfig: Pick<AiConfig, "baseUrl" | "apiKey" | "apiFormat" | "model" | "systemPrompt"> = {
|
||||
|
||||
@@ -218,6 +218,11 @@ export function listAdminChannelModels(channelId: string) {
|
||||
return request<{ models: ChannelModel[] }>(api.get(`/admin/channels/${encodeURIComponent(channelId)}/models`));
|
||||
}
|
||||
|
||||
// 管理员从上游拉取模型目录;服务端只导入缺失项,价格和启用仍需人工确认。
|
||||
export function fetchAdminChannelModels(channelId: string) {
|
||||
return request<{ models: string[]; added: number }>(api.post(`/admin/channels/${encodeURIComponent(channelId)}/models/fetch`));
|
||||
}
|
||||
|
||||
export function createAdminChannelModel(channelId: string, input: Omit<ChannelModel, "id" | "channelId" | "priceVersion" | "createdAt" | "updatedAt">) {
|
||||
return request<{ model: ChannelModel }>(api.post(`/admin/channels/${encodeURIComponent(channelId)}/models`, input));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user