mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-14 13:52:11 +00:00
characters update to use real api
This commit is contained in:
1 parent
0b8aea44af
commit
c644e378fb
5 files changed
+143
-133
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user