diff --git a/.gitignore b/.gitignore index 7cc8fbce..2cbd3e5c 100644 --- a/.gitignore +++ b/.gitignore @@ -50,7 +50,6 @@ logs/ .env.local # Generated files -app/src/lib/api/ app/openapi.json tauri/src-tauri/binaries/* diff --git a/app/src/lib/api/.gitkeep b/app/src/lib/api/.gitkeep new file mode 100644 index 00000000..8a6dcd77 --- /dev/null +++ b/app/src/lib/api/.gitkeep @@ -0,0 +1 @@ +# Generated OpenAPI client will be placed here diff --git a/app/src/lib/api/client.ts b/app/src/lib/api/client.ts new file mode 100644 index 00000000..2f0ca9cc --- /dev/null +++ b/app/src/lib/api/client.ts @@ -0,0 +1,201 @@ +import { useServerStore } from '@/stores/serverStore'; +import type { + VoiceProfileCreate, + VoiceProfileResponse, + ProfileSampleResponse, + GenerationRequest, + GenerationResponse, + HistoryQuery, + HistoryListResponse, + HistoryResponse, + TranscriptionResponse, + HealthResponse, + ModelStatusListResponse, + ModelDownloadRequest, +} from './types'; + +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(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 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', + }); + } + + 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(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', + }); + } + + // Generation + async generateSpeech( + data: GenerationRequest, + ): Promise { + return this.request('/generate', { + method: 'POST', + body: JSON.stringify(data), + }); + } + + // 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', + }); + } + + // Audio + getAudioUrl(audioId: string): string { + return `${this.getBaseUrl()}/audio/${audioId}`; + } + + // Transcription + async transcribeAudio( + file: File, + language?: 'en' | 'zh', + ): Promise { + 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 { + return this.request('/models/status'); + } + + async triggerModelDownload(modelName: string): Promise<{ message: string }> { + return this.request<{ message: string }>('/models/download', { + method: 'POST', + body: JSON.stringify({ model_name: modelName } as ModelDownloadRequest), + }); + } +} + +export const apiClient = new ApiClient(); diff --git a/app/src/lib/api/types.ts b/app/src/lib/api/types.ts new file mode 100644 index 00000000..bde0c1d0 --- /dev/null +++ b/app/src/lib/api/types.ts @@ -0,0 +1,107 @@ +// API Types matching backend Pydantic models + +export interface VoiceProfileCreate { + name: string; + description?: string; + language: 'en' | 'zh'; +} + +export interface VoiceProfileResponse { + id: string; + name: string; + description?: string; + language: string; + created_at: string; + updated_at: string; +} + +export interface ProfileSampleCreate { + reference_text: string; +} + +export interface ProfileSampleResponse { + id: string; + profile_id: string; + audio_path: string; + reference_text: string; +} + +export interface GenerationRequest { + profile_id: string; + text: string; + language: 'en' | 'zh'; + seed?: number; + model_size?: '1.7B' | '0.6B'; +} + +export interface GenerationResponse { + id: string; + profile_id: string; + text: string; + language: string; + audio_path: string; + duration: number; + seed?: number; + created_at: string; +} + +export interface HistoryQuery { + profile_id?: string; + search?: string; + limit?: number; + offset?: number; +} + +export interface HistoryResponse extends GenerationResponse { + profile_name: string; +} + +export interface HistoryListResponse { + items: HistoryResponse[]; + total: number; +} + +export interface TranscriptionRequest { + language?: 'en' | 'zh'; +} + +export interface TranscriptionResponse { + text: string; + duration: number; +} + +export interface HealthResponse { + status: string; + model_loaded: boolean; + model_downloaded?: boolean; + model_size?: string; + gpu_available: boolean; + vram_used_mb?: number; +} + +export interface ModelProgress { + model_name: string; + current: number; + total: number; + progress: number; + filename?: string; + status: 'downloading' | 'extracting' | 'complete' | 'error'; + timestamp: string; + error?: string; +} + +export interface ModelStatus { + model_name: string; + display_name: string; + downloaded: boolean; + size_mb?: number; + loaded: boolean; +} + +export interface ModelStatusListResponse { + models: ModelStatus[]; +} + +export interface ModelDownloadRequest { + model_name: string; +}