diff --git a/README.md b/README.md index 15a4d6f..d036ed8 100644 --- a/README.md +++ b/README.md @@ -40,11 +40,18 @@ cp .env.example .env docker-compose up -d ``` -本地源码构建运行: +本地源码一键运行(适合本机长期调试/兼容版): ```bash -cp .env.example .env -docker compose -f docker-compose.local.yml up -d --build +bash scripts/install-local-source.sh install +``` + +默认会克隆/更新当前仓库,启动 Go 后端 `8080` 与 Next.js 前端 `3000`,数据保存在 `data/`。常用命令: + +```bash +bash scripts/install-local-source.sh status +bash scripts/install-local-source.sh restart +bash scripts/install-local-source.sh stop ``` 运行后默认端口3000,可访问 `http://localhost:3000`。 diff --git a/VERSION b/VERSION index 1474d00..0768674 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.2.0 +v0.2.0-sub2api-src diff --git a/docs/pending-test.md b/docs/pending-test.md index df1e51c..3ca118e 100644 --- a/docs/pending-test.md +++ b/docs/pending-test.md @@ -1,5 +1,7 @@ # 待测试 +- 管理后台模型渠道新增 Sub2API 兼容模式和图片返回格式配置;选择 Sub2API 后,图片生成/编辑代理会自动携带 `response_format: "url"`,并支持带 `/v1` 的 OpenAI 兼容 Base URL 不重复拼接。需要用真实 Sub2API Key 验证文本、生图、图生图/编辑图三类请求。 + - 外部软件可通过 URL 查询参数 `baseUrl`/`baseurl` 和 `apiKey`/`apikey` 跳转到前端;读取后会从地址栏移除这些参数,后台允许自定义渠道时会自动切到自定义渠道、填入配置并打开配置弹窗,未允许时会打开配置弹窗并提示无法导入。 - 生图工作台和画布生图会把参考图按当前顺序显示为 `图片1`、`图片2` 等编号,并在图生图请求的实际提示词中注入编号说明;需要验证 `/image` 参考图排序、画布配置节点输入顺序和画布助手参考图编号一致。 - GPT Image 生图请求会在前端把 `9:16`、`16:9` 等比例转换成合法 `WIDTHxHEIGHT` 尺寸,并在非法尺寸时直接显示中文错误,避免上游返回 `invalid_value Invalid size`。 diff --git a/docs/system-settings.md b/docs/system-settings.md index 0996665..dd8ae52 100644 --- a/docs/system-settings.md +++ b/docs/system-settings.md @@ -78,13 +78,17 @@ "channels": [ { "protocol": "openai", + "compatibility": "openai", "name": "默认渠道", "baseUrl": "https://api.example.com", "apiKey": "sk-xxx", "models": ["gpt-5.5", "gpt-image-2"], "weight": 1, "enabled": true, - "remark": "" + "remark": "", + "requestOptions": { + "imageResponseFormat": "b64_json" + } } ], "promptSync": { @@ -104,16 +108,20 @@ | 字段 | 类型 | 说明 | | --- | --- | --- | | `protocol` | string | 协议,当前为 `openai` | +| `compatibility` | string | 接口兼容模式,支持 `openai`、`sub2api`;选择 `sub2api` 时图片请求默认使用 URL 返回格式 | | `name` | string | 渠道名称 | -| `baseUrl` | string | OpenAI 兼容接口地址 | +| `baseUrl` | string | OpenAI 兼容接口地址;可填写带 `/v1` 的 Sub2API Base URL,后端不会重复追加 `/v1` | | `apiKey` | string | 渠道密钥 | | `models` | string[] | 该渠道可用模型 | | `weight` | number | 渠道权重;同一模型有多个可用渠道时按权重随机 | | `enabled` | boolean | 是否启用 | | `remark` | string | 备注 | +| `requestOptions.imageResponseFormat` | string | 图片生成/编辑请求的 `response_format`,支持 `b64_json`、`url`;Sub2API 默认 `url` | 后端调用模型时,会从已启用、已配置 `baseUrl` 和 `apiKey`、且 `models` 包含目标模型的渠道中选择一个。 +Sub2API 接入建议:在管理员设置的模型渠道中新增渠道,接口兼容模式选择 `Sub2API`,`baseUrl` 填 Sub2API 提供的 OpenAI 兼容地址(例如 `https://你的域名/v1`),`apiKey` 填 Sub2API 生成的密钥,并配置该 key 可用的模型。图片生成/编辑会自动携带 `response_format: "url"`,前端仍兼容 URL 与 Base64 两种返回。 + `promptSync` 字段: | 字段 | 类型 | 说明 | diff --git a/handler/ai.go b/handler/ai.go index 26d5d7d..0b60f9d 100644 --- a/handler/ai.go +++ b/handler/ai.go @@ -9,8 +9,10 @@ import ( "mime" "mime/multipart" "net/http" + "net/textproto" "strings" + "github.com/basketikun/infinite-canvas/model" "github.com/basketikun/infinite-canvas/service" ) @@ -84,6 +86,12 @@ func proxyAIRequest(w http.ResponseWriter, r *http.Request, path string) { Fail(w, "AI 接口请求失败") return } + body, contentType, err = adaptAIRequestForChannel(body, contentType, path, channel) + if err != nil { + log.Printf("AI proxy adapt request failed: model=%s path=%s err=%v", modelName, path, err) + Fail(w, "AI 接口请求失败") + return + } path = resolveAIProxyPath(channel.BaseURL, modelName, path) request, err := http.NewRequest(http.MethodPost, service.BuildModelChannelURL(channel, path), bytes.NewReader(body)) if err != nil { @@ -140,6 +148,90 @@ func copyAIResponse(w http.ResponseWriter, request *http.Request, onFailure func _, _ = io.Copy(w, response.Body) } +func adaptAIRequestForChannel(body []byte, contentType string, path string, channel model.ModelChannel) ([]byte, string, error) { + if path != "/images/generations" && path != "/images/edits" { + return body, contentType, nil + } + format := strings.ToLower(strings.TrimSpace(channel.RequestOptions.ImageResponseFormat)) + if format == "" { + if strings.EqualFold(strings.TrimSpace(channel.Compatibility), "sub2api") { + format = "url" + } else { + format = "b64_json" + } + } + if format != "url" && format != "b64_json" { + return body, contentType, nil + } + if strings.HasPrefix(contentType, "multipart/form-data") { + return setMultipartField(body, contentType, "response_format", format) + } + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return nil, "", err + } + payload["response_format"] = format + nextBody, err := json.Marshal(payload) + if err != nil { + return nil, "", err + } + return nextBody, contentType, nil +} + +func setMultipartField(body []byte, contentType string, fieldName string, value string) ([]byte, string, error) { + _, params, err := mime.ParseMediaType(contentType) + if err != nil { + return nil, "", err + } + reader := multipart.NewReader(bytes.NewReader(body), params["boundary"]) + var buffer bytes.Buffer + writer := multipart.NewWriter(&buffer) + written := false + for { + part, err := reader.NextPart() + if err == io.EOF { + break + } + if err != nil { + return nil, "", err + } + if part.FormName() == fieldName && part.FileName() == "" { + if err := writer.WriteField(fieldName, value); err != nil { + _ = part.Close() + return nil, "", err + } + written = true + _ = part.Close() + continue + } + header := make(textproto.MIMEHeader, len(part.Header)) + for key, values := range part.Header { + copied := make([]string, len(values)) + copy(copied, values) + header[key] = copied + } + target, err := writer.CreatePart(header) + if err != nil { + _ = part.Close() + return nil, "", err + } + if _, err := io.Copy(target, part); err != nil { + _ = part.Close() + return nil, "", err + } + _ = part.Close() + } + if !written { + if err := writer.WriteField(fieldName, value); err != nil { + return nil, "", err + } + } + if err := writer.Close(); err != nil { + return nil, "", err + } + return buffer.Bytes(), writer.FormDataContentType(), nil +} + func readAIRequest(r *http.Request) ([]byte, string, string, error) { contentType := r.Header.Get("Content-Type") body, err := io.ReadAll(r.Body) diff --git a/handler/ai_test.go b/handler/ai_test.go index 2eaa897..6c0c8a4 100644 --- a/handler/ai_test.go +++ b/handler/ai_test.go @@ -1,8 +1,13 @@ package handler import ( + "bytes" + "encoding/json" + "mime/multipart" "strings" "testing" + + "github.com/basketikun/infinite-canvas/model" ) func TestAIUpstreamErrorDetail(t *testing.T) { @@ -25,3 +30,51 @@ func TestSafeUpstreamTextTruncates(t *testing.T) { t.Fatalf("truncated rune length = %d", len([]rune(got))) } } + +func TestAdaptAIRequestForSub2APIImagesUsesURLResponseFormat(t *testing.T) { + body, contentType, err := adaptAIRequestForChannel([]byte(`{"model":"gpt-image-1","prompt":"cat"}`), "application/json", "/images/generations", model.ModelChannel{Compatibility: "sub2api"}) + if err != nil { + t.Fatalf("adaptAIRequestForChannel returned error: %v", err) + } + if contentType != "application/json" { + t.Fatalf("contentType = %q", contentType) + } + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("invalid json body: %v", err) + } + if got := payload["response_format"]; got != "url" { + t.Fatalf("response_format = %#v, want url", got) + } +} + +func TestAdaptAIRequestForSub2APIImageEditsUpdatesMultipartResponseFormat(t *testing.T) { + var input bytes.Buffer + writer := multipart.NewWriter(&input) + _ = writer.WriteField("model", "gpt-image-1") + _ = writer.WriteField("response_format", "b64_json") + part, err := writer.CreateFormFile("image", "input.png") + if err != nil { + t.Fatalf("CreateFormFile: %v", err) + } + _, _ = part.Write([]byte("png")) + if err := writer.Close(); err != nil { + t.Fatalf("Close multipart writer: %v", err) + } + + body, contentType, err := adaptAIRequestForChannel(input.Bytes(), writer.FormDataContentType(), "/images/edits", model.ModelChannel{Compatibility: "sub2api"}) + if err != nil { + t.Fatalf("adaptAIRequestForChannel returned error: %v", err) + } + reader := multipart.NewReader(bytes.NewReader(body), strings.TrimPrefix(contentType, "multipart/form-data; boundary=")) + form, err := reader.ReadForm(1024) + if err != nil { + t.Fatalf("ReadForm: %v", err) + } + if got := form.Value["response_format"]; len(got) != 1 || got[0] != "url" { + t.Fatalf("response_format = %#v, want [url]", got) + } + if files := form.File["image"]; len(files) != 1 || files[0].Filename != "input.png" { + t.Fatalf("image file = %#v", files) + } +} diff --git a/model/setting.go b/model/setting.go index d6d4a1f..8b4eb3f 100644 --- a/model/setting.go +++ b/model/setting.go @@ -11,14 +11,23 @@ const ( // ModelChannel 模型渠道配置。 type ModelChannel struct { - Protocol string `json:"protocol"` - Name string `json:"name"` - BaseURL string `json:"baseUrl"` - APIKey string `json:"apiKey"` - Models []string `json:"models"` - Weight int `json:"weight"` - Enabled bool `json:"enabled"` - Remark string `json:"remark"` + Protocol string `json:"protocol"` + Compatibility string `json:"compatibility"` + Name string `json:"name"` + BaseURL string `json:"baseUrl"` + APIKey string `json:"apiKey"` + Models []string `json:"models"` + Weight int `json:"weight"` + Enabled bool `json:"enabled"` + Remark string `json:"remark"` + RequestOptions ModelRequestOptions `json:"requestOptions"` +} + +// ModelRequestOptions 控制 OpenAI 兼容接口的可选请求参数。 +type ModelRequestOptions struct { + // ImageResponseFormat 为图片生成/编辑请求设置 response_format。 + // OpenAI 默认使用 b64_json;Sub2API 推荐使用 url,避免部分上游不返回 b64_json。 + ImageResponseFormat string `json:"imageResponseFormat"` } // ModelCost 模型算力点配置。 diff --git a/scripts/install-local-source.sh b/scripts/install-local-source.sh new file mode 100755 index 0000000..5eaa67f --- /dev/null +++ b/scripts/install-local-source.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +APP_NAME="infinite-canvas" +REPO_URL="${REPO_URL:-https://gitea.chickliu.fun/Hermes/infinite-canvas.git}" +APP_DIR="${APP_DIR:-$HOME/.Hermes/workspace/git/infinite-canvas}" +HOST="${HOST:-0.0.0.0}" +WEB_PORT="${WEB_PORT:-3000}" +API_PORT="${API_PORT:-8080}" +DATA_DIR="${DATA_DIR:-$APP_DIR/data}" +DB_PATH="${DATABASE_DSN:-$DATA_DIR/infinite-canvas.db}" +PROMPT_DATA_DIR="${PROMPT_DATA_DIR:-$DATA_DIR/prompts}" +LOG_DIR="${LOG_DIR:-$APP_DIR/logs}" +PID_DIR="${PID_DIR:-$APP_DIR/.pids}" +MODE="${1:-start}" + +info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33mWARN:\033[0m %s\n' "$*" >&2; } +fail() { printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2; exit 1; } +need_cmd() { command -v "$1" >/dev/null 2>&1 || fail "缺少命令:$1"; } + +ensure_repo() { + if [ -d "$APP_DIR/.git" ]; then + info "使用现有代码目录:$APP_DIR" + return 0 + fi + info "克隆代码到:$APP_DIR" + mkdir -p "$(dirname "$APP_DIR")" + git clone "$REPO_URL" "$APP_DIR" +} + +ensure_deps() { + need_cmd git + need_cmd go + need_cmd npm + mkdir -p "$DATA_DIR" "$PROMPT_DATA_DIR" "$LOG_DIR" "$PID_DIR" + if [ ! -d "$APP_DIR/web/node_modules" ]; then + info "安装前端依赖" + (cd "$APP_DIR/web" && npm install --legacy-peer-deps) + fi +} + +kill_pid_file() { + local file="$1" + if [ -f "$file" ]; then + local pid + pid="$(cat "$file" 2>/dev/null || true)" + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + kill "$pid" 2>/dev/null || true + for _ in $(seq 1 20); do + kill -0 "$pid" 2>/dev/null || break + sleep 0.2 + done + kill -9 "$pid" 2>/dev/null || true + fi + rm -f "$file" + fi +} + +stop_app() { + info "停止 $APP_NAME 源码服务" + kill_pid_file "$PID_DIR/backend.pid" + kill_pid_file "$PID_DIR/frontend.pid" +} + +start_app() { + ensure_repo + ensure_deps + stop_app + + info "启动后端:http://127.0.0.1:$API_PORT" + ( + cd "$APP_DIR" + PORT="$API_PORT" \ + DATABASE_DSN="$DB_PATH" \ + PROMPT_DATA_DIR="$PROMPT_DATA_DIR" \ + nohup go run . >>"$LOG_DIR/backend.log" 2>&1 & + echo $! >"$PID_DIR/backend.pid" + ) + + info "启动前端:http://127.0.0.1:$WEB_PORT" + ( + cd "$APP_DIR/web" + API_BASE_URL="http://127.0.0.1:$API_PORT" \ + nohup npm run dev -- -H "$HOST" -p "$WEB_PORT" >>"$LOG_DIR/frontend.log" 2>&1 & + echo $! >"$PID_DIR/frontend.pid" + ) + + info "等待服务就绪" + for _ in $(seq 1 120); do + if curl -fsS "http://127.0.0.1:$API_PORT/api/health" >/dev/null 2>&1 && \ + curl -fsS "http://127.0.0.1:$WEB_PORT/api/health" >/dev/null 2>&1; then + info "启动完成" + printf '前端: http://127.0.0.1:%s\n' "$WEB_PORT" + printf '后端: http://127.0.0.1:%s\n' "$API_PORT" + printf '账号: admin\n' + printf '默认密码: infinite-canvas(如已改过,以数据库中现有用户为准)\n' + return 0 + fi + sleep 1 + done + warn "服务未在预期时间内完成健康检查,请查看日志:" + printf ' tail -f %q %q\n' "$LOG_DIR/backend.log" "$LOG_DIR/frontend.log" + return 1 +} + +status_app() { + for name in backend frontend; do + local file="$PID_DIR/$name.pid" + if [ -f "$file" ] && kill -0 "$(cat "$file")" 2>/dev/null; then + printf '%s: running pid=%s\n' "$name" "$(cat "$file")" + else + printf '%s: stopped\n' "$name" + fi + done + curl -fsS "http://127.0.0.1:$API_PORT/api/health" 2>/dev/null && printf ' backend_http=ok\n' || true + curl -fsS "http://127.0.0.1:$WEB_PORT/api/health" 2>/dev/null && printf ' frontend_proxy=ok\n' || true +} + +case "$MODE" in + start|install) start_app ;; + stop) stop_app ;; + restart) stop_app; start_app ;; + status) status_app ;; + *) + cat <= 0 { + segment = pathValue[slash+1:] + } + segment = strings.ToLower(strings.TrimSpace(segment)) + if len(segment) < 2 || segment[0] != 'v' || !isASCIIDigit(segment[1]) { + return false + } + i := 1 + for i < len(segment) && isASCIIDigit(segment[i]) { + i++ + } + if i == len(segment) { + return true + } + if segment[i] == '.' { + i++ + if i == len(segment) || !isASCIIDigit(segment[i]) { + return false + } + for i < len(segment) && isASCIIDigit(segment[i]) { + i++ + } + return i == len(segment) + } + suffix := segment[i:] + return strings.HasPrefix(suffix, "alpha") || strings.HasPrefix(suffix, "beta") || strings.HasPrefix(suffix, "preview") +} + +func isASCIIDigit(ch byte) bool { + return ch >= '0' && ch <= '9' +} + func normalizeModelChannelBaseURL(baseURL string) string { baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") parsed, err := url.Parse(baseURL) @@ -299,6 +343,8 @@ func normalizeModelChannel(channel model.ModelChannel) model.ModelChannel { if channel.Protocol == "" { channel.Protocol = "openai" } + channel.Compatibility = normalizeChannelCompatibility(channel.Compatibility) + channel.RequestOptions = normalizeModelRequestOptions(channel.Compatibility, channel.RequestOptions) if channel.Models == nil { channel.Models = []string{} } @@ -308,6 +354,30 @@ func normalizeModelChannel(channel model.ModelChannel) model.ModelChannel { return channel } +func normalizeChannelCompatibility(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "sub2api": + return "sub2api" + default: + return "openai" + } +} + +func normalizeModelRequestOptions(compatibility string, options model.ModelRequestOptions) model.ModelRequestOptions { + format := strings.ToLower(strings.TrimSpace(options.ImageResponseFormat)) + switch format { + case "url", "b64_json": + options.ImageResponseFormat = format + default: + if normalizeChannelCompatibility(compatibility) == "sub2api" { + options.ImageResponseFormat = "url" + } else { + options.ImageResponseFormat = "b64_json" + } + } + return options +} + func resolveAdminChannel(index *int, channel model.ModelChannel) (model.ModelChannel, error) { resolved := normalizeModelChannel(channel) if strings.TrimSpace(resolved.APIKey) == "" { @@ -380,6 +450,9 @@ func testAdminChannelModel(channel model.ModelChannel, modelName string) (string if strings.TrimSpace(modelName) == "" { return "", errors.New("缺少模型名称") } + if isImageModelName(modelName) { + return testAdminImageChannelModel(channel, modelName) + } body, _ := json.Marshal(map[string]any{ "model": modelName, "messages": []map[string]string{{ @@ -416,6 +489,40 @@ func testAdminChannelModel(channel model.ModelChannel, modelName string) (string return "ok", nil } +func testAdminImageChannelModel(channel model.ModelChannel, modelName string) (string, error) { + format := strings.ToLower(strings.TrimSpace(channel.RequestOptions.ImageResponseFormat)) + if format == "" { + if strings.EqualFold(strings.TrimSpace(channel.Compatibility), "sub2api") { + format = "url" + } else { + format = "b64_json" + } + } + body, _ := json.Marshal(map[string]any{ + "model": modelName, + "prompt": "test", + "n": 1, + "size": "1024x1024", + "response_format": format, + }) + request, err := http.NewRequest(http.MethodPost, BuildModelChannelURL(channel, "/images/generations"), strings.NewReader(string(body))) + if err != nil { + return "", err + } + request.Header.Set("Authorization", "Bearer "+channel.APIKey) + request.Header.Set("Content-Type", "application/json") + response, err := adminModelHTTPClient.Do(request) + if err != nil { + return "", safeMessageError{message: "测试失败:上游接口无响应或网络不可达"} + } + defer response.Body.Close() + responseBody, _ := io.ReadAll(response.Body) + if response.StatusCode >= http.StatusBadRequest { + return "", readAdminChannelError(responseBody, response.StatusCode, "测试失败") + } + return "ok", nil +} + func testArkSeedanceChannelModel(channel model.ModelChannel, modelName string) (string, error) { if strings.TrimSpace(modelName) == "" { return "", errors.New("缺少模型名称") diff --git a/service/settings_test.go b/service/settings_test.go index 27c6b2e..eb29c68 100644 --- a/service/settings_test.go +++ b/service/settings_test.go @@ -1,6 +1,7 @@ package service import ( + "encoding/json" "net/http" "net/http/httptest" "reflect" @@ -61,6 +62,56 @@ func TestBuildModelChannelURLNormalizesArkPlanTaskPath(t *testing.T) { } } +func TestBuildModelChannelURLAcceptsVersionedSub2APIBaseURL(t *testing.T) { + got := BuildModelChannelURL(model.ModelChannel{BaseURL: "https://sub2api.example.com/v1"}, "/images/generations") + want := "https://sub2api.example.com/v1/images/generations" + if got != want { + t.Fatalf("BuildModelChannelURL = %q, want %q", got, want) + } +} + +func TestNormalizePrivateSettingDefaultsSub2APIImageResponseFormatToURL(t *testing.T) { + setting := normalizePrivateSetting(model.PrivateSetting{Channels: []model.ModelChannel{{Compatibility: "sub2api"}}}) + if got := setting.Channels[0].RequestOptions.ImageResponseFormat; got != "url" { + t.Fatalf("image response format = %q, want url", got) + } +} + +func TestAdminTestChannelModelUsesImageGenerationProbeForImageModels(t *testing.T) { + var gotPath string + var gotPayload map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + if err := json.NewDecoder(r.Body).Decode(&gotPayload); err != nil { + t.Fatalf("Decode request body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"url":"https://example.com/image.png"}]}`)) + })) + defer server.Close() + + result, err := testAdminChannelModel(model.ModelChannel{ + Compatibility: "sub2api", + BaseURL: server.URL + "/v1", + APIKey: "test-key", + }, "gpt-image-2") + if err != nil { + t.Fatalf("testAdminChannelModel returned error: %v", err) + } + if result != "ok" { + t.Fatalf("result = %q, want ok", result) + } + if gotPath != "/v1/images/generations" { + t.Fatalf("path = %q, want /v1/images/generations", gotPath) + } + if gotPayload["model"] != "gpt-image-2" { + t.Fatalf("model = %#v", gotPayload["model"]) + } + if gotPayload["response_format"] != "url" { + t.Fatalf("response_format = %#v, want url", gotPayload["response_format"]) + } +} + func TestNormalizeSettingsPublishesEnabledChannelModelsAndRepairsDefaults(t *testing.T) { settings := normalizeSettings(model.Settings{ Public: model.PublicSetting{ diff --git a/web/src/app/(admin)/admin/settings/page.tsx b/web/src/app/(admin)/admin/settings/page.tsx index 7b5029f..84f64ed 100644 --- a/web/src/app/(admin)/admin/settings/page.tsx +++ b/web/src/app/(admin)/admin/settings/page.tsx @@ -40,7 +40,7 @@ const emptySettings: AdminSettings = { }, private: { channels: [], promptSync: { enabled: true, cron: "*/5 * * * *" }, auth: { linuxDo: { clientId: "", clientSecret: "" } } }, }; -const emptyChannel: AdminModelChannel = { protocol: "openai", name: "", baseUrl: "", apiKey: "", models: [], weight: 1, enabled: true, remark: "" }; +const emptyChannel: AdminModelChannel = { protocol: "openai", compatibility: "openai", name: "", baseUrl: "", apiKey: "", models: [], weight: 1, enabled: true, remark: "", requestOptions: { imageResponseFormat: "b64_json" } }; type SettingsTabKey = "public" | "private"; type EditorMode = "visual" | "json"; @@ -467,15 +467,17 @@ export default function AdminSettingsPage() { dataIndex: "credits", width: 220, render: (_, item) => ( - setModelCost(form, setModelCosts, item.model, Number(value) || 0)} - /> + + setModelCost(form, setModelCosts, item.model, Number(value) || 0)} + /> + + ), }, ]} @@ -558,6 +560,7 @@ export default function AdminSettingsPage() { columns={[ { title: "名称", dataIndex: "name", render: (value) => value || "未命名渠道" }, { title: "协议", dataIndex: "protocol", width: 96, render: (value) => {value || "openai"} }, + { title: "兼容", dataIndex: "compatibility", width: 110, render: (value) => {value === "sub2api" ? "Sub2API" : "OpenAI"} }, { title: "状态", dataIndex: "enabled", width: 96, render: (value) => {value ? "已启用" : "已停用"} }, { title: "模型", @@ -640,6 +643,20 @@ export default function AdminSettingsPage() { { + if (value === "sub2api") channelForm.setFieldValue(["requestOptions", "imageResponseFormat"], "url"); + if (value === "openai") channelForm.setFieldValue(["requestOptions", "imageResponseFormat"], "b64_json"); + }} + /> + + @@ -670,6 +687,16 @@ export default function AdminSettingsPage() { + + +