diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 42bbacc..f4c4bb5 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -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 { 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(() => ""); diff --git a/core/frontend/src/pipeline-page.css b/core/frontend/src/pipeline-page.css index 8ad72dd..5c3427c 100644 --- a/core/frontend/src/pipeline-page.css +++ b/core/frontend/src/pipeline-page.css @@ -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; } diff --git a/core/frontend/src/routes/pipeline.tsx b/core/frontend/src/routes/pipeline.tsx index 16e03c2..aa1031c 100644 --- a/core/frontend/src/routes/pipeline.tsx +++ b/core/frontend/src/routes/pipeline.tsx @@ -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 = { real: "真实测评", pain: "痛点种草", xhs: "小红书种草", review: "开箱测评", compare: "对比展示" }; const WIZ_PERSONA_LABEL: Record = { 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
-
+
setOpenThink((v) => !v) : undefined} + onKeyDown={hasThink ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpenThink((v) => !v); } } : undefined} + > {text} {active && startedAt ? {elapsed}s : null} - {hasThink ? ( - - ) : null} + {hasThink ? : null}
{hasThink && openThink ?
{reasoning}
: null}
@@ -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>(() => { + try { return new Set(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(null); + // ⑤ 停止按钮:流式生成可中断(AbortController);streamBusy=true 时发送键变停止键 + const genAbortRef = useRef(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>(new Set()); + function persistPending(next: Set) { + 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(null); const [gutterDragging, setGutterDragging] = useState(false); @@ -2385,11 +2439,18 @@ export function PipelinePage(props: {
) : null} - + {streamBusy ? ( + // ⑤ 生成中 → 停止键(中间方块 + 外圈转圈),点它掐断本次生成 + + ) : ( + + )}