2026-02-26 16:38:23 -08:00
< script lang = "ts" >
2026-02-26 18:21:42 -08:00
import { invoke } from '@tauri-apps/api/core' ;
import { openUrl } from '@tauri-apps/plugin-opener' ;
2026-02-26 16:38:23 -08:00
import { settings , saveSettings , type AppSettings } from '$lib/stores/settings' ;
interface Props {
visible : boolean ;
onClose : () => void ;
}
let { visible , onClose } : Props = $props ();
let localSettings = $state < AppSettings >({ ... $settings });
2026-02-26 18:08:51 -08:00
let activeTab = $state < 'transcription' | 'speakers' | 'ai' | 'local' > ( 'transcription' );
2026-02-26 18:21:42 -08:00
let modelStatus = $state < 'idle' | 'downloading' | 'success' | 'error' > ( 'idle' );
let modelError = $state ( '' );
2026-03-20 21:33:43 -07:00
let revealedFields = $state < Set < string >>( new Set ());
2026-02-26 18:21:42 -08:00
async function testAndDownloadModel() {
if ( ! localSettings . hf_token ) {
modelStatus = 'error' ;
modelError = 'Please enter a HuggingFace token first.' ;
return ;
}
modelStatus = 'downloading' ;
modelError = '' ;
try {
const result = await invoke < { ok : boolean ; error? : string } > ( 'download_diarize_model' , {
hfToken : localSettings.hf_token ,
});
if ( result . ok ) {
modelStatus = 'success' ;
} else {
modelStatus = 'error' ;
modelError = result . error || 'Unknown error' ;
}
} catch ( err ) {
modelStatus = 'error' ;
modelError = String ( err );
}
}
2026-02-26 16:38:23 -08:00
// Sync when settings store changes
$effect (() => {
localSettings = { ... $settings };
});
async function handleSave() {
await saveSettings ( localSettings );
onClose ();
}
function handleCancel() {
localSettings = { ... $settings };
onClose ();
}
function handleOverlayClick ( e : MouseEvent ) {
if (( e . target as HTMLElement ). classList . contains ( 'modal-overlay' )) {
handleCancel ();
}
}
</ script >
{ #if visible }
<!-- svelte-ignore a11y_no_static_element_interactions -->
< div class = "modal-overlay" onclick = { handleOverlayClick } onkeydown= {( e ) => { if ( e . key === 'Escape' ) handleCancel (); }}>
< div class = "modal" >
< div class = "modal-header" >
< h2 > Settings</ h2 >
< button class = "close-btn" onclick = { handleCancel } > x</button >
</ div >
< div class = "tabs" >
< button class = "tab" class:active = { activeTab === 'transcription' } onclick= {() => activeTab = 'transcription' }>
Transcription
</ button >
2026-02-26 18:08:51 -08:00
< button class = "tab" class:active = { activeTab === 'speakers' } onclick= {() => activeTab = 'speakers' }>
Speakers
</ button >
2026-02-26 16:38:23 -08:00
< button class = "tab" class:active = { activeTab === 'ai' } onclick= {() => activeTab = 'ai' }>
AI Provider
</ button >
< button class = "tab" class:active = { activeTab === 'local' } onclick= {() => activeTab = 'local' }>
Local AI
</ button >
</ div >
< div class = "modal-body" >
{ #if activeTab === 'transcription' }
< div class = "field" >
< label for = "stt-model" > Whisper Model</ label >
< select id = "stt-model" bind:value = { localSettings . transcription_model } >
< option value = "tiny" > Tiny (fastest, least accurate)</ option >
< option value = "base" > Base (fast, good accuracy)</ option >
< option value = "small" > Small (balanced)</ option >
< option value = "medium" > Medium (slower, better accuracy)</ option >
< option value = "large-v3" > Large v3 (slowest, best accuracy)</ option >
</ select >
</ div >
< div class = "field" >
< label for = "stt-device" > Device</ label >
< select id = "stt-device" bind:value = { localSettings . transcription_device } >
< option value = "cpu" > CPU</ option >
< option value = "cuda" > CUDA (NVIDIA GPU)</ option >
</ select >
</ div >
< div class = "field" >
< label for = "stt-lang" > Language (blank = auto-detect)</ label >
< input id = "stt-lang" type = "text" bind:value = { localSettings . transcription_language } placeholder="e.g., en , es , fr " />
</ div >
2026-02-26 18:08:51 -08:00
{ :else if activeTab === 'speakers' }
< div class = "field" >
< label for = "hf-token" > HuggingFace Token</ label >
2026-03-20 21:33:43 -07:00
< div class = "input-reveal" >
< input id = "hf-token" type = { revealedFields . has ( 'hf-token' ) ? 'text' : 'password' } bind:value= { localSettings . hf_token } placeholder = "hf_..." />
< button type = "button" class = "reveal-btn" onclick = {() => { const s = new Set ( revealedFields ); s . has ( 'hf-token' ) ? s . delete ( 'hf-token' ) : s . add ( 'hf-token' ); revealedFields = s ; }} > { revealedFields . has ( 'hf-token' ) ? 'Hide' : 'Show' } </button >
</ div >
2026-02-26 18:08:51 -08:00
</ div >
< div class = "info-box" >
2026-02-26 18:21:42 -08:00
< p class = "info-title" > Setup (one-time)</ p >
< p > Speaker detection uses < strong > pyannote.audio</ strong > models hosted on HuggingFace. You must accept the license for each model:</ p >
2026-02-26 18:08:51 -08:00
< ol >
2026-02-26 18:21:42 -08:00
< li > Create a free account at <!-- svelte-ignore a11y_no_static_element_interactions --> < a class = "ext-link" onclick = {() => openUrl ( 'https://huggingface.co/join' )} > huggingface.co</a ></ li >
2026-02-26 19:46:07 -08:00
< li > Accept the license on < strong > all three</ strong > of these pages:
2026-02-26 18:21:42 -08:00
< ul >
<!-- svelte-ignore a11y_no_static_element_interactions -->
< li >< a class = "ext-link" onclick = {() => openUrl ( 'https://huggingface.co/pyannote/speaker-diarization-3.1' )} > pyannote/speaker-diarization-3.1</a ></ li >
<!-- svelte-ignore a11y_no_static_element_interactions -->
< li >< a class = "ext-link" onclick = {() => openUrl ( 'https://huggingface.co/pyannote/segmentation-3.0' )} > pyannote/segmentation-3.0</a ></ li >
2026-02-26 19:46:07 -08:00
<!-- svelte-ignore a11y_no_static_element_interactions -->
< li >< a class = "ext-link" onclick = {() => openUrl ( 'https://huggingface.co/pyannote/speaker-diarization-community-1' )} > pyannote/speaker-diarization-community-1</a ></ li >
2026-02-26 18:21:42 -08:00
</ ul >
</ li >
<!-- svelte-ignore a11y_no_static_element_interactions -->
< li > Create a token at < a class = "ext-link" onclick = {() => openUrl ( 'https://huggingface.co/settings/tokens' )} > huggingface.co/settings/tokens</a > (read access)</ li >
< li > Paste the token above and click < strong > Test & Download</ strong ></ li >
2026-02-26 18:08:51 -08:00
</ ol >
</ div >
2026-02-26 18:21:42 -08:00
< button
class = "btn-download"
onclick = { testAndDownloadModel }
disabled= { modelStatus === 'downloading' }
>
{ #if modelStatus === 'downloading' }
Downloading model...
{ : else }
Test & Download Model
{ /if }
</ button >
{ #if modelStatus === 'success' }
< p class = "status-success" > Model downloaded successfully. Speaker detection is ready.</ p >
{ /if }
{ #if modelStatus === 'error' }
< p class = "status-error" >{ modelError }</ p >
{ /if }
2026-03-20 21:33:43 -07:00
< div class = "field" style = "margin-top: 1rem;" >
< label for = "num-speakers" > Number of speakers</ label >
< select
id = "num-speakers"
value = { localSettings . num_speakers === null || localSettings . num_speakers === 0 ? '0' : String ( localSettings . num_speakers )}
onchange= {( e ) => {
const v = parseInt (( e . target as HTMLSelectElement ). value , 10 );
localSettings . num_speakers = v === 0 ? null : v ;
}}
>
< option value = "0" > Auto-detect</ option >
{ #each Array . from ({ length : 20 }, ( _ , i ) => i + 1 ) as n }
< option value = { String ( n )} > { n } </option >
{ /each }
</ select >
< p class = "hint" > Hint the expected number of speakers to speed up diarization clustering.</ p >
</ div >
2026-02-26 18:21:42 -08:00
< div class = "field checkbox" style = "margin-top: 1rem;" >
2026-02-26 16:38:23 -08:00
< label >
< input type = "checkbox" bind:checked = { localSettings . skip_diarization } / >
2026-02-26 18:08:51 -08:00
Skip speaker detection (faster, no speaker labels)
2026-02-26 16:38:23 -08:00
</ label >
</ div >
{ :else if activeTab === 'ai' }
< div class = "field" >
< label for = "ai-provider" > AI Provider</ label >
< select id = "ai-provider" bind:value = { localSettings . ai_provider } >
< option value = "local" > Local (llama-server)</ option >
< option value = "openai" > OpenAI</ option >
< option value = "anthropic" > Anthropic</ option >
2026-03-20 21:33:43 -07:00
< option value = "litellm" > OpenAI Compatible</ option >
2026-02-26 16:38:23 -08:00
</ select >
</ div >
{ #if localSettings . ai_provider === 'openai' }
< div class = "field" >
< label for = "openai-key" > OpenAI API Key</ label >
2026-03-20 21:33:43 -07:00
< div class = "input-reveal" >
< input id = "openai-key" type = { revealedFields . has ( 'openai-key' ) ? 'text' : 'password' } bind:value= { localSettings . openai_api_key } placeholder = "sk-..." />
< button type = "button" class = "reveal-btn" onclick = {() => { const s = new Set ( revealedFields ); s . has ( 'openai-key' ) ? s . delete ( 'openai-key' ) : s . add ( 'openai-key' ); revealedFields = s ; }} > { revealedFields . has ( 'openai-key' ) ? 'Hide' : 'Show' } </button >
</ div >
2026-02-26 16:38:23 -08:00
</ div >
< div class = "field" >
< label for = "openai-model" > Model</ label >
< input id = "openai-model" type = "text" bind:value = { localSettings . openai_model } / >
</ div >
{ :else if localSettings . ai_provider === 'anthropic' }
< div class = "field" >
< label for = "anthropic-key" > Anthropic API Key</ label >
2026-03-20 21:33:43 -07:00
< div class = "input-reveal" >
< input id = "anthropic-key" type = { revealedFields . has ( 'anthropic-key' ) ? 'text' : 'password' } bind:value= { localSettings . anthropic_api_key } placeholder = "sk-ant-..." />
< button type = "button" class = "reveal-btn" onclick = {() => { const s = new Set ( revealedFields ); s . has ( 'anthropic-key' ) ? s . delete ( 'anthropic-key' ) : s . add ( 'anthropic-key' ); revealedFields = s ; }} > { revealedFields . has ( 'anthropic-key' ) ? 'Hide' : 'Show' } </button >
</ div >
2026-02-26 16:38:23 -08:00
</ div >
< div class = "field" >
< label for = "anthropic-model" > Model</ label >
< input id = "anthropic-model" type = "text" bind:value = { localSettings . anthropic_model } / >
</ div >
{ :else if localSettings . ai_provider === 'litellm' }
2026-03-20 21:33:43 -07:00
< div class = "field" >
< label for = "litellm-base" > API Base URL</ label >
< input id = "litellm-base" type = "text" bind:value = { localSettings . litellm_api_base } placeholder="https://your-litellm-proxy.example.com" />
</ div >
< div class = "field" >
< label for = "litellm-key" > API Key</ label >
< div class = "input-reveal" >
< input id = "litellm-key" type = { revealedFields . has ( 'litellm-key' ) ? 'text' : 'password' } bind:value= { localSettings . litellm_api_key } placeholder = "sk-..." />
< button type = "button" class = "reveal-btn" onclick = {() => { const s = new Set ( revealedFields ); s . has ( 'litellm-key' ) ? s . delete ( 'litellm-key' ) : s . add ( 'litellm-key' ); revealedFields = s ; }} > { revealedFields . has ( 'litellm-key' ) ? 'Hide' : 'Show' } </button >
</ div >
</ div >
2026-02-26 16:38:23 -08:00
< div class = "field" >
< label for = "litellm-model" > Model</ label >
< input id = "litellm-model" type = "text" bind:value = { localSettings . litellm_model } placeholder="provider/model-name" />
</ div >
{ /if }
{ : else }
< div class = "field" >
< label for = "llama-binary" > llama-server Binary Path</ label >
< input id = "llama-binary" type = "text" bind:value = { localSettings . local_binary_path } placeholder="llama-server" />
</ div >
< div class = "field" >
< label for = "llama-model" > GGUF Model Path</ label >
< input id = "llama-model" type = "text" bind:value = { localSettings . local_model_path } placeholder="~/.voicetonotes/models/model.gguf" />
</ div >
< p class = "hint" >
Place GGUF model files in ~/.voicetonotes/models/ for auto-detection.
The local AI server uses the OpenAI-compatible API from llama.cpp.
</ p >
{ /if }
</ div >
< div class = "modal-footer" >
< button class = "btn-secondary" onclick = { handleCancel } > Cancel</button >
< button class = "btn-primary" onclick = { handleSave } > Save</button >
</ div >
</ div >
</ div >
{ /if }
< style >
. modal-overlay {
position : fixed ;
inset : 0 ;
background : rgba ( 0 , 0 , 0 , 0.6 );
display : flex ;
align-items : center ;
justify-content : center ;
z-index : 100 ;
}
. modal {
background : #16213e ;
border-radius : 12 px ;
width : 500 px ;
max-width : 90 vw ;
max-height : 80 vh ;
display : flex ;
flex-direction : column ;
color : #e0e0e0 ;
box-shadow : 0 8 px 32 px rgba ( 0 , 0 , 0 , 0.5 );
}
. modal-header {
display : flex ;
align-items : center ;
justify-content : space-between ;
padding : 1 rem 1.25 rem ;
border-bottom : 1 px solid #2a3a5e ;
}
. modal-header h2 {
margin : 0 ;
font-size : 1.1 rem ;
}
. close-btn {
background : none ;
border : none ;
color : #999 ;
font-size : 1.2 rem ;
cursor : pointer ;
padding : 0.25 rem ;
}
. close-btn : hover {
color : #e0e0e0 ;
}
. tabs {
display : flex ;
border-bottom : 1 px solid #2a3a5e ;
padding : 0 1.25 rem ;
}
. tab {
background : none ;
border : none ;
color : #888 ;
padding : 0.6 rem 1 rem ;
cursor : pointer ;
font-size : 0.85 rem ;
border-bottom : 2 px solid transparent ;
}
. tab : hover {
color : #e0e0e0 ;
}
. tab . active {
color : #e94560 ;
border-bottom-color : #e94560 ;
}
. modal-body {
padding : 1.25 rem ;
overflow-y : auto ;
flex : 1 ;
}
. field {
margin-bottom : 1 rem ;
}
. field label {
display : block ;
font-size : 0.8 rem ;
color : #aaa ;
margin-bottom : 0.3 rem ;
}
2026-03-20 21:33:43 -07:00
. input-reveal {
display : flex ;
gap : 0 ;
}
. input-reveal input {
flex : 1 ;
border-top-right-radius : 0 ;
border-bottom-right-radius : 0 ;
}
. reveal-btn {
background : #0f3460 ;
border : 1 px solid #4a5568 ;
border-left : none ;
color : #aaa ;
padding : 0.5 rem 0.6 rem ;
border-radius : 0 4 px 4 px 0 ;
cursor : pointer ;
font-size : 0.75 rem ;
white-space : nowrap ;
}
. reveal-btn : hover {
color : #e0e0e0 ;
background : #1a4a7a ;
}
2026-02-26 16:38:23 -08:00
. field input ,
. field select {
width : 100 % ;
background : #1a1a2e ;
color : #e0e0e0 ;
2026-03-20 21:33:43 -07:00
color-scheme : dark ;
2026-02-26 16:38:23 -08:00
border : 1 px solid #4a5568 ;
border-radius : 4 px ;
padding : 0.5 rem ;
font-size : 0.85 rem ;
font-family : inherit ;
box-sizing : border-box ;
}
. field input : focus ,
. field select : focus {
outline : none ;
border-color : #e94560 ;
}
. field . checkbox label {
display : flex ;
align-items : center ;
gap : 0.5 rem ;
cursor : pointer ;
color : #e0e0e0 ;
}
. field . checkbox input {
width : auto ;
}
. hint {
font-size : 0.75 rem ;
color : #666 ;
line-height : 1.4 ;
}
2026-02-26 18:08:51 -08:00
. info-box {
background : rgba ( 233 , 69 , 96 , 0.05 );
border : 1 px solid #2a3a5e ;
border-radius : 6 px ;
padding : 0.75 rem 1 rem ;
margin-bottom : 1 rem ;
font-size : 0.8 rem ;
color : #b0b0b0 ;
line-height : 1.5 ;
}
. info-box p {
margin : 0 0 0.5 rem ;
}
. info-box p : last-child {
margin-bottom : 0 ;
}
. info-box . info-title {
color : #e0e0e0 ;
font-weight : 600 ;
font-size : 0.8 rem ;
}
. info-box ol {
margin : 0.25 rem 0 0.5 rem ;
padding-left : 1.25 rem ;
}
. info-box li {
margin-bottom : 0.25 rem ;
}
. info-box strong {
color : #e0e0e0 ;
}
2026-02-26 18:21:42 -08:00
. ext-link {
color : #e94560 ;
cursor : pointer ;
text-decoration : underline ;
}
. ext-link : hover {
color : #ff6b81 ;
}
. info-box ul {
margin : 0.25 rem 0 ;
padding-left : 1.25 rem ;
}
. btn-download {
background : #0f3460 ;
border : 1 px solid #4a5568 ;
color : #e0e0e0 ;
padding : 0.5 rem 1 rem ;
border-radius : 6 px ;
cursor : pointer ;
font-size : 0.85 rem ;
width : 100 % ;
margin-bottom : 0.5 rem ;
}
. btn-download : hover : not ( : disabled ) {
background : #1a4a7a ;
border-color : #e94560 ;
}
. btn-download : disabled {
opacity : 0.6 ;
cursor : not-allowed ;
}
. status-success {
color : #4ecdc4 ;
font-size : 0.8 rem ;
margin : 0.25 rem 0 ;
}
. status-error {
color : #e94560 ;
font-size : 0.8 rem ;
margin : 0.25 rem 0 ;
word-break : break-word ;
}
2026-02-26 16:38:23 -08:00
. modal-footer {
display : flex ;
justify-content : flex-end ;
gap : 0.5 rem ;
padding : 1 rem 1.25 rem ;
border-top : 1 px solid #2a3a5e ;
}
. btn-secondary {
background : none ;
border : 1 px solid #4a5568 ;
color : #e0e0e0 ;
padding : 0.5 rem 1 rem ;
border-radius : 6 px ;
cursor : pointer ;
font-size : 0.85 rem ;
}
. btn-secondary : hover {
background : rgba ( 255 , 255 , 255 , 0.05 );
}
. btn-primary {
background : #e94560 ;
border : none ;
color : white ;
padding : 0.5 rem 1 rem ;
border-radius : 6 px ;
cursor : pointer ;
font-size : 0.85 rem ;
font-weight : 500 ;
}
. btn-primary : hover {
background : #d63851 ;
}
</ style >