feat(core): 脚本交互再打磨 — 点字展开思考 / 一句话主题对话流 / 删除秒删持久队列 / 生成可停止
- 思考过程:点整行(不只小箭头)即可展开/收起 - 一句话主题改对话式:确定=确认风格/类型 → 模型主动问主题 → 输入再生成;设定卡「返回」回三选项 - 删除分镜:本地秒删 + localStorage 持久重试队列(扛刷新、网络不好自动重试到删成、绝不回滚) - 生成中发送键变停止键:AbortController 掐断流(后端检测断连自动释放预扣额度) 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
4399da2090
commit
5cfee715a2
@@ -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(() => "");
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user