添加艾特角色功能
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user