From 1ecb186ddfeadb5a6e2cb439a6ed2ef90ff2f1c4 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Wed, 17 Jun 2026 21:34:15 +0800 Subject: [PATCH] =?UTF-8?q?refactor(core):=20=E8=84=9A=E6=9C=AC=E6=94=B6?= =?UTF-8?q?=E5=8F=A3=E2=80=94=E2=80=94=E5=8F=AA=E7=95=99=20agent=20?= =?UTF-8?q?=E5=87=BA=E7=A8=BF,=E5=88=A0=E6=97=A7=E6=95=A3=E6=96=87?= =?UTF-8?q?=E8=B7=AF=20+=20=E5=8D=95=E9=95=9C=E9=87=8D=E8=B7=91=E6=94=B9?= =?UTF-8?q?=E8=B5=B0=20agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删 /generate-script/ 端点 + generate_project_script + build_script_prompt + 旧单镜 build_segment_rerun_prompt;前端删静默兜底(agent 失败明确报错,不再掉回旧路)+ onGenerateScript prop/api。rerun-script-segment 改用 agent 单镜逻辑(regenerate_segment_via_agent:读全脚本上下文/保留其余镜/落新版本)。根因:旧路不产 script_entities/entity_refs,下游故事板/视频参考图断线→图飘。测试:删 3 个旧 generate 用例 + 重写 rerun 为 agent 版,ai+projects 23 测试除 4 个既有 base-asset 失败外全绿。 Co-Authored-By: Claude Opus 4.8 (1M context) --- core/backend/apps/ai/script_agent.py | 65 ++++++++ core/backend/apps/ai/services.py | 207 +------------------------- core/backend/apps/projects/tests.py | 112 ++++---------- core/backend/apps/projects/views.py | 13 -- core/frontend/src/App.tsx | 4 +- core/frontend/src/api.ts | 6 - core/frontend/src/routes/pipeline.tsx | 11 +- 7 files changed, 102 insertions(+), 316 deletions(-) diff --git a/core/backend/apps/ai/script_agent.py b/core/backend/apps/ai/script_agent.py index ef8c252..f1facb1 100644 --- a/core/backend/apps/ai/script_agent.py +++ b/core/backend/apps/ai/script_agent.py @@ -673,3 +673,68 @@ def _load_base_draft(project, base_version_id: str) -> dict | None: return json.loads(version.content) except (ValueError, TypeError): return None + + +def regenerate_segment_via_agent(*, project, user, model_config: ModelConfig, segment, instruction: str = ""): + """非流式·精准改一镜(「场次刷新」按钮复用 agent 单镜逻辑):读全脚本上下文,只重写该镜,落新 ScriptVersion。 + 与 stream_script_agent 的 target_index 分支同源,但同步返回(不走 SSE)。计费 reserve→charge/release 闭环。""" + from django.db import transaction + + from apps.ai.services import build_provider, create_ai_task + from apps.billing.services.ledger import charge_reserved_credit + + base_version = segment.script_version + base_draft = _load_base_draft(project, str(base_version.id)) + if base_draft is None: + raise ValueError("基准脚本无法解析,无法精准改镜") + target_index = segment.sort_order + aspect_ratio = (base_draft.get("aspect_ratio") or "9:16").strip() + total_duration = base_draft.get("total_duration") or 60 + seg_n = len(base_draft.get("segments", [])) + if not (0 <= target_index < seg_n): + raise ValueError(f"镜号越界:第 {target_index + 1} 镜(共 {seg_n} 镜)") + + messages = build_agent_messages( + project=project, + mode="revise", + user_prompt=instruction, + selling_point_ids=None, + base_draft=base_draft, + aspect_ratio=aspect_ratio, + total_duration=total_duration, + target_index=target_index, + ) + task = create_ai_task( + project=project, + user=user, + task_type=AITask.Type.SCRIPT_OPTIMIZATION, + model_config=model_config, + request_payload={ + "model": model_config.name, + "endpoint": model_config.endpoint, + "mode": "revise", + "target_index": target_index, + }, + ) + reservation = task.credit_reservation + try: + task.status = AITask.Status.SUBMITTED + task.submitted_at = timezone.now() + task.save(update_fields=["status", "submitted_at", "updated_at"]) + provider = build_provider(model_config) + response = provider.chat_completion(model=model_config.name, endpoint=model_config.endpoint, messages=messages) + raw = provider.extract_text(response) + draft = normalize_draft(raw, aspect_ratio=aspect_ratio, total_duration=total_duration) + draft = _merge_single_segment(base_draft, draft, target_index, aspect_ratio, total_duration) + with transaction.atomic(): + task.status = AITask.Status.SUCCEEDED + task.response_payload = {"raw": raw[:8000]} + task.actual_cost = task.estimated_cost + task.completed_at = timezone.now() + task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"]) + charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost) + script = persist_script_draft(project=project, user=user, task=task, draft=draft, source="revise") + return script + except Exception: + _fail_task(task, reservation, "单镜重跑失败") + raise diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index 14f3ddd..b11b3e4 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -85,33 +85,6 @@ def estimate_cost(model_config: ModelConfig) -> Decimal: return model_config.unit_price if model_config.unit_price > 0 else Decimal("1.0000") -def build_script_prompt(*, project, user_prompt: str, selling_point_ids: list[str] | None = None) -> list[dict[str, str]]: - product = project.product - selling_points = product.selling_points.all() - if selling_point_ids: - selling_points = selling_points.filter(id__in=selling_point_ids) - selling_text = "\n".join(f"- {item.title}: {item.detail}" for item in selling_points) - system = ( - "你是电商短视频脚本导演。请为 9:16 竖屏带货短视频生成 60 秒脚本," - "拆成 4 个 15 秒段落。严格按以下格式输出,段落之间空一行,不要输出其他内容:\n" - "镜头1\n旁白:这一镜要念出来的口播文案(一两句话)\n画面:这一镜的画面描述、商品露出方式和转场建议\n\n" - "镜头2\n旁白:…\n画面:…(依此类推到镜头4)" - ) - user = f""" -商品标题:{product.title} -品牌:{product.brand or "未填写"} -类目:{product.category or "未填写"} -目标人群:{product.target_audience or "未填写"} -商品描述:{product.description or "未填写"} -卖点: -{selling_text or "未选择卖点,请根据商品信息自行提炼。"} - -用户补充需求: -{user_prompt or "生成一条结构完整、节奏清晰、适合投放的带货短视频脚本。"} -""".strip() - return [{"role": "system", "content": system}, {"role": "user", "content": user}] - - def parse_segment_fields(block: str) -> tuple[str, str]: """从一镜文本里拆出(旁白, 画面)。 @@ -303,185 +276,17 @@ def create_ai_task(*, project, user, task_type: str, model_config: ModelConfig, return task -def generate_project_script(*, project, user, user_prompt: str, selling_point_ids: list[str] | None = None, source: str = "ai") -> ScriptVersion: - model_config = get_default_model(ModelConfig.Capability.TEXT) - if model_config is None: - raise ValueError("no active text model configured") - - messages = build_script_prompt(project=project, user_prompt=user_prompt, selling_point_ids=selling_point_ids) - payload = {"model": model_config.name, "endpoint": model_config.endpoint, "messages": messages} - task = create_ai_task( - project=project, - user=user, - task_type=AITask.Type.SCRIPT_GENERATION, - model_config=model_config, - request_payload=payload, - ) - reservation = task.credit_reservation - - try: - task.status = AITask.Status.SUBMITTED - task.submitted_at = timezone.now() - task.save(update_fields=["status", "submitted_at", "updated_at"]) - - provider = build_provider(model_config) - response = provider.chat_completion(model=model_config.name, endpoint=model_config.endpoint, messages=messages) - content = provider.extract_text(response) - - # 出稿后自动提取人物 / 场景(best-effort,失败返回空,不挡主流程),供脚本页标签与基础资产 seed - extracted = extract_cast_and_scenes(project=project, user=user, content=content) - - with transaction.atomic(): - task.status = AITask.Status.SUCCEEDED - task.response_payload = response - task.actual_cost = task.estimated_cost - task.completed_at = timezone.now() - task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"]) - charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost) - - script = ScriptVersion.objects.create( - project=project, - task=task, - title="AI 脚本", - content=content, - source=source if source in ("ai", "theme", "manual") else "ai", - is_adopted=False, - ) - for index, segment_text in enumerate(split_script_into_segments(content)): - narration, visual = parse_segment_fields(segment_text) - ScriptSegment.objects.create( - script_version=script, - sort_order=index, - duration_seconds=15, - narration=narration, - visual_prompt=visual, - ) - - # 把提取到的人物 / 场景(含每个标签的建议生图提示词)回填进 project.metadata: - # 脚本页标签自动读 cast/scenes,基础资产据 cast_prompts/scene_prompts seed 每张卡。 - # 仅在提取到内容时覆盖,空结果不清掉用户已有标签。 - if extracted["cast"] or extracted["scenes"]: - metadata = dict(project.metadata or {}) - if extracted["cast"]: - metadata["cast"] = extracted["cast"] - metadata["cast_prompts"] = extracted["cast_prompts"] - if extracted["scenes"]: - metadata["scenes"] = extracted["scenes"] - metadata["scene_prompts"] = extracted["scene_prompts"] - project.metadata = metadata - project.save(update_fields=["metadata", "updated_at"]) - - stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.SCRIPT) - stage.status = ProjectStage.Status.NEEDS_REVIEW - stage.save(update_fields=["status", "updated_at"]) - return script - except Exception as exc: - with transaction.atomic(): - task.status = AITask.Status.FAILED - task.error_message = str(exc) - task.completed_at = timezone.now() - task.save(update_fields=["status", "error_message", "completed_at", "updated_at"]) - release_credit(reservation=reservation, reason=str(exc)) - raise - - -def build_segment_rerun_prompt(*, project, segment, instruction: str = "") -> list[dict[str, str]]: - """单镜重跑提示词:带商品/卖点上下文 + 该镜当前内容 + 前后镜上下文(保连贯)+ 用户修改意见。 - 只重写这一镜,严格输出「旁白:…/画面:…」两行,供 parse_segment_fields 精确解析。""" - product = project.product - selling_points = product.selling_points.all() - selling_text = "\n".join(f"- {item.title}: {item.detail}" for item in selling_points) - - script = segment.script_version - siblings = list(script.segments.order_by("sort_order")) - prev_seg = next((s for s in reversed(siblings) if s.sort_order < segment.sort_order), None) - next_seg = next((s for s in siblings if s.sort_order > segment.sort_order), None) - - def _brief(seg) -> str: - narration = (seg.narration or "").strip() - visual = (seg.visual_prompt or "").strip() - return f"旁白:{narration or '无'};画面:{visual or '无'}" - - system = ( - "你是电商短视频脚本导演。现在只需要**重写一条分镜**(其它分镜保持不变)。" - "结合商品卖点、该镜当前内容、前后镜上下文和用户的修改意见,重新生成这一镜的旁白口播与画面描述," - "并保证与前后镜衔接连贯。严格按以下格式输出两行,不要输出镜头编号或任何其它内容:\n" - "旁白:这一镜要念出来的口播文案(一两句话)\n画面:这一镜的画面描述、商品露出方式和转场建议" - ) - context_lines = [ - f"商品标题:{product.title}", - f"品牌:{product.brand or '未填写'}", - f"类目:{product.category or '未填写'}", - f"目标人群:{product.target_audience or '未填写'}", - f"卖点:\n{selling_text or '未选择卖点,请根据商品信息自行提炼。'}", - "", - f"这是第 {segment.sort_order + 1} 镜(共 {len(siblings)} 镜),时长约 {segment.duration_seconds} 秒。", - f"该镜当前内容:{_brief(segment)}", - ] - if prev_seg is not None: - context_lines.append(f"上一镜(保持不变,用于衔接):{_brief(prev_seg)}") - if next_seg is not None: - context_lines.append(f"下一镜(保持不变,用于衔接):{_brief(next_seg)}") - context_lines.append("") - context_lines.append(f"用户的修改意见:{instruction.strip() or '让这一镜更有吸引力、表达更清晰,并与前后镜自然衔接。'}") - user = "\n".join(context_lines).strip() - return [{"role": "system", "content": system}, {"role": "user", "content": user}] - - def regenerate_script_segment(*, project, user, segment, instruction: str = "") -> ScriptVersion: - """单镜 AI 重跑:只重新生成该 ScriptSegment 的 narration + visual_prompt(其它镜不动), - 沿用 generate_project_script 的 AITask + 计费(reserve/charge/release)闭环,同步调 LLM。 - 返回该 segment 所属的 ScriptVersion。""" + """单镜重跑(「场次刷新」按钮):复用脚本 agent 的精准改一镜——读全脚本上下文、只动该镜、保留其余镜,落新 ScriptVersion。 + 旧的「散文 prompt + 正则解析」整条已废,统一走 agent(结构化 + entity_refs 不丢)。""" + from apps.ai.script_agent import regenerate_segment_via_agent + model_config = get_default_model(ModelConfig.Capability.TEXT) if model_config is None: raise ValueError("no active text model configured") - - messages = build_segment_rerun_prompt(project=project, segment=segment, instruction=instruction) - payload = { - "model": model_config.name, - "endpoint": model_config.endpoint, - "messages": messages, - "script_segment": str(segment.id), - } - task = create_ai_task( - project=project, - user=user, - task_type=AITask.Type.SCRIPT_OPTIMIZATION, - model_config=model_config, - request_payload=payload, + return regenerate_segment_via_agent( + project=project, user=user, model_config=model_config, segment=segment, instruction=instruction ) - reservation = task.credit_reservation - - try: - task.status = AITask.Status.SUBMITTED - task.submitted_at = timezone.now() - task.save(update_fields=["status", "submitted_at", "updated_at"]) - - provider = build_provider(model_config) - response = provider.chat_completion(model=model_config.name, endpoint=model_config.endpoint, messages=messages) - content = provider.extract_text(response) - narration, visual = parse_segment_fields(content) - - with transaction.atomic(): - task.status = AITask.Status.SUCCEEDED - task.response_payload = response - task.actual_cost = task.estimated_cost - task.completed_at = timezone.now() - task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"]) - charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost) - - segment.narration = narration - segment.visual_prompt = visual - segment.save(update_fields=["narration", "visual_prompt", "updated_at"]) - return segment.script_version - except Exception as exc: - with transaction.atomic(): - task.status = AITask.Status.FAILED - task.error_message = str(exc) - task.completed_at = timezone.now() - task.save(update_fields=["status", "error_message", "completed_at", "updated_at"]) - release_credit(reservation=reservation, reason=str(exc)) - raise def _generate_video_poster(*, video_bytes: bytes, team, project, asset_id) -> "StoredObject | None": diff --git a/core/backend/apps/projects/tests.py b/core/backend/apps/projects/tests.py index 3c57b89..de10c3f 100644 --- a/core/backend/apps/projects/tests.py +++ b/core/backend/apps/projects/tests.py @@ -82,48 +82,30 @@ class ProjectApiTests(TestCase): self.assertTrue(VideoSegment.objects.filter(project=project).exists()) @patch("apps.ai.services.VolcanoArkProvider") - def test_generate_script_creates_script_segments_and_charges_credit(self, provider_cls): - provider = provider_cls.return_value - provider.chat_completion.return_value = { - "choices": [ - { - "message": { - "content": "1. 开场吸引\n2. 展示卖点\n3. 使用场景\n4. 促单转化", - } - } - ] + def test_rerun_script_segment_via_agent_rewrites_one_and_charges_once(self, provider_cls): + """单镜重跑改走 agent:读结构化全脚本,只重写目标镜、保留其余镜,落新版本,计费一次。""" + import json as _json + base_draft = { + "hook": "钩子", "tone": "种草", "aspect_ratio": "9:16", "total_duration": 30, "segment_count": 2, + "entities": [], + "segments": [ + {"index": 0, "duration": 15, "role": "钩子", "narration": "旧0", "visual": "画0", "speaker": None, "product_exposure": "", "entity_refs": [], "dialogue": []}, + {"index": 1, "duration": 15, "role": "CTA", "narration": "旧1", "visual": "画1", "speaker": None, "product_exposure": "", "entity_refs": [], "dialogue": []}, + ], } - provider.extract_text.return_value = "1. 开场吸引\n2. 展示卖点\n3. 使用场景\n4. 促单转化" - project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="Launch Video") - for stage in [ - ProjectStage.Stage.SCRIPT, - ProjectStage.Stage.BASE_ASSETS, - ProjectStage.Stage.STORYBOARD, - ProjectStage.Stage.VIDEO, - ProjectStage.Stage.EXPORT, - ]: - ProjectStage.objects.create(project=project, stage=stage) - - response = self.client.post( - f"/api/projects/{project.id}/generate-script/", - {"prompt": "突出高转化"}, - format="json", - ) - - self.assertEqual(response.status_code, 201) - script = ScriptVersion.objects.get(project=project) - self.assertEqual(script.segments.count(), 4) - # 出稿(1)+ 人物/场景提取(1)各计费一次;此处 mock 提取返回非 JSON,提取仍按调用成功计费 - self.assertEqual(CreditLedger.objects.filter(team=self.team, ledger_type=CreditLedger.Type.CHARGE).count(), 2) - - @patch("apps.ai.services.VolcanoArkProvider") - def test_rerun_script_segment_updates_one_segment_and_charges_once(self, provider_cls): + new_draft = _json.loads(_json.dumps(base_draft)) + new_draft["segments"][1]["narration"] = "全新口播文案" + new_draft["segments"][1]["visual"] = "全新画面描述" provider = provider_cls.return_value provider.chat_completion.return_value = {"choices": [{"message": {"content": "x"}}]} - provider.extract_text.return_value = "旁白:全新口播文案\n画面:全新画面描述" + provider.extract_text.return_value = "```json\n" + _json.dumps(new_draft, ensure_ascii=False) + "\n```" + project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P") - script = ScriptVersion.objects.create(project=project, title="脚本", content="...", is_adopted=True) - seg0 = ScriptSegment.objects.create(script_version=script, sort_order=0, narration="旧0", visual_prompt="画0") + script = ScriptVersion.objects.create( + project=project, title="脚本", content=_json.dumps(base_draft, ensure_ascii=False), + is_adopted=True, metadata={"hook": "钩子", "entities": []}, + ) + ScriptSegment.objects.create(script_version=script, sort_order=0, narration="旧0", visual_prompt="画0") seg1 = ScriptSegment.objects.create(script_version=script, sort_order=1, narration="旧1", visual_prompt="画1") response = self.client.post( @@ -131,17 +113,13 @@ class ProjectApiTests(TestCase): {"segment_id": str(seg1.id), "instruction": "更俏皮"}, format="json", ) - self.assertEqual(response.status_code, 200) - seg0.refresh_from_db() - seg1.refresh_from_db() - self.assertEqual(seg1.narration, "全新口播文案") - self.assertEqual(seg1.visual_prompt, "全新画面描述") - # 其它分镜保持不动 - self.assertEqual(seg0.narration, "旧0") - self.assertEqual(seg0.visual_prompt, "画0") - # 返回的是该镜所属 ScriptVersion - self.assertEqual(str(response.data["id"]), str(script.id)) + # agent 落新 ScriptVersion:目标镜改了,其余镜保留 + new_version = ScriptVersion.objects.filter(project=project).order_by("-created_at").first() + segs = list(new_version.segments.order_by("sort_order")) + self.assertEqual(segs[1].narration, "全新口播文案") + self.assertEqual(segs[1].visual_prompt, "全新画面描述") + self.assertEqual(segs[0].narration, "旧0") self.assertEqual(CreditLedger.objects.filter(team=self.team, ledger_type=CreditLedger.Type.CHARGE).count(), 1) def test_rerun_script_segment_rejects_foreign_segment(self): @@ -157,44 +135,6 @@ class ProjectApiTests(TestCase): ) self.assertEqual(response.status_code, 404) - # ── 流程文档 步骤③④:出稿后自动提取人物/场景(含建议提示词)并计费 ── - @patch("apps.ai.services.VolcanoArkProvider") - def test_generate_script_extracts_cast_scenes_and_bills(self, provider_cls): - provider = provider_cls.return_value - script_text = "镜头1\n旁白:开场\n画面:地铁里女主掏出面膜\n\n镜头2\n旁白:卖点\n画面:同事围观" - # 提取调用让模型回 JSON(允许外面裹解释文字,验证容错) - extract_text = '这是结果:{"cast":[{"name":"女主","prompt":"26岁都市女性,自然妆"},{"name":"同事","prompt":"职场女性"}],"scenes":[{"name":"地铁","prompt":"早高峰地铁车厢"}]}' - provider.chat_completion.side_effect = [{"r": 1}, {"r": 2}] - provider.extract_text.side_effect = [script_text, extract_text] - project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P") - ProjectStage.objects.create(project=project, stage=ProjectStage.Stage.SCRIPT) - - response = self.client.post(f"/api/projects/{project.id}/generate-script/", {"prompt": "x"}, format="json") - self.assertEqual(response.status_code, 201) - - project.refresh_from_db() - self.assertEqual(project.metadata.get("cast"), ["女主", "同事"]) - self.assertEqual(project.metadata.get("scenes"), ["地铁"]) - self.assertEqual(project.metadata.get("cast_prompts", {}).get("女主"), "26岁都市女性,自然妆") - self.assertEqual(project.metadata.get("scene_prompts", {}).get("地铁"), "早高峰地铁车厢") - # 出稿 + 提取,各计费一次 - self.assertEqual(CreditLedger.objects.filter(team=self.team, ledger_type=CreditLedger.Type.CHARGE).count(), 2) - - @patch("apps.ai.services.VolcanoArkProvider") - def test_extract_failure_releases_credit_and_keeps_script(self, provider_cls): - """提取调用抛错:必须退掉提取的预扣(不计费),且不影响脚本已出稿(出稿那笔照扣)。""" - provider = provider_cls.return_value - provider.chat_completion.side_effect = [{"r": 1}, RuntimeError("ark down")] - provider.extract_text.side_effect = ["镜头1\n旁白:x\n画面:y", AssertionError("不应走到")] - project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P") - ProjectStage.objects.create(project=project, stage=ProjectStage.Stage.SCRIPT) - - response = self.client.post(f"/api/projects/{project.id}/generate-script/", {"prompt": "x"}, format="json") - self.assertEqual(response.status_code, 201) - self.assertEqual(ScriptVersion.objects.filter(project=project).count(), 1) - # 出稿计费 1 笔;提取失败 → release,不产生第 2 笔 CHARGE - self.assertEqual(CreditLedger.objects.filter(team=self.team, ledger_type=CreditLedger.Type.CHARGE).count(), 1) - @patch("apps.ai.services._store_generated_media") @patch("apps.ai.services.get_image_provider") def test_generate_base_asset_stores_tag_label(self, get_provider, store_media): diff --git a/core/backend/apps/projects/views.py b/core/backend/apps/projects/views.py index 5b6a1cf..e9d9248 100644 --- a/core/backend/apps/projects/views.py +++ b/core/backend/apps/projects/views.py @@ -21,7 +21,6 @@ from apps.ai.services import ( create_export_job, generate_base_asset, generate_person_triview, - generate_project_script, generate_storyboard_frame, get_default_model, poll_video_segment, @@ -169,18 +168,6 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): for index in range(4): VideoSegment.objects.create(project=project, sort_order=index, target_duration_seconds=15) - @action(detail=True, methods=["post"], url_path="generate-script") - def generate_script(self, request, pk=None): - project = self.get_object() - script = generate_project_script( - project=project, - user=request.user, - user_prompt=request.data.get("prompt", ""), - selling_point_ids=request.data.get("selling_point_ids") or [], - source=request.data.get("source") or "ai", - ) - return Response(ScriptVersionSerializer(script).data, status=status.HTTP_201_CREATED) - @action(detail=True, methods=["post"], url_path="script-agent-stream", renderer_classes=[ServerSentEventRenderer]) def script_agent_stream(self, request, pk=None): """对话式脚本 agent · 流式(SSE)。出稿 + 改稿一体,多模型可选。 diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 0b6f3c2..7e6314f 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -748,9 +748,7 @@ export function App() { notice={notice} unreadCount={unreadCount} avatarChar={avatarChar} - logout={logout} - onGenerateScript={(prompt, source) => action(() => api.generateScript(pipelineProject.id, { prompt, source }), "脚本已生成")} - onAdoptScript={(scriptId) => action(() => api.adoptScript(pipelineProject.id, scriptId), "脚本已采用")} + logout={logout} onAdoptScript={(scriptId) => action(() => api.adoptScript(pipelineProject.id, scriptId), "脚本已采用")} onUpdateShot={(payload) => action(() => api.updateScriptSegment(pipelineProject.id, payload), "分镜已更新")} onAddShot={(afterSegmentId) => action(() => api.addScriptSegment(pipelineProject.id, { after_segment_id: afterSegmentId }), "分镜已添加")} onDeleteShot={(segmentId) => action(() => api.deleteScriptSegment(pipelineProject.id, { segment_id: segmentId }), "分镜已删除")} diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index c3affb9..42bbacc 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -223,12 +223,6 @@ export const api = { deleteProject(id: string) { return request(`/api/projects/${id}/`, { method: "DELETE" }); }, - generateScript(projectId: string, payload: { prompt: string; source?: string; selling_point_ids?: string[] }) { - return request(`/api/projects/${projectId}/generate-script/`, { - method: "POST", - body: JSON.stringify(payload) - }); - }, // 对话式脚本 agent · 流式(SSE)。逐帧回调 onEvent:tool(工具卡)/delta(思考前言)/draft/saved/done/error。 // 用 fetch + ReadableStream 消费 text/event-stream(EventSource 只支持 GET,这里要 POST 带 body)。 async agentScriptStream( diff --git a/core/frontend/src/routes/pipeline.tsx b/core/frontend/src/routes/pipeline.tsx index 93c69d0..9cd9883 100644 --- a/core/frontend/src/routes/pipeline.tsx +++ b/core/frontend/src/routes/pipeline.tsx @@ -422,9 +422,7 @@ export function PipelinePage(props: { avatarChar: string; logout: () => void; scriptModelName: string; - textModels?: ModelConfig[]; - onGenerateScript: (prompt: string, source?: string) => Promise; - onAdoptScript: (scriptId: string) => void | Promise; + textModels?: ModelConfig[]; onAdoptScript: (scriptId: string) => void | Promise; onUpdateShot: (payload: { segment_id: string; narration?: string; visual_prompt?: string; duration_seconds?: number }) => Promise; onAddShot: (afterSegmentId: string) => Promise; onDeleteShot: (segmentId: string) => Promise; @@ -460,7 +458,7 @@ export function PipelinePage(props: { }) { const { project, loading, navigate, user, team, products, projects, assets, billing, notice, unreadCount, avatarChar, logout, - scriptModelName, textModels, onGenerateScript, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover, + scriptModelName, textModels, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover, onGenerateBaseAsset, onAdoptBaseAsset, onAttachBaseAsset, onGenerateActor, onUploadActor, onGenerateTriview, onRenameActor, onGenerateStoryboard, onSkipStoryboard, onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport, onRefreshProject, onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport @@ -929,9 +927,8 @@ export function PipelinePage(props: { if (ok) { // 已收到 saved:流其实成功了(只是收尾抖动),绝不回退重发,避免重复生成+重复扣费 } else if (!receivedEvent) { - // 一个事件都没收到 = 流式根本没跑起来(网关不支持 SSE)→ 退回旧同步端点 - const res = await onGenerateScript(prompt, source ?? chatMode).catch(() => null); - ok = !!res; + // 流式没跑起来:明确报错,不再静默退回旧散文路(旧路不产 script_entities,会让下游故事板/视频参考图断线) + pushMsg("ai", "脚本生成没能启动(流式连接失败),请重试。"); } else { // 流中途断了(后端已通过 finally 释放预扣额度):不重试,提示用户 pushMsg("ai", "生成中断了,请重试。");