视频创作解决已发现问题

This commit is contained in:
Azmat@qq.com
2026-09-16 10:21:29 +08:00
parent d4f282bfa9
commit a95cd159db
59 changed files with 263 additions and 39 deletions
+39 -11
View File
@@ -671,8 +671,9 @@ def default_reply_options(
*, *,
has_context: bool = False, has_context: bool = False,
is_video: bool = True, is_video: bool = True,
assistant_text: str = "",
) -> list[dict[str, str]]: ) -> list[dict[str, str]]:
"""纯文字收束时给前端的可点下一步,避免用户面对一段说明不知道怎么继续""" """只给与上一句相关的快捷回复,绝不塞与当前进度无关的固定话术"""
stage = "" stage = ""
if conversation is not None: if conversation is not None:
try: try:
@@ -686,17 +687,39 @@ def default_reply_options(
{"label": "重新来", "text": "重新来"}, {"label": "重新来", "text": "重新来"},
] ]
if has_context: if has_context:
if is_video: text = str(assistant_text or "")
if re.search(r"颜色|色号|色彩|SKU|款式|几种", text, re.IGNORECASE):
return [ return [
{"label": "继续完善方案", "text": "继续完善方案"}, {"label": "补充颜色和顺序", "text": "我来补充每个颜色和展示顺序"},
{"label": "换个场景", "text": "想换个场景"}, {"label": "上传各款实物图", "text": "补充各颜色/款式的实物图"},
{"label": "改卖点", "text": "我想改卖点"}, {"label": "先按当前主款做", "text": "先按当前主款做,其他颜色后面再补"},
] ]
return [ if re.search(r"商品|产品|主推|哪款", text, re.IGNORECASE):
{"label": "按这个方向出图", "text": "按这个方向出图"}, return [
{"label": "换个场景", "text": "我想换个场景"}, {"label": "发商品列表", "text": "把商品列表发给我选"},
{"label": "改人物状态", "text": "想改人物状态"}, {"label": "我直接说商品名", "text": "直接告诉你商品名"},
] {"label": "你来推荐", "text": "你根据当前需求推荐一款"},
]
if re.search(r"人物|角色|模特|出镜", text, re.IGNORECASE):
return [
{"label": "上传人物图", "text": "我上传人物参考图"},
{"label": "由你设定角色", "text": "你先帮我设定一个合适的角色"},
{"label": "不需要人物", "text": "这条先不需要人物出镜"},
]
if re.search(r"场景|地点|背景|在哪", text, re.IGNORECASE):
return [
{"label": "上传场景图", "text": "我上传场景参考图"},
{"label": "你来推荐场景", "text": "你按商品和预设推荐场景"},
{"label": "用干净日常场景", "text": "先用干净自然的日常场景"},
]
if re.search(r"卖点|功能|效果|优惠|价格", text, re.IGNORECASE):
return [
{"label": "补充真实卖点", "text": "我来补充商品真实卖点"},
{"label": "从素材里判断", "text": "先根据我上传的素材判断可表达的卖点"},
{"label": "先只突出一个点", "text": "先围绕一个最核心的卖点创作"},
]
# 没有足够语境时只留自由输入,不伪造“继续/改卖点”这种人机按钮。
return []
kind = "短视频" if is_video else "商品图" kind = "短视频" if is_video else "商品图"
return [ return [
{"label": f"帮我做一条{kind}" if is_video else "帮我做一张商品图", "text": f"帮我做一条{kind}" if is_video else "帮我做一张商品图"}, {"label": f"帮我做一条{kind}" if is_video else "帮我做一张商品图", "text": f"帮我做一条{kind}" if is_video else "帮我做一张商品图"},
@@ -745,7 +768,12 @@ def ensure_turn_guides(
return [] return []
hint = default_reply_hint(conversation, has_context=has_context, is_video=is_video) hint = default_reply_hint(conversation, has_context=has_context, is_video=is_video)
options = default_reply_options(conversation, has_context=has_context, is_video=is_video) options = default_reply_options(
conversation,
has_context=has_context,
is_video=is_video,
assistant_text=last_text_bubble.text if last_text_bubble is not None else "",
)
events: list[dict] = [] events: list[dict] = []
if last_text_bubble is not None: if last_text_bubble is not None:
if text_already_guides(last_text_bubble.text): if text_already_guides(last_text_bubble.text):
@@ -29,6 +29,7 @@ from .creation_agent import (
build_system_prompt, build_system_prompt,
build_messages, build_messages,
default_reply_hint, default_reply_hint,
default_reply_options,
ensure_turn_guides, ensure_turn_guides,
get_creation_chat_model, get_creation_chat_model,
get_video_gate_stage, get_video_gate_stage,
@@ -1842,6 +1843,26 @@ class ReplyGuidanceTests(CreationAgentBaseTests):
self.assertIn("按这个方向出图", str((bubble.payload or {}).get("reply_hint") or "")) self.assertIn("按这个方向出图", str((bubble.payload or {}).get("reply_hint") or ""))
self.assertTrue((bubble.payload or {}).get("reply_options")) self.assertTrue((bubble.payload or {}).get("reply_options"))
def test_reply_options_follow_the_current_question(self):
options = default_reply_options(
self.conversation,
has_context=True,
is_video=True,
assistant_text="这款耳机一共有六个配色,分别是什么颜色?",
)
labels = [option["label"] for option in options]
self.assertEqual(labels, ["补充颜色和顺序", "上传各款实物图", "先按当前主款做"])
self.assertNotIn("改卖点", labels)
def test_unknown_context_question_does_not_get_fixed_actions(self):
options = default_reply_options(
self.conversation,
has_context=True,
is_video=True,
assistant_text="我先把你刚刚提到的细节整理一下。",
)
self.assertEqual(options, [])
def test_text_already_guides_detects_reply_instruction(self): def test_text_already_guides_detects_reply_instruction(self):
self.assertTrue(text_already_guides("这个方向能做。请回复:按这个方向继续。")) self.assertTrue(text_already_guides("这个方向能做。请回复:按这个方向继续。"))
self.assertFalse(text_already_guides("这个方向能做。有想调整可以直接说。")) self.assertFalse(text_already_guides("这个方向能做。有想调整可以直接说。"))
@@ -1370,6 +1370,9 @@ class QuickCreateCoordinatorTests(TestCase):
+48
View File
@@ -163,6 +163,54 @@
white-space: pre-wrap; white-space: pre-wrap;
} }
.omni-chat-markdown {
width: 100%;
}
.omni-chat-markdown > :first-child {
margin-top: 0;
}
.omni-chat-markdown > :last-child {
margin-bottom: 0;
}
.omni-chat-markdown h3 {
margin: 0 0 8px;
color: var(--accent-black);
font-size: 14px;
font-weight: 600;
line-height: 1.5;
}
.omni-chat-markdown p {
margin: 0 0 8px;
}
.omni-chat-markdown ol,
.omni-chat-markdown ul {
margin: 0 0 8px;
padding-left: 20px;
}
.omni-chat-markdown li + li {
margin-top: 4px;
}
.omni-chat-markdown strong {
color: var(--accent-black);
font-weight: 600;
}
.omni-chat-markdown code {
padding: 1px 4px;
border-radius: var(--r-sm);
color: var(--accent-black);
background: var(--black-alpha-4);
font-family: var(--font-mono);
font-size: .92em;
}
.omni-reply-hint { .omni-reply-hint {
margin: 8px 0 0; margin: 8px 0 0;
+15 -9
View File
@@ -556,7 +556,7 @@ export function OmniHistoryPage({
navigate: NavigateFn; navigate: NavigateFn;
onNotify?: (type: "success" | "error" | "info", text: string) => void; onNotify?: (type: "success" | "error" | "info", text: string) => void;
}) { }) {
const [filter, setFilter] = useState<"all" | "running" | "completed">("all"); const [modeFilter, setModeFilter] = useState<"all" | "video" | "image">("all");
const [items, setItems] = useState<CreationConversation[] | null>(null); const [items, setItems] = useState<CreationConversation[] | null>(null);
const [deleteTarget, setDeleteTarget] = useState<CreationConversation | null>(null); const [deleteTarget, setDeleteTarget] = useState<CreationConversation | null>(null);
// 同会话页:onNotify 是内联箭头,进依赖会让列表每次 App 重渲染都重拉一遍 // 同会话页:onNotify 是内联箭头,进依赖会让列表每次 App 重渲染都重拉一遍
@@ -567,7 +567,7 @@ export function OmniHistoryPage({
let cancelled = false; let cancelled = false;
setItems(null); setItems(null);
api api
.listCreations(filter === "all" ? {} : { status: filter }) .listCreations(modeFilter === "all" ? {} : { mode: modeFilter })
.then((page) => { .then((page) => {
if (!cancelled) setItems(page.results || []); if (!cancelled) setItems(page.results || []);
}) })
@@ -579,7 +579,7 @@ export function OmniHistoryPage({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [filter]); }, [modeFilter]);
const remove = async (id: string) => { const remove = async (id: string) => {
// 软删:会话没了,已生成的图和视频仍留在资产库里 // 软删:会话没了,已生成的图和视频仍留在资产库里
@@ -602,20 +602,20 @@ export function OmniHistoryPage({
</button> </button>
<h1></h1> <h1></h1>
</div> </div>
<p></p> <p></p>
</div> </div>
<div className="omni-history-tools"> <div className="omni-history-tools">
<div className="omni-history-toolbar"> <div className="omni-history-toolbar">
{([ {([
["all", "全部"], ["all", "全部"],
["running", "进行中"], ["video", "视频创作"],
["completed", "已完成"], ["image", "图片创作"],
] as const).map(([key, label]) => ( ] as const).map(([key, label]) => (
<button <button
type="button" type="button"
key={key} key={key}
className={filter === key ? "active" : ""} className={modeFilter === key ? "active" : ""}
onClick={() => setFilter(key)} onClick={() => setModeFilter(key)}
> >
{label} {label}
</button> </button>
@@ -634,7 +634,13 @@ export function OmniHistoryPage({
</div> </div>
) : items.length === 0 ? ( ) : items.length === 0 ? (
<div className="omni-history-empty"> <div className="omni-history-empty">
<p></p> <p>
{modeFilter === "video"
? "还没有视频创作记录,从全能创作开始。"
: modeFilter === "image"
? "还没有图片创作记录,从全能创作开始。"
: "还没有创作记录,从全能创作开始。"}
</p>
<button type="button" onClick={() => navigate("omniCreate")}> <button type="button" onClick={() => navigate("omniCreate")}>
<ChevronRight size={14} /> <ChevronRight size={14} />
+137 -19
View File
@@ -1,4 +1,4 @@
import { type FormEvent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { type FormEvent, type ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { import {
ArrowLeft, ArrowLeft,
@@ -43,6 +43,94 @@ type PromptBlock =
type ReplyOption = { label: string; text: string }; type ReplyOption = { label: string; text: string };
function renderInlineMarkdown(text: string): ReactNode[] {
return text.split(/(\*\*[^*]+\*\*|`[^`]+`|\*[^*]+\*)/g).filter(Boolean).map((part, index) => {
if (part.startsWith("**") && part.endsWith("**")) {
return <strong key={index}>{part.slice(2, -2)}</strong>;
}
if (part.startsWith("`") && part.endsWith("`")) {
return <code key={index}>{part.slice(1, -1)}</code>;
}
if (part.startsWith("*") && part.endsWith("*")) {
return <em key={index}>{part.slice(1, -1)}</em>;
}
return part;
});
}
/** 对话正文的安全 Markdown 子集;不使用 HTML 注入,避免模型文本影响页面结构。 */
function ChatMarkdown({ text }: { text: string }) {
const lines = text.replace(/\r\n/g, "\n").split("\n");
const nodes: ReactNode[] = [];
let index = 0;
while (index < lines.length) {
const line = lines[index].trim();
if (!line) {
index += 1;
continue;
}
const heading = line.match(/^#{1,3}\s+(.+)$/);
if (heading) {
nodes.push(<h3 key={`heading-${index}`}>{renderInlineMarkdown(heading[1])}</h3>);
index += 1;
continue;
}
const ordered = line.match(/^(\d+)[.、.]\s+(.+)$/);
if (ordered) {
const start = Number(ordered[1]);
const items: Array<{ key: number; text: string }> = [];
while (index < lines.length) {
const match = lines[index].trim().match(/^(\d+)[.、.]\s+(.+)$/);
if (!match) break;
items.push({ key: Number(match[1]), text: match[2] });
index += 1;
}
nodes.push(
<ol key={`ordered-${index}`} start={start}>
{items.map((item) => <li key={item.key}>{renderInlineMarkdown(item.text)}</li>)}
</ol>,
);
continue;
}
if (/^[-*+](?:\s+)/.test(line)) {
const items: string[] = [];
while (index < lines.length) {
const match = lines[index].trim().match(/^[-*+](?:\s+)(.+)$/);
if (!match) break;
items.push(match[1]);
index += 1;
}
nodes.push(
<ul key={`unordered-${index}`}>
{items.map((item, itemIndex) => <li key={itemIndex}>{renderInlineMarkdown(item)}</li>)}
</ul>,
);
continue;
}
const paragraph: string[] = [];
while (index < lines.length) {
const current = lines[index].trim();
if (!current || /^#{1,3}\s+/.test(current) || /^(\d+)[.、.]\s+/.test(current) || /^[-*+](?:\s+)/.test(current)) break;
paragraph.push(lines[index]);
index += 1;
}
nodes.push(
<p key={`paragraph-${index}`}>
{paragraph.map((item, lineIndex) => (
<span key={lineIndex}>{lineIndex ? <br /> : null}{renderInlineMarkdown(item)}</span>
))}
</p>,
);
}
return <div className="omni-chat-markdown">{nodes}</div>;
}
function stripNumericReplyInstruction(text: string): string { function stripNumericReplyInstruction(text: string): string {
return (text || "") return (text || "")
.replace( .replace(
@@ -61,7 +149,46 @@ function numberedReplyOptions(text: string): ReplyOption[] {
})); }));
} }
function replyOptionsForMessage(text: string, payload: Record<string, unknown>, isVideo: boolean): ReplyOption[] { function contextualReplyOptions(text: string): ReplyOption[] {
if (/颜色|色号|色彩|SKU|款式|几种/i.test(text)) {
return [
{ label: "补充颜色和顺序", text: "我来补充每个颜色和展示顺序" },
{ label: "上传各款实物图", text: "我补充各颜色/款式的实物图" },
{ label: "先按当前主款做", text: "先按当前主款做,其他颜色后面再补" },
];
}
if (/商品|产品|主推|哪款/i.test(text)) {
return [
{ label: "发商品列表", text: "把商品列表发给我选" },
{ label: "我直接说商品名", text: "我直接告诉你商品名" },
{ label: "你来推荐", text: "你根据当前需求推荐一款" },
];
}
if (/人物|角色|模特|出镜/i.test(text)) {
return [
{ label: "上传人物图", text: "我上传人物参考图" },
{ label: "由你设定角色", text: "你先帮我设定一个合适的角色" },
{ label: "不需要人物", text: "这条先不需要人物出镜" },
];
}
if (/场景|地点|背景|在哪/i.test(text)) {
return [
{ label: "上传场景图", text: "我上传场景参考图" },
{ label: "你来推荐场景", text: "你按商品和预设推荐场景" },
{ label: "用干净日常场景", text: "先用干净自然的日常场景" },
];
}
if (/卖点|功能|效果|优惠|价格/i.test(text)) {
return [
{ label: "补充真实卖点", text: "我来补充商品真实卖点" },
{ label: "从素材里判断", text: "先根据我上传的素材判断可表达的卖点" },
{ label: "先只突出一个点", text: "先围绕一个最核心的卖点创作" },
];
}
return [];
}
function replyOptionsForMessage(text: string, payload: Record<string, unknown>): ReplyOption[] {
const numbered = numberedReplyOptions(text); const numbered = numberedReplyOptions(text);
if (numbered.length) return numbered; if (numbered.length) return numbered;
const stored = payload.reply_options; const stored = payload.reply_options;
@@ -71,19 +198,10 @@ function replyOptionsForMessage(text: string, payload: Record<string, unknown>,
.map((item) => ({ label: String(item.label || "").trim(), text: String(item.text || "").trim() })) .map((item) => ({ label: String(item.label || "").trim(), text: String(item.text || "").trim() }))
.filter((item) => item.label && item.text) .filter((item) => item.label && item.text)
.slice(0, 3); .slice(0, 3);
if (options.length) return options; const legacyGeneric = ["继续完善方案", "换个场景", "改卖点", "按这个方向出图", "改人物状态"];
if (options.length && !options.every((item) => legacyGeneric.includes(item.label))) return options;
} }
return isVideo return contextualReplyOptions(text);
? [
{ label: "继续完善方案", text: "继续完善方案" },
{ label: "换个场景", text: "我想换个场景" },
{ label: "改卖点", text: "我想改卖点" },
]
: [
{ label: "按这个方向出图", text: "按这个方向出图" },
{ label: "换个场景", text: "我想换个场景" },
{ label: "改人物状态", text: "我想改人物状态" },
];
} }
function ReplyActions({ function ReplyActions({
@@ -954,7 +1072,7 @@ function ElicitCard({
<Sparkles /> <Sparkles />
</span> </span>
<div className="omni-chat-bubble omni-gate-bubble"> <div className="omni-chat-bubble omni-gate-bubble">
<p className="omni-chat-text">{message.text || gateLabel}</p> <ChatMarkdown text={message.text || gateLabel} />
{!submitted ? ( {!submitted ? (
<div className="omni-gate-actions" aria-label="商品选择操作"> <div className="omni-gate-actions" aria-label="商品选择操作">
{(gateField?.options || []).map((option, index) => ( {(gateField?.options || []).map((option, index) => (
@@ -991,7 +1109,7 @@ function ElicitCard({
</span> </span>
<div className="omni-chat-stack"> <div className="omni-chat-stack">
<div className="omni-chat-bubble"> <div className="omni-chat-bubble">
<p className="omni-chat-text">{question}</p> <ChatMarkdown text={question} />
</div> </div>
{!submitted ? ( {!submitted ? (
<div className="omni-reply-guide" aria-label="回复操作"> <div className="omni-reply-guide" aria-label="回复操作">
@@ -2403,7 +2521,7 @@ export function OmniSessionPage({
&& message.kind === "text" && message.kind === "text"
&& messages[messages.length - 1]?.id === message.id && messages[messages.length - 1]?.id === message.id
&& !streaming; && !streaming;
const replyOptions = showReplyGuide ? replyOptionsForMessage(body, message.payload, isVideo) : []; const replyOptions = showReplyGuide ? replyOptionsForMessage(body, message.payload) : [];
return ( return (
<div <div
className={`omni-chat-row ${message.role === "user" ? "is-user" : "agent"}${pending ? " is-pending" : ""}`} className={`omni-chat-row ${message.role === "user" ? "is-user" : "agent"}${pending ? " is-pending" : ""}`}
@@ -2418,7 +2536,7 @@ export function OmniSessionPage({
{message.role === "user" ? <MentionChips refs={refs} tone="user" /> : null} {message.role === "user" ? <MentionChips refs={refs} tone="user" /> : null}
{(body || message.role !== "user") ? ( {(body || message.role !== "user") ? (
<div className="omni-chat-bubble" aria-busy={pending || undefined}> <div className="omni-chat-bubble" aria-busy={pending || undefined}>
{body ? <p className="omni-chat-text">{body}</p> : null} {body ? (message.role === "assistant" ? <ChatMarkdown text={body} /> : <p className="omni-chat-text">{body}</p>) : null}
</div> </div>
) : null} ) : null}
{showReplyGuide ? ( {showReplyGuide ? (
@@ -2521,7 +2639,7 @@ export function OmniSessionPage({
<Sparkles /> <Sparkles />
</span> </span>
<div className="omni-chat-bubble"> <div className="omni-chat-bubble">
<p className="omni-chat-text">{liveText}</p> <ChatMarkdown text={liveText} />
</div> </div>
</div> </div>
) : ( ) : (
Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 778 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 770 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 744 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 678 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 662 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 842 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 843 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 843 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB