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>