Files
voicebox/app/src/lib/api/client.ts
T
Jamie Pine 655a60ca81 feat: async generation queue with serial execution
Generations now return immediately with a 'generating' status and appear
in history right away. TTS runs in a serial background queue to avoid
GPU contention. Users can kick off multiple generations without blocking.

- Async POST /generate creates DB record immediately, queues TTS work
- Serial generation queue prevents concurrent GPU access (Metal/CUDA/CPU)
- SSE endpoint GET /generate/{id}/status for real-time completion tracking
- Retry endpoint POST /generate/{id}/retry for failed generations
- Store engine and model_size on generation records for retry support
- History cards show animated loader (react-loaders) for generating/playing
- Failed generations show retry button instead of actions menu
- Model downloads happen inline in the queue instead of rejecting with 202
- Stale 'generating' records marked as failed on server startup
- Autoplay on generate setting (default: on)
- Show engine name on generation cards
- Remove sidebar generation spinner
- Checkbox alignment fix in settings
2026-03-13 10:02:41 -07:00

589 lines
16 KiB
TypeScript

import type { LanguageCode } from '@/lib/constants/languages';
import { useServerStore } from '@/stores/serverStore';
import type {
ActiveTasksResponse,
CudaStatus,
GenerationRequest,
GenerationResponse,
HealthResponse,
HistoryListResponse,
HistoryQuery,
HistoryResponse,
ModelDownloadRequest,
ModelStatusListResponse,
ProfileSampleResponse,
StoryCreate,
StoryDetailResponse,
StoryItemBatchUpdate,
StoryItemCreate,
StoryItemDetail,
StoryItemMove,
StoryItemReorder,
StoryItemSplit,
StoryItemTrim,
StoryResponse,
TranscriptionResponse,
VoiceProfileCreate,
VoiceProfileResponse,
} from './types';
class ApiClient {
private getBaseUrl(): string {
const serverUrl = useServerStore.getState().serverUrl;
return serverUrl;
}
private async request<T>(endpoint: string, options?: RequestInit): Promise<T> {
const url = `${this.getBaseUrl()}${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({
detail: response.statusText,
}));
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
}
return response.json();
}
// Health
async getHealth(): Promise<HealthResponse> {
return this.request<HealthResponse>('/health');
}
// Profiles
async createProfile(data: VoiceProfileCreate): Promise<VoiceProfileResponse> {
return this.request<VoiceProfileResponse>('/profiles', {
method: 'POST',
body: JSON.stringify(data),
});
}
async listProfiles(): Promise<VoiceProfileResponse[]> {
return this.request<VoiceProfileResponse[]>('/profiles');
}
async getProfile(profileId: string): Promise<VoiceProfileResponse> {
return this.request<VoiceProfileResponse>(`/profiles/${profileId}`);
}
async updateProfile(profileId: string, data: VoiceProfileCreate): Promise<VoiceProfileResponse> {
return this.request<VoiceProfileResponse>(`/profiles/${profileId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async deleteProfile(profileId: string): Promise<void> {
await this.request<void>(`/profiles/${profileId}`, {
method: 'DELETE',
});
}
async addProfileSample(
profileId: string,
file: File,
referenceText: string,
): Promise<ProfileSampleResponse> {
const url = `${this.getBaseUrl()}/profiles/${profileId}/samples`;
const formData = new FormData();
formData.append('file', file);
formData.append('reference_text', referenceText);
const response = await fetch(url, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const error = await response.json().catch(() => ({
detail: response.statusText,
}));
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
}
return response.json();
}
async listProfileSamples(profileId: string): Promise<ProfileSampleResponse[]> {
return this.request<ProfileSampleResponse[]>(`/profiles/${profileId}/samples`);
}
async deleteProfileSample(sampleId: string): Promise<void> {
await this.request<void>(`/profiles/samples/${sampleId}`, {
method: 'DELETE',
});
}
async updateProfileSample(
sampleId: string,
referenceText: string,
): Promise<ProfileSampleResponse> {
return this.request<ProfileSampleResponse>(`/profiles/samples/${sampleId}`, {
method: 'PUT',
body: JSON.stringify({ reference_text: referenceText }),
});
}
async exportProfile(profileId: string): Promise<Blob> {
const url = `${this.getBaseUrl()}/profiles/${profileId}/export`;
const response = await fetch(url);
if (!response.ok) {
const error = await response.json().catch(() => ({
detail: response.statusText,
}));
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
}
return response.blob();
}
async importProfile(file: File): Promise<VoiceProfileResponse> {
const url = `${this.getBaseUrl()}/profiles/import`;
const formData = new FormData();
formData.append('file', file);
const response = await fetch(url, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const error = await response.json().catch(() => ({
detail: response.statusText,
}));
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
}
return response.json();
}
async uploadAvatar(profileId: string, file: File): Promise<VoiceProfileResponse> {
const url = `${this.getBaseUrl()}/profiles/${profileId}/avatar`;
const formData = new FormData();
formData.append('file', file);
const response = await fetch(url, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const error = await response.json().catch(() => ({
detail: response.statusText,
}));
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
}
return response.json();
}
async deleteAvatar(profileId: string): Promise<void> {
await this.request<void>(`/profiles/${profileId}/avatar`, {
method: 'DELETE',
});
}
// Generation
async generateSpeech(data: GenerationRequest): Promise<GenerationResponse> {
return this.request<GenerationResponse>('/generate', {
method: 'POST',
body: JSON.stringify(data),
});
}
async retryGeneration(generationId: string): Promise<GenerationResponse> {
return this.request<GenerationResponse>(`/generate/${generationId}/retry`, {
method: 'POST',
});
}
// History
async listHistory(query?: HistoryQuery): Promise<HistoryListResponse> {
const params = new URLSearchParams();
if (query?.profile_id) params.append('profile_id', query.profile_id);
if (query?.search) params.append('search', query.search);
if (query?.limit) params.append('limit', query.limit.toString());
if (query?.offset) params.append('offset', query.offset.toString());
const queryString = params.toString();
const endpoint = queryString ? `/history?${queryString}` : '/history';
return this.request<HistoryListResponse>(endpoint);
}
async getGeneration(generationId: string): Promise<HistoryResponse> {
return this.request<HistoryResponse>(`/history/${generationId}`);
}
async deleteGeneration(generationId: string): Promise<void> {
await this.request<void>(`/history/${generationId}`, {
method: 'DELETE',
});
}
async exportGeneration(generationId: string): Promise<Blob> {
const url = `${this.getBaseUrl()}/history/${generationId}/export`;
const response = await fetch(url);
if (!response.ok) {
const error = await response.json().catch(() => ({
detail: response.statusText,
}));
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
}
return response.blob();
}
async exportGenerationAudio(generationId: string): Promise<Blob> {
const url = `${this.getBaseUrl()}/history/${generationId}/export-audio`;
const response = await fetch(url);
if (!response.ok) {
const error = await response.json().catch(() => ({
detail: response.statusText,
}));
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
}
return response.blob();
}
async importGeneration(file: File): Promise<{
id: string;
profile_id: string;
profile_name: string;
text: string;
message: string;
}> {
const url = `${this.getBaseUrl()}/history/import`;
const formData = new FormData();
formData.append('file', file);
const response = await fetch(url, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const error = await response.json().catch(() => ({
detail: response.statusText,
}));
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
}
return response.json();
}
// Generation status SSE
getGenerationStatusUrl(generationId: string): string {
return `${this.getBaseUrl()}/generate/${generationId}/status`;
}
// Audio
getAudioUrl(audioId: string): string {
return `${this.getBaseUrl()}/audio/${audioId}`;
}
getSampleUrl(sampleId: string): string {
return `${this.getBaseUrl()}/samples/${sampleId}`;
}
// Transcription
async transcribeAudio(file: File, language?: LanguageCode): Promise<TranscriptionResponse> {
const formData = new FormData();
formData.append('file', file);
if (language) {
formData.append('language', language);
}
const url = `${this.getBaseUrl()}/transcribe`;
const response = await fetch(url, {
method: 'POST',
body: formData,
});
if (!response.ok) {
const error = await response.json().catch(() => ({
detail: response.statusText,
}));
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
}
return response.json();
}
// Model Management
async getModelStatus(): Promise<ModelStatusListResponse> {
return this.request<ModelStatusListResponse>('/models/status');
}
async getModelsCacheDir(): Promise<{ path: string }> {
return this.request<{ path: string }>('/models/cache-dir');
}
async migrateModels(destination: string): Promise<{ source: string; destination: string }> {
return this.request('/models/migrate', {
method: 'POST',
body: JSON.stringify({ destination }),
});
}
getMigrationProgressUrl(): string {
return `${this.getBaseUrl()}/models/migrate/progress`;
}
async triggerModelDownload(modelName: string): Promise<{ message: string }> {
console.log(
'[API] triggerModelDownload called for:',
modelName,
'at',
new Date().toISOString(),
);
const result = await this.request<{ message: string }>('/models/download', {
method: 'POST',
body: JSON.stringify({ model_name: modelName } as ModelDownloadRequest),
});
console.log('[API] triggerModelDownload response:', result);
return result;
}
async deleteModel(modelName: string): Promise<{ message: string }> {
return this.request<{ message: string }>(`/models/${modelName}`, {
method: 'DELETE',
});
}
async unloadModel(modelName: string): Promise<{ message: string }> {
return this.request<{ message: string }>(`/models/${modelName}/unload`, {
method: 'POST',
});
}
async cancelDownload(modelName: string): Promise<{ message: string }> {
return this.request<{ message: string }>('/models/download/cancel', {
method: 'POST',
body: JSON.stringify({ model_name: modelName } as ModelDownloadRequest),
});
}
// Task Management
async getActiveTasks(): Promise<ActiveTasksResponse> {
return this.request<ActiveTasksResponse>('/tasks/active');
}
async clearAllTasks(): Promise<{ message: string }> {
return this.request<{ message: string }>('/tasks/clear', { method: 'POST' });
}
// Audio Channels
async listChannels(): Promise<
Array<{
id: string;
name: string;
is_default: boolean;
device_ids: string[];
created_at: string;
}>
> {
return this.request('/channels');
}
async createChannel(data: { name: string; device_ids: string[] }): Promise<{
id: string;
name: string;
is_default: boolean;
device_ids: string[];
created_at: string;
}> {
return this.request('/channels', {
method: 'POST',
body: JSON.stringify(data),
});
}
async updateChannel(
channelId: string,
data: {
name?: string;
device_ids?: string[];
},
): Promise<{
id: string;
name: string;
is_default: boolean;
device_ids: string[];
created_at: string;
}> {
return this.request(`/channels/${channelId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async deleteChannel(channelId: string): Promise<{ message: string }> {
return this.request(`/channels/${channelId}`, {
method: 'DELETE',
});
}
async getChannelVoices(channelId: string): Promise<{ profile_ids: string[] }> {
return this.request(`/channels/${channelId}/voices`);
}
async setChannelVoices(channelId: string, profileIds: string[]): Promise<{ message: string }> {
return this.request(`/channels/${channelId}/voices`, {
method: 'PUT',
body: JSON.stringify({ profile_ids: profileIds }),
});
}
async getProfileChannels(profileId: string): Promise<{ channel_ids: string[] }> {
return this.request(`/profiles/${profileId}/channels`);
}
async setProfileChannels(profileId: string, channelIds: string[]): Promise<{ message: string }> {
return this.request(`/profiles/${profileId}/channels`, {
method: 'PUT',
body: JSON.stringify({ channel_ids: channelIds }),
});
}
// CUDA Backend Management
async getCudaStatus(): Promise<CudaStatus> {
return this.request<CudaStatus>('/backend/cuda-status');
}
async downloadCudaBackend(): Promise<{ message: string; progress_key: string }> {
return this.request<{ message: string; progress_key: string }>('/backend/download-cuda', {
method: 'POST',
});
}
async deleteCudaBackend(): Promise<{ message: string }> {
return this.request<{ message: string }>('/backend/cuda', {
method: 'DELETE',
});
}
// Stories
async listStories(): Promise<StoryResponse[]> {
return this.request<StoryResponse[]>('/stories');
}
async createStory(data: StoryCreate): Promise<StoryResponse> {
return this.request<StoryResponse>('/stories', {
method: 'POST',
body: JSON.stringify(data),
});
}
async getStory(storyId: string): Promise<StoryDetailResponse> {
return this.request<StoryDetailResponse>(`/stories/${storyId}`);
}
async updateStory(storyId: string, data: StoryCreate): Promise<StoryResponse> {
return this.request<StoryResponse>(`/stories/${storyId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async deleteStory(storyId: string): Promise<void> {
await this.request<void>(`/stories/${storyId}`, {
method: 'DELETE',
});
}
async addStoryItem(storyId: string, data: StoryItemCreate): Promise<StoryItemDetail> {
return this.request<StoryItemDetail>(`/stories/${storyId}/items`, {
method: 'POST',
body: JSON.stringify(data),
});
}
async removeStoryItem(storyId: string, itemId: string): Promise<void> {
await this.request<void>(`/stories/${storyId}/items/${itemId}`, {
method: 'DELETE',
});
}
async updateStoryItemTimes(storyId: string, data: StoryItemBatchUpdate): Promise<void> {
await this.request<void>(`/stories/${storyId}/items/times`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async reorderStoryItems(storyId: string, data: StoryItemReorder): Promise<StoryItemDetail[]> {
return this.request<StoryItemDetail[]>(`/stories/${storyId}/items/reorder`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async moveStoryItem(
storyId: string,
itemId: string,
data: StoryItemMove,
): Promise<StoryItemDetail> {
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/move`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async trimStoryItem(
storyId: string,
itemId: string,
data: StoryItemTrim,
): Promise<StoryItemDetail> {
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/trim`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async splitStoryItem(
storyId: string,
itemId: string,
data: StoryItemSplit,
): Promise<StoryItemDetail[]> {
return this.request<StoryItemDetail[]>(`/stories/${storyId}/items/${itemId}/split`, {
method: 'POST',
body: JSON.stringify(data),
});
}
async duplicateStoryItem(storyId: string, itemId: string): Promise<StoryItemDetail> {
return this.request<StoryItemDetail>(`/stories/${storyId}/items/${itemId}/duplicate`, {
method: 'POST',
});
}
async exportStoryAudio(storyId: string): Promise<Blob> {
const url = `${this.getBaseUrl()}/stories/${storyId}/export-audio`;
const response = await fetch(url);
if (!response.ok) {
const error = await response.json().catch(() => ({
detail: response.statusText,
}));
throw new Error(error.detail || `HTTP error! status: ${response.status}`);
}
return response.blob();
}
}
export const apiClient = new ApiClient();