diff --git a/.env.example b/.env.example index 4708dad..cf0319d 100644 --- a/.env.example +++ b/.env.example @@ -9,12 +9,17 @@ JWT_EXPIRE_HOURS=168 # 后端监听端口 PORT=8080 +# 公开访问地址,用于把本地上传的 Seedance 参考图/视频暴露给火山方舟拉取。 +# 线上部署时填写站点根地址,例如:https://your-domain.example.com +# PUBLIC_BASE_URL=https://your-domain.example.com + # 前端开发代理默认使用 http://127.0.0.1:8080,如后端开发端口不同,启动前端时可单独设置 API_BASE_URL。 # API_BASE_URL=http://127.0.0.1:8080 # 数据库配置,默认使用本地 SQLite。 STORAGE_DRIVER=sqlite # sqlite: DATABASE_DSN=data/infinite-canvas.db +# Docker 部署时建议使用绝对路径,避免工作目录变化后写入临时库:DATABASE_DSN=/app/data/infinite-canvas.db # mysql: DATABASE_DSN=user:password@tcp(127.0.0.1:3306)/infinite_canvas?parseTime=true # postgres: DATABASE_DSN=postgres://user:password@127.0.0.1:5432/infinite_canvas?sslmode=disable DATABASE_DSN=data/infinite-canvas.db diff --git a/README.md b/README.md index 077c2cc..15a4d6f 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ ## 核心功能 - 无限画布:多画布项目、节点拖拽缩放、连线、小地图、撤销重做、导入导出。 -- AI 创作:支持 OpenAI 兼容接口的文生图、图生图、参考图编辑和文本问答。 +- AI 创作:支持 OpenAI 兼容接口的文生图、图生图、参考图编辑、文本问答和视频生成;Seedance 2.0 可通过火山方舟 Agent Plan 接入。 - 画布助手:围绕选中节点和上游节点对话、生图,并把结果插回画布。 - 提示词库:抓取多个 GitHub 开源项目,按案例整理数百个图片提示词。 diff --git a/config/config.go b/config/config.go index f91f51f..a4ed42c 100644 --- a/config/config.go +++ b/config/config.go @@ -3,6 +3,8 @@ package config import ( "crypto/rand" "encoding/base64" + "os" + "path/filepath" "strings" "github.com/caarlos0/env/v11" @@ -17,6 +19,7 @@ type Config struct { JWTExpireHours int `env:"JWT_EXPIRE_HOURS" envDefault:"168"` StorageDriver string `env:"STORAGE_DRIVER" envDefault:"sqlite"` DatabaseDSN string `env:"DATABASE_DSN" envDefault:"data/infinite-canvas.db"` + PublicBaseURL string `env:"PUBLIC_BASE_URL"` LinuxDoAuthorizeURL string `env:"LINUX_DO_AUTHORIZE_URL" envDefault:"https://connect.linux.do/oauth2/authorize"` LinuxDoTokenURL string `env:"LINUX_DO_TOKEN_URL" envDefault:"https://connect.linux.do/oauth2/token"` LinuxDoUserInfoURL string `env:"LINUX_DO_USERINFO_URL" envDefault:"https://connect.linux.do/api/user"` @@ -29,6 +32,7 @@ func Load() error { if err := env.Parse(&Cfg); err != nil { return err } + normalizeDockerSQLiteDSN("/app/data") if strings.TrimSpace(Cfg.JWTSecret) == "" || Cfg.JWTSecret == "infinite-canvas" { secret, err := randomSecret() if err != nil { @@ -39,6 +43,33 @@ func Load() error { return nil } +func normalizeDockerSQLiteDSN(appDataDir string) { + driver := strings.ToLower(strings.TrimSpace(Cfg.StorageDriver)) + if driver != "" && driver != "sqlite" { + return + } + dsn := strings.TrimSpace(Cfg.DatabaseDSN) + if dsn == "" || dsn == ":memory:" || strings.HasPrefix(dsn, "file:") { + return + } + pathPart, suffix := dsn, "" + if index := strings.Index(dsn, "?"); index >= 0 { + pathPart = dsn[:index] + suffix = dsn[index:] + } + if filepath.IsAbs(pathPart) { + return + } + slashPath := filepath.ToSlash(pathPart) + if slashPath != "data" && !strings.HasPrefix(slashPath, "data/") { + return + } + if _, err := os.Stat(appDataDir); err != nil { + return + } + Cfg.DatabaseDSN = filepath.Join(filepath.Dir(appDataDir), filepath.FromSlash(slashPath)) + suffix +} + func randomSecret() (string, error) { buf := make([]byte, 32) if _, err := rand.Read(buf); err != nil { diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..2f18680 --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,33 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestNormalizeDockerSQLiteDSNUsesMountedDataDir(t *testing.T) { + root := t.TempDir() + appDataDir := filepath.Join(root, "data") + if err := os.MkdirAll(appDataDir, 0755); err != nil { + t.Fatal(err) + } + Cfg = Config{StorageDriver: "sqlite", DatabaseDSN: "data/infinite-canvas.db?_pragma=busy_timeout(5000)"} + + normalizeDockerSQLiteDSN(appDataDir) + + want := filepath.Join(root, "data", "infinite-canvas.db") + "?_pragma=busy_timeout(5000)" + if Cfg.DatabaseDSN != want { + t.Fatalf("DatabaseDSN = %q, want %q", Cfg.DatabaseDSN, want) + } +} + +func TestNormalizeDockerSQLiteDSNLeavesLocalPathWithoutMountedDataDir(t *testing.T) { + Cfg = Config{StorageDriver: "sqlite", DatabaseDSN: "data/infinite-canvas.db"} + + normalizeDockerSQLiteDSN(filepath.Join(t.TempDir(), "missing-data")) + + if Cfg.DatabaseDSN != "data/infinite-canvas.db" { + t.Fatalf("DatabaseDSN = %q, want relative local path", Cfg.DatabaseDSN) + } +} diff --git a/docs/canvas-node-manual.md b/docs/canvas-node-manual.md index 845b9a6..4849bac 100644 --- a/docs/canvas-node-manual.md +++ b/docs/canvas-node-manual.md @@ -20,7 +20,7 @@ - 当文本节点已有内容时,输入框用于填写想把本段文本修改成什么;点击发送后,会在右侧生成新的文本节点,并自动连接原节点和新节点。 - 输入内容可以手写,也可以从提示词库选择。 - 对话框里的模型下拉来自全局配置里已拉取的模型列表;选择结果只作用于当前节点,不会修改其它节点或全局默认模型。 -- 如果下拉中没有模型,需要先打开配置弹窗拉取模型列表,并设置默认生图模型和默认文本模型。 +- 如果下拉中没有模型,需要先打开配置弹窗拉取模型列表,并设置默认生图模型和默认文本模型。火山方舟 Agent Plan 若提示不支持 `/models`,请手动填写模型名。 ### 用文本节点生成图片 @@ -45,7 +45,11 @@ - 视频节点使用原生播放器展示内容,可在节点内直接播放、暂停和拖动进度。 - 空视频节点下方对话框可输入提示词生成视频,结果会回填到当前节点。 - 从文本、图片或配置节点创建视频生成时,会在右侧生成新的视频节点并自动连接。 -- 视频生成接口使用 OpenAI 兼容的 `POST /v1/videos`、`GET /v1/videos/{id}` 和 `GET /v1/videos/{id}/content`。 +- 生成配置节点的视频模式会读取上游文本作为 prompt,读取上游图片作为参考图,读取上游视频作为参考视频,并在输入预览里显示参考视频。 +- 视频生成接口支持 OpenAI 风格的 `POST /v1/videos`、`GET /v1/videos/{id}` 和 `GET /v1/videos/{id}/content`。 +- 使用火山方舟 Agent Plan / Seedance 2.0 时,Base URL 配置为 `https://ark.cn-beijing.volces.com/api/plan/v3`,模型名使用 Seedance 2.0 对应模型;系统会改用 `POST /contents/generations/tasks` 创建异步任务,并轮询 `GET /contents/generations/tasks/{id}`。 +- Agent Plan 专属 `/api/plan/v3` 当前未提供 OpenAI `/models` 模型列表接口,后台不会伪造模型列表;请手动填写 `doubao-seedance-2.0` 或文档列出的其他可用模型。 +- Seedance 参考视频必须是公网可访问 URL,或由本项目后端在配置 `PUBLIC_BASE_URL` 后上传并暴露的参考素材 URL。本地/内网地址无法被火山服务器拉取。 ### 推荐流程 diff --git a/docs/features.md b/docs/features.md index bec3f05..a6963d0 100644 --- a/docs/features.md +++ b/docs/features.md @@ -52,12 +52,26 @@ ## AI 生成 -项目通过前端直接请求 OpenAI 兼容接口: +项目支持两种 AI 调用方式: + +- 本地直连:前端使用本地配置的 Base URL、API Key 和 Model 直接请求 OpenAI 兼容接口。 +- 后台渠道:前端请求本项目后端 `/api/v1/*` 代理接口,后端按模型选择管理后台配置的渠道。 + +OpenAI 兼容图像和文本能力继续复用现有接口: - `/v1/images/generations`:文生图。 - `/v1/images/edits`:图生图/参考图编辑。 - `/v1/chat/completions`:文本问答和带图问答。 -- `/v1/models`:读取模型列表。 +- `/v1/models`:读取模型列表;火山方舟 Agent Plan 专属 `/api/plan/v3` 当前未提供 OpenAI `/models` 模型列表接口,需要手动填写模型名。 + +视频能力支持两类接口: + +- OpenAI 风格视频:`POST /v1/videos`、`GET /v1/videos/{id}`、`GET /v1/videos/{id}/content`。 +- 火山方舟 Agent Plan / Seedance 2.0:Base URL 使用 `https://ark.cn-beijing.volces.com/api/plan/v3`,创建任务为 `POST /contents/generations/tasks`,查询任务为 `GET /contents/generations/tasks/{id}`,成功结果读取 `content.video_url`。 + +Base URL 如果已经以 `/v1`、`/api/v3` 或 `/api/plan/v3` 结尾,系统不会再追加 `/v1`。因此 cpa 反代或火山方舟 Agent Plan 可以继续通过现有 Base URL + API Key + Model 方式配置,不需要新增火山生图 Provider。 + +后台“拉取模型列表”会尝试真实请求 OpenAI `/models`,不会为 Agent Plan 伪造模型结果。如果火山方舟 Agent Plan 返回 404,请手动增加 `doubao-seedance-2.0` 或文档列出的其他模型名。 可配置项: @@ -67,9 +81,13 @@ - 图片质量。 - 图片比例。 - 生成数量。 +- 视频模型。 +- 视频比例、清晰度、时长、生成声音和水印。 普通图片/文本节点可以直接输入提示词生成结果。生成配置节点可以读取上游节点内容,并按节点自己的配置批量生成多个图片或文本结果。生成配置节点支持预览当前提示词和参考图输入,并调整输入顺序。 +视频生成可从文本节点读取 prompt,从图片节点读取参考图,从视频节点读取参考视频,从音频节点读取参考音频。Seedance 2.0 支持最多 9 张参考图、3 个参考视频、3 个参考音频;分辨率支持 `480p`、`720p`、`1080p`(fast 模型不支持 `1080p`),比例支持 `16:9`、`4:3`、`1:1`、`3:4`、`9:16`、`21:9`、`adaptive`,时长支持 4-15 秒或智能时长。生成成功后会把视频插入画布为视频节点并使用原生播放器预览。Seedance 参考视频和参考音频需要公网可访问 URL;本地上传素材会先通过 `/api/v1/media/references` 保存到服务端,再由 `PUBLIC_BASE_URL` 生成可供火山服务器拉取的公开链接。 + ## 画布助手 画布右侧助手面板支持: @@ -161,6 +179,8 @@ ## 当前限制 - 画布项目和“我的素材”目前只保存在浏览器本地,不会随账号同步。 -- AI API Key 目前保存在浏览器本地,并由浏览器直接请求配置的 OpenAI 兼容接口。 +- 本地直连模式下,AI API Key 保存在浏览器本地,并由浏览器直接请求配置的 OpenAI 兼容接口;只适合本地或个人使用,公网多人使用不安全。公网部署推荐使用后台渠道,把真实密钥保存在服务端配置中。 - 服务器素材库目前主要保存 URL 或文本,暂未提供文件上传接口。 +- Seedance 本地参考图/视频上传依赖 `PUBLIC_BASE_URL`,如果服务部署在 localhost、内网或不可被火山访问的地址,火山无法拉取参考素材。 +- Seedance 返回远程视频 URL 时,前端会尽量下载为本地 Blob 持久化;如果因 CORS 或网络限制无法下载,会保留远程 URL,后续是否可播放取决于上游 URL 的有效期。 - 画布更适合桌面端使用,移动端触控体验还未系统完善。 diff --git a/docs/pending-test.md b/docs/pending-test.md index 989c14d..df1e51c 100644 --- a/docs/pending-test.md +++ b/docs/pending-test.md @@ -1,6 +1,11 @@ # 待测试 - 外部软件可通过 URL 查询参数 `baseUrl`/`baseurl` 和 `apiKey`/`apikey` 跳转到前端;读取后会从地址栏移除这些参数,后台允许自定义渠道时会自动切到自定义渠道、填入配置并打开配置弹窗,未允许时会打开配置弹窗并提示无法导入。 +- 生图工作台和画布生图会把参考图按当前顺序显示为 `图片1`、`图片2` 等编号,并在图生图请求的实际提示词中注入编号说明;需要验证 `/image` 参考图排序、画布配置节点输入顺序和画布助手参考图编号一致。 +- GPT Image 生图请求会在前端把 `9:16`、`16:9` 等比例转换成合法 `WIDTHxHEIGHT` 尺寸,并在非法尺寸时直接显示中文错误,避免上游返回 `invalid_value Invalid size`。 +- Docker 部署时,`DATABASE_DSN=data/infinite-canvas.db` 会在存在 `/app/data` 挂载目录时自动归一到 `/app/data/infinite-canvas.db`,需要验证后台模型配置不会再因为工作目录变为 `/app/web` 而读到空库。 +- Seedance 参考视频被火山判定包含真人或隐私信息时,前端错误摘要会提示改用不含真人的视频、官方允许的模型产物或已授权的 `asset://` 素材;参考素材上传目录改为跟随 SQLite 数据目录,并补充公开素材的 HEAD 访问。 +- Seedance 参考素材失败原因排查:后端会把火山上游错误摘要返回给前端;`/video` 和画布视频生成会按 `图片1/视频1/音频1` 自动编号参考素材,并在实际请求提示词中注入编号说明;参考视频会在请求前校验大小、时长、宽高、宽高比和像素总量。 - 画布项目导出改为下载 `.zip` 压缩包,包内包含 `projects.json` 和当前画布引用到的本地图片、视频文件,避免只导出 JSON 时丢失媒体内容。 - 画布库支持多选后一键导出多个画布项目,导出的压缩包可一次恢复多个项目。 - 画布项目导入改为读取新版 `.zip` 压缩包,会先按 `projects.json` 中的文件映射恢复图片、视频到本地存储,再插入画布项目,导入成功后仍停留在画布库。 @@ -22,3 +27,10 @@ - 视频生成前端会识别后端 `{ code, msg }` 错误响应,创建失败不再继续轮询 `undefined`。 - 新增 `/video` 视频创作台页面,参考生图工作台布局,支持提示词、参考图、视频参数、生成结果、保存素材、下载和本地生成记录;清晰度和秒数均支持常用值选择与手动输入,生成记录只保存媒体 `storageKey` 并可回填本次提示词、参考图和参数。 - 视频创作台生成前会把模型、尺寸、秒数、清晰度归一化为视频接口支持的参数,并展示后端返回的错误信息,避免页面侧残留的生图参数影响视频请求。 +- 火山方舟 Agent Plan / Seedance 2.0 视频生成需要在真实账号下验证:`/contents/generations/tasks` 创建任务、轮询状态、`content.video_url` 回填画布,以及 401/403/429/超时错误提示。 +- 管理后台保存私有渠道后,需要验证所有已启用渠道里的模型会自动出现在公开 `availableModels`,并且 `defaultVideoModel`、`defaultImageModel`、`defaultTextModel` 在为空或失效时会自动修复,前台不再显示旧的 `grok` 默认值。 +- `/video` 和画布视频设置已按 Seedance 2.0 增加分辨率、比例、4-15 秒/智能时长、生成声音和水印参数;需要在真实浏览器里验证参数回填、生成记录和画布节点配置都能保持一致。 +- `/video` 支持最多 9 张参考图、3 个参考视频、3 个参考音频;需要验证格式、大小、音频时长提示和生成请求中的 `reference_image`、`reference_video`、`reference_audio` 组装。 +- 画布新增音频节点,支持上传、拖入、播放、移动、缩放、删除,并可作为上游参考音频参与 Seedance 视频生成;需要验证刷新后本地音频 URL 能恢复。 +- `PUBLIC_BASE_URL` 已配置公网域名时,需要验证本地上传参考视频和参考音频能被火山拉取;未配置或配置为内网地址时,需要验证前端能给出明确提示。 +- Seedance 返回远程视频 URL 但浏览器无法下载为 Blob 时,需要验证视频节点刷新后仍保留远程 URL,并确认上游 URL 有效期限制。 diff --git a/docs/system-settings.md b/docs/system-settings.md index 74a267b..0996665 100644 --- a/docs/system-settings.md +++ b/docs/system-settings.md @@ -41,11 +41,12 @@ | 字段 | 类型 | 说明 | | --- | --- | --- | -| `availableModels` | string[] | 系统可用模型,由管理员手动选择;页面下拉选项可来自私有渠道模型 | +| `availableModels` | string[] | 系统可用模型;保存设置时会自动合并所有已启用私有渠道的模型 | | `modelCosts` | object[] | 模型算力点配置,后端模型接口调用前按模型预扣,上游失败时返还;未配置默认不扣除 | -| `defaultModel` | string | 默认模型,从 `availableModels` 中选择 | -| `defaultImageModel` | string | 默认图片模型,从 `availableModels` 中选择 | -| `defaultTextModel` | string | 默认文本模型,从 `availableModels` 中选择 | +| `defaultModel` | string | 默认模型,从 `availableModels` 中选择;为空或失效时优先选择文本模型 | +| `defaultImageModel` | string | 默认图片模型,从 `availableModels` 中选择;为空或失效时优先选择 `seedream`、`image`、`gpt-image` 模型 | +| `defaultVideoModel` | string | 默认视频模型,从 `availableModels` 中选择;为空或失效时优先选择 `seedance`、`video` 模型 | +| `defaultTextModel` | string | 默认文本模型,从 `availableModels` 中选择;为空或失效时优先选择非图片/视频模型 | | `systemPrompt` | string | 系统提示词 | | `allowCustomChannel` | boolean | 是否允许用户在配置弹窗中切换为本地直连渠道,默认允许 | diff --git a/handler/ai.go b/handler/ai.go index f2c3389..26d5d7d 100644 --- a/handler/ai.go +++ b/handler/ai.go @@ -49,6 +49,7 @@ func proxyAIGetRequest(w http.ResponseWriter, r *http.Request, path string) { Fail(w, "AI 接口请求失败") return } + path = resolveAIProxyPath(channel.BaseURL, modelName, path) request, err := http.NewRequest(http.MethodGet, service.BuildModelChannelURL(channel, path), nil) if err != nil { Fail(w, "AI 接口请求失败") @@ -83,6 +84,7 @@ func proxyAIRequest(w http.ResponseWriter, r *http.Request, path string) { 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 { log.Printf("AI proxy build request failed: url=%s err=%v", service.BuildModelChannelURL(channel, path), err) @@ -117,12 +119,12 @@ func copyAIResponse(w http.ResponseWriter, request *http.Request, onFailure func defer response.Body.Close() if response.StatusCode >= http.StatusBadRequest { - payload, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) - log.Printf("AI upstream error: url=%s status=%d body=%s", request.URL.String(), response.StatusCode, strings.TrimSpace(string(payload))) + body, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) + log.Printf("AI upstream error: url=%s status=%d", request.URL.String(), response.StatusCode) if onFailure != nil { onFailure() } - Fail(w, "AI 接口请求失败") + Fail(w, aiUpstreamStatusMessage(response.StatusCode, body)) return } @@ -207,6 +209,95 @@ func readAIRequestCount(body []byte, contentType string) int { var errMissingModel = &aiError{"缺少模型名称"} +func resolveAIProxyPath(baseURL string, modelName string, path string) string { + if !isArkSeedanceVideo(baseURL, modelName) { + return path + } + if path == "/videos" { + return "/contents/generations/tasks" + } + if strings.HasPrefix(path, "/videos/") && !strings.HasSuffix(path, "/content") { + return "/contents/generations/tasks/" + strings.TrimPrefix(path, "/videos/") + } + return path +} + +func isArkSeedanceVideo(baseURL string, modelName string) bool { + base := strings.ToLower(baseURL) + model := strings.ToLower(modelName) + return strings.Contains(model, "seedance") || strings.Contains(model, "doubao-seedance") || strings.Contains(base, "/api/plan/v3") +} + +func aiStatusMessage(statusCode int) string { + switch statusCode { + case http.StatusUnauthorized, http.StatusForbidden: + return "AI 接口鉴权失败,请检查 API Key、套餐权限或模型权限" + case http.StatusTooManyRequests: + return "AI 接口限流或额度不足,请稍后重试或检查额度" + default: + return "AI 接口请求失败" + } +} + +func aiUpstreamStatusMessage(statusCode int, body []byte) string { + base := aiStatusMessage(statusCode) + detail := aiUpstreamErrorDetail(body) + if detail == "" { + return base + } + return base + ":" + detail +} + +func aiUpstreamErrorDetail(body []byte) string { + text := strings.TrimSpace(string(body)) + if text == "" { + return "" + } + var payload struct { + Msg string `json:"msg"` + Message string `json:"message"` + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(body, &payload); err == nil { + if payload.Error.Message != "" { + if detail := friendlyUpstreamError(payload.Error.Code, payload.Error.Message); detail != "" { + return safeUpstreamText(detail) + } + if payload.Error.Code != "" { + return safeUpstreamText(payload.Error.Code + " " + payload.Error.Message) + } + return safeUpstreamText(payload.Error.Message) + } + if payload.Msg != "" { + return safeUpstreamText(payload.Msg) + } + if payload.Message != "" { + return safeUpstreamText(payload.Message) + } + } + return safeUpstreamText(text) +} + +func friendlyUpstreamError(code string, message string) string { + lowerCode := strings.ToLower(strings.TrimSpace(code)) + if strings.Contains(lowerCode, "inputvideosensitivecontentdetected") || strings.Contains(lowerCode, "privacyinformation") { + return strings.TrimSpace(code + " 参考视频疑似包含真人或隐私信息,火山方舟拒绝使用普通 URL 作为真人视频参考;请改用不含真人的视频、官方允许的模型产物,或已授权的 asset:// 素材。原始错误:" + message) + } + return "" +} + +func safeUpstreamText(text string) string { + text = strings.Join(strings.Fields(strings.TrimSpace(text)), " ") + runes := []rune(text) + if len(runes) > 300 { + return string(runes[:300]) + "..." + } + return text +} + type aiError struct { message string } diff --git a/handler/ai_test.go b/handler/ai_test.go new file mode 100644 index 0000000..2eaa897 --- /dev/null +++ b/handler/ai_test.go @@ -0,0 +1,27 @@ +package handler + +import ( + "strings" + "testing" +) + +func TestAIUpstreamErrorDetail(t *testing.T) { + got := aiUpstreamErrorDetail([]byte(`{"error":{"code":"InvalidParameter","message":"reference video fps is invalid"}}`)) + if got != "InvalidParameter reference video fps is invalid" { + t.Fatalf("detail = %q", got) + } +} + +func TestAIUpstreamErrorDetailExplainsSensitiveVideo(t *testing.T) { + got := aiUpstreamErrorDetail([]byte(`{"error":{"code":"InputVideoSensitiveContentDetected.PrivacyInformation","message":"The request failed because the input video may contain real person."}}`)) + if !strings.Contains(got, "参考视频疑似包含真人") || !strings.Contains(got, "asset://") { + t.Fatalf("detail = %q", got) + } +} + +func TestSafeUpstreamTextTruncates(t *testing.T) { + got := safeUpstreamText(strings.Repeat("错", 320)) + if len([]rune(got)) != 303 { + t.Fatalf("truncated rune length = %d", len([]rune(got))) + } +} diff --git a/handler/media_reference.go b/handler/media_reference.go new file mode 100644 index 0000000..94d64b7 --- /dev/null +++ b/handler/media_reference.go @@ -0,0 +1,238 @@ +package handler + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/basketikun/infinite-canvas/config" + "github.com/google/uuid" +) + +const ( + referenceMediaMaxBytes = 80 << 20 + referenceImageMaxBytes = 30 << 20 + referenceVideoMaxBytes = 50 << 20 + referenceAudioMaxBytes = 15 << 20 + referenceImageAllowedText = "jpeg/png/webp/bmp/gif/heic/heif 图片" + referenceVideoAllowedText = "mp4/mov 视频" + referenceAudioAllowedText = "mp3/wav 音频" + referenceMediaAllowedText = referenceImageAllowedText + "、" + referenceVideoAllowedText + "或" + referenceAudioAllowedText +) + +type referenceMediaUploadResult struct { + ID string `json:"id"` + URL string `json:"url"` + MimeType string `json:"mimeType"` + Bytes int64 `json:"bytes"` +} + +func UploadReferenceMedia(w http.ResponseWriter, r *http.Request) { + publicBaseURL := strings.TrimRight(strings.TrimSpace(config.Cfg.PublicBaseURL), "/") + if publicBaseURL == "" { + Fail(w, "未配置 PUBLIC_BASE_URL,无法把本地参考素材提供给火山方舟访问") + return + } + r.Body = http.MaxBytesReader(w, r.Body, referenceMediaMaxBytes+1) + if err := r.ParseMultipartForm(referenceMediaMaxBytes); err != nil { + Fail(w, "参考素材过大或上传格式不正确") + return + } + if r.MultipartForm != nil { + defer r.MultipartForm.RemoveAll() + } + file, header, err := r.FormFile("file") + if err != nil { + Fail(w, "请上传参考图片或视频") + return + } + defer file.Close() + + mimeType, ext, ok := normalizeReferenceMediaType(header.Header.Get("Content-Type"), filepath.Ext(header.Filename)) + if !ok { + Fail(w, "参考素材格式不支持,请使用 "+referenceMediaAllowedText) + return + } + if err := os.MkdirAll(referenceMediaDir(), 0o755); err != nil { + Fail(w, "参考素材保存失败") + return + } + id := uuid.NewString() + ext + targetPath := filepath.Join(referenceMediaDir(), id) + target, err := os.OpenFile(targetPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + Fail(w, "参考素材保存失败") + return + } + bytes, copyErr := io.Copy(target, file) + closeErr := target.Close() + if copyErr != nil || closeErr != nil { + _ = os.Remove(targetPath) + Fail(w, "参考素材保存失败") + return + } + if bytes <= 0 { + _ = os.Remove(targetPath) + Fail(w, "参考素材为空") + return + } + if limit := referenceMediaTypeMaxBytes(mimeType); limit > 0 && bytes > limit { + _ = os.Remove(targetPath) + Fail(w, referenceMediaSizeMessage(mimeType)) + return + } + OK(w, referenceMediaUploadResult{ + ID: id, + URL: fmt.Sprintf("%s/api/media/references/%s", publicBaseURL, id), + MimeType: mimeType, + Bytes: bytes, + }) +} + +func ReferenceMedia(w http.ResponseWriter, r *http.Request, id string) { + if id == "" || id != filepath.Base(id) || strings.Contains(id, "..") { + http.NotFound(w, r) + return + } + path := filepath.Join(referenceMediaDir(), id) + file, err := os.Open(path) + if err != nil { + http.NotFound(w, r) + return + } + defer file.Close() + info, err := file.Stat() + if err != nil || info.IsDir() { + http.NotFound(w, r) + return + } + if mimeType := mimeTypeByReferenceMediaExt(filepath.Ext(id)); mimeType != "" { + w.Header().Set("Content-Type", mimeType) + } + w.Header().Set("Cache-Control", "public, max-age=86400") + http.ServeContent(w, r, id, info.ModTime(), file) +} + +func referenceMediaDir() string { + return filepath.Join(referenceDataDir(), "reference-media") +} + +func referenceDataDir() string { + driver := strings.ToLower(strings.TrimSpace(config.Cfg.StorageDriver)) + dsn := strings.TrimSpace(config.Cfg.DatabaseDSN) + if (driver == "" || driver == "sqlite") && dsn != "" && dsn != ":memory:" && !strings.HasPrefix(dsn, "file:") { + pathPart := dsn + if index := strings.Index(dsn, "?"); index >= 0 { + pathPart = dsn[:index] + } + if filepath.IsAbs(pathPart) { + return filepath.Dir(pathPart) + } + } + if _, err := os.Stat("/app/data"); err == nil { + return "/app/data" + } + return "data" +} + +func normalizeReferenceMediaType(contentType string, ext string) (string, string, bool) { + contentType = strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0])) + ext = strings.ToLower(strings.TrimSpace(ext)) + if contentType == "" || contentType == "application/octet-stream" { + contentType = mimeTypeByReferenceMediaExt(ext) + } + if fixedExt := referenceMediaExtByMimeType(contentType); fixedExt != "" { + return contentType, fixedExt, true + } + if mimeType := mimeTypeByReferenceMediaExt(ext); mimeType != "" { + return mimeType, ext, true + } + return "", "", false +} + +func referenceMediaExtByMimeType(mimeType string) string { + switch strings.ToLower(mimeType) { + case "image/jpeg", "image/jpg": + return ".jpg" + case "image/png": + return ".png" + case "image/webp": + return ".webp" + case "image/bmp": + return ".bmp" + case "image/gif": + return ".gif" + case "image/heic": + return ".heic" + case "image/heif": + return ".heif" + case "video/mp4": + return ".mp4" + case "video/quicktime", "video/mov": + return ".mov" + case "audio/mpeg", "audio/mp3": + return ".mp3" + case "audio/wav", "audio/x-wav", "audio/wave": + return ".wav" + default: + return "" + } +} + +func mimeTypeByReferenceMediaExt(ext string) string { + switch strings.ToLower(ext) { + case ".jpg", ".jpeg": + return "image/jpeg" + case ".png": + return "image/png" + case ".webp": + return "image/webp" + case ".bmp": + return "image/bmp" + case ".gif": + return "image/gif" + case ".heic": + return "image/heic" + case ".heif": + return "image/heif" + case ".mp4": + return "video/mp4" + case ".mov": + return "video/quicktime" + case ".mp3": + return "audio/mpeg" + case ".wav": + return "audio/wav" + default: + return "" + } +} + +func referenceMediaTypeMaxBytes(mimeType string) int64 { + if strings.HasPrefix(mimeType, "image/") { + return referenceImageMaxBytes + } + if strings.HasPrefix(mimeType, "video/") { + return referenceVideoMaxBytes + } + if strings.HasPrefix(mimeType, "audio/") { + return referenceAudioMaxBytes + } + return referenceMediaMaxBytes +} + +func referenceMediaSizeMessage(mimeType string) string { + if strings.HasPrefix(mimeType, "image/") { + return "参考图片超过大小限制,请使用 30MB 以内的图片" + } + if strings.HasPrefix(mimeType, "video/") { + return "参考视频超过大小限制,请使用 50MB 以内的 mp4/mov 视频" + } + if strings.HasPrefix(mimeType, "audio/") { + return "参考音频超过大小限制,请使用 15MB 以内的 mp3/wav 音频" + } + return "参考素材超过大小限制" +} diff --git a/handler/media_reference_test.go b/handler/media_reference_test.go new file mode 100644 index 0000000..826e045 --- /dev/null +++ b/handler/media_reference_test.go @@ -0,0 +1,56 @@ +package handler + +import ( + "path/filepath" + "testing" + + "github.com/basketikun/infinite-canvas/config" +) + +func TestNormalizeReferenceMediaTypeSupportsAudio(t *testing.T) { + tests := []struct { + name string + contentType string + ext string + wantMime string + wantExt string + }{ + {name: "mp3 mime", contentType: "audio/mpeg", ext: ".bin", wantMime: "audio/mpeg", wantExt: ".mp3"}, + {name: "wav ext fallback", contentType: "application/octet-stream", ext: ".wav", wantMime: "audio/wav", wantExt: ".wav"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mimeType, ext, ok := normalizeReferenceMediaType(tt.contentType, tt.ext) + if !ok { + t.Fatal("expected media type to be accepted") + } + if mimeType != tt.wantMime || ext != tt.wantExt { + t.Fatalf("got (%q, %q), want (%q, %q)", mimeType, ext, tt.wantMime, tt.wantExt) + } + }) + } +} + +func TestReferenceMediaTypeMaxBytes(t *testing.T) { + if got := referenceMediaTypeMaxBytes("audio/mpeg"); got != referenceAudioMaxBytes { + t.Fatalf("audio max bytes = %d, want %d", got, referenceAudioMaxBytes) + } + if got := referenceMediaTypeMaxBytes("video/mp4"); got != referenceVideoMaxBytes { + t.Fatalf("video max bytes = %d, want %d", got, referenceVideoMaxBytes) + } + if got := referenceMediaTypeMaxBytes("image/png"); got != referenceImageMaxBytes { + t.Fatalf("image max bytes = %d, want %d", got, referenceImageMaxBytes) + } +} + +func TestReferenceMediaDirUsesAbsoluteSQLiteDataDir(t *testing.T) { + previous := config.Cfg + t.Cleanup(func() { config.Cfg = previous }) + root := t.TempDir() + config.Cfg = config.Config{StorageDriver: "sqlite", DatabaseDSN: filepath.Join(root, "infinite-canvas.db")} + + if got := referenceMediaDir(); got != filepath.Join(root, "reference-media") { + t.Fatalf("referenceMediaDir = %q", got) + } +} diff --git a/router/router.go b/router/router.go index acf5bb7..6493903 100644 --- a/router/router.go +++ b/router/router.go @@ -22,11 +22,18 @@ func New() *gin.Engine { api.GET("/auth/linux-do/callback", gin.WrapF(handler.LinuxDoCallback)) api.GET("/auth/me", middleware.OptionalAuth, gin.WrapF(handler.CurrentUser)) api.GET("/settings", gin.WrapF(handler.Settings)) + api.GET("/media/references/:id", func(c *gin.Context) { + handler.ReferenceMedia(c.Writer, c.Request, c.Param("id")) + }) + api.HEAD("/media/references/:id", func(c *gin.Context) { + handler.ReferenceMedia(c.Writer, c.Request, c.Param("id")) + }) v1 := api.Group("/v1", middleware.UserAuth) v1.POST("/images/generations", gin.WrapF(handler.AIImagesGenerations)) v1.POST("/images/edits", gin.WrapF(handler.AIImagesEdits)) v1.POST("/chat/completions", gin.WrapF(handler.AIChatCompletions)) v1.POST("/videos", gin.WrapF(handler.AIVideos)) + v1.POST("/media/references", gin.WrapF(handler.UploadReferenceMedia)) v1.GET("/videos/:id", func(c *gin.Context) { handler.AIVideo(c.Writer, c.Request, c.Param("id")) }) diff --git a/service/settings.go b/service/settings.go index bbf088f..121c802 100644 --- a/service/settings.go +++ b/service/settings.go @@ -7,16 +7,20 @@ import ( "io" "math/rand" "net/http" + "net/url" "sort" "strings" + "time" "github.com/basketikun/infinite-canvas/model" "github.com/basketikun/infinite-canvas/repository" ) +var adminModelHTTPClient = &http.Client{Timeout: 30 * time.Second} + func PublicSettings() (model.PublicSetting, error) { settings, err := repository.GetSettings() - return normalizePublicSetting(settings.Public), err + return normalizeSettings(settings).Public, err } func AdminSettings() (model.Settings, error) { @@ -52,16 +56,23 @@ func AdminTestChannelModel(index *int, channel model.ModelChannel, modelName str if err != nil { return "", err } + if isArkAgentPlanChannel(resolved) || isSeedanceModelName(modelName) { + return testArkSeedanceChannelModel(resolved, modelName) + } return testAdminChannelModel(resolved, modelName) } func normalizeSettings(settings model.Settings) model.Settings { - settings.Public = normalizePublicSetting(settings.Public) settings.Private = normalizePrivateSetting(settings.Private) + settings.Public = normalizePublicSettingWithChannels(settings.Public, settings.Private.Channels) return settings } func normalizePublicSetting(setting model.PublicSetting) model.PublicSetting { + return normalizePublicSettingWithChannels(setting, nil) +} + +func normalizePublicSettingWithChannels(setting model.PublicSetting, channels []model.ModelChannel) model.PublicSetting { if setting.ModelChannel.AvailableModels == nil { setting.ModelChannel.AvailableModels = []string{} } @@ -82,6 +93,16 @@ func normalizePublicSetting(setting model.PublicSetting) model.PublicSetting { enabled := true setting.Auth.AllowRegister = &enabled } + enabledModels := enabledChannelModels(channels) + if len(enabledModels) > 0 { + setting.ModelChannel.AvailableModels = enabledModels + } else { + setting.ModelChannel.AvailableModels = uniqueModelNames(setting.ModelChannel.AvailableModels) + } + setting.ModelChannel.DefaultTextModel = repairDefaultModel(setting.ModelChannel.DefaultTextModel, setting.ModelChannel.AvailableModels, isTextModelName) + setting.ModelChannel.DefaultImageModel = repairDefaultModel(setting.ModelChannel.DefaultImageModel, setting.ModelChannel.AvailableModels, isImageModelName) + setting.ModelChannel.DefaultVideoModel = repairDefaultModel(setting.ModelChannel.DefaultVideoModel, setting.ModelChannel.AvailableModels, isVideoModelName) + setting.ModelChannel.DefaultModel = repairDefaultModel(setting.ModelChannel.DefaultModel, setting.ModelChannel.AvailableModels, isTextModelName) return setting } @@ -179,13 +200,101 @@ func SelectModelChannel(modelName string) (model.ModelChannel, error) { } func BuildModelChannelURL(channel model.ModelChannel, path string) string { - baseURL := strings.TrimRight(channel.BaseURL, "/") - if !strings.HasSuffix(baseURL, "/v1") { + baseURL := normalizeModelChannelBaseURL(channel.BaseURL) + lowerBaseURL := strings.ToLower(baseURL) + if !strings.HasSuffix(lowerBaseURL, "/v1") && !strings.HasSuffix(lowerBaseURL, "/api/v3") && !strings.HasSuffix(lowerBaseURL, "/api/plan/v3") { baseURL += "/v1" } return baseURL + path } +func normalizeModelChannelBaseURL(baseURL string) string { + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + parsed, err := url.Parse(baseURL) + if err == nil && parsed.Scheme != "" && parsed.Host != "" { + path := strings.TrimRight(parsed.Path, "/") + lowerPath := strings.ToLower(path) + if index := strings.Index(lowerPath, "/api/plan/v3"); index >= 0 { + end := index + len("/api/plan/v3") + if len(lowerPath) == end || lowerPath[end] == '/' { + parsed.Path = path[:end] + parsed.RawPath = "" + parsed.RawQuery = "" + parsed.Fragment = "" + return strings.TrimRight(parsed.String(), "/") + } + } + } + return baseURL +} + +func isArkAgentPlanChannel(channel model.ModelChannel) bool { + baseURL := strings.ToLower(normalizeModelChannelBaseURL(channel.BaseURL)) + return strings.HasSuffix(baseURL, "/api/plan/v3") +} + +func isSeedanceModelName(modelName string) bool { + modelName = strings.ToLower(strings.TrimSpace(modelName)) + return strings.Contains(modelName, "seedance") || strings.Contains(modelName, "doubao-seedance") +} + +func enabledChannelModels(channels []model.ModelChannel) []string { + models := []string{} + for _, channel := range channels { + if !channel.Enabled { + continue + } + models = append(models, channel.Models...) + } + return uniqueModelNames(models) +} + +func uniqueModelNames(models []string) []string { + result := []string{} + seen := map[string]bool{} + for _, item := range models { + name := strings.TrimSpace(item) + if name == "" || seen[name] { + continue + } + seen[name] = true + result = append(result, name) + } + return result +} + +func repairDefaultModel(current string, models []string, preferred func(string) bool) string { + current = strings.TrimSpace(current) + for _, item := range models { + if item == current { + return current + } + } + for _, item := range models { + if preferred(item) { + return item + } + } + if len(models) > 0 { + return models[0] + } + return "" +} + +func isVideoModelName(modelName string) bool { + name := strings.ToLower(strings.TrimSpace(modelName)) + return strings.Contains(name, "seedance") || strings.Contains(name, "video") +} + +func isImageModelName(modelName string) bool { + name := strings.ToLower(strings.TrimSpace(modelName)) + return strings.Contains(name, "seedream") || strings.Contains(name, "gpt-image") || strings.Contains(name, "image") +} + +func isTextModelName(modelName string) bool { + return !isImageModelName(modelName) && !isVideoModelName(modelName) +} + func normalizeModelChannel(channel model.ModelChannel) model.ModelChannel { if channel.Protocol == "" { channel.Protocol = "openai" @@ -239,13 +348,16 @@ func fetchAdminChannelModels(channel model.ModelChannel) ([]string, error) { return nil, err } request.Header.Set("Authorization", "Bearer "+channel.APIKey) - response, err := http.DefaultClient.Do(request) + response, err := adminModelHTTPClient.Do(request) if err != nil { - return nil, err + return nil, safeMessageError{message: "读取模型失败:上游接口无响应或网络不可达"} } defer response.Body.Close() body, _ := io.ReadAll(response.Body) if response.StatusCode >= http.StatusBadRequest { + if response.StatusCode == http.StatusNotFound && isArkAgentPlanChannel(channel) { + return nil, safeMessageError{message: "火山方舟 Agent Plan 未提供 OpenAI /models 模型列表接口,请手动填写模型名称,例如 doubao-seedance-2.0。"} + } return nil, readAdminChannelError(body, response.StatusCode, "读取模型失败") } var payload struct { @@ -281,9 +393,9 @@ func testAdminChannelModel(channel model.ModelChannel, modelName string) (string } request.Header.Set("Authorization", "Bearer "+channel.APIKey) request.Header.Set("Content-Type", "application/json") - response, err := http.DefaultClient.Do(request) + response, err := adminModelHTTPClient.Do(request) if err != nil { - return "", err + return "", safeMessageError{message: "测试失败:上游接口无响应或网络不可达"} } defer response.Body.Close() responseBody, _ := io.ReadAll(response.Body) @@ -304,6 +416,22 @@ func testAdminChannelModel(channel model.ModelChannel, modelName string) (string return "ok", nil } +func testArkSeedanceChannelModel(channel model.ModelChannel, modelName string) (string, error) { + if strings.TrimSpace(modelName) == "" { + return "", errors.New("缺少模型名称") + } + if strings.TrimSpace(channel.BaseURL) == "" { + return "", safeMessageError{message: "缺少接口地址"} + } + if strings.TrimSpace(channel.APIKey) == "" { + return "", safeMessageError{message: "缺少 API Key"} + } + if !isArkAgentPlanChannel(channel) { + return "Seedance 视频模型不会发送 /chat/completions 文本测试。已检查 Base URL、API Key 和模型名非空;未调用视频生成接口,因此未验证套餐额度或模型权限。", nil + } + return "Agent Plan / Seedance 视频模型配置格式已通过。后台测试不会调用视频生成接口,因此未验证 API Key、套餐额度或模型权限;请在画布中使用视频生成验证。", nil +} + func readAdminChannelError(body []byte, statusCode int, fallback string) error { var payload struct { Error *struct { @@ -319,8 +447,11 @@ func readAdminChannelError(body []byte, statusCode int, fallback string) error { return safeMessageError{message: payload.Msg} } } - if statusCode == http.StatusUnauthorized { - return safeMessageError{message: "上游接口认证失败(401),请检查 API Key"} + if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden { + return safeMessageError{message: fmt.Sprintf("上游接口鉴权失败(%d),请检查 API Key、套餐权限或模型权限", statusCode)} + } + if statusCode == http.StatusTooManyRequests { + return safeMessageError{message: "上游接口限流或额度不足(429),请稍后重试或检查额度"} } if statusCode > 0 { return safeMessageError{message: fmt.Sprintf("%s:%d", fallback, statusCode)} diff --git a/service/settings_test.go b/service/settings_test.go new file mode 100644 index 0000000..27c6b2e --- /dev/null +++ b/service/settings_test.go @@ -0,0 +1,100 @@ +package service + +import ( + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/basketikun/infinite-canvas/model" +) + +func TestFetchAdminChannelModelsParsesOpenAIModels(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/models" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"z-model"},{"id":"a-model"},{"id":""}]}`)) + })) + defer server.Close() + + models, err := fetchAdminChannelModels(model.ModelChannel{ + BaseURL: server.URL, + APIKey: "test-key", + }) + if err != nil { + t.Fatalf("fetchAdminChannelModels returned error: %v", err) + } + if want := []string{"a-model", "z-model"}; !reflect.DeepEqual(models, want) { + t.Fatalf("models = %#v, want %#v", models, want) + } +} + +func TestFetchAdminChannelModelsReportsArkPlanModelsUnsupported(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/plan/v3/models" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + http.NotFound(w, r) + })) + defer server.Close() + + _, err := fetchAdminChannelModels(model.ModelChannel{ + BaseURL: server.URL + "/api/plan/v3/contents/generations/tasks", + APIKey: "test-key", + }) + if err == nil { + t.Fatal("expected unsupported /models error") + } + if !strings.Contains(err.Error(), "Agent Plan 未提供 OpenAI /models") { + t.Fatalf("error = %q", err.Error()) + } +} + +func TestBuildModelChannelURLNormalizesArkPlanTaskPath(t *testing.T) { + got := BuildModelChannelURL(model.ModelChannel{BaseURL: "https://ark.cn-beijing.volces.com/api/plan/v3/contents/generations/tasks?debug=1"}, "/models") + want := "https://ark.cn-beijing.volces.com/api/plan/v3/models" + if got != want { + t.Fatalf("BuildModelChannelURL = %q, want %q", got, want) + } +} + +func TestNormalizeSettingsPublishesEnabledChannelModelsAndRepairsDefaults(t *testing.T) { + settings := normalizeSettings(model.Settings{ + Public: model.PublicSetting{ + ModelChannel: model.PublicModelChannelSetting{ + AvailableModels: []string{"grok-imagine-video", "disabled-model"}, + DefaultModel: "grok-imagine-video", + DefaultTextModel: "missing-text", + DefaultImageModel: "missing-image", + DefaultVideoModel: "missing-video", + }, + }, + Private: model.PrivateSetting{ + Channels: []model.ModelChannel{ + {Enabled: true, Models: []string{"gpt-5.5", "doubao-seedream-5.0-lite", "doubao-seedance-2.0-fast", "gpt-5.5"}}, + {Enabled: false, Models: []string{"disabled-model"}}, + }, + }, + }) + + channel := settings.Public.ModelChannel + wantModels := []string{"gpt-5.5", "doubao-seedream-5.0-lite", "doubao-seedance-2.0-fast"} + if !reflect.DeepEqual(channel.AvailableModels, wantModels) { + t.Fatalf("available models = %#v, want %#v", channel.AvailableModels, wantModels) + } + if channel.DefaultModel != "gpt-5.5" { + t.Fatalf("default model = %q, want text model", channel.DefaultModel) + } + if channel.DefaultTextModel != "gpt-5.5" { + t.Fatalf("default text model = %q, want text model", channel.DefaultTextModel) + } + if channel.DefaultImageModel != "doubao-seedream-5.0-lite" { + t.Fatalf("default image model = %q, want seedream", channel.DefaultImageModel) + } + if channel.DefaultVideoModel != "doubao-seedance-2.0-fast" { + t.Fatalf("default video model = %q, want seedance", channel.DefaultVideoModel) + } +} diff --git a/web/src/app/(admin)/admin/settings/page.tsx b/web/src/app/(admin)/admin/settings/page.tsx index 7ca449e..7b5029f 100644 --- a/web/src/app/(admin)/admin/settings/page.tsx +++ b/web/src/app/(admin)/admin/settings/page.tsx @@ -215,6 +215,10 @@ export default function AdminSettingsPage() { const channelModels = await fetchChannelModels(token, { index: editingChannelIndex ?? undefined, channel: normalizeChannel(channel) }); const current = isModelSelectorOpen ? uniqueModels(modelSelectSelected) : uniqueModels(channelForm.getFieldValue("models") || []); rememberModels(channelModels); + if (!channelModels.length) { + message.warning("上游未返回模型列表,请手动输入模型名称"); + return; + } setModelSelectExisting(current); setModelSelectSource(uniqueModels(channelModels)); setModelSelectSelected(uniqueModels([...current, ...channelModels])); @@ -337,7 +341,7 @@ export default function AdminSettingsPage() { const nextChannelModels = collectChannelModels(nextChannels); const nextSettings = normalizeSettings({ ...values, - public: { ...values.public, modelChannel: { ...values.public.modelChannel, availableModels: filterModels(values.public.modelChannel.availableModels, nextChannelModels) } }, + public: { ...values.public, modelChannel: { ...values.public.modelChannel, availableModels: nextChannelModels } }, private: { ...values.private, channels: nextChannels }, }); const saved = normalizeSettings(await saveAdminSettings(token, nextSettings)); @@ -410,7 +414,7 @@ export default function AdminSettingsPage() {