优化已发现问题

This commit is contained in:
Azmat@qq.com
2026-09-18 14:51:57 +08:00
parent abccf4393a
commit b5d970a4b5
8 changed files with 1080 additions and 236 deletions
+222 -5
View File
@@ -360,13 +360,34 @@
.omni-result-card,
.omni-process-card {
width: min(760px, calc(100% - 44px));
box-sizing: border-box;
width: min(320px, calc(100% - 44px));
max-width: 320px;
margin: 0 0 24px 44px;
border: 1px solid rgba(34, 42, 54, .09);
border-radius: 15px;
background: #fff;
box-shadow: 0 10px 26px rgba(20, 27, 38, .06);
animation: omniMessageIn 220ms ease both;
overflow: hidden;
}
.omni-result-card.has-multiple,
.omni-process-card.has-multiple {
width: min(520px, calc(100% - 44px));
max-width: 520px;
}
.omni-result-card.has-single.is-ratio-16-9,
.omni-process-card.has-single.is-ratio-16-9 {
width: min(480px, calc(100% - 44px));
max-width: 480px;
}
.omni-result-card.has-single.is-ratio-1-1,
.omni-process-card.has-single.is-ratio-1-1 {
width: min(340px, calc(100% - 44px));
max-width: 340px;
}
.omni-strategy-card,
@@ -1167,7 +1188,10 @@
}
.omni-process-frame {
height: 310px;
width: 100%;
height: 100%;
aspect-ratio: 9 / 16;
min-height: 200px;
display: flex;
flex-direction: column;
align-items: center;
@@ -1178,6 +1202,14 @@
animation: omniShimmer 1.2s ease infinite;
}
.is-ratio-16-9 .omni-process-frame {
aspect-ratio: 16 / 9;
}
.is-ratio-1-1 .omni-process-frame {
aspect-ratio: 1 / 1;
}
.omni-process-frame strong {
color: #5c6570;
font-size: 14px;
@@ -1211,6 +1243,8 @@
.omni-result-media.is-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
padding: 10px;
}
.omni-result-tile {
@@ -1220,11 +1254,22 @@
border-radius: var(--r-md, 12px);
background: #edf0f4;
box-shadow: 0 8px 20px rgba(20, 27, 38, .08);
aspect-ratio: 9 / 16;
width: 100%;
}
.is-ratio-16-9 .omni-result-tile {
aspect-ratio: 16 / 9;
}
.is-ratio-1-1 .omni-result-tile {
aspect-ratio: 1 / 1;
}
.omni-result-preview {
display: block;
width: 100%;
height: 100%;
padding: 0;
border: 0;
background: transparent;
@@ -1233,18 +1278,19 @@
.omni-result-tile img {
width: 100%;
height: 310px;
height: 100%;
aspect-ratio: inherit;
display: block;
object-fit: cover;
background: #edf0f4;
}
.omni-result-media.is-grid .omni-result-tile img {
height: 220px;
height: 100%;
}
.omni-result-media.is-grid .omni-process-frame {
height: 220px;
height: 100%;
}
.omni-result-play {
@@ -1731,14 +1777,36 @@
background: var(--klein);
color: #fff;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 160ms ease;
}
.omni-elicit-actions button.cancel-btn {
background: var(--surface, #fff);
color: var(--text-secondary, #606a78);
border: 1px solid var(--border-faint, #e2e8f0);
}
.omni-elicit-actions button.cancel-btn:hover:not(:disabled) {
background: var(--background-hover, #f8fafc);
color: var(--accent-black, #1a202c);
border-color: var(--border-subtle, #cbd5e1);
}
.omni-elicit-actions button:disabled {
background: rgba(34, 42, 54, .18);
color: rgba(255, 255, 255, .7);
cursor: not-allowed;
}
.omni-elicit-actions button.cancel-btn:disabled {
background: var(--surface, #fff);
color: var(--text-tertiary, #94a3b8);
border-color: var(--border-faint, #e2e8f0);
opacity: 0.6;
}
/* 已提交的追问卡变成只读回顾,不能再改 —— 重复提交后端会 409 */
.omni-elicit-card.is-submitted .omni-elicit-options button,
.omni-elicit-card.is-submitted .omni-elicit-assets button {
@@ -2703,6 +2771,47 @@
cursor: not-allowed;
}
.omni-person-generate-panel {
display: grid;
width: 100%;
gap: 8px;
margin-top: 4px;
padding: 12px;
border-radius: var(--r-md);
background: var(--background-lighter);
box-shadow: inset 0 0 0 1px var(--border-faint);
}
.omni-person-generate-panel label {
color: var(--accent-black);
font-size: 12px;
font-weight: 600;
}
.omni-person-generate-panel .textarea {
width: 100%;
min-height: 88px;
resize: vertical;
}
.omni-person-generate-panel > div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.omni-person-generate-panel > div > span {
flex: 1 1 auto;
color: var(--black-alpha-48);
font-size: 11px;
line-height: 1.6;
}
.omni-person-generate-panel > div > button {
flex: 0 0 auto;
}
/* 单选追问直接点选即提交;长方向文案纵向铺开,避免挤成难扫读的小胶囊。 */
.omni-chat-choice-actions {
width: 100%;
@@ -3079,6 +3188,105 @@
font-size: 11px;
}
.omni-model-picker-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.omni-model-picker-card {
display: flex;
min-width: 0;
flex-direction: column;
gap: 8px;
padding: 8px;
border: 0;
border-radius: var(--r-md);
background: var(--surface);
color: var(--accent-black);
box-shadow: inset 0 0 0 1px var(--border-faint);
text-align: left;
cursor: pointer;
transition: background var(--t-base), box-shadow var(--t-base);
}
.omni-model-picker-card:hover,
.omni-model-picker-card.is-selected {
background: var(--background-lighter);
box-shadow: inset 0 0 0 1px var(--heat-40);
}
.omni-model-picker-card.is-selected {
color: var(--heat);
}
.omni-model-picker-media {
display: grid;
width: 100%;
aspect-ratio: 3 / 4;
place-items: center;
overflow: hidden;
border-radius: 6px;
background: var(--background-base);
color: var(--black-alpha-48);
}
.omni-model-picker-media img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.omni-model-picker-media svg {
width: 24px;
height: 24px;
}
.omni-model-picker-copy {
display: block;
min-width: 0;
padding: 0 2px 2px;
}
.omni-model-picker-copy strong,
.omni-model-picker-copy small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.omni-model-picker-copy strong {
font-size: 13px;
font-weight: 600;
}
.omni-model-picker-copy small {
margin-top: 2px;
color: var(--black-alpha-48);
font-family: var(--font-mono);
font-size: 10px;
}
.omni-model-picker-footer {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
}
.omni-model-picker-selection {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
color: var(--black-alpha-48);
font-family: var(--font-mono);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (min-width: 1101px) {
.omni-session-page.has-assets-panel {
box-sizing: border-box;
@@ -3130,4 +3338,13 @@
.omni-assets-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.omni-person-generate-panel > div {
align-items: stretch;
flex-direction: column;
}
.omni-person-generate-panel > div > button {
width: 100%;
}
}
+396 -176
View File
@@ -38,6 +38,7 @@ import type {
CreationMessage,
CreationRef,
ModelConfig,
ModelEntity,
} from "../types";
import type { NavigateFn } from "./route-config";
@@ -219,6 +220,14 @@ function contextualReplyOptions(text: string): ReplyOption[] {
{ label: "你来推荐", text: "你根据当前需求推荐一款" },
];
}
// 角色/人物图已生成时,询问用户是否使用该角色,不再提供「由你设定角色/上传人物图/不需要人物」
if (/是否使用这[个位]角色|角色.{0,8}(?:已生成|合适吗)|你看这[个位]角色|人物参考已生成/i.test(guidance)) {
return [
{ label: "使用这个角色", text: "使用这个角色继续创作" },
{ label: "重新生成一个", text: "重新生成一个角色" },
{ label: "上传其他人物", text: "我上传人物参考图", action: "upload" },
];
}
if (/(?:哪位|哪个|什么|选择|选|换|更换|调整|修改|改).{0,12}(?:人物|角色|模特|出镜)|(?:人物|角色|模特|出镜).{0,12}(?:哪位|哪个|选择|选|换|更换|调整|修改|改)/i.test(guidance)) {
return [
{ label: "上传人物图", text: "我上传人物参考图", action: "upload" },
@@ -1143,6 +1152,7 @@ function ElicitCard({
onSubmit,
onChatAnswer,
onPersonSourceAction,
onUpload,
}: {
message: CreationMessage;
disabled: boolean;
@@ -1152,7 +1162,11 @@ function ElicitCard({
/** 普通追问直接在气泡下回答,仍走 text 链路以保留用户消息与上下文。 */
onChatAnswer: (text: string) => void;
/** 人物来源三选一要分别打开系统文件、模特库和平台生成流程。 */
onPersonSourceAction: (source: "local_upload" | "model_library" | "platform_generate") => void;
onPersonSourceAction: (
source: "local_upload" | "model_library" | "platform_generate",
personPrompt?: string,
) => void;
onUpload?: () => void;
}) {
const fields = ((message.payload.fields as CreationField[] | undefined) || []).filter(Boolean);
const submitted = Boolean(message.payload.submitted);
@@ -1164,6 +1178,8 @@ function ElicitCard({
const [assetPicked, setAssetPicked] = useState<Record<string, CreationRef>>({});
const [customDirection, setCustomDirection] = useState("");
const [chatAnswer, setChatAnswer] = useState("");
const [personPromptOpen, setPersonPromptOpen] = useState(false);
const [personPrompt, setPersonPrompt] = useState("");
useEffect(() => {
if (submitted || interaction === "chat" || interaction === "step_confirm") return;
@@ -1210,15 +1226,21 @@ function ElicitCard({
if (interaction === "person_source_gate") {
const selected = String(saved.person_source || "");
const selectedLabel = fields[0]?.options?.find((option) => option.value === selected)?.label;
const isPet = Boolean(
message.payload?.is_pet ||
message.text?.includes("宠物")
);
const libraryOption = fields[0]?.options?.find((option) => option.value === "model_library");
const libraryLabel = libraryOption?.label || (isPet ? "从角色库选择" : "从模特库选择");
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 || "先选定出镜人物。"} />
<ChatMarkdown text={message.text || (isPet ? "先选定出镜宠物角色。" : "先选定出镜人物。")} />
{!submitted ? (
<div className="omni-gate-actions" aria-label="人物来源选择">
<div className="omni-gate-actions" aria-label={isPet ? "宠物角色来源选择" : "人物来源选择"}>
<button
type="button"
className="primary"
@@ -1232,19 +1254,55 @@ function ElicitCard({
disabled={disabled}
onClick={() => onPersonSourceAction("model_library")}
>
{libraryLabel}
</button>
<button
type="button"
disabled={disabled}
onClick={() => onPersonSourceAction("platform_generate")}
aria-expanded={personPromptOpen}
onClick={() => setPersonPromptOpen((open) => !open)}
>
</button>
{personPromptOpen ? (
<div className="omni-person-generate-panel">
<label htmlFor={`person-prompt-${message.id}`}>
{isPet ? "描述想要的宠物外观或品种(可选)" : "描述想要的角色外观(可选)"}
</label>
<textarea
id={`person-prompt-${message.id}`}
className="textarea"
value={personPrompt}
disabled={disabled}
maxLength={500}
placeholder={
isPet
? "例如:呆萌可爱的金毛幼犬,毛发蓬松干净,系着小红领巾,眼神灵动"
: "例如:25岁左右女性,短发,干练通勤风,表情自然"
}
onChange={(event) => setPersonPrompt(event.target.value)}
/>
<div>
<span>
{isPet
? "不填写也可以,平台会结合当前商品和拟人主题自动设计萌宠角色。"
: "不填写也可以,平台会结合当前商品和创作主题自动设计角色。"}
</span>
<button
type="button"
className="primary"
disabled={disabled}
onClick={() => onPersonSourceAction("platform_generate", personPrompt.trim())}
>
{isPet ? "开始生成宠物角色" : "开始生成角色"}
</button>
</div>
</div>
) : null}
</div>
) : (
<p className="omni-gate-answered">
{selectedLabel ? `已选择:${selectedLabel}` : "人物来源已确定"}
{selectedLabel ? `已选择:${selectedLabel}` : isPet ? "宠物角色已确定" : "人物来源已确定"}
</p>
)}
</div>
@@ -1441,6 +1499,10 @@ function ElicitCard({
className={index === 0 ? "primary" : ""}
disabled={disabled}
onClick={() => {
if (option.value === "upload" || /上传/.test(option.label)) {
onUpload?.();
return;
}
if (!gateField?.key) return;
onSubmit({ [gateField.key]: option.value }, []);
}}
@@ -1462,11 +1524,37 @@ function ElicitCard({
if (interaction === "chat") {
const question = message.text || fields[0]?.label || "这项你想怎么定?";
const compactChoiceOnly = String(message.payload.topic || "") === "cast_relation";
const choiceField = fields.find(
(field) => field.type === "single" && Array.isArray(field.options) && field.options.length > 0,
// 兼容多种 options 来源:单选、多选、或字段内直接携带 options
const rawChoiceField = fields.find(
(field) => Array.isArray(field.options) && field.options.length > 0,
);
// 如果是痛点方向追问、或文案明确提及「直接点击一个选项」,但在 fields 里未能取到 options,提供兜底选项
const isPainPointQuestion =
fields[0]?.key === "pain_point_direction"
|| /痛点方向|最想拍的痛点|解决的痛点/.test(question)
|| /直接点击一个选项/.test(question);
const fallbackOptions = isPainPointQuestion
? [
{ value: "dry_makeup", label: "换季皮肤干绷紧绷,上妆卡粉斑驳浮粉" },
{ value: "dull_rough", label: "熬夜后脸部粗糙,肤色暗沉蜡黄无光泽" },
{ value: "poor_absorption", label: "护肤浮在表面吸收慢,黏腻厚重不透气" },
]
: [];
const choiceField = rawChoiceField || (
fallbackOptions.length > 0
? {
key: fields[0]?.key || "pain_point_direction",
label: question,
type: "single" as const,
options: fallbackOptions,
}
: null
);
const savedChoice = choiceField ? String(saved[choiceField.key] || "") : "";
const savedChoiceLabel = choiceField?.options?.find((option) => option.value === savedChoice)?.label;
const savedChoiceLabel = (choiceField?.options as any[])?.find((option: any) => {
const val = typeof option === "string" ? option : option?.value;
return val === savedChoice;
})?.label || (typeof choiceField?.options?.[0] === "object" ? "" : savedChoice);
const savedChoiceText = savedChoiceLabel || savedChoice;
const submitChatAnswer = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
@@ -1491,16 +1579,21 @@ function ElicitCard({
className={`omni-gate-actions${compactChoiceOnly ? "" : " 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>
))}
{(choiceField.options || []).map((option: any, index: number) => {
const label = typeof option === "string" ? option : String(option?.label || option?.text || option?.title || option?.value || `选项 ${index + 1}`).trim();
const value = typeof option === "string" ? option : String(option?.value || option?.label || option?.text || option?.title || `option_${index + 1}`).trim();
if (!label) return null;
return (
<button
type="button"
key={value || index}
disabled={disabled}
onClick={() => onSubmit({ [choiceField.key || "choice"]: value }, [])}
>
{label}
</button>
);
})}
</div>
) : null}
{!compactChoiceOnly ? (
@@ -1532,7 +1625,7 @@ function ElicitCard({
<section className={`omni-elicit-card${submitted ? " is-submitted" : ""}`}>
<header className="omni-elicit-head">
<strong></strong>
<span>{submitted ? "已回答" : "选一下就好"}</span>
<span>{submitted ? (saved._action === "cancel" ? "已取消" : "已回答") : "选一下就好"}</span>
</header>
<div className="omni-elicit-body">
{fields.map((field) => {
@@ -1592,7 +1685,9 @@ function ElicitCard({
<span>{option.name}</span>
</button>
))}
{(assetOptions[field.key] || []).length === 0 && <span></span>}
{(assetOptions[field.key] || []).length === 0 && (
<span>{submitted && saved._action === "cancel" ? "已取消选择" : "暂无可选素材"}</span>
)}
</div>
)}
</div>
@@ -1601,6 +1696,17 @@ function ElicitCard({
</div>
{!submitted && (
<div className="omni-elicit-actions">
<button
type="button"
className="cancel-btn"
disabled={disabled}
onClick={() => {
const primaryKey = fields[0]?.key || "choice";
onSubmit({ _action: "cancel", [primaryKey]: "cancel" }, []);
}}
>
</button>
<button
type="button"
disabled={disabled || !complete}
@@ -1746,9 +1852,12 @@ function ResultCard({
const meta = [payload.model, payload.resolution, payload.ratio].filter(Boolean).join(" · ");
const isGeneratedVideo = first.type === "video";
const [preview, setPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null);
const ratio = String(payload.ratio || "").trim();
const ratioClass = ratio === "16:9" ? "is-ratio-16-9" : ratio === "1:1" ? "is-ratio-1-1" : "is-ratio-9-16";
const multiClass = assets.length > 1 ? "has-multiple" : "has-single";
return (
<section className="omni-result-card">
<section className={`omni-result-card ${multiClass} ${ratioClass}`}>
<div className={`omni-result-media${assets.length > 1 ? " is-grid" : ""}`}>
{assets.map((asset, index) => {
const cover = asset.cover || asset.url || "";
@@ -2114,9 +2223,14 @@ function ProcessCard({
const assets = (payload.assets as Array<Record<string, string>> | undefined) || [];
const completedSegments = Number(payload.completed_segment_count || 0);
const waitingSegments = isSegmentedVideo ? Math.max(1, segmentCount - completedSegments) : 1;
const totalTiles = assets.length + waitingSegments;
const multiClass = totalTiles > 1 || isSegmentedVideo ? "has-multiple" : "has-single";
const ratio = String(payload.ratio || "").trim();
const ratioClass = ratio === "16:9" ? "is-ratio-16-9" : ratio === "1:1" ? "is-ratio-1-1" : "is-ratio-9-16";
return (
<section className="omni-result-card omni-process-card">
<div className={`omni-result-media${isSegmentedVideo ? " is-grid" : ""}`}>
<section className={`omni-result-card omni-process-card ${multiClass} ${ratioClass}`}>
<div className={`omni-result-media${multiClass === "has-multiple" ? " is-grid" : ""}`}>
{assets.map((asset, index) => {
const cover = asset.cover || asset.url || "";
const url = asset.url || cover;
@@ -2236,6 +2350,7 @@ export function OmniSessionPage({
const [sessionUploads, setSessionUploads] = useState<CreationRef[]>(firstUploads || []);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const personFileInputRef = useRef<HTMLInputElement>(null);
// 对话引导里的「上传人物图」要直达系统文件选择器,而不是绕回通用素材菜单。
const quickUploadReplyRef = useRef<ReplyOption | null>(null);
// 人物来源闸门借用全局文件选择器 / @素材面板,选完后需回到对应卡片提交。
@@ -2262,8 +2377,12 @@ export function OmniSessionPage({
const [stopping, setStopping] = useState(false);
const [promptView, setPromptView] = useState<{ title: string; body: string } | null>(null);
const [assetsOpen, setAssetsOpen] = useState(false);
const [assetsPanelMode, setAssetsPanelMode] = useState<"session" | "model_library">("session");
const [assetView, setAssetView] = useState<"grid" | "list">("grid");
const [assetQuery, setAssetQuery] = useState("");
const [modelLibraryItems, setModelLibraryItems] = useState<ModelEntity[]>([]);
const [modelLibraryLoading, setModelLibraryLoading] = useState(false);
const [selectedModelRef, setSelectedModelRef] = useState<CreationRef | null>(null);
const [assetPreview, setAssetPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null);
const [pendingUserId, setPendingUserId] = useState<string | null>(() => bootLocalIdRef.current);
// App 传进来的 onNotify 是内联箭头,**每次 App 重渲染都是新身份**。
@@ -2285,6 +2404,26 @@ export function OmniSessionPage({
}
}, [notify]);
const openPersonModelLibrary = useCallback(async (messageId: string) => {
personSourceRequestRef.current = { messageId, source: "model_library" };
setMentionMenuOpen(false);
setUploadMenuOpen(false);
setSessionAssetModalOpen(false);
setAssetsPanelMode("model_library");
setAssetQuery("");
setSelectedModelRef(null);
setAssetsOpen(true);
setModelLibraryLoading(true);
try {
const response = await api.listModels({ pageSize: 200 });
setModelLibraryItems(response.results || []);
} catch (error) {
notify("error", (error as Error).message || "模特库加载失败");
} finally {
setModelLibraryLoading(false);
}
}, [notify]);
const editUserMessage = useCallback((message: CreationMessage) => {
const refs = message.refs || [];
setPrompt(message.text);
@@ -2465,6 +2604,13 @@ export function OmniSessionPage({
}))
.filter((group) => group.items.length > 0);
}, [assetQuery, sessionAssetGroups]);
const visibleModelLibraryItems = useMemo(() => {
const query = assetQuery.trim().toLocaleLowerCase();
if (!query) return modelLibraryItems;
return modelLibraryItems.filter((model) =>
`${model.name} ${model.description || ""}`.toLocaleLowerCase().includes(query)
);
}, [assetQuery, modelLibraryItems]);
messagesRef.current = messages;
// 出片在 worker 里跑。刷新后 GENERATING 还在库里,进来立刻拉一次再轮询,
@@ -2639,7 +2785,7 @@ export function OmniSessionPage({
const send = useCallback(
async (payload: Parameters<typeof api.creationSend>[1]) => {
// awaiting_user 下允许回答闸门;其它状态仍禁止并发发送
if (streamingRef.current && conversation?.agent_status !== "awaiting_user") return;
if (streamingRef.current && conversation?.agent_status !== "awaiting_user") return false;
// 先占位用户气泡,再亮「正在整理方案」—— 顺序不能反
const isTextTurn = payload.kind !== "elicit_answer";
let localId: string | null = null;
@@ -2711,7 +2857,7 @@ export function OmniSessionPage({
/* 已 idle / 竞态忽略 */
});
}
return;
return false;
}
setConversation((prev) =>
prev
@@ -2739,6 +2885,7 @@ export function OmniSessionPage({
.catch(() => {
/* agent poll 会接着拉 */
});
return true;
} catch (error) {
if (localId) {
setMessages((prev) => prev.filter((m) => m.id !== localId));
@@ -2754,11 +2901,37 @@ export function OmniSessionPage({
setActiveTool("");
const status = (error as { status?: number }).status;
notify(status === 409 ? "info" : "error", (error as Error).message);
return false;
}
},
[conversationId, conversation?.agent_status, mergeCreationDetail, notify]
);
const submitPersonSourceChoice = useCallback(async (
messageId: string,
source: "local_upload" | "model_library" | "platform_generate",
refs: CreationRef[] = [],
personPrompt = "",
) => {
const answers: Record<string, string> = { person_source: source };
if (source === "platform_generate") answers.person_prompt = personPrompt.trim();
const succeeded = await send({
kind: "elicit_answer",
reply_to: messageId,
answers,
refs,
});
if (!succeeded) return false;
setMessages((prev) =>
prev.map((item) =>
item.id === messageId
? { ...item, payload: { ...item.payload, submitted: true, answers } }
: item
)
);
return true;
}, [send]);
const handleStop = useCallback(async () => {
if (stopping || !isPlanning) return;
setStopping(true);
@@ -2917,31 +3090,13 @@ export function OmniSessionPage({
const insertMention = (ref: CreationRef) => {
const personRequest = personSourceRequestRef.current;
if (personRequest) {
if (ref.type !== "model" && ref.type !== "character") {
notify("info", "请选择一位模特或角色");
if (personRequest.source !== "model_library" || ref.type !== "model") {
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],
void submitPersonSourceChoice(personRequest.messageId, "model_library", [ref]).then((succeeded) => {
if (succeeded) personSourceRequestRef.current = null;
});
return;
}
@@ -3008,43 +3163,22 @@ export function OmniSessionPage({
void send({ kind: "elicit_answer", reply_to: message.id, answers, refs });
}}
onChatAnswer={(text) => void send({ kind: "text", text })}
onPersonSourceAction={(source) => {
onPersonSourceAction={(source, personPrompt = "") => {
if (source === "local_upload") {
personSourceRequestRef.current = { messageId: message.id, source };
quickUploadReplyRef.current = null;
setMentionMenuOpen(false);
setUploadMenuOpen(false);
fileInputRef.current?.click();
personFileInputRef.current?.click();
return;
}
if (source === "model_library") {
personSourceRequestRef.current = { messageId: message.id, source };
setMentionMenuOpen(false);
setSessionAssetModalType("character");
setSessionAssetModalOpen(true);
void openPersonModelLibrary(message.id);
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: [],
});
void submitPersonSourceChoice(message.id, source, [], personPrompt);
}}
onUpload={() => fileInputRef.current?.click()}
/>
);
case "strategy":
@@ -3222,7 +3356,11 @@ export function OmniSessionPage({
aria-expanded={assetsOpen}
aria-controls="omni-session-assets-drawer"
title="查看对话资源"
onClick={() => setAssetsOpen((open) => !open)}
onClick={() => {
setAssetsPanelMode("session");
setAssetQuery("");
setAssetsOpen((open) => !open || assetsPanelMode !== "session");
}}
>
<List />
<span></span>
@@ -3350,16 +3488,10 @@ export function OmniSessionPage({
const files = Array.from(event.target.files || []);
event.target.value = "";
if (!files.length) return;
const personRequest = personSourceRequestRef.current;
const selectedImages = files.filter((file) => file.type.startsWith("image/"));
const images = personRequest ? selectedImages.slice(0, 1) : selectedImages;
const images = selectedImages;
if (images.length !== files.length) {
notify(
"info",
personRequest && selectedImages.length > 1
? "人物参考每次选择一张图片"
: "全能创作仅支持上传图片",
);
notify("info", "全能创作仅支持上传图片");
}
if (!images.length) return;
setUploading(true);
@@ -3372,15 +3504,14 @@ export function OmniSessionPage({
// 后端上传时同步送审并等待通过,期间按钮保持「上传中」
const data = await api.uploadFreeVideoRef(form);
const ref: CreationRef = {
// 人物闸门上传的图是身份参考,不能作为普通 asset 传给出片模型。
type: personRequest ? "character" : "asset",
type: "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 && !personRequest) {
if (!quickReply) {
setPendingRefs((prev) => {
if (prev.some((item) => item.id === ref.id) || prev.length >= MENTION_REF_LIMIT) return prev;
return [...prev, ref];
@@ -3388,40 +3519,62 @@ export function OmniSessionPage({
insertPromptMention(ref);
}
}
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) {
notify("success", "素材已上传并通过审核");
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);
}
}}
/>
<input
ref={personFileInputRef}
type="file"
accept="image/*"
hidden
onChange={async (event) => {
const file = event.target.files?.[0];
event.target.value = "";
const personRequest = personSourceRequestRef.current;
if (!file || !personRequest || personRequest.source !== "local_upload") {
if (!file) personSourceRequestRef.current = null;
return;
}
const isPet = Boolean(conversation?.preset && /宠物/.test(conversation.preset));
if (!file.type.startsWith("image/")) {
notify("info", isPet ? "请选择一张宠物图片" : "请选择一张人物图片");
return;
}
setUploading(true);
try {
const form = new FormData();
form.append("file", file);
const data = await api.uploadFreeVideoRef(form);
const ref: CreationRef = {
type: "character",
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]
);
const succeeded = await submitPersonSourceChoice(
personRequest.messageId,
"local_upload",
[ref],
);
if (succeeded) {
personSourceRequestRef.current = null;
notify("success", isPet ? "宠物参考已上传并锁定" : "人物参考已上传并锁定");
}
} catch (error) {
notify("error", (error as Error).message || (isPet ? "宠物图片上传失败" : "人物图片上传失败"));
} finally {
setUploading(false);
}
@@ -3597,50 +3750,113 @@ export function OmniSessionPage({
id="omni-session-assets-drawer"
className="drawer omni-assets-drawer show"
role="dialog"
aria-label="对话资源"
aria-label={assetsPanelMode === "model_library" ? "选择模特" : "对话资源"}
>
<header className="drawer-h omni-assets-drawer-head">
<div>
<strong></strong>
<small>{sessionAssets.length ? `本会话 · ${sessionAssets.length}` : "仅展示本会话素材"}</small>
<strong>{assetsPanelMode === "model_library" ? "模特库" : "资源"}</strong>
<small>
{assetsPanelMode === "model_library"
? modelLibraryLoading
? "正在加载模特"
: `选择一位出镜人物 · ${modelLibraryItems.length}`
: sessionAssets.length
? `本会话 · ${sessionAssets.length}`
: "仅展示本会话素材"}
</small>
</div>
<div className="omni-assets-panel-tools">
<div className="view-toggle" aria-label="资源显示方式">
<button
type="button"
className={assetView === "grid" ? "active" : ""}
aria-label="网格显示"
title="网格显示"
onClick={() => setAssetView("grid")}
>
<Grid2X2 />
</button>
<button
type="button"
className={assetView === "list" ? "active" : ""}
aria-label="列表显示"
title="列表显示"
onClick={() => setAssetView("list")}
>
<List />
</button>
</div>
{assetsPanelMode === "session" ? (
<div className="view-toggle" aria-label="资源显示方式">
<button
type="button"
className={assetView === "grid" ? "active" : ""}
aria-label="网格显示"
title="网格显示"
onClick={() => setAssetView("grid")}
>
<Grid2X2 />
</button>
<button
type="button"
className={assetView === "list" ? "active" : ""}
aria-label="列表显示"
title="列表显示"
onClick={() => setAssetView("list")}
>
<List />
</button>
</div>
) : null}
<label className="omni-assets-search">
<Search />
<input
value={assetQuery}
onChange={(event) => setAssetQuery(event.target.value)}
placeholder="搜索资源…"
aria-label="搜索对话资源"
placeholder={assetsPanelMode === "model_library" ? "搜索模特…" : "搜索资源…"}
aria-label={assetsPanelMode === "model_library" ? "搜索模特" : "搜索对话资源"}
/>
</label>
<button type="button" className="x" onClick={() => setAssetsOpen(false)} aria-label="关闭资源">
<button
type="button"
className="x"
onClick={() => {
setAssetsOpen(false);
if (assetsPanelMode === "model_library") {
personSourceRequestRef.current = null;
setSelectedModelRef(null);
}
}}
aria-label={assetsPanelMode === "model_library" ? "关闭模特库" : "关闭资源"}
>
<X />
</button>
</div>
</header>
<div className="drawer-b omni-assets-drawer-body">
{sessionAssets.length === 0 ? (
{assetsPanelMode === "model_library" ? (
modelLibraryLoading ? (
<div className="omni-assets-empty" role="status">
<span className="omni-send-spinner" />
<p></p>
</div>
) : visibleModelLibraryItems.length === 0 ? (
<div className="omni-assets-empty" role="status">
<Search />
<p>{modelLibraryItems.length ? "没有找到相关模特" : "模特库还没有人物"}</p>
</div>
) : (
<div className="omni-model-picker-grid" role="listbox" aria-label="模特列表">
{visibleModelLibraryItems.map((model) => {
const ref: CreationRef = {
type: "model",
id: model.id,
name: model.name,
cover: model.portrait,
};
const selected = selectedModelRef?.id === model.id;
return (
<button
type="button"
role="option"
aria-selected={selected}
className={`omni-model-picker-card${selected ? " is-selected" : ""}`}
key={model.id}
onClick={() => setSelectedModelRef(ref)}
>
<span className="omni-model-picker-media">
{model.portrait ? <img src={model.portrait} alt="" /> : <UserRound />}
</span>
<span className="omni-model-picker-copy">
<strong>{model.name}</strong>
<small>{model.is_official ? "官方模特" : "我的模特"}</small>
</span>
</button>
);
})}
</div>
)
) : sessionAssets.length === 0 ? (
<div className="omni-assets-empty" role="status">
<FolderOpen />
<p></p>
@@ -3721,6 +3937,44 @@ export function OmniSessionPage({
))
)}
</div>
{assetsPanelMode === "model_library" ? (
<footer className="drawer-f omni-model-picker-footer">
<span className="omni-model-picker-selection">
{selectedModelRef ? `已选择:${selectedModelRef.name}` : "请选择一位模特"}
</span>
<button
type="button"
className="btn"
onClick={() => {
setAssetsOpen(false);
personSourceRequestRef.current = null;
setSelectedModelRef(null);
}}
>
</button>
<button
type="button"
className="btn btn-primary"
disabled={!selectedModelRef || streaming}
onClick={async () => {
const request = personSourceRequestRef.current;
if (!request || !selectedModelRef) return;
const succeeded = await submitPersonSourceChoice(
request.messageId,
"model_library",
[selectedModelRef],
);
if (!succeeded) return;
personSourceRequestRef.current = null;
setAssetsOpen(false);
setSelectedModelRef(null);
}}
>
使
</button>
</footer>
) : null}
</aside>
) : null}
<MediaLightbox
@@ -3733,42 +3987,8 @@ export function OmniSessionPage({
<AssetSelectModal
open={sessionAssetModalOpen}
type={sessionAssetModalType}
onClose={() => {
setSessionAssetModalOpen(false);
if (personSourceRequestRef.current?.source === "model_library") {
personSourceRequestRef.current = null;
}
}}
onClose={() => setSessionAssetModalOpen(false)}
onSelect={(newRef) => {
const personRequest = personSourceRequestRef.current;
if (personRequest) {
if (newRef.type !== "model" && newRef.type !== "character") {
notify("info", "请选择一位角色");
return;
}
personSourceRequestRef.current = null;
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: [newRef],
});
return;
}
setPendingRefs((prev) => {
if (prev.some((item) => item.type === newRef.type && item.id === newRef.id) || prev.length >= MENTION_REF_LIMIT) {
return prev;