feat(core): 脚本交互再打磨 — 点字展开思考 / 一句话主题对话流 / 删除秒删持久队列 / 生成可停止

- 思考过程:点整行(不只小箭头)即可展开/收起
- 一句话主题改对话式:确定=确认风格/类型 → 模型主动问主题 → 输入再生成;设定卡「返回」回三选项
- 删除分镜:本地秒删 + localStorage 持久重试队列(扛刷新、网络不好自动重试到删成、绝不回滚)
- 生成中发送键变停止键:AbortController 掐断流(后端检测断连自动释放预扣额度)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-18 23:32:15 +08:00
co-authored by Claude Opus 4.8
parent 4399da2090
commit 5cfee715a2
3 changed files with 111 additions and 40 deletions
+4 -2
View File
@@ -237,7 +237,8 @@ export const api = {
total_duration?: number;
target_index?: number;
},
onEvent: (evt: { type: string; [k: string]: unknown }) => void
onEvent: (evt: { type: string; [k: string]: unknown }) => void,
signal?: AbortSignal
): Promise<void> {
const token = getToken();
const headers = new Headers({ "Content-Type": "application/json", Accept: "text/event-stream" });
@@ -245,7 +246,8 @@ export const api = {
const response = await fetch(`${API_BASE}/api/projects/${projectId}/script-agent-stream/`, {
method: "POST",
headers,
body: JSON.stringify(payload)
body: JSON.stringify(payload),
signal // 停止按钮:abort 这个 signal 即掐断流(后端检测断连会自动释放预扣额度)
});
if (!response.ok || !response.body) {
const text = await response.text().catch(() => "");
+10 -2
View File
@@ -167,6 +167,12 @@
.script-gen-veil .spinner, .shot-gen-veil .spinner { width: 26px; height: 26px; border: 2.5px solid var(--heat-20); border-top-color: var(--heat); border-radius: 50%; animation: assetSpin .7s linear infinite; flex: 0 0 auto; }
.shot-gen-veil .spinner { width: 20px; height: 20px; }
.chat-send-btn .spinner.spinner-on-accent { width: 14px; height: 14px; border: 2px solid color-mix(in srgb, currentColor 40%, transparent); border-top-color: currentColor; border-radius: 50%; animation: assetSpin .7s linear infinite; }
/* ⑤ 停止键:中间方块 + 外圈转圈;悬停转红提示中断 */
.chat-stop-btn { cursor: pointer; }
.chat-stop-btn:hover { background: var(--accent-crimson); border-color: var(--accent-crimson); }
.chat-stop-ring { position: relative; width: 18px; height: 18px; display: grid; place-items: center; }
.chat-stop-ring::before { content: ""; position: absolute; inset: 0; border: 2px solid color-mix(in srgb, currentColor 38%, transparent); border-top-color: currentColor; border-radius: 50%; animation: assetSpin .7s linear infinite; }
.chat-stop-square { width: 7px; height: 7px; border-radius: 1.5px; background: currentColor; }
.shot-list > .pane-h { flex-wrap: wrap; row-gap: 8px; }
.shot-headline { display: inline-flex; align-items: center; gap: 8px; min-width: 0; }
@@ -569,8 +575,10 @@
-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-line-click { cursor: pointer; }
.progress-timeline .pt-line-click:hover .pt-text { color: var(--ink); }
.progress-timeline .pt-line-click:hover .pt-caret { color: var(--ink-2); }
.progress-timeline .pt-caret { flex: 0 0 auto; padding: 0 2px; color: var(--ink-4); font-size: 15px; line-height: 1; transition: transform .18s ease; }
.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; }
+97 -36
View File
@@ -1,7 +1,7 @@
import { Fragment, memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
import type { ChangeEvent, CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import { Play } from "lucide-react";
import { api } from "../api";
import { api, ApiError } from "../api";
import type { Asset, BillingSummary, ExportPoll, ModelConfig, Product, Project, Team, TimelineSavePayload, User } from "../types";
import type { Notice, Page } from "./route-config";
import { money, stageOrder, statusPill } from "./stage-config";
@@ -29,7 +29,6 @@ const VO_VOICES = [
// 脚本风格预设(顺序对齐设计稿设定卡下拉:真实测评 / 痛点种草 / 小红书种草 / 开箱测评 / 对比展示)
const WIZ_STYLE_LABEL: Record<string, string> = { real: "真实测评", pain: "痛点种草", xhs: "小红书种草", review: "开箱测评", compare: "对比展示" };
const WIZ_PERSONA_LABEL: Record<string, string> = { urban: "都市白领女性", bestie: "闺蜜种草", ceo: "总裁亲选", reviewer: "专业测评师", mom: "实用宝妈", genz: "学生党" };
const THEME_TIP = "好,用一句话描述主题(5-30 字),例如「熬夜党的早八续命面膜」,输入后点发送。";
// 视频片段状态 pill 文案(色调走 statusPill:ok/info/err/neutral)
function statusLabel(status: string): string {
if (["succeeded", "completed", "done", "ok"].includes(status)) return "完成";
@@ -407,12 +406,17 @@ function ProgressTimeline({ steps, reasoning, stream, done, startedAt }: { steps
<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">
<div
className={`pt-line${hasThink ? " pt-line-click" : ""}`}
role={hasThink ? "button" : undefined}
tabIndex={hasThink ? 0 : undefined}
aria-label={hasThink ? (openThink ? "收起思考" : "展开思考") : undefined}
onClick={hasThink ? () => setOpenThink((v) => !v) : undefined}
onKeyDown={hasThink ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenThink((v) => !v); } } : undefined}
>
<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}
{hasThink ? <span className={`pt-caret${openThink ? " open" : ""}`} aria-hidden="true"></span> : null}
</div>
{hasThink && openThink ? <div className="pt-think" ref={bodyRef}>{reasoning}</div> : null}
</div>
@@ -519,7 +523,11 @@ export function PipelinePage(props: {
[...scripts].sort((a, b) => (b.created_at || "").localeCompare(a.created_at || ""))[0] ||
null;
const scriptAdopted = Boolean(currentScript?.is_adopted);
const shots = [...(currentScript?.segments ?? [])].sort((a, b) => a.sort_order - b.sort_order);
// ④ 持久删除队列:点删 → 本地秒删进队列(localStorage,扛刷新)+ 后台重试到删成才出队,绝不回滚
const [pendingDeletes, setPendingDeletes] = useState<Set<string>>(() => {
try { return new Set<string>(JSON.parse(localStorage.getItem(`airshelf:pending-del:${project.id}`) || "[]")); } catch { return new Set(); }
});
const shots = [...(currentScript?.segments ?? [])].sort((a, b) => a.sort_order - b.sort_order).filter((s) => !pendingDeletes.has(s.id));
// 对白 speaker(entity id)→ 角色名;null = 旁白。entities 在 ScriptVersion.metadata
const scriptEntities = ((currentScript?.metadata as { entities?: Array<{ id: string; name: string }> } | undefined)?.entities) ?? [];
const entityName = (id: string | null | undefined) => (id ? (scriptEntities.find((e) => e.id === id)?.name || id) : "旁白");
@@ -893,6 +901,9 @@ export function PipelinePage(props: {
const [scriptBusy, setScriptBusy] = useState(false);
// 单镜「改写中」:只在重跑/改稿那一镜盖罩子。与 busyShot 区分——删除也占 busyShot,但删除不该显「改写」罩子
const [rewriteShot, setRewriteShot] = useState<string | null>(null);
// ⑤ 停止按钮:流式生成可中断(AbortController);streamBusy=true 时发送键变停止键
const genAbortRef = useRef<AbortController | null>(null);
const [streamBusy, setStreamBusy] = useState(false);
// 行35 · 单条分镜重跑:调后端 rerun-script-segment 只重写该镜(instruction 带本镜要点微调),给即时反馈
async function rerunShot(shotId: string, _index: number, hint: string) {
if (busyShot) return;
@@ -905,17 +916,48 @@ export function PipelinePage(props: {
setRewriteShot(null);
}
}
// 单条分镜删除:立即反馈(置 busy)再落库
async function deleteShot(shotId: string) {
if (busyShot) return;
setBusyShot(shotId);
setArmedDelete(null);
// ④ 持久删除队列:本地秒删 + 后台重试,绝不回滚;网络不好就一直扛着重试,直到后端确实删掉
const delRetryingRef = useRef<Set<string>>(new Set());
function persistPending(next: Set<string>) {
try { localStorage.setItem(`airshelf:pending-del:${project.id}`, JSON.stringify([...next])); } catch { /* localStorage 不可用就算了 */ }
}
function dropPending(id: string) {
delRetryingRef.current.delete(id);
setPendingDeletes((prev) => { if (!prev.has(id)) return prev; const next = new Set(prev); next.delete(id); persistPending(next); return next; });
}
async function runDeleteRetry(shotId: string, attempt: number) {
try {
await onDeleteShot(shotId);
} finally {
setBusyShot(null);
await api.deleteScriptSegment(project.id, { segment_id: shotId });
dropPending(shotId);
void onRefreshProject(); // 后端已删 → 拉回最新(re-sort 后的镜号)
} catch (e) {
const status = e instanceof ApiError ? e.status : 0;
if (status === 404) { dropPending(shotId); void onRefreshProject(); return; } // 本来就没了 = 删成
if (status >= 400 && status < 500) { console.warn("删除被拒(非网络),停重试但按要求不回滚:", status, shotId); delRetryingRef.current.delete(shotId); return; }
// 网络/5xx → 退避重试(最长 30s),扛到删成
const delay = Math.min(30000, 1000 * 2 ** Math.min(attempt, 5));
window.setTimeout(() => { void runDeleteRetry(shotId, attempt + 1); }, delay);
}
}
function ensureDeleteRetry(shotId: string) {
if (delRetryingRef.current.has(shotId)) return;
delRetryingRef.current.add(shotId);
void runDeleteRetry(shotId, 0);
}
// 单条分镜删除:本地秒删(进持久队列,那一镜立刻消失)→ 后台重试。绝不回滚。
function deleteShot(shotId: string) {
setArmedDelete(null);
setPendingDeletes((prev) => { const next = new Set(prev).add(shotId); persistPending(next); return next; });
ensureDeleteRetry(shotId);
}
// 扛刷新/换项目:把 localStorage 里还没删成的继续重试,期间那些镜保持隐藏
useEffect(() => {
let ids: string[] = [];
try { ids = JSON.parse(localStorage.getItem(`airshelf:pending-del:${project.id}`) || "[]"); } catch { ids = []; }
setPendingDeletes(new Set(ids));
ids.forEach((id) => ensureDeleteRetry(id));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [project.id]);
useEffect(() => {
if (!armedDelete) return;
const timer = window.setTimeout(() => setArmedDelete(null), 3000);
@@ -942,6 +984,9 @@ export function PipelinePage(props: {
// 生成时给左侧上罩子:指定镜号 → 只盖那一镜(busyShot);否则 → 盖整个脚本区(scriptBusy)
const targetShotId = targetIndex != null ? (shots[targetIndex]?.id ?? null) : null;
if (targetShotId) { setBusyShot(targetShotId); setRewriteShot(targetShotId); } else setScriptBusy(true);
const ac = new AbortController();
genAbortRef.current = ac;
setStreamBusy(true);
let ok = false;
let receivedEvent = false;
let summaryText = "";
@@ -998,11 +1043,15 @@ export function PipelinePage(props: {
setChatMsgs((list) => list.map((m) => (m.id === progressId ? { ...m, done: true } : m)));
pushMsg("ai", `生成失败:${typeof evt.detail === "string" ? evt.detail : "请稍后重试"}`);
}
}
},
ac.signal
);
} catch {
if (ok) {
// 已收到 saved:流其实成功了(只是收尾抖动),绝不回退重发,避免重复生成+重复扣费
} else if (ac.signal.aborted) {
// 用户点了停止键:正常中断,不当报错(后端检测断连已释放预扣额度)
pushMsg("ai", "已停止生成。");
} else if (!receivedEvent) {
// 流式没跑起来:明确报错,不再静默退回旧散文路(旧路不产 script_entities,会让下游故事板/视频参考图断线)
pushMsg("ai", "脚本生成没能启动(流式连接失败),请重试。");
@@ -1011,6 +1060,8 @@ export function PipelinePage(props: {
pushMsg("ai", "生成中断了,请重试。");
}
} finally {
genAbortRef.current = null;
setStreamBusy(false);
if (targetShotId) { setBusyShot(null); setRewriteShot(null); } else setScriptBusy(false);
}
setChatMsgs((list) => list.map((m) => (m.id === progressId ? { ...m, done: true } : m)));
@@ -1024,18 +1075,16 @@ export function PipelinePage(props: {
async function runScriptWithSetup() {
const styleLabel = WIZ_STYLE_LABEL[setupStyle] || setupStyle;
const personaLabel = WIZ_PERSONA_LABEL[setupPersona] || setupPersona;
// 一句话主题没填:不关卡、不存档,提示补主题(否则空主题也走下去 → 关卡空窗期闪回三个菜单)
if (setupSource === "theme" && !chatText.trim()) { focusThemeMode(); return; }
// 行28 · 持久化所选风格/人物到 metadata.wizard(合并现有 wizard,不冲掉 duration/selling_point_ids)。
// 关键修复:先 await 落库(此时设定卡仍开着、确定按钮 disabled),存完再「关卡 + 发消息」同一帧切换,
// 不再出现「卡已关但消息未发」的空窗 → 不闪回三个菜单。
// 行28 · 持久化所选风格/人物到 metadata.wizard。先 await 落库(设定卡仍开着、确定 disabled),
// 存完再「关卡」一帧切换,不出现空窗 → 不闪回三个菜单。
await onSaveProjectMeta?.({ wizard: { ...(project.metadata?.wizard ?? {}), script_style: setupStyle, persona: setupPersona } });
setSetupOpen(false);
const sourceLabel = SOURCE_LABEL[setupSource] || "AI 全生";
if (setupSource === "theme") {
const theme = chatText.trim();
setChatText("");
await runScriptGeneration(`一句话主题:${theme}。风格:${styleLabel},目标人群:${personaLabel}。生成镜头脚本,突出商品卖点,适合短视频投放`, `一句话主题:${theme} · ${styleLabel} · ${personaLabel}`, "theme");
// 确定 = 确认风格/人物;主题交给模型来问 → 用户输入后由 submitChat 的 theme 分支带风格/人物生成
setChatMode("theme");
pushMsg("ai", `好,${styleLabel} · ${personaLabel} 定了。用一句话说说这条视频的主题(5-30字),例如「熬夜党的早八续命面膜」,发我就生成。`);
chatTextareaRef.current?.focus();
return;
}
if (setupSource === "manual") {
@@ -1059,6 +1108,15 @@ export function PipelinePage(props: {
const text = chatText.trim();
const tagInstr = tagEditsInstruction();
if (!text && !tagInstr) return;
// 一句话主题:确定后模型已问主题,这次输入就是主题 → 带上已确认的风格/人物生成(仅首次出稿前)
if (chatMode === "theme" && !currentScript && text) {
const styleLabel = WIZ_STYLE_LABEL[setupStyle] || setupStyle;
const personaLabel = WIZ_PERSONA_LABEL[setupPersona] || setupPersona;
setChatText("");
setChatAttachments([]);
void runScriptGeneration(`一句话主题:${text}。风格:${styleLabel},目标人群:${personaLabel}。生成镜头脚本,突出商品卖点,适合短视频投放`, `一句话主题:${text}`, "theme");
return;
}
const prompt = [tagInstr, text].filter(Boolean).join("\n");
const label = [pendingTagEdits.map(tagEditVerb).join(" · "), text].filter(Boolean).join(" · ");
setChatText("");
@@ -1091,8 +1149,9 @@ export function PipelinePage(props: {
setSetupSource(source);
setChatMode(source);
setSetupOpen(true);
if (source === "theme") { focusThemeMode(); }
else if (source === "manual") { pickScriptMode(); }
// 一句话主题:开卡时不再 push 提示(否则 chatMsgs 非空,点返回就回不到三选项);
// 主题改成在「确定」(确认风格/人物)之后由模型来问,更像对话流。
if (source === "manual") { pickScriptMode(); }
}
function pickScriptMode() {
setChatMode("manual");
@@ -1114,11 +1173,6 @@ export function PipelinePage(props: {
};
reader.readAsText(file);
}
function focusThemeMode() {
setChatMode("theme");
if (chatMsgs[chatMsgs.length - 1]?.text !== THEME_TIP) pushMsg("ai", THEME_TIP);
chatTextareaRef.current?.focus();
}
// Stage 1 · 脚本助手宽度拖拽(对齐设计稿:clamp [380, 680],写 --chat-w)
const scriptGridRef = useRef<HTMLDivElement | null>(null);
const [gutterDragging, setGutterDragging] = useState(false);
@@ -2385,11 +2439,18 @@ export function PipelinePage(props: {
</div>
) : null}
<span className="spacer"></span>
<button className="chat-send-btn" id="chat-send-btn" type="button" title="发送" aria-label="发送" disabled={loading || scriptBusy || !!rewriteShot || (!setupOpen && !chatText.trim() && pendingTagEdits.length === 0)} onClick={submitChat}>
{scriptBusy || rewriteShot
? <span className="spinner spinner-on-accent" aria-hidden="true"></span>
: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M13 6l6 6-6 6" /></svg>}
</button>
{streamBusy ? (
// ⑤ 生成中 → 停止键(中间方块 + 外圈转圈),点它掐断本次生成
<button className="chat-send-btn chat-stop-btn" id="chat-send-btn" type="button" title="停止生成" aria-label="停止生成" onClick={() => genAbortRef.current?.abort()}>
<span className="chat-stop-ring" aria-hidden="true"><span className="chat-stop-square"></span></span>
</button>
) : (
<button className="chat-send-btn" id="chat-send-btn" type="button" title="发送" aria-label="发送" disabled={loading || scriptBusy || !!rewriteShot || (!setupOpen && !chatText.trim() && pendingTagEdits.length === 0)} onClick={submitChat}>
{scriptBusy || rewriteShot
? <span className="spinner spinner-on-accent" aria-hidden="true"></span>
: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M13 6l6 6-6 6" /></svg>}
</button>
)}
</div>
</div>
</div>