添加艾特角色功能
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>
|
||||
);
|
||||
|
||||
@@ -26,6 +26,12 @@ import { api } from "../api";
|
||||
import { findCatalogModel, normalizeDurationValue, OmniParamBar } from "../components/omni-param-bar";
|
||||
import { estimateCost, pointsPerImageFromCatalog } from "../components/free-create/constants";
|
||||
import { 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 {
|
||||
CreationConversationDetail,
|
||||
CreationField,
|
||||
@@ -542,14 +548,25 @@ function MentionChips({
|
||||
return (
|
||||
<span className={`omni-mention-chip${cover ? " has-thumb" : ""}`} key={`${ref.type}-${ref.id}`}>
|
||||
{cover ? (
|
||||
<button
|
||||
type="button"
|
||||
className="omni-mention-thumb"
|
||||
aria-label={`预览 ${label}`}
|
||||
onClick={() => setPreview({ src: cover, name: label })}
|
||||
>
|
||||
<img src={cover} alt={label} />
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="omni-mention-thumb"
|
||||
aria-label={`预览 ${label}`}
|
||||
onClick={() => setPreview({ src: cover, name: label })}
|
||||
>
|
||||
<img src={cover} alt={label} />
|
||||
</button>
|
||||
<span className="omni-mention-hover-preview" role="tooltip">
|
||||
<span className="omni-mention-hover-head">
|
||||
<strong>{label}</strong>
|
||||
<small>{REF_CHIP_LABEL[ref.type] || ref.type}</small>
|
||||
</span>
|
||||
<span className="omni-mention-hover-media">
|
||||
<img src={cover} alt={label} />
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i>{REF_CHIP_LABEL[ref.type] || ref.type}</i>
|
||||
@@ -1444,6 +1461,7 @@ 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,
|
||||
);
|
||||
@@ -1469,7 +1487,10 @@ function ElicitCard({
|
||||
{!submitted ? (
|
||||
<div className="omni-reply-guide" aria-label="回复操作">
|
||||
{choiceField ? (
|
||||
<div className="omni-gate-actions omni-chat-choice-actions" aria-label={choiceField.label}>
|
||||
<div
|
||||
className={`omni-gate-actions${compactChoiceOnly ? "" : " omni-chat-choice-actions"}`}
|
||||
aria-label={choiceField.label}
|
||||
>
|
||||
{(choiceField.options || []).map((option) => (
|
||||
<button
|
||||
type="button"
|
||||
@@ -1482,19 +1503,21 @@ function ElicitCard({
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<form className="omni-chat-question-input" onSubmit={submitChatAnswer}>
|
||||
<input
|
||||
className="input"
|
||||
value={chatAnswer}
|
||||
disabled={disabled}
|
||||
onChange={(event) => setChatAnswer(event.target.value)}
|
||||
placeholder="输入你的想法…"
|
||||
aria-label="回答这个问题"
|
||||
/>
|
||||
<button type="submit" aria-label="发送回答" disabled={disabled || !chatAnswer.trim()}>
|
||||
<ArrowUp size={14} />
|
||||
</button>
|
||||
</form>
|
||||
{!compactChoiceOnly ? (
|
||||
<form className="omni-chat-question-input" onSubmit={submitChatAnswer}>
|
||||
<input
|
||||
className="input"
|
||||
value={chatAnswer}
|
||||
disabled={disabled}
|
||||
onChange={(event) => setChatAnswer(event.target.value)}
|
||||
placeholder="输入你的想法…"
|
||||
aria-label="回答这个问题"
|
||||
/>
|
||||
<button type="submit" aria-label="发送回答" disabled={disabled || !chatAnswer.trim()}>
|
||||
<ArrowUp size={14} />
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
) : savedChoiceText ? (
|
||||
<p className="omni-gate-answered">已选择:{savedChoiceText}</p>
|
||||
@@ -2206,6 +2229,10 @@ export function OmniSessionPage({
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [composerHint, setComposerHint] = useState<string | null>(null);
|
||||
const [pendingRefs, setPendingRefs] = useState<CreationRef[]>([]);
|
||||
const hasComposerDescription = useMemo(
|
||||
() => Boolean(getMentionFreeText(prompt, pendingRefs)),
|
||||
[pendingRefs, prompt],
|
||||
);
|
||||
const [sessionUploads, setSessionUploads] = useState<CreationRef[]>(firstUploads || []);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -2216,7 +2243,7 @@ export function OmniSessionPage({
|
||||
messageId: string;
|
||||
source: "local_upload" | "model_library";
|
||||
} | null>(null);
|
||||
const composerRef = useRef<HTMLTextAreaElement>(null);
|
||||
const composerRef = useRef<RichMentionEditorHandle>(null);
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [liveText, setLiveText] = useState("");
|
||||
const [liveReasoning, setLiveReasoning] = useState("");
|
||||
@@ -2228,6 +2255,8 @@ export function OmniSessionPage({
|
||||
const [typeLabels, setTypeLabels] = useState<Record<string, string>>({});
|
||||
const mentionWrapRef = useRef<HTMLDivElement>(null);
|
||||
const [uploadMenuOpen, setUploadMenuOpen] = useState(false);
|
||||
const [sessionAssetModalOpen, setSessionAssetModalOpen] = useState(false);
|
||||
const [sessionAssetModalType, setSessionAssetModalType] = useState<AssetModalType>("product");
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [mergingMessageIds, setMergingMessageIds] = useState<string[]>([]);
|
||||
const [stopping, setStopping] = useState(false);
|
||||
@@ -2258,15 +2287,11 @@ export function OmniSessionPage({
|
||||
|
||||
const editUserMessage = useCallback((message: CreationMessage) => {
|
||||
const refs = message.refs || [];
|
||||
setPrompt(stripMentionText(message.text, refs));
|
||||
setPrompt(message.text);
|
||||
setPendingRefs(refs);
|
||||
setComposerHint(null);
|
||||
requestAnimationFrame(() => {
|
||||
composerRef.current?.focus();
|
||||
composerRef.current?.setSelectionRange(
|
||||
composerRef.current.value.length,
|
||||
composerRef.current.value.length,
|
||||
);
|
||||
composerRef.current?.focusAtEnd();
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -2840,7 +2865,10 @@ export function OmniSessionPage({
|
||||
if (uploading) return;
|
||||
if (streaming && conversation?.agent_status !== "awaiting_user") return;
|
||||
const text = prompt.trim();
|
||||
if (!text && pendingRefs.length === 0) return;
|
||||
if (!getMentionFreeText(text, pendingRefs)) {
|
||||
notify("info", "请先输入具体的创作描述,@引用不能单独发送");
|
||||
return;
|
||||
}
|
||||
setPrompt("");
|
||||
setComposerHint(null);
|
||||
const refs = pendingRefs;
|
||||
@@ -2876,6 +2904,16 @@ export function OmniSessionPage({
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
}, [mentionMenuOpen]);
|
||||
|
||||
const insertPromptMention = (ref: CreationRef, replaceTrigger = false) => {
|
||||
const name = ref.name.split(" · ")[0].trim();
|
||||
const tag = `@${name} `;
|
||||
if (!composerRef.current) {
|
||||
setPrompt((prev) => (prev ? `${prev} ${tag}` : tag));
|
||||
return;
|
||||
}
|
||||
composerRef.current.insertMention(ref, replaceTrigger);
|
||||
};
|
||||
|
||||
const insertMention = (ref: CreationRef) => {
|
||||
const personRequest = personSourceRequestRef.current;
|
||||
if (personRequest) {
|
||||
@@ -2908,17 +2946,14 @@ export function OmniSessionPage({
|
||||
return;
|
||||
}
|
||||
// 整条 Ref 存起来一起发:只把名字拼进文本的话,后端取不到卖点和参考图
|
||||
if (pendingRefs.some((r) => r.id === ref.id)) {
|
||||
setMentionMenuOpen(false);
|
||||
return;
|
||||
}
|
||||
if (pendingRefs.length >= MENTION_REF_LIMIT) {
|
||||
const exists = pendingRefs.some((item) => item.type === ref.type && item.id === ref.id);
|
||||
if (!exists && pendingRefs.length >= MENTION_REF_LIMIT) {
|
||||
notify("info", `最多引用 ${MENTION_REF_LIMIT} 个`);
|
||||
setMentionMenuOpen(false);
|
||||
return;
|
||||
}
|
||||
setPendingRefs((prev) => [...prev, ref]);
|
||||
setPrompt((prev) => prev.replace(/@\S*$/, "").replace(/\s+$/, " "));
|
||||
if (!exists) setPendingRefs((prev) => [...prev, ref]);
|
||||
insertPromptMention(ref, true);
|
||||
setMentionMenuOpen(false);
|
||||
};
|
||||
|
||||
@@ -2984,7 +3019,9 @@ export function OmniSessionPage({
|
||||
}
|
||||
if (source === "model_library") {
|
||||
personSourceRequestRef.current = { messageId: message.id, source };
|
||||
void openMentions("", "model");
|
||||
setMentionMenuOpen(false);
|
||||
setSessionAssetModalType("character");
|
||||
setSessionAssetModalOpen(true);
|
||||
return;
|
||||
}
|
||||
setMessages((prev) =>
|
||||
@@ -3226,30 +3263,17 @@ export function OmniSessionPage({
|
||||
</main>
|
||||
|
||||
<footer className={`omni-session-composer${composerHint ? " is-revise-hint" : ""}`}>
|
||||
<MentionChips
|
||||
refs={pendingRefs}
|
||||
tone="composer"
|
||||
onRemove={(id) => setPendingRefs((prev) => prev.filter((ref) => ref.id !== id))}
|
||||
/>
|
||||
<textarea
|
||||
<RichMentionEditor
|
||||
id="omniSessionPrompt"
|
||||
ref={composerRef}
|
||||
rows={2}
|
||||
placeholder={composerHint || "回复创作助手,也可以继续补充图片或要求……"}
|
||||
placeholder={composerHint || "继续补充图片或创作要求……"}
|
||||
value={prompt}
|
||||
refs={pendingRefs}
|
||||
disabled={uploading || (streaming && conversation?.agent_status !== "awaiting_user")}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value;
|
||||
setPrompt(value);
|
||||
const caret = event.target.selectionStart ?? 0;
|
||||
if (caret > 0 && value.charAt(caret - 1) === "@") void openMentions();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
}}
|
||||
submitOnEnter
|
||||
onChange={setPrompt}
|
||||
onAtTrigger={() => void openMentions()}
|
||||
onSubmit={handleSend}
|
||||
/>
|
||||
<div className="omni-session-composer-tools">
|
||||
<div className="omni-session-composer-options">
|
||||
@@ -3278,12 +3302,27 @@ export function OmniSessionPage({
|
||||
onClick={() => {
|
||||
personSourceRequestRef.current = null;
|
||||
setUploadMenuOpen(false);
|
||||
void openMentions();
|
||||
setSessionAssetModalType("product");
|
||||
setSessionAssetModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<FolderOpen />
|
||||
<Box />
|
||||
<span>
|
||||
从资产库选择<small>引用已有商品、人物或场景</small>
|
||||
添加商品<small>从商品库选择或新增商品</small>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
personSourceRequestRef.current = null;
|
||||
setUploadMenuOpen(false);
|
||||
setSessionAssetModalType("character");
|
||||
setSessionAssetModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<UserRound />
|
||||
<span>
|
||||
添加角色<small>从角色库选择或新增人物</small>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
@@ -3297,7 +3336,7 @@ export function OmniSessionPage({
|
||||
>
|
||||
<Upload />
|
||||
<span>
|
||||
本地上传<small>添加电脑中的图片</small>
|
||||
上传素材<small>添加电脑中的图片作为素材</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -3346,6 +3385,7 @@ export function OmniSessionPage({
|
||||
if (prev.some((item) => item.id === ref.id) || prev.length >= MENTION_REF_LIMIT) return prev;
|
||||
return [...prev, ref];
|
||||
});
|
||||
insertPromptMention(ref);
|
||||
}
|
||||
}
|
||||
notify(
|
||||
@@ -3443,14 +3483,15 @@ export function OmniSessionPage({
|
||||
<button
|
||||
type="button"
|
||||
className={`omni-session-send${isPlanning ? " is-stop" : ""}`}
|
||||
aria-label={isPlanning ? "终止" : "发送"}
|
||||
title={isPlanning ? "终止整理方案" : "发送"}
|
||||
aria-label={isPlanning ? "终止" : hasComposerDescription ? "发送" : "请先输入创作描述"}
|
||||
title={isPlanning ? "终止整理方案" : hasComposerDescription ? "发送" : "请先输入创作描述,@引用不能单独发送"}
|
||||
disabled={
|
||||
isPlanning
|
||||
? stopping
|
||||
: (
|
||||
uploading
|
||||
|| (streaming && conversation?.agent_status !== "awaiting_user")
|
||||
|| !hasComposerDescription
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
@@ -3689,6 +3730,55 @@ export function OmniSessionPage({
|
||||
name={assetPreview?.name}
|
||||
close={() => setAssetPreview(null)}
|
||||
/>
|
||||
<AssetSelectModal
|
||||
open={sessionAssetModalOpen}
|
||||
type={sessionAssetModalType}
|
||||
onClose={() => {
|
||||
setSessionAssetModalOpen(false);
|
||||
if (personSourceRequestRef.current?.source === "model_library") {
|
||||
personSourceRequestRef.current = null;
|
||||
}
|
||||
}}
|
||||
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;
|
||||
}
|
||||
return [...prev, newRef];
|
||||
});
|
||||
insertPromptMention(newRef);
|
||||
}}
|
||||
onNotify={notify}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user