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
@@ -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<HTMLDivElement>(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 (
|
||||
<div className="progress-stream done">
|
||||
<span className="ps-check" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5" /></svg>
|
||||
</span>
|
||||
已完成 {list.length} 步思考 · 镜头脚本已生成
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<div className="progress-stream">
|
||||
{list.map((step, i) => {
|
||||
const isLast = i === list.length - 1;
|
||||
<div className={`progress-timeline${done ? " done" : ""}`}>
|
||||
{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 (
|
||||
<div className={`ps-row${isLast ? " active" : " past"}`} key={i}>
|
||||
<span className="ps-dot" aria-hidden="true"></span>
|
||||
<span className="ps-text">{step}</span>
|
||||
<div className={`pt-row${rowDone ? " done" : ""}${active ? " active" : ""}`} key={s.id}>
|
||||
<span className="pt-rail" aria-hidden="true"><span className="pt-dot"></span></span>
|
||||
<div className="pt-main">
|
||||
<div className="pt-line">
|
||||
<span className={`pt-text${active ? " shimmer" : ""}`}>{text}</span>
|
||||
{active && startedAt ? <span className="pt-sec">{elapsed}s</span> : null}
|
||||
{hasThink ? (
|
||||
<button type="button" className={`pt-caret${openThink ? " open" : ""}`} aria-label={openThink ? "收起思考" : "展开思考"} onClick={() => setOpenThink((v) => !v)}>›</button>
|
||||
) : null}
|
||||
</div>
|
||||
{hasThink && openThink ? <div className="pt-think" ref={bodyRef}>{reasoning}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -811,7 +842,7 @@ export function PipelinePage(props: {
|
||||
const chatBodyRef = useRef<HTMLDivElement | null>(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: {
|
||||
<div className="bubble">
|
||||
{msg.kind === "progress"
|
||||
? <>
|
||||
<ProgressStream steps={msg.steps} done={msg.done} />
|
||||
{msg.stream ? <div style={{ marginTop: 6, opacity: 0.85, whiteSpace: "pre-wrap" }}>{msg.stream}</div> : null}
|
||||
<ProgressTimeline steps={msg.steps} reasoning={msg.reasoning} stream={msg.stream} done={msg.done} startedAt={msg.startedAt} />
|
||||
</>
|
||||
: <CollapsibleText text={msg.text} maxLines={10} />}
|
||||
</div>
|
||||
@@ -2222,6 +2273,8 @@ export function PipelinePage(props: {
|
||||
</label>
|
||||
<div className="setup-rec">根据商品目标人群推荐</div>
|
||||
<div className="setup-foot">
|
||||
{/* ← 返回:收起设定卡,回到「AI全生/一句话主题/自带脚本」三选项页 */}
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setSetupOpen(false)}>← 返回</button>
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => {
|
||||
// 重新推荐:随机换一组,模拟「再推荐」(纯前端,后端会从 prompt 推断)
|
||||
setSetupStyle(SETUP_STYLE_KEYS[Math.floor(Math.random() * SETUP_STYLE_KEYS.length)] || setupStyle);
|
||||
@@ -2529,8 +2582,13 @@ export function PipelinePage(props: {
|
||||
const mainUrl = groupMainUrl(grp);
|
||||
const promptValue = assetPromptDraft[entity.key] ?? grp.prompt ?? "";
|
||||
const entBK = `ent:${kind}:${entity.key}`;
|
||||
const busy = isBusy(entBK) || isBusy(`${entBK}:tri`) || pendingHas(kind, entity.name);
|
||||
const rs = kind === "person" && grp.adopted_asset ? assetReview(grp.adopted_asset) : "";
|
||||
// 已生成卡(有 mainUrl)只在本次主动重跑(本地 isBusy)时转圈;
|
||||
// 不再被轮询到的在途任务(可能是残留/重复/三视图同名任务)误盖成「生成中」——
|
||||
// 否则刷新后已出图的卡会无故转圈。仅当还没出图(mainUrl 空)才用 pendingHas 兜底。
|
||||
const busy = isBusy(entBK) || isBusy(`${entBK}:tri`) || (!mainUrl && pendingHas(kind, entity.name));
|
||||
// 审核态:优先本地轮询的实时态(processing→终态),回退项目详情持久下发的 adopted_asset_review
|
||||
// (刷新后轮询 map 为空也能回显;不再依赖 byId,因为页面没喂全量 assets)
|
||||
const rs = kind === "person" && grp.adopted_asset ? (reviews[grp.adopted_asset] || grp.adopted_asset_review || "") : "";
|
||||
return (
|
||||
<div className="asset-card-2" data-asset-kind={kind} data-asset-id={entity.key} key={entity.key}>
|
||||
<div className={`placeholder thumb-2${mainUrl && !busy ? " has-mock-media" : ""}`} style={mainUrl && !busy ? { ...mediaStyle(mainUrl), cursor: "pointer" } : { cursor: "pointer" }}
|
||||
@@ -2545,7 +2603,7 @@ export function PipelinePage(props: {
|
||||
<strong style={{ fontSize: "13.5px", cursor: "pointer" }} onClick={() => openAssetDetail(kind, entity)}>{entity.name}</strong>
|
||||
<span className="spacer"></span>
|
||||
{rs === "active" && <span className="pill ok" title="火山真人审核已通过"><span className="dot"></span>审核✓</span>}
|
||||
{rs === "failed" && <span className="pill err" title={`审核未过:${byId.get(grp.adopted_asset!)?.review_error || "建议改提示词重新生成"}`}><span className="dot"></span>审核✗</span>}
|
||||
{rs === "failed" && <span className="pill err" title={`审核未过:${grp.adopted_asset_review_error || byId.get(grp.adopted_asset!)?.review_error || "建议改提示词重新生成"}`}><span className="dot"></span>审核✗</span>}
|
||||
{rs === "processing" && <span className="pill neutral"><span className="dot"></span>审核中</span>}
|
||||
</div>
|
||||
<PromptBox key={entity.key} value={promptValue} onChange={(v) => setAssetPromptDraft((m) => ({ ...m, [entity.key]: v }))} />
|
||||
|
||||
Reference in New Issue
Block a user