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:
co-authored by
Claude Opus 4.8
parent
675b4db0aa
commit
4c21d85ba1
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"), "正在生成脚本…")
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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()}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user