From 4c21d85ba1eeefccc1ca10123a02819947153338 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Thu, 18 Jun 2026 11:25:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(core):=20=E8=84=9A=E6=9C=AC=20agent=20?= =?UTF-8?q?=E7=9C=9F=E6=B5=81=E5=BC=8F=E6=80=9D=E8=80=83=E5=8A=A8=E7=94=BB?= =?UTF-8?q?=20+=20=E5=9C=BA=E6=99=AF=E5=9B=BE16:9=20+=20=E8=B5=84=E4=BA=A7?= =?UTF-8?q?=E5=8D=A1/=E5=AE=A1=E6=A0=B8=E6=A0=87=E4=BF=AE=E5=A4=8D=20+=20?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E6=94=B6=E6=95=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 进度区改竖线时间轴:推理模型 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) --- core/backend/apps/ai/providers/volcano.py | 5 + core/backend/apps/ai/script_agent.py | 13 ++- core/backend/apps/ai/services.py | 9 +- core/backend/apps/ai/tests.py | 47 +++++++++ core/backend/apps/ai/views.py | 4 +- core/backend/apps/projects/serializers.py | 11 +- core/frontend/src/pipeline-page.css | 37 +++++-- core/frontend/src/routes/pipeline.tsx | 122 ++++++++++++++++------ core/frontend/src/types.ts | 3 + 9 files changed, 201 insertions(+), 50 deletions(-) diff --git a/core/backend/apps/ai/providers/volcano.py b/core/backend/apps/ai/providers/volcano.py index c6bc811..a06a70b 100644 --- a/core/backend/apps/ai/providers/volcano.py +++ b/core/backend/apps/ai/providers/volcano.py @@ -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} diff --git a/core/backend/apps/ai/script_agent.py b/core/backend/apps/ai/script_agent.py index baca9dc..171a8a7 100644 --- a/core/backend/apps/ai/script_agent.py +++ b/core/backend/apps/ai/script_agent.py @@ -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) diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index a3ffe27..8a71a70 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -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 diff --git a/core/backend/apps/ai/tests.py b/core/backend/apps/ai/tests.py index 0873906..82ff267 100644 --- a/core/backend/apps/ai/tests.py +++ b/core/backend/apps/ai/tests.py @@ -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"), "正在生成脚本…") diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index b6a4dc1..e256ca7 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -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"] diff --git a/core/backend/apps/projects/serializers.py b/core/backend/apps/projects/serializers.py index 562acfd..de1d7ee 100644 --- a/core/backend/apps/projects/serializers.py +++ b/core/backend/apps/projects/serializers.py @@ -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()} diff --git a/core/frontend/src/pipeline-page.css b/core/frontend/src/pipeline-page.css index aed6159..6a0312f 100644 --- a/core/frontend/src/pipeline-page.css +++ b/core/frontend/src/pipeline-page.css @@ -530,17 +530,32 @@ .chat-tagedit-row .tagedit-chip.add { border-color: var(--heat); color: var(--heat); } .chat-tagedit-row .tagedit-chip.remove { color: var(--black-alpha-48); text-decoration: line-through; } - /* ── 行33 · 进度提示流 ── */ - .progress-stream { display: flex; flex-direction: column; gap: 6px; } - .progress-stream .ps-row { display: flex; align-items: flex-start; gap: 8px; font-size: 13px; line-height: 1.5; animation: psRowIn .28s ease; } - .progress-stream .ps-row .ps-dot { width: 6px; height: 6px; margin-top: 6px; border-radius: 50%; background: var(--heat); flex: 0 0 6px; } - .progress-stream .ps-row.past { color: var(--black-alpha-48); } - .progress-stream .ps-row.past .ps-dot { background: var(--black-alpha-24); } - .progress-stream .ps-row.active { color: var(--accent-black); } - .progress-stream .ps-row.active .ps-dot { animation: prog-pulse 1.2s ease-in-out infinite; } - .progress-stream.done { display: inline-flex; flex-direction: row; align-items: center; gap: 7px; font-size: 13px; color: var(--accent-forest); } - .progress-stream.done .ps-check { width: 16px; height: 16px; display: grid; place-items: center; background: var(--forest-bg); color: var(--accent-forest); border-radius: 50%; flex: 0 0 16px; } - .progress-stream.done .ps-check svg { width: 10px; height: 10px; } + /* ── 行33 · 进度时间轴(竖线串步骤 · 当前步光扫+秒数 · 思考折叠展开)── */ + .progress-timeline { display: flex; flex-direction: column; } + .progress-timeline .pt-row { display: flex; gap: 10px; animation: ptRowIn .28s ease; } + .progress-timeline .pt-rail { position: relative; flex: 0 0 12px; display: flex; justify-content: center; } + .progress-timeline .pt-rail::after { content: ""; position: absolute; top: 11px; bottom: -5px; left: 50%; width: 1px; transform: translateX(-50%); background: var(--border-faint); } + .progress-timeline .pt-row:last-child .pt-rail::after { display: none; } + .progress-timeline .pt-dot { width: 7px; height: 7px; margin-top: 4px; border-radius: 50%; background: var(--heat); flex: 0 0 7px; z-index: 1; } + .progress-timeline .pt-row:not(.done):not(.active) .pt-dot { background: var(--ink-4); } + .progress-timeline .pt-row.active .pt-dot { animation: pt-pulse 1.4s ease-in-out infinite; } + .progress-timeline .pt-main { flex: 1; min-width: 0; padding-bottom: 9px; } + .progress-timeline .pt-line { display: flex; align-items: center; gap: 8px; font-size: 13px; line-height: 1.45; } + .progress-timeline .pt-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink-2); } + .progress-timeline .pt-row.done .pt-text { color: var(--ink-3); } + .progress-timeline .pt-text.shimmer { + background: linear-gradient(90deg, var(--ink-2) 0%, var(--ink-2) 42%, var(--ink-4) 50%, var(--ink-2) 58%, var(--ink-2) 100%); + background-size: 220% 100%; -webkit-background-clip: text; background-clip: text; + -webkit-text-fill-color: transparent; color: transparent; animation: pt-sweep 1.6s linear infinite; + } + .progress-timeline .pt-sec { font-family: var(--font-mono); font-size: 11px; color: var(--ink-4); letter-spacing: .02em; flex: 0 0 auto; } + .progress-timeline .pt-caret { flex: 0 0 auto; padding: 0 2px; background: none; border: 0; cursor: pointer; color: var(--ink-4); font-size: 15px; line-height: 1; transition: transform .18s ease; } + .progress-timeline .pt-caret:hover { color: var(--ink-2); } + .progress-timeline .pt-caret.open { transform: rotate(90deg); } + .progress-timeline .pt-think { margin-top: 6px; font-size: 12px; line-height: 1.6; color: var(--ink-3); white-space: pre-wrap; word-break: break-word; max-height: 168px; overflow-y: auto; border-left: 1px solid var(--border-faint); padding-left: 8px; } + @keyframes ptRowIn { from { opacity: 0; transform: translateY(-3px); } to { opacity: 1; transform: none; } } + @keyframes pt-pulse { 0%, 100% { box-shadow: 0 0 0 2px var(--heat-12); } 50% { box-shadow: 0 0 0 5px transparent; } } + @keyframes pt-sweep { from { background-position: 140% 0; } to { background-position: -40% 0; } } /* ── 行32 · 长文本折叠 ── */ .clamp-lines { display: -webkit-box; -webkit-line-clamp: var(--clamp-lines, 10); -webkit-box-orient: vertical; overflow: hidden; white-space: pre-wrap; word-break: break-word; } diff --git a/core/frontend/src/routes/pipeline.tsx b/core/frontend/src/routes/pipeline.tsx index 8025d30..6802cc5 100644 --- a/core/frontend/src/routes/pipeline.tsx +++ b/core/frontend/src/routes/pipeline.tsx @@ -364,27 +364,58 @@ function DraftShotCard({ draft, onCommit, onCancel }: { ); } -// 行33 · 进度提示流气泡:steps 逐条滚动;done 后折叠成一行「已完成」结果 -function ProgressStream({ steps, done }: { steps?: string[]; done?: boolean }) { +// 行33 · 进度时间轴节点:每步一个点,竖线串起;模型调用那步(id=generate)会进入「思考」态。 +type StepNode = { id: string; label: string; done?: boolean; think?: boolean }; + +// 行33 · 进度时间轴(参考主流 AI 应用的流式+推理体感): +// 竖线串起每步;当前步状态字「光扫」+ 秒数;generate 步思考态用模型实时思考最新一句做状态字, +// 右侧 › 折叠/展开思考全文(默认收起);完成后收成「已完成思考 · 用时 Xs」。非推理模型无思考节点,照常跑步骤。 +function ProgressTimeline({ steps, reasoning, stream, done, startedAt }: { steps?: StepNode[]; reasoning?: string; stream?: string; done?: boolean; startedAt?: number }) { + const [openThink, setOpenThink] = useState(false); + const [now, setNow] = useState(() => Date.now()); + const bodyRef = useRef(null); + useEffect(() => { + if (done) return; + const t = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(t); + }, [done]); + useEffect(() => { if (openThink && bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight; }, [reasoning, openThink]); + const list = steps ?? []; - if (done) { - return ( -
- - 已完成 {list.length} 步思考 · 镜头脚本已生成 -
- ); - } + const elapsed = startedAt ? Math.max(0, Math.round((now - startedAt) / 1000)) : 0; + const activeId = !done ? ([...list].reverse().find((s) => !s.done)?.id ?? null) : null; + // 思考流最新一句(按句末标点/换行切)= 动态状态字,不写死 + const latestThought = (() => { + const parts = (reasoning ?? "").split(/[\n。.!?!?;;]/).map((s) => s.trim()).filter(Boolean); + return parts.length ? parts[parts.length - 1] : ""; + })(); + const preamble = (stream ?? "").trim(); + return ( -
- {list.map((step, i) => { - const isLast = i === list.length - 1; +
+ {list.map((s) => { + const rowDone = !!s.done || !!done; + const active = s.id === activeId; + const hasThink = s.id === "generate" && (reasoning ?? "").length > 0; + let text = s.label; + if (s.id === "generate") { + if (rowDone) text = hasThink ? `已完成思考 · 用时 ${elapsed}s` : (preamble || s.label); + else if (s.think) text = latestThought || "思考"; + else text = preamble || s.label; + } return ( -
- - {step} +
+ +
+
+ {text} + {active && startedAt ? {elapsed}s : null} + {hasThink ? ( + + ) : null} +
+ {hasThink && openThink ?
{reasoning}
: null} +
); })} @@ -811,7 +842,7 @@ export function PipelinePage(props: { const chatBodyRef = useRef(null); // 对话记录(本地会话态):生成动作可追溯,不再是「点了按钮、对话区永远空着」 // kind=progress:进度提示流(行33),steps 逐条滚动出现,done 后折叠成一行结果 - type ChatMsg = { id: number; role: "ai" | "user"; text: string; time: string; kind?: "progress"; steps?: string[]; stream?: string; done?: boolean; auto?: boolean }; + type ChatMsg = { id: number; role: "ai" | "user"; text: string; time: string; kind?: "progress"; steps?: StepNode[]; stream?: string; reasoning?: string; done?: boolean; startedAt?: number; auto?: boolean }; const nowHm = () => new Date().toTimeString().slice(0, 5); const msgIdRef = useRef(1); const nextMsgId = () => msgIdRef.current++; @@ -898,7 +929,7 @@ export function PipelinePage(props: { async function runScriptGeneration(prompt: string, userLabel?: string, source?: string, mode?: "auto" | "theme" | "revise", targetIndex?: number) { pushMsg("user", userLabel || prompt); const progressId = nextMsgId(); - setChatMsgs((list) => [...list, { id: progressId, role: "ai", text: "", kind: "progress", steps: [], stream: "", done: false, time: nowHm() }]); + setChatMsgs((list) => [...list, { id: progressId, role: "ai", text: "", kind: "progress", steps: [], stream: "", reasoning: "", done: false, startedAt: Date.now(), time: nowHm() }]); // 指定镜号 = 精准改一镜,强制走 revise(后端读全脚本上下文、只动那一镜) const agentMode = targetIndex != null ? "revise" : (mode ?? mapSourceToMode(source ?? chatMode)); const baseVersionId = agentMode === "revise" ? currentScript?.id : undefined; @@ -919,14 +950,35 @@ export function PipelinePage(props: { (evt) => { receivedEvent = true; if (evt.type === "tool") { - // 工具卡:running 时把 label 滚进进度流(done/error 暂只用于结束态) - if (evt.status === "running" && typeof evt.label === "string") { - const label = evt.label; - setChatMsgs((list) => list.map((m) => (m.id === progressId ? { ...m, steps: [...(m.steps ?? []), label] } : m))); - } - } else if (evt.type === "delta" && typeof evt.text === "string") { + // 工具卡 → 时间轴节点:running 入列/置活动,done 标完成(extract/check 只发 done 也入列) + const sid = String((evt as { id?: unknown }).id || ""); + if (!sid) return; + const label = typeof evt.label === "string" ? evt.label : undefined; + const running = evt.status === "running"; + setChatMsgs((list) => list.map((m) => { + if (m.id !== progressId) return m; + const steps = [...(m.steps ?? [])]; + const idx = steps.findIndex((s) => s.id === sid); + if (idx === -1) steps.push({ id: sid, label: label ?? sid, done: !running }); + else steps[idx] = { ...steps[idx], ...(label ? { label } : {}), ...(running ? {} : { done: true }) }; + return { ...m, steps }; + })); + } else if (evt.type === "reasoning" && typeof evt.text === "string") { + // 推理模型思考流:逐字累积,并把 generate 步置「思考态」(状态字 = 思考最新一句) const piece = evt.text; - setChatMsgs((list) => list.map((m) => (m.id === progressId ? { ...m, stream: (m.stream ?? "") + piece } : m))); + setChatMsgs((list) => list.map((m) => { + if (m.id !== progressId) return m; + const steps = (m.steps ?? []).map((s) => (s.id === "generate" ? { ...s, think: true } : s)); + return { ...m, reasoning: (m.reasoning ?? "") + piece, steps }; + })); + } else if (evt.type === "delta" && typeof evt.text === "string") { + // 出正文 = 思考结束:generate 步退出思考态,状态字转成模型那句真前言 + const piece = evt.text; + setChatMsgs((list) => list.map((m) => { + if (m.id !== progressId) return m; + const steps = (m.steps ?? []).map((s) => (s.id === "generate" ? { ...s, think: false } : s)); + return { ...m, stream: (m.stream ?? "") + piece, steps }; + })); } else if (evt.type === "saved") { ok = true; } else if (evt.type === "error") { @@ -2194,8 +2246,7 @@ export function PipelinePage(props: {
{msg.kind === "progress" ? <> - - {msg.stream ?
{msg.stream}
: null} + : }
@@ -2222,6 +2273,8 @@ export function PipelinePage(props: {
根据商品目标人群推荐
+ {/* ← 返回:收起设定卡,回到「AI全生/一句话主题/自带脚本」三选项页 */} +