/ src / components / SettingsModal.vue
SettingsModal.vue
  1  <template>
  2    <n-modal
  3      v-model:show="showModal"
  4      preset="card"
  5      :style="{ width: '600px' }"
  6      title="Settings"
  7      :bordered="false"
  8      size="huge"
  9      :segmented="{ content: true, footer: 'soft' }"
 10    >
 11      <n-tabs type="line" animated>
 12        <n-tab-pane name="api" tab="API Configuration">
 13          <n-space vertical size="large">
 14            <n-form-item label="Provider" label-placement="left">
 15              <n-select
 16                v-model:value="settings.provider"
 17                :options="providerOptions"
 18                @update:value="onProviderChange"
 19              />
 20            </n-form-item>
 21  
 22            <n-form-item label="API Endpoint" label-placement="left">
 23              <n-input
 24                v-model:value="settings.apiEndpoint"
 25                :placeholder="getEndpointPlaceholder()"
 26                clearable
 27              />
 28            </n-form-item>
 29            
 30            <n-form-item v-if="settings.provider !== 'ollama'" label="API Key" label-placement="left">
 31              <n-input
 32                v-model:value="settings.apiKey"
 33                type="password"
 34                show-password-on="click"
 35                :placeholder="getApiKeyPlaceholder()"
 36                clearable
 37              />
 38            </n-form-item>
 39  
 40            <n-form-item label="Model" label-placement="left">
 41              <n-input-group>
 42                <n-select
 43                  v-model:value="settings.selectedModel"
 44                  :options="modelOptions"
 45                  :loading="loadingModels"
 46                  placeholder="Select a model"
 47                  filterable
 48                  tag
 49                  style="flex: 1"
 50                />
 51                <n-button
 52                  type="primary"
 53                  ghost
 54                  :loading="loadingModels"
 55                  @click="fetchModels"
 56                >
 57                  <template #icon>
 58                    <n-icon><refresh-outline /></n-icon>
 59                  </template>
 60                </n-button>
 61              </n-input-group>
 62            </n-form-item>
 63  
 64            <n-collapse>
 65              <n-collapse-item title="Provider Presets" name="presets">
 66                <n-space vertical>
 67                  <n-text depth="3" style="font-size: 12px;">
 68                    Click to quickly configure popular providers:
 69                  </n-text>
 70                  <n-space>
 71                    <n-button size="small" @click="setPreset('openai')">OpenAI</n-button>
 72                    <n-button size="small" @click="setPreset('gemini')">Gemini</n-button>
 73                    <n-button size="small" @click="setPreset('ollama')">Ollama</n-button>
 74                    <n-button size="small" @click="setPreset('lmstudio')">LM Studio</n-button>
 75                    <n-button size="small" @click="setPreset('openrouter')">OpenRouter</n-button>
 76                  </n-space>
 77                </n-space>
 78              </n-collapse-item>
 79            </n-collapse>
 80          </n-space>
 81        </n-tab-pane>
 82  
 83        <n-tab-pane name="translation" tab="Translation">
 84          <n-space vertical size="large">
 85            <n-form-item label="Source Language" label-placement="left">
 86              <n-select
 87                v-model:value="settings.sourceLanguage"
 88                :options="languageOptions"
 89                placeholder="Auto-detect"
 90              />
 91            </n-form-item>
 92  
 93            <n-form-item label="Target Language" label-placement="left">
 94              <n-select
 95                v-model:value="settings.targetLanguage"
 96                :options="languageOptions"
 97                placeholder="Select target language"
 98              />
 99            </n-form-item>
100  
101            <n-form-item label="Translation Style" label-placement="left">
102              <n-select
103                v-model:value="settings.translationStyle"
104                :options="styleOptions"
105              />
106            </n-form-item>
107  
108            <n-form-item label="Context Lines" label-placement="left">
109              <n-input-number
110                v-model:value="settings.contextLines"
111                :min="0"
112                :max="10"
113                placeholder="Lines of context for better translation"
114              />
115            </n-form-item>
116  
117            <n-collapse>
118              <n-collapse-item title="System Prompt Preview" name="prompt">
119                <n-input
120                  :value="getSystemPrompt()"
121                  type="textarea"
122                  readonly
123                  :rows="8"
124                  style="font-family: monospace; font-size: 12px;"
125                />
126              </n-collapse-item>
127            </n-collapse>
128          </n-space>
129        </n-tab-pane>
130  
131        <n-tab-pane name="output" tab="Output">
132          <n-space vertical size="large">
133            <n-form-item label="Output Directory" label-placement="left">
134              <n-input-group>
135                <n-input
136                  v-model:value="settings.outputDirectory"
137                  placeholder="Same as input"
138                  readonly
139                />
140                <n-button type="primary" ghost @click="selectOutputDir">
141                  <template #icon>
142                    <n-icon><folder-open-outline /></n-icon>
143                  </template>
144                </n-button>
145              </n-input-group>
146            </n-form-item>
147  
148            <n-form-item label="Output Format" label-placement="left">
149              <n-select
150                v-model:value="settings.outputFormat"
151                :options="formatOptions"
152              />
153            </n-form-item>
154  
155            <n-form-item label="FFmpeg Path" label-placement="left">
156              <n-input-group>
157                <n-input
158                  v-model:value="settings.ffmpegPath"
159                  placeholder="ffmpeg (uses PATH)"
160                  clearable
161                />
162                <n-button type="primary" ghost @click="selectFfmpegPath">
163                  <template #icon>
164                    <n-icon><folder-open-outline /></n-icon>
165                  </template>
166                </n-button>
167              </n-input-group>
168            </n-form-item>
169  
170            <n-divider />
171  
172            <n-form-item label="Backup Settings" label-placement="top">
173              <n-space vertical>
174                <n-checkbox v-model:checked="settings.autoBackup">
175                  Automatically backup subtitles before translation
176                </n-checkbox>
177                <n-checkbox v-model:checked="settings.keepOriginalTrack">
178                  Keep original subtitle track in video
179                </n-checkbox>
180              </n-space>
181            </n-form-item>
182          </n-space>
183        </n-tab-pane>
184      </n-tabs>
185  
186      <template #footer>
187        <n-space justify="end">
188          <n-button @click="resetSettings">Reset</n-button>
189          <n-button type="primary" @click="saveSettings">
190            <template #icon>
191              <n-icon><save-outline /></n-icon>
192            </template>
193            Save Settings
194          </n-button>
195        </n-space>
196      </template>
197    </n-modal>
198  </template>
199  
200  <script setup lang="ts">
201  import { ref, reactive, computed } from 'vue'
202  import {
203    NModal,
204    NTabs,
205    NTabPane,
206    NSpace,
207    NFormItem,
208    NInput,
209    NInputGroup,
210    NInputNumber,
211    NSelect,
212    NButton,
213    NIcon,
214    NCollapse,
215    NCollapseItem,
216    NText,
217    NCheckbox,
218    NDivider,
219    useMessage
220  } from 'naive-ui'
221  import {
222    RefreshOutline,
223    FolderOpenOutline,
224    SaveOutline
225  } from '@vicons/ionicons5'
226  import { open } from '@tauri-apps/plugin-dialog'
227  
228  export interface Settings {
229    provider: string
230    apiEndpoint: string
231    apiKey: string
232    selectedModel: string | null
233    sourceLanguage: string
234    targetLanguage: string
235    translationStyle: string
236    contextLines: number
237    outputDirectory: string
238    outputFormat: string
239    ffmpegPath: string
240    autoBackup: boolean
241    keepOriginalTrack: boolean
242  }
243  
244  const props = defineProps<{
245    show: boolean
246  }>()
247  
248  const emit = defineEmits<{
249    (e: 'update:show', value: boolean): void
250  }>()
251  
252  const message = useMessage()
253  
254  const showModal = computed({
255    get: () => props.show,
256    set: (value) => emit('update:show', value)
257  })
258  
259  const loadingModels = ref(false)
260  const modelOptions = ref<{ label: string; value: string }[]>([])
261  
262  const settings = reactive<Settings>({
263    provider: 'openai',
264    apiEndpoint: 'https://api.openai.com/v1',
265    apiKey: '',
266    selectedModel: null,
267    sourceLanguage: '',
268    targetLanguage: 'en',
269    translationStyle: 'natural',
270    contextLines: 2,
271    outputDirectory: '',
272    outputFormat: 'srt',
273    ffmpegPath: '',
274    autoBackup: true,
275    keepOriginalTrack: true
276  })
277  
278  const providerOptions = [
279    { label: 'OpenAI', value: 'openai' },
280    { label: 'Google Gemini', value: 'gemini' },
281    { label: 'Ollama (Local)', value: 'ollama' },
282    { label: 'LM Studio (Local)', value: 'lmstudio' },
283    { label: 'OpenRouter', value: 'openrouter' },
284    { label: 'Custom OpenAI-compatible', value: 'custom' }
285  ]
286  
287  const providerPresets: Record<string, { endpoint: string; models: string[] }> = {
288    openai: {
289      endpoint: 'https://api.openai.com/v1',
290      models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-3.5-turbo']
291    },
292    gemini: {
293      endpoint: 'https://generativelanguage.googleapis.com/v1beta/openai',
294      models: ['gemini-2.0-flash-exp', 'gemini-1.5-pro', 'gemini-1.5-flash', 'gemini-1.5-flash-8b']
295    },
296    ollama: {
297      endpoint: 'http://localhost:11434/v1',
298      models: ['llama3.2', 'llama3.1', 'mistral', 'qwen2.5', 'gemma2']
299    },
300    lmstudio: {
301      endpoint: 'http://localhost:1234/v1',
302      models: []
303    },
304    openrouter: {
305      endpoint: 'https://openrouter.ai/api/v1',
306      models: ['anthropic/claude-3.5-sonnet', 'openai/gpt-4o', 'google/gemini-pro-1.5', 'meta-llama/llama-3.1-70b-instruct']
307    },
308    custom: {
309      endpoint: '',
310      models: []
311    }
312  }
313  
314  const languageOptions = [
315    { label: 'Auto-detect', value: '' },
316    { label: 'Japanese', value: 'ja' },
317    { label: 'English', value: 'en' },
318    { label: 'Chinese (Simplified)', value: 'zh-CN' },
319    { label: 'Chinese (Traditional)', value: 'zh-TW' },
320    { label: 'Korean', value: 'ko' },
321    { label: 'Spanish', value: 'es' },
322    { label: 'French', value: 'fr' },
323    { label: 'German', value: 'de' },
324    { label: 'Portuguese', value: 'pt' },
325    { label: 'Russian', value: 'ru' },
326    { label: 'Italian', value: 'it' },
327    { label: 'Arabic', value: 'ar' },
328    { label: 'Thai', value: 'th' },
329    { label: 'Vietnamese', value: 'vi' },
330    { label: 'Indonesian', value: 'id' },
331    { label: 'Polish', value: 'pl' },
332    { label: 'Turkish', value: 'tr' }
333  ]
334  
335  const styleOptions = [
336    { label: 'Natural & Fluent', value: 'natural' },
337    { label: 'Literal Translation', value: 'literal' },
338    { label: 'Localized (Cultural Adaptation)', value: 'localized' },
339    { label: 'Formal', value: 'formal' },
340    { label: 'Casual', value: 'casual' },
341    { label: 'Honorifics Preserved', value: 'honorifics' }
342  ]
343  
344  const formatOptions = [
345    { label: 'SRT (.srt)', value: 'srt' },
346    { label: 'ASS/SSA (.ass)', value: 'ass' },
347    { label: 'WebVTT (.vtt)', value: 'vtt' }
348  ]
349  
350  const systemPrompts: Record<string, string> = {
351    natural: `You are an expert anime subtitle translator. Translate the following subtitle lines to {targetLang}.
352  
353  Guidelines:
354  - Provide natural, fluent translations that sound like native speech
355  - Preserve the emotional tone and intent of the original dialogue
356  - Adapt idioms and expressions to their closest natural equivalent
357  - Keep character names in their original form unless there's a well-known localized version
358  - Maintain the pacing suitable for subtitle reading
359  - Do NOT add explanations or notes, only provide the translation
360  
361  {context}`,
362  
363    literal: `You are a precise subtitle translator. Translate the following subtitle lines to {targetLang}.
364  
365  Guidelines:
366  - Translate as literally as possible while maintaining grammatical correctness
367  - Preserve the original sentence structure when feasible
368  - Keep all names and terms in their original form
369  - Do not add or remove information from the original
370  - Do NOT add explanations or notes, only provide the translation
371  
372  {context}`,
373  
374    localized: `You are a localization expert for anime subtitles. Translate and adapt the following lines to {targetLang}.
375  
376  Guidelines:
377  - Adapt cultural references to equivalents the target audience will understand
378  - Convert measurements, currencies, and cultural concepts appropriately
379  - Rewrite jokes and wordplay to work in the target language
380  - Make dialogue feel natural for the target culture
381  - Preserve the overall story meaning and character relationships
382  - Do NOT add explanations or notes, only provide the translation
383  
384  {context}`,
385  
386    formal: `You are a professional subtitle translator. Translate the following lines to {targetLang} using formal language.
387  
388  Guidelines:
389  - Use formal register and polite language
390  - Avoid slang, contractions, and casual expressions
391  - Maintain professional and respectful tone
392  - Suitable for educational or professional contexts
393  - Do NOT add explanations or notes, only provide the translation
394  
395  {context}`,
396  
397    casual: `You are a subtitle translator specializing in casual dialogue. Translate to {targetLang}.
398  
399  Guidelines:
400  - Use casual, conversational language
401  - Include appropriate slang and colloquialisms
402  - Use contractions and informal expressions
403  - Match the relaxed tone of casual conversation
404  - Do NOT add explanations or notes, only provide the translation
405  
406  {context}`,
407  
408    honorifics: `You are an anime subtitle translator who preserves Japanese honorifics. Translate to {targetLang}.
409  
410  Guidelines:
411  - Keep Japanese honorifics (-san, -kun, -chan, -sama, -sensei, -senpai, etc.)
412  - Preserve name order (family name first if appropriate)
413  - Keep certain untranslatable terms (onii-chan, kawaii, etc.) with context clues
414  - Maintain the social relationship nuances through honorific usage
415  - Translate the rest naturally and fluently
416  - Do NOT add explanations or notes, only provide the translation
417  
418  {context}`
419  }
420  
421  const getSystemPrompt = (): string => {
422    const langName = languageOptions.find(l => l.value === settings.targetLanguage)?.label || settings.targetLanguage
423    const context = settings.sourceLanguage 
424      ? `Source language: ${languageOptions.find(l => l.value === settings.sourceLanguage)?.label || settings.sourceLanguage}`
425      : 'Detect the source language automatically.'
426    
427    return (systemPrompts[settings.translationStyle] || systemPrompts.natural)
428      .replace('{targetLang}', langName)
429      .replace('{context}', context)
430  }
431  
432  const getEndpointPlaceholder = (): string => {
433    return providerPresets[settings.provider]?.endpoint || 'https://api.example.com/v1'
434  }
435  
436  const getApiKeyPlaceholder = (): string => {
437    const placeholders: Record<string, string> = {
438      openai: 'sk-...',
439      gemini: 'AIza...',
440      openrouter: 'sk-or-...',
441      lmstudio: '(optional)',
442      custom: 'API key'
443    }
444    return placeholders[settings.provider] || 'API key'
445  }
446  
447  const onProviderChange = (provider: string) => {
448    const preset = providerPresets[provider]
449    if (preset) {
450      settings.apiEndpoint = preset.endpoint
451      if (preset.models.length > 0) {
452        modelOptions.value = preset.models.map(m => ({ label: m, value: m }))
453      }
454    }
455  }
456  
457  const setPreset = (provider: string) => {
458    settings.provider = provider
459    onProviderChange(provider)
460    message.info(`Configured for ${providerOptions.find(p => p.value === provider)?.label}`)
461  }
462  
463  // Load settings from localStorage on mount
464  const loadSettings = () => {
465    const saved = localStorage.getItem('animesubs-settings')
466    if (saved) {
467      try {
468        const parsed = JSON.parse(saved)
469        Object.assign(settings, parsed)
470        // Load cached models for provider
471        const cachedModels = localStorage.getItem(`animesubs-models-${settings.provider}`)
472        if (cachedModels) {
473          modelOptions.value = JSON.parse(cachedModels)
474        }
475      } catch (e) {
476        console.error('Failed to load settings:', e)
477      }
478    }
479  }
480  
481  loadSettings()
482  
483  const fetchModels = async () => {
484    if (!settings.apiEndpoint) {
485      message.warning('Please enter API endpoint first')
486      return
487    }
488  
489    if (settings.provider !== 'ollama' && !settings.apiKey) {
490      message.warning('Please enter API key first')
491      return
492    }
493  
494    loadingModels.value = true
495    try {
496      const headers: Record<string, string> = {
497        'Content-Type': 'application/json'
498      }
499      
500      if (settings.apiKey) {
501        headers['Authorization'] = `Bearer ${settings.apiKey}`
502      }
503  
504      // Handle different providers' model list endpoints
505      let url = `${settings.apiEndpoint}/models`
506      
507      if (settings.provider === 'gemini') {
508        // Gemini uses native API for listing models (not OpenAI-compatible endpoint)
509        url = `https://generativelanguage.googleapis.com/v1beta/models?key=${settings.apiKey}`
510        delete headers['Authorization']
511      }
512  
513      const response = await fetch(url, { headers })
514      
515      if (!response.ok) {
516        throw new Error(`HTTP ${response.status}: ${await response.text()}`)
517      }
518      
519      const data = await response.json()
520      
521      // Handle different response formats
522      let models: { label: string; value: string }[] = []
523      
524      if (data.data && Array.isArray(data.data)) {
525        // OpenAI format
526        models = data.data.map((m: any) => ({
527          label: m.id,
528          value: m.id
529        }))
530      } else if (data.models && Array.isArray(data.models)) {
531        // Ollama format
532        models = data.models.map((m: any) => ({
533          label: m.name || m.model,
534          value: m.name || m.model
535        }))
536      } else if (Array.isArray(data)) {
537        // Gemini format
538        models = data
539          .filter((m: any) => m.name?.includes('gemini'))
540          .map((m: any) => ({
541            label: m.name.replace('models/', ''),
542            value: m.name.replace('models/', '')
543          }))
544      }
545  
546      models.sort((a, b) => a.label.localeCompare(b.label))
547      modelOptions.value = models
548      
549      // Cache models for this provider
550      localStorage.setItem(`animesubs-models-${settings.provider}`, JSON.stringify(models))
551      
552      message.success(`Loaded ${models.length} models`)
553    } catch (error) {
554      message.error(`Failed to fetch models: ${error}`)
555      // Fall back to preset models
556      const preset = providerPresets[settings.provider]
557      if (preset?.models.length) {
558        modelOptions.value = preset.models.map(m => ({ label: m, value: m }))
559      }
560    } finally {
561      loadingModels.value = false
562    }
563  }
564  
565  const selectOutputDir = async () => {
566    const selected = await open({
567      directory: true,
568      multiple: false,
569      title: 'Select Output Directory'
570    })
571    
572    if (selected) {
573      settings.outputDirectory = selected as string
574    }
575  }
576  
577  const selectFfmpegPath = async () => {
578    const selected = await open({
579      multiple: false,
580      title: 'Select FFmpeg Executable'
581    })
582    
583    if (selected) {
584      settings.ffmpegPath = selected as string
585    }
586  }
587  
588  const saveSettings = () => {
589    localStorage.setItem('animesubs-settings', JSON.stringify(settings))
590    message.success('Settings saved')
591    showModal.value = false
592  }
593  
594  const resetSettings = () => {
595    Object.assign(settings, {
596      provider: 'openai',
597      apiEndpoint: 'https://api.openai.com/v1',
598      apiKey: '',
599      selectedModel: null,
600      sourceLanguage: '',
601      targetLanguage: 'en',
602      translationStyle: 'natural',
603      contextLines: 2,
604      outputDirectory: '',
605      outputFormat: 'srt',
606      ffmpegPath: '',
607      autoBackup: true,
608      keepOriginalTrack: true
609    })
610    modelOptions.value = []
611    message.info('Settings reset to defaults')
612  }
613  
614  defineExpose({ settings, getSystemPrompt })
615  </script>