import type { LanguageCode } from '@/lib/constants/languages'; import { useServerStore } from '@/stores/serverStore'; import type { ActiveTasksResponse, ApplyEffectsRequest, AvailableEffectsResponse, CudaStatus, EffectConfig, EffectPresetCreate, EffectPresetResponse, GenerationRequest, GenerationResponse, GenerationVersionResponse, HealthResponse, HistoryListResponse, HistoryQuery, HistoryResponse, ModelDownloadRequest, ModelStatusListResponse, PresetVoice, PersonalityTextResponse, ProfileSampleResponse, StoryCreate, StoryDetailResponse, StoryItemBatchUpdate, StoryItemCreate, StoryItemDetail, StoryItemMove, StoryItemReorder, StoryItemSplit, StoryItemTrim, StoryItemVersionUpdate, StoryItemVolumeUpdate, StoryResponse, TranscriptionResponse, VoiceProfileCreate, VoiceProfileResponse, WhisperModelSize, CaptureListResponse, CaptureResponse, CaptureCreateResponse, CaptureReadinessResponse, CaptureRefineRequest, CaptureRetranscribeRequest, CaptureSettings, CaptureSettingsUpdate, CaptureSource, GenerationSettings, GenerationSettingsUpdate, MCPClientBinding, MCPClientBindingListResponse, MCPClientBindingUpsert, } from './types'; function formatErrorDetail(detail: unknown, fallback: string): string { if (typeof detail === 'string') return detail; if (Array.isArray(detail)) { return detail .map((e: Record) => e.msg || e.message || JSON.stringify(e)) .join('; '); } if (detail && typeof detail === 'object') { const obj = detail as Record; if (typeof obj.message === 'string') return obj.message; return JSON.stringify(detail); } return fallback; } class ApiClient { private getBaseUrl(): string { const serverUrl = useServerStore.getState().serverUrl; return serverUrl; } private async request(endpoint: string, options?: RequestInit): Promise { 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(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`)); } return response.json(); } // Health async getHealth(): Promise { return this.request('/health'); } // Profiles async createProfile(data: VoiceProfileCreate): Promise { return this.request('/profiles', { method: 'POST', body: JSON.stringify(data), }); } async listProfiles(): Promise { return this.request('/profiles'); } async getProfile(profileId: string): Promise { return this.request(`/profiles/${profileId}`); } async listPresetVoices(engine: string): Promise<{ engine: string; voices: PresetVoice[] }> { return this.request<{ engine: string; voices: PresetVoice[] }>(`/profiles/presets/${engine}`); } async updateProfile(profileId: string, data: VoiceProfileCreate): Promise { return this.request(`/profiles/${profileId}`, { method: 'PUT', body: JSON.stringify(data), }); } async deleteProfile(profileId: string): Promise { await this.request(`/profiles/${profileId}`, { method: 'DELETE', }); } // ── Personality-driven text generation ───────────────────────────── // Compose produces a fresh in-character utterance the UI drops into // the generate textarea. Rewrite now happens server-side inside // `/generate` when `personality: true` is passed in the request body. async composeWithPersonality(profileId: string): Promise { return this.request(`/profiles/${profileId}/compose`, { method: 'POST', }); } async addProfileSample( profileId: string, file: File, referenceText: string, ): Promise { 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(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`)); } return response.json(); } async listProfileSamples(profileId: string): Promise { return this.request(`/profiles/${profileId}/samples`); } async deleteProfileSample(sampleId: string): Promise { await this.request(`/profiles/samples/${sampleId}`, { method: 'DELETE', }); } async updateProfileSample( sampleId: string, referenceText: string, ): Promise { return this.request(`/profiles/samples/${sampleId}`, { method: 'PUT', body: JSON.stringify({ reference_text: referenceText }), }); } async exportProfile(profileId: string): Promise { 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(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`)); } return response.blob(); } async importProfile(file: File): Promise { 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(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`)); } return response.json(); } async uploadAvatar(profileId: string, file: File): Promise { 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(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`)); } return response.json(); } async deleteAvatar(profileId: string): Promise { await this.request(`/profiles/${profileId}/avatar`, { method: 'DELETE', }); } // Generation async generateSpeech(data: GenerationRequest): Promise { return this.request('/generate', { method: 'POST', body: JSON.stringify(data), }); } async retryGeneration(generationId: string): Promise { return this.request(`/generate/${generationId}/retry`, { method: 'POST', }); } async cancelGeneration(generationId: string): Promise<{ message: string }> { return this.request<{ message: string }>(`/generate/${generationId}/cancel`, { method: 'POST', }); } async regenerateGeneration(generationId: string): Promise { return this.request(`/generate/${generationId}/regenerate`, { method: 'POST', }); } async importAudio(file: File): Promise { const form = new FormData(); form.append('file', file); const res = await fetch(`${this.getBaseUrl()}/generate/import`, { method: 'POST', body: form, }); if (!res.ok) { const detail = await res.text().catch(() => res.statusText); throw new Error(detail || `HTTP ${res.status}`); } return res.json(); } async toggleFavorite(generationId: string): Promise<{ is_favorited: boolean }> { return this.request<{ is_favorited: boolean }>(`/history/${generationId}/favorite`, { method: 'POST', }); } // History async listHistory(query?: HistoryQuery): Promise { 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(endpoint); } async getGeneration(generationId: string): Promise { return this.request(`/history/${generationId}`); } async deleteGeneration(generationId: string): Promise { await this.request(`/history/${generationId}`, { method: 'DELETE', }); } async clearFailedGenerations(): Promise<{ deleted: number }> { return this.request<{ deleted: number }>(`/history/failed`, { method: 'DELETE', }); } async exportGeneration(generationId: string): Promise { 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(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`)); } return response.blob(); } async exportGenerationAudio(generationId: string): Promise { 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(formatErrorDetail(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(formatErrorDetail(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, model?: WhisperModelSize, ): Promise { const formData = new FormData(); formData.append('file', file); if (language) { formData.append('language', language); } if (model) { formData.append('model', model); } 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(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`)); } return response.json(); } // Captures async listCaptures(limit = 50, offset = 0): Promise { return this.request( `/captures?limit=${limit}&offset=${offset}`, ); } async getCapture(captureId: string): Promise { return this.request(`/captures/${captureId}`); } async createCapture( file: File, options?: { source?: CaptureSource; language?: LanguageCode; sttModel?: WhisperModelSize; }, ): Promise { const formData = new FormData(); formData.append('file', file); formData.append('source', options?.source ?? 'file'); if (options?.language) formData.append('language', options.language); if (options?.sttModel) formData.append('stt_model', options.sttModel); const url = `${this.getBaseUrl()}/captures`; const response = await fetch(url, { method: 'POST', body: formData }); if (!response.ok) { const error = await response.json().catch(() => ({ detail: response.statusText, })); throw new Error(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`)); } return response.json(); } async deleteCapture(captureId: string): Promise<{ message: string }> { return this.request<{ message: string }>(`/captures/${captureId}`, { method: 'DELETE', }); } async refineCapture( captureId: string, body: CaptureRefineRequest, ): Promise { return this.request(`/captures/${captureId}/refine`, { method: 'POST', body: JSON.stringify(body), }); } async retranscribeCapture( captureId: string, body: CaptureRetranscribeRequest, ): Promise { return this.request(`/captures/${captureId}/retranscribe`, { method: 'POST', body: JSON.stringify(body), }); } getCaptureAudioUrl(captureId: string): string { return `${this.getBaseUrl()}/captures/${captureId}/audio`; } // Settings async getCaptureSettings(): Promise { return this.request('/settings/captures'); } async getCaptureReadiness(): Promise { return this.request('/capture/readiness'); } async updateCaptureSettings(patch: CaptureSettingsUpdate): Promise { return this.request('/settings/captures', { method: 'PUT', body: JSON.stringify(patch), }); } async getGenerationSettings(): Promise { return this.request('/settings/generation'); } async updateGenerationSettings( patch: GenerationSettingsUpdate, ): Promise { return this.request('/settings/generation', { method: 'PUT', body: JSON.stringify(patch), }); } // MCP bindings — per-MCP-client voice/engine/personality mapping. async listMCPBindings(): Promise { return this.request('/mcp/bindings'); } async upsertMCPBinding( data: MCPClientBindingUpsert, ): Promise { return this.request('/mcp/bindings', { method: 'PUT', body: JSON.stringify(data), }); } async deleteMCPBinding(clientId: string): Promise<{ deleted: string }> { return this.request<{ deleted: string }>( `/mcp/bindings/${encodeURIComponent(clientId)}`, { method: 'DELETE' }, ); } // Model Management async getModelStatus(): Promise { return this.request('/models/status'); } async getModelsCacheDir(): Promise<{ path: string }> { return this.request<{ path: string }>('/models/cache-dir'); } async migrateModels( destination: string, ): Promise<{ source: string; destination: string; moved: number; errors: 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 { return this.request('/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 { return this.request('/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 { return this.request('/stories'); } async createStory(data: StoryCreate): Promise { return this.request('/stories', { method: 'POST', body: JSON.stringify(data), }); } async getStory(storyId: string): Promise { return this.request(`/stories/${storyId}`); } async updateStory(storyId: string, data: StoryCreate): Promise { return this.request(`/stories/${storyId}`, { method: 'PUT', body: JSON.stringify(data), }); } async deleteStory(storyId: string): Promise { await this.request(`/stories/${storyId}`, { method: 'DELETE', }); } async addStoryItem(storyId: string, data: StoryItemCreate): Promise { return this.request(`/stories/${storyId}/items`, { method: 'POST', body: JSON.stringify(data), }); } async removeStoryItem(storyId: string, itemId: string): Promise { await this.request(`/stories/${storyId}/items/${itemId}`, { method: 'DELETE', }); } async updateStoryItemTimes(storyId: string, data: StoryItemBatchUpdate): Promise { await this.request(`/stories/${storyId}/items/times`, { method: 'PUT', body: JSON.stringify(data), }); } async reorderStoryItems(storyId: string, data: StoryItemReorder): Promise { return this.request(`/stories/${storyId}/items/reorder`, { method: 'PUT', body: JSON.stringify(data), }); } async moveStoryItem( storyId: string, itemId: string, data: StoryItemMove, ): Promise { return this.request(`/stories/${storyId}/items/${itemId}/move`, { method: 'PUT', body: JSON.stringify(data), }); } async trimStoryItem( storyId: string, itemId: string, data: StoryItemTrim, ): Promise { return this.request(`/stories/${storyId}/items/${itemId}/trim`, { method: 'PUT', body: JSON.stringify(data), }); } async updateStoryItemVolume( storyId: string, itemId: string, data: StoryItemVolumeUpdate, ): Promise { return this.request(`/stories/${storyId}/items/${itemId}/volume`, { method: 'PUT', body: JSON.stringify(data), }); } async splitStoryItem( storyId: string, itemId: string, data: StoryItemSplit, ): Promise { return this.request(`/stories/${storyId}/items/${itemId}/split`, { method: 'POST', body: JSON.stringify(data), }); } async duplicateStoryItem(storyId: string, itemId: string): Promise { return this.request(`/stories/${storyId}/items/${itemId}/duplicate`, { method: 'POST', }); } async setStoryItemVersion( storyId: string, itemId: string, data: StoryItemVersionUpdate, ): Promise { return this.request(`/stories/${storyId}/items/${itemId}/version`, { method: 'PUT', body: JSON.stringify(data), }); } async exportStoryAudio(storyId: string): Promise { 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(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`)); } return response.blob(); } // Effects & Versions async getAvailableEffects(): Promise { return this.request('/effects/available'); } async listEffectPresets(): Promise { return this.request('/effects/presets'); } async createEffectPreset(data: EffectPresetCreate): Promise { return this.request('/effects/presets', { method: 'POST', body: JSON.stringify(data), }); } async updateEffectPreset( presetId: string, data: { name?: string; description?: string; effects_chain?: EffectConfig[] }, ): Promise { return this.request(`/effects/presets/${presetId}`, { method: 'PUT', body: JSON.stringify(data), }); } async deleteEffectPreset(presetId: string): Promise { await this.request(`/effects/presets/${presetId}`, { method: 'DELETE', }); } async listGenerationVersions(generationId: string): Promise { return this.request(`/generations/${generationId}/versions`); } async applyEffectsToGeneration( generationId: string, data: ApplyEffectsRequest, ): Promise { return this.request( `/generations/${generationId}/versions/apply-effects`, { method: 'POST', body: JSON.stringify(data), }, ); } async setDefaultVersion( generationId: string, versionId: string, ): Promise { return this.request( `/generations/${generationId}/versions/${versionId}/set-default`, { method: 'PUT' }, ); } async deleteGenerationVersion(generationId: string, versionId: string): Promise { await this.request(`/generations/${generationId}/versions/${versionId}`, { method: 'DELETE', }); } getVersionAudioUrl(versionId: string): string { return `${this.getBaseUrl()}/audio/version/${versionId}`; } async updateProfileEffects( profileId: string, effectsChain: EffectConfig[] | null, ): Promise { return this.request(`/profiles/${profileId}/effects`, { method: 'PUT', body: JSON.stringify({ effects_chain: effectsChain }), }); } async previewEffects(generationId: string, effectsChain: EffectConfig[]): Promise { const url = `${this.getBaseUrl()}/effects/preview/${generationId}`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ effects_chain: effectsChain }), }); if (!response.ok) { const error = await response.json().catch(() => ({ detail: response.statusText, })); throw new Error(formatErrorDetail(error.detail, `HTTP error! status: ${response.status}`)); } return response.blob(); } } export const apiClient = new ApiClient();