characters

This commit is contained in:
BFlat authored and bflatastic committed 2026-04-03 14:39:03 +07:00
1 parent 702bdc8777
commit 0b8aea44af
6 files changed
+920 -32

No files matched your search

+2
View File
@@ -23,5 +23,7 @@ export * from "./lib/WeightsApi.js";
export * from "./lib/GetCdnOrigin.js";
export * from "./lib/GalleryModalApi.js";
export * from "./lib/OmniGenApi.js";
export * from "./lib/CharactersApi.js";
export * from "./lib/models/Character.js";
export * from "./lib/config/StorytellerApiHostStore.js";
export * from "./lib/enums/EIntermediateFile.js";
+132
View File
@@ -0,0 +1,132 @@
import { ApiManager, ApiResponse } from "./ApiManager.js";
import { Character } from "./models/Character.js";
import { PaginationInfinite } from "./models/Pagination.js";
export interface CreateCharacterRequest {
name: string;
description?: string;
image_media_file_token: string;
}
export interface UpdateCharacterRequest {
name?: string;
description?: string;
image_media_file_token?: string;
}
export interface ListCharactersRequest {
cursor?: string;
cursorIsReversed?: boolean;
pageSize?: number;
}
export class CharactersApi extends ApiManager {
public CreateCharacter(
params: CreateCharacterRequest,
): Promise<ApiResponse<Character>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/characters/create`;
return this.post<CreateCharacterRequest, { success: boolean } & Character>({
endpoint,
body: params,
})
.then(({ success, ...character }) => ({
success,
data: character,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public ListCharacters({
...params
}: ListCharactersRequest): Promise<
ApiResponse<Character[], PaginationInfinite>
> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/characters/list`;
const query = this.parseQueryValues(params);
return this.get<{
success: boolean;
results: Character[];
pagination: PaginationInfinite;
}>({ endpoint, query })
.then((response) => ({
success: true,
data: response.results,
pagination: response.pagination,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public GetCharacter({
characterToken,
}: {
characterToken: string;
}): Promise<ApiResponse<Character>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/characters/character/${characterToken}`;
return this.get<{ success: boolean } & Character>({ endpoint })
.then(({ success, ...character }) => ({
success,
data: character,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public UpdateCharacter({
characterToken,
...params
}: UpdateCharacterRequest & {
characterToken: string;
}): Promise<ApiResponse<undefined>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/characters/character/${characterToken}`;
const body = this.parseBodyValues<
UpdateCharacterRequest,
Record<string, unknown>
>(params);
return this.post<Record<string, unknown>, { success?: boolean; BadInput?: string }>({
endpoint,
body,
})
.then(({ success, BadInput }) => ({
success: success ?? false,
errorMessage: BadInput,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public DeleteCharacter({
characterToken,
}: {
characterToken: string;
}): Promise<ApiResponse<undefined>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/characters/character/${characterToken}`;
return this.delete<null, { success?: boolean; BadInput?: string }>({
endpoint,
})
.then(({ success, BadInput }) => ({
success: success ?? false,
errorMessage: BadInput,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
}
@@ -0,0 +1,10 @@
export interface Character {
character_token: string;
name: string;
description?: string;
avatar_image_url?: string;
full_image_url?: string;
image_media_file_token?: string;
created_at: string;
updated_at: string;
}
@@ -0,0 +1,588 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { Modal } from "@storyteller/ui-modal";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faPlus,
faArrowLeft,
faUpload,
faTrash,
faUserGroup,
faSpinnerThird,
faImages,
faXmark,
} from "@fortawesome/pro-solid-svg-icons";
import { twMerge } from "tailwind-merge";
import { CharactersApi, Character, downloadFileFromUrl } from "@storyteller/api";
import { toast } from "@storyteller/ui-toaster";
import { MediaUploadApi } from "@storyteller/api";
import { v4 as uuidv4 } from "uuid";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
import { useCharactersStore } from "./promptStore";
interface CharactersModalProps {
isOpen: boolean;
onClose: () => void;
onSelectCharacter?: (character: Character) => void;
}
type ModalView = "list" | "create";
interface UploadedImage {
file: File;
url: string;
mediaToken?: string;
}
export const CharactersModal = ({
isOpen,
onClose,
onSelectCharacter,
}: CharactersModalProps) => {
const [view, setView] = useState<ModalView>("list");
const handleClose = () => {
setView("list");
onClose();
};
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
title={view === "list" ? "Characters" : undefined}
width={520}
className="max-h-[80vh]"
>
{view === "list" ? (
<CharacterListView
onCreateClick={() => setView("create")}
onSelectCharacter={onSelectCharacter}
/>
) : (
<NewCharacterView
onBack={() => setView("list")}
onCreated={() => setView("list")}
/>
)}
</Modal>
);
};
// ---------------------------------------------------------------------------
// Character List View
// ---------------------------------------------------------------------------
const CharacterListView = ({
onCreateClick,
onSelectCharacter,
}: {
onCreateClick: () => void;
onSelectCharacter?: (character: Character) => void;
}) => {
const [characters, setCharacters] = useState<Character[]>([]);
const [loading, setLoading] = useState(true);
const [hasMore, setHasMore] = useState(false);
const [cursor, setCursor] = useState<string | undefined>(undefined);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const sentinelRef = useRef<HTMLDivElement>(null);
const loadingMoreRef = useRef(false);
const storeSetCharacters = useCharactersStore((s) => s.setCharacters);
const storeSetLoaded = useCharactersStore((s) => s.setLoaded);
const fetchCharacters = useCallback(
async (nextCursor?: string) => {
if (loadingMoreRef.current) return;
loadingMoreRef.current = true;
try {
const api = new CharactersApi();
const res = await api.ListCharacters({
pageSize: 20,
cursor: nextCursor,
});
if (res.success && res.data) {
setCharacters((prev) => {
const updated = nextCursor ? [...prev, ...res.data!] : res.data!;
// Sync to global store for @-mention system
storeSetCharacters(
updated.map((c) => ({
character_token: c.character_token,
name: c.name,
avatar_image_url: c.avatar_image_url,
})),
);
storeSetLoaded(true);
return updated;
});
const nextPage = res.pagination?.maybe_next;
setCursor(nextPage);
setHasMore(!!nextPage);
}
} catch {
// API not available yet - expected during development
storeSetLoaded(true);
} finally {
setLoading(false);
loadingMoreRef.current = false;
}
},
[storeSetCharacters, storeSetLoaded],
);
useEffect(() => {
fetchCharacters();
}, [fetchCharacters]);
// Infinite scroll via IntersectionObserver
useEffect(() => {
if (!sentinelRef.current || !hasMore) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && hasMore && cursor) {
fetchCharacters(cursor);
}
},
{ root: scrollContainerRef.current, threshold: 0.1 },
);
observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, [hasMore, cursor, fetchCharacters]);
return (
<div className="flex flex-col">
<div
ref={scrollContainerRef}
className="max-h-[60vh] overflow-y-auto px-1"
>
{/* Create New button */}
<button
onClick={onCreateClick}
className="mb-3 flex h-28 w-40 flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-white/20 text-white/60 transition-colors hover:border-white/40 hover:text-white/80"
>
<FontAwesomeIcon icon={faPlus} className="text-lg" />
<span className="text-sm font-medium">Create New</span>
</button>
{loading && characters.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-white/40">
<FontAwesomeIcon
icon={faSpinnerThird}
className="mb-3 text-2xl animate-spin"
/>
<p className="text-sm">Loading characters...</p>
</div>
) : characters.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-white/40">
<FontAwesomeIcon
icon={faUserGroup}
className="mb-3 text-3xl"
/>
<p className="text-sm">No characters yet</p>
</div>
) : (
<div className="grid grid-cols-3 gap-2">
{characters.map((character) => (
<button
key={character.character_token}
onClick={() => onSelectCharacter?.(character)}
className="group relative flex flex-col overflow-hidden rounded-lg border border-white/10 bg-white/5 transition-colors hover:border-white/25 hover:bg-white/10"
>
<div className="aspect-square w-full overflow-hidden bg-white/5">
{character.avatar_image_url ? (
<img
src={character.avatar_image_url}
alt={character.name}
className="h-full w-full object-cover"
loading="lazy"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-white/20">
<FontAwesomeIcon
icon={faUserGroup}
className="text-2xl"
/>
</div>
)}
</div>
<div className="px-2 py-1.5">
<p className="truncate text-xs font-medium text-white/80">
{character.name}
</p>
</div>
</button>
))}
</div>
)}
{/* Sentinel for infinite scroll */}
{hasMore && (
<div ref={sentinelRef} className="flex justify-center py-4">
<FontAwesomeIcon
icon={faSpinnerThird}
className="text-white/30 animate-spin"
/>
</div>
)}
</div>
</div>
);
};
// ---------------------------------------------------------------------------
// New Character View
// ---------------------------------------------------------------------------
const NewCharacterView = ({
onBack,
onCreated,
}: {
onBack: () => void;
onCreated: () => void;
}) => {
const addCharacterToStore = useCharactersStore((s) => s.addCharacter);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [images, setImages] = useState<UploadedImage[]>([]);
const [creating, setCreating] = useState(false);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const dropZoneRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [isGalleryOpen, setIsGalleryOpen] = useState(false);
const [selectedGalleryImages, setSelectedGalleryImages] = useState<string[]>([]);
const processFiles = useCallback(
async (files: FileList | File[]) => {
const imageFiles = Array.from(files).filter((f) =>
f.type.startsWith("image/"),
);
if (imageFiles.length === 0) {
toast.error("Please upload image files");
return;
}
// Only keep the first file (single image)
const file = imageFiles[0]!;
const newImages: UploadedImage[] = [{ file, url: URL.createObjectURL(file) }];
// Replace any existing image
setImages((prev) => {
prev.forEach((img) => URL.revokeObjectURL(img.url));
return newImages;
});
// Upload each image to get media tokens
setUploading(true);
const uploadApi = new MediaUploadApi();
const updatedImages: UploadedImage[] = [];
for (const img of newImages) {
try {
const res = await uploadApi.UploadImage({
uuid: uuidv4(),
blob: img.file,
fileName: img.file.name,
maybe_title: `character_ref_${name || "unnamed"}`,
});
if (res.success && res.data) {
updatedImages.push({ ...img, mediaToken: res.data });
} else {
toast.error(`Failed to upload ${img.file.name}`);
updatedImages.push(img);
}
} catch {
toast.error(`Failed to upload ${img.file.name}`);
updatedImages.push(img);
}
}
setImages((prev) =>
prev.map((existing) => {
const updated = updatedImages.find((u) => u.url === existing.url);
return updated || existing;
}),
);
setUploading(false);
},
[name],
);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragOver = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!isDragging) setIsDragging(true);
if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
},
[isDragging],
);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
const { clientX: x, clientY: y } = e;
if (x < rect.left || x >= rect.right || y < rect.top || y >= rect.bottom) {
setIsDragging(false);
}
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (e.dataTransfer.files?.length) {
processFiles(e.dataTransfer.files);
}
},
[processFiles],
);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files?.length) {
processFiles(e.target.files);
}
};
const removeImage = (index: number) => {
setImages((prev) => {
const removed = prev[index];
if (removed) URL.revokeObjectURL(removed.url);
return prev.filter((_, i) => i !== index);
});
};
const handleCreate = async () => {
if (!name.trim()) {
toast.error("Please enter a character name");
return;
}
const uploadedImages = images.filter((img) => img.mediaToken);
if (uploadedImages.length < 1) {
toast.error("Please upload a reference image");
return;
}
setCreating(true);
try {
const api = new CharactersApi();
const res = await api.CreateCharacter({
name: name.trim(),
description: description.trim() || undefined,
image_media_file_token: uploadedImages[0]!.mediaToken!,
});
if (res.success) {
toast.success(`Character "${name.trim()}" created!`);
// Add to global store so it appears in @-mentions immediately
addCharacterToStore({
character_token: res.data?.character_token ?? "",
name: name.trim(),
avatar_image_url: uploadedImages[0]!.url,
});
// Cleanup object URLs
images.forEach((img) => URL.revokeObjectURL(img.url));
onCreated();
} else {
toast.error(res.errorMessage || "Failed to create character");
}
} catch {
toast.error("Failed to create character");
} finally {
setCreating(false);
}
};
// Cleanup object URLs on unmount
useEffect(() => {
return () => {
images.forEach((img) => URL.revokeObjectURL(img.url));
};
}, []);
return (
<div className="flex flex-col gap-4">
{/* Header with back button */}
<button
onClick={onBack}
className="flex items-center gap-2 text-sm text-white/60 transition-colors hover:text-white/90 -mt-1"
>
<FontAwesomeIcon icon={faArrowLeft} className="text-xs" />
New Character
</button>
{/* Image upload area */}
<div
ref={dropZoneRef}
className={twMerge(
"flex h-56 max-h-56 cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-white/20 bg-white/5 transition-colors overflow-hidden",
isDragging && "border-blue-400 bg-blue-500/10",
)}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
{images.length > 0 ? (
<div className="group relative flex h-full w-full items-center justify-center" onClick={(e) => e.stopPropagation()}>
<img
src={images[0]!.url}
alt="Reference"
className="max-h-full max-w-full object-contain"
/>
{!images[0]!.mediaToken && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
<FontAwesomeIcon
icon={faSpinnerThird}
className="text-white animate-spin"
/>
</div>
)}
<button
onClick={(e) => {
e.stopPropagation();
removeImage(0);
}}
className="absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white/80 opacity-0 transition-all group-hover:opacity-100 hover:bg-red-500"
>
<FontAwesomeIcon icon={faXmark} className="text-sm" />
</button>
</div>
) : (
<div className="flex h-full w-full flex-col items-center justify-center text-white/60">
<FontAwesomeIcon
icon={faUpload}
className="mb-2 text-xl text-white/40"
/>
<p className="text-sm">Upload reference image</p>
<p className="mb-3 text-xs text-white/40">
Click or drag an image here
</p>
<div className="flex items-center gap-3" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => setIsGalleryOpen(true)}
className="flex items-center gap-2 rounded-lg bg-white/10 px-3 py-1.5 text-sm text-white/80 transition-colors hover:bg-white/20"
>
<FontAwesomeIcon icon={faImages} className="text-xs" />
Choose from Library
</button>
<button
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-2 rounded-lg bg-white/10 px-3 py-1.5 text-sm text-white/80 transition-colors hover:bg-white/20"
>
<FontAwesomeIcon icon={faUpload} className="text-xs" />
Upload Image
</button>
</div>
</div>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileSelect}
className="hidden"
/>
{/* Name input */}
<div>
<label className="mb-1 block text-sm font-medium text-white/80">
Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Character name"
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white placeholder-white/30 outline-none transition-colors focus:border-white/25"
/>
</div>
{/* Description input */}
<div>
<label className="mb-1 block text-sm font-medium text-white/80">
Description{" "}
<span className="font-normal text-white/40">(optional)</span>
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Describe this character..."
rows={3}
className="w-full resize-none rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white placeholder-white/30 outline-none transition-colors focus:border-white/25"
/>
</div>
{/* Action buttons */}
<div className="flex justify-end gap-2">
<button
onClick={onBack}
className="rounded-lg px-4 py-2 text-sm text-white/60 transition-colors hover:text-white/90"
>
Cancel
</button>
<button
onClick={handleCreate}
disabled={
creating ||
uploading ||
!name.trim() ||
images.filter((i) => i.mediaToken).length < 1
}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white transition-colors disabled:cursor-not-allowed disabled:opacity-50"
>
{creating ? (
<FontAwesomeIcon icon={faSpinnerThird} className="animate-spin" />
) : (
"Create"
)}
</button>
</div>
<GalleryModal
isOpen={isGalleryOpen}
onClose={() => {
setIsGalleryOpen(false);
setSelectedGalleryImages([]);
}}
mode="select"
selectedItemIds={selectedGalleryImages}
onSelectItem={(id) => {
setSelectedGalleryImages((prev) => (prev.includes(id) ? [] : [id]));
}}
maxSelections={1}
onUseSelected={(selectedItems: GalleryItem[]) => {
const item = selectedItems[0];
if (item && item.fullImage) {
// Clean up previous image
images.forEach((img) => URL.revokeObjectURL(img.url));
setImages([
{
file: new File([], "library-image"),
url: item.fullImage,
mediaToken: item.id,
},
]);
}
setIsGalleryOpen(false);
setSelectedGalleryImages([]);
}}
onDownloadClicked={downloadFileFromUrl}
forceFilter="image"
/>
</div>
);
};
@@ -30,7 +30,12 @@ import {
SizeOption,
VideoModel,
} from "@storyteller/model-list";
import { usePromptVideoStore, RefImage, VideoInputMode } from "./promptStore";
import {
usePromptVideoStore,
RefImage,
VideoInputMode,
useCharactersStore,
} from "./promptStore";
import { gtagEvent } from "@storyteller/google-analytics";
import { ImagePromptRow } from "./ImagePromptRow";
import type { UploadImageFn } from "./ImagePromptRow";
@@ -39,6 +44,9 @@ import { VideoGenerationCountPicker } from "./common/VideoGenerationCountPicker"
import { twMerge } from "tailwind-merge";
import { toast } from "@storyteller/ui-toaster";
import { GenerationProvider } from "@storyteller/api-enums";
import { CharactersModal } from "./CharactersModal";
import { CharactersApi } from "@storyteller/api";
import { faUser } from "@fortawesome/pro-solid-svg-icons";
declare global {
interface Window {
@@ -132,6 +140,34 @@ export const PromptBoxVideo = ({
const [isEnqueueing, setIsEnqueueing] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [isCharactersModalOpen, setIsCharactersModalOpen] = useState(false);
// Characters store for @-mentions
const storedCharacters = useCharactersStore((s) => s.characters);
const charactersLoaded = useCharactersStore((s) => s.loaded);
const storeSetCharacters = useCharactersStore((s) => s.setCharacters);
const storeSetLoaded = useCharactersStore((s) => s.setLoaded);
// Load characters on mount if not already loaded
useEffect(() => {
if (charactersLoaded) return;
const api = new CharactersApi();
api
.ListCharacters({ pageSize: 100 })
.then((res) => {
if (res.success && res.data) {
storeSetCharacters(
res.data.map((c) => ({
character_token: c.character_token,
name: c.name,
avatar_image_url: c.avatar_image_url,
})),
);
}
})
.catch(() => {})
.finally(() => storeSetLoaded(true));
}, [charactersLoaded, storeSetCharacters, storeSetLoaded]);
// CSS viewport units handle resize reactivity automatically
const EXPANDED_HEIGHT = "clamp(120px, calc(100vh - 700px), 500px)";
@@ -380,15 +416,35 @@ export const PromptBoxVideo = ({
"rgb(192, 132, 252)", // violet
"rgb(232, 121, 249)", // fuchsia
];
const CHARACTER_COLORS = [
"rgb(45, 212, 191)", // teal
"rgb(34, 197, 94)", // emerald
"rgb(14, 165, 233)", // sky
];
const hasAnyRefs =
referenceImages.length > 0 ||
referenceVideos.length > 0 ||
referenceAudios.length > 0;
// Build a set of character names for highlight matching
const characterNames = storedCharacters.map((c) => c.name);
const hasAnyMentionables = hasAnyRefs || storedCharacters.length > 0;
const renderHighlightedPrompt = () => {
if (!isReferenceMode || !hasAnyRefs) return null;
const parts = prompt.split(/(@(?:Image|Video|Audio)\d+)/g);
if (!hasAnyMentionables) return null;
// Build regex that matches @Image1, @Video1, @Audio1 and @CharacterName
const charPattern = characterNames
.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
.join("|");
const refPattern = "@(?:Image|Video|Audio)\\d+";
const fullPattern = charPattern
? `(${refPattern}|@(?:${charPattern}))`
: `(${refPattern})`;
const regex = new RegExp(fullPattern, "g");
const parts = prompt.split(regex);
return parts.map((part, i) => {
const imgMatch = part.match(/^@Image(\d+)$/);
if (imgMatch) {
@@ -426,6 +482,23 @@ export const PromptBoxVideo = ({
</span>
);
}
// Check character match
if (part.startsWith("@")) {
const charName = part.slice(1);
const charIdx = characterNames.indexOf(charName);
if (charIdx !== -1) {
return (
<span
key={i}
style={{
color: CHARACTER_COLORS[charIdx % CHARACTER_COLORS.length],
}}
>
{part}
</span>
);
}
}
return <span key={i}>{part}</span>;
});
};
@@ -436,29 +509,36 @@ export const PromptBoxVideo = ({
const [mentionIndex, setMentionIndex] = useState(0);
const mentionAnchorRef = useRef<number | null>(null);
const mentionItems = isReferenceMode
? [
...referenceImages.map((img, i) => ({
label: `@Image${i + 1}`,
type: "image" as const,
preview: img.url,
})),
...referenceVideos.map((vid, i) => ({
label: `@Video${i + 1}`,
type: "video" as const,
preview: vid.url,
})),
...referenceAudios.map((_aud, i) => ({
label: `@Audio${i + 1}`,
type: "audio" as const,
preview: undefined as string | undefined,
})),
].filter((item) =>
mentionFilter
? item.label.toLowerCase().includes(mentionFilter.toLowerCase())
: true,
)
: [];
const mentionItems = [
...(isReferenceMode
? [
...referenceImages.map((img, i) => ({
label: `@Image${i + 1}`,
type: "image" as const,
preview: img.url,
})),
...referenceVideos.map((vid, i) => ({
label: `@Video${i + 1}`,
type: "video" as const,
preview: vid.url,
})),
...referenceAudios.map((_aud, i) => ({
label: `@Audio${i + 1}`,
type: "audio" as const,
preview: undefined as string | undefined,
})),
]
: []),
...storedCharacters.map((char) => ({
label: `@${char.name}`,
type: "character" as const,
preview: char.avatar_image_url,
})),
].filter((item) =>
mentionFilter
? item.label.toLowerCase().includes(mentionFilter.toLowerCase())
: true,
);
const insertMention = (label: string) => {
const textarea = textareaRef.current;
@@ -496,8 +576,8 @@ export const PromptBoxVideo = ({
const cursorPos = e.target.selectionStart;
setPrompt(value);
if (isReferenceMode && hasAnyRefs) {
// Find the last '@' before cursor that could be a mention trigger
// Trigger @-mention for reference files (in reference mode) or characters (always)
if ((isReferenceMode && hasAnyRefs) || storedCharacters.length > 0) {
const textBeforeCursor = value.slice(0, cursorPos);
const lastAtIndex = textBeforeCursor.lastIndexOf("@");
@@ -823,7 +903,7 @@ export const PromptBoxVideo = ({
{mentionOpen && mentionItems.length > 0 && (
<div className="absolute bottom-full left-0 z-50 mb-1 w-64 overflow-hidden rounded-lg border border-white/10 bg-ui-controls shadow-lg backdrop-blur-xl">
<div className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-base-fg/50">
Reference Files
References
</div>
{mentionItems.map((item, i) => (
<button
@@ -839,7 +919,18 @@ export const PromptBoxVideo = ({
onMouseEnter={() => setMentionIndex(i)}
>
<div className="h-8 w-8 flex-shrink-0 overflow-hidden rounded-md border border-white/20 flex items-center justify-center bg-black/20">
{item.type === "image" && item.preview ? (
{item.type === "character" && item.preview ? (
<img
src={item.preview}
alt={item.label}
className="h-full w-full object-cover"
/>
) : item.type === "character" ? (
<FontAwesomeIcon
icon={faUser}
className="h-3.5 w-3.5 text-base-fg/60"
/>
) : item.type === "image" && item.preview ? (
<img
src={item.preview}
alt={item.label}
@@ -896,7 +987,7 @@ export const PromptBoxVideo = ({
</Tooltip> */}
<div className="promptbox-resize-wrap relative flex-1">
{isReferenceMode && hasAnyRefs && (
{hasAnyMentionables && (
<div
ref={highlightRef}
aria-hidden
@@ -915,7 +1006,7 @@ export const PromptBoxVideo = ({
}
className={twMerge(
"promptbox-scrollbar text-md relative mb-2 min-h-[2.5em] w-full resize-y overflow-y-auto rounded bg-transparent pb-2 pr-2 pt-1 placeholder-base-fg/60 focus:outline-none",
isReferenceMode && hasAnyRefs
hasAnyMentionables
? "text-transparent caret-base-fg"
: "text-base-fg",
)}
@@ -1037,6 +1128,23 @@ export const PromptBoxVideo = ({
/>
</Tooltip>
)}
{selectedModel?.id === "seedance_2p0" && (
<Tooltip
content="Characters"
position="top"
className="z-50"
delay={200}
>
<button
type="button"
onClick={() => setIsCharactersModalOpen(true)}
className="flex h-9 items-center justify-center gap-1 rounded-lg border border-ui-controls-border bg-ui-controls px-3 text-sm font-medium text-base-fg transition-all duration-150 hover:bg-ui-controls/80 active:scale-95"
>
@Characters
</button>
</Tooltip>
)}
</div>
<div className="flex items-center gap-2">
{modelNeedsAnImageButNoneAreSelected && (
@@ -1109,6 +1217,30 @@ export const PromptBoxVideo = ({
</div>
)}
</div>
<CharactersModal
isOpen={isCharactersModalOpen}
onClose={() => setIsCharactersModalOpen(false)}
onSelectCharacter={(character) => {
const mention = `@${character.name}`;
const textarea = textareaRef.current;
if (textarea) {
const pos = textarea.selectionStart ?? prompt.length;
const before = prompt.slice(0, pos);
const after = prompt.slice(pos);
const spaceBefore =
before.length > 0 && !before.endsWith(" ") ? " " : "";
setPrompt(before + spaceBefore + mention + " " + after);
requestAnimationFrame(() => {
const newPos = pos + spaceBefore.length + mention.length + 1;
textarea.setSelectionRange(newPos, newPos);
textarea.focus();
});
} else {
setPrompt(prompt ? prompt + " " + mention + " " : mention + " ");
}
setIsCharactersModalOpen(false);
}}
/>
<GalleryModal
isOpen={!!isGalleryModalOpen}
onClose={() => {
@@ -201,3 +201,27 @@ export const usePromptEditStore = create<PromptEditStore>()((set) => ({
setAspectRatio: (aspectRatio) => set({ aspectRatio }),
setResolution: (resolution) => set({ resolution }),
}));
// ----- Characters Store -----
export interface StoredCharacter {
character_token: string;
name: string;
avatar_image_url?: string;
}
interface CharactersStore {
characters: StoredCharacter[];
loaded: boolean;
setCharacters: (characters: StoredCharacter[]) => void;
addCharacter: (character: StoredCharacter) => void;
setLoaded: (loaded: boolean) => void;
}
export const useCharactersStore = create<CharactersStore>()((set) => ({
characters: [],
loaded: false,
setCharacters: (characters) => set({ characters }),
addCharacter: (character) =>
set((state) => ({ characters: [...state.characters, character] })),
setLoaded: (loaded) => set({ loaded }),
}));