diff --git a/frontend/libs/api/src/index.ts b/frontend/libs/api/src/index.ts index 4e94bff03a..45c52fb0b1 100644 --- a/frontend/libs/api/src/index.ts +++ b/frontend/libs/api/src/index.ts @@ -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"; diff --git a/frontend/libs/api/src/lib/CharactersApi.ts b/frontend/libs/api/src/lib/CharactersApi.ts new file mode 100644 index 0000000000..d8550ef530 --- /dev/null +++ b/frontend/libs/api/src/lib/CharactersApi.ts @@ -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> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/characters/create`; + + return this.post({ + endpoint, + body: params, + }) + .then(({ success, ...character }) => ({ + success, + data: character, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } + + public ListCharacters({ + ...params + }: ListCharactersRequest): Promise< + ApiResponse + > { + 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> { + 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> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/characters/character/${characterToken}`; + + const body = this.parseBodyValues< + UpdateCharacterRequest, + Record + >(params); + + return this.post, { 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> { + const endpoint = `${this.getApiSchemeAndHost()}/v1/characters/character/${characterToken}`; + + return this.delete({ + endpoint, + }) + .then(({ success, BadInput }) => ({ + success: success ?? false, + errorMessage: BadInput, + })) + .catch((err) => ({ + success: false, + errorMessage: err.message, + })); + } +} diff --git a/frontend/libs/api/src/lib/models/Character.ts b/frontend/libs/api/src/lib/models/Character.ts new file mode 100644 index 0000000000..33a4e934f8 --- /dev/null +++ b/frontend/libs/api/src/lib/models/Character.ts @@ -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; +} diff --git a/frontend/libs/components/promptbox/src/lib/CharactersModal.tsx b/frontend/libs/components/promptbox/src/lib/CharactersModal.tsx new file mode 100644 index 0000000000..9f12933b02 --- /dev/null +++ b/frontend/libs/components/promptbox/src/lib/CharactersModal.tsx @@ -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("list"); + + const handleClose = () => { + setView("list"); + onClose(); + }; + + return ( + + {view === "list" ? ( + setView("create")} + onSelectCharacter={onSelectCharacter} + /> + ) : ( + setView("list")} + onCreated={() => setView("list")} + /> + )} + + ); +}; + +// --------------------------------------------------------------------------- +// Character List View +// --------------------------------------------------------------------------- + +const CharacterListView = ({ + onCreateClick, + onSelectCharacter, +}: { + onCreateClick: () => void; + onSelectCharacter?: (character: Character) => void; +}) => { + const [characters, setCharacters] = useState([]); + const [loading, setLoading] = useState(true); + const [hasMore, setHasMore] = useState(false); + const [cursor, setCursor] = useState(undefined); + const scrollContainerRef = useRef(null); + const sentinelRef = useRef(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 ( +
+
+ {/* Create New button */} + + + {loading && characters.length === 0 ? ( +
+ +

Loading characters...

+
+ ) : characters.length === 0 ? ( +
+ +

No characters yet

+
+ ) : ( +
+ {characters.map((character) => ( + + ))} +
+ )} + + {/* Sentinel for infinite scroll */} + {hasMore && ( +
+ +
+ )} +
+
+ ); +}; + +// --------------------------------------------------------------------------- +// 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([]); + const [creating, setCreating] = useState(false); + const [uploading, setUploading] = useState(false); + const fileInputRef = useRef(null); + const dropZoneRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + const [isGalleryOpen, setIsGalleryOpen] = useState(false); + const [selectedGalleryImages, setSelectedGalleryImages] = useState([]); + + 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) => { + 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 ( +
+ {/* Header with back button */} + + + {/* Image upload area */} +
fileInputRef.current?.click()} + > + {images.length > 0 ? ( +
e.stopPropagation()}> + Reference + {!images[0]!.mediaToken && ( +
+ +
+ )} + +
+ ) : ( +
+ +

Upload reference image

+

+ Click or drag an image here +

+
e.stopPropagation()}> + + +
+
+ )} +
+ + + + {/* Name input */} +
+ + 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" + /> +
+ + {/* Description input */} +
+ +