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)}
/>
-
+
-
+
-
+
-
+
form.setValue('variant', event.currentTarget.value)}
/>
-
+
-
+
@@ -144,7 +146,7 @@ export const ModelEditForm = ({
-
+
diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/models/detail/ModelImageUpload.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/models/detail/ModelImageUpload.tsx
index 7fc9d841584..bbe52ef0f87 100644
--- a/invokeai/frontend/webv2/src/workbench/launchpad/models/detail/ModelImageUpload.tsx
+++ b/invokeai/frontend/webv2/src/workbench/launchpad/models/detail/ModelImageUpload.tsx
@@ -7,6 +7,7 @@ import { deleteModelImage, getModelImageUrl, updateModelImage } from '@workbench
import { markCoverImageChanged, useModelsSelector } from '@workbench/models/modelsStore';
import { ImageIcon, UploadIcon, XIcon } from 'lucide-react';
import { useEffect, useRef, useState, type DragEvent } from 'react';
+import { useTranslation } from 'react-i18next';
const ACCEPTED_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
@@ -26,6 +27,7 @@ export const ModelImageUpload = ({
onError: (message: string) => void;
onUpdated: () => void;
}) => {
+ const { t } = useTranslation();
const inputRef = useRef(null);
const dragDepthRef = useRef(0);
const imageVersion = useModelsSelector((snapshot) => snapshot.coverImageVersions[model.key]);
@@ -46,7 +48,7 @@ export const ModelImageUpload = ({
}
if (!ACCEPTED_TYPES.has(file.type)) {
- onError('Use a PNG, JPEG, or WebP image.');
+ onError(t('models.useSupportedImage'));
return;
}
@@ -60,7 +62,7 @@ export const ModelImageUpload = ({
markCoverImageChanged(modelKey, true);
onUpdated();
} catch (error) {
- onError(error instanceof Error ? error.message : 'Failed to upload model image.');
+ onError(error instanceof Error ? error.message : t('models.failedToUploadModelImage'));
} finally {
setIsBusy(false);
}
@@ -76,7 +78,7 @@ export const ModelImageUpload = ({
markCoverImageChanged(modelKey, false);
onUpdated();
} catch (error) {
- onError(error instanceof Error ? error.message : 'Failed to remove model image.');
+ onError(error instanceof Error ? error.message : t('models.failedToRemoveModelImage'));
} finally {
setIsBusy(false);
}
@@ -85,7 +87,7 @@ export const ModelImageUpload = ({
return (
{hasImage ? (
- Add image
+ {t('models.addImage')}
)}
{isDropActive ? (
@@ -196,9 +198,9 @@ export const ModelImageUpload = ({
_groupFocusWithin={{ opacity: 1, pointerEvents: 'auto' }}
_groupHover={{ opacity: 1, pointerEvents: 'auto' }}
>
-
+
;
onError: (message: string) => void;
}) => {
+ const { t } = useTranslation();
const models = useModelsSelector((snapshot) => snapshot.models);
const [relatedKeys, setRelatedKeys] = useState(null);
const [isMutating, setIsMutating] = useState(false);
@@ -52,14 +54,14 @@ export const RelatedModelsSection = ({
.catch((error: unknown) => {
if (!isStale) {
setRelatedKeys([]);
- onError(error instanceof Error ? error.message : 'Failed to load related models.');
+ onError(error instanceof Error ? error.message : t('models.failedToLoadRelatedModels'));
}
});
return () => {
isStale = true;
};
- }, [model.key, onError]);
+ }, [model.key, onError, t]);
const relatedModels = useMemo(() => {
const byKey = new Map(models.map((candidate) => [candidate.key, candidate]));
@@ -80,7 +82,7 @@ export const RelatedModelsSection = ({
await addModelRelationship(model.key, target.key);
setRelatedKeys((current) => [...(current ?? []), target.key]);
} catch (error) {
- onError(error instanceof Error ? error.message : 'Failed to link models.');
+ onError(error instanceof Error ? error.message : t('models.failedToLinkModels'));
} finally {
setIsMutating(false);
}
@@ -93,7 +95,7 @@ export const RelatedModelsSection = ({
await removeModelRelationship(model.key, key);
setRelatedKeys((current) => (current ?? []).filter((existing) => existing !== key));
} catch (error) {
- onError(error instanceof Error ? error.message : 'Failed to unlink models.');
+ onError(error instanceof Error ? error.message : t('models.failedToUnlink'));
} finally {
setIsMutating(false);
}
@@ -102,16 +104,16 @@ export const RelatedModelsSection = ({
return (
- Related Models
+ {t('models.relatedModels')}
- Link the models that pair well with this one — they are suggested together elsewhere in the app.
+ {t('models.relatedModelsHelp')}
candidate.base === model.base || candidate.base === 'any' || model.base === 'any'}
modelTypes={LINKABLE_TYPES}
- placeholder="Search compatible models to link…"
+ placeholder={t('models.searchCompatibleToLink')}
showManagerButton={false}
size="sm"
value={null}
@@ -123,7 +125,7 @@ export const RelatedModelsSection = ({
) : relatedModels.length === 0 ? (
- No related models linked yet.
+ {t('models.noRelatedModels')}
) : (
@@ -150,9 +152,9 @@ export const RelatedModelsSection = ({
}
title={relatedModel?.name ?? key}
trailing={
-
+
void;
phrases: readonly string[];
}) => {
+ const { t } = useTranslation();
const [editor, setEditor] = useState(() => ({
draft: '',
error: null,
@@ -51,7 +53,7 @@ export const TriggerPhrasesEditor = ({
return true;
} catch (persistError) {
- onError(persistError instanceof Error ? persistError.message : 'Failed to update trigger phrases.');
+ onError(persistError instanceof Error ? persistError.message : t('models.failedToUpdateTriggerPhrases'));
return false;
} finally {
@@ -63,12 +65,17 @@ export const TriggerPhrasesEditor = ({
const parsed = triggerPhraseSchema.safeParse(draft);
if (!parsed.success) {
- setEditor({ draft, error: parsed.error.issues[0]?.message ?? 'Invalid trigger phrase.', isSaving, modelKey });
+ setEditor({
+ draft,
+ error: parsed.error.issues[0]?.message ?? t('models.invalidTriggerPhrase'),
+ isSaving,
+ modelKey,
+ });
return;
}
if (phrases.some((phrase) => phrase.toLowerCase() === parsed.data.toLowerCase())) {
- setEditor({ draft, error: 'That trigger phrase is already on this model.', isSaving, modelKey });
+ setEditor({ draft, error: t('models.triggerPhraseDuplicate'), isSaving, modelKey });
return;
}
@@ -81,15 +88,11 @@ export const TriggerPhrasesEditor = ({
};
return (
-
+
{
@@ -112,7 +115,7 @@ export const TriggerPhrasesEditor = ({
}}
>
- Add
+ {t('common.add')}
{phrases.length > 0 ? (
@@ -122,7 +125,7 @@ export const TriggerPhrasesEditor = ({
{phrase}
{
void persist(phrases.filter((existing) => existing !== phrase));
}}
diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/models/install-queue/InstallJobProgress.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/models/install-queue/InstallJobProgress.tsx
index 088bc988545..b3c63398a27 100644
--- a/invokeai/frontend/webv2/src/workbench/launchpad/models/install-queue/InstallJobProgress.tsx
+++ b/invokeai/frontend/webv2/src/workbench/launchpad/models/install-queue/InstallJobProgress.tsx
@@ -3,8 +3,10 @@ import type { ModelInstallJob } from '@workbench/models/types';
import { Box, Progress, Text } from '@chakra-ui/react';
import { useInstallProgress } from '@workbench/models/installsStore';
import { formatBytes } from '@workbench/models/taxonomy';
+import { useTranslation } from 'react-i18next';
export const InstallJobProgress = ({ job }: { job: ModelInstallJob }) => {
+ const { t } = useTranslation();
const liveProgress = useInstallProgress(job.id);
const bytes = liveProgress?.bytes ?? job.bytes ?? 0;
const totalBytes = liveProgress?.totalBytes ?? job.total_bytes ?? 0;
@@ -13,7 +15,7 @@ export const InstallJobProgress = ({ job }: { job: ModelInstallJob }) => {
return (
-
+
@@ -21,7 +23,7 @@ export const InstallJobProgress = ({ job }: { job: ModelInstallJob }) => {
{hasTotal
? `${Math.round((ratio ?? 0) * 100)}% · ${formatBytes(bytes)} / ${formatBytes(totalBytes)}`
- : 'Waiting for download to start…'}
+ : t('models.waitingForDownload')}
);
diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/models/install-queue/InstallJobRow.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/models/install-queue/InstallJobRow.tsx
index 41059a45bb2..5c80c980857 100644
--- a/invokeai/frontend/webv2/src/workbench/launchpad/models/install-queue/InstallJobRow.tsx
+++ b/invokeai/frontend/webv2/src/workbench/launchpad/models/install-queue/InstallJobRow.tsx
@@ -15,6 +15,7 @@ import { isActiveInstallStatus, refreshInstalls, replaceInstallJob } from '@work
import { getInstallSourceLabel } from '@workbench/models/taxonomy';
import { PauseIcon, PlayIcon, RotateCcwIcon, TriangleAlertIcon, XIcon } from 'lucide-react';
import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
import { InstallJobProgress } from './InstallJobProgress';
import { STATUS_BADGES, getInstallJobDisplayName } from './queueUtils';
@@ -29,6 +30,7 @@ export const InstallJobRow = ({
job: ModelInstallJob;
onError: (title: string, message: string) => void;
}) => {
+ const { t } = useTranslation();
const [isActing, setIsActing] = useState(false);
const connectionStatus = useConnectionStatusSelector((snapshot) => snapshot.status);
const badge = STATUS_BADGES[job.status] ?? { label: job.status, palette: 'gray' };
@@ -72,7 +74,7 @@ export const InstallJobRow = ({
{displayName}
{showDisconnected ? (
-
+
) : null}
@@ -81,52 +83,52 @@ export const InstallJobRow = ({
{job.status === 'downloading' ? (
-
+
void runAction(() => pauseModelInstall(job.id), 'Pause failed')}
+ onClick={() => void runAction(() => pauseModelInstall(job.id), t('models.pauseFailed'))}
>
) : null}
{job.status === 'paused' ? (
-
+
void runAction(() => resumeModelInstall(job.id), 'Resume failed')}
+ onClick={() => void runAction(() => resumeModelInstall(job.id), t('models.resumeFailed'))}
>
) : null}
{job.status === 'error' ? (
-
+
void runAction(() => restartFailedModelInstall(job.id), 'Retry failed')}
+ onClick={() => void runAction(() => restartFailedModelInstall(job.id), t('models.retryFailed'))}
>
) : null}
{isActiveInstallStatus(job.status) || job.status === 'paused' ? (
-
+
void runAction(() => cancelModelInstall(job.id), 'Cancel failed')}
+ onClick={() => void runAction(() => cancelModelInstall(job.id), t('models.cancelFailed'))}
>
@@ -164,17 +166,17 @@ export const InstallJobRow = ({
size="sm"
variant="surface"
>
- {isResume ? 'Resume required' : 'Failed'}
+ {isResume ? t('models.resumeRequired') : t('common.failed')}
{partUrl ? (
-
+
- void runAction(() => restartModelInstallFile(job.id, partUrl), 'Restart file failed')
+ void runAction(() => restartModelInstallFile(job.id, partUrl), t('models.restartFileFailed'))
}
>
diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/models/install-queue/InstallQueueBar.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/models/install-queue/InstallQueueBar.tsx
index f7968cd6b93..5300a565535 100644
--- a/invokeai/frontend/webv2/src/workbench/launchpad/models/install-queue/InstallQueueBar.tsx
+++ b/invokeai/frontend/webv2/src/workbench/launchpad/models/install-queue/InstallQueueBar.tsx
@@ -18,6 +18,7 @@ import { setQueueExpanded, useModelsUiSelector } from '@workbench/models/uiStore
import { useNotify } from '@workbench/useNotify';
import { ChevronUpIcon, ListOrderedIcon, PauseIcon, PlayIcon, RefreshCcwIcon, Trash2Icon, XIcon } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
import { InstallQueueList } from './InstallQueueList';
import { getInstallJobDisplayName } from './queueUtils';
@@ -27,6 +28,7 @@ const INDICATOR_OPEN = { transform: 'rotate(180deg)' } as const;
/** Persistent, collapsible install queue footer for the model manager detail pane. */
export const InstallQueueBar = () => {
+ const { t } = useTranslation();
const notify = useNotify();
const error = useInstallsSelector((snapshot) => snapshot.error);
const jobs = useInstallsSelector((snapshot) => snapshot.jobs);
@@ -59,13 +61,13 @@ export const InstallQueueBar = () => {
await Promise.all(targets.map((job) => call(job.id)));
await refreshInstalls();
} catch (bulkError) {
- notify.error('Queue action failed', bulkError instanceof Error ? bulkError.message : String(bulkError));
+ notify.error(t('models.queueActionFailed'), bulkError instanceof Error ? bulkError.message : String(bulkError));
void refreshInstalls();
} finally {
setBusyAction(null);
}
},
- [notify]
+ [notify, t]
);
const handlePrune = useCallback(async () => {
@@ -75,11 +77,11 @@ export const InstallQueueBar = () => {
await pruneCompletedModelInstalls();
await refreshInstalls();
} catch (pruneError) {
- notify.error('Prune failed', pruneError instanceof Error ? pruneError.message : String(pruneError));
+ notify.error(t('models.pruneFailed'), pruneError instanceof Error ? pruneError.message : String(pruneError));
} finally {
setBusyAction(null);
}
- }, [notify]);
+ }, [notify, t]);
const handleRefresh = useCallback(async () => {
setBusyAction('refresh');
@@ -97,10 +99,10 @@ export const InstallQueueBar = () => {
const summary =
activeJobs.length > 0
- ? `${getInstallJobDisplayName(activeJobs[0]!)}${activeJobs.length > 1 ? ` +${activeJobs.length - 1} more` : ''}`
+ ? `${getInstallJobDisplayName(activeJobs[0]!)}${activeJobs.length > 1 ? t('models.plusMore', { count: activeJobs.length - 1 }) : ''}`
: jobs.length > 0
- ? `${jobs.length} job${jobs.length === 1 ? '' : 's'} · no active installs`
- : 'No installs yet';
+ ? t('models.installJobSummary', { count: jobs.length })
+ : t('models.noInstallsYet');
return (
{
- Install Queue
+ {t('models.installQueue')}
@@ -173,9 +175,9 @@ export const InstallQueueBar = () => {
{pausableJobs.length > 0 ? (
-
+
{
) : null}
{pausedJobs.length > 0 ? (
-
+
{
) : null}
{cancellableJobs.length > 0 ? (
-
+
{
+ const { t } = useTranslation();
const notify = useNotify();
if (status === 'loading' || status === 'idle') {
@@ -31,13 +33,13 @@ export const InstallQueueList = ({
return (
- Could not load the install queue
+ {t('models.couldNotLoadInstallQueue')}
{error}
);
@@ -47,17 +49,17 @@ export const InstallQueueList = ({
return (
- No installs yet
+ {t('models.noInstallsYet')}
- Models you install will download here.
+ {t('models.noInstallsDescription')}
);
}
return (
-
+
{jobs.map((job) => (
diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/models/library/MaintenanceMenu.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/models/library/MaintenanceMenu.tsx
index d3f5159567f..05269aefe11 100644
--- a/invokeai/frontend/webv2/src/workbench/launchpad/models/library/MaintenanceMenu.tsx
+++ b/invokeai/frontend/webv2/src/workbench/launchpad/models/library/MaintenanceMenu.tsx
@@ -9,21 +9,23 @@ import { formatBytes } from '@workbench/models/taxonomy';
import { useNotify } from '@workbench/useNotify';
import { BrushCleaningIcon, FolderSearchIcon, MoreHorizontalIcon, RefreshCcwIcon } from 'lucide-react';
import { useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
/**
* Library maintenance: refresh, clean up orphaned model folders (files on disk
* with no database record), and empty the in-memory model cache.
*/
export const MaintenanceMenu = () => {
+ const { t } = useTranslation();
const notify = useNotify();
const [isSyncDialogOpen, setIsSyncDialogOpen] = useState(false);
const handleEmptyCache = async () => {
try {
await emptyModelCache();
- notify.success('Model cache emptied');
+ notify.success(t('models.cacheEmptied'));
} catch (error) {
- notify.error('Failed to empty model cache', error instanceof Error ? error.message : String(error));
+ notify.error(t('models.failedToEmptyCache'), error instanceof Error ? error.message : String(error));
}
};
@@ -31,7 +33,7 @@ export const MaintenanceMenu = () => {
<>
-
+
@@ -40,15 +42,15 @@ export const MaintenanceMenu = () => {
void refreshModels()}>
- Refresh model list
+ {t('models.refreshList')}
setIsSyncDialogOpen(true)}>
- Clean up orphaned models…
+ {t('models.cleanupOrphaned')}
void handleEmptyCache()}>
- Empty model cache
+ {t('models.emptyCache')}
@@ -60,6 +62,7 @@ export const MaintenanceMenu = () => {
};
const OrphanedModelsDialog = ({ onClose }: { onClose: () => void }) => {
+ const { t } = useTranslation();
const notify = useNotify();
const [orphans, setOrphans] = useState(null);
const [loadError, setLoadError] = useState(null);
@@ -78,14 +81,14 @@ const OrphanedModelsDialog = ({ onClose }: { onClose: () => void }) => {
})
.catch((error: unknown) => {
if (!isStale) {
- setLoadError(error instanceof Error ? error.message : 'Failed to scan for orphaned models.');
+ setLoadError(error instanceof Error ? error.message : t('models.failedToScanOrphaned'));
}
});
return () => {
isStale = true;
};
- }, []);
+ }, [t]);
const togglePath = (path: string) => {
setSelectedPaths((current) => {
@@ -110,17 +113,24 @@ const OrphanedModelsDialog = ({ onClose }: { onClose: () => void }) => {
if (errorCount > 0) {
notify.error(
- 'Orphaned model cleanup',
- `${result.deleted.length} deleted, ${errorCount} failed: ${Object.values(result.errors)[0]}`
+ t('models.orphanedCleanup'),
+ t('models.orphanedCleanupPartialDescription', {
+ deleted: result.deleted.length,
+ error: Object.values(result.errors)[0],
+ failed: errorCount,
+ })
);
} else {
- notify.success('Orphaned model cleanup', `${result.deleted.length} orphaned model folder(s) deleted.`);
+ notify.success(
+ t('models.orphanedCleanup'),
+ t('models.orphanedDeletedDescription', { count: result.deleted.length })
+ );
}
void refreshModels();
onClose();
} catch (error) {
- notify.error('Orphaned model cleanup failed', error instanceof Error ? error.message : String(error));
+ notify.error(t('models.orphanedCleanupFailed'), error instanceof Error ? error.message : String(error));
} finally {
setIsDeleting(false);
}
@@ -149,11 +159,10 @@ const OrphanedModelsDialog = ({ onClose }: { onClose: () => void }) => {
- Orphaned Models
+ {t('models.orphanedTitle')}
- Folders in the models directory with no database record — usually leftovers from failed installs or
- external deletes.
+ {t('models.orphanedDescription')}
@@ -168,7 +177,7 @@ const OrphanedModelsDialog = ({ onClose }: { onClose: () => void }) => {
) : orphans.length === 0 ? (
- No orphaned model folders found — your library is clean.
+ {t('models.noOrphaned')}
) : (
@@ -190,7 +199,7 @@ const OrphanedModelsDialog = ({ onClose }: { onClose: () => void }) => {
- Select all ({orphans.length})
+ {t('models.selectAllCount', { count: orphans.length })}
{orphans.map((orphan) => (
@@ -209,8 +218,7 @@ const OrphanedModelsDialog = ({ onClose }: { onClose: () => void }) => {
{orphan.path}
- {orphan.files.length} file{orphan.files.length === 1 ? '' : 's'} ·{' '}
- {formatBytes(orphan.size_bytes)}
+ {t('models.fileCount', { count: orphan.files.length })} · {formatBytes(orphan.size_bytes)}
@@ -220,7 +228,7 @@ const OrphanedModelsDialog = ({ onClose }: { onClose: () => void }) => {
diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/models/library/ModelFilterBar.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/models/library/ModelFilterBar.tsx
index c7bbd5434da..61927e724d8 100644
--- a/invokeai/frontend/webv2/src/workbench/launchpad/models/library/ModelFilterBar.tsx
+++ b/invokeai/frontend/webv2/src/workbench/launchpad/models/library/ModelFilterBar.tsx
@@ -6,13 +6,14 @@ import { HStack, Icon, Input, InputGroup } from '@chakra-ui/react';
import { FilterMenuItem, ModelFilterMenu } from '@workbench/launchpad/models/shared/ModelFilterMenu';
import { SearchIcon } from 'lucide-react';
import { useCallback, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
-const SORT_FIELDS: { field: ModelSortField; label: string }[] = [
- { field: 'default', label: 'Default' },
- { field: 'name', label: 'Name' },
- { field: 'base', label: 'Base' },
- { field: 'size', label: 'Size' },
- { field: 'format', label: 'Format' },
+const SORT_FIELDS: { field: ModelSortField; labelKey: string }[] = [
+ { field: 'default', labelKey: 'models.sort.default' },
+ { field: 'name', labelKey: 'models.sort.name' },
+ { field: 'base', labelKey: 'models.sort.base' },
+ { field: 'size', labelKey: 'models.sort.size' },
+ { field: 'format', labelKey: 'models.sort.format' },
];
const isFiltering = (filters: ModelLibraryFilters): boolean =>
@@ -35,6 +36,7 @@ export const ModelFilterBar = ({
missingCount: number;
onChange: (filters: ModelLibraryFilters) => void;
}) => {
+ const { t } = useTranslation();
const handleSearchChange = useCallback(
(searchTerm: string) => onChange({ ...filters, searchTerm }),
[filters, onChange]
@@ -60,27 +62,26 @@ export const ModelFilterBar = ({
missingCount > 0 ? (
) : null,
- [filters.missingOnly, handleMissingFilterToggle, missingCount]
+ [filters.missingOnly, handleMissingFilterToggle, missingCount, t]
);
-
return (
}>
handleSearchChange(event.currentTarget.value)}
/>
void;
selectedKeys?: ReadonlySet;
}) => {
+ const { t } = useTranslation();
const { coverImageVersions, error, missingModelKeys, models, status } = useModelsSelector(
(snapshot) => ({
coverImageVersions: snapshot.coverImageVersions,
@@ -116,20 +118,20 @@ export const ModelLibraryList = ({
}
if (status === 'error') {
- return } danger />;
+ return } danger />;
}
if (rows.length === 0) {
return (
}
>
@@ -146,7 +148,7 @@ export const ModelLibraryList = ({
return (
-
+
{virtualizer.virtualItems.map((virtualRow) => {
diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/models/library/ModelRowContextMenu.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/models/library/ModelRowContextMenu.tsx
index e8fd834c6cc..d559d832dd1 100644
--- a/invokeai/frontend/webv2/src/workbench/launchpad/models/library/ModelRowContextMenu.tsx
+++ b/invokeai/frontend/webv2/src/workbench/launchpad/models/library/ModelRowContextMenu.tsx
@@ -8,6 +8,7 @@ import { isConvertibleToDiffusers } from '@workbench/models/baseIdentity';
import { useModelsSelector } from '@workbench/models/modelsStore';
import { RefreshCcwIcon, Trash2Icon } from 'lucide-react';
import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
import { SiHuggingface } from 'react-icons/si';
export interface ModelContextMenuTarget {
@@ -28,6 +29,7 @@ export const ModelRowContextMenu = ({
onClose: () => void;
target: ModelContextMenuTarget | null;
}) => {
+ const { t } = useTranslation();
const { convert, reidentify, remove } = useModelActions();
const [pendingConfirm, setPendingConfirm] = useState<{ kind: 'delete' | 'convert'; model: ModelConfig } | null>(null);
const model = useModelsSelector((snapshot) =>
@@ -57,18 +59,18 @@ export const ModelRowContextMenu = ({
void reidentify(model)}>
- Re-identify model
+ {t('models.reidentify')}
{isConvertibleToDiffusers(model) ? (
setPendingConfirm({ kind: 'convert', model })}>
- Convert to diffusers
+ {t('models.convertToDiffusers')}
) : null}
setPendingConfirm({ kind: 'delete', model })}>
- Delete model
+ {t('models.deleteModel')}
) : null}
@@ -78,12 +80,12 @@ export const ModelRowContextMenu = ({
setPendingConfirm(null)}
onConfirm={async () => {
if (!pendingConfirm) {
diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/models/manager/DetailPane.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/models/manager/DetailPane.tsx
index 2bc4dff28ff..f6e56c08d44 100644
--- a/invokeai/frontend/webv2/src/workbench/launchpad/models/manager/DetailPane.tsx
+++ b/invokeai/frontend/webv2/src/workbench/launchpad/models/manager/DetailPane.tsx
@@ -9,17 +9,19 @@ import { useModelsSelector } from '@workbench/models/modelsStore';
import { updateModelsUi, useModelsUiSelector, type ModelManagerTab } from '@workbench/models/uiStore';
import { BoxIcon, KeyRoundIcon, PlusIcon } from 'lucide-react';
import { useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
import { HEADER_MIN_HEIGHT } from './layoutConstants';
/** The tabbed detail pane: selected model, Add Models, API Keys, and queue footer. */
export const DetailPane = () => {
+ const { t } = useTranslation();
const { activeModelKey, activeTab } = useModelsUiSelector(
(snapshot) => ({ activeModelKey: snapshot.activeModelKey, activeTab: snapshot.activeTab }),
(left, right) => left.activeModelKey === right.activeModelKey && left.activeTab === right.activeTab
);
const detailLabel = useModelsSelector(
- (snapshot) => snapshot.models.find((model) => model.key === activeModelKey)?.name ?? 'Model Details'
+ (snapshot) => snapshot.models.find((model) => model.key === activeModelKey)?.name ?? t('models.details')
);
return (
@@ -40,11 +42,11 @@ export const DetailPane = () => {
- Add Models
+ {t('models.addModels')}
- API Keys
+ {t('models.apiKeys')}
@@ -54,7 +56,7 @@ export const DetailPane = () => {
{activeTab === 'details' ? : null}
{activeTab === 'add' ? : null}
{activeTab === 'keys' ? (
-
+
) : null}
@@ -66,6 +68,7 @@ export const DetailPane = () => {
};
const DetailTab = ({ modelKey }: { modelKey: string | null }) => {
+ const { t } = useTranslation();
const handleDeleted = useCallback(() => updateModelsUi({ activeModelKey: null }), []);
if (modelKey === null) {
@@ -73,18 +76,17 @@ const DetailTab = ({ modelKey }: { modelKey: string | null }) => {
- Select a model
+ {t('models.selectModel')}
- Pick a model from the library to view details, edit metadata, set per-model defaults, and manage trigger
- phrases.
+ {t('models.selectModelDescription')}
);
}
return (
-
+
);
diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/models/manager/LibraryColumn.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/models/manager/LibraryColumn.tsx
index 5021334a92f..fd9f3763651 100644
--- a/invokeai/frontend/webv2/src/workbench/launchpad/models/manager/LibraryColumn.tsx
+++ b/invokeai/frontend/webv2/src/workbench/launchpad/models/manager/LibraryColumn.tsx
@@ -17,11 +17,13 @@ import {
import { useNotify } from '@workbench/useNotify';
import { Trash2Icon, XIcon } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
import { HEADER_MIN_HEIGHT, LIBRARY_WIDTH } from './layoutConstants';
/** The persistent master list: header, search/filter bar, and bulk actions. */
export const LibraryColumn = () => {
+ const { t } = useTranslation();
const notify = useNotify();
const models = useModelsSelector((snapshot) => snapshot.models);
const missingCount = useModelsSelector((snapshot) => snapshot.missingModelKeys.size);
@@ -55,17 +57,18 @@ export const LibraryColumn = () => {
if (result.failed.length > 0) {
notify.error(
- 'Some models could not be deleted',
- `${result.deleted.length} deleted; ${result.failed.length} failed: ${result.failed[0]?.error ?? ''}`
+ t('models.someCouldNotBeDeleted'),
+ t('models.bulkDeletePartialDescription', {
+ deleted: result.deleted.length,
+ error: result.failed[0]?.error ?? '',
+ failed: result.failed.length,
+ })
);
} else {
- notify.success(
- 'Models deleted',
- `${result.deleted.length} model${result.deleted.length === 1 ? '' : 's'} deleted.`
- );
+ notify.success(t('models.deleted'), t('models.deletedDescription', { count: result.deleted.length }));
}
} catch (error) {
- notify.error('Bulk delete failed', error instanceof Error ? error.message : String(error));
+ notify.error(t('models.bulkDeleteFailed'), error instanceof Error ? error.message : String(error));
void refreshModels();
}
};
@@ -74,7 +77,7 @@ export const LibraryColumn = () => {
- Models
+ {t('models.title')}
{models.length}
@@ -126,16 +129,16 @@ export const LibraryColumn = () => {
py="1"
rounded="md"
>
- {selectedKeys.size} selected
+ {t('models.selectedCount', { count: selectedKeys.size })}
updateModelsUi({ selectedKeys: new Set() })}
@@ -146,10 +149,10 @@ export const LibraryColumn = () => {
) : null}
setIsBulkDeleteOpen(false)}
onConfirm={handleBulkDelete}
/>
diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/models/shared/ModelFilterMenu.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/models/shared/ModelFilterMenu.tsx
index cb9a85b1bb9..0ba6de199c9 100644
--- a/invokeai/frontend/webv2/src/workbench/launchpad/models/shared/ModelFilterMenu.tsx
+++ b/invokeai/frontend/webv2/src/workbench/launchpad/models/shared/ModelFilterMenu.tsx
@@ -9,18 +9,19 @@ import { getModelBaseLabel } from '@workbench/models/baseIdentity';
import { getModelTypeLabel } from '@workbench/models/taxonomy';
import { CheckIcon, SlidersHorizontalIcon } from 'lucide-react';
import { memo } from 'react';
+import { useTranslation } from 'react-i18next';
export interface ModelFilterSortOption {
field: ModelSortField;
- label: string;
+ labelKey: string;
}
const DEFAULT_SORT_FIELDS: ModelFilterSortOption[] = [
- { field: 'default', label: 'Default' },
- { field: 'name', label: 'Name' },
- { field: 'base', label: 'Base' },
- { field: 'size', label: 'Size' },
- { field: 'format', label: 'Format' },
+ { field: 'default', labelKey: 'models.sortDefault' },
+ { field: 'name', labelKey: 'models.sortName' },
+ { field: 'base', labelKey: 'models.sortBase' },
+ { field: 'size', labelKey: 'models.sortSize' },
+ { field: 'format', labelKey: 'models.sortFormat' },
];
/** Shared taxonomy filter + sort menu for installed and starter model lists. */
@@ -38,7 +39,7 @@ export const ModelFilterMenu = ({
sortField,
sortFields = DEFAULT_SORT_FIELDS,
typeAllChecked,
- typeAllLabel = 'All models',
+ typeAllLabel,
typeFilter,
}: {
ariaLabel: string;
@@ -56,71 +57,76 @@ export const ModelFilterMenu = ({
typeAllChecked?: boolean;
typeAllLabel?: string;
typeFilter: ModelTaxonomyType | null;
-}) => (
-
-
-
-
-
-
-
-
-
-
-
- Model Type
-
-
- {extraTypeItems}
- {availableTypes.map((type) => (
- {
+ const { t } = useTranslation();
+ const resolvedTypeAllLabel = typeAllLabel ?? t('models.allModels');
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {t('models.modelType')}
+
+
- ))}
-
-
-
-
- Base Architecture
-
-
- {availableBases.map((base) => (
-
- ))}
-
-
-
-
- Sort By
-
- {sortFields.map(({ field, label }) => (
-
- ))}
-
-
-
-
-
-);
+ {extraTypeItems}
+ {availableTypes.map((type) => (
+
+ ))}
+
+
+
+
+ {t('models.baseArchitecture')}
+
+
+ {availableBases.map((base) => (
+
+ ))}
+
+
+
+
+ {t('models.sortBy')}
+
+ {sortFields.map(({ field, labelKey }) => (
+
+ ))}
+
+
+
+
+
+ );
+};
/** Checkmark-style menu item shared by the model filter menus. */
interface FilterMenuItemProps {
@@ -191,10 +197,12 @@ const AllBasesFilterMenuItem = memo(function AllBasesFilterMenuItem({
isChecked: boolean;
onBaseFilterChange: (base: string | null) => void;
}) {
+ const { t } = useTranslation();
+
return (
onBaseFilterChange(null)}
/>
@@ -222,24 +230,26 @@ const BaseFilterMenuItem = memo(function BaseFilterMenuItem({
const SortFilterMenuItem = memo(function SortFilterMenuItem({
field,
- label,
+ labelKey,
sortDirection,
sortField,
onSortChange,
}: {
field: ModelSortField;
- label: string;
+ labelKey: string;
sortDirection: 'asc' | 'desc';
sortField: ModelSortField;
onSortChange: (field: ModelSortField, direction: 'asc' | 'desc') => void;
}) {
+ const { t } = useTranslation();
+
return (
onSortChange(field, sortField === field ? (sortDirection === 'asc' ? 'desc' : 'asc') : 'asc')}
- trailing={sortField === field ? (sortDirection === 'asc' ? 'Asc' : 'Desc') : undefined}
+ trailing={sortField === field ? (sortDirection === 'asc' ? t('common.asc') : t('common.desc')) : undefined}
/>
);
});
diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/models/shared/ResultsListHeader.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/models/shared/ResultsListHeader.tsx
index 5d108b71eac..70533a75678 100644
--- a/invokeai/frontend/webv2/src/workbench/launchpad/models/shared/ResultsListHeader.tsx
+++ b/invokeai/frontend/webv2/src/workbench/launchpad/models/shared/ResultsListHeader.tsx
@@ -2,6 +2,7 @@ import { HStack, Icon, Input, InputGroup, Stack, Text } from '@chakra-ui/react';
import { Button, IconButton } from '@workbench/components/ui';
import { DownloadIcon, SearchIcon, XIcon } from 'lucide-react';
import { useCallback, type ChangeEvent, type ReactNode } from 'react';
+import { useTranslation } from 'react-i18next';
const SEARCH_ICON = ;
@@ -14,11 +15,11 @@ const SEARCH_ICON = ;
export const ResultsListHeader = ({
extra,
installAllDisabled,
- installAllLabel = 'Install all',
+ installAllLabel,
onClear,
onInstallAll,
onSearchChange,
- searchPlaceholder = 'Filter results',
+ searchPlaceholder,
searchValue,
summary,
}: {
@@ -32,6 +33,9 @@ export const ResultsListHeader = ({
searchValue: string;
summary: ReactNode;
}) => {
+ const { t } = useTranslation();
+ const resolvedInstallAllLabel = installAllLabel ?? t('models.installAll');
+ const resolvedSearchPlaceholder = searchPlaceholder ?? t('models.filterResults');
const handleSearchChange = useCallback(
(event: ChangeEvent) => onSearchChange(event.currentTarget.value),
[onSearchChange]
@@ -45,7 +49,7 @@ export const ResultsListHeader = ({
{extra}
-
+
@@ -53,8 +57,8 @@ export const ResultsListHeader = ({
diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/nodes/activity/NodeActivityBar.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/nodes/activity/NodeActivityBar.tsx
index ebe8e42e1ff..5abbab32d23 100644
--- a/invokeai/frontend/webv2/src/workbench/launchpad/nodes/activity/NodeActivityBar.tsx
+++ b/invokeai/frontend/webv2/src/workbench/launchpad/nodes/activity/NodeActivityBar.tsx
@@ -4,22 +4,24 @@ import { Button } from '@workbench/components/ui';
import { clearCustomNodeInstallLog, useCustomNodeInstallLog } from '@workbench/customNodes/installLogStore';
import { setNodeActivityExpanded, useNodesUiSelector } from '@workbench/customNodes/nodesUiStore';
import { ChevronUpIcon, ListOrderedIcon, Trash2Icon } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
import { NodeInstallLog } from './NodeInstallLog';
/** Persistent, collapsible install activity footer for the nodes manager detail pane. */
export const NodeActivityBar = () => {
+ const { t } = useTranslation();
const activityExpanded = useNodesUiSelector((snapshot) => snapshot.activityExpanded);
const log = useCustomNodeInstallLog();
const installingCount = log.filter((entry) => entry.status === 'installing').length;
const summary =
installingCount > 0
- ? `${log.find((entry) => entry.status === 'installing')?.name ?? 'Installing'}${
- installingCount > 1 ? ` +${installingCount - 1} more` : ''
+ ? `${log.find((entry) => entry.status === 'installing')?.name ?? t('nodes.installing')}${
+ installingCount > 1 ? t('models.plusMore', { count: installingCount - 1 }) : ''
}`
: log.length > 0
- ? `${log.length} recent activit${log.length === 1 ? 'y' : 'ies'}`
- : 'No recent activity';
+ ? t('nodes.recentActivitySummary', { count: log.length })
+ : t('nodes.noRecentActivity');
return (
{
- Install Activity
+ {t('nodes.installActivity')}
diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/nodes/activity/NodeInstallLog.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/nodes/activity/NodeInstallLog.tsx
index fe5b2aa9b66..3153d4f5b0f 100644
--- a/invokeai/frontend/webv2/src/workbench/launchpad/nodes/activity/NodeInstallLog.tsx
+++ b/invokeai/frontend/webv2/src/workbench/launchpad/nodes/activity/NodeInstallLog.tsx
@@ -7,17 +7,18 @@ import { Scrollable } from '@workbench/components/ui';
import { EmptyState } from '@workbench/components/ui/EmptyState';
import { useCustomNodeInstallLog } from '@workbench/customNodes/installLogStore';
import { InboxIcon } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
const INSTALL_LOG_TABLE_SX: SystemStyleObject = {
'& tbody tr:nth-of-type(odd)': { bg: 'bg.muted' },
'& th': { bg: 'bg.subtle', position: 'sticky', top: 0, zIndex: 1 },
};
-const STATUS_BADGES: Record = {
- completed: { label: 'Completed', palette: 'green' },
- error: { label: 'Error', palette: 'red' },
- installing: { label: 'Installing', palette: 'blue' },
- uninstalled: { label: 'Uninstalled', palette: 'orange' },
+const STATUS_BADGES: Record = {
+ completed: { labelKey: 'common.status.completed', palette: 'green' },
+ error: { labelKey: 'common.error', palette: 'red' },
+ installing: { labelKey: 'nodes.installing', palette: 'blue' },
+ uninstalled: { labelKey: 'nodes.uninstalled', palette: 'orange' },
};
/**
@@ -25,29 +26,30 @@ const STATUS_BADGES: Record {
+ const { t } = useTranslation();
const log = useCustomNodeInstallLog();
if (log.length === 0) {
return (
}
- title="No recent activity"
+ title={t('nodes.noRecentActivity')}
/>
);
}
return (
-
+
- Name
+ {t('common.name')}
- Status
+ {t('nodes.status')}
- Message
+ {t('nodes.message')}
@@ -62,6 +64,7 @@ export const NodeInstallLog = () => {
};
const InstallLogRow = ({ entry }: { entry: CustomNodeInstallLogEntry }) => {
+ const { t } = useTranslation();
const badge = STATUS_BADGES[entry.status];
return (
@@ -73,7 +76,7 @@ const InstallLogRow = ({ entry }: { entry: CustomNodeInstallLogEntry }) => {
- {badge.label}
+ {t(badge.labelKey)}
diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/nodes/add-nodes/AddNodesView.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/nodes/add-nodes/AddNodesView.tsx
index 02f44d1e83d..b032bfd1880 100644
--- a/invokeai/frontend/webv2/src/workbench/launchpad/nodes/add-nodes/AddNodesView.tsx
+++ b/invokeai/frontend/webv2/src/workbench/launchpad/nodes/add-nodes/AddNodesView.tsx
@@ -7,6 +7,7 @@ import { refreshCustomNodePacks, useCustomNodesSelector } from '@workbench/custo
import { updateNodesUi, useNodesUiSelector, type AddNodesTab } from '@workbench/customNodes/nodesUiStore';
import { FolderOpenIcon, GitBranchIcon } from 'lucide-react';
import { useCallback, useState, type ChangeEvent, type KeyboardEvent } from 'react';
+import { useTranslation } from 'react-i18next';
/**
* Every way to add custom nodes, one sub-tab per source — Git URL install and
@@ -14,6 +15,7 @@ import { useCallback, useState, type ChangeEvent, type KeyboardEvent } from 'rea
* active sub-tab lives in the nodes UI store so it survives navigation.
*/
export const AddNodesView = () => {
+ const { t } = useTranslation();
const addTab = useNodesUiSelector((snapshot) => snapshot.addTab);
const customNodesPath = useCustomNodesSelector((snapshot) => snapshot.customNodesPath);
const handleValueChange = useCallback(
@@ -27,15 +29,15 @@ export const AddNodesView = () => {
- Git URL
+ {t('nodes.gitUrl')}
- Scan Folder
+ {t('nodes.scanFolder')}
-
+
{addTab === 'git' ? : }
@@ -43,6 +45,7 @@ export const AddNodesView = () => {
};
const InstallFromGitForm = () => {
+ const { t } = useTranslation();
const [source, setSource] = useState('');
const [isInstalling, setIsInstalling] = useState(false);
const trimmedSource = source.trim();
@@ -65,8 +68,11 @@ const InstallFromGitForm = () => {
if (result.requires_dependencies) {
toaster.create({
- description: `${result.name} requires dependencies from ${result.dependency_file ?? 'requirements.txt'}. Install them, then restart InvokeAI.`,
- title: 'Dependencies required',
+ description: t('nodes.dependenciesRequiredDescription', {
+ dependencyFile: result.dependency_file ?? 'requirements.txt',
+ name: result.name,
+ }),
+ title: t('nodes.dependenciesRequired'),
type: 'warning',
});
}
@@ -75,14 +81,14 @@ const InstallFromGitForm = () => {
}
} catch (error) {
addCustomNodeInstallLogEntry({
- message: getApiErrorMessage(error, 'Install failed.'),
+ message: getApiErrorMessage(error, t('nodes.installFailed')),
name: trimmedSource,
status: 'error',
});
} finally {
setIsInstalling(false);
}
- }, [trimmedSource]);
+ }, [t, trimmedSource]);
const handleSourceChange = useCallback(
(event: ChangeEvent) => setSource(event.currentTarget.value),
[]
@@ -102,14 +108,9 @@ const InstallFromGitForm = () => {
-
- Custom nodes execute Python code. Only install packs from authors you trust.
-
+ {t('nodes.trustWarning')}
-
+
{
onKeyDown={handleKeyDown}
/>
@@ -127,21 +128,24 @@ const InstallFromGitForm = () => {
);
};
-const ScanFolderInfo = ({ customNodesPath }: { customNodesPath: string | null }) => (
-
-
- Add node packs directly to the custom nodes directory, then reload nodes. InvokeAI scans this folder on startup
- and reload.
-
- {customNodesPath ? (
-
-
- Nodes Directory
-
-
- {customNodesPath}
-
-
- ) : null}
-
-);
+const ScanFolderInfo = ({ customNodesPath }: { customNodesPath: string | null }) => {
+ const { t } = useTranslation();
+
+ return (
+
+
+ {t('nodes.scanFolderDescription')}
+
+ {customNodesPath ? (
+
+
+ {t('nodes.nodesDirectory')}
+
+
+ {customNodesPath}
+
+
+ ) : null}
+
+ );
+};
diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/nodes/detail/NodePackDetail.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/nodes/detail/NodePackDetail.tsx
index 67d4ec275e5..eb6e02d6986 100644
--- a/invokeai/frontend/webv2/src/workbench/launchpad/nodes/detail/NodePackDetail.tsx
+++ b/invokeai/frontend/webv2/src/workbench/launchpad/nodes/detail/NodePackDetail.tsx
@@ -8,6 +8,7 @@ import { useNodePackActions } from '@workbench/launchpad/nodes/shared/useNodePac
import { ensureInvocationTemplatesLoaded, useInvocationTemplatesSelector } from '@workbench/workflows/templates';
import { BlocksIcon, TriangleAlertIcon, Trash2Icon } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
import { NodePreviewCard } from './NodePreviewCard';
@@ -19,6 +20,7 @@ import { NodePreviewCard } from './NodePreviewCard';
* share `template.nodePack === pack.name`.
*/
export const NodePackDetail = ({ onUninstalled, pack }: { onUninstalled: () => void; pack: NodePackInfo }) => {
+ const { t } = useTranslation();
const status = useInvocationTemplatesSelector((snapshot) => snapshot.status);
const templates = useInvocationTemplatesSelector((snapshot) => snapshot.templates);
@@ -48,7 +50,7 @@ export const NodePackDetail = ({ onUninstalled, pack }: { onUninstalled: () => v
- {pack.node_count} node{pack.node_count === 1 ? '' : 's'}
+ {t('nodes.nodeCount', { count: pack.node_count })}
@@ -57,7 +59,7 @@ export const NodePackDetail = ({ onUninstalled, pack }: { onUninstalled: () => v
- Nodes in this pack
+ {t('nodes.nodesInPack')}
{isLoadingTemplates ? (
@@ -65,9 +67,9 @@ export const NodePackDetail = ({ onUninstalled, pack }: { onUninstalled: () => v
) : packTemplates.length === 0 ? (
}
- title="No node previews available"
+ title={t('nodes.noPreviews')}
/>
) : (
@@ -84,6 +86,7 @@ export const NodePackDetail = ({ onUninstalled, pack }: { onUninstalled: () => v
};
const UninstallButton = ({ onUninstalled, pack }: { onUninstalled: () => void; pack: NodePackInfo }) => {
+ const { t } = useTranslation();
const { uninstall } = useNodePackActions();
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
const [isUninstalling, setIsUninstalling] = useState(false);
@@ -109,13 +112,13 @@ const UninstallButton = ({ onUninstalled, pack }: { onUninstalled: () => void; p
onClick={() => setIsConfirmOpen(true)}
>
- Uninstall
+ {t('nodes.uninstall')}
setIsConfirmOpen(false)}
onConfirm={handleUninstall}
/>
diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/nodes/detail/NodePreviewCard.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/nodes/detail/NodePreviewCard.tsx
index 6243e8e75b7..63317ca02b4 100644
--- a/invokeai/frontend/webv2/src/workbench/launchpad/nodes/detail/NodePreviewCard.tsx
+++ b/invokeai/frontend/webv2/src/workbench/launchpad/nodes/detail/NodePreviewCard.tsx
@@ -11,6 +11,7 @@ import { Tooltip } from '@workbench/components/ui';
import { getWorkflowNodeChromeProps } from '@workbench/widgets/workflow/editor/nodeChrome';
import { getFieldTypeColor, getFieldTypeLabel, isModelFieldType } from '@workbench/workflows/fields';
import { InfoIcon } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
/**
* A read-only, static rendering of a single node — the same visual language as
@@ -53,22 +54,26 @@ const FieldTooltip = ({
template,
}: {
description: string;
- direction: 'Input' | 'Output';
+ direction: 'input' | 'output';
template: { name: string; title: string; type: FieldType };
-}) => (
-
- {template.title}
- Field: {template.name}
- Type: {getFieldTypeLabel(template.type)}
- {direction}
- {description ? {description} : null}
-
-);
+}) => {
+ const { t } = useTranslation();
+
+ return (
+
+ {template.title}
+ {t('nodes.fieldName', { name: template.name })}
+ {t('nodes.fieldType', { type: getFieldTypeLabel(template.type) })}
+ {t(direction === 'input' ? 'nodes.input' : 'nodes.output')}
+ {description ? {description} : null}
+
+ );
+};
const OutputRow = ({ template }: { template: FieldOutputTemplate }) => (
}
+ content={}
positioning={{ placement: 'top-end' }}
>
@@ -84,7 +89,7 @@ const OutputRow = ({ template }: { template: FieldOutputTemplate }) => (
const InputRow = ({ template }: { template: FieldInputTemplate }) => (
}
+ content={}
positioning={{ placement: 'top-start' }}
>
@@ -100,15 +105,19 @@ const InputRow = ({ template }: { template: FieldInputTemplate }) => (
);
-const NodeInfoTooltip = ({ template }: { template: InvocationTemplate }) => (
-
- {template.title}
- Type: {template.type}
- Category: {template.category}
- Version: {template.version}
- {template.description ? {template.description} : null}
-
-);
+const NodeInfoTooltip = ({ template }: { template: InvocationTemplate }) => {
+ const { t } = useTranslation();
+
+ return (
+
+ {template.title}
+ {t('nodes.nodeType', { type: template.type })}
+ {t('nodes.nodeCategory', { category: template.category })}
+ {t('nodes.nodeVersion', { version: template.version })}
+ {template.description ? {template.description} : null}
+
+ );
+};
export const NodePreviewCard = ({ template }: { template: InvocationTemplate }) => {
const inputTemplates = sortByUiOrder(Object.values(template.inputs).filter((input) => !input.uiHidden));
diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/nodes/library/NodePackContextMenu.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/nodes/library/NodePackContextMenu.tsx
index 1ed7dc23066..5e649583bc1 100644
--- a/invokeai/frontend/webv2/src/workbench/launchpad/nodes/library/NodePackContextMenu.tsx
+++ b/invokeai/frontend/webv2/src/workbench/launchpad/nodes/library/NodePackContextMenu.tsx
@@ -6,6 +6,7 @@ import { ConfirmDialog, MenuContent } from '@workbench/components/ui';
import { useNodePackActions } from '@workbench/launchpad/nodes/shared/useNodePackActions';
import { Trash2Icon } from 'lucide-react';
import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
export interface NodePackContextMenuTarget {
pack: NodePackInfo;
@@ -22,6 +23,7 @@ export const NodePackContextMenu = ({
onUninstalled: (packName: string) => void;
target: NodePackContextMenuTarget | null;
}) => {
+ const { t } = useTranslation();
const { uninstall } = useNodePackActions();
const [pendingUninstall, setPendingUninstall] = useState(null);
const pack = target?.pack ?? null;
@@ -49,7 +51,7 @@ export const NodePackContextMenu = ({
setPendingUninstall(pack)}>
- Uninstall
+ {t('nodes.uninstall')}
) : null}
@@ -57,10 +59,10 @@ export const NodePackContextMenu = ({
setPendingUninstall(null)}
onConfirm={async () => {
if (!pendingUninstall) {
diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/nodes/library/NodePackList.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/nodes/library/NodePackList.tsx
index 83b722a949b..adab87b4a58 100644
--- a/invokeai/frontend/webv2/src/workbench/launchpad/nodes/library/NodePackList.tsx
+++ b/invokeai/frontend/webv2/src/workbench/launchpad/nodes/library/NodePackList.tsx
@@ -7,6 +7,7 @@ import { EmptyState } from '@workbench/components/ui/EmptyState';
import { refreshCustomNodePacks } from '@workbench/customNodes/nodesStore';
import { BlocksIcon, PackageOpenIcon, SearchIcon, TriangleAlertIcon } from 'lucide-react';
import { useDeferredValue, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
import { NodePackContextMenu, type NodePackContextMenuTarget } from './NodePackContextMenu';
@@ -35,6 +36,7 @@ export const NodePackList = ({
searchTerm: string;
status: 'idle' | 'loading' | 'loaded' | 'error';
}) => {
+ const { t } = useTranslation();
const [contextMenuTarget, setContextMenuTarget] = useState(null);
const deferredSearchTerm = useDeferredValue(searchTerm);
const filtered = useMemo(() => {
@@ -54,10 +56,10 @@ export const NodePackList = ({
danger
description={error}
icon={}
- title="Could not load custom node packs"
+ title={t('nodes.couldNotLoadPacks')}
>
@@ -68,26 +70,30 @@ export const NodePackList = ({
}>
onSearchChange(event.currentTarget.value)}
/>
-
+
{status === 'idle' || status === 'loading' ? (
) : packs.length === 0 ? (
}
- title="No custom node packs"
+ title={t('nodes.noPacks')}
/>
) : filtered.length === 0 ? (
- } title="No packs match" />
+ }
+ title={t('nodes.noPacksMatch')}
+ />
) : (
{filtered.map((pack) => (
diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/nodes/manager/DetailPane.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/nodes/manager/DetailPane.tsx
index b038fef8031..fe7beafa681 100644
--- a/invokeai/frontend/webv2/src/workbench/launchpad/nodes/manager/DetailPane.tsx
+++ b/invokeai/frontend/webv2/src/workbench/launchpad/nodes/manager/DetailPane.tsx
@@ -7,16 +7,18 @@ import { NodeActivityBar } from '@workbench/launchpad/nodes/activity/NodeActivit
import { AddNodesView } from '@workbench/launchpad/nodes/add-nodes/AddNodesView';
import { NodePackDetail } from '@workbench/launchpad/nodes/detail/NodePackDetail';
import { BlocksIcon, PlusIcon } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
import { HEADER_MIN_HEIGHT } from './layoutConstants';
/** Right side of the nodes manager: selected pack details, Add Nodes, and activity footer. */
export const DetailPane = () => {
+ const { t } = useTranslation();
const activePackName = useNodesUiSelector((snapshot) => snapshot.activePackName);
const activeTab = useNodesUiSelector((snapshot) => snapshot.activeTab);
const nodePacks = useCustomNodesSelector((snapshot) => snapshot.nodePacks);
const activePack = nodePacks.find((pack) => pack.name === activePackName) ?? null;
- const detailLabel = activePack?.name ?? 'Node Pack Details';
+ const detailLabel = activePack?.name ?? t('nodes.details');
return (
@@ -36,7 +38,7 @@ export const DetailPane = () => {
- Add Nodes
+ {t('nodes.addNodes')}
@@ -53,6 +55,7 @@ export const DetailPane = () => {
};
const DetailTab = ({ packName }: { packName: string | null }) => {
+ const { t } = useTranslation();
const nodePacks = useCustomNodesSelector((snapshot) => snapshot.nodePacks);
const activePack = nodePacks.find((pack) => pack.name === packName) ?? null;
@@ -61,17 +64,17 @@ const DetailTab = ({ packName }: { packName: string | null }) => {
- Select a node pack
+ {t('nodes.selectPack')}
- Choose a pack from the library to see its path, manage it, and preview every node it adds.
+ {t('nodes.selectPackDescription')}
);
}
return (
-
+
updateNodesUi({ activePackName: null })} />
);
diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/nodes/manager/LibraryColumn.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/nodes/manager/LibraryColumn.tsx
index 20b23b2f9a5..3fd4e0b5526 100644
--- a/invokeai/frontend/webv2/src/workbench/launchpad/nodes/manager/LibraryColumn.tsx
+++ b/invokeai/frontend/webv2/src/workbench/launchpad/nodes/manager/LibraryColumn.tsx
@@ -3,12 +3,14 @@ import { Box, Flex, HStack, Text } from '@chakra-ui/react';
import { useCustomNodesSelector } from '@workbench/customNodes/nodesStore';
import { openNodePackDetail, updateNodesUi, useNodesUiSelector } from '@workbench/customNodes/nodesUiStore';
import { NodePackList } from '@workbench/launchpad/nodes/library/NodePackList';
+import { useTranslation } from 'react-i18next';
import { HEADER_MIN_HEIGHT, PACK_LIBRARY_WIDTH } from './layoutConstants';
import { ReloadNodesButton } from './ReloadNodesButton';
/** Persistent custom-node pack list, matching the model manager's library column. */
export const LibraryColumn = () => {
+ const { t } = useTranslation();
const activePackName = useNodesUiSelector((snapshot) => snapshot.activePackName);
const searchTerm = useNodesUiSelector((snapshot) => snapshot.searchTerm);
const error = useCustomNodesSelector((snapshot) => snapshot.error);
@@ -27,7 +29,7 @@ export const LibraryColumn = () => {
>
- Node Packs
+ {t('nodes.nodePacks')}
{nodePacks.length}
diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/nodes/manager/ReloadNodesButton.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/nodes/manager/ReloadNodesButton.tsx
index d6d0bbb9512..0e20e411108 100644
--- a/invokeai/frontend/webv2/src/workbench/launchpad/nodes/manager/ReloadNodesButton.tsx
+++ b/invokeai/frontend/webv2/src/workbench/launchpad/nodes/manager/ReloadNodesButton.tsx
@@ -7,9 +7,11 @@ import { refreshCustomNodePacks } from '@workbench/customNodes/nodesStore';
import { useNotify } from '@workbench/useNotify';
import { RefreshCwIcon } from 'lucide-react';
import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
export const ReloadNodesButton = () => {
const notify = useNotify();
+ const { t } = useTranslation();
const [isReloading, setIsReloading] = useState(false);
const handleReload = async () => {
@@ -18,9 +20,9 @@ export const ReloadNodesButton = () => {
try {
await reloadCustomNodes();
await refreshCustomNodePacks();
- notify.success('Custom nodes reloaded');
+ notify.success(t('nodes.customNodesReloaded'));
} catch (error) {
- notify.error('Reload failed', getApiErrorMessage(error, 'Could not reload custom nodes.'));
+ notify.error(t('nodes.reloadFailed'), getApiErrorMessage(error, t('nodes.couldNotReloadCustomNodes')));
} finally {
setIsReloading(false);
}
@@ -29,7 +31,7 @@ export const ReloadNodesButton = () => {
return (
);
};
diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/pages/ProjectsPage.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/pages/ProjectsPage.tsx
index 2a234d08c35..eaa082c9eaa 100644
--- a/invokeai/frontend/webv2/src/workbench/launchpad/pages/ProjectsPage.tsx
+++ b/invokeai/frontend/webv2/src/workbench/launchpad/pages/ProjectsPage.tsx
@@ -9,6 +9,7 @@ import { refreshProjectLibrary } from '@workbench/projects/library';
import { importProjectFile, pickProjectFile } from '@workbench/projects/projectFile';
import { FileUpIcon, PlusIcon } from 'lucide-react';
import { useCallback, useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
/**
* The Launchpad's home section: your project library. It keeps a comfortable
@@ -27,13 +28,16 @@ const NEW_PROJECT_SEARCH = { new: true } as const;
export const ProjectsPage = () => {
const session = useAuthSession();
const navigate = useNavigate();
+ const { t } = useTranslation();
useEffect(() => {
void refreshProjectLibrary();
}, []);
const displayName = session.user?.display_name?.trim();
- const greeting = displayName ? `Welcome back, ${displayName}` : 'Welcome to Invoke';
+ const greeting = displayName
+ ? t('launchpad.projectsGreetingWithName', { name: displayName })
+ : t('launchpad.projectsGreeting');
const handleImport = useCallback(async () => {
const file = await pickProjectFile();
@@ -49,15 +53,15 @@ export const ProjectsPage = () => {
} catch (error) {
toaster.create({
description: error instanceof Error ? error.message : undefined,
- title: 'Import failed',
+ title: t('projects.importFailed'),
type: 'error',
});
}
- }, [navigate]);
+ }, [navigate, t]);
const handleImportClick = useCallback(() => void handleImport(), [handleImport]);
return (
-
+
@@ -65,18 +69,18 @@ export const ProjectsPage = () => {
{greeting}
- Pick up where you left off, or start something new.
+ {t('launchpad.projectsSubtitle')}
diff --git a/invokeai/frontend/webv2/src/workbench/launchpad/pages/UsersPage.tsx b/invokeai/frontend/webv2/src/workbench/launchpad/pages/UsersPage.tsx
index 45297bbbf43..5a74d4667e9 100644
--- a/invokeai/frontend/webv2/src/workbench/launchpad/pages/UsersPage.tsx
+++ b/invokeai/frontend/webv2/src/workbench/launchpad/pages/UsersPage.tsx
@@ -3,6 +3,7 @@ import type { SystemStyleObject } from '@chakra-ui/react';
import { Box } from '@chakra-ui/react';
import { Scrollable } from '@workbench/components/ui';
import { UsersManagementPanel } from '@workbench/users';
+import { useTranslation } from 'react-i18next';
/**
* Admin-only section: create, edit, and remove users. Mirrors the Projects
@@ -15,10 +16,14 @@ const USERS_PAGE_MEASURE_SX: SystemStyleObject = {
w: 'full',
};
-export const UsersPage = () => (
-
-
-
-
-
-);
+export const UsersPage = () => {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+
+ );
+};
diff --git a/invokeai/frontend/webv2/src/workbench/projects/components/OpenProjectDialog.tsx b/invokeai/frontend/webv2/src/workbench/projects/components/OpenProjectDialog.tsx
index b70c4a76b7b..d5d4ef07b59 100644
--- a/invokeai/frontend/webv2/src/workbench/projects/components/OpenProjectDialog.tsx
+++ b/invokeai/frontend/webv2/src/workbench/projects/components/OpenProjectDialog.tsx
@@ -10,6 +10,7 @@ import { useWorkbenchDispatch, useWorkbenchSelector } from '@workbench/Workbench
import { areArraysEqual } from '@workbench/workbenchSelectors';
import { ArrowRightIcon, FileUpIcon } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
const disabledRowStyles = { opacity: 0.6 } as const;
@@ -25,6 +26,7 @@ export const OpenProjectDialog = ({ isOpen, onClose }: { isOpen: boolean; onClos
);
const dispatch = useWorkbenchDispatch();
const notify = useNotify();
+ const { t } = useTranslation();
const summaries = useProjectLibrarySelector((snapshot) => snapshot.summaries);
const [busyProjectId, setBusyProjectId] = useState(null);
@@ -49,7 +51,7 @@ export const OpenProjectDialog = ({ isOpen, onClose }: { isOpen: boolean; onClos
setBusyProjectId(null);
if (!project) {
- notify.error('Could not open project', `"${summary.name}" could not be loaded from the server.`);
+ notify.error(t('projects.couldNotOpen'), t('projects.couldNotOpenDescription', { name: summary.name }));
void refreshProjectLibrary();
return;
@@ -59,7 +61,7 @@ export const OpenProjectDialog = ({ isOpen, onClose }: { isOpen: boolean; onClos
dispatch({ project, type: 'openProject' });
onClose();
},
- [dispatch, notify, onClose]
+ [dispatch, notify, onClose, t]
);
const handleImport = useCallback(async () => {
@@ -79,9 +81,9 @@ export const OpenProjectDialog = ({ isOpen, onClose }: { isOpen: boolean; onClos
onClose();
}
} catch (error) {
- notify.error('Import failed', error instanceof Error ? error.message : undefined);
+ notify.error(t('projects.importFailed'), error instanceof Error ? error.message : undefined);
}
- }, [dispatch, notify, onClose]);
+ }, [dispatch, notify, onClose, t]);
const handleOpenChange = useCallback(
(event: { open: boolean }) => {
@@ -102,7 +104,7 @@ export const OpenProjectDialog = ({ isOpen, onClose }: { isOpen: boolean; onClos
- Open project
+ {t('projects.openProject')}
@@ -119,7 +121,7 @@ export const OpenProjectDialog = ({ isOpen, onClose }: { isOpen: boolean; onClos
))}
{available.length === 0 ? (
- {summaries.length === 0 ? 'No saved projects yet.' : 'All saved projects are already open.'}
+ {summaries.length === 0 ? t('projects.noSavedProjects') : t('projects.allSavedAlreadyOpen')}
) : null}
@@ -128,10 +130,10 @@ export const OpenProjectDialog = ({ isOpen, onClose }: { isOpen: boolean; onClos
@@ -156,6 +158,7 @@ const OpenProjectRow = ({
summary: ProjectSummary;
}) => {
const open = useCallback(() => void onOpen(summary), [onOpen, summary]);
+ const { t } = useTranslation();
return (
@@ -165,7 +168,7 @@ const OpenProjectRow = ({
{summary.name}
- Edited {formatRelativeTime(summary.updatedAt)}
+ {t('projects.editedRelative', { time: formatRelativeTime(summary.updatedAt) })}
{isBusy ? : }
diff --git a/invokeai/frontend/webv2/src/workbench/projects/components/ProjectTabs.tsx b/invokeai/frontend/webv2/src/workbench/projects/components/ProjectTabs.tsx
index a4a7ef5004e..ed03ba995c1 100644
--- a/invokeai/frontend/webv2/src/workbench/projects/components/ProjectTabs.tsx
+++ b/invokeai/frontend/webv2/src/workbench/projects/components/ProjectTabs.tsx
@@ -36,6 +36,7 @@ import {
XIcon,
} from 'lucide-react';
import { useCallback, useMemo, useRef, useState, type MouseEvent } from 'react';
+import { useTranslation } from 'react-i18next';
import { OpenProjectDialog } from './OpenProjectDialog';
@@ -74,6 +75,7 @@ const deleteMenuItemHover = { bg: 'bg.error', color: 'fg.error' } as const;
* with rename, details, export, close, and delete.
*/
export const ProjectTabs = () => {
+ const { t } = useTranslation();
const projectTabSummaries = useWorkbenchSelector(
(snapshot) => selectProjectTabSummaries(snapshot.state.projects),
areProjectTabSummariesEqual
@@ -172,9 +174,9 @@ export const ProjectTabs = () => {
-
+
{
-
+
{
onSubmit={renameProject}
/>
@@ -257,6 +259,7 @@ const ProjectTab = ({
queueItems: project.queueItems,
});
const switchProject = useCallback(() => onSwitchProject(project.id), [onSwitchProject, project.id]);
+ const { t } = useTranslation();
const openContextMenu = useCallback((event: MouseEvent) => onContextMenu(project, event), [onContextMenu, project]);
const closeProject = useCallback(
@@ -282,7 +285,7 @@ const ProjectTab = ({
me="-2"
as="span"
role="button"
- aria-label={`Close ${project.name}`}
+ aria-label={t('projects.closeProjectLabel', { name: project.name })}
onClick={closeProject}
/>
@@ -306,6 +309,7 @@ const ProjectTabContextMenu = ({
const store = useWorkbenchStore();
const openWorkbenchWidget = useOpenWorkbenchWidget();
const targetRef = useRef(target);
+ const { t } = useTranslation();
targetRef.current = target;
@@ -407,25 +411,25 @@ const ProjectTabContextMenu = ({
- Rename…
+ {t('projects.renameWithEllipsis')}
- Project details
+ {t('projects.projectDetails')}
- Export
+ {t('common.export')}
- Close
+ {t('common.close')}
- Delete project…
+ {t('projects.deleteProjectWithEllipsis')}
) : null}
diff --git a/invokeai/frontend/webv2/src/workbench/projects/useProjectActions.ts b/invokeai/frontend/webv2/src/workbench/projects/useProjectActions.ts
index af0a9b87299..8e7640d2269 100644
--- a/invokeai/frontend/webv2/src/workbench/projects/useProjectActions.ts
+++ b/invokeai/frontend/webv2/src/workbench/projects/useProjectActions.ts
@@ -4,6 +4,7 @@ import { useNavigate } from '@tanstack/react-router';
import { useNotify } from '@workbench/useNotify';
import { flushGenerateDrafts } from '@workbench/widgets/generate/generateDraftRegistry';
import { useWorkbenchDispatch, useWorkbenchStore } from '@workbench/WorkbenchContext';
+import { useTranslation } from 'react-i18next';
import { deleteLibraryProject } from './library';
import {
@@ -32,6 +33,7 @@ export const useProjectActions = (): {
const dispatch = useWorkbenchDispatch();
const navigate = useNavigate();
const notify = useNotify();
+ const { t } = useTranslation();
/** When the last tab goes, the session empties and Home takes over. */
const leaveEditorIfLast = (projectId: string): boolean => {
@@ -73,7 +75,7 @@ export const useProjectActions = (): {
await deleteLibraryProject(project.id);
} catch (error) {
unmarkProjectDeleted(project.id);
- notify.error('Delete failed', error instanceof Error ? error.message : undefined);
+ notify.error(t('projects.deleteFailed'), error instanceof Error ? error.message : undefined);
return;
}
diff --git a/invokeai/frontend/webv2/src/workbench/settings/HotkeysSettingsSection.tsx b/invokeai/frontend/webv2/src/workbench/settings/HotkeysSettingsSection.tsx
index d2d091af006..240dd1a34f4 100644
--- a/invokeai/frontend/webv2/src/workbench/settings/HotkeysSettingsSection.tsx
+++ b/invokeai/frontend/webv2/src/workbench/settings/HotkeysSettingsSection.tsx
@@ -13,6 +13,7 @@ import { patchWorkbenchPreferences, useWorkbenchPreferences } from '@workbench/s
import { CheckIcon, PlusIcon, RotateCcwIcon, SearchIcon, Trash2Icon, XIcon } from 'lucide-react';
import { Fragment, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
import { useVirtualizer } from 'react-hook-tanstack-virtual';
+import { useTranslation } from 'react-i18next';
type HotkeyConflict = { hotkey: HotkeyDefinition; title: string };
@@ -20,12 +21,12 @@ type HotkeyRow =
| { kind: 'category'; category: HotkeyCategory; title: string }
| { kind: 'hotkey'; hotkey: HotkeyDefinition; effectiveKeys: string[]; isCustomized: boolean };
-const CATEGORY_LABELS: Record = {
- app: 'App',
- canvas: 'Canvas',
- gallery: 'Gallery',
- viewer: 'Viewer',
- workflows: 'Workflows',
+const CATEGORY_LABEL_KEYS: Record = {
+ app: 'hotkeys.categories.app',
+ canvas: 'hotkeys.categories.canvas',
+ gallery: 'hotkeys.categories.gallery',
+ viewer: 'hotkeys.categories.viewer',
+ workflows: 'hotkeys.categories.workflows',
};
const CATEGORY_ORDER: HotkeyCategory[] = ['app', 'canvas', 'workflows', 'viewer', 'gallery'];
@@ -145,7 +146,7 @@ const buildRows = ({
continue;
}
- rows.push({ category, kind: 'category', title: CATEGORY_LABELS[category] });
+ rows.push({ category, kind: 'category', title: CATEGORY_LABEL_KEYS[category] });
for (const hotkey of hotkeys) {
rows.push({
@@ -161,6 +162,7 @@ const buildRows = ({
};
export const HotkeysSettingsSection = () => {
+ const { t } = useTranslation();
const { customHotkeys } = useWorkbenchPreferences();
const extensionHotkeys = useExtensionHotkeyDefinitions();
const [searchTerm, setSearchTerm] = useState('');
@@ -212,24 +214,29 @@ export const HotkeysSettingsSection = () => {
- Hotkeys
+ {t('hotkeys.title')}
- Account-bound keybinds. Project-level overrides can layer on later without changing hotkey ids.
+ {t('hotkeys.description')}
-
+
-
+
{virtualRows.map((row) => {
@@ -269,13 +276,17 @@ export const HotkeysSettingsSection = () => {
);
};
-const CategoryRow = ({ title }: { title: string }) => (
-
-
- {title}
-
-
-);
+const CategoryRow = ({ title }: { title: string }) => {
+ const { t } = useTranslation();
+
+ return (
+
+
+ {t(title)}
+
+
+ );
+};
const VirtualHotkeyRow = ({
conflictMap,
@@ -334,6 +345,7 @@ const HotkeyListRow = ({
onReset: () => void;
onSave: (keys: string[]) => void;
}) => {
+ const { t } = useTranslation();
const [draftKeys, setDraftKeys] = useState(effectiveKeys);
const [editingIndex, setEditingIndex] = useState(null);
const isEditing = editingIndex !== null;
@@ -391,12 +403,12 @@ const HotkeyListRow = ({
{hotkey.implemented === false ? (
- Pending
+ {t('hotkeys.pending')}
) : null}
{isCustomized ? (
- Custom
+ {t('hotkeys.custom')}
) : null}
@@ -405,11 +417,11 @@ const HotkeyListRow = ({
{conflict ? (
- Conflicts with {conflict.title}
+ {t('hotkeys.conflictsWith', { title: conflict.title })}
) : hasDuplicate ? (
- Duplicate binding in this hotkey
+ {t('hotkeys.duplicateBinding')}
) : null}
@@ -430,21 +442,21 @@ const HotkeyListRow = ({
))
) : (
- Disabled
+ {t('hotkeys.disabled')}
)}
-
+
{isEditing || isDirty ? (
<>
-
+
0 ? (
) : null}
{isCustomized ? (
-
+
) : null}
@@ -485,6 +497,7 @@ const HotkeyChip = ({
onEdit: () => void;
onRecord: (hotkey: string) => void;
}) => {
+ const { t } = useTranslation();
useEffect(() => {
if (!editing) {
return;
@@ -524,9 +537,15 @@ const HotkeyChip = ({
return (
- Press keys
+ {t('hotkeys.pressKeys')}
-
+
diff --git a/invokeai/frontend/webv2/src/workbench/settings/SettingsDialog.tsx b/invokeai/frontend/webv2/src/workbench/settings/SettingsDialog.tsx
index 1f701e26b45..f3bcebd1ae1 100644
--- a/invokeai/frontend/webv2/src/workbench/settings/SettingsDialog.tsx
+++ b/invokeai/frontend/webv2/src/workbench/settings/SettingsDialog.tsx
@@ -30,6 +30,7 @@ import { themeCardRecipe } from '@theme/recipes';
import { previewSwatches, THEMES, type ThemeDefinition } from '@theme/system';
import { Button, CloseButton, IconButton, ConfirmDialog, Tabs, Tooltip } from '@workbench/components/ui';
import { registerHotkeyModalLayer } from '@workbench/hotkeys';
+import { WORKBENCH_LANGUAGE_OPTIONS } from '@workbench/i18n';
import { syncedWorkbenchPersistence } from '@workbench/projects/syncedPersistence';
import {
shallowEqual,
@@ -53,6 +54,7 @@ import {
type LucideIcon,
} from 'lucide-react';
import { useCallback, useEffect, useId, useMemo, useState, type ReactNode } from 'react';
+import { useTranslation } from 'react-i18next';
import { HotkeysSettingsSection } from './HotkeysSettingsSection';
import {
@@ -68,7 +70,6 @@ import {
patchWorkbenchPreferences,
useWorkbenchPreferences,
useWorkbenchSettings,
- WORKBENCH_LANGUAGES,
} from './store';
interface SettingsTabDefinition {
@@ -149,6 +150,7 @@ export const SettingsDialog = ({ isOpen, onClose }: { isOpen: boolean; onClose:
};
const SettingsDialogContent = ({ onClose }: { onClose: () => void }) => {
+ const { t } = useTranslation();
const { error, scope, status } = useWorkbenchSettings();
const handlePositionerClick = useCallback(
(event: { target: EventTarget; currentTarget: EventTarget }) => {
@@ -169,12 +171,10 @@ const SettingsDialogContent = ({ onClose }: { onClose: () => void }) => {
- Settings
+ {t('settings.title')}
- {scope === 'user'
- ? 'Preferences for the signed-in user. Project settings apply only to the active project.'
- : 'Global preferences for this single-user install. Project settings apply only to the active project.'}
+ {scope === 'user' ? t('settings.descriptionUser') : t('settings.descriptionGlobal')}
{status === 'error' && error ? (
@@ -197,60 +197,61 @@ const SettingsDialogContent = ({ onClose }: { onClose: () => void }) => {
};
const SettingsTabs = () => {
+ const { t } = useTranslation();
const hasWorkbench = useOptionalWorkbenchStore() !== null;
const settingsTabs: SettingsTabDefinition[] = useMemo(
() => [
{
children: ,
icon: PaletteIcon,
- label: 'Appearance',
+ label: t('settings.tabs.appearance'),
value: 'appearance',
},
{
children: ,
icon: SlidersHorizontalIcon,
- label: 'Behavior',
+ label: t('settings.tabs.behavior'),
value: 'behavior',
},
{
children: ,
icon: KeyboardIcon,
- label: 'Hotkeys',
+ label: t('settings.tabs.hotkeys'),
value: 'hotkeys',
},
{
children: ,
condition: hasWorkbench,
icon: FolderIcon,
- label: 'Project',
+ label: t('settings.tabs.project'),
value: 'project',
},
{
children: ,
icon: ListOrderedIcon,
- label: 'Queue',
+ label: t('settings.tabs.queue'),
value: 'queue',
},
{
children: ,
icon: WorkflowIcon,
- label: 'Workflow',
+ label: t('settings.tabs.workflow'),
value: 'workflow',
},
{
children: ,
icon: Code2Icon,
- label: 'Developer',
+ label: t('settings.tabs.developer'),
value: 'developer',
},
{
children: ,
icon: DatabaseIcon,
- label: 'Workspace',
+ label: t('settings.tabs.workspace'),
value: 'workspace',
},
],
- [hasWorkbench]
+ [hasWorkbench, t]
);
const tabs = settingsTabs.filter((tab) => tab.condition !== false);
const sectionId = settingsDialogStore.useSelector((snapshot) => snapshot.sectionId);
@@ -323,6 +324,7 @@ const SettingsSection = ({
);
const AppearanceSection = () => {
+ const { t } = useTranslation();
const { language, reduceMotion, showFocusRegionHighlight, themeId } = useWorkbenchPreferences();
const selectTheme = useCallback((nextThemeId: WorkbenchThemeId) => {
@@ -346,15 +348,14 @@ const AppearanceSection = () => {
))}
- {WORKBENCH_LANGUAGES.map((value) => (
-
))}
@@ -366,8 +367,7 @@ const AppearanceSection = () => {
/>
@@ -780,7 +780,7 @@ const SettingToggle = ({
}: {
checked: boolean;
comingSoon?: boolean;
- description: string;
+ description?: string;
label: string;
onChange: (checked: boolean) => void;
}) => {
@@ -805,9 +805,11 @@ const SettingToggle = ({
{label}
-
- {description}
-
+ {description && (
+
+ {description}
+
+ )}
@@ -827,7 +829,7 @@ const SettingSelect = ({
}: {
children: ReactNode;
comingSoon?: boolean;
- description: string;
+ description?: string;
label: string;
onChange: (value: string) => void;
value: string;
@@ -850,9 +852,11 @@ const SettingSelect = ({
{label}
-
- {description}
-
+ {description && (
+
+ {description}
+
+ )}
diff --git a/invokeai/frontend/webv2/src/workbench/settings/store.test.ts b/invokeai/frontend/webv2/src/workbench/settings/store.test.ts
index 42278a68b38..6d8b4beb100 100644
--- a/invokeai/frontend/webv2/src/workbench/settings/store.test.ts
+++ b/invokeai/frontend/webv2/src/workbench/settings/store.test.ts
@@ -256,3 +256,18 @@ describe('normalizeWorkbenchPreferences queue scope', () => {
expect(store.normalizeWorkbenchPreferences({ queueJobsScope: 'current-project' }).queueJobsScope).toBe('all');
});
});
+
+describe('normalizeWorkbenchPreferences language', () => {
+ it('accepts locale files that were migrated from the legacy web app', () => {
+ expect(store.normalizeWorkbenchPreferences({ language: 'uk' }).language).toBe('uk');
+ expect(store.normalizeWorkbenchPreferences({ language: 'en-GB' }).language).toBe('en-GB');
+ });
+
+ it('migrates legacy ua preferences to the uk locale file', () => {
+ expect(store.normalizeWorkbenchPreferences({ language: 'ua' as never }).language).toBe('uk');
+ });
+
+ it('heals unsupported languages to the default', () => {
+ expect(store.normalizeWorkbenchPreferences({ language: 'missing' as never }).language).toBe('en');
+ });
+});
diff --git a/invokeai/frontend/webv2/src/workbench/settings/store.ts b/invokeai/frontend/webv2/src/workbench/settings/store.ts
index d38ff242d3e..f96f4a9febc 100644
--- a/invokeai/frontend/webv2/src/workbench/settings/store.ts
+++ b/invokeai/frontend/webv2/src/workbench/settings/store.ts
@@ -1,47 +1,18 @@
-import type {
- DeveloperLogLevel,
- DeveloperLogNamespace,
- ProjectSettings,
- WorkbenchLanguage,
- WorkbenchPreferences,
-} from '@workbench/types';
+import type { DeveloperLogLevel, DeveloperLogNamespace, ProjectSettings, WorkbenchPreferences } from '@workbench/types';
import { DEFAULT_THEME_ID, isWorkbenchThemeId } from '@theme/themes';
import { getUserStorageScope } from '@workbench/auth/session';
import { createExternalStore } from '@workbench/externalStore';
+import { normalizeWorkbenchLanguage } from '@workbench/i18n/languages';
import { deleteClientStateValue, getClientStateValue, setClientStateValue } from '@workbench/projects/api';
import { fetchSessionBlob } from '@workbench/projects/session';
+export { WORKBENCH_LANGUAGES } from '@workbench/i18n/languages';
+
const SETTINGS_BASE_STORAGE_KEY = 'invokeai:v7:webv2:settings';
const LEGACY_WORKBENCH_BASE_STORAGE_KEY = 'invokeai:v7:webv2:workbench';
const SETTINGS_CLIENT_STATE_KEY = 'webv2:workbench-settings';
-export const WORKBENCH_LANGUAGES: WorkbenchLanguage[] = [
- 'ar',
- 'az',
- 'de',
- 'en',
- 'es',
- 'fi',
- 'fr',
- 'he',
- 'hu',
- 'it',
- 'ja',
- 'ko',
- 'nl',
- 'pl',
- 'pt',
- 'pt-BR',
- 'ru',
- 'sv',
- 'tr',
- 'ua',
- 'vi',
- 'zh-CN',
- 'zh-Hant',
-];
-
export const DEVELOPER_LOG_LEVELS: DeveloperLogLevel[] = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'];
export const DEVELOPER_LOG_NAMESPACES: DeveloperLogNamespace[] = [
@@ -109,9 +80,6 @@ const getLegacyWorkbenchStorageKey = (): string => `${LEGACY_WORKBENCH_BASE_STOR
const getSettingsScope = (): WorkbenchSettingsSnapshot['scope'] => (getUserStorageScope() ? 'user' : 'global');
-const isWorkbenchLanguage = (value: unknown): value is WorkbenchLanguage =>
- typeof value === 'string' && WORKBENCH_LANGUAGES.includes(value as WorkbenchLanguage);
-
const isDeveloperLogLevel = (value: unknown): value is DeveloperLogLevel =>
typeof value === 'string' && DEVELOPER_LOG_LEVELS.includes(value as DeveloperLogLevel);
@@ -202,7 +170,7 @@ export const normalizeWorkbenchPreferences = (preferences?: WorkbenchPreferences
typeof preferences?.enableModelDescriptions === 'boolean'
? preferences.enableModelDescriptions
: DEFAULT_PREFERENCES.enableModelDescriptions,
- language: isWorkbenchLanguage(preferences?.language) ? preferences.language : DEFAULT_PREFERENCES.language,
+ language: normalizeWorkbenchLanguage(preferences?.language) ?? DEFAULT_PREFERENCES.language,
queueJobsScope:
preferences?.queueJobsScope === 'all-projects'
? 'all'
diff --git a/invokeai/frontend/webv2/src/workbench/shell/BottomPanel.tsx b/invokeai/frontend/webv2/src/workbench/shell/BottomPanel.tsx
index e66d3012e1d..0adf0588cd6 100644
--- a/invokeai/frontend/webv2/src/workbench/shell/BottomPanel.tsx
+++ b/invokeai/frontend/webv2/src/workbench/shell/BottomPanel.tsx
@@ -1,9 +1,12 @@
import { MissingWidgetFrame, WidgetRendererById } from '@workbench/widget-frame';
import { areWidgetRenderInstancesEqual } from '@workbench/widget-frame/widgetRenderInstance';
+import { resolveWidgetLabel } from '@workbench/widgetLabels';
import { getWidgetById } from '@workbench/widgetRegistry';
import { useActiveProjectSelector } from '@workbench/WorkbenchContext';
+import { useTranslation } from 'react-i18next';
export const BottomPanel = () => {
+ const { t } = useTranslation();
const panels = useActiveProjectSelector((project) => project.layout.panels);
const bottomRegion = useActiveProjectSelector((project) => project.widgetRegions.bottom);
const instance = useActiveProjectSelector(
@@ -24,7 +27,12 @@ export const BottomPanel = () => {
}
if (!instance || !widget || !View) {
- return ;
+ return (
+
+ );
}
return ;
diff --git a/invokeai/frontend/webv2/src/workbench/shell/CenterArea.tsx b/invokeai/frontend/webv2/src/workbench/shell/CenterArea.tsx
index 3553a9ff077..88865de6b5c 100644
--- a/invokeai/frontend/webv2/src/workbench/shell/CenterArea.tsx
+++ b/invokeai/frontend/webv2/src/workbench/shell/CenterArea.tsx
@@ -23,6 +23,7 @@ import {
type WidgetEnableMenuItem,
type WidgetInstanceContextMenuTarget,
} from '@workbench/widget-frame';
+import { resolveWidgetLabel } from '@workbench/widgetLabels';
import { closeWidgetPlacement, openWidgetPlacement, revealWidgetPlacement } from '@workbench/widgetPlacementCommands';
import { areWidgetPlacementProjectsEqual, getWidgetPlacementProject } from '@workbench/widgetPlacementMeta';
import {
@@ -33,6 +34,7 @@ import {
import { getWidgetById, getWidgetsForRegion } from '@workbench/widgetRegistry';
import { useActiveProjectSelector, useWorkbenchDispatch, useWorkbenchSelector } from '@workbench/WorkbenchContext';
import { type MouseEvent, useCallback, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
type CenterWidgetItem = PlacedWidgetRegionItem;
@@ -42,6 +44,7 @@ const CENTER_PREFERRED_REGIONS = ['center'] as const;
/** Center work area: the view tab strip plus the active registered center view. */
export const CenterArea = ({ dropState }: { dropState: WidgetRegionDropState }) => {
+ const { t } = useTranslation();
const placementProject = useActiveProjectSelector(getWidgetPlacementProject, areWidgetPlacementProjectsEqual);
const centerRegion = useActiveProjectSelector((project) => project.widgetRegions.center);
const invocation = useActiveProjectSelector((project) => project.invocation);
@@ -55,8 +58,13 @@ export const CenterArea = ({ dropState }: { dropState: WidgetRegionDropState })
} | null>(null);
const [instanceMenuTarget, setInstanceMenuTarget] = useState(null);
const focusRegionProps = useFocusRegionProps('center');
+ const getWidgetLabel = useCallback(
+ (manifest: Parameters[0]) => resolveWidgetLabel(manifest, t),
+ [t]
+ );
const centerRegionViewModel = createWidgetRegionViewModelFromState({
+ getWidgetLabel,
region: 'center',
regionState: centerRegion,
widgetInstances: placementProject.widgetInstances,
diff --git a/invokeai/frontend/webv2/src/workbench/shell/Panels.tsx b/invokeai/frontend/webv2/src/workbench/shell/Panels.tsx
index b840c31790a..f16aef41ccc 100644
--- a/invokeai/frontend/webv2/src/workbench/shell/Panels.tsx
+++ b/invokeai/frontend/webv2/src/workbench/shell/Panels.tsx
@@ -2,8 +2,10 @@ import type { WidgetInstanceId, WorkbenchRegion } from '@workbench/types';
import { MissingWidgetFrame, WidgetRendererById } from '@workbench/widget-frame';
import { areWidgetRenderInstancesEqual } from '@workbench/widget-frame/widgetRenderInstance';
+import { resolveWidgetLabel } from '@workbench/widgetLabels';
import { getWidgetById } from '@workbench/widgetRegistry';
import { useActiveProjectSelector } from '@workbench/WorkbenchContext';
+import { useTranslation } from 'react-i18next';
/** Left panel — hosts the active registered widget panel view. */
export const LeftPanel = ({ instanceId }: { instanceId: WidgetInstanceId }) => (
@@ -21,6 +23,7 @@ const panelRegions = {
} as const satisfies Record;
const WidgetPanelSlot = ({ instanceId, panel }: { instanceId: WidgetInstanceId; panel: keyof typeof panelRegions }) => {
+ const { t } = useTranslation();
const instance = useActiveProjectSelector(
(project) => project.widgetInstances[instanceId],
areWidgetRenderInstancesEqual
@@ -30,7 +33,7 @@ const WidgetPanelSlot = ({ instanceId, panel }: { instanceId: WidgetInstanceId;
const region = panelRegions[panel];
if (!instance || !widget || widget.status !== 'enabled' || !View) {
- return ;
+ return ;
}
return ;
diff --git a/invokeai/frontend/webv2/src/workbench/shell/StatusBar.tsx b/invokeai/frontend/webv2/src/workbench/shell/StatusBar.tsx
index 938288a835e..c5c46e7722c 100644
--- a/invokeai/frontend/webv2/src/workbench/shell/StatusBar.tsx
+++ b/invokeai/frontend/webv2/src/workbench/shell/StatusBar.tsx
@@ -14,6 +14,7 @@ import {
type WidgetEnableMenuItem,
type WidgetInstanceContextMenuTarget,
} from '@workbench/widget-frame';
+import { resolveWidgetLabel } from '@workbench/widgetLabels';
import { closeWidgetPlacement, openWidgetPlacement, revealWidgetPlacement } from '@workbench/widgetPlacementCommands';
import { areWidgetPlacementProjectsEqual, getWidgetPlacementProject } from '@workbench/widgetPlacementMeta';
import {
@@ -25,6 +26,7 @@ import {
import { getWidgetById, getWidgetsForRegion } from '@workbench/widgetRegistry';
import { useActiveProjectSelector, useWorkbenchDispatch } from '@workbench/WorkbenchContext';
import { type KeyboardEvent, type MouseEvent, useCallback, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
interface BottomWidgetItem extends PlacedWidgetRegionItem {
isExpandable: boolean;
@@ -37,12 +39,18 @@ const COMPACT_ROW_ACTIVE_HOVER_PROPS = { color: 'accent.contrast' };
const TOOLTIP_POSITIONING = { placement: 'top-start' } as const;
export const StatusBar = ({ dropState }: { dropState: WidgetRegionDropState }) => {
+ const { t } = useTranslation();
const placementProject = useActiveProjectSelector(getWidgetPlacementProject, areWidgetPlacementProjectsEqual);
const bottomRegion = useActiveProjectSelector((project) => project.widgetRegions.bottom);
const dispatch = useWorkbenchDispatch();
const [enableMenuTarget, setEnableMenuTarget] = useState<{ x: number; y: number } | null>(null);
const [instanceMenuTarget, setInstanceMenuTarget] = useState(null);
+ const getWidgetLabel = useCallback(
+ (manifest: Parameters[0]) => resolveWidgetLabel(manifest, t),
+ [t]
+ );
const bottomRegionViewModel = createWidgetRegionViewModelFromState({
+ getWidgetLabel,
region: 'bottom',
regionState: bottomRegion,
widgetInstances: placementProject.widgetInstances,
diff --git a/invokeai/frontend/webv2/src/workbench/shell/WorkbenchShell.tsx b/invokeai/frontend/webv2/src/workbench/shell/WorkbenchShell.tsx
index 5b8b6eb4937..3150eb75de4 100644
--- a/invokeai/frontend/webv2/src/workbench/shell/WorkbenchShell.tsx
+++ b/invokeai/frontend/webv2/src/workbench/shell/WorkbenchShell.tsx
@@ -22,6 +22,7 @@ import {
type ActiveWidgetDrag,
widgetCollisionDetection,
} from '@workbench/widgetDnd';
+import { resolveWidgetLabel } from '@workbench/widgetLabels';
import {
closeWidgetPlacement,
dispatchWidgetDragEndPlacement,
@@ -34,6 +35,7 @@ import { getWidgetById, getWidgetsForRegion, widgetRegistrationFailures } from '
import { useActiveProjectSelector, useWorkbenchDispatch } from '@workbench/WorkbenchContext';
import { WorkbenchWidgetRegistryProvider } from '@workbench/WorkbenchWidgetRegistryContext';
import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
import { BottomPanel } from './BottomPanel';
import { CenterArea } from './CenterArea';
@@ -46,6 +48,7 @@ const DND_MODIFIERS = [restrictToWindowEdges];
export const WorkbenchShell = () => {
const dispatch = useWorkbenchDispatch();
+ const { t } = useTranslation();
const panels = useActiveProjectSelector((project) => project.layout.panels);
const leftRegion = useActiveProjectSelector((project) => project.widgetRegions.left);
const rightRegion = useActiveProjectSelector((project) => project.widgetRegions.right);
@@ -55,6 +58,10 @@ export const WorkbenchShell = () => {
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
const [activeDrag, setActiveDrag] = useState(null);
+ const getWidgetLabel = useCallback(
+ (manifest: Parameters[0]) => resolveWidgetLabel(manifest, t),
+ [t]
+ );
const leftRegionViewModel = useMemo(
() =>
createWidgetRegionViewModelFromState({
@@ -62,8 +69,9 @@ export const WorkbenchShell = () => {
regionState: leftRegion,
widgetInstances: placementProject.widgetInstances,
widgets: getWidgetsForRegion('left'),
+ getWidgetLabel,
}),
- [leftRegion, placementProject.widgetInstances]
+ [getWidgetLabel, leftRegion, placementProject.widgetInstances]
);
const rightRegionViewModel = useMemo(
() =>
@@ -72,8 +80,9 @@ export const WorkbenchShell = () => {
regionState: rightRegion,
widgetInstances: placementProject.widgetInstances,
widgets: getWidgetsForRegion('right'),
+ getWidgetLabel,
}),
- [rightRegion, placementProject.widgetInstances]
+ [getWidgetLabel, placementProject.widgetInstances, rightRegion]
);
const leftMenuItems = useMemo(() => getWidgetRegionItems(leftRegionViewModel), [leftRegionViewModel]);
const rightMenuItems = useMemo(() => getWidgetRegionItems(rightRegionViewModel), [rightRegionViewModel]);
@@ -129,11 +138,11 @@ export const WorkbenchShell = () => {
fromRegion: activeData.region,
icon: widget.manifest.icon,
instanceId: activeData.instanceId,
- label: instance.title ?? widget.manifest.labelText,
+ label: instance.title ?? getWidgetLabel(widget.manifest),
typeId: instance.typeId,
});
},
- [placementProject.widgetInstances]
+ [getWidgetLabel, placementProject.widgetInstances]
);
const handleDragEnd = useCallback(
diff --git a/invokeai/frontend/webv2/src/workbench/shell/topbar/ModelManagerButton.tsx b/invokeai/frontend/webv2/src/workbench/shell/topbar/ModelManagerButton.tsx
index e1acdc1ca31..3cab1806beb 100644
--- a/invokeai/frontend/webv2/src/workbench/shell/topbar/ModelManagerButton.tsx
+++ b/invokeai/frontend/webv2/src/workbench/shell/topbar/ModelManagerButton.tsx
@@ -1,13 +1,18 @@
import { Link } from '@tanstack/react-router';
import { IconButton, Tooltip } from '@workbench/components/ui';
import { BoxIcon } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
-export const ModelManagerButton = () => (
-
-
-
-
-
-
-
-);
+export const ModelManagerButton = () => {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+
+
+
+ );
+};
diff --git a/invokeai/frontend/webv2/src/workbench/shell/topbar/NodeManagerButton.tsx b/invokeai/frontend/webv2/src/workbench/shell/topbar/NodeManagerButton.tsx
index 2896a390017..cfdd52b52da 100644
--- a/invokeai/frontend/webv2/src/workbench/shell/topbar/NodeManagerButton.tsx
+++ b/invokeai/frontend/webv2/src/workbench/shell/topbar/NodeManagerButton.tsx
@@ -2,13 +2,18 @@ import { Icon } from '@chakra-ui/react';
import { Link } from '@tanstack/react-router';
import { IconButton, Tooltip } from '@workbench/components/ui';
import { BlocksIcon } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
-export const NodesManagerButton = () => (
-
-
-
-
-
-
-
-);
+export const NodesManagerButton = () => {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+
+
+
+ );
+};
diff --git a/invokeai/frontend/webv2/src/workbench/types.ts b/invokeai/frontend/webv2/src/workbench/types.ts
index 5d981bc43ba..7a76b0f6e1d 100644
--- a/invokeai/frontend/webv2/src/workbench/types.ts
+++ b/invokeai/frontend/webv2/src/workbench/types.ts
@@ -1,7 +1,11 @@
+import type { TFunction } from 'i18next';
import type { ComponentType, ExoticComponent, JSXElementConstructor, SVGProps } from 'react';
+import type { WorkbenchLanguage } from './i18n/languages';
import type { ProjectGraphState } from './workflows/types';
+export type { WorkbenchLanguage } from './i18n/languages';
+
export type BuiltInLayoutPresetId = 'canvas-default' | 'gallery' | 'workflow' | 'canvas';
export type LayoutPresetId = BuiltInLayoutPresetId | (string & {});
@@ -147,7 +151,8 @@ export interface WidgetLabelProps {
presentation?: 'compact' | 'expanded' | 'tooltip';
}
-export type WidgetLabel = string | ComponentType;
+export type WidgetLabel = string | ((t: TFunction) => string);
+export type WidgetHeaderLabel = ComponentType;
export type WidgetIconComponent =
| JSXElementConstructor>
@@ -319,7 +324,7 @@ export interface WidgetManifest {
apiVersion?: 1;
id: WidgetTypeId;
label: WidgetLabel;
- labelText: string;
+ headerLabel?: WidgetHeaderLabel;
version: 1;
allowedRegions: WidgetRegion[];
allowMultiple: boolean;
@@ -653,31 +658,6 @@ export interface AutosaveState {
*/
export type WorkbenchThemeId = 'classic' | 'light' | 'forest' | 'mono' | 'ultradark';
-export type WorkbenchLanguage =
- | 'ar'
- | 'az'
- | 'de'
- | 'en'
- | 'es'
- | 'fi'
- | 'fr'
- | 'he'
- | 'hu'
- | 'it'
- | 'ja'
- | 'ko'
- | 'nl'
- | 'pl'
- | 'pt'
- | 'pt-BR'
- | 'ru'
- | 'sv'
- | 'tr'
- | 'ua'
- | 'vi'
- | 'zh-CN'
- | 'zh-Hant';
-
export type DeveloperLogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal';
export type DeveloperLogNamespace =
diff --git a/invokeai/frontend/webv2/src/workbench/users/UserFormDialog.tsx b/invokeai/frontend/webv2/src/workbench/users/UserFormDialog.tsx
index bdc4185e1a8..04527fd1f0d 100644
--- a/invokeai/frontend/webv2/src/workbench/users/UserFormDialog.tsx
+++ b/invokeai/frontend/webv2/src/workbench/users/UserFormDialog.tsx
@@ -10,6 +10,7 @@ import { useZodForm } from '@workbench/models/useZodForm';
import { useNotify } from '@workbench/useNotify';
import { WandSparklesIcon } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
export type UserFormTarget = { mode: 'create' } | { mode: 'edit'; user: UserDTO };
@@ -31,6 +32,7 @@ export const UserFormDialog = ({
onSaved: () => void;
target: UserFormTarget | null;
}) => {
+ const { t } = useTranslation();
const handleOpenChange = useCallback(
(event: { open: boolean }) => {
if (!event.open) {
@@ -59,7 +61,7 @@ export const UserFormDialog = ({
- {target.mode === 'create' ? 'Add user' : 'Edit user'}
+ {target.mode === 'create' ? t('users.addUser') : t('users.editUser')}
{target.mode === 'edit' ? (
@@ -90,6 +92,7 @@ const UserForm = ({
onSaved: () => void;
target: UserFormTarget;
}) => {
+ const { t } = useTranslation();
const session = useAuthSession();
const notify = useNotify();
const [isGenerating, setIsGenerating] = useState(false);
@@ -117,13 +120,13 @@ const UserForm = ({
try {
form.setValue('password', await generatePassword());
- notify.info('Password generated', 'Reveal it with the eye icon and share it with the user securely.');
+ notify.info(t('users.passwordGenerated'), t('users.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(
() =>
@@ -160,16 +163,14 @@ const UserForm = ({
await updateUser(editedUser.user_id, changes);
}
} catch (error) {
- throw new Error(
- getApiErrorMessage(error, isCreate ? 'Could not create the user.' : 'Could not update the user.')
- );
+ throw new Error(getApiErrorMessage(error, isCreate ? t('users.couldNotCreate') : t('users.couldNotUpdate')));
}
- notify.success(isCreate ? 'User created' : 'User updated');
+ notify.success(isCreate ? t('users.created') : t('users.updated'));
onSaved();
onClose();
}),
- [editedUser, form, isCreate, notify, onClose, onSaved]
+ [editedUser, form, isCreate, notify, onClose, onSaved, t]
);
const handleEmailChange = useCallback(
@@ -197,7 +198,7 @@ const UserForm = ({
{form.formError ? : null}
{isCreate ? (
-
+
) : null}
-
+
@@ -233,7 +234,7 @@ const UserForm = ({
/>
@@ -250,10 +251,10 @@ const UserForm = ({
>
- Administrator
+ {t('users.administrator')}
- {isSelf ? 'You cannot change your own role.' : 'Can manage users and all workspace settings.'}
+ {isSelf ? t('users.cannotChangeSelfRole') : t('users.administratorHelp')}
@@ -265,10 +266,10 @@ const UserForm = ({
>
diff --git a/invokeai/frontend/webv2/src/workbench/users/UserManagement.tsx b/invokeai/frontend/webv2/src/workbench/users/UserManagement.tsx
index c72c5522f5e..661fb92b396 100644
--- a/invokeai/frontend/webv2/src/workbench/users/UserManagement.tsx
+++ b/invokeai/frontend/webv2/src/workbench/users/UserManagement.tsx
@@ -7,6 +7,7 @@ import { Button, IconButton, ConfirmDialog, Scrollable, Tooltip } from '@workben
import { useNotify } from '@workbench/useNotify';
import { PencilIcon, Trash2Icon, UserPlusIcon } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
import { UserFormDialog, type UserFormTarget } from './UserFormDialog';
@@ -21,21 +22,24 @@ const DELETE_BUTTON_HOVER_STYLES = { color: 'fg.error' };
* layouts that still point at it.
*/
export const UserManagement = () => {
+ const { t } = useTranslation();
+
return (
-
+
);
};
export const UsersManagementPanel = () => {
+ const { t } = useTranslation();
const session = useAuthSession();
if (!session.multiuserEnabled || session.user?.is_admin !== true) {
return (
- User management is available to administrators only.
+ {t('users.adminOnly')}
);
@@ -45,6 +49,7 @@ export const UsersManagementPanel = () => {
};
const UsersDirectory = ({ currentUserId }: { currentUserId: string }) => {
+ const { t } = useTranslation();
const notify = useNotify();
const [users, setUsers] = useState(null);
const [loadError, setLoadError] = useState(null);
@@ -56,9 +61,9 @@ const UsersDirectory = ({ currentUserId }: { currentUserId: string }) => {
setUsers(await listUsers());
setLoadError(null);
} catch (error) {
- setLoadError(getApiErrorMessage(error, 'Could not load users.'));
+ setLoadError(getApiErrorMessage(error, t('users.couldNotLoad')));
}
- }, []);
+ }, [t]);
useEffect(() => {
void refresh();
@@ -71,12 +76,12 @@ const UsersDirectory = ({ currentUserId }: { currentUserId: string }) => {
await refresh();
} catch (error) {
notify.error(
- isActive ? 'Could not activate the user' : 'Could not deactivate the user',
- getApiErrorMessage(error, 'The backend rejected the change.')
+ isActive ? t('users.couldNotActivate') : t('users.couldNotDeactivate'),
+ getApiErrorMessage(error, t('users.backendRejectedChange'))
);
}
},
- [notify, refresh]
+ [notify, refresh, t]
);
const confirmDelete = useCallback(async () => {
@@ -86,12 +91,12 @@ const UsersDirectory = ({ currentUserId }: { currentUserId: string }) => {
try {
await deleteUser(deleteTarget.user_id);
- notify.success('User deleted');
+ notify.success(t('users.deleted'));
await refresh();
} catch (error) {
- notify.error('Could not delete the user', getApiErrorMessage(error, 'The backend rejected the request.'));
+ notify.error(t('users.couldNotDelete'), getApiErrorMessage(error, t('users.backendRejectedRequest')));
}
- }, [deleteTarget, notify, refresh]);
+ }, [deleteTarget, notify, refresh, t]);
const openCreateForm = useCallback(() => setFormTarget({ mode: 'create' }), []);
const closeForm = useCallback(() => setFormTarget(null), []);
@@ -106,17 +111,15 @@ const UsersDirectory = ({ currentUserId }: { currentUserId: string }) => {
- Users
+ {t('users.title')}
- {users
- ? `${users.length} ${users.length === 1 ? 'account' : 'accounts'} in this workspace.`
- : 'Manage workspace accounts and roles.'}
+ {users ? t('users.accountCount', { count: users.length }) : t('users.description')}
@@ -128,7 +131,7 @@ const UsersDirectory = ({ currentUserId }: { currentUserId: string }) => {
{loadError}
) : (
@@ -140,16 +143,16 @@ const UsersDirectory = ({ currentUserId }: { currentUserId: string }) => {
- User
+ {t('users.user')}
- Role
+ {t('users.role')}
- Last sign-in
+ {t('users.lastSignIn')}
- Active
+ {t('common.active')}
@@ -173,10 +176,10 @@ const UsersDirectory = ({ currentUserId }: { currentUserId: string }) => {
@@ -186,14 +189,14 @@ const UsersDirectory = ({ currentUserId }: { currentUserId: string }) => {
const getUserLabel = (user: UserDTO): string => user.display_name?.trim() || user.email;
-const formatLastSignIn = (lastLoginAt: string | null): string => {
+const formatLastSignIn = (lastLoginAt: string | null, neverLabel: string): string => {
if (!lastLoginAt) {
- return 'Never';
+ return neverLabel;
}
const date = new Date(lastLoginAt);
- return Number.isNaN(date.getTime()) ? 'Never' : date.toLocaleString();
+ return Number.isNaN(date.getTime()) ? neverLabel : date.toLocaleString();
};
const UserRow = ({
@@ -209,6 +212,7 @@ const UserRow = ({
setUserActive: (user: UserDTO, isActive: boolean) => void;
user: UserDTO;
}) => {
+ const { t } = useTranslation();
const handleDelete = useCallback(() => setDeleteTarget(user), [setDeleteTarget, user]);
const handleEdit = useCallback(() => setFormTarget({ mode: 'edit', user }), [setFormTarget, user]);
const handleSetActive = useCallback(
@@ -230,7 +234,7 @@ const UserRow = ({
{isSelf ? (
- You
+ {t('users.you')}
) : null}
@@ -242,18 +246,18 @@ const UserRow = ({
- {user.is_admin ? 'Admin' : 'User'}
+ {user.is_admin ? t('users.admin') : t('users.user')}
- {formatLastSignIn(user.last_login_at)}
+ {formatLastSignIn(user.last_login_at, t('users.never'))}
-
+
-
+
void;
}) => {
+ const { t } = useTranslation();
const activeSourceId = useActiveProjectSelector((project) => project.invocation.sourceId);
const dispatch = useWorkbenchDispatch();
const isActiveSource = activeSourceId === surface.sourceId;
@@ -66,7 +69,7 @@ const GraphSurfaceMenuItems = ({
return (
- Graph
+ {t('common.graph')}
- Set Source
+ {t('widgets.graph.setSource')}
{isActiveSource ? (
- Active
+ {t('common.active')}
) : null}
- View Graph
+ {t('widgets.graph.viewGraph')}
);
@@ -101,21 +104,23 @@ export const WidgetActionsMenu = ({
region: WorkbenchRegion;
runtime: WidgetRuntimeApi;
}) => {
+ const { t } = useTranslation();
const activeProject = useActiveProjectSelector(
(project) => ({ projectGraph: project.projectGraph, widgetGraphs: project.widgetGraphs }),
shallowEqual
) as Project;
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
- const surface = manifest.graphBearing?.surfaces.includes(region) ? createGraphBearingSurface(manifest, region) : null;
const HeaderMenu = manifest.headerMenu;
+ const label = resolveWidgetLabel(manifest, t);
+ const surface = manifest.graphBearing?.surfaces.includes(region)
+ ? createGraphBearingSurface(manifest, region, label)
+ : null;
+ const surfaceSourceId = surface?.sourceId;
const handlePreview = useCallback(() => setIsPreviewOpen(true), []);
- const positionHints = useMemo(
- () =>
- isPreviewOpen && surface?.sourceId === 'workflow'
- ? Object.fromEntries(activeProject.projectGraph.nodes.map((node) => [node.id, node.position]))
- : undefined,
- [activeProject.projectGraph.nodes, isPreviewOpen, surface?.sourceId]
- );
+ const positionHints =
+ isPreviewOpen && surfaceSourceId === 'workflow'
+ ? Object.fromEntries(activeProject.projectGraph.nodes.map((node) => [node.id, node.position]))
+ : undefined;
if (!surface && !HeaderMenu) {
return null;
@@ -128,7 +133,7 @@ export const WidgetActionsMenu = ({
<>
-
+
diff --git a/invokeai/frontend/webv2/src/workbench/widget-frame/WidgetBar.tsx b/invokeai/frontend/webv2/src/workbench/widget-frame/WidgetBar.tsx
index 24190bebb76..ac6eb4f4434 100644
--- a/invokeai/frontend/webv2/src/workbench/widget-frame/WidgetBar.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widget-frame/WidgetBar.tsx
@@ -7,6 +7,7 @@ import { verticalListSortingStrategy } from '@dnd-kit/sortable';
import { Row, Tooltip } from '@workbench/components/ui';
import { WidgetIcon } from '@workbench/iconResolver';
import { type MouseEvent, useCallback, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
import { useWidgetSortable } from './useWidgetSortable';
import { WidgetEnableMenu, type WidgetEnableMenuItem } from './WidgetEnableMenu';
@@ -38,6 +39,7 @@ export const WidgetBar = ({
region,
side,
}: WidgetBarProps) => {
+ const { t } = useTranslation();
const [enableMenuTarget, setEnableMenuTarget] = useState<{
x: number;
y: number;
@@ -103,11 +105,11 @@ export const WidgetBar = ({
diff --git a/invokeai/frontend/webv2/src/workbench/widget-frame/WidgetEnableMenu.tsx b/invokeai/frontend/webv2/src/workbench/widget-frame/WidgetEnableMenu.tsx
index d8b1042fe89..e381197c94f 100644
--- a/invokeai/frontend/webv2/src/workbench/widget-frame/WidgetEnableMenu.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widget-frame/WidgetEnableMenu.tsx
@@ -11,6 +11,7 @@ import { IconButton } from '@workbench/components/ui';
import { WidgetIcon } from '@workbench/iconResolver';
import { CheckIcon, MoreHorizontalIcon } from 'lucide-react';
import { useCallback, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
export interface WidgetEnableMenuItem {
allowMultiple: boolean;
@@ -86,6 +87,7 @@ export const WidgetEnableMenu = ({
trigger,
triggerLabel,
}: WidgetEnableMenuProps) => {
+ const { t } = useTranslation();
const content = (
@@ -93,7 +95,7 @@ export const WidgetEnableMenu = ({
{groupLabel}
{items.map((item) => {
- const meta = getItemMeta?.(item) ?? (item.failureMessage ? 'Failed' : null);
+ const meta = getItemMeta?.(item) ?? (item.failureMessage ? t('common.failed') : null);
const disabled = item.status === 'disabled' || isItemDisabled?.(item) === true;
return ;
diff --git a/invokeai/frontend/webv2/src/workbench/widget-frame/WidgetFrames.tsx b/invokeai/frontend/webv2/src/workbench/widget-frame/WidgetFrames.tsx
index 5a78891401e..8e25fd9b82c 100644
--- a/invokeai/frontend/webv2/src/workbench/widget-frame/WidgetFrames.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widget-frame/WidgetFrames.tsx
@@ -14,6 +14,7 @@ import { chipRecipe } from '@theme/recipes';
import { IconButton, Tooltip } from '@workbench/components/ui';
import { useFocusRegionProps } from '@workbench/focusRegions';
import { openWorkbenchSettings } from '@workbench/settings/settingsDialogStore';
+import { resolveWidgetLabel } from '@workbench/widgetLabels';
import { useActiveProjectSelector, useWorkbenchDispatch } from '@workbench/WorkbenchContext';
import { SettingsIcon, type LucideIcon } from 'lucide-react';
import {
@@ -24,6 +25,7 @@ import {
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from 'react';
+import { useTranslation } from 'react-i18next';
import { WidgetActionsMenu } from './WidgetActionsMenu';
@@ -204,9 +206,11 @@ export const WidgetHeader = ({
region: WorkbenchRegion;
runtime: WidgetRuntimeApi;
}) => {
+ const { t } = useTranslation();
// Manifests may provide a component label (e.g. Workflow's editable
// `Workflow / [name]`); plain strings render as the standard title.
- const Label = manifest.label;
+ const HeaderLabel = manifest.headerLabel;
+ const label = resolveWidgetLabel(manifest, t);
const handleSettingsClick = useCallback(
() => openWorkbenchSettings(manifest.settingsSection),
[manifest.settingsSection]
@@ -215,20 +219,20 @@ export const WidgetHeader = ({
return (
- {typeof Label === 'string' ? (
+ {HeaderLabel ? (
+
+ ) : (
- {Label}
+ {label}
- ) : (
-
)}
{actions}
{manifest.settingsSection ? (
-
+
{
+ const { t } = useTranslation();
const positioning = useMemo(
() => ({
getAnchorRect: () => (target ? { height: 1, width: 1, x: target.x, y: target.y } : null),
@@ -69,7 +71,7 @@ export const WidgetInstanceContextMenu = ({
onClick={handleRemove}
>
- Remove {target.item.label}
+ {t('widgets.removeWidget', { label: target.item.label })}
{isDisabled ? (
{removeDisabledLabel}
diff --git a/invokeai/frontend/webv2/src/workbench/widget-frame/createWidgetRuntime.test.ts b/invokeai/frontend/webv2/src/workbench/widget-frame/createWidgetRuntime.test.ts
index e876abe13a2..a677dfbdad1 100644
--- a/invokeai/frontend/webv2/src/workbench/widget-frame/createWidgetRuntime.test.ts
+++ b/invokeai/frontend/webv2/src/workbench/widget-frame/createWidgetRuntime.test.ts
@@ -17,7 +17,7 @@ const TestIcon = () => null;
const TestView = () => null;
const createWidget = (
- overrides: Partial & Pick
+ overrides: Partial & Pick
): RegisteredWidget => ({
manifest: {
apiVersion: 1,
@@ -27,7 +27,6 @@ const createWidget = (
centerPlacement: 'view',
failurePolicy: { isolateRenderFailure: true, onRegistrationFailure: 'disable' },
icon: TestIcon,
- label: overrides.labelText,
state: { createInitial: () => ({}), persistence: 'project', version: 1 },
version: 1,
view: TestView,
@@ -166,12 +165,12 @@ describe('createWidgetRuntime', () => {
dispatch,
getWidgetById: () => undefined,
getWidgetsForRegion: createRegistry({
- bottom: [createWidget({ bottomPanel: 'tooltip', id: 'tooltip-widget', labelText: 'Tooltip Widget' })],
- center: [createWidget({ centerPlacement: 'toolbar', id: 'toolbar-widget', labelText: 'Toolbar Widget' })],
+ bottom: [createWidget({ bottomPanel: 'tooltip', id: 'tooltip-widget', label: 'Tooltip Widget' })],
+ center: [createWidget({ centerPlacement: 'toolbar', id: 'toolbar-widget', label: 'Toolbar Widget' })],
right: [
createWidget({
id: 'panel-widget',
- labelText: 'Panel Widget',
+ label: 'Panel Widget',
state: { createInitial, persistence: 'project', version: 1 },
}),
],
@@ -216,7 +215,7 @@ describe('createWidgetRuntime', () => {
const { actions, dispatch } = createDispatch();
const runtime = createWidgetRuntime({
dispatch,
- getWidgetById: (typeId) => createWidget({ id: typeId, labelText: typeId }),
+ getWidgetById: (typeId) => createWidget({ id: typeId, label: typeId }),
getWidgetsForRegion: createRegistry({}),
instance: createInstance(),
project: createPlacementProject({
diff --git a/invokeai/frontend/webv2/src/workbench/widgetLabels.ts b/invokeai/frontend/webv2/src/workbench/widgetLabels.ts
new file mode 100644
index 00000000000..89091541896
--- /dev/null
+++ b/invokeai/frontend/webv2/src/workbench/widgetLabels.ts
@@ -0,0 +1,11 @@
+import type { TFunction } from 'i18next';
+
+import type { WidgetManifest, WidgetTypeId } from './types';
+
+type WidgetLabelSource = Pick;
+
+export const resolveWidgetLabel = (manifest: WidgetLabelSource, t: TFunction): string =>
+ typeof manifest.label === 'function' ? manifest.label(t) : manifest.label;
+
+export const getWidgetFallbackLabel = (manifest: { id: WidgetTypeId; label: WidgetManifest['label'] }): string =>
+ typeof manifest.label === 'string' ? manifest.label : manifest.id;
diff --git a/invokeai/frontend/webv2/src/workbench/widgetPlacementCommands.test.ts b/invokeai/frontend/webv2/src/workbench/widgetPlacementCommands.test.ts
index 926e1e47072..ebbfc547d2c 100644
--- a/invokeai/frontend/webv2/src/workbench/widgetPlacementCommands.test.ts
+++ b/invokeai/frontend/webv2/src/workbench/widgetPlacementCommands.test.ts
@@ -18,7 +18,7 @@ const getActiveProject = (state: WorkbenchState): Project => {
};
const createWidget = (
- overrides: Partial & Pick
+ overrides: Partial & Pick
): RegisteredWidget => ({
manifest: {
apiVersion: 1,
@@ -28,7 +28,6 @@ const createWidget = (
centerPlacement: 'view',
failurePolicy: { isolateRenderFailure: true, onRegistrationFailure: 'disable' },
icon: TestIcon,
- label: overrides.labelText,
state: { createInitial: () => ({}), persistence: 'project', version: 1 },
version: 1,
view: TestView,
@@ -59,7 +58,7 @@ const applyCommand = (
describe('widget placement commands', () => {
it('opens a singleton already in the target region without duplicating it', () => {
const state = createInitialWorkbenchState();
- const widget = createWidget({ id: 'queue', labelText: 'Queue' });
+ const widget = createWidget({ id: 'queue', label: 'Queue' });
const nextState = applyCommand(state, (dispatch) => {
expect(
openWidgetPlacement({
@@ -78,7 +77,7 @@ describe('widget placement commands', () => {
it('opens an existing singleton instance from another region', () => {
const state = createInitialWorkbenchState();
- const widget = createWidget({ id: 'preview', labelText: 'Preview' });
+ const widget = createWidget({ id: 'preview', label: 'Preview' });
const nextState = applyCommand(state, (dispatch) => {
openWidgetPlacement({
dispatch,
@@ -95,7 +94,7 @@ describe('widget placement commands', () => {
it('passes allowMultiple through as createNew', () => {
const actions: WorkbenchAction[] = [];
- const widget = createWidget({ allowMultiple: true, id: 'queue', labelText: 'Queue' });
+ const widget = createWidget({ allowMultiple: true, id: 'queue', label: 'Queue' });
openWidgetPlacement({
dispatch: (action) => actions.push(action),
@@ -109,7 +108,7 @@ describe('widget placement commands', () => {
it('does not create a new instance for singleton widgets even when requested', () => {
const actions: WorkbenchAction[] = [];
- const widget = createWidget({ allowMultiple: false, id: 'queue', labelText: 'Queue' });
+ const widget = createWidget({ allowMultiple: false, id: 'queue', label: 'Queue' });
openWidgetPlacement({
dispatch: (action) => actions.push(action),
@@ -133,7 +132,7 @@ describe('widget placement commands', () => {
openWidgetPlacement({
dispatch,
getWidgetsForRegion: createRegistry({
- left: [createWidget({ allowedRegions: ['center'], id: 'models', labelText: 'Models' })],
+ left: [createWidget({ allowedRegions: ['center'], id: 'models', label: 'Models' })],
}),
options: { preferredRegions: ['left'] },
typeId: 'models',
@@ -143,7 +142,7 @@ describe('widget placement commands', () => {
openWidgetPlacement({
dispatch,
getWidgetsForRegion: createRegistry({
- bottom: [createWidget({ bottomPanel: 'tooltip', id: 'diagnostics', labelText: 'Diagnostics' })],
+ bottom: [createWidget({ bottomPanel: 'tooltip', id: 'diagnostics', label: 'Diagnostics' })],
}),
options: { preferredRegions: ['bottom'] },
typeId: 'diagnostics',
@@ -153,7 +152,7 @@ describe('widget placement commands', () => {
openWidgetPlacement({
dispatch,
getWidgetsForRegion: createRegistry({
- center: [createWidget({ centerPlacement: 'toolbar', id: 'toolbar-tools', labelText: 'Toolbar Tools' })],
+ center: [createWidget({ centerPlacement: 'toolbar', id: 'toolbar-tools', label: 'Toolbar Tools' })],
}),
options: { preferredRegions: ['center'] },
typeId: 'toolbar-tools',
@@ -169,7 +168,7 @@ describe('widget placement commands', () => {
expect(
closeWidgetPlacement({
dispatch: (action) => actions.push(action),
- getWidgetById: (typeId) => createWidget({ id: typeId, labelText: typeId }),
+ getWidgetById: (typeId) => createWidget({ id: typeId, label: typeId }),
instanceId: 'canvas',
project: {
...project,
@@ -221,7 +220,7 @@ describe('widget placement commands', () => {
const nextState = applyCommand(state, (dispatch) => {
closeWidgetPlacement({
dispatch,
- getWidgetById: (typeId) => createWidget({ id: typeId, labelText: typeId }),
+ getWidgetById: (typeId) => createWidget({ id: typeId, label: typeId }),
instanceId: 'layers',
project: getActiveProject(state),
region: 'right',
diff --git a/invokeai/frontend/webv2/src/workbench/widgetRegionViewModel.test.ts b/invokeai/frontend/webv2/src/workbench/widgetRegionViewModel.test.ts
index 2870e8430c0..dedec0f91f5 100644
--- a/invokeai/frontend/webv2/src/workbench/widgetRegionViewModel.test.ts
+++ b/invokeai/frontend/webv2/src/workbench/widgetRegionViewModel.test.ts
@@ -13,7 +13,7 @@ const TestIcon = () => null;
const TestView = () => null;
const createWidget = (
- overrides: Partial & Pick
+ overrides: Partial & Pick
): RegisteredWidget => ({
manifest: {
apiVersion: 1,
@@ -21,7 +21,6 @@ const createWidget = (
allowedRegions: ['left', 'center', 'bottom'],
failurePolicy: { isolateRenderFailure: true, onRegistrationFailure: 'disable' },
icon: TestIcon,
- label: overrides.labelText,
state: { createInitial: () => ({}), persistence: 'project', version: 1 },
version: 1,
view: TestView,
@@ -44,7 +43,7 @@ describe('widget region view model', () => {
instanceIds: ['alpha'],
region: 'left',
widgetInstances: { alpha: createInstance('alpha', 'alpha') },
- widgets: [createWidget({ id: 'alpha', labelText: 'Alpha' }), createWidget({ id: 'beta', labelText: 'Beta' })],
+ widgets: [createWidget({ id: 'alpha', label: 'Alpha' }), createWidget({ id: 'beta', label: 'Beta' })],
});
expect(viewModel.placedItems.map((item) => item.id)).toEqual(['alpha']);
@@ -59,7 +58,7 @@ describe('widget region view model', () => {
instanceIds: ['alpha'],
region: 'left',
widgetInstances: { alpha: createInstance('alpha', 'alpha') },
- widgets: [createWidget({ id: 'alpha', labelText: 'Alpha' })],
+ widgets: [createWidget({ id: 'alpha', label: 'Alpha' })],
});
expect(viewModel.availableItems).toEqual([]);
@@ -70,7 +69,7 @@ describe('widget region view model', () => {
instanceIds: ['alpha'],
region: 'left',
widgetInstances: { alpha: createInstance('alpha', 'alpha') },
- widgets: [createWidget({ allowMultiple: true, id: 'alpha', labelText: 'Alpha' })],
+ widgets: [createWidget({ allowMultiple: true, id: 'alpha', label: 'Alpha' })],
});
expect(viewModel.availableItems).toHaveLength(1);
@@ -86,8 +85,8 @@ describe('widget region view model', () => {
'toolbar-tools': createInstance('toolbar-tools', 'toolbar-tools'),
},
widgets: [
- createWidget({ centerPlacement: 'view', id: 'canvas', labelText: 'Canvas' }),
- createWidget({ centerPlacement: 'toolbar', id: 'toolbar-tools', labelText: 'Toolbar Tools' }),
+ createWidget({ centerPlacement: 'view', id: 'canvas', label: 'Canvas' }),
+ createWidget({ centerPlacement: 'toolbar', id: 'toolbar-tools', label: 'Toolbar Tools' }),
],
});
const canvas = viewModel.placedItems.find((item) => item.id === 'canvas');
@@ -109,7 +108,7 @@ describe('widget region view model', () => {
widgets: [
createWidget({
id: 'alpha',
- labelText: 'Alpha',
+ label: 'Alpha',
state: { createInitial, persistence: 'project', version: 1 },
}),
],
diff --git a/invokeai/frontend/webv2/src/workbench/widgetRegionViewModel.ts b/invokeai/frontend/webv2/src/workbench/widgetRegionViewModel.ts
index 752f32e79c5..08886fcb2f2 100644
--- a/invokeai/frontend/webv2/src/workbench/widgetRegionViewModel.ts
+++ b/invokeai/frontend/webv2/src/workbench/widgetRegionViewModel.ts
@@ -1,4 +1,5 @@
import type {
+ NormalizedWidgetManifest,
RegisteredWidget,
WidgetIconComponent,
WidgetInstanceContract,
@@ -57,12 +58,14 @@ export const createWidgetRegionViewModel = (typeof manifest.label === 'string' ? manifest.label : manifest.id),
}: {
activeInstanceId?: WidgetInstanceId;
instanceIds: WidgetInstanceId[];
region: WidgetRegion;
widgetInstances: Record;
widgets: RegisteredWidget[];
+ getWidgetLabel?: (manifest: NormalizedWidgetManifest) => string;
}): WidgetRegionViewModel => {
const widgetsByType = new Map(widgets.map((widget) => [widget.manifest.id, widget]));
const placedItems = instanceIds.flatMap((instanceId): PlacedWidgetRegionItem[] => {
@@ -81,7 +84,7 @@ export const createWidgetRegionViewModel = ;
widgets: RegisteredWidget[];
+ getWidgetLabel?: (manifest: NormalizedWidgetManifest) => string;
}): WidgetRegionViewModel =>
createWidgetRegionViewModel({
activeInstanceId: regionState.activeInstanceId,
+ getWidgetLabel,
instanceIds: regionState.instanceIds,
region,
widgetInstances,
diff --git a/invokeai/frontend/webv2/src/workbench/widgetRegistry.test.ts b/invokeai/frontend/webv2/src/workbench/widgetRegistry.test.ts
index ad1bd76331d..f84cce1af4c 100644
--- a/invokeai/frontend/webv2/src/workbench/widgetRegistry.test.ts
+++ b/invokeai/frontend/webv2/src/workbench/widgetRegistry.test.ts
@@ -14,7 +14,6 @@ const createManifest = (overrides: Partial = {}): WidgetManifest
icon: TestIcon,
id: 'vendor.widget',
label: 'Test Widget',
- labelText: 'Test Widget',
version: 1,
view: TestView,
...overrides,
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/autosave-status/AutosaveStatusWidgetView.tsx b/invokeai/frontend/webv2/src/workbench/widgets/autosave-status/AutosaveStatusWidgetView.tsx
index 56cf8a5afcc..0fe18d913bf 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/autosave-status/AutosaveStatusWidgetView.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/autosave-status/AutosaveStatusWidgetView.tsx
@@ -4,24 +4,26 @@ import { Stack, Text } from '@chakra-ui/react';
import { StatusWidgetChip } from '@workbench/widget-frame';
import { useWorkbenchSelector } from '@workbench/WorkbenchContext';
import { CloudAlertIcon, CloudCheckIcon } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
export const AutosaveStatusWidgetView = ({ presentation }: WidgetViewProps) => {
+ const { t } = useTranslation();
const autosave = useWorkbenchSelector((snapshot) => snapshot.state.autosave);
const icon = autosave.status === 'error' ? CloudAlertIcon : CloudCheckIcon;
- const label = autosave.status === 'saved' ? 'Saved' : autosave.status;
+ const label = autosave.status === 'saved' ? t('common.saved') : autosave.status;
if (presentation === 'tooltip') {
return (
- Autosave
+ {t('widgets.autosaveStatus.label')}
- Status: {label}
+ {t('widgets.autosaveStatus.status', { status: label })}
{autosave.lastSavedAt ? (
- Last saved: {autosave.lastSavedAt}
+ {t('widgets.autosaveStatus.lastSaved', { time: autosave.lastSavedAt })}
) : null}
{autosave.error ? (
@@ -33,5 +35,5 @@ export const AutosaveStatusWidgetView = ({ presentation }: WidgetViewProps) => {
);
}
- return Autosave: {label};
+ return {t('widgets.autosaveStatus.chipLabel', { status: label })};
};
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/autosave-status/index.ts b/invokeai/frontend/webv2/src/workbench/widgets/autosave-status/index.ts
index d35f288a8f8..5c6bdea35e0 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/autosave-status/index.ts
+++ b/invokeai/frontend/webv2/src/workbench/widgets/autosave-status/index.ts
@@ -11,8 +11,7 @@ export const autosaveStatusWidgetManifest: WidgetManifest = {
failurePolicy: { isolateRenderFailure: true, onRegistrationFailure: 'disable' },
icon: CloudCheckIcon,
id: 'autosave-status',
- label: 'Autosave',
- labelText: 'Autosave',
+ label: (t) => t('widgets.labels.autosaveStatus'),
version: 1,
view: AutosaveStatusWidgetView,
};
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasDocumentFrame.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasDocumentFrame.tsx
index ccbc4424a61..9341c13a4cd 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasDocumentFrame.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasDocumentFrame.tsx
@@ -2,6 +2,7 @@ import type { CanvasPlacementContract, GeneratedImageContract } from '@workbench
import type { ReactNode } from 'react';
import { Box, Flex, Text } from '@chakra-ui/react';
+import { useTranslation } from 'react-i18next';
const toPercent = (value: number, max: number) => `${(value / max) * 100}%`;
@@ -47,42 +48,50 @@ export const CanvasPlaneImage = ({
placement: CanvasPlacementContract;
planeHeight: number;
planeWidth: number;
-}) => (
-
-
-
-);
+}) => {
+ const { t } = useTranslation();
-export const EmptyCanvasFrame = () => (
-
- Canvas layer stack is empty.
-
-);
+ return (
+
+
+
+ );
+};
+
+export const EmptyCanvasFrame = () => {
+ const { t } = useTranslation();
+
+ return (
+
+ {t('widgets.canvas.emptyLayerStack')}
+
+ );
+};
export const ToolScrubber = () => (
(
-
-
-
-
-
- {pendingImages.map((candidate, index) => (
- onSelectImage(index)}
- />
- ))}
+}: CanvasStagingControlsProps) => {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+
+ {pendingImages.map((candidate, index) => (
+ onSelectImage(index)}
+ />
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+ {t('widgets.canvas.candidateCount', { current: selectedImageIndex + 1, total: pendingImages.length })}
+
+
+ {selectedCandidate.width} x {selectedCandidate.height}
+
+
+
+
+
+ {areThumbnailsVisible ? : }
+
+ onCycle(-1)}
+ >
+
+
+ onCycle(1)}
+ >
+
+
-
-
-
-
-
-
+
+
+ {isVisible ? : }
+
+
+
+
+
+
+
+
+ );
+};
+export const EmptyStagingControls = () => {
+ const { t } = useTranslation();
+
+ return (
-
-
- Candidate {selectedImageIndex + 1} of {pendingImages.length}
-
-
- {selectedCandidate.width} x {selectedCandidate.height}
-
-
-
-
-
- {areThumbnailsVisible ? : }
-
- onCycle(-1)}
- >
-
-
- onCycle(1)}
- >
-
-
-
-
-
- {isVisible ? : }
-
-
-
-
-
-
+
+ {t('widgets.canvas.emptyStaging')}
+
-
-);
-
-export const EmptyStagingControls = () => (
-
-
- Invoke Generate to Canvas to stage results here.
-
-
-);
+ );
+};
const StagingThumbnail = ({
candidate,
@@ -171,39 +186,43 @@ const StagingThumbnail = ({
index: number;
isSelected: boolean;
onSelect: () => void;
-}) => (
-
-
- {
+ const { t } = useTranslation();
+
+ return (
+
- {index + 1}
-
-
-);
+
+
+ {index + 1}
+
+
+ );
+};
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasWidgetView.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasWidgetView.tsx
index a6251709e9e..99e03402e8e 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasWidgetView.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasWidgetView.tsx
@@ -3,11 +3,13 @@ import type { WidgetViewProps } from '@workbench/types';
import { Box, Flex } from '@chakra-ui/react';
import { useActiveProjectSelector, useWorkbenchDispatch } from '@workbench/WorkbenchContext';
import { useCallback, useEffect, useEffectEvent } from 'react';
+import { useTranslation } from 'react-i18next';
import { CanvasDocumentFrame, CanvasPlaneImage, EmptyCanvasFrame, ToolScrubber } from './CanvasDocumentFrame';
import { CanvasStagingControls, EmptyStagingControls } from './CanvasStagingControls';
export const CanvasWidgetView = ({ runtime }: WidgetViewProps) => {
+ const { t } = useTranslation();
const canvas = useActiveProjectSelector((project) => project.canvas);
const dispatch = useWorkbenchDispatch();
const { document, stagingArea } = canvas;
@@ -49,11 +51,11 @@ export const CanvasWidgetView = ({ runtime }: WidgetViewProps) => {
useEffect(() => {
const hotkeys = [
- ['canvas.prevEntity', 'Previous canvas entity', ['alt+[', 'arrowleft']],
- ['canvas.nextEntity', 'Next canvas entity', ['alt+]', 'arrowright']],
- ['canvas.deleteSelected', 'Delete selected canvas item', ['delete', 'backspace']],
- ['canvas.undo', 'Undo canvas edit', ['mod+z']],
- ['canvas.redo', 'Redo canvas edit', ['mod+shift+z', 'mod+y']],
+ ['canvas.prevEntity', t('widgets.canvas.commands.previousEntity'), ['alt+[', 'arrowleft']],
+ ['canvas.nextEntity', t('widgets.canvas.commands.nextEntity'), ['alt+]', 'arrowright']],
+ ['canvas.deleteSelected', t('widgets.canvas.commands.deleteSelected'), ['delete', 'backspace']],
+ ['canvas.undo', t('widgets.canvas.commands.undo'), ['mod+z']],
+ ['canvas.redo', t('widgets.canvas.commands.redo'), ['mod+shift+z', 'mod+y']],
] as const;
const disposers = hotkeys.flatMap(([id, title, defaultKeys]) => [
runtime.commands.register({ handler: () => executeCanvasHotkey(id), id, title }),
@@ -63,11 +65,11 @@ export const CanvasWidgetView = ({ runtime }: WidgetViewProps) => {
return () => {
disposers.forEach((dispose) => dispose());
};
- }, [runtime.commands, runtime.hotkeys]);
+ }, [runtime.commands, runtime.hotkeys, t]);
return (
t('widgets.labels.canvas'),
version: 1,
view: CanvasWidgetView,
};
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/diagnostics/index.ts b/invokeai/frontend/webv2/src/workbench/widgets/diagnostics/index.ts
index 3250a2dcb3c..ce0f8e68a7a 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/diagnostics/index.ts
+++ b/invokeai/frontend/webv2/src/workbench/widgets/diagnostics/index.ts
@@ -13,8 +13,7 @@ export const diagnosticsWidgetManifest: WidgetManifest = {
headerActions: DiagnosticsHeaderActions,
icon: BugIcon,
id: 'diagnostics',
- label: 'Diagnostics',
- labelText: 'Diagnostics',
+ label: (t) => t('widgets.labels.diagnostics'),
version: 1,
view: DiagnosticsWidgetView,
};
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/gallery/GalleryBoardMenu.tsx b/invokeai/frontend/webv2/src/workbench/widgets/gallery/GalleryBoardMenu.tsx
index a49d0d2c352..a43d17a6414 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/gallery/GalleryBoardMenu.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/gallery/GalleryBoardMenu.tsx
@@ -5,6 +5,7 @@ import { Dialog, HStack, Icon, Input, Menu, Portal, Stack, Text } from '@chakra-
import { Button } from '@workbench/components/ui';
import { ArchiveIcon, DownloadIcon, PencilIcon, Trash2Icon, type LucideIcon } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
import { useGalleryWidget } from './GalleryWidgetContext';
@@ -32,6 +33,7 @@ export const GalleryBoardMenu = ({
onActiveChange?: (isActive: boolean) => void;
onClose: () => void;
}) => {
+ const { t } = useTranslation();
const { actions, gallery } = useGalleryWidget();
const [renameTarget, setRenameTarget] = useState(null);
const [deleteTarget, setDeleteTarget] = useState(null);
@@ -164,11 +166,11 @@ export const GalleryBoardMenu = ({
- Rename board
+ {t('widgets.gallery.renameBoard')}
@@ -194,28 +196,32 @@ export const GalleryBoardMenu = ({
- Delete board “{deleteTarget?.name}”?
+
+ {t('widgets.gallery.deleteBoardQuestion', { name: deleteTarget?.name ?? '' })}
+
- Deleted boards cannot be restored. Choose whether the board’s images move to Uncategorized or
- are permanently deleted with it.
+ {t('widgets.gallery.deleteBoardDescription')}
- {deleteTarget?.imageCount ?? 0} images | {deleteTarget?.assetCount ?? 0} assets
+ {t('widgets.gallery.boardItemCounts', {
+ assets: deleteTarget?.assetCount ?? 0,
+ images: deleteTarget?.imageCount ?? 0,
+ })}
@@ -227,10 +233,18 @@ export const GalleryBoardMenu = ({
};
const BoardDownloadMenuItem = ({ boardId }: { boardId: string }) => {
+ const { t } = useTranslation();
const { actions } = useGalleryWidget();
const handleClick = useCallback(() => void actions.downloadBoard(boardId), [actions, boardId]);
- return ;
+ return (
+
+ );
};
const BoardRenameMenuItem = ({
@@ -242,22 +256,31 @@ const BoardRenameMenuItem = ({
onRename: React.Dispatch>;
onRenameValue: React.Dispatch>;
}) => {
+ const { t } = useTranslation();
const handleClick = useCallback(() => {
onRenameValue(board.name);
onRename(board);
}, [board, onRename, onRenameValue]);
- return ;
+ return (
+
+ );
};
const BoardArchiveMenuItem = ({ archived, boardId }: { archived: boolean; boardId: string }) => {
+ const { t } = useTranslation();
const { actions } = useGalleryWidget();
const handleClick = useCallback(() => void actions.archiveBoard(boardId, !archived), [actions, archived, boardId]);
return (
@@ -271,10 +294,17 @@ const BoardDeleteMenuItem = ({
board: GalleryBoard;
onDelete: React.Dispatch>;
}) => {
+ const { t } = useTranslation();
const handleClick = useCallback(() => onDelete(board), [board, onDelete]);
return (
-
+
);
};
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/gallery/GalleryBoardSelect.tsx b/invokeai/frontend/webv2/src/workbench/widgets/gallery/GalleryBoardSelect.tsx
index 54b28dfb528..858ad265061 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/gallery/GalleryBoardSelect.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/gallery/GalleryBoardSelect.tsx
@@ -40,6 +40,7 @@ import {
type MouseEvent,
type PointerEvent,
} from 'react';
+import { useTranslation } from 'react-i18next';
import { GalleryBoardMenu, type GalleryBoardMenuTarget } from './GalleryBoardMenu';
import {
@@ -53,6 +54,7 @@ import { getBoardCounts } from './galleryStateView';
import { useGalleryWidget } from './GalleryWidgetContext';
export const GalleryBoardSelect = () => {
+ const { t } = useTranslation();
const { actions, gallery, imageActions, projectName } = useGalleryWidget();
const { active } = useDndContext();
const [isOpen, setIsOpen] = useState(false);
@@ -156,11 +158,11 @@ export const GalleryBoardSelect = () => {
const clearSearchButton = useMemo(
() =>
boardSearchTerm ? (
-
+
) : null,
- [boardSearchTerm, clearSearch]
+ [boardSearchTerm, clearSearch, t]
);
const searchStartElement = useMemo(() => , []);
@@ -216,13 +218,14 @@ export const GalleryBoardSelect = () => {
@@ -287,7 +291,7 @@ export const GalleryBoardSelect = () => {
)}
{dateBoards.length > 0 && (
<>
- By Date
+ {t('widgets.gallery.boardGroups.byDate')}
{dateBoards.map((board) => (
{
))}
>
)}
- {(regularBoards.length > 0 || canCreateFromSearch) && Boards}
+ {(regularBoards.length > 0 || canCreateFromSearch) && (
+ {t('widgets.gallery.boardGroups.boards')}
+ )}
{regularBoards.map((board) => (
{
))}
{!hasAnyMatch && !canCreateFromSearch && (
- No boards match this search.
+ {t('widgets.gallery.noBoardsMatchSearch')}
)}
{canCreateFromSearch && (
@@ -321,7 +328,7 @@ export const GalleryBoardSelect = () => {
- Create board “{trimmedSearchTerm}”
+ {t('widgets.gallery.createBoardNamed', { name: trimmedSearchTerm })}
@@ -346,6 +353,7 @@ export const GalleryBoardSelect = () => {
};
const BoardListControls = () => {
+ const { t } = useTranslation();
const { actions, gallery } = useGalleryWidget();
const { boardOrderBy, boardOrderDir, showArchivedBoards, showDateBoards } = gallery.settings;
const isAscending = boardOrderDir === 'ASC';
@@ -371,13 +379,15 @@ const BoardListControls = () => {
{
@@ -419,19 +431,22 @@ const BoardGroupLabel = ({ children }: { children: string }) => (
const BoardRow = ({
badge,
+ badgeColor,
board,
isSelected,
onOpenMenu,
onSelectBoard,
onSelectComplete,
}: {
- badge?: 'Project' | 'Archived';
+ badge?: string;
+ badgeColor?: 'blue' | 'gray';
board: GalleryBoard;
isSelected: boolean;
onOpenMenu?: (board: GalleryBoard, x: number, y: number) => void;
onSelectBoard: (boardId: string) => void;
onSelectComplete: () => void;
}) => {
+ const { t } = useTranslation();
const { active } = useDndContext();
const dragData = active?.data.current;
@@ -498,10 +513,10 @@ const BoardRow = ({
onClick={handleSelect}
onContextMenu={onOpenMenu ? handleContextMenu : undefined}
>
-
+
{onOpenMenu && (
{badge && (
-
+
{badge}
)}
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/gallery/GalleryImageGrid.tsx b/invokeai/frontend/webv2/src/workbench/widgets/gallery/GalleryImageGrid.tsx
index 114e2298364..96493e01570 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/gallery/GalleryImageGrid.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/gallery/GalleryImageGrid.tsx
@@ -24,6 +24,7 @@ import {
type MouseEvent,
} from 'react';
import { useVirtualizer } from 'react-hook-tanstack-virtual';
+import { useTranslation } from 'react-i18next';
import type { GalleryImageDragImage } from './galleryDnd';
import type { GalleryQueuePlaceholder } from './galleryStateView';
@@ -65,6 +66,7 @@ type GridCell =
| { kind: 'placeholder'; placeholder: GalleryQueuePlaceholder };
export const GalleryImageGrid = ({ layout }: { layout: 'stacked' | 'wide' }) => {
+ const { t } = useTranslation();
const { actions, gallery, imageActions, runtime } = useGalleryWidget();
const dispatch = useWorkbenchDispatch();
const [contextMenuTarget, setContextMenuTarget] = useState(null);
@@ -81,7 +83,9 @@ export const GalleryImageGrid = ({ layout }: { layout: 'stacked' | 'wide' }) =>
gallery.compareImageName !== null &&
gallery.selectedImageName !== null &&
gallery.compareImageName !== gallery.selectedImageName;
- const selectedBoardName = gallery.boards.find((board) => board.id === gallery.selectedBoardId)?.name ?? 'this board';
+ const selectedBoardName =
+ gallery.boards.find((board) => board.id === gallery.selectedBoardId)?.name ??
+ t('widgets.gallery.selectedBoardFallback');
const isEmpty = gallery.images.length === 0 && gallery.pendingPlaceholders.length === 0;
// Fresh generations are unstarred, so with newest-first ordering they land
@@ -382,18 +386,18 @@ export const GalleryImageGrid = ({ layout }: { layout: 'stacked' | 'wide' }) =>
useEffect(() => {
const directions = [
- ['gallery.selectAllOnPage', 'Select all gallery images on page', null, ['mod+a']],
- ['gallery.clearSelection', 'Clear gallery selection', null, ['esc']],
- ['gallery.galleryNavUp', 'Gallery navigation up', 'up', ['arrowup']],
- ['gallery.galleryNavRight', 'Gallery navigation right', 'right', ['arrowright']],
- ['gallery.galleryNavDown', 'Gallery navigation down', 'down', ['arrowdown']],
- ['gallery.galleryNavLeft', 'Gallery navigation left', 'left', ['arrowleft']],
- ['gallery.galleryNavUpAlt', 'Gallery navigation up', 'up', ['alt+arrowup']],
- ['gallery.galleryNavRightAlt', 'Gallery navigation right', 'right', ['alt+arrowright']],
- ['gallery.galleryNavDownAlt', 'Gallery navigation down', 'down', ['alt+arrowdown']],
- ['gallery.galleryNavLeftAlt', 'Gallery navigation left', 'left', ['alt+arrowleft']],
- ['gallery.deleteSelection', 'Delete gallery selection', null, ['delete', 'backspace']],
- ['gallery.starImage', 'Toggle gallery image star', null, ['.']],
+ ['gallery.selectAllOnPage', t('widgets.gallery.commands.selectAllOnPage'), null, ['mod+a']],
+ ['gallery.clearSelection', t('widgets.gallery.commands.clearSelection'), null, ['esc']],
+ ['gallery.galleryNavUp', t('widgets.gallery.commands.navigationUp'), 'up', ['arrowup']],
+ ['gallery.galleryNavRight', t('widgets.gallery.commands.navigationRight'), 'right', ['arrowright']],
+ ['gallery.galleryNavDown', t('widgets.gallery.commands.navigationDown'), 'down', ['arrowdown']],
+ ['gallery.galleryNavLeft', t('widgets.gallery.commands.navigationLeft'), 'left', ['arrowleft']],
+ ['gallery.galleryNavUpAlt', t('widgets.gallery.commands.navigationUp'), 'up', ['alt+arrowup']],
+ ['gallery.galleryNavRightAlt', t('widgets.gallery.commands.navigationRight'), 'right', ['alt+arrowright']],
+ ['gallery.galleryNavDownAlt', t('widgets.gallery.commands.navigationDown'), 'down', ['alt+arrowdown']],
+ ['gallery.galleryNavLeftAlt', t('widgets.gallery.commands.navigationLeft'), 'left', ['alt+arrowleft']],
+ ['gallery.deleteSelection', t('widgets.gallery.commands.deleteSelection'), null, ['delete', 'backspace']],
+ ['gallery.starImage', t('widgets.gallery.commands.toggleStarImage'), null, ['.']],
] as const;
const disposers = directions.flatMap(([id, title, direction, defaultKeys]) => [
runtime.commands.register({
@@ -412,7 +416,7 @@ export const GalleryImageGrid = ({ layout }: { layout: 'stacked' | 'wide' }) =>
return () => {
disposers.forEach((dispose) => dispose());
};
- }, [runtime.commands, runtime.hotkeys]);
+ }, [runtime.commands, runtime.hotkeys, t]);
return (
{isEmpty ? (
- {gallery.isLoading ? 'Loading backend gallery...' : 'No images match this board, tab, or search.'}
+ {gallery.isLoading ? t('widgets.gallery.loadingBackendGallery') : t('widgets.gallery.noImagesMatch')}
) : (
alwaysShowDimensions={showImageDimensions}
compareRole={
isComparisonActive && cell.image.imageName === gallery.selectedImageName
- ? 'Viewing'
+ ? t('widgets.preview.viewing')
: isComparisonActive && cell.image.imageName === gallery.compareImageName
- ? 'Compare'
+ ? t('widgets.preview.compare')
: null
}
fit={thumbnailFit}
@@ -523,7 +527,7 @@ export const GalleryImageGrid = ({ layout }: { layout: 'stacked' | 'wide' }) =>
>
- Drop images to upload to {selectedBoardName}
+ {t('widgets.gallery.dropImagesToUploadToBoard', { name: selectedBoardName })}
)}
@@ -548,6 +552,7 @@ const GalleryQueuePlaceholderCell = ({
onClick: () => void;
placeholder: GalleryQueuePlaceholder;
}) => {
+ const { t } = useTranslation();
const progressImage = useQueueItemProgressImage(placeholder.queueItemId, placeholder.itemIndex);
const progress = useQueueItemProgress(placeholder.queueItemId);
const isActive = progress?.activeItemIndex === placeholder.itemIndex;
@@ -556,7 +561,7 @@ const GalleryQueuePlaceholderCell = ({
return (
{
+ const { t } = useTranslation();
+
return (
void;
onToggleStarred: (image: GalleryImage) => void;
}) => {
+ const { t } = useTranslation();
const isCompared = compareRole !== null;
const { isDragging, listeners, setNodeRef, transform } = useDraggable({
@@ -693,7 +705,7 @@ const GalleryThumbnail = ({
onContextMenu={handleContextMenu}
>
@@ -205,7 +213,11 @@ const LoraRow = ({
-
+
setWeight(defaultWeight)}
/>
}
endElementProps={{ pointerEvents: 'auto' }}
>
-
+
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/generate/GenerateDimensionFields.tsx b/invokeai/frontend/webv2/src/workbench/widgets/generate/GenerateDimensionFields.tsx
index dbd1addd0f4..a11f28c0c2e 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/generate/GenerateDimensionFields.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/generate/GenerateDimensionFields.tsx
@@ -27,6 +27,7 @@ import {
} from '@workbench/generation/settings';
import { ArrowLeftRightIcon, LockIcon, LockOpenIcon, RulerDimensionLineIcon, ScalingIcon } from 'lucide-react';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
import { AspectRatioPreview } from './shared/AspectRatioPreview';
import { GenerateCollapsibleSection } from './shared/GenerateCollapsibleSection';
@@ -59,6 +60,7 @@ export const GenerateDimensionFields = ({
selectedModel,
settings,
}: GenerateDimensionFieldsProps) => {
+ const { t } = useTranslation();
const [draftDimensions, setDraftDimensions] = useState(null);
const modelDefaults = selectedModel ? getDefaultGenerateSettings(selectedModel) : null;
const dimensions = getGenerationDimensions(selectedModel);
@@ -279,8 +281,8 @@ export const GenerateDimensionFields = ({
);
return (
-
-
+
+
-
+
: }
-
-
+
+
-
-
+
+
@@ -349,7 +371,10 @@ export const GenerateDimensionFields = ({
-
+
setDimensionToModelDefault('width')}
/>
}
@@ -381,7 +406,10 @@ export const GenerateDimensionFields = ({
-
+
setDimensionToModelDefault('height')}
/>
}
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/generate/GenerateModelFields.tsx b/invokeai/frontend/webv2/src/workbench/widgets/generate/GenerateModelFields.tsx
index b5add4ce1a8..9cd711370b7 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/generate/GenerateModelFields.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/generate/GenerateModelFields.tsx
@@ -20,6 +20,7 @@ import {
import { ModelSelect } from '@workbench/models/components';
import { useNotify } from '@workbench/useNotify';
import { ArrowRightLeft, DicesIcon } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
import { GenerateCollapsibleSection } from './shared/GenerateCollapsibleSection';
import { ModelDefaultButton } from './shared/ModelDefaultButton';
@@ -62,6 +63,7 @@ export const GenerateModelFields = ({
settings,
vaeModels,
}: GenerateModelFieldsProps) => {
+ const { t } = useTranslation();
const notify = useNotify();
const modelDefaults = selectedModel ? getDefaultGenerateSettings(selectedModel) : null;
const policy = getGenerationModelPolicy(selectedModel, settings);
@@ -98,10 +100,12 @@ export const GenerateModelFields = ({
if (result.clearedLabels.length > 0) {
notify.info(
- 'Incompatible settings cleared',
- `${formatClearedSettings(result.clearedLabels)} ${
- result.clearedLabels.length === 1 ? 'was' : 'were'
- } not compatible with ${model.name}.`
+ t('widgets.generate.incompatibleSettingsCleared'),
+ t('widgets.generate.incompatibleSettingsClearedDescription', {
+ count: result.clearedLabels.length,
+ labels: formatClearedSettings(result.clearedLabels),
+ name: model.name,
+ })
);
}
};
@@ -111,15 +115,15 @@ export const GenerateModelFields = ({
};
return (
-
+
-
+
isGenerateModelConfig(model) && isGenerateModelSelectable(model)}
isClearable={false}
modelTypes={['main', 'external_image_generator']}
- placeholder="Select a model…"
+ placeholder={t('widgets.generate.selectModel')}
value={selectedModel?.key ?? null}
size="xs"
onChange={(model) => {
@@ -140,7 +144,7 @@ export const GenerateModelFields = ({
-
+
{
if (modelDefaults) {
onCommit({ steps: modelDefaults.steps });
@@ -185,7 +189,7 @@ export const GenerateModelFields = ({
endElement={
{
if (modelDefaults) {
onCommit({ cfgScale: modelDefaults.cfgScale });
@@ -200,11 +204,11 @@ export const GenerateModelFields = ({
{policy.ui.schedulerVisible ? (
-
+
) =>
onCommit({ scheduler: event.currentTarget.value })
@@ -220,7 +224,7 @@ export const GenerateModelFields = ({
{
if (modelDefaults) {
onCommit({ scheduler: modelDefaults.scheduler });
@@ -232,7 +236,7 @@ export const GenerateModelFields = ({
) : null}
{policy.ui.seedVisible ? (
-
+
-
+
onCommit({ shouldRandomizeSeed: !settings.shouldRandomizeSeed })}
>
- Random
+ {t('widgets.generate.random')}
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/generate/index.ts b/invokeai/frontend/webv2/src/workbench/widgets/generate/index.ts
index af729ce6682..6d1b097965b 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/generate/index.ts
+++ b/invokeai/frontend/webv2/src/workbench/widgets/generate/index.ts
@@ -11,8 +11,7 @@ export const generateWidgetManifest: WidgetManifest = {
graphBearing: { defaultGraphId: 'generate-graph', sourceId: 'generate', surfaces: ['left'] },
icon: SlidersHorizontalIcon,
id: 'generate',
- label: 'Generate',
- labelText: 'Generate',
+ label: (t) => t('widgets.labels.generate'),
version: 1,
view: GenerateWidgetView,
};
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/generate/promptFields/NegativePromptField.tsx b/invokeai/frontend/webv2/src/workbench/widgets/generate/promptFields/NegativePromptField.tsx
index b17b95e8d15..8f3ce68494b 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/generate/promptFields/NegativePromptField.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/generate/promptFields/NegativePromptField.tsx
@@ -7,6 +7,7 @@ import { Field } from '@workbench/components/ui';
import { useRegisterGenerateDraftFlusher } from '@workbench/widgets/generate/generateDraftRegistry';
import { useDebouncedDraftValue } from '@workbench/widgets/generate/useDebouncedDraftValue';
import { useCallback, useMemo, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
import { AddPromptTriggerButton, PromptTriggerPopover } from './PositivePromptActions';
import { PROMPT_ATTENTION_TARGET_PROPS } from './promptAttentionHotkeys';
@@ -48,6 +49,7 @@ export const NegativePromptField = ({
showSyntaxHighlighting,
value,
}: NegativePromptFieldProps) => {
+ const { t } = useTranslation();
const textareaRef = useRef(null);
const [triggerPickerState, setTriggerPickerState] = useState(null);
const { draftValue, flushDraftValue, setDraftValue } = useDebouncedDraftValue({
@@ -140,11 +142,11 @@ export const NegativePromptField = ({
- Enable negative prompt
+ {t('widgets.generate.enableNegativePrompt')}
),
- [handleEnabledChange, handleOpenPromptTriggerPicker, isEnabled, triggerPickerState]
+ [handleEnabledChange, handleOpenPromptTriggerPicker, isEnabled, t, triggerPickerState]
);
const triggerPickerPositioning = useMemo(
@@ -153,16 +155,16 @@ export const NegativePromptField = ({
);
return (
-
+
{isEnabled ? (
<>
(
-
-
- {DISABLED_PROMPT_ACTIONS.map(({ icon: ActionIcon, label }) => (
-
-
-
-
-
- ))}
-
-
-
-
-);
+}: PositivePromptActionsProps) => {
+ const { t } = useTranslation();
+
+ return (
+
+
+ {DISABLED_PROMPT_ACTIONS.map(({ icon: ActionIcon, labelKey }) => (
+
+
+
+
+
+ ))}
+
+
+
+
+ );
+};
type PromptTriggerOption = {
group: string;
@@ -95,18 +103,22 @@ const getTriggerPhrases = (model: unknown): string[] => {
};
const getPromptTriggerOptions = ({
+ compatibleEmbeddingsLabel,
loras,
+ mainModelLabel,
models,
selectedModel,
}: {
+ compatibleEmbeddingsLabel: string;
loras: GenerateLora[];
+ mainModelLabel: string;
models: readonly ModelConfig[];
selectedModel: GenerateModelConfig | undefined;
}): PromptTriggerOption[] => {
const options: PromptTriggerOption[] = [];
for (const phrase of getTriggerPhrases(selectedModel)) {
- options.push({ group: selectedModel?.name ?? 'Main model', label: phrase, value: phrase });
+ options.push({ group: selectedModel?.name ?? mainModelLabel, label: phrase, value: phrase });
}
for (const lora of loras) {
@@ -122,7 +134,7 @@ const getPromptTriggerOptions = ({
if (selectedModel) {
for (const model of models) {
if (model.type === 'embedding' && model.base === selectedModel.base) {
- options.push({ group: 'Compatible embeddings', label: model.name, value: `<${model.name}>` });
+ options.push({ group: compatibleEmbeddingsLabel, label: model.name, value: `<${model.name}>` });
}
}
}
@@ -148,14 +160,21 @@ export const AddPromptTriggerButton = ({
isOpen: boolean;
onOpenPromptTriggerPicker: (anchorElement: HTMLElement) => void;
}) => {
+ const { t } = useTranslation();
const handleClick = useCallback(
(event: MouseEvent) => onOpenPromptTriggerPicker(event.currentTarget),
[onOpenPromptTriggerPicker]
);
return (
-
-
+
+
@@ -175,12 +194,20 @@ export const PromptTriggerPopover = ({
onClose: () => void;
onSelect: (trigger: string) => void;
}) => {
+ const { t } = useTranslation();
const models = useModelsSelector((snapshot) => snapshot.models);
const [searchTerm, setSearchTerm] = useState('');
const options = useMemo(
- () => getPromptTriggerOptions({ loras, models, selectedModel }),
- [loras, models, selectedModel]
+ () =>
+ getPromptTriggerOptions({
+ compatibleEmbeddingsLabel: t('widgets.generate.compatibleEmbeddings'),
+ loras,
+ mainModelLabel: t('widgets.generate.mainModel'),
+ models,
+ selectedModel,
+ }),
+ [loras, models, selectedModel, t]
);
const filteredOptions = useMemo(
@@ -238,19 +265,19 @@ export const PromptTriggerPopover = ({
-
+
{options.length === 0 ? (
- No prompt triggers available.
+ {t('widgets.generate.noPromptTriggersAvailable')}
) : filteredOptions.length === 0 ? (
- No matching triggers.
+ {t('widgets.generate.noMatchingTriggers')}
) : (
{groupedOptions.map((group) => (
@@ -316,6 +343,7 @@ const ExpandPromptButton = ({
projectId: string;
onPositivePromptChange: (prompt: string) => void;
}) => {
+ const { t } = useTranslation();
const dispatch = useWorkbenchDispatch();
const models = useModelsSelector((snapshot) => snapshot.models);
const activeProjectId = useActiveProjectId();
@@ -351,7 +379,7 @@ const ExpandPromptButton = ({
} catch (error) {
dispatch({
area: 'expand-prompt',
- message: getApiErrorMessage(error, 'Could not expand the prompt.'),
+ message: getApiErrorMessage(error, t('widgets.generate.couldNotExpandPrompt')),
namespace: 'generation',
projectId,
type: 'recordError',
@@ -359,7 +387,7 @@ const ExpandPromptButton = ({
} finally {
setIsLoading(false);
}
- }, [dispatch, onPositivePromptChange, positivePrompt, projectId, selectedModel]);
+ }, [dispatch, onPositivePromptChange, positivePrompt, projectId, selectedModel, t]);
const popoverIds = useMemo(() => ({ trigger: triggerId }), [triggerId]);
const handleOpenChange = useCallback((event: { open: boolean }) => setIsOpen(event.open), []);
@@ -374,10 +402,15 @@ const ExpandPromptButton = ({
positioning={POPOVER_POSITIONING_BOTTOM_END}
onOpenChange={handleOpenChange}
>
-
+
- Expand Prompt
+ {t('widgets.generate.expandPrompt')}
{textLlmModels.length === 0 ? (
- Install a Text LLM model to expand prompts.
+ {t('widgets.generate.installTextLlmToExpandPrompts')}
) : (
<>
- Expand
+ {t('widgets.generate.expand')}
>
)}
@@ -434,6 +467,7 @@ const ImageToPromptButton = ({
projectId: string;
onPositivePromptChange: (prompt: string) => void;
}) => {
+ const { t } = useTranslation();
const dispatch = useWorkbenchDispatch();
const models = useModelsSelector((snapshot) => snapshot.models);
const selectedImage = useActiveProjectSelector(getSelectedGalleryImage, shallowEqual);
@@ -470,7 +504,7 @@ const ImageToPromptButton = ({
} catch (error) {
dispatch({
area: 'image-to-prompt',
- message: getApiErrorMessage(error, 'Could not generate a prompt from the selected image.'),
+ message: getApiErrorMessage(error, t('widgets.generate.couldNotGeneratePromptFromImage')),
namespace: 'generation',
projectId,
type: 'recordError',
@@ -478,7 +512,7 @@ const ImageToPromptButton = ({
} finally {
setIsLoading(false);
}
- }, [dispatch, onPositivePromptChange, projectId, selectedImage, selectedModel]);
+ }, [dispatch, onPositivePromptChange, projectId, selectedImage, selectedModel, t]);
const popoverIds = useMemo(() => ({ trigger: triggerId }), [triggerId]);
const handleOpenChange = useCallback((event: { open: boolean }) => setIsOpen(event.open), []);
@@ -493,9 +527,14 @@ const ImageToPromptButton = ({
positioning={POPOVER_POSITIONING_BOTTOM_END}
onOpenChange={handleOpenChange}
>
-
+
-
+
@@ -506,18 +545,18 @@ const ImageToPromptButton = ({
- Image to Prompt
+ {t('widgets.generate.imageToPrompt')}
{llavaModels.length === 0 ? (
- Install a LLaVA OneVision model to generate prompts from images.
+ {t('widgets.generate.installVisionModelToGeneratePrompts')}
) : (
<>
) : (
- Select an image in Gallery or Preview first.
+ {t('widgets.generate.selectImageFirst')}
)}
- Generate Prompt
+ {t('widgets.generate.generatePrompt')}
>
)}
@@ -561,6 +600,7 @@ const ImageToPromptButton = ({
};
const PositivePromptHistoryButton = ({ onUsePrompt }: Pick) => {
+ const { t } = useTranslation();
const dispatch = useWorkbenchDispatch();
const promptHistory = useActiveProjectSelector((project) => project.promptHistory);
const historyTriggerId = useId();
@@ -592,9 +632,9 @@ const PositivePromptHistoryButton = ({ onUsePrompt }: Pick
-
+
-
+
@@ -606,24 +646,24 @@ const PositivePromptHistoryButton = ({ onUsePrompt }: Pick
- Clear
+ {t('common.clear')}
-
+
{promptHistory.length === 0 ? (
- No prompt history yet.
+ {t('widgets.generate.noPromptHistoryYet')}
) : filteredPrompts.length === 0 ? (
- No matching prompts.
+ {t('widgets.generate.noMatchingPrompts')}
) : (
{filteredPrompts.map((prompt, index) => (
@@ -637,7 +677,7 @@ const PositivePromptHistoryButton = ({ onUsePrompt }: Pick
- Alt+Up/Down switches between prompts while focused.
+ {t('widgets.generate.promptHistoryKeyboardHelp')}
@@ -676,20 +716,21 @@ const PromptHistoryItemRow = ({
onUsePrompt: (prompt: PromptHistoryItem) => void;
prompt: PromptHistoryItem;
}) => {
+ const { t } = useTranslation();
const dispatch = useWorkbenchDispatch();
const handleUsePrompt = useCallback(() => onUsePrompt(prompt), [onUsePrompt, prompt]);
const handleDelete = useCallback(() => dispatch({ prompt, type: 'removePromptFromHistory' }), [dispatch, prompt]);
return (
-
+
{prompt.positivePrompt ? (
- Prompt:
+ {t('common.prompt')}:
{' '}
{prompt.positivePrompt}
@@ -697,7 +738,7 @@ const PromptHistoryItemRow = ({
{prompt.negativePrompt ? (
- Negative:
+ {t('common.negative')}:
{' '}
{prompt.negativePrompt}
@@ -705,7 +746,7 @@ const PromptHistoryItemRow = ({
{
+ const { t } = useTranslation();
const textareaRef = useRef(null);
const [triggerPickerState, setTriggerPickerState] = useState(null);
const { commitDraftValue, draftValue, flushDraftValue, replaceDraftValue, setDraftValue } = useDebouncedDraftValue({
@@ -164,14 +166,14 @@ export const PositivePromptField = ({
);
return (
-
+
void;
}) => {
+ const { t } = useTranslation();
+ const resolvedLabel = label ?? t('widgets.generate.useModelDefault');
const isActive = active ?? !disabled;
const handleClick = useCallback(
(event: React.MouseEvent) => {
@@ -25,9 +28,9 @@ export const ModelDefaultButton = ({
);
return (
-
+
(
-
-
-
-);
+export const LayersHeaderActions = () => {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+ );
+};
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/layers/LayersWidgetView.tsx b/invokeai/frontend/webv2/src/workbench/widgets/layers/LayersWidgetView.tsx
index 6570e320916..d12e78e37c2 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/layers/LayersWidgetView.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/layers/LayersWidgetView.tsx
@@ -2,8 +2,10 @@ import { Box, Flex, HStack, Icon, Stack, Text } from '@chakra-ui/react';
import { Panel } from '@workbench/components/ui';
import { useActiveProjectSelector } from '@workbench/WorkbenchContext';
import { LayersIcon } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
export const LayersWidgetView = () => {
+ const { t } = useTranslation();
const { eventsCount, graphHistoryCount, layers, redoCount, stagedImageCount, undoCount } = useActiveProjectSelector(
(project) => ({
eventsCount: project.events.length,
@@ -39,13 +41,13 @@ export const LayersWidgetView = () => {
>
- {layers.length} layers, {stagedImageCount} staged images.
+ {t('widgets.layers.summary', { layers: layers.length, stagedImages: stagedImageCount })}
{layers.length === 0 ? (
- Accepted canvas layers will appear here.
+ {t('widgets.layers.empty')}
) : (
layers.map((layer) => (
@@ -79,16 +81,16 @@ export const LayersWidgetView = () => {
- Project Contracts
+ {t('widgets.layers.projectContracts')}
- Graph history: {graphHistoryCount}
+ {t('widgets.layers.graphHistoryCount', { count: graphHistoryCount })}
- Events: {eventsCount}
+ {t('widgets.layers.eventsCount', { count: eventsCount })}
- Undo: {undoCount} / Redo: {redoCount}
+ {t('widgets.layers.undoRedoCount', { redo: redoCount, undo: undoCount })}
>
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/layers/index.ts b/invokeai/frontend/webv2/src/workbench/widgets/layers/index.ts
index fdc074379e3..680ae2cf89f 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/layers/index.ts
+++ b/invokeai/frontend/webv2/src/workbench/widgets/layers/index.ts
@@ -12,8 +12,7 @@ export const layersWidgetManifest: WidgetManifest = {
headerActions: LayersHeaderActions,
icon: LayersIcon,
id: 'layers',
- label: 'Layers',
- labelText: 'Layers',
+ label: (t) => t('widgets.labels.layers'),
version: 1,
view: LayersWidgetView,
};
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/notifications/NotificationsHeaderActions.tsx b/invokeai/frontend/webv2/src/workbench/widgets/notifications/NotificationsHeaderActions.tsx
index e97835d7c97..c4110ef46d9 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/notifications/NotificationsHeaderActions.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/notifications/NotificationsHeaderActions.tsx
@@ -2,8 +2,10 @@ import { HStack } from '@chakra-ui/react';
import { Button } from '@workbench/components/ui';
import { useWorkbenchDispatch } from '@workbench/WorkbenchContext';
import { useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
export const NotificationsHeaderActions = () => {
+ const { t } = useTranslation();
const dispatch = useWorkbenchDispatch();
const markAllRead = useCallback(() => dispatch({ type: 'markAllNotificationsRead' }), [dispatch]);
const clearNotifications = useCallback(() => dispatch({ type: 'clearNotifications' }), [dispatch]);
@@ -11,10 +13,10 @@ export const NotificationsHeaderActions = () => {
return (
- Mark Read
+ {t('notifications.markRead')}
- Clear
+ {t('common.clear')}
);
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/notifications/NotificationsWidgetView.tsx b/invokeai/frontend/webv2/src/workbench/widgets/notifications/NotificationsWidgetView.tsx
index 5ef4fd026db..38ab8a934a3 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/notifications/NotificationsWidgetView.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/notifications/NotificationsWidgetView.tsx
@@ -4,6 +4,7 @@ import { Badge, HStack, Icon, Stack, Text } from '@chakra-ui/react';
import { StatusWidgetChip } from '@workbench/widget-frame';
import { useWorkbenchSelector } from '@workbench/WorkbenchContext';
import { BellIcon, CircleCheckIcon, CircleXIcon, InfoIcon, TriangleAlertIcon, type LucideIcon } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
const kindColorPalette: Record = {
error: 'red',
@@ -18,29 +19,34 @@ const kindIcon = {
} satisfies Record;
export const NotificationsWidgetView = ({ presentation, region }: WidgetViewProps) => {
+ const { t } = useTranslation();
const { errorCount, totalCount, unreadCount } = useWorkbenchSelector((snapshot) => ({
errorCount: snapshot.state.notifications.filter((notification) => notification.kind === 'error').length,
totalCount: snapshot.state.notifications.length,
unreadCount: snapshot.state.notifications.filter((notification) => !notification.isRead).length,
}));
- const label = unreadCount > 0 ? `${unreadCount} new` : `${totalCount} total`;
+ const label =
+ unreadCount > 0
+ ? t('notifications.newCount', { count: unreadCount })
+ : t('notifications.totalCount', { count: totalCount });
const icon = errorCount > 0 ? TriangleAlertIcon : BellIcon;
if (region === 'bottom' && presentation !== 'expanded') {
- return Notifications: {label};
+ return {t('notifications.labelWithCount', { label })};
}
return ;
};
const NotificationsPanel = () => {
+ const { t } = useTranslation();
const notifications = useWorkbenchSelector((snapshot) => snapshot.state.notifications);
return (
{notifications.length === 0 ? (
- Successful operations, errors, and system messages appear here.
+ {t('notifications.empty')}
) : (
@@ -65,7 +71,7 @@ const NotificationsPanel = () => {
- {notification.kind}
+ {t(`notifications.kind.${notification.kind}`)}
{notification.message ? (
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/notifications/index.ts b/invokeai/frontend/webv2/src/workbench/widgets/notifications/index.ts
index 459ce2b110a..a979808c1e0 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/notifications/index.ts
+++ b/invokeai/frontend/webv2/src/workbench/widgets/notifications/index.ts
@@ -13,8 +13,7 @@ export const notificationsWidgetManifest: WidgetManifest = {
headerActions: NotificationsHeaderActions,
icon: BellIcon,
id: 'notifications',
- label: 'Notifications',
- labelText: 'Notifications',
+ label: (t) => t('widgets.labels.notifications'),
version: 1,
view: NotificationsWidgetView,
};
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewCompare.tsx b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewCompare.tsx
index 952dfa9d820..821ea7587df 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewCompare.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewCompare.tsx
@@ -13,6 +13,7 @@ import {
type CSSProperties,
type PointerEvent,
} from 'react';
+import { useTranslation } from 'react-i18next';
type CompareMode = 'slider' | 'side-by-side';
@@ -53,30 +54,32 @@ export const PreviewCompare = ({
onSwap: () => void;
runtime: WidgetRuntimeApi;
}) => {
+ const { t } = useTranslation();
const [mode, setMode] = useState('slider');
const [dividerPercent, setDividerPercent] = useState(50);
const containerRef = useRef(null);
const isDraggingRef = useRef(false);
const nextMode = useEffectEvent(() => setMode((current) => (current === 'slider' ? 'side-by-side' : 'slider')));
+ const nextComparisonModeLabel = t('widgets.preview.commands.nextComparisonMode');
useEffect(() => {
const command = runtime.commands.register({
handler: nextMode,
id: 'viewer.nextComparisonMode',
- title: 'Next comparison mode',
+ title: nextComparisonModeLabel,
});
const hotkey = runtime.hotkeys.register({
commandId: 'viewer.nextComparisonMode',
defaultKeys: ['m'],
id: 'viewer.nextComparisonMode',
- title: 'Next comparison mode',
+ title: nextComparisonModeLabel,
});
return () => {
command();
hotkey();
};
- }, [runtime.commands, runtime.hotkeys]);
+ }, [nextComparisonModeLabel, runtime.commands, runtime.hotkeys]);
const updateDivider = useCallback((clientX: number) => {
const rect = containerRef.current?.getBoundingClientRect();
@@ -172,31 +175,31 @@ export const PreviewCompare = ({
w="2px"
/>
- Compare
+ {t('widgets.preview.compare')}
- Viewing
+ {t('widgets.preview.viewing')}
) : (
-
-
+
+
)}
- {mode === 'slider' ? 'Side by Side' : 'Slider'}
+ {mode === 'slider' ? t('widgets.preview.sideBySide') : t('widgets.preview.slider')}
- Swap
+ {t('common.swap')}
- Exit Compare
+ {t('widgets.preview.exitCompare')}
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewHeaderActions.tsx b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewHeaderActions.tsx
index 413b38633e9..0ad0d44f134 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewHeaderActions.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewHeaderActions.tsx
@@ -4,12 +4,16 @@ import { IconButton } from '@workbench/components/ui';
import { useActiveProjectSelector, useWorkbenchDispatch } from '@workbench/WorkbenchContext';
import { HourglassIcon } from 'lucide-react';
import { useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
export const PreviewHeaderActions = () => {
+ const { t } = useTranslation();
const showProgressImagesInViewer = useActiveProjectSelector((project) => project.settings.showProgressImagesInViewer);
const hasProgressImage = useProgressImage() !== null;
const dispatch = useWorkbenchDispatch();
- const label = showProgressImagesInViewer ? 'Hide in-progress diffusion' : 'Show in-progress diffusion';
+ const label = showProgressImagesInViewer
+ ? t('widgets.preview.hideInProgressDiffusion')
+ : t('widgets.preview.showInProgressDiffusion');
const toggleProgressImages = useCallback(
() =>
dispatch({
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.tsx b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.tsx
index 902a19cd230..40f911fd454 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/preview/PreviewWidgetView.tsx
@@ -41,6 +41,7 @@ import {
type MouseEvent,
type CSSProperties,
} from 'react';
+import { useTranslation } from 'react-i18next';
import { PreviewCompare } from './PreviewCompare';
@@ -104,8 +105,13 @@ const getImageGalleryView = (image: PreviewImage): GalleryView => {
return 'images';
};
-const getBoardName = (boards: GalleryBoard[], boardId: string): string =>
- boards.find((board) => board.id === boardId)?.name ?? (boardId === 'none' ? 'Uncategorized' : 'Unknown board');
+const getBoardName = (
+ boards: GalleryBoard[],
+ boardId: string,
+ uncategorizedLabel: string,
+ unknownBoardLabel: string
+): string =>
+ boardId === 'none' ? uncategorizedLabel : (boards.find((board) => board.id === boardId)?.name ?? unknownBoardLabel);
const shouldLoadBackendBoard = (image: PreviewImage): boolean => image.sourceQueueItemId === 'backend-gallery';
@@ -128,6 +134,7 @@ const previewGridCss = {
const selectGenerateRecallValues = createGenerateFormValuesSelector();
export const PreviewWidgetView = ({ region, runtime }: WidgetViewProps) => {
+ const { t } = useTranslation();
const galleryValues = useActiveProjectSelector((project) => getProjectWidgetValues(project, 'gallery'));
const generateValues = useWidgetValuesSelector('generate', selectGenerateRecallValues);
const { antialiasProgressImages, showProgressImagesInViewer } = useActiveProjectSelector(
@@ -155,7 +162,12 @@ export const PreviewWidgetView = ({ region, runtime }: WidgetViewProps) => {
const selectedIndex = selectedImage
? boardImages.findIndex((image) => image.imageName === selectedImage.imageName)
: -1;
- const boardName = getBoardName(boards, displayBoardId);
+ const boardName = getBoardName(
+ boards,
+ displayBoardId,
+ t('widgets.gallery.uncategorized'),
+ t('widgets.gallery.unknownBoard')
+ );
const selectedImageRef = useRef(selectedImage);
const boardImagesRef = useRef(boardImages);
@@ -370,9 +382,9 @@ export const PreviewWidgetView = ({ region, runtime }: WidgetViewProps) => {
useEffect(() => {
const hotkeys = [
- ['viewer.toggleViewer', 'Toggle preview', ['z']],
- ['viewer.swapImages', 'Swap comparison images', ['c']],
- ['viewer.deleteImage', 'Delete preview image', ['delete', 'backspace']],
+ ['viewer.toggleViewer', t('widgets.preview.commands.togglePreview'), ['z']],
+ ['viewer.swapImages', t('widgets.preview.commands.swapComparisonImages'), ['c']],
+ ['viewer.deleteImage', t('widgets.preview.commands.deletePreviewImage'), ['delete', 'backspace']],
] as const;
const disposers = hotkeys.flatMap(([id, title, defaultKeys]) => [
runtime.commands.register({ handler: () => executeViewerHotkey(id), id, title }),
@@ -382,7 +394,7 @@ export const PreviewWidgetView = ({ region, runtime }: WidgetViewProps) => {
return () => {
disposers.forEach((dispose) => dispose());
};
- }, [runtime.commands, runtime.hotkeys]);
+ }, [runtime.commands, runtime.hotkeys, t]);
return (
@@ -453,6 +465,7 @@ const SelectedImagePreview = ({
onNext: () => void;
onPrevious: () => void;
}) => {
+ const { t } = useTranslation();
const previewImage = useStreamingImageSource({
fallbackImage: imageUrlToStreamingSource({
alt: image.imageName,
@@ -534,7 +547,7 @@ const SelectedImagePreview = ({
{previewImage ?
: null}
{isProgressImage ? (
- Generating
+ {t('common.generating')}
) : null}
@@ -547,15 +560,15 @@ const SelectedImagePreview = ({
{isLoadingBoard
- ? 'Loading board'
+ ? t('widgets.preview.loadingBoard')
: selectedIndex === -1
- ? `${boardImageCount} image${boardImageCount === 1 ? '' : 's'}`
- : `${selectedIndex + 1} of ${boardImageCount}`}
+ ? t('widgets.preview.imageCount', { count: boardImageCount })
+ : t('common.countOfTotal', { count: selectedIndex + 1, total: boardImageCount })}
= boardImageCount - 1}
size="2xs"
variant="outline"
@@ -579,10 +592,10 @@ const SelectedImagePreview = ({
{image.imageName}
- Source run {image.sourceQueueItemId}
+ {t('widgets.preview.sourceRun', { id: image.sourceQueueItemId })}
- {isProgressImage ? 'Generating' : `${image.width} x ${image.height}`}
+ {isProgressImage ? t('common.generating') : `${image.width} x ${image.height}`}
@@ -597,6 +610,7 @@ const EmptyPreview = ({
progressImage: ProgressImageSnapshot | null;
shouldAntialiasProgressImage: boolean;
}) => {
+ const { t } = useTranslation();
const previewImage = useStreamingImageSource({
liveImage: progressImageToStreamingSource(progressImage),
});
@@ -636,16 +650,16 @@ const EmptyPreview = ({
}}
/>
- Generating
+ {t('common.generating')}
) : (
- No gallery selection
+ {t('widgets.preview.noGallerySelection')}
- Generate to Gallery, then select a thumbnail in the Gallery widget to inspect it here.
+ {t('widgets.preview.emptyDescription')}
)}
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/preview/index.ts b/invokeai/frontend/webv2/src/workbench/widgets/preview/index.ts
index bcacfd58046..6a5ce786025 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/preview/index.ts
+++ b/invokeai/frontend/webv2/src/workbench/widgets/preview/index.ts
@@ -12,8 +12,7 @@ export const previewWidgetManifest: WidgetManifest = {
headerActions: PreviewHeaderActions,
icon: EyeIcon,
id: 'preview',
- label: 'Preview',
- labelText: 'Preview',
+ label: (t) => t('widgets.labels.preview'),
version: 1,
view: PreviewWidgetView,
};
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/project/ProjectWidgetView.tsx b/invokeai/frontend/webv2/src/workbench/widgets/project/ProjectWidgetView.tsx
index 388d148edd3..d1670015597 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/project/ProjectWidgetView.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/project/ProjectWidgetView.tsx
@@ -14,6 +14,7 @@ import {
} from '@workbench/WorkbenchContext';
import { ArrowRightIcon, CopyIcon, History as HistoryIcon, Trash2Icon } from 'lucide-react';
import { useCallback, useState, type ReactNode } from 'react';
+import { useTranslation } from 'react-i18next';
const RECOVERY_DELETE_HOVER = { color: 'fg.error' } as const;
@@ -53,6 +54,7 @@ type ProjectPanelViewModel = Pick<
>;
const NameSection = ({ project }: { project: ProjectPanelViewModel }) => {
+ const { t } = useTranslation();
const dispatch = useWorkbenchDispatch();
const commitName = useCallback(
@@ -76,7 +78,7 @@ const NameSection = ({ project }: { project: ProjectPanelViewModel }) => {
}, []);
return (
-
+
{
/** The id whose recovery family this project belongs to. */
const getRecoveryRootId = (project: Pick): string => project.recoveryOf ?? project.id;
-const formatTimestamp = (timestamp: string | undefined): string => {
+const formatTimestamp = (timestamp: string | undefined, unknownTime: string): string => {
if (!timestamp) {
- return 'Unknown time';
+ return unknownTime;
}
const date = new Date(timestamp);
- return Number.isNaN(date.getTime()) ? 'Unknown time' : date.toLocaleString();
+ return Number.isNaN(date.getTime()) ? unknownTime : date.toLocaleString();
};
const RecoverySection = ({ project }: { project: ProjectPanelViewModel }) => {
+ const { t } = useTranslation();
const projects = useWorkbenchSelector((snapshot) => snapshot.state.projects);
const dispatch = useWorkbenchDispatch();
const { deleteProject } = useProjectActions();
@@ -130,24 +133,26 @@ const RecoverySection = ({ project }: { project: ProjectPanelViewModel }) => {
return (
- Recoveries
+ {t('widgets.project.recoveries')}
{project.recoveryOf ? (
- Recovery copy
+ {t('widgets.project.recoveryCopy')}
- Forked {formatTimestamp(project.recoveredAt)} after this project was changed elsewhere.
+ {t('widgets.project.recoveryForkedDescription', {
+ time: formatTimestamp(project.recoveredAt, t('common.unknownTime')),
+ })}
{original ? (
- Open original "{original.name}"
+ {t('widgets.project.openOriginal', { name: original.name })}
) : null}
@@ -156,10 +161,10 @@ const RecoverySection = ({ project }: { project: ProjectPanelViewModel }) => {
))}
@@ -174,6 +179,7 @@ const RecoveryRow = ({
recovery: Project;
onDelete: React.Dispatch>;
}) => {
+ const { t } = useTranslation();
const dispatch = useWorkbenchDispatch();
const handleOpen = useCallback(() => {
flushGenerateDrafts();
@@ -188,14 +194,20 @@ const RecoveryRow = ({
{recovery.name}
- {formatTimestamp(recovery.recoveredAt)}
+ {formatTimestamp(recovery.recoveredAt, t('common.unknownTime'))}
-
+
{
+ const { t } = useTranslation();
const backendConnectionStatus = useWorkbenchSelector((snapshot) => snapshot.state.backendConnection.status);
const lastSavedAt = useWorkbenchSelector((snapshot) => snapshot.state.autosave.lastSavedAt);
const projectSync = useProjectSyncSelector((snapshot) => snapshot.projects[project.id]);
@@ -216,40 +229,48 @@ const DetailsSection = ({ project }: { project: ProjectPanelViewModel }) => {
const syncLabel =
backendConnectionStatus !== 'connected'
- ? 'Offline — saved in this browser'
+ ? t('widgets.project.syncOffline')
: projectSync === undefined || projectSync.isPendingPush
- ? 'Waiting to sync'
- : `Synced (revision ${projectSync.revision ?? '—'})`;
+ ? t('widgets.project.syncWaiting')
+ : t('widgets.project.syncSynced', { revision: projectSync.revision ?? '—' });
const copyId = useCallback(async () => {
try {
await navigator.clipboard.writeText(project.id);
- notify.success('Project id copied');
+ notify.success(t('widgets.project.idCopied'));
} catch {
- notify.error('Could not copy', 'Clipboard access was blocked by the browser.');
+ notify.error(t('common.couldNotCopy'), t('common.clipboardBlocked'));
}
- }, [notify, project.id]);
+ }, [notify, project.id, t]);
const handleCopyId = useCallback(() => void copyId(), [copyId]);
return (
- Details
+ {t('common.details')}
-
+
{project.id}
-
+
- {syncLabel}
- {lastSavedAt ? formatTimestamp(lastSavedAt) : 'Not yet'}
- {project.projectGraph.nodes.length}
- {project.queue.items.length}
- {project.events.length}
+ {syncLabel}
+
+ {lastSavedAt ? formatTimestamp(lastSavedAt, t('common.unknownTime')) : t('common.notYet')}
+
+ {project.projectGraph.nodes.length}
+ {project.queue.items.length}
+ {project.events.length}
);
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/project/index.ts b/invokeai/frontend/webv2/src/workbench/widgets/project/index.ts
index a9cda6a300a..70eeaae357a 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/project/index.ts
+++ b/invokeai/frontend/webv2/src/workbench/widgets/project/index.ts
@@ -10,8 +10,7 @@ export const projectWidgetManifest: WidgetManifest = {
failurePolicy: { isolateRenderFailure: true, onRegistrationFailure: 'disable' },
icon: FolderCogIcon,
id: 'project',
- label: 'Project',
- labelText: 'Project',
+ label: (t) => t('widgets.labels.project'),
version: 1,
view: ProjectWidgetView,
};
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/queue/CancelQueueItemButton.tsx b/invokeai/frontend/webv2/src/workbench/widgets/queue/CancelQueueItemButton.tsx
index 0a46e271004..168cb15ee41 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/queue/CancelQueueItemButton.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/queue/CancelQueueItemButton.tsx
@@ -6,6 +6,7 @@ import { IconButton, Tooltip } from '@workbench/components/ui';
import { useNotify } from '@workbench/useNotify';
import { XIcon } from 'lucide-react';
import { useCallback, useState } from 'react';
+import { useTranslation } from 'react-i18next';
import { refreshQueue } from './queueDataStore';
import { cancelQueueItem } from './queueServerApi';
@@ -17,6 +18,7 @@ import { cancelQueueItem } from './queueServerApi';
* click from also toggling the row it sits in.
*/
export const CancelQueueItemButton = ({ itemId }: { itemId: number }) => {
+ const { t } = useTranslation();
const notify = useNotify();
const [busy, setBusy] = useState(false);
@@ -29,18 +31,18 @@ export const CancelQueueItemButton = ({ itemId }: { itemId: number }) => {
await cancelQueueItem(itemId);
await refreshQueue();
} catch (error) {
- notify.error('Cancel failed', getApiErrorMessage(error, 'Could not cancel this item.'));
+ notify.error(t('common.cancelFailed'), getApiErrorMessage(error, t('widgets.queue.couldNotCancelItem')));
} finally {
setBusy(false);
}
},
- [itemId, notify]
+ [itemId, notify, t]
);
return (
-
+
(
* "Disable" toggle. Mirrors the model manager's `InstallQueueBar` collapse shape.
*/
export const ModelCacheFooter = () => {
+ const { t } = useTranslation();
const { stats } = useModelCacheStats();
const notify = useNotify();
const [expanded, setExpanded] = useState(false);
@@ -51,14 +53,17 @@ export const ModelCacheFooter = () => {
try {
const clearResult = await clearModelCache();
- const toast = getModelCacheClearToast(clearResult);
+ const toast = getModelCacheClearToast(clearResult, t);
notify[toast.status](toast.title, toast.description);
} catch (error) {
- notify.error('Clear cache failed', getApiErrorMessage(error, 'Could not clear the model cache.'));
+ notify.error(
+ t('widgets.queue.modelCache.clearFailed'),
+ getApiErrorMessage(error, t('widgets.queue.modelCache.couldNotClear'))
+ );
} finally {
setBusy(false);
}
- }, [notify]);
+ }, [notify, t]);
return (
{
-
-
-
+
+
+
- Clear cache
+ {t('widgets.queue.modelCache.clear')}
@@ -86,8 +91,11 @@ export const ModelCacheFooter = () => {
{
letterSpacing="0.06em"
textTransform="uppercase"
>
- Model Cache
+ {t('widgets.queue.modelCache.label')}
{formatBytes(used)} / {formatBytes(total)}
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/queue/NowNextSection.tsx b/invokeai/frontend/webv2/src/workbench/widgets/queue/NowNextSection.tsx
index 91493b65ee0..11f85dd2f45 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/queue/NowNextSection.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/queue/NowNextSection.tsx
@@ -1,4 +1,5 @@
import { Stack } from '@chakra-ui/react';
+import { useTranslation } from 'react-i18next';
import { QueueItemRow } from './QueueItemRow';
import { useScopedCurrentBatchItems } from './queueScope';
@@ -6,6 +7,7 @@ import { SectionHeader } from './SectionHeader';
/** CURRENT BATCH — the running item plus every pending item in the same backend batch. */
export const CurrentBatchSection = () => {
+ const { t } = useTranslation();
const items = useScopedCurrentBatchItems();
const count = items.length;
@@ -15,7 +17,7 @@ export const CurrentBatchSection = () => {
return (
-
+
{items.map((item) => (
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/queue/QueueFilterTabs.tsx b/invokeai/frontend/webv2/src/workbench/widgets/queue/QueueFilterTabs.tsx
index 0c9f8cc05fd..deb6c9fc12e 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/queue/QueueFilterTabs.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/queue/QueueFilterTabs.tsx
@@ -1,5 +1,6 @@
import { SegmentGroup } from '@chakra-ui/react';
import { useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
import type { QueueFilterId } from './queueFilters';
@@ -13,6 +14,7 @@ export const QueueFilterTabs = ({
value: QueueFilterId;
onChange: (filter: QueueFilterId) => void;
}) => {
+ const { t } = useTranslation();
const onValueChange = useCallback(
(details: { value: string | null }) => {
if (details.value) {
@@ -24,7 +26,7 @@ export const QueueFilterTabs = ({
return (
(
- {filter.label}
+ {t(filter.labelKey)}
))}
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/queue/QueueHeaderActions.test.ts b/invokeai/frontend/webv2/src/workbench/widgets/queue/QueueHeaderActions.test.ts
index d6ce68b5361..9b08e2b5058 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/queue/QueueHeaderActions.test.ts
+++ b/invokeai/frontend/webv2/src/workbench/widgets/queue/QueueHeaderActions.test.ts
@@ -6,7 +6,7 @@ describe('getQueueHeaderCancelState', () => {
it('enables cancelling when connected with an in-progress current item', () => {
expect(getQueueHeaderCancelState({ currentStatus: 'in_progress', isConnected: true })).toEqual({
disabled: false,
- itemLabel: 'Cancel Current',
+ itemLabel: 'widgets.queue.cancelCurrent',
});
});
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/queue/QueueHeaderActions.tsx b/invokeai/frontend/webv2/src/workbench/widgets/queue/QueueHeaderActions.tsx
index f876158d149..0332c4fbe6c 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/queue/QueueHeaderActions.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/queue/QueueHeaderActions.tsx
@@ -1,10 +1,12 @@
+/* oxlint-disable react-perf/jsx-no-new-function-as-prop */
import { Icon } from '@chakra-ui/react';
import { getApiErrorMessage } from '@workbench/backend/http';
import { Button } from '@workbench/components/ui';
import { useNotify } from '@workbench/useNotify';
import { useWorkbenchSelector } from '@workbench/WorkbenchContext';
import { XIcon } from 'lucide-react';
-import { useCallback, useState } from 'react';
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
import type { QueueServerItem } from './queueServerApi';
@@ -19,7 +21,7 @@ export const getQueueHeaderCancelState = ({
isConnected: boolean;
}): { disabled: boolean; itemLabel: string } => ({
disabled: !isConnected || currentStatus !== 'in_progress',
- itemLabel: 'Cancel Current',
+ itemLabel: 'widgets.queue.cancelCurrent',
});
/**
@@ -27,36 +29,38 @@ export const getQueueHeaderCancelState = ({
* most common interruption action sits inline beside the actions menu.
*/
export const QueueHeaderActions = () => {
+ const { t } = useTranslation();
const isConnected = useWorkbenchSelector((snapshot) => snapshot.state.backendConnection.status === 'connected');
const { current } = useNowNextItems();
const notify = useNotify();
const [busy, setBusy] = useState(false);
+ const currentItemId = current?.item_id ?? null;
const { disabled, itemLabel } = getQueueHeaderCancelState({
currentStatus: current?.status ?? null,
isConnected,
});
- const onCancel = useCallback(async () => {
- if (disabled || !current) {
+ const onCancel = async () => {
+ if (disabled || currentItemId === null) {
return;
}
setBusy(true);
try {
- await cancelQueueItem(current.item_id);
+ await cancelQueueItem(currentItemId);
await refreshQueue();
} catch (error) {
- notify.error('Cancel failed', getApiErrorMessage(error, 'Could not cancel the current item.'));
+ notify.error(t('common.cancelFailed'), getApiErrorMessage(error, t('widgets.queue.couldNotCancelCurrentItem')));
} finally {
setBusy(false);
}
- }, [current, disabled, notify]);
+ };
return (
- {itemLabel}
+ {t(itemLabel)}
);
};
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/queue/QueueHeaderLabel.tsx b/invokeai/frontend/webv2/src/workbench/widgets/queue/QueueHeaderLabel.tsx
index ac50439034c..837a7837a5b 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/queue/QueueHeaderLabel.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/queue/QueueHeaderLabel.tsx
@@ -1,4 +1,5 @@
import { Stack, Text } from '@chakra-ui/react';
+import { useTranslation } from 'react-i18next';
import { useScopedQueueCounts } from './queueScope';
@@ -9,19 +10,20 @@ import { useScopedQueueCounts } from './queueScope';
* numerals so the summary doesn't jitter as items move through the queue.
*/
export const QueueHeaderLabel = () => {
+ const { t } = useTranslation();
const counts = useScopedQueueCounts();
const isGenerating = counts.in_progress > 0;
return (
- Queue
+ {t('widgets.labels.queue')}
- {counts.in_progress} generating
+ {t('widgets.queue.generating', { count: counts.in_progress })}
{' '}
- · {counts.pending} waiting
+ · {t('widgets.queue.waiting', { count: counts.pending })}
);
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/queue/QueueHeaderMenu.tsx b/invokeai/frontend/webv2/src/workbench/widgets/queue/QueueHeaderMenu.tsx
index 4b0df0d9b41..8f249719705 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/queue/QueueHeaderMenu.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/queue/QueueHeaderMenu.tsx
@@ -3,6 +3,7 @@ import { getApiErrorMessage } from '@workbench/backend/http';
import { useNotify } from '@workbench/useNotify';
import { Trash2Icon, TrashIcon } from 'lucide-react';
import { useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
import { requestQueueConfirmation } from './queueConfirmationStore';
import { refreshQueue, useQueueCounts } from './queueDataStore';
@@ -18,6 +19,7 @@ const ERROR_ITEM_HOVER_PROPS = { bg: 'bg.error', color: 'fg.error' };
* topbar queue menu so backend mutations keep local run coordination in sync.
*/
export const QueueHeaderMenu = () => {
+ const { t } = useTranslation();
const actions = useQueueMenuActions();
const counts = useQueueCounts();
const scope = useQueueQueryScope();
@@ -27,45 +29,51 @@ export const QueueHeaderMenu = () => {
try {
await clearFailedQueueItems(scope);
await refreshQueue();
- notify.success('Failed queue items cleared');
+ notify.success(t('widgets.queue.failedItemsCleared'));
} catch (error) {
- notify.error('Failed to clear queue items', getApiErrorMessage(error, 'Could not clear failed queue items.'));
+ notify.error(
+ t('widgets.queue.failedToClearItems'),
+ getApiErrorMessage(error, t('widgets.queue.couldNotClearFailedItems'))
+ );
}
- }, [notify, scope]);
+ }, [notify, scope, t]);
const clearAll = useCallback(async () => {
try {
await clearScopedQueue(scope);
await refreshQueue();
- notify.success('Queue cleared');
+ notify.success(t('widgets.queue.queueCleared'));
} catch (error) {
- notify.error('Failed to clear queue', getApiErrorMessage(error, 'Could not clear the queue.'));
+ notify.error(
+ t('widgets.queue.failedToClearQueue'),
+ getApiErrorMessage(error, t('widgets.queue.couldNotClearQueue'))
+ );
}
- }, [notify, scope]);
+ }, [notify, scope, t]);
const onClearFailed = useCallback(() => {
requestQueueConfirmation({
- body: 'This permanently removes failed queue items in the current queue scope.',
- confirmLabel: 'Clear Failed Items',
+ body: t('widgets.queue.clearFailedConfirmationBody'),
+ confirmLabel: t('widgets.queue.clearFailedItems'),
onConfirm: clearFailed,
- title: 'Clear failed queue items?',
+ title: t('widgets.queue.clearFailedTitle'),
});
- }, [clearFailed]);
+ }, [clearFailed, t]);
const onClearAll = useCallback(() => {
requestQueueConfirmation({
- body: 'This permanently clears queue items in the current queue scope.',
- confirmLabel: 'Clear Queue',
+ body: t('widgets.queue.clearQueueConfirmationBody'),
+ confirmLabel: t('widgets.queue.clearQueue'),
onConfirm: clearAll,
- title: 'Clear queue?',
+ title: t('widgets.queue.clearQueueTitle'),
});
- }, [clearAll]);
+ }, [clearAll, t]);
return (
<>
-
+
- Clear
+ {t('common.clear')}
{
onClick={onClearFailed}
>
- Clear Failed Items
+ {t('widgets.queue.clearFailedItems')}
{
onClick={onClearAll}
>
- Clear All Items
+ {t('widgets.queue.clearAllItems')}
>
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/queue/QueueItemActions.tsx b/invokeai/frontend/webv2/src/workbench/widgets/queue/QueueItemActions.tsx
index b1990943722..a7d3be619c1 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/queue/QueueItemActions.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/queue/QueueItemActions.tsx
@@ -7,6 +7,7 @@ import { useOpenWorkbenchWidget } from '@workbench/useOpenWorkbenchWidget';
import { useWorkbenchDispatch } from '@workbench/WorkbenchContext';
import { FileTextIcon, UndoIcon, WandSparklesIcon } from 'lucide-react';
import { useCallback, useState } from 'react';
+import { useTranslation } from 'react-i18next';
import type { QueueServerItem } from './queueServerApi';
@@ -26,6 +27,7 @@ export const QueueItemActions = ({
item: QueueServerItem;
localGenerateValues: GenerateWidgetValues | null;
}) => {
+ const { t } = useTranslation();
const dispatch = useWorkbenchDispatch();
const openWidget = useOpenWorkbenchWidget();
const notify = useNotify();
@@ -40,12 +42,12 @@ export const QueueItemActions = ({
dispatch({ type: 'setGenerateSettings', values: localGenerateValues });
openWidget('generate', { preferredRegions: ['left'] });
- notify.success('Settings recalled', 'Loaded these settings into Generate.');
- }, [dispatch, localGenerateValues, notify, openWidget]);
+ notify.success(t('widgets.queue.settingsRecalled'), t('widgets.queue.settingsRecalledDescription'));
+ }, [dispatch, localGenerateValues, notify, openWidget, t]);
const onSendToCanvas = useCallback(
- () => notify.info('Send to canvas', 'Sending queue results to the canvas is coming soon.'),
- [notify]
+ () => notify.info(t('widgets.queue.sendToCanvas'), t('widgets.queue.sendToCanvasComingSoon')),
+ [notify, t]
);
const openJson = useCallback(() => setJsonOpen(true), []);
@@ -68,19 +70,21 @@ export const QueueItemActions = ({
*/}
-
+
- Recall All
+ {t('widgets.queue.recallAll')}
- Send to canvas
+ {t('widgets.queue.sendToCanvas')}
- View JSON
+ {t('common.viewJson')}
@@ -91,11 +95,11 @@ export const QueueItemActions = ({
- Queue item #{item.item_id}
+ {t('widgets.queue.itemTitle', { id: item.item_id })}
-
+
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/queue/QueueItemDetails.tsx b/invokeai/frontend/webv2/src/workbench/widgets/queue/QueueItemDetails.tsx
index 74cef8c7bb4..a63fec4ab4f 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/queue/QueueItemDetails.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/queue/QueueItemDetails.tsx
@@ -1,6 +1,7 @@
import type { ReactNode } from 'react';
import { DataList, Separator, Text } from '@chakra-ui/react';
+import { useTranslation } from 'react-i18next';
import type { QueueServerItem } from './queueServerApi';
@@ -20,33 +21,34 @@ const DetailRow = ({ label, children }: { label: string; children: ReactNode })
/** Expanded detail grid + actions for a RECENT queue item row. */
export const QueueItemDetails = ({ item }: { item: QueueServerItem }) => {
+ const { t } = useTranslation();
const meta = extractGenerationMeta(item);
const localGenerateValues = useLocalGenerateValues(item.origin);
const duration = formatDuration(item.started_at, item.completed_at);
return (
- {meta.positivePrompt ?? '—'}
- {meta.negativePrompt ?? '—'}
-
+ {meta.positivePrompt ?? '—'}
+ {meta.negativePrompt ?? '—'}
+
{meta.seed ?? '—'}
- {new Date(item.created_at).toLocaleString()}
- {duration ?? '—'}
-
+ {new Date(item.created_at).toLocaleString()}
+ {duration ?? '—'}
+
{item.batch_id}
-
+
#{item.item_id}
{item.error_message ? (
-
+
{item.error_message}
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/queue/QueueItemRow.tsx b/invokeai/frontend/webv2/src/workbench/widgets/queue/QueueItemRow.tsx
index d15e787a9d6..9d5969f9162 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/queue/QueueItemRow.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/queue/QueueItemRow.tsx
@@ -5,6 +5,7 @@ import { useItemProgress } from '@workbench/backend/itemProgressStore';
import { Row } from '@workbench/components/ui';
import { ChevronRightIcon } from 'lucide-react';
import { memo, useCallback, useState } from 'react';
+import { useTranslation } from 'react-i18next';
import type { QueueServerItem } from './queueServerApi';
@@ -34,6 +35,7 @@ const QUEUE_ITEM_BUTTON_SX: SystemStyleObject = {
} as const;
export const QueueItemRow = memo(({ item }: { item: QueueServerItem }) => {
+ const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const toggle = useCallback(() => setExpanded((open) => !open), []);
const meta = extractGenerationMeta(item);
@@ -45,7 +47,7 @@ export const QueueItemRow = memo(({ item }: { item: QueueServerItem }) => {
const progress = useItemProgress(item.item_id);
const liveImage = progress?.image ?? null;
const resultImageName = getResultImageName(item);
- const statusLabel = getStatusMeta(item.status).label;
+ const statusLabel = t(getStatusMeta(item.status).labelKey);
const showBorder = expanded || isFailed;
const borderColor = showBorder ? (isFailed ? 'fg.error' : 'border') : 'transparent';
@@ -57,7 +59,7 @@ export const QueueItemRow = memo(({ item }: { item: QueueServerItem }) => {
- {meta.positivePrompt?.trim() || 'No prompt'}
+ {meta.positivePrompt?.trim() || t('widgets.queue.noPrompt')}
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/queue/QueueStats.tsx b/invokeai/frontend/webv2/src/workbench/widgets/queue/QueueStats.tsx
index 0b74bf16631..e0575e419a1 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/queue/QueueStats.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/queue/QueueStats.tsx
@@ -1,4 +1,5 @@
import { SimpleGrid, Stat } from '@chakra-ui/react';
+import { useTranslation } from 'react-i18next';
import { useScopedQueueCounts } from './queueScope';
@@ -15,14 +16,15 @@ const QueueStatCard = ({ value, label, danger }: { value: number; label: string;
);
export const QueueStats = () => {
+ const { t } = useTranslation();
const counts = useScopedQueueCounts();
return (
-
- 0} label="failed" value={counts.failed} />
-
-
+
+ 0} label={t('common.failed')} value={counts.failed} />
+
+
);
};
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/queue/QueueStatusDot.tsx b/invokeai/frontend/webv2/src/workbench/widgets/queue/QueueStatusDot.tsx
index 12987859b67..0d6a62d37d5 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/queue/QueueStatusDot.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/queue/QueueStatusDot.tsx
@@ -1,6 +1,7 @@
import type { BackendQueueItemStatus } from '@workbench/backend/events';
import { Box } from '@chakra-ui/react';
+import { useTranslation } from 'react-i18next';
import { getStatusMeta } from './statusMeta';
@@ -8,11 +9,12 @@ const ACTIVE_HALO = { boxShadow: '0 0 0 3px {colors.accent.subtle}' } as const;
/** Small status indicator dot; the running state gets a soft accent halo. */
export const QueueStatusDot = ({ status, boxSize = '2' }: { status: BackendQueueItemStatus; boxSize?: string }) => {
- const { dotColor, label } = getStatusMeta(status);
+ const { t } = useTranslation();
+ const { dotColor, labelKey } = getStatusMeta(status);
return (
{
+ const { t } = useTranslation();
const percentLabel = percentage !== null ? `${Math.round(percentage * 100)}%` : '';
const caption = [message.trim(), percentLabel].filter(Boolean).join(' · ');
return (
{
+ const { t } = useTranslation();
const counts = useScopedQueueCounts();
if (region === 'bottom' && presentation !== 'expanded') {
@@ -27,7 +29,9 @@ export const QueueWidgetView = ({ presentation, region }: WidgetViewProps) => {
return (
- {isGenerating ? `${counts.in_progress} generating` : `${counts.pending} queued`}
+ {isGenerating
+ ? t('widgets.queue.generating', { count: counts.in_progress })
+ : t('widgets.queue.queued', { count: counts.pending })}
);
}
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/queue/RecentSection.tsx b/invokeai/frontend/webv2/src/workbench/widgets/queue/RecentSection.tsx
index 731ed3c8333..677f1ec9b28 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/queue/RecentSection.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/queue/RecentSection.tsx
@@ -1,5 +1,6 @@
import { Stack, Text } from '@chakra-ui/react';
import { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
import type { QueueFilterId } from './queueFilters';
@@ -14,6 +15,7 @@ import { SectionHeader } from './SectionHeader';
* and next items are excluded here since NOW & NEXT already shows them.
*/
export const RecentSection = ({ filter }: { filter: QueueFilterId }) => {
+ const { t } = useTranslation();
const currentBatchItems = useScopedCurrentBatchItems();
const items = useScopedRecentItems();
const { error, loadState } = useQueueLoadState();
@@ -26,10 +28,14 @@ export const RecentSection = ({ filter }: { filter: QueueFilterId }) => {
return (
-
+
{filtered.length === 0 ? (
- {loadState === 'loading' ? 'Loading queue…' : loadState === 'error' ? error : 'Nothing here yet.'}
+ {loadState === 'loading'
+ ? t('widgets.queue.loading')
+ : loadState === 'error'
+ ? error
+ : t('common.nothingHereYet')}
) : (
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/queue/index.ts b/invokeai/frontend/webv2/src/workbench/widgets/queue/index.ts
index 1d8ac3e1e48..34251c79eb1 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/queue/index.ts
+++ b/invokeai/frontend/webv2/src/workbench/widgets/queue/index.ts
@@ -22,8 +22,8 @@ export const queueWidgetManifest: WidgetManifest = {
host: QueueDataRuntime,
icon: ListOrderedIcon,
id: 'queue',
- label: QueueHeaderLabel,
- labelText: 'Queue',
+ headerLabel: QueueHeaderLabel,
+ label: (t) => t('widgets.labels.queue'),
settingsSection: 'queue',
version: 1,
view: QueueWidgetView,
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/queue/modelCacheStore.test.ts b/invokeai/frontend/webv2/src/workbench/widgets/queue/modelCacheStore.test.ts
index 822d4a6d38c..71ca812016c 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/queue/modelCacheStore.test.ts
+++ b/invokeai/frontend/webv2/src/workbench/widgets/queue/modelCacheStore.test.ts
@@ -2,6 +2,18 @@ import { describe, expect, it } from 'vitest';
import { getModelCacheClearToast, getModelCacheUsage, normalizeModelCacheClearResult } from './modelCacheStore';
+const t = ((key: string, values?: Record) => {
+ const translations: Record = {
+ 'widgets.queue.modelCache.cleared': 'Model cache cleared',
+ 'widgets.queue.modelCache.clearedDescription': `Cleared ${values?.count} cached models and freed ${values?.bytes}.`,
+ 'widgets.queue.modelCache.noModelsCleared': 'No cached models cleared',
+ 'widgets.queue.modelCache.noModelsClearedDescription':
+ 'No unlocked cached models were available to clear. Active models stay loaded until they are no longer in use.',
+ };
+
+ return translations[key] ?? key;
+}) as never;
+
describe('model cache stats helpers', () => {
it('uses current cache usage instead of the historical high watermark', () => {
expect(
@@ -18,13 +30,13 @@ describe('model cache stats helpers', () => {
});
it('reports whether clear cache actually cleared models', () => {
- expect(getModelCacheClearToast({ bytes_freed: 1024, models_cleared: 2 })).toEqual({
+ expect(getModelCacheClearToast({ bytes_freed: 1024, models_cleared: 2 }, t)).toEqual({
description: 'Cleared 2 cached models and freed 1.0 KB.',
status: 'success',
title: 'Model cache cleared',
});
- expect(getModelCacheClearToast({ bytes_freed: 0, models_cleared: 0 })).toEqual({
+ expect(getModelCacheClearToast({ bytes_freed: 0, models_cleared: 0 }, t)).toEqual({
description:
'No unlocked cached models were available to clear. Active models stay loaded until they are no longer in use.',
status: 'info',
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/queue/modelCacheStore.ts b/invokeai/frontend/webv2/src/workbench/widgets/queue/modelCacheStore.ts
index e15879c4e64..c18d368a093 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/queue/modelCacheStore.ts
+++ b/invokeai/frontend/webv2/src/workbench/widgets/queue/modelCacheStore.ts
@@ -1,3 +1,5 @@
+import type { TFunction } from 'i18next';
+
import { apiFetchJson, getApiErrorMessage } from '@workbench/backend/http';
import { createExternalStore } from '@workbench/externalStore';
import { formatBytes } from '@workbench/models/taxonomy';
@@ -38,23 +40,25 @@ export const getModelCacheUsage = (stats: ModelCacheStats | null): { used: numbe
used: stats?.cache_used ?? stats?.high_watermark ?? 0,
});
-export const getModelCacheClearToast = ({
- bytes_freed,
- models_cleared,
-}: ModelCacheClearResult): { title: string; description: string; status: 'info' | 'success' } => {
+export const getModelCacheClearToast = (
+ { bytes_freed, models_cleared }: ModelCacheClearResult,
+ t: TFunction
+): { title: string; description: string; status: 'info' | 'success' } => {
if (models_cleared === 0) {
return {
- description:
- 'No unlocked cached models were available to clear. Active models stay loaded until they are no longer in use.',
+ description: t('widgets.queue.modelCache.noModelsClearedDescription'),
status: 'info',
- title: 'No cached models cleared',
+ title: t('widgets.queue.modelCache.noModelsCleared'),
};
}
return {
- description: `Cleared ${models_cleared} cached model${models_cleared === 1 ? '' : 's'} and freed ${formatBytes(bytes_freed)}.`,
+ description: t('widgets.queue.modelCache.clearedDescription', {
+ bytes: formatBytes(bytes_freed),
+ count: models_cleared,
+ }),
status: 'success',
- title: 'Model cache cleared',
+ title: t('widgets.queue.modelCache.cleared'),
};
};
@@ -82,7 +86,7 @@ export const refreshModelCacheStats = (): Promise => {
store.patchSnapshot({ error: null, loadState: 'loaded', stats });
})
.catch((error: unknown) => {
- store.patchSnapshot({ error: getApiErrorMessage(error, 'Failed to load cache stats'), loadState: 'error' });
+ store.patchSnapshot({ error: getApiErrorMessage(error, 'Failed to load model cache stats'), loadState: 'error' });
})
.finally(() => {
inflight = null;
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/queue/queueFilters.ts b/invokeai/frontend/webv2/src/workbench/widgets/queue/queueFilters.ts
index 5a0650351df..f57e3ef22b6 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/queue/queueFilters.ts
+++ b/invokeai/frontend/webv2/src/workbench/widgets/queue/queueFilters.ts
@@ -5,12 +5,12 @@ import type { QueueStatusCounts } from './queueServerApi';
/** Status filters for the RECENT list, matching the screenshot's tab row. */
export type QueueFilterId = 'all' | 'active' | 'completed' | 'failed' | 'canceled';
-export const QUEUE_FILTERS: { id: QueueFilterId; label: string }[] = [
- { id: 'all', label: 'All' },
- { id: 'active', label: 'Active' },
- { id: 'completed', label: 'Done' },
- { id: 'failed', label: 'Failed' },
- { id: 'canceled', label: 'Canceled' },
+export const QUEUE_FILTERS: { id: QueueFilterId; labelKey: string }[] = [
+ { id: 'all', labelKey: 'common.all' },
+ { id: 'active', labelKey: 'common.active' },
+ { id: 'completed', labelKey: 'common.done' },
+ { id: 'failed', labelKey: 'common.failed' },
+ { id: 'canceled', labelKey: 'common.canceled' },
];
export const matchesFilter = (status: BackendQueueItemStatus, filter: QueueFilterId): boolean => {
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/queue/queueMenuActions.test.ts b/invokeai/frontend/webv2/src/workbench/widgets/queue/queueMenuActions.test.ts
index 66da0a4c02e..3f7864b9afb 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/queue/queueMenuActions.test.ts
+++ b/invokeai/frontend/webv2/src/workbench/widgets/queue/queueMenuActions.test.ts
@@ -5,6 +5,14 @@ import { getQueueMenuActions } from './queueMenuActions';
describe('getQueueMenuActions', () => {
it('uses the same cancellation and processor actions for topbar and widget menus', () => {
const actions = getQueueMenuActions({
+ labels: {
+ cancelAll: 'Cancel All Items',
+ cancelAllExceptCurrent: 'Cancel all except current item',
+ cancelCurrent: 'Cancel Current Item',
+ openQueue: 'Open Queue',
+ pauseProcessor: 'Pause Processor',
+ resumeProcessor: 'Resume Processor',
+ },
cancellableCount: 2,
canManageProcessor: true,
hasPendingQueueWork: true,
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/queue/queueMenuActions.tsx b/invokeai/frontend/webv2/src/workbench/widgets/queue/queueMenuActions.tsx
index 6ca7b441099..3e887004777 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/queue/queueMenuActions.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/queue/queueMenuActions.tsx
@@ -6,6 +6,7 @@ import { useOpenWorkbenchWidget } from '@workbench/useOpenWorkbenchWidget';
import { useWorkbenchSelector } from '@workbench/WorkbenchContext';
import { ListOrderedIcon, PauseIcon, PlayIcon, XIcon, type LucideIcon } from 'lucide-react';
import { useCallback, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
import { requestQueueConfirmation } from './queueConfirmationStore';
import { refreshQueue, useNowNextItems, useQueueCounts } from './queueDataStore';
@@ -15,6 +16,14 @@ import { cancelQueueItem, cancelScopedQueueItems, pauseQueueProcessor, resumeQue
const ERROR_ITEM_HOVER_PROPS = { bg: 'bg.error', color: 'fg.error' };
interface QueueMenuActionInputs {
+ labels: {
+ cancelAll: string;
+ cancelAllExceptCurrent: string;
+ cancelCurrent: string;
+ openQueue: string;
+ pauseProcessor: string;
+ resumeProcessor: string;
+ };
cancellableCount: number;
hasPendingQueueWork: boolean;
hasRunningItem: boolean;
@@ -37,6 +46,7 @@ export interface QueueMenuAction {
}
export const getQueueMenuActions = ({
+ labels,
cancellableCount,
hasPendingQueueWork,
hasRunningItem,
@@ -53,44 +63,45 @@ export const getQueueMenuActions = ({
destructive: true,
disabled: !isConnected || !hasRunningItem,
icon: XIcon,
- label: 'Cancel Current Item',
+ label: labels.cancelCurrent,
onClick: onCancelCurrent,
},
{
destructive: true,
disabled: cancellableCount === 0,
icon: XIcon,
- label: 'Cancel All Items',
+ label: labels.cancelAll,
onClick: onCancelAll,
},
{
destructive: true,
disabled: !isConnected || !hasPendingQueueWork,
icon: XIcon,
- label: 'Cancel all except current item',
+ label: labels.cancelAllExceptCurrent,
onClick: onCancelAllExceptCurrent,
},
{
disabled: !isConnected || !canManageProcessor,
icon: PlayIcon,
- label: 'Resume Processor',
+ label: labels.resumeProcessor,
onClick: onResumeProcessor,
},
{
disabled: !isConnected || !canManageProcessor,
icon: PauseIcon,
- label: 'Pause Processor',
+ label: labels.pauseProcessor,
onClick: onPauseProcessor,
},
{
disabled: false,
icon: ListOrderedIcon,
- label: 'Open Queue',
+ label: labels.openQueue,
onClick: onOpenQueue,
},
];
export const useQueueMenuActions = (): QueueMenuAction[] => {
+ const { t } = useTranslation();
const backendConnectionStatus = useWorkbenchSelector((snapshot) => snapshot.state.backendConnection.status);
const counts = useQueueCounts();
const { current } = useNowNextItems();
@@ -105,56 +116,74 @@ export const useQueueMenuActions = (): QueueMenuAction[] => {
const cancelCurrent = useCallback(() => {
if (!current) {
- notify.info('No current item', 'The scoped queue has no in-progress item to cancel.');
+ notify.info(t('widgets.queue.noCurrentItem'), t('widgets.queue.noCurrentItemDescription'));
return;
}
cancelQueueItem(current.item_id)
.then(() => refreshQueue())
- .then(() => notify.success('Cancellation requested', `Backend queue item ${current.item_id}.`))
+ .then(() =>
+ notify.success(
+ t('widgets.queue.cancellationRequested'),
+ t('widgets.queue.backendItem', { id: current.item_id })
+ )
+ )
.catch((error: unknown) =>
- notify.error('Failed to cancel current item', getApiErrorMessage(error, 'Could not cancel the current item.'))
+ notify.error(
+ t('widgets.queue.failedToCancelCurrentItem'),
+ getApiErrorMessage(error, t('widgets.queue.couldNotCancelCurrentItem'))
+ )
);
- }, [current, notify]);
+ }, [current, notify, t]);
const cancelAllExceptCurrent = useCallback(() => {
cancelScopedQueueItems(scope, current?.item_id ?? null)
.then(() => refreshQueue())
.then(() =>
notify.success(
- 'Queue cancellation requested',
- 'All pending scoped items except the current item were requested.'
+ t('widgets.queue.queueCancellationRequested'),
+ t('widgets.queue.cancelAllExceptCurrentRequested')
)
)
.catch((error: unknown) =>
- notify.error('Failed to cancel queue items', getApiErrorMessage(error, 'Could not cancel scoped queue items.'))
+ notify.error(
+ t('widgets.queue.failedToCancelItems'),
+ getApiErrorMessage(error, t('widgets.queue.couldNotCancelItems'))
+ )
);
- }, [current?.item_id, notify, scope]);
+ }, [current?.item_id, notify, scope, t]);
const runProcessorAction = useCallback(
(label: 'pause' | 'resume') => {
const action = label === 'pause' ? pauseQueueProcessor : resumeQueueProcessor;
action()
- .then(() => notify.success(label === 'pause' ? 'Processor paused' : 'Processor resumed'))
+ .then(() =>
+ notify.success(label === 'pause' ? t('widgets.queue.processorPaused') : t('widgets.queue.processorResumed'))
+ )
.then(() => refreshQueue())
.catch((error: unknown) =>
notify.error(
- label === 'pause' ? 'Failed to pause processor' : 'Failed to resume processor',
- getApiErrorMessage(error, 'Could not change the queue processor.')
+ label === 'pause' ? t('widgets.queue.failedToPauseProcessor') : t('widgets.queue.failedToResumeProcessor'),
+ getApiErrorMessage(error, t('widgets.queue.couldNotChangeProcessor'))
)
);
},
- [notify]
+ [notify, t]
);
const cancelAll = useCallback(() => {
cancelScopedQueueItems(scope)
.then(() => refreshQueue())
- .then(() => notify.success('Queue cancellation requested', 'All scoped queue items were requested.'))
+ .then(() =>
+ notify.success(t('widgets.queue.queueCancellationRequested'), t('widgets.queue.allScopedItemsRequested'))
+ )
.catch((error: unknown) =>
- notify.error('Failed to cancel queue items', getApiErrorMessage(error, 'Could not cancel scoped queue items.'))
+ notify.error(
+ t('widgets.queue.failedToCancelItems'),
+ getApiErrorMessage(error, t('widgets.queue.couldNotCancelItems'))
+ )
);
- }, [notify, scope]);
+ }, [notify, scope, t]);
const resumeProcessor = useCallback(() => runProcessorAction('resume'), [runProcessorAction]);
const pauseProcessor = useCallback(() => runProcessorAction('pause'), [runProcessorAction]);
const openQueue = useCallback(() => openWorkbenchWidget('queue'), [openWorkbenchWidget]);
@@ -162,6 +191,14 @@ export const useQueueMenuActions = (): QueueMenuAction[] => {
return useMemo(
() =>
getQueueMenuActions({
+ labels: {
+ cancelAll: t('widgets.queue.cancelAllItems'),
+ cancelAllExceptCurrent: t('widgets.queue.cancelAllExceptCurrent'),
+ cancelCurrent: t('widgets.queue.cancelCurrentItem'),
+ openQueue: t('widgets.queue.openQueue'),
+ pauseProcessor: t('widgets.queue.pauseProcessor'),
+ resumeProcessor: t('widgets.queue.resumeProcessor'),
+ },
cancellableCount,
canManageProcessor: canManageModels,
hasPendingQueueWork,
@@ -186,6 +223,7 @@ export const useQueueMenuActions = (): QueueMenuAction[] => {
openQueue,
pauseProcessor,
resumeProcessor,
+ t,
]
);
};
@@ -220,15 +258,16 @@ export const QueueMenuItems = ({ actions, label }: { actions: QueueMenuAction[];
};
const DestructiveQueueMenuItem = ({ action }: { action: QueueMenuAction }) => {
+ const { t } = useTranslation();
const onClick = useCallback(
() =>
requestQueueConfirmation({
- body: 'This requests cancellation for queue work in the current scope. In-progress items may stop after the backend acknowledges cancellation.',
+ body: t('widgets.queue.cancelConfirmationBody'),
confirmLabel: action.label,
onConfirm: action.onClick,
- title: `${action.label}?`,
+ title: t('widgets.queue.actionConfirmationTitle', { action: action.label }),
}),
- [action]
+ [action, t]
);
return (
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/queue/statusMeta.ts b/invokeai/frontend/webv2/src/workbench/widgets/queue/statusMeta.ts
index 42ad65cb3b3..811fe994dc8 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/queue/statusMeta.ts
+++ b/invokeai/frontend/webv2/src/workbench/widgets/queue/statusMeta.ts
@@ -6,17 +6,17 @@ import type { BackendQueueItemStatus } from '@workbench/backend/events';
* NOW & NEXT card. One table so status styling stays consistent everywhere.
*/
export interface QueueStatusMeta {
- label: string;
+ labelKey: string;
colorPalette: string;
dotColor: string;
}
const STATUS_META: Record = {
- canceled: { colorPalette: 'orange', dotColor: 'orange.solid', label: 'Canceled' },
- completed: { colorPalette: 'green', dotColor: 'green.solid', label: 'Completed' },
- failed: { colorPalette: 'red', dotColor: 'red.solid', label: 'Failed' },
- in_progress: { colorPalette: 'accent', dotColor: 'accent.solid', label: 'Generating' },
- pending: { colorPalette: 'gray', dotColor: 'fg.muted', label: 'Pending' },
+ canceled: { colorPalette: 'orange', dotColor: 'orange.solid', labelKey: 'common.status.canceled' },
+ completed: { colorPalette: 'green', dotColor: 'green.solid', labelKey: 'common.status.completed' },
+ failed: { colorPalette: 'red', dotColor: 'red.solid', labelKey: 'common.status.failed' },
+ in_progress: { colorPalette: 'accent', dotColor: 'accent.solid', labelKey: 'common.generating' },
+ pending: { colorPalette: 'gray', dotColor: 'fg.muted', labelKey: 'common.status.pending' },
};
export const getStatusMeta = (status: BackendQueueItemStatus): QueueStatusMeta => STATUS_META[status];
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/server-status/ServerStatusWidgetView.tsx b/invokeai/frontend/webv2/src/workbench/widgets/server-status/ServerStatusWidgetView.tsx
index 0c9738d1204..ce87140596e 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/server-status/ServerStatusWidgetView.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/server-status/ServerStatusWidgetView.tsx
@@ -4,26 +4,29 @@ import { Stack, Text } from '@chakra-ui/react';
import { StatusWidgetChip } from '@workbench/widget-frame';
import { useWorkbenchSelector } from '@workbench/WorkbenchContext';
import { CircleXIcon, PlugZapIcon } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
export const ServerStatusWidgetView = ({ presentation }: WidgetViewProps) => {
+ const { t } = useTranslation();
const backendConnection = useWorkbenchSelector((snapshot) => snapshot.state.backendConnection);
const isConnected = backendConnection.status === 'connected';
const isDisconnected = backendConnection.status === 'disconnected';
const label = isConnected
- ? 'Connected to Server'
+ ? t('widgets.serverStatus.connected')
: isDisconnected
- ? 'Disconnected from Server'
- : 'Connecting to Server';
+ ? t('widgets.serverStatus.disconnected')
+ : t('widgets.serverStatus.connecting');
if (presentation === 'tooltip') {
return (
- Server Status
+ {t('widgets.serverStatus.label')}
- {label}
- {backendConnection.error ? `: ${backendConnection.error}` : ''}
+ {backendConnection.error
+ ? t('widgets.serverStatus.labelWithError', { error: backendConnection.error, label })
+ : label}
);
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/server-status/index.ts b/invokeai/frontend/webv2/src/workbench/widgets/server-status/index.ts
index 424dc9773ab..eefa02b2ddb 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/server-status/index.ts
+++ b/invokeai/frontend/webv2/src/workbench/widgets/server-status/index.ts
@@ -11,8 +11,7 @@ export const serverStatusWidgetManifest: WidgetManifest = {
failurePolicy: { isolateRenderFailure: true, onRegistrationFailure: 'disable' },
icon: PlugZapIcon,
id: 'server-status',
- label: 'Server Status',
- labelText: 'Server Status',
+ label: (t) => t('widgets.labels.serverStatus'),
version: 1,
view: ServerStatusWidgetView,
};
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/version-status/VersionStatusWidgetView.tsx b/invokeai/frontend/webv2/src/workbench/widgets/version-status/VersionStatusWidgetView.tsx
index 3745a7899c7..5717a7af10b 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/version-status/VersionStatusWidgetView.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/version-status/VersionStatusWidgetView.tsx
@@ -3,20 +3,23 @@ import type { WidgetViewProps } from '@workbench/types';
import { Stack, Text } from '@chakra-ui/react';
import { StatusWidgetChip } from '@workbench/widget-frame';
import { InfoIcon } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
export const VersionStatusWidgetView = ({ presentation }: WidgetViewProps) => {
+ const { t } = useTranslation();
+
if (presentation === 'tooltip') {
return (
- Version
+ {t('widgets.versionStatus.label')}
- Invoke V7 shell version 7.0.
+ {t('widgets.versionStatus.description')}
);
}
- return Version 7.0;
+ return {t('widgets.versionStatus.chipLabel')};
};
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/version-status/index.ts b/invokeai/frontend/webv2/src/workbench/widgets/version-status/index.ts
index 874c48a1ba3..3e4e95fb717 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/version-status/index.ts
+++ b/invokeai/frontend/webv2/src/workbench/widgets/version-status/index.ts
@@ -11,8 +11,7 @@ export const versionStatusWidgetManifest: WidgetManifest = {
failurePolicy: { isolateRenderFailure: true, onRegistrationFailure: 'disable' },
icon: InfoIcon,
id: 'version-status',
- label: 'Version',
- labelText: 'Version',
+ label: (t) => t('widgets.labels.versionStatus'),
version: 1,
view: VersionStatusWidgetView,
};
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/workflow/WorkflowWidgetChrome.tsx b/invokeai/frontend/webv2/src/workbench/widgets/workflow/WorkflowWidgetChrome.tsx
index 55ef7c0dceb..8522f0ec867 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/workflow/WorkflowWidgetChrome.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/workflow/WorkflowWidgetChrome.tsx
@@ -19,6 +19,7 @@ import { getCompatibleInputTemplate, getCompatibleOutputTemplate } from '@workbe
import { parseWorkflowJson } from '@workbench/workflows/workflowJson';
import { HistoryIcon, LibraryIcon, PlusIcon } from 'lucide-react';
import { useEffect, useId, useRef, type ChangeEvent } from 'react';
+import { useTranslation } from 'react-i18next';
import { AddNodeDialog } from './editor/AddNodeDialog';
import { getWorkflowFlowInstance } from './editor/flowInstanceStore';
@@ -41,13 +42,14 @@ import {
*/
export const WorkflowWidgetLabel = ({ region }: WidgetLabelProps) => {
+ const { t } = useTranslation();
const workflowName = useActiveProjectSelector((project) => project.projectGraph.name);
const dispatch = useWorkbenchDispatch();
if (region !== 'center') {
return (
- Workflow
+ {t('widgets.labels.workflow')}
);
}
@@ -55,18 +57,18 @@ export const WorkflowWidgetLabel = ({ region }: WidgetLabelProps) => {
return (
- Workflow
+ {t('widgets.labels.workflow')}
/
{
/** Entries contributed to the shared widget actions menu. */
export const WorkflowMenuItems = (_props: WidgetViewProps) => {
+ const { t } = useTranslation();
const store = useWorkbenchStore();
const dispatch = useWorkbenchDispatch();
const notify = useNotify();
@@ -95,38 +98,39 @@ export const WorkflowMenuItems = (_props: WidgetViewProps) => {
return (
- Workflow
+ {t('widgets.labels.workflow')}
dispatch({ type: 'saveProjectGraphSnapshot' })}>
- Save graph snapshot
+ {t('widgets.workflow.saveGraphSnapshot')}
- Workflow details…
+ {t('widgets.workflow.detailsWithEllipsis')}
- Import workflow JSON…
+ {t('widgets.workflow.importJsonWithEllipsis')}
downloadWorkflowJson(store.getSnapshot().activeProject.projectGraph)}>
- Export workflow JSON
+ {t('widgets.workflow.exportJson')}
{
copyWorkflowJson(store.getSnapshot().activeProject.projectGraph)
- .then(() => notify.success('Workflow JSON copied'))
- .catch(() => notify.error('Failed to copy workflow JSON'));
+ .then(() => notify.success(t('widgets.workflow.copyJsonSuccess')))
+ .catch(() => notify.error(t('widgets.workflow.copyJsonFailed')));
}}
>
- Copy workflow JSON
+ {t('widgets.workflow.copyJson')}
setNewWorkflowConfirmOpen(true)}>
- New workflow…
+ {t('widgets.workflow.newWorkflowWithEllipsis')}
);
};
export const WorkflowHeaderActions = ({ region }: WidgetViewProps) => {
+ const { t } = useTranslation();
const graphHistory = useActiveProjectSelector((project) => project.graphHistory);
const dispatch = useWorkbenchDispatch();
const restorableHistory = graphHistory.filter((entry) => entry.document);
@@ -135,9 +139,9 @@ export const WorkflowHeaderActions = ({ region }: WidgetViewProps) => {
return (
{region === 'center' ? (
-
+
{
) : null}
-
+
{
-
+
{
- Graph History Snapshots
+ {t('widgets.workflow.graphHistorySnapshots')}
{restorableHistory.map((entry) => (
t('widgets.labels.workflow'),
settingsSection: 'workflow',
version: 1,
view: WorkflowWidgetView,
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/workflow/library/WorkflowLibraryDialog.tsx b/invokeai/frontend/webv2/src/workbench/widgets/workflow/library/WorkflowLibraryDialog.tsx
index 2e16bbe19a7..d187dc9f27c 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/workflow/library/WorkflowLibraryDialog.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/workflow/library/WorkflowLibraryDialog.tsx
@@ -23,6 +23,7 @@ import {
} from '@workbench/workflows/libraryCache';
import { parseWorkflowJson, serializeWorkflowJson } from '@workbench/workflows/workflowJson';
import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react';
+import { useTranslation } from 'react-i18next';
/**
* Backend workflow library browser: list/search user and default workflows,
@@ -50,7 +51,8 @@ const WorkflowPreviewPane = ({
onLoad: () => void;
}) => {
const [mode, setMode] = useState<'nodes' | 'json'>('nodes');
- const { graph, positionHints } = documentToPreviewGraph(preview.document);
+ const { t } = useTranslation();
+ const { graph, positionHints } = documentToPreviewGraph(preview.document, t('widgets.labels.workflow'));
return (
diff --git a/invokeai/frontend/webv2/src/workbench/widgets/workflow/linear/NodeInspector.tsx b/invokeai/frontend/webv2/src/workbench/widgets/workflow/linear/NodeInspector.tsx
index d389bd304d9..3f0ca9b653b 100644
--- a/invokeai/frontend/webv2/src/workbench/widgets/workflow/linear/NodeInspector.tsx
+++ b/invokeai/frontend/webv2/src/workbench/widgets/workflow/linear/NodeInspector.tsx
@@ -13,6 +13,7 @@ import { getProjectWidgetValues } from '@workbench/widgetState';
import { useActiveProjectSelector, useWorkbenchDispatch } from '@workbench/WorkbenchContext';
import { useInvocationTemplatesSelector } from '@workbench/workflows/templates';
import { useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
/**
* Edit-mode inspector for the editor's selected node, with the legacy tab
@@ -41,6 +42,7 @@ const DetailRow = ({ label, value }: { label: string; value: string }) => (
const JsonBlock = ({ label, value }: { label: string; value: unknown }) => ;
const DetailsTab = ({ node, template }: { node: WorkflowInvocationNode; template: InvocationTemplate | undefined }) => {
+ const { t } = useTranslation();
const dispatch = useWorkbenchDispatch();
const onNotesChange = useCallback(
(event: ChangeEvent) =>
@@ -53,21 +55,21 @@ const DetailsTab = ({ node, template }: { node: WorkflowInvocationNode; template
return (
-
-
-
- {template ? : null}
- {template ? : null}
- {template?.description ? : null}
+
+
+
+ {template ? : null}
+ {template ? : null}
+ {template?.description ? : null}
- Notes
+ {t('widgets.workflow.notes')}