From f0ce9ed80cb3473906389cc9b86e3dd94caf142a Mon Sep 17 00:00:00 2001 From: kris Date: Tue, 7 Apr 2026 16:08:18 +0800 Subject: [PATCH] feat: remember ai video provider per project --- CHANGELOG.md | 6 +++ web/storyforge-web-v4/assets/app.js | 38 ++++++++++++++++++- .../tests/workbench-pages.test.mjs | 5 +++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2d901d..3a218a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,12 @@ - `Seedance 配置` 提示也会随引擎切换即时更新,表单第一眼就能看出这次走的是默认视频链,还是 `Seedance 2.0 -> 火山视频配置`。 - 这套联动同样保留“手动改过就不再自动覆盖”的原则,避免把用户已经输入的模型名冲掉。 +### AI 视频开始按项目记忆最近一次视频引擎 + +- `创建 AI 视频任务` 现在会按项目记住你最近一次使用的 `视频引擎 / 引擎模型`。 +- 如果某个项目最近一次就是用 `Seedance 2.0 + seedance-2.0-pro`,下次再打开这张表单时会优先带出这套组合,不用每次重新选。 +- 这套记忆只在当前项目内生效,不会把一个项目的视频引擎偏好串到别的项目上。 + ### 修复额度页套餐建议引起的全局渲染报错 - `额度` 页面现在会先初始化 `packageRecommendation` 再渲染套餐建议,不再因为变量未定义把整个工作台渲染链打断。 diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js index 93a65d3..709e45e 100644 --- a/web/storyforge-web-v4/assets/app.js +++ b/web/storyforge-web-v4/assets/app.js @@ -2,6 +2,7 @@ const STORAGE_KEY = "storyforge-web-v4-session"; const SESSION_STORE = StoryForgeSessionStore.create(STORAGE_KEY); const DEFAULT_BACKEND_URL = StoryForgeApiClient.detectDefaultBackendUrl(); const RECOVERY_HISTORY_KEY = STORAGE_KEY + ":recovery-history"; +const AI_VIDEO_DEFAULTS_KEY = STORAGE_KEY + ":ai-video-defaults"; const navButtons = document.querySelectorAll("[data-screen-target]"); const screens = Array.from(document.querySelectorAll("[data-screen]")); @@ -578,6 +579,34 @@ function getCompletedJobById(jobId = "") { || null; } +function getAiVideoPreferences(projectId = "") { + const normalizedProjectId = String(projectId || "").trim(); + if (!normalizedProjectId) return {}; + try { + const payload = JSON.parse(localStorage.getItem(AI_VIDEO_DEFAULTS_KEY) || "{}"); + const saved = payload && typeof payload === "object" ? payload[normalizedProjectId] : null; + return saved && typeof saved === "object" ? saved : {}; + } catch { + return {}; + } +} + +function saveAiVideoPreferences(projectId = "", values = {}) { + const normalizedProjectId = String(projectId || "").trim(); + if (!normalizedProjectId) return; + try { + const payload = JSON.parse(localStorage.getItem(AI_VIDEO_DEFAULTS_KEY) || "{}"); + const nextPayload = payload && typeof payload === "object" ? payload : {}; + nextPayload[normalizedProjectId] = { + videoProvider: String(values.videoProvider || "doubao").trim() || "doubao", + videoModel: String(values.videoModel || "").trim() + }; + localStorage.setItem(AI_VIDEO_DEFAULTS_KEY, JSON.stringify(nextPayload)); + } catch { + // ignore local preference persistence failures + } +} + function bindCreativeSourceJobRecommendations(fields, options = {}) { const sourceJobSelect = fields.querySelector('[data-action-field="sourceJobId"]'); if (!(sourceJobSelect instanceof HTMLSelectElement)) return; @@ -12173,11 +12202,12 @@ function openCreateAiVideoAction(defaults = {}) { const defaultAssistantId = assistant?.id || ""; const sourceJob = defaults.sourceJob || null; const defaultPlatform = normalizePlatformValue(defaults.platform || sourceJob?.platform || "douyin"); + const persistedDefaults = getAiVideoPreferences(project.id); const defaultVideoProvider = String( - defaults.videoProvider || defaults.video_provider || sourceJob?.artifacts?.video_provider || "doubao" + defaults.videoProvider || defaults.video_provider || sourceJob?.artifacts?.video_provider || persistedDefaults.videoProvider || "doubao" ).trim() || "doubao"; const defaultVideoModel = String( - defaults.videoModel || defaults.video_model || sourceJob?.artifacts?.video_model || "" + defaults.videoModel || defaults.video_model || sourceJob?.artifacts?.video_model || persistedDefaults.videoModel || "" ).trim() || (defaultVideoProvider === "seedance2" ? "seedance-2.0-pro" : ""); openActionModal({ title: "创建 AI 视频任务", @@ -12282,6 +12312,10 @@ function openCreateAiVideoAction(defaults = {}) { video_model: values.videoModel || "", } }); + saveAiVideoPreferences(project.id, { + videoProvider: values.videoProvider || "doubao", + videoModel: values.videoModel || "" + }); rememberAction( "AI 视频任务已创建", `已创建任务 ${job.title || job.id},引擎 ${normalizedProvider === "seedance2" ? "Seedance 2.0" : "当前默认引擎"}。`, diff --git a/web/storyforge-web-v4/tests/workbench-pages.test.mjs b/web/storyforge-web-v4/tests/workbench-pages.test.mjs index 92265f4..3c9d4d7 100644 --- a/web/storyforge-web-v4/tests/workbench-pages.test.mjs +++ b/web/storyforge-web-v4/tests/workbench-pages.test.mjs @@ -1126,8 +1126,13 @@ test("ai video form explains where Seedance 火山配置 lives", () => { assert.match(APP, /id="integration-\$\{escapeHtml\(item\.key\)\}-anchor"/); assert.match(APP, /function focusAutomationHealthWorkspace\(anchorId = "integration-huobao-anchor"\)/); assert.match(APP, /function bindAiVideoProviderRecommendations\(fields, options = \{\}\)/); + assert.match(APP, /const AI_VIDEO_DEFAULTS_KEY = STORAGE_KEY \+ ":ai-video-defaults"/); + assert.match(APP, /function getAiVideoPreferences\(projectId = ""\)/); + assert.match(APP, /function saveAiVideoPreferences\(projectId = "", values = \{\}\)/); + assert.match(APP, /const persistedDefaults = getAiVideoPreferences\(project\.id\)/); assert.match(APP, /modelInput\.placeholder = provider === "seedance2" \? "例如:seedance-2\.0-pro" : "留空则沿用当前默认视频模型"/); assert.match(APP, /bindAiVideoProviderRecommendations\(fields, \{ seedanceModel: defaultVideoModel \|\| "seedance-2\.0-pro" \}\)/); + assert.match(APP, /saveAiVideoPreferences\(project\.id, \{/); assert.match(clickActions, /name === "focus-huobao-video-config"[\s\S]*focusAutomationHealthWorkspace\("integration-huobao-anchor"\)/); });