diff --git a/invokeai/frontend/webv2/index.html b/invokeai/frontend/webv2/index.html index d5a772e46db..ef74665c231 100644 --- a/invokeai/frontend/webv2/index.html +++ b/invokeai/frontend/webv2/index.html @@ -35,6 +35,12 @@ if (localStorage.getItem('invokeai:v7:webv2:reduce-motion') === 'true') { root.setAttribute('data-reduce-motion', 'true'); } + var language = localStorage.getItem('invokeai:v7:webv2:language'); + if (language) { + root.setAttribute('lang', language); + var direction = language === 'ar' || language === 'he' ? 'rtl' : 'ltr'; + root.setAttribute('dir', direction); + } } catch (error) { /* ignore malformed storage */ } diff --git a/invokeai/frontend/webv2/package.json b/invokeai/frontend/webv2/package.json index 4bc3b578967..f401b2ffd90 100644 --- a/invokeai/frontend/webv2/package.json +++ b/invokeai/frontend/webv2/package.json @@ -10,6 +10,8 @@ "fix": "pnpm run lint:oxc:fix && pnpm run format:write", "format:check": "oxfmt --check .", "format:write": "oxfmt --write .", + "i18n:report": "node scripts/i18n-report.mjs", + "i18n:report:verbose": "node scripts/i18n-report.mjs --verbose", "lint": "pnpm run format:check && pnpm run lint:oxc && pnpm run lint:tsc", "lint:oxc": "oxlint --react-plugin --import-plugin --deny-warnings .", "lint:oxc:fix": "oxlint --react-plugin --import-plugin --fix .", @@ -28,10 +30,13 @@ "@tanstack/react-router": "^1.170.16", "@tanstack/react-virtual": "^3.14.3", "@xyflow/react": "^12.11.1", + "i18next": "^25.7.3", + "i18next-http-backend": "^3.0.2", "lucide-react": "^1.21.0", "react": "^19.2.7", "react-dom": "^19.2.7", "react-hook-tanstack-virtual": "^0.0.4", + "react-i18next": "^16.5.0", "react-icons": "^5.6.0", "socket.io-client": "^4.8.3", "tinykeys": "^4.0.0", diff --git a/invokeai/frontend/webv2/pnpm-lock.yaml b/invokeai/frontend/webv2/pnpm-lock.yaml index 16860f2e0c1..8616726e6bc 100644 --- a/invokeai/frontend/webv2/pnpm-lock.yaml +++ b/invokeai/frontend/webv2/pnpm-lock.yaml @@ -38,6 +38,12 @@ importers: '@xyflow/react': specifier: ^12.11.1 version: 12.11.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + i18next: + specifier: ^25.7.3 + version: 25.10.10(typescript@6.0.3) + i18next-http-backend: + specifier: ^3.0.2 + version: 3.0.6 lucide-react: specifier: ^1.21.0 version: 1.21.0(react@19.2.7) @@ -50,6 +56,9 @@ importers: react-hook-tanstack-virtual: specifier: ^0.0.4 version: 0.0.4(@tanstack/react-virtual@3.14.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@tanstack/virtual-core@3.17.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react-i18next: + specifier: ^16.5.0 + version: 16.6.6(i18next@25.10.10(typescript@6.0.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3) react-icons: specifier: ^5.6.0 version: 5.6.0(react@19.2.7) @@ -1239,6 +1248,9 @@ packages: resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} engines: {node: '>=10'} + cross-fetch@4.1.0: + resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -1364,6 +1376,20 @@ packages: hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + + i18next-http-backend@3.0.6: + resolution: {integrity: sha512-mBOqy8993jtqAoj6XaI1XeC/8/9v6EPS+681ziegrPvTB0DoaCY7PpTS0SpY56qLMoS4OI1TZEM2Zf59zNh05w==} + + i18next@25.10.10: + resolution: {integrity: sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ==} + peerDependencies: + typescript: ^5 || ^6 + peerDependenciesMeta: + typescript: + optional: true + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -1487,6 +1513,15 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + node-releases@2.0.50: resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} engines: {node: '>=18'} @@ -1572,6 +1607,22 @@ packages: react: ^19.2.0 react-dom: ^19.2.0 + react-i18next@16.6.6: + resolution: {integrity: sha512-ZgL2HUoW34UKUkOV7uSQFE1CDnRPD+tCR3ywSuWH7u2iapnz86U8Bi3Vrs620qNDzCf1F47NxglCEkchCTDOHw==} + peerDependencies: + i18next: '>= 25.10.9' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 || ^6 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + react-icons@5.6.0: resolution: {integrity: sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==} peerDependencies: @@ -1670,6 +1721,9 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -1779,6 +1833,16 @@ packages: jsdom: optional: true + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -3212,6 +3276,12 @@ snapshots: path-type: 4.0.0 yaml: 1.10.3 + cross-fetch@4.1.0: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + csstype@3.2.3: {} d3-color@3.1.0: {} @@ -3341,6 +3411,22 @@ snapshots: dependencies: react-is: 16.13.1 + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + + i18next-http-backend@3.0.6: + dependencies: + cross-fetch: 4.1.0 + transitivePeerDependencies: + - encoding + + i18next@25.10.10(typescript@6.0.3): + dependencies: + '@babel/runtime': 7.29.7 + optionalDependencies: + typescript: 6.0.3 + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -3429,6 +3515,10 @@ snapshots: nanoid@3.3.15: {} + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + node-releases@2.0.50: {} obug@2.1.3: {} @@ -3526,6 +3616,17 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) + react-i18next@16.6.6(i18next@25.10.10(typescript@6.0.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3): + dependencies: + '@babel/runtime': 7.29.7 + html-parse-stringify: 3.0.1 + i18next: 25.10.10(typescript@6.0.3) + react: 19.2.7 + use-sync-external-store: 1.6.0(react@19.2.7) + optionalDependencies: + react-dom: 19.2.7(react@19.2.7) + typescript: 6.0.3 + react-icons@5.6.0(react@19.2.7): dependencies: react: 19.2.7 @@ -3621,6 +3722,8 @@ snapshots: tinyrainbow@3.1.0: {} + tr46@0.0.3: {} + tslib@2.8.1: {} typescript@6.0.3: {} @@ -3678,6 +3781,15 @@ snapshots: transitivePeerDependencies: - msw + void-elements@3.1.0: {} + + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 diff --git a/invokeai/frontend/webv2/public/locales/ar.json b/invokeai/frontend/webv2/public/locales/ar.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/ar.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/az.json b/invokeai/frontend/webv2/public/locales/az.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/az.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/bg.json b/invokeai/frontend/webv2/public/locales/bg.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/bg.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/de.json b/invokeai/frontend/webv2/public/locales/de.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/de.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/en-GB.json b/invokeai/frontend/webv2/public/locales/en-GB.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/en-GB.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json new file mode 100644 index 00000000000..f74ced7dfd9 --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -0,0 +1,1014 @@ +{ + "common": { + "actions": "Actions", + "active": "Active", + "add": "Add", + "all": "All", + "archived": "Archived", + "asc": "Asc", + "assets": "Assets", + "cancel": "Cancel", + "cancelFailed": "Cancel failed", + "canceled": "Canceled", + "clear": "Clear", + "clearSearch": "Clear search", + "close": "Close", + "configure": "Configure", + "copy": "Copy", + "clipboardBlocked": "Clipboard access was blocked by the browser.", + "couldNotCopy": "Could not copy", + "countOfTotal": "{{count}} of {{total}}", + "created": "Created", + "delete": "Delete", + "desc": "Desc", + "discard": "Discard", + "discardAll": "Discard All", + "done": "Done", + "duplicate": "Duplicate", + "edit": "Edit", + "error": "Error", + "export": "Export", + "failed": "Failed", + "generating": "Generating", + "graph": "Graph", + "images": "Images", + "import": "Import", + "id": "ID", + "item": "Item", + "json": "JSON", + "lastSaved": "Last saved", + "loadingApplication": "Loading Application", + "negative": "Negative", + "name": "Name", + "nothingHereYet": "Nothing here yet.", + "none": "None", + "notYet": "Not yet", + "open": "Open", + "pageNumber": "Page {{page}}", + "pages": "Pages", + "previousPage": "Previous page", + "prompt": "Prompt", + "project": "Project", + "rename": "Rename", + "required": "Required", + "refresh": "Refresh", + "reload": "Reload", + "retry": "Retry", + "saved": "Saved", + "save": "Save", + "seed": "Seed", + "settings": "Settings", + "nextPage": "Next page", + "somethingWentWrong": "Something went wrong.", + "swap": "Swap", + "status": { + "canceled": "Canceled", + "completed": "Completed", + "failed": "Failed", + "pending": "Pending" + }, + "total": "Total", + "unknownTime": "Unknown time", + "view": "View", + "viewJson": "View JSON", + "widgetViewUnavailable": "Widget view unavailable." + }, + "app": { + "name": "Invoke AI", + "nameWithVersion": "{{name}} {{version}}" + }, + "auth": { + "account": "Account", + "accountSettings": "Account settings", + "accountUpdated": "Account updated", + "administrator": "Administrator", + "changePassword": "Change password", + "confirmNewPassword": "Confirm new password", + "confirmPassword": "Confirm password", + "couldNotCreateAdmin": "Could not create the administrator account.", + "couldNotUpdateAccount": "Could not update your account.", + "createAdminAccount": "Create administrator account", + "currentPassword": "Current password", + "displayNameHelp": "Optional — shown instead of your email.", + "hidePassword": "Hide password", + "keepSignedIn": "Keep me signed in for a week", + "leaveBlankKeepPassword": "Leave blank to keep your password.", + "password": "Password", + "passwordGeneratedDescription": "Reveal it with the eye icon and store it somewhere safe.", + "passwordStrength": { + "moderate": "Moderate", + "strong": "Strong", + "weak": "Weak" + }, + "profileDisplayNameHelp": "Shown instead of your email across the workspace.", + "sessionExpired": "Your session expired. Sign in again to continue.", + "setupFooter": "You can add more users later from the user menu.", + "setupSubtitle": "Create the administrator account to get started.", + "setupTitle": "Set up Invoke", + "showPassword": "Show password", + "signIn": "Sign in", + "signInFailed": "Sign-in failed. Check your email and password.", + "signInSubtitle": "Sign in to your workspace to continue.", + "signedInAs": "Signed in as {{email}}", + "signOut": "Sign out", + "welcomeTitle": "Welcome to Invoke", + "yourPassword": "Your password" + }, + "splash": { + "artworkBy": "Artwork by {{artist}}", + "copyright": "© {{year}} Invoke AI | All rights reserved", + "loadingApplication": "Loading Application", + "loadingWorkspace": "Loading workspace", + "openingProject": "Opening project", + "tagline": "Image Generation for Creatives" + }, + "notifications": { + "empty": "Successful operations, errors, and system messages appear here.", + "labelWithCount": "Notifications: {{label}}", + "newCount_one": "{{count}} new", + "newCount_other": "{{count}} new", + "totalCount_one": "{{count}} total", + "totalCount_other": "{{count}} total", + "kind": { + "error": "Error", + "info": "Info", + "success": "Success" + }, + "markRead": "Mark Read" + }, + "projects": { + "allSavedAlreadyOpen": "All saved projects are already open.", + "closeProjectLabel": "Close {{name}}", + "couldNotOpen": "Could not open project", + "couldNotOpenDescription": "\"{{name}}\" could not be loaded from the server.", + "createNewProject": "Create new project", + "deleteFailed": "Delete failed", + "deleteProject": "Delete project", + "deleteProjectQuestion": "Delete project?", + "deleteProjectCardBody": "Delete \"{{name}}\"? The project is removed from the server permanently.", + "deleteProjectTabBody": "Delete \"{{name}}\"? The project and its saved copy on the server are removed permanently. To keep it in your library, close the tab instead.", + "deleteProjectWithEllipsis": "Delete project…", + "duplicateFailed": "Duplicate failed", + "editedRelative": "Edited {{time}}", + "exportFailed": "Export failed", + "failedToLoad": "Failed to load your projects.", + "importFailed": "Import failed", + "importWithEllipsis": "Import…", + "newProject": "New project", + "noSavedProjects": "No saved projects yet.", + "openProject": "Open project", + "openProjectLabel": "Open {{name}}", + "projectDetails": "Project details", + "renameFailed": "Rename failed", + "renameWithEllipsis": "Rename…", + "projectDuplicated": "Project duplicated", + "projectDuplicatedDescription": "\"{{name}}\" was created." + }, + "users": { + "accountCount_one": "{{count}} account in this workspace.", + "accountCount_other": "{{count}} accounts in this workspace.", + "activeLabel": "{{name}} active", + "addUser": "Add user", + "admin": "Admin", + "adminOnly": "User management is available to administrators only.", + "administrator": "Administrator", + "administratorHelp": "Can manage users and all workspace settings.", + "backendRejectedChange": "The backend rejected the change.", + "backendRejectedRequest": "The backend rejected the request.", + "cannotChangeSelfRole": "You cannot change your own role.", + "cannotDeactivateSelf": "You cannot deactivate your own account.", + "cannotDeleteSelf": "You cannot delete your own account.", + "couldNotActivate": "Could not activate the user", + "couldNotCreate": "Could not create the user.", + "couldNotDeactivate": "Could not deactivate the user", + "couldNotDelete": "Could not delete the user", + "couldNotGeneratePassword": "Could not generate a password", + "couldNotLoad": "Could not load users.", + "couldNotUpdate": "Could not update the user.", + "createUser": "Create user", + "created": "User created", + "deleted": "User deleted", + "deleteBody": "Delete {{name}}? Their account is removed permanently.", + "deleteTitle": "Delete user?", + "deleteUser": "Delete user", + "deleteUserNamed": "Delete {{name}}", + "description": "Manage workspace accounts and roles.", + "displayName": "Display name", + "displayNameHelp": "Optional — shown instead of the email.", + "editUser": "Edit user", + "editUserNamed": "Edit {{name}}", + "email": "Email", + "generate": "Generate", + "lastSignIn": "Last sign-in", + "leaveBlankPassword": "Leave blank to keep the current password.", + "manageUsers": "Manage users", + "management": "User management", + "never": "Never", + "newPassword": "New password", + "password": "Password", + "passwordGenerated": "Password generated", + "passwordGeneratedDescription": "Reveal it with the eye icon and share it with the user securely.", + "role": "Role", + "saveChanges": "Save changes", + "thisUser": "this user", + "title": "Users", + "updated": "User updated", + "user": "User", + "you": "You" + }, + "graphPreview": { + "graphJsonLabel": "{{title}} graph JSON", + "inputCount_one": "{{count}} input", + "inputCount_other": "{{count}} inputs", + "invokeRoute": "Invoke {{route}}", + "noCompiledGraph": "No compiled graph is available for \"{{graphId}}\" yet.", + "nodes": "Nodes", + "title": "{{title}} Graph Preview" + }, + "hotkeys": { + "addHotkey": "Add hotkey", + "bindings": "Hotkey bindings", + "cancelEdit": "Cancel hotkey edit", + "categories": { + "app": "App", + "canvas": "Canvas", + "gallery": "Gallery", + "viewer": "Viewer", + "workflows": "Workflows" + }, + "conflictsWith": "Conflicts with {{title}}", + "custom": "Custom", + "deleteHotkey": "Delete hotkey", + "description": "Account-bound keybinds. Project-level overrides can layer on later without changing hotkey ids.", + "disable": "Disable", + "disabled": "Disabled", + "duplicateBinding": "Duplicate binding in this hotkey", + "pending": "Pending", + "pressKeys": "Press keys", + "resetAll": "Reset all", + "resetHotkey": "Reset hotkey", + "saveEdit": "Save hotkey edit", + "searchPlaceholder": "Search hotkeys", + "title": "Hotkeys" + }, + "models": { + "accessToken": "Access Token", + "accessTokenForDownload": "Access token for this download", + "accessTokenHelp": "Attached to the next Pull only. For saved HuggingFace, Civitai, and provider keys, use the API Keys tab.", + "accessTokenPlaceholder": "Only needed for protected sources", + "actions": "Model actions", + "addImage": "Add image", + "addModels": "Add Models", + "addModelsResults": "Add models results", + "addTriggerPhrase": "Add a trigger phrase...", + "allBases": "All bases", + "allModels": "All models", + "apiKey": "API key", + "apiKeyConfigured": "API key configured", + "apiKeyFor": "{{title}} API key", + "apiKeys": "API Keys", + "apiKeysDescription": "Keys are used when installing protected models and when generating with external API models.", + "base": "Base", + "baseArchitecture": "Base Architecture", + "baseUrlFor": "{{title}} base URL", + "backendDisconnectedProgressStale": "Backend disconnected. Progress may be stale.", + "bundleInstallQueued": "Bundle install queued", + "bundleInstallQueuedDescription_one": "{{name}}: {{count}} install queued.", + "bundleInstallQueuedDescription_other": "{{name}}: {{count}} installs queued.", + "bytePlusApiKeyPlaceholder": "API key from BytePlus console", + "bulkDeleteBody_one": "Delete {{count}} selected model? Database records are removed, and files inside the InvokeAI models directory are deleted.", + "bulkDeleteBody_other": "Delete {{count}} selected models? Database records are removed, and files inside the InvokeAI models directory are deleted.", + "bulkDeleteConfirm_one": "Delete {{count}} Model", + "bulkDeleteConfirm_other": "Delete {{count}} Models", + "bulkDeleteFailed": "Bulk delete failed", + "bulkDeletePartialDescription": "{{deleted}} deleted; {{failed}} failed: {{error}}", + "cacheEmptied": "Model cache emptied", + "cleanupOrphaned": "Clean up orphaned models…", + "clearSelection": "Clear selection", + "convert": "Convert", + "convertBody": "Convert \"{{name}}\" to the diffusers format in place? The original checkpoint file is replaced by a diffusers folder.", + "convertToDiffusers": "Convert to diffusers", + "couldNotLoad": "Could not load models", + "couldNotLoadInstallQueue": "Could not load the install queue", + "couldNotLoadStarterModels": "Could not load starter models", + "customizedForThisModel": "Customized for this model", + "customizeDefaultField": "Customize {{field}} for this model", + "civitaiKey": "Civitai key", + "civitaiKeyDescription": "Attached automatically when installing civitai.com models that require login. Stored in this browser only.", + "civitaiKeyPlaceholder": "32-character key from civitai.com/user/account", + "cancelAllInstalls": "Cancel all installs", + "cancelFailed": "Cancel failed", + "cancelInstall": "Cancel install", + "clearFinished": "Clear finished", + "defaultFieldInherited": { + "cfgRescale": "App default: 0 (off)", + "cfgScale": "App default: 7", + "guidance": "Uses the generator default", + "height": "App default: 1024", + "preprocessor": "Chosen automatically per image", + "scheduler": "App default: euler_a", + "steps": "App default: 30", + "vaePrecision": "App default: fp16", + "weight": "App default: 0.75", + "width": "App default: 1024" + }, + "defaultFields": { + "cfgRescale": "CFG Rescale", + "cfgScale": "CFG Scale", + "guidance": "Guidance", + "height": "Height", + "preprocessor": "Preprocessor", + "scheduler": "Scheduler", + "steps": "Steps", + "vaePrecision": "VAE Precision", + "weight": "Weight", + "width": "Width" + }, + "defaultSettings": "Default Settings", + "defaultSettingsHelp": "Applied automatically when this model is selected. Off = use the app default.", + "defaultSettingsSaved": "Default settings saved", + "deleteBody": "Delete \"{{name}}\"? The database record is removed, and the model files are deleted if they live inside the InvokeAI models directory.", + "deleteModel": "Delete model", + "deleted": "Models deleted", + "deletedDescription_one": "{{count}} model deleted.", + "deletedDescription_other": "{{count}} models deleted.", + "deleteSelectedTitle": "Delete selected models", + "deleteSelectedWithBytes": "Delete Selected ({{bytes}})", + "description": "Description", + "details": "Model Details", + "dismissResults": "Dismiss results", + "dismissScanResults": "Dismiss scan results", + "downloadProgress": "Download progress", + "editing": "Editing", + "emptyCache": "Empty model cache", + "failedToEmptyCache": "Failed to empty model cache", + "failedToLinkModels": "Failed to link models.", + "failedToLoadExternalProviders": "Failed to load external providers.", + "failedToLoadRelatedModels": "Failed to load related models.", + "failedToRemoveModelImage": "Failed to remove model image.", + "failedToSaveDefaults": "Failed to save default settings.", + "failedToScanOrphaned": "Failed to scan for orphaned models.", + "failedToUnlink": "Failed to unlink models.", + "failedToUpdateTriggerPhrases": "Failed to update trigger phrases.", + "failedToUploadModelImage": "Failed to upload model image.", + "fileNotFoundOnDisk": "The file for this model was not found on disk.", + "fileCount_one": "{{count}} file", + "fileCount_other": "{{count}} files", + "filesInRepo_one": "{{count}} file in {{repo}}", + "filesInRepo_other": "{{count}} files in {{repo}}", + "fileSize": "File Size", + "filterAndSortStarterModels": "Filter and sort starter models", + "filterFiles": "Filter files", + "filterAndSort": "Filter and sort models", + "filterResults": "Filter results", + "hash": "Hash", + "huggingFaceKeyDescription": "For gated repos like FLUX. Stored on the InvokeAI server and verified with HuggingFace.", + "huggingFaceRejectedToken": "HuggingFace rejected this token. Check that it has read access.", + "huggingFaceToken": "HuggingFace token", + "installAll": "Install all", + "installAllCount": "Install all ({{count}})", + "installFailed": "Install failed", + "installJobs": "Install jobs", + "installJobSummary_one": "{{count}} job · no active installs", + "installJobSummary_other": "{{count}} jobs · no active installs", + "installInPlace": "Install in place", + "installQueue": "Install Queue", + "installed": "Installed", + "installsDependencies_one": " (installs {{count}} dependency)", + "installsDependencies_other": " (installs {{count}} dependencies)", + "invalidDefaultSettings": "Invalid default settings.", + "invalidTriggerPhrase": "Invalid trigger phrase.", + "keyStatus": { + "configured": "Configured", + "invalid": "Invalid", + "saved": "Saved", + "unknown": "Not set", + "valid": "Valid" + }, + "library": "Model library", + "libraryMaintenance": "Model library maintenance", + "manager": "Model Manager", + "manageApiKeys": "Manage API keys", + "missingFilesCount": "Missing files ({{count}})", + "modelAndDependenciesQueued_one": "{{name}} and {{count}} dependency", + "modelAndDependenciesQueued_other": "{{name}} and {{count}} dependencies", + "modelCoverAlt": "{{name}} cover", + "modelImage": "Model image", + "modelImageUpdated": "Model image updated", + "modelInstallQueued": "Model install queued", + "modelManager": "Model manager", + "modelUpdated": "Model updated", + "modelType": "Model Type", + "noInstallableModelFiles": "This HuggingFace repo has no installable model files.", + "noInstallsDescription": "Model installs appear here while they download and import.", + "noInstallsYet": "No installs yet", + "noModelFilesFound": "No model files found in {{path}}.", + "noModelFilesFoundTitle": "No model files found", + "noRelatedModels": "No related models linked yet.", + "noStarterModelsPull": "No starter models match - press Pull to install the source you entered.", + "noStarterModelsSearch": "No starter models match your search.", + "noneInstalled": "No models installed", + "noneInstalledDescription": "Use Add Models to install your first model.", + "noneMatchFilters": "No models match your filters", + "noneMatchFiltersDescription": "Try clearing the search or filters.", + "noOrphaned": "No orphaned model folders found — your library is clean.", + "orphanedCleanup": "Orphaned model cleanup", + "orphanedCleanupFailed": "Orphaned model cleanup failed", + "orphanedCleanupPartialDescription": "{{deleted}} deleted, {{failed}} failed: {{error}}", + "orphanedDeletedDescription_one": "{{count}} orphaned model folder deleted.", + "orphanedDeletedDescription_other": "{{count}} orphaned model folders deleted.", + "orphanedDescription": "Folders in the models directory with no database record — usually leftovers from failed installs or external deletes.", + "orphanedTitle": "Orphaned Models", + "predictionType": "Prediction Type", + "overrideBaseUrl": "Override base URL", + "path": "Path", + "pauseAllDownloads": "Pause all downloads", + "pauseAllInstalls": "Pause all installs", + "pauseDownload": "Pause download", + "pauseFailed": "Pause failed", + "pauseInstall": "Pause install", + "plusMore": " +{{count}} more", + "pruneFailed": "Prune failed", + "press": "Press", + "pull": "Pull", + "queueActionFailed": "Queue action failed", + "refreshList": "Refresh model list", + "reidentify": "Re-identify model", + "relatedModels": "Related Models", + "relatedModelsHelp": "Link the models that pair well with this one - they are suggested together elsewhere in the app.", + "removeImage": "Remove image", + "removeModelImage": "Remove model image", + "removeTriggerPhrase": "Remove trigger phrase {{phrase}}", + "restartFile": "Restart file", + "restartFileFailed": "Restart file failed", + "restartThisFile": "Restart this file", + "resumeAllInstalls": "Resume all installs", + "resumeAllPausedDownloads": "Resume all paused downloads", + "resumeDownload": "Resume download", + "resumeFailed": "Resume failed", + "resumeInstall": "Resume install", + "resumeRequired": "Resume required", + "retryFailed": "Retry failed", + "retryFailedDownload": "Retry failed download", + "retryInstall": "Retry install", + "saveDefaults": "Save Defaults", + "scan": "Scan", + "scanFailed": "Scan failed", + "scanFolderTooltip": "Scan this folder for model files", + "scanSummary_one": "{{count}} file in {{path}} · {{notInstalled}} not installed", + "scanSummary_other": "{{count}} files in {{path}} · {{notInstalled}} not installed", + "searchModels": "Search models", + "searchModelsPlaceholder": "Search models…", + "searchCompatibleToLink": "Search compatible models to link...", + "searchOrAdd": "Search or add a model", + "searchOrAddPlaceholder": "Search models, or paste a URL, file path, or Hugging Face repo", + "selectAllCount": "Select all ({{count}})", + "selectedCount_one": "{{count}} selected", + "selectedCount_other": "{{count}} selected", + "selectModel": "Select a model", + "selectModelDescription": "Pick a model from the library to view details, edit metadata, set per-model defaults, and manage trigger phrases.", + "someCouldNotBeDeleted": "Some models could not be deleted", + "sortBase": "Base", + "sortDefault": "Default", + "sortFormat": "Format", + "sortName": "Name", + "sortSize": "Size", + "sort": { + "base": "Base", + "default": "Default", + "format": "Format", + "name": "Name", + "size": "Size" + }, + "sortBy": "Sort By", + "source": "Source", + "sourceUrl": "Source URL", + "sourceUrlHelp": "Model page or download URL.", + "title": "Models", + "toFindModelsInFolder": "to find models in this folder.", + "toInstallFrom": "to install from {{source}}.", + "type": "Type", + "triggerPhraseDuplicate": "That trigger phrase is already on this model.", + "triggerPhrases": "Trigger Phrases", + "triggerPhrasesHelp": "Phrases that activate this model, surfaced in the prompt editor.", + "unlink": "Unlink", + "unlinkNamed": "Unlink {{name}}", + "uploadCoverImageFor": "Upload cover image for {{name}}", + "useSupportedImage": "Use a PNG, JPEG, or WebP image.", + "variant": "Variant", + "variantHelp": "Architecture variant, e.g. inpaint or dev.", + "waitingForDownload": "Waiting for download" + }, + "nodes": { + "addCustomNodes": "Add custom nodes", + "addNodes": "Add Nodes", + "couldNotReloadCustomNodes": "Could not reload custom nodes.", + "couldNotLoadPacks": "Could not load custom node packs", + "customNodesReloaded": "Custom nodes reloaded", + "dependenciesRequired": "Dependencies required", + "dependenciesRequiredDescription": "{{name}} requires dependencies from {{dependencyFile}}. Install them, then restart InvokeAI.", + "details": "Node Pack Details", + "fieldName": "Field: {{name}}", + "fieldType": "Type: {{type}}", + "gitUrl": "Git URL", + "gitUrlHelp": "Paste a public Git repository URL. InvokeAI clones it into your custom nodes directory.", + "input": "Input", + "install": "Install", + "installActivity": "Custom node install activity", + "installFailed": "Install failed.", + "installing": "Installing", + "installedPacks": "Installed custom node packs", + "manager": "Nodes Manager", + "message": "Message", + "nodeCategory": "Category: {{category}}", + "nodeCount_one": "{{count}} node", + "nodeCount_other": "{{count}} nodes", + "nodePack": "node pack", + "nodePacks": "Node Packs", + "nodeType": "Type: {{type}}", + "nodeVersion": "Version: {{version}}", + "nodesDirectory": "Nodes Directory", + "nodesInPack": "Nodes in this pack", + "noPacks": "No custom node packs", + "noPacksDescription": "Install from a Git URL or place packs in your custom nodes directory, then reload.", + "noPacksMatch": "No packs match", + "noPreviews": "No node previews available", + "noPreviewsDescription": "The backend has no node definitions for this pack yet. Reload nodes or restart InvokeAI to load them.", + "noRecentActivity": "No recent activity", + "noRecentActivityDescription": "Install or uninstall a node pack and the outcome shows up here.", + "output": "Output", + "recentActivitySummary_one": "{{count}} recent activity", + "recentActivitySummary_other": "{{count}} recent activities", + "reloadFailed": "Reload failed", + "reloading": "Reloading", + "scanFolder": "Scan Folder", + "scanFolderDescription": "Add node packs directly to the custom nodes directory, then reload nodes. InvokeAI scans this folder on startup and reload.", + "searchPacks": "Search node packs", + "searchPacksPlaceholder": "Search packs…", + "selectPack": "Select a node pack", + "selectPackDescription": "Choose a pack from the library to see its path, manage it, and preview every node it adds.", + "status": "Status", + "trustWarning": "Custom nodes execute Python code. Only install packs from authors you trust.", + "tryDifferentSearch": "Try a different search.", + "uninstall": "Uninstall", + "uninstallBody": "Remove this pack from the custom nodes directory? A restart is required for removal to fully apply.", + "uninstallPack": "Uninstall Node Pack", + "uninstallTitle": "Uninstall {{name}}?", + "uninstalled": "Uninstalled" + }, + "launchpad": { + "projectsGreeting": "Welcome to Invoke", + "projectsGreetingWithName": "Welcome back, {{name}}", + "projectsSubtitle": "Pick up where you left off, or start something new.", + "sections": { + "models": "Models", + "nodes": "Nodes", + "projects": "Projects", + "users": "Users" + }, + "sectionsLabel": "Launchpad sections" + }, + "settings": { + "title": "Settings", + "descriptionGlobal": "Global preferences for this single-user install. Project settings apply only to the active project.", + "descriptionUser": "Preferences for the signed-in user. Project settings apply only to the active project.", + "enableHighlightFocusedRegions": "Highlight focused region", + "language": "Language", + "languageDescription": "Interface language. New workbench copy falls back to English until translators add coverage.", + "tabs": { + "appearance": "Appearance", + "behavior": "Behavior", + "developer": "Developer", + "hotkeys": "Hotkeys", + "project": "Project", + "queue": "Queue", + "workflow": "Workflow", + "workspace": "Workspace" + } + }, + "shell": { + "addCurrentLayout": "Add current layout...", + "builtInPresets": "Built-in presets", + "customLayout": "Custom layout {{number}}", + "customPresets": "Custom presets", + "deleteLayoutPreset": "Delete preset", + "deleteLayoutPresetBody": "Delete \"{{name}}\"? This only removes the saved preset.", + "deleteLayoutPresetQuestion": "Delete layout preset?", + "layoutPresetName": "Preset name", + "renameLayoutPreset": "Rename layout preset" + }, + "widgets": { + "actionsLabel": "{{label}} actions", + "removeWidget": "Remove {{label}}", + "resizePanel": "Resize {{region}} widget panel", + "settingsLabel": "{{label}} settings", + "visibilityLabel": "{{region}} widget visibility", + "groupLabel": "Widgets", + "labels": { + "autosaveStatus": "Autosave", + "canvas": "Canvas", + "diagnostics": "Diagnostics", + "gallery": "Gallery", + "generate": "Generate", + "layers": "Layers", + "notifications": "Notifications", + "preview": "Preview", + "project": "Project", + "queue": "Queue", + "serverStatus": "Server Status", + "versionStatus": "Version", + "workflow": "Workflow" + }, + "generate": { + "addPromptTrigger": "Add prompt trigger", + "activeCount_one": "{{count}} active", + "activeCount_other": "{{count}} active", + "advanced": "Advanced", + "addConcept": "Add concept", + "addConceptsHelp": "Add LoRAs/concepts here to blend them into this generation.", + "aspectRatio": "Aspect ratio", + "cfgRescale": "CFG rescale", + "clipSkip": "CLIP skip", + "colorCompensation": "Color compensation", + "components": "Components", + "concepts": "Concepts", + "conceptWeight": "{{name}} weight", + "compatibleEmbeddings": "Compatible embeddings", + "couldNotExpandPrompt": "Could not expand the prompt.", + "couldNotGeneratePromptFromImage": "Could not generate a prompt from the selected image.", + "deletePromptHistoryItem": "Delete prompt history item", + "dimensions": "Dimensions", + "disableConcept": "Disable {{name}}", + "enableNegativePrompt": "Enable negative prompt", + "enableConcept": "Enable {{name}}", + "expand": "Expand", + "expandPrompt": "Expand prompt", + "generatePrompt": "Generate Prompt", + "imageToPrompt": "Image to prompt", + "height": "Height", + "incompatible": "Incompatible", + "incompatibleSettingsCleared": "Incompatible settings cleared", + "incompatibleSettingsClearedDescription_one": "{{labels}} was not compatible with {{name}}.", + "incompatibleSettingsClearedDescription_other": "{{labels}} were not compatible with {{name}}.", + "installTextLlmToExpandPrompts": "Install a Text LLM model to expand prompts.", + "installVisionModelToGeneratePrompts": "Install a LLaVA OneVision model to generate prompts from images.", + "lockAspectRatio": "Lock aspect ratio", + "mainModel": "Main model", + "model": "Model", + "modelDefault": "Model default", + "multipleOf": "Multiple of {{value}}", + "negativePrompt": "Negative prompt", + "noMatchingPrompts": "No matching prompts.", + "noMatchingTriggers": "No matching triggers.", + "noPromptHistoryYet": "No prompt history yet.", + "noPromptTriggersAvailable": "No prompt triggers available.", + "noTextLlmInstalled": "No Text LLM installed", + "noVisionModelInstalled": "No vision model installed", + "offCount_one": "{{count}} off", + "offCount_other": "{{count}} off", + "positivePrompt": "Positive prompt", + "promptHistory": "Prompt history", + "promptHistoryEntries": "Prompt history entries", + "promptHistoryKeyboardHelp": "Alt+Up/Down switches between prompts while focused.", + "promptTemplate": "Prompt Template", + "promptTriggerOptions": "Prompt trigger options", + "random": "Random", + "removeConcept": "Remove concept", + "removeConceptNamed": "Remove {{name}}", + "resizeNegativePrompt": "Resize negative prompt", + "resizePositivePrompt": "Resize positive prompt", + "scheduler": "Scheduler", + "searchPromptHistory": "Search prompt history", + "searchPromptTriggers": "Search prompt triggers", + "searchCompatibleConcepts": "Search compatible concepts...", + "seamlessTiling": "Seamless tiling", + "selectImageFirst": "Select an image in Gallery or Preview first.", + "selectMainModelBeforeConcepts": "Select a main model before adding concepts.", + "selectModel": "Select a model…", + "selectModelFirst": "Select a model first", + "selectTextLlm": "Select Text LLM", + "selectVisionModel": "Select vision model", + "setOptimalSize": "Set optimal size", + "setOptimalSizeDescription": "Set size to the model's optimal pixel count", + "shuffleSeed": "Shuffle seed", + "showDynamicPrompts": "Show dynamic prompts", + "steps": "Steps", + "swapWidthAndHeight": "Swap width and height", + "tileX": "Tile X", + "tileY": "Tile Y", + "unlockAspectRatio": "Unlock aspect ratio", + "useModelDefault": "Use model default", + "useConceptDefaultWeight": "Use concept default weight", + "useModelDefaultCfgRescale": "Use model default CFG rescale", + "useModelDefaultField": "Use model default {{field}}", + "useModelDefaultHeight": "Use model default height", + "useModelDefaultScheduler": "Use model default scheduler", + "useModelDefaultSteps": "Use model default steps", + "useModelDefaultVae": "Use model default VAE", + "useModelDefaultVaePrecision": "Use model default VAE precision", + "useModelDefaultWidth": "Use model default width", + "usePrompt": "Use prompt", + "usingBundledVae": "Using the VAE bundled with the model.", + "vae": "VAE", + "vaePrecision": "VAE precision", + "width": "Width", + "xAxis": "X axis", + "yAxis": "Y axis" + }, + "graph": { + "setSource": "Set Source", + "viewGraph": "View Graph" + }, + "project": { + "copyId": "Copy project id", + "deleteRecovery": "Delete recovery", + "deleteRecoveryAria": "Delete {{name}}", + "deleteRecoveryBody": "Delete \"{{name}}\"? The recovery copy is removed permanently.", + "deleteRecoveryTitle": "Delete recovery?", + "events": "Events", + "graphNodes": "Graph nodes", + "idCopied": "Project id copied", + "nameHelp": "Saved with the project.", + "nameLabel": "Project name", + "openOriginal": "Open original \"{{name}}\"", + "openRecovery": "Open {{name}}", + "queueItems": "Queue items", + "recoveries": "Recoveries", + "recoveryCopy": "Recovery copy", + "recoveryForkedDescription": "Forked {{time}} after this project was changed elsewhere.", + "sync": "Sync", + "syncOffline": "Offline — saved in this browser", + "syncSynced": "Synced (revision {{revision}})", + "syncWaiting": "Waiting to sync" + }, + "preview": { + "commands": { + "deletePreviewImage": "Delete preview image", + "nextComparisonMode": "Next comparison mode", + "swapComparisonImages": "Swap comparison images", + "togglePreview": "Toggle preview" + }, + "compare": "Compare", + "emptyDescription": "Generate to Gallery, then select a thumbnail in the Gallery widget to inspect it here.", + "exitCompare": "Exit Compare", + "hideInProgressDiffusion": "Hide in-progress diffusion", + "imageCount_one": "{{count}} image", + "imageCount_other": "{{count}} images", + "loadingBoard": "Loading board", + "nextImageInBoard": "Next image in board", + "noGallerySelection": "No gallery selection", + "previousImageInBoard": "Previous image in board", + "showInProgressDiffusion": "Show in-progress diffusion", + "sideBySide": "Side by Side", + "slider": "Slider", + "sourceRun": "Source run {{id}}", + "viewing": "Viewing" + }, + "queue": { + "allScopedItemsRequested": "All scoped queue items were requested.", + "actionConfirmationTitle": "{{action}}?", + "backendItem": "Backend queue item {{id}}.", + "batch": "Batch", + "cancelAllExceptCurrent": "Cancel all except current item", + "cancelAllExceptCurrentRequested": "All pending scoped items except the current item were requested.", + "cancelAllItems": "Cancel All Items", + "cancelConfirmationBody": "This requests cancellation for queue work in the current scope. In-progress items may stop after the backend acknowledges cancellation.", + "cancelCurrent": "Cancel Current", + "cancelCurrentItem": "Cancel Current Item", + "cancelItem": "Cancel queue item #{{id}}", + "cancellationRequested": "Cancellation requested", + "clearAllItems": "Clear All Items", + "clearFailedConfirmationBody": "This permanently removes failed queue items in the current queue scope.", + "clearFailedItems": "Clear Failed Items", + "clearFailedTitle": "Clear failed queue items?", + "clearQueue": "Clear Queue", + "clearQueueConfirmationBody": "This permanently clears queue items in the current queue scope.", + "clearQueueTitle": "Clear queue?", + "couldNotCancelCurrentItem": "Could not cancel the current item.", + "couldNotCancelItem": "Could not cancel this item.", + "couldNotCancelItems": "Could not cancel scoped queue items.", + "couldNotChangeProcessor": "Could not change the queue processor.", + "couldNotClearFailedItems": "Could not clear failed queue items.", + "couldNotClearQueue": "Could not clear the queue.", + "currentBatch": "Current Batch", + "failedItemsCleared": "Failed queue items cleared", + "failedToCancelCurrentItem": "Failed to cancel current item", + "failedToCancelItems": "Failed to cancel queue items", + "failedToClearItems": "Failed to clear queue items", + "failedToClearQueue": "Failed to clear queue", + "failedToPauseProcessor": "Failed to pause processor", + "failedToResumeProcessor": "Failed to resume processor", + "filterRecentByStatus": "Filter recent queue items by status", + "generating_one": "{{count}} generating", + "generating_other": "{{count}} generating", + "itemJsonLabel": "Queue item {{id}} JSON", + "itemProgress": "Queue item progress", + "itemTitle": "Queue item #{{id}}", + "loadSettingsIntoGenerate": "Load these settings into Generate", + "loading": "Loading queue…", + "modelCache": { + "clear": "Clear cache", + "clearFailed": "Clear cache failed", + "cleared": "Model cache cleared", + "clearedDescription_one": "Cleared {{count}} cached model and freed {{bytes}}.", + "clearedDescription_other": "Cleared {{count}} cached models and freed {{bytes}}.", + "couldNotClear": "Could not clear the model cache.", + "hits": "Hits", + "label": "Model Cache", + "loaded": "Loaded", + "misses": "Misses", + "noModelsCleared": "No cached models cleared", + "noModelsClearedDescription": "No unlocked cached models were available to clear. Active models stay loaded until they are no longer in use.", + "usage": "Model cache usage", + "usedOfTotal": "{{used}} of {{total}} used" + }, + "noCurrentItem": "No current item", + "noCurrentItemDescription": "The scoped queue has no in-progress item to cancel.", + "noPrompt": "No prompt", + "openQueue": "Open Queue", + "pauseProcessor": "Pause Processor", + "processorPaused": "Processor paused", + "processorResumed": "Processor resumed", + "queueCancellationRequested": "Queue cancellation requested", + "queueCleared": "Queue cleared", + "queued_one": "{{count}} queued", + "queued_other": "{{count}} queued", + "recallAll": "Recall All", + "recallFromGallery": "Recall this generation from the gallery", + "resumeProcessor": "Resume Processor", + "sendToCanvas": "Send to canvas", + "sendToCanvasComingSoon": "Sending queue results to the canvas is coming soon.", + "settingsRecalled": "Settings recalled", + "settingsRecalledDescription": "Loaded these settings into Generate.", + "took": "Took", + "waiting_one": "{{count}} waiting", + "waiting_other": "{{count}} waiting" + }, + "autosaveStatus": { + "chipLabel": "Autosave: {{status}}", + "label": "Autosave", + "lastSaved": "Last saved: {{time}}", + "status": "Status: {{status}}" + }, + "canvas": { + "acceptToLayer": "Accept to Layer", + "candidateCount": "Candidate {{current}} of {{total}}", + "commands": { + "deleteSelected": "Delete selected canvas item", + "nextEntity": "Next canvas entity", + "previousEntity": "Previous canvas entity", + "redo": "Redo canvas edit", + "undo": "Undo canvas edit" + }, + "emptyLayerStack": "Canvas layer stack is empty.", + "emptyStaging": "Invoke Generate to Canvas to stage results here.", + "hideStagedResultPreview": "Hide staged result preview", + "hideStagingThumbnails": "Hide staging thumbnails", + "nextStagedCandidate": "Next staged candidate", + "previousStagedCandidate": "Previous staged candidate", + "selectStagedCandidate": "Select staged candidate {{number}}", + "showStagedResultPreview": "Show staged result preview", + "showStagingThumbnails": "Show staging thumbnails", + "stagingPreviewAlt": "Staging preview {{name}}", + "surface": "Canvas surface" + }, + "workflow": { + "about": "About", + "addNode": "Add node", + "author": "Author", + "class": "Class", + "connectorNode": "Connector node", + "contact": "Contact", + "copyJson": "Copy workflow JSON", + "copyJsonFailed": "Failed to copy workflow JSON", + "copyJsonSuccess": "Workflow JSON copied", + "currentImageNode": "Current Image node", + "declaredOutputs": "Declared outputs", + "description": "Description", + "details": "Details", + "detailsWithEllipsis": "Workflow details…", + "exportJson": "Export workflow JSON", + "form": "Form", + "graphHistorySnapshots": "Graph History Snapshots", + "importJsonWithEllipsis": "Import workflow JSON…", + "inspectorTabs": { + "data": "Data", + "details": "Details", + "outputs": "Outputs", + "template": "Template" + }, + "library": "Workflow library", + "name": "Workflow name", + "nodeData": "Node data", + "nodeInspector": "Node Inspector", + "nodeNotes": "Node notes", + "nodeNotesPlaceholder": "Notes about this node…", + "nodeTemplate": "Node template", + "notes": "Notes", + "notesNode": "Notes node", + "noTemplateKnown": "No template is known for \"{{type}}\" on this backend.", + "pack": "Pack", + "panelContent": "Workflow panel content", + "resizeNodeInspector": "Resize node inspector", + "runOutputsNotRecorded": "Run outputs are not recorded per node yet; results currently route to the Gallery or Canvas. This tab will show the node's outputs from its most recent run once run records land.", + "newWorkflowWithEllipsis": "New workflow…", + "saveGraphSnapshot": "Save graph snapshot", + "selectNodeToInspect": "Select a node in the Workflow editor to inspect it.", + "selectedNodeInspector": "Selected node inspector", + "tags": "Tags", + "title": "Title", + "type": "Type", + "version": "Version", + "workflowJson": "Workflow JSON", + "untitled": "Untitled Workflow" + }, + "layers": { + "empty": "Accepted canvas layers will appear here.", + "eventsCount": "Events: {{count}}", + "graphHistoryCount": "Graph history: {{count}}", + "options": "Layer options", + "projectContracts": "Project Contracts", + "summary": "{{layers}} layers, {{stagedImages}} staged images.", + "undoRedoCount": "Undo: {{undo}} / Redo: {{redo}}" + }, + "gallery": { + "alwaysShowDimensions": "Always show dimensions", + "archiveBoard": "Archive Board", + "boardActionsForBoard": "Board actions for {{name}}", + "boardGroups": { + "boards": "Boards", + "byDate": "By Date" + }, + "boardItemCounts": "{{images}} images | {{assets}} assets", + "boardName": "Board name", + "commands": { + "clearSelection": "Clear gallery selection", + "deleteSelection": "Delete gallery selection", + "navigationDown": "Gallery navigation down", + "navigationLeft": "Gallery navigation left", + "navigationRight": "Gallery navigation right", + "navigationUp": "Gallery navigation up", + "selectAllOnPage": "Select all gallery images on page", + "toggleStarImage": "Toggle gallery image star" + }, + "createBoardNamed": "Create board \"{{name}}\"", + "deleteBoard": "Delete Board", + "deleteBoardAndImages": "Delete Board and Images", + "deleteBoardDescription": "Deleted boards cannot be restored. Choose whether the board's images move to Uncategorized or are permanently deleted with it.", + "deleteBoardOnly": "Delete Board Only", + "deleteBoardQuestion": "Delete board \"{{name}}\"?", + "downloadBoard": "Download Board", + "dropImagesToUploadToBoard": "Drop images to upload to {{name}}", + "generationProgress": "Generation progress", + "generationProgressPercent": "Generation {{percentage}}%", + "hideArchivedBoards": "Hide archived boards", + "hideDateBoards": "Hide date boards", + "imageDensity": "Image density", + "imagesAriaLabel": "Gallery images", + "imageSort": "Image Sort", + "infinite": "Infinite", + "loadingBackendGallery": "Loading backend gallery...", + "loadingBoard": "Loading board...", + "newest": "Newest", + "noBoardsMatchSearch": "No boards match this search.", + "noImagesMatch": "No images match this board, tab, or search.", + "oldest": "Oldest", + "pagination": "Pagination", + "renameBoard": "Rename board", + "searchImagesAriaLabel": "Search gallery images", + "searchImagesPlaceholder": "Search images", + "searchOrCreateBoards": "Search or create boards", + "selectImageForPreview": "Select {{name}} for preview", + "selectedBoardFallback": "selected board", + "settings": "Gallery settings", + "settingsGridDensity": "Grid Density", + "settingsThumbnails": "Thumbnails", + "showArchivedBoards": "Show archived boards", + "showDateBoards": "Show date boards", + "showStarredImagesFirst": "Show starred images first", + "sortBoardsAscending": "Sort boards ascending", + "sortBoardsDescending": "Sort boards descending", + "starImage": "Star {{name}}", + "thumbnailFitAspect": "Aspect", + "thumbnailFitSquare": "Square", + "uncategorized": "Uncategorized", + "unarchiveBoard": "Unarchive Board", + "unknownBoard": "Unknown board", + "unstarImage": "Unstar {{name}}", + "uploadImagesToBoard": "Upload images to {{name}}", + "uploadsUnavailableForDateBoards": "Uploads are unavailable for date boards" + }, + "serverStatus": { + "connected": "Connected to Server", + "connecting": "Connecting to Server", + "disconnected": "Disconnected from Server", + "label": "Server Status", + "labelWithError": "{{label}}: {{error}}" + }, + "versionStatus": { + "chipLabel": "Version 7.0", + "description": "Invoke V7 shell version 7.0.", + "label": "Version" + } + } +} diff --git a/invokeai/frontend/webv2/public/locales/es.json b/invokeai/frontend/webv2/public/locales/es.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/es.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/fi.json b/invokeai/frontend/webv2/public/locales/fi.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/fi.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/fr.json b/invokeai/frontend/webv2/public/locales/fr.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/fr.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/he.json b/invokeai/frontend/webv2/public/locales/he.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/he.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/hu.json b/invokeai/frontend/webv2/public/locales/hu.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/hu.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/it.json b/invokeai/frontend/webv2/public/locales/it.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/it.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/ja.json b/invokeai/frontend/webv2/public/locales/ja.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/ja.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/ko.json b/invokeai/frontend/webv2/public/locales/ko.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/ko.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/mn.json b/invokeai/frontend/webv2/public/locales/mn.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/mn.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/nl.json b/invokeai/frontend/webv2/public/locales/nl.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/nl.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/pl.json b/invokeai/frontend/webv2/public/locales/pl.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/pl.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/pt-BR.json b/invokeai/frontend/webv2/public/locales/pt-BR.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/pt-BR.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/pt.json b/invokeai/frontend/webv2/public/locales/pt.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/pt.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/ro.json b/invokeai/frontend/webv2/public/locales/ro.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/ro.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/ru.json b/invokeai/frontend/webv2/public/locales/ru.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/ru.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/sv.json b/invokeai/frontend/webv2/public/locales/sv.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/sv.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/tr.json b/invokeai/frontend/webv2/public/locales/tr.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/tr.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/uk.json b/invokeai/frontend/webv2/public/locales/uk.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/uk.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/vi.json b/invokeai/frontend/webv2/public/locales/vi.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/vi.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/zh-CN.json b/invokeai/frontend/webv2/public/locales/zh-CN.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/zh-CN.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/public/locales/zh-Hant.json b/invokeai/frontend/webv2/public/locales/zh-Hant.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/invokeai/frontend/webv2/public/locales/zh-Hant.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/webv2/scripts/i18n-report.mjs b/invokeai/frontend/webv2/scripts/i18n-report.mjs new file mode 100644 index 00000000000..4c16f00b7de --- /dev/null +++ b/invokeai/frontend/webv2/scripts/i18n-report.mjs @@ -0,0 +1,51 @@ +/* eslint-disable no-console */ + +import { readdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +const localeDir = join(process.cwd(), 'public/locales'); +const verbose = process.argv.includes('--verbose'); + +const flattenKeys = (value, prefix = '') => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return prefix ? [prefix] : []; + } + + return Object.entries(value).flatMap(([key, child]) => flattenKeys(child, prefix ? `${prefix}.${key}` : key)); +}; + +const readJson = async (fileName) => JSON.parse(await readFile(join(localeDir, fileName), 'utf8')); + +const localeFiles = (await readdir(localeDir)).filter((fileName) => fileName.endsWith('.json')).sort(); +const english = await readJson('en.json'); +const englishKeys = flattenKeys(english); +const englishKeySet = new Set(englishKeys); +let missingCount = 0; + +for (const fileName of localeFiles) { + if (fileName === 'en.json') { + continue; + } + + const locale = await readJson(fileName); + const localeKeys = new Set(flattenKeys(locale)); + const missing = englishKeys.filter((key) => !localeKeys.has(key)); + const extra = [...localeKeys].filter((key) => !englishKeySet.has(key)); + + missingCount += missing.length; + console.log(`\n${fileName}`); + console.log(` missing: ${missing.length}`); + console.log(` extra: ${extra.length}`); + + if (verbose) { + for (const key of missing) { + console.log(` - ${key}`); + } + + for (const key of extra) { + console.log(` + ${key}`); + } + } +} + +console.log(`\nTotal missing keys: ${missingCount}`); diff --git a/invokeai/frontend/webv2/src/App.tsx b/invokeai/frontend/webv2/src/App.tsx index bc0fe6cdcd9..9826a01e033 100644 --- a/invokeai/frontend/webv2/src/App.tsx +++ b/invokeai/frontend/webv2/src/App.tsx @@ -4,11 +4,13 @@ import { RouterProvider } from '@tanstack/react-router'; import { router } from './router'; import { system } from './theme/system'; import { AppToaster } from './workbench/components/ui/toaster'; +import { I18nController } from './workbench/i18n'; import { ThemeController } from './workbench/ThemeController'; export const App = () => ( + diff --git a/invokeai/frontend/webv2/src/appMetadata.ts b/invokeai/frontend/webv2/src/appMetadata.ts new file mode 100644 index 00000000000..c3e51ede928 --- /dev/null +++ b/invokeai/frontend/webv2/src/appMetadata.ts @@ -0,0 +1 @@ +export const APP_VERSION = '7.0'; diff --git a/invokeai/frontend/webv2/src/i18n.ts b/invokeai/frontend/webv2/src/i18n.ts new file mode 100644 index 00000000000..9d014a8cbb1 --- /dev/null +++ b/invokeai/frontend/webv2/src/i18n.ts @@ -0,0 +1,20 @@ +import i18n from 'i18next'; +import Backend from 'i18next-http-backend'; +import { initReactI18next } from 'react-i18next'; + +void i18n + .use(Backend) + .use(initReactI18next) + .init({ + backend: { + loadPath: `${import.meta.env.BASE_URL}locales/{{lng}}.json`, + }, + debug: false, + fallbackLng: 'en', + interpolation: { + escapeValue: false, + }, + returnNull: false, + }); + +export default i18n; diff --git a/invokeai/frontend/webv2/src/main.tsx b/invokeai/frontend/webv2/src/main.tsx index a4a889073f2..6c81cc73d19 100644 --- a/invokeai/frontend/webv2/src/main.tsx +++ b/invokeai/frontend/webv2/src/main.tsx @@ -1,4 +1,5 @@ import '@fontsource/inter/index.css'; +import './i18n'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; diff --git a/invokeai/frontend/webv2/src/router.tsx b/invokeai/frontend/webv2/src/router.tsx index b6a06e12be0..141bb865a3e 100644 --- a/invokeai/frontend/webv2/src/router.tsx +++ b/invokeai/frontend/webv2/src/router.tsx @@ -175,7 +175,7 @@ const setupRoute = createRoute({ path: '/setup', }); -const RouterPending = () => ; +const RouterPending = () => ; export const router = createRouter({ defaultNotFoundComponent: () => , diff --git a/invokeai/frontend/webv2/src/workbench/WorkbenchContext.tsx b/invokeai/frontend/webv2/src/workbench/WorkbenchContext.tsx index e255b9ae654..273c175c8a1 100644 --- a/invokeai/frontend/webv2/src/workbench/WorkbenchContext.tsx +++ b/invokeai/frontend/webv2/src/workbench/WorkbenchContext.tsx @@ -303,7 +303,7 @@ export const WorkbenchProvider = ({ return ( - {hasHydrated ? children : } + {hasHydrated ? children : } ); diff --git a/invokeai/frontend/webv2/src/workbench/auth/components/AccountMenu.tsx b/invokeai/frontend/webv2/src/workbench/auth/components/AccountMenu.tsx index 8ab3f871cab..4145911c710 100644 --- a/invokeai/frontend/webv2/src/workbench/auth/components/AccountMenu.tsx +++ b/invokeai/frontend/webv2/src/workbench/auth/components/AccountMenu.tsx @@ -4,6 +4,7 @@ import { logoutSession, useAuthSession } from '@workbench/auth/session'; import { MenuContent } from '@workbench/components/ui'; import { ChevronDownIcon, LogOutIcon, UserRoundCogIcon, UsersIcon } from 'lucide-react'; import { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { ProfileDialog } from './ProfileDialog'; @@ -17,6 +18,7 @@ const TRIGGER_HOVER = { bg: 'bg.subtle' } as const; * surface's top bar places alongside it. */ export const AccountMenu = () => { + const { t } = useTranslation(); const session = useAuthSession(); const navigate = useNavigate(); const [isProfileOpen, setIsProfileOpen] = useState(false); @@ -82,7 +84,7 @@ export const AccountMenu = () => { {user.is_admin ? ( - Admin + {t('users.admin')} ) : null} @@ -90,18 +92,18 @@ export const AccountMenu = () => { - Account settings + {t('auth.accountSettings')} {user.is_admin ? ( - Manage users + {t('users.manageUsers')} ) : null} - Sign out + {t('auth.signOut')} diff --git a/invokeai/frontend/webv2/src/workbench/auth/components/LoginScreen.tsx b/invokeai/frontend/webv2/src/workbench/auth/components/LoginScreen.tsx index 76711dfbdd4..0201cd4ab04 100644 --- a/invokeai/frontend/webv2/src/workbench/auth/components/LoginScreen.tsx +++ b/invokeai/frontend/webv2/src/workbench/auth/components/LoginScreen.tsx @@ -6,12 +6,14 @@ import { getApiErrorMessage } from '@workbench/backend/http'; import { Button, Field } from '@workbench/components/ui'; import { useZodForm } from '@workbench/models/useZodForm'; import { useCallback, type ChangeEvent, type FormEvent } from 'react'; +import { useTranslation } from 'react-i18next'; import { AuthFormAlert, AuthScreen } from './AuthScreen'; import { PasswordInput } from './PasswordInput'; /** Sign-in screen, shown only when the backend runs in multi-user mode. */ export const LoginScreen = () => { + const { t } = useTranslation(); const session = useAuthSession(); const navigate = useNavigate(); const form = useZodForm(loginSchema, { email: '', password: '', rememberMe: false }); @@ -22,12 +24,12 @@ export const LoginScreen = () => { try { await loginWithCredentials(values.email, values.password, values.rememberMe); } catch (error) { - throw new Error(getApiErrorMessage(error, 'Sign-in failed. Check your email and password.')); + throw new Error(getApiErrorMessage(error, t('auth.signInFailed'))); } await navigate({ to: '/' }); }), - [form, navigate] + [form, navigate, t] ); const handleSubmit = useCallback( @@ -54,13 +56,11 @@ export const LoginScreen = () => { ); return ( - + - {session.sessionExpired ? ( - - ) : null} + {session.sessionExpired ? : null} {form.formError ? : null} - + { onChange={handleEmailChange} /> - + @@ -83,11 +83,11 @@ export const LoginScreen = () => { - Keep me signed in for a week + {t('auth.keepSignedIn')} diff --git a/invokeai/frontend/webv2/src/workbench/auth/components/PasswordInput.tsx b/invokeai/frontend/webv2/src/workbench/auth/components/PasswordInput.tsx index 84d17865349..3dafe2e8b62 100644 --- a/invokeai/frontend/webv2/src/workbench/auth/components/PasswordInput.tsx +++ b/invokeai/frontend/webv2/src/workbench/auth/components/PasswordInput.tsx @@ -3,17 +3,19 @@ import { getPasswordStrength, type PasswordStrength } from '@workbench/auth/sche import { IconButton } from '@workbench/components/ui'; import { EyeIcon, EyeOffIcon } from 'lucide-react'; import { useCallback, useMemo, useState, type ComponentProps } from 'react'; +import { useTranslation } from 'react-i18next'; type InputProps = ComponentProps; /** Password input with an inline visibility toggle. */ export const PasswordInput = (props: InputProps) => { + const { t } = useTranslation(); const [isVisible, setIsVisible] = useState(false); const handleToggleVisibility = useCallback(() => setIsVisible((current) => !current), []); const endElement = useMemo( () => ( { {isVisible ? : } ), - [handleToggleVisibility, isVisible] + [handleToggleVisibility, isVisible, t] ); return ( @@ -32,14 +34,16 @@ export const PasswordInput = (props: InputProps) => { ); }; -const STRENGTH_META: Record = { - moderate: { filledSegments: 2, label: 'Moderate', tone: 'fg.warning' }, - strong: { filledSegments: 3, label: 'Strong', tone: 'fg.success' }, - weak: { filledSegments: 1, label: 'Weak', tone: 'fg.error' }, +const STRENGTH_META: Record = { + moderate: { filledSegments: 2, labelKey: 'auth.passwordStrength.moderate', tone: 'fg.warning' }, + strong: { filledSegments: 3, labelKey: 'auth.passwordStrength.strong', tone: 'fg.success' }, + weak: { filledSegments: 1, labelKey: 'auth.passwordStrength.weak', tone: 'fg.error' }, }; /** Three-segment strength readout; renders nothing until the user types. */ export const PasswordStrengthMeter = ({ password }: { password: string }) => { + const { t } = useTranslation(); + if (!password) { return null; } @@ -60,7 +64,7 @@ export const PasswordStrengthMeter = ({ password }: { password: string }) => { ))} - {meta.label} + {t(meta.labelKey)} ); diff --git a/invokeai/frontend/webv2/src/workbench/auth/components/ProfileDialog.tsx b/invokeai/frontend/webv2/src/workbench/auth/components/ProfileDialog.tsx index 2f6f501d190..6072075dae3 100644 --- a/invokeai/frontend/webv2/src/workbench/auth/components/ProfileDialog.tsx +++ b/invokeai/frontend/webv2/src/workbench/auth/components/ProfileDialog.tsx @@ -8,12 +8,14 @@ import { useZodForm } from '@workbench/models/useZodForm'; import { useNotify } from '@workbench/useNotify'; import { WandSparklesIcon } from 'lucide-react'; import { useCallback, useMemo, useState, type ChangeEvent } from 'react'; +import { useTranslation } from 'react-i18next'; import { AuthFormAlert } from './AuthScreen'; import { PasswordInput, PasswordStrengthMeter } from './PasswordInput'; /** Account settings: display name and password change for the signed-in user. */ export const ProfileDialog = ({ isOpen, onClose, user }: { isOpen: boolean; onClose: () => void; user: UserDTO }) => { + const { t } = useTranslation(); const handleOpenChange = useCallback( (event: Dialog.OpenChangeDetails) => { if (!event.open) { @@ -40,10 +42,10 @@ export const ProfileDialog = ({ isOpen, onClose, user }: { isOpen: boolean; onCl - Account + {t('auth.account')} - Signed in as {user.email} + {t('auth.signedInAs', { email: user.email })} @@ -59,6 +61,7 @@ export const ProfileDialog = ({ isOpen, onClose, user }: { isOpen: boolean; onCl }; const ProfileForm = ({ onClose, user }: { onClose: () => void; user: UserDTO }) => { + const { t } = useTranslation(); const session = useAuthSession(); const notify = useNotify(); const [isGenerating, setIsGenerating] = useState(false); @@ -77,13 +80,13 @@ const ProfileForm = ({ onClose, user }: { onClose: () => void; user: UserDTO }) form.setValue('newPassword', password); form.setValue('confirmPassword', password); - notify.info('Password generated', 'Reveal it with the eye icon and store it somewhere safe.'); + notify.info(t('users.passwordGenerated'), t('auth.passwordGeneratedDescription')); } catch (error) { - notify.error('Could not generate a password', getApiErrorMessage(error, 'The backend rejected the request.')); + notify.error(t('users.couldNotGeneratePassword'), getApiErrorMessage(error, t('users.backendRejectedRequest'))); } finally { setIsGenerating(false); } - }, [form, notify]); + }, [form, notify, t]); const submit = useCallback( () => @@ -110,13 +113,13 @@ const ProfileForm = ({ onClose, user }: { onClose: () => void; user: UserDTO }) setSessionUser(updated); } catch (error) { - throw new Error(getApiErrorMessage(error, 'Could not update your account.')); + throw new Error(getApiErrorMessage(error, t('auth.couldNotUpdateAccount'))); } - notify.success('Account updated'); + notify.success(t('auth.accountUpdated')); onClose(); }), - [form, notify, onClose, user.display_name] + [form, notify, onClose, t, user.display_name] ); const handleDisplayNameChange = useCallback( (event: ChangeEvent) => form.setValue('displayName', event.target.value), @@ -142,7 +145,7 @@ const ProfileForm = ({ onClose, user }: { onClose: () => void; user: UserDTO }) {form.formError ? : null} - + void; user: UserDTO }) - Change password + {t('auth.changePassword')} - + void; user: UserDTO }) void; user: UserDTO }) - + void; user: UserDTO }) diff --git a/invokeai/frontend/webv2/src/workbench/auth/components/SetupScreen.tsx b/invokeai/frontend/webv2/src/workbench/auth/components/SetupScreen.tsx index 675f9de9b9e..3e5a32cbd70 100644 --- a/invokeai/frontend/webv2/src/workbench/auth/components/SetupScreen.tsx +++ b/invokeai/frontend/webv2/src/workbench/auth/components/SetupScreen.tsx @@ -6,6 +6,7 @@ import { getApiErrorMessage } from '@workbench/backend/http'; import { Button, Field } from '@workbench/components/ui'; import { useZodForm } from '@workbench/models/useZodForm'; import { useCallback, useMemo, type ChangeEvent, type FormEvent } from 'react'; +import { useTranslation } from 'react-i18next'; import { AuthFormAlert, AuthScreen } from './AuthScreen'; import { PasswordInput, PasswordStrengthMeter } from './PasswordInput'; @@ -17,6 +18,7 @@ const INITIAL_VALUES: SetupFormValues = { confirmPassword: '', displayName: '', * signs straight into it. */ export const SetupScreen = () => { + const { t } = useTranslation(); const session = useAuthSession(); const navigate = useNavigate(); const schema = useMemo(() => createSetupSchema(session.strictPasswordChecking), [session.strictPasswordChecking]); @@ -28,12 +30,12 @@ export const SetupScreen = () => { try { await completeAdminSetup(values.email, values.displayName.trim() || null, values.password); } catch (error) { - throw new Error(getApiErrorMessage(error, 'Could not create the administrator account.')); + throw new Error(getApiErrorMessage(error, t('auth.couldNotCreateAdmin'))); } await navigate({ to: '/' }); }), - [form, navigate] + [form, navigate, t] ); const handleSubmit = useCallback( (event: FormEvent) => { @@ -60,14 +62,10 @@ export const SetupScreen = () => { ); return ( - + {form.formError ? : null} - + { onChange={handleEmailChange} /> - + @@ -88,7 +86,7 @@ export const SetupScreen = () => { { - + { /> diff --git a/invokeai/frontend/webv2/src/workbench/components/WorkbenchSplashScreen.tsx b/invokeai/frontend/webv2/src/workbench/components/WorkbenchSplashScreen.tsx index 55273c07721..a247000b525 100644 --- a/invokeai/frontend/webv2/src/workbench/components/WorkbenchSplashScreen.tsx +++ b/invokeai/frontend/webv2/src/workbench/components/WorkbenchSplashScreen.tsx @@ -1,39 +1,46 @@ import splashImageUrl from '@assets/SplashImage.webp'; import { Box, Flex, Heading, HStack, Spinner, Text, VStack } from '@chakra-ui/react'; import { InvokeMark } from '@workbench/components/InvokeMark'; +import { useTranslation } from 'react-i18next'; + +import { APP_VERSION } from '@/appMetadata'; const splashImageStyle = { backgroundPosition: 'center', backgroundSize: 'cover' } as const; -export const WorkbenchSplashScreen = ({ message = 'Loading workspace' }: { message?: string }) => ( - - - - - - - - Invoke AI 7.0 - Image Generation for Creatives - Artwork by John Smith - - - - {message} +export const WorkbenchSplashScreen = ({ messageKey = 'splash.loadingWorkspace' }: { messageKey?: string }) => { + const { t } = useTranslation(); + + return ( + + + + + + + + {t('app.nameWithVersion', { name: t('app.name'), version: APP_VERSION })} + {t('splash.tagline')} + {t('splash.artworkBy', { artist: 'John Smith' })} + + + + {t(messageKey)} + + + + {t('splash.copyright', { year: new Date().getFullYear() })} - - - © {new Date().getFullYear()} Invoke AI | All rights reserved - - - - - -); + + + + + ); +}; diff --git a/invokeai/frontend/webv2/src/workbench/graph-preview/GraphPreviewDialog.tsx b/invokeai/frontend/webv2/src/workbench/graph-preview/GraphPreviewDialog.tsx index e1d48d13fde..5b9bc1fbc7d 100644 --- a/invokeai/frontend/webv2/src/workbench/graph-preview/GraphPreviewDialog.tsx +++ b/invokeai/frontend/webv2/src/workbench/graph-preview/GraphPreviewDialog.tsx @@ -15,6 +15,7 @@ import { flushGenerateDrafts } from '@workbench/widgets/generate/generateDraftRe import { useActiveProjectSelector, useWorkbenchDispatch, useWorkbenchStore } from '@workbench/WorkbenchContext'; import { useInvocationTemplatesSelector } from '@workbench/workflows/templates'; import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; import { GraphPreviewFlow } from './GraphPreviewFlow'; @@ -33,8 +34,8 @@ type PreviewMode = 'nodes' | 'json'; const selectInvocationRouteInput = createInvocationRouteInputSelector(); const modeItems = [ - { label: 'Nodes', value: 'nodes' }, - { label: 'JSON', value: 'json' }, + { labelKey: 'graphPreview.nodes', value: 'nodes' }, + { labelKey: 'common.json', value: 'json' }, ]; const PreviewPane = ({ children }: { children: ReactNode }) => ( @@ -52,6 +53,7 @@ export const GraphPreviewDialog = ({ title, onOpenChange, }: GraphPreviewDialogProps) => { + const { t } = useTranslation(); const routeInput = useActiveProjectSelector(selectInvocationRouteInput); const dispatch = useWorkbenchDispatch(); const store = useWorkbenchStore(); @@ -106,7 +108,7 @@ export const GraphPreviewDialog = ({ }); onOpenChange(false); }, [availabilityModels, dispatch, onOpenChange, sourceId, store]); - const jsonLabel = useMemo(() => `${title} graph JSON`, [title]); + const jsonLabel = useMemo(() => t('graphPreview.graphJsonLabel', { title }), [t, title]); return ( @@ -115,16 +117,21 @@ export const GraphPreviewDialog = ({ - {title} Graph Preview + {t('graphPreview.title', { title })} - + {modeItems.map((item) => ( + + + {t(item.labelKey)} + + ))} {!graph ? ( - No compiled graph is available for "{graphId}" yet. + {t('graphPreview.noCompiledGraph', { graphId })} ) : mode === 'nodes' ? ( @@ -146,11 +153,11 @@ export const GraphPreviewDialog = ({ title={dialogRoute.validationMessage} onClick={invokeRoute} > - Invoke {formatRoute(dialogRoute)} + {t('graphPreview.invokeRoute', { route: formatRoute(dialogRoute) })} ) : null} diff --git a/invokeai/frontend/webv2/src/workbench/graph-preview/GraphPreviewFlow.tsx b/invokeai/frontend/webv2/src/workbench/graph-preview/GraphPreviewFlow.tsx index f2f93a3df53..2ab939b3453 100644 --- a/invokeai/frontend/webv2/src/workbench/graph-preview/GraphPreviewFlow.tsx +++ b/invokeai/frontend/webv2/src/workbench/graph-preview/GraphPreviewFlow.tsx @@ -17,6 +17,7 @@ import { type NodeTypes, } from '@xyflow/react'; import { useId, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; import { flowThemeCss, getFlowColorMode } from './flowTheme'; @@ -35,7 +36,8 @@ type PreviewFlowNode = FlowNode<{ inputCount: number; nodeId: string; nodeType: * library entries before loading them. */ export const documentToPreviewGraph = ( - document: ProjectGraphState + document: ProjectGraphState, + fallbackLabel: string ): { graph: GraphContract; positionHints: Record } => { const invocationNodes = document.nodes.filter(isInvocationNode); const invocationNodeIds = new Set(invocationNodes.map((node) => node.id)); @@ -52,7 +54,7 @@ export const documentToPreviewGraph = ( targetNodeId: edge.target, })), id: document.id, - label: document.name || 'Workflow', + label: document.name || fallbackLabel, nodes: invocationNodes.map((node) => ({ id: node.id, inputs: Object.fromEntries(Object.values(node.data.inputs).map((instance) => [instance.name, instance.value])), @@ -71,20 +73,24 @@ const handleStyle = { background: 'var(--wb-flow-grid)', border: 'none' } as con const reactFlowProOptions = { hideAttribution: true } as const; const reactFlowStyle = { background: 'transparent' } as const; -const PreviewNode = ({ data }: NodeProps) => ( - - - - - - {data.nodeType} - - - {data.nodeId} · {data.inputCount} input{data.inputCount === 1 ? '' : 's'} - - - -); +const PreviewNode = ({ data }: NodeProps) => { + const { t } = useTranslation(); + + return ( + + + + + + {data.nodeType} + + + {data.nodeId} · {t('graphPreview.inputCount', { count: data.inputCount })} + + + + ); +}; const nodeTypes: NodeTypes = { preview: PreviewNode }; diff --git a/invokeai/frontend/webv2/src/workbench/graphSurfaces.ts b/invokeai/frontend/webv2/src/workbench/graphSurfaces.ts index 68eb9e5679e..42d8821b676 100644 --- a/invokeai/frontend/webv2/src/workbench/graphSurfaces.ts +++ b/invokeai/frontend/webv2/src/workbench/graphSurfaces.ts @@ -4,7 +4,8 @@ import { isInvocationSourceAvailable } from './invocation'; export const createGraphBearingSurface = ( manifest: WidgetManifest, - region: WorkbenchRegion + region: WorkbenchRegion, + label: string ): GraphBearingSurfaceContract | null => { const graphBearing = manifest.graphBearing; @@ -16,7 +17,7 @@ export const createGraphBearingSurface = ( canPreviewGraph: true, canSetSource: isInvocationSourceAvailable(graphBearing.sourceId), graphId: graphBearing.defaultGraphId, - label: manifest.labelText, + label, region, sourceId: graphBearing.sourceId, surfaceId: `${manifest.id}:${region}`, diff --git a/invokeai/frontend/webv2/src/workbench/i18n/I18nController.tsx b/invokeai/frontend/webv2/src/workbench/i18n/I18nController.tsx new file mode 100644 index 00000000000..e6fca0ff90b --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/i18n/I18nController.tsx @@ -0,0 +1,36 @@ +import { useWorkbenchPreferenceSelector, useWorkbenchSettingsSelector } from '@workbench/settings/store'; +import { useLayoutEffect } from 'react'; + +import i18n from '@/i18n'; + +import { getWorkbenchLanguageDirection } from './languages'; + +const LANGUAGE_HINT_STORAGE_KEY = 'invokeai:v7:webv2:language'; + +export const I18nController = () => { + const language = useWorkbenchPreferenceSelector((preferences) => preferences.language); + const status = useWorkbenchSettingsSelector((snapshot) => snapshot.status); + const hasResolved = status === 'ready' || status === 'error'; + + useLayoutEffect(() => { + if (!hasResolved) { + return; + } + + const direction = getWorkbenchLanguageDirection(language); + const root = document.documentElement; + + root.lang = language; + root.dir = direction; + document.body.dir = direction; + void i18n.changeLanguage(language); + + try { + window.localStorage.setItem(LANGUAGE_HINT_STORAGE_KEY, language); + } catch { + // Storage unavailable; the next load paints with the default language. + } + }, [hasResolved, language]); + + return null; +}; diff --git a/invokeai/frontend/webv2/src/workbench/i18n/index.ts b/invokeai/frontend/webv2/src/workbench/i18n/index.ts new file mode 100644 index 00000000000..4a9d71541ea --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/i18n/index.ts @@ -0,0 +1,9 @@ +export { I18nController } from './I18nController'; +export { + getWorkbenchLanguageDirection, + normalizeWorkbenchLanguage, + WORKBENCH_LANGUAGE_OPTIONS, + WORKBENCH_LANGUAGES, + type WorkbenchLanguage, + type WorkbenchLanguageDirection, +} from './languages'; diff --git a/invokeai/frontend/webv2/src/workbench/i18n/languages.ts b/invokeai/frontend/webv2/src/workbench/i18n/languages.ts new file mode 100644 index 00000000000..90c7a41af88 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/i18n/languages.ts @@ -0,0 +1,47 @@ +export const WORKBENCH_LANGUAGE_OPTIONS = [ + { value: 'ar', label: 'العربية', direction: 'rtl' }, + { value: 'az', label: 'Azərbaycan dili', direction: 'ltr' }, + { value: 'bg', label: 'Български', direction: 'ltr' }, + { value: 'de', label: 'Deutsch', direction: 'ltr' }, + { value: 'en', label: 'English', direction: 'ltr' }, + { value: 'en-GB', label: 'English (UK)', direction: 'ltr' }, + { value: 'es', label: 'Español', direction: 'ltr' }, + { value: 'fi', label: 'Suomi', direction: 'ltr' }, + { value: 'fr', label: 'Français', direction: 'ltr' }, + { value: 'he', label: 'עִבְֿרִית', direction: 'rtl' }, + { value: 'hu', label: 'Magyar Nyelv', direction: 'ltr' }, + { value: 'it', label: 'Italiano', direction: 'ltr' }, + { value: 'ja', label: '日本語', direction: 'ltr' }, + { value: 'ko', label: '한국어', direction: 'ltr' }, + { value: 'mn', label: 'Монгол', direction: 'ltr' }, + { value: 'nl', label: 'Nederlands', direction: 'ltr' }, + { value: 'pl', label: 'Polski', direction: 'ltr' }, + { value: 'pt', label: 'Português', direction: 'ltr' }, + { value: 'pt-BR', label: 'Português do Brasil', direction: 'ltr' }, + { value: 'ro', label: 'Română', direction: 'ltr' }, + { value: 'ru', label: 'Русский', direction: 'ltr' }, + { value: 'sv', label: 'Svenska', direction: 'ltr' }, + { value: 'tr', label: 'Türkçe', direction: 'ltr' }, + { value: 'uk', label: 'Українська', direction: 'ltr' }, + { value: 'vi', label: 'Tiếng Việt', direction: 'ltr' }, + { value: 'zh-CN', label: '简体中文', direction: 'ltr' }, + { value: 'zh-Hant', label: '漢語', direction: 'ltr' }, +] as const; + +export type WorkbenchLanguage = (typeof WORKBENCH_LANGUAGE_OPTIONS)[number]['value']; +export type WorkbenchLanguageDirection = (typeof WORKBENCH_LANGUAGE_OPTIONS)[number]['direction']; + +export const WORKBENCH_LANGUAGES: WorkbenchLanguage[] = WORKBENCH_LANGUAGE_OPTIONS.map((option) => option.value); + +export const normalizeWorkbenchLanguage = (value: unknown): WorkbenchLanguage | null => { + if (value === 'ua') { + return 'uk'; + } + + return typeof value === 'string' && WORKBENCH_LANGUAGES.includes(value as WorkbenchLanguage) + ? (value as WorkbenchLanguage) + : null; +}; + +export const getWorkbenchLanguageDirection = (language: WorkbenchLanguage): WorkbenchLanguageDirection => + WORKBENCH_LANGUAGE_OPTIONS.find((option) => option.value === language)?.direction ?? 'ltr'; diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/Launchpad.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/Launchpad.tsx index da0304b32ab..b05d34a5a63 100644 --- a/invokeai/frontend/webv2/src/workbench/launchpad/Launchpad.tsx +++ b/invokeai/frontend/webv2/src/workbench/launchpad/Launchpad.tsx @@ -4,6 +4,7 @@ import { useCapabilities } from '@workbench/auth/capabilities'; import { Tabs } from '@workbench/components/ui'; import { BoxIcon, BlocksIcon, FolderIcon, UsersIcon, type LucideIcon } from 'lucide-react'; import { useCallback, useMemo, type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; import { LaunchpadNav } from './LaunchpadNav'; import { LaunchpadTopBar } from './LaunchpadTopBar'; @@ -69,18 +70,42 @@ const getActiveSectionId = ( export const Launchpad = () => { const { canManageModels, canManageNodes, canManageUsers } = useCapabilities(); + const { t } = useTranslation(); const filtered = useMemo( () => ( [ - { icon: FolderIcon, id: 'projects', label: 'Projects', render: () => }, - { condition: canManageModels, icon: BoxIcon, id: 'models', label: 'Models', render: () => }, - { condition: canManageNodes, icon: BlocksIcon, id: 'nodes', label: 'Nodes', render: () => }, - { condition: canManageUsers, icon: UsersIcon, id: 'users', label: 'Users', render: () => }, + { + icon: FolderIcon, + id: 'projects', + label: t('launchpad.sections.projects'), + render: () => , + }, + { + condition: canManageModels, + icon: BoxIcon, + id: 'models', + label: t('launchpad.sections.models'), + render: () => , + }, + { + condition: canManageNodes, + icon: BlocksIcon, + id: 'nodes', + label: t('launchpad.sections.nodes'), + render: () => , + }, + { + condition: canManageUsers, + icon: UsersIcon, + id: 'users', + label: t('launchpad.sections.users'), + render: () => , + }, ] satisfies (LaunchpadSection & { condition?: boolean })[] ).filter((section) => section.condition ?? true), - [canManageModels, canManageNodes, canManageUsers] + [canManageModels, canManageNodes, canManageUsers, t] ); return ( diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/NewProjectCard.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/NewProjectCard.tsx index 72a98ad1af5..c09b88e22c1 100644 --- a/invokeai/frontend/webv2/src/workbench/launchpad/NewProjectCard.tsx +++ b/invokeai/frontend/webv2/src/workbench/launchpad/NewProjectCard.tsx @@ -2,28 +2,38 @@ import { Flex, Icon, Text } from '@chakra-ui/react'; import { Link } from '@tanstack/react-router'; import { PlusIcon } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; /** The grid's leading cell: start a fresh draft in the editor. */ -export const NewProjectCard = () => ( - - - - - New project - - -); +export const NewProjectCard = () => { + const { t } = useTranslation(); + + return ( + + + + + {t('projects.newProject')} + + + ); +}; /* eslint-disable react-perf/jsx-no-jsx-as-prop, react-perf/jsx-no-new-array-as-prop, react-perf/jsx-no-new-function-as-prop, react-perf/jsx-no-new-object-as-prop */ diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/ProjectCard.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/ProjectCard.tsx index 3369963dc1b..8e96f173e76 100644 --- a/invokeai/frontend/webv2/src/workbench/launchpad/ProjectCard.tsx +++ b/invokeai/frontend/webv2/src/workbench/launchpad/ProjectCard.tsx @@ -18,6 +18,7 @@ import { Trash2Icon, } from 'lucide-react'; import { useCallback, useMemo, useState, type MouseEvent } from 'react'; +import { useTranslation } from 'react-i18next'; import { formatRelativeTime } from './formatRelativeTime'; @@ -33,6 +34,7 @@ const MENU_POSITION_BOTTOM_END = { placement: 'bottom-end' } as const; * mounting the editor. */ export const ProjectCard = ({ summary }: { summary: ProjectSummary }) => { + const { t } = useTranslation(); const [isRenameOpen, setIsRenameOpen] = useState(false); const [isDeleteOpen, setIsDeleteOpen] = useState(false); const [isActionsOpen, setIsActionsOpen] = useState(false); @@ -57,28 +59,32 @@ export const ProjectCard = ({ summary }: { summary: ProjectSummary }) => { } catch (error) { toaster.create({ description: error instanceof Error ? error.message : undefined, - title: 'Rename failed', + title: t('projects.renameFailed'), type: 'error', }); throw error; } }, - [summary.id] + [summary.id, t] ); const handleDuplicate = useCallback(async () => { try { const copy = await duplicateLibraryProject(summary.id); - toaster.create({ description: `"${copy.name}" was created.`, title: 'Project duplicated', type: 'success' }); + toaster.create({ + description: t('projects.projectDuplicatedDescription', { name: copy.name }), + title: t('projects.projectDuplicated'), + type: 'success', + }); } catch (error) { toaster.create({ description: error instanceof Error ? error.message : undefined, - title: 'Duplicate failed', + title: t('projects.duplicateFailed'), type: 'error', }); } - }, [summary.id]); + }, [summary.id, t]); const handleExport = useCallback(async () => { try { @@ -86,11 +92,11 @@ export const ProjectCard = ({ summary }: { summary: ProjectSummary }) => { } catch (error) { toaster.create({ description: error instanceof Error ? error.message : undefined, - title: 'Export failed', + title: t('projects.exportFailed'), type: 'error', }); } - }, [summary.id]); + }, [summary.id, t]); const handleDelete = useCallback(async () => { try { @@ -98,11 +104,11 @@ export const ProjectCard = ({ summary }: { summary: ProjectSummary }) => { } catch (error) { toaster.create({ description: error instanceof Error ? error.message : undefined, - title: 'Delete failed', + title: t('projects.deleteFailed'), type: 'error', }); } - }, [summary.id]); + }, [summary.id, t]); const handleContextMenu = useCallback((event: MouseEvent) => { event.preventDefault(); setContextMenuTarget({ x: event.clientX, y: event.clientY }); @@ -133,7 +139,12 @@ export const ProjectCard = ({ summary }: { summary: ProjectSummary }) => { _hover={CARD_HOVER} onContextMenu={handleContextMenu} > - + @@ -143,7 +154,7 @@ export const ProjectCard = ({ summary }: { summary: ProjectSummary }) => { {summary.name} - Edited {formatRelativeTime(summary.updatedAt)} + {t('projects.editedRelative', { time: formatRelativeTime(summary.updatedAt) })} @@ -151,7 +162,7 @@ export const ProjectCard = ({ summary }: { summary: ProjectSummary }) => { { - Open + {t('common.open')} - Rename… + {t('projects.renameWithEllipsis')} - Duplicate + {t('common.duplicate')} - Export + {t('common.export')} - Delete… + {t('common.delete')}… @@ -200,10 +211,10 @@ export const ProjectCard = ({ summary }: { summary: ProjectSummary }) => { /> diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/ProjectsGrid.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/ProjectsGrid.tsx index 58d77d7d9a9..4f9fee16318 100644 --- a/invokeai/frontend/webv2/src/workbench/launchpad/ProjectsGrid.tsx +++ b/invokeai/frontend/webv2/src/workbench/launchpad/ProjectsGrid.tsx @@ -2,6 +2,7 @@ import { Alert, SimpleGrid, Skeleton, Stack } from '@chakra-ui/react'; import { Button } from '@workbench/components/ui'; import { refreshProjectLibrary, useProjectLibrarySelector } from '@workbench/projects/library'; import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; import { NewProjectCard } from './NewProjectCard'; import { ProjectCard } from './ProjectCard'; @@ -14,6 +15,7 @@ const GRID_COLUMNS = { base: 1, lg: 3, sm: 2 } as const; * leads, so an empty library still presents the next step. */ export const ProjectsGrid = () => { + const { t } = useTranslation(); const error = useProjectLibrarySelector((snapshot) => snapshot.error); const status = useProjectLibrarySelector((snapshot) => snapshot.status); const summaries = useProjectLibrarySelector((snapshot) => snapshot.summaries); @@ -26,10 +28,10 @@ export const ProjectsGrid = () => { - {error ?? 'Failed to load your projects.'} + {error ?? t('projects.failedToLoad')} ) : null} diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/models/add-models/AccessTokenPopover.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/models/add-models/AccessTokenPopover.tsx index 6ac5b135e40..18cbf1aaaa7 100644 --- a/invokeai/frontend/webv2/src/workbench/launchpad/models/add-models/AccessTokenPopover.tsx +++ b/invokeai/frontend/webv2/src/workbench/launchpad/models/add-models/AccessTokenPopover.tsx @@ -2,6 +2,7 @@ import { Icon, Input, Popover, Portal, Stack, Text } from '@chakra-ui/react'; import { Button, IconButton } from '@workbench/components/ui'; import { KeyRoundIcon } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; /** Key icon -> popover with a one-off access token and a jump to the keys tab. */ export const AccessTokenPopover = ({ @@ -12,45 +13,49 @@ export const AccessTokenPopover = ({ onChange: (value: string) => void; onManageKeys: () => void; value: string; -}) => ( - - - - - - - - - - - - - Access Token - - onChange(event.currentTarget.value)} - /> - - Attached to the next Pull only. For saved HuggingFace, Civitai, and provider keys, use the API Keys tab. - - - - - - - - -); +}) => { + const { t } = useTranslation(); + + return ( + + + + + + + + + + + + + {t('models.accessToken')} + + onChange(event.currentTarget.value)} + /> + + {t('models.accessTokenHelp')} + + + + + + + + + ); +}; /* eslint-disable react-perf/jsx-no-jsx-as-prop, react-perf/jsx-no-new-array-as-prop, react-perf/jsx-no-new-function-as-prop, react-perf/jsx-no-new-object-as-prop */ diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/models/add-models/AddModelsView.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/models/add-models/AddModelsView.tsx index 8e5021c57c1..de03d036167 100644 --- a/invokeai/frontend/webv2/src/workbench/launchpad/models/add-models/AddModelsView.tsx +++ b/invokeai/frontend/webv2/src/workbench/launchpad/models/add-models/AddModelsView.tsx @@ -12,6 +12,7 @@ import { updateModelsUi, useModelsUiSelector } from '@workbench/models/uiStore'; import { useNotify } from '@workbench/useNotify'; import { DownloadIcon, FileIcon, FolderIcon, FolderSearchIcon, LinkIcon, SearchIcon } from 'lucide-react'; import { useDeferredValue, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { SiHuggingface } from 'react-icons/si'; import type { StarterInstallSource } from './starterModelInstallSources'; @@ -54,6 +55,7 @@ const compareStarterModels = (a: IndexedStarterModel, b: IndexedStarterModel, fi * stays focused on state and install orchestration. */ export const AddModelsView = () => { + const { t } = useTranslation(); const notify = useNotify(); const { install, pendingSources } = useInstallActions(); const loadError = useStartersSelector((snapshot) => snapshot.error); @@ -97,13 +99,16 @@ export const AddModelsView = () => { } setConfiguredExternalProviders(new Set()); - notify.error('External provider keys unavailable', error instanceof Error ? error.message : String(error)); + notify.error( + t('models.externalProviderKeysUnavailable'), + error instanceof Error ? error.message : String(error) + ); }); return () => { isStale = true; }; - }, [notify]); + }, [notify, t]); const trimmed = query.trim(); const deferredTrimmed = useDeferredValue(trimmed); @@ -181,8 +186,8 @@ export const AddModelsView = () => { if (queued > 0) { notify.success( - 'Model install queued', - queued > 1 ? `${model.name} and ${queued - 1} dependenc${queued === 2 ? 'y' : 'ies'}` : model.name + t('models.modelInstallQueued'), + queued > 1 ? t('models.modelAndDependenciesQueued', { count: queued - 1, name: model.name }) : model.name ); } }; @@ -199,8 +204,8 @@ export const AddModelsView = () => { if (queued > 0) { notify.success( - 'Bundle install queued', - `${selectedBundle.name}: ${queued} install${queued === 1 ? '' : 's'} queued.` + t('models.bundleInstallQueued'), + t('models.bundleInstallQueuedDescription', { count: queued, name: selectedBundle.name }) ); } } finally { @@ -230,7 +235,7 @@ export const AddModelsView = () => { } if (!lookup.urls || lookup.urls.length === 0) { - notify.error('No model files found', 'This HuggingFace repo has no installable model files.'); + notify.error(t('models.noModelFilesFoundTitle'), t('models.noInstallableModelFiles')); return; } @@ -258,7 +263,7 @@ export const AddModelsView = () => { setQuery(''); } } catch (error) { - notify.error('Install failed', error instanceof Error ? error.message : String(error)); + notify.error(t('models.installFailed'), error instanceof Error ? error.message : String(error)); } finally { setIsPulling(false); } @@ -270,7 +275,7 @@ export const AddModelsView = () => { try { updateModelsUi({ scan: { path: trimmed, results: await scanFolderForModels(trimmed) } }); } catch (error) { - notify.error('Scan failed', error instanceof Error ? error.message : String(error)); + notify.error(t('models.scanFailed'), error instanceof Error ? error.message : String(error)); } finally { setIsScanning(false); } @@ -282,8 +287,8 @@ export const AddModelsView = () => { }> setQuery(event.currentTarget.value)} @@ -312,10 +317,10 @@ export const AddModelsView = () => { ) : null} {canScan ? ( - + ) : null} @@ -323,7 +328,7 @@ export const AddModelsView = () => { {canPull ? ( ) : null} @@ -332,19 +337,19 @@ export const AddModelsView = () => { {canScan ? ( - Press{' '} + {t('models.press')}{' '} - Scan + {t('models.scan')} {' '} - to find models in this folder. + {t('models.toFindModelsInFolder')} ) : ( - Press{' '} + {t('models.press')}{' '} - Pull + {t('models.pull')} {' '} - to install from {kind.label}. + {t('models.toInstallFrom', { source: kind.label })} )} {kind.localKind === 'file' ? ( @@ -356,7 +361,7 @@ export const AddModelsView = () => { > - Install in place + {t('models.installInPlace')} ) : null} @@ -393,7 +398,7 @@ export const AddModelsView = () => { - + {hfLookup ? ( { + const { t } = useTranslation(); + if (bundles.length === 0 && !trailing) { return null; } @@ -36,7 +39,7 @@ export const BundleChips = ({ > onSelect(null)} /> diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/models/add-models/HuggingFaceFiles.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/models/add-models/HuggingFaceFiles.tsx index 69136c62e9f..70743b4f45a 100644 --- a/invokeai/frontend/webv2/src/workbench/launchpad/models/add-models/HuggingFaceFiles.tsx +++ b/invokeai/frontend/webv2/src/workbench/launchpad/models/add-models/HuggingFaceFiles.tsx @@ -5,6 +5,7 @@ import { Stack } from '@chakra-ui/react'; import { ResultsListHeader } from '@workbench/launchpad/models/shared/ResultsListHeader'; import { InstallSourceButton, SourceListItem } from '@workbench/launchpad/models/shared/SourceListItem'; import { useDeferredValue, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; const fileNameOf = (url: string): string => url.split(/[\\/]/).at(-1) ?? url; @@ -19,6 +20,7 @@ export const HuggingFaceFiles = ({ onInstall: (url: string) => void; pendingSources: ReadonlySet; }) => { + const { t } = useTranslation(); const [filter, setFilter] = useState(''); const deferredFilter = useDeferredValue(filter); @@ -42,10 +44,10 @@ export const HuggingFaceFiles = ({ path.split(/[\\/]/).at(-1) ?? path; @@ -25,6 +26,7 @@ export const ScanResults = ({ pendingSources: ReadonlySet; scan: { path: string; results: FoundModel[] }; }) => { + const { t } = useTranslation(); const [filter, setFilter] = useState(''); const deferredFilter = useDeferredValue(filter); @@ -51,9 +53,9 @@ export const ScanResults = ({ return ( - No model files found in {scan.path}. + {t('models.noModelFilesFound', { path: scan.path })} - + @@ -72,13 +74,17 @@ export const ScanResults = ({ > - Install in place + {t('models.installInPlace')} } installAllDisabled={installable.length === 0} - installAllLabel={`Install all (${installable.length})`} + installAllLabel={t('models.installAllCount', { count: installable.length })} searchValue={filter} - summary={`${scan.results.length} file${scan.results.length === 1 ? '' : 's'} in ${scan.path} · ${notInstalledCount} not installed`} + summary={t('models.scanSummary', { + count: scan.results.length, + notInstalled: notInstalledCount, + path: scan.path, + })} onClear={onClear} onInstallAll={installAll} onSearchChange={setFilter} diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/models/add-models/StarterFilterMenu.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/models/add-models/StarterFilterMenu.tsx index cb7d00e2ce6..29f8ddf8ba6 100644 --- a/invokeai/frontend/webv2/src/workbench/launchpad/models/add-models/StarterFilterMenu.tsx +++ b/invokeai/frontend/webv2/src/workbench/launchpad/models/add-models/StarterFilterMenu.tsx @@ -4,6 +4,7 @@ import type { ModelSortField } from '@workbench/models/library'; import type { ModelTaxonomyType } from '@workbench/models/types'; import { ModelFilterMenu } from '@workbench/launchpad/models/shared/ModelFilterMenu'; +import { useTranslation } from 'react-i18next'; export interface StarterModelFilters { /** null = all types. */ @@ -22,10 +23,10 @@ export const DEFAULT_STARTER_MODEL_FILTERS: StarterModelFilters = { }; const STARTER_SORT_FIELDS: ModelFilterSortOption[] = [ - { field: 'default', label: 'Default' }, - { field: 'name', label: 'Name' }, - { field: 'base', label: 'Base' }, - { field: 'format', label: 'Format' }, + { field: 'default', labelKey: 'models.sortDefault' }, + { field: 'name', labelKey: 'models.sortName' }, + { field: 'base', labelKey: 'models.sortBase' }, + { field: 'format', labelKey: 'models.sortFormat' }, ]; const isFiltering = (filters: StarterModelFilters): boolean => @@ -41,20 +42,24 @@ export const StarterFilterMenu = ({ availableTypes: ModelTaxonomyType[]; filters: StarterModelFilters; onChange: (filters: StarterModelFilters) => void; -}) => ( - onChange({ ...filters, baseFilter })} - onSortChange={(sortField, sortDirection) => onChange({ ...filters, sortDirection, sortField })} - onTypeFilterChange={(typeFilter) => onChange({ ...filters, typeFilter })} - /> -); +}) => { + const { t } = useTranslation(); + + return ( + onChange({ ...filters, baseFilter })} + onSortChange={(sortField, sortDirection) => onChange({ ...filters, sortDirection, sortField })} + onTypeFilterChange={(typeFilter) => onChange({ ...filters, typeFilter })} + /> + ); +}; /* eslint-disable react-perf/jsx-no-jsx-as-prop, react-perf/jsx-no-new-array-as-prop, react-perf/jsx-no-new-function-as-prop, react-perf/jsx-no-new-object-as-prop */ diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/models/add-models/StarterList.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/models/add-models/StarterList.tsx index c59e8246357..ad94f9d2359 100644 --- a/invokeai/frontend/webv2/src/workbench/launchpad/models/add-models/StarterList.tsx +++ b/invokeai/frontend/webv2/src/workbench/launchpad/models/add-models/StarterList.tsx @@ -8,6 +8,7 @@ import { InstallSourceButton, SourceListItem } from '@workbench/launchpad/models import { getModelBaseColorPalette, getModelBaseLabel } from '@workbench/models/baseIdentity'; import { getModelTypeLabel } from '@workbench/models/taxonomy'; import { KeyRoundIcon } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; const getExternalProviderId = (source: string): string | null => { if (!source.startsWith('external://')) { @@ -40,11 +41,13 @@ export const StarterList = ({ selectedBundleSources: ReadonlySet | undefined; status: ReturnType['status']; }) => { + const { t } = useTranslation(); + if (status === 'error' && loadError) { return ( - Could not load starter models + {t('models.couldNotLoadStarterModels')} {loadError} @@ -64,9 +67,7 @@ export const StarterList = ({ if (models.length === 0) { return ( - {isInstallable - ? 'No starter models match - press Pull to install the source you entered.' - : 'No starter models match your search.'} + {isInstallable ? t('models.noStarterModelsPull') : t('models.noStarterModelsSearch')} ); } @@ -81,7 +82,7 @@ export const StarterList = ({ const trailing = externalProviderId ? ( configuredExternalProviders.has(externalProviderId) ? ( - Installed + {t('models.installed')} ) : ( ) ) : ( @@ -126,9 +127,7 @@ export const StarterList = ({ } description={`${model.description}${ - dependencyCount > 0 - ? ` (installs ${dependencyCount} dependenc${dependencyCount === 1 ? 'y' : 'ies'})` - : '' + dependencyCount > 0 ? t('models.installsDependencies', { count: dependencyCount }) : '' }`} title={model.name} trailing={trailing} diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/models/credentials/ApiKeysSection.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/models/credentials/ApiKeysSection.tsx index f7d9f59610e..c7f0332e9be 100644 --- a/invokeai/frontend/webv2/src/workbench/launchpad/models/credentials/ApiKeysSection.tsx +++ b/invokeai/frontend/webv2/src/workbench/launchpad/models/credentials/ApiKeysSection.tsx @@ -18,6 +18,7 @@ import { refreshStartersIfLoaded } from '@workbench/models/startersStore'; import { useNotify } from '@workbench/useNotify'; import { HexagonIcon } from 'lucide-react'; import { useEffect, useState, type ElementType } from 'react'; +import { useTranslation } from 'react-i18next'; import { SiAlibabacloud, SiBytedance, SiGooglegemini, SiHuggingface, SiOpenai } from 'react-icons/si'; /** @@ -28,15 +29,16 @@ import { SiAlibabacloud, SiBytedance, SiGooglegemini, SiHuggingface, SiOpenai } */ export const ApiKeysSection = () => { const notify = useNotify(); + const { t } = useTranslation(); return ( - Keys are used when installing protected models and when generating with external API models. + {t('models.apiKeysDescription')} - notify.error('Civitai key', message)} /> + notify.error(t('models.civitaiKey'), message)} /> @@ -73,6 +75,7 @@ const ApiKeyCard = ({ status: KeyStatusBadge | null; title: string; }) => { + const { t } = useTranslation(); const [draft, setDraft] = useState(''); const [isBusy, setIsBusy] = useState(false); @@ -84,7 +87,7 @@ const ApiKeyCard = ({ return true; } catch (error) { - onError(error instanceof Error ? error.message : 'Something went wrong.'); + onError(error instanceof Error ? error.message : t('common.somethingWentWrong')); return false; } finally { @@ -139,7 +142,7 @@ const ApiKeyCard = ({ void handleSave()} > - Save + {t('common.save')} {onClear ? ( ) : null} @@ -172,14 +175,16 @@ const ApiKeyCard = ({ ); }; -const HF_STATUS_BADGES: Record = { - invalid: { label: 'Invalid', palette: 'red' }, - unknown: { label: 'Not set', palette: 'gray' }, - valid: { label: 'Valid', palette: 'green' }, +const HF_STATUS_PALETTES: Record = { + invalid: 'red', + unknown: 'gray', + valid: 'green', }; const HuggingFaceKeyCard = ({ onError }: { onError: (title: string, message: string) => void }) => { + const { t } = useTranslation(); const [status, setStatus] = useState(null); + const statusBadge = status ? { label: t(`models.keyStatus.${status}`), palette: HF_STATUS_PALETTES[status] } : null; useEffect(() => { let isStale = false; @@ -199,17 +204,17 @@ const HuggingFaceKeyCard = ({ onError }: { onError: (title: string, message: str return () => { isStale = true; }; - }, []); + }, [t]); return ( onError('HuggingFace token', message)} + onError={(message) => onError(t('models.huggingFaceToken'), message)} onClear={ status === 'valid' || status === 'invalid' ? async () => { @@ -223,7 +228,7 @@ const HuggingFaceKeyCard = ({ onError }: { onError: (title: string, message: str setStatus(nextStatus); if (nextStatus === 'invalid') { - onError('HuggingFace token', 'HuggingFace rejected this token. Check that it has read access.'); + onError(t('models.huggingFaceToken'), t('models.huggingFaceRejectedToken')); } }} /> @@ -231,14 +236,19 @@ const HuggingFaceKeyCard = ({ onError }: { onError: (title: string, message: str }; const CivitaiKeyCard = ({ onError }: { onError: (message: string) => void }) => { + const { t } = useTranslation(); const [hasKey, setHasKey] = useState(() => getCivitaiApiKey() !== null); return ( void }) => { + const { t } = useTranslation(); const [configs, setConfigs] = useState(null); const [loadError, setLoadError] = useState(null); @@ -280,14 +291,14 @@ const ExternalProviderKeyCards = ({ onError }: { onError: (title: string, messag .catch((error: unknown) => { if (!isStale) { setConfigs([]); - setLoadError(error instanceof Error ? error.message : 'Failed to load external providers.'); + setLoadError(error instanceof Error ? error.message : t('models.failedToLoadExternalProviders')); } }); return () => { isStale = true; }; - }, []); + }, [t]); const replaceConfig = (next: ExternalProviderConfig) => { setConfigs((current) => (current ?? []).map((config) => (config.provider_id === next.provider_id ? next : config))); @@ -296,7 +307,7 @@ const ExternalProviderKeyCards = ({ onError }: { onError: (title: string, messag if (loadError) { return ( - External providers unavailable: {loadError} + {t('models.externalProvidersUnavailable', { error: loadError })} ); } @@ -306,17 +317,23 @@ const ExternalProviderKeyCards = ({ onError }: { onError: (title: string, messag {(configs ?? []).map((config) => { const presentation = EXTERNAL_PROVIDER_PRESENTATION[config.provider_id] ?? { icon: HexagonIcon, - placeholder: 'API key', + placeholder: 'generic', title: config.provider_id, }; + const placeholder = + presentation.placeholder === 'byteplus' + ? t('models.bytePlusApiKeyPlaceholder') + : presentation.placeholder === 'generic' + ? t('models.apiKey') + : presentation.placeholder; return ( onError(presentation.title, message)} onUpdated={replaceConfig} @@ -344,6 +361,7 @@ const ExternalProviderKeyCard = ({ placeholder: string; title: string; }) => { + const { t } = useTranslation(); const [apiKeyDraft, setApiKeyDraft] = useState(''); const [baseUrlDraft, setBaseUrlDraft] = useState(config.base_url ?? ''); const [overrideBaseUrl, setOverrideBaseUrl] = useState(config.base_url !== null); @@ -373,7 +391,7 @@ const ExternalProviderKeyCard = ({ setOverrideBaseUrl(nextConfig.base_url !== null); onSuccess?.(nextConfig); } catch (error) { - onError(error instanceof Error ? error.message : 'Something went wrong.'); + onError(error instanceof Error ? error.message : t('common.somethingWentWrong')); } finally { setIsBusy(false); } @@ -444,7 +462,7 @@ const ExternalProviderKeyCard = ({ size="sm" variant="surface" > - {config.api_key_configured ? 'Configured' : 'Not set'} + {config.api_key_configured ? t('models.keyStatus.configured') : t('models.keyStatus.unknown')} @@ -456,9 +474,9 @@ const ExternalProviderKeyCard = ({ - Override base URL + {t('models.overrideBaseUrl')} {overrideBaseUrl ? ( void handleSave()} > - Save + {t('common.save')} {canClear ? ( ) : null} diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/models/detail/DefaultSettingsSection.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/models/detail/DefaultSettingsSection.tsx index 91020d7ea30..1ecaf6362aa 100644 --- a/invokeai/frontend/webv2/src/workbench/launchpad/models/detail/DefaultSettingsSection.tsx +++ b/invokeai/frontend/webv2/src/workbench/launchpad/models/detail/DefaultSettingsSection.tsx @@ -1,5 +1,6 @@ /* eslint-disable react-perf/jsx-no-jsx-as-prop, react-perf/jsx-no-new-array-as-prop, react-perf/jsx-no-new-function-as-prop, react-perf/jsx-no-new-object-as-prop */ import type { AnyModelDefaultSettings, ModelConfig } from '@workbench/models/types'; +import type { TFunction } from 'i18next'; import { Grid, HStack, Icon, NativeSelect, NumberInput, Stack, Switch, Text } from '@chakra-ui/react'; import { Button, FieldLabel, Panel } from '@workbench/components/ui'; @@ -8,6 +9,7 @@ import { replaceModelInStore } from '@workbench/models/modelsStore'; import { loraDefaultSettingsSchema, mainDefaultSettingsSchema } from '@workbench/models/schemas'; import { MoveHorizontalIcon } from 'lucide-react'; import { useMemo, useState, type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; /** * Per-model generation defaults ("use these settings when this model is @@ -79,13 +81,13 @@ export const supportsDefaultSettings = (model: Pick): boole interface FieldSpec { key: keyof AnyModelDefaultSettings; - label: string; + labelKey: string; /** Render the control; disabled (showing the inherited value) until customized. */ control: (value: unknown, setValue: (value: unknown) => void, disabled: boolean) => ReactNode; /** Value used when the toggle is switched on; also previewed while off. */ defaultValue: unknown; - /** What applies while the toggle is off, e.g. "App default: 30 steps". */ - inheritLabel: string; + /** Translation key for what applies while the toggle is off. */ + inheritLabelKey: string; } const numberControl = @@ -147,58 +149,58 @@ const MAIN_FIELDS: FieldSpec[] = [ { control: selectControl(SCHEDULERS), defaultValue: 'euler_a', - inheritLabel: 'App default: euler_a', + inheritLabelKey: 'models.defaultFieldInherited.scheduler', key: 'scheduler', - label: 'Scheduler', + labelKey: 'models.defaultFields.scheduler', }, { control: numberControl({ max: 10000, min: 1 }), defaultValue: 30, - inheritLabel: 'App default: 30', + inheritLabelKey: 'models.defaultFieldInherited.steps', key: 'steps', - label: 'Steps', + labelKey: 'models.defaultFields.steps', }, { control: numberControl({ max: 200, min: 1, step: 0.5 }), defaultValue: 7, - inheritLabel: 'App default: 7', + inheritLabelKey: 'models.defaultFieldInherited.cfgScale', key: 'cfg_scale', - label: 'CFG Scale', + labelKey: 'models.defaultFields.cfgScale', }, { control: numberControl({ max: 0.99, min: 0, step: 0.05 }), defaultValue: 0, - inheritLabel: 'App default: 0 (off)', + inheritLabelKey: 'models.defaultFieldInherited.cfgRescale', key: 'cfg_rescale_multiplier', - label: 'CFG Rescale', + labelKey: 'models.defaultFields.cfgRescale', }, { control: numberControl({ max: 20, min: 1, step: 0.5 }), defaultValue: 4, - inheritLabel: 'Uses the generator default', + inheritLabelKey: 'models.defaultFieldInherited.guidance', key: 'guidance', - label: 'Guidance', + labelKey: 'models.defaultFields.guidance', }, { control: numberControl({ max: 8192, min: 64, step: 8 }), defaultValue: 1024, - inheritLabel: 'App default: 1024', + inheritLabelKey: 'models.defaultFieldInherited.width', key: 'width', - label: 'Width', + labelKey: 'models.defaultFields.width', }, { control: numberControl({ max: 8192, min: 64, step: 8 }), defaultValue: 1024, - inheritLabel: 'App default: 1024', + inheritLabelKey: 'models.defaultFieldInherited.height', key: 'height', - label: 'Height', + labelKey: 'models.defaultFields.height', }, { control: selectControl(['fp16', 'fp32']), defaultValue: 'fp16', - inheritLabel: 'App default: fp16', + inheritLabelKey: 'models.defaultFieldInherited.vaePrecision', key: 'vae_precision', - label: 'VAE Precision', + labelKey: 'models.defaultFields.vaePrecision', }, ]; @@ -206,9 +208,9 @@ const LORA_FIELDS: FieldSpec[] = [ { control: numberControl({ max: 10, min: -10, step: 0.05 }), defaultValue: 0.75, - inheritLabel: 'App default: 0.75', + inheritLabelKey: 'models.defaultFieldInherited.weight', key: 'weight', - label: 'Weight', + labelKey: 'models.defaultFields.weight', }, ]; @@ -216,9 +218,9 @@ const CONTROL_ADAPTER_FIELDS: FieldSpec[] = [ { control: selectControl(PREPROCESSORS), defaultValue: 'canny_edge_detection', - inheritLabel: 'Chosen automatically per image', + inheritLabelKey: 'models.defaultFieldInherited.preprocessor', key: 'preprocessor', - label: 'Preprocessor', + labelKey: 'models.defaultFields.preprocessor', }, ]; @@ -234,7 +236,11 @@ const getFieldsForModel = (model: Pick): FieldSpec[] => { return CONTROL_ADAPTER_FIELDS; }; -const validateDefaults = (model: Pick, settings: AnyModelDefaultSettings): string | null => { +const validateDefaults = ( + model: Pick, + settings: AnyModelDefaultSettings, + t: TFunction +): string | null => { if (model.type === 'main') { const result = mainDefaultSettingsSchema.safeParse({ cfgRescaleMultiplier: settings.cfg_rescale_multiplier ?? null, @@ -247,13 +253,13 @@ const validateDefaults = (model: Pick, settings: AnyModelDe width: settings.width ?? null, }); - return result.success ? null : (result.error.issues[0]?.message ?? 'Invalid default settings.'); + return result.success ? null : (result.error.issues[0]?.message ?? t('models.invalidDefaultSettings')); } if (model.type === 'lora') { const result = loraDefaultSettingsSchema.safeParse({ weight: settings.weight ?? null }); - return result.success ? null : (result.error.issues[0]?.message ?? 'Invalid default settings.'); + return result.success ? null : (result.error.issues[0]?.message ?? t('models.invalidDefaultSettings')); } return null; @@ -268,6 +274,7 @@ export const DefaultSettingsSection = ({ onError: (message: string) => void; onSaved: () => void; }) => { + const { t } = useTranslation(); const fields = useMemo(() => getFieldsForModel(model), [model]); const [draft, setDraft] = useState(() => ({ modelKey: model.key, @@ -298,7 +305,7 @@ export const DefaultSettingsSection = ({ }; const handleSave = async () => { - const validationError = validateDefaults(model, settings); + const validationError = validateDefaults(model, settings, t); if (validationError) { setError(validationError); @@ -311,7 +318,7 @@ export const DefaultSettingsSection = ({ replaceModelInStore(await updateModel(model.key, { default_settings: settings })); onSaved(); } catch (saveError) { - onError(saveError instanceof Error ? saveError.message : 'Failed to save default settings.'); + onError(saveError instanceof Error ? saveError.message : t('models.failedToSaveDefaults')); } finally { setIsSaving(false); } @@ -321,9 +328,9 @@ export const DefaultSettingsSection = ({ - Default Settings + {t('models.defaultSettings')} - Applied automatically when this model is selected. Off = use the app default. + {t('models.defaultSettingsHelp')} {visibleError ? ( @@ -350,7 +357,7 @@ export const DefaultSettingsSection = ({ - {field.label} + {t(field.labelKey)} - {`Customize ${field.label} for this model`} + {t('models.customizeDefaultField', { field: t(field.labelKey) })} {field.control( @@ -371,7 +378,7 @@ export const DefaultSettingsSection = ({ !isEnabled )} - {isEnabled ? 'Customized for this model' : field.inheritLabel} + {isEnabled ? t('models.customizedForThisModel') : t(field.inheritLabelKey)} ); diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/models/detail/ModelDetail.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/models/detail/ModelDetail.tsx index 9b2928807c2..36447916ae4 100644 --- a/invokeai/frontend/webv2/src/workbench/launchpad/models/detail/ModelDetail.tsx +++ b/invokeai/frontend/webv2/src/workbench/launchpad/models/detail/ModelDetail.tsx @@ -10,6 +10,7 @@ import { useNotify } from '@workbench/useNotify'; import { areArraysEqual } from '@workbench/workbenchSelectors'; import { ArrowLeftIcon, MoreHorizontalIcon, PencilIcon, RefreshCcwIcon, Trash2Icon } from 'lucide-react'; import { memo, useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { SiHuggingface } from 'react-icons/si'; import { DefaultSettingsSection, supportsDefaultSettings } from './DefaultSettingsSection'; @@ -185,6 +186,7 @@ const ModelIdentitySection = memo(function ModelIdentitySection({ onDeleted, }: ModelIdentitySectionProps) { const notify = useNotify(); + const { t } = useTranslation(); const [editingModelKey, setEditingModelKey] = useState(null); const isEditing = editingModelKey === model.key; @@ -194,8 +196,8 @@ const ModelIdentitySection = memo(function ModelIdentitySection({ notify.error('Model image', message)} - onUpdated={() => notify.success('Model image updated')} + onError={(message) => notify.error(t('models.modelImage'), message)} + onUpdated={() => notify.success(t('models.modelImageUpdated'))} /> @@ -209,7 +211,7 @@ const ModelIdentitySection = memo(function ModelIdentitySection({ - The file for this model was not found on disk. + {t('models.fileNotFoundOnDisk')} ) : null} @@ -233,7 +235,7 @@ const ModelIdentitySection = memo(function ModelIdentitySection({ onCancel={() => setEditingModelKey(null)} onSaved={() => { setEditingModelKey(null); - notify.success('Model updated', model.name); + notify.success(t('models.modelUpdated'), model.name); }} /> ) : ( @@ -254,6 +256,7 @@ const ModelDetailActions = ({ onDeleted: () => void; onToggleEditing: () => void; }) => { + const { t } = useTranslation(); const { convert, reidentify, remove } = useModelActions(); const [pendingAction, setPendingAction] = useState<'delete' | 'convert' | null>(null); const [isActionBusy, setIsActionBusy] = useState(false); @@ -273,16 +276,16 @@ const ModelDetailActions = ({ {isConvertibleToDiffusers(model) ? ( ) : null} - + @@ -291,22 +294,22 @@ const ModelDetailActions = ({ void handleReidentify()}> - Re-identify model + {t('models.reidentify')} setPendingAction('delete')}> - Delete model + {t('models.deleteModel')} setPendingAction(null)} onConfirm={async () => { await remove(model); @@ -315,10 +318,10 @@ const ModelDetailActions = ({ /> {/* Destructive styling: the original checkpoint file is replaced. */} setPendingAction(null)} onConfirm={() => convert(model)} /> @@ -332,6 +335,7 @@ const DefaultSettingsSectionContainer = memo(function DefaultSettingsSectionCont modelKey: string; }) { const notify = useNotify(); + const { t } = useTranslation(); const model = useModelsSelector((snapshot) => selectDefaultSettingsModel(snapshot.models, modelKey)); if (!model || !supportsDefaultSettings(model)) { @@ -341,15 +345,16 @@ const DefaultSettingsSectionContainer = memo(function DefaultSettingsSectionCont return ( notify.error('Default settings', message)} - onSaved={() => notify.success('Default settings saved')} + onError={(message) => notify.error(t('models.defaultSettings'), message)} + onSaved={() => notify.success(t('models.defaultSettingsSaved'))} /> ); }); const TriggerPhrasesEditorContainer = memo(function TriggerPhrasesEditorContainer({ modelKey }: { modelKey: string }) { const notify = useNotify(); - const handleError = useCallback((message: string) => notify.error('Trigger phrases', message), [notify]); + const { t } = useTranslation(); + const handleError = useCallback((message: string) => notify.error(t('models.triggerPhrases'), message), [notify, t]); const model = useModelsSelector( (snapshot) => selectTriggerPhrasesModel(snapshot.models, modelKey), areTriggerPhrasesModelsEqual @@ -370,6 +375,7 @@ const TriggerPhrasesEditorContainer = memo(function TriggerPhrasesEditorContaine const RelatedModelsSectionContainer = memo(function RelatedModelsSectionContainer({ modelKey }: { modelKey: string }) { const notify = useNotify(); + const { t } = useTranslation(); const model = useModelsSelector( (snapshot) => { const candidate = snapshot.models.find((model) => model.key === modelKey); @@ -378,7 +384,10 @@ const RelatedModelsSectionContainer = memo(function RelatedModelsSectionContaine }, (left, right) => left?.key === right?.key && left?.base === right?.base ); - const handleSectionError = useCallback((message: string) => notify.error('Model manager', message), [notify]); + const handleSectionError = useCallback( + (message: string) => notify.error(t('models.modelManager'), message), + [notify, t] + ); if (!model) { return null; @@ -387,16 +396,21 @@ const RelatedModelsSectionContainer = memo(function RelatedModelsSectionContaine return ; }); -const BackButton = ({ onBack }: { onBack: () => void }) => ( - -); +const BackButton = ({ onBack }: { onBack: () => void }) => { + const { t } = useTranslation(); + + return ( + + ); +}; const isAbsolutePath = (path: string): boolean => path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path); const ModelAttributes = ({ density, model }: { density: 'panel' | 'full'; model: ModelIdentityModel }) => { + const { t } = useTranslation(); const modelsDir = useModelsSelector((snapshot) => snapshot.modelsDir); // Managed models store paths relative to the models directory; show the // resolved absolute path so it can be found on disk. @@ -404,12 +418,12 @@ const ModelAttributes = ({ density, model }: { density: 'panel' | 'full'; model: isAbsolutePath(model.path) || !modelsDir ? model.path : `${modelsDir.replace(/\/+$/, '')}/${model.path}`; const attributes: { label: string; value: string }[] = [ - { label: 'File Size', value: formatBytes(model.file_size) }, - { label: 'Variant', value: model.variant ?? '—' }, - { label: 'Prediction Type', value: model.prediction_type ?? '—' }, - { label: 'Hash', value: model.hash }, - { label: 'Path', value: fullPath }, - { label: 'Source', value: model.source }, + { label: t('models.fileSize'), value: formatBytes(model.file_size) }, + { label: t('models.variant'), value: model.variant ?? '—' }, + { label: t('models.predictionType'), value: model.prediction_type ?? '—' }, + { label: t('models.hash'), value: model.hash }, + { label: t('models.path'), value: fullPath }, + { label: t('models.source'), value: model.source }, ]; return ( diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/models/detail/ModelEditForm.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/models/detail/ModelEditForm.tsx index f15584390fc..20ebd42e8d3 100644 --- a/invokeai/frontend/webv2/src/workbench/launchpad/models/detail/ModelEditForm.tsx +++ b/invokeai/frontend/webv2/src/workbench/launchpad/models/detail/ModelEditForm.tsx @@ -9,6 +9,7 @@ import { replaceModelInStore } from '@workbench/models/modelsStore'; import { modelEditSchema, type ModelEditFormValues } from '@workbench/models/schemas'; import { getModelTypeLabel, MODEL_CATEGORIES } from '@workbench/models/taxonomy'; import { useZodForm } from '@workbench/models/useZodForm'; +import { useTranslation } from 'react-i18next'; const KNOWN_BASES = [ 'sd-1', @@ -41,6 +42,7 @@ export const ModelEditForm = ({ onCancel: () => void; onSaved: () => void; }) => { + const { t } = useTranslation(); const form = useZodForm(modelEditSchema, { base: String(model.base), description: model.description ?? '', @@ -71,7 +73,7 @@ export const ModelEditForm = ({ return ( - + form.setValue('name', event.currentTarget.value)} /> - +