Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,14 @@ Thumbs.db
data/profiles/*
data/generations/*
data/projects/*
data/cache/
data/finetune/
data/voicebox.db
!data/.gitkeep

# Model binaries (downloaded at runtime)
backend/models/

# Logs
*.log
logs/
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Audio export failing when Tauri save dialog returns object instead of string path

### Added
- **Hebrew Language Support** - Full Hebrew voice cloning and transcription
- Chatterbox Multilingual TTS backend (`ResembleAI/chatterbox`) for Hebrew voice generation
- ivrit-ai Whisper models (`ivrit-ai/whisper-large-v3-turbo`) for Hebrew transcription
- Automatic backend routing: Hebrew requests use Chatterbox, all other languages use Qwen3-TTS
- Auto-download of Hebrew models on first use with progress tracking
- Trailing silence trimming for Chatterbox output
- **Model Unloading** - New `/models/{model_name}/unload` endpoint to free memory for individual models
- **Makefile** - Comprehensive development workflow automation with commands for setup, development, building, testing, and code quality checks
- Includes Python version detection and compatibility warnings
- Self-documenting help system with `make help`
Expand Down
171 changes: 171 additions & 0 deletions app/src/components/FinetuneTab/AdapterSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { useState } from 'react';
import { Check, Pencil, Trash2, Zap } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useToast } from '@/components/ui/use-toast';
import type { AdapterInfo } from '@/lib/api/types';
import { useAdapters, useSetActiveAdapter, useUpdateAdapterLabel, useDeleteAdapter } from '@/lib/hooks/useFinetune';
import { adapterDisplayName } from '@/lib/utils/adapters';

interface AdapterSelectorProps {
profileId: string;
}

function formatDate(dateStr?: string) {
if (!dateStr) return '';
const d = new Date(dateStr);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}

export function AdapterSelector({ profileId }: AdapterSelectorProps) {
const { toast } = useToast();
const { data: adapters, isLoading } = useAdapters(profileId);
const setActiveAdapter = useSetActiveAdapter();
const updateLabel = useUpdateAdapterLabel();
const deleteAdapter = useDeleteAdapter();

const [editingId, setEditingId] = useState<string | null>(null);
const [editValue, setEditValue] = useState('');

if (isLoading || !adapters || adapters.length === 0) return null;

const activeAdapter = adapters.find((a) => a.is_active);
const currentValue = activeAdapter?.job_id ?? 'none';

const handleChange = async (value: string) => {
const jobId = value === 'none' ? null : value;
try {
await setActiveAdapter.mutateAsync({ profileId, jobId });
toast({
title: jobId ? 'Adapter activated' : 'Adapter deactivated',
description: jobId ? 'Generation will use the selected adapter.' : 'Generation will use the base model.',
});
} catch (error) {
toast({
title: 'Failed to switch adapter',
description: error instanceof Error ? error.message : 'Error',
variant: 'destructive',
});
}
};

const handleStartEdit = (adapter: AdapterInfo) => {
setEditingId(adapter.job_id);
setEditValue(adapter.label || '');
};

const handleSaveLabel = async (jobId: string) => {
if (!editValue.trim()) {
setEditingId(null);
return;
}
try {
await updateLabel.mutateAsync({ profileId, jobId, label: editValue.trim() });
setEditingId(null);
} catch {
toast({ title: 'Failed to rename adapter', variant: 'destructive' });
}
};

const handleDelete = async (adapter: AdapterInfo) => {
const confirmed = window.confirm(
`Delete adapter "${adapterDisplayName(adapter)}"? This permanently removes the trained model files and cannot be undone.`,
);
if (!confirmed) return;

try {
await deleteAdapter.mutateAsync({ profileId, jobId: adapter.job_id });
toast({ title: 'Adapter deleted' });
} catch {
toast({ title: 'Failed to delete adapter', variant: 'destructive' });
}
};

return (
<div className="flex flex-col gap-3 border rounded-lg p-4">
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-accent" />
<h3 className="text-sm font-semibold">Active Adapter</h3>
</div>

<Select value={currentValue} onValueChange={handleChange} disabled={setActiveAdapter.isPending}>
<SelectTrigger className="h-9">
<SelectValue placeholder="Select adapter..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None (base model)</SelectItem>
{adapters.map((adapter) => (
<SelectItem key={adapter.job_id} value={adapter.job_id}>
{adapterDisplayName(adapter)}
</SelectItem>
))}
</SelectContent>
</Select>

{/* Adapter list with management actions */}
<div className="space-y-2">
{adapters.map((adapter) => (
<div
key={adapter.job_id}
className={`flex items-center gap-2 rounded-md px-3 py-2 text-sm ${
adapter.is_active ? 'bg-accent/10 border border-accent/20' : 'bg-muted/50'
}`}
>
{editingId === adapter.job_id ? (
<div className="flex items-center gap-1 flex-1">
<Input
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveLabel(adapter.job_id);
if (e.key === 'Escape') setEditingId(null);
}}
className="h-7 text-xs"
autoFocus
/>
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => handleSaveLabel(adapter.job_id)}>
<Check className="h-3 w-3" />
</Button>
</div>
) : (
<>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{adapterDisplayName(adapter)}</p>
<p className="text-xs text-muted-foreground">
{formatDate(adapter.completed_at)}
{adapter.is_active && ' \u00B7 Active'}
</p>
</div>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 shrink-0"
onClick={() => handleStartEdit(adapter)}
title="Rename"
>
<Pencil className="h-3 w-3" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 shrink-0 text-destructive hover:text-destructive"
onClick={() => handleDelete(adapter)}
title="Delete adapter"
>
<Trash2 className="h-3 w-3" />
</Button>
</>
)}
</div>
))}
</div>
</div>
);
}
42 changes: 42 additions & 0 deletions app/src/components/FinetuneTab/FinetuneProgressToast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Loader2 } from 'lucide-react';
import { Progress } from '@/components/ui/progress';
import type { ActiveFinetuneTask } from '@/lib/api/types';

interface FinetuneProgressToastProps {
task: ActiveFinetuneTask;
}

export function FinetuneProgressToast({ task }: FinetuneProgressToastProps) {
const progressPercent =
task.total_steps > 0
? Math.round((task.current_step / task.total_steps) * 100)
: 0;

return (
<div className="fixed bottom-4 right-4 z-50 bg-card border rounded-lg shadow-lg p-4 w-80 space-y-2">
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-accent" />
<span className="font-medium text-sm">Fine-tuning in progress</span>
</div>

{task.status === 'training' && (
<>
<Progress value={progressPercent} className="h-1.5" />
<div className="flex justify-between text-xs text-muted-foreground">
<span>
Epoch {task.current_epoch}/{task.total_epochs}
</span>
<span>{progressPercent}%</span>
</div>
{task.current_loss != null && (
<p className="text-xs text-muted-foreground">Loss: {task.current_loss.toFixed(4)}</p>
)}
</>
)}

{task.status === 'preparing' && (
<p className="text-xs text-muted-foreground">Preparing dataset...</p>
)}
</div>
);
}
Loading