feat(core): 脚本 agent 真流式思考动画 + 场景图16:9 + 资产卡/审核标修复 + 模型收敛

- 进度区改竖线时间轴:推理模型 reasoning_content 逐字转发(volcano),状态字=模型实时最新一句+秒数+折叠展开思考全文(替代旧 ProgressStream/ThinkingStream)
- 场景基础资产出图改 16:9(run_base_asset_task size + 提取提示词横屏)
- 已出图立绘主卡不再被在途轮询任务误盖转圈
- 审核态随项目详情持久下发(BaseAssetGroup.adopted_asset_review),刷新不丢徽章
- 设定卡加「← 返回」回三选项;模型选择器固定按 created_at 排序(默认豆包2.0Pro)
- 新增 reasoning 转发回归测试

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-18 11:25:13 +08:00
co-authored by Claude Opus 4.8
parent 675b4db0aa
commit 4c21d85ba1
9 changed files with 201 additions and 50 deletions
@@ -97,6 +97,11 @@ class VolcanoArkProvider:
if not choices:
continue
delta = choices[0].get("delta") or {}
# 推理模型(豆包 seed-pro / 部分中转 o系/gemini)思考阶段只发 reasoning_content,
# 不发 content。必须单独转发,否则整个思考期(可达几十秒~分钟)前端零输出 = 假死。
reason = delta.get("reasoning_content")
if reason:
yield {"type": "reasoning", "text": reason}
piece = delta.get("content")
if piece:
yield {"type": "delta", "text": piece}
+11 -2
View File
@@ -8,6 +8,7 @@
SSE 事件(每帧 `data: {json}\n\n`,json 带 type):
tool {id,label?,status:running|done|error} —— 工具卡(加载skill/分析商品/生成分镜/提取实体/自检)
reasoning {text} —— 推理模型思考流(逐字,纯展示,不进答案)
delta {text} —— 模型自然语言前言(JSON 部分不外露)
draft {draft} —— 规范化后的 ScriptDraft(前端结构化渲染)
saved {script_version_id, version} —— 已落库的 ScriptVersion(含 segments/metadata)
@@ -621,7 +622,15 @@ def stream_script_agent(
messages=messages,
temperature=0.85,
):
if ev.get("type") == "delta":
et = ev.get("type")
if et == "reasoning":
# 思考流:推理模型在出 JSON 前会先想很久,把思考逐字下发给前端(像对话一样可见),
# 不进 full(不是答案正文,纯展示)。这是「卡在生成分镜」假死的根因修复。
rpiece = ev.get("text") or ""
if rpiece:
yield _sse({"type": "reasoning", "text": rpiece})
continue
if et == "delta":
full.append(ev["text"])
if forwarding:
text = "".join(full)
@@ -634,7 +643,7 @@ def stream_script_agent(
shown = len(visible)
if piece.strip():
yield _sse({"type": "delta", "text": piece})
elif ev.get("type") == "done":
elif et == "done":
break
raw = "".join(full)
draft = normalize_draft(raw, aspect_ratio=aspect_ratio, total_duration=effective_duration)
+6 -3
View File
@@ -125,11 +125,12 @@ def build_cast_scene_extract_prompt(content: str) -> list[dict[str, str]]:
"""轻量抽取提示词:从镜头脚本里提炼人物 / 场景标签,并给每个标签一句可直接生图的画面提示词。"""
system = (
"你是短视频脚本分析助手。请从给定的镜头脚本中提取出现的『人物』和『场景』,"
"并为每个人物 / 场景写一句可直接用于文生图的画面提示词(中文,30 字内,描述外形 / 着装 / 环境 / 光线,9:16 竖屏)。"
"并为每个人物 / 场景写一句可直接用于文生图的画面提示词(中文,30 字内,描述外形 / 着装 / 环境 / 光线)。"
"人物提示词用 9:16 竖屏(出镜角色全身);场景提示词用 16:9 横屏(环境 / 背景空镜)。"
"人物指出镜的角色(例:女主、同事、闺蜜);场景指画面发生的地点或环境(例:卫生间、地铁、办公室)。"
"去重,人物与场景各最多 6 个。只输出一个 JSON 对象,不要 markdown 代码块,不要任何额外文字,格式如下:\n"
'{"cast":[{"name":"女主","prompt":"26岁都市女性,自然妆容,米色针织衫,柔和室内光,9:16竖屏"}],'
'"scenes":[{"name":"卫生间","prompt":"现代简约浴室,暖色灯光,干净台面,9:16竖"}]}'
'"scenes":[{"name":"卫生间","prompt":"现代简约浴室,暖色灯光,干净台面,16:9横"}]}'
)
return [{"role": "system", "content": system}, {"role": "user", "content": f"镜头脚本如下:\n{content}".strip()}]
@@ -536,7 +537,9 @@ def run_base_asset_task(*, task_id: str) -> None:
if use_edit and ref_url:
response = provider.image_edit(model=model_config.name, prompt=prompt, images=[ref_url], size="1536x1024")
else:
response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=prompt)
# 场景 = 16:9 横构图(环境/背景空镜);人物立绘 = 9:16 竖全身。默认竖。
gen_size = "1536x1024" if kind == BaseAssetGroup.Kind.SCENE else "1024x1536"
response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=prompt, size=gen_size)
media = provider.extract_first_media_url(response)
with transaction.atomic():
task.status = AITask.Status.SUCCEEDED
+47
View File
@@ -142,6 +142,7 @@ from unittest.mock import patch
from django.test import TestCase
from apps.accounts.models import Team, User
from apps.ai.providers.volcano import VolcanoArkProvider
from apps.ai.services import enqueue_standalone_images
from apps.assets.models import Asset, AssetFile
from apps.billing.models import CreditAccount
@@ -209,3 +210,49 @@ class StandaloneImageReferenceTests(TestCase):
enqueue_standalone_images(team=self.team, user=self.user, prompt="平台套图", mode="cover", count=1, product_id=str(self.product.id), ratio="4:5")
prov.image_generation.assert_called_once()
prov.image_edit.assert_not_called()
class _FakeStreamResp:
"""模拟 requests 流式响应:支持 with、raise_for_status、可写 encoding、iter_lines。"""
status_code = 200
encoding = None
def __init__(self, lines):
self._lines = lines
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def raise_for_status(self):
return None
def iter_lines(self, decode_unicode=True): # noqa: ARG002
yield from self._lines
class ChatStreamReasoningTests(SimpleTestCase):
"""推理模型(豆包 seed-pro 等)思考期只发 reasoning_content、不发 content。
provider 必须把它作为独立 `reasoning` 事件转发否则脚本 agent 思考期零输出 =
前端卡在生成分镜假死本测试锁住该转发,防回归"""
def test_reasoning_content_forwarded_as_reasoning_event(self):
def _chunk(delta):
return "data: " + json.dumps({"choices": [{"delta": delta}]}, ensure_ascii=False)
lines = [
_chunk({"reasoning_content": "先想想"}),
_chunk({"reasoning_content": "用户要4镜"}),
_chunk({"content": "正在生成"}),
_chunk({"content": "脚本…"}),
"data: [DONE]",
]
prov = VolcanoArkProvider(api_key="k", base_url="http://x")
with patch("apps.ai.providers.volcano.requests.post", return_value=_FakeStreamResp(lines)):
events = list(prov.chat_completion_stream(model="m", messages=[{"role": "user", "content": "hi"}]))
self.assertEqual([e["type"] for e in events], ["reasoning", "reasoning", "delta", "delta", "done"])
self.assertEqual([e["text"] for e in events if e["type"] == "reasoning"], ["先想想", "用户要4镜"])
self.assertEqual("".join(e["text"] for e in events if e["type"] == "delta"), "正在生成脚本…")
+3 -1
View File
@@ -75,7 +75,9 @@ class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet):
class ModelConfigViewSet(ReadOnlyModelViewSet):
queryset = ModelConfig.objects.select_related("provider").filter(status=ModelConfig.Status.ACTIVE)
# 按创建序固定排序:最早创建的 active 模型排第一 = 前端选择器默认项,与 get_default_model 口径一致
# (否则 DB 默认序不稳定,可能默认选到 Gemini 等;用户要默认 = 豆包 2.0 Pro,它最早创建)
queryset = ModelConfig.objects.select_related("provider").filter(status=ModelConfig.Status.ACTIVE).order_by("created_at")
serializer_class = ModelConfigSerializer
search_fields = ["name", "display_name", "capability"]
ordering_fields = ["created_at", "display_name"]
+10 -1
View File
@@ -78,15 +78,24 @@ class BaseAssetGroupSerializer(serializers.ModelSerializer):
candidate_assets = serializers.PrimaryKeyRelatedField(many=True, read_only=True)
adopted_asset_url = serializers.SerializerMethodField()
candidate_asset_urls = serializers.SerializerMethodField()
# 采用资产的火山审核态随项目详情下发,刷新后仍能回显绿/红/审核中徽章(前端不再依赖那次性的轮询)
adopted_asset_review = serializers.SerializerMethodField()
adopted_asset_review_error = serializers.SerializerMethodField()
class Meta:
model = BaseAssetGroup
fields = ["id", "kind", "prompt", "adopted_asset", "adopted_asset_url", "candidate_assets", "candidate_asset_urls", "version", "metadata", "created_at"]
fields = ["id", "kind", "prompt", "adopted_asset", "adopted_asset_url", "candidate_assets", "candidate_asset_urls", "adopted_asset_review", "adopted_asset_review_error", "version", "metadata", "created_at"]
read_only_fields = fields
def get_adopted_asset_url(self, obj) -> str:
return _asset_preview_url(obj.adopted_asset)
def get_adopted_asset_review(self, obj) -> str:
return getattr(obj.adopted_asset, "review_status", "") or ""
def get_adopted_asset_review_error(self, obj) -> str:
return getattr(obj.adopted_asset, "review_error", "") or ""
def get_candidate_asset_urls(self, obj) -> dict:
return {str(asset.id): _asset_preview_url(asset) for asset in obj.candidate_assets.all()}