添加艾特角色功能

This commit is contained in:
Azmat@qq.com
2026-09-17 18:31:29 +08:00
parent 5df038c635
commit abccf4393a
18 changed files with 2769 additions and 182 deletions
+302 -69
View File
@@ -21,7 +21,13 @@ import {
import { api, ApiError } from "../api";
import { findCatalogModel } from "../components/omni-param-bar";
import { modelResolutions } from "../components/free-create/constants";
import { ConfirmModal } from "../components/overlays";
import { ConfirmModal, MediaLightbox } from "../components/overlays";
import { AssetSelectModal, type AssetModalType } from "../components/asset-select-modal";
import {
getMentionFreeText,
RichMentionEditor,
type RichMentionEditorHandle,
} from "../components/rich-mention-editor";
import type { CreationConversation, CreationRef, ModelConfig } from "../types";
import type { NavigateFn } from "./route-config";
@@ -62,7 +68,8 @@ const IMAGE_PRESETS: PresetItem[] = [
{ name: "新中式", category: "style", mode: "image", title: "新中式", cover: "/assets/image-presets/neo-chinese.png" },
];
const MENTION_REF_LIMIT = 5;
const MENTION_REF_LIMIT = 20;
const ROLE_REF_LIMIT = 3;
const MENTION_TABS: Array<{ type: CreationRef["type"]; label: string; Icon: typeof ImageIcon }> = [
{ type: "asset", label: "素材", Icon: ImageIcon },
@@ -119,7 +126,11 @@ export function OmniCreatePage({
const [typeLabels, setTypeLabels] = useState<Record<string, string>>({});
const [pendingRefs, setPendingRefs] = useState<CreationRef[]>([]);
const [previewCase, setPreviewCase] = useState<PresetItem | null>(null);
const [assetPreview, setAssetPreview] = useState<{ src: string; name: string } | null>(null);
const [assetModalOpen, setAssetModalOpen] = useState(false);
const [assetModalType, setAssetModalType] = useState<AssetModalType>("product");
const fileInputRef = useRef<HTMLInputElement>(null);
const promptRef = useRef<RichMentionEditorHandle>(null);
const toolsRef = useRef<HTMLDivElement>(null);
const [starting, setStarting] = useState(false);
@@ -156,6 +167,28 @@ export function OmniCreatePage({
const presets = useMemo(() => {
return outputMode === "video" ? VIDEO_PRESETS : IMAGE_PRESETS;
}, [outputMode]);
const productRefs = useMemo(
() => pendingRefs.filter((ref) => ref.type === "product"),
[pendingRefs],
);
const roleRefs = useMemo(
() => pendingRefs.filter((ref) => ref.type === "character" || ref.type === "model"),
[pendingRefs],
);
const materialRefs = useMemo(
() => pendingRefs.filter((ref) => ref.type === "asset" || ref.type === "scene"),
[pendingRefs],
);
const hasPromptDescription = useMemo(
() => Boolean(getMentionFreeText(prompt, pendingRefs)),
[pendingRefs, prompt],
);
const visibleSlotCount =
(productRefs.length || 1)
+ roleRefs.length
+ (roleRefs.length < ROLE_REF_LIMIT ? 1 : 0)
+ materialRefs.length;
const slotColumnCount = Math.min(Math.max(visibleSlotCount, 1), 4);
const applyPreset = (preset: PresetItem) => {
setSelectedCase(preset);
@@ -181,21 +214,53 @@ export function OmniCreatePage({
}
};
const insertMention = (ref: CreationRef) => {
if (pendingRefs.some((r) => r.id === ref.id)) {
setMentionMenuOpen(false);
return;
const insertVisibleMention = (ref: CreationRef, replaceTrigger: boolean) => {
const name = ref.name.split(" · ")[0].trim();
const tag = `@${name} `;
if (promptRef.current) {
promptRef.current.insertMention(ref, replaceTrigger);
} else {
setPrompt((prev) => (prev ? `${prev} ${tag}` : tag));
}
if (pendingRefs.length >= MENTION_REF_LIMIT) {
};
const insertMention = (ref: CreationRef) => {
const exists = pendingRefs.some((item) => item.type === ref.type && item.id === ref.id);
if (!exists && pendingRefs.length >= MENTION_REF_LIMIT) {
onNotify?.("info", `最多引用 ${MENTION_REF_LIMIT} 个`);
setMentionMenuOpen(false);
return;
}
setPendingRefs((prev) => [...prev, ref]);
setPrompt((prev) => prev.replace(/@\S*$/, "").replace(/\s+$/, " "));
if (!exists) setPendingRefs((prev) => [...prev, ref]);
insertVisibleMention(ref, true);
setMentionMenuOpen(false);
};
const insertCardMention = (ref: CreationRef) => insertVisibleMention(ref, false);
const removePendingRef = (ref: CreationRef) => {
setPendingRefs((prev) => prev.filter((item) => !(item.type === ref.type && item.id === ref.id)));
const name = ref.name.split(" · ")[0].trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
setPrompt((prev) => prev.replace(new RegExp(`(^|\\s)@${name}(?=\\s|$)`, "g"), " ").replace(/\s{2,}/g, " ").trimStart());
};
const addStructuredRef = (ref: CreationRef) => {
setPendingRefs((prev) => {
if (prev.some((item) => item.type === ref.type && item.id === ref.id)) return prev;
if (prev.length >= MENTION_REF_LIMIT) {
onNotify?.("info", `最多添加 ${MENTION_REF_LIMIT} 个引用`);
return prev;
}
const isRole = ref.type === "character" || ref.type === "model";
const currentRoleCount = prev.filter((item) => item.type === "character" || item.type === "model").length;
if (isRole && currentRoleCount >= ROLE_REF_LIMIT) {
onNotify?.("info", `最多添加 ${ROLE_REF_LIMIT} 个角色`);
return prev;
}
return [...prev, ref];
});
};
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
event.target.value = "";
@@ -243,7 +308,7 @@ export function OmniCreatePage({
创作历史
</button>
<span className="omni-home-kicker">
<Sparkles /> YINGQING CREATIVE AGENT
<Sparkles /> YINGQING CREATIVE STUDIO
</span>
<h1>和全能创作聊聊你的想法</h1>
<p>选择一种创作方法,或直接描述你想生成的画面。</p>
@@ -254,77 +319,228 @@ export function OmniCreatePage({
</header>
<section className="omni-start-composer" aria-label="全能创作输入区">
<div className="omni-start-context" hidden={!selectedCase && pendingRefs.length === 0}>
<div className="omni-selected-case" hidden={!selectedCase}>
<span>
<WandSparkles />
<strong>{selectedCase?.title}</strong>
</span>
<button type="button" aria-label="取消预设" onClick={() => setSelectedCase(null)}>
<X />
</button>
</div>
<div className="omni-start-attachments" aria-live="polite">{pendingRefs.map((ref) => (
<span className="omni-attachment-chip" key={ref.id}>
{ref.cover ? <img src={ref.cover} alt="" /> : <ImageIcon />}
<span>{ref.name.split(" · ")[0]}</span>
<button
type="button"
className="omni-attachment-remove"
aria-label={`删除 ${ref.name}`}
onClick={() => setPendingRefs((prev) => prev.filter((item) => item.id !== ref.id))}
>
{selectedCase && (
<div className="omni-start-context">
<div className="omni-selected-case">
<span>
<WandSparkles />
<strong>{selectedCase.title}</strong>
</span>
<button type="button" aria-label="取消预设" onClick={() => setSelectedCase(null)}>
<X />
</button>
</span>
))}</div>
</div>
</div>
)}
<div className="omni-composer-main-wrap">
<div className="omni-composer-slots" aria-label="创作槽位">
<div className={`omni-slots-grid is-columns-${slotColumnCount}`}>
{/* 1. 商品槽位 */}
{productRefs.map((ref) => (
<div className="omni-slot-box is-filled" key={`${ref.type}:${ref.id}`} title={ref.name}>
<span className="omni-slot-tag">商品</span>
{ref.cover ? (
<button
type="button"
className="omni-slot-preview"
aria-label={`放大预览商品 ${ref.name}`}
title="点击放大预览"
onClick={() => setAssetPreview({ src: ref.cover || "", name: ref.name })}
>
<img src={ref.cover} alt={ref.name} className="omni-slot-img" />
</button>
) : (
<div className="omni-slot-img-placeholder">
<Box size={24} />
</div>
)}
<button
type="button"
className="omni-slot-remove"
aria-label={`移除商品 ${ref.name}`}
title="移除"
onClick={(e) => {
e.stopPropagation();
removePendingRef(ref);
}}
>
<X size={11} />
</button>
<button
type="button"
className="omni-slot-at-btn"
aria-label={`艾特 ${ref.name}`}
title={`在输入框中艾特 ${ref.name}`}
onClick={(e) => {
e.stopPropagation();
insertCardMention(ref);
}}
>
<span className="omni-slot-at-char">@</span>
</button>
</div>
))}
{productRefs.length === 0 && (
<button
type="button"
className="omni-slot-box is-empty"
onClick={() => {
setAssetModalType("product");
setAssetModalOpen(true);
}}
title="点击添加商品"
>
<span className="omni-slot-tag">商品</span>
<span className="omni-slot-plus">
<Plus size={20} />
</span>
</button>
)}
{/* 2. 角色槽位 */}
{roleRefs.map((ref, idx) => (
<div className="omni-slot-box is-filled" key={`${ref.type}:${ref.id}`} title={ref.name}>
<span className="omni-slot-tag">
{`角色${idx + 1}`}
</span>
{ref.cover ? (
<button
type="button"
className="omni-slot-preview"
aria-label={`放大预览角色 ${ref.name}`}
title="点击放大预览"
onClick={() => setAssetPreview({ src: ref.cover || "", name: ref.name })}
>
<img src={ref.cover} alt={ref.name} className="omni-slot-img" />
</button>
) : (
<div className="omni-slot-img-placeholder">
<UserRound size={24} />
</div>
)}
<button
type="button"
className="omni-slot-remove"
aria-label={`移除角色 ${ref.name}`}
title="移除"
onClick={(e) => {
e.stopPropagation();
removePendingRef(ref);
}}
>
<X size={11} />
</button>
<button
type="button"
className="omni-slot-at-btn"
aria-label={`艾特 ${ref.name}`}
title={`在输入框中艾特 ${ref.name}`}
onClick={(e) => {
e.stopPropagation();
insertCardMention(ref);
}}
>
<span className="omni-slot-at-char">@</span>
</button>
</div>
))}
{roleRefs.length < ROLE_REF_LIMIT && (
<button
type="button"
className="omni-slot-box is-empty"
onClick={() => {
setAssetModalType("character");
setAssetModalOpen(true);
}}
title={roleRefs.length ? "继续添加角色" : "点击添加角色"}
>
<span className="omni-slot-tag">{roleRefs.length ? `角色${roleRefs.length + 1}` : "角色"}</span>
<span className="omni-slot-plus">
<Plus size={20} />
</span>
</button>
)}
{/* 3. 素材槽位 (直接上传算素材) */}
{materialRefs.map((ref) => (
<div className="omni-slot-box is-filled" key={`${ref.type}:${ref.id}`} title={ref.name}>
<span className="omni-slot-tag">{ref.type === "scene" ? "场景" : "素材"}</span>
{ref.cover ? (
<button
type="button"
className="omni-slot-preview"
aria-label={`放大预览素材 ${ref.name}`}
title="点击放大预览"
onClick={() => setAssetPreview({ src: ref.cover || "", name: ref.name })}
>
<img src={ref.cover} alt={ref.name} className="omni-slot-img" />
</button>
) : (
<div className="omni-slot-img-placeholder">
<ImageIcon size={24} />
</div>
)}
<button
type="button"
className="omni-slot-remove"
aria-label={`移除素材 ${ref.name}`}
title="移除"
onClick={(e) => {
e.stopPropagation();
removePendingRef(ref);
}}
>
<X size={11} />
</button>
<button
type="button"
className="omni-slot-at-btn"
aria-label={`艾特 ${ref.name}`}
title={`在输入框中艾特 ${ref.name}`}
onClick={(e) => {
e.stopPropagation();
insertCardMention(ref);
}}
>
<span className="omni-slot-at-char">@</span>
</button>
</div>
))}
</div>
<span className="omni-slots-counter">{pendingRefs.length}/20</span>
</div>
<div className="omni-composer-editor">
<RichMentionEditor
id="omniStartPrompt"
ref={promptRef}
placeholder="添加商品,写下核心卖点或创意方向,系统将为你补全生成方案"
value={prompt}
refs={pendingRefs}
onChange={setPrompt}
onAtTrigger={() => void openMentions()}
/>
</div>
</div>
<textarea
id="omniStartPrompt"
rows={3}
placeholder="描述你想制作的内容,@ 可引用商品、模特、场景、素材或刚上传的图片……"
value={prompt}
onChange={(event) => {
const value = event.target.value;
setPrompt(value);
const caret = event.target.selectionStart ?? 0;
if (caret > 0 && value.charAt(caret - 1) === "@") void openMentions();
}}
/>
<div className="omni-start-toolbar">
<div className="omni-start-tools" ref={toolsRef}>
<div className="omni-upload-wrap">
<button
type="button"
className={`omni-icon-tool${uploading ? " is-uploading" : ""}`}
aria-label={uploading ? "正在上传参考素材" : "添加参考素材"}
aria-label={uploading ? "正在上传素材" : "上传素材"}
title={uploading ? "正在上传素材…" : "上传素材(直接添加为素材参考)"}
aria-busy={uploading}
disabled={uploading}
onClick={() => {
setUploadMenuOpen((open) => !open);
setMentionMenuOpen(false);
}}
onClick={() => fileInputRef.current?.click()}
>
{uploading ? <LoaderCircle /> : <Plus />}
</button>
<div className="omni-upload-menu" hidden={!uploadMenuOpen}>
<strong>添加参考素材</strong>
<button
type="button"
onClick={() => {
setUploadMenuOpen(false);
void openMentions();
}}
>
<FolderOpen />
<span>从资产库选择<small>使用平台已有商品、人物或场景</small></span>
</button>
<button type="button" onClick={() => fileInputRef.current?.click()}>
<Upload />
<span>本地上传<small>添加电脑中的图片</small></span>
</button>
</div>
</div>
{uploading ? (
<span className="omni-upload-status" role="status" aria-live="polite">
<LoaderCircle aria-hidden="true" />
@@ -395,12 +611,14 @@ export function OmniCreatePage({
<button
type="button"
className="omni-start-generate"
disabled={starting || uploading}
aria-label={hasPromptDescription ? "开始创作" : "请先输入创作描述"}
title={hasPromptDescription ? "开始创作" : "请先输入创作描述,@引用不能代替描述"}
disabled={starting || uploading || !hasPromptDescription}
onClick={() => {
const text = prompt.trim();
const creationBrief = text || selectedCase?.starter || "";
if (!text && !selectedCase && pendingRefs.length === 0) {
onNotify?.("info", "请先输入你想制作的内容,或选择一个创作预设");
if (!getMentionFreeText(text, pendingRefs)) {
onNotify?.("info", "请先输入具体的创作描述,@引用不能单独提交");
return;
}
if (starting) return;
@@ -545,6 +763,21 @@ export function OmniCreatePage({
</section>
)}
</div>
<AssetSelectModal
open={assetModalOpen}
type={assetModalType}
onClose={() => setAssetModalOpen(false)}
onSelect={addStructuredRef}
onNotify={onNotify}
/>
<MediaLightbox
open={Boolean(assetPreview?.src)}
src={assetPreview?.src || ""}
kind="image"
name={assetPreview?.name}
close={() => setAssetPreview(null)}
/>
</div>
</section>
);