解决已发现问题

This commit is contained in:
Azmat@qq.com
2026-09-17 15:00:15 +08:00
parent f3c49b36be
commit 5df038c635
24 changed files with 1913 additions and 94 deletions
+2 -1
View File
@@ -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
+23
View File
@@ -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;
+19 -3
View File
@@ -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>
+4 -3
View File
@@ -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;
+286 -15
View File
@@ -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);
}}
+2
View File
@@ -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;