diff --git a/CHANGELOG.md b/CHANGELOG.md index f3c2d06..0b6a1f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ 这个文件用于给 Gitea 仓库保留阶段性版本更新记录,方便直接查看每一轮里程碑,不用只依赖零散 commit。 +## 2026-04-07 + +### 创作表单开始跟随来源任务动态刷新推荐值 + +- `生成文案 / 创建 AI 视频 / 创建实拍剪辑 / 写复盘` 这四类创作表单,现在不只会在打开时算一次默认值;如果你在表单里切换来源任务,平台、标题、受众、画幅、时长、目标这些推荐值会继续跟着刷新。 +- 这套联动只会在字段还处于“自动推荐”状态时继续接管;一旦用户手动改过,就会尊重手改内容,不会再被来源任务覆盖。 +- `来源任务` 摘要区也会跟着联动更新,切换任务后第一眼就能看到当前承接的是哪条任务。 +- 为了支持这层联动,输入型表单里的 HTML 字段现在也带了稳定的 `data-action-field` 标记,后续继续做表单智能化和回归锁定会更稳。 + ## 2026-04-06 ### 主 Agent 高注意图动作统一切到直执行入口 diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js index 2f3e53e..61e314a 100644 --- a/web/storyforge-web-v4/assets/app.js +++ b/web/storyforge-web-v4/assets/app.js @@ -503,6 +503,96 @@ function bindManualIntakeTitleRecommendation(fields, kind, options = {}) { sync(); } +function getCompletedJobById(jobId = "") { + const normalizedId = String(jobId || "").trim(); + if (!normalizedId) return null; + return safeArray(appState.dashboard?.recent_jobs).find((item) => item.id === normalizedId) + || (appState.lastJobDetail?.job?.id === normalizedId ? appState.lastJobDetail.job : null) + || null; +} + +function bindCreativeSourceJobRecommendations(fields, options = {}) { + const sourceJobSelect = fields.querySelector('[data-action-field="sourceJobId"]'); + if (!(sourceJobSelect instanceof HTMLSelectElement)) return; + const sourceContext = fields.querySelector('[data-action-field="sourceJobContext"] .sheet-html'); + const platformSelect = fields.querySelector(`[data-action-field="${options.platformField || "platform"}"]`); + const defaultPlatform = options.defaultPlatform || "douyin"; + const managedFields = []; + const resolveSourceJob = () => getCompletedJobById(sourceJobSelect.value) || options.sourceJob || null; + const recommendedPlatform = (job) => normalizePlatformValue(job?.platform || getPreferredPlatform(), defaultPlatform); + const registerManagedField = (field, compute, eventName = field instanceof HTMLSelectElement ? "change" : "input") => { + if (!(field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement || field instanceof HTMLSelectElement)) return; + field.dataset.recommendationMode = field.dataset.recommendationMode || "auto"; + field.addEventListener(eventName, () => { + if (field.dataset.recommendationApplying === "1") return; + field.dataset.recommendationMode = "manual"; + }); + managedFields.push({ field, compute }); + }; + const applyManagedValue = (field, value) => { + if (value == null || value === "") return; + field.dataset.recommendationApplying = "1"; + field.value = String(value); + field.dataset.recommendationMode = "auto"; + delete field.dataset.recommendationApplying; + }; + const titleInput = fields.querySelector('[data-action-field="title"]'); + if (options.titleSuffix && titleInput instanceof HTMLInputElement) { + registerManagedField(titleInput, (job) => recommendDerivativeJobTitle(job, options.titleSuffix, options.titleFallback || "")); + } + if (platformSelect instanceof HTMLSelectElement) { + registerManagedField(platformSelect, (job) => recommendedPlatform(job), "change"); + } + const audienceInput = fields.querySelector('[data-action-field="audience"]'); + if (audienceInput instanceof HTMLInputElement) { + registerManagedField(audienceInput, (job) => { + const platformValue = platformSelect instanceof HTMLSelectElement ? platformSelect.value : recommendedPlatform(job); + return recommendAudienceForPlatform(platformValue); + }); + } + const styleInput = fields.querySelector('[data-action-field="style"]'); + if (styleInput instanceof HTMLInputElement) { + registerManagedField(styleInput, (job) => recommendCreativeStyle(job)); + } + const aspectRatioField = fields.querySelector('[data-action-field="aspectRatio"]'); + if (aspectRatioField instanceof HTMLInputElement || aspectRatioField instanceof HTMLSelectElement) { + registerManagedField( + aspectRatioField, + (job) => { + const platformValue = platformSelect instanceof HTMLSelectElement ? platformSelect.value : recommendedPlatform(job); + return job?.artifacts?.aspect_ratio || recommendAspectRatioForPlatform(platformValue); + }, + aspectRatioField instanceof HTMLSelectElement ? "change" : "input" + ); + } + const durationField = fields.querySelector('[data-action-field="duration"]'); + if (durationField instanceof HTMLInputElement) { + registerManagedField(durationField, (job) => recommendAiVideoShotDuration(job)); + } + const targetDurationField = fields.querySelector('[data-action-field="targetDurationSec"]'); + if (targetDurationField instanceof HTMLInputElement) { + registerManagedField(targetDurationField, (job) => recommendRealCutDuration(job)); + } + const objectiveField = fields.querySelector('[data-action-field="objective"]'); + if (objectiveField instanceof HTMLTextAreaElement) { + registerManagedField(objectiveField, (job) => recommendRealCutObjective(job)); + } + const sync = () => { + const sourceJob = resolveSourceJob(); + if (sourceContext instanceof HTMLElement) { + sourceContext.innerHTML = sourceJob ? renderSourceJobContextHtml(sourceJob) : ""; + sourceContext.parentElement?.classList.toggle("hidden", !sourceJob); + } + managedFields.forEach(({ field, compute }) => { + if (field.dataset.recommendationMode === "manual") return; + applyManagedValue(field, compute(sourceJob)); + }); + }; + sourceJobSelect.addEventListener("change", sync); + platformSelect?.addEventListener("change", sync); + sync(); +} + function formatDateTime(value) { if (!value) return "-"; const date = new Date(value); @@ -1071,7 +1161,7 @@ function renderActionFields(fields) { const common = `data-action-field="${escapeHtml(field.name)}"`; if (field.type === "html") { return ` -
+
${field.html || ""}
@@ -11564,6 +11654,9 @@ function openGenerateCopyAction(defaults = {}) { { name: "audience", label: "受众", value: defaults.audience || recommendAudienceForPlatform(defaultPlatform) }, { name: "extraRequirements", label: "额外要求", placeholder: "例如:强结论开头,结尾带 CTA" } ], + onOpen: ({ fields }) => { + bindCreativeSourceJobRecommendations(fields, { sourceJob, defaultPlatform }); + }, onSubmit: async (values) => { if (!values.brief?.trim()) throw new Error("请填写创作需求"); const result = await storyforgeFetch(`/v2/assistants/${encodeURIComponent(assistant.id)}/generate`, { @@ -11670,6 +11763,9 @@ function openCreateAiVideoAction(defaults = {}) { placeholder: "例如:避免过暗,人物手部自然,保持真实商业质感", } ], + onOpen: ({ fields }) => { + bindCreativeSourceJobRecommendations(fields, { sourceJob, defaultPlatform, titleSuffix: "AI 视频" }); + }, onSubmit: async (values) => { if (!values.title?.trim()) throw new Error("请填写任务标题"); if (!values.brief?.trim()) throw new Error("请填写视频 brief"); @@ -11737,6 +11833,9 @@ function openCreateRealCutAction(defaults = {}) { { name: "aspectRatio", label: "画幅", value: defaults.aspectRatio || sourceJob?.artifacts?.aspect_ratio || recommendAspectRatioForPlatform(defaultPlatform) }, { name: "objective", label: "目标", type: "textarea", rows: 4, value: defaults.objective || recommendRealCutObjective(sourceJob), placeholder: "例如:保留高信息密度片段,输出适合短视频平台的粗剪结果" } ], + onOpen: ({ fields }) => { + bindCreativeSourceJobRecommendations(fields, { sourceJob, defaultPlatform, titleSuffix: "实拍剪辑" }); + }, onSubmit: async (values) => { if (!values.title?.trim()) throw new Error("请填写任务标题"); if (!values.sourceJobId) throw new Error("请先选择一个已完成的源任务"); @@ -12030,6 +12129,9 @@ function openReviewAction(defaults = {}) { { name: "nextActions", label: "下一步", type: "textarea", rows: 4, value: existingReview?.next_actions || "", placeholder: "例如:保留结构,换一个细分人群再做一条" }, { name: "notes", label: "备注", type: "textarea", rows: 4, value: existingReview?.notes || "", placeholder: "补充团队讨论、平台环境、发布时间段等信息" } ], + onOpen: ({ fields }) => { + bindCreativeSourceJobRecommendations(fields, { sourceJob, defaultPlatform: normalizePlatformValue(existingReview?.platform || defaults.platform || sourceJob?.platform || "douyin"), titleSuffix: "复盘" }); + }, onSubmit: async (values) => { if (!values.title?.trim()) throw new Error("请填写复盘标题"); const payload = { diff --git a/web/storyforge-web-v4/tests/workbench-pages.test.mjs b/web/storyforge-web-v4/tests/workbench-pages.test.mjs index 63b1a61..b200ff5 100644 --- a/web/storyforge-web-v4/tests/workbench-pages.test.mjs +++ b/web/storyforge-web-v4/tests/workbench-pages.test.mjs @@ -1196,6 +1196,7 @@ test("input-heavy intake sheets surface current context and smarter defaults", ( assert.match(APP, /function recommendLiveRecorderImportSamples\(platform\)/); assert.match(APP, /function recommendManualIntakeTitle\(project, platform, kind\)/); assert.match(APP, /function bindManualIntakeTitleRecommendation\(fields, kind, options = \{\}\)/); + assert.match(APP, /function bindCreativeSourceJobRecommendations\(fields, options = \{\}\)/); assert.match(importHomepage, /label: "当前上下文", type: "html"/); assert.match(importHomepage, /const defaultAssistantId = getSelectedAssistant\(\)\?\.id \|\| assistants\[0\]\?\.value \|\| ""/); assert.match(importHomepage, /const defaultPlatform = normalizePlatformValue\(getPreferredPlatform\(\), "douyin"\)/); @@ -1236,6 +1237,7 @@ test("input-heavy intake sheets surface current context and smarter defaults", ( assert.match(generateCopy, /sourceJob \? \[\{ name: "sourceJobContext", label: "来源任务", type: "html", html: renderSourceJobContextHtml\(sourceJob\) \}\] : \[\]/); assert.match(generateCopy, /const defaultPlatform = normalizePlatformValue\(defaults\.platform \|\| sourceJob\?\.platform \|\| "douyin"\)/); assert.match(generateCopy, /value: defaults\.audience \|\| recommendAudienceForPlatform\(defaultPlatform\)/); + assert.match(generateCopy, /onOpen:\s*\(\{ fields \}\) => \{\s*bindCreativeSourceJobRecommendations\(fields, \{/); assert.match(createAiVideo, /label: "当前上下文", type: "html"/); assert.match(createAiVideo, /const defaultAssistantId = assistant\?\.id \|\| ""/); assert.match(createAiVideo, /const defaultPlatform = normalizePlatformValue\(defaults\.platform \|\| sourceJob\?\.platform \|\| "douyin"\)/); @@ -1245,6 +1247,7 @@ test("input-heavy intake sheets surface current context and smarter defaults", ( assert.match(createAiVideo, /value: defaults\.style \|\| recommendCreativeStyle\(sourceJob\)/); assert.match(createAiVideo, /recommendAspectRatioForPlatform\(defaultPlatform\)/); assert.match(createAiVideo, /value: defaults\.duration \|\| recommendAiVideoShotDuration\(sourceJob\)/); + assert.match(createAiVideo, /onOpen:\s*\(\{ fields \}\) => \{\s*bindCreativeSourceJobRecommendations\(fields, \{/); assert.match(createRealCut, /label: "当前上下文", type: "html"/); assert.match(createRealCut, /const defaultPlatform = normalizePlatformValue\(defaults\.platform \|\| sourceJob\?\.platform \|\| "douyin"\)/); assert.match(createRealCut, /renderIntakeActionContextHtml\(project\.id, assistant\?\.id \|\| ""\)/); @@ -1253,6 +1256,7 @@ test("input-heavy intake sheets surface current context and smarter defaults", ( assert.match(createRealCut, /value: defaults\.targetDurationSec \|\| recommendRealCutDuration\(sourceJob\)/); assert.match(createRealCut, /recommendAspectRatioForPlatform\(defaultPlatform\)/); assert.match(createRealCut, /value: defaults\.objective \|\| recommendRealCutObjective\(sourceJob\)/); + assert.match(createRealCut, /onOpen:\s*\(\{ fields \}\) => \{\s*bindCreativeSourceJobRecommendations\(fields, \{/); assert.match(liveRecorderCreate, /label: "当前上下文", type: "html"/); assert.match(liveRecorderCreate, /const defaultAssistantId = getSelectedAssistant\(\)\?\.id \|\| assistants\[0\]\?\.value \|\| ""/); assert.match(liveRecorderCreate, /const defaultPlatform = normalizePlatformValue\(getPreferredPlatform\(\), "kuaishou"\)/); @@ -1276,6 +1280,7 @@ test("input-heavy intake sheets surface current context and smarter defaults", ( assert.match(reviewAction, /sourceJob \? \[\{ name: "sourceJobContext", label: "来源任务", type: "html", html: renderSourceJobContextHtml\(sourceJob\) \}\] : \[\]/); assert.match(reviewAction, /value: existingReview\?\.title \|\| defaults\.title \|\| recommendDerivativeJobTitle\(sourceJob, "复盘", ""\)/); assert.match(reviewAction, /normalizePlatformValue\(existingReview\?\.platform \|\| defaults\.platform \|\| sourceJob\?\.platform \|\| "douyin"\)/); + assert.match(reviewAction, /onOpen:\s*\(\{ fields \}\) => \{\s*bindCreativeSourceJobRecommendations\(fields, \{/); }); test("discovery analysis actions focus the most relevant detail tab after success", () => {