mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-15 12:50:42 -07:00
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
589 lines
16 KiB
TypeScript
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();
|