feat(core): 脚本 agent 体感打磨 — skill松绑/对白去重/模型自写收尾/品牌遮罩/删除提速
- skill:旁白/对白每镜交给模型按内容判断(去掉「默认旁白·对白按门槛」);visual 加厚撑满15秒 - 脚本 agent:输出协议加收尾交付语 + _closing_summary → summary 事件(替写死的「已生成」) - 前端:有对白的镜不再重复显旁白;summary 当 AI 气泡;进度时间轴 @keyframes 嵌套失效→移顶层(动画真跑起来) - 生成遮罩改品牌生成卡(icon-box转圈+标题+动态点+橙扫光条);AI全生确定后不再闪三菜单 - 删除分镜:不再误显改写罩子(拆 rewriteShot);放开「至少保留一个分镜」可删空;删除提速(后端 bulk_update + 前端 liteRefresh) - 去掉脚本助手标题旁冗余的模型名 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
9fce7e8fff
commit
a71be19500
@@ -12,6 +12,7 @@ SSE 事件(每帧 `data: {json}\n\n`,json 带 type):
|
|||||||
delta {text} —— 模型自然语言前言(JSON 部分不外露)
|
delta {text} —— 模型自然语言前言(JSON 部分不外露)
|
||||||
draft {draft} —— 规范化后的 ScriptDraft(前端结构化渲染)
|
draft {draft} —— 规范化后的 ScriptDraft(前端结构化渲染)
|
||||||
saved {script_version_id, version} —— 已落库的 ScriptVersion(含 segments/metadata)
|
saved {script_version_id, version} —— 已落库的 ScriptVersion(含 segments/metadata)
|
||||||
|
summary {text} —— 模型自己写的收尾交付语(当 AI 回复气泡,替代写死的「已生成」)
|
||||||
done {} —— 结束
|
done {} —— 结束
|
||||||
error {detail} —— 失败(已回滚额度)
|
error {detail} —— 失败(已回滚额度)
|
||||||
"""
|
"""
|
||||||
@@ -72,7 +73,7 @@ _OUTPUT_PROTOCOL = """
|
|||||||
严格按以下顺序输出,不要有别的内容:
|
严格按以下顺序输出,不要有别的内容:
|
||||||
1. 先用 **1 句中文口语**告诉用户你正在做什么(≤40 字,例:「在为这款保温杯生成 4 镜痛点脚本…」),让用户看到进展;
|
1. 先用 **1 句中文口语**告诉用户你正在做什么(≤40 字,例:「在为这款保温杯生成 4 镜痛点脚本…」),让用户看到进展;
|
||||||
2. 紧接着输出**且仅输出一个** ```json 代码块,内容为符合技能契约(铁律1)的 ScriptDraft 对象;
|
2. 紧接着输出**且仅输出一个** ```json 代码块,内容为符合技能契约(铁律1)的 ScriptDraft 对象;
|
||||||
3. json 代码块之后**不要再写任何文字**。
|
3. json 代码块**收尾之后另起一行**,用 **1–2 句中文口语**跟用户交付这一版:做了什么、为什么这么改、可以怎么接着调(像同事汇报,**别复述 JSON 字段、别再写代码块**)。这句会作为你的回复气泡展示给用户。
|
||||||
|
|
||||||
### 字段名锚定(硬性 · 下游靠它取数,跑偏即数据全空)
|
### 字段名锚定(硬性 · 下游靠它取数,跑偏即数据全空)
|
||||||
- 分镜数组的键名**必须**叫 `segments`(禁止用 scenes / script / shots / 分镜 等同义词)。
|
- 分镜数组的键名**必须**叫 `segments`(禁止用 scenes / script / shots / 分镜 等同义词)。
|
||||||
@@ -536,6 +537,21 @@ def _visible_cut(text: str) -> int:
|
|||||||
return min(cands) if cands else len(text)
|
return min(cands) if cands else len(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _closing_summary(raw: str) -> str:
|
||||||
|
"""模型在 json 之后写的收尾交付语 = 给用户的回复气泡(像同事汇报「这版做了啥/怎么接着调」)。
|
||||||
|
取最后一个 json 对象之后的文字,去掉收尾围栏与空白;没写或残留花括号就返回空(前端兜底)。"""
|
||||||
|
js = _extract_json(raw)
|
||||||
|
tail = ""
|
||||||
|
if js:
|
||||||
|
idx = raw.rfind(js)
|
||||||
|
if idx != -1:
|
||||||
|
tail = raw[idx + len(js):]
|
||||||
|
tail = re.sub(r"`+", " ", tail).strip() # 去掉 json 收尾的 ``` 围栏
|
||||||
|
if "{" in tail or len(tail) < 4:
|
||||||
|
return ""
|
||||||
|
return tail[:160]
|
||||||
|
|
||||||
|
|
||||||
def stream_script_agent(
|
def stream_script_agent(
|
||||||
*,
|
*,
|
||||||
project,
|
project,
|
||||||
@@ -697,6 +713,10 @@ def stream_script_agent(
|
|||||||
"version": ScriptVersionSerializer(script).data,
|
"version": ScriptVersionSerializer(script).data,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
# 模型自己写的收尾交付语 → AI 回复气泡(没写则前端兜底默认句)
|
||||||
|
summary = _closing_summary(raw)
|
||||||
|
if summary:
|
||||||
|
yield _sse({"type": "summary", "text": summary})
|
||||||
yield _sse({"type": "done"})
|
yield _sse({"type": "done"})
|
||||||
finally:
|
finally:
|
||||||
# 断连(GeneratorExit)或任何 settled=False 的退出路径:释放预扣,避免额度冻结
|
# 断连(GeneratorExit)或任何 settled=False 的退出路径:释放预扣,避免额度冻结
|
||||||
|
|||||||
@@ -444,8 +444,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
id=request.data.get("segment_id"), script_version__project=project
|
id=request.data.get("segment_id"), script_version__project=project
|
||||||
)
|
)
|
||||||
script = segment.script_version
|
script = segment.script_version
|
||||||
if script.segments.count() <= 1:
|
# 允许删空:用户可以把镜头全删了(再整体重新生成或手动加),不再强制「至少保留一个分镜」。
|
||||||
return Response({"detail": "至少保留一个分镜"}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
# 删本场脚本时,它对应的故事板分镜帧也一起删 —— frame.script_segment 是 SET_NULL,
|
# 删本场脚本时,它对应的故事板分镜帧也一起删 —— frame.script_segment 是 SET_NULL,
|
||||||
# 不主动删会留下孤儿帧,故事板里仍显示这一(已删的)场。各版本受影响的帧统一收口后重排 sort_order。
|
# 不主动删会留下孤儿帧,故事板里仍显示这一(已删的)场。各版本受影响的帧统一收口后重排 sort_order。
|
||||||
affected_storyboards = list(StoryboardVersion.objects.filter(frames__script_segment=segment).distinct())
|
affected_storyboards = list(StoryboardVersion.objects.filter(frames__script_segment=segment).distinct())
|
||||||
@@ -456,10 +455,14 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
|||||||
frame.sort_order = index
|
frame.sort_order = index
|
||||||
frame.save(update_fields=["sort_order", "updated_at"])
|
frame.save(update_fields=["sort_order", "updated_at"])
|
||||||
segment.delete()
|
segment.delete()
|
||||||
|
# 删后重排 sort_order:收集变动的镜一次性 bulk_update,替代逐条 save(远程库少跑 N 个往返 → 删除快很多)
|
||||||
|
resort = []
|
||||||
for index, seg in enumerate(script.segments.order_by("sort_order")):
|
for index, seg in enumerate(script.segments.order_by("sort_order")):
|
||||||
if seg.sort_order != index:
|
if seg.sort_order != index:
|
||||||
seg.sort_order = index
|
seg.sort_order = index
|
||||||
seg.save(update_fields=["sort_order", "updated_at"])
|
resort.append(seg)
|
||||||
|
if resort:
|
||||||
|
ScriptSegment.objects.bulk_update(resort, ["sort_order"])
|
||||||
self._sync_video_segments_to_script(project, script)
|
self._sync_video_segments_to_script(project, script)
|
||||||
return Response(ScriptVersionSerializer(script).data)
|
return Response(ScriptVersionSerializer(script).data)
|
||||||
|
|
||||||
|
|||||||
@@ -369,7 +369,7 @@ export function App() {
|
|||||||
// 用 ref 而非 loading state,避免闭包拿到旧值;同步置位,任何同一 tick 的二次点击都拦得住。
|
// 用 ref 而非 loading state,避免闭包拿到旧值;同步置位,任何同一 tick 的二次点击都拦得住。
|
||||||
const actionInFlightRef = useRef(false);
|
const actionInFlightRef = useRef(false);
|
||||||
|
|
||||||
async function action<T>(work: () => Promise<T>, successText: string): Promise<T | null> {
|
async function action<T>(work: () => Promise<T>, successText: string, opts?: { liteRefresh?: boolean }): Promise<T | null> {
|
||||||
if (actionInFlightRef.current) {
|
if (actionInFlightRef.current) {
|
||||||
setNotice({ type: "error", text: "操作进行中,请稍候…" });
|
setNotice({ type: "error", text: "操作进行中,请稍候…" });
|
||||||
return null;
|
return null;
|
||||||
@@ -383,7 +383,8 @@ export function App() {
|
|||||||
if (successText) setNotice({ type: "success", text: successText });
|
if (successText) setNotice({ type: "success", text: successText });
|
||||||
// 后台刷新,不阻塞操作返回:全量 loadData 会分页拉全部 assets 很重,await 它会让
|
// 后台刷新,不阻塞操作返回:全量 loadData 会分页拉全部 assets 很重,await 它会让
|
||||||
// 「项目已创建/确认脚本」后白等很久(行29/37)。改为后台 hydrate,操作立即返回。
|
// 「项目已创建/确认脚本」后白等很久(行29/37)。改为后台 hydrate,操作立即返回。
|
||||||
void loadData();
|
// liteRefresh(改/删/加分镜等只动脚本、不动商品/资产的轻操作):跳过全量 loadData,只刷项目详情 → 快很多。
|
||||||
|
if (!opts?.liteRefresh) void loadData();
|
||||||
void refreshProjectDetail();
|
void refreshProjectDetail();
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -803,10 +804,10 @@ export function App() {
|
|||||||
unreadCount={unreadCount}
|
unreadCount={unreadCount}
|
||||||
avatarChar={avatarChar}
|
avatarChar={avatarChar}
|
||||||
logout={logout} onAdoptScript={(scriptId) => action(() => api.adoptScript(pipelineProject.id, scriptId), "脚本已采用")}
|
logout={logout} onAdoptScript={(scriptId) => action(() => api.adoptScript(pipelineProject.id, scriptId), "脚本已采用")}
|
||||||
onUpdateShot={(payload) => action(() => api.updateScriptSegment(pipelineProject.id, payload), "分镜已更新")}
|
onUpdateShot={(payload) => action(() => api.updateScriptSegment(pipelineProject.id, payload), "分镜已更新", { liteRefresh: true })}
|
||||||
onAddShot={(afterSegmentId, content) => action(() => api.addScriptSegment(pipelineProject.id, { after_segment_id: afterSegmentId, ...content }), "分镜已添加")}
|
onAddShot={(afterSegmentId, content) => action(() => api.addScriptSegment(pipelineProject.id, { after_segment_id: afterSegmentId, ...content }), "分镜已添加", { liteRefresh: true })}
|
||||||
onDeleteShot={(segmentId) => action(() => api.deleteScriptSegment(pipelineProject.id, { segment_id: segmentId }), "分镜已删除")}
|
onDeleteShot={(segmentId) => action(() => api.deleteScriptSegment(pipelineProject.id, { segment_id: segmentId }), "分镜已删除", { liteRefresh: true })}
|
||||||
onRerunShot={(segmentId, instruction) => action(() => api.rerunScriptSegment(pipelineProject.id, { segment_id: segmentId, instruction }), "分镜已重跑")}
|
onRerunShot={(segmentId, instruction) => action(() => api.rerunScriptSegment(pipelineProject.id, { segment_id: segmentId, instruction }), "分镜已重跑", { liteRefresh: true })}
|
||||||
onSaveProjectMeta={(meta) =>
|
onSaveProjectMeta={(meta) =>
|
||||||
// metadata 是整体替换:合并现有 project.metadata 后再 PATCH,别把别的 key(wizard 等)冲掉
|
// metadata 是整体替换:合并现有 project.metadata 后再 PATCH,别把别的 key(wizard 等)冲掉
|
||||||
action(() => api.updateProject(pipelineProject.id, { metadata: { ...(pipelineProject.metadata ?? {}), ...meta } }), "已保存")
|
action(() => api.updateProject(pipelineProject.id, { metadata: { ...(pipelineProject.metadata ?? {}), ...meta } }), "已保存")
|
||||||
|
|||||||
@@ -146,7 +146,27 @@
|
|||||||
.chat-attach-row { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
|
.chat-attach-row { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
|
||||||
|
|
||||||
.shot-list { display: flex; flex-direction: column; }
|
.shot-list { display: flex; flex-direction: column; }
|
||||||
.shots-body { padding: 12px 16px; flex: 1; overflow-y: auto; max-height: 540px; display: flex; flex-direction: column; gap: 0; }
|
.shots-body { padding: 12px 16px; flex: 1; overflow-y: auto; max-height: 540px; display: flex; flex-direction: column; gap: 0; position: relative; }
|
||||||
|
|
||||||
|
/* 行: 生成时左侧脚本区罩子(整体)/ 单镜罩子 + 转圈 */
|
||||||
|
.script-gen-veil { position: absolute; inset: 0; z-index: 6; display: flex; align-items: center; justify-content: center; background: color-mix(in srgb, var(--background-base) 86%, transparent); }
|
||||||
|
/* 方案 B · 品牌生成卡:icon-box 转圈 + 标题 + 动态点 + 橙色扫光进度条 */
|
||||||
|
.script-gen-veil .sgv-card { display: flex; align-items: center; gap: 12px; padding: 14px 18px; min-width: 252px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); box-shadow: var(--shadow-floating); }
|
||||||
|
.script-gen-veil .sgv-ico { width: 32px; height: 32px; flex: 0 0 32px; display: grid; place-items: center; background: var(--heat-12); border-radius: var(--r-sm); }
|
||||||
|
.script-gen-veil .sgv-ico .spinner { width: 18px; height: 18px; border: 2.5px solid var(--heat-20); border-top-color: var(--heat); border-radius: 50%; animation: assetSpin .7s linear infinite; }
|
||||||
|
.script-gen-veil .sgv-body { flex: 1; display: flex; flex-direction: column; gap: 9px; }
|
||||||
|
.script-gen-veil .sgv-title { font-size: 13px; font-weight: 500; color: var(--ink); display: inline-flex; align-items: center; gap: 4px; }
|
||||||
|
.script-gen-veil .sgv-dots { display: inline-flex; gap: 3px; align-items: center; }
|
||||||
|
.script-gen-veil .sgv-dots i { width: 3px; height: 3px; border-radius: 50%; background: var(--heat); animation: sgvBlink 1.2s infinite; }
|
||||||
|
.script-gen-veil .sgv-dots i:nth-child(2) { animation-delay: .2s; }
|
||||||
|
.script-gen-veil .sgv-dots i:nth-child(3) { animation-delay: .4s; }
|
||||||
|
.script-gen-veil .sgv-bar { position: relative; height: 4px; border-radius: 999px; background: var(--heat-12); overflow: hidden; }
|
||||||
|
.script-gen-veil .sgv-bar::after { content: ""; position: absolute; top: 0; bottom: 0; width: 42%; border-radius: 999px; background: var(--heat); animation: sgvBar 1.15s ease-in-out infinite; }
|
||||||
|
.shot-gen-veil { position: absolute; inset: 0; z-index: 4; display: flex; gap: 8px; align-items: center; justify-content: center; background: color-mix(in srgb, var(--background-base) 80%, transparent); border-radius: var(--r-sm); }
|
||||||
|
.shot-gen-veil .mono { font-size: 12px; color: var(--ink-3); letter-spacing: .04em; }
|
||||||
|
.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; }
|
||||||
|
|
||||||
.shot-list > .pane-h { flex-wrap: wrap; row-gap: 8px; }
|
.shot-list > .pane-h { flex-wrap: wrap; row-gap: 8px; }
|
||||||
.shot-headline { display: inline-flex; align-items: center; gap: 8px; min-width: 0; }
|
.shot-headline { display: inline-flex; align-items: center; gap: 8px; min-width: 0; }
|
||||||
@@ -167,7 +187,7 @@
|
|||||||
.shots-empty .empty-hint { font-size: 12px; color: var(--black-alpha-56); line-height: 1.55; max-width: 280px; font-family: var(--font-mono); letter-spacing: .02em; }
|
.shots-empty .empty-hint { font-size: 12px; color: var(--black-alpha-56); line-height: 1.55; max-width: 280px; font-family: var(--font-mono); letter-spacing: .02em; }
|
||||||
|
|
||||||
/* 镜头脚本卡(真实脚本镜头列表) */
|
/* 镜头脚本卡(真实脚本镜头列表) */
|
||||||
.shot-card { display: flex; gap: 12px; padding: 12px 4px; border-bottom: 1px solid var(--border-faint); }
|
.shot-card { display: flex; gap: 12px; padding: 12px 4px; border-bottom: 1px solid var(--border-faint); position: relative; }
|
||||||
.shot-card:last-child { border-bottom: 0; }
|
.shot-card:last-child { border-bottom: 0; }
|
||||||
.shot-card .shot-n { flex: 0 0 auto; width: 26px; height: 26px; border-radius: 6px; background: var(--heat-12); color: var(--heat); font-family: var(--font-mono); font-size: 12px; font-weight: 600; display: grid; place-items: center; }
|
.shot-card .shot-n { flex: 0 0 auto; width: 26px; height: 26px; border-radius: 6px; background: var(--heat-12); color: var(--heat); font-family: var(--font-mono); font-size: 12px; font-weight: 600; display: grid; place-items: center; }
|
||||||
.shot-card .shot-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
|
.shot-card .shot-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||||
@@ -553,9 +573,6 @@
|
|||||||
.progress-timeline .pt-caret:hover { color: var(--ink-2); }
|
.progress-timeline .pt-caret:hover { color: var(--ink-2); }
|
||||||
.progress-timeline .pt-caret.open { transform: rotate(90deg); }
|
.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; }
|
.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 · 长文本折叠 ── */
|
/* ── 行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; }
|
.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; }
|
||||||
@@ -690,6 +707,15 @@
|
|||||||
/* 顶层 @keyframes:嵌套进 .pipeline-page 块内不会注册,动画不执行(product-detail 同坑) */
|
/* 顶层 @keyframes:嵌套进 .pipeline-page 块内不会注册,动画不执行(product-detail 同坑) */
|
||||||
@keyframes edXfadeFlash { from { opacity: 0.72; } to { opacity: 0; } }
|
@keyframes edXfadeFlash { from { opacity: 0.72; } to { opacity: 0; } }
|
||||||
|
|
||||||
|
/* 行33 · 进度时间轴(顶层才注册:行入场 / 当前点脉冲 / 状态字光扫) */
|
||||||
|
@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; } }
|
||||||
|
|
||||||
|
/* 方案 B · 生成卡(顶层才注册:动态点闪烁 / 橙色扫光进度条) */
|
||||||
|
@keyframes sgvBlink { 0%, 100% { opacity: .25; } 50% { opacity: 1; } }
|
||||||
|
@keyframes sgvBar { 0% { left: -42%; } 100% { left: 100%; } }
|
||||||
|
|
||||||
/* ── 流程步骤4 · 演员库覆盖层(portal 到 body,顶层选择器才生效)── */
|
/* ── 流程步骤4 · 演员库覆盖层(portal 到 body,顶层选择器才生效)── */
|
||||||
/* 资产卡/演员库工作台的提示词输入框:顶层定义,页面内(资产卡)与 portal 弹窗(添加人物工作台)都生效 */
|
/* 资产卡/演员库工作台的提示词输入框:顶层定义,页面内(资产卡)与 portal 弹窗(添加人物工作台)都生效 */
|
||||||
.asset-prompt-edit { width: 100%; margin-top: 8px; font-family: var(--font-mono); font-size: 12px; line-height: 1.55; letter-spacing: .01em; color: var(--black-alpha-72); background: var(--background-base); border: 1px solid var(--border-faint); border-radius: var(--r-sm); padding: 8px 10px; outline: none; resize: vertical; min-height: 56px; transition: border-color var(--t-base), background var(--t-base), box-shadow var(--t-base); }
|
.asset-prompt-edit { width: 100%; margin-top: 8px; font-family: var(--font-mono); font-size: 12px; line-height: 1.55; letter-spacing: .01em; color: var(--black-alpha-72); background: var(--background-base); border: 1px solid var(--border-faint); border-radius: var(--r-sm); padding: 8px 10px; outline: none; resize: vertical; min-height: 56px; transition: border-color var(--t-base), background var(--t-base), box-shadow var(--t-base); }
|
||||||
|
|||||||
@@ -489,7 +489,7 @@ export function PipelinePage(props: {
|
|||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
project, loading, navigate, user, team, products, projects, assets, billing, notice, unreadCount, avatarChar, logout,
|
project, loading, navigate, user, team, products, projects, assets, billing, notice, unreadCount, avatarChar, logout,
|
||||||
scriptModelName, textModels, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover,
|
textModels, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover,
|
||||||
onGenerateBaseAsset, onAdoptBaseAsset, onAttachBaseAsset, onGenerateActor, onUploadActor, onGenerateTriview, onRenameActor, onGenerateStoryboard, onSkipStoryboard,
|
onGenerateBaseAsset, onAdoptBaseAsset, onAttachBaseAsset, onGenerateActor, onUploadActor, onGenerateTriview, onRenameActor, onGenerateStoryboard, onSkipStoryboard,
|
||||||
onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport, onRefreshProject,
|
onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport, onRefreshProject,
|
||||||
onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport
|
onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport
|
||||||
@@ -889,14 +889,20 @@ export function PipelinePage(props: {
|
|||||||
const [armedDelete, setArmedDelete] = useState<string | null>(null);
|
const [armedDelete, setArmedDelete] = useState<string | null>(null);
|
||||||
// 行35 · 单条分镜重跑 / 删除的即时反馈:正在处理的 shot id(按钮转「处理中」并禁用)
|
// 行35 · 单条分镜重跑 / 删除的即时反馈:正在处理的 shot id(按钮转「处理中」并禁用)
|
||||||
const [busyShot, setBusyShot] = useState<string | null>(null);
|
const [busyShot, setBusyShot] = useState<string | null>(null);
|
||||||
|
// 整体生成中:给整个镜头脚本区盖罩子转圈
|
||||||
|
const [scriptBusy, setScriptBusy] = useState(false);
|
||||||
|
// 单镜「改写中」:只在重跑/改稿那一镜盖罩子。与 busyShot 区分——删除也占 busyShot,但删除不该显「改写」罩子
|
||||||
|
const [rewriteShot, setRewriteShot] = useState<string | null>(null);
|
||||||
// 行35 · 单条分镜重跑:调后端 rerun-script-segment 只重写该镜(instruction 带本镜要点微调),给即时反馈
|
// 行35 · 单条分镜重跑:调后端 rerun-script-segment 只重写该镜(instruction 带本镜要点微调),给即时反馈
|
||||||
async function rerunShot(shotId: string, _index: number, hint: string) {
|
async function rerunShot(shotId: string, _index: number, hint: string) {
|
||||||
if (busyShot) return;
|
if (busyShot) return;
|
||||||
setBusyShot(shotId);
|
setBusyShot(shotId);
|
||||||
|
setRewriteShot(shotId); // 重跑 = 改写 → 那一镜盖「改写中」罩子
|
||||||
try {
|
try {
|
||||||
await onRerunShot?.(shotId, hint || undefined);
|
await onRerunShot?.(shotId, hint || undefined);
|
||||||
} finally {
|
} finally {
|
||||||
setBusyShot(null);
|
setBusyShot(null);
|
||||||
|
setRewriteShot(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 单条分镜删除:立即反馈(置 busy)再落库
|
// 单条分镜删除:立即反馈(置 busy)再落库
|
||||||
@@ -933,8 +939,12 @@ export function PipelinePage(props: {
|
|||||||
// 指定镜号 = 精准改一镜,强制走 revise(后端读全脚本上下文、只动那一镜)
|
// 指定镜号 = 精准改一镜,强制走 revise(后端读全脚本上下文、只动那一镜)
|
||||||
const agentMode = targetIndex != null ? "revise" : (mode ?? mapSourceToMode(source ?? chatMode));
|
const agentMode = targetIndex != null ? "revise" : (mode ?? mapSourceToMode(source ?? chatMode));
|
||||||
const baseVersionId = agentMode === "revise" ? currentScript?.id : undefined;
|
const baseVersionId = agentMode === "revise" ? currentScript?.id : undefined;
|
||||||
|
// 生成时给左侧上罩子:指定镜号 → 只盖那一镜(busyShot);否则 → 盖整个脚本区(scriptBusy)
|
||||||
|
const targetShotId = targetIndex != null ? (shots[targetIndex]?.id ?? null) : null;
|
||||||
|
if (targetShotId) { setBusyShot(targetShotId); setRewriteShot(targetShotId); } else setScriptBusy(true);
|
||||||
let ok = false;
|
let ok = false;
|
||||||
let receivedEvent = false;
|
let receivedEvent = false;
|
||||||
|
let summaryText = "";
|
||||||
try {
|
try {
|
||||||
await api.agentScriptStream(
|
await api.agentScriptStream(
|
||||||
project.id,
|
project.id,
|
||||||
@@ -979,6 +989,9 @@ export function PipelinePage(props: {
|
|||||||
const steps = (m.steps ?? []).map((s) => (s.id === "generate" ? { ...s, think: false } : s));
|
const steps = (m.steps ?? []).map((s) => (s.id === "generate" ? { ...s, think: false } : s));
|
||||||
return { ...m, stream: (m.stream ?? "") + piece, steps };
|
return { ...m, stream: (m.stream ?? "") + piece, steps };
|
||||||
}));
|
}));
|
||||||
|
} else if (evt.type === "summary" && typeof evt.text === "string") {
|
||||||
|
// 模型自己写的收尾交付语 → 当 AI 回复气泡(替掉写死的「已生成」)
|
||||||
|
summaryText = evt.text;
|
||||||
} else if (evt.type === "saved") {
|
} else if (evt.type === "saved") {
|
||||||
ok = true;
|
ok = true;
|
||||||
} else if (evt.type === "error") {
|
} else if (evt.type === "error") {
|
||||||
@@ -997,26 +1010,30 @@ export function PipelinePage(props: {
|
|||||||
// 流中途断了(后端已通过 finally 释放预扣额度):不重试,提示用户
|
// 流中途断了(后端已通过 finally 释放预扣额度):不重试,提示用户
|
||||||
pushMsg("ai", "生成中断了,请重试。");
|
pushMsg("ai", "生成中断了,请重试。");
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
if (targetShotId) { setBusyShot(null); setRewriteShot(null); } else setScriptBusy(false);
|
||||||
}
|
}
|
||||||
setChatMsgs((list) => list.map((m) => (m.id === progressId ? { ...m, done: true } : m)));
|
setChatMsgs((list) => list.map((m) => (m.id === progressId ? { ...m, done: true } : m)));
|
||||||
if (ok) {
|
if (ok) {
|
||||||
await onRefreshProject();
|
await onRefreshProject();
|
||||||
pushMsg("ai", "镜头脚本已生成,左侧已刷新。可继续输入修改意见(会基于当前脚本改稿),或点「确认脚本」进入下一步。");
|
// 模型写了收尾就用它(真 agent 感);没写则兜底默认句
|
||||||
|
pushMsg("ai", summaryText || "镜头脚本已生成,左侧已刷新。可继续输入修改意见,或点「确认脚本」进入下一步。");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 行28/行30 · 确认设定后真正发起生成:把所选「风格 / 人物」并进提示词(后端从 prompt 推断)
|
// 行28/行30 · 确认设定后真正发起生成:把所选「风格 / 人物」并进提示词(后端从 prompt 推断)
|
||||||
async function runScriptWithSetup() {
|
async function runScriptWithSetup() {
|
||||||
const styleLabel = WIZ_STYLE_LABEL[setupStyle] || setupStyle;
|
const styleLabel = WIZ_STYLE_LABEL[setupStyle] || setupStyle;
|
||||||
const personaLabel = WIZ_PERSONA_LABEL[setupPersona] || setupPersona;
|
const personaLabel = WIZ_PERSONA_LABEL[setupPersona] || setupPersona;
|
||||||
setSetupOpen(false);
|
// 一句话主题没填:不关卡、不存档,提示补主题(否则空主题也走下去 → 关卡空窗期闪回三个菜单)
|
||||||
// 行28 · 持久化所选风格/人物到 metadata.wizard(合并现有 wizard,不冲掉 duration/selling_point_ids);
|
if (setupSource === "theme" && !chatText.trim()) { focusThemeMode(); return; }
|
||||||
// 顶部「风格/人物」brief pill 读 metadata.wizard,刷新后即显已确认值。
|
// 行28 · 持久化所选风格/人物到 metadata.wizard(合并现有 wizard,不冲掉 duration/selling_point_ids)。
|
||||||
// 先 await 落库再发起生成:action 有 in-flight 互斥锁,并发会被「操作进行中」挡掉
|
// 关键修复:先 await 落库(此时设定卡仍开着、确定按钮 disabled),存完再「关卡 + 发消息」同一帧切换,
|
||||||
|
// 不再出现「卡已关但消息未发」的空窗 → 不闪回三个菜单。
|
||||||
await onSaveProjectMeta?.({ wizard: { ...(project.metadata?.wizard ?? {}), script_style: setupStyle, persona: setupPersona } });
|
await onSaveProjectMeta?.({ wizard: { ...(project.metadata?.wizard ?? {}), script_style: setupStyle, persona: setupPersona } });
|
||||||
|
setSetupOpen(false);
|
||||||
const sourceLabel = SOURCE_LABEL[setupSource] || "AI 全生";
|
const sourceLabel = SOURCE_LABEL[setupSource] || "AI 全生";
|
||||||
if (setupSource === "theme") {
|
if (setupSource === "theme") {
|
||||||
const theme = chatText.trim();
|
const theme = chatText.trim();
|
||||||
if (!theme) { focusThemeMode(); return; }
|
|
||||||
setChatText("");
|
setChatText("");
|
||||||
await runScriptGeneration(`一句话主题:${theme}。风格:${styleLabel},目标人群:${personaLabel}。生成镜头脚本,突出商品卖点,适合短视频投放`, `一句话主题:${theme} · ${styleLabel} · ${personaLabel}`, "theme");
|
await runScriptGeneration(`一句话主题:${theme}。风格:${styleLabel},目标人群:${personaLabel}。生成镜头脚本,突出商品卖点,适合短视频投放`, `一句话主题:${theme} · ${styleLabel} · ${personaLabel}`, "theme");
|
||||||
return;
|
return;
|
||||||
@@ -2130,6 +2147,17 @@ export function PipelinePage(props: {
|
|||||||
<button className="btn btn-ghost btn-sm" type="button" id="chat-regen-btn" disabled={loading} onClick={() => void runScriptGeneration("整体重新生成 · 突出商品卖点,节奏紧凑,适合短视频投放", "整体重写")}>↻ 整体重写</button>
|
<button className="btn btn-ghost btn-sm" type="button" id="chat-regen-btn" disabled={loading} onClick={() => void runScriptGeneration("整体重新生成 · 突出商品卖点,节奏紧凑,适合短视频投放", "整体重写")}>↻ 整体重写</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="shots-body" id="shots-body">
|
<div className="shots-body" id="shots-body">
|
||||||
|
{scriptBusy ? (
|
||||||
|
<div className="script-gen-veil" aria-hidden="true">
|
||||||
|
<div className="sgv-card">
|
||||||
|
<span className="sgv-ico"><span className="spinner"></span></span>
|
||||||
|
<div className="sgv-body">
|
||||||
|
<div className="sgv-title">正在生成镜头脚本<span className="sgv-dots"><i></i><i></i><i></i></span></div>
|
||||||
|
<div className="sgv-bar"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{shots.length ? (() => {
|
{shots.length ? (() => {
|
||||||
let cum = 0;
|
let cum = 0;
|
||||||
return shots.map((shot, index) => {
|
return shots.map((shot, index) => {
|
||||||
@@ -2142,6 +2170,9 @@ export function PipelinePage(props: {
|
|||||||
return (
|
return (
|
||||||
<Fragment key={shot.id}>
|
<Fragment key={shot.id}>
|
||||||
<div className="shot-card">
|
<div className="shot-card">
|
||||||
|
{rewriteShot === shot.id ? (
|
||||||
|
<div className="shot-gen-veil" aria-hidden="true"><span className="spinner"></span><span className="mono">// 改这一镜…</span></div>
|
||||||
|
) : null}
|
||||||
<div className="shot-n">{index + 1}</div>
|
<div className="shot-n">{index + 1}</div>
|
||||||
<div className="shot-main">
|
<div className="shot-main">
|
||||||
<div className="shot-meta-row">
|
<div className="shot-meta-row">
|
||||||
@@ -2153,7 +2184,7 @@ export function PipelinePage(props: {
|
|||||||
className={`icon-mini-btn${armedDelete === shot.id ? " armed" : ""}`}
|
className={`icon-mini-btn${armedDelete === shot.id ? " armed" : ""}`}
|
||||||
type="button"
|
type="button"
|
||||||
title={armedDelete === shot.id ? "再点一次确认删除" : "删除本场"}
|
title={armedDelete === shot.id ? "再点一次确认删除" : "删除本场"}
|
||||||
disabled={loading || shots.length <= 1 || busyShot === shot.id}
|
disabled={loading || busyShot === shot.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (armedDelete === shot.id) {
|
if (armedDelete === shot.id) {
|
||||||
void deleteShot(shot.id);
|
void deleteShot(shot.id);
|
||||||
@@ -2164,14 +2195,17 @@ export function PipelinePage(props: {
|
|||||||
>{armedDelete === shot.id ? "确认删除?" : "×"}</button>
|
>{armedDelete === shot.id ? "确认删除?" : "×"}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="shot-row">
|
{/* 这一镜有对白时,旁白=对白拼接(同一句),只显对白、不再重复显旁白 */}
|
||||||
<span className="shot-k">旁白</span>
|
{!(shot.dialogue && shot.dialogue.length > 0) ? (
|
||||||
<EditableShotField
|
<div className="shot-row">
|
||||||
value={narration}
|
<span className="shot-k">旁白</span>
|
||||||
placeholder="(旁白)点击编辑"
|
<EditableShotField
|
||||||
onCommit={(text) => { void onUpdateShot({ segment_id: shot.id, narration: text }); }}
|
value={narration}
|
||||||
/>
|
placeholder="(旁白)点击编辑"
|
||||||
</div>
|
onCommit={(text) => { void onUpdateShot({ segment_id: shot.id, narration: text }); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<div className="shot-row">
|
<div className="shot-row">
|
||||||
<span className="shot-k">画面</span>
|
<span className="shot-k">画面</span>
|
||||||
<EditableShotField
|
<EditableShotField
|
||||||
@@ -2234,7 +2268,6 @@ export function PipelinePage(props: {
|
|||||||
<div className="pane-h">
|
<div className="pane-h">
|
||||||
<div className="ai-avatar">AI</div>
|
<div className="ai-avatar">AI</div>
|
||||||
<strong>脚本助手</strong>
|
<strong>脚本助手</strong>
|
||||||
<span className="muted-2 mono" style={{ fontSize: "12px" }}>· {scriptModelName}</span>
|
|
||||||
<span className="spacer"></span>
|
<span className="spacer"></span>
|
||||||
<button className="btn btn-ghost btn-sm" type="button" id="chat-clear-btn" disabled={!chatText && chatAttachments.length === 0 && chatMsgs.length === 0} onClick={clearChat}>清空对话</button>
|
<button className="btn btn-ghost btn-sm" type="button" id="chat-clear-btn" disabled={!chatText && chatAttachments.length === 0 && chatMsgs.length === 0} onClick={clearChat}>清空对话</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2352,8 +2385,10 @@ export function PipelinePage(props: {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<span className="spacer"></span>
|
<span className="spacer"></span>
|
||||||
<button className="chat-send-btn" id="chat-send-btn" type="button" title="发送" aria-label="发送" disabled={loading || (!setupOpen && !chatText.trim() && pendingTagEdits.length === 0)} onClick={submitChat}>
|
<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}>
|
||||||
<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>
|
{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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ description: >
|
|||||||
"role": "钩子|痛点|卖点|CTA",
|
"role": "钩子|痛点|卖点|CTA",
|
||||||
"narration": "这一镜被说出来的台词/旁白,≤55字",
|
"narration": "这一镜被说出来的台词/旁白,≤55字",
|
||||||
"speaker": "可选,指向某 entity 的 id;画外旁白时为 null",
|
"speaker": "可选,指向某 entity 的 id;画外旁白时为 null",
|
||||||
"visual": "画面描述",
|
"visual": "这一镜的画面:主体+动作+景别/运镜(特写/全景/手持跟拍/推拉摇)+一个画面或情绪的变化,够导演撑满15秒,约40-70字,别只写一句静态动作",
|
||||||
"product_exposure": "商品露出方式(手持/特写/使用中)",
|
"product_exposure": "商品露出方式(手持/特写/使用中)",
|
||||||
"entity_refs": ["c1", "s1"],
|
"entity_refs": ["c1", "s1"],
|
||||||
"dialogue": []
|
"dialogue": []
|
||||||
@@ -73,10 +73,15 @@ description: >
|
|||||||
- **每 15 秒切一镜**:`segment_count = total_duration / 15`,即 15→1 镜、30→2 镜、60→4 镜、90→6 镜;每镜 `duration=15`;`index` 从 0 连续递增(粗暴切,不做复杂时长算法)。
|
- **每 15 秒切一镜**:`segment_count = total_duration / 15`,即 15→1 镜、30→2 镜、60→4 镜、90→6 镜;每镜 `duration=15`;`index` 从 0 连续递增(粗暴切,不做复杂时长算法)。
|
||||||
- 各档位的 4 镜功能(role)如何分配/压缩/扩展,见 `references/methodology.md`「档位 × 黄金结构映射」。
|
- 各档位的 4 镜功能(role)如何分配/压缩/扩展,见 `references/methodology.md`「档位 × 黄金结构映射」。
|
||||||
- `entities[].id` 全局唯一,`segments[].entity_refs` 与 `speaker` 只能引用已声明的 id。
|
- `entities[].id` 全局唯一,`segments[].entity_refs` 与 `speaker` 只能引用已声明的 id。
|
||||||
- **`dialogue`(角色对白)默认留空 `[]`** —— 绝大多数电商场景是口播/旁白,用 `narration` 即可,**不要无故造对白**。仅当 ① `tone` 为「剧情」且画面确有多角色互动,或 ② 用户明确要求「加对白 / 角色对话 / 剧情向」时,才填 `dialogue`:元素为 `{"speaker":"角色 entity 的 id,或 null=旁白","line":"台词"}`,每条 `line` 仍 ≤55 字;并把各 `line` 拼进 `narration` 兜底下游字幕/配音。纯产品展示向甚至可让 `narration` 也留空(没人说话,只有画面)。
|
- **发声方式每镜自己判断**(不强求统一,看这一镜的内容和场景):
|
||||||
|
- **旁白/口播** → 填 `narration`,`dialogue` 留空 `[]`(大多数电商镜是这种);
|
||||||
|
- **角色对话** → 填 `dialogue`:元素为 `{"speaker":"角色 entity 的 id,或 null=旁白","line":"台词"}`,每条 `line` ≤55 字;并把各 `line` 拼进 `narration` 兜底下游字幕/配音。剧情向、多角色互动、或一句自然的吐槽/接话更带感时都可以用,**不必非到「剧情」档**;
|
||||||
|
- **纯画面展示** → `narration` 与 `dialogue` 都留空(没人说话,只有画面)。
|
||||||
|
- **判断权交给你**:依据用户输入与这一镜的功能/场景决定。**不是每个 15 秒都得有对白,也不必死守旁白**;但**别无故给每镜都塞对白**(那样很假),自然才好。
|
||||||
- **每个声明的 entity 至少被一个 segment 引用**(不留孤儿 entity)。
|
- **每个声明的 entity 至少被一个 segment 引用**(不留孤儿 entity)。
|
||||||
- **场景必抽,且每镜必绑一个场景**:每条脚本**至少声明 1 个 `type:"scene"` 实体**表示画面所在环境;**每个 segment 的 `entity_refs` 必须恰好引用一个 scene**。多镜在同一环境就**复用同一个 scene id**(绝不为同一环境写两份 visual_prompt,否则下游背景漂移);只有真正换了环境才新建另一个 scene。纯产品特写镜也要绑它所处环境的 scene(如「宿舍书桌」「厨房台面」),没有合适环境时复用主场景。
|
- **场景必抽,且每镜必绑一个场景**:每条脚本**至少声明 1 个 `type:"scene"` 实体**表示画面所在环境;**每个 segment 的 `entity_refs` 必须恰好引用一个 scene**。多镜在同一环境就**复用同一个 scene id**(绝不为同一环境写两份 visual_prompt,否则下游背景漂移);只有真正换了环境才新建另一个 scene。纯产品特写镜也要绑它所处环境的 scene(如「宿舍书桌」「厨房台面」),没有合适环境时复用主场景。
|
||||||
- `visual_prompt` 由你自动生成,小白无需打字。
|
- `visual_prompt` 由你自动生成,小白无需打字。
|
||||||
|
- **每镜 `visual` 要够厚撑满 15 秒**:一段话写清 ①主体+动作 ②景别/运镜(特写/全景/手持跟拍/推拉摇,至少给一个镜头语言)③一个画面或情绪的变化(从…到…)。约 **40–70 字**,**禁止只写一句静态动作**(如「女主举起商品展示」撑不住 15 秒,要补镜头与变化)。注意:这是给生图/视频导演的画面,**不占 narration 的 55 字额度**。
|
||||||
- 不要输出 schema 之外的字段,也不要省略必填字段。
|
- 不要输出 schema 之外的字段,也不要省略必填字段。
|
||||||
|
|
||||||
### 铁律 2 · 输出前自检
|
### 铁律 2 · 输出前自检
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
## C. 旁白红线扫描(逐镜)
|
## C. 旁白红线扫描(逐镜)
|
||||||
|
|
||||||
- [ ] 每镜 `narration` ≤ 55 字(硬上限;目标 ≤50 留缓冲,逐字数一遍,别凭感觉)。
|
- [ ] 每镜 `narration` ≤ 55 字(硬上限;目标 ≤50 留缓冲,逐字数一遍,别凭感觉)。
|
||||||
|
- [ ] 每镜 `visual` ≥ 40 字且含「景别/运镜 + 一个画面变化」,不是一句静态动作(撑满 15 秒)。
|
||||||
- [ ] 口语化,无书面腔/AI 腔("综上""不仅…而且""值得一提"等已清除)。
|
- [ ] 口语化,无书面腔/AI 腔("综上""不仅…而且""值得一提"等已清除)。
|
||||||
- [ ] **违规词扫描**:无医疗功效词(治疗/根治/抗癌/消炎/排毒/速效…)。
|
- [ ] **违规词扫描**:无医疗功效词(治疗/根治/抗癌/消炎/排毒/速效…)。
|
||||||
- [ ] **绝对化用语扫描**:无 最/第一/唯一/100%/国家级/永久/绝对/史上 等。
|
- [ ] **绝对化用语扫描**:无 最/第一/唯一/100%/国家级/永久/绝对/史上 等。
|
||||||
|
|||||||
Reference in New Issue
Block a user