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", () => {