diff --git a/public/locales/en/common.json b/public/locales/en/common.json index d1fdcc1..16fe0c0 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -34,10 +34,14 @@ "systemPrompt": "System Prompt", "systemPromptPlaceholder": "Enter system prompt to define AI behavior", "userPrompt": "User Prompt", - "userPromptPlaceholder": "Enter user prompt template with {language}, {line_count}, {content} variables", - "availableVariables": "Available variables: {language}, {line_count}, {content}", + "userPromptPlaceholder": "Enter user prompt template with {{language}}, {{line_count}}, {{content}} variables", + "availableVariables": "Available variables: {{language}}, {{line_count}}, {{content}}", "temperature": "Temperature", "temperatureHint": "Controls randomness (0.0-2.0). Higher values make output more creative.", + "apiKeyProviderHint": "This API key is only for {{provider}}", + "modelProviderHint": "Default model for {{provider}}: {{defaultModel}}", + "advancedSettings": "Advanced Settings", + "prompts": "Prompts", "providers": { "openai": "OpenAI", "anthropic": "Anthropic", @@ -76,7 +80,29 @@ "saveSuccess": "Settings saved", "saveSuccessDescription": "Your settings have been saved successfully", "saveError": "Failed to save settings", - "saveErrorDescription": "An error occurred while saving your settings" + "saveErrorDescription": "An error occurred while saving your settings", + "backup": { + "title": "Backup Settings", + "description": "Configure automatic backup of translation files", + "enabled": "Enable Automatic Backup", + "enabledDescription": "Create backups before overwriting translation files", + "retentionDays": "Retention Period (days)", + "keepForever": "Keep forever", + "days": "days", + "retentionDescription": "Backups older than this will be automatically deleted. Set to 0 to keep forever.", + "maxBackupsPerType": "Max Backups per Type", + "unlimited": "Unlimited", + "backups": "backups", + "maxBackupsDescription": "Maximum number of backups to keep for each translation type. Set to 0 for unlimited.", + "autoPrune": "Auto-prune on Startup", + "autoPruneDescription": "Automatically delete old backups when the application starts", + "storageUsed": "Storage Used", + "pruning": "Pruning...", + "pruneNow": "Prune Old Backups", + "simpleBackupDescription": "Backups are created automatically during translation. Original files are preserved before translation, and results are saved after completion.", + "translationHistory": "Translation History", + "noHistory": "No translation history found" + } }, "tabs": { "mods": "Mods", @@ -85,7 +111,8 @@ "customFiles": "Custom Files", "settings": "Settings", "targetLanguage": "Target Language", - "selectLanguage": "Select language" + "selectLanguage": "Select language", + "translations": "Translations" }, "cards": { "modTranslation": "Mod Translation", @@ -144,14 +171,18 @@ "noFilesSelected": "No files selected for translation", "selectDirectoryFirst": "Please select a directory first", "selectProfileDirectoryFirst": "Please select a profile directory first", - "noTargetLanguageSelected": "No target language selected. Please select a target language from the dropdown in the translation tab." + "noTargetLanguageSelected": "No target language selected. Please select a target language from the dropdown in the translation tab.", + "translationInProgress": "Translation in Progress", + "cannotSwitchTabs": "Cannot switch tabs while translation is in progress. Please wait for the current translation to complete or cancel it.", + "failedToLoad": "Failed to load" }, "info": { "translationCancelled": "Translation cancelled by user" }, "misc": { "loading": "Loading...", - "selectedDirectory": "Selected directory:" + "selectedDirectory": "Selected directory:", + "pleaseWait": "Please wait..." }, "logs": { "title": "Logs", @@ -183,7 +214,33 @@ "clear": "Clear", "confirmClear": "Are you sure you want to clear all translation history?", "noHistory": "No translation history yet", - "noResultsFound": "No results found matching your search" + "noResultsFound": "No results found matching your search", + "sessionDate": "Session Date", + "targetLanguage": "Target Language", + "totalItems": "Total Items", + "success": "Success", + "successCount": "Success Count", + "successRate": "Success Rate", + "translationDetails": "Translation Details", + "keyCount": "Keys", + "fileName": "File Name", + "type": "Type", + "status": "Status", + "completed": "Completed", + "failed": "Failed", + "types": { + "quest": "Quest", + "mod": "Mod", + "guidebook": "Guidebook", + "customFiles": "Custom Files", + "resource_pack": "Resource Pack" + }, + "statuses": { + "completed": "Completed", + "failed": "Failed", + "pending": "Pending", + "in_progress": "In Progress" + } }, "update": { "title": "Update Available", @@ -202,7 +259,7 @@ "installNow": "Install Now", "downloading": "Downloading Update...", "installing": "Installing Update...", - "progressSize": "{downloaded}MB / {total}MB", + "progressSize": "{{downloaded}}MB / {{total}}MB", "restartPrompt": "Update has been installed. Would you like to restart the application now?", "updateComplete": "Update Complete", "restartNow": "Restart Now", @@ -215,7 +272,11 @@ } }, "common": { - "close": "Close" + "close": "Close", + "loading": "Loading...", + "refresh": "Refresh", + "viewDetails": "View Details", + "hideDetails": "Hide Details" }, "header": { "downloadReleases": "Download releases", diff --git a/public/locales/ja/common.json b/public/locales/ja/common.json index 54a8b42..54db018 100644 --- a/public/locales/ja/common.json +++ b/public/locales/ja/common.json @@ -34,10 +34,14 @@ "systemPrompt": "システムプロンプト", "systemPromptPlaceholder": "AIの振る舞いを定義するシステムプロンプトを入力", "userPrompt": "ユーザープロンプト", - "userPromptPlaceholder": "{language}, {line_count}, {content} 変数を含むユーザープロンプトテンプレートを入力", - "availableVariables": "利用可能な変数: {language}, {line_count}, {content}", + "userPromptPlaceholder": "{{language}}, {{line_count}}, {{content}} 変数を含むユーザープロンプトテンプレートを入力", + "availableVariables": "利用可能な変数: {{language}}, {{line_count}}, {{content}}", "temperature": "温度", "temperatureHint": "ランダム性を制御します (0.0-2.0)。値が高いほど出力がよりクリエイティブになります。", + "apiKeyProviderHint": "このAPIキーは{{provider}}専用です", + "modelProviderHint": "{{provider}}のデフォルトモデル: {{defaultModel}}", + "advancedSettings": "詳細設定", + "prompts": "プロンプト", "providers": { "openai": "OpenAI", "anthropic": "Anthropic", @@ -76,7 +80,29 @@ "saveSuccess": "設定を保存しました", "saveSuccessDescription": "設定が正常に保存されました", "saveError": "設定の保存に失敗しました", - "saveErrorDescription": "設定の保存中にエラーが発生しました" + "saveErrorDescription": "設定の保存中にエラーが発生しました", + "backup": { + "title": "バックアップ設定", + "description": "翻訳ファイルの自動バックアップを設定", + "enabled": "自動バックアップを有効化", + "enabledDescription": "翻訳ファイルを上書きする前にバックアップを作成", + "retentionDays": "保存期間(日数)", + "keepForever": "永久保存", + "days": "日", + "retentionDescription": "この期間より古いバックアップは自動的に削除されます。0に設定すると永久保存されます。", + "maxBackupsPerType": "タイプごとの最大バックアップ数", + "unlimited": "無制限", + "backups": "個", + "maxBackupsDescription": "各翻訳タイプごとに保持する最大バックアップ数。0に設定すると無制限になります。", + "autoPrune": "起動時に自動削除", + "autoPruneDescription": "アプリケーション起動時に古いバックアップを自動的に削除", + "storageUsed": "使用容量", + "pruning": "削除中...", + "pruneNow": "古いバックアップを削除", + "simpleBackupDescription": "バックアップは翻訳中に自動的に作成されます。翻訳前に元のファイルが保存され、完了後に結果が保存されます。", + "translationHistory": "翻訳履歴", + "noHistory": "翻訳履歴が見つかりません" + } }, "tabs": { "mods": "Mod", @@ -85,7 +111,8 @@ "customFiles": "カスタムファイル", "settings": "設定", "targetLanguage": "対象言語", - "selectLanguage": "対象言語を選択" + "selectLanguage": "対象言語を選択", + "translations": "翻訳" }, "cards": { "modTranslation": "Mod翻訳", @@ -144,14 +171,18 @@ "noFilesSelected": "翻訳するファイルが選択されていません", "selectDirectoryFirst": "最初にディレクトリを選択してください", "selectProfileDirectoryFirst": "最初にプロファイルディレクトリを選択してください", - "noTargetLanguageSelected": "対象言語が選択されていません。翻訳タブのドロップダウンから対象言語を選択してください。" + "noTargetLanguageSelected": "対象言語が選択されていません。翻訳タブのドロップダウンから対象言語を選択してください。", + "translationInProgress": "翻訳中", + "cannotSwitchTabs": "翻訳中はタブを切り替えることができません。現在の翻訳が完了するまでお待ちいただくか、キャンセルしてください。", + "failedToLoad": "読み込みに失敗しました" }, "info": { "translationCancelled": "翻訳はユーザーによってキャンセルされました" }, "misc": { "loading": "読み込み中...", - "selectedDirectory": "選択されたディレクトリ:" + "selectedDirectory": "選択されたディレクトリ:", + "pleaseWait": "しばらくお待ちください..." }, "logs": { "title": "ログ", @@ -183,7 +214,33 @@ "clear": "クリア", "confirmClear": "翻訳履歴をすべてクリアしてもよろしいですか?", "noHistory": "翻訳履歴はまだありません", - "noResultsFound": "検索にマッチする結果が見つかりません" + "noResultsFound": "検索にマッチする結果が見つかりません", + "sessionDate": "セッション日時", + "targetLanguage": "対象言語", + "totalItems": "総アイテム数", + "success": "成功", + "successCount": "成功数", + "successRate": "成功率", + "translationDetails": "翻訳詳細", + "keyCount": "キー数", + "fileName": "ファイル名", + "type": "タイプ", + "status": "ステータス", + "completed": "完了", + "failed": "失敗", + "types": { + "quest": "クエスト", + "mod": "Mod", + "guidebook": "ガイドブック", + "customFiles": "カスタムファイル", + "resource_pack": "リソースパック" + }, + "statuses": { + "completed": "完了", + "failed": "失敗", + "pending": "保留中", + "in_progress": "進行中" + } }, "update": { "title": "アップデートが利用可能", @@ -202,7 +259,7 @@ "installNow": "今すぐインストール", "downloading": "アップデートをダウンロード中...", "installing": "アップデートをインストール中...", - "progressSize": "{downloaded}MB / {total}MB", + "progressSize": "{{downloaded}}MB / {{total}}MB", "restartPrompt": "アップデートがインストールされました。今すぐアプリケーションを再起動しますか?", "updateComplete": "アップデート完了", "restartNow": "今すぐ再起動", @@ -215,7 +272,11 @@ } }, "common": { - "close": "閉じる" + "close": "閉じる", + "loading": "読み込み中...", + "refresh": "更新", + "viewDetails": "詳細を表示", + "hideDetails": "詳細を非表示" }, "header": { "downloadReleases": "リリースをダウンロード", diff --git a/src-tauri/src/backup.rs b/src-tauri/src/backup.rs index e5fca30..2b9cbb6 100644 --- a/src-tauri/src/backup.rs +++ b/src-tauri/src/backup.rs @@ -1,7 +1,8 @@ use crate::logging::AppLogger; /** - * Backup module for managing translation file backups - * Integrates with existing logging infrastructure to store backups in session directories + * Simplified backup module for translation system + * Only handles backup creation - all management features have been removed + * as per TX016 specification for a minimal backup system */ use serde::{Deserialize, Serialize}; use std::fs; @@ -137,315 +138,273 @@ pub fn create_backup( Ok(backup_path) } -/// List available backups with optional filtering +/// Copy directory recursively +fn copy_dir_all(src: impl AsRef, dst: impl AsRef) -> std::io::Result<()> { + fs::create_dir_all(&dst)?; + for entry in fs::read_dir(src)? { + let entry = entry?; + let ty = entry.file_type()?; + let src_path = entry.path(); + let dst_path = dst.as_ref().join(entry.file_name()); + + if ty.is_dir() { + copy_dir_all(&src_path, &dst_path)?; + } else { + fs::copy(&src_path, &dst_path)?; + } + } + Ok(()) +} + +/// Backup original SNBT files before translation #[tauri::command] -pub fn list_backups( - r#type: Option, - session_id: Option, - limit: Option, +pub fn backup_snbt_files( + files: Vec, + session_path: String, logger: State>, -) -> Result, String> { - logger.debug("Listing backups", Some("BACKUP")); +) -> Result<(), String> { + logger.info( + &format!("Backing up {} SNBT files", files.len()), + Some("BACKUP"), + ); - let logs_dir = PathBuf::from("logs").join("localizer"); + // Create backup directory: {session_path}/backup/snbt_original/ + let backup_dir = PathBuf::from(&session_path) + .join("backup") + .join("snbt_original"); - if !logs_dir.exists() { - return Ok(Vec::new()); + if let Err(e) = fs::create_dir_all(&backup_dir) { + let error_msg = format!("Failed to create SNBT backup directory: {e}"); + logger.error(&error_msg, Some("BACKUP")); + return Err(error_msg); } - let mut backups = Vec::new(); - - // Iterate through session directories - let session_dirs = - fs::read_dir(&logs_dir).map_err(|e| format!("Failed to read logs directory: {e}"))?; - - for session_entry in session_dirs { - let session_entry = - session_entry.map_err(|e| format!("Failed to read session directory entry: {e}"))?; - - let session_path = session_entry.path(); - if !session_path.is_dir() { - continue; - } - - let session_name = session_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or_default(); - - // Filter by session ID if specified - if let Some(ref filter_session) = session_id { - if session_name != filter_session { - continue; - } - } - - // Check for backups directory in this session - let backups_dir = session_path.join("backups"); - if !backups_dir.exists() { - continue; - } - - // Iterate through backup directories - let backup_entries = fs::read_dir(&backups_dir) - .map_err(|e| format!("Failed to read backups directory: {e}"))?; - - for backup_entry in backup_entries { - let backup_entry = - backup_entry.map_err(|e| format!("Failed to read backup entry: {e}"))?; - - let backup_path = backup_entry.path(); - if !backup_path.is_dir() { - continue; - } - - // Read metadata - let metadata_path = backup_path.join("metadata.json"); - if !metadata_path.exists() { - continue; - } - - let metadata_content = fs::read_to_string(&metadata_path) - .map_err(|e| format!("Failed to read backup metadata: {e}"))?; - - let metadata: BackupMetadata = serde_json::from_str(&metadata_content) - .map_err(|e| format!("Failed to parse backup metadata: {e}"))?; - - // Filter by type if specified - if let Some(ref filter_type) = r#type { - if &metadata.r#type != filter_type { - continue; + // Copy each SNBT file to backup directory + let mut backed_up_count = 0; + for file_path in files { + let source = Path::new(&file_path); + if source.exists() { + if let Some(file_name) = source.file_name() { + let dest = backup_dir.join(file_name); + + if let Err(e) = fs::copy(source, &dest) { + logger.warning( + &format!("Failed to backup SNBT file {file_path}: {e}"), + Some("BACKUP"), + ); + } else { + backed_up_count += 1; + logger.debug( + &format!("Backed up SNBT: {} -> {}", file_path, dest.display()), + Some("BACKUP"), + ); } } - - // Check if backup can be restored (original files exist) - let original_files_dir = backup_path.join("original_files"); - let can_restore = original_files_dir.exists() - && original_files_dir - .read_dir() - .map(|mut entries| entries.next().is_some()) - .unwrap_or(false); - - let backup_info = BackupInfo { - metadata, - backup_path: backup_path.to_string_lossy().to_string(), - can_restore, - }; - - backups.push(backup_info); + } else { + logger.warning( + &format!("SNBT file not found for backup: {file_path}"), + Some("BACKUP"), + ); } } - // Sort by timestamp (newest first) - backups.sort_by(|a, b| b.metadata.timestamp.cmp(&a.metadata.timestamp)); - - // Apply limit if specified - if let Some(limit) = limit { - backups.truncate(limit); - } + logger.info( + &format!("SNBT backup completed: {backed_up_count} files backed up"), + Some("BACKUP"), + ); - logger.info(&format!("Found {} backups", backups.len()), Some("BACKUP")); - Ok(backups) + Ok(()) } -/// Restore files from a backup +/// Backup generated resource pack after mods translation #[tauri::command] -pub fn restore_backup( - backup_id: String, - target_path: String, +pub fn backup_resource_pack( + pack_path: String, + session_path: String, logger: State>, ) -> Result<(), String> { logger.info( - &format!("Restoring backup: {backup_id} to {target_path}"), + &format!("Backing up resource pack: {pack_path}"), Some("BACKUP"), ); - // Find the backup by ID - let backups = list_backups(None, None, None, logger.clone())?; - let backup = backups - .iter() - .find(|b| b.metadata.id == backup_id) - .ok_or_else(|| format!("Backup not found: {backup_id}"))?; + let source = Path::new(&pack_path); - let backup_path = Path::new(&backup.backup_path); - let original_files_dir = backup_path.join("original_files"); - - if !original_files_dir.exists() { - return Err("Backup original files not found".to_string()); + if !source.exists() { + return Err(format!("Resource pack not found: {pack_path}")); } - let target_dir = Path::new(&target_path); + // Extract pack name from path + let pack_name = source + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| "Invalid resource pack path".to_string())?; + + // Create backup directory: {session_path}/backup/resource_pack/ + let backup_dir = PathBuf::from(&session_path) + .join("backup") + .join("resource_pack"); - // Create target directory if it doesn't exist - if let Err(e) = fs::create_dir_all(target_dir) { - let error_msg = format!("Failed to create target directory: {e}"); + if let Err(e) = fs::create_dir_all(&backup_dir) { + let error_msg = format!("Failed to create resource pack backup directory: {e}"); logger.error(&error_msg, Some("BACKUP")); return Err(error_msg); } - // Copy files from backup to target - let backup_files = fs::read_dir(&original_files_dir) - .map_err(|e| format!("Failed to read backup files: {e}"))?; + // Copy entire resource pack directory + let dest = backup_dir.join(pack_name); - let mut restored_count = 0; - for backup_file in backup_files { - let backup_file = - backup_file.map_err(|e| format!("Failed to read backup file entry: {e}"))?; - - let source_path = backup_file.path(); - let file_name = source_path - .file_name() - .ok_or_else(|| "Invalid backup file name".to_string())?; - let dest_path = target_dir.join(file_name); - - if let Err(e) = fs::copy(&source_path, &dest_path) { - logger.warning( - &format!("Failed to restore file {}: {}", source_path.display(), e), - Some("BACKUP"), - ); - } else { - restored_count += 1; - logger.debug( - &format!( - "Restored file: {} -> {}", - source_path.display(), - dest_path.display() - ), - Some("BACKUP"), - ); - } + if let Err(e) = copy_dir_all(source, &dest) { + let error_msg = format!("Failed to backup resource pack: {e}"); + logger.error(&error_msg, Some("BACKUP")); + return Err(error_msg); } logger.info( - &format!("Backup restoration completed: {restored_count} files restored"), + &format!("Resource pack backup completed: {}", dest.display()), Some("BACKUP"), ); + Ok(()) } -/// Delete a specific backup -#[tauri::command] -pub fn delete_backup(backup_id: String, logger: State>) -> Result<(), String> { - logger.info(&format!("Deleting backup: {backup_id}"), Some("BACKUP")); - - // Find the backup by ID - let backups = list_backups(None, None, None, logger.clone())?; - let backup = backups - .iter() - .find(|b| b.metadata.id == backup_id) - .ok_or_else(|| format!("Backup not found: {backup_id}"))?; - - let backup_path = Path::new(&backup.backup_path); - - if backup_path.exists() { - fs::remove_dir_all(backup_path) - .map_err(|e| format!("Failed to delete backup directory: {e}"))?; - - logger.info( - &format!("Backup deleted successfully: {backup_id}"), - Some("BACKUP"), - ); - } else { - logger.warning( - &format!("Backup directory not found for deletion: {backup_id}"), - Some("BACKUP"), - ); - } +/// Translation summary types for translation history +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TranslationSummary { + pub lang: String, + pub translations: Vec, +} - Ok(()) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TranslationEntry { + #[serde(rename = "type")] + pub translation_type: String, // "mod", "quest", "patchouli", "custom" + pub name: String, + pub status: String, // "completed" or "failed" + pub keys: String, // Format: "translated/total" e.g. "234/234" } -/// Prune old backups based on retention policy +/// List all translation session directories #[tauri::command] -pub fn prune_old_backups( - retention_days: u32, - logger: State>, -) -> Result { - logger.info( - &format!("Pruning backups older than {retention_days} days"), - Some("BACKUP"), - ); +pub async fn list_translation_sessions(minecraft_dir: String) -> Result, String> { + let logs_path = PathBuf::from(&minecraft_dir).join("logs").join("localizer"); - let cutoff_time = chrono::Utc::now() - chrono::Duration::days(retention_days as i64); - let backups = list_backups(None, None, None, logger.clone())?; + if !logs_path.exists() { + return Ok(Vec::new()); + } - let mut deleted_count = 0; - for backup in backups { - // Parse backup timestamp - if let Ok(backup_time) = chrono::DateTime::parse_from_rfc3339(&backup.metadata.timestamp) { - if backup_time.with_timezone(&chrono::Utc) < cutoff_time { - if let Ok(()) = delete_backup(backup.metadata.id.clone(), logger.clone()) { - deleted_count += 1; - logger.debug( - &format!("Pruned old backup: {}", backup.metadata.id), - Some("BACKUP"), - ); + let mut sessions = Vec::new(); + + // Read directory entries + let entries = + fs::read_dir(&logs_path).map_err(|e| format!("Failed to read logs directory: {e}"))?; + + for entry in entries { + let entry = entry.map_err(|e| format!("Failed to read directory entry: {e}"))?; + let path = entry.path(); + + // Only include directories that match session ID format + if path.is_dir() { + if let Some(dir_name) = path.file_name() { + if let Some(name_str) = dir_name.to_str() { + // Simple validation: check if it matches YYYY-MM-DD_HH-MM-SS format + if name_str.len() == 19 && name_str.chars().nth(10) == Some('_') { + sessions.push(name_str.to_string()); + } } } } } - logger.info( - &format!("Backup pruning completed: {deleted_count} backups removed"), - Some("BACKUP"), - ); - Ok(deleted_count) + // Sort sessions by name (newest first due to timestamp format) + sessions.sort_by(|a, b| b.cmp(a)); + + Ok(sessions) } -/// Get backup information by ID +/// Read translation summary for a specific session #[tauri::command] -pub fn get_backup_info( - backup_id: String, - logger: State>, -) -> Result, String> { - let backups = list_backups(None, None, None, logger)?; - Ok(backups.into_iter().find(|b| b.metadata.id == backup_id)) +pub async fn get_translation_summary( + minecraft_dir: String, + session_id: String, +) -> Result { + let summary_path = PathBuf::from(&minecraft_dir) + .join("logs") + .join("localizer") + .join(&session_id) + .join("translation_summary.json"); + + if !summary_path.exists() { + return Err(format!( + "Translation summary not found for session: {session_id}" + )); + } + + // Read and parse the JSON file + let content = fs::read_to_string(&summary_path) + .map_err(|e| format!("Failed to read summary file: {e}"))?; + + let summary: TranslationSummary = + serde_json::from_str(&content).map_err(|e| format!("Failed to parse summary JSON: {e}"))?; + + Ok(summary) } -/// Get total backup storage size +/// Update translation summary with a new entry #[tauri::command] -pub fn get_backup_storage_size(logger: State>) -> Result { - let logs_dir = PathBuf::from("logs").join("localizer"); +#[allow(clippy::too_many_arguments)] +pub async fn update_translation_summary( + minecraft_dir: String, + session_id: String, + translation_type: String, + name: String, + status: String, + translated_keys: i32, + total_keys: i32, + target_language: String, +) -> Result<(), String> { + let session_dir = PathBuf::from(&minecraft_dir) + .join("logs") + .join("localizer") + .join(&session_id); - if !logs_dir.exists() { - return Ok(0); - } + // Ensure session directory exists + fs::create_dir_all(&session_dir) + .map_err(|e| format!("Failed to create session directory: {e}"))?; - let mut total_size = 0u64; + let summary_path = session_dir.join("translation_summary.json"); - // Calculate size recursively for all backup directories - fn calculate_dir_size(dir: &Path) -> Result { - let mut size = 0u64; - for entry in fs::read_dir(dir)? { - let entry = entry?; - let path = entry.path(); - if path.is_dir() { - size += calculate_dir_size(&path)?; - } else { - size += entry.metadata()?.len(); - } + // Read existing summary or create new one + let mut summary = if summary_path.exists() { + let content = fs::read_to_string(&summary_path) + .map_err(|e| format!("Failed to read existing summary: {e}"))?; + + serde_json::from_str::(&content) + .map_err(|e| format!("Failed to parse existing summary: {e}"))? + } else { + TranslationSummary { + lang: target_language.clone(), + translations: Vec::new(), } - Ok(size) - } + }; - // Iterate through session directories looking for backup subdirectories - let session_dirs = - fs::read_dir(&logs_dir).map_err(|e| format!("Failed to read logs directory: {e}"))?; + // Add new translation entry + let entry = TranslationEntry { + translation_type, + name, + status, + keys: format!("{translated_keys}/{total_keys}"), + }; - for session_entry in session_dirs { - let session_entry = - session_entry.map_err(|e| format!("Failed to read session directory: {e}"))?; + summary.translations.push(entry); - let backups_dir = session_entry.path().join("backups"); - if backups_dir.exists() { - total_size += calculate_dir_size(&backups_dir) - .map_err(|e| format!("Failed to calculate backup size: {e}"))?; - } - } + // Write updated summary back to file + let json = serde_json::to_string_pretty(&summary) + .map_err(|e| format!("Failed to serialize summary: {e}"))?; - logger.debug( - &format!("Total backup storage size: {total_size} bytes"), - Some("BACKUP"), - ); - Ok(total_size) + fs::write(&summary_path, json).map_err(|e| format!("Failed to write summary file: {e}"))?; + + Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b48dea3..842b02a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,8 +9,8 @@ pub mod minecraft; mod tests; use backup::{ - create_backup, delete_backup, get_backup_info, get_backup_storage_size, list_backups, - prune_old_backups, restore_backup, + backup_resource_pack, backup_snbt_files, create_backup, get_translation_summary, + list_translation_sessions, update_translation_summary, }; use config::{load_config, save_config}; use filesystem::{ @@ -115,12 +115,12 @@ pub fn run() { log_performance_metrics, // Backup operations create_backup, - list_backups, - restore_backup, - delete_backup, - prune_old_backups, - get_backup_info, - get_backup_storage_size + backup_snbt_files, + backup_resource_pack, + // Translation history operations + list_translation_sessions, + get_translation_summary, + update_translation_summary ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/minecraft/mod.rs b/src-tauri/src/minecraft/mod.rs index eb048fe..5ddd758 100644 --- a/src-tauri/src/minecraft/mod.rs +++ b/src-tauri/src/minecraft/mod.rs @@ -497,86 +497,12 @@ fn extract_mod_info(archive: &mut ZipArchive) -> Result<(String, String, S /// Extract language files from a JAR archive for a specific language fn extract_lang_files_from_archive( archive: &mut ZipArchive, - _mod_id: &str, + mod_id: &str, target_language: &str, ) -> Result> { - let mut lang_files = Vec::new(); - - // Find all language files - for i in 0..archive.len() { - let mut file = archive.by_index(i)?; - let name = file.name().to_string(); - - // Check if the file is a language file (.json or .lang) - if name.contains("/lang/") && (name.ends_with(".json") || name.ends_with(".lang")) { - // Extract language code from the file name - let parts: Vec<&str> = name.split('/').collect(); - let filename = parts.last().unwrap_or(&"unknown.json"); - let language = if filename.ends_with(".json") { - filename.trim_end_matches(".json").to_lowercase() - } else if filename.ends_with(".lang") { - filename.trim_end_matches(".lang").to_lowercase() - } else { - filename.to_lowercase() - }; - - // Only process the target language file (case-insensitive) - if language == target_language.to_lowercase() { - // Read the file content - let mut buffer = Vec::new(); - file.read_to_end(&mut buffer)?; - - // First, remove any null bytes and other problematic bytes - let cleaned_buffer: Vec = buffer - .into_iter() - .filter(|&b| b != 0 && (b >= 0x20 || b == 0x09 || b == 0x0A || b == 0x0D)) - .collect(); - - // Try to convert to UTF-8, handling invalid sequences - let content_str = String::from_utf8_lossy(&cleaned_buffer).to_string(); - debug!( - "Attempting to parse lang file: {}. Content snippet: {}", - name, - content_str.chars().take(100).collect::() - ); // Log file path and content snippet - - // Parse content based on extension - let content: HashMap = if name.ends_with(".json") { - // Strip _comment lines before parsing - let clean_content_str = strip_json_comments(&content_str); - match serde_json::from_str(&clean_content_str) { - Ok(content) => content, - Err(e) => { - error!("Failed to parse lang file '{name}': {e}. Skipping this file."); - // Skip this file instead of failing the entire mod - continue; - } - } - } else { - // .lang legacy format: key=value per line - let mut map = HashMap::new(); - for line in content_str.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() || trimmed.starts_with('#') { - continue; - } - if let Some((key, value)) = trimmed.split_once('=') { - map.insert(key.trim().to_string(), value.trim().to_string()); - } - } - map - }; - - // Create LangFile - lang_files.push(LangFile { - language, - path: name, - content, - }); - } - } - } - + // Use the _with_format function and just return the files + let (lang_files, _format) = + extract_lang_files_from_archive_with_format(archive, mod_id, target_language)?; Ok(lang_files) } diff --git a/src/__tests__/e2e/backup-system-e2e.test.ts b/src/__tests__/e2e/backup-system-e2e.test.ts new file mode 100644 index 0000000..0aa7252 --- /dev/null +++ b/src/__tests__/e2e/backup-system-e2e.test.ts @@ -0,0 +1,315 @@ +import { describe, test, expect, beforeAll, afterAll, mock } from 'bun:test'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { TranslationService } from '@/lib/services/translation-service'; +import { FileService } from '@/lib/services/file-service'; +import { BackupService } from '@/lib/services/backup-service'; +import type { TranslationTarget, TranslationTargetType } from '@/lib/types/minecraft'; + +// Mock Tauri API +const mockInvoke = mock(); +global.window = { + __TAURI_INTERNALS__: { + invoke: mockInvoke + } +} as any; + +// Simple mock adapter for E2E testing +class SimpleE2EMockAdapter { + async translate(request: any): Promise { + const translations: Record = {}; + + // Simple mock translation: add [JP] prefix + for (const [key, value] of Object.entries(request.content)) { + if (typeof value === 'string') { + translations[key] = `[JP] ${value}`; + } + } + + return { + success: true, + content: translations, + usage: { + prompt_tokens: 100, + completion_tokens: 100, + total_tokens: 200 + } + }; + } + + getChunkSize(): number { + return 50; + } + + dispose(): void { + // No-op + } +} + +describe('Backup System E2E Tests', () => { + const testDir = path.join(process.cwd(), 'test-output', 'backup-e2e'); + const configDir = path.join(testDir, 'config'); + const logsDir = path.join(testDir, 'logs'); + const sessionId = '2025-07-16_14-30-45'; + + beforeAll(async () => { + // Create test directories + await fs.mkdir(testDir, { recursive: true }); + await fs.mkdir(configDir, { recursive: true }); + await fs.mkdir(logsDir, { recursive: true }); + + // Mock file system operations + mockInvoke.mockImplementation(async (command: string, args: any) => { + switch (command) { + case 'generate_session_id': + return sessionId; + + case 'backup_snbt_files': { + // Simulate SNBT backup + const backupDir = path.join(args.sessionPath, 'backup', 'snbt_original'); + await fs.mkdir(backupDir, { recursive: true }); + for (const file of args.files) { + const fileName = path.basename(file); + await fs.writeFile(path.join(backupDir, fileName), 'original content'); + } + return; + } + + case 'backup_resource_pack': { + // Simulate resource pack backup + const packBackupDir = path.join(args.sessionPath, 'backup', 'resource_pack'); + await fs.mkdir(packBackupDir, { recursive: true }); + await fs.writeFile(path.join(packBackupDir, 'pack.mcmeta'), '{}'); + return; + } + + case 'update_translation_summary': { + // Simulate summary update + const summaryPath = path.join(logsDir, 'localizer', args.sessionId, 'translation_summary.json'); + await fs.mkdir(path.dirname(summaryPath), { recursive: true }); + + let summary = { lang: args.targetLanguage, translations: [] }; + try { + const existing = await fs.readFile(summaryPath, 'utf-8'); + summary = JSON.parse(existing); + } catch {} + + summary.translations.push({ + type: args.translationType, + name: args.name, + status: args.status, + keys: `${args.translatedKeys}/${args.totalKeys}` + }); + + await fs.writeFile(summaryPath, JSON.stringify(summary, null, 2)); + return; + } + + case 'list_translation_sessions': + // Return mock sessions + return [sessionId, '2025-07-15_10-15-23']; + + case 'get_translation_summary': + // Return mock summary + return { + lang: 'ja_jp', + translations: [ + { + type: 'quest', + name: 'test_quest.snbt', + status: 'completed', + keys: '10/10' + } + ] + }; + + // Handle logging commands + case 'log_translation_start': + case 'log_translation_statistics': + case 'log_translation_process': + case 'log_translation_completion': + case 'log_error': + case 'log_file_operation': + // Mock logging - just return success + return; + + default: + throw new Error(`Unknown command: ${command}`); + } + }); + }); + + afterAll(async () => { + // Clean up test directory + try { + await fs.rm(testDir, { recursive: true }); + } catch {} + }); + + test('SNBT backup flow', async () => { + const files = ['/test/quest1.snbt', '/test/quest2.snbt']; + const sessionPath = path.join(logsDir, 'localizer', sessionId); + + // Trigger backup + await mockInvoke('backup_snbt_files', { files, sessionPath }); + + // Verify backup was created + const backupDir = path.join(sessionPath, 'backup', 'snbt_original'); + const backupExists = await fs.access(backupDir).then(() => true).catch(() => false); + expect(backupExists).toBe(true); + + // Verify files were backed up + const backedUpFiles = await fs.readdir(backupDir); + expect(backedUpFiles).toContain('quest1.snbt'); + expect(backedUpFiles).toContain('quest2.snbt'); + }); + + test('Resource pack backup flow', async () => { + const packPath = '/test/resource-pack'; + const sessionPath = path.join(logsDir, 'localizer', sessionId); + + // Trigger backup + await mockInvoke('backup_resource_pack', { packPath, sessionPath }); + + // Verify backup was created + const backupDir = path.join(sessionPath, 'backup', 'resource_pack'); + const backupExists = await fs.access(backupDir).then(() => true).catch(() => false); + expect(backupExists).toBe(true); + + // Verify pack.mcmeta exists + const packMeta = await fs.readFile(path.join(backupDir, 'pack.mcmeta'), 'utf-8'); + expect(packMeta).toBe('{}'); + }); + + test('Translation summary updates', async () => { + const summaryData = { + minecraftDir: testDir, + sessionId, + translationType: 'quest', + name: 'test_quest.snbt', + status: 'completed', + translatedKeys: 10, + totalKeys: 10, + targetLanguage: 'ja_jp' + }; + + // Update summary + await mockInvoke('update_translation_summary', summaryData); + + // Verify summary was created + const summaryPath = path.join(logsDir, 'localizer', sessionId, 'translation_summary.json'); + const summaryExists = await fs.access(summaryPath).then(() => true).catch(() => false); + expect(summaryExists).toBe(true); + + // Verify content + const summary = JSON.parse(await fs.readFile(summaryPath, 'utf-8')); + expect(summary.lang).toBe('ja_jp'); + expect(summary.translations).toHaveLength(1); + expect(summary.translations[0]).toEqual({ + type: 'quest', + name: 'test_quest.snbt', + status: 'completed', + keys: '10/10' + }); + }); + + test('Translation history retrieval', async () => { + // Get session list + const sessions = await mockInvoke('list_translation_sessions', { minecraftDir: testDir }); + expect(sessions).toContain(sessionId); + expect(sessions[0]).toBe(sessionId); // Newest first + + // Get session summary + const summary = await mockInvoke('get_translation_summary', { + minecraftDir: testDir, + sessionId + }); + + expect(summary.lang).toBe('ja_jp'); + expect(summary.translations).toHaveLength(1); + + // Test session stats calculation + const totalTranslations = summary.translations.length; + const successfulTranslations = summary.translations.filter(t => t.status === 'completed').length; + const successRate = (successfulTranslations / totalTranslations) * 100; + + expect(totalTranslations).toBe(1); + expect(successfulTranslations).toBe(1); + expect(successRate).toBe(100); + }); + + test('Complete translation flow with backups', async () => { + // Setup test files + const questFile = path.join(configDir, 'ftbquests', 'quests', 'test.snbt'); + await fs.mkdir(path.dirname(questFile), { recursive: true }); + await fs.writeFile(questFile, 'quest_data { title: "Test Quest" }'); + + // The backup flow is triggered from the UI components (QuestsTab/ModsTab), + // not from the translation service itself. + // Here we'll just verify the backup commands work when called directly. + + const sessionPath = path.join(logsDir, 'localizer', sessionId); + + // Test SNBT backup + await mockInvoke('backup_snbt_files', { + files: [questFile], + sessionPath + }); + + // Verify backup directory was created + const backupDir = path.join(sessionPath, 'backup', 'snbt_original'); + const backupExists = await fs.access(backupDir).then(() => true).catch(() => false); + expect(backupExists).toBe(true); + + // Test translation summary update + await mockInvoke('update_translation_summary', { + minecraftDir: testDir, + sessionId, + translationType: 'quest', + name: 'test.snbt', + status: 'completed', + translatedKeys: 1, + totalKeys: 1, + targetLanguage: 'ja_jp' + }); + + // Verify summary was created + const summaryPath = path.join(logsDir, 'localizer', sessionId, 'translation_summary.json'); + const summaryExists = await fs.access(summaryPath).then(() => true).catch(() => false); + expect(summaryExists).toBe(true); + }); + + test('Backup service integration', async () => { + const backupService = new BackupService(); + + // Test backup ID generation + const backupId = backupService.generateBackupId('quest'); + expect(backupId).toMatch(/^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}_quest$/); + + // Mock Tauri environment for createBackup test + const originalInvoke = backupService['invoke']; + backupService['invoke'] = mockInvoke as any; + + // Mock successful backup creation + mockInvoke.mockImplementationOnce(async () => path.join(logsDir, 'backup', 'test-backup')); + + const result = await backupService.createBackup({ + type: 'quest' as TranslationTargetType, + sourceName: 'test_quest', + targetLanguage: 'ja_jp', + sessionId, + filePaths: ['/test/quest.snbt'], + statistics: { + totalKeys: 10, + successfulTranslations: 10, + fileSize: 1024 + } + }); + + expect(result.metadata.type).toBe('quest'); + expect(result.metadata.sourceName).toBe('test_quest'); + + // Restore original invoke + backupService['invoke'] = originalInvoke; + }); +}); \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx index 88b909c..e3abc6f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -7,6 +7,7 @@ import { MainLayout } from "@/components/layout/main-layout"; import { useAppStore } from "@/lib/store"; import { ConfigService } from "@/lib/services/config-service"; import { useAppTranslation } from "@/lib/i18n"; +import { toast } from "sonner"; // Import tabs import { ModsTab } from "@/components/tabs/mods-tab"; @@ -16,11 +17,14 @@ import { CustomFilesTab } from "@/components/tabs/custom-files-tab"; export default function Home() { const [isLoading, setIsLoading] = useState(true); - const { setConfig } = useAppStore(); - const { t } = useAppTranslation(); + const [activeTab, setActiveTab] = useState("mods"); + const { setConfig, isTranslating } = useAppStore(); + const { t, ready } = useAppTranslation(); + const [mounted, setMounted] = useState(false); // Load configuration on mount useEffect(() => { + setMounted(true); const loadConfig = async () => { try { const config = await ConfigService.load(); @@ -39,25 +43,43 @@ export default function Home() { return (
-

{t('misc.loading')}

+

{mounted && ready ? t('misc.loading') : 'Loading...'}

); } + const handleTabChange = (value: string) => { + if (isTranslating) { + toast.error(t('errors.translationInProgress'), { + description: t('errors.cannotSwitchTabs'), + }); + return; + } + setActiveTab(value); + }; + return ( - + - {t('tabs.mods')} - {t('tabs.quests')} - {t('tabs.guidebooks')} - {t('tabs.customFiles')} + + {t('tabs.mods')} + + + {t('tabs.quests')} + + + {t('tabs.guidebooks')} + + + {t('tabs.customFiles')} + - {t('cards.modTranslation')} + {mounted && ready ? t('cards.modTranslation') : 'Mod Translation'} @@ -67,7 +89,7 @@ export default function Home() { - {t('cards.questTranslation')} + {mounted && ready ? t('cards.questTranslation') : 'Quest Translation'} @@ -77,7 +99,7 @@ export default function Home() { - {t('cards.guidebookTranslation')} + {mounted && ready ? t('cards.guidebookTranslation') : 'Guidebook Translation'} @@ -87,7 +109,7 @@ export default function Home() { - {t('cards.customFilesTranslation')} + {mounted && ready ? t('cards.customFilesTranslation') : 'Custom Files Translation'} diff --git a/src/components/layout/main-layout.tsx b/src/components/layout/main-layout.tsx index 568300f..7f08d28 100644 --- a/src/components/layout/main-layout.tsx +++ b/src/components/layout/main-layout.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from 'react'; import { ThemeProvider } from '@/components/theme/theme-provider'; import { LogDialog } from '@/components/ui/log-dialog'; -import { HistoryDialog } from '@/components/ui/history-dialog'; +import { TranslationHistoryDialog } from '@/components/ui/translation-history-dialog'; import { DebugLogDialog } from '@/components/debug-log-dialog'; import { Header } from './header'; import { useAppStore } from '@/lib/store'; @@ -37,7 +37,7 @@ export function MainLayout({ children }: MainLayoutProps) { onOpenChange={setLogDialogOpen} /> {/* History Dialog */} - diff --git a/src/components/settings/backup-settings.tsx b/src/components/settings/backup-settings.tsx index 4a7eb3c..0e0b7ad 100644 --- a/src/components/settings/backup-settings.tsx +++ b/src/components/settings/backup-settings.tsx @@ -2,244 +2,28 @@ import { useAppTranslation } from "@/lib/i18n"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Label } from "@/components/ui/label"; -import { Switch } from "@/components/ui/switch"; -import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; -import { HardDrive, Trash2, Database } from "lucide-react"; -import { type AppConfig } from "@/lib/types/config"; -import { useState, useEffect } from "react"; -import { backupService } from "@/lib/services/backup-service"; +import { HardDrive } from "lucide-react"; -interface BackupSettingsProps { - config: AppConfig; - setConfig: (config: AppConfig) => void; -} - -export function BackupSettings({ config, setConfig }: BackupSettingsProps) { +export function BackupSettings() { const { t } = useAppTranslation(); - const [storageSize, setStorageSize] = useState(0); - const [isPruning, setIsPruning] = useState(false); - - // Load storage size on mount - useEffect(() => { - const loadStorageSize = async () => { - try { - const size = await backupService.getBackupStorageSize(); - setStorageSize(size); - } catch (error) { - console.error('Failed to load backup storage size:', error); - } - }; - - loadStorageSize(); - }, []); - - const formatSize = (bytes: number) => { - const sizes = ['Bytes', 'KB', 'MB', 'GB']; - if (bytes === 0) return '0 Bytes'; - const i = Math.floor(Math.log(bytes) / Math.log(1024)); - return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i]; - }; - - const handleToggleBackup = (enabled: boolean) => { - setConfig({ - ...config, - backup: { - ...backupConfig, - enabled - } - }); - }; - - const handleRetentionDaysChange = (value: string) => { - const retentionDays = parseInt(value) || 0; - setConfig({ - ...config, - backup: { - ...backupConfig, - retentionDays - } - }); - }; - - const handleMaxBackupsChange = (value: string) => { - const maxBackupsPerType = parseInt(value) || 0; - setConfig({ - ...config, - backup: { - ...backupConfig, - maxBackupsPerType - } - }); - }; - - const handleAutoPruneToggle = (autoPruneOnStartup: boolean) => { - setConfig({ - ...config, - backup: { - ...backupConfig, - autoPruneOnStartup - } - }); - }; - - const handlePruneNow = async () => { - try { - setIsPruning(true); - const deletedCount = await backupService.pruneOldBackups(config.backup?.retentionDays || 30); - - // Refresh storage size - const newSize = await backupService.getBackupStorageSize(); - setStorageSize(newSize); - - console.log(`Pruned ${deletedCount} old backups`); - } catch (error) { - console.error('Failed to prune backups:', error); - } finally { - setIsPruning(false); - } - }; - - // Ensure backup config exists - const backupConfig = config.backup || { - enabled: true, - retentionDays: 30, - maxBackupsPerType: 10, - autoPruneOnStartup: false - }; return ( - {t('settings.backup.title', 'Backup Settings')} + {t('settings.backup.title')} - {t('settings.backup.description', 'Configure automatic backup of translation files')} + {t('settings.backup.description')} - - {/* Enable Backup */} -
-
- -

- {t('settings.backup.enabledDescription', 'Create backups before overwriting translation files')} -

-
- -
- - {/* Retention Period */} -
- -
- handleRetentionDaysChange(e.target.value)} - className="w-32" - disabled={!backupConfig.enabled} - /> - - {backupConfig.retentionDays === 0 - ? t('settings.backup.keepForever', 'Keep forever') - : t('settings.backup.days', 'days') - } - -
-

- {t('settings.backup.retentionDescription', 'Backups older than this will be automatically deleted. Set to 0 to keep forever.')} -

-
- - {/* Max Backups Per Type */} -
- -
- handleMaxBackupsChange(e.target.value)} - className="w-32" - disabled={!backupConfig.enabled} - /> - - {backupConfig.maxBackupsPerType === 0 - ? t('settings.backup.unlimited', 'Unlimited') - : t('settings.backup.backups', 'backups') - } - -
-

- {t('settings.backup.maxBackupsDescription', 'Maximum number of backups to keep for each translation type. Set to 0 for unlimited.')} + +

+

+ {t('settings.backup.simpleBackupDescription', 'Backups are created automatically during translation. Original files are preserved before translation, and results are saved after completion.')}

- - {/* Auto Prune */} -
-
- -

- {t('settings.backup.autoPruneDescription', 'Automatically delete old backups when the application starts')} -

-
- -
- - {/* Storage Info */} -
-
-
- - - {t('settings.backup.storageUsed', 'Storage Used')} - -
- - {formatSize(storageSize)} - -
- -
- -
-
); diff --git a/src/components/settings/llm-settings.tsx b/src/components/settings/llm-settings.tsx index 8c8c37a..7e5c878 100644 --- a/src/components/settings/llm-settings.tsx +++ b/src/components/settings/llm-settings.tsx @@ -17,8 +17,55 @@ interface LLMSettingsProps { export function LLMSettings({ config, setConfig }: LLMSettingsProps) { - const { t } = useAppTranslation(); + const { t, ready } = useAppTranslation(); const [showApiKey, setShowApiKey] = useState(false); + + // Initialize apiKeys if not present + useEffect(() => { + if (!config.llm.apiKeys) { + const newConfig = { ...config }; + newConfig.llm = { + ...newConfig.llm, + apiKeys: { + openai: "", + anthropic: "", + google: "" + } + }; + setConfig(newConfig); + } + }, [config, setConfig]); + + // Get current API key based on selected provider + const getCurrentApiKey = () => { + const provider = config.llm.provider as keyof typeof config.llm.apiKeys; + // Use provider-specific key if available, fallback to legacy apiKey + return config.llm.apiKeys?.[provider] || config.llm.apiKey || ""; + }; + + // Set API key for current provider + const setCurrentApiKey = (value: string) => { + const newConfig = { ...config }; + const provider = newConfig.llm.provider as keyof typeof newConfig.llm.apiKeys; + + // Ensure apiKeys object exists + if (!newConfig.llm.apiKeys) { + newConfig.llm.apiKeys = { + openai: "", + anthropic: "", + google: "" + }; + } + + // Set provider-specific key + newConfig.llm.apiKeys[provider] = value; + + // Also update legacy apiKey for backward compatibility + newConfig.llm.apiKey = value; + + setConfig(newConfig); + }; + // Set default model when provider changes const handleProviderChange = (value: string) => { const newConfig = { ...config }; @@ -29,7 +76,16 @@ export function LLMSettings({ config, setConfig }: LLMSettingsProps) { newConfig.llm.model = DEFAULT_MODELS[value as keyof typeof DEFAULT_MODELS]; } + // Always update the legacy apiKey to match the provider-specific key (even if empty) + const provider = value as keyof typeof newConfig.llm.apiKeys; + if (newConfig.llm.apiKeys) { + newConfig.llm.apiKey = newConfig.llm.apiKeys[provider] || ""; + } + setConfig(newConfig); + + // Reset the show/hide state when switching providers + setShowApiKey(false); }; // Set default model on initial load if not set @@ -44,133 +100,193 @@ export function LLMSettings({ config, setConfig }: LLMSettingsProps) { } }, [config, setConfig]); + // Don't render until translations are loaded + if (!ready) { + return
; + } + + // Get provider display name + const getProviderDisplayName = (provider: string) => { + switch (provider) { + case "openai": + return t('settings.providers.openai'); + case "anthropic": + return t('settings.providers.anthropic'); + case "google": + return t('settings.providers.google'); + default: + return provider; + } + }; + return ( {t('settings.llmSettings')} - -
-
- - -
+ + {/* Provider Configuration Group */} +
+

+ {getProviderDisplayName(config.llm.provider)} Configuration +

-
- -
+
+ {/* Provider Selection */} +
+ + +
+ + {/* API Key for current provider */} +
+ +
+ setCurrentApiKey(e.target.value)} + placeholder={t('settings.apiKeyPlaceholder')} + className="pr-10" + /> + +
+

+ {t('settings.apiKeyProviderHint', { provider: getProviderDisplayName(config.llm.provider) })} +

+
+ + {/* Model for current provider */} +
+ { - config.llm.apiKey = e.target.value; - setConfig({ ...config }); + const newConfig = { ...config }; + newConfig.llm.model = e.target.value; + setConfig(newConfig); }} - placeholder={t('settings.apiKeyPlaceholder')} - className="pr-10" + placeholder={DEFAULT_MODELS[config.llm.provider as keyof typeof DEFAULT_MODELS] || t('settings.modelPlaceholder')} /> - +

+ {t('settings.modelProviderHint', { + provider: getProviderDisplayName(config.llm.provider), + defaultModel: DEFAULT_MODELS[config.llm.provider as keyof typeof DEFAULT_MODELS] + })} +

+
+ + {/* Advanced Settings */} +
+

+ {t('settings.advancedSettings')} +

-
- - { - config.llm.model = e.target.value; - setConfig({ ...config }); - }} - placeholder={DEFAULT_MODELS[config.llm.provider as keyof typeof DEFAULT_MODELS] || t('settings.modelPlaceholder')} - /> -
- -
- - { - config.llm.maxRetries = parseInt(e.target.value); - setConfig({ ...config }); - }} - placeholder={DEFAULT_API_CONFIG.maxRetries.toString()} - /> -
- -
- - { - config.llm.temperature = parseFloat(e.target.value); - setConfig({ ...config }); - }} - placeholder={DEFAULT_API_CONFIG.temperature.toString()} - min="0" - max="2" - step="0.1" - /> -

- {t('settings.temperatureHint')} -

-
- -
- -