解决已发现问题
This commit is contained in:
@@ -1262,10 +1262,11 @@ export const adminApi = {
|
||||
{ method: "POST", body: JSON.stringify(assetIds ? { asset_ids: assetIds } : {}) }
|
||||
);
|
||||
},
|
||||
tasks(params?: { status?: string; task_type?: string; team?: string; anomaly?: string; page?: number; page_size?: number }) {
|
||||
tasks(params?: { status?: string; task_type?: string; category?: "omni_create"; team?: string; anomaly?: string; page?: number; page_size?: number }) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.status) qs.set("status", params.status);
|
||||
if (params?.task_type) qs.set("task_type", params.task_type);
|
||||
if (params?.category) qs.set("category", params.category);
|
||||
if (params?.team) qs.set("team", params.team);
|
||||
if (params?.anomaly) qs.set("anomaly", params.anomaly);
|
||||
if (params?.page) qs.set("page", String(params.page));
|
||||
|
||||
@@ -24,6 +24,19 @@ function withCurrent(values: string[], current: string) {
|
||||
return current && !values.includes(current) ? [current, ...values] : values;
|
||||
}
|
||||
|
||||
/** 同一秒数允许「8秒 / 8 秒」等历史格式共存,不因展示格式触发参数变更。 */
|
||||
export function normalizeDurationValue(value: string): string {
|
||||
const raw = String(value || "").trim();
|
||||
const seconds = raw.match(/\d+(?:\.\d+)?/)?.[0];
|
||||
if (seconds) return `seconds:${Number(seconds)}`;
|
||||
return `label:${raw.replace(/\s+/g, "").toLowerCase()}`;
|
||||
}
|
||||
|
||||
function includesDuration(values: string[], current: string): boolean {
|
||||
const normalized = normalizeDurationValue(current);
|
||||
return values.some((value) => normalizeDurationValue(value) === normalized);
|
||||
}
|
||||
|
||||
function modelOptionLabel(config: ModelConfig): string {
|
||||
return (config.display_name || config.name || "").trim();
|
||||
}
|
||||
@@ -187,7 +200,7 @@ export function OmniParamBar({
|
||||
const seconds = Number(String(duration || "").replace(/\D/g, ""));
|
||||
const isPlannedSegmentedDuration = seconds > 30 && seconds <= 60 && canCreateSegmentedVideo(selected, model);
|
||||
// 60 秒是总时长,生成时会拆段;不要因为单段目录最高 30 秒就把它偷偷改回 8 秒/智能时长。
|
||||
if (duration && duration !== "智能时长" && !nextDur.includes(duration) && !isPlannedSegmentedDuration) {
|
||||
if (duration && duration !== "智能时长" && !includesDuration(nextDur, duration) && !isPlannedSegmentedDuration) {
|
||||
onDuration(nextDur.includes("8 秒") ? "8 秒" : (nextDur.find((x) => x !== "智能时长") || "智能时长"));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -2612,6 +2612,29 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 单选追问直接点选即提交;长方向文案纵向铺开,避免挤成难扫读的小胶囊。 */
|
||||
.omni-chat-choice-actions {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.omni-chat-choice-actions button {
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
padding: 9px 12px;
|
||||
text-align: left;
|
||||
line-height: 1.55;
|
||||
transition: background-color 160ms ease, border-color 160ms ease, color 160ms ease;
|
||||
}
|
||||
|
||||
.omni-chat-choice-actions button:hover:not(:disabled) {
|
||||
border-color: var(--heat-40);
|
||||
color: var(--heat);
|
||||
background: var(--heat-12);
|
||||
}
|
||||
|
||||
.omni-gate-answered {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -53,6 +53,12 @@ function attemptKind(attempt: AdminTaskDetail["attempts"][number]) {
|
||||
return "首次调用";
|
||||
}
|
||||
|
||||
function taskTypeLabel(task: AdminTask) {
|
||||
return task.task_category === "omni_create"
|
||||
? `全能创作 · ${task.task_type}`
|
||||
: task.task_type;
|
||||
}
|
||||
|
||||
export function AdminTasksPage({ notify }: { notify: Notify }) {
|
||||
const [tasks, setTasks] = useState<AdminTask[]>([]);
|
||||
const [count, setCount] = useState(0);
|
||||
@@ -60,6 +66,7 @@ export function AdminTasksPage({ notify }: { notify: Notify }) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [tab, setTab] = useState("");
|
||||
const [anomalyOnly, setAnomalyOnly] = useState(false);
|
||||
const [omniOnly, setOmniOnly] = useState(false);
|
||||
const [detail, setDetail] = useState<AdminTaskDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -67,7 +74,13 @@ export function AdminTasksPage({ notify }: { notify: Notify }) {
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await adminApi.tasks({ status: tab || undefined, anomaly: anomalyOnly ? "1" : undefined, page, page_size: PAGE_SIZE });
|
||||
const res = await adminApi.tasks({
|
||||
status: tab || undefined,
|
||||
category: omniOnly ? "omni_create" : undefined,
|
||||
anomaly: anomalyOnly ? "1" : undefined,
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
});
|
||||
if (res.results.length === 0 && res.count > 0 && page > 1) { setPage((p) => Math.max(1, p - 1)); return; }
|
||||
setTasks(res.results);
|
||||
setCount(res.count);
|
||||
@@ -77,7 +90,7 @@ export function AdminTasksPage({ notify }: { notify: Notify }) {
|
||||
setLoading(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tab, anomalyOnly, page]);
|
||||
}, [tab, anomalyOnly, omniOnly, page]);
|
||||
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
@@ -160,6 +173,9 @@ export function AdminTasksPage({ notify }: { notify: Notify }) {
|
||||
<button type="button" className={`chip admin-anomaly-chip${anomalyOnly ? " active" : ""}`} onClick={() => { setAnomalyOnly((v) => !v); setPage(1); }}>
|
||||
仅成本异常
|
||||
</button>
|
||||
<button type="button" className={`chip admin-anomaly-chip${omniOnly ? " active" : ""}`} onClick={() => { setOmniOnly((v) => !v); setPage(1); }}>
|
||||
全能创作
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
@@ -175,7 +191,7 @@ export function AdminTasksPage({ notify }: { notify: Notify }) {
|
||||
<tbody>
|
||||
{tasks.map((t) => (
|
||||
<tr key={t.id}>
|
||||
<td className="mono admin-code">{t.task_type}</td>
|
||||
<td className="mono admin-code">{taskTypeLabel(t)}</td>
|
||||
<td>{t.team_name || <span className="muted">—</span>}</td>
|
||||
<td>{t.model_name || <span className="muted">—</span>}</td>
|
||||
<td>{statusPill(t.status)}</td>
|
||||
|
||||
@@ -43,7 +43,7 @@ const VIDEO_PRESETS: PresetItem[] = [
|
||||
{ name: "剧情反转带货", category: "story", mode: "video", title: "剧情反转带货", desc: "用人物冲突与意外反转建立记忆点,商品承担剧情中的关键作用。", starter: "创作一条有前后反转的剧情带货视频,让商品自然成为解决问题的关键。", cover: "/assets/video-presets/plot-twist-commerce.jpg", previewVideo: "/assets/video-presets/plot-twist-commerce.mp4" },
|
||||
{ name: "达人口播种草", category: "speaker", mode: "video", title: "达人口播种草", desc: "用真实体验与生活化表达建立信任,适合电商和本地生活内容。", starter: "创作一条真实自然的达人口播种草视频,重点讲清使用场景和核心卖点。", cover: "/assets/video-presets/creator-recommendation.jpg", previewVideo: "/assets/video-presets/creator-recommendation.mp4" },
|
||||
{ name: "鱼眼换装", category: "visual", mode: "video", title: "鱼眼换装", desc: "强调近距离透视与连续变装节奏,适合服饰和人物视觉内容。", starter: "创作一条鱼眼镜头风格的连续换装视频,人物和服装需要保持稳定。", cover: "/assets/video-presets/rhythm-outfit-change.jpg", previewVideo: "/assets/video-presets/rhythm-outfit-change.mp4" },
|
||||
{ name: "多色商品换款", category: "visual", mode: "video", title: "多色商品换款", desc: "统一商品比例与机位,用动作连续展示不同颜色和款式。", starter: "创作一条多色商品换款视频:统一构图展示不同颜色或款式,用明确动作触发切换,最后给出全款总览。", cover: "/assets/video-presets/multi-sku-switch.jpg", previewVideo: "/assets/video-presets/multi-sku-switch.mp4" },
|
||||
{ name: "点击换款", category: "visual", mode: "video", title: "点击换款", desc: "固定商品与机位,手指每次点击都在原位切换下一款。", starter: "创作一条点击换款视频:使用单一固定机位和同一背景,商品始终保持同一位置和比例;每次手指点击商品时,立即在原位切换到下一个颜色或款式,按确认顺序逐款展示,最后以全款式总览收束。不做口播、剧情、换场景或普通商品使用演示。", cover: "/assets/video-presets/multi-sku-switch.jpg", previewVideo: "/assets/video-presets/multi-sku-switch.mp4" },
|
||||
{ name: "AI 宠物拟人", category: "story", mode: "video", title: "AI 宠物拟人", desc: "让宠物角色参与有趣小剧情,同时保留商品真实结构与用途。", starter: "创作一条 AI 宠物拟人短片:宠物有明确性格和动作,商品以真实结构和正常用法自然参与剧情。", cover: "/assets/video-presets/ai-pet-personification.jpg", previewVideo: "/assets/video-presets/ai-pet-personification.mp4" },
|
||||
];
|
||||
|
||||
@@ -398,6 +398,7 @@ export function OmniCreatePage({
|
||||
disabled={starting || uploading}
|
||||
onClick={() => {
|
||||
const text = prompt.trim();
|
||||
const creationBrief = text || selectedCase?.starter || "";
|
||||
if (!text && !selectedCase && pendingRefs.length === 0) {
|
||||
onNotify?.("info", "请先输入你想制作的内容,或选择一个创作预设");
|
||||
return;
|
||||
@@ -407,7 +408,7 @@ export function OmniCreatePage({
|
||||
// 会话 mode 与顶栏参数在这里定死,进对话页后不再改(契约 §0)
|
||||
void api
|
||||
.createCreation({
|
||||
title: (text || selectedCase?.title || "未命名创作").slice(0, 20),
|
||||
title: (creationBrief || selectedCase?.title || "未命名创作").slice(0, 20),
|
||||
mode: outputMode,
|
||||
preset: selectedCase?.name || "",
|
||||
params: {
|
||||
@@ -420,7 +421,7 @@ export function OmniCreatePage({
|
||||
})
|
||||
.then((conversation) => {
|
||||
// 首条消息交给对话页发,避免这里再复制一份 SSE 消费逻辑
|
||||
navigate("omniSession", { conversationId: conversation.id, firstMessage: text, firstRefs: pendingRefs, firstUploads: sessionUploads });
|
||||
navigate("omniSession", { conversationId: conversation.id, firstMessage: creationBrief, firstRefs: pendingRefs, firstUploads: sessionUploads });
|
||||
})
|
||||
.catch((error) => {
|
||||
const status = (error as { status?: number }).status;
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api } from "../api";
|
||||
import { findCatalogModel, OmniParamBar } from "../components/omni-param-bar";
|
||||
import { findCatalogModel, normalizeDurationValue, OmniParamBar } from "../components/omni-param-bar";
|
||||
import { estimateCost, pointsPerImageFromCatalog } from "../components/free-create/constants";
|
||||
import { MediaLightbox } from "../components/overlays";
|
||||
import type {
|
||||
@@ -153,8 +153,33 @@ function numberedReplyOptions(text: string): ReplyOption[] {
|
||||
}));
|
||||
}
|
||||
|
||||
function replyGuidanceText(text: string): string {
|
||||
const paragraphs = (text || "")
|
||||
.split(/\n+/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
const source = paragraphs.at(-1) || text || "";
|
||||
const sentences = source
|
||||
.split(/(?<=[。!?!?])/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
return (sentences.slice(-2).join("") || source).slice(-240);
|
||||
}
|
||||
|
||||
function contextualReplyOptions(text: string): ReplyOption[] {
|
||||
if (/(?:两位|两个|多位|多个).{0,24}(?:人物|角色|模特|男士|女生).{0,80}(?:哪位|哪个|选择|用哪|出镜)/i.test(text)) {
|
||||
// 只判断回复末尾真正交给用户决定的内容。创作描述本身经常同时出现商品、人物、
|
||||
// 场景和颜色;扫描整段会把正文里的普通名词误判成下一步操作。
|
||||
const guidance = replyGuidanceText(text);
|
||||
const asksForDetail = /(?:额外|另外|其他|重点).{0,12}(?:突出|强调).{0,12}(?:细节|重点|卖点)|(?:细节|重点|卖点).{0,12}(?:突出|强调)/i.test(guidance);
|
||||
const asksForColorOrder = /配色.{0,12}(?:顺序|排序|调换|调整)|(?:调换|调整).{0,12}配色/i.test(guidance);
|
||||
if (asksForDetail && asksForColorOrder) {
|
||||
return [
|
||||
{ label: "补充突出细节", text: "我想补充需要额外突出的细节" },
|
||||
{ label: "调整配色顺序", text: "我想调整配色的展示顺序" },
|
||||
{ label: "按当前描述继续", text: "没有其他调整,按当前描述继续" },
|
||||
];
|
||||
}
|
||||
if (/(?:两位|两个|多位|多个).{0,24}(?:人物|角色|模特|男士|女生).{0,80}(?:哪位|哪个|选择|用哪|出镜)/i.test(guidance)) {
|
||||
return [
|
||||
{ label: "1", text: "选择第 1 位人物出镜" },
|
||||
{ label: "2", text: "选择第 2 位人物出镜" },
|
||||
@@ -163,42 +188,46 @@ function contextualReplyOptions(text: string): ReplyOption[] {
|
||||
}
|
||||
// 人物参考和商品参考是两件事。已锁定人物后,助手若提到实物图,不能又给出「上传人物图」——
|
||||
// 这会让用户误以为刚上传的参考没有生效。
|
||||
if (/实物图|商品图|产品图|商品外观|产品外观|产品素材|耳机(?:图|外观)?|IP设计/i.test(text)) {
|
||||
const productReference = /实物图|商品图|产品图|商品外观|产品外观|产品素材|耳机(?:图|外观)?|IP设计/i;
|
||||
const asksForProductReference =
|
||||
/(?:上传|提供|补充|补一张|发一张|给我|需要|最好|建议).{0,24}(?:实物图|商品图|产品图|商品外观|产品外观|产品素材|耳机(?:图|外观)?|IP设计)/i.test(guidance)
|
||||
|| /(?:实物图|商品图|产品图|商品外观|产品外观|产品素材|耳机(?:图|外观)?|IP设计).{0,24}(?:上传|提供|补充|发来|参考|需要)/i.test(guidance);
|
||||
if (productReference.test(guidance) && asksForProductReference) {
|
||||
return [
|
||||
{ label: "上传商品实物图", text: "我上传商品实物图", action: "upload" },
|
||||
{ label: "按当前描述继续", text: "先按当前商品描述继续,不再补图" },
|
||||
{ label: "换一个商品", text: "我想换一个商品来创作" },
|
||||
];
|
||||
}
|
||||
if (/颜色|色号|色彩|SKU|款式|几种/i.test(text)) {
|
||||
if (/颜色|色号|色彩|配色|SKU|款式|几种|展示顺序/i.test(guidance)) {
|
||||
return [
|
||||
{ label: "补充颜色和顺序", text: "我来补充每个颜色和展示顺序" },
|
||||
{ label: "上传各款实物图", text: "我补充各颜色/款式的实物图", action: "upload" },
|
||||
{ label: "先按当前主款做", text: "先按当前主款做,其他颜色后面再补" },
|
||||
];
|
||||
}
|
||||
if (/商品|产品|主推|哪款/i.test(text)) {
|
||||
if (/(?:哪款|哪个|什么).{0,8}(?:商品|产品)|(?:商品|产品).{0,12}(?:选择|选|换|更换|主推|想推|要推)|(?:选择|选|换|更换|主推|想推|要推).{0,12}(?:商品|产品)/i.test(guidance)) {
|
||||
return [
|
||||
{ label: "发商品列表", text: "把商品列表发给我选" },
|
||||
{ label: "我直接说商品名", text: "我直接告诉你商品名" },
|
||||
{ label: "你来推荐", text: "你根据当前需求推荐一款" },
|
||||
];
|
||||
}
|
||||
if (/人物|角色|模特|出镜/i.test(text)) {
|
||||
if (/(?:哪位|哪个|什么|选择|选|换|更换|调整|修改|改).{0,12}(?:人物|角色|模特|出镜)|(?:人物|角色|模特|出镜).{0,12}(?:哪位|哪个|选择|选|换|更换|调整|修改|改)/i.test(guidance)) {
|
||||
return [
|
||||
{ label: "上传人物图", text: "我上传人物参考图", action: "upload" },
|
||||
{ label: "由你设定角色", text: "你先帮我设定一个合适的角色" },
|
||||
{ label: "不需要人物", text: "这条先不需要人物出镜" },
|
||||
];
|
||||
}
|
||||
if (/场景|地点|背景|在哪/i.test(text)) {
|
||||
if (/(?:哪里|哪儿|什么|选择|选|换|更换|调整|修改|改).{0,12}(?:场景|地点|背景)|(?:场景|地点|背景).{0,12}(?:哪里|哪儿|选择|选|换|更换|调整|修改|改)/i.test(guidance)) {
|
||||
return [
|
||||
{ label: "上传场景图", text: "我上传场景参考图", action: "upload" },
|
||||
{ label: "你来推荐场景", text: "你按商品和预设推荐场景" },
|
||||
{ label: "用干净日常场景", text: "先用干净自然的日常场景" },
|
||||
];
|
||||
}
|
||||
if (/卖点|功能|效果|优惠|价格/i.test(text)) {
|
||||
if (/(?:补充|选择|选|换|更换|调整|修改|改|突出|强调).{0,12}(?:卖点|功能|效果|优惠|价格)|(?:卖点|功能|效果|优惠|价格).{0,12}(?:补充|选择|选|换|更换|调整|修改|改|突出|强调)/i.test(guidance)) {
|
||||
return [
|
||||
{ label: "补充真实卖点", text: "我来补充商品真实卖点" },
|
||||
{ label: "从素材里判断", text: "先根据我上传的素材判断可表达的卖点" },
|
||||
@@ -745,6 +774,59 @@ function strategyField(payload: Record<string, unknown>, ...keys: string[]): str
|
||||
return "";
|
||||
}
|
||||
|
||||
const STRATEGY_TEXT_SECTION_ALIASES: Record<string, "target" | "trust" | "belief" | "direction"> = {
|
||||
目标受众: "target",
|
||||
目标人群: "target",
|
||||
这条视频给谁看: "target",
|
||||
给谁看: "target",
|
||||
用户为什么相信: "trust",
|
||||
为什么相信: "trust",
|
||||
内容逻辑: "trust",
|
||||
可信依据: "trust",
|
||||
信任依据: "trust",
|
||||
希望用户相信什么: "belief",
|
||||
希望相信: "belief",
|
||||
核心卖点: "belief",
|
||||
核心主张: "belief",
|
||||
创作方向: "direction",
|
||||
视觉调性: "direction",
|
||||
视觉风格: "direction",
|
||||
表达方向: "direction",
|
||||
};
|
||||
|
||||
/** 兼容历史上漏调 write_strategy、已经落库成普通文字的策略消息。 */
|
||||
function strategyPayloadFromText(text: string): Record<string, string> | null {
|
||||
const sections: Record<"target" | "trust" | "belief" | "direction", string[]> = {
|
||||
target: [], trust: [], belief: [], direction: [],
|
||||
};
|
||||
let current: keyof typeof sections | "" = "";
|
||||
for (const rawLine of String(text || "").split("\n")) {
|
||||
const line = rawLine.replace(/^\s*(?:[-*#>]+\s*)?/, "").replace(/\*\*/g, "").trim();
|
||||
if (!line) continue;
|
||||
const match = line.match(/^([^::\n]{2,36})\s*[::]\s*(.*)$/);
|
||||
if (match) {
|
||||
const heading = match[1].replace(/[((].*$/, "").replace(/\s+/g, "").trim();
|
||||
const key = STRATEGY_TEXT_SECTION_ALIASES[heading];
|
||||
if (key) {
|
||||
current = key;
|
||||
const content = match[2].trim();
|
||||
if (content) sections[key].push(content);
|
||||
continue;
|
||||
}
|
||||
if (["创作策略", "策略理解", "创作策略理解"].includes(heading)) {
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!current || /^(?:你看|请确认|如果你|是否需要|可以再)/.test(line)) continue;
|
||||
sections[current].push(line);
|
||||
}
|
||||
const payload = Object.fromEntries(
|
||||
Object.entries(sections).map(([key, lines]) => [key, lines.join("\n").trim()]),
|
||||
) as Record<string, string>;
|
||||
return Object.values(payload).every(Boolean) ? payload : null;
|
||||
}
|
||||
|
||||
function StrategyCard({ payload }: { payload: Record<string, unknown> }) {
|
||||
const items: Array<[string, string]> = [
|
||||
["这条视频给谁看", strategyField(payload, "target", "audience", "who", "给谁看", "目标人群")],
|
||||
@@ -1043,6 +1125,7 @@ function ElicitCard({
|
||||
disabled,
|
||||
onSubmit,
|
||||
onChatAnswer,
|
||||
onPersonSourceAction,
|
||||
}: {
|
||||
message: CreationMessage;
|
||||
disabled: boolean;
|
||||
@@ -1051,6 +1134,8 @@ function ElicitCard({
|
||||
onSubmit: (answers: Record<string, string | string[]>, refs: CreationRef[]) => void;
|
||||
/** 普通追问直接在气泡下回答,仍走 text 链路以保留用户消息与上下文。 */
|
||||
onChatAnswer: (text: string) => void;
|
||||
/** 人物来源三选一要分别打开系统文件、模特库和平台生成流程。 */
|
||||
onPersonSourceAction: (source: "local_upload" | "model_library" | "platform_generate") => void;
|
||||
}) {
|
||||
const fields = ((message.payload.fields as CreationField[] | undefined) || []).filter(Boolean);
|
||||
const submitted = Boolean(message.payload.submitted);
|
||||
@@ -1105,6 +1190,51 @@ function ElicitCard({
|
||||
);
|
||||
}
|
||||
|
||||
if (interaction === "person_source_gate") {
|
||||
const selected = String(saved.person_source || "");
|
||||
const selectedLabel = fields[0]?.options?.find((option) => option.value === selected)?.label;
|
||||
return (
|
||||
<div className="omni-chat-row agent">
|
||||
<span className="omni-chat-avatar">
|
||||
<Sparkles />
|
||||
</span>
|
||||
<div className="omni-chat-bubble omni-gate-bubble">
|
||||
<ChatMarkdown text={message.text || "先选定出镜人物。"} />
|
||||
{!submitted ? (
|
||||
<div className="omni-gate-actions" aria-label="人物来源选择">
|
||||
<button
|
||||
type="button"
|
||||
className="primary"
|
||||
disabled={disabled}
|
||||
onClick={() => onPersonSourceAction("local_upload")}
|
||||
>
|
||||
本地上传
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onPersonSourceAction("model_library")}
|
||||
>
|
||||
从模特库选择
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onPersonSourceAction("platform_generate")}
|
||||
>
|
||||
平台帮忙生成
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="omni-gate-answered">
|
||||
{selectedLabel ? `已选择:${selectedLabel}` : "人物来源已确定"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (interaction === "selling_point_gate") {
|
||||
const sellingPoint = typeof answers.selling_point === "string" ? answers.selling_point : "";
|
||||
const savedMode = String(saved.selling_point_mode || "");
|
||||
@@ -1314,6 +1444,12 @@ function ElicitCard({
|
||||
|
||||
if (interaction === "chat") {
|
||||
const question = message.text || fields[0]?.label || "这项你想怎么定?";
|
||||
const choiceField = fields.find(
|
||||
(field) => field.type === "single" && Array.isArray(field.options) && field.options.length > 0,
|
||||
);
|
||||
const savedChoice = choiceField ? String(saved[choiceField.key] || "") : "";
|
||||
const savedChoiceLabel = choiceField?.options?.find((option) => option.value === savedChoice)?.label;
|
||||
const savedChoiceText = savedChoiceLabel || savedChoice;
|
||||
const submitChatAnswer = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const text = chatAnswer.trim();
|
||||
@@ -1332,6 +1468,20 @@ function ElicitCard({
|
||||
</div>
|
||||
{!submitted ? (
|
||||
<div className="omni-reply-guide" aria-label="回复操作">
|
||||
{choiceField ? (
|
||||
<div className="omni-gate-actions omni-chat-choice-actions" aria-label={choiceField.label}>
|
||||
{(choiceField.options || []).map((option) => (
|
||||
<button
|
||||
type="button"
|
||||
key={option.value}
|
||||
disabled={disabled}
|
||||
onClick={() => onSubmit({ [choiceField.key]: option.value }, [])}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<form className="omni-chat-question-input" onSubmit={submitChatAnswer}>
|
||||
<input
|
||||
className="input"
|
||||
@@ -1346,6 +1496,8 @@ function ElicitCard({
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
) : savedChoiceText ? (
|
||||
<p className="omni-gate-answered">已选择:{savedChoiceText}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1485,9 +1637,13 @@ function ConfirmCard({
|
||||
const payloadParams = asStringMap(message.payload.params);
|
||||
const snapshot = { ...sessionParams, ...payloadParams };
|
||||
const [draft, setDraft] = useState(snapshot);
|
||||
const [initialDuration] = useState(snapshot.duration || "");
|
||||
const cardIsVideo = message.payload.kind !== "image" && isVideo;
|
||||
const durationChanged =
|
||||
cardIsVideo && Boolean(draft.duration) && Boolean(snapshot.duration) && draft.duration !== snapshot.duration;
|
||||
cardIsVideo
|
||||
&& Boolean(draft.duration)
|
||||
&& Boolean(initialDuration)
|
||||
&& normalizeDurationValue(draft.duration) !== normalizeDurationValue(initialDuration);
|
||||
const durationSeconds = Number(String(draft.duration || "").replace(/\D/g, ""));
|
||||
const willGenerateInSegments = cardIsVideo && durationSeconds > 30 && durationSeconds <= 60;
|
||||
const setField = (key: string, value: string) => setDraft((prev) => ({ ...prev, [key]: value }));
|
||||
@@ -2055,6 +2211,11 @@ export function OmniSessionPage({
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
// 对话引导里的「上传人物图」要直达系统文件选择器,而不是绕回通用素材菜单。
|
||||
const quickUploadReplyRef = useRef<ReplyOption | null>(null);
|
||||
// 人物来源闸门借用全局文件选择器 / @素材面板,选完后需回到对应卡片提交。
|
||||
const personSourceRequestRef = useRef<{
|
||||
messageId: string;
|
||||
source: "local_upload" | "model_library";
|
||||
} | null>(null);
|
||||
const composerRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [liveText, setLiveText] = useState("");
|
||||
@@ -2716,6 +2877,36 @@ export function OmniSessionPage({
|
||||
}, [mentionMenuOpen]);
|
||||
|
||||
const insertMention = (ref: CreationRef) => {
|
||||
const personRequest = personSourceRequestRef.current;
|
||||
if (personRequest) {
|
||||
if (ref.type !== "model" && ref.type !== "character") {
|
||||
notify("info", "请选择一位模特或角色");
|
||||
return;
|
||||
}
|
||||
personSourceRequestRef.current = null;
|
||||
setMentionMenuOpen(false);
|
||||
setMessages((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === personRequest.messageId
|
||||
? {
|
||||
...item,
|
||||
payload: {
|
||||
...item.payload,
|
||||
submitted: true,
|
||||
answers: { person_source: "model_library" },
|
||||
},
|
||||
}
|
||||
: item
|
||||
)
|
||||
);
|
||||
void send({
|
||||
kind: "elicit_answer",
|
||||
reply_to: personRequest.messageId,
|
||||
answers: { person_source: "model_library" },
|
||||
refs: [ref],
|
||||
});
|
||||
return;
|
||||
}
|
||||
// 整条 Ref 存起来一起发:只把名字拼进文本的话,后端取不到卖点和参考图
|
||||
if (pendingRefs.some((r) => r.id === ref.id)) {
|
||||
setMentionMenuOpen(false);
|
||||
@@ -2782,6 +2973,41 @@ export function OmniSessionPage({
|
||||
void send({ kind: "elicit_answer", reply_to: message.id, answers, refs });
|
||||
}}
|
||||
onChatAnswer={(text) => void send({ kind: "text", text })}
|
||||
onPersonSourceAction={(source) => {
|
||||
if (source === "local_upload") {
|
||||
personSourceRequestRef.current = { messageId: message.id, source };
|
||||
quickUploadReplyRef.current = null;
|
||||
setMentionMenuOpen(false);
|
||||
setUploadMenuOpen(false);
|
||||
fileInputRef.current?.click();
|
||||
return;
|
||||
}
|
||||
if (source === "model_library") {
|
||||
personSourceRequestRef.current = { messageId: message.id, source };
|
||||
void openMentions("", "model");
|
||||
return;
|
||||
}
|
||||
setMessages((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === message.id
|
||||
? {
|
||||
...item,
|
||||
payload: {
|
||||
...item.payload,
|
||||
submitted: true,
|
||||
answers: { person_source: source },
|
||||
},
|
||||
}
|
||||
: item
|
||||
)
|
||||
);
|
||||
void send({
|
||||
kind: "elicit_answer",
|
||||
reply_to: message.id,
|
||||
answers: { person_source: source },
|
||||
refs: [],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case "strategy":
|
||||
@@ -2844,6 +3070,12 @@ export function OmniSessionPage({
|
||||
const rawBody = stripMentionText(message.text, refs);
|
||||
const body = message.role === "assistant" ? stripNumericReplyInstruction(rawBody) : rawBody;
|
||||
const pending = message.id === pendingUserId;
|
||||
const legacyStrategy = message.role === "assistant" && message.kind === "text"
|
||||
? strategyPayloadFromText(body)
|
||||
: null;
|
||||
if (legacyStrategy) {
|
||||
return <StrategyCard key={message.clientKey || message.id} payload={legacyStrategy} />;
|
||||
}
|
||||
const showReplyGuide =
|
||||
message.role === "assistant"
|
||||
&& message.kind === "text"
|
||||
@@ -2875,6 +3107,7 @@ export function OmniSessionPage({
|
||||
onSubmit={(text) => void send({ kind: "text", text })}
|
||||
onUpload={(option) => {
|
||||
if (streaming || uploading) return;
|
||||
personSourceRequestRef.current = null;
|
||||
quickUploadReplyRef.current = option;
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
@@ -3029,6 +3262,7 @@ export function OmniSessionPage({
|
||||
disabled={uploading || streaming}
|
||||
onClick={() => {
|
||||
if (uploading || streaming) return;
|
||||
personSourceRequestRef.current = null;
|
||||
quickUploadReplyRef.current = null;
|
||||
setUploadMenuOpen((open) => !open);
|
||||
setMentionMenuOpen(false);
|
||||
@@ -3042,6 +3276,7 @@ export function OmniSessionPage({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
personSourceRequestRef.current = null;
|
||||
setUploadMenuOpen(false);
|
||||
void openMentions();
|
||||
}}
|
||||
@@ -3054,6 +3289,7 @@ export function OmniSessionPage({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
personSourceRequestRef.current = null;
|
||||
quickUploadReplyRef.current = null;
|
||||
setUploadMenuOpen(false);
|
||||
fileInputRef.current?.click();
|
||||
@@ -3075,9 +3311,16 @@ export function OmniSessionPage({
|
||||
const files = Array.from(event.target.files || []);
|
||||
event.target.value = "";
|
||||
if (!files.length) return;
|
||||
const images = files.filter((file) => file.type.startsWith("image/"));
|
||||
const personRequest = personSourceRequestRef.current;
|
||||
const selectedImages = files.filter((file) => file.type.startsWith("image/"));
|
||||
const images = personRequest ? selectedImages.slice(0, 1) : selectedImages;
|
||||
if (images.length !== files.length) {
|
||||
notify("info", "全能创作仅支持上传图片");
|
||||
notify(
|
||||
"info",
|
||||
personRequest && selectedImages.length > 1
|
||||
? "人物参考每次选择一张图片"
|
||||
: "全能创作仅支持上传图片",
|
||||
);
|
||||
}
|
||||
if (!images.length) return;
|
||||
setUploading(true);
|
||||
@@ -3090,28 +3333,55 @@ export function OmniSessionPage({
|
||||
// 后端上传时同步送审并等待通过,期间按钮保持「上传中」
|
||||
const data = await api.uploadFreeVideoRef(form);
|
||||
const ref: CreationRef = {
|
||||
type: "asset",
|
||||
// 人物闸门上传的图是身份参考,不能作为普通 asset 传给出片模型。
|
||||
type: personRequest ? "character" : "asset",
|
||||
id: data.asset_id,
|
||||
name: data.name || file.name,
|
||||
cover: data.thumb_url || data.url,
|
||||
};
|
||||
setSessionUploads((prev) => (prev.some((item) => item.id === ref.id) ? prev : [...prev, ref]));
|
||||
uploadedRefs.push(ref);
|
||||
if (!quickReply) {
|
||||
if (!quickReply && !personRequest) {
|
||||
setPendingRefs((prev) => {
|
||||
if (prev.some((item) => item.id === ref.id) || prev.length >= MENTION_REF_LIMIT) return prev;
|
||||
return [...prev, ref];
|
||||
});
|
||||
}
|
||||
}
|
||||
notify("success", images.length > 1 ? "素材已上传并通过审核" : "素材已上传并通过审核");
|
||||
if (quickReply && uploadedRefs.length) {
|
||||
notify(
|
||||
"success",
|
||||
personRequest ? "人物参考已上传并锁定" : "素材已上传并通过审核",
|
||||
);
|
||||
if (personRequest && uploadedRefs.length) {
|
||||
personSourceRequestRef.current = null;
|
||||
setMessages((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === personRequest.messageId
|
||||
? {
|
||||
...item,
|
||||
payload: {
|
||||
...item.payload,
|
||||
submitted: true,
|
||||
answers: { person_source: "local_upload" },
|
||||
},
|
||||
}
|
||||
: item
|
||||
)
|
||||
);
|
||||
void send({
|
||||
kind: "elicit_answer",
|
||||
reply_to: personRequest.messageId,
|
||||
answers: { person_source: "local_upload" },
|
||||
refs: uploadedRefs,
|
||||
});
|
||||
} else if (quickReply && uploadedRefs.length) {
|
||||
quickUploadReplyRef.current = null;
|
||||
void send({ kind: "text", text: uploadReplyText(quickReply), refs: uploadedRefs });
|
||||
}
|
||||
} catch (error) {
|
||||
notify("error", (error as Error).message);
|
||||
quickUploadReplyRef.current = null;
|
||||
personSourceRequestRef.current = null;
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
@@ -3124,6 +3394,7 @@ export function OmniSessionPage({
|
||||
className="omni-icon-tool"
|
||||
aria-label="引用素材"
|
||||
onClick={() => {
|
||||
personSourceRequestRef.current = null;
|
||||
if (mentionMenuOpen) setMentionMenuOpen(false);
|
||||
else void openMentions("", mentionTab);
|
||||
}}
|
||||
|
||||
@@ -118,6 +118,8 @@ export type AdminReviewAsset = {
|
||||
export type AdminTask = {
|
||||
id: string;
|
||||
task_type: string;
|
||||
/** 平台后台展示来源:全能创作任务沿用底层 task_type 调度,不与自由创作混在一类。 */
|
||||
task_category?: "omni_create" | "standard";
|
||||
status: string;
|
||||
team: string;
|
||||
team_name: string | null;
|
||||
|
||||
Reference in New Issue
Block a user