视频创作解决已发现问题

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
+48
View File
@@ -163,6 +163,54 @@
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 {
margin: 8px 0 0;
+15 -9
View File
@@ -556,7 +556,7 @@ export function OmniHistoryPage({
navigate: NavigateFn;
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 [deleteTarget, setDeleteTarget] = useState<CreationConversation | null>(null);
// 同会话页:onNotify 是内联箭头,进依赖会让列表每次 App 重渲染都重拉一遍
@@ -567,7 +567,7 @@ export function OmniHistoryPage({
let cancelled = false;
setItems(null);
api
.listCreations(filter === "all" ? {} : { status: filter })
.listCreations(modeFilter === "all" ? {} : { mode: modeFilter })
.then((page) => {
if (!cancelled) setItems(page.results || []);
})
@@ -579,7 +579,7 @@ export function OmniHistoryPage({
return () => {
cancelled = true;
};
}, [filter]);
}, [modeFilter]);
const remove = async (id: string) => {
// 软删:会话没了,已生成的图和视频仍留在资产库里
@@ -602,20 +602,20 @@ export function OmniHistoryPage({
</button>
<h1></h1>
</div>
<p></p>
<p></p>
</div>
<div className="omni-history-tools">
<div className="omni-history-toolbar">
{([
["all", "全部"],
["running", "进行中"],
["completed", "已完成"],
["video", "视频创作"],
["image", "图片创作"],
] as const).map(([key, label]) => (
<button
type="button"
key={key}
className={filter === key ? "active" : ""}
onClick={() => setFilter(key)}
className={modeFilter === key ? "active" : ""}
onClick={() => setModeFilter(key)}
>
{label}
</button>
@@ -634,7 +634,13 @@ export function OmniHistoryPage({
</div>
) : items.length === 0 ? (
<div className="omni-history-empty">
<p></p>
<p>
{modeFilter === "video"
? "还没有视频创作记录,从全能创作开始。"
: modeFilter === "image"
? "还没有图片创作记录,从全能创作开始。"
: "还没有创作记录,从全能创作开始。"}
</p>
<button type="button" onClick={() => navigate("omniCreate")}>
<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 {
ArrowLeft,
@@ -43,6 +43,94 @@ type PromptBlock =
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 {
return (text || "")
.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);
if (numbered.length) return numbered;
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() }))
.filter((item) => item.label && item.text)
.slice(0, 3);
if (options.length) return options;
const legacyGeneric = ["继续完善方案", "换个场景", "改卖点", "按这个方向出图", "改人物状态"];
if (options.length && !options.every((item) => legacyGeneric.includes(item.label))) return options;
}
return isVideo
? [
{ label: "继续完善方案", text: "继续完善方案" },
{ label: "换个场景", text: "我想换个场景" },
{ label: "改卖点", text: "我想改卖点" },
]
: [
{ label: "按这个方向出图", text: "按这个方向出图" },
{ label: "换个场景", text: "我想换个场景" },
{ label: "改人物状态", text: "我想改人物状态" },
];
return contextualReplyOptions(text);
}
function ReplyActions({
@@ -954,7 +1072,7 @@ function ElicitCard({
<Sparkles />
</span>
<div className="omni-chat-bubble omni-gate-bubble">
<p className="omni-chat-text">{message.text || gateLabel}</p>
<ChatMarkdown text={message.text || gateLabel} />
{!submitted ? (
<div className="omni-gate-actions" aria-label="商品选择操作">
{(gateField?.options || []).map((option, index) => (
@@ -991,7 +1109,7 @@ function ElicitCard({
</span>
<div className="omni-chat-stack">
<div className="omni-chat-bubble">
<p className="omni-chat-text">{question}</p>
<ChatMarkdown text={question} />
</div>
{!submitted ? (
<div className="omni-reply-guide" aria-label="回复操作">
@@ -2403,7 +2521,7 @@ export function OmniSessionPage({
&& message.kind === "text"
&& messages[messages.length - 1]?.id === message.id
&& !streaming;
const replyOptions = showReplyGuide ? replyOptionsForMessage(body, message.payload, isVideo) : [];
const replyOptions = showReplyGuide ? replyOptionsForMessage(body, message.payload) : [];
return (
<div
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}
{(body || message.role !== "user") ? (
<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>
) : null}
{showReplyGuide ? (
@@ -2521,7 +2639,7 @@ export function OmniSessionPage({
<Sparkles />
</span>
<div className="omni-chat-bubble">
<p className="omni-chat-text">{liveText}</p>
<ChatMarkdown text={liveText} />
</div>
</div>
) : (