characters update to use real api

This commit is contained in:
BFlat authored and bflatastic committed 2026-04-03 14:39:03 +07:00
1 parent 0b8aea44af
commit c644e378fb
5 files changed
+143 -133

No files matched your search

+61 -67
View File
@@ -1,38 +1,38 @@
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;
image_media_token: string;
model: string;
uuid_idempotency_token: string;
}
export interface UpdateCharacterRequest {
name?: string;
description?: string;
image_media_file_token?: string;
export interface EditCharacterRequest {
token: string;
updated_name?: string | null;
updated_description?: string | null;
}
export interface ListCharactersRequest {
cursor?: string;
cursorIsReversed?: boolean;
pageSize?: number;
cursor?: number;
}
export class CharactersApi extends ApiManager {
public CreateCharacter(
params: CreateCharacterRequest,
): Promise<ApiResponse<Character>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/characters/create`;
): Promise<ApiResponse<{ inference_job_token: string }>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/character/create`;
return this.post<CreateCharacterRequest, { success: boolean } & Character>({
return this.post<
CreateCharacterRequest,
{ success: boolean; inference_job_token: string }
>({
endpoint,
body: params,
})
.then(({ success, ...character }) => ({
.then(({ success, inference_job_token }) => ({
success,
data: character,
data: { inference_job_token },
}))
.catch((err) => ({
success: false,
@@ -40,24 +40,21 @@ export class CharactersApi extends ApiManager {
}));
}
public ListCharacters({
...params
}: ListCharactersRequest): Promise<
ApiResponse<Character[], PaginationInfinite>
> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/characters/list`;
public EditCharacter(
params: EditCharacterRequest,
): Promise<ApiResponse<undefined>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/character/edit`;
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,
return this.post<
EditCharacterRequest,
{ success: boolean; BadInput?: string }
>({
endpoint,
body: params,
})
.then(({ success, BadInput }) => ({
success: success ?? false,
errorMessage: BadInput,
}))
.catch((err) => ({
success: false,
@@ -70,10 +67,10 @@ export class CharactersApi extends ApiManager {
}: {
characterToken: string;
}): Promise<ApiResponse<Character>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/characters/character/${characterToken}`;
const endpoint = `${this.getApiSchemeAndHost()}/v1/character/${characterToken}`;
return this.get<{ success: boolean } & Character>({ endpoint })
.then(({ success, ...character }) => ({
return this.get<{ success: boolean; character: Character }>({ endpoint })
.then(({ success, character }) => ({
success,
data: character,
}))
@@ -83,46 +80,43 @@ export class CharactersApi extends ApiManager {
}));
}
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}`;
const endpoint = `${this.getApiSchemeAndHost()}/v1/character/${characterToken}`;
return this.delete<null, { success?: boolean; BadInput?: string }>({
return this.delete<null, { success: boolean }>({
endpoint,
})
.then(({ success, BadInput }) => ({
.then(({ success }) => ({
success: success ?? false,
errorMessage: BadInput,
}))
.catch((err) => ({
success: false,
errorMessage: err.message,
}));
}
public ListCharacters(
params?: ListCharactersRequest,
): Promise<ApiResponse<Character[], { next_cursor?: number | null }>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/characters/session`;
const query = params?.cursor
? { cursor: params.cursor.toString() }
: undefined;
return this.get<{
success: boolean;
characters: Character[];
next_cursor?: number | null;
}>({ endpoint, query })
.then((response) => ({
success: response.success,
data: response.characters,
pagination: { next_cursor: response.next_cursor },
}))
.catch((err) => ({
success: false,
+14 -7
View File
@@ -1,10 +1,17 @@
export interface MediaFileLinks {
cdn_url: string;
maybe_thumbnail_template?: string | null;
maybe_video_previews?: {
cdn_url: string;
maybe_thumbnail_template?: string | null;
} | null;
}
export interface Character {
character_token: string;
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;
maybe_description?: string | null;
maybe_avatar?: MediaFileLinks | null;
maybe_full_image?: MediaFileLinks | null;
models: string[];
}
@@ -12,9 +12,13 @@ import {
faXmark,
} from "@fortawesome/pro-solid-svg-icons";
import { twMerge } from "tailwind-merge";
import { CharactersApi, Character, downloadFileFromUrl } from "@storyteller/api";
import {
CharactersApi,
Character,
MediaUploadApi,
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";
@@ -50,20 +54,22 @@ export const CharactersModal = ({
isOpen={isOpen}
onClose={handleClose}
title={view === "list" ? "Characters" : undefined}
width={520}
className="max-h-[80vh]"
width={680}
className="max-h-[80vh] flex flex-col overflow-hidden"
>
{view === "list" ? (
<CharacterListView
onCreateClick={() => setView("create")}
onSelectCharacter={onSelectCharacter}
/>
) : (
<NewCharacterView
onBack={() => setView("list")}
onCreated={() => setView("list")}
/>
)}
<div className="min-h-0 flex-1 overflow-y-auto">
{view === "list" ? (
<CharacterListView
onCreateClick={() => setView("create")}
onSelectCharacter={onSelectCharacter}
/>
) : (
<NewCharacterView
onBack={() => setView("list")}
onCreated={() => setView("list")}
/>
)}
</div>
</Modal>
);
};
@@ -82,22 +88,20 @@ const CharacterListView = ({
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 [cursor, setCursor] = useState<number | undefined>(undefined);
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) => {
async (nextCursor?: number) => {
if (loadingMoreRef.current) return;
loadingMoreRef.current = true;
try {
const api = new CharactersApi();
const res = await api.ListCharacters({
pageSize: 20,
cursor: nextCursor,
});
@@ -107,16 +111,16 @@ const CharacterListView = ({
// Sync to global store for @-mention system
storeSetCharacters(
updated.map((c) => ({
character_token: c.character_token,
character_token: c.token,
name: c.name,
avatar_image_url: c.avatar_image_url,
avatar_image_url: c.maybe_avatar?.cdn_url,
})),
);
storeSetLoaded(true);
return updated;
});
const nextPage = res.pagination?.maybe_next;
setCursor(nextPage);
const nextPage = res.pagination?.next_cursor;
setCursor(nextPage ?? undefined);
setHasMore(!!nextPage);
}
} catch {
@@ -143,27 +147,14 @@ const CharacterListView = ({
fetchCharacters(cursor);
}
},
{ root: scrollContainerRef.current, threshold: 0.1 },
{ 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>
<div className="flex flex-col px-1">
{loading && characters.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-white/40">
<FontAwesomeIcon
@@ -172,26 +163,29 @@ const CharacterListView = ({
/>
<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">
<div className="grid grid-cols-4 gap-2">
{/* Create New card */}
<button
onClick={onCreateClick}
className="flex flex-col items-center justify-center gap-2 overflow-hidden rounded-lg border-2 border-dashed border-white/20 text-white/60 transition-colors hover:border-white/40 hover:text-white/80"
>
<div className="flex aspect-square w-full flex-col items-center justify-center gap-2">
<FontAwesomeIcon icon={faPlus} className="text-lg" />
<span className="text-sm font-medium">Create New</span>
</div>
</button>
{characters.map((character) => (
<button
key={character.character_token}
key={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 ? (
{character.maybe_avatar?.cdn_url ? (
<img
src={character.avatar_image_url}
src={character.maybe_avatar.cdn_url}
alt={character.name}
className="h-full w-full object-cover"
loading="lazy"
@@ -224,7 +218,6 @@ const CharacterListView = ({
/>
</div>
)}
</div>
</div>
);
};
@@ -377,16 +370,16 @@ const NewCharacterView = ({
try {
const api = new CharactersApi();
const res = await api.CreateCharacter({
name: name.trim(),
description: description.trim() || undefined,
image_media_file_token: uploadedImages[0]!.mediaToken!,
image_media_token: uploadedImages[0]!.mediaToken!,
model: "seedance_2p0",
uuid_idempotency_token: uuidv4(),
});
if (res.success) {
if (res.success && res.data) {
toast.success(`Character "${name.trim()}" created!`);
// Add to global store so it appears in @-mentions immediately
addCharacterToStore({
character_token: res.data?.character_token ?? "",
character_token: res.data.inference_job_token,
name: name.trim(),
avatar_image_url: uploadedImages[0]!.url,
});
@@ -153,14 +153,14 @@ export const PromptBoxVideo = ({
if (charactersLoaded) return;
const api = new CharactersApi();
api
.ListCharacters({ pageSize: 100 })
.ListCharacters()
.then((res) => {
if (res.success && res.data) {
storeSetCharacters(
res.data.map((c) => ({
character_token: c.character_token,
character_token: c.token,
name: c.name,
avatar_image_url: c.avatar_image_url,
avatar_image_url: c.maybe_avatar?.cdn_url,
})),
);
}
@@ -690,6 +690,14 @@ export const PromptBoxVideo = ({
);
}
// Extract character token from @-mention in prompt
const mentionedChar = storedCharacters.find((c) =>
prompt.includes(`@${c.name}`),
);
if (mentionedChar) {
request.character_token = mentionedChar.character_token;
}
// Pass duration if model supports it
if (selectedModel.durationOptions && duration !== null) {
request.duration_seconds = duration;
@@ -60,6 +60,9 @@ export interface EnqueueImageToVideoRequest {
// Optional. Reference audio media tokens (for audio reference).
reference_audio_media_tokens?: string[];
// Optional. Character token for character consistency.
character_token?: string;
}
interface RawEnqueueImageToVideoRequest {
@@ -78,6 +81,7 @@ interface RawEnqueueImageToVideoRequest {
reference_image_media_tokens?: string[];
reference_video_media_tokens?: string[];
reference_audio_media_tokens?: string[];
character_token?: string;
}
export interface EnqueueImageToVideoError extends CommandResult {
@@ -168,6 +172,10 @@ export const EnqueueImageToVideo = async (
request.reference_audio_media_tokens;
}
if (request.character_token) {
mutableRequest.character_token = request.character_token;
}
const result = await invoke("enqueue_image_to_video_command", {
request: mutableRequest,
});