diff --git a/docs/canvas-node-manual.md b/docs/canvas-node-manual.md
index 48db9b8..4849bac 100644
--- a/docs/canvas-node-manual.md
+++ b/docs/canvas-node-manual.md
@@ -20,7 +20,7 @@
- 当文本节点已有内容时,输入框用于填写想把本段文本修改成什么;点击发送后,会在右侧生成新的文本节点,并自动连接原节点和新节点。
- 输入内容可以手写,也可以从提示词库选择。
- 对话框里的模型下拉来自全局配置里已拉取的模型列表;选择结果只作用于当前节点,不会修改其它节点或全局默认模型。
-- 如果下拉中没有模型,需要先打开配置弹窗拉取模型列表,并设置默认生图模型和默认文本模型。
+- 如果下拉中没有模型,需要先打开配置弹窗拉取模型列表,并设置默认生图模型和默认文本模型。火山方舟 Agent Plan 若提示不支持 `/models`,请手动填写模型名。
### 用文本节点生成图片
@@ -48,6 +48,7 @@
- 生成配置节点的视频模式会读取上游文本作为 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 344dd90..5b7a1ca 100644
--- a/docs/features.md
+++ b/docs/features.md
@@ -62,7 +62,7 @@ OpenAI 兼容图像和文本能力继续复用现有接口:
- `/v1/images/generations`:文生图。
- `/v1/images/edits`:图生图/参考图编辑。
- `/v1/chat/completions`:文本问答和带图问答。
-- `/v1/models`:读取模型列表。
+- `/v1/models`:读取模型列表;火山方舟 Agent Plan 专属 `/api/plan/v3` 当前未提供 OpenAI `/models` 模型列表接口,需要手动填写模型名。
视频能力支持两类接口:
@@ -71,6 +71,8 @@ OpenAI 兼容图像和文本能力继续复用现有接口:
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` 或文档列出的其他模型名。
+
可配置项:
- Base URL。
diff --git a/service/settings.go b/service/settings.go
index e84e5c1..4a943ab 100644
--- a/service/settings.go
+++ b/service/settings.go
@@ -7,6 +7,7 @@ import (
"io"
"math/rand"
"net/http"
+ "net/url"
"sort"
"strings"
@@ -44,9 +45,6 @@ func AdminChannelModels(index *int, channel model.ModelChannel) ([]string, error
if err != nil {
return nil, err
}
- if isArkAgentPlanChannel(resolved) {
- return []string{"doubao-seedance-2.0"}, nil
- }
return fetchAdminChannelModels(resolved)
}
@@ -186,7 +184,8 @@ func SelectModelChannel(modelName string) (model.ModelChannel, error) {
func BuildModelChannelURL(channel model.ModelChannel, path string) string {
baseURL := normalizeModelChannelBaseURL(channel.BaseURL)
- if !strings.HasSuffix(baseURL, "/v1") && !strings.HasSuffix(baseURL, "/api/v3") && !strings.HasSuffix(baseURL, "/api/plan/v3") {
+ 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
@@ -194,9 +193,20 @@ func BuildModelChannelURL(channel model.ModelChannel, path string) string {
func normalizeModelChannelBaseURL(baseURL string) string {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
- lowerBaseURL := strings.ToLower(baseURL)
- if index := strings.Index(lowerBaseURL, "/api/plan/v3"); index >= 0 {
- return baseURL[:index+len("/api/plan/v3")]
+ 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
}
@@ -271,6 +281,9 @@ func fetchAdminChannelModels(channel model.ModelChannel) ([]string, error) {
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 {
@@ -340,9 +353,9 @@ func testArkSeedanceChannelModel(channel model.ModelChannel, modelName string) (
return "", safeMessageError{message: "缺少 API Key"}
}
if !isArkAgentPlanChannel(channel) {
- return "", safeMessageError{message: "Seedance 2.0 请使用火山方舟 Agent Plan Base URL:https://ark.cn-beijing.volces.com/api/plan/v3"}
+ return "Seedance 视频模型不会发送 /chat/completions 文本测试。已检查 Base URL、API Key 和模型名非空;未调用视频生成接口,因此未验证套餐额度或模型权限。", nil
}
- return "Agent Plan / Seedance 视频模型配置格式已通过。后台测试不会调用视频生成接口,请在画布中使用视频生成验证模型权限。", nil
+ return "Agent Plan / Seedance 视频模型配置格式已通过。后台测试不会调用视频生成接口,因此未验证 API Key、套餐额度或模型权限;请在画布中使用视频生成验证。", nil
}
func readAdminChannelError(body []byte, statusCode int, fallback string) error {
@@ -360,8 +373,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..ea92d83
--- /dev/null
+++ b/service/settings_test.go
@@ -0,0 +1,62 @@
+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)
+ }
+}
diff --git a/web/src/app/(admin)/admin/settings/page.tsx b/web/src/app/(admin)/admin/settings/page.tsx
index 7ca449e..c262680 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]));
@@ -707,6 +711,7 @@ export default function AdminSettingsPage() {
+ 如果上游不提供 OpenAI /models 模型列表接口,请在这里手动增加模型名称。
setModelSelectTab(key as ModelSelectTabKey)}
@@ -765,7 +770,7 @@ export default function AdminSettingsPage() {
destroyOnHidden
>
- 测试会向选中模型发送一条 hi,用于确认渠道是否有响应。
+ 普通文本模型会发送一条 hi;Agent Plan / Seedance 视频模型只做配置格式检查,不会发起视频生成,也不代表模型权限已验证。
setTestKeyword(event.target.value)} />
= 0) normalizedBaseUrl = normalizedBaseUrl.slice(0, arkPlanIndex + "/api/plan/v3".length);
- const apiBaseUrl = normalizedBaseUrl.endsWith("/v1") || normalizedBaseUrl.endsWith("/api/v3") || normalizedBaseUrl.endsWith("/api/plan/v3") ? normalizedBaseUrl : `${normalizedBaseUrl}/v1`;
+ normalizedBaseUrl = normalizeArkPlanBaseUrl(normalizedBaseUrl);
+ const lowerBaseUrl = normalizedBaseUrl.toLowerCase();
+ const apiBaseUrl = lowerBaseUrl.endsWith("/v1") || lowerBaseUrl.endsWith("/api/v3") || lowerBaseUrl.endsWith("/api/plan/v3") ? normalizedBaseUrl : `${normalizedBaseUrl}/v1`;
return `${apiBaseUrl}${path}`;
}
+
+function normalizeArkPlanBaseUrl(baseUrl: string) {
+ try {
+ const url = new URL(baseUrl);
+ const path = url.pathname.replace(/\/+$/, "");
+ const lowerPath = path.toLowerCase();
+ const arkPlanIndex = lowerPath.indexOf("/api/plan/v3");
+ if (arkPlanIndex < 0) return baseUrl;
+ const end = arkPlanIndex + "/api/plan/v3".length;
+ if (lowerPath.length !== end && lowerPath[end] !== "/") return baseUrl;
+ url.pathname = path.slice(0, end);
+ url.search = "";
+ url.hash = "";
+ return url.toString().replace(/\/+$/, "");
+ } catch {
+ return baseUrl;
+ }
+}